Skip to content

Commit f098fd4

Browse files
committed
refactor(spec)!: remove DataEventType 'data.field.changed' — no producer (#4673)
`data.field.changed` was declared in `DataEventType` and emitted by nothing. The engine's `publishDataEvent` sends `data.record.{created,updated,deleted}` and (since #4639) `data.records.{updated,deleted}`; no other producer exists in either repository. A subscriber switching on it held a branch that could never run, and the surrounding `switch` still compiled — ADR-0078's silently-inert declaration, on the event vocabulary. It could not have been implemented against this contract as written either: `DataEventSchema` is record-shaped (`recordId`, `changes`, `before`, `after`) with no `field` / `oldValue` / `newValue` slot, so the member advertised a granularity the payload has no room for. FROM `type: 'data.field.changed'` TO `type: 'data.record.updated'`, reading the per-field detail off the payload's `changes` map (with `before` / `after`). Nothing is lost — that detail has always ridden on the record event, as one event per write rather than N on a wide table. Registered as an ADR-0087 D3 semantic migration (`data-field-changed-event-retired`) rather than a D2 conversion: this is a runtime EVENT surface, so there is no authorable source for `os migrate meta` to rewrite. Deliberately no `retiredKey()` tombstone — a removed enum VALUE cannot carry a fix-it prescription the way an authorable object key can (the same limit the sharing-rule `full` retirement hit). The enforced channels are tsc and the enum parse. ADR-0049 enforce-or-remove, route 3. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LYnZrTwXbrctB8E8HpJAPT
1 parent 65ca83a commit f098fd4

7 files changed

Lines changed: 196 additions & 5 deletions

File tree

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
---
2+
'@objectstack/spec': major
3+
---
4+
5+
**BREAKING**: `DataEventType` drops `data.field.changed` — it had no producer (ADR-0049 enforce-or-remove, #4673)
6+
7+
`data.field.changed` was declared in the `DataEventType` enum and emitted by
8+
nothing. The engine's `publishDataEvent` sends `data.record.{created,updated,deleted}`
9+
and (since #4639) `data.records.{updated,deleted}`; no other producer exists in
10+
either repository. A subscriber that switched on `data.field.changed` held a
11+
branch that could never run — and because the surrounding `switch` still
12+
compiled, nothing ever reported the gap. That is ADR-0078's silently-inert
13+
declaration, on the event vocabulary.
14+
15+
It also could not have been implemented against this contract as written:
16+
`DataEventSchema` is record-shaped (`recordId`, `changes`, `before`, `after`)
17+
with no `field` / `oldValue` / `newValue` slot, so the member advertised a
18+
granularity the payload has no room for.
19+
20+
**FROM → TO**
21+
22+
| FROM | TO |
23+
| :--- | :--- |
24+
| `type: 'data.field.changed'` | `type: 'data.record.updated'`, reading the per-field detail from the payload's `changes` map (with `before` / `after` for surrounding state) |
25+
26+
**The one-line fix** — delete the dead branch and read `changes` off the update
27+
event:
28+
29+
```ts
30+
// BEFORE — never ran; no producer ever sent this event
31+
if (event.type === 'data.field.changed') { onFieldChange(event); }
32+
33+
// AFTER — the changed fields have always ridden on the record event
34+
if (event.type === 'data.record.updated') {
35+
for (const [field, value] of Object.entries(event.changes ?? {})) onFieldChange(field, value);
36+
}
37+
```
38+
39+
Removing that branch changes no observable behaviour — it never executed — so
40+
this is deleting code that could not run, not rebuilding a capability. Note the
41+
replacement is one event per write rather than N events on a wide table.
42+
43+
**The retirement kit:**
44+
45+
- **Schema** — the member is gone from `DataEventType` (`api/events.zod.ts`),
46+
with an in-schema comment recording what was removed and what the live
47+
mechanism is. Deliberately **no `retiredKey()` tombstone**: a removed enum
48+
VALUE cannot carry a fix-it prescription the way an authorable object key
49+
can (the same limit the sharing-rule `full` retirement hit). The enforced
50+
channels are `tsc`, which fails any consumer still naming the value in a
51+
`DataEventType` position, and the enum parse, which now rejects the name
52+
instead of accepting an event that never arrives.
53+
- **ADR-0087 D3 semantic migration**`data-field-changed-event-retired` in
54+
`migrations/registry.ts` (step 17), carrying the reason and acceptance
55+
criteria. Registered as a **semantic TODO rather than a D2 conversion**
56+
because this is a runtime EVENT surface: no stack, example or template
57+
authors an event name, so there is no source for `os migrate meta` to
58+
rewrite. (Webhooks subscribe through the separate authorable
59+
`WebhookTriggerType`, whose vocabulary was already trimmed to producers that
60+
exist, #3196.)
61+
- **No liveness-ledger entry** — the ledger governs authorable metadata types
62+
(`object`, `field`, `flow`, …); `DataEvent` is a runtime payload contract and
63+
has no ledger file. `check:liveness` and `check:empty-state` pass unchanged.
64+
- **No `authorable-surface.json` movement** — that ratchet tracks authorable
65+
*keys* (`api/DataEvent:type` and friends), not enum members, so the key list
66+
is unchanged and gates (a)/(b) correctly stay silent.
67+
- **Tests**`api/events.test.ts` pins the narrowed `.options`, asserts the
68+
retired name no longer parses, and pins the FROM → TO replacement (that
69+
`data.record.updated` really does carry `changes` / `before` / `after`).
70+
- **Docs**`content/docs/references/api/events.mdx` and
71+
`docs/protocol-upgrade-guide.md` regenerated.
72+
73+
If a genuine per-field change stream is ever wanted, it earns its own honest
74+
contract — the precedent #4639 set for bulk writes — rather than reclaiming
75+
this slot.

content/docs/references/api/events.mdx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ const result = DataEventSchema.parse(data);
4242
| Property | Type | Required | Description |
4343
| :--- | :--- | :--- | :--- |
4444
| **id** | `string` || Unique event identifier |
45-
| **type** | `Enum<'data.record.created' \| 'data.record.updated' \| 'data.record.deleted' \| 'data.field.changed'>` || Event type |
45+
| **type** | `Enum<'data.record.created' \| 'data.record.updated' \| 'data.record.deleted'>` || Event type |
4646
| **object** | `string` || Object name |
4747
| **recordId** | `string` || Record ID |
4848
| **changes** | `Record<string, any>` | optional | Changed fields |
@@ -61,7 +61,6 @@ const result = DataEventSchema.parse(data);
6161
* `data.record.created`
6262
* `data.record.updated`
6363
* `data.record.deleted`
64-
* `data.field.changed`
6564

6665

6766
---

docs/protocol-upgrade-guide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,8 @@ Separately, `object.managedBy: 'system'` is retired in favour of `'system-data'`
168168

169169
Finally, five keys retire because the advisory lint could never have warned about them (#4509): mapping `extractQuery` / `errorPolicy` / `batchSize`, and app `contextSelectors[].includeAll` / `.placement`. Four of the five carry schema DEFAULTS, and a default materialises at parse time — so the liveness lint cannot tell a value the author wrote from one the schema supplied, and marking them would have warned on every mapping and every selector in existence. For a key in that state removal is not the escalation after a warning; it is the only channel that ever reaches the author, which is why they ship inside the 17.0.0 window rather than after a deprecation cycle. What they claimed: `extractQuery` promised an export path no exporter implements (exports go through the ordinary query API); `errorPolicy` offered skip/abort/retry where error handling belongs to the import REQUEST; `batchSize` sized batches the write path sizes itself; `placement` offered a topbar that places nothing. `includeAll` is the one worth reading twice — it was not unread but deliberately DISOBEYED, because context selectors are mandatory-scope and an "All" row would clear the scope: on Studio's package selector that means listing the platform's own system/cloud kernel packages to a developer who scoped to their package. `STUDIO_APP` authored `includeAll: true` against a renderer that ignored it. The mapping prescription for `batchSize` deliberately offers no rename: bulk-action, connector, sync, offline, seed-loader and NoSQL-cursor `batchSize` are all live, but each is a different key sizing its own path — the same trap `datasource.retryPolicy` vs `hook`/`job` `retryPolicy` had to defuse one issue earlier.
170170

171+
The same enforce-or-remove pass reaches the event vocabulary: `DataEventType` drops `data.field.changed` (#4673). It had no producer anywhere — the engine emits `data.record.{created,updated,deleted}` and, since #4639, `data.records.{updated,deleted}` — so a subscriber switching on it held a branch that could never run, and the `switch` still compiled, which is why an empty member could sit in a public enum this long. It could not have been implemented against this contract as written: `DataEventSchema` is record-shaped and has no `field` / `oldValue` / `newValue` slot, so the member advertised a granularity the payload has no room for. Nothing is lost — per-field detail already rides on `data.record.updated` as `changes` (with `before` / `after`), one event per write instead of N on a wide table. Like the driver contract above it is a runtime surface, never stored in stack metadata, so it is one semantic TODO for event consumers rather than a source rewrite, and it carries no tombstone: a removed enum VALUE cannot hold a fix-it error, exactly as the sharing-rule `full` retirement noted. Should a real per-field stream ever be wanted, it earns its own contract on the #4639 precedent rather than reclaiming this slot.
172+
171173
### Mechanical (applied for you)
172174

173175
| Conversion | Surface | Change | Load window |
@@ -241,6 +243,9 @@ Finally, five keys retire because the advisory lint could never have warned abou
241243
- **`data-driver-find-stream-retired`**`contracts.IDataDriver.findStream / data.DriverInterfaceSchema.findStream` → find() with limit/offset — the paged read whose determinism IS enforced (IDataDriver.find, data/pagination-conformance.ts)
242244
- Why not automatic: `findStream` was a REQUIRED contract method documented as "optimized for large datasets to avoid memory overflow", and in two of its three implementations it delivered the opposite: `SqlDriver` and `InMemoryDriver` both awaited `find()` for the ENTIRE result set and then yielded it row by row, so the peak memory a caller was promised protection from was already reached before the first yield. The third (`MongoDBDriver._findStream`) did walk a cursor, but it was the one read path in that driver never routed through `buildFindOptions`, so it hardcoded `projection: { _id: 0 }` and silently discarded `query.fields`. None of it was ever observed, because the method had NO caller in either repository: the engine exposes no stream entry, and the REST export, import and bulk-read paths all go through `find()`. The ~20 driver test doubles that existed only to satisfy a required method almost all threw `not implemented`, and nothing ever noticed — which is the proof, not the anecdote. Being REQUIRED, it also taxed every new driver and every test double with an implementation of a capability the platform does not have. Rather than build a caller to justify three implementations, the method is retired; a real cursor-based read should return WITH the caller that needs it (ADR-0049 enforce-or-remove). This is a TS/API contract surface — a driver is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone either: nothing ever ran a driver object through `DriverInterfaceSchema.parse()`, so a prescription there would have no one to reach. The enforced channel is tsc, and it points at callers. ADR-0049 / ADR-0078, #4484.
243245
- Done when: No code calls `driver.findStream(...)`; large reads page through `find()` with `limit`/`offset` (which guarantees a total order across the whole walk) or go through the export surface. Drivers and test doubles no longer implement the method — one left behind still compiles and is simply never reached, so removing it is cleanup rather than a break, while a CALLER of it no longer type-checks.
246+
- **`data-field-changed-event-retired`**`api.DataEventType 'data.field.changed'` → the `data.record.updated` event, whose payload already carries the per-field detail: `changes` (the changed fields), plus `before` / `after`
247+
- Why not automatic: `data.field.changed` was declared in `DataEventType` and emitted by nothing — the engine's `publishDataEvent` sends `data.record.{created,updated,deleted}` and (since #4639) `data.records.{updated,deleted}`, and no other producer exists in either repository. A subscriber that switched on it was waiting on an event no producer sends: the branch never ran, and because the surrounding `switch` still compiled, nothing anywhere reported the gap (ADR-0078's silently-inert declaration, on the event vocabulary). `DataEventSchema` could not have carried the semantics even if something had emitted it — the payload is record-shaped (`recordId`, `changes`, `before`, `after`) with no `field` / `oldValue` / `newValue` slot — so the member promised a granularity the contract has no room for. Per-field detail is therefore not lost: it has always ridden on `data.record.updated` as `changes`, which is one event per write rather than N events on a wide table. This is a runtime EVENT surface — no stack, example or template authors an event name (webhooks subscribe through the separate authorable `WebhookTriggerType`, whose vocabulary was already trimmed to producers that exist, #3196) — so there is no source for the chain to rewrite, and deliberately no schema tombstone: a removed ENUM MEMBER cannot carry a retiredKey() fix-it error the way an authorable object key can (the same limit the sharing-rule `full` retirement hit above). The enforced channels are tsc, which fails any consumer still naming the value in a `DataEventType` position, and the enum parse, which now rejects the name instead of accepting an event that never arrives. A genuine per-field stream, if one is ever wanted, gets its own honest contract the way #4639 gave bulk writes theirs. ADR-0049 / ADR-0078, #4673.
248+
- Done when: No consumer subscribes to or switches on `data.field.changed`; per-field change detail is read from a `data.record.updated` event's `changes` map (with `before` / `after` for the surrounding state). Deleting the dead branch changes no observable behaviour — it never executed — so the migration is removing code that could not run, not rebuilding a capability.
244249

245250
---
246251

0 commit comments

Comments
 (0)