@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.
- package/CHANGELOG.md +23 -0
- package/docker-compose.postgres.yml +21 -0
- package/package.json +7 -6
- package/scripts/run-pg-integration-tests.sh +58 -0
- package/src/Platform.res +49 -0
- package/src/Platform.res.mjs +48 -2
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res +7 -1
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res.mjs +10 -1
- package/src/adapter/Api/Platform_UIFragments_Lambda.res +7 -1
- package/src/adapter/Api/Platform_UIFragments_Lambda.res.mjs +10 -1
- package/src/adapter/Counter/CounterHandler_DynamoDbStream.res +6 -21
- package/src/adapter/Counter/CounterHandler_DynamoDbStream.res.mjs +2 -17
- package/src/adapter/DcbEventLog/DcbBackend.res +4 -0
- package/src/adapter/DcbEventLog/DcbEventLogStorage_Postgres.res +2 -2
- package/src/adapter/DcbEventLog/DcbEventLogStorage_Postgres.res.mjs +7 -1
- package/src/adapter/Postgres/PgChangeFeedRelay_Builder.res +1 -11
- package/src/adapter/Postgres/PgChangeFeedRelay_Builder.res.mjs +2 -24
- package/src/adapter/Postgres/PgConnection.res +70 -10
- package/src/adapter/Postgres/PgConnection.res.mjs +48 -3
- package/src/adapter/Postgres/PgMigration_Builder.res +127 -0
- package/src/adapter/Postgres/PgMigration_Builder.res.mjs +51 -0
- package/src/adapter/QueryDb/PgQueryResolver_Builder.res +279 -0
- package/src/adapter/QueryDb/PgQueryResolver_Builder.res.mjs +170 -0
- package/src/adapter/QueryDb/PgQueryResolver_Lambda.res +202 -11
- package/src/adapter/QueryDb/PgQueryResolver_Lambda.res.mjs +150 -9
- package/src/adapter/QueryDb/QueryDbBackend.res +8 -0
- package/src/adapter/QueryDb/QueryDbBackend.res.mjs +4 -1
- package/src/adapter/QueryDb/QueryDbResolvers.res +6 -5
- package/src/adapter/QueryDb/QueryDbResolvers.res.mjs +6 -3
- package/src/adapter/QueryDb/QueryDbResolvers_Lambda.res +288 -0
- package/src/adapter/QueryDb/QueryDbResolvers_Lambda.res.mjs +170 -0
- package/src/adapter/QueryDb/QueryDbStorage.res +6 -1
- package/src/adapter/QueryDb/QueryDbStorage.res.mjs +2 -1
- package/src/adapter/QueryDb/QueryDbStorage_Postgres.res +5 -1
- package/src/adapter/QueryDb/QueryDbStorage_Postgres.res.mjs +3 -3
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res +3 -14
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res.mjs +2 -25
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res +3 -14
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res.mjs +2 -25
- package/src/adapter/Runtime/DcbCommandTopicEntryPoint.mjs +7 -1
- package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res +78 -15
- package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res.mjs +43 -26
- package/src/adapter/Runtime/PgMigrationEntryPoint.mjs +40 -0
- package/src/adapter/Runtime/PgQueryResolverEntryPoint.mjs +88 -0
- package/src/adapter/Runtime/ReadModelEntryPoint.mjs +17 -5
- package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res +10 -0
- package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res.mjs +3 -0
- package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res +9 -14
- package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res.mjs +2 -25
- package/src/adapter/Runtime/StateTopicPublish.mjs +204 -0
- package/src/adapter/Runtime/StateViewSliceEntryPoint.mjs +17 -3
- package/src/adapter/Runtime/StateViewSliceRuntime_Builder_Single.res +86 -17
- package/src/adapter/Runtime/StateViewSliceRuntime_Builder_Single.res.mjs +50 -33
- package/src/adapter/StateTopic/StateTopic_AppSync.res +7 -1
- package/src/adapter/StateTopic/StateTopic_AppSync.res.mjs +10 -1
- package/src/plugin/cloner/ClonerRunner_Fargate.res +7 -1
- package/src/plugin/cloner/ClonerRunner_Fargate.res.mjs +10 -1
- package/src/util/Util_Bundle.res +71 -1
- package/src/util/Util_Bundle.res.mjs +49 -1
- package/tests/PgChangeFeedRelay_IntegrationTest.res +3 -3
- package/tests/PgConnectionJsonTest.res +84 -0
- package/tests/PgConnectionJsonTest.res.mjs +68 -0
- package/tests/PgMigrationEntryPoint_IntegrationTest.res +82 -0
- package/tests/PgMigrationEntryPoint_IntegrationTest.res.mjs +63 -0
- package/tests/PgPipeline_IntegrationTest.res +3 -1
- package/tests/PgQueryResolver_LambdaTest.res +163 -2
- package/tests/PgQueryResolver_LambdaTest.res.mjs +226 -14
- package/tests/StateTopicPublishTest.res +162 -0
- package/tests/StateTopicPublishTest.res.mjs +158 -0
- package/tests/Util_BundleEsmLoaderTest.res +57 -0
- 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
|
},
|
package/src/util/Util_Bundle.res
CHANGED
|
@@ -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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
//
|
|
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
|