Skip to content

Commit d2723e2

Browse files
os-zhuangclaude
andauthored
fix(metadata): register()/unregister() announce to subscribe() watchers (#3112) (#3131)
* fix(metadata): register()/unregister() announce to subscribe() watchers (#3112) `MetadataManager.register()` updated the in-memory registry, persisted to writable loaders and published to the realtime service — but never called `notifyWatchers()`. `unregister()` had the same gap. Watchers only ever fired from the `saveMetaItem` path (indirectly, via the sys_metadata repository watch stream) and the filesystem watcher, so `subscribe()` looked like it covered every write while silently missing all of them. The consumer that pays for this is ObjectQL's SchemaRegistry bridge — the component that decides what is queryable. #3108 worked around it for the `metadata:reloaded` seam by carrying the parsed artifact on the event payload, but the asymmetry remained for every other `register()` call site and every other `subscribe()` consumer. Announce by default, so a new call site is correct without knowing the contract exists. Two concrete fixes fall out: `unregisterPackage()` now tells cached consumers to drop uninstalled objects instead of serving them until restart, and runtime datasource writes reach watchers. Bulk ingest opts out explicitly via the new `MetadataWriteOptions` (`{ notify: false }`) at the five boot/batch sites, each of which either runs before consumers cache anything or announces the whole batch once. ObjectQL's registry bridge MUST stay silent for a second reason: it copies objects OUT of the SchemaRegistry, and announcing would feed them back through a handler that re-registers under `_packageId ?? 'metadata-service'` — overwriting the true package provenance of every object whose body carries no `_packageId`. Additive only: the 3-arg forms keep working, and the api-surface ratchet reports 0 breaking / 1 added. Tests pin both halves of the contract (announce by default, silence only when asked) and the no-flood invariant on the reload path; both were verified to fail against the pre-change behavior. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(changeset): state the register() notify change as a contract fix, not a bug fix unregisterPackage()/bulkUnregister() have no production caller in framework or cloud today, so their announce is correct-but-latent. The one live behavior change is runtime datasource writes reaching the HMR SSE stream. Say so instead of advertising two 'concrete fixes'. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c22e2c3 commit d2723e2

8 files changed

Lines changed: 453 additions & 25 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/metadata": patch
4+
"@objectstack/objectql": patch
5+
---
6+
7+
**`MetadataManager.register()` / `unregister()` now announce to `subscribe()` watchers.** Both updated the registry, persisted to writable loaders and published to realtime, but never fired the watch callbacks — so `subscribe()` looked like it covered every write while silently missing all of them. Only the `saveMetaItem` path (via the repository watch stream) and the filesystem watcher ever reached a subscriber. Runtime consumers that cache metadata — notably ObjectQL's SchemaRegistry bridge, the component that decides what is queryable — went stale on every other write until the process restarted.
8+
9+
Announcing is now the **default**, so a new call site is correct without knowing this contract exists. This is a contract fix rather than a bug fix: the one live behavior change is that runtime datasource writes (`datasource-admin`) now reach the HMR SSE stream, which subscribes to every registered type. `unregisterPackage()` / `bulkUnregister()` also announce their deletes now — correct, but latent, since neither has a production caller today.
10+
11+
Bulk ingest opts out explicitly with the new `MetadataWriteOptions` (`{ notify: false }`) — boot-time filesystem priming, artifact ingest, and ObjectQL's registry bridge, each of which either runs before consumers cache anything or announces the whole batch once (as the artifact reload path does via `metadata:reloaded`). The bridge in particular MUST stay silent: it copies objects out of the SchemaRegistry, and announcing would feed them back through a handler that re-registers under `_packageId ?? 'metadata-service'`, overwriting the true package provenance of every object whose body carries no `_packageId`.
12+
13+
Additive only — `register(type, name, data)` and `unregister(type, name)` keep working unchanged.
14+
15+
Fixes #3112.

packages/metadata/src/metadata-manager.ts

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import type {
2828
MetadataImportOptions,
2929
MetadataImportResult,
3030
MetadataTypeInfo,
31+
MetadataWriteOptions,
3132
IRealtimeService,
3233
RealtimeEventPayload,
3334
IPubSub,
@@ -276,8 +277,20 @@ export class MetadataManager implements IMetadataService {
276277
* Stores in-memory registry and persists to database-backed loaders only.
277278
* FilesystemLoader (protocol 'file:') is read-only for static metadata and
278279
* should not be written to during runtime registration.
280+
*
281+
* Announces the write to {@link subscribe} watchers as an `added` /
282+
* `changed` {@link MetadataWatchEvent}, so consumers that cache metadata
283+
* (ObjectQL's SchemaRegistry bridge, the HMR SSE stream) refresh instead of
284+
* serving the pre-write definition until restart. Pass `{ notify: false }`
285+
* for bulk ingest that announces by other means — read
286+
* {@link MetadataWriteOptions.notify} before doing so.
279287
*/
280-
async register(type: string, name: string, data: unknown): Promise<void> {
288+
async register(
289+
type: string,
290+
name: string,
291+
data: unknown,
292+
options?: MetadataWriteOptions,
293+
): Promise<void> {
281294
// Persistence write gate: when `persistence.writable` is explicitly false
282295
// we treat register() as read-only. Default `true` (or omitted) preserves
283296
// historical behavior.
@@ -290,6 +303,11 @@ export class MetadataManager implements IMetadataService {
290303
return;
291304
}
292305

306+
// Captured before the write so the event distinguishes a first
307+
// registration from an overwrite, matching the repo-watch path's
308+
// 'added' vs 'changed' split.
309+
const existed = this.registry.get(type)?.has(name) ?? false;
310+
293311
if (!this.registry.has(type)) {
294312
this.registry.set(type, new Map());
295313
}
@@ -326,6 +344,20 @@ export class MetadataManager implements IMetadataService {
326344
this.logger.warn(`Failed to publish metadata event`, { type, name, error });
327345
}
328346
}
347+
348+
// Announce last, once the write has landed in the registry and every
349+
// writable loader — a subscriber that re-reads on the event must not
350+
// race ahead of the data it is meant to observe.
351+
if (options?.notify !== false) {
352+
this.notifyWatchers(type, {
353+
type: existed ? 'changed' : 'added',
354+
metadataType: type,
355+
name,
356+
path: '',
357+
data,
358+
timestamp: new Date().toISOString(),
359+
});
360+
}
329361
}
330362

331363
/**
@@ -336,6 +368,13 @@ export class MetadataManager implements IMetadataService {
336368
* Addendum) declared in `*.datasource.ts` and owned by source control. Writing
337369
* them through `register()` would persist them to `sys_metadata` and create
338370
* drift between the artefact and the DB; this method avoids that.
371+
*
372+
* Deliberately silent: it does NOT announce to {@link subscribe} watchers.
373+
* This is a boot-time seeding primitive for artefacts that source control
374+
* owns — callers that mutate metadata mid-run want {@link register}, which
375+
* announces. If you add a mid-run caller here, announce the change yourself
376+
* (as the artifact reload path does via `metadata:reloaded`) or its
377+
* consumers will read the pre-write definition until restart.
339378
*/
340379
registerInMemory(type: string, name: string, data: unknown): void {
341380
if (!this.registry.has(type)) {
@@ -415,8 +454,13 @@ export class MetadataManager implements IMetadataService {
415454
/**
416455
* Unregister/remove a metadata item by type and name.
417456
* Deletes from database-backed loaders only (same rationale as register()).
457+
*
458+
* Announces the removal to {@link subscribe} watchers as a `deleted`
459+
* {@link MetadataWatchEvent} — the delete half of the {@link register}
460+
* contract. Pass `{ notify: false }` only for teardown that announces by
461+
* other means.
418462
*/
419-
async unregister(type: string, name: string): Promise<void> {
463+
async unregister(type: string, name: string, options?: MetadataWriteOptions): Promise<void> {
420464
// Remove from in-memory registry
421465
const typeStore = this.registry.get(type);
422466
if (typeStore) {
@@ -458,6 +502,18 @@ export class MetadataManager implements IMetadataService {
458502
this.logger.warn(`Failed to publish metadata event`, { type, name, error });
459503
}
460504
}
505+
506+
// Announce last, once the removal has landed everywhere (see register()).
507+
if (options?.notify !== false) {
508+
this.notifyWatchers(type, {
509+
type: 'deleted',
510+
metadataType: type,
511+
name,
512+
path: '',
513+
data: undefined,
514+
timestamp: new Date().toISOString(),
515+
});
516+
}
461517
}
462518

463519
/**
@@ -892,20 +948,24 @@ export class MetadataManager implements IMetadataService {
892948
// ==========================================
893949

894950
/**
895-
* Register multiple metadata items in a single batch
951+
* Register multiple metadata items in a single batch.
952+
*
953+
* Announces one event per item, like {@link register}. Pass
954+
* `{ notify: false }` when the batch is boot-time ingest or when the caller
955+
* announces the whole set once — see {@link MetadataWriteOptions.notify}.
896956
*/
897957
async bulkRegister(
898958
items: Array<{ type: string; name: string; data: unknown }>,
899-
options?: { continueOnError?: boolean; validate?: boolean }
959+
options?: { continueOnError?: boolean; validate?: boolean } & MetadataWriteOptions
900960
): Promise<MetadataBulkResult> {
901-
const { continueOnError = false } = options ?? {};
961+
const { continueOnError = false, notify } = options ?? {};
902962
let succeeded = 0;
903963
let failed = 0;
904964
const errors: Array<{ type: string; name: string; error: string }> = [];
905965

906966
for (const item of items) {
907967
try {
908-
await this.register(item.type, item.name, item.data);
968+
await this.register(item.type, item.name, item.data, { notify });
909969
succeeded++;
910970
} catch (e) {
911971
failed++;
@@ -927,16 +987,21 @@ export class MetadataManager implements IMetadataService {
927987
}
928988

929989
/**
930-
* Unregister multiple metadata items in a single batch
990+
* Unregister multiple metadata items in a single batch.
991+
*
992+
* Announces one `deleted` event per item, like {@link unregister}.
931993
*/
932-
async bulkUnregister(items: Array<{ type: string; name: string }>): Promise<MetadataBulkResult> {
994+
async bulkUnregister(
995+
items: Array<{ type: string; name: string }>,
996+
options?: MetadataWriteOptions,
997+
): Promise<MetadataBulkResult> {
933998
let succeeded = 0;
934999
let failed = 0;
9351000
const errors: Array<{ type: string; name: string; error: string }> = [];
9361001

9371002
for (const item of items) {
9381003
try {
939-
await this.unregister(item.type, item.name);
1004+
await this.unregister(item.type, item.name, options);
9401005
succeeded++;
9411006
} catch (e) {
9421007
failed++;

packages/metadata/src/plugin-hmr-reload.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,34 @@ describe('MetadataPlugin._reloadAndAnnounce — fires metadata:reloaded after re
9090
]);
9191
});
9292

93+
it('announces ONCE per reload, not once per ingested item (#3112)', async () => {
94+
// `register()` announces to subscribe() watchers by default. Artifact
95+
// ingest deliberately opts out (`{ notify: false }`) because this hook
96+
// is the announcement for the whole batch. If someone drops that
97+
// opt-out, every reload fans N per-item events into every watcher —
98+
// each one racing the ingest that is still landing — and boot-time
99+
// registration floods subscribers that have nothing to refresh yet.
100+
const plugin = new MetadataPlugin({
101+
watch: false,
102+
config: { bootstrap: 'eager' },
103+
environmentId: 'proj_test',
104+
});
105+
const mgr = (plugin as any).manager as NodeMetadataManager;
106+
const ctx = fakeCtx();
107+
const file = writeArtifact('sweep3');
108+
109+
const perItemEvents: unknown[] = [];
110+
mgr.subscribe('flow', (evt) => perItemEvents.push(evt));
111+
112+
await (plugin as any)._reloadAndAnnounce(ctx, { path: file, fetchTimeoutMs: undefined }, [file]);
113+
114+
expect(perItemEvents).toEqual([]);
115+
// The single batch-level announcement is the contract, and it carries
116+
// the bodies consumers need to re-ingest.
117+
expect(ctx.trigger).toHaveBeenCalledTimes(1);
118+
expect(await mgr.get('flow', 'sweep3')).toBeDefined();
119+
});
120+
93121
it('still announces even if a subscriber throws (reload must not break)', async () => {
94122
const plugin = new MetadataPlugin({
95123
watch: false,

packages/metadata/src/plugin.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,14 @@ export class MetadataPlugin implements Plugin {
510510
/**
511511
* Parse raw artifact JSON (envelope or bare definition) and register all
512512
* metadata items into the MetadataManager.
513+
*
514+
* Registers with `{ notify: false }` — one announcement per artifact, not
515+
* one per item. Both callers cover the whole set already: the boot load
516+
* runs before consumers have cached anything, and the reload path
517+
* (`_reloadAndAnnounce`) fires `metadata:reloaded` carrying the parsed
518+
* artifact once the ingest is complete. Announcing here too would emit N
519+
* duplicate events per reload, each racing the batch that is still
520+
* landing.
513521
*/
514522
private async _parseAndRegisterArtifact(ctx: PluginContext, raw: unknown, label: string): Promise<number> {
515523
const { EnvironmentArtifactSchema } = await import('@objectstack/spec/cloud');
@@ -568,7 +576,7 @@ export class MetadataPlugin implements Plugin {
568576
packageVersion: manifestVersion,
569577
});
570578
await memLoader.save('view', viewObject, item);
571-
await this.manager.register('view', viewObject, item);
579+
await this.manager.register('view', viewObject, item, { notify: false });
572580
totalRegistered++;
573581
for (const vi of expandViewContainer(viewObject, item)) {
574582
for (const w of vi._diagnostics?.warnings ?? []) {
@@ -579,7 +587,7 @@ export class MetadataPlugin implements Plugin {
579587
packageVersion: manifestVersion,
580588
});
581589
await memLoader.save('view', vi.name, vi);
582-
await this.manager.register('view', vi.name, vi);
590+
await this.manager.register('view', vi.name, vi, { notify: false });
583591
totalRegistered++;
584592
}
585593
continue;
@@ -614,7 +622,7 @@ export class MetadataPlugin implements Plugin {
614622
packageVersion: manifestVersion,
615623
});
616624
await memLoader.save(metaType, name, item);
617-
await this.manager.register(metaType, name, item);
625+
await this.manager.register(metaType, name, item, { notify: false });
618626
totalRegistered++;
619627
}
620628
}
@@ -738,7 +746,11 @@ export class MetadataPlugin implements Plugin {
738746
applyProtection(meta, {
739747
packageId: this.options.packageId,
740748
});
741-
await this.manager.register(entry.type, meta.name, item);
749+
// Silent: boot-time priming, before any consumer
750+
// has cached a definition to go stale. Post-boot
751+
// edits to these files reach watchers through the
752+
// FileSystemRepository attached by onEnable.
753+
await this.manager.register(entry.type, meta.name, item, { notify: false });
742754
}
743755
}
744756
ctx.logger.info(`Loaded ${items.length} ${entry.type} from file system`);

0 commit comments

Comments
 (0)