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
75 changes: 75 additions & 0 deletions .changeset/20288-realtime-event-type-emitted-vocabulary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
---
'@objectstack/spec': minor
---

feat(spec)!: `RealtimeEventType` names the realtime events the runtime emits — `data.record.*` and `data.records.*`; `record.created` / `record.updated` / `record.deleted` / `field.changed` retired (#20288)

Clause-②: yes (narrowing)

**BREAKING** — shipped as `minor` under the launch-window convention
(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by
this banner, the `(narrowing)` arm above and the ADR-0087 disposition below,
never by the level). The enum both gains and loses values: it gains the five
names the runtime emits and loses the four it never emitted.

`RealtimeEventType` (`@objectstack/spec/api`) types `SubscriptionEvent.type`, so
it is the event vocabulary of `Subscription.events[]` and
`RealtimeConfig.subscriptions[].events[]`, and the generated API reference
published it as such. None of its four values was ever emitted by anything. The
ObjectQL engine publishes `data.record.created` / `data.record.updated` /
`data.record.deleted` for each written record and `data.records.updated` /
`data.records.deleted` for a predicate write (`multi: true`), and it parses every
event through `DataEventSchema` / `BulkDataEventSchema` before publishing. A
subscription written with the names the reference showed could never fire.
The runtime's published event names do not change; the enum moves to them.

- **Accepted now:** exactly `DataEventType` + `BulkDataEventType` —
`data.record.created`, `data.record.updated`, `data.record.deleted`,
`data.records.updated`, `data.records.deleted`. Metadata change events
(`metadata.{type}.{action}`) are not members: a subscription event is
record-shaped, and metadata events keep their own `MetadataEventType` contract
and `subscribeMetadata` client primitive.
- **Refused now:** `record.created`, `record.updated`, `record.deleted` and
`field.changed`, each with its own prescription. Any other unknown value keeps
zod's own message, which lists the legal names.

```
FROM SubscriptionSchema.parse({ id, transport: 'websocket', events: [{ type: 'record.created', object: 'account' }] })
-> parsed; nothing ever published 'record.created', so the subscription never fired
TO -> ZodError at events[0].type (invalid_value): `record.created` was removed from
`RealtimeEventType` in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — … Write
`data.record.created` — the same event, spelled the way the engine publishes it.

FROM { type: 'record.updated' } -> TO { type: 'data.record.updated' }
(add { type: 'data.records.updated' } to also hear predicate writes, which carry a count, not records)
FROM { type: 'record.deleted' } -> TO { type: 'data.record.deleted' }
(add { type: 'data.records.deleted' } to also hear predicate writes)
FROM { type: 'field.changed' } -> TO { type: 'data.record.updated' }
(read the field from the DataEvent payload's `changes`; no event is published per field)
```

**Fix.** Rename each value as mapped above. `tsc` refuses the old values at a
`RealtimeEventType` / `SubscriptionEvent` position (the type no longer contains
them), and a `SubscriptionSchema`, `SubscriptionEventSchema` or
`RealtimeConfigSchema` parse refuses them with the prescription. A handler keyed
on an old name never ran, so the rename changes behaviour only by making it fire.
Nothing in the open framework parses a subscription today — it mounts no realtime
transport — so no running deployment changes behaviour.

### The kit

- **Schema.** `RealtimeEventType` lists the five emitted names; its error map
gives each retired value its own prescription (the `HookBodyCapability`
precedent for a removed enum value). `realtime.test.ts` pins the enum equal to
`DataEventType` + `BulkDataEventType`, and pins each refusal's code, path and
first sentence through `SubscriptionSchema`.
- **ADR-0087.** The D3 entry `realtime-event-type-unemitted-values-retired`
carries the prescription to `os migrate meta` and the upgrade guide. No D2
conversion and no `RETIRED_KEYS_BY_MAJOR` row: an enum value is not a key, and
a subscription is neither a stack collection member nor a stored row, so no
conversion seam would ever see one.
- **Docs.** The `realtime` reference page is regenerated; the
`RealtimeEventPayload.type` and `RealtimeEvent.type` examples name emitted
events.

<!-- adr-0087: registered realtime-event-type-unemitted-values-retired -->
14 changes: 14 additions & 0 deletions .changeset/20288-service-realtime-emitted-event-examples.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/service-realtime': patch
---

docs(service-realtime): the README and `InMemoryRealtimeAdapter` examples name the events the ObjectQL engine actually publishes (#20288)

The usage example subscribed to and published `record.created`, and the security
posture section said the engine publishes `record.created` / `record.updated`.
The engine publishes `data.record.created` / `data.record.updated` /
`data.record.deleted` (and `data.records.updated` / `data.records.deleted` for a
predicate write), with the spec's `DataEvent` as the envelope's `payload` — the row
is `payload.after`, not the payload itself. Both examples now subscribe to
`data.record.created` and publish that shape. No code changes: the adapter matches
whatever event type a subscription names, exactly as before.
21 changes: 11 additions & 10 deletions content/docs/references/api/realtime.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const result = RealtimeConfigSchema.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **id** | `string` | ✅ | Unique subscription identifier |
| **events** | `{ type: Enum<'record.created' \| 'record.updated' \| 'record.deleted' \| 'field.changed'>; object?: string; filters?: any }[]` | ✅ | Array of events to subscribe to |
| **events** | `{ type: Enum<'data.record.created' \| 'data.record.updated' \| 'data.record.deleted' \| …>; object?: string; filters?: any }[]` | ✅ | Array of events to subscribe to |
| **transport** | `Enum<'websocket' \| 'sse' \| 'polling'>` | ✅ | Transport protocol to use |
| **channel** | `string` | optional | Optional channel name for grouping subscriptions |

Expand All @@ -51,7 +51,7 @@ const result = RealtimeConfigSchema.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **id** | `string` | ✅ | Unique event identifier |
| **type** | `string` | ✅ | Event type (e.g., record.created, record.updated) |
| **type** | `string` | ✅ | Event type (e.g., data.record.created, data.records.updated) |
| **object** | `string` | optional | Object name the event relates to |
| **action** | `Enum<'created' \| 'updated' \| 'deleted'>` | optional | Action performed |
| **payload** | `Record<string, any>` | ✅ | Event payload data |
Expand All @@ -64,14 +64,15 @@ const result = RealtimeConfigSchema.parse(data);

## RealtimeEventType

Realtime event type
Realtime event type: a record-change event name the runtime emits

### Allowed Values

* `record.created`
* `record.updated`
* `record.deleted`
* `field.changed`
* `data.record.created`
* `data.record.updated`
* `data.record.deleted`
* `data.records.updated`
* `data.records.deleted`


---
Expand All @@ -97,15 +98,15 @@ Realtime event type
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **id** | `string` | ✅ | Unique subscription identifier |
| **events** | `{ type: Enum<'record.created' \| 'record.updated' \| 'record.deleted' \| 'field.changed'>; object?: string; filters?: any }[]` | ✅ | Array of events to subscribe to |
| **events** | `{ type: Enum<'data.record.created' \| 'data.record.updated' \| 'data.record.deleted' \| …>; object?: string; filters?: any }[]` | ✅ | Array of events to subscribe to |
| **transport** | `Enum<'websocket' \| 'sse' \| 'polling'>` | ✅ | Transport protocol to use |
| **channel** | `string` | optional | Optional channel name for grouping subscriptions |

### Nested Shape: `Subscription.events[number]`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **type** | `Enum<'record.created' \| 'record.updated' \| 'record.deleted' \| 'field.changed'>` | ✅ | Type of event to subscribe to |
| **type** | `Enum<'data.record.created' \| 'data.record.updated' \| 'data.record.deleted' \| …>` | ✅ | Type of event to subscribe to |
| **object** | `string` | optional | Object name to subscribe to |
| **filters** | `any` | optional | Filter conditions |

Expand All @@ -118,7 +119,7 @@ Realtime event type

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **type** | `Enum<'record.created' \| 'record.updated' \| 'record.deleted' \| 'field.changed'>` | ✅ | Type of event to subscribe to |
| **type** | `Enum<'data.record.created' \| 'data.record.updated' \| 'data.record.deleted' \| 'data.records.updated' \| 'data.records.deleted'>` | ✅ | Type of event to subscribe to |
| **object** | `string` | optional | Object name to subscribe to |
| **filters** | `any` | optional | Filter conditions |

Expand Down
16 changes: 11 additions & 5 deletions packages/services/service-realtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,19 @@ const realtime = kernel.getService<IRealtimeService>('realtime');

const subId = await realtime.subscribe('records', (event) => {
console.log(event.type, event.payload);
}, { object: 'account', eventTypes: ['record.created'] });
}, { object: 'account', eventTypes: ['data.record.created'] });

