@reventlessdev/reventless-local 3.0.0-alpha.113 → 3.0.0-alpha.115
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 +21 -0
- package/package.json +8 -8
- package/src/Platform.res +25 -50
- package/src/Platform.res.mjs +33 -160
- package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res +10 -1
- package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res.mjs +9 -1
- package/src/adapter/EventLog/EventLogStorage_InMemory.res +1 -1
- package/src/adapter/EventLog/EventLogStorage_InMemory.res.mjs +1 -1
- package/src/adapter/EventLog/EventLogStorage_Sqlite.res +16 -3
- package/src/adapter/EventLog/EventLogStorage_Sqlite.res.mjs +35 -7
- package/src/adapter/LocalBus.res +23 -3
- package/src/adapter/LocalBus.res.mjs +100 -66
- package/src/adapter/QueryDb/QueryDbStorage_InMemory.res +99 -7
- package/src/adapter/QueryDb/QueryDbStorage_InMemory.res.mjs +97 -13
- package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res +24 -1
- package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res.mjs +19 -4
- package/src/adapter/SqliteDriver.res +6 -1
- package/src/adapter/SqliteDriver.res.mjs +5 -1
- package/src/components/DcbEventLog_Builder.res +7 -1
- package/src/components/DcbEventLog_Builder.res.mjs +3 -3
- package/src/test/Mocks/MockEventLogStorage.res +1 -1
- package/src/test/Mocks/MockEventLogStorage.res.mjs +4 -1
- package/tests/adapter/BackendParityTest.res +64 -0
- package/tests/adapter/BackendParityTest.res.mjs +45 -0
- package/tests/adapter/EventLogStorageSqliteTest.res +4 -2
- package/tests/adapter/EventLogStorageSqliteTest.res.mjs +6 -1
- package/tests/adapter/LocalBusPubSubTest.res +31 -0
- package/tests/adapter/LocalBusPubSubTest.res.mjs +28 -0
- package/tests/components/inboundtranslationslice/InboundTranslationSliceCallbackTest.res +2 -0
- package/tests/components/inboundtranslationslice/InboundTranslationSliceCallbackTest.res.mjs +3 -0
- package/tests/components/inboundtranslationslice/InboundTranslationSliceFixtures.res +1 -0
- package/tests/components/inboundtranslationslice/InboundTranslationSliceFixtures.res.mjs +1 -0
- package/tests/components/outboundtranslationslice/OutboundTranslationSliceCallbackTest.res.mjs +4 -2
- package/tests/components/outboundtranslationslice/OutboundTranslationSliceFixtures.res +2 -0
- package/tests/components/outboundtranslationslice/OutboundTranslationSliceFixtures.res.mjs +4 -2
|
@@ -76,6 +76,58 @@ function Make(Bus) {
|
|
|
76
76
|
let syncAll = () => {
|
|
77
77
|
allItems.contents = flattenStore(store.contents);
|
|
78
78
|
};
|
|
79
|
+
let expiries = {
|
|
80
|
+
contents: {}
|
|
81
|
+
};
|
|
82
|
+
let recordExpiry = (id, subKey, ttl) => {
|
|
83
|
+
if (ttl !== undefined) {
|
|
84
|
+
let m = expiries.contents[id];
|
|
85
|
+
let subExp;
|
|
86
|
+
if (m !== undefined) {
|
|
87
|
+
subExp = m;
|
|
88
|
+
} else {
|
|
89
|
+
let m$1 = {};
|
|
90
|
+
expiries.contents[id] = m$1;
|
|
91
|
+
subExp = m$1;
|
|
92
|
+
}
|
|
93
|
+
subExp[subKey] = ttl;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
let m$2 = expiries.contents[id];
|
|
97
|
+
if (m$2 !== undefined) {
|
|
98
|
+
return Stdlib_Dict.$$delete(m$2, subKey);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
let purgeExpired = () => {
|
|
102
|
+
let now = Date.now() / 1000.0;
|
|
103
|
+
let removedAny = {
|
|
104
|
+
contents: false
|
|
105
|
+
};
|
|
106
|
+
Object.entries(expiries.contents).forEach(param => {
|
|
107
|
+
let subExp = param[1];
|
|
108
|
+
let id = param[0];
|
|
109
|
+
Object.entries(subExp).forEach(param => {
|
|
110
|
+
if (param[1] > now) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
let subKey = param[0];
|
|
114
|
+
let sm = store.contents[id];
|
|
115
|
+
if (sm !== undefined) {
|
|
116
|
+
if (Stdlib_Option.isSome(sm[subKey])) {
|
|
117
|
+
Stdlib_Dict.$$delete(sm, subKey);
|
|
118
|
+
removedAny.contents = true;
|
|
119
|
+
}
|
|
120
|
+
if (Object.keys(sm).length === 0) {
|
|
121
|
+
Stdlib_Dict.$$delete(store.contents, id);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
Stdlib_Dict.$$delete(subExp, subKey);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
if (removedAny.contents) {
|
|
128
|
+
return syncAll();
|
|
129
|
+
}
|
|
130
|
+
};
|
|
79
131
|
let getOrCreateSubMap = partitionKey => {
|
|
80
132
|
let m = store.contents[partitionKey];
|
|
81
133
|
if (m !== undefined) {
|
|
@@ -85,11 +137,17 @@ function Make(Bus) {
|
|
|
85
137
|
store.contents[partitionKey] = m$1;
|
|
86
138
|
return m$1;
|
|
87
139
|
};
|
|
88
|
-
let load = async id =>
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
140
|
+
let load = async id => {
|
|
141
|
+
purgeExpired();
|
|
142
|
+
return {
|
|
143
|
+
TAG: "Ok",
|
|
144
|
+
_0: sortedItems(store.contents, id)
|
|
145
|
+
};
|
|
146
|
+
};
|
|
147
|
+
let loadStream = id => {
|
|
148
|
+
purgeExpired();
|
|
149
|
+
return Stream.fromIterable(sortedItems(store.contents, id));
|
|
150
|
+
};
|
|
93
151
|
let entityKeyFor = (id, subKey) => {
|
|
94
152
|
if (subIdField !== undefined) {
|
|
95
153
|
return id + "-" + subKey;
|
|
@@ -106,10 +164,11 @@ function Make(Bus) {
|
|
|
106
164
|
let descriptor = LocalBus$ReventlessLocal.makeStateChangeDescriptor("Removed", entityKeyFor(id, subKey), undefined);
|
|
107
165
|
Bus.publishStateChange(name, descriptor);
|
|
108
166
|
};
|
|
109
|
-
let save = async (id, state, _saveMode,
|
|
167
|
+
let save = async (id, state, _saveMode, ttl) => {
|
|
110
168
|
let subKey = getSubKey(state, subIdField);
|
|
111
169
|
let subMap = getOrCreateSubMap(id);
|
|
112
170
|
subMap[subKey] = state;
|
|
171
|
+
recordExpiry(id, subKey, ttl);
|
|
113
172
|
syncAll();
|
|
114
173
|
publishUpdated(id, state);
|
|
115
174
|
return {
|
|
@@ -120,9 +179,11 @@ function Make(Bus) {
|
|
|
120
179
|
let saveBatch = async batch => {
|
|
121
180
|
batch.forEach(param => {
|
|
122
181
|
let state = param[1];
|
|
182
|
+
let id = param[0];
|
|
123
183
|
let subKey = getSubKey(state, subIdField);
|
|
124
|
-
let subMap = getOrCreateSubMap(
|
|
184
|
+
let subMap = getOrCreateSubMap(id);
|
|
125
185
|
subMap[subKey] = state;
|
|
186
|
+
recordExpiry(id, subKey, param[2]);
|
|
126
187
|
});
|
|
127
188
|
syncAll();
|
|
128
189
|
batch.forEach(param => publishUpdated(param[0], param[1]));
|
|
@@ -131,10 +192,27 @@ function Make(Bus) {
|
|
|
131
192
|
_0: undefined
|
|
132
193
|
};
|
|
133
194
|
};
|
|
134
|
-
let count = async (
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
195
|
+
let count = async (id, fieldName, inc) => {
|
|
196
|
+
let subMap = getOrCreateSubMap(id);
|
|
197
|
+
let existing = Stdlib_Option.flatMap(subMap[""], Stdlib_JSON.Decode.object);
|
|
198
|
+
let current = Stdlib_Option.mapOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(existing, o => o[fieldName]), Stdlib_JSON.Decode.float), 0, prim => prim | 0);
|
|
199
|
+
let next = current + inc | 0;
|
|
200
|
+
let obj = {};
|
|
201
|
+
if (existing !== undefined) {
|
|
202
|
+
Object.entries(existing).forEach(param => {
|
|
203
|
+
obj[param[0]] = param[1];
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
obj["id"] = id;
|
|
207
|
+
obj[fieldName] = next;
|
|
208
|
+
subMap[""] = obj;
|
|
209
|
+
syncAll();
|
|
210
|
+
publishUpdated(id, obj);
|
|
211
|
+
return {
|
|
212
|
+
TAG: "Ok",
|
|
213
|
+
_0: next
|
|
214
|
+
};
|
|
215
|
+
};
|
|
138
216
|
let $$delete = async (id, subIdOpt) => {
|
|
139
217
|
if (subIdOpt !== undefined) {
|
|
140
218
|
let subValue = subIdOpt[1];
|
|
@@ -209,8 +287,14 @@ function Make(Bus) {
|
|
|
209
287
|
deleteBatch: deleteBatch
|
|
210
288
|
};
|
|
211
289
|
Bus.registerQueryDb(name, ops);
|
|
212
|
-
Bus.registerQueryDbScan(name, () =>
|
|
213
|
-
|
|
290
|
+
Bus.registerQueryDbScan(name, () => {
|
|
291
|
+
purgeExpired();
|
|
292
|
+
return allItems.contents;
|
|
293
|
+
});
|
|
294
|
+
Bus.registerQueryDbStream(name, () => {
|
|
295
|
+
purgeExpired();
|
|
296
|
+
return Stream.fromIterable(allItems.contents);
|
|
297
|
+
});
|
|
214
298
|
return {
|
|
215
299
|
resources: [],
|
|
216
300
|
dataSourceName: Pulumi.output(""),
|
|
@@ -260,7 +260,30 @@ let makeStorage = (
|
|
|
260
260
|
Ok()
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
-
|
|
263
|
+
// Mirror DynamoDB's `ADD #fieldName :inc` on key {id}: read the counter field
|
|
264
|
+
// on the partition-key item (counters are single-state, sub_key=""), add `inc`,
|
|
265
|
+
// upsert, and return the NEW total. The previous `Ok(inc)` echoed the increment
|
|
266
|
+
// and never persisted, so the total was wrong and `loadStream` never saw it.
|
|
267
|
+
let count: QueryDb.count<string> = async (id, fieldName, inc) => {
|
|
268
|
+
let existing = rowsFor(id)->Array.get(0)->Option.flatMap(JSON.Decode.object)
|
|
269
|
+
let current =
|
|
270
|
+
existing
|
|
271
|
+
->Option.flatMap(o => o->Dict.get(fieldName))
|
|
272
|
+
->Option.flatMap(JSON.Decode.float)
|
|
273
|
+
->Option.mapOr(0, Float.toInt)
|
|
274
|
+
let next = current + inc
|
|
275
|
+
let obj = Dict.make()
|
|
276
|
+
switch existing {
|
|
277
|
+
| Some(o) => o->Dict.toArray->Array.forEach(((k, v)) => obj->Dict.set(k, v))
|
|
278
|
+
| None => ()
|
|
279
|
+
}
|
|
280
|
+
obj->Dict.set("id", JSON.Encode.string(id))
|
|
281
|
+
obj->Dict.set(fieldName, JSON.Encode.int(next))
|
|
282
|
+
let newItem = JSON.Encode.object(obj)
|
|
283
|
+
saveOne(id, newItem, None)
|
|
284
|
+
publishUpdated(id, newItem)
|
|
285
|
+
Ok(next)
|
|
286
|
+
}
|
|
264
287
|
|
|
265
288
|
let deleteOne = (id: string, subIdOpt) =>
|
|
266
289
|
switch subIdOpt {
|
|
@@ -209,10 +209,25 @@ function makeStorage(db, bus, name, indexes, subIdField) {
|
|
|
209
209
|
_0: undefined
|
|
210
210
|
};
|
|
211
211
|
};
|
|
212
|
-
let count = async (
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
212
|
+
let count = async (id, fieldName, inc) => {
|
|
213
|
+
let existing = Stdlib_Option.flatMap(rowsFor(id)[0], Stdlib_JSON.Decode.object);
|
|
214
|
+
let current = Stdlib_Option.mapOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(existing, o => o[fieldName]), Stdlib_JSON.Decode.float), 0, prim => prim | 0);
|
|
215
|
+
let next = current + inc | 0;
|
|
216
|
+
let obj = {};
|
|
217
|
+
if (existing !== undefined) {
|
|
218
|
+
Object.entries(existing).forEach(param => {
|
|
219
|
+
obj[param[0]] = param[1];
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
obj["id"] = id;
|
|
223
|
+
obj[fieldName] = next;
|
|
224
|
+
saveOne(id, obj, undefined);
|
|
225
|
+
publishUpdated(id, obj);
|
|
226
|
+
return {
|
|
227
|
+
TAG: "Ok",
|
|
228
|
+
_0: next
|
|
229
|
+
};
|
|
230
|
+
};
|
|
216
231
|
let deleteOne = (id, subIdOpt) => {
|
|
217
232
|
if (subIdOpt !== undefined) {
|
|
218
233
|
return SqliteDriver$ReventlessLocal.run(deleteBySubKeyStmt, [
|
|
@@ -46,7 +46,12 @@ let transaction = (db, fn) => {
|
|
|
46
46
|
result
|
|
47
47
|
} catch {
|
|
48
48
|
| exn =>
|
|
49
|
-
|
|
49
|
+
// Roll back, but never let a failing ROLLBACK replace the original error —
|
|
50
|
+
// the original is the diagnostic one; a rollback failure (e.g. no active
|
|
51
|
+
// transaction) would otherwise mask it.
|
|
52
|
+
try exec(db, "ROLLBACK") catch {
|
|
53
|
+
| _ => ()
|
|
54
|
+
}
|
|
50
55
|
throw(exn)
|
|
51
56
|
}
|
|
52
57
|
}
|
|
@@ -3,7 +3,13 @@
|
|
|
3
3
|
module Make = (Bus: LocalBus.T) => {
|
|
4
4
|
module EventTopicPublisher = LocalEventTopicPublisher.Make(Bus)
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
// Use the backend-aware storage functor (as Platform does), not the plain
|
|
7
|
+
// module: the functor consults BackendState so a standalone DCB log honours
|
|
8
|
+
// REVENTLESS_LOCAL_BACKEND=sqlite, and it registers the read with the Bus so
|
|
9
|
+
// `Bus.getDcbEventLogRead` can see it. The plain module did neither — the log
|
|
10
|
+
// silently stayed in memory under SQLite and was invisible to bus readers.
|
|
11
|
+
module Storage = DcbEventLogStorage_InMemory.Make(Bus)
|
|
12
|
+
module Inner = ReventlessCore.DcbEventLog_Builder.Make(Storage, EventTopicPublisher)
|
|
7
13
|
type component = ReventlessInfra.DcbEventLog.component
|
|
8
14
|
let make: (
|
|
9
15
|
~name: string,
|
|
@@ -7,11 +7,11 @@ import * as DcbEventLogStorage_InMemory$ReventlessLocal from "../adapter/DcbEven
|
|
|
7
7
|
|
|
8
8
|
function Make(Bus) {
|
|
9
9
|
let EventTopicPublisher = LocalEventTopicPublisher$ReventlessLocal.Make(Bus);
|
|
10
|
-
let
|
|
11
|
-
|
|
12
|
-
})(EventTopicPublisher);
|
|
10
|
+
let Storage = DcbEventLogStorage_InMemory$ReventlessLocal.Make(Bus);
|
|
11
|
+
let Inner = DcbEventLog_Builder$ReventlessCore.Make(Storage)(EventTopicPublisher);
|
|
13
12
|
return {
|
|
14
13
|
EventTopicPublisher: EventTopicPublisher,
|
|
14
|
+
Storage: Storage,
|
|
15
15
|
Inner: Inner,
|
|
16
16
|
make: Inner.make,
|
|
17
17
|
operations: Component$ReventlessInfra.operations
|
|
@@ -15,7 +15,7 @@ let make = (~name as _="mock-event-log", ~opts as _: Pulumi.CustomResourceOption
|
|
|
15
15
|
let append: ReventlessCore.EventLog.append<string, JSON.t> = async (_seqNr, id, jsons) => {
|
|
16
16
|
if failNextAppends.contents > 0 {
|
|
17
17
|
failNextAppends := failNextAppends.contents - 1
|
|
18
|
-
Error("mock append failure")
|
|
18
|
+
Error(ReventlessCore.EventLog.StorageFailure("mock append failure"))
|
|
19
19
|
} else {
|
|
20
20
|
let existing = events.contents->Dict.get(id)->Option.getOr([])
|
|
21
21
|
events.contents->Dict.set(id, existing->Array.concat(jsons))
|
|
@@ -21,7 +21,10 @@ function make($staropt$star, $staropt$star$1) {
|
|
|
21
21
|
failNextAppends.contents = failNextAppends.contents - 1 | 0;
|
|
22
22
|
return {
|
|
23
23
|
TAG: "Error",
|
|
24
|
-
_0:
|
|
24
|
+
_0: {
|
|
25
|
+
TAG: "StorageFailure",
|
|
26
|
+
_0: "mock append failure"
|
|
27
|
+
}
|
|
25
28
|
};
|
|
26
29
|
}
|
|
27
30
|
let existing = Stdlib_Option.getOr(events.contents[id], []);
|
|
@@ -70,6 +70,70 @@ describe("Backend parity (Memory vs Sqlite)", () => {
|
|
|
70
70
|
await runUnderSqlite(scenario)
|
|
71
71
|
})
|
|
72
72
|
|
|
73
|
+
testPromise("QueryDb: count returns a running total and loadStream reflects it under both", async () => {
|
|
74
|
+
let scenario = async () => {
|
|
75
|
+
module TestBus = LocalBus.Make()
|
|
76
|
+
module Storage = QueryDbStorage_InMemory.Make(TestBus)
|
|
77
|
+
let s = Storage.make(~name="parity-count", ~indexes=[], ~api=(), ~apiRole=(), ~opts)
|
|
78
|
+
let ops = await s.operations->TestRunner.resolve
|
|
79
|
+
|
|
80
|
+
// First increment creates the counter item and returns the new total.
|
|
81
|
+
let r1 = await ops.count("prod-1", "orderCount", 3)
|
|
82
|
+
expect(r1)->toEqual(Ok(3))
|
|
83
|
+
// Second increment accumulates (not just echoes the increment).
|
|
84
|
+
let r2 = await ops.count("prod-1", "orderCount", 2)
|
|
85
|
+
expect(r2)->toEqual(Ok(5))
|
|
86
|
+
|
|
87
|
+
// loadStream must see the persisted counter — count is not a side channel.
|
|
88
|
+
let items =
|
|
89
|
+
await ops.loadStream("prod-1")
|
|
90
|
+
->Stream.runCollect
|
|
91
|
+
->Effect.catchAll(_ => Effect.succeed([]))
|
|
92
|
+
->Effect.runPromise
|
|
93
|
+
expect(items->Array.length)->toBe(1)
|
|
94
|
+
let field =
|
|
95
|
+
items
|
|
96
|
+
->Array.getUnsafe(0)
|
|
97
|
+
->JSON.Decode.object
|
|
98
|
+
->Option.flatMap(o => o->Dict.get("orderCount"))
|
|
99
|
+
->Option.flatMap(JSON.Decode.float)
|
|
100
|
+
->Option.mapOr(0, Float.toInt)
|
|
101
|
+
expect(field)->toBe(5)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
await runUnderMemory(scenario)
|
|
105
|
+
await runUnderSqlite(scenario)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
testPromise("QueryDb: an expired-TTL item is filtered from loadStream under both", async () => {
|
|
109
|
+
let scenario = async () => {
|
|
110
|
+
module TestBus = LocalBus.Make()
|
|
111
|
+
module Storage = QueryDbStorage_InMemory.Make(TestBus)
|
|
112
|
+
let s = Storage.make(~name="parity-ttl", ~indexes=[], ~api=(), ~apiRole=(), ~opts)
|
|
113
|
+
let ops = await s.operations->TestRunner.resolve
|
|
114
|
+
|
|
115
|
+
let item = k => JSON.Encode.object(Dict.fromArray([("id", JSON.Encode.string(k))]))
|
|
116
|
+
let readCount = async k =>
|
|
117
|
+
(
|
|
118
|
+
await ops.loadStream(k)
|
|
119
|
+
->Stream.runCollect
|
|
120
|
+
->Effect.catchAll(_ => Effect.succeed([]))
|
|
121
|
+
->Effect.runPromise
|
|
122
|
+
)->Array.length
|
|
123
|
+
|
|
124
|
+
// A live item (no TTL) and one whose absolute expiry (epoch second 1) is
|
|
125
|
+
// long past — the expired one must not surface under either backend.
|
|
126
|
+
let _ = await ops.save("live", item("live"), ReventlessCore.QueryDb.Any, None)
|
|
127
|
+
let _ = await ops.save("dead", item("dead"), ReventlessCore.QueryDb.Any, Some(1))
|
|
128
|
+
|
|
129
|
+
expect(await readCount("live"))->toBe(1)
|
|
130
|
+
expect(await readCount("dead"))->toBe(0)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
await runUnderMemory(scenario)
|
|
134
|
+
await runUnderSqlite(scenario)
|
|
135
|
+
})
|
|
136
|
+
|
|
73
137
|
testPromise("EventLog: conflict detection works under both", async () => {
|
|
74
138
|
let scenario = async () => {
|
|
75
139
|
module TestBus = LocalBus.Make()
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
3
|
import * as Stream from "@reventlessdev/rescript-effect/src/Stream.res.mjs";
|
|
4
|
+
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
5
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
4
6
|
import * as Effect from "effect/Effect";
|
|
5
7
|
import * as LocalBus$ReventlessLocal from "../../src/adapter/LocalBus.res.mjs";
|
|
6
8
|
import * as TestRunner$ReventlessLocal from "../../src/test/TestRunner.res.mjs";
|
|
@@ -65,6 +67,49 @@ globalThis.describe("Backend parity (Memory vs Sqlite)", () => {
|
|
|
65
67
|
await runUnderMemory(scenario);
|
|
66
68
|
return await runUnderSqlite(scenario);
|
|
67
69
|
});
|
|
70
|
+
globalThis.test("QueryDb: count returns a running total and loadStream reflects it under both", async () => {
|
|
71
|
+
let scenario = async () => {
|
|
72
|
+
let TestBus = LocalBus$ReventlessLocal.Make({});
|
|
73
|
+
let Storage = QueryDbStorage_InMemory$ReventlessLocal.Make(TestBus);
|
|
74
|
+
let s = Storage.make("parity-count", [], undefined, undefined, undefined, undefined, opts);
|
|
75
|
+
let ops = await TestRunner$ReventlessLocal.resolve(s.operations);
|
|
76
|
+
let r1 = await ops.count("prod-1", "orderCount", 3);
|
|
77
|
+
globalThis.expect(r1).toEqual({
|
|
78
|
+
TAG: "Ok",
|
|
79
|
+
_0: 3
|
|
80
|
+
});
|
|
81
|
+
let r2 = await ops.count("prod-1", "orderCount", 2);
|
|
82
|
+
globalThis.expect(r2).toEqual({
|
|
83
|
+
TAG: "Ok",
|
|
84
|
+
_0: 5
|
|
85
|
+
});
|
|
86
|
+
let items = await Effect.runPromise(Effect.catchAll(Stream.runCollect(ops.loadStream("prod-1")), param => Effect.succeed([])));
|
|
87
|
+
globalThis.expect(items.length).toBe(1);
|
|
88
|
+
let field = Stdlib_Option.mapOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(items[0]), o => o["orderCount"]), Stdlib_JSON.Decode.float), 0, prim => prim | 0);
|
|
89
|
+
globalThis.expect(field).toBe(5);
|
|
90
|
+
};
|
|
91
|
+
await runUnderMemory(scenario);
|
|
92
|
+
return await runUnderSqlite(scenario);
|
|
93
|
+
});
|
|
94
|
+
globalThis.test("QueryDb: an expired-TTL item is filtered from loadStream under both", async () => {
|
|
95
|
+
let scenario = async () => {
|
|
96
|
+
let TestBus = LocalBus$ReventlessLocal.Make({});
|
|
97
|
+
let Storage = QueryDbStorage_InMemory$ReventlessLocal.Make(TestBus);
|
|
98
|
+
let s = Storage.make("parity-ttl", [], undefined, undefined, undefined, undefined, opts);
|
|
99
|
+
let ops = await TestRunner$ReventlessLocal.resolve(s.operations);
|
|
100
|
+
let item = k => Object.fromEntries([[
|
|
101
|
+
"id",
|
|
102
|
+
k
|
|
103
|
+
]]);
|
|
104
|
+
let readCount = async k => (await Effect.runPromise(Effect.catchAll(Stream.runCollect(ops.loadStream(k)), param => Effect.succeed([])))).length;
|
|
105
|
+
await ops.save("live", item("live"), "Any", undefined);
|
|
106
|
+
await ops.save("dead", item("dead"), "Any", 1);
|
|
107
|
+
globalThis.expect(await readCount("live")).toBe(1);
|
|
108
|
+
globalThis.expect(await readCount("dead")).toBe(0);
|
|
109
|
+
};
|
|
110
|
+
await runUnderMemory(scenario);
|
|
111
|
+
return await runUnderSqlite(scenario);
|
|
112
|
+
});
|
|
68
113
|
globalThis.test("EventLog: conflict detection works under both", async () => {
|
|
69
114
|
let scenario = async () => {
|
|
70
115
|
let TestBus = LocalBus$ReventlessLocal.Make({});
|
|
@@ -51,10 +51,12 @@ describe("EventLogStorage_Sqlite", () => {
|
|
|
51
51
|
let e = JSON.Encode.object(Dict.fromArray([("t", JSON.Encode.string("X"))]))
|
|
52
52
|
let _ = await ops.append(0, "id-c", [e])
|
|
53
53
|
|
|
54
|
-
// Caller re-uses seqNr=0 — conflict
|
|
54
|
+
// Caller re-uses seqNr=0 — a genuine conflict must be the typed Conflict
|
|
55
|
+
// sentinel (not a StorageFailure), so the core retry loop treats it as OCC.
|
|
55
56
|
let result = await ops.append(0, "id-c", [e])
|
|
56
57
|
switch result {
|
|
57
|
-
| Error(
|
|
58
|
+
| Error(ReventlessCore.EventLog.Conflict) => expect(true)->toBe(true)
|
|
59
|
+
| Error(StorageFailure(msg)) => expect("expected Conflict, got StorageFailure")->toBe(msg)
|
|
58
60
|
| Ok() => expect("expected conflict")->toBe("actual Ok")
|
|
59
61
|
}
|
|
60
62
|
})
|
|
@@ -65,7 +65,12 @@ globalThis.describe("EventLogStorage_Sqlite", () => {
|
|
|
65
65
|
globalThis.expect("expected conflict").toBe("actual Ok");
|
|
66
66
|
return;
|
|
67
67
|
}
|
|
68
|
-
|
|
68
|
+
let msg = result._0;
|
|
69
|
+
if (typeof msg !== "object") {
|
|
70
|
+
globalThis.expect(true).toBe(true);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
globalThis.expect("expected Conflict, got StorageFailure").toBe(msg._0);
|
|
69
74
|
});
|
|
70
75
|
globalThis.test("replay returns empty array for unknown id", async () => {
|
|
71
76
|
let TestBus = LocalBus$ReventlessLocal.Make({});
|
|
@@ -78,4 +78,35 @@ describe("LocalBus PubSub (Phase F)", () => {
|
|
|
78
78
|
expect(countB.contents)->toBe(0)
|
|
79
79
|
})
|
|
80
80
|
})
|
|
81
|
+
|
|
82
|
+
// A5: a throwing/rejecting subscriber must not (a) hang publishEvent (its
|
|
83
|
+
// done_ countdown never reaching zero) nor (b) kill the drain fiber so the
|
|
84
|
+
// topic stops working. If either regressed, these tests would time out.
|
|
85
|
+
describe("failing subscriber", () => {
|
|
86
|
+
testPromise("a throwing subscriber does not hang publish; healthy siblings still receive it", async () => {
|
|
87
|
+
module TestBus = LocalBus.Make()
|
|
88
|
+
let healthy = ref(0)
|
|
89
|
+
TestBus.subscribeToEvents("T", async (_, _, _) => JsError.throwWithMessage("boom"))
|
|
90
|
+
TestBus.subscribeToEvents("T", async (_, _, _) => {
|
|
91
|
+
healthy := healthy.contents + 1
|
|
92
|
+
})
|
|
93
|
+
// Must resolve — not hang — even though one subscriber threw.
|
|
94
|
+
await TestBus.publishEvent("T", "svc", defaultMeta, JSON.Null)
|
|
95
|
+
expect(healthy.contents)->toBe(1)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
testPromise("the topic keeps working after a subscriber throws (drain fiber survives)", async () => {
|
|
99
|
+
module TestBus = LocalBus.Make()
|
|
100
|
+
let healthy = ref(0)
|
|
101
|
+
TestBus.subscribeToEvents("T", async (_, _, _) => JsError.throwWithMessage("boom"))
|
|
102
|
+
TestBus.subscribeToEvents("T", async (_, _, _) => {
|
|
103
|
+
healthy := healthy.contents + 1
|
|
104
|
+
})
|
|
105
|
+
await TestBus.publishEvent("T", "svc", defaultMeta, JSON.Null)
|
|
106
|
+
// Second publish on the same topic must also complete and be delivered —
|
|
107
|
+
// proving the dead-fiber cascade is gone.
|
|
108
|
+
await TestBus.publishEvent("T", "svc", defaultMeta, JSON.Null)
|
|
109
|
+
expect(healthy.contents)->toBe(2)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
81
112
|
})
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
|
+
import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
|
|
3
4
|
import * as LocalBus$ReventlessLocal from "../../src/adapter/LocalBus.res.mjs";
|
|
4
5
|
import * as TestRunner$ReventlessLocal from "../../src/test/TestRunner.res.mjs";
|
|
5
6
|
|
|
@@ -73,6 +74,33 @@ globalThis.describe("LocalBus PubSub (Phase F)", () => {
|
|
|
73
74
|
globalThis.expect(countB.contents).toBe(0);
|
|
74
75
|
});
|
|
75
76
|
});
|
|
77
|
+
globalThis.describe("failing subscriber", () => {
|
|
78
|
+
globalThis.test("a throwing subscriber does not hang publish; healthy siblings still receive it", async () => {
|
|
79
|
+
let TestBus = LocalBus$ReventlessLocal.Make({});
|
|
80
|
+
let healthy = {
|
|
81
|
+
contents: 0
|
|
82
|
+
};
|
|
83
|
+
TestBus.subscribeToEvents("T", async (param, param$1, param$2) => Stdlib_JsError.throwWithMessage("boom"));
|
|
84
|
+
TestBus.subscribeToEvents("T", async (param, param$1, param$2) => {
|
|
85
|
+
healthy.contents = healthy.contents + 1 | 0;
|
|
86
|
+
});
|
|
87
|
+
await TestBus.publishEvent("T", "svc", defaultMeta, null);
|
|
88
|
+
globalThis.expect(healthy.contents).toBe(1);
|
|
89
|
+
});
|
|
90
|
+
globalThis.test("the topic keeps working after a subscriber throws (drain fiber survives)", async () => {
|
|
91
|
+
let TestBus = LocalBus$ReventlessLocal.Make({});
|
|
92
|
+
let healthy = {
|
|
93
|
+
contents: 0
|
|
94
|
+
};
|
|
95
|
+
TestBus.subscribeToEvents("T", async (param, param$1, param$2) => Stdlib_JsError.throwWithMessage("boom"));
|
|
96
|
+
TestBus.subscribeToEvents("T", async (param, param$1, param$2) => {
|
|
97
|
+
healthy.contents = healthy.contents + 1 | 0;
|
|
98
|
+
});
|
|
99
|
+
await TestBus.publishEvent("T", "svc", defaultMeta, null);
|
|
100
|
+
await TestBus.publishEvent("T", "svc", defaultMeta, null);
|
|
101
|
+
globalThis.expect(healthy.contents).toBe(2);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
76
104
|
});
|
|
77
105
|
|
|
78
106
|
export {
|
|
@@ -140,6 +140,7 @@ describe("InboundTranslationSlice Callback", () => {
|
|
|
140
140
|
})
|
|
141
141
|
|
|
142
142
|
let targetName = "ConfirmPayment"
|
|
143
|
+
let externalSystem = None
|
|
143
144
|
|
|
144
145
|
let translate = (input: externalInput) =>
|
|
145
146
|
Ok(
|
|
@@ -206,6 +207,7 @@ describe("InboundTranslationSlice Callback", () => {
|
|
|
206
207
|
})
|
|
207
208
|
|
|
208
209
|
let targetName = "ConfirmPayment"
|
|
210
|
+
let externalSystem = None
|
|
209
211
|
|
|
210
212
|
let translate = (_input: externalInput) => Ok([])
|
|
211
213
|
|
package/tests/components/inboundtranslationslice/InboundTranslationSliceCallbackTest.res.mjs
CHANGED
|
@@ -22,6 +22,7 @@ let Callback = InboundTranslationSlice_Callback$ReventlessCore.Make({
|
|
|
22
22
|
externalInputSchema: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.externalInputSchema,
|
|
23
23
|
commandSchema: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.commandSchema,
|
|
24
24
|
targetName: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.targetName,
|
|
25
|
+
externalSystem: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.externalSystem,
|
|
25
26
|
commandAuthorization: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.commandAuthorization
|
|
26
27
|
})(PaymentWebhookTranslation);
|
|
27
28
|
|
|
@@ -153,6 +154,7 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
|
|
|
153
154
|
externalInputSchema: externalInputSchema,
|
|
154
155
|
commandSchema: commandSchema,
|
|
155
156
|
targetName: "ConfirmPayment",
|
|
157
|
+
externalSystem: undefined,
|
|
156
158
|
commandAuthorization: commandAuthorization
|
|
157
159
|
})(MultiTranslation);
|
|
158
160
|
let publishedCommands = {
|
|
@@ -210,6 +212,7 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
|
|
|
210
212
|
externalInputSchema: externalInputSchema,
|
|
211
213
|
commandSchema: commandSchema,
|
|
212
214
|
targetName: "ConfirmPayment",
|
|
215
|
+
externalSystem: undefined,
|
|
213
216
|
commandAuthorization: commandAuthorization
|
|
214
217
|
})(EmptyTranslation);
|
|
215
218
|
let publishedCommands = {
|
|
@@ -26,6 +26,7 @@ module PaymentWebhookSpec = {
|
|
|
26
26
|
type command = ConfirmPayment({orderId: @s.matches(Reventless.DcbTag.string) string, paymentId: string})
|
|
27
27
|
|
|
28
28
|
let targetName = "ConfirmPayment"
|
|
29
|
+
let externalSystem = None
|
|
29
30
|
|
|
30
31
|
let translate = (input: externalInput) =>
|
|
31
32
|
switch input.status {
|
package/tests/components/outboundtranslationslice/OutboundTranslationSliceCallbackTest.res.mjs
CHANGED
|
@@ -37,7 +37,8 @@ let FireForgetCallback = OutboundTranslationSlice_Callback$ReventlessCore.Make({
|
|
|
37
37
|
inboundCommandSchema: OutboundTranslationSliceFixtures$ReventlessLocal.SendTrackingEmailSpec.inboundCommandSchema,
|
|
38
38
|
maxRetries: OutboundTranslationSliceFixtures$ReventlessLocal.SendTrackingEmailSpec.maxRetries,
|
|
39
39
|
heartbeatInterval: OutboundTranslationSliceFixtures$ReventlessLocal.SendTrackingEmailSpec.heartbeatInterval,
|
|
40
|
-
targetName: OutboundTranslationSliceFixtures$ReventlessLocal.SendTrackingEmailSpec.targetName
|
|
40
|
+
targetName: OutboundTranslationSliceFixtures$ReventlessLocal.SendTrackingEmailSpec.targetName,
|
|
41
|
+
externalSystem: OutboundTranslationSliceFixtures$ReventlessLocal.SendTrackingEmailSpec.externalSystem
|
|
41
42
|
})(SendTrackingEmailTranslation);
|
|
42
43
|
|
|
43
44
|
let CommandBackCallback = OutboundTranslationSlice_Callback$ReventlessCore.Make({
|
|
@@ -48,7 +49,8 @@ let CommandBackCallback = OutboundTranslationSlice_Callback$ReventlessCore.Make(
|
|
|
48
49
|
inboundCommandSchema: OutboundTranslationSliceFixtures$ReventlessLocal.ProcessPaymentSpec.inboundCommandSchema,
|
|
49
50
|
maxRetries: OutboundTranslationSliceFixtures$ReventlessLocal.ProcessPaymentSpec.maxRetries,
|
|
50
51
|
heartbeatInterval: OutboundTranslationSliceFixtures$ReventlessLocal.ProcessPaymentSpec.heartbeatInterval,
|
|
51
|
-
targetName: OutboundTranslationSliceFixtures$ReventlessLocal.ProcessPaymentSpec.targetName
|
|
52
|
+
targetName: OutboundTranslationSliceFixtures$ReventlessLocal.ProcessPaymentSpec.targetName,
|
|
53
|
+
externalSystem: OutboundTranslationSliceFixtures$ReventlessLocal.ProcessPaymentSpec.externalSystem
|
|
52
54
|
})(ProcessPaymentTranslation);
|
|
53
55
|
|
|
54
56
|
globalThis.describe("OutboundTranslationSlice Callback", () => {
|
|
@@ -40,6 +40,7 @@ module SendTrackingEmailSpec = {
|
|
|
40
40
|
let maxRetries = 3
|
|
41
41
|
let heartbeatInterval = 60
|
|
42
42
|
let targetName = None
|
|
43
|
+
let externalSystem = None
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
// ─────────────────────────────────────────────────────────────
|
|
@@ -75,4 +76,5 @@ module ProcessPaymentSpec = {
|
|
|
75
76
|
let maxRetries = 2
|
|
76
77
|
let heartbeatInterval = 30
|
|
77
78
|
let targetName = Some("ConfirmPayment")
|
|
79
|
+
let externalSystem = None
|
|
78
80
|
}
|
|
@@ -64,7 +64,8 @@ let SendTrackingEmailSpec = {
|
|
|
64
64
|
translate: translate,
|
|
65
65
|
maxRetries: 3,
|
|
66
66
|
heartbeatInterval: 60,
|
|
67
|
-
targetName: undefined
|
|
67
|
+
targetName: undefined,
|
|
68
|
+
externalSystem: undefined
|
|
68
69
|
};
|
|
69
70
|
|
|
70
71
|
let moduleUrl$2 = import.meta.url;
|
|
@@ -126,7 +127,8 @@ let ProcessPaymentSpec = {
|
|
|
126
127
|
translate: translate$1,
|
|
127
128
|
maxRetries: 2,
|
|
128
129
|
heartbeatInterval: 30,
|
|
129
|
-
targetName: ProcessPaymentSpec_targetName
|
|
130
|
+
targetName: ProcessPaymentSpec_targetName,
|
|
131
|
+
externalSystem: undefined
|
|
130
132
|
};
|
|
131
133
|
|
|
132
134
|
export {
|