Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions foundations/core/packages/core/src/__tests__/memdb.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -910,6 +910,40 @@ describe('memdb', () => {
spy.mockRestore()
})

it('addTxes applies an update after its create transaction when loading an unordered model', async () => {
const hierarchy = new Hierarchy()
for (const tx of txes) hierarchy.tx(tx)
const model = new ModelDb(hierarchy)
for (const tx of txes) await model.tx(tx)
const ctx = { warn: jest.fn(), info: jest.fn() } as any

const space = createDoc(core.class.Space, {
name: 'Original',
description: '',
private: false,
members: [],
archived: false
})
const update = updateDoc<Space>(core.class.Space, core.space.Model, space.objectId, {
name: 'Updated'
})

model.addTxes(ctx, [update, space], false)

expect(model.getObject<Space>(space.objectId).name).toBe('Updated')
expect(ctx.info).not.toHaveBeenCalled()
})

it('addTxes fails when a required model transaction is skipped', async () => {
const { model } = await createModel()
const ctx = { warn: jest.fn(), info: jest.fn() } as any
const unknown = createDoc<Doc>('test:class:Unknown' as Ref<Class<Doc>>, {})

expect(() => {
model.addTxes(ctx, [unknown], false, new Set([unknown._id]))
}).toThrow(`Failed to apply required model transactions: ${unknown._id}`)
})

