@reventlessdev/reventless-aws 3.0.0-alpha.176 → 3.0.0-alpha.178

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 (71) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/docker-compose.postgres.yml +21 -0
  3. package/package.json +7 -6
  4. package/scripts/run-pg-integration-tests.sh +58 -0
  5. package/src/Platform.res +49 -0
  6. package/src/Platform.res.mjs +48 -2
  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/Postgres/PgChangeFeedRelay_Builder.res +1 -11
  17. package/src/adapter/Postgres/PgChangeFeedRelay_Builder.res.mjs +2 -24
  18. package/src/adapter/Postgres/PgConnection.res +70 -10
  19. package/src/adapter/Postgres/PgConnection.res.mjs +48 -3
  20. package/src/adapter/Postgres/PgMigration_Builder.res +127 -0
  21. package/src/adapter/Postgres/PgMigration_Builder.res.mjs +51 -0
  22. package/src/adapter/QueryDb/PgQueryResolver_Builder.res +279 -0
  23. package/src/adapter/QueryDb/PgQueryResolver_Builder.res.mjs +170 -0
  24. package/src/adapter/QueryDb/PgQueryResolver_Lambda.res +202 -11
  25. package/src/adapter/QueryDb/PgQueryResolver_Lambda.res.mjs +150 -9
  26. package/src/adapter/QueryDb/QueryDbBackend.res +8 -0
  27. package/src/adapter/QueryDb/QueryDbBackend.res.mjs +4 -1
  28. package/src/adapter/QueryDb/QueryDbResolvers.res +6 -5
  29. package/src/adapter/QueryDb/QueryDbResolvers.res.mjs +6 -3
  30. package/src/adapter/QueryDb/QueryDbResolvers_Lambda.res +288 -0
  31. package/src/adapter/QueryDb/QueryDbResolvers_Lambda.res.mjs +170 -0
  32. package/src/adapter/QueryDb/QueryDbStorage.res +6 -1
  33. package/src/adapter/QueryDb/QueryDbStorage.res.mjs +2 -1
  34. package/src/adapter/QueryDb/QueryDbStorage_Postgres.res +5 -1
  35. package/src/adapter/QueryDb/QueryDbStorage_Postgres.res.mjs +3 -3
  36. package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res +3 -14
  37. package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res.mjs +2 -25
  38. package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res +3 -14
  39. package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res.mjs +2 -25
  40. package/src/adapter/Runtime/DcbCommandTopicEntryPoint.mjs +7 -1
  41. package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res +78 -15
  42. package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res.mjs +43 -26
  43. package/src/adapter/Runtime/PgMigrationEntryPoint.mjs +40 -0
  44. package/src/adapter/Runtime/PgQueryResolverEntryPoint.mjs +88 -0
  45. package/src/adapter/Runtime/ReadModelEntryPoint.mjs +17 -5
  46. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res +10 -0
  47. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res.mjs +3 -0
  48. package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res +9 -14
  49. package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res.mjs +2 -25
  50. package/src/adapter/Runtime/StateTopicPublish.mjs +204 -0
  51. package/src/adapter/Runtime/StateViewSliceEntryPoint.mjs +17 -3
  52. package/src/adapter/Runtime/StateViewSliceRuntime_Builder_Single.res +86 -17
  53. package/src/adapter/Runtime/StateViewSliceRuntime_Builder_Single.res.mjs +50 -33
  54. package/src/adapter/StateTopic/StateTopic_AppSync.res +7 -1
  55. package/src/adapter/StateTopic/StateTopic_AppSync.res.mjs +10 -1
  56. package/src/plugin/cloner/ClonerRunner_Fargate.res +7 -1
  57. package/src/plugin/cloner/ClonerRunner_Fargate.res.mjs +10 -1
  58. package/src/util/Util_Bundle.res +71 -1
  59. package/src/util/Util_Bundle.res.mjs +49 -1
  60. package/tests/PgChangeFeedRelay_IntegrationTest.res +3 -3
  61. package/tests/PgConnectionJsonTest.res +84 -0
  62. package/tests/PgConnectionJsonTest.res.mjs +68 -0
  63. package/tests/PgMigrationEntryPoint_IntegrationTest.res +82 -0
  64. package/tests/PgMigrationEntryPoint_IntegrationTest.res.mjs +63 -0
  65. package/tests/PgPipeline_IntegrationTest.res +3 -1
  66. package/tests/PgQueryResolver_LambdaTest.res +163 -2
  67. package/tests/PgQueryResolver_LambdaTest.res.mjs +226 -14
  68. package/tests/StateTopicPublishTest.res +162 -0
  69. package/tests/StateTopicPublishTest.res.mjs +158 -0
  70. package/tests/Util_BundleEsmLoaderTest.res +57 -0
  71. package/tests/Util_BundleEsmLoaderTest.res.mjs +44 -0
