Skip to content

Commit a0b48e7

Browse files
committed
fix(objectql,client): subscribeData delivers a real DataEvent (#4626)
The producer (ObjectQL engine) published a raw RealtimeEventPayload envelope with `{ recordId, after, changes }` nested under `payload` and never generated `id`/`userId`, while `@objectstack/client`'s `subscribeData` force-cast that envelope into the callback (`callback(event as any as DataEvent)`). Subscribers reading the declared top-level `event.recordId` / `event.changes` compiled green and got `undefined` at runtime. Data-side twin of #4602. Producer fulfils the contract: insert/update/delete build a true DataEvent (uuid `id`, flattened top-level fields, `userId` from the execution context) and `DataEventSchema.parse` it before publish. A multi-row updateMany/deleteMany names no single record, so it publishes nothing (warn) instead of the previous `recordId: ''` fabrication; bulk contract tracked in #4639. Consumers read the fulfilled shape: the client validates at the boundary and rejects off-contract payloads loudly; the webhook auto-enqueuer drops its `recordId ?? id ?? after?.id ?? 'unknown'` tolerance chain; service-knowledge reads the record from `after` and the delete id from `recordId` instead of indexing the envelope as if it were the row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012C2cd7tL8QDoZ2QKN3djJ5
1 parent c13350b commit a0b48e7

10 files changed

Lines changed: 919 additions & 99 deletions

File tree

.changeset/data-event-contract.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
---
2+
"@objectstack/objectql": minor
3+
"@objectstack/client": patch
4+
"@objectstack/plugin-webhooks": patch
5+
"@objectstack/service-knowledge": patch
6+
---
7+
8+
fix(objectql,client): `subscribeData` callbacks receive real `DataEvent`s — the producer now fulfils the declared contract (#4626)
9+
10+
`@objectstack/spec/api`'s `DataEvent` declares top-level `id` (uuid,
11+
required), `type`, `object`, `recordId` (required), `changes?`, `before?`,
12+
`after?`, `userId?`, `timestamp`. But the producer (the ObjectQL engine)
13+
published a raw `RealtimeEventPayload` envelope with `{ recordId, after,
14+
changes }` nested under `payload` and never generated `id`/`userId`, while the
15+
client SDK force-cast that envelope into the callback (`callback(event as any
16+
as DataEvent)`). Subscribers who wrote `event.recordId` / `event.changes` —
17+
exactly what the types promised — compiled green and read `undefined` at
18+
runtime. The data-side twin of #4602.
19+
20+
Producer now fulfils the contract:
21+
22+
- `ObjectQL.insert()` / `update()` / `delete()` build a true `DataEvent`
23+
(generated uuid `id`, flattened top-level fields, `userId` from the
24+
execution context when the write names an actor) and validate it with
25+
`DataEventSchema.parse` before publishing. The transport envelope is
26+
unchanged (`RealtimeEventPayload`, with `payload` carrying the complete
27+
`DataEvent`), so subscribers keep receiving `{ type, object, payload,
28+
timestamp }` on the wire.
29+
- A batch insert publishes one event **per record** (as before), each with its
30+
own event id.
31+
- **A multi-row write (`multi: true``updateMany` / `deleteMany`) now
32+
publishes nothing.** Those driver methods return only an affected count, so
33+
there is no record for a required `recordId` to name; the engine logs a
34+
warning naming the gap instead of publishing the previous fabrication
35+
(`recordId: ''`, `after: <affected count>`), which every schema-compliant
36+
consumer had to reject. **Consequence: webhooks and knowledge sync no longer
37+
fire for bulk writes** — they previously fired once with an unusable body. A
38+
real bulk event contract is tracked in #4639.
39+
40+
Consumers validate or read the fulfilled shape instead of guessing:
41+
42+
- `@objectstack/client`'s `subscribeData` (and therefore
43+
`@objectstack/client-react`'s `useDataSubscription` /
44+
`useDataSubscriptionCallback` / `useAutoRefresh`, which delegate to it)
45+
unwraps the envelope and runs `DataEventSchema.safeParse` at the boundary.
46+
An off-contract payload is rejected loudly (handler error, callback never
47+
invoked) — never coerced or passed through. The `as any as DataEvent`
48+
double-cast is gone, and the `recordId` option now filters on the fulfilled
49+
event.
50+
- `@objectstack/plugin-webhooks`' auto-enqueuer reads the required
51+
`recordId` directly; its `recordId ?? id ?? after?.id ?? before?.id ??
52+
'unknown'` fallback chain is gone, and an off-contract event is dropped with
53+
a warning rather than delivered under the literal id `'unknown'`. Delivered
54+
webhook bodies now also carry the event's `id`/`type`/`userId`; the record
55+
itself stays nested under `after` and the envelope keys (`object`,
56+
`recordId`, `action`, `timestamp`) still win.
57+
- `@objectstack/service-knowledge`'s event sync reads the record from `after`
58+
(create/update) and the id from `recordId` (delete) for `data.record.*`.
59+
It previously indexed the envelope itself as if it were the row, and never
60+
resolved an id for deletes.

content/docs/automation/webhooks.mdx

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -192,9 +192,33 @@ Five stages, each implemented as a thin layer over an existing primitive.
192192

193193
Producers (the ObjectQL engine's insert/update/delete handlers) call
194194
`IRealtimeService.publish(event)` after the write commits, where `event` is a
195-
plain `{ type, object, payload, timestamp }` record — e.g.
196-
`{ type: 'data.record.updated', object: 'account', payload: { recordId,
197-
changes, after }, timestamp: <ISO 8601 string> }`.
195+
plain `{ type, object, payload, timestamp }` transport envelope whose `payload`
196+
is the spec's `DataEvent` (`@objectstack/spec/api`) — validated against
197+
`DataEventSchema` before it is published (#4626):
198+
199+
```ts
200+
{
201+
type: 'data.record.updated',
202+
object: 'account',
203+
timestamp: '<ISO 8601>',
204+
payload: {
205+
id: '<uuid>', // unique event id
206+
type: 'data.record.updated',
207+
object: 'account',
208+
recordId: 'acc_1', // REQUIRED — the record the event is about
209+
changes: { status: 'active' },// update only: the submitted payload
210+
after: { id: 'acc_1', … }, // create/update only: the written row
211+
userId: 'usr_1', // when the write names an actor
212+
timestamp: '<ISO 8601>',
213+
},
214+
}
215+
```
216+
217+
> **A multi-row write emits no record event.** `updateMany` / `deleteMany`
218+
> (`multi: true`) return only an affected count, so there is no record for a
219+
> `DataEvent` to name and the engine publishes nothing rather than an event
220+
> with an empty `recordId` — meaning webhooks do **not** fire for bulk writes
221+
> today. Tracked in [#4639](https://github.com/objectstack-ai/objectstack/issues/4639).
198222
199223
> **Not yet cluster-aware.** The only shipped `IRealtimeService` implementation
200224
> is `InMemoryRealtimeAdapter`, an in-process, single-node pub/sub with no
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #4626 — subscribeData delivers TRUE `DataEvent`s, validated at the boundary
5+
* (the data-side twin of #4602's metadata pins).
6+
*
7+
* The callback is typed `(event: DataEvent) => void` — top-level `id` (uuid),
8+
* `type`, `object`, `recordId` (required), `changes?`, `before?`, `after?`,
9+
* `userId?`, `timestamp`. Before this fix the handler delivered the raw
10+
* `RealtimeEventPayload` envelope via `callback(event as any as DataEvent)`,
11+
* so `event.recordId` / `event.changes` / `event.id` were `undefined` at
12+
* runtime while the types said otherwise.
13+
*
14+
* Pins:
15+
* - the subscriber receives the top-level fields (fails on the pre-fix
16+
* envelope passthrough);
17+
* - an off-contract payload — including the pre-fix producer's
18+
* `{ recordId, after }` shape — is rejected LOUDLY: callback never invoked,
19+
* error surfaced, nothing coerced;
20+
* - the `recordId` filter narrows on the FULFILLED event.
21+
*/
22+
23+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
24+
import type { RealtimeEventPayload } from '@objectstack/spec/contracts';
25+
import { RealtimeAPI } from './realtime-api';
26+
27+
const VALID_EVENT = {
28+
id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
29+
type: 'data.record.updated',
30+
object: 'project_task',
31+
recordId: 'task_1',
32+
changes: { status: 'done' },
33+
after: { id: 'task_1', title: 'Ship it', status: 'done' },
34+
userId: 'usr_123',
35+
timestamp: '2026-08-02T12:00:00.000Z',
36+
} as const;
37+
38+
function envelopeOf(
39+
payload: Record<string, unknown>,
40+
type = 'data.record.updated',
41+
object = 'project_task',
42+
): RealtimeEventPayload {
43+
return { type, object, payload, timestamp: '2026-08-02T12:00:00.000Z' };
44+
}
45+
46+
describe('#4626 — RealtimeAPI.subscribeData 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 DataEvent with top-level fields to the callback', () => {
66+
const seen: unknown[] = [];
67+
api.subscribeData('project_task', (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('data.record.updated');
76+
expect(event.object).toBe('project_task');
77+
expect(event.recordId).toBe('task_1');
78+
expect(event.changes).toEqual({ status: 'done' });
79+
expect(event.after).toEqual({ id: 'task_1', title: 'Ship it', status: 'done' });
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 engine payload: `{ recordId, after }` with no id/type/object/
86+
// timestamp. The envelope carried those — which is precisely why the
87+
// double-cast compiled and lied.
88+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
89+
const callback = vi.fn();
90+
api.subscribeData('project_task', callback);
91+
92+
deliver(envelopeOf({ recordId: 'task_1', after: { id: 'task_1', title: 'Ship it' } }));
93+
94+
expect(callback).not.toHaveBeenCalled();
95+
expect(errorSpy).toHaveBeenCalled();
96+
const logged = String(errorSpy.mock.calls.map((c) => c.join(' ')).join('\n'));
97+
expect(logged).toContain('realtime event handler');
98+
});
99+
100+
it('rejects a payload with a wrong field type instead of coercing it', () => {
101+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
102+
const callback = vi.fn();
103+
api.subscribeData('project_task', callback);
104+
105+
deliver(envelopeOf({ ...VALID_EVENT, id: 'not-a-uuid' }));
106+
expect(callback).not.toHaveBeenCalled();
107+
108+
// A numeric recordId is a producer bug, not something to String() here.
109+
deliver(envelopeOf({ ...VALID_EVENT, recordId: 42 }));
110+
expect(callback).not.toHaveBeenCalled();
111+
expect(errorSpy).toHaveBeenCalled();
112+
});
113+
114+
it('filters by recordId on the fulfilled event', () => {
115+
const callback = vi.fn();
116+
api.subscribeData('project_task', callback, { recordId: 'task_2' });
117+
118+
deliver(envelopeOf({ ...VALID_EVENT }));
119+
expect(callback).not.toHaveBeenCalled();
120+
121+
deliver(envelopeOf({ ...VALID_EVENT, recordId: 'task_2', after: { id: 'task_2' } }));
122+
expect(callback).toHaveBeenCalledTimes(1);
123+
expect(callback.mock.calls[0][0].recordId).toBe('task_2');
124+
});
125+
126+
it('ignores events for another object', () => {
127+
const callback = vi.fn();
128+
api.subscribeData('project_task', callback);
129+
130+
deliver(envelopeOf(
131+
{ ...VALID_EVENT, object: 'account', recordId: 'acc_1' },
132+
'data.record.updated',
133+
'account',
134+
));
135+
136+
expect(callback).not.toHaveBeenCalled();
137+
});
138+
139+
it('delivers created and deleted events too', () => {
140+
const seen: any[] = [];
141+
api.subscribeData('project_task', (event) => seen.push(event));
142+
143+
deliver(envelopeOf({
144+
id: '9f8b0f6e-1c2d-4a3b-8c9d-0e1f2a3b4c5d',
145+
type: 'data.record.created',
146+
object: 'project_task',
147+
recordId: 'task_9',
148+
after: { id: 'task_9', title: 'New' },
149+
timestamp: '2026-08-02T12:00:01.000Z',
150+
}, 'data.record.created'));
151+
152+
deliver(envelopeOf({
153+
id: '1b2c3d4e-5f60-4718-9a2b-3c4d5e6f7081',
154+
type: 'data.record.deleted',
155+
object: 'project_task',
156+
recordId: 'task_9',
157+
timestamp: '2026-08-02T12:00:02.000Z',
158+
}, 'data.record.deleted'));
159+
160+
expect(seen.map((e) => e.type)).toEqual(['data.record.created', 'data.record.deleted']);
161+
expect(seen.map((e) => e.recordId)).toEqual(['task_9', 'task_9']);
162+
expect(seen[1].after).toBeUndefined();
163+
});
164+
165+
it('unsubscribe stops delivery', () => {
166+
const callback = vi.fn();
167+
const off = api.subscribeData('project_task', callback);
168+
169+
deliver(envelopeOf({ ...VALID_EVENT }));
170+
expect(callback).toHaveBeenCalledTimes(1);
171+
172+
off();
173+
deliver(envelopeOf({ ...VALID_EVENT }));
174+
expect(callback).toHaveBeenCalledTimes(1);
175+
});
176+
});

packages/client/src/realtime-api.ts

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

1010
import type { RealtimeEventPayload } from '@objectstack/spec/contracts';
11-
import { MetadataEventSchema, type MetadataEvent, type DataEvent } from '@objectstack/spec/api';
11+
import {
12+
MetadataEventSchema,
13+
DataEventSchema,
14+
type MetadataEvent,
15+
type DataEvent,
16+
} from '@objectstack/spec/api';
1217

1318
export interface RealtimeSubscriptionFilter {
1419
/** Metadata/object type filter */
@@ -121,12 +126,28 @@ export class RealtimeAPI {
121126
]
122127
},
123128
handler: (event) => {
124-
// Type guard and filter
125-
if (event.type.startsWith('data.') && event.object === object) {
126-
if (!options?.recordId || (event.payload as any)?.recordId === options.recordId) {
127-
callback(event as any as DataEvent);
128-
}
129+
if (!event.type.startsWith('data.') || event.object !== object) return;
130+
// Contract boundary (#4626): the wire carries a RealtimeEventPayload
131+
// envelope whose `payload` is the producer's DataEvent (the ObjectQL
132+
// engine builds and validates it). Validate it here too — the callback
133+
// is typed `(event: DataEvent) => void`, so delivering the envelope
134+
// itself (what `event as any as DataEvent` used to do) left every
135+
// subscriber reading `undefined` for the top-level `recordId` /
136+
// `changes` / `id` the type promised. An off-contract payload is
137+
// rejected LOUDLY (throw → surfaced by emitEvent's handler-error log),
138+
// never coerced or passed through: a malformed event means the
139+
// producer is broken and must be fixed there, not tolerated here.
140+
const parsed = DataEventSchema.safeParse(event.payload);
141+
if (!parsed.success) {
142+
throw new Error(
143+
`subscribeData('${object}'): event '${event.type}' payload does not satisfy ` +
144+
`DataEventSchema — rejecting off-contract event (fix the producer): ${parsed.error.message}`
145+
);
129146
}
147+
// Narrow on the FULFILLED event, not the envelope — `recordId` is a
148+
// declared top-level field of what the subscriber receives.
149+
if (options?.recordId && parsed.data.recordId !== options.recordId) return;
150+
callback(parsed.data);
130151
}
131152
});
132153

0 commit comments

Comments
 (0)