Skip to content

Commit 0bae112

Browse files
committed
docs(protocol): put the realtime event fences on the shape EventMessageSchema declares
The six `type: "event"` fences on the realtime protocol page carried top-level `subscription_id` / `event` / `data` / `changes` / `reason` where `EventMessageSchema` declares `subscriptionId` / `eventName` / `object` / `payload` / `userId` on top of the three required `BaseWebSocketMessage` fields. A client written from them reads `msg.data` and `msg.subscription_id` and gets `undefined` on both. Prose only, one file. `packages/spec/src/api/websocket.zod.ts` is untouched. Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude <noreply@anthropic.com>
1 parent fffe3e5 commit 0bae112

1 file changed

Lines changed: 140 additions & 51 deletions

File tree

content/docs/protocol/kernel/realtime-protocol.mdx

Lines changed: 140 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -341,60 +341,117 @@ for anything else. There is no nested `error` object — a client that reads `ms
341341

342342
### Receiving Events
343343

344-
When subscribed data changes, server pushes events:
344+
When subscribed data changes, the server pushes `event` messages.
345+
346+
**`EventMessageSchema` declares five fields of its own**`subscriptionId`, `eventName`, `object`,
347+
`payload` and `userId` — on top of the three `BaseWebSocketMessage` fields every message carries.
348+
They are camelCase, and the record body travels **inside `payload`**, never at the top level.
345349

346350
**Created Event:**
347351
```json
348352
{
353+
"messageId": "a41d6c58-92b7-4f0e-8d3a-16b5c7e04912",
349354
"type": "event",
350-
"subscription_id": "sub_1",
351-
"event": "created",
355+
"timestamp": "2024-01-16T14:30:00Z",
356+
"subscriptionId": "9f8c1e42-5b7a-4d33-9e10-6c2f8a91b4d7",
357+
"eventName": "data.record.created",
352358
"object": "task",
353-
"data": {
354-
"id": "task_456",
355-
"title": "New task assigned to you",
356-
"status": "todo",
357-
"assignee_id": "user_123",
358-
"created_at": "2024-01-16T14:30:00Z"
359+
"userId": "user_123",
360+
"payload": {
361+
"id": "0c8b7a36-4e21-4d59-b7f2-3a9e5d1c8046",
362+
"type": "data.record.created",
363+
"object": "task",
364+
"recordId": "task_456",
365+
"after": {
366+
"id": "task_456",
367+
"title": "New task assigned to you",
368+
"status": "todo",
369+
"assignee_id": "user_123",
370+
"created_at": "2024-01-16T14:30:00Z"
371+
},
372+
"userId": "user_123",
373+
"timestamp": "2024-01-16T14:30:00Z"
359374
}
360375
}
361376
```
362377

363378
**Updated Event:**
364379
```json
365380
{
381+
"messageId": "b7e2f409-51ac-4d68-9b30-2c7a8e15d643",
366382
"type": "event",
367-
"subscription_id": "sub_1",
368-
"event": "updated",
383+
"timestamp": "2024-01-16T15:00:00Z",
384+
"subscriptionId": "9f8c1e42-5b7a-4d33-9e10-6c2f8a91b4d7",
385+
"eventName": "data.record.updated",
369386
"object": "task",
370-
"data": {
371-
"id": "task_456",
372-
"status": "in_progress",
373-
"updated_at": "2024-01-16T15:00:00Z"
374-
},
375-
"changes": {
376-
"status": {
377-
"old": "todo",
378-
"new": "in_progress"
379-
}
387+
"userId": "user_123",
388+
"payload": {
389+
"id": "5d3c9f71-8a04-4b62-91e7-0f6b2d8a3c15",
390+
"type": "data.record.updated",
391+
"object": "task",
392+
"recordId": "task_456",
393+
"changes": {
394+
"status": "in_progress"
395+
},
396+
"after": {
397+
"id": "task_456",
398+
"status": "in_progress",
399+
"updated_at": "2024-01-16T15:00:00Z"
400+
},
401+
"userId": "user_123",
402+
"timestamp": "2024-01-16T15:00:00Z"
380403
}
381404
}
382405
```
383406

384407
**Deleted Event:**
385408
```json
386409
{
410+
"messageId": "c9a04b17-6d38-4e52-8f71-4b2e6c9d0537",
387411
"type": "event",
388-
"subscription_id": "sub_1",
389-
"event": "deleted",
412+
"timestamp": "2024-01-16T16:00:00Z",
413+
"subscriptionId": "9f8c1e42-5b7a-4d33-9e10-6c2f8a91b4d7",
414+
"eventName": "data.record.deleted",
390415
"object": "task",
391-
"data": {
392-
"id": "task_456",
393-
"deleted_at": "2024-01-16T16:00:00Z"
416+
"userId": "user_123",
417+
"payload": {
418+
"id": "e18f2b05-7c46-4a93-b2d0-9e35c7a14f68",
419+
"type": "data.record.deleted",
420+
"object": "task",
421+
"recordId": "task_456",
422+
"userId": "user_123",
423+
"timestamp": "2024-01-16T16:00:00Z"
394424
}
395425
}
396426
```
397427

428+
<Callout type="warn">
429+
**`subscription_id`, `event` and `data` are not fields of this envelope, and `changes` and
430+
`reason` are not top-level fields either.** The declared names are `subscriptionId`, `eventName`
431+
and `payload`, so a client written from the old fences reads `msg.subscription_id` and `msg.data`
432+
and gets `undefined` on both. `changes` is real but sits **one level down**, on the `DataEvent`
433+
inside `payload`, and it carries the write's own patch — a flat `{ field: newValue }` map
434+
(`publishDataEvent`, `packages/objectql/src/engine.ts`), not the `old` / `new` pairs this page
435+
used to show; read the post-state from `payload.after`. `reason` is declared nowhere in the
436+
protocol at all — see **Subscribe to Query Results** below.
437+
</Callout>
438+
439+
**Fields:**
440+
- `messageId`: UUID for this message (`BaseWebSocketMessage`)
441+
- `timestamp`: ISO 8601 datetime the message was sent (`BaseWebSocketMessage`)
442+
- `subscriptionId`: **UUID** of the subscription this event belongs to — the schema declares
443+
`z.string().uuid()`, and it is the same UUID the subscribe message declared, so an opaque handle
444+
like `"sub_1"` does not parse
445+
- `eventName`: the event name, dot notation by convention. The platform-checked vocabulary is the
446+
closed `DataEventType` / `BulkDataEventType` enums (`packages/spec/src/api/events.zod.ts`) —
447+
`data.record.created` / `data.record.updated` / `data.record.deleted`, plus the two bulk siblings
448+
- `object`: optional object name the event relates to — the one key the old fences had right
449+
- `payload`: the event body, declared `z.unknown()` on the envelope. The platform's only publisher
450+
today is the ObjectQL engine, which emits a `DataEvent` for a single-row write and a
451+
`BulkDataEvent` for a predicate write; that is the shape shown above
452+
- `userId`: optional — the user who triggered the event
453+
454+
398455
### Unsubscribe
399456

400457
Stop receiving events for a subscription:
@@ -492,37 +549,55 @@ the declared subscription carries no query:
492549
- Evaluate query membership **on the client**: apply the predicate to each event and derive the
493550
enter/exit transitions yourself by comparing against the set you are already holding
494551

495-
**Example:** Task enters subscription:
552+
**Example:** a `task` the subscription covers is updated so that it now matches your predicate:
496553
```json
497554
{
555+
"messageId": "d2c74f38-9b15-4e06-a7f3-8c1d5b902e47",
498556
"type": "event",
499-
"subscription_id": "sub_3",
500-
"event": "updated",
557+
"timestamp": "2024-01-16T14:46:00Z",
558+
"subscriptionId": "6b3c9e07-2d54-4f18-9c8a-0e7d1b5a4632",
559+
"eventName": "data.record.updated",
501560
"object": "task",
502-
"data": {
503-
"id": "task_789",
504-
"status": "todo", // Changed from "in_progress" to "todo"
505-
"assignee_id": "user_123"
506-
},
507-
"reason": "entered_query"
561+
"payload": {
562+
"id": "7a1e4c92-0d63-4f85-b91a-2e6c8d357b04",
563+
"type": "data.record.updated",
564+
"object": "task",
565+
"recordId": "task_789",
566+
"changes": { "status": "todo" },
567+
"after": { "id": "task_789", "status": "todo", "assignee_id": "user_123" },
568+
"timestamp": "2024-01-16T14:46:00Z"
569+
}
508570
}
509571
```
510572

511-
**Example:** Task exits subscription:
573+
**Example:** the same subscription, a `task` updated so that it no longer matches:
512574
```json
513575
{
576+
"messageId": "e6f18a03-4d27-4c91-8b52-7a0e3d9c1465",
514577
"type": "event",
515-
"subscription_id": "sub_3",
516-
"event": "updated",
578+
"timestamp": "2024-01-16T14:47:00Z",
579+
"subscriptionId": "6b3c9e07-2d54-4f18-9c8a-0e7d1b5a4632",
580+
"eventName": "data.record.updated",
517581
"object": "task",
518-
"data": {
519-
"id": "task_456",
520-
"status": "done" // No longer matches "todo" filter
521-
},
522-
"reason": "exited_query"
582+
"payload": {
583+
"id": "9c05b7d4-1a68-4e30-97f2-5d8a1c60e3b9",
584+
"type": "data.record.updated",
585+
"object": "task",
586+
"recordId": "task_456",
587+
"changes": { "status": "done" },
588+
"after": { "id": "task_456", "status": "done" },
589+
"timestamp": "2024-01-16T14:47:00Z"
590+
}
523591
}
524592
```
525593

594+
**The two messages are identical in shape — nothing in the envelope says "entered" or "exited".**
595+
`EventMessageSchema` has no `reason` field, and no runtime tracks query membership to populate one,
596+
so the earlier `reason: "entered_query"` / `"exited_query"` examples described a signal the contract
597+
cannot send. The transition is yours to derive: apply your predicate to `payload.after` and compare
598+
against the set you are already holding.
599+
600+
526601
## Server-Sent Events (SSE)
527602

528603
### Connection Endpoint
@@ -731,20 +806,34 @@ Real-time subscriptions respect object-level and row-level permissions:
731806

732807
**Example:** User has rule "see only assigned tasks":
733808

734-
```javascript
735-
// User is assigned task_456
809+
```json
736810
{
811+
"messageId": "f3b90d67-5a24-4e18-9c07-1b8f2a6d4e35",
737812
"type": "event",
738-
"event": "updated",
813+
"timestamp": "2024-01-16T14:51:00Z",
814+
"subscriptionId": "9f8c1e42-5b7a-4d33-9e10-6c2f8a91b4d7",
815+
"eventName": "data.record.updated",
739816
"object": "task",
740-
"data": { "id": "task_456", "assignee_id": "user_123" },
741-
"reason": "entered_query"
817+
"userId": "user_123",
818+
"payload": {
819+
"id": "2d7c1f85-6b39-4a04-8e13-9f05c2b71a68",
820+
"type": "data.record.updated",
821+
"object": "task",
822+
"recordId": "task_456",
823+
"changes": { "assignee_id": "user_123" },
824+
"after": { "id": "task_456", "assignee_id": "user_123" },
825+
"userId": "user_123",
826+
"timestamp": "2024-01-16T14:51:00Z"
827+
}
742828
}
743-
744-
// Task reassigned to someone else
745-
// No event sent - task is now invisible to user
746829
```
747830

831+
Reassign `task_456` to someone else and, under the row-level behaviour described above, no further
832+
message arrives for it: the protocol has no "you lost access" signal to send, so absence of events
833+
is the whole signal. There is no `reason` field here either. What delivery actually enforces today
834+
is the implementation-status callout at the top of this page, not this section.
835+
836+
748837
## Scaling Considerations
749838

750839
### Connection Limits

0 commit comments

Comments
 (0)