@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 +7 -0
- package/package.json +7 -7
- package/src/Platform.res.mjs +2 -0
- package/src/adapter/EventLog/EventLogStorage_DynamoDb.res +3 -1
- package/src/adapter/EventLog/EventLogStorage_DynamoDb.res.mjs +3 -1
- package/src/adapter/EventLog/EventLogStorage_DynamoDbStream.res +3 -1
- package/src/adapter/EventLog/EventLogStorage_DynamoDbStream.res.mjs +3 -1
- package/src/adapter/EventLog/EventLogStorage_DynamoDb_Runtime.res +96 -7
- package/src/adapter/EventLog/EventLogStorage_DynamoDb_Runtime.res.mjs +161 -8
- package/src/util/Util_DynamoDbStream_Runtime.res +4 -3
- package/tests/EventLogStorage_DynamoDb_RuntimeTest.res +66 -0
- package/tests/EventLogStorage_DynamoDb_RuntimeTest.res.mjs +61 -0
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.
|
|
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-
|
|
18
|
-
"@reventlessdev/reventless-
|
|
19
|
-
"@reventlessdev/reventless-interop": "3.0.0-alpha.
|
|
20
|
-
"@reventlessdev/reventless-spec": "3.0.0-alpha.
|
|
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",
|
package/src/Platform.res.mjs
CHANGED
|
@@ -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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
109
|
+
ExpressionAttributeNames: positionAttrNames,
|
|
110
|
+
ExpressionAttributeValues: Object.fromEntries([
|
|
111
|
+
[
|
|
97
112
|
":id",
|
|
98
113
|
id
|
|
99
|
-
]
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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 {
|