@reventlessdev/reventless-aws 3.0.0-alpha.169 → 3.0.0-alpha.170

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,13 @@
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.170 (2026-07-03)
7
+
8
+ ### Features
9
+
10
+ * **core,local,aws:** EventLog snapshot storage surface, all backends (aggregate-snapshotting plan, steps 3+4) ([b6e50e2](https://github.com/ReventlessDev/reventless-core/commit/b6e50e2e5fe50b7372e54811f883fa91f6758dd1))
11
+
12
+
6
13
  # 3.0.0-alpha.169 (2026-07-02)
7
14
 
8
15
  **Note:** Version bump only for package @reventlessdev/reventless-aws
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.169",
3
+ "version": "3.0.0-alpha.170",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "dependencies": {
@@ -9,15 +9,15 @@
9
9
  "sury": "11.0.0-alpha.4",
10
10
  "uuid": "^13.0.0",
11
11
  "@reventlessdev/rescript-aws-sdk": "2.2.0-alpha.20",
12
- "@reventlessdev/rescript-effect": "0.1.0-alpha.24",
13
- "@reventlessdev/rescript-jest": "1.0.0-alpha.5",
14
12
  "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.44",
13
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.5",
15
14
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.14",
15
+ "@reventlessdev/rescript-effect": "0.1.0-alpha.24",
16
16
  "@reventlessdev/rescript-uuid": "1.1.0-alpha.13",
17
- "@reventlessdev/reventless-infra": "3.0.0-alpha.83",
18
- "@reventlessdev/reventless-core": "3.0.0-alpha.136",
19
- "@reventlessdev/reventless-interop": "3.0.0-alpha.22",
20
- "@reventlessdev/reventless-spec": "3.0.0-alpha.63"
17
+ "@reventlessdev/reventless-core": "3.0.0-alpha.137",
18
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.84",
19
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.23",
20
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.64"
21
21
  },
22
22
  "devDependencies": {
23
23
  "rescript": "^12.3.0",
@@ -764,6 +764,7 @@ function MakeWithConfig(Config) {
764
764
  initialState: PluginBehavior$ReventlessCore.initialState,
765
765
  evolve: PluginBehavior$ReventlessCore.evolve,
766
766
  decide: PluginBehavior$ReventlessCore.decide,
767
+ snapshot: undefined,
767
768
  moduleUrl: PluginBehavior$ReventlessCore.moduleUrl
768
769
  })(NoEventMappings$ReventlessInfra.Make({
769
770
  name: PluginSpec$ReventlessCore.name,
@@ -1851,6 +1852,7 @@ function Make($star) {
1851
1852
  initialState: PluginBehavior$ReventlessCore.initialState,
1852
1853
  evolve: PluginBehavior$ReventlessCore.evolve,
1853
1854
  decide: PluginBehavior$ReventlessCore.decide,
1855
+ snapshot: undefined,
1854
1856
  moduleUrl: PluginBehavior$ReventlessCore.moduleUrl
1855
1857
  })(NoEventMappings$ReventlessInfra.Make({
1856
1858
  name: PluginSpec$ReventlessCore.name,
@@ -18,8 +18,10 @@ let make: ReventlessCore.EventLog_Adapter.storageMaker = (~name, ~opts) => {
18
18
  ...
19
19
  ),
20
20
  replay: EventLogStorage_DynamoDb_Runtime.replay(resolvedTable, ...),
21
- replayStream: EventLogStorage_DynamoDb_Runtime.replayStream(resolvedTable, ...),
21
+ replayStream: EventLogStorage_DynamoDb_Runtime.replayStream(resolvedTable),
22
22
  appendStream: EventLogStorage_DynamoDb_Runtime.appendStream(resolvedTable, ...),
23
+ latestSnapshot: EventLogStorage_DynamoDb_Runtime.latestSnapshot(resolvedTable),
24
+ writeSnapshot: EventLogStorage_DynamoDb_Runtime.writeSnapshot(resolvedTable),
23
25
  }),
24
26
  }
25
27
  }
@@ -23,7 +23,9 @@ function make(name, opts) {
23
23
  append: EventLogStorage_DynamoDb_Runtime$ReventlessAws.append(resolvedTable),
24
24
  replay: EventLogStorage_DynamoDb_Runtime$ReventlessAws.replay(resolvedTable),
25
25
  replayStream: EventLogStorage_DynamoDb_Runtime$ReventlessAws.replayStream(resolvedTable),
26
- appendStream: EventLogStorage_DynamoDb_Runtime$ReventlessAws.appendStream(resolvedTable)
26
+ appendStream: EventLogStorage_DynamoDb_Runtime$ReventlessAws.appendStream(resolvedTable),
27
+ latestSnapshot: EventLogStorage_DynamoDb_Runtime$ReventlessAws.latestSnapshot(resolvedTable),
28
+ writeSnapshot: EventLogStorage_DynamoDb_Runtime$ReventlessAws.writeSnapshot(resolvedTable)
27
29
  }))
28
30
  };