@@ -243,8 +243,9 @@ function finish(eventsApi, opts) {
243
243
  });
244
244
  let archiveContents = {};
245
245
  archiveContents["index.mjs"] = new (Pulumi.asset.StringAsset)(handlerCode);
246
+ let loaderHash = Util_Bundle$ReventlessAws.addEsmLoaderAssets(archiveContents);
246
247
  let code = new (Pulumi.asset.AssetArchive)(archiveContents);
247
- let sourceCodeHash = Util_Bundle$ReventlessAws.hashString(handlerCode);
248
+ let sourceCodeHash = Util_Bundle$ReventlessAws.hashString(handlerCode + "\n---\n" + loaderHash);
248
249
  let layers = Stdlib_Option.getOr(Stdlib_Option.map(Lambda$PulumiAws.reventlessLayerArn, arn => [arn]), []);
249
250
  let appsyncEndpoint = AppSync_EventsApi$ReventlessAws.httpEndpoint(eventsApi);
250
251
  let lambda = new (Aws.lambda.Function)(name + "StateTopicPublisher", {
@@ -269,6 +270,14 @@ function finish(eventsApi, opts) {
269
270
  [
270
271
  "STATE_TOPIC_MAP",
271
272
  topicMapJson
273
+ ],
274
+ [
275
+ "NODE_OPTIONS",
276
+ Util_Bundle$ReventlessAws.esmLoaderNodeOptions
277
+ ],
278
+ [
279
+ "ESM_FALLBACK_DIRS",
280
+ Util_Bundle$ReventlessAws.esmFallbackDirs
272
281
  ]
273
282
  ])
274
283
  },
@@ -116,8 +116,12 @@ export const handler = async (event) => {
116
116
  "index.mjs",
117
117
  Pulumi.Asset.stringAsset(entryPointCode)->Pulumi.Archive.assetToAssetOrArchive,
118
118
  )
119
+ // ESM self-containment: the handler imports @aws-sdk/client-ecs, provided by the
120
+ // nodejs22.x runtime only under /var/runtime — unreachable from /var/task ESM
121
+ // without the resolver hook. Ship the loader + set its env vars on the Lambda.
122
+ let loaderHash = Util_Bundle.addEsmLoaderAssets(clonerArchiveContents)
119
123
  let code = Pulumi.Archive.assetArchive(clonerArchiveContents)
120
- let sourceCodeHash = Util_Bundle.hashString(entryPointCode)
124
+ let sourceCodeHash = Util_Bundle.hashString(entryPointCode ++ "\n---\n" ++ loaderHash)
121
125
 
122
126
  let layers =
123
127
  Lambda.reventlessLayerArn
@@ -231,6 +235,8 @@ export const handler = async (event) => {
231
235
  ->Pulumi.Input.make,
232
236
  ),
233
237
  ("Environment", Pulumi.Pulumi.getStackName()->Pulumi.Input.make),
238
+ ("NODE_OPTIONS", Util_Bundle.esmLoaderNodeOptions->Pulumi.Input.make),
239
+ ("ESM_FALLBACK_DIRS", Util_Bundle.esmFallbackDirs->Pulumi.Input.make),
234
240
  ]),
235
241
  }: Lambda.Function.functionEnvironment
236
242
  )->Pulumi.Input.make,
