@reventlessdev/reventless-aws 3.0.0-alpha.266 → 3.0.0-alpha.268

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,21 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.268 (2026-08-05)
7
+
8
+ ### Features
9
+
10
+ * **local,aws:** carry the changed row in the live-update descriptor ([9272e2e](https://github.com/ReventlessDev/reventless-core/commit/9272e2e18e9e1d740f9a2c5aa83eccbcb41feff7))
11
+ * **local:** scoped seed:reset for the local platform ([a4dd003](https://github.com/ReventlessDev/reventless-core/commit/a4dd0033b7d01be23be91bac3f01d38ebeab7d45))
12
+
13
+
14
+ # 3.0.0-alpha.267 (2026-08-04)
15
+
16
+ ### Features
17
+
18
+ * **admin:** serve the complete plugin structure to developer tooling ([e99cda0](https://github.com/ReventlessDev/reventless-core/commit/e99cda02a2f862b36bcfdb018a0fb34bb7426a2b))
19
+
20
+
6
21
  # 3.0.0-alpha.266 (2026-08-04)
7
22
 
8
23
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.266",
3
+ "version": "3.0.0-alpha.268",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "dependencies": {
@@ -11,18 +11,18 @@
11
11
  "@aws-sdk/s3-request-presigner": "3.970.0",
12
12
  "sury": "11.0.0-alpha.4",
13
13
  "uuid": "^13.0.0",
14
- "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.3",
14
+ "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.4",
15
15
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
16
- "@reventlessdev/rescript-node": "2.0.0-alpha.1",
16
+ "@reventlessdev/rescript-node": "2.0.0-alpha.2",
17
17
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
18
- "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.65",
18
+ "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.66",
19
19
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
20
20
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
21
- "@reventlessdev/reventless-core": "3.0.0-alpha.211",
22
- "@reventlessdev/reventless-infra": "3.0.0-alpha.126",
23
- "@reventlessdev/reventless-spec": "3.0.0-alpha.100",
21
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.127",
22
+ "@reventlessdev/reventless-core": "3.0.0-alpha.213",
24
23
  "@reventlessdev/reventless-interop": "3.0.0-alpha.30",
25
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.75"
24
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.77",
25
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.101"
26
26
  },
27
27
  "devDependencies": {
28
28
  "rescript": "12.3.0",
package/src/Platform.res CHANGED
@@ -1452,6 +1452,7 @@ module MakeWithConfig = (
1452
1452
  ~api=platformApi,
1453
1453
  ~pluginReadModelTableName=tableName,
1454
1454
  ~offloadBucketName,
1455
+ ~schemaReady=admin.adminSchemaPushed,
1455
1456
  ~opts={},
1456
1457
  )
1457
1458
  | None => ()
@@ -861,7 +861,7 @@ function MakeWithConfig(Config) {
861
861
  Pulumi$Pulumi.$$export("offloadBucket", offloadBucketName);
862
862
  if (pluginReadModelTableName !== undefined) {
863
863
  AggregateRuntime_Builder_Single$ReventlessAws.setPluginReadModelTable(pluginReadModelTableName);
864
- Platform_ComponentDefinitions_Lambda$ReventlessAws.make(platformApi, pluginReadModelTableName, offloadBucketName, {});
864
+ Platform_ComponentDefinitions_Lambda$ReventlessAws.make(platformApi, pluginReadModelTableName, offloadBucketName, admin.adminSchemaPushed, {});
865
865
  }
866
866
  let rm = admin.stateViewSlicesOutputs["UiFragments"];
867
867
  if (rm !== undefined) {
@@ -2096,7 +2096,7 @@ function Make($star) {
2096
2096
  Pulumi$Pulumi.$$export("offloadBucket", offloadBucketName);
2097
2097
  if (pluginReadModelTableName !== undefined) {
2098
2098
  AggregateRuntime_Builder_Single$ReventlessAws.setPluginReadModelTable(pluginReadModelTableName);
2099
- Platform_ComponentDefinitions_Lambda$ReventlessAws.make(platformApi, pluginReadModelTableName, offloadBucketName, {});
2099
+ Platform_ComponentDefinitions_Lambda$ReventlessAws.make(platformApi, pluginReadModelTableName, offloadBucketName, admin.adminSchemaPushed, {});
2100
2100
  }
2101
2101
  let rm = admin.stateViewSlicesOutputs["UiFragments"];
2102
2102
  if (rm !== undefined) {
@@ -24,10 +24,29 @@ export function response(ctx) {
24
24
  }
25
25
  `->Pulumi.Input.make
26
26
 
27
+ // `Platform_PluginStructures` — the same scan, unfiltered. The `complete` flag is
28
+ // the whole difference; see the handler's `toEntryWith`.
29
+ let completeResolverCode = `
30
+ import { util } from '@aws-appsync/utils';
31
+ export function request(ctx) {
32
+ return { operation: 'Invoke', payload: { complete: true } };
33
+ }
34
+ export function response(ctx) {
35
+ if (ctx.error) util.error(ctx.error.message, ctx.error.type);
36
+ return ctx.result;
37
+ }
38
+ `->Pulumi.Input.make
39
+
27
40
  let make = (
28
41
  ~api: Pulumi.Output.t<AppSync.GraphQLApi.t>,
29
42
  ~pluginReadModelTableName: Pulumi.Output.t<string>,
30
43
  ~offloadBucketName: Pulumi.Output.t<string>,
44
+ // Resolves once the admin schema push is ACTIVE (Platform_Admin.adminSchemaPushed).
45
+ // Only the `Platform_PluginStructures` resolver is gated on it: that field is new,
46
+ // so CreateResolver would otherwise race StartSchemaCreation on the deploy that
47
+ // first ships it. `Platform_ComponentDefinitions` predates the gate and is left
48
+ // ungated so this change adds no dependency edge to an already-deployed resource.
49
+ ~schemaReady: Pulumi.Output.t<unit>,
31
50
  ~opts: Pulumi.ComponentResource.options,
32
51
  ) => {
33
52
  let opts = opts->ReventlessCore.Util.Pulumi.ComponentResourceOptions.toCustomResourceOptions
@@ -215,4 +234,21 @@ let make = (
215
234
  ~code=resolverCode,
216
235
  ~opts,
217
236
  )
237
+
238
+ // Second field, same DataSource and same Lambda — the developer-tooling read of
239
+ // the identical scan. Sharing the data source is what keeps the two answers from
240
+ // drifting: there is one place that decides what a deployed plugin's structure is.
241
+ // Created only after the schema carrying `Platform_PluginStructures` is ACTIVE.
242
+ let _structuresResolver =
243
+ schemaReady->Pulumi.Output.apply(() =>
244
+ AppSync_Resolver_Native.makeUnitJsResolver(
245
+ ~name=name ++ "StructuresResolver",
246
+ ~api,
247
+ ~dataSourceName=dataSource.name->Pulumi.Output.asInput,
248
+ ~type_="Query"->Pulumi.Input.make,
249
+ ~field="Platform_PluginStructures"->Pulumi.Input.make,
250
+ ~code=completeResolverCode,
251
+ ~opts,
252
+ )
253
+ )
218
254
  }
@@ -27,7 +27,18 @@ export function response(ctx) {
27
27
  }
28
28
  `;
29
29
 
30
- function make(api, pluginReadModelTableName, offloadBucketName, opts) {
30
+ let completeResolverCode = `
31
+ import { util } from '@aws-appsync/utils';
32
+ export function request(ctx) {
33
+ return { operation: 'Invoke', payload: { complete: true } };
34
+ }
35
+ export function response(ctx) {
36
+ if (ctx.error) util.error(ctx.error.message, ctx.error.type);
37
+ return ctx.result;
38
+ }
39
+ `;
40
+
41
+ function make(api, pluginReadModelTableName, offloadBucketName, schemaReady, opts) {
31
42
  let opts$1 = Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions(opts);
32
43
  let name = "PlatformUIDefinitions";
33
44
  let lambdaRole = IAM$PulumiAws.Role.makeWithDefaultPolicy(name + "Lambda", Pulumi.output(AWS$ReventlessAws.Lambda.principal), AWS_Tags$ReventlessAws.make(name + "Lambda", "Platform", "Identity", "Platform", undefined, undefined, undefined, undefined), opts$1);
@@ -131,10 +142,12 @@ function make(api, pluginReadModelTableName, offloadBucketName, opts) {
131
142
  serviceRoleArn: dataSourceRole.arn
132
143
  }, opts$1);
133
144
  AppSync_Resolver_Native$ReventlessAws.makeUnitJsResolver(name + "Resolver", api, dataSource.name, "Query", "Platform_ComponentDefinitions", resolverCode, opts$1);
145
+ schemaReady.apply(() => AppSync_Resolver_Native$ReventlessAws.makeUnitJsResolver(name + "StructuresResolver", api, dataSource.name, "Query", "Platform_PluginStructures", completeResolverCode, opts$1));
134
146
  }
135
147
 
136
148
  export {
137
149
  resolverCode,
150
+ completeResolverCode,
138
151
  make,
139
152
  }
140
153
  /* @pulumi/aws Not a pure module */
@@ -43,11 +43,21 @@ let filterStructure = (structure: JSON.t): option<dict<JSON.t>> =>
43
43
  Some(out)
44
44
  }
45
45
 
46
- // `{pluginId: name, ...filterStructure(structure)}` — pluginId first so the
47
- // structure's fields win on the (never-expected) key collision, matching the JS
48
- // spread. Drops a row with no decodable structure (former `!item.structure`).
49
- let toEntry = (item: dict<JSON.t>, ~name: string): option<JSON.t> =>
50
- switch item->Dict.get("structure")->Option.flatMap(filterStructure) {
46
+ // `{pluginId: name, ...structure}` — pluginId first so the structure's fields win
47
+ // on the (never-expected) key collision, matching the JS spread. Drops a row with
48
+ // no decodable structure (former `!item.structure`).
49
+ //
50
+ // `~filter` is what separates the two fields this handler serves: AutoUI
51
+ // (`Platform_ComponentDefinitions`) gets the public-queryable subset, developer
52
+ // tooling (`Platform_PluginStructures`) gets the structure exactly as persisted,
53
+ // including Internal components and the producer-side `extensionPoints` that the
54
+ // AutoUI entry type does not declare.
55
+ let toEntryWith = (~filter: bool, item: dict<JSON.t>, ~name: string): option<JSON.t> => {
56
+ let structure =
57
+ item
58
+ ->Dict.get("structure")
59
+ ->Option.flatMap(s => filter ? filterStructure(s) : s->JSON.Decode.object)
60
+ switch structure {
51
61
  | None => None
52
62
  | Some(structureObj) =>
53
63
  let entry = Dict.make()
@@ -55,10 +65,17 @@ let toEntry = (item: dict<JSON.t>, ~name: string): option<JSON.t> =>
55
65
  structureObj->Dict.toArray->Array.forEach(((k, v)) => entry->Dict.set(k, v))
56
66
  Some(JSON.Encode.object(entry))
57
67
  }
68
+ }
69
+
58
70
 
59
71
  // The built-in Platform_Admin entry, injected at deploy time as the
60
72
  // ADMIN_ENTRY_JSON env var (the admin never Connects to itself, so its structure
61
73
  // never enters the Plugin read model).
74
+ //
75
+ // Shared by both fields. The admin structure has no Internal components and no
76
+ // extension points, so its filtered and complete encodings carry the same
77
+ // components; the structure-level fields the complete entry adds are absent from
78
+ // this JSON and resolve to null, which is what `extensionPoints: None` means.
62
79
  let adminEntry: option<JSON.t> =
63
80
  switch NodeProcess.env->Dict.get("ADMIN_ENTRY_JSON") {
64
81
  | Some(s) if s != "" => Some(JSON.parseOrThrow(s))
@@ -94,7 +111,21 @@ let resolveStructure = (
94
111
  }
95
112
  }
96
113
 
97
- let handler = async (_event: JSON.t): array<JSON.t> => {
114
+ // One Lambda serves both fields the scan, the offload resolution and the
115
+ // latest-version-per-plugin collapse are identical work, and duplicating them into
116
+ // a second function would mean a second cold start and a second chance for the two
117
+ // to disagree about what "the deployed structure" is. The resolver for
118
+ // `Platform_PluginStructures` invokes with `{complete: true}`.
119
+ let isComplete = (event: JSON.t): bool =>
120
+ event
121
+ ->JSON.Decode.object
122
+ ->Option.flatMap(o => o->Dict.get("complete"))
123
+ ->Option.flatMap(JSON.Decode.bool)
124
+ ->Option.getOr(false)
125
+
126
+ let handler = async (event: JSON.t): array<JSON.t> => {
127
+ let complete = isComplete(event)
128
+ let toEntry = (item, ~name) => toEntryWith(~filter=!complete, item, ~name)
98
129
  let admin = adminEntry->Option.mapOr([], e => [e])
99
130
  switch NodeProcess.env->Dict.get("PLUGIN_RM_TABLE") {
100
131
  | None | Some("") =>
@@ -31,14 +31,20 @@ function filterStructure(structure) {
31
31
  return out;
32
32
  }
33
33
 
34
- function toEntry(item, name) {
35
- let structureObj = Stdlib_Option.flatMap(item["structure"], filterStructure);
36
- if (structureObj === undefined) {
34
+ function toEntryWith(filter, item, name) {
35
+ let structure = Stdlib_Option.flatMap(item["structure"], s => {
36
+ if (filter) {
37
+ return filterStructure(s);
38
+ } else {
39
+ return Stdlib_JSON.Decode.object(s);
40
+ }
41
+ });
42
+ if (structure === undefined) {
37
43
  return;
38
44
  }
39
45
  let entry = {};
40
46
  entry["pluginId"] = name;
41
- Object.entries(structureObj).forEach(param => {
47
+ Object.entries(structure).forEach(param => {
42
48
  entry[param[0]] = param[1];
43
49
  });
44
50
  return entry;
@@ -65,7 +71,13 @@ function resolveStructure(fetch, item) {
65
71
  }
66
72
  }
67
73
 
68
- async function handler(_event) {
74
+ function isComplete(event) {
75
+ return Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(event), o => o["complete"]), Stdlib_JSON.Decode.bool), false);
76
+ }
77
+
78
+ async function handler(event) {
79
+ let complete = isComplete(event);
80
+ let toEntry = (item, name) => toEntryWith(!complete, item, name);
69
81
  let admin = Stdlib_Option.mapOr(adminEntry, [], e => [e]);
70
82
  let table = process.env["PLUGIN_RM_TABLE"];
71
83
  if (table !== undefined) {
@@ -94,9 +106,10 @@ export {
94
106
  str,
95
107
  isPublicQueryable,
96
108
  filterStructure,
97
- toEntry,
109
+ toEntryWith,
98
110
  adminEntry,
99
111
  resolveStructure,
112
+ isComplete,
100
113
  handler,
101
114
  }
102
115
  /* s Not a pure module */
@@ -12,7 +12,9 @@
12
12
  //
13
13
  // Channel: /default/{pathSegment(topicName)}/{pathSegment(entityKey)}
14
14
  // Descriptor: { changeKind: "Updated" | "Removed", id: <entityKey>,
15
- // sortKeyValue?: <updatedAt | createdAt if present> }
15
+ // sortKeyValue?: <updatedAt | createdAt if present>,
16
+ // seq: <monotonic ordering token>,
17
+ // state?: <the full new row, saves only> }
16
18
  //
17
19
  // changeKind is fixed per operation (save→"Updated", delete→"Removed"): a
18
20
  // Postgres upsert doesn't distinguish insert vs update, and the Sqlite/InMemory
@@ -64,8 +66,8 @@ function pathSegment(value) {
64
66
  }
65
67
 
66
68
  // Pick the natural sort timestamp from the saved state (updatedAt preferred,
67
- // createdAt fallback). Mirrors StateTopic_AppSync.pickSortKeyValue and
68
- // LocalBus.pickSortKeyValue.
69
+ // createdAt fallback). Mirrors StateTopic_AppSync_Ops.pickSortKeyValue and
70
+ // LocalStateChangeDescriptor.pickSortKeyValue.
69
71
  function pickSortKeyValue(state) {
70
72
  if (state && typeof state === "object" && !Array.isArray(state)) {
71
73
  if (typeof state.updatedAt === "string") return state.updatedAt;
@@ -74,21 +76,62 @@ function pickSortKeyValue(state) {
74
76
  return undefined;
75
77
  }
76
78
 
79
+ // Cap on the serialised state payload, in characters. Must match
80
+ // LocalStateChangeDescriptor.maxStateChars and StateTopic_AppSync_Ops.maxStateChars
81
+ // — see the local module for the reasoning.
82
+ const MAX_STATE_CHARS = 60 * 1024;
83
+
84
+ // Monotonic ordering token, seeded from the wall clock so it keeps rising across
85
+ // container restarts. Per Lambda instance: two instances writing the same entity
86
+ // within one millisecond have no defined order. This is an ordering hint, not a
87
+ // lock, and it is sparse — the client rule is "greater than what I hold", never
88
+ // "exactly one more". See docs/analysis/live-update-descriptor-sequencing.md.
89
+ let lastSequence = 0;
90
+ export function nextSequence() {
91
+ const now = Date.now();
92
+ lastSequence = now > lastSequence ? now : lastSequence + 1;
93
+ return String(lastSequence);
94
+ }
95
+
96
+ // Assemble the descriptor. `state` is the full new row for a save, undefined for a
97
+ // delete — which carries neither `state` nor `sortKeyValue`, matching the other two
98
+ // implementations. Over the size cap the state is dropped and the downgrade logged:
99
+ // a metadata-only descriptor still tells the client to refetch, where a publish
100
+ // rejected for size would tell it nothing.
101
+ export function makeDescriptor({ changeKind, entityKey, state, seq }) {
102
+ const descriptor = { changeKind, id: entityKey };
103
+ if (state !== undefined) {
104
+ const sortKeyValue = pickSortKeyValue(state);
105
+ if (sortKeyValue !== undefined) descriptor.sortKeyValue = sortKeyValue;
106
+ }
107
+ descriptor.seq = seq;
108
+ if (state !== undefined) {
109
+ const encoded = JSON.stringify(state);
110
+ if (encoded.length <= MAX_STATE_CHARS) {
111
+ descriptor.state = state;
112
+ } else {
113
+ console.warn(
114
+ "STATE_PAYLOAD_DOWNGRADED id=" + entityKey + " chars=" + encoded.length,
115
+ );
116
+ }
117
+ }
118
+ return descriptor;
119
+ }
120
+
77
121
  // Build the descriptor + channel and POST to {endpoint}/event.
78
122
  // - endpoint: base AppSync Events HTTP endpoint (no trailing /event).
79
123
  // - region: AWS region for the SigV4 credential scope.
80
124
  // - topicName: the plugin-prefixed query LIST field name (channel root).
81
125
  // - entityKey: ORIGINAL entity key (id or `id-subKey`) — descriptor body + path.
82
126
  // - changeKind: "Updated" (save) | "Removed" (delete).
83
- // - sortKeyValue: optional; included when defined.
127
+ // - state: the full new row for a save; omitted for a delete.
84
128
  // - dedupeId: AppSync publish `id` (idempotency hint).
85
- export async function publishStateChange({ endpoint, region, topicName, entityKey, changeKind, sortKeyValue, dedupeId }) {
129
+ export async function publishStateChange({ endpoint, region, topicName, entityKey, changeKind, state, dedupeId }) {
86
130
  if (!endpoint || !topicName) return; // not live-enabled — no-op
87
131
  try {
88
132
  const url = new URL(endpoint);
89
133
  const channel = "/default/" + pathSegment(topicName) + "/" + pathSegment(entityKey);
90
- const descriptor = { changeKind, id: entityKey };
91
- if (sortKeyValue !== undefined) descriptor.sortKeyValue = sortKeyValue;
134
+ const descriptor = makeDescriptor({ changeKind, entityKey, state, seq: nextSequence() });
92
135
  const body = JSON.stringify({
93
136
  id: dedupeId || (topicName + ":" + entityKey + ":" + changeKind),
94
137
  channel,
@@ -154,7 +197,7 @@ export function withLiveUpdates(ops, liveConfig) {
154
197
  endpoint, region, topicName,
155
198
  entityKey: entityKeyFor(id, state, subIdField),
156
199
  changeKind: "Updated",
157
- sortKeyValue: pickSortKeyValue(state),
200
+ state,
158
201
  });
159
202
 
160
203
  const publishRemovedKey = async (entityKey) =>
@@ -6,9 +6,16 @@
6
6
  //
7
7
  // Triggered by DynamoDB streams (one shared Lambda; routing is per-record via
8
8
  // STATE_TOPIC_MAP). For each changed row it derives the entity channel and a
9
- // `{changeKind, id, sortKeyValue?}` descriptor and publishes it. 4xx failures
10
- // are logged and skipped; 5xx / network failures are recorded and rethrown after
11
- // the batch so the EventSourceMapping retries (bisectBatchOnFunctionError).
9
+ // `{changeKind, id, sortKeyValue?, seq, state?}` descriptor and publishes it. 4xx
10
+ // failures are logged and skipped; 5xx / network failures are recorded and
11
+ // rethrown after the batch so the EventSourceMapping retries
12
+ // (bisectBatchOnFunctionError).
13
+ //
14
+ // The descriptor is one of three implementations of a shared wire format (the
15
+ // others: `LocalStateChangeDescriptor` in reventless-local, `StateTopicPublish.mjs`
16
+ // for Postgres read models). They share no code — this module stays Pulumi-free so
17
+ // a core import can't drag deploy-time code into the Lambda's import graph — so
18
+ // `StateChangeDescriptorParityTest` drives all three and asserts they agree.
12
19
 
13
20
 
14
21
  let endpoint = NodeProcess.env->Dict.get("APPSYNC_ENDPOINT")->Option.getOr("")
@@ -31,6 +38,10 @@ type streamRecord = {
31
38
  @as("Keys") keys: dict<attributeValue>,
32
39
  @as("NewImage") newImage?: dict<attributeValue>,
33
40
  @as("OldImage") oldImage?: dict<attributeValue>,
41
+ // Monotonic within the entity's shard lineage — records for one partition key
42
+ // always land on the same shard — so it orders an entity's changes without
43
+ // anything being written to the table. Sparse, so it detects staleness, not gaps.
44
+ @as("SequenceNumber") sequenceNumber?: string,
34
45
  }
35
46
  type record = {
36
47
  eventID: string,
@@ -103,6 +114,52 @@ let pickSortKeyValue = (image: dict<JSON.t>): option<string> =>
103
114
  | None => image->Dict.get("createdAt")->Option.flatMap(JSON.Decode.string)
104
115
  }
105
116
 
117
+ /** Cap on the serialised state payload, in characters. Must match
118
+ `LocalStateChangeDescriptor.maxStateChars` and `StateTopicPublish.mjs`'s
119
+ MAX_STATE_CHARS — see the local module for the reasoning. */
120
+ let maxStateChars = 60 * 1024
121
+
122
+ /** Build the change descriptor. Split out of `processRecord` so the wire format
123
+ can be asserted against the other two implementations without a network call.
124
+
125
+ `image` is the unmarshalled NewImage for a save, the OldImage for a REMOVE.
126
+ A REMOVE carries neither `state` (there is no new row) nor `sortKeyValue`
127
+ (a sort position for a deleted row has no consumer, and the other two
128
+ implementations cannot produce one). */
129
+ let makeDescriptor = (
130
+ ~changeKind: string,
131
+ ~entityKey: string,
132
+ ~image: dict<JSON.t>,
133
+ ~seq: option<string>,
134
+ ): JSON.t => {
135
+ let removed = changeKind == "Removed"
136
+ let descriptor = Dict.make()
137
+ descriptor->Dict.set("changeKind", JSON.Encode.string(changeKind))
138
+ descriptor->Dict.set("id", JSON.Encode.string(entityKey))
139
+ if !removed {
140
+ pickSortKeyValue(image)->Option.forEach(v =>
141
+ descriptor->Dict.set("sortKeyValue", JSON.Encode.string(v))
142
+ )
143
+ }
144
+ seq->Option.forEach(s => descriptor->Dict.set("seq", JSON.Encode.string(s)))
145
+ if !removed {
146
+ let state = image->JSON.Encode.object
147
+ let encoded = state->JSON.stringify
148
+ if encoded->String.length <= maxStateChars {
149
+ descriptor->Dict.set("state", state)
150
+ } else {
151
+ // Metadata-only still tells the client to refetch; a publish rejected for
152
+ // size would tell it nothing at all.
153
+ Console.warn(
154
+ `STATE_PAYLOAD_DOWNGRADED id=${entityKey} chars=${encoded
155
+ ->String.length
156
+ ->Int.toString}`,
157
+ )
158
+ }
159
+ }
160
+ descriptor->JSON.Encode.object
161
+ }
162
+
106
163
  // Publish one record; returns Some(errorMessage) on a transient (5xx / network)
107
164
  // failure so the caller can rethrow after the batch, None otherwise.
108
165
  let processRecord = async (
@@ -129,20 +186,17 @@ let processRecord = async (
129
186
  let entityKey = entityKeyFromRecord(dynamodb)
130
187
  let channel = `/default/${topicRoot}/${AppSyncEventsSigner_Ops.pathSegment(entityKey)}`
131
188
  let unmarshalled: dict<JSON.t> = AwsSdk.DynamoDb_Util_Helpers.unmarshallDict(image)
132
- let descriptor = Dict.make()
133
- descriptor->Dict.set("changeKind", JSON.Encode.string(changeKindFor(record.eventName)))
134
- descriptor->Dict.set("id", JSON.Encode.string(entityKey))
135
- pickSortKeyValue(unmarshalled)->Option.forEach(v =>
136
- descriptor->Dict.set("sortKeyValue", JSON.Encode.string(v))
189
+ let descriptor = makeDescriptor(
190
+ ~changeKind=changeKindFor(record.eventName),
191
+ ~entityKey,
192
+ ~image=unmarshalled,
193
+ ~seq=dynamodb.sequenceNumber,
137
194
  )
138
195
  let body =
139
196
  Dict.fromArray([
140
197
  ("id", JSON.Encode.string(record.eventID)),
141
198
  ("channel", JSON.Encode.string(channel)),
142
- (
143
- "events",
144
- JSON.Encode.array([JSON.Encode.string(descriptor->JSON.Encode.object->JSON.stringify)]),
145
- ),
199
+ ("events", JSON.Encode.array([JSON.Encode.string(descriptor->JSON.stringify)])),
146
200
  ])
147
201
  ->JSON.Encode.object
148
202
  ->JSON.stringify
@@ -89,6 +89,30 @@ function pickSortKeyValue(image) {
89
89
  }
90
90
  }
91
91
 
92
+ function makeDescriptor(changeKind, entityKey, image, seq) {
93
+ let removed = changeKind === "Removed";
94
+ let descriptor = {};
95
+ descriptor["changeKind"] = changeKind;
96
+ descriptor["id"] = entityKey;
97
+ if (!removed) {
98
+ Stdlib_Option.forEach(pickSortKeyValue(image), v => {
99
+ descriptor["sortKeyValue"] = v;
100
+ });
101
+ }
102
+ Stdlib_Option.forEach(seq, s => {
103
+ descriptor["seq"] = s;
104
+ });
105
+ if (!removed) {
106
+ let encoded = JSON.stringify(image);
107
+ if (encoded.length <= 61440) {
108
+ descriptor["state"] = image;
109
+ } else {
110
+ console.warn(`STATE_PAYLOAD_DOWNGRADED id=` + entityKey + ` chars=` + encoded.length.toString());
111
+ }
112
+ }
113
+ return descriptor;
114
+ }
115
+
92
116
  async function processRecord(record, region, creds) {
93
117
  let topicRoot = topicRootFromEventSourceArn(record.eventSourceARN);
94
118
  if (topicRoot !== undefined) {
@@ -103,12 +127,7 @@ async function processRecord(record, region, creds) {
103
127
  let entityKey = entityKeyFromRecord(dynamodb);
104
128
  let channel = `/default/` + topicRoot + `/` + AppSyncEventsSigner_Ops$ReventlessAws.pathSegment(entityKey);
105
129
  let unmarshalled = DynamoDb_Util_Helpers$AwsSdk.unmarshallDict(undefined, image);
106
- let descriptor = {};
107
- descriptor["changeKind"] = changeKindFor(record.eventName);
108
- descriptor["id"] = entityKey;
109
- Stdlib_Option.forEach(pickSortKeyValue(unmarshalled), v => {
110
- descriptor["sortKeyValue"] = v;
111
- });
130
+ let descriptor = makeDescriptor(changeKindFor(record.eventName), entityKey, unmarshalled, dynamodb.SequenceNumber);
112
131
  let body = JSON.stringify(Object.fromEntries([
113
132
  [
114
133
  "id",
@@ -169,6 +188,8 @@ async function handler(event) {
169
188
  }
170
189
  }
171
190
 
191
+ let maxStateChars = 61440;
192
+
172
193
  export {
173
194
  endpoint,
174
195
  topicMap,
@@ -177,6 +198,8 @@ export {
177
198
  entityKeyFromRecord,
178
199
  changeKindFor,
179
200
  pickSortKeyValue,
201
+ maxStateChars,
202
+ makeDescriptor,
180
203
  processRecord,
181
204
  handler,
182
205
  }
@@ -138,7 +138,7 @@ machinery: a stack that predates a change to this function empties its stores
138
138
  (`seed:reset`, which wipes per plugin) and re-seeds. Carrying a permanent prefix
139
139
  set to spare a disposable stack one wipe is the worse trade.
140
140
  */
141
- let keyPrefixFor = (~plugin: string, ~store: string): string => `${plugin}/${store}`
141
+ let keyPrefixFor = ReventlessCore.StoreLayout.keyPrefixFor
142
142
 
143
143
  /**
144
144
  Who serves the declared stores — and the answer is never "both".
@@ -3,6 +3,7 @@
3
3
  import * as Stdlib_Int from "@rescript/runtime/lib/es6/Stdlib_Int.js";
4
4
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
5
5
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
+ import * as StoreLayout$ReventlessCore from "@reventlessdev/reventless-core/src/util/StoreLayout.res.mjs";
6
7
 
7
8
  let defaultEphemeralPrefixes = ["pr-"];
8
9
 
@@ -31,10 +32,6 @@ function bucketNameFor(layout, stack, plugin, store) {
31
32
  }
32
33
  }
33
34
 
34
- function keyPrefixFor(plugin, store) {
35
- return plugin + `/` + store;
36
- }
37
-
38
35
  function servingFor(hasHostUiBundle, declaredBucketCount) {
39
36
  if (declaredBucketCount !== 0) {
40
37
  if (hasHostUiBundle) {
@@ -81,6 +78,8 @@ function pendingExpiryFor(config, store) {
81
78
  })[0];
82
79
  }
83
80
 
81
+ let keyPrefixFor = StoreLayout$ReventlessCore.keyPrefixFor;
82
+
84
83
  export {
85
84
  defaultEphemeralPrefixes,
86
85
  layoutFor,
@@ -0,0 +1,58 @@
1
+ // Parity of the live-update change descriptor across its three implementations:
2
+ //
3
+ // - LocalStateChangeDescriptor (reventless-local, both backends)
4
+ // - StateTopic_AppSync_Ops (DynamoDB stream relay)
5
+ // - StateTopicPublish.mjs (Postgres projection-side publisher)
6
+ //
7
+ // They share no code on purpose — the relay module stays Pulumi-free so a core
8
+ // import can't pull deploy-time code into its Lambda graph — which makes drift
9
+ // the standing risk: three sites, one wire format, and a browser that cannot tell
10
+ // which one produced a frame. This test is the guard.
11
+ //
12
+ // `seq` is normalised away by the harness because the three take it from different
13
+ // monotonic sources by design (a DynamoDB stream SequenceNumber vs a wall-clock
14
+ // counter); the harness still asserts each produced a non-empty string.
15
+
16
+ open JestGlobals
17
+
18
+ type case = {
19
+ name: string,
20
+ local: JSON.t,
21
+ relay: JSON.t,
22
+ postgres: JSON.t,
23
+ }
24
+
25
+ @module("./stateChangeDescriptorParity.mjs")
26
+ external buildAll: unit => promise<array<case>> = "buildAll"
27
+
28
+ describe("state-change descriptor parity", () => {
29
+ testAsync("every implementation builds the same descriptor", async () => {
30
+ let cases = await buildAll()
31
+ // A silent zero-case run would pass while asserting nothing.
32
+ expect(cases->Array.length)->toBe(6)
33
+ cases->Array.forEach(c => {
34
+ expect((c.name, c.relay))->toEqual((c.name, c.local))
35
+ expect((c.name, c.postgres))->toEqual((c.name, c.local))
36
+ })
37
+ })
38
+
39
+ testAsync("a save carries the row, a delete carries none", async () => {
40
+ let cases = await buildAll()
41
+ let stateOf = (d: JSON.t) =>
42
+ d->JSON.Decode.object->Option.flatMap(o => o->Dict.get("state"))
43
+ let byName = name => cases->Array.find(c => c.name == name)
44
+
45
+ switch byName("single-key save with updatedAt") {
46
+ | Some(c) => expect(stateOf(c.local)->Option.isSome)->toBe(true)
47
+ | None => expect("save case missing")->toBe("present")
48
+ }
49
+ switch byName("delete carries no row") {
50
+ | Some(c) => expect(stateOf(c.local))->toEqual(None)
51
+ | None => expect("delete case missing")->toBe("present")
52
+ }
53
+ switch byName("oversized row degrades to metadata only") {
54
+ | Some(c) => expect(stateOf(c.local))->toEqual(None)
55
+ | None => expect("oversized case missing")->toBe("present")
56
+ }
57
+ })
58
+ })
@@ -0,0 +1,60 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
+ import * as StateChangeDescriptorParityMjs from "./stateChangeDescriptorParity.mjs";
6
+
7
+ function buildAll(prim) {
8
+ return StateChangeDescriptorParityMjs.buildAll();
9
+ }
10
+
11
+ globalThis.describe("state-change descriptor parity", () => {
12
+ globalThis.test("every implementation builds the same descriptor", async () => {
13
+ let cases = await StateChangeDescriptorParityMjs.buildAll();
14
+ globalThis.expect(cases.length).toBe(6);
15
+ cases.forEach(c => {
16
+ globalThis.expect([
17
+ c.name,
18
+ c.relay
19
+ ]).toEqual([
20
+ c.name,
21
+ c.local
22
+ ]);
23
+ globalThis.expect([
24
+ c.name,
25
+ c.postgres
26
+ ]).toEqual([
27
+ c.name,
28
+ c.local
29
+ ]);
30
+ });
31
+ });
32
+ globalThis.test("a save carries the row, a delete carries none", async () => {
33
+ let cases = await StateChangeDescriptorParityMjs.buildAll();
34
+ let stateOf = d => Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(d), o => o["state"]);
35
+ let byName = name => cases.find(c => c.name === name);
36
+ let c = byName("single-key save with updatedAt");
37
+ if (c !== undefined) {
38
+ globalThis.expect(Stdlib_Option.isSome(stateOf(c.local))).toBe(true);
39
+ } else {
40
+ globalThis.expect("save case missing").toBe("present");
41
+ }
42
+ let c$1 = byName("delete carries no row");
43
+ if (c$1 !== undefined) {
44
+ globalThis.expect(stateOf(c$1.local)).toEqual(undefined);
45
+ } else {
46
+ globalThis.expect("delete case missing").toBe("present");
47
+ }
48
+ let c$2 = byName("oversized row degrades to metadata only");
49
+ if (c$2 !== undefined) {
50
+ globalThis.expect(stateOf(c$2.local)).toEqual(undefined);
51
+ } else {
52
+ globalThis.expect("oversized case missing").toBe("present");
53
+ }
54
+ });
55
+ });
56
+
57
+ export {
58
+ buildAll,
59
+ }
60
+ /* Not a pure module */
@@ -17,9 +17,14 @@ open JestGlobals
17
17
  let run: string => promise<JSON.t> = %raw(`
18
18
  async function(scenario) {
19
19
  const mod = await import("../src/adapter/Runtime/StateTopicPublish.mjs");
20
- const { withLiveUpdates } = mod;
20
+ const { withLiveUpdates, makeDescriptor } = mod;
21
21
  const captured = [];
22
- const publish = async (call) => { captured.push(call); };
22
+ // Record the descriptor the real publisher would build alongside the call, with
23
+ // a fixed seq so assertions stay deterministic. sortKeyValue/state derivation
24
+ // lives in makeDescriptor, so this keeps it under test at the wrapper level.
25
+ const publish = async (call) => {
26
+ captured.push({ ...call, descriptor: makeDescriptor({ ...call, seq: "1" }) });
27
+ };
23
28
  const ok = async () => ({ TAG: "Ok", _0: undefined });
24
29
 
25
30
  // Mock ops. load returns the composite partition's rows for the enumerate case.
@@ -53,6 +58,9 @@ async function(scenario) {
53
58
  case "save-no-sortkey":
54
59
  await w(undefined).save("p1", { id: "p1" }, "Insert", undefined);
55
60
  break;
61
+ case "save-oversized":
62
+ await w(undefined).save("p1", { id: "p1", blob: "x".repeat(70 * 1024) }, "Insert", undefined);
63
+ break;
56
64
  case "saveBatch":
57
65
  await w(undefined).saveBatch([
58
66
  ["a", { id: "a", updatedAt: "t1" }, undefined],
@@ -86,6 +94,11 @@ let getField = (call: JSON.t, key: string): option<JSON.t> =>
86
94
 
87
95
  let str = (call, key) => getField(call, key)->Option.flatMap(JSON.Decode.string)
88
96
 
97
+ let descriptorField = (call: JSON.t, key: string): option<JSON.t> =>
98
+ getField(call, "descriptor")->Option.flatMap(d => getField(d, key))
99
+
100
+ let descriptorStr = (call, key) => descriptorField(call, key)->Option.flatMap(JSON.Decode.string)
101
+
89
102
  let calls = (j: JSON.t): array<JSON.t> => j->JSON.Decode.array->Option.getOr([])
90
103
 
91
104
  describe("withLiveUpdates (B3.3a Postgres live updates)", () => {
@@ -96,8 +109,11 @@ describe("withLiveUpdates (B3.3a Postgres live updates)", () => {
96
109
  let call = arr->Array.getUnsafe(0)
97
110
  expect(str(call, "entityKey"))->toEqual(Some("p1"))
98
111
  expect(str(call, "changeKind"))->toEqual(Some("Updated"))
99
- expect(str(call, "sortKeyValue"))->toEqual(Some("2024-01-02"))
112
+ expect(descriptorStr(call, "sortKeyValue"))->toEqual(Some("2024-01-02"))
100
113
  expect(str(call, "topicName"))->toEqual(Some("Shop_Orders"))
114
+ // The saved row rides along so a subscriber can apply it without refetching.
115
+ expect(descriptorField(call, "state"))->toEqual(getField(call, "state"))
116
+ expect(descriptorStr(call, "seq"))->toEqual(Some("1"))
101
117
  })
102
118
 
103
119
  testAsync("save on a composite table uses id-subKey and createdAt fallback", async () => {
@@ -105,13 +121,21 @@ describe("withLiveUpdates (B3.3a Postgres live updates)", () => {
105
121
  let call = calls(c)->Array.getUnsafe(0)
106
122
  expect(str(call, "entityKey"))->toEqual(Some("o1-L2"))
107
123
  expect(str(call, "changeKind"))->toEqual(Some("Updated"))
108
- expect(str(call, "sortKeyValue"))->toEqual(Some("2024-03-04"))
124
+ expect(descriptorStr(call, "sortKeyValue"))->toEqual(Some("2024-03-04"))
109
125
  })
110
126
 
111
127
  testAsync("save without updatedAt/createdAt omits sortKeyValue", async () => {
112
128
  let c = await run("save-no-sortkey")
113
129
  let call = calls(c)->Array.getUnsafe(0)
114
- expect(getField(call, "sortKeyValue"))->toEqual(None)
130
+ expect(descriptorField(call, "sortKeyValue"))->toEqual(None)
131
+ })
132
+
133
+ testAsync("an oversized row degrades to a metadata-only descriptor", async () => {
134
+ let c = await run("save-oversized")
135
+ let call = calls(c)->Array.getUnsafe(0)
136
+ expect(descriptorField(call, "state"))->toEqual(None)
137
+ expect(descriptorStr(call, "changeKind"))->toEqual(Some("Updated"))
138
+ expect(descriptorStr(call, "seq"))->toEqual(Some("1"))
115
139
  })
116
140
 
117
141
  testAsync("saveBatch publishes one Updated per item", async () => {
@@ -5,9 +5,14 @@ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
5
 
6
6
  let run = (async function(scenario) {
7
7
  const mod = await import("../src/adapter/Runtime/StateTopicPublish.mjs");
8
- const { withLiveUpdates } = mod;
8
+ const { withLiveUpdates, makeDescriptor } = mod;
9
9
  const captured = [];
10
- const publish = async (call) => { captured.push(call); };
10
+ // Record the descriptor the real publisher would build alongside the call, with
11
+ // a fixed seq so assertions stay deterministic. sortKeyValue/state derivation
12
+ // lives in makeDescriptor, so this keeps it under test at the wrapper level.
13
+ const publish = async (call) => {
14
+ captured.push({ ...call, descriptor: makeDescriptor({ ...call, seq: "1" }) });
15
+ };
11
16
  const ok = async () => ({ TAG: "Ok", _0: undefined });
12
17
 
13
18
  // Mock ops. load returns the composite partition's rows for the enumerate case.
@@ -41,6 +46,9 @@ let run = (async function(scenario) {
41
46
  case "save-no-sortkey":
42
47
  await w(undefined).save("p1", { id: "p1" }, "Insert", undefined);
43
48
  break;
49
+ case "save-oversized":
50
+ await w(undefined).save("p1", { id: "p1", blob: "x".repeat(70 * 1024) }, "Insert", undefined);
51
+ break;
44
52
  case "saveBatch":
45
53
  await w(undefined).saveBatch([
46
54
  ["a", { id: "a", updatedAt: "t1" }, undefined],
@@ -76,6 +84,14 @@ function str(call, key) {
76
84
  return Stdlib_Option.flatMap(getField(call, key), Stdlib_JSON.Decode.string);
77
85
  }
78
86
 
87
+ function descriptorField(call, key) {
88
+ return Stdlib_Option.flatMap(getField(call, "descriptor"), d => getField(d, key));
89
+ }
90
+
91
+ function descriptorStr(call, key) {
92
+ return Stdlib_Option.flatMap(descriptorField(call, key), Stdlib_JSON.Decode.string);
93
+ }
94
+
79
95
  function calls(j) {
80
96
  return Stdlib_Option.getOr(Stdlib_JSON.Decode.array(j), []);
81
97
  }
@@ -88,20 +104,29 @@ globalThis.describe("withLiveUpdates (B3.3a Postgres live updates)", () => {
88
104
  let call = arr[0];
89
105
  globalThis.expect(str(call, "entityKey")).toEqual("p1");
90
106
  globalThis.expect(str(call, "changeKind")).toEqual("Updated");
91
- globalThis.expect(str(call, "sortKeyValue")).toEqual("2024-01-02");
107
+ globalThis.expect(descriptorStr(call, "sortKeyValue")).toEqual("2024-01-02");
92
108
  globalThis.expect(str(call, "topicName")).toEqual("Shop_Orders");
109
+ globalThis.expect(descriptorField(call, "state")).toEqual(getField(call, "state"));
110
+ globalThis.expect(descriptorStr(call, "seq")).toEqual("1");
93
111
  });
94
112
  globalThis.test("save on a composite table uses id-subKey and createdAt fallback", async () => {
95
113
  let c = await run("save-composite");
96
114
  let call = calls(c)[0];
97
115
  globalThis.expect(str(call, "entityKey")).toEqual("o1-L2");
98
116
  globalThis.expect(str(call, "changeKind")).toEqual("Updated");
99
- globalThis.expect(str(call, "sortKeyValue")).toEqual("2024-03-04");
117
+ globalThis.expect(descriptorStr(call, "sortKeyValue")).toEqual("2024-03-04");
100
118
  });
101
119
  globalThis.test("save without updatedAt/createdAt omits sortKeyValue", async () => {
102
120
  let c = await run("save-no-sortkey");
103
121
  let call = calls(c)[0];
104
- globalThis.expect(getField(call, "sortKeyValue")).toEqual(undefined);
122
+ globalThis.expect(descriptorField(call, "sortKeyValue")).toEqual(undefined);
123
+ });
124
+ globalThis.test("an oversized row degrades to a metadata-only descriptor", async () => {
125
+ let c = await run("save-oversized");
126
+ let call = calls(c)[0];
127
+ globalThis.expect(descriptorField(call, "state")).toEqual(undefined);
128
+ globalThis.expect(descriptorStr(call, "changeKind")).toEqual("Updated");
129
+ globalThis.expect(descriptorStr(call, "seq")).toEqual("1");
105
130
  });
106
131
  globalThis.test("saveBatch publishes one Updated per item", async () => {
107
132
  let c = await run("saveBatch");
@@ -153,6 +178,8 @@ export {
153
178
  run,
154
179
  getField,
155
180
  str,
181
+ descriptorField,
182
+ descriptorStr,
156
183
  calls,
157
184
  }
158
185
  /* run Not a pure module */
@@ -0,0 +1,102 @@
1
+ // Harness for StateChangeDescriptorParityTest.
2
+ //
3
+ // The live-update change descriptor has three independent implementations and no
4
+ // shared code (the DynamoDB relay's module is deliberately Pulumi-free, so it
5
+ // cannot import a core builder without risking deploy-time code in its Lambda
6
+ // graph). This harness drives all three through the same logical changes and
7
+ // hands the results back for comparison.
8
+ //
9
+ // Plain ESM rather than ReScript because two of the three modules live outside
10
+ // this package and one is hand-written JS; the assertions stay in the .res test.
11
+
12
+ const LOCAL = "../../local/src/adapter/LocalStateChangeDescriptor.res.mjs";
13
+ const RELAY = "../src/adapter/StateTopic/StateTopic_AppSync_Ops.res.mjs";
14
+ const POSTGRES = "../src/adapter/Runtime/StateTopicPublish.mjs";
15
+
16
+ // Each case is one logical change expressed three ways. `changeKind` is already
17
+ // normalised: the three sites derive it differently (local compares against the
18
+ // stored row, the relay maps the stream eventName, Postgres has only upserts), and
19
+ // that derivation is covered by their own tests — what is asserted here is that
20
+ // the same logical change produces the same descriptor.
21
+ const CASES = [
22
+ {
23
+ name: "single-key save with updatedAt",
24
+ changeKind: "Updated",
25
+ entityKey: "p1",
26
+ state: { id: "p1", name: "Widget", updatedAt: "2026-05-19T12:00:00Z" },
27
+ },
28
+ {
29
+ name: "composite-key save falling back to createdAt",
30
+ changeKind: "Updated",
31
+ entityKey: "o1-L2",
32
+ state: { id: "o1", lineId: "L2", createdAt: "2026-03-04T08:00:00Z" },
33
+ },
34
+ {
35
+ name: "save with no sort timestamp",
36
+ changeKind: "Updated",
37
+ entityKey: "p1",
38
+ state: { id: "p1", name: "Widget" },
39
+ },
40
+ {
41
+ name: "first save of a key",
42
+ changeKind: "Added",
43
+ entityKey: "p2",
44
+ state: { id: "p2", name: "Gadget", updatedAt: "2026-05-19T12:00:00Z" },
45
+ },
46
+ {
47
+ name: "oversized row degrades to metadata only",
48
+ changeKind: "Updated",
49
+ entityKey: "p3",
50
+ state: { id: "p3", blob: "x".repeat(70 * 1024), updatedAt: "2026-05-19T12:00:00Z" },
51
+ },
52
+ {
53
+ name: "delete carries no row",
54
+ changeKind: "Removed",
55
+ entityKey: "p1",
56
+ state: undefined,
57
+ },
58
+ ];
59
+
60
+ /**
61
+ * Build every case with every implementation.
62
+ *
63
+ * Returns `[{ name, local, relay, postgres }]`, each descriptor a plain object
64
+ * with `seq` replaced by the literal "<seq>" — the three sites take their
65
+ * sequence from different monotonic sources by design (stream SequenceNumber vs
66
+ * a wall-clock counter), so the values differ while the field's presence and
67
+ * type must not.
68
+ */
69
+ export async function buildAll() {
70
+ const local = await import(LOCAL);
71
+ const relay = await import(RELAY);
72
+ const postgres = await import(POSTGRES);
73
+
74
+ const normalise = (descriptor) => {
75
+ const seq = descriptor.seq;
76
+ if (typeof seq !== "string" || seq.length === 0) {
77
+ throw new Error("descriptor is missing a string seq: " + JSON.stringify(descriptor));
78
+ }
79
+ return { ...descriptor, seq: "<seq>" };
80
+ };
81
+
82
+ return CASES.map(({ name, changeKind, entityKey, state }) => ({
83
+ name,
84
+ local: normalise(
85
+ local.make(changeKind, entityKey, state === undefined ? undefined : state, local.nextSequence()),
86
+ ),
87
+ relay: normalise(
88
+ relay.makeDescriptor(
89
+ changeKind,
90
+ entityKey,
91
+ // The relay unmarshalls the stream image before building the descriptor,
92
+ // so it takes the same plain row the other two do. A REMOVE is handed the
93
+ // OldImage, which makeDescriptor then drops.
94
+ state === undefined ? { id: entityKey } : state,
95
+ "49590300000000016818000000",
96
+ ),
97
+ ),
98
+ postgres: normalise(
99
+ postgres.makeDescriptor({ changeKind, entityKey, state, seq: postgres.nextSequence() }),
100
+ ),
101
+ }));
102
+ }