// The shape the ObjectQL engine publishes for an insert: the envelope's
// `payload` is the spec's `DataEvent`, and the row itself is `payload.after`.
const timestamp = new Date().toISOString();
await realtime.publish({
type: 'record.created',
type: 'data.record.created',
object: 'account',
payload: { id: 'acc-1', name: 'Acme' },
timestamp: new Date().toISOString(),
payload: {
id: crypto.randomUUID(), type: 'data.record.created', object: 'account',
recordId: 'acc-1', after: { id: 'acc-1', name: 'Acme' }, timestamp,
},
timestamp,
});

await realtime.unsubscribe(subId);
Expand All @@ -70,7 +76,7 @@ Delivery is a **pure fan-out with no per-recipient authorization seam**:

- subscriptions carry **no principal** — there is nothing to check a row against;
- `matchesSubscription` filters only by object name + event type;
- the ObjectQL engine publishes `record.created` / `record.updated` events with the
- the ObjectQL engine publishes `data.record.created` / `data.record.updated` events with the
**full record body** (the `after` row) — rows and fields a subscriber's own `find`
would hide under RLS/FLS/tenant scoping.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,19 @@ export interface InMemoryRealtimeAdapterOptions {
*
* const subId = await realtime.subscribe('records', (event) => {
* console.log('Received:', event.type, event.payload);
* }, { object: 'account', eventTypes: ['record.created'] });
* }, { object: 'account', eventTypes: ['data.record.created'] });
*
* // The shape the ObjectQL engine publishes for an insert: the envelope's
* // `payload` is the spec's `DataEvent`, and the row itself is `payload.after`.
* const timestamp = new Date().toISOString();
* await realtime.publish({
* type: 'record.created',
* type: 'data.record.created',
* object: 'account',
* payload: { id: 'acc-1', name: 'Acme' },
* timestamp: new Date().toISOString(),
* payload: {
* id: crypto.randomUUID(), type: 'data.record.created', object: 'account',
* recordId: 'acc-1', after: { id: 'acc-1', name: 'Acme' }, timestamp,
* },
* timestamp,
* });
*
* await realtime.unsubscribe(subId);
Expand Down
Loading
Loading