it('txUpdateDoc via tx() should swallow errors for a missing document', async () => {
const { model } = await createModel()

Expand Down
82 changes: 69 additions & 13 deletions foundations/core/packages/core/src/memdb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,19 +406,20 @@ export class ModelDb extends MemDb {
return {}
}

addTxes (ctx: MeasureContext, txes: Tx[], doClone: boolean): void {
addTxes (ctx: MeasureContext, txes: Tx[], doClone: boolean, requiredTxes?: ReadonlySet<Ref<Tx>>): void {
// Materialize first, index by class after: a doc may appear before its own class is created.
const created: Doc[] = []
const created: Array<{ doc: Doc, tx: Tx }> = []
// Orphan-targeted txes are reported as one summary line at the end, not one warning each.
const orphans: Array<{ _id: Ref<Doc>, _class: string, objectId: Ref<Doc> }> = []
const orphans: Tx[] = []
const skipped = new Map<Ref<Tx>, Tx>()
for (const tx of txes) {
// Hierarchy references the created instance instead of deserializing its own copy.
let ownDoc: Doc | undefined
switch (tx._class) {
case core.class.TxCreateDoc: {
const doc = TxProcessor.createDoc2Doc(tx as TxCreateDoc<Doc>, doClone)
this.objectById.set(doc._id, doc)
created.push(doc)
created.push({ doc, tx })
ownDoc = doc
break
}
Expand All @@ -429,19 +430,15 @@ export class ModelDb extends MemDb {
this.updateDoc(cud.objectId, doc, cud)
TxProcessor.updateDoc2Doc(doc, cud)
} else {
orphans.push({ _id: tx._id, _class: tx._class, objectId: cud.objectId })
orphans.push(tx)
}
break
}
case core.class.TxRemoveDoc:
try {
this.delDoc((tx as TxRemoveDoc<Doc>).objectId)
} catch (err: any) {
orphans.push({
_id: tx._id,
_class: tx._class,
objectId: (tx as TxRemoveDoc<Doc>).objectId
})
orphans.push(tx)
}
break
case core.class.TxMixin: {
Expand All @@ -451,34 +448,89 @@ export class ModelDb extends MemDb {
this.updateDoc(mix.objectId, doc, mix)
TxProcessor.updateMixin4Doc(doc, mix)
} else {
orphans.push({ _id: tx._id, _class: tx._class, objectId: mix.objectId })
orphans.push(tx)
}
break
}
}
try {
this.hierarchy.tx(tx, ownDoc)
} catch (err: any) {
skipped.set(tx._id, tx)
ctx.warn('failed to apply model transaction to hierarchy, skipping', {
_id: tx._id,
_class: tx._class,
err
})
}
}
for (const doc of created) {
// Database snapshots may return related model transactions in an arbitrary order. Retry updates and mixins after
// their create transaction has been seen; removes still follow their original ordering and remain skipped.
for (const tx of orphans.splice(0)) {
switch (tx._class) {
case core.class.TxUpdateDoc: {
const update = tx as TxUpdateDoc<Doc>
const doc = this.ownDoc(update.objectId)
if (doc === undefined) {
orphans.push(tx)
break
}
this.updateDoc(update.objectId, doc, update)
TxProcessor.updateDoc2Doc(doc, update)
try {
this.hierarchy.tx(tx)
} catch (err: any) {
skipped.set(tx._id, tx)
ctx.warn('failed to apply model transaction to hierarchy, skipping', {
_id: tx._id,
_class: tx._class,
err
})
}
break
}
case core.class.TxMixin: {
const mixin = tx as TxMixin<Doc, Doc>
const doc = this.ownDoc(mixin.objectId)
if (doc === undefined) {
orphans.push(tx)
break
}
this.updateDoc(mixin.objectId, doc, mixin)
TxProcessor.updateMixin4Doc(doc, mixin)
try {
this.hierarchy.tx(tx)
} catch (err: any) {
skipped.set(tx._id, tx)
ctx.warn('failed to apply model transaction to hierarchy, skipping', {
_id: tx._id,
_class: tx._class,
err
})
}
break
}
default:
orphans.push(tx)
}
}
for (const { doc, tx } of created) {
// Skip what a later tx of the same batch removed or replaced.
if (this.objectById.get(doc._id) !== doc) continue
try {
this.indexByClass(doc)
} catch (err: any) {
skipped.set(tx._id, tx)
// A doc of an unknown class must not stop indexing the rest of the batch.
ctx.warn('failed to index model document, skipping', { _id: doc._id, _class: doc._class, err })
}
}
if (orphans.length > 0) {
const byClass: Record<string, number> = {}
for (const o of orphans) byClass[o._class] = (byClass[o._class] ?? 0) + 1
for (const tx of orphans) {
skipped.set(tx._id, tx)
byClass[tx._class] = (byClass[tx._class] ?? 0) + 1
}
// Single coalesced info line per addTxes call. The previous per-TX
// `ctx.warn` produced one console line per orphan on every page
// load. MeasureContext has no `debug` channel, so a deeper dump for
Expand All @@ -489,6 +541,10 @@ export class ModelDb extends MemDb {
byClass
})
}
const skippedRequired = Array.from(skipped.values()).filter((tx) => requiredTxes?.has(tx._id) === true)
if (skippedRequired.length > 0) {
throw new Error(`Failed to apply required model transactions: ${skippedRequired.map((tx) => tx._id).join(', ')}`)
}
}

protected async txUpdateDoc (tx: TxUpdateDoc<Doc>): Promise<TxResult> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
export let shrink: boolean = false

const client = getClient()
const _class = value !== undefined ? client.getModel().getObject(value) : undefined
const _class = value !== undefined ? client.getModel().findObject(value) : undefined
</script>

{#if _class}
Expand Down
10 changes: 7 additions & 3 deletions server/tool/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ export async function initModel (
}

try {
await buildModel(ctx, txes, txes)

logger.log('creating database...', { workspaceId })
const firstTx: Tx = {
_class: core.class.Tx,
Expand Down Expand Up @@ -272,7 +274,7 @@ export async function upgradeModel (
const newModelRes = await ctx.with('load-model', {}, (ctx) => pipeline.loadModel(ctx, 0))
const newModel = Array.isArray(newModelRes) ? newModelRes : newModelRes.transactions

const { hierarchy, modelDb, model } = await buildModel(ctx, newModel)
const { hierarchy, modelDb, model } = await buildModel(ctx, newModel, txes)
const { migrateClient: preMigrateClient } = await prepareMigrationClient(
ctx,
pipeline,
Expand Down Expand Up @@ -430,14 +432,16 @@ async function prepareMigrationClient (

export async function buildModel (
ctx: MeasureContext,
model: Tx[]
model: Tx[],
requiredTxes?: Tx[]
): Promise<{ hierarchy: Hierarchy, modelDb: ModelDb, model: Tx[] }> {
const hierarchy = new Hierarchy()
const modelDb = new ModelDb(hierarchy)

// addTxes feeds the hierarchy itself, a separate pass would just deserialize everything twice.
ctx.withSync('build local model', {}, () => {
modelDb.addTxes(ctx, model, false)
const requiredTxIds = requiredTxes === undefined ? undefined : new Set(requiredTxes.map((tx) => tx._id))
modelDb.addTxes(ctx, model, false, requiredTxIds)
})
return { hierarchy, modelDb, model: model ?? [] }
}
Expand Down
Loading