29
31
  }
@@ -19,8 +19,10 @@ let make: ReventlessCore.EventLog_Adapter.storageMaker = (~name, ~opts) => {
19
19
  ...
20
20
  ),
21
21
  replay: EventLogStorage_DynamoDb_Runtime.replay(resolvedTable, ...),
22
- replayStream: EventLogStorage_DynamoDb_Runtime.replayStream(resolvedTable, ...),
22
+ replayStream: EventLogStorage_DynamoDb_Runtime.replayStream(resolvedTable),
23
23
  appendStream: EventLogStorage_DynamoDb_Runtime.appendStream(resolvedTable, ...),
24
+ latestSnapshot: EventLogStorage_DynamoDb_Runtime.latestSnapshot(resolvedTable),
25
+ writeSnapshot: EventLogStorage_DynamoDb_Runtime.writeSnapshot(resolvedTable),
24
26
  }),
25
27
  }
26
28
  }
@@ -24,7 +24,9 @@ function make(name, opts) {
24
24
  append: EventLogStorage_DynamoDb_Runtime$ReventlessAws.append(resolvedTable),
25
25
  replay: EventLogStorage_DynamoDb_Runtime$ReventlessAws.replay(resolvedTable),
26
26
  replayStream: EventLogStorage_DynamoDb_Runtime$ReventlessAws.replayStream(resolvedTable),
27
- appendStream: EventLogStorage_DynamoDb_Runtime$ReventlessAws.appendStream(resolvedTable)
27
+ appendStream: EventLogStorage_DynamoDb_Runtime$ReventlessAws.appendStream(resolvedTable),
28
+ latestSnapshot: EventLogStorage_DynamoDb_Runtime$ReventlessAws.latestSnapshot(resolvedTable),
29
+ writeSnapshot: EventLogStorage_DynamoDb_Runtime$ReventlessAws.writeSnapshot(resolvedTable)
28
30
  }))
29
31
  };
30
32
  }
@@ -71,17 +71,106 @@ let append = table =>
71
71
  )
72
72
  ->Effect.runPromise
73
73
 
