Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .changeset/metadata-event-contract.md
Original file line numberDiff line numberDiff line change
@@ -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".
140 changes: 140 additions & 0 deletions packages/client/src/realtime-api.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<string, unknown>, 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);
});
});
21 changes: 17 additions & 4 deletions packages/client/src/realtime-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 */
Expand DownExpand Up@@ -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);
}
});

Expand Down
137 changes: 98 additions & 39 deletions packages/metadata/src/metadata-manager.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand All@@ -64,6 +69,24 @@ import type {
*/
export type WatchCallback = (event: MetadataWatchEvent) => void | Promise<void>;

/**
* 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.
*
Expand DownExpand Up@@ -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<void> {
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)
*/
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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) {
Expand Down
Loading
Loading