@@ -81,8 +81,9 @@ export const handler = async (event) => {
81
81
  };`;
82
82
  let clonerArchiveContents = {};
83
83
  clonerArchiveContents["index.mjs"] = new (Pulumi.asset.StringAsset)(entryPointCode);
84
+ let loaderHash = Util_Bundle$ReventlessAws.addEsmLoaderAssets(clonerArchiveContents);
84
85
  let code = new (Pulumi.asset.AssetArchive)(clonerArchiveContents);
85
- let sourceCodeHash = Util_Bundle$ReventlessAws.hashString(entryPointCode);
86
+ let sourceCodeHash = Util_Bundle$ReventlessAws.hashString(entryPointCode + "\n---\n" + loaderHash);
86
87
  let layers = Stdlib_Option.getOr(Stdlib_Option.map(Lambda$PulumiAws.reventlessLayerArn, arn => [arn]), []);
87
88
  let vpcStackName = Stdlib_Option.getOrThrow(new Pulumi.Config("vpc").get("stack"), undefined);
88
89
  let vpcConfig = Util_Vpc$ReventlessAws.getVpcConfig(vpcStackName, "vpc");
@@ -168,6 +169,14 @@ export const handler = async (event) => {
168
169
  [
169
170
  "Environment",
170
171
  Pulumi.getStack()
172
+ ],
173
+ [
174
+ "NODE_OPTIONS",
175
+ Util_Bundle$ReventlessAws.esmLoaderNodeOptions
176
+ ],
177
+ [
178
+ "ESM_FALLBACK_DIRS",
179
+ Util_Bundle$ReventlessAws.esmFallbackDirs
171
180
  ]
172
181
  ])
173
182
  },
@@ -112,6 +112,72 @@ let resolvePackageRoot = (packageName: string): string => {
112
112
  let hashString = (str: string): string =>
113
113
  createHash("sha256")->update(str)->digest("base64")
114
114
 
115
+ // ── ESM self-containment loader (Option C) ──────────────────────────────────
116
+ // Deployed Lambda entry points are ESM (`.mjs`) and statically/dynamically import
117
+ // bare specifiers (`@reventlessdev/*`, `effect`, `sury`, `@aws-sdk/*`) that live in
118
+ // the attached layer at /opt/nodejs/node_modules or the runtime SDK dir. ESM
119
+ // `import` ignores NODE_PATH and /opt, so those imports fail from /var/task with
120
+ // ERR_MODULE_NOT_FOUND. These two files install a `resolve` hook that, on an
121
+ // unresolved bare specifier, retries against each dir named in ESM_FALLBACK_DIRS
122
+ // (set on the Lambda env by RuntimeEnvironment_Lambda.makeFromCodeAsset). Shipped
123
+ // at the root of every code archive; NODE_OPTIONS=--import points Node at
124
+ // register-hook.mjs. Validated on a Node 22.17.1 rig; see
125
+ // docs/plans/deployed-lambda-esm-self-containment.md.
126
+ let registerHookFileName = "register-hook.mjs"
127
+ let layerResolverFileName = "layer-resolver.mjs"
128
+
129
+ let registerHookSource = `import { register } from "node:module";
130
+ register("./${layerResolverFileName}", import.meta.url);
131
+ `
132
+
133
+ let layerResolverSource = `// Fallback dirs (real Lambda: /opt/nodejs for the layer, /var/runtime for the SDK).
134
+ // Passed via env so the same hook is testable locally.
135
+ const FALLBACKS = (process.env.ESM_FALLBACK_DIRS || "").split(":").filter(Boolean);
136
+ export async function resolve(specifier, context, nextResolve) {
137
+ try {
138
+ return await nextResolve(specifier, context);
139
+ } catch (err) {
140
+ const bare = !/^[./]|^file:|^node:/.test(specifier);
141
+ if (err?.code !== "ERR_MODULE_NOT_FOUND" || !bare) throw err;
142
+ for (const dir of FALLBACKS) {
143
+ try {
144
+ // Re-resolve as if imported from a virtual file inside the fallback dir,
145
+ // so Node's own node_modules walk finds it (honours package.json exports).
146
+ const parentURL = new URL("file://" + dir + "/__resolver__.mjs").href;
147
+ return await nextResolve(specifier, { ...context, parentURL });
148
+ } catch { /* try next fallback */ }
149
+ }
150
+ throw err;
151
+ }
152
+ }
153
+ `
154
+
155
+ // Env vars that activate the loader. Any Lambda whose archive includes the loader
156
+ // files (via addEsmLoaderAssets / buildCodeArchive) MUST also set these two; any
157
+ // that does NOT ship the loader MUST NOT set NODE_OPTIONS (it would --import a
158
+ // missing file and fail cold start). --import registers the resolve hook;
159
+ // ESM_FALLBACK_DIRS names the layer (/opt/nodejs) and runtime-SDK (/var/runtime)
160
+ // node_modules dirs the hook retries against for @aws-sdk/*.
161
+ let esmLoaderNodeOptions = `--import file:///var/task/${registerHookFileName}`
162
+ let esmFallbackDirs = "/opt/nodejs/node_modules:/var/runtime/node_modules"
163
+
164
+ /**
165
+ * Add the ESM self-containment loader files to an archive-contents dict and return
166
+ * a hash fragment so callers can fold them into their sourceCodeHash. Every code
167
+ * archive whose Lambda sets the esmLoader env vars MUST include these files.
168
+ */
169
+ let addEsmLoaderAssets = (archiveContents: dict<Pulumi.Archive.assetOrArchive>): string => {
170
+ archiveContents->Dict.set(
171
+ registerHookFileName,
172
+ Pulumi.Asset.stringAsset(registerHookSource)->Pulumi.Archive.assetToAssetOrArchive,
173
+ )
174
+ archiveContents->Dict.set(
175
+ layerResolverFileName,
176
+ Pulumi.Asset.stringAsset(layerResolverSource)->Pulumi.Archive.assetToAssetOrArchive,
177
+ )
178
+ hashString(registerHookSource ++ "\n---\n" ++ layerResolverSource)
179
+ }
180
+
115
181
  let isSkippedDir = (n: string) =>
