diff --git a/.changeset/summary-index-registry-revision.md b/.changeset/summary-index-registry-revision.md new file mode 100644 index 0000000000..e5acbf2f46 --- /dev/null +++ b/.changeset/summary-index-registry-revision.md @@ -0,0 +1,23 @@ +--- +"@objectstack/objectql": patch +--- + +fix(objectql): a roll-up registered at RUNTIME must not need a restart to compute + +The engine's roll-up summary index had exactly one invalidation site — +`engine.registerApp` — and the runtime publish path does not go through it: it +registers straight into the registry (`protocol.saveMetaItem` → +`registry.registerObject`). So a kernel that had already performed a single +write — publishing itself writes `sys_metadata` rows — held a summary index +built before the new object existed. Every child write of a freshly published +roll-up then found no descriptor and silently skipped the recompute, leaving the +parent field null until the process restarted. + +That is how an AI-built app's "已完成任务数" shipped permanently empty over +completely correct metadata: the roll-up was configured, the child rows were +seeded with resolved foreign keys, and nothing recomputed (cloud#970). + +`SchemaRegistry` now carries a monotonic `objectRevision`, bumped on every +change to the registered object set, and the engine rebuilds its index whenever +that number has moved — so a registry-derived cache can no longer go stale +through a path that forgot to call the invalidator. diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 34f243233d..730ea3596e 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -2819,8 +2819,26 @@ export class ObjectQL implements IObjectQLEngine { return index; } + /** `registry.objectRevision` the cached {@link summaryIndex} was built at. */ + private summaryIndexRevision = -1; + private getSummaryDescriptors(childObject: string): SummaryDescriptor[] { - if (!this.summaryIndex) this.summaryIndex = this.buildSummaryIndex(); + // Rebuild whenever the REGISTRY's object set has moved since the index was + // built — not only when someone remembered to call + // `invalidateSummaryIndex`. That single site (`registerApp`) is bypassed by + // the runtime publish path, which registers straight into the registry + // (`protocol.saveMetaItem` → `registry.registerObject`). A kernel that had + // already done one write — publishing itself writes `sys_metadata` — held an + // index built before the new object existed, so every child write of a + // freshly published roll-up skipped the recompute and the parent read null + // until the process restarted. That is exactly how an AI-built app's + // "已完成任务数" shipped empty over correct metadata (cloud#970). + const revision = (this._registry as unknown as { objectRevision?: number })?.objectRevision; + const stale = typeof revision === 'number' && revision !== this.summaryIndexRevision; + if (!this.summaryIndex || stale) { + this.summaryIndex = this.buildSummaryIndex(); + if (typeof revision === 'number') this.summaryIndexRevision = revision; + } return this.summaryIndex.get(childObject) ?? []; } diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index e2b4ee4feb..85ea5acec5 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -656,6 +656,25 @@ export class SchemaRegistry { /** FQN → Merged ServiceObject (cached, invalidated on changes) */ private mergedObjectCache = new Map(); + /** + * Monotonic counter bumped on every change to the registered object set. + * + * Consumers that derive their OWN cache from the registry (the engine's + * roll-up summary index) key it on this, so a runtime registration can never + * leave them stale. Before it existed, the engine's summary index had a single + * invalidation site — `engine.registerApp` — which the runtime publish path + * does not go through (it calls `registry.registerObject` directly). Any + * kernel that had already performed one write therefore held an index built + * before the object existed, and every child write of a newly-published + * roll-up silently skipped the recompute until the process restarted. + */ + private _objectRevision = 0; + + /** See {@link _objectRevision}. Read it, compare it, rebuild when it moves. */ + get objectRevision(): number { + return this._objectRevision; + } + /** Namespace → Set (multiple packages can share a namespace) */ private namespaceRegistry = new Map>(); @@ -878,6 +897,9 @@ export class SchemaRegistry { // Invalidate merge cache this.mergedObjectCache.delete(fqn); + // …and tell registry-derived caches (the engine's roll-up summary index) + // that the object set moved, whichever path got here. + this._objectRevision += 1; this.log(`[Registry] Registered object: ${fqn} (${ownership}, priority=${priority}) from ${packageId}`); return fqn; @@ -1079,6 +1101,7 @@ export class SchemaRegistry { // Invalidate cache this.mergedObjectCache.delete(fqn); + this._objectRevision += 1; } } @@ -1702,6 +1725,7 @@ export class SchemaRegistry { * name are invalidated. */ invalidate(fqnOrName: string): void { + this._objectRevision += 1; if (this.mergedObjectCache.has(fqnOrName)) { this.mergedObjectCache.delete(fqnOrName); return; @@ -1718,6 +1742,7 @@ export class SchemaRegistry { /** Drop every entry from the merged-schema cache. */ invalidateAll(): void { this.mergedObjectCache.clear(); + this._objectRevision += 1; } /** @@ -1729,6 +1754,7 @@ export class SchemaRegistry { this.namespaceRegistry.clear(); this.metadata.clear(); this.appNavContributions.clear(); + this._objectRevision += 1; this.log('[Registry] Reset complete'); } } diff --git a/packages/objectql/src/summary-rollup.test.ts b/packages/objectql/src/summary-rollup.test.ts index 44a783ea1d..fcf084c656 100644 --- a/packages/objectql/src/summary-rollup.test.ts +++ b/packages/objectql/src/summary-rollup.test.ts @@ -237,3 +237,49 @@ describe('roll-up summary fields with a filter predicate', () => { expect(pub(p.id).total_events).toBe(1); // the unfiltered count still sees the view }); }); + +describe('roll-up summary index — a roll-up registered at RUNTIME still computes', () => { + it('picks up an object registered after the index was already built', async () => { + // The runtime publish path registers straight into the registry + // (`protocol.saveMetaItem` → `registry.registerObject`), never through + // `engine.registerApp` — the sole site that used to invalidate the engine's + // summary index. Any kernel that had already written a row (publishing + // itself writes `sys_metadata`) held an index built before the new object, + // so a freshly published roll-up silently never recomputed until restart: + // an AI-built "已完成任务数" over correct metadata, permanently empty + // (cloud#970). + const engine = new ObjectQL(); + const d = makeDriver(); + engine.registerDriver(d.driver, true); + await engine.init(); + + // A write BEFORE the roll-up exists — this is what warmed the stale cache. + engine.registry.registerObject({ name: 'note', fields: { body: { type: 'text' } } } as any); + await engine.insert('note', { body: 'warm the summary index' }); + + // Now publish the parent + child, the way a runtime publish does. + engine.registry.registerObject({ + name: 'project', + fields: { + name: { type: 'text' }, + task_count: { type: 'summary', summaryOperations: { object: 'task', field: 'id', function: 'count' } }, + completed_task_count: { + type: 'summary', + summaryOperations: { object: 'task', field: 'id', function: 'count', filter: { status: 'completed' } }, + }, + }, + } as any); + engine.registry.registerObject({ + name: 'task', + fields: { title: { type: 'text' }, status: { type: 'text' }, project: { type: 'master_detail', reference: 'project' } }, + } as any); + + const p = await engine.insert('project', { name: 'Apollo' }); + await engine.insert('task', { title: 'a', status: 'completed', project: p.id }); + await engine.insert('task', { title: 'b', status: 'todo', project: p.id }); + + const parent = d.storeFor('project').get(p.id); + expect(parent.task_count).toBe(2); + expect(parent.completed_task_count).toBe(1); + }); +});