diff --git a/.changeset/metadata-event-contract.md b/.changeset/metadata-event-contract.md new file mode 100644 index 0000000000..b8ca84ed6e --- /dev/null +++ b/.changeset/metadata-event-contract.md @@ -0,0 +1,46 @@ +--- +"@objectstack/metadata": patch +"@objectstack/client": patch +"@objectstack/spec": minor +--- + +fix(metadata,client): `subscribeMetadata` callbacks receive real `MetadataEvent`s — the producer now fulfils the declared contract (#4602) + +`@objectstack/spec/api`'s `MetadataEvent` declares top-level `id` (uuid, +required), `metadataType`, `name`, `definition?`, `userId?` — and after +#4587's convergence it is the **only** declared contract for realtime +metadata-change events. But the producer (`MetadataManager`) published a raw +`RealtimeEventPayload` envelope with everything nested under `payload` and no +`id`/`userId`, while the client SDK force-cast that envelope into the callback +(`callback(event as any as MetadataEvent)`). Subscribers who wrote +`event.name` / `event.metadataType` — exactly what the types promised — +compiled green and read `undefined` at runtime. + +Producer now fulfils the contract: + +- `MetadataManager.register()` / `unregister()` build a true `MetadataEvent` + (generated uuid `id`, flattened top-level fields, `userId` when the write + declares an actor) and validate it with `MetadataEventSchema.parse` before + publishing. The transport envelope is unchanged (`RealtimeEventPayload`, + with `payload` carrying the complete `MetadataEvent`). +- A `register()` **overwrite now publishes `metadata.{type}.updated`** instead + of a second `.created`, mirroring the existing `added`/`changed` watcher + split. Previously `.updated` was declared with no producer at all. +- `MetadataEventType` is a closed enum: metadata types outside it (e.g. + `translation`) have no declared realtime event, so nothing is published for + them (debug-logged) instead of emitting an event every schema-compliant + consumer must reject. + +Consumer validates instead of casting: + +- `@objectstack/client`'s `subscribeMetadata` (and therefore + `@objectstack/client-react`'s metadata hooks, which delegate to it) unwraps + the envelope and runs `MetadataEventSchema.safeParse` at the boundary. An + off-contract payload is rejected loudly (handler error, callback never + invoked) — never coerced or passed through. The `as any as MetadataEvent` + double-cast is gone. + +New seam: `MetadataWriteOptions.userId` (`@objectstack/spec/contracts`) lets +write paths that know the acting user carry it into the published event's +`userId`. Existing callers are unaffected — the field is optional and absence +means "no human actor". diff --git a/packages/client/src/realtime-api.test.ts b/packages/client/src/realtime-api.test.ts new file mode 100644 index 0000000000..b4a6a591d6 --- /dev/null +++ b/packages/client/src/realtime-api.test.ts @@ -0,0 +1,140 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #4602 — subscribeMetadata delivers TRUE `MetadataEvent`s, validated at the + * boundary. + * + * The callback is typed `(event: MetadataEvent) => void` — top-level `id` + * (uuid), `metadataType`, `name`, `definition?`, `userId?`. Before this fix + * the handler delivered the raw `RealtimeEventPayload` envelope via + * `callback(event as any as MetadataEvent)`, so `event.name` / + * `event.metadataType` were `undefined` at runtime while the types said + * `string`. + * + * Pins: + * - the subscriber receives the top-level fields (fails on the pre-fix + * envelope-passthrough); + * - an off-contract payload (e.g. the pre-fix producer's nested shape) is + * rejected LOUDLY — callback never invoked, error surfaced — not passed + * through or coerced. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { RealtimeEventPayload } from '@objectstack/spec/contracts'; +import { RealtimeAPI } from './realtime-api'; + +const VALID_EVENT = { + id: 'a3bb189e-8bf9-4888-9912-ace4e6543002', + type: 'metadata.object.created', + metadataType: 'object', + name: 'account', + packageId: 'com.acme.crm', + definition: { name: 'account', label: 'Account' }, + userId: 'usr_123', + timestamp: '2026-08-02T12:00:00.000Z', +} as const; + +function envelopeOf(payload: Record, type = 'metadata.object.created'): RealtimeEventPayload { + return { + type, + object: 'object', + payload, + timestamp: '2026-08-02T12:00:00.000Z', + }; +} + +describe('#4602 — RealtimeAPI.subscribeMetadata contract boundary', () => { + let api: RealtimeAPI; + + beforeEach(() => { + vi.useFakeTimers(); + api = new RealtimeAPI('http://localhost:3000'); + }); + + afterEach(() => { + api.disconnect(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + function deliver(envelope: RealtimeEventPayload): void { + api._bufferEvent(envelope); + vi.advanceTimersByTime(2000); // poll interval drains the buffer + } + + it('delivers the MetadataEvent with top-level fields to the callback', () => { + const seen: unknown[] = []; + api.subscribeMetadata('object', (event) => seen.push(event)); + + deliver(envelopeOf({ ...VALID_EVENT })); + + expect(seen).toHaveLength(1); + const event = seen[0] as typeof VALID_EVENT; + // Top-level, as the type declares — NOT nested under `payload`. + expect(event.id).toBe(VALID_EVENT.id); + expect(event.type).toBe('metadata.object.created'); + expect(event.metadataType).toBe('object'); + expect(event.name).toBe('account'); + expect(event.packageId).toBe('com.acme.crm'); + expect(event.definition).toEqual({ name: 'account', label: 'Account' }); + expect(event.userId).toBe('usr_123'); + expect(event.timestamp).toBe(VALID_EVENT.timestamp); + }); + + it('rejects the pre-fix producer shape LOUDLY instead of passing it through', () => { + // The old MetadataManager payload: no id/type/timestamp inside, fields + // that DO exist are fine — but the event as a whole violates the schema. + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const callback = vi.fn(); + api.subscribeMetadata('object', callback); + + deliver(envelopeOf({ + metadataType: 'object', + name: 'account', + definition: { name: 'account' }, + })); + + expect(callback).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalled(); + const logged = String(errorSpy.mock.calls.map((c) => c.join(' ')).join('\n')); + expect(logged).toContain('realtime event handler'); + }); + + it('rejects a payload with a wrong field type instead of coercing it', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const callback = vi.fn(); + api.subscribeMetadata('object', callback); + + deliver(envelopeOf({ ...VALID_EVENT, id: 'not-a-uuid' })); + + expect(callback).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalled(); + }); + + it('still filters by event type and packageId on the envelope', () => { + const callback = vi.fn(); + api.subscribeMetadata('object', callback, { packageId: 'com.other' }); + + // packageId mismatch → filtered out before the boundary parse. + deliver(envelopeOf({ ...VALID_EVENT })); + expect(callback).not.toHaveBeenCalled(); + + // matching packageId → delivered. + const matching = { ...VALID_EVENT, packageId: 'com.other' }; + deliver(envelopeOf(matching)); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback.mock.calls[0][0].packageId).toBe('com.other'); + }); + + it('unsubscribe stops delivery', () => { + const callback = vi.fn(); + const off = api.subscribeMetadata('object', callback); + + deliver(envelopeOf({ ...VALID_EVENT })); + expect(callback).toHaveBeenCalledTimes(1); + + off(); + deliver(envelopeOf({ ...VALID_EVENT })); + expect(callback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/client/src/realtime-api.ts b/packages/client/src/realtime-api.ts index bb65de6f72..7d3446d89e 100644 --- a/packages/client/src/realtime-api.ts +++ b/packages/client/src/realtime-api.ts @@ -8,7 +8,7 @@ */ import type { RealtimeEventPayload } from '@objectstack/spec/contracts'; -import type { MetadataEvent, DataEvent } from '@objectstack/spec/api'; +import { MetadataEventSchema, type MetadataEvent, type DataEvent } from '@objectstack/spec/api'; export interface RealtimeSubscriptionFilter { /** Metadata/object type filter */ @@ -67,10 +67,23 @@ export class RealtimeAPI { ] }, handler: (event) => { - // Type guard and filter - if (event.type.startsWith('metadata.')) { - callback(event as any as MetadataEvent); + if (!event.type.startsWith('metadata.')) return; + // Contract boundary (#4602): the wire carries a RealtimeEventPayload + // envelope whose `payload` is the producer's MetadataEvent. Validate + // it here — the callback is typed `(event: MetadataEvent) => void`, + // so delivering anything else would be a lie the type system can't + // catch. An off-contract payload is rejected LOUDLY (throw → surfaced + // by emitEvent's handler-error log), never coerced or passed through: + // a malformed event means the producer is broken and must be fixed + // there, not tolerated here. + const parsed = MetadataEventSchema.safeParse(event.payload); + if (!parsed.success) { + throw new Error( + `subscribeMetadata('${type}'): event '${event.type}' payload does not satisfy ` + + `MetadataEventSchema — rejecting off-contract event (fix the producer): ${parsed.error.message}` + ); } + callback(parsed.data); } }); diff --git a/packages/metadata/src/metadata-manager.ts b/packages/metadata/src/metadata-manager.ts index 3ec9ae9564..bd3efd86ea 100644 --- a/packages/metadata/src/metadata-manager.ts +++ b/packages/metadata/src/metadata-manager.ts @@ -44,6 +44,11 @@ import type { } from '@objectstack/spec/kernel'; import type { MetadataOverlay } from '@objectstack/spec/kernel'; import { getMetadataTypeActions } from '@objectstack/spec/kernel'; +import { + MetadataEventType, + MetadataEventSchema, + type MetadataEvent as RealtimeMetadataEvent, +} from '@objectstack/spec/api'; import { createLogger, type Logger } from '@objectstack/core'; import { JSONSerializer } from './serializers/json-serializer.js'; import { YAMLSerializer } from './serializers/yaml-serializer.js'; @@ -64,6 +69,24 @@ import type { */ export type WatchCallback = (event: MetadataWatchEvent) => void | Promise; +/** + * RFC-4122 v4 uuid for realtime `MetadataEvent.id` (#4602). + * Prefers `crypto.randomUUID`; the fallback keeps browser-compatible (Pure) + * environments without WebCrypto working while still satisfying + * `MetadataEventSchema`'s `z.string().uuid()`. + */ +function generateEventUuid(): string { + const c = globalThis.crypto; + if (c && typeof c.randomUUID === 'function') { + return c.randomUUID(); + } + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => { + const r = (Math.random() * 16) | 0; + const v = ch === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + /** * Payload format for cluster-wide metadata change broadcasts. * @@ -260,6 +283,70 @@ export class MetadataManager implements IMetadataService { this.logger.info('RealtimeService configured for metadata events'); } + /** + * Publish a realtime {@link RealtimeMetadataEvent} for a metadata write + * (#4602 — contract-first). + * + * What reaches a `subscribeMetadata` callback must BE the spec's + * `MetadataEvent` (`@objectstack/spec/api`): `id` (uuid) at the top level, + * flattened `metadataType`/`name`/`definition`, `userId` when the write + * carried an actor. The transport keeps its `RealtimeEventPayload` + * envelope — `payload` carries the complete `MetadataEvent`, and the client + * SDK unwraps + validates it at the boundary. + * + * Two loud-by-design gates: + * - `MetadataEventType` is a CLOSED enum. A metadata type outside it has + * no declared realtime event contract, so we skip publishing (debug log) + * instead of emitting an event every compliant consumer must reject. + * Declared = enforced; widening coverage means widening the spec enum, + * not producing off-contract events. + * - The event body is `MetadataEventSchema.parse`d before publish, so a + * malformed producer fails here (warn log, event not published) rather + * than delivering a lie downstream. + */ + private async publishRealtimeMetadataEvent( + action: 'created' | 'updated' | 'deleted', + type: string, + name: string, + opts: { definition?: unknown; packageId?: unknown; userId?: string } = {}, + ): Promise { + if (!this.realtimeService) return; + + const eventType = `metadata.${type}.${action}`; + if (!(MetadataEventType.options as readonly string[]).includes(eventType)) { + this.logger.debug( + `Metadata type '${type}' has no declared realtime event type (MetadataEventType) — skipping publish`, + { eventType, name }, + ); + return; + } + + try { + const event: RealtimeMetadataEvent = MetadataEventSchema.parse({ + id: generateEventUuid(), + type: eventType, + metadataType: type, + name, + ...(typeof opts.packageId === 'string' ? { packageId: opts.packageId } : {}), + ...(opts.definition !== undefined ? { definition: opts.definition } : {}), + ...(opts.userId ? { userId: opts.userId } : {}), + timestamp: new Date().toISOString(), + }); + + const envelope: RealtimeEventPayload = { + type: event.type, + object: type, + payload: { ...event }, + timestamp: event.timestamp, + }; + + await this.realtimeService.publish(envelope); + this.logger.debug(`Published ${eventType} event`, { name }); + } catch (error) { + this.logger.warn(`Failed to publish metadata event`, { type, name, error }); + } + } + /** * Register a new metadata loader (data source) */ @@ -323,27 +410,14 @@ export class MetadataManager implements IMetadataService { } } - // Publish metadata.{type}.created event to realtime service - if (this.realtimeService) { - const event: RealtimeEventPayload = { - type: `metadata.${type}.created`, - object: type, - payload: { - metadataType: type, - name, - definition: data, - packageId: (data as any)?.packageId, - }, - timestamp: new Date().toISOString(), - }; - - try { - await this.realtimeService.publish(event); - this.logger.debug(`Published metadata.${type}.created event`, { name }); - } catch (error) { - this.logger.warn(`Failed to publish metadata event`, { type, name, error }); - } - } + // Publish metadata.{type}.created / .updated event to realtime service. + // An overwrite is an UPDATE, mirroring the 'added' vs 'changed' split the + // watcher event below already makes (#4602). + await this.publishRealtimeMetadataEvent(existed ? 'updated' : 'created', type, name, { + definition: data, + packageId: (data as any)?.packageId, + userId: options?.userId, + }); // Announce last, once the write has landed in the registry and every // writable loader — a subscriber that re-reads on the event must not @@ -484,24 +558,9 @@ export class MetadataManager implements IMetadataService { } // Publish metadata.{type}.deleted event to realtime service - if (this.realtimeService) { - const event: RealtimeEventPayload = { - type: `metadata.${type}.deleted`, - object: type, - payload: { - metadataType: type, - name, - }, - timestamp: new Date().toISOString(), - }; - - try { - await this.realtimeService.publish(event); - this.logger.debug(`Published metadata.${type}.deleted event`, { name }); - } catch (error) { - this.logger.warn(`Failed to publish metadata event`, { type, name, error }); - } - } + await this.publishRealtimeMetadataEvent('deleted', type, name, { + userId: options?.userId, + }); // Announce last, once the removal has landed everywhere (see register()). if (options?.notify !== false) { diff --git a/packages/metadata/src/metadata-realtime-events.test.ts b/packages/metadata/src/metadata-realtime-events.test.ts new file mode 100644 index 0000000000..bd9b86af21 --- /dev/null +++ b/packages/metadata/src/metadata-realtime-events.test.ts @@ -0,0 +1,184 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #4602 — MetadataManager publishes TRUE `MetadataEvent`s (contract-first). + * + * `@objectstack/spec/api`'s `MetadataEvent` is the single declared contract + * for realtime metadata changes. Before this fix the producer published a + * bare `RealtimeEventPayload` envelope with `metadataType`/`name`/`definition` + * nested under `payload` and never generated `id`/`userId` — so every + * subscriber that wrote `event.name` (as the types promised) read `undefined` + * at runtime. + * + * These tests pin the producer half of the contract: + * - the transport envelope's `payload` IS a schema-valid `MetadataEvent` + * (top-level `id` uuid, `type`, `metadataType`, `name`, `definition`, + * `timestamp`); + * - a register() overwrite publishes `.updated` (mirroring the + * 'added'/'changed' watcher split); + * - `userId` is carried when the write declares an actor; + * - a metadata type outside the closed `MetadataEventType` enum publishes + * NOTHING (no off-contract event a compliant consumer must reject). + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { MetadataEventSchema } from '@objectstack/spec/api'; +import type { IRealtimeService, RealtimeEventPayload } from '@objectstack/spec/contracts'; +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(), + }), +})); + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +describe('#4602 — register()/unregister() publish true MetadataEvents', () => { + let manager: MetadataManager; + let published: RealtimeEventPayload[]; + let realtime: IRealtimeService; + + beforeEach(() => { + published = []; + realtime = { + publish: vi.fn(async (event: RealtimeEventPayload) => { + published.push(event); + }), + subscribe: vi.fn(async () => 'sub-1'), + unsubscribe: vi.fn(async () => undefined), + }; + manager = new MetadataManager({ + formats: ['json'], + loaders: [new MemoryLoader()], + }); + manager.setTypeRegistry(DEFAULT_METADATA_TYPE_REGISTRY); + manager.setRealtimeService(realtime); + }); + + it('register() publishes an envelope whose payload IS a schema-valid MetadataEvent', async () => { + await manager.register('object', 'account', { + name: 'account', + label: 'Account', + packageId: 'com.acme.crm', + }); + + expect(published).toHaveLength(1); + const envelope = published[0]; + + // Transport envelope keeps its shape (nothing else changes on the wire). + expect(envelope.type).toBe('metadata.object.created'); + expect(envelope.object).toBe('object'); + expect(typeof envelope.timestamp).toBe('string'); + + // The payload is the full MetadataEvent — parse with the SPEC schema, not + // a hand-rolled shape, so this pin fails the moment either side drifts. + const event = MetadataEventSchema.parse(envelope.payload); + expect(event.id).toMatch(UUID_RE); + expect(event.type).toBe('metadata.object.created'); + expect(event.metadataType).toBe('object'); + expect(event.name).toBe('account'); + expect(event.packageId).toBe('com.acme.crm'); + expect(event.definition).toEqual({ + name: 'account', + label: 'Account', + packageId: 'com.acme.crm', + }); + expect(event.timestamp).toBe(envelope.timestamp); + }); + + it('register() overwrite publishes .updated, not a second .created', async () => { + await manager.register('object', 'account', { name: 'account', label: 'V1' }); + await manager.register('object', 'account', { name: 'account', label: 'V2' }); + + expect(published.map((e) => e.type)).toEqual([ + 'metadata.object.created', + 'metadata.object.updated', + ]); + const updated = MetadataEventSchema.parse(published[1].payload); + expect(updated.type).toBe('metadata.object.updated'); + expect(updated.definition).toEqual({ name: 'account', label: 'V2' }); + }); + + it('unregister() publishes a schema-valid .deleted event without a definition', async () => { + await manager.register('object', 'account', { name: 'account' }); + published.length = 0; + + await manager.unregister('object', 'account'); + + expect(published).toHaveLength(1); + const event = MetadataEventSchema.parse(published[0].payload); + expect(event.type).toBe('metadata.object.deleted'); + expect(event.metadataType).toBe('object'); + expect(event.name).toBe('account'); + expect(event.id).toMatch(UUID_RE); + expect(event.definition).toBeUndefined(); + }); + + it('carries userId when the write declares an actor (MetadataWriteOptions.userId)', async () => { + await manager.register( + 'view', + 'account_grid', + { name: 'account_grid' }, + { userId: 'usr_123' }, + ); + await manager.unregister('view', 'account_grid', { userId: 'usr_456' }); + + const created = MetadataEventSchema.parse(published[0].payload); + const deleted = MetadataEventSchema.parse(published[1].payload); + expect(created.userId).toBe('usr_123'); + expect(deleted.userId).toBe('usr_456'); + }); + + it('omits userId for system-initiated writes (no actor known)', async () => { + await manager.register('view', 'account_grid', { name: 'account_grid' }); + const event = MetadataEventSchema.parse(published[0].payload); + expect(event.userId).toBeUndefined(); + }); + + it('publishes NOTHING for a type outside the closed MetadataEventType enum', async () => { + // 'translation' is registrable metadata but has no declared realtime + // event type. Producing `metadata.translation.created` would be an event + // every compliant consumer (MetadataEventSchema.parse at the client + // boundary) must reject — declared = enforced means we don't emit it. + await manager.register('translation', 'zh_cn', { name: 'zh_cn' }); + await manager.unregister('translation', 'zh_cn'); + + expect(published).toHaveLength(0); + }); + + it('omits a non-string packageId instead of publishing an invalid event', async () => { + await manager.register('object', 'account', { + name: 'account', + packageId: 42 as unknown as string, + }); + + expect(published).toHaveLength(1); + const event = MetadataEventSchema.parse(published[0].payload); + expect(event.packageId).toBeUndefined(); + }); + + it('event ids are unique per event', async () => { + await manager.register('object', 'a', { name: 'a' }); + await manager.register('object', 'b', { name: 'b' }); + + const ids = published.map((e) => MetadataEventSchema.parse(e.payload).id); + expect(new Set(ids).size).toBe(2); + }); + + it('a publish failure never fails the write itself', async () => { + (realtime.publish as ReturnType).mockRejectedValueOnce( + new Error('transport down'), + ); + + await expect( + manager.register('object', 'account', { name: 'account' }), + ).resolves.toBeUndefined(); + expect(await manager.get('object', 'account')).toEqual({ name: 'account' }); + }); +}); diff --git a/packages/spec/src/contracts/metadata-service.ts b/packages/spec/src/contracts/metadata-service.ts index 91087651cd..905a319ee5 100644 --- a/packages/spec/src/contracts/metadata-service.ts +++ b/packages/spec/src/contracts/metadata-service.ts @@ -165,6 +165,15 @@ export interface MetadataWriteOptions { * that nothing else announces is exactly how runtime consumers go stale. */ notify?: boolean; + + /** + * The user (actor) performing this write, when known. Carried into the + * realtime `MetadataEvent` (`@objectstack/spec/api`) published for the + * write as its `userId` field (#4602). Omit for system-initiated writes + * (boot registration, package install) — `MetadataEvent.userId` is + * optional and absence means "no human actor". + */ + userId?: string; } export interface IMetadataService {