@@ -44,6 +44,11 @@ import type {
4444} from '@objectstack/spec/kernel' ;
4545import type { MetadataOverlay } from '@objectstack/spec/kernel' ;
4646import { getMetadataTypeActions } from '@objectstack/spec/kernel' ;
47+ import {
48+ MetadataEventType ,
49+ MetadataEventSchema ,
50+ type MetadataEvent as RealtimeMetadataEvent ,
51+ } from '@objectstack/spec/api' ;
4752import { createLogger , type Logger } from '@objectstack/core' ;
4853import { JSONSerializer } from './serializers/json-serializer.js' ;
4954import { YAMLSerializer } from './serializers/yaml-serializer.js' ;
@@ -64,6 +69,24 @@ import type {
6469 */
6570export type WatchCallback = ( event : MetadataWatchEvent ) => void | Promise < void > ;
6671
72+ /**
73+ * RFC-4122 v4 uuid for realtime `MetadataEvent.id` (#4602).
74+ * Prefers `crypto.randomUUID`; the fallback keeps browser-compatible (Pure)
75+ * environments without WebCrypto working while still satisfying
76+ * `MetadataEventSchema`'s `z.string().uuid()`.
77+ */
78+ function generateEventUuid ( ) : string {
79+ const c = globalThis . crypto ;
80+ if ( c && typeof c . randomUUID === 'function' ) {
81+ return c . randomUUID ( ) ;
82+ }
83+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx' . replace ( / [ x y ] / g, ( ch ) => {
84+ const r = ( Math . random ( ) * 16 ) | 0 ;
85+ const v = ch === 'x' ? r : ( r & 0x3 ) | 0x8 ;
86+ return v . toString ( 16 ) ;
87+ } ) ;
88+ }
89+
6790/**
6891 * Payload format for cluster-wide metadata change broadcasts.
6992 *
@@ -260,6 +283,70 @@ export class MetadataManager implements IMetadataService {
260283 this . logger . info ( 'RealtimeService configured for metadata events' ) ;
261284 }
262285
286+ /**
287+ * Publish a realtime {@link RealtimeMetadataEvent} for a metadata write
288+ * (#4602 — contract-first).
289+ *
290+ * What reaches a `subscribeMetadata` callback must BE the spec's
291+ * `MetadataEvent` (`@objectstack/spec/api`): `id` (uuid) at the top level,
292+ * flattened `metadataType`/`name`/`definition`, `userId` when the write
293+ * carried an actor. The transport keeps its `RealtimeEventPayload`
294+ * envelope — `payload` carries the complete `MetadataEvent`, and the client
295+ * SDK unwraps + validates it at the boundary.
296+ *
297+ * Two loud-by-design gates:
298+ * - `MetadataEventType` is a CLOSED enum. A metadata type outside it has
299+ * no declared realtime event contract, so we skip publishing (debug log)
300+ * instead of emitting an event every compliant consumer must reject.
301+ * Declared = enforced; widening coverage means widening the spec enum,
302+ * not producing off-contract events.
303+ * - The event body is `MetadataEventSchema.parse`d before publish, so a
304+ * malformed producer fails here (warn log, event not published) rather
305+ * than delivering a lie downstream.
306+ */
307+ private async publishRealtimeMetadataEvent (
308+ action : 'created' | 'updated' | 'deleted' ,
309+ type : string ,
310+ name : string ,
311+ opts : { definition ?: unknown ; packageId ?: unknown ; userId ?: string } = { } ,
312+ ) : Promise < void > {
313+ if ( ! this . realtimeService ) return ;
314+
315+ const eventType = `metadata.${ type } .${ action } ` ;
316+ if ( ! ( MetadataEventType . options as readonly string [ ] ) . includes ( eventType ) ) {
317+ this . logger . debug (
318+ `Metadata type '${ type } ' has no declared realtime event type (MetadataEventType) — skipping publish` ,
319+ { eventType, name } ,
320+ ) ;
321+ return ;
322+ }
323+
324+ try {
325+ const event : RealtimeMetadataEvent = MetadataEventSchema . parse ( {
326+ id : generateEventUuid ( ) ,
327+ type : eventType ,
328+ metadataType : type ,
329+ name,
330+ ...( typeof opts . packageId === 'string' ? { packageId : opts . packageId } : { } ) ,
331+ ...( opts . definition !== undefined ? { definition : opts . definition } : { } ) ,
332+ ...( opts . userId ? { userId : opts . userId } : { } ) ,
333+ timestamp : new Date ( ) . toISOString ( ) ,
334+ } ) ;
335+
336+ const envelope : RealtimeEventPayload = {
337+ type : event . type ,
338+ object : type ,
339+ payload : { ...event } ,
340+ timestamp : event . timestamp ,
341+ } ;
342+
343+ await this . realtimeService . publish ( envelope ) ;
344+ this . logger . debug ( `Published ${ eventType } event` , { name } ) ;
345+ } catch ( error ) {
346+ this . logger . warn ( `Failed to publish metadata event` , { type, name, error } ) ;
347+ }
348+ }
349+
263350 /**
264351 * Register a new metadata loader (data source)
265352 */
@@ -323,27 +410,14 @@ export class MetadataManager implements IMetadataService {
323410 }
324411 }
325412
326- // Publish metadata.{type}.created event to realtime service
327- if ( this . realtimeService ) {
328- const event : RealtimeEventPayload = {
329- type : `metadata.${ type } .created` ,
330- object : type ,
331- payload : {
332- metadataType : type ,
333- name,
334- definition : data ,
335- packageId : ( data as any ) ?. packageId ,
336- } ,
337- timestamp : new Date ( ) . toISOString ( ) ,
338- } ;
339-
340- try {
341- await this . realtimeService . publish ( event ) ;
342- this . logger . debug ( `Published metadata.${ type } .created event` , { name } ) ;
343- } catch ( error ) {
344- this . logger . warn ( `Failed to publish metadata event` , { type, name, error } ) ;
345- }
346- }
413+ // Publish metadata.{type}.created / .updated event to realtime service.
414+ // An overwrite is an UPDATE, mirroring the 'added' vs 'changed' split the
415+ // watcher event below already makes (#4602).
416+ await this . publishRealtimeMetadataEvent ( existed ? 'updated' : 'created' , type , name , {
417+ definition : data ,
418+ packageId : ( data as any ) ?. packageId ,
419+ userId : options ?. userId ,
420+ } ) ;
347421
348422 // Announce last, once the write has landed in the registry and every
349423 // writable loader — a subscriber that re-reads on the event must not
@@ -484,24 +558,9 @@ export class MetadataManager implements IMetadataService {
484558 }
485559
486560 // Publish metadata.{type}.deleted event to realtime service
487- if ( this . realtimeService ) {
488- const event : RealtimeEventPayload = {
489- type : `metadata.${ type } .deleted` ,
490- object : type ,
491- payload : {
492- metadataType : type ,
493- name,
494- } ,
495- timestamp : new Date ( ) . toISOString ( ) ,
496- } ;
497-
498- try {
499- await this . realtimeService . publish ( event ) ;
500- this . logger . debug ( `Published metadata.${ type } .deleted event` , { name } ) ;
501- } catch ( error ) {
502- this . logger . warn ( `Failed to publish metadata event` , { type, name, error } ) ;
503- }
504- }
561+ await this . publishRealtimeMetadataEvent ( 'deleted' , type , name , {
562+ userId : options ?. userId ,
563+ } ) ;
505564
506565 // Announce last, once the removal has landed everywhere (see register()).
507566 if ( options ?. notify !== false ) {
0 commit comments