Skip to content

Commit f78dd83

Browse files
os-zhuangclaude
andauthored
fix(metadata,client): subscribeMetadata delivers true MetadataEvents — producer fulfils the declared contract (#4602) (#4628)
MetadataManager now builds a schema-valid MetadataEvent (generated uuid id, flattened top-level metadataType/name/definition, userId when the write declares an actor via the new MetadataWriteOptions.userId seam), validates it with MetadataEventSchema.parse before publishing, and carries it as the RealtimeEventPayload envelope's payload. A register() overwrite now publishes metadata.{type}.updated (mirroring the added/changed watcher split) instead of a second .created. Types outside the closed MetadataEventType enum publish nothing (declared = enforced) instead of an event every compliant consumer must reject. The client SDK's subscribeMetadata unwraps the envelope and validates with MetadataEventSchema.safeParse at the boundary — off-contract payloads are rejected loudly (callback never invoked), and the 'as any as MetadataEvent' double-cast is deleted. client-react's metadata hooks delegate to it and are fixed transitively. Out-of-scope findings filed: #4626 (subscribeData/DataEvent twin defect), #4627 (MetadataEventType enum coverage vs registrable types). Claude-Session: https://claude.ai/code/session_012C2cd7tL8QDoZ2QKN3djJ5 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0c0fbd9 commit f78dd83

6 files changed

Lines changed: 494 additions & 43 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/metadata": patch
3+
"@objectstack/client": patch
4+
"@objectstack/spec": minor
5+
---
6+
7+
fix(metadata,client): `subscribeMetadata` callbacks receive real `MetadataEvent`s — the producer now fulfils the declared contract (#4602)
8+
9+
`@objectstack/spec/api`'s `MetadataEvent` declares top-level `id` (uuid,
10+
required), `metadataType`, `name`, `definition?`, `userId?` — and after
11+
#4587's convergence it is the **only** declared contract for realtime
12+
metadata-change events. But the producer (`MetadataManager`) published a raw
13+
`RealtimeEventPayload` envelope with everything nested under `payload` and no
14+
`id`/`userId`, while the client SDK force-cast that envelope into the callback
15+
(`callback(event as any as MetadataEvent)`). Subscribers who wrote
16+
`event.name` / `event.metadataType` — exactly what the types promised —
17+
compiled green and read `undefined` at runtime.
18+
19+
Producer now fulfils the contract:
20+
21+
- `MetadataManager.register()` / `unregister()` build a true `MetadataEvent`
22+
(generated uuid `id`, flattened top-level fields, `userId` when the write
23+
declares an actor) and validate it with `MetadataEventSchema.parse` before
24+
publishing. The transport envelope is unchanged (`RealtimeEventPayload`,
25+
with `payload` carrying the complete `MetadataEvent`).
26+
- A `register()` **overwrite now publishes `metadata.{type}.updated`** instead
27+
of a second `.created`, mirroring the existing `added`/`changed` watcher
28+
split. Previously `.updated` was declared with no producer at all.
29+
- `MetadataEventType` is a closed enum: metadata types outside it (e.g.
30+
`translation`) have no declared realtime event, so nothing is published for
31+
them (debug-logged) instead of emitting an event every schema-compliant
32+
consumer must reject.
33+
34+
Consumer validates instead of casting:
35+
36+
- `@objectstack/client`'s `subscribeMetadata` (and therefore
37+
`@objectstack/client-react`'s metadata hooks, which delegate to it) unwraps
38+
the envelope and runs `MetadataEventSchema.safeParse` at the boundary. An
39+
off-contract payload is rejected loudly (handler error, callback never
40+
invoked) — never coerced or passed through. The `as any as MetadataEvent`
41+
double-cast is gone.
42+
43+
New seam: `MetadataWriteOptions.userId` (`@objectstack/spec/contracts`) lets
44+
write paths that know the acting user carry it into the published event's
45+
`userId`. Existing callers are unaffected — the field is optional and absence
46+
means "no human actor".
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #4602 — subscribeMetadata delivers TRUE `MetadataEvent`s, validated at the
5+
* boundary.
6+
*
7+
* The callback is typed `(event: MetadataEvent) => void` — top-level `id`
8+
* (uuid), `metadataType`, `name`, `definition?`, `userId?`. Before this fix
9+
* the handler delivered the raw `RealtimeEventPayload` envelope via
10+
* `callback(event as any as MetadataEvent)`, so `event.name` /
11+
* `event.metadataType` were `undefined` at runtime while the types said
12+
* `string`.
13+
*
14+
* Pins:
15+
* - the subscriber receives the top-level fields (fails on the pre-fix
16+
* envelope-passthrough);
17+
* - an off-contract payload (e.g. the pre-fix producer's nested shape) is
18+
* rejected LOUDLY — callback never invoked, error surfaced — not passed
19+
* through or coerced.
20+
*/
21+
22+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
23+
import type { RealtimeEventPayload } from '@objectstack/spec/contracts';
24+
import { RealtimeAPI } from './realtime-api';
25+
26+
const VALID_EVENT = {
27+
id: 'a3bb189e-8bf9-4888-9912-ace4e6543002',
28+
type: 'metadata.object.created',
29+
metadataType: 'object',
30+
name: 'account',
31+
packageId: 'com.acme.crm',
32+
definition: { name: 'account', label: 'Account' },
33+
userId: 'usr_123',
34+
timestamp: '2026-08-02T12:00:00.000Z',
35+
} as const;
36+
37+
function envelopeOf(payload: Record<string, unknown>, type = 'metadata.object.created'): RealtimeEventPayload {
38+
return {
39+
type,
40+
object: 'object',
41+
payload,
42+
timestamp: '2026-08-02T12:00:00.000Z',
43+
};
44+
}
45+
46+
describe('#4602 — RealtimeAPI.subscribeMetadata contract boundary', () => {
47+
let api: RealtimeAPI;
48+
49+
beforeEach(() => {
50+
vi.useFakeTimers();
51+
api = new RealtimeAPI('http://localhost:3000');
52+
});
53+
54+
afterEach(() => {
55+
api.disconnect();
56+
vi.useRealTimers();
57+
vi.restoreAllMocks();
58+
});
59+
60+
function deliver(envelope: RealtimeEventPayload): void {
61+
api._bufferEvent(envelope);
62+
vi.advanceTimersByTime(2000); // poll interval drains the buffer
63+
}
64+
65+
it('delivers the MetadataEvent with top-level fields to the callback', () => {
66+
const seen: unknown[] = [];
67+
api.subscribeMetadata('object', (event) => seen.push(event));
68+
69+
deliver(envelopeOf({ ...VALID_EVENT }));
70+
71+
expect(seen).toHaveLength(1);
72+
const event = seen[0] as typeof VALID_EVENT;
73+
// Top-level, as the type declares — NOT nested under `payload`.
74+
expect(event.id).toBe(VALID_EVENT.id);
75+
expect(event.type).toBe('metadata.object.created');
76+
expect(event.metadataType).toBe('object');
77+
expect(event.name).toBe('account');
78+
expect(event.packageId).toBe('com.acme.crm');
79+
expect(event.definition).toEqual({ name: 'account', label: 'Account' });
80+
expect(event.userId).toBe('usr_123');
81+
expect(event.timestamp).toBe(VALID_EVENT.timestamp);
82+
});
83+
84+
it('rejects the pre-fix producer shape LOUDLY instead of passing it through', () => {
85+
// The old MetadataManager payload: no id/type/timestamp inside, fields
86+
// that DO exist are fine — but the event as a whole violates the schema.
87+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
88+
const callback = vi.fn();
89+
api.subscribeMetadata('object', callback);
90+
91+
deliver(envelopeOf({
92+
metadataType: 'object',
93+
name: 'account',
94+
definition: { name: 'account' },
95+
}));
96+
97+
expect(callback).not.toHaveBeenCalled();
98+
expect(errorSpy).toHaveBeenCalled();
99+
const logged = String(errorSpy.mock.calls.map((c) => c.join(' ')).join('\n'));
100+
expect(logged).toContain('realtime event handler');
101+
});
102+
103+
it('rejects a payload with a wrong field type instead of coercing it', () => {
104+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
105+
const callback = vi.fn();
106+
api.subscribeMetadata('object', callback);
107+
108+
deliver(envelopeOf({ ...VALID_EVENT, id: 'not-a-uuid' }));
109+
110+
expect(callback).not.toHaveBeenCalled();
111+
expect(errorSpy).toHaveBeenCalled();
112+
});
113+
114+
it('still filters by event type and packageId on the envelope', () => {
115+
const callback = vi.fn();
116+
api.subscribeMetadata('object', callback, { packageId: 'com.other' });
117+
118+
// packageId mismatch → filtered out before the boundary parse.
119+
deliver(envelopeOf({ ...VALID_EVENT }));
120+
expect(callback).not.toHaveBeenCalled();
121+
122+
// matching packageId → delivered.
123+
const matching = { ...VALID_EVENT, packageId: 'com.other' };
124+
deliver(envelopeOf(matching));
125+
expect(callback).toHaveBeenCalledTimes(1);
126+
expect(callback.mock.calls[0][0].packageId).toBe('com.other');
127+
});
128+
129+
it('unsubscribe stops delivery', () => {
130+
const callback = vi.fn();
131+
const off = api.subscribeMetadata('object', callback);
132+
133+
deliver(envelopeOf({ ...VALID_EVENT }));
134+
expect(callback).toHaveBeenCalledTimes(1);
135+
136+
off();
137+
deliver(envelopeOf({ ...VALID_EVENT }));
138+
expect(callback).toHaveBeenCalledTimes(1);
139+
});
140+
});

packages/client/src/realtime-api.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
*/
99

1010
import type { RealtimeEventPayload } from '@objectstack/spec/contracts';
11-
import type { MetadataEvent, DataEvent } from '@objectstack/spec/api';
11+
import { MetadataEventSchema, type MetadataEvent, type DataEvent } from '@objectstack/spec/api';
1212

1313
export interface RealtimeSubscriptionFilter {
1414
/** Metadata/object type filter */
@@ -67,10 +67,23 @@ export class RealtimeAPI {
6767
]
6868
},
6969
handler: (event) => {
70-
// Type guard and filter
71-
if (event.type.startsWith('metadata.')) {
72-
callback(event as any as MetadataEvent);
70+
if (!event.type.startsWith('metadata.')) return;
71+
// Contract boundary (#4602): the wire carries a RealtimeEventPayload
72+
// envelope whose `payload` is the producer's MetadataEvent. Validate
73+
// it here — the callback is typed `(event: MetadataEvent) => void`,
74+
// so delivering anything else would be a lie the type system can't
75+
// catch. An off-contract payload is rejected LOUDLY (throw → surfaced
76+
// by emitEvent's handler-error log), never coerced or passed through:
77+
// a malformed event means the producer is broken and must be fixed
78+
// there, not tolerated here.
79+
const parsed = MetadataEventSchema.safeParse(event.payload);
80+
if (!parsed.success) {
81+
throw new Error(
82+
`subscribeMetadata('${type}'): event '${event.type}' payload does not satisfy ` +
83+
`MetadataEventSchema — rejecting off-contract event (fix the producer): ${parsed.error.message}`
84+
);
7385
}
86+
callback(parsed.data);
7487
}
7588
});
7689

packages/metadata/src/metadata-manager.ts

Lines changed: 98 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ import type {
4444
} from '@objectstack/spec/kernel';
4545
import type { MetadataOverlay } from '@objectstack/spec/kernel';
4646
import { getMetadataTypeActions } from '@objectstack/spec/kernel';
47+
import {
48+
MetadataEventType,
49+
MetadataEventSchema,
50+
type MetadataEvent as RealtimeMetadataEvent,
51+
} from '@objectstack/spec/api';
4752
import { createLogger, type Logger } from '@objectstack/core';
4853
import { JSONSerializer } from './serializers/json-serializer.js';
4954
import { YAMLSerializer } from './serializers/yaml-serializer.js';
@@ -64,6 +69,24 @@ import type {
6469
*/
6570
export 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(/[xy]/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

Comments
 (0)