74
+ // ─── Aggregate snapshots (docs/plans/aggregate-snapshotting.md) ───
75
+ // A snapshot lives in the SAME table as the events, on the reserved sort key
76
+ // `position = "SNAPSHOT"`. Event positions are 9-digit zero-padded numbers, so
77
+ // bounding the replay query to the all-digit range excludes the snapshot row
78
+ // (and "S" > "9" keeps it out of any position-ordered event scan prefix).
79
+ // Keep-one: writeSnapshot is a plain overwrite Put. The DynamoDB stream feed is
80
+ // unaffected — Util_DynamoDbStream_Runtime.buildJsonEvent' drops rows without
81
+ // an `event` column, so snapshot writes never reach event collectors.
82
+ let snapshotPosition = "SNAPSHOT"
83
+ let maxEventPosition = "999999999"
84
+ let padSeq = seq => seq->Int.toString->String.padStart(9, "0")
85
+
86
+ // Pure input builders, exposed for tests (same pattern as buildTransactItems).
87
+ let replayQueryInput = (tableName, id, ~fromSeq=0): QueryCommand.input => {
88
+ tableName,
89
+ consistentRead: true,
90
+ keyConditionExpression: "id=:id AND #p BETWEEN :from AND :to",
91
+ expressionAttributeNames: positionAttrNames,
92
+ expressionAttributeValues: [
93
+ (":id", id->JSON.Encode.string),
94
+ (":from", padSeq(fromSeq)->JSON.Encode.string),
95
+ (":to", maxEventPosition->JSON.Encode.string),
96
+ ]->Dict.fromArray,
97
+ }
98
+
99
+ let snapshotKey = id =>
100
+ [
101
+ ("id", id->JSON.Encode.string),
102
+ ("position", snapshotPosition->JSON.Encode.string),
103
+ ]->Dict.fromArray
104
+
105
+ let snapshotItem = (id, snap: ReventlessCore.EventLog.snapshot): JSON.t =>
106
+ [
107
+ ("id", id->JSON.Encode.string),
108
+ ("position", snapshotPosition->JSON.Encode.string),
109
+ ("seqNr", snap.seqNr->JSON.Encode.int),
110
+ ("state", snap.state),
111
+ ("schemaHash", snap.schemaHash->JSON.Encode.string),
112
+ ]
113
+ ->Dict.fromArray
114
+ ->JSON.Encode.object
115
+
116
+ let decodeSnapshotItem = (item: JSON.t): option<ReventlessCore.EventLog.snapshot> =>
117
+ item
118
+ ->JSON.Decode.object
119
+ ->Option.flatMap(d =>
120
+ switch (d->Dict.get("seqNr"), d->Dict.get("state"), d->Dict.get("schemaHash")) {
121
+ | (Some(JSON.Number(n)), Some(state), Some(JSON.String(schemaHash))) =>
122
+ Some({ReventlessCore.EventLog.seqNr: Float.toInt(n), state, schemaHash})
123
+ | _ => None
124
+ }
125
+ )
126
+
127
+ let exnMessage = (exn, fallback) =>
128
+ exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr(fallback)
129
+
130
+ let latestSnapshot = table =>
131
+ async id =>
132
+ try {
133
+ let out = await GetCommand.send(
134
+ GetCommand.make({
135
+ tableName: table.Util_DynamoDb_Runtime.name,
136
+ key: snapshotKey(id),
137
+ consistentRead: true,
138
+ }),
139
+ )
140
+ switch out.item {
141
+ | Some(item) =>
142
+ switch decodeSnapshotItem(item) {
143
+ | Some(snap) => Ok(Some(snap))
144
+ | None => Error("snapshot item has unexpected shape")
145
+ }
146
+ | None => Ok(None)
147
+ }
148
+ } catch {
149
+ | exn => Error(exnMessage(exn, "snapshot read error"))
150
+ }
151
+
152
+ let writeSnapshot = table =>
153
+ async (id, snap) =>
154
+ try {
155
+ let _ = await PutCommand.send(
156
+ PutCommand.make({
157
+ tableName: table.Util_DynamoDb_Runtime.name,
158
+ item: snapshotItem(id, snap),
159
+ }),
160
+ )
161
+ Ok()
162
+ } catch {
163
+ | exn => Error(exnMessage(exn, "snapshot write error"))
164
+ }
165
+
74
166
  // True lazy pagination: each DynamoDB page is fetched on demand.
75
167
  // Per-page retry is handled inside queryStream.
76
168
  // Stream.take(n) short-circuits pagination once n events are consumed.
169
+ // `fromSeq` narrows the key condition so a snapshot-seeded delta read only
170
+ // pays for the events it returns.
77
171
  let replayStream = table =>
78
- id =>
79
- queryStream({
80
- tableName: table.name,
81
- consistentRead: true,
82
- keyConditionExpression: "id=:id",
83
- expressionAttributeValues: [(":id", id->JSON.Encode.string)]->Dict.fromArray,
84
- })
172
+ (id, ~fromSeq=?) =>
173
+ queryStream(replayQueryInput(table.Util_DynamoDb_Runtime.name, id, ~fromSeq=?fromSeq))
85
174
  ->Stream.catchAll(err => {
86
175
  let msg = DynamoDb_Error.message(err)
87
176
  Stream.fromEffect(Effect.fail(msg))
@@ -2,8 +2,12 @@
2
2
 
3
3
  import * as Effect from "@reventlessdev/rescript-effect/src/Effect.res.mjs";
4
4
  import * as Stream from "@reventlessdev/rescript-effect/src/Stream.res.mjs";
5
+ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
6
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
7
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
8
  import * as Effect$1 from "effect/Effect";
6
9
  import * as Stream$1 from "effect/Stream";
10
+ import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
7
11
  import * as LibDynamodb from "@aws-sdk/lib-dynamodb";
8
12
  import * as EffectLogger$ReventlessCore from "@reventlessdev/reventless-core/src/util/EffectLogger.res.mjs";
9
13
  import * as DynamoDb_Error$ReventlessAws from "../../errors/DynamoDb_Error.res.mjs";
@@ -89,23 +93,162 @@ function append(table) {
89
93
  }));
90
94
  }
91
95
 
92
- function replayStream(table) {
93
- return id => Stream$1.catchAll(Util_DynamoDb_Runtime$ReventlessAws.queryStream({
94
- TableName: table.name,
96
+ let snapshotPosition = "SNAPSHOT";
97
+
98
+ let maxEventPosition = "999999999";
99
+
100
+ function padSeq(seq) {
101
+ return seq.toString().padStart(9, "0");
102
+ }
103
+
104
+ function replayQueryInput(tableName, id, fromSeqOpt) {
105
+ let fromSeq = fromSeqOpt !== undefined ? fromSeqOpt : 0;
106
+ return {
107
+ TableName: tableName,
95
108
  ConsistentRead: true,
96
- ExpressionAttributeValues: Object.fromEntries([[
109
+ ExpressionAttributeNames: positionAttrNames,
110
+ ExpressionAttributeValues: Object.fromEntries([
111
+ [
97
112
  ":id",
98
113
  id
99
- ]]),
100
- KeyConditionExpression: "id=:id"
101
- }), err => {
114
+ ],
115
+ [
116
+ ":from",
117
+ padSeq(fromSeq)
118
+ ],
119
+ [
120
+ ":to",
121
+ maxEventPosition
122
+ ]
123
+ ]),
124
+ KeyConditionExpression: "id=:id AND #p BETWEEN :from AND :to"
125
+ };
126
+ }
127
+
128
+ function snapshotKey(id) {
129
+ return Object.fromEntries([
130
+ [
131
+ "id",
132
+ id
133
+ ],
134
+ [
135
+ "position",
136
+ snapshotPosition
137
+ ]
138
+ ]);
139
+ }
140
+
141
+ function snapshotItem(id, snap) {
142
+ return Object.fromEntries([
143
+ [
144
+ "id",
145
+ id
146
+ ],
147
+ [
148
+ "position",
149
+ snapshotPosition
150
+ ],
151
+ [
152
+ "seqNr",
153
+ snap.seqNr
154
+ ],
155
+ [
156
+ "state",
157
+ snap.state
158
+ ],
159
+ [
160
+ "schemaHash",
161
+ snap.schemaHash
162
+ ]
163
+ ]);
164
+ }
165
+
166
+ function decodeSnapshotItem(item) {
167
+ return Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), d => {
168
+ let match = d["seqNr"];
169
+ let match$1 = d["state"];
170
+ let match$2 = d["schemaHash"];
171
+ if (typeof match === "number" && match$1 !== undefined && typeof match$2 === "string") {
172
+ return {
173
+ seqNr: match | 0,
174
+ state: match$1,
175
+ schemaHash: match$2
176
+ };
177
+ }
178
+ });
179
+ }
180
+
181
+ function exnMessage(exn, fallback) {
182
+ return Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(exn), Stdlib_JsExn.message), fallback);
183
+ }
184
+
185
+ function latestSnapshot(table) {
186
+ return async id => {
187
+ try {
188
+ let out = await DynamoDb_DocumentClient$AwsSdk.GetCommand.send(new LibDynamodb.GetCommand({
189
+ TableName: table.name,
190
+ Key: snapshotKey(id),
191
+ ConsistentRead: true
192
+ }));
193
+ let item = out.Item;
194
+ if (item === undefined) {
195
+ return {
196
+ TAG: "Ok",
197
+ _0: undefined
198
+ };
199
+ }
200
+ let snap = decodeSnapshotItem(item);
201
+ if (snap !== undefined) {
202
+ return {
203
+ TAG: "Ok",
204
+ _0: snap
205
+ };
206
+ } else {
207
+ return {
208
+ TAG: "Error",
209
+ _0: "snapshot item has unexpected shape"
210
+ };
211
+ }
212
+ } catch (raw_exn) {
213
+ let exn = Primitive_exceptions.internalToException(raw_exn);
214
+ return {
215
+ TAG: "Error",
216
+ _0: exnMessage(exn, "snapshot read error")
217
+ };
218
+ }
219
+ };
220
+ }
221
+
222
+ function writeSnapshot(table) {
223
+ return async (id, snap) => {
224
+ try {
225
+ await DynamoDb_DocumentClient$AwsSdk.PutCommand.send(new LibDynamodb.PutCommand({
226
+ Item: snapshotItem(id, snap),
227
+ TableName: table.name
228
+ }));
229
+ return {
230
+ TAG: "Ok",
231
+ _0: undefined
232
+ };
233
+ } catch (raw_exn) {
234
+ let exn = Primitive_exceptions.internalToException(raw_exn);
235
+ return {
236
+ TAG: "Error",
237
+ _0: exnMessage(exn, "snapshot write error")
238
+ };
239
+ }
240
+ };
241
+ }
242
+
243
+ function replayStream(table) {
244
+ return (id, fromSeq) => Stream$1.catchAll(Util_DynamoDb_Runtime$ReventlessAws.queryStream(replayQueryInput(table.name, id, fromSeq)), err => {
102
245
  let msg = DynamoDb_Error$ReventlessAws.message(err);
103
246
  return Stream$1.fromEffect(Effect$1.fail(msg));
104
247
  });
105
248
  }