116
182
  n == "node_modules" ||
117
183
  n == "lib" ||
@@ -207,6 +273,8 @@ let buildCodeArchive = (
207
273
  "index.mjs",
208
274
  Pulumi.Asset.stringAsset(reExportCode)->Pulumi.Archive.assetToAssetOrArchive,
209
275
  )
276
+ // ESM self-containment loader — shipped in every archive (see addEsmLoaderAssets).
277
+ let loaderHash = addEsmLoaderAssets(archiveContents)
210
278
  extraStringAssets->Dict.forEachWithKey((content, fileName) => {
211
279
  archiveContents->Dict.set(
212
280
  fileName,
@@ -249,7 +317,9 @@ let buildCodeArchive = (
249
317
  "\n---\n" ++
250
318
  packageContentHashes.contents->Array.join(",") ++
251
319
  "\n---\n" ++
252
- extraHashEntries->Array.join("\n"),
320
+ extraHashEntries->Array.join("\n") ++
321
+ "\n---\n" ++
322
+ loaderHash,
253
323
  )
254
324
  {code, sourceCodeHash}
255
325
  }
@@ -78,6 +78,44 @@ function hashString(str) {
78
78
  return Crypto.createHash("sha256").update(str).digest("base64");
79
79
  }
80
80
 
81
+ let registerHookFileName = "register-hook.mjs";
82
+
83
+ let layerResolverFileName = "layer-resolver.mjs";
84
+
85
+ let registerHookSource = `import { register } from "node:module";
86
+ register("./` + layerResolverFileName + `", import.meta.url);
87
+ `;
88
+
89
+ let layerResolverSource = `// Fallback dirs (real Lambda: /opt/nodejs for the layer, /var/runtime for the SDK).
90
+ // Passed via env so the same hook is testable locally.
91
+ const FALLBACKS = (process.env.ESM_FALLBACK_DIRS || "").split(":").filter(Boolean);
92
+ export async function resolve(specifier, context, nextResolve) {
93
+ try {
94
+ return await nextResolve(specifier, context);
95
+ } catch (err) {
96
+ const bare = !/^[./]|^file:|^node:/.test(specifier);
97
+ if (err?.code !== "ERR_MODULE_NOT_FOUND" || !bare) throw err;
98
+ for (const dir of FALLBACKS) {
99
+ try {
100
+ // Re-resolve as if imported from a virtual file inside the fallback dir,
101
+ // so Node's own node_modules walk finds it (honours package.json exports).
102
+ const parentURL = new URL("file://" + dir + "/__resolver__.mjs").href;
103
+ return await nextResolve(specifier, { ...context, parentURL });
104
+ } catch { /* try next fallback */ }
105
+ }
106
+ throw err;
107
+ }
108
+ }
109
+ `;
110
+
111
+ let esmLoaderNodeOptions = `--import file:///var/task/` + registerHookFileName;
112
+
113
+ function addEsmLoaderAssets(archiveContents) {
114
+ archiveContents[registerHookFileName] = new (Pulumi.asset.StringAsset)(registerHookSource);
115
+ archiveContents[layerResolverFileName] = new (Pulumi.asset.StringAsset)(layerResolverSource);
116
+ return hashString(registerHookSource + "\n---\n" + layerResolverSource);
117
+ }
118
+
81
119
  function isSkippedDir(n) {
82
120
  if (n === "node_modules" || n === "lib" || n === "cjs" || n === "dts" || n === "tests" || n === "test" || n === "__mocks__" || n === "__tests__" || n === ".git") {
83
121
  return true;
@@ -129,6 +167,7 @@ function buildCodeArchive(entryPointModule, packageDirs, extraStringAssetsOpt) {
129
167
  let reExportCode = `export { handler } from "` + entryPointModule + `";`;
130
168
  let archiveContents = {};
131
169
  archiveContents["index.mjs"] = new (Pulumi.asset.StringAsset)(reExportCode);
170
+ let loaderHash = addEsmLoaderAssets(archiveContents);
132
171
  Stdlib_Dict.forEachWithKey(extraStringAssets, (content, fileName) => {
133
172
  archiveContents[fileName] = new (Pulumi.asset.StringAsset)(content);
134
173
  });
@@ -157,13 +196,15 @@ function buildCodeArchive(entryPointModule, packageDirs, extraStringAssetsOpt) {
157
196
  });
158
197
  let code = new (Pulumi.asset.AssetArchive)(archiveContents);
159
198
  packageContentHashes.contents.sort(Primitive_string.compare);
160
- let sourceCodeHash = hashString(reExportCode + "\n---\n" + packageContentHashes.contents.join(",") + "\n---\n" + extraHashEntries.join("\n"));
199
+ let sourceCodeHash = hashString(reExportCode + "\n---\n" + packageContentHashes.contents.join(",") + "\n---\n" + extraHashEntries.join("\n") + "\n---\n" + loaderHash);
161
200
  return {
162
201
  code: code,
163
202
  sourceCodeHash: sourceCodeHash
164
203
  };
165
204
  }
166
205
 
206
+ let esmFallbackDirs = "/opt/nodejs/node_modules:/var/runtime/node_modules";
207
+
167
208
  export {
168
209
  packageRootCache,
169
210
  localRequire,
@@ -171,6 +212,13 @@ export {
171
212
  extractPackageName,
172
213
  resolvePackageRoot,
173
214
  hashString,
215
+ registerHookFileName,
216
+ layerResolverFileName,
217
+ registerHookSource,
218
+ layerResolverSource,
219
+ esmLoaderNodeOptions,
220
+ esmFallbackDirs,
221
+ addEsmLoaderAssets,
174
222
  isSkippedDir,
175
223
  walkDir,
176
224
  createFilteredPackageArchive,
@@ -1,8 +1,8 @@
1
1
  // Live-Postgres integration suite for the change-feed relay (B2.4).
2
2
  //
3
- // Skipped unless PG_URL is set, so the default `pnpm test` stays dependency-free.
4
- // Run against a real database with e.g.:
5
- // PG_URL=postgres://postgres:postgres@localhost:5432/postgres pnpm test
3
+ // Run via `pnpm run test:integration:pg` (boots a Postgres sidecar, runs the PG
4
+ // suites serially, tears down). Excluded from the default parallel `pnpm test`;
5
+ // self-skips unless PG_URL is set (the script exports it).
6
6
  //
7
7
  // Exercises the deployed propagation path end-to-end minus AWS: DCB events are
8
8
  // appended to a real `dcb_event` log (classic events to `event_log`), then
@@ -0,0 +1,84 @@
1
+ // Rung-1 guard for the `pgConnection` HANDLER_CONFIG serialization (A3/B/C).
2
+ //
3
+ // `PgConnection.connectionConfigToJson` is the single producer of the object
4
+ // every Postgres entry point reads at cold start (`config.pgConnection.*` →
5
+ // `PgRuntime.poolFor`). The field set is an untyped cross-language contract — a
6
+ // dropped/renamed/retyped field here only fails at runtime in the deployed
7
+ // Lambda, which no compile catches. These tests pin the exact shape so a drift
8
+ // fails in CI instead. All seven deploy-time builders now route through this
9
+ // helper, so pinning it pins them all.
10
+
11
+ open JestGlobals
12
+
13
+ let cc: PgConnection.connectionConfig = {
14
+ host: "db.example.eu-west-1.rds.amazonaws.com",
15
+ port: 5432,
16
+ database: "reventless",
17
+ username: "reventless_admin",
18
+ secretArn: "arn:aws:secretsmanager:eu-west-1:123456789012:secret:rds!db-abc-123",
19
+ }
20
+
21
+ let obj = json => json->JSON.Decode.object->Option.getOrThrow
22
+ let field = (json, key) => json->obj->Dict.get(key)
23
+
24
+ describe("PgConnection.connectionConfigToJson", () => {
25
+ testSync("emits exactly the five connection fields with correct types", () => {
26
+ let json = cc->PgConnection.connectionConfigToJson
27
+ // Exact key set — no extra keys, none missing (the cold-start contract).
28
+ expect(json->obj->Dict.keysToArray->Array.toSorted(String.compare))->toEqual([
29
+ "database",
30
+ "host",
31
+ "port",
32
+ "secretArn",
33
+ "username",
34
+ ])
35
+ expect(json->field("host"))->toEqual(Some(JSON.Encode.string(cc.host)))
36
+ expect(json->field("database"))->toEqual(Some(JSON.Encode.string(cc.database)))
37
+ expect(json->field("username"))->toEqual(Some(JSON.Encode.string(cc.username)))
38
+ expect(json->field("secretArn"))->toEqual(Some(JSON.Encode.string(cc.secretArn)))
39
+ // port MUST be a JSON number (poolFor reads it as an int), not a string.
40
+ expect(json->field("port"))->toEqual(Some(JSON.Encode.float(5432.)))
41
+ })
42
+
43
+ testSync("omits lockStrategy when not given (classic / QueryDb / migration paths)", () =>
44
+ expect(cc->PgConnection.connectionConfigToJson->field("lockStrategy"))->toEqual(None)
45
+ )
46
+
47
+ testSync("adds lockStrategy=\"AdvisoryLocks\" as the polyvariant string name", () =>
48
+ expect(
49
+ cc
50
+ ->PgConnection.connectionConfigToJson(~lockStrategy=#AdvisoryLocks)
51
+ ->field("lockStrategy"),
52
+ )->toEqual(Some(JSON.Encode.string("AdvisoryLocks")))
53
+ )
54
+
55
+ testSync("adds lockStrategy=\"RowLocks\" as the polyvariant string name", () =>
56
+ expect(
57
+ cc
58
+ ->PgConnection.connectionConfigToJson(~lockStrategy=#RowLocks)
59
+ ->field("lockStrategy"),
60
+ )->toEqual(Some(JSON.Encode.string("RowLocks")))
61
+ )
62
+
63
+ testSync("with lockStrategy the object has exactly six keys", () =>
64
+ expect(
65
+ cc
66
+ ->PgConnection.connectionConfigToJson(~lockStrategy=#RowLocks)
67
+ ->obj
68
+ ->Dict.keysToArray
69
+ ->Array.length,
70
+ )->toBe(6)
71
+ )
72
+
73
+ testSync("round-trips through JSON.parse as the entry points do", () => {
74
+ // The entry point does `JSON.parse(HANDLER_CONFIG).pgConnection` — prove the
75
+ // stringify→parse survives and still exposes the fields poolFor reads.
76
+ let parsed =
77
+ `{"pgConnection":${cc->PgConnection.connectionConfigToJson->JSON.stringify}}`
78
+ ->JSON.parseOrThrow
79
+ ->field("pgConnection")
80
+ ->Option.getOrThrow
81
+ expect(parsed->field("host"))->toEqual(Some(JSON.Encode.string(cc.host)))
82
+ expect(parsed->field("secretArn"))->toEqual(Some(JSON.Encode.string(cc.secretArn)))
83
+ })
84
+ })
@@ -0,0 +1,68 @@
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
+ import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
6
+ import * as PgConnection$ReventlessAws from "../src/adapter/Postgres/PgConnection.res.mjs";
7
+
8
+ let cc = {
9
+ host: "db.example.eu-west-1.rds.amazonaws.com",
10
+ port: 5432,
11
+ database: "reventless",
12
+ username: "reventless_admin",
13
+ secretArn: "arn:aws:secretsmanager:eu-west-1:123456789012:secret:rds!db-abc-123"
14
+ };
15
+
16
+ function obj(json) {
17
+ return Stdlib_Option.getOrThrow(Stdlib_JSON.Decode.object(json), undefined);
18
+ }
19
+
20
+ function field(json, key) {
21
+ return obj(json)[key];
22
+ }
23
+
24
+ globalThis.describe("PgConnection.connectionConfigToJson", () => {
25
+ globalThis.test("emits exactly the five connection fields with correct types", () => {
26
+ let json = PgConnection$ReventlessAws.connectionConfigToJson(undefined, cc);
27
+ globalThis.expect(Object.keys(obj(json)).toSorted(Primitive_string.compare)).toEqual([
28
+ "database",
29
+ "host",
30
+ "port",
31
+ "secretArn",
32
+ "username"
33
+ ]);
34
+ globalThis.expect(obj(json)["host"]).toEqual("db.example.eu-west-1.rds.amazonaws.com");
35
+ globalThis.expect(obj(json)["database"]).toEqual("reventless");
36
+ globalThis.expect(obj(json)["username"]).toEqual("reventless_admin");
37
+ globalThis.expect(obj(json)["secretArn"]).toEqual("arn:aws:secretsmanager:eu-west-1:123456789012:secret:rds!db-abc-123");
38
+ globalThis.expect(obj(json)["port"]).toEqual(5432);
39
+ });
40
+ globalThis.test("omits lockStrategy when not given (classic / QueryDb / migration paths)", () => {
41
+ let json = PgConnection$ReventlessAws.connectionConfigToJson(undefined, cc);
42
+ globalThis.expect(obj(json)["lockStrategy"]).toEqual(undefined);
43
+ });
44
+ globalThis.test("adds lockStrategy=\"AdvisoryLocks\" as the polyvariant string name", () => {
45
+ let json = PgConnection$ReventlessAws.connectionConfigToJson("AdvisoryLocks", cc);
46
+ globalThis.expect(obj(json)["lockStrategy"]).toEqual("AdvisoryLocks");
47
+ });
48
+ globalThis.test("adds lockStrategy=\"RowLocks\" as the polyvariant string name", () => {
49
+ let json = PgConnection$ReventlessAws.connectionConfigToJson("RowLocks", cc);
50
+ globalThis.expect(obj(json)["lockStrategy"]).toEqual("RowLocks");
51
+ });
52
+ globalThis.test("with lockStrategy the object has exactly six keys", () => {
53
+ globalThis.expect(Object.keys(obj(PgConnection$ReventlessAws.connectionConfigToJson("RowLocks", cc))).length).toBe(6);
54
+ });
55
+ globalThis.test("round-trips through JSON.parse as the entry points do", () => {
56
+ let json = JSON.parse(`{"pgConnection":` + JSON.stringify(PgConnection$ReventlessAws.connectionConfigToJson(undefined, cc)) + `}`);
57
+ let parsed = Stdlib_Option.getOrThrow(obj(json)["pgConnection"], undefined);
58
+ globalThis.expect(obj(parsed)["host"]).toEqual("db.example.eu-west-1.rds.amazonaws.com");
59
+ globalThis.expect(obj(parsed)["secretArn"]).toEqual("arn:aws:secretsmanager:eu-west-1:123456789012:secret:rds!db-abc-123");
60
+ });
61
+ });
62
+
63
+ export {
64
+ cc,
65
+ obj,
66
+ field,
67
+ }
68
+ /* Not a pure module */
@@ -0,0 +1,82 @@
1
+ // Rung-1 integration guard for the A3 schema-migration Lambda entry point.
2
+ //
3
+ // Drives the real `PgMigrationEntryPoint.runMigration` — the orchestration the
4
+ // deployed one-shot migration Lambda runs (parse HANDLER_CONFIG → build pool →
5
+ // PgSchema.ensureSchema). The pool source is injected so this exercises the
6
+ // whole path against a real Postgres WITHOUT the Secrets Manager round-trip
7
+ // (that AWS boundary is the only remaining unvalidated piece). Proves the
8
+ // migration leaves a queryable schema and is safely re-runnable.
9
+ //
10
+ // Run via `pnpm run test:integration:pg` (boots a Postgres sidecar, runs the PG
11
+ // suites serially, tears down). Excluded from the default parallel `pnpm test`.
12
+ // The guard test (no pgConnection → throws) needs no DB; the live path is
13
+ // skipped unless PG_URL is set (the script exports it).
14
+
15
+ open JestGlobals
16
+
17
+ @val external processEnv: dict<string> = "process.env"
18
+
19
+ // Import the .mjs entry point and call runMigration with the pool injected.
20
+ // Returns "ok" on success. A minimal pgConnection satisfies the guard; the real
21
+ // fields are ignored because `makePool` is injected.
22
+ let runMigrationWithPool: (
23
+ ReventlessPostgres.PgDriver.pool,
24
+ ) => promise<string> = %raw(`
25
+ async (pool) => {
26
+ const { runMigration } = await import(
27
+ "@reventlessdev/reventless-aws/src/adapter/Runtime/PgMigrationEntryPoint.mjs"
28
+ );
29
+ const config = {
30
+ pgConnection: {
31
+ host: "ignored", port: 5432, database: "ignored",
32
+ username: "ignored", secretArn: "ignored",
33
+ },
34
+ };
35
+ return await runMigration(config, { makePool: () => pool });
36
+ }
37
+ `)
38
+
39
+ // runMigration with an empty config — the guard must reject a HANDLER_CONFIG
40
+ // that carries no pgConnection (a misconfigured deploy), rather than silently
41
+ // building a bad pool.
42
+ let runMigrationMissingConfigThrows: unit => promise<bool> = %raw(`
43
+ async () => {
44
+ const { runMigration } = await import(
45
+ "@reventlessdev/reventless-aws/src/adapter/Runtime/PgMigrationEntryPoint.mjs"
46
+ );
47
+ try { await runMigration({}, {}); return false; }
48
+ catch { return true; }
49
+ }
50
+ `)
51
+
52
+ describe("PgMigrationEntryPoint.runMigration", () => {
53
+ testPromise("rejects a HANDLER_CONFIG with no pgConnection", async () => {
54
+ let threw = await runMigrationMissingConfigThrows()
55
+ expect(threw)->toBe(true)
56
+ })
57
+
58
+ switch processEnv->Dict.get("PG_URL") {
59
+ | None =>
60
+ testSync("live migration (skipped — set PG_URL to run)", () => expect(true)->toBe(true))
61
+ | Some(url) =>
62
+ let pool = ReventlessPostgres.PgDriver.makePool({connectionString: url})
63
+ afterAll(() => {
64
+ let _ = pool->ReventlessPostgres.PgDriver.endPool
65
+ })
66
+
67
+ testPromise("runs ensureSchema, leaving both event logs queryable", async () => {
68
+ let _ = await runMigrationWithPool(pool)
69
+ // countAll queries the tables ensureSchema creates — it resolves (>= 0)
70
+ // only if `event_log` / `dcb_event` exist, and throws otherwise.
71
+ let classicCount = await ReventlessPostgres.EventLogStorage_Postgres.countAll(pool)
72
+ let dcbCount = await ReventlessPostgres.DcbEventLogStorage_Postgres.countAll(pool)
73
+ expect(classicCount >= 0 && dcbCount >= 0)->toBe(true)
74
+ })
75
+
76
+ testPromise("is idempotent — a second run does not throw", async () => {
77
+ let _ = await runMigrationWithPool(pool)
78
+ let again = await runMigrationWithPool(pool)
79
+ expect(again)->toBe("ok")
80
+ })
81
+ }
82
+ })
@@ -0,0 +1,63 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as PgDriver$ReventlessPostgres from "@reventlessdev/reventless-postgres/src/PgDriver.res.mjs";
4
+ import * as EventLogStorage_Postgres$ReventlessPostgres from "@reventlessdev/reventless-postgres/src/EventLogStorage_Postgres.res.mjs";
5
+ import * as DcbEventLogStorage_Postgres$ReventlessPostgres from "@reventlessdev/reventless-postgres/src/DcbEventLogStorage_Postgres.res.mjs";
6
+
7
+ let runMigrationWithPool = (async (pool) => {
8
+ const { runMigration } = await import(
9
+ "@reventlessdev/reventless-aws/src/adapter/Runtime/PgMigrationEntryPoint.mjs"
10
+ );
11
+ const config = {
12
+ pgConnection: {
13
+ host: "ignored", port: 5432, database: "ignored",
14
+ username: "ignored", secretArn: "ignored",
15
+ },
16
+ };
17
+ return await runMigration(config, { makePool: () => pool });
18
+ });
19
+
20
+ let runMigrationMissingConfigThrows = (async () => {
21
+ const { runMigration } = await import(
22
+ "@reventlessdev/reventless-aws/src/adapter/Runtime/PgMigrationEntryPoint.mjs"
23
+ );
24
+ try { await runMigration({}, {}); return false; }
25
+ catch { return true; }
26
+ });
27
+
28
+ globalThis.describe("PgMigrationEntryPoint.runMigration", () => {
29
+ globalThis.test("rejects a HANDLER_CONFIG with no pgConnection", async () => {
30
+ let threw = await runMigrationMissingConfigThrows();
31
+ globalThis.expect(threw).toBe(true);
32
+ });
33
+ let url = process.env["PG_URL"];
34
+ if (url !== undefined) {
35
+ let pool = PgDriver$ReventlessPostgres.makePool({
36
+ connectionString: url
37
+ });
38
+ globalThis.afterAll(() => {
39
+ PgDriver$ReventlessPostgres.endPool(pool);
40
+ });
41
+ globalThis.test("runs ensureSchema, leaving both event logs queryable", async () => {
42
+ await runMigrationWithPool(pool);
43
+ let classicCount = await EventLogStorage_Postgres$ReventlessPostgres.countAll(pool);
44
+ let dcbCount = await DcbEventLogStorage_Postgres$ReventlessPostgres.countAll(pool);
45
+ globalThis.expect(classicCount >= 0 && dcbCount >= 0).toBe(true);
46
+ });
47
+ globalThis.test("is idempotent — a second run does not throw", async () => {
48
+ await runMigrationWithPool(pool);
49
+ let again = await runMigrationWithPool(pool);
50
+ globalThis.expect(again).toBe("ok");
51
+ });
52
+ return;
53
+ }
54
+ globalThis.test("live migration (skipped — set PG_URL to run)", () => {
55
+ globalThis.expect(true).toBe(true);
56
+ });
57
+ });
58
+
59
+ export {
60
+ runMigrationWithPool,
61
+ runMigrationMissingConfigThrows,
62
+ }
63
+ /* runMigrationWithPool Not a pure module */
@@ -1,6 +1,8 @@
1
1
  // End-to-end Postgres projection pipeline (B1 + B3.0 + B3.1 composed), minus AWS.
2
2
  //
3
- // Skipped unless PG_URL is set. Exercises the full deployed classic path against
3
+ // Run via `pnpm run test:integration:pg` (boots a Postgres sidecar, runs the PG
4
+ // suites serially, tears down). Excluded from the default parallel `pnpm test`;
5
+ // self-skips unless PG_URL is set. Exercises the full deployed classic path against
4
6
  // a real database:
5
7
  // classic append (`event_log`) → change-feed relay (`relayClassicWithPool`,
6
8
  // emitting the EventCollector `{id, meta, event}` bodies) → feed-queue SQS