@reventlessdev/reventless-aws 3.0.0-alpha.168 → 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 +15 -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 +104 -10
- package/src/adapter/EventLog/EventLogStorage_DynamoDb_Runtime.res.mjs +170 -11
- package/src/util/Util_DynamoDbStream_Runtime.res +4 -3
- package/tests/EventLogStorage_DynamoDb_RuntimeTest.res +68 -1
- package/tests/EventLogStorage_DynamoDb_RuntimeTest.res.mjs +68 -2
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.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
|
+
|
|
13
|
+
# 3.0.0-alpha.169 (2026-07-02)
|
|
14
|
+
|
|
15
|
+
**Note:** Version bump only for package @reventlessdev/reventless-aws
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
6
21
|
# 3.0.0-alpha.168 (2026-06-29)
|
|
7
22
|
|
|
8
23
|
**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": {
|
|
@@ -8,16 +8,16 @@
|
|
|
8
8
|
"@aws-sdk/client-cloudfront": "3.970.0",
|
|
9
9
|
"sury": "11.0.0-alpha.4",
|
|
10
10
|
"uuid": "^13.0.0",
|
|
11
|
-
"@reventlessdev/rescript-effect": "0.1.0-alpha.23",
|
|
12
11
|
"@reventlessdev/rescript-aws-sdk": "2.2.0-alpha.20",
|
|
13
12
|
"@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.44",
|
|
13
|
+
"@reventlessdev/rescript-jest": "1.0.0-alpha.5",
|
|
14
14
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.14",
|
|
15
|
-
"@reventlessdev/rescript-
|
|
15
|
+
"@reventlessdev/rescript-effect": "0.1.0-alpha.24",
|
|
16
16
|
"@reventlessdev/rescript-uuid": "1.1.0-alpha.13",
|
|
17
|
-
"@reventlessdev/reventless-core": "3.0.0-alpha.
|
|
18
|
-
"@reventlessdev/reventless-infra": "3.0.0-alpha.
|
|
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
|
}
|
|
@@ -40,7 +40,9 @@ let appendWithCondition = (tableName, jsons) => {
|
|
|
40
40
|
if count > 100 {
|
|
41
41
|
Effect.succeed(
|
|
42
42
|
Error(
|
|
43
|
-
|
|
43
|
+
ReventlessCore.EventLog.StorageFailure(
|
|
44
|
+
`EventLog.append: max 100 events per command, got ${count->Int.toString}`,
|
|
45
|
+
),
|
|
44
46
|
),
|
|
45
47
|
)
|
|
46
48
|
} else if count == 1 {
|
|
@@ -58,25 +60,117 @@ let append = table =>
|
|
|
58
60
|
appendWithCondition(table.name, jsons)
|
|
59
61
|
->Effect.catchAll(err =>
|
|
60
62
|
switch err {
|
|
61
|
-
| DynamoDb_Error.StaleState(_) =>
|
|
63
|
+
| DynamoDb_Error.StaleState(_) =>
|
|
64
|
+
Effect.succeed(Error(ReventlessCore.EventLog.Conflict))
|
|
62
65
|
| _ =>
|
|
63
66
|
let msg = DynamoDb_Error.message(err)
|
|
64
|
-
Effect.succeed(
|
|
67
|
+
Effect.succeed(
|
|
68
|
+
Error(ReventlessCore.EventLog.StorageFailure(`DynamoDB conditional append failed: ${msg}`)),
|
|
69
|
+
)
|
|
65
70
|
}
|
|
66
71
|
)
|
|
67
72
|
->Effect.runPromise
|
|
68
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
|
+
|
|
69
166
|
// True lazy pagination: each DynamoDB page is fetched on demand.
|
|
70
167
|
// Per-page retry is handled inside queryStream.
|
|
71
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.
|
|
72
171
|
let replayStream = table =>
|
|
73
|
-
id =>
|
|
74
|
-
queryStream(
|
|
75
|
-
tableName: table.name,
|
|
76
|
-
consistentRead: true,
|
|
77
|
-
keyConditionExpression: "id=:id",
|
|
78
|
-
expressionAttributeValues: [(":id", id->JSON.Encode.string)]->Dict.fromArray,
|
|
79
|
-
})
|
|
172
|
+
(id, ~fromSeq=?) =>
|
|
173
|
+
queryStream(replayQueryInput(table.Util_DynamoDb_Runtime.name, id, ~fromSeq=?fromSeq))
|
|
80
174
|
->Stream.catchAll(err => {
|
|
81
175
|
let msg = DynamoDb_Error.message(err)
|
|
82
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";
|
|
@@ -51,7 +55,10 @@ function appendWithCondition(tableName, jsons) {
|
|
|
51
55
|
if (count > 100) {
|
|
52
56
|
return Effect$1.succeed({
|
|
53
57
|
TAG: "Error",
|
|
54
|
-
_0:
|
|
58
|
+
_0: {
|
|
59
|
+
TAG: "StorageFailure",
|
|
60
|
+
_0: `EventLog.append: max 100 events per command, got ` + count.toString()
|
|
61
|
+
}
|
|
55
62
|
});
|
|
56
63
|
} else if (count === 1) {
|
|
57
64
|
return Effect$1.map(Effect.tryPromise(DynamoDb_Error$ReventlessAws.classify, () => putItemConditional(tableName, jsons[0])), param => ({
|
|
@@ -69,7 +76,7 @@ function append(table) {
|
|
|
69
76
|
case "StaleState" :
|
|
70
77
|
return Effect$1.succeed({
|
|
71
78
|
TAG: "Error",
|
|
72
|
-
_0: "
|
|
79
|
+
_0: "Conflict"
|
|
73
80
|
});
|
|
74
81
|
case "Transient" :
|
|
75
82
|
case "Permanent" :
|
|
@@ -78,28 +85,170 @@ function append(table) {
|
|
|
78
85
|
let msg = DynamoDb_Error$ReventlessAws.message(err);
|
|
79
86
|
return Effect$1.succeed({
|
|
80
87
|
TAG: "Error",
|
|
81
|
-
_0:
|
|
88
|
+
_0: {
|
|
89
|
+
TAG: "StorageFailure",
|
|
90
|
+
_0: `DynamoDB conditional append failed: ` + msg
|
|
91
|
+
}
|
|
82
92
|
});
|
|
83
93
|
}));
|
|
84
94
|
}
|
|
85
95
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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,
|
|
89
108
|
ConsistentRead: true,
|
|
90
|
-
|
|
109
|
+
ExpressionAttributeNames: positionAttrNames,
|
|
110
|
+
ExpressionAttributeValues: Object.fromEntries([
|
|
111
|
+
[
|
|
91
112
|
":id",
|
|
92
113
|
id
|
|
93
|
-
]
|
|
94
|
-
|
|
95
|
-
|
|
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 => {
|
|
96
245
|
let msg = DynamoDb_Error$ReventlessAws.message(err);
|
|
97
246
|
return Stream$1.fromEffect(Effect$1.fail(msg));
|
|
98
247
|
});
|
|
99
248
|
}
|
|
100
249
|
|
|
101
250
|
function replay(table) {
|
|
102
|
-
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))));
|
|
103
252
|
}
|
|
104
253
|
|
|
105
254
|
function appendStream(table) {
|
|
@@ -130,6 +279,16 @@ export {
|
|
|
130
279
|
transactWriteConditional,
|
|
131
280
|
appendWithCondition,
|
|
132
281
|
append,
|
|
282
|
+
snapshotPosition,
|
|
283
|
+
maxEventPosition,
|
|
284
|
+
padSeq,
|
|
285
|
+
replayQueryInput,
|
|
286
|
+
snapshotKey,
|
|
287
|
+
snapshotItem,
|
|
288
|
+
decodeSnapshotItem,
|
|
289
|
+
exnMessage,
|
|
290
|
+
latestSnapshot,
|
|
291
|
+
writeSnapshot,
|
|
133
292
|
replayStream,
|
|
134
293
|
replay,
|
|
135
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) =>
|
|
@@ -17,9 +17,10 @@ describe("Runtime.append", () => {
|
|
|
17
17
|
let jsons = Array.fromInitializer(~length=101, mkJson)
|
|
18
18
|
let result = await Runtime.append(table)(0, "test-id", jsons)
|
|
19
19
|
switch result {
|
|
20
|
-
| Error(msg) =>
|
|
20
|
+
| Error(ReventlessCore.EventLog.StorageFailure(msg)) =>
|
|
21
21
|
expect(msg->String.includes("max 100 events per command"))->toBe(true)
|
|
22
22
|
expect(msg->String.includes("101"))->toBe(true)
|
|
23
|
+
| Error(Conflict) => expect("expected StorageFailure, got Conflict")->toBe("")
|
|
23
24
|
| Ok(_) => expect("expected Error, got Ok")->toBe("")
|
|
24
25
|
}
|
|
25
26
|
})
|
|
@@ -55,3 +56,69 @@ describe("Runtime.buildTransactItems", () => {
|
|
|
55
56
|
expect(items->Array.length)->toBe(0)
|
|
56
57
|
})
|
|
57
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 = {
|
|
@@ -27,8 +29,13 @@ globalThis.describe("Runtime.append", () => {
|
|
|
27
29
|
return;
|
|
28
30
|
}
|
|
29
31
|
let msg = result._0;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
if (typeof msg !== "object") {
|
|
33
|
+
globalThis.expect("expected StorageFailure, got Conflict").toBe("");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
let msg$1 = msg._0;
|
|
37
|
+
globalThis.expect(msg$1.includes("max 100 events per command")).toBe(true);
|
|
38
|
+
globalThis.expect(msg$1.includes("101")).toBe(true);
|
|
32
39
|
});
|
|
33
40
|
});
|
|
34
41
|
|
|
@@ -58,6 +65,65 @@ globalThis.describe("Runtime.buildTransactItems", () => {
|
|
|
58
65
|
});
|
|
59
66
|
});
|
|
60
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
|
+
|
|
61
127
|
let Runtime;
|
|
62
128
|
|
|
63
129
|
export {
|