106
249
 
107
250
  function replay(table) {
108
- return id => Effect$1.runPromise(Effect$1.catchAll(Stream.runCollect(replayStream(table)(id)), msg => Effect$1.flatMap(EffectLogger$ReventlessCore.logError("EventLogStorage_DynamoDb_Runtime-ReventlessAws", undefined, `Couldn't replay events for id ` + id + ` after retries: ` + msg), () => Effect$1.fail(msg))));
251
+ return id => Effect$1.runPromise(Effect$1.catchAll(Stream.runCollect(replayStream(table)(id, undefined)), msg => Effect$1.flatMap(EffectLogger$ReventlessCore.logError("EventLogStorage_DynamoDb_Runtime-ReventlessAws", undefined, `Couldn't replay events for id ` + id + ` after retries: ` + msg), () => Effect$1.fail(msg))));
109
252
  }
110
253
 
111
254
  function appendStream(table) {
@@ -136,6 +279,16 @@ export {
136
279
  transactWriteConditional,
137
280
  appendWithCondition,
138
281
  append,
282
+ snapshotPosition,
283
+ maxEventPosition,
284
+ padSeq,
285
+ replayQueryInput,
286
+ snapshotKey,
287
+ snapshotItem,
288
+ decodeSnapshotItem,
289
+ exnMessage,
290
+ latestSnapshot,
291
+ writeSnapshot,
139
292
  replayStream,
140
293
  replay,
141
294
  appendStream,
@@ -4,9 +4,10 @@ type result =
4
4
  | NewAndOldImage(string, JSON.t, JSON.t)
5
5
  | Invalid
6
6
 
7
- // Returns None for rows that aren't events — e.g. DCB FENCE rows (no `event`
8
- // column). The caller filters these out via `Invalid` rather than synthesising
9
- // a bogus event that sury would later reject.
7
+ // Returns None for rows that aren't events — e.g. DCB FENCE rows and aggregate
8
+ // snapshot rows (position = "SNAPSHOT"; docs/plans/aggregate-snapshotting.md),
9
+ // neither of which has an `event` column. The caller filters these out via
10
+ // `Invalid` rather than synthesising a bogus event that sury would later reject.
10
11
  let buildJsonEvent' = dict => {
11
12
  switch (dict->Dict.get("event"), dict->Dict.get("data")) {
12
13
  | (Some(JSON.String(eventType)), data) =>
@@ -56,3 +56,69 @@ describe("Runtime.buildTransactItems", () => {
56
56
  expect(items->Array.length)->toBe(0)
57
57
  })
58
58
  })
59
+
60
+ // ─── Aggregate snapshots (docs/plans/aggregate-snapshotting.md) ───
61
+
62
+ describe("Runtime.replayQueryInput", () => {
63
+ testSync("default bounds cover exactly the zero-padded event range", () => {
64
+ let input = Runtime.replayQueryInput(table.name, "agg-1")
65
+ expect(input.tableName)->toBe("TestTable")
66
+ expect(input.consistentRead)->toEqual(Some(true))
67
+ expect(input.keyConditionExpression)->toEqual(Some("id=:id AND #p BETWEEN :from AND :to"))
68
+ expect(input.expressionAttributeNames->Option.flatMap(Dict.get(_, "#p")))->toEqual(
69
+ Some("position"),
70
+ )
71
+ let values = input.expressionAttributeValues->Option.getOr(Dict.make())
72
+ expect(values->Dict.get(":from"))->toEqual(Some(JSON.Encode.string("000000000")))
73
+ expect(values->Dict.get(":to"))->toEqual(Some(JSON.Encode.string("999999999")))
74
+ })
75
+
76
+ testSync("fromSeq narrows the lower bound to the padded position", () => {
77
+ let input = Runtime.replayQueryInput(table.name, "agg-1", ~fromSeq=42)
78
+ let values = input.expressionAttributeValues->Option.getOr(Dict.make())
79
+ expect(values->Dict.get(":from"))->toEqual(Some(JSON.Encode.string("000000042")))
80
+ })
81
+
82
+ testSync("the snapshot sentinel sorts outside the event range", () => {
83
+ // DynamoDB compares string range keys lexicographically; the BETWEEN upper
84
+ // bound excludes the snapshot row because "S" > "9".
85
+ expect(Runtime.snapshotPosition > Runtime.maxEventPosition)->toBe(true)
86
+ // And every padded event position stays inside the bounds.
87
+ expect(Runtime.padSeq(0) >= "000000000" && Runtime.padSeq(0) <= "999999999")->toBe(true)
88
+ expect(Runtime.padSeq(999999999) <= "999999999")->toBe(true)
89
+ })
90
+ })
91
+
92
+ describe("Runtime snapshot item codec", () => {
93
+ let snap: ReventlessCore.EventLog.snapshot = {
94
+ seqNr: 50,
95
+ state: JSON.Encode.object(Dict.fromArray([("names", JSON.Encode.int(3))])),
96
+ schemaHash: "abc123",
97
+ }
98
+
99
+ testSync("snapshotItem round-trips through decodeSnapshotItem", () => {
100
+ let item = Runtime.snapshotItem("agg-1", snap)
101
+ expect(Runtime.decodeSnapshotItem(item))->toEqual(Some(snap))
102
+ // The item rides the reserved sort key.
103
+ let d = item->JSON.Decode.object->Option.getOr(Dict.make())
104
+ expect(d->Dict.get("position"))->toEqual(Some(JSON.Encode.string("SNAPSHOT")))
105
+ expect(d->Dict.get("id"))->toEqual(Some(JSON.Encode.string("agg-1")))
106
+ })
107
+
108
+ testSync("decodeSnapshotItem rejects a malformed item", () => {
109
+ let bad =
110
+ [("id", JSON.Encode.string("agg-1")), ("seqNr", JSON.Encode.string("not-a-number"))]
111
+ ->Dict.fromArray
112
+ ->JSON.Encode.object
113
+ expect(Runtime.decodeSnapshotItem(bad))->toEqual(None)
114
+ })
115
+
116
+ testSync("a snapshot row never reaches the event stream feed", () => {
117
+ // The DynamoDB stream decoder drops rows without an `event` column (same
118
+ // mechanism that filters DCB FENCE rows), so snapshot writes are invisible
119
+ // to event collectors.
120
+ let asDict =
121
+ Runtime.snapshotItem("agg-1", snap)->JSON.Decode.object->Option.getOr(Dict.make())
122
+ expect(Util_DynamoDbStream_Runtime.buildJsonEvent'(asDict))->toEqual(None)
123
+ })
124
+ })
@@ -1,7 +1,9 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
3
4
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
4
5
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
+ import * as Util_DynamoDbStream_Runtime$ReventlessAws from "../src/util/Util_DynamoDbStream_Runtime.res.mjs";
5
7
  import * as EventLogStorage_DynamoDb_Runtime$ReventlessAws from "../src/adapter/EventLog/EventLogStorage_DynamoDb_Runtime.res.mjs";
6
8
 
7
9
  let table = {
@@ -63,6 +65,65 @@ globalThis.describe("Runtime.buildTransactItems", () => {
63
65
  });
64
66
  });
65
67
 
68
+ globalThis.describe("Runtime.replayQueryInput", () => {
69
+ globalThis.test("default bounds cover exactly the zero-padded event range", () => {
70
+ let input = EventLogStorage_DynamoDb_Runtime$ReventlessAws.replayQueryInput("TestTable", "agg-1", undefined);
71
+ globalThis.expect(input.TableName).toBe("TestTable");
72
+ globalThis.expect(input.ConsistentRead).toEqual(true);
73
+ globalThis.expect(input.KeyConditionExpression).toEqual("id=:id AND #p BETWEEN :from AND :to");
74
+ globalThis.expect(Stdlib_Option.flatMap(input.ExpressionAttributeNames, __x => __x["#p"])).toEqual("position");
75
+ let values = Stdlib_Option.getOr(input.ExpressionAttributeValues, {});
76
+ globalThis.expect(values[":from"]).toEqual("000000000");
77
+ globalThis.expect(values[":to"]).toEqual("999999999");
78
+ });
79
+ globalThis.test("fromSeq narrows the lower bound to the padded position", () => {
80
+ let input = EventLogStorage_DynamoDb_Runtime$ReventlessAws.replayQueryInput("TestTable", "agg-1", 42);
81
+ let values = Stdlib_Option.getOr(input.ExpressionAttributeValues, {});
82
+ globalThis.expect(values[":from"]).toEqual("000000042");
83
+ });
84
+ globalThis.test("the snapshot sentinel sorts outside the event range", () => {
85
+ globalThis.expect(EventLogStorage_DynamoDb_Runtime$ReventlessAws.snapshotPosition > EventLogStorage_DynamoDb_Runtime$ReventlessAws.maxEventPosition).toBe(true);
86
+ globalThis.expect(EventLogStorage_DynamoDb_Runtime$ReventlessAws.padSeq(0) >= "000000000" && EventLogStorage_DynamoDb_Runtime$ReventlessAws.padSeq(0) <= "999999999").toBe(true);
87
+ globalThis.expect(EventLogStorage_DynamoDb_Runtime$ReventlessAws.padSeq(999999999) <= "999999999").toBe(true);
88
+ });
89
+ });
90
+
91
+ globalThis.describe("Runtime snapshot item codec", () => {
92
+ let snap_state = Object.fromEntries([[
93
+ "names",
94
+ 3
95
+ ]]);
96
+ let snap = {
97
+ seqNr: 50,
98
+ state: snap_state,
99
+ schemaHash: "abc123"
100
+ };
101
+ globalThis.test("snapshotItem round-trips through decodeSnapshotItem", () => {
102
+ let item = EventLogStorage_DynamoDb_Runtime$ReventlessAws.snapshotItem("agg-1", snap);
103
+ globalThis.expect(EventLogStorage_DynamoDb_Runtime$ReventlessAws.decodeSnapshotItem(item)).toEqual(snap);
104
+ let d = Stdlib_Option.getOr(Stdlib_JSON.Decode.object(item), {});
105
+ globalThis.expect(d["position"]).toEqual("SNAPSHOT");
106
+ globalThis.expect(d["id"]).toEqual("agg-1");
107
+ });
108
+ globalThis.test("decodeSnapshotItem rejects a malformed item", () => {
109
+ let bad = Object.fromEntries([
110
+ [
111
+ "id",
112
+ "agg-1"
113
+ ],
114
+ [
115
+ "seqNr",
116
+ "not-a-number"
117
+ ]
118
+ ]);
119
+ globalThis.expect(EventLogStorage_DynamoDb_Runtime$ReventlessAws.decodeSnapshotItem(bad)).toEqual(undefined);
120
+ });
121
+ globalThis.test("a snapshot row never reaches the event stream feed", () => {
122
+ let asDict = Stdlib_Option.getOr(Stdlib_JSON.Decode.object(EventLogStorage_DynamoDb_Runtime$ReventlessAws.snapshotItem("agg-1", snap)), {});
123
+ globalThis.expect(Util_DynamoDbStream_Runtime$ReventlessAws.buildJsonEvent$p(asDict)).toEqual(undefined);
124
+ });
125
+ });
126
+
66
127
  let Runtime;
67
128
 
68
129
  export {