Skip to content
Merged
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
23 changes: 23 additions & 0 deletions .changeset/summary-index-registry-revision.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 19 additions & 1 deletion packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ?? [];
}

Expand Down
26 changes: 26 additions & 0 deletions packages/objectql/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,25 @@ export class SchemaRegistry {
/** FQN → Merged ServiceObject (cached, invalidated on changes) */
private mergedObjectCache = new Map<string, ServiceObject>();

/**
* 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<PackageId> (multiple packages can share a namespace) */
private namespaceRegistry = new Map<string, Set<string>>();

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1079,6 +1101,7 @@ export class SchemaRegistry {

// Invalidate cache
this.mergedObjectCache.delete(fqn);
this._objectRevision += 1;
}
}

Expand Down Expand Up @@ -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;
Expand All @@ -1718,6 +1742,7 @@ export class SchemaRegistry {
/** Drop every entry from the merged-schema cache. */
invalidateAll(): void {
this.mergedObjectCache.clear();
this._objectRevision += 1;
}

/**
Expand All @@ -1729,6 +1754,7 @@ export class SchemaRegistry {
this.namespaceRegistry.clear();
this.metadata.clear();
this.appNavContributions.clear();
this._objectRevision += 1;
this.log('[Registry] Reset complete');
}
}
46 changes: 46 additions & 0 deletions packages/objectql/src/summary-rollup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading