@reventlessdev/reventless-aws 3.0.0-alpha.177 → 3.0.0-alpha.179

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.
Files changed (74) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/docker-compose.postgres.yml +21 -0
  3. package/package.json +6 -5
  4. package/scripts/run-pg-integration-tests.sh +58 -0
  5. package/src/Platform.res +37 -0
  6. package/src/Platform.res.mjs +41 -4
  7. package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res +7 -1
  8. package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res.mjs +10 -1
  9. package/src/adapter/Api/Platform_UIFragments_Lambda.res +7 -1
  10. package/src/adapter/Api/Platform_UIFragments_Lambda.res.mjs +10 -1
  11. package/src/adapter/Counter/CounterHandler_DynamoDbStream.res +6 -21
  12. package/src/adapter/Counter/CounterHandler_DynamoDbStream.res.mjs +2 -17
  13. package/src/adapter/DcbEventLog/DcbBackend.res +4 -0
  14. package/src/adapter/DcbEventLog/DcbEventLogStorage_Postgres.res +2 -2
  15. package/src/adapter/DcbEventLog/DcbEventLogStorage_Postgres.res.mjs +7 -1
  16. package/src/adapter/DcbEventLog/DcbEventLogStorage_Postgres_Runtime.res +4 -2
  17. package/src/adapter/DcbEventLog/DcbEventLogStorage_Postgres_Runtime.res.mjs +2 -2
  18. package/src/adapter/EventLog/EventLogStorage_Postgres_Runtime.res +3 -1
  19. package/src/adapter/EventLog/EventLogStorage_Postgres_Runtime.res.mjs +2 -2
  20. package/src/adapter/Postgres/PgChangeFeedRelay_Builder.res +1 -11
  21. package/src/adapter/Postgres/PgChangeFeedRelay_Builder.res.mjs +2 -24
  22. package/src/adapter/Postgres/PgConnection.res +70 -10
  23. package/src/adapter/Postgres/PgConnection.res.mjs +48 -3
  24. package/src/adapter/Postgres/PgMigration_Builder.res +127 -0
  25. package/src/adapter/Postgres/PgMigration_Builder.res.mjs +51 -0
  26. package/src/adapter/QueryDb/PgQueryResolver_Builder.res +11 -13
  27. package/src/adapter/QueryDb/PgQueryResolver_Builder.res.mjs +5 -24
  28. package/src/adapter/QueryDb/PgQueryResolver_Lambda.res +51 -3
  29. package/src/adapter/QueryDb/PgQueryResolver_Lambda.res.mjs +48 -18
  30. package/src/adapter/QueryDb/QueryDbBackend.res +8 -0
  31. package/src/adapter/QueryDb/QueryDbBackend.res.mjs +4 -1
  32. package/src/adapter/QueryDb/QueryDbResolvers.res.mjs +1 -1
  33. package/src/adapter/QueryDb/QueryDbResolvers_Lambda.res +31 -7
  34. package/src/adapter/QueryDb/QueryDbResolvers_Lambda.res.mjs +16 -10
  35. package/src/adapter/QueryDb/QueryDbStorage.res +6 -1
  36. package/src/adapter/QueryDb/QueryDbStorage.res.mjs +2 -1
  37. package/src/adapter/QueryDb/QueryDbStorage_Postgres.res.mjs +1 -1
  38. package/src/adapter/QueryDb/QueryDbStorage_Postgres_Runtime.res +3 -1
  39. package/src/adapter/QueryDb/QueryDbStorage_Postgres_Runtime.res.mjs +2 -2
  40. package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res +3 -14
  41. package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res.mjs +2 -25
  42. package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res +3 -14
  43. package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res.mjs +2 -25
  44. package/src/adapter/Runtime/DcbCommandTopicEntryPoint.mjs +7 -1
  45. package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res +78 -15
  46. package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res.mjs +43 -26
  47. package/src/adapter/Runtime/PgMigrationEntryPoint.mjs +40 -0
  48. package/src/adapter/Runtime/ReadModelEntryPoint.mjs +17 -5
  49. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res +10 -0
  50. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res.mjs +3 -0
  51. package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res +9 -14
  52. package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res.mjs +2 -25
  53. package/src/adapter/Runtime/StateTopicPublish.mjs +204 -0
  54. package/src/adapter/Runtime/StateViewSliceEntryPoint.mjs +17 -3
  55. package/src/adapter/Runtime/StateViewSliceRuntime_Builder_Single.res +86 -17
  56. package/src/adapter/Runtime/StateViewSliceRuntime_Builder_Single.res.mjs +50 -33
  57. package/src/adapter/StateTopic/StateTopic_AppSync.res +7 -1
  58. package/src/adapter/StateTopic/StateTopic_AppSync.res.mjs +10 -1
  59. package/src/plugin/cloner/ClonerRunner_Fargate.res +7 -1
  60. package/src/plugin/cloner/ClonerRunner_Fargate.res.mjs +10 -1
  61. package/src/util/Util_Bundle.res +71 -1
  62. package/src/util/Util_Bundle.res.mjs +49 -1
  63. package/tests/PgChangeFeedRelay_IntegrationTest.res +3 -3
  64. package/tests/PgConnectionJsonTest.res +84 -0
  65. package/tests/PgConnectionJsonTest.res.mjs +68 -0
  66. package/tests/PgMigrationEntryPoint_IntegrationTest.res +82 -0
  67. package/tests/PgMigrationEntryPoint_IntegrationTest.res.mjs +63 -0
  68. package/tests/PgPipeline_IntegrationTest.res +3 -1
  69. package/tests/PgQueryResolver_LambdaTest.res +59 -0
  70. package/tests/PgQueryResolver_LambdaTest.res.mjs +95 -16
  71. package/tests/StateTopicPublishTest.res +162 -0
  72. package/tests/StateTopicPublishTest.res.mjs +158 -0
  73. package/tests/Util_BundleEsmLoaderTest.res +57 -0
  74. package/tests/Util_BundleEsmLoaderTest.res.mjs +44 -0
@@ -157,7 +157,7 @@ function ids(arr) {
157
157
 
158
158
  globalThis.describe("PgQueryResolver_Lambda.dispatch", () => {
159
159
  globalThis.test("getById returns the item (id-carrying)", async () => {
160
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), mkPayload("getById", undefined, Object.fromEntries([[
160
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("getById", undefined, Object.fromEntries([[
161
161
  "id",
162
162
  "p-2"
163
163
  ]]), undefined));
@@ -165,14 +165,14 @@ globalThis.describe("PgQueryResolver_Lambda.dispatch", () => {
165
165
  globalThis.expect(str(r, "name")).toEqual("Bravo");
166
166
  });
167
167
  globalThis.test("getById miss → null", async () => {
168
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), mkPayload("getById", undefined, Object.fromEntries([[
168
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("getById", undefined, Object.fromEntries([[
169
169
  "id",
170
170
  "absent"
171
171
  ]]), undefined));
172
172
  globalThis.expect(r).toBe(null);
173
173
  });
174
174
  globalThis.test("byIds returns matches, drops missing", async () => {
175
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), mkPayload("byIds", undefined, Object.fromEntries([[
175
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("byIds", undefined, Object.fromEntries([[
176
176
  "ids",
177
177
  [
178
178
  "p-1",
@@ -186,7 +186,7 @@ globalThis.describe("PgQueryResolver_Lambda.dispatch", () => {
186
186
  ]);
187
187
  });
188
188
  globalThis.test("index routes to indexLookup on the idField", async () => {
189
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), mkPayload("index", "byStatus", Object.fromEntries([[
189
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("index", "byStatus", Object.fromEntries([[
190
190
  "byStatus",
191
191
  "active"
192
192
  ]]), undefined));
@@ -197,13 +197,13 @@ globalThis.describe("PgQueryResolver_Lambda.dispatch", () => {
197
197
  });
198
198
  globalThis.test("list with push-down → returns the connection listPage gives", async () => {
199
199
  listPageReturn.contents = "PUSHED";
200
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), mkPayload("list", undefined, undefined, undefined));
200
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("list", undefined, undefined, undefined));
201
201
  listPageReturn.contents = undefined;
202
202
  globalThis.expect(r).toEqual("PUSHED");
203
203
  });
204
204
  globalThis.test("list without push-down → falls back to the shared spec", async () => {
205
205
  listPageReturn.contents = undefined;
206
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), mkPayload("list", undefined, undefined, undefined));
206
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("list", undefined, undefined, undefined));
207
207
  let edgeIds = Stdlib_Array.filterMap(Stdlib_Option.getOr(Stdlib_Option.flatMap(field(r, "edges"), Stdlib_JSON.Decode.array), []), e => Stdlib_Option.flatMap(field(e, "node"), n => str(n, "id")));
208
208
  globalThis.expect(edgeIds).toEqual([
209
209
  "p-1",
@@ -212,14 +212,14 @@ globalThis.describe("PgQueryResolver_Lambda.dispatch", () => {
212
212
  ]);
213
213
  });
214
214
  globalThis.test("items with subId routes to itemsPage (id echoed)", async () => {
215
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, "seq", undefined), mkPayload("items", undefined, Object.fromEntries([[
215
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, "seq", undefined), undefined, mkPayload("items", undefined, Object.fromEntries([[
216
216
  "id",
217
217
  "p-2"
218
218
  ]]), undefined));
219
219
  globalThis.expect(str(r, "itemsFor")).toEqual("p-2");
220
220
  });
221
221
  globalThis.test("items without subId → empty connection", async () => {
222
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), mkPayload("items", undefined, Object.fromEntries([[
222
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("items", undefined, Object.fromEntries([[
223
223
  "id",
224
224
  "p-2"
225
225
  ]]), undefined));
@@ -227,14 +227,14 @@ globalThis.describe("PgQueryResolver_Lambda.dispatch", () => {
227
227
  });
228
228
  globalThis.test("authorization DenyAll → empty shape per kind", async () => {
229
229
  let b = makeBinding("DenyAll", undefined, undefined);
230
- let single = await PgQueryResolver_Lambda$ReventlessAws.dispatch(b, mkPayload("getById", undefined, Object.fromEntries([[
230
+ let single = await PgQueryResolver_Lambda$ReventlessAws.dispatch(b, undefined, mkPayload("getById", undefined, Object.fromEntries([[
231
231
  "id",
232
232
  "p-1"
233
233
  ]]), undefined));
234
234
  globalThis.expect(single).toBe(null);
235
- let list = await PgQueryResolver_Lambda$ReventlessAws.dispatch(b, mkPayload("list", undefined, undefined, undefined));
235
+ let list = await PgQueryResolver_Lambda$ReventlessAws.dispatch(b, undefined, mkPayload("list", undefined, undefined, undefined));
236
236
  globalThis.expect(Stdlib_Option.getOr(Stdlib_Option.flatMap(field(list, "edges"), Stdlib_JSON.Decode.array), []).length).toBe(0);
237
- let byIds = await PgQueryResolver_Lambda$ReventlessAws.dispatch(b, mkPayload("byIds", undefined, Object.fromEntries([[
237
+ let byIds = await PgQueryResolver_Lambda$ReventlessAws.dispatch(b, undefined, mkPayload("byIds", undefined, Object.fromEntries([[
238
238
  "ids",
239
239
  []
240
240
  ]]), undefined));
@@ -245,7 +245,7 @@ globalThis.describe("PgQueryResolver_Lambda.dispatch", () => {
245
245
  TAG: "Deny",
246
246
  _0: "nope"
247
247
  }));
248
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), mkPayload("getById", undefined, Object.fromEntries([[
248
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("getById", undefined, Object.fromEntries([[
249
249
  "id",
250
250
  "p-1"
251
251
  ]]), undefined));
@@ -271,7 +271,7 @@ globalThis.describe("PgQueryResolver_Lambda.dispatch", () => {
271
271
  arguments: payload_arguments,
272
272
  identity: Identity$Reventless.anonymous
273
273
  };
274
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), payload);
274
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, payload);
275
275
  globalThis.expect(str(r, "id")).toEqual("p-2");
276
276
  });
277
277
  globalThis.test("resolveOne multi via target index → array", async () => {
@@ -297,7 +297,7 @@ globalThis.describe("PgQueryResolver_Lambda.dispatch", () => {
297
297
  arguments: payload_arguments,
298
298
  identity: Identity$Reventless.anonymous
299
299
  };
300
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), payload);
300
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, payload);
301
301
  globalThis.expect(ids(r)).toEqual([
302
302
  "p-1",
303
303
  "p-3"
@@ -324,7 +324,7 @@ globalThis.describe("PgQueryResolver_Lambda.dispatch", () => {
324
324
  arguments: payload_arguments,
325
325
  identity: Identity$Reventless.anonymous
326
326
  };
327
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), payload);
327
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, payload);
328
328
  globalThis.expect(ids(r)).toEqual([
329
329
  "p-1",
330
330
  "p-3"
@@ -361,10 +361,89 @@ globalThis.describe("PgQueryResolver_Lambda.dispatch", () => {
361
361
  let r = await PgQueryResolver_Lambda$ReventlessAws.handler(payload, null);
362
362
  globalThis.expect(r).toBe(null);
363
363
  });
364
+ let authStore = Object.fromEntries([[
365
+ "active",
366
+ Object.fromEntries([[
367
+ "ownerId",
368
+ "alice"
369
+ ]])
370
+ ]]);
371
+ let authOps_load = async id => ({
372
+ TAG: "Ok",
373
+ _0: Stdlib_Option.mapOr(authStore[id], [], r => [r])
374
+ });
375
+ let authOps = {
376
+ load: authOps_load,
377
+ loadStream: 0,
378
+ save: 0,
379
+ saveBatch: 0,
380
+ count: 0,
381
+ delete: 0,
382
+ deleteBatch: 0
383
+ };
384
+ let init = makeBinding(undefined, undefined, undefined);
385
+ let authBinding_pushdowns = init.pushdowns;
386
+ let authBinding_indexes = init.indexes;
387
+ let authBinding_subIdField = init.subIdField;
388
+ let authBinding_capability = init.capability;
389
+ let authBinding_labelField = init.labelField;
390
+ let authBinding_includeIdParam = init.includeIdParam;
391
+ let authBinding_authorization = init.authorization;
392
+ let authBinding = {
393
+ ops: authOps,
394
+ pushdowns: authBinding_pushdowns,
395
+ indexes: authBinding_indexes,
396
+ subIdField: authBinding_subIdField,
397
+ capability: authBinding_capability,
398
+ labelField: authBinding_labelField,
399
+ includeIdParam: authBinding_includeIdParam,
400
+ authorization: authBinding_authorization
401
+ };
402
+ let lookupAuth = name => {
403
+ if (name === "AuthTable") {
404
+ return authBinding;
405
+ }
406
+ };
407
+ let ident = (username, groups) => ({
408
+ userId: username,
409
+ username: username,
410
+ groups: groups,
411
+ provider: "InMemory"
412
+ });
413
+ let authIndexPayload = (username, groups) => ({
414
+ readModelName: "Things",
415
+ kind: "index",
416
+ index: "byStatus",
417
+ authTable: "AuthTable",
418
+ authGroup: "Owner",
419
+ arguments: Object.fromEntries([[
420
+ "byStatus",
421
+ "active"
422
+ ]]),
423
+ identity: ident(username, groups)
424
+ });
425
+ globalThis.test("auth index: group member who owns → results", async () => {
426
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), lookupAuth, authIndexPayload("alice", ["Owner"]));
427
+ globalThis.expect(ids(r)).toEqual([
428
+ "p-1",
429
+ "p-3"
430
+ ]);
431
+ });
432
+ globalThis.test("auth index: group member who does NOT own → empty", async () => {
433
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), lookupAuth, authIndexPayload("bob", ["Owner"]));
434
+ globalThis.expect(Stdlib_Option.getOr(Stdlib_JSON.Decode.array(r), []).length).toBe(0);
435
+ });
436
+ globalThis.test("auth index: non-member passes through → results", async () => {
437
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), lookupAuth, authIndexPayload("bob", []));
438
+ globalThis.expect(ids(r)).toEqual([
439
+ "p-1",
440
+ "p-3"
441
+ ]);
442
+ });
364
443
  globalThis.test("unmapped kind throws", async () => {
365
444
  let threw;
366
445
  try {
367
- await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), mkPayload("frobnicate", undefined, undefined, undefined));
446
+ await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("frobnicate", undefined, undefined, undefined));
368
447
  threw = false;
369
448
  } catch (exn) {
370
449
  threw = true;
@@ -0,0 +1,162 @@
1
+ // Unit tests for the Postgres live-update publisher (B3.3a).
2
+ //
3
+ // `withLiveUpdates` wraps a Postgres QueryDb ops set so save/delete also publish
4
+ // an AppSync-Events change descriptor. The publish itself (SigV4 POST) is
5
+ // injected here as a capturing stub, so this exercises the WRAPPING logic
6
+ // headlessly — entity-key composition, changeKind, sortKeyValue extraction, the
7
+ // `option<(field,subValue)>` delete signature, and composite partition-wide
8
+ // delete enumeration via ops.load. The real SigV4/channel wire format matches
9
+ // StateTopic_AppSync (the DynamoDB path) by construction and is not re-asserted
10
+ // here.
11
+
12
+ open JestGlobals
13
+
14
+ // Drive a scenario against withLiveUpdates with a mock ops + capturing publish,
15
+ // entirely in JS (the mock ops are plain JS objects). Returns the captured
16
+ // publish-call array as JSON for assertions.
17
+ let run: string => promise<JSON.t> = %raw(`
18
+ async function(scenario) {
19
+ const mod = await import("../src/adapter/Runtime/StateTopicPublish.mjs");
20
+ const { withLiveUpdates } = mod;
21
+ const captured = [];
22
+ const publish = async (call) => { captured.push(call); };
23
+ const ok = async () => ({ TAG: "Ok", _0: undefined });
24
+
25
+ // Mock ops. load returns the composite partition's rows for the enumerate case.
26
+ const partitionRows = { TAG: "Ok", _0: [
27
+ { id: "o1", lineId: "L1" },
28
+ { id: "o1", lineId: "L2" },
29
+ ]};
30
+ const ops = {
31
+ load: async (id) => partitionRows,
32
+ loadStream: () => {},
33
+ save: ok, saveBatch: ok, count: ok, delete: ok, deleteBatch: ok,
34
+ };
35
+
36
+ const cfg = (subIdField) => ({
37
+ endpoint: "https://x.appsync-api.eu-west-1.amazonaws.com",
38
+ region: "eu-west-1",
39
+ topicName: "Shop_Orders",
40
+ subIdField,
41
+ publish,
42
+ });
43
+
44
+ const w = (subIdField) => withLiveUpdates(ops, cfg(subIdField));
45
+
46
+ switch (scenario) {
47
+ case "save-single":
48
+ await w(undefined).save("p1", { id: "p1", updatedAt: "2024-01-02" }, "Insert", undefined);
49
+ break;
50
+ case "save-composite":
51
+ await w("lineId").save("o1", { id: "o1", lineId: "L2", createdAt: "2024-03-04" }, "Insert", undefined);
52
+ break;
53
+ case "save-no-sortkey":
54
+ await w(undefined).save("p1", { id: "p1" }, "Insert", undefined);
55
+ break;
56
+ case "saveBatch":
57
+ await w(undefined).saveBatch([
58
+ ["a", { id: "a", updatedAt: "t1" }, undefined],
59
+ ["b", { id: "b", updatedAt: "t2" }, undefined],
60
+ ]);
61
+ break;
62
+ case "delete-single":
63
+ await w(undefined).delete("p1", undefined);
64
+ break;
65
+ case "delete-composite-row":
66
+ await w("lineId").delete("o1", ["lineId", "L2"]);
67
+ break;
68
+ case "delete-composite-partition":
69
+ await w("lineId").delete("o1", undefined);
70
+ break;
71
+ case "deleteBatch":
72
+ await w(undefined).deleteBatch([["a", undefined], ["b", undefined]]);
73
+ break;
74
+ case "gated-no-topic": {
75
+ const gatedOps = withLiveUpdates(ops, { endpoint: "https://x", region: "r", topicName: undefined, publish });
76
+ await gatedOps.save("p1", { id: "p1" }, "Insert", undefined);
77
+ break;
78
+ }
79
+ }
80
+ return captured;
81
+ }
82
+ `)
83
+
84
+ let getField = (call: JSON.t, key: string): option<JSON.t> =>
85
+ call->JSON.Decode.object->Option.flatMap(d => d->Dict.get(key))
86
+
87
+ let str = (call, key) => getField(call, key)->Option.flatMap(JSON.Decode.string)
88
+
89
+ let calls = (j: JSON.t): array<JSON.t> => j->JSON.Decode.array->Option.getOr([])
90
+
91
+ describe("withLiveUpdates (B3.3a Postgres live updates)", () => {
92
+ testAsync("save on a single-key table publishes Updated with sortKeyValue", async () => {
93
+ let c = await run("save-single")
94
+ let arr = calls(c)
95
+ expect(arr->Array.length)->toBe(1)
96
+ let call = arr->Array.getUnsafe(0)
97
+ expect(str(call, "entityKey"))->toEqual(Some("p1"))
98
+ expect(str(call, "changeKind"))->toEqual(Some("Updated"))
99
+ expect(str(call, "sortKeyValue"))->toEqual(Some("2024-01-02"))
100
+ expect(str(call, "topicName"))->toEqual(Some("Shop_Orders"))
101
+ })
102
+
103
+ testAsync("save on a composite table uses id-subKey and createdAt fallback", async () => {
104
+ let c = await run("save-composite")
105
+ let call = calls(c)->Array.getUnsafe(0)
106
+ expect(str(call, "entityKey"))->toEqual(Some("o1-L2"))
107
+ expect(str(call, "changeKind"))->toEqual(Some("Updated"))
108
+ expect(str(call, "sortKeyValue"))->toEqual(Some("2024-03-04"))
109
+ })
110
+
111
+ testAsync("save without updatedAt/createdAt omits sortKeyValue", async () => {
112
+ let c = await run("save-no-sortkey")
113
+ let call = calls(c)->Array.getUnsafe(0)
114
+ expect(getField(call, "sortKeyValue"))->toEqual(None)
115
+ })
116
+
117
+ testAsync("saveBatch publishes one Updated per item", async () => {
118
+ let c = await run("saveBatch")
119
+ let arr = calls(c)
120
+ expect(arr->Array.length)->toBe(2)
121
+ expect(str(arr->Array.getUnsafe(0), "entityKey"))->toEqual(Some("a"))
122
+ expect(str(arr->Array.getUnsafe(1), "entityKey"))->toEqual(Some("b"))
123
+ arr->Array.forEach(call => expect(str(call, "changeKind"))->toEqual(Some("Updated")))
124
+ })
125
+
126
+ testAsync("delete on a single-key table publishes Removed for the id", async () => {
127
+ let c = await run("delete-single")
128
+ let call = calls(c)->Array.getUnsafe(0)
129
+ expect(str(call, "entityKey"))->toEqual(Some("p1"))
130
+ expect(str(call, "changeKind"))->toEqual(Some("Removed"))
131
+ })
132
+
133
+ testAsync("delete of a specific composite row uses id-subValue", async () => {
134
+ let c = await run("delete-composite-row")
135
+ let arr = calls(c)
136
+ expect(arr->Array.length)->toBe(1)
137
+ expect(str(arr->Array.getUnsafe(0), "entityKey"))->toEqual(Some("o1-L2"))
138
+ expect(str(arr->Array.getUnsafe(0), "changeKind"))->toEqual(Some("Removed"))
139
+ })
140
+
141
+ testAsync("partition-wide composite delete enumerates rows via load", async () => {
142
+ let c = await run("delete-composite-partition")
143
+ let arr = calls(c)
144
+ expect(arr->Array.length)->toBe(2)
145
+ expect(str(arr->Array.getUnsafe(0), "entityKey"))->toEqual(Some("o1-L1"))
146
+ expect(str(arr->Array.getUnsafe(1), "entityKey"))->toEqual(Some("o1-L2"))
147
+ arr->Array.forEach(call => expect(str(call, "changeKind"))->toEqual(Some("Removed")))
148
+ })
149
+
150
+ testAsync("deleteBatch publishes one Removed per entry", async () => {
151
+ let c = await run("deleteBatch")
152
+ let arr = calls(c)
153
+ expect(arr->Array.length)->toBe(2)
154
+ expect(str(arr->Array.getUnsafe(0), "entityKey"))->toEqual(Some("a"))
155
+ expect(str(arr->Array.getUnsafe(1), "entityKey"))->toEqual(Some("b"))
156
+ })
157
+
158
+ testAsync("no topicName → ops pass through, nothing published", async () => {
159
+ let c = await run("gated-no-topic")
160
+ expect(calls(c)->Array.length)->toBe(0)
161
+ })
162
+ })
@@ -0,0 +1,158 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
+
6
+ let run = (async function(scenario) {
7
+ const mod = await import("../src/adapter/Runtime/StateTopicPublish.mjs");
8
+ const { withLiveUpdates } = mod;
9
+ const captured = [];
10
+ const publish = async (call) => { captured.push(call); };
11
+ const ok = async () => ({ TAG: "Ok", _0: undefined });
12
+
13
+ // Mock ops. load returns the composite partition's rows for the enumerate case.
14
+ const partitionRows = { TAG: "Ok", _0: [
15
+ { id: "o1", lineId: "L1" },
16
+ { id: "o1", lineId: "L2" },
17
+ ]};
18
+ const ops = {
19
+ load: async (id) => partitionRows,
20
+ loadStream: () => {},
21
+ save: ok, saveBatch: ok, count: ok, delete: ok, deleteBatch: ok,
22
+ };
23
+
24
+ const cfg = (subIdField) => ({
25
+ endpoint: "https://x.appsync-api.eu-west-1.amazonaws.com",
26
+ region: "eu-west-1",
27
+ topicName: "Shop_Orders",
28
+ subIdField,
29
+ publish,
30
+ });
31
+
32
+ const w = (subIdField) => withLiveUpdates(ops, cfg(subIdField));
33
+
34
+ switch (scenario) {
35
+ case "save-single":
36
+ await w(undefined).save("p1", { id: "p1", updatedAt: "2024-01-02" }, "Insert", undefined);
37
+ break;
38
+ case "save-composite":
39
+ await w("lineId").save("o1", { id: "o1", lineId: "L2", createdAt: "2024-03-04" }, "Insert", undefined);
40
+ break;
41
+ case "save-no-sortkey":
42
+ await w(undefined).save("p1", { id: "p1" }, "Insert", undefined);
43
+ break;
44
+ case "saveBatch":
45
+ await w(undefined).saveBatch([
46
+ ["a", { id: "a", updatedAt: "t1" }, undefined],
47
+ ["b", { id: "b", updatedAt: "t2" }, undefined],
48
+ ]);
49
+ break;
50
+ case "delete-single":
51
+ await w(undefined).delete("p1", undefined);
52
+ break;
53
+ case "delete-composite-row":
54
+ await w("lineId").delete("o1", ["lineId", "L2"]);
55
+ break;
56
+ case "delete-composite-partition":
57
+ await w("lineId").delete("o1", undefined);
58
+ break;
59
+ case "deleteBatch":
60
+ await w(undefined).deleteBatch([["a", undefined], ["b", undefined]]);
61
+ break;
62
+ case "gated-no-topic": {
63
+ const gatedOps = withLiveUpdates(ops, { endpoint: "https://x", region: "r", topicName: undefined, publish });
64
+ await gatedOps.save("p1", { id: "p1" }, "Insert", undefined);
65
+ break;
66
+ }
67
+ }
68
+ return captured;
69
+ });
70
+
71
+ function getField(call, key) {
72
+ return Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(call), d => d[key]);
73
+ }
74
+
75
+ function str(call, key) {
76
+ return Stdlib_Option.flatMap(getField(call, key), Stdlib_JSON.Decode.string);
77
+ }
78
+
79
+ function calls(j) {
80
+ return Stdlib_Option.getOr(Stdlib_JSON.Decode.array(j), []);
81
+ }
82
+
83
+ globalThis.describe("withLiveUpdates (B3.3a Postgres live updates)", () => {
84
+ globalThis.test("save on a single-key table publishes Updated with sortKeyValue", async () => {
85
+ let c = await run("save-single");
86
+ let arr = calls(c);
87
+ globalThis.expect(arr.length).toBe(1);
88
+ let call = arr[0];
89
+ globalThis.expect(str(call, "entityKey")).toEqual("p1");
90
+ globalThis.expect(str(call, "changeKind")).toEqual("Updated");
91
+ globalThis.expect(str(call, "sortKeyValue")).toEqual("2024-01-02");
92
+ globalThis.expect(str(call, "topicName")).toEqual("Shop_Orders");
93
+ });
94
+ globalThis.test("save on a composite table uses id-subKey and createdAt fallback", async () => {
95
+ let c = await run("save-composite");
96
+ let call = calls(c)[0];
97
+ globalThis.expect(str(call, "entityKey")).toEqual("o1-L2");
98
+ globalThis.expect(str(call, "changeKind")).toEqual("Updated");
99
+ globalThis.expect(str(call, "sortKeyValue")).toEqual("2024-03-04");
100
+ });
101
+ globalThis.test("save without updatedAt/createdAt omits sortKeyValue", async () => {
102
+ let c = await run("save-no-sortkey");
103
+ let call = calls(c)[0];
104
+ globalThis.expect(getField(call, "sortKeyValue")).toEqual(undefined);
105
+ });
106
+ globalThis.test("saveBatch publishes one Updated per item", async () => {
107
+ let c = await run("saveBatch");
108
+ let arr = calls(c);
109
+ globalThis.expect(arr.length).toBe(2);
110
+ globalThis.expect(str(arr[0], "entityKey")).toEqual("a");
111
+ globalThis.expect(str(arr[1], "entityKey")).toEqual("b");
112
+ arr.forEach(call => {
113
+ globalThis.expect(str(call, "changeKind")).toEqual("Updated");
114
+ });
115
+ });
116
+ globalThis.test("delete on a single-key table publishes Removed for the id", async () => {
117
+ let c = await run("delete-single");
118
+ let call = calls(c)[0];
119
+ globalThis.expect(str(call, "entityKey")).toEqual("p1");
120
+ globalThis.expect(str(call, "changeKind")).toEqual("Removed");
121
+ });
122
+ globalThis.test("delete of a specific composite row uses id-subValue", async () => {
123
+ let c = await run("delete-composite-row");
124
+ let arr = calls(c);
125
+ globalThis.expect(arr.length).toBe(1);
126
+ globalThis.expect(str(arr[0], "entityKey")).toEqual("o1-L2");
127
+ globalThis.expect(str(arr[0], "changeKind")).toEqual("Removed");
128
+ });
129
+ globalThis.test("partition-wide composite delete enumerates rows via load", async () => {
130
+ let c = await run("delete-composite-partition");
131
+ let arr = calls(c);
132
+ globalThis.expect(arr.length).toBe(2);
133
+ globalThis.expect(str(arr[0], "entityKey")).toEqual("o1-L1");
134
+ globalThis.expect(str(arr[1], "entityKey")).toEqual("o1-L2");
135
+ arr.forEach(call => {
136
+ globalThis.expect(str(call, "changeKind")).toEqual("Removed");
137
+ });
138
+ });
139
+ globalThis.test("deleteBatch publishes one Removed per entry", async () => {
140
+ let c = await run("deleteBatch");
141
+ let arr = calls(c);
142
+ globalThis.expect(arr.length).toBe(2);
143
+ globalThis.expect(str(arr[0], "entityKey")).toEqual("a");
144
+ globalThis.expect(str(arr[1], "entityKey")).toEqual("b");
145
+ });
146
+ globalThis.test("no topicName → ops pass through, nothing published", async () => {
147
+ let c = await run("gated-no-topic");
148
+ globalThis.expect(calls(c).length).toBe(0);
149
+ });
150
+ });
151
+
152
+ export {
153
+ run,
154
+ getField,
155
+ str,
156
+ calls,
157
+ }
158
+ /* run Not a pure module */
@@ -0,0 +1,57 @@
1
+ open JestGlobals
2
+
3
+ // Guards the ESM self-containment loader (Option C) against silent drift. The
4
+ // load-bearing contract: every code archive that ships the loader files MUST set
5
+ // NODE_OPTIONS=--import pointing at the shipped register-hook.mjs, and the two
6
+ // files must keep cross-referencing each other by the exact filenames used here.
7
+ // See docs/plans/deployed-lambda-esm-self-containment.md.
8
+
9
+ describe("Util_Bundle ESM loader — env-var contract", () => {
10
+ testSync("NODE_OPTIONS --imports the shipped register-hook file by name", () => {
11
+ expect(Util_Bundle.esmLoaderNodeOptions)->toBe(
12
+ `--import file:///var/task/${Util_Bundle.registerHookFileName}`,
13
+ )
14
+ })
15
+
16
+ testSync("fallback dirs cover both the layer and the runtime SDK dir", () => {
17
+ // @aws-sdk/* lives in the runtime dir; @reventlessdev/*, effect, sury in the layer.
18
+ expect(Util_Bundle.esmFallbackDirs->String.includes("/opt/nodejs/node_modules"))->toBe(true)
19
+ expect(Util_Bundle.esmFallbackDirs->String.includes("/var/runtime/node_modules"))->toBe(true)
20
+ })
21
+ })
22
+
23
+ describe("Util_Bundle ESM loader — file cross-references", () => {
24
+ testSync("register-hook registers exactly the layer-resolver filename", () => {
25
+ expect(Util_Bundle.registerHookSource->String.includes(Util_Bundle.layerResolverFileName))->toBe(
26
+ true,
27
+ )
28
+ expect(Util_Bundle.registerHookSource->String.includes("register("))->toBe(true)
29
+ })
30
+
31
+ testSync("layer-resolver retries only bare specifiers on ERR_MODULE_NOT_FOUND", () => {
32
+ expect(Util_Bundle.layerResolverSource->String.includes("ESM_FALLBACK_DIRS"))->toBe(true)
33
+ expect(Util_Bundle.layerResolverSource->String.includes("ERR_MODULE_NOT_FOUND"))->toBe(true)
34
+ // The resolve hook is the exported entry point Node's loader calls.
35
+ expect(Util_Bundle.layerResolverSource->String.includes("export async function resolve"))->toBe(
36
+ true,
37
+ )
38
+ })
39
+ })
40
+
41
+ describe("Util_Bundle.addEsmLoaderAssets", () => {
42
+ testSync("injects both loader files at the archive root", () => {
43
+ let contents: dict<Pulumi.Archive.assetOrArchive> = Dict.make()
44
+ let _ = Util_Bundle.addEsmLoaderAssets(contents)
45
+ expect(contents->Dict.has(Util_Bundle.registerHookFileName))->toBe(true)
46
+ expect(contents->Dict.has(Util_Bundle.layerResolverFileName))->toBe(true)
47
+ })
48
+
49
+ testSync("returns a stable, non-empty hash fragment", () => {
50
+ let a: dict<Pulumi.Archive.assetOrArchive> = Dict.make()
51
+ let b: dict<Pulumi.Archive.assetOrArchive> = Dict.make()
52
+ let hashA = Util_Bundle.addEsmLoaderAssets(a)
53
+ let hashB = Util_Bundle.addEsmLoaderAssets(b)
54
+ expect(hashA->String.length > 0)->toBe(true)
55
+ expect(hashA)->toBe(hashB)
56
+ })
57
+ })
@@ -0,0 +1,44 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Util_Bundle$ReventlessAws from "../src/util/Util_Bundle.res.mjs";
4
+
5
+ globalThis.describe("Util_Bundle ESM loader — env-var contract", () => {
6
+ globalThis.test("NODE_OPTIONS --imports the shipped register-hook file by name", () => {
7
+ globalThis.expect(Util_Bundle$ReventlessAws.esmLoaderNodeOptions).toBe(`--import file:///var/task/` + Util_Bundle$ReventlessAws.registerHookFileName);
8
+ });
9
+ globalThis.test("fallback dirs cover both the layer and the runtime SDK dir", () => {
10
+ globalThis.expect(Util_Bundle$ReventlessAws.esmFallbackDirs.includes("/opt/nodejs/node_modules")).toBe(true);
11
+ globalThis.expect(Util_Bundle$ReventlessAws.esmFallbackDirs.includes("/var/runtime/node_modules")).toBe(true);
12
+ });
13
+ });
14
+
15
+ globalThis.describe("Util_Bundle ESM loader — file cross-references", () => {
16
+ globalThis.test("register-hook registers exactly the layer-resolver filename", () => {
17
+ globalThis.expect(Util_Bundle$ReventlessAws.registerHookSource.includes(Util_Bundle$ReventlessAws.layerResolverFileName)).toBe(true);
18
+ globalThis.expect(Util_Bundle$ReventlessAws.registerHookSource.includes("register(")).toBe(true);
19
+ });
20
+ globalThis.test("layer-resolver retries only bare specifiers on ERR_MODULE_NOT_FOUND", () => {
21
+ globalThis.expect(Util_Bundle$ReventlessAws.layerResolverSource.includes("ESM_FALLBACK_DIRS")).toBe(true);
22
+ globalThis.expect(Util_Bundle$ReventlessAws.layerResolverSource.includes("ERR_MODULE_NOT_FOUND")).toBe(true);
23
+ globalThis.expect(Util_Bundle$ReventlessAws.layerResolverSource.includes("export async function resolve")).toBe(true);
24
+ });
25
+ });
26
+
27
+ globalThis.describe("Util_Bundle.addEsmLoaderAssets", () => {
28
+ globalThis.test("injects both loader files at the archive root", () => {
29
+ let contents = {};
30
+ Util_Bundle$ReventlessAws.addEsmLoaderAssets(contents);
31
+ globalThis.expect(Util_Bundle$ReventlessAws.registerHookFileName in contents).toBe(true);
32
+ globalThis.expect(Util_Bundle$ReventlessAws.layerResolverFileName in contents).toBe(true);
33
+ });
34
+ globalThis.test("returns a stable, non-empty hash fragment", () => {
35
+ let a = {};
36
+ let b = {};
37
+ let hashA = Util_Bundle$ReventlessAws.addEsmLoaderAssets(a);
38
+ let hashB = Util_Bundle$ReventlessAws.addEsmLoaderAssets(b);
39
+ globalThis.expect(hashA.length > 0).toBe(true);
40
+ globalThis.expect(hashA).toBe(hashB);
41
+ });
42
+ });
43
+
44
+ /* Not a pure module */