Skip to content

Commit 26bb053

Browse files
os-zhuangclaude
andauthored
fix(objectql): a roll-up registered at RUNTIME must not need a restart to compute (#4427)
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, and the parent field read 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 (register, invalidate, unregister, reset), 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. `invalidateSummaryIndex` stays as a belt-and-braces hook. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 462d9c4 commit 26bb053

4 files changed

Lines changed: 114 additions & 1 deletion

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): a roll-up registered at RUNTIME must not need a restart to compute
6+
7+
The engine's roll-up summary index had exactly one invalidation site —
8+
`engine.registerApp` — and the runtime publish path does not go through it: it
9+
registers straight into the registry (`protocol.saveMetaItem`
10+
`registry.registerObject`). So a kernel that had already performed a single
11+
write — publishing itself writes `sys_metadata` rows — held a summary index
12+
built before the new object existed. Every child write of a freshly published
13+
roll-up then found no descriptor and silently skipped the recompute, leaving the
14+
parent field null until the process restarted.
15+
16+
That is how an AI-built app's "已完成任务数" shipped permanently empty over
17+
completely correct metadata: the roll-up was configured, the child rows were
18+
seeded with resolved foreign keys, and nothing recomputed (cloud#970).
19+
20+
`SchemaRegistry` now carries a monotonic `objectRevision`, bumped on every
21+
change to the registered object set, and the engine rebuilds its index whenever
22+
that number has moved — so a registry-derived cache can no longer go stale
23+
through a path that forgot to call the invalidator.

packages/objectql/src/engine.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2819,8 +2819,26 @@ export class ObjectQL implements IObjectQLEngine {
28192819
return index;
28202820
}
28212821

2822+
/** `registry.objectRevision` the cached {@link summaryIndex} was built at. */
2823+
private summaryIndexRevision = -1;
2824+
28222825
private getSummaryDescriptors(childObject: string): SummaryDescriptor[] {
2823-
if (!this.summaryIndex) this.summaryIndex = this.buildSummaryIndex();
2826+
// Rebuild whenever the REGISTRY's object set has moved since the index was
2827+
// built — not only when someone remembered to call
2828+
// `invalidateSummaryIndex`. That single site (`registerApp`) is bypassed by
2829+
// the runtime publish path, which registers straight into the registry
2830+
// (`protocol.saveMetaItem` → `registry.registerObject`). A kernel that had
2831+
// already done one write — publishing itself writes `sys_metadata` — held an
2832+
// index built before the new object existed, so every child write of a
2833+
// freshly published roll-up skipped the recompute and the parent read null
2834+
// until the process restarted. That is exactly how an AI-built app's
2835+
// "已完成任务数" shipped empty over correct metadata (cloud#970).
2836+
const revision = (this._registry as unknown as { objectRevision?: number })?.objectRevision;
2837+
const stale = typeof revision === 'number' && revision !== this.summaryIndexRevision;
2838+
if (!this.summaryIndex || stale) {
2839+
this.summaryIndex = this.buildSummaryIndex();
2840+
if (typeof revision === 'number') this.summaryIndexRevision = revision;
2841+
}
28242842
return this.summaryIndex.get(childObject) ?? [];
28252843
}
28262844

packages/objectql/src/registry.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,25 @@ export class SchemaRegistry {
656656
/** FQN → Merged ServiceObject (cached, invalidated on changes) */
657657
private mergedObjectCache = new Map<string, ServiceObject>();
658658

659+
/**
660+
* Monotonic counter bumped on every change to the registered object set.
661+
*
662+
* Consumers that derive their OWN cache from the registry (the engine's
663+
* roll-up summary index) key it on this, so a runtime registration can never
664+
* leave them stale. Before it existed, the engine's summary index had a single
665+
* invalidation site — `engine.registerApp` — which the runtime publish path
666+
* does not go through (it calls `registry.registerObject` directly). Any
667+
* kernel that had already performed one write therefore held an index built
668+
* before the object existed, and every child write of a newly-published
669+
* roll-up silently skipped the recompute until the process restarted.
670+
*/
671+
private _objectRevision = 0;
672+
673+
/** See {@link _objectRevision}. Read it, compare it, rebuild when it moves. */
674+
get objectRevision(): number {
675+
return this._objectRevision;
676+
}
677+
659678
/** Namespace → Set<PackageId> (multiple packages can share a namespace) */
660679
private namespaceRegistry = new Map<string, Set<string>>();
661680

@@ -878,6 +897,9 @@ export class SchemaRegistry {
878897

879898
// Invalidate merge cache
880899
this.mergedObjectCache.delete(fqn);
900+
// …and tell registry-derived caches (the engine's roll-up summary index)
901+
// that the object set moved, whichever path got here.
902+
this._objectRevision += 1;
881903

882904
this.log(`[Registry] Registered object: ${fqn} (${ownership}, priority=${priority}) from ${packageId}`);
883905
return fqn;
@@ -1079,6 +1101,7 @@ export class SchemaRegistry {
10791101

10801102
// Invalidate cache
10811103
this.mergedObjectCache.delete(fqn);
1104+
this._objectRevision += 1;
10821105
}
10831106
}
10841107

@@ -1702,6 +1725,7 @@ export class SchemaRegistry {
17021725
* name are invalidated.
17031726
*/
17041727
invalidate(fqnOrName: string): void {
1728+
this._objectRevision += 1;
17051729
if (this.mergedObjectCache.has(fqnOrName)) {
17061730
this.mergedObjectCache.delete(fqnOrName);
17071731
return;
@@ -1718,6 +1742,7 @@ export class SchemaRegistry {
17181742
/** Drop every entry from the merged-schema cache. */
17191743
invalidateAll(): void {
17201744
this.mergedObjectCache.clear();
1745+
this._objectRevision += 1;
17211746
}
17221747

17231748
/**
@@ -1729,6 +1754,7 @@ export class SchemaRegistry {
17291754
this.namespaceRegistry.clear();
17301755
this.metadata.clear();
17311756
this.appNavContributions.clear();
1757+
this._objectRevision += 1;
17321758
this.log('[Registry] Reset complete');
17331759
}
17341760
}

packages/objectql/src/summary-rollup.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,3 +237,49 @@ describe('roll-up summary fields with a filter predicate', () => {
237237
expect(pub(p.id).total_events).toBe(1); // the unfiltered count still sees the view
238238
});
239239
});
240+
241+
describe('roll-up summary index — a roll-up registered at RUNTIME still computes', () => {
242+
it('picks up an object registered after the index was already built', async () => {
243+
// The runtime publish path registers straight into the registry
244+
// (`protocol.saveMetaItem` → `registry.registerObject`), never through
245+
// `engine.registerApp` — the sole site that used to invalidate the engine's
246+
// summary index. Any kernel that had already written a row (publishing
247+
// itself writes `sys_metadata`) held an index built before the new object,
248+
// so a freshly published roll-up silently never recomputed until restart:
249+
// an AI-built "已完成任务数" over correct metadata, permanently empty
250+
// (cloud#970).
251+
const engine = new ObjectQL();
252+
const d = makeDriver();
253+
engine.registerDriver(d.driver, true);
254+
await engine.init();
255+
256+
// A write BEFORE the roll-up exists — this is what warmed the stale cache.
257+
engine.registry.registerObject({ name: 'note', fields: { body: { type: 'text' } } } as any);
258+
await engine.insert('note', { body: 'warm the summary index' });
259+
260+
// Now publish the parent + child, the way a runtime publish does.
261+
engine.registry.registerObject({
262+
name: 'project',
263+
fields: {
264+
name: { type: 'text' },
265+
task_count: { type: 'summary', summaryOperations: { object: 'task', field: 'id', function: 'count' } },
266+
completed_task_count: {
267+
type: 'summary',
268+
summaryOperations: { object: 'task', field: 'id', function: 'count', filter: { status: 'completed' } },
269+
},
270+
},
271+
} as any);
272+
engine.registry.registerObject({
273+
name: 'task',
274+
fields: { title: { type: 'text' }, status: { type: 'text' }, project: { type: 'master_detail', reference: 'project' } },
275+
} as any);
276+
277+
const p = await engine.insert('project', { name: 'Apollo' });
278+
await engine.insert('task', { title: 'a', status: 'completed', project: p.id });
279+
await engine.insert('task', { title: 'b', status: 'todo', project: p.id });
280+
281+
const parent = d.storeFor('project').get(p.id);
282+
expect(parent.task_count).toBe(2);
283+
expect(parent.completed_task_count).toBe(1);
284+
});
285+
});

0 commit comments

Comments
 (0)