diff --git a/.changeset/metadata-register-notifies-watchers.md b/.changeset/metadata-register-notifies-watchers.md new file mode 100644 index 000000000..f77d1ad86 --- /dev/null +++ b/.changeset/metadata-register-notifies-watchers.md @@ -0,0 +1,15 @@ +--- +"@objectstack/spec": patch +"@objectstack/metadata": patch +"@objectstack/objectql": patch +--- + +**`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. + +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. + +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`. + +Additive only — `register(type, name, data)` and `unregister(type, name)` keep working unchanged. + +Fixes #3112. diff --git a/packages/metadata/src/metadata-manager.ts b/packages/metadata/src/metadata-manager.ts index d9b7c3563..54a81354f 100644 --- a/packages/metadata/src/metadata-manager.ts +++ b/packages/metadata/src/metadata-manager.ts @@ -28,6 +28,7 @@ import type { MetadataImportOptions, MetadataImportResult, MetadataTypeInfo, + MetadataWriteOptions, IRealtimeService, RealtimeEventPayload, IPubSub, @@ -276,8 +277,20 @@ export class MetadataManager implements IMetadataService { * Stores in-memory registry and persists to database-backed loaders only. * FilesystemLoader (protocol 'file:') is read-only for static metadata and * should not be written to during runtime registration. + * + * Announces the write to {@link subscribe} watchers as an `added` / + * `changed` {@link MetadataWatchEvent}, so consumers that cache metadata + * (ObjectQL's SchemaRegistry bridge, the HMR SSE stream) refresh instead of + * serving the pre-write definition until restart. Pass `{ notify: false }` + * for bulk ingest that announces by other means — read + * {@link MetadataWriteOptions.notify} before doing so. */ - async register(type: string, name: string, data: unknown): Promise { + async register( + type: string, + name: string, + data: unknown, + options?: MetadataWriteOptions, + ): Promise { // Persistence write gate: when `persistence.writable` is explicitly false // we treat register() as read-only. Default `true` (or omitted) preserves // historical behavior. @@ -290,6 +303,11 @@ export class MetadataManager implements IMetadataService { return; } + // Captured before the write so the event distinguishes a first + // registration from an overwrite, matching the repo-watch path's + // 'added' vs 'changed' split. + const existed = this.registry.get(type)?.has(name) ?? false; + if (!this.registry.has(type)) { this.registry.set(type, new Map()); } @@ -326,6 +344,20 @@ export class MetadataManager implements IMetadataService { this.logger.warn(`Failed to publish metadata event`, { type, name, error }); } } + + // Announce last, once the write has landed in the registry and every + // writable loader — a subscriber that re-reads on the event must not + // race ahead of the data it is meant to observe. + if (options?.notify !== false) { + this.notifyWatchers(type, { + type: existed ? 'changed' : 'added', + metadataType: type, + name, + path: '', + data, + timestamp: new Date().toISOString(), + }); + } } /** @@ -336,6 +368,13 @@ export class MetadataManager implements IMetadataService { * Addendum) declared in `*.datasource.ts` and owned by source control. Writing * them through `register()` would persist them to `sys_metadata` and create * drift between the artefact and the DB; this method avoids that. + * + * Deliberately silent: it does NOT announce to {@link subscribe} watchers. + * This is a boot-time seeding primitive for artefacts that source control + * owns — callers that mutate metadata mid-run want {@link register}, which + * announces. If you add a mid-run caller here, announce the change yourself + * (as the artifact reload path does via `metadata:reloaded`) or its + * consumers will read the pre-write definition until restart. */ registerInMemory(type: string, name: string, data: unknown): void { if (!this.registry.has(type)) { @@ -415,8 +454,13 @@ export class MetadataManager implements IMetadataService { /** * Unregister/remove a metadata item by type and name. * Deletes from database-backed loaders only (same rationale as register()). + * + * Announces the removal to {@link subscribe} watchers as a `deleted` + * {@link MetadataWatchEvent} — the delete half of the {@link register} + * contract. Pass `{ notify: false }` only for teardown that announces by + * other means. */ - async unregister(type: string, name: string): Promise { + async unregister(type: string, name: string, options?: MetadataWriteOptions): Promise { // Remove from in-memory registry const typeStore = this.registry.get(type); if (typeStore) { @@ -458,6 +502,18 @@ export class MetadataManager implements IMetadataService { this.logger.warn(`Failed to publish metadata event`, { type, name, error }); } } + + // Announce last, once the removal has landed everywhere (see register()). + if (options?.notify !== false) { + this.notifyWatchers(type, { + type: 'deleted', + metadataType: type, + name, + path: '', + data: undefined, + timestamp: new Date().toISOString(), + }); + } } /** @@ -892,20 +948,24 @@ export class MetadataManager implements IMetadataService { // ========================================== /** - * Register multiple metadata items in a single batch + * Register multiple metadata items in a single batch. + * + * Announces one event per item, like {@link register}. Pass + * `{ notify: false }` when the batch is boot-time ingest or when the caller + * announces the whole set once — see {@link MetadataWriteOptions.notify}. */ async bulkRegister( items: Array<{ type: string; name: string; data: unknown }>, - options?: { continueOnError?: boolean; validate?: boolean } + options?: { continueOnError?: boolean; validate?: boolean } & MetadataWriteOptions ): Promise { - const { continueOnError = false } = options ?? {}; + const { continueOnError = false, notify } = options ?? {}; let succeeded = 0; let failed = 0; const errors: Array<{ type: string; name: string; error: string }> = []; for (const item of items) { try { - await this.register(item.type, item.name, item.data); + await this.register(item.type, item.name, item.data, { notify }); succeeded++; } catch (e) { failed++; @@ -927,16 +987,21 @@ export class MetadataManager implements IMetadataService { } /** - * Unregister multiple metadata items in a single batch + * Unregister multiple metadata items in a single batch. + * + * Announces one `deleted` event per item, like {@link unregister}. */ - async bulkUnregister(items: Array<{ type: string; name: string }>): Promise { + async bulkUnregister( + items: Array<{ type: string; name: string }>, + options?: MetadataWriteOptions, + ): Promise { let succeeded = 0; let failed = 0; const errors: Array<{ type: string; name: string; error: string }> = []; for (const item of items) { try { - await this.unregister(item.type, item.name); + await this.unregister(item.type, item.name, options); succeeded++; } catch (e) { failed++; diff --git a/packages/metadata/src/plugin-hmr-reload.test.ts b/packages/metadata/src/plugin-hmr-reload.test.ts index c8525fe21..7c1a2ab61 100644 --- a/packages/metadata/src/plugin-hmr-reload.test.ts +++ b/packages/metadata/src/plugin-hmr-reload.test.ts @@ -90,6 +90,34 @@ describe('MetadataPlugin._reloadAndAnnounce — fires metadata:reloaded after re ]); }); + it('announces ONCE per reload, not once per ingested item (#3112)', async () => { + // `register()` announces to subscribe() watchers by default. Artifact + // ingest deliberately opts out (`{ notify: false }`) because this hook + // is the announcement for the whole batch. If someone drops that + // opt-out, every reload fans N per-item events into every watcher — + // each one racing the ingest that is still landing — and boot-time + // registration floods subscribers that have nothing to refresh yet. + const plugin = new MetadataPlugin({ + watch: false, + config: { bootstrap: 'eager' }, + environmentId: 'proj_test', + }); + const mgr = (plugin as any).manager as NodeMetadataManager; + const ctx = fakeCtx(); + const file = writeArtifact('sweep3'); + + const perItemEvents: unknown[] = []; + mgr.subscribe('flow', (evt) => perItemEvents.push(evt)); + + await (plugin as any)._reloadAndAnnounce(ctx, { path: file, fetchTimeoutMs: undefined }, [file]); + + expect(perItemEvents).toEqual([]); + // The single batch-level announcement is the contract, and it carries + // the bodies consumers need to re-ingest. + expect(ctx.trigger).toHaveBeenCalledTimes(1); + expect(await mgr.get('flow', 'sweep3')).toBeDefined(); + }); + it('still announces even if a subscriber throws (reload must not break)', async () => { const plugin = new MetadataPlugin({ watch: false, diff --git a/packages/metadata/src/plugin.ts b/packages/metadata/src/plugin.ts index d03e2c29a..b873c0047 100644 --- a/packages/metadata/src/plugin.ts +++ b/packages/metadata/src/plugin.ts @@ -510,6 +510,14 @@ export class MetadataPlugin implements Plugin { /** * Parse raw artifact JSON (envelope or bare definition) and register all * metadata items into the MetadataManager. + * + * Registers with `{ notify: false }` — one announcement per artifact, not + * one per item. Both callers cover the whole set already: the boot load + * runs before consumers have cached anything, and the reload path + * (`_reloadAndAnnounce`) fires `metadata:reloaded` carrying the parsed + * artifact once the ingest is complete. Announcing here too would emit N + * duplicate events per reload, each racing the batch that is still + * landing. */ private async _parseAndRegisterArtifact(ctx: PluginContext, raw: unknown, label: string): Promise { const { EnvironmentArtifactSchema } = await import('@objectstack/spec/cloud'); @@ -568,7 +576,7 @@ export class MetadataPlugin implements Plugin { packageVersion: manifestVersion, }); await memLoader.save('view', viewObject, item); - await this.manager.register('view', viewObject, item); + await this.manager.register('view', viewObject, item, { notify: false }); totalRegistered++; for (const vi of expandViewContainer(viewObject, item)) { for (const w of vi._diagnostics?.warnings ?? []) { @@ -579,7 +587,7 @@ export class MetadataPlugin implements Plugin { packageVersion: manifestVersion, }); await memLoader.save('view', vi.name, vi); - await this.manager.register('view', vi.name, vi); + await this.manager.register('view', vi.name, vi, { notify: false }); totalRegistered++; } continue; @@ -614,7 +622,7 @@ export class MetadataPlugin implements Plugin { packageVersion: manifestVersion, }); await memLoader.save(metaType, name, item); - await this.manager.register(metaType, name, item); + await this.manager.register(metaType, name, item, { notify: false }); totalRegistered++; } } @@ -738,7 +746,11 @@ export class MetadataPlugin implements Plugin { applyProtection(meta, { packageId: this.options.packageId, }); - await this.manager.register(entry.type, meta.name, item); + // Silent: boot-time priming, before any consumer + // has cached a definition to go stale. Post-boot + // edits to these files reach watchers through the + // FileSystemRepository attached by onEnable. + await this.manager.register(entry.type, meta.name, item, { notify: false }); } } ctx.logger.info(`Loaded ${items.length} ${entry.type} from file system`); diff --git a/packages/metadata/src/register-notifies-watchers.test.ts b/packages/metadata/src/register-notifies-watchers.test.ts new file mode 100644 index 000000000..227a1e5c4 --- /dev/null +++ b/packages/metadata/src/register-notifies-watchers.test.ts @@ -0,0 +1,261 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #3112 — `register()` / `unregister()` announce to `subscribe()` watchers. + * + * Before this contract existed, `register()` updated the registry, persisted to + * writable loaders and published to realtime, but never called + * `notifyWatchers()`. `subscribe()` therefore looked like it covered every + * write while silently missing all of them — ObjectQL's SchemaRegistry bridge + * (the component that keeps queryable schemas in sync) never heard about + * anything registered at runtime, and served the pre-write definition until + * the process restarted. + * + * These tests pin both halves of the contract: announcing is the DEFAULT, and + * silence is only ever opt-in. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { MetadataManager } from './metadata-manager'; +import { MemoryLoader } from './loaders/memory-loader'; +import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; + +vi.mock('@objectstack/core', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})); + +describe('#3112 — register()/unregister() notify subscribe() watchers', () => { + let manager: MetadataManager; + + beforeEach(() => { + manager = new MetadataManager({ + formats: ['json'], + loaders: [new MemoryLoader()], + }); + manager.setTypeRegistry(DEFAULT_METADATA_TYPE_REGISTRY); + }); + + describe('register()', () => { + it('announces a first registration as "added"', async () => { + const seen: any[] = []; + manager.subscribe('object', (evt) => seen.push(evt)); + + await manager.register('object', 'account', { name: 'account', label: 'Account' }); + + expect(seen).toHaveLength(1); + expect(seen[0]).toMatchObject({ + type: 'added', + metadataType: 'object', + name: 'account', + data: { name: 'account', label: 'Account' }, + }); + }); + + it('announces an overwrite as "changed", carrying the NEW body', async () => { + await manager.register('object', 'account', { name: 'account', label: 'V1' }); + + const seen: any[] = []; + manager.subscribe('object', (evt) => seen.push(evt)); + + await manager.register('object', 'account', { name: 'account', label: 'V2' }); + + expect(seen).toHaveLength(1); + expect(seen[0].type).toBe('changed'); + expect(seen[0].data).toEqual({ name: 'account', label: 'V2' }); + }); + + it('announces AFTER the write lands, so a subscriber that re-reads sees the new body', async () => { + // The ordering guarantee that makes the event useful: ObjectQL's bridge + // re-reads via get() on the event rather than trusting the payload. + let readBack: unknown; + manager.subscribe('object', async () => { + readBack = await manager.get('object', 'account'); + }); + + await manager.register('object', 'account', { name: 'account', label: 'Fresh' }); + + expect(readBack).toEqual({ name: 'account', label: 'Fresh' }); + }); + + it('is silent when the caller opts out with { notify: false }', async () => { + const seen: any[] = []; + manager.subscribe('object', (evt) => seen.push(evt)); + + await manager.register('object', 'account', { name: 'account' }, { notify: false }); + + expect(seen).toHaveLength(0); + // Silence must not mean "skipped" — the write still landed. + expect(await manager.get('object', 'account')).toEqual({ name: 'account' }); + }); + + it('only notifies watchers of the written type', async () => { + const objects: any[] = []; + const views: any[] = []; + manager.subscribe('object', (evt) => objects.push(evt)); + manager.subscribe('view', (evt) => views.push(evt)); + + await manager.register('object', 'account', { name: 'account' }); + + expect(objects).toHaveLength(1); + expect(views).toHaveLength(0); + }); + + it('does not notify when the write is refused (persistence.writable=false)', async () => { + const readOnly = new MetadataManager({ + formats: ['json'], + loaders: [new MemoryLoader()], + persistence: { writable: false }, + }); + readOnly.setTypeRegistry(DEFAULT_METADATA_TYPE_REGISTRY); + + const seen: any[] = []; + readOnly.subscribe('object', (evt) => seen.push(evt)); + + await readOnly.register('object', 'account', { name: 'account' }); + + expect(seen).toHaveLength(0); + }); + + it('survives a throwing subscriber without failing the write', async () => { + manager.subscribe('object', () => { + throw new Error('subscriber blew up'); + }); + + await expect( + manager.register('object', 'account', { name: 'account' }), + ).resolves.toBeUndefined(); + expect(await manager.get('object', 'account')).toEqual({ name: 'account' }); + }); + + it('stops notifying after unsubscribe', async () => { + const seen: any[] = []; + const off = manager.subscribe('object', (evt) => seen.push(evt)); + + await manager.register('object', 'a', { name: 'a' }); + off(); + await manager.register('object', 'b', { name: 'b' }); + + expect(seen).toHaveLength(1); + expect(seen[0].name).toBe('a'); + }); + }); + + describe('unregister()', () => { + it('announces a removal as "deleted"', async () => { + await manager.register('object', 'account', { name: 'account' }); + + const seen: any[] = []; + manager.subscribe('object', (evt) => seen.push(evt)); + + await manager.unregister('object', 'account'); + + expect(seen).toHaveLength(1); + expect(seen[0]).toMatchObject({ + type: 'deleted', + metadataType: 'object', + name: 'account', + }); + }); + + it('is silent when the caller opts out with { notify: false }', async () => { + await manager.register('object', 'account', { name: 'account' }, { notify: false }); + + const seen: any[] = []; + manager.subscribe('object', (evt) => seen.push(evt)); + + await manager.unregister('object', 'account', { notify: false }); + + expect(seen).toHaveLength(0); + expect(await manager.get('object', 'account')).toBeUndefined(); + }); + }); + + describe('bulk forms', () => { + it('bulkRegister announces one event per item by default', async () => { + const seen: any[] = []; + manager.subscribe('object', (evt) => seen.push(evt)); + + await manager.bulkRegister([ + { type: 'object', name: 'a', data: { name: 'a' } }, + { type: 'object', name: 'b', data: { name: 'b' } }, + ]); + + expect(seen.map((e) => e.name)).toEqual(['a', 'b']); + }); + + it('bulkRegister forwards { notify: false } to every item', async () => { + const seen: any[] = []; + manager.subscribe('object', (evt) => seen.push(evt)); + + await manager.bulkRegister( + [ + { type: 'object', name: 'a', data: { name: 'a' } }, + { type: 'object', name: 'b', data: { name: 'b' } }, + ], + { notify: false }, + ); + + expect(seen).toHaveLength(0); + expect(await manager.get('object', 'a')).toEqual({ name: 'a' }); + }); + + it('bulkUnregister announces one deleted event per item', async () => { + await manager.bulkRegister( + [ + { type: 'object', name: 'a', data: { name: 'a' } }, + { type: 'object', name: 'b', data: { name: 'b' } }, + ], + { notify: false }, + ); + + const seen: any[] = []; + manager.subscribe('object', (evt) => seen.push(evt)); + + await manager.bulkUnregister([ + { type: 'object', name: 'a' }, + { type: 'object', name: 'b' }, + ]); + + expect(seen.map((e) => e.type)).toEqual(['deleted', 'deleted']); + }); + }); + + describe('unregisterPackage()', () => { + it('announces every removed item, so cached consumers drop the uninstalled schemas', async () => { + // Latent today — nothing calls unregisterPackage() in production — but it + // is the shape the silent write path would have broken: the objects leave + // the registry while ObjectQL's SchemaRegistry bridge, never hearing a + // 'deleted' event, keeps resolving them until the process restarts. + await manager.register('object', 'crm_account', { name: 'crm_account', packageId: 'com.acme.crm' }, { notify: false }); + await manager.register('object', 'crm_contact', { name: 'crm_contact', packageId: 'com.acme.crm' }, { notify: false }); + await manager.register('object', 'other', { name: 'other', packageId: 'com.other' }, { notify: false }); + + const seen: any[] = []; + manager.subscribe('object', (evt) => seen.push(evt)); + + await manager.unregisterPackage('com.acme.crm'); + + expect(seen.map((e) => e.name).sort()).toEqual(['crm_account', 'crm_contact']); + expect(seen.every((e) => e.type === 'deleted')).toBe(true); + // The untouched package must not be announced (or removed). + expect(await manager.get('object', 'other')).toMatchObject({ name: 'other' }); + }); + }); + + describe('registerInMemory()', () => { + it('stays silent by design (GitOps-owned artefacts, documented on the method)', async () => { + const seen: any[] = []; + manager.subscribe('datasource', (evt) => seen.push(evt)); + + manager.registerInMemory('datasource', 'crm_db', { name: 'crm_db', origin: 'code' }); + + expect(seen).toHaveLength(0); + expect(await manager.get('datasource', 'crm_db')).toMatchObject({ name: 'crm_db' }); + }); + }); +}); diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index cc8b1f5fc..6d6235bbc 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -402,13 +402,15 @@ export class ObjectQLPlugin implements Plugin { await this.resyncAuthoredActions(ctx); // 15.1 third-party eval: an object added while `os dev` runs was // invisible until a manual restart. Two gaps compounded: - // 1. MetadataPlugin's artifact reload calls `manager.register()`, - // which does NOT fire `subscribe()` watchers — so the bridge in - // `subscribeToMetadataEvents` never saw the new object and the - // SchemaRegistry never learned it ("Object … is not registered"). - // Ingest the reloaded object definitions straight off the - // `metadata:reloaded` payload (mirroring the subscribe handler's - // registerObject call, provenance included). + // 1. MetadataPlugin's artifact reload ingests through + // `manager.register(…, { notify: false })` — one announcement per + // artifact, not per item (#3112) — so the bridge in + // `subscribeToMetadataEvents` never sees the new object and the + // SchemaRegistry never learns it ("Object … is not registered"). + // This hook IS that announcement: ingest the reloaded object + // definitions straight off the `metadata:reloaded` payload + // (mirroring the subscribe handler's registerObject call, + // provenance included). // 2. Tables were only ever created by the boot-time sync — re-run // the idempotent schema sync after each reload so new objects // get their DDL immediately. Honors the same opt-out as boot @@ -1056,6 +1058,15 @@ export class ObjectQLPlugin implements Plugin { * * Runs after both restoreMetadataFromDb() and syncRegisteredSchemas() to * catch all objects in the SchemaRegistry regardless of their source. + * + * Registers with `{ notify: false }`: this bridge copies objects OUT of the + * SchemaRegistry, so announcing would feed our own `subscribe('object')` + * handler right back into the registry the definitions came from. That is + * not merely redundant — the handler re-registers under + * `_packageId ?? 'metadata-service'`, so every bridged object whose body + * carries no `_packageId` would have its true package provenance + * overwritten with 'metadata-service'. Nothing is stale either: the + * registry is the source here, and it already holds what we just copied. */ private async bridgeObjectsToMetadataService(ctx: PluginContext): Promise { try { @@ -1079,7 +1090,7 @@ export class ObjectQLPlugin implements Plugin { const existing = await metadataService.getObject(obj.name); if (!existing) { // Register object that exists in SchemaRegistry but not in metadata service - await metadataService.register('object', obj.name, obj); + await metadataService.register('object', obj.name, obj, { notify: false }); bridged++; } } catch (e: unknown) { diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 063694811..3cd9a70a3 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -3678,6 +3678,7 @@ "MetadataTypeInfo (interface)", "MetadataWatchCallback (type)", "MetadataWatchHandle (interface)", + "MetadataWriteOptions (interface)", "Middleware (type)", "MigrationApplyResult (interface)", "ModelMessage (type)", diff --git a/packages/spec/src/contracts/metadata-service.ts b/packages/spec/src/contracts/metadata-service.ts index 41f5f5bed..f03b4cfb6 100644 --- a/packages/spec/src/contracts/metadata-service.ts +++ b/packages/spec/src/contracts/metadata-service.ts @@ -124,18 +124,47 @@ export interface MetadataTypeInfo { actions?: Action[]; } +/** + * Options for the {@link IMetadataService} write path + * (`register` / `unregister` and their bulk forms). + */ +export interface MetadataWriteOptions { + /** + * Announce the write to `subscribe(type, …)` watchers. Default `true`. + * + * A write that changes what readers see must reach the consumers that + * cache it — ObjectQL's SchemaRegistry bridge, the HMR SSE stream — + * otherwise they serve the pre-write definition until the process + * restarts. Announcing is therefore the default, so a new call site is + * correct without knowing this contract exists. + * + * Pass `false` ONLY for bulk ingest whose subscribers either do not exist + * yet (boot-time registration) or re-read the whole set from a different + * signal — those paths MUST announce by other means, as the artifact + * reload path does via the `metadata:reloaded` hook. Silencing a write + * that nothing else announces is exactly how runtime consumers go stale. + */ + notify?: boolean; +} + export interface IMetadataService { // ========================================== // Core CRUD Operations // ========================================== /** - * Register/save a metadata item by type + * Register/save a metadata item by type. + * + * Announces the write to `subscribe(type, …)` watchers unless + * `options.notify === false` — see {@link MetadataWriteOptions.notify} + * before silencing it. + * * @param type - Metadata type (e.g. 'object', 'view', 'flow') * @param name - Item name/identifier (snake_case) * @param data - The metadata definition to register + * @param options - Write options; `{ notify: false }` suppresses the watcher event */ - register(type: string, name: string, data: unknown): Promise; + register(type: string, name: string, data: unknown, options?: MetadataWriteOptions): Promise; /** * Get a metadata item by type and name @@ -153,11 +182,17 @@ export interface IMetadataService { list(type: string): Promise; /** - * Unregister/remove a metadata item by type and name + * Unregister/remove a metadata item by type and name. + * + * Announces the removal to `subscribe(type, …)` watchers as a `deleted` + * event unless `options.notify === false` — see + * {@link MetadataWriteOptions.notify} before silencing it. + * * @param type - Metadata type * @param name - Item name/identifier + * @param options - Write options; `{ notify: false }` suppresses the watcher event */ - unregister(type: string, name: string): Promise; + unregister(type: string, name: string, options?: MetadataWriteOptions): Promise; /** * Check if a metadata item exists