From f098fd42ab38ecac1b36221e49612a18605597d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 16:53:28 +0000 Subject: [PATCH] =?UTF-8?q?refactor(spec)!:=20remove=20DataEventType=20'da?= =?UTF-8?q?ta.field.changed'=20=E2=80=94=20no=20producer=20(#4673)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01LYnZrTwXbrctB8E8HpJAPT --- .../data-field-changed-event-removed.md | 75 +++++++++++++++++++ content/docs/references/api/events.mdx | 3 +- docs/protocol-upgrade-guide.md | 5 ++ packages/spec/spec-changes.json | 14 ++++ packages/spec/src/api/events.test.ts | 35 ++++++++- packages/spec/src/api/events.zod.ts | 16 +++- packages/spec/src/migrations/registry.ts | 53 ++++++++++++- 7 files changed, 196 insertions(+), 5 deletions(-) create mode 100644 .changeset/data-field-changed-event-removed.md diff --git a/.changeset/data-field-changed-event-removed.md b/.changeset/data-field-changed-event-removed.md new file mode 100644 index 0000000000..b8ced3f0d6 --- /dev/null +++ b/.changeset/data-field-changed-event-removed.md @@ -0,0 +1,75 @@ +--- +'@objectstack/spec': major +--- + +**BREAKING**: `DataEventType` drops `data.field.changed` — it had no producer (ADR-0049 enforce-or-remove, #4673) + +`data.field.changed` was declared in the `DataEventType` enum 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 that switched on `data.field.changed` held a +branch that could never run — and because the surrounding `switch` still +compiled, nothing ever reported the gap. That is ADR-0078's silently-inert +declaration, on the event vocabulary. + +It also could not have been implemented against this contract as written: +`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 → TO** + +| FROM | TO | +| :--- | :--- | +| `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) | + +**The one-line fix** — delete the dead branch and read `changes` off the update +event: + +```ts +// BEFORE — never ran; no producer ever sent this event +if (event.type === 'data.field.changed') { onFieldChange(event); } + +// AFTER — the changed fields have always ridden on the record event +if (event.type === 'data.record.updated') { + for (const [field, value] of Object.entries(event.changes ?? {})) onFieldChange(field, value); +} +``` + +Removing that branch changes no observable behaviour — it never executed — so +this is deleting code that could not run, not rebuilding a capability. Note the +replacement is one event per write rather than N events on a wide table. + +**The retirement kit:** + +- **Schema** — the member is gone from `DataEventType` (`api/events.zod.ts`), + with an in-schema comment recording what was removed and what the live + mechanism is. 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`, 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. +- **ADR-0087 D3 semantic migration** — `data-field-changed-event-retired` in + `migrations/registry.ts` (step 17), carrying the reason and acceptance + criteria. Registered as a **semantic TODO rather than a D2 conversion** + because this is a runtime EVENT surface: no stack, example or template + authors an event name, so there is no source for `os migrate meta` to + rewrite. (Webhooks subscribe through the separate authorable + `WebhookTriggerType`, whose vocabulary was already trimmed to producers that + exist, #3196.) +- **No liveness-ledger entry** — the ledger governs authorable metadata types + (`object`, `field`, `flow`, …); `DataEvent` is a runtime payload contract and + has no ledger file. `check:liveness` and `check:empty-state` pass unchanged. +- **No `authorable-surface.json` movement** — that ratchet tracks authorable + *keys* (`api/DataEvent:type` and friends), not enum members, so the key list + is unchanged and gates (a)/(b) correctly stay silent. +- **Tests** — `api/events.test.ts` pins the narrowed `.options`, asserts the + retired name no longer parses, and pins the FROM → TO replacement (that + `data.record.updated` really does carry `changes` / `before` / `after`). +- **Docs** — `content/docs/references/api/events.mdx` and + `docs/protocol-upgrade-guide.md` regenerated. + +If a genuine per-field change stream is ever wanted, it earns its own honest +contract — the precedent #4639 set for bulk writes — rather than reclaiming +this slot. diff --git a/content/docs/references/api/events.mdx b/content/docs/references/api/events.mdx index 6b27f61acc..b5a92b6576 100644 --- a/content/docs/references/api/events.mdx +++ b/content/docs/references/api/events.mdx @@ -42,7 +42,7 @@ const result = DataEventSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique event identifier | -| **type** | `Enum<'data.record.created' \| 'data.record.updated' \| 'data.record.deleted' \| 'data.field.changed'>` | ✅ | Event type | +| **type** | `Enum<'data.record.created' \| 'data.record.updated' \| 'data.record.deleted'>` | ✅ | Event type | | **object** | `string` | ✅ | Object name | | **recordId** | `string` | ✅ | Record ID | | **changes** | `Record` | optional | Changed fields | @@ -61,7 +61,6 @@ const result = DataEventSchema.parse(data); * `data.record.created` * `data.record.updated` * `data.record.deleted` -* `data.field.changed` --- diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 0c5568f8ea..b5086c7cac 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -168,6 +168,8 @@ Separately, `object.managedBy: 'system'` is retired in favour of `'system-data'` 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. +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. + ### Mechanical (applied for you) | Conversion | Surface | Change | Load window | @@ -241,6 +243,9 @@ Finally, five keys retire because the advisory lint could never have warned abou - **`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) - 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. - 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. +- **`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` + - 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. + - 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. --- diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 08d240f1fb..058e41bc5a 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -403,6 +403,13 @@ "migrationId": "data-driver-find-stream-retired", "toMajor": 17, "rationale": "`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." + }, + { + "surface": "api.DataEventType 'data.field.changed'", + "replacement": "the `data.record.updated` event, whose payload already carries the per-field detail: `changes` (the changed fields), plus `before` / `after`", + "migrationId": "data-field-changed-event-retired", + "toMajor": 17, + "rationale": "`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." } ], "removed": [] @@ -865,6 +872,13 @@ "migrationId": "data-driver-find-stream-retired", "toMajor": 17, "rationale": "`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." + }, + { + "surface": "api.DataEventType 'data.field.changed'", + "replacement": "the `data.record.updated` event, whose payload already carries the per-field detail: `changes` (the changed fields), plus `before` / `after`", + "migrationId": "data-field-changed-event-retired", + "toMajor": 17, + "rationale": "`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." } ], "removed": [] diff --git a/packages/spec/src/api/events.test.ts b/packages/spec/src/api/events.test.ts index 76624c65b0..e4d6c02d02 100644 --- a/packages/spec/src/api/events.test.ts +++ b/packages/spec/src/api/events.test.ts @@ -111,7 +111,6 @@ describe('DataEventSchema', () => { 'data.record.created', 'data.record.updated', 'data.record.deleted', - 'data.field.changed', ]); expect(() => DataEventSchema.parse({ id: '4b4720e8-97c3-4a12-9b70-b70a3d2314a3', @@ -121,4 +120,38 @@ describe('DataEventSchema', () => { timestamp: new Date().toISOString(), })).toThrow(); }); + + // #4673 (ADR-0049 enforce-or-remove): `data.field.changed` was declared here + // with no producer anywhere in the repo. It is gone, and the enum is the only + // channel that can say so — an enum member cannot carry a retiredKey() + // fix-it prescription the way an authorable object key can. + it('no longer accepts the retired data.field.changed event name', () => { + expect(DataEventType.options).not.toContain('data.field.changed'); + expect(() => DataEventSchema.parse({ + id: '4b4720e8-97c3-4a12-9b70-b70a3d2314a4', + type: 'data.field.changed', + object: 'account', + recordId: 'rec_1', + timestamp: new Date().toISOString(), + })).toThrow(); + }); + + // The replacement is not a different event name — it is the payload the live + // record event already carries. This pins that FROM → TO so the changeset's + // one-line fix stays true. + it('carries per-field detail on data.record.updated via changes/before/after', () => { + const event = DataEventSchema.parse({ + id: '4b4720e8-97c3-4a12-9b70-b70a3d2314a5', + type: 'data.record.updated', + object: 'account', + recordId: 'rec_1', + changes: { name: 'New Name' }, + before: { name: 'Old Name' }, + after: { name: 'New Name' }, + timestamp: new Date().toISOString(), + }); + expect(event.changes).toEqual({ name: 'New Name' }); + expect(event.before).toEqual({ name: 'Old Name' }); + expect(event.after).toEqual({ name: 'New Name' }); + }); }); diff --git a/packages/spec/src/api/events.zod.ts b/packages/spec/src/api/events.zod.ts index c01b9df4ae..78655506b7 100644 --- a/packages/spec/src/api/events.zod.ts +++ b/packages/spec/src/api/events.zod.ts @@ -63,12 +63,26 @@ export type MetadataEventType = z.infer; * * Triggered when data records are created, updated, or deleted. * Follows the pattern: `data.record.{action}` + * + * Only event names with a REAL producer are declared here — a subscriber must + * not be able to wait on something that never fires (the same rule + * `WebhookTriggerType` states for its own vocabulary). The engine's + * `publishDataEvent` emits exactly these three. + * + * REMOVED in 17.0.0 (#4673, ADR-0049 enforce-or-remove): `data.field.changed`. + * Nothing in either repository ever emitted it, and `DataEventSchema` has no + * `field` / `oldValue` / `newValue` slot to carry per-field semantics anyway — + * it is record-shaped. Per-field detail is already on the record event: a + * `data.record.updated` payload carries `changes` (the changed fields), + * `before` and `after`. A consumer that keyed on `data.field.changed` was + * keying on an event no producer sent (ADR-0078's silently-inert declaration). + * If a genuine per-field stream is ever needed it gets its own honest contract + * — the precedent #4639 set for bulk writes — rather than an empty member here. */ export const DataEventType = z.enum([ 'data.record.created', 'data.record.updated', 'data.record.deleted', - 'data.field.changed', ]); export type DataEventType = z.infer; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index ee52502f18..d07fc43f43 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -637,7 +637,22 @@ const step17: MigrationStep = { + '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.', + + 'retryPolicy` vs `hook`/`job` `retryPolicy` had to defuse one issue earlier.\n\n' + + '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.', conversionIds: [ 'action-execute-to-target', 'field-conditionalRequired-to-requiredWhen', @@ -920,6 +935,42 @@ const step17: MigrationStep = { + '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.', }, + { + id: 'data-field-changed-event-retired', + surface: "api.DataEventType 'data.field.changed'", + replacement: + "the `data.record.updated` event, whose payload already carries the per-field " + + 'detail: `changes` (the changed fields), plus `before` / `after`', + reason: + '`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.', + acceptanceCriteria: + '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.', + }, ], };