@reventlessdev/reventless-aws 3.0.0-alpha.342 → 3.0.0-alpha.344

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 CHANGED
@@ -3,6 +3,20 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.344 (2026-09-09)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **aws:** the archive asks what the layer holds, not what resolves ([fca352f](https://github.com/ReventlessDev/reventless-core/commit/fca352f7a19f0518a84944d4efd23eb98285f6bf))
11
+
12
+
13
+ # 3.0.0-alpha.343 (2026-09-08)
14
+
15
+ ### Bug Fixes
16
+
17
+ * **aws:** the collector gate waits on the one property an update changes ([2c48193](https://github.com/ReventlessDev/reventless-core/commit/2c481934d58c64b8e637d0df1b051b03cf59a35c))
18
+
19
+
6
20
  # 3.0.0-alpha.342 (2026-09-08)
7
21
 
8
22
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.342",
3
+ "version": "3.0.0-alpha.344",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -15,18 +15,18 @@
15
15
  "@aws-sdk/s3-request-presigner": "3.970.0",
16
16
  "sury": "11.0.0-rc.2",
17
17
  "uuid": "^13.0.0",
18
- "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.16",
19
18
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
20
19
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
20
+ "@reventlessdev/rescript-pulumi-aws": "3.0.0-alpha.11",
21
+ "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.16",
21
22
  "@reventlessdev/rescript-node": "2.0.0-alpha.10",
22
- "@reventlessdev/rescript-pulumi-aws": "3.0.0-alpha.10",
23
- "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
24
23
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
25
24
  "@reventlessdev/reventless-core": "3.0.0-alpha.260",
25
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.35",
26
26
  "@reventlessdev/reventless-postgres": "3.0.0-alpha.124",
27
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.161",
27
28
  "@reventlessdev/reventless-spec": "3.0.0-alpha.133",
28
- "@reventlessdev/reventless-interop": "3.0.0-alpha.35",
29
- "@reventlessdev/reventless-infra": "3.0.0-alpha.161"
29
+ "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19"
30
30
  },
31
31
  "devDependencies": {
32
32
  "rescript": "12.3.0",
@@ -677,13 +677,12 @@ module Make = (
677
677
  ~opts,
678
678
  )
679
679
 
680
- // Through an output property, not the resource. `apply` receives the Function
681
- // record as soon as the outer Output carries it — at construction, fields
682
- // still unresolved — so discarding it reported ready mid-update, and the
683
- // re-detect reached a Lambda still serving the previous bundle. `flatMap`
684
- // returns the Output itself, which Pulumi awaits.
685
- eventCollectorReadyRef :=
686
- runtime.parts.lambda->Pulumi.Output.flatMap(fn => fn.arn)->Pulumi.Output.apply(_ => ())
680
+ // Not the resource, and not one of its identifiers: both are known before the
681
+ // update lands, so either opens the gate while the collector is still serving
682
+ // the previous bundle — and the re-detect that follows is answered with the
683
+ // previous definition. `updateLanded` depends on the one property that cannot
684
+ // be known early.
685
+ eventCollectorReadyRef := runtime.parts.lambda->Util_Lambda.updateLanded
687
686
 
688
687
  // Admin-only cross-plugin SNS subscription permissions. The admin EC
689
688
  // Lambda's manageSubscriptions hook (Phase 3 Step 1) creates SNS
@@ -2,7 +2,6 @@
2
2
 
3
3
  import * as Aws from "@pulumi/aws";
4
4
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
5
- import * as Output$Pulumi from "@reventlessdev/rescript-pulumi-pulumi/src/Output.res.mjs";
6
5
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
6
  import * as Pulumi from "@pulumi/pulumi";
8
7
  import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
@@ -15,6 +14,7 @@ import * as Heartbeat$ReventlessCore from "@reventlessdev/reventless-core/src/co
15
14
  import * as PolicyDocument$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/PolicyDocument.res.mjs";
16
15
  import * as PluginSpec$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/lifecycle/PluginSpec.res.mjs";
17
16
  import * as Util_Bundle$ReventlessAws from "../../util/Util_Bundle.res.mjs";
17
+ import * as Util_Lambda$ReventlessAws from "../../util/Util_Lambda.res.mjs";
18
18
  import * as ComponentType$ReventlessCore from "@reventlessdev/reventless-core/src/ComponentType.res.mjs";
19
19
  import * as EventCollector$ReventlessCore from "@reventlessdev/reventless-core/src/components/EventCollector/EventCollector.res.mjs";
20
20
  import * as EventLogBackend$ReventlessAws from "../../adapter/EventLog/EventLogBackend.res.mjs";
@@ -414,7 +414,7 @@ function Make(EventCollectorChannel) {
414
414
  eventTopics: eventTopics,
415
415
  resources: resources
416
416
  }], runtime, opts);
417
- eventCollectorReadyRef.contents = Output$Pulumi.flatMap(runtime.parts.lambda, fn => fn.arn).apply(param => {});
417
+ eventCollectorReadyRef.contents = Util_Lambda$ReventlessAws.updateLanded(runtime.parts.lambda);
418
418
  let isAdminEventCollector = Stdlib_Option.isNone(Plugin_Helpers$ReventlessCore.eventCollectorContextRef.contents[name]);
419
419
  if (isAdminEventCollector) {
420
420
  new (Aws.iam.RolePolicy)(name + `-snsManageSubs`, {
@@ -349,32 +349,35 @@ let isRuntimeProvided = (specifier: string, ~pkgName: string): bool =>
349
349
  }
350
350
  )
351
351
 
352
- // Memo for isFrameworkPackage — one require.resolve per package name per deploy.
353
- let frameworkPackageCache: dict<bool> = Dict.make()
354
-
355
- /**
356
- * Whether the framework's own module resolution reaches this package: the
357
- * deploy-time stand-in for "the Lambda layer already carries it", since the
358
- * layer is built from reventless-aws's dependency closure.
359
- *
360
- * Deliberately framework-rooted only — no Pulumi-project fallback. The question
361
- * here is the opposite of `resolvePackageRoot`'s: a package the *project*
362
- * resolves but the framework does not is precisely a user package, which is
363
- * what the closure walk below must treat as a starting point rather than as
364
- * something the runtime provides.
365
- */
366
- let isFrameworkPackage = (pkgName: string): bool =>
367
- switch frameworkPackageCache->Dict.get(pkgName) {
368
- | Some(known) => known
369
- | None =>
370
- let known = try {
371
- let _ = localRequire->NodeModule.requireResolve(pkgName ++ "/package.json")
372
- true
373
- } catch {
374
- | _ => false
352
+ /** The framework's own package root: this module ships inside reventless-aws, so
353
+ the nearest package.json walking up from it is reventless-aws's own. */
354
+ let frameworkPackageRoot: string = {
355
+ let filePath = newURL(%raw("import.meta.url"))->pathname
356
+ let dirRef = ref(NodePath.dirname(filePath))
357
+ let foundRef: ref<option<string>> = ref(None)
358
+ while dirRef.contents != "/" && foundRef.contents->Option.isNone {
359
+ if NodeFs.existsSync(NodePath.join([dirRef.contents, "package.json"])) {
360
+ foundRef := Some(dirRef.contents)
361
+ } else {
362
+ dirRef := NodePath.dirname(dirRef.contents)
375
363
  }
376
- frameworkPackageCache->Dict.set(pkgName, known)
377
- known
364
+ }
365
+ foundRef.contents->Option.getOr(NodePath.dirname(filePath))
366
+ }
367
+
368
+ /** The `dependencies` a package declares — the edges the layer's own npm install
369
+ follows. devDependencies are deliberately not read: the layer is a production
370
+ install, so a dev-only package is not in it. */
371
+ let declaredDependencies = (packageRoot: string): array<string> =>
372
+ try {
373
+ NodeFs.readFileSync(NodePath.join([packageRoot, "package.json"]))
374
+ ->JSON.parseOrThrow
375
+ ->JSON.Decode.object
376
+ ->Option.flatMap(o => o->Dict.get("dependencies"))
377
+ ->Option.flatMap(JSON.Decode.object)
378
+ ->Option.mapOr([], Dict.keysToArray)
379
+ } catch {
380
+ | _ => []
378
381
  }
379
382
 
380
383
  // The distinct bare packages a package's own bundled files import, memoised per
@@ -427,6 +430,76 @@ let resolvePackageRootFrom = (~fromRoot: string, pkgName: string): option<string
427
430
  | _ => None
428
431
  }
429
432
 
433
+ /** Packages the layer carries that reventless-aws does not declare, mirroring
434
+ `includeModules` in the layer builder's config. `@rescript/runtime` is the
435
+ case that matters: it is a transitive of `rescript`, which the layer excludes
436
+ as a build tool, so the production closure misses it while the layer holds
437
+ it. Without this the walk would add 19 MB to every archive.
438
+
439
+ The layer's `excludeModules` are deliberately NOT mirrored. Those are
440
+ deploy-time-only packages (the @pulumi bindings, esbuild, the SSH stack); the
441
+ walk is import-driven, so a package no runtime file imports never comes up. */
442
+ let layerIncludedModules = Set.fromArray(["@rescript/runtime"])
443
+
444
+ // Memo for the framework's dependency closure — one walk per deploy.
445
+ let frameworkClosureCache: ref<option<dict<bool>>> = ref(None)
446
+
447
+ /**
448
+ * The packages the Lambda layer carries: reventless-aws and its transitive
449
+ * `dependencies`, which is exactly what the layer builder npm-installs.
450
+ *
451
+ * Membership rather than resolvability, and the distinction is the whole point.
452
+ * Resolvability asked whether `require.resolve` reaches a package from this
453
+ * module — but Node resolution walks *up*, so in a consumer repo with a hoisted
454
+ * `node_modules` every package at the workspace root answers yes, including the
455
+ * plugin packages and their traits. That made this predicate true for packages
456
+ * the layer has never held: they were dropped from the archive as
457
+ * layer-provided, and the plugin package itself was filtered out of the closure
458
+ * walk's starting points, so its imports were never walked at all. The failure
459
+ * lands at the first command as `Cannot find package`, on a green deploy.
460
+ *
461
+ * Declared edges are followed from the declaring package's own directory, so a
462
+ * pnpm layout resolves the same way the runtime would. A dependency that is
463
+ * declared but not installed still counts as the layer's: the archive is not the
464
+ * place to compensate for an incomplete install.
465
+ */
466
+ let frameworkPackages = (): dict<bool> =>
467
+ switch frameworkClosureCache.contents {
468
+ | Some(closure) => closure
469
+ | None =>
470
+ let closure = Dict.make()
471
+ let pending = [frameworkPackageRoot]
472
+ let next = ref(0)
473
+ while next.contents < pending->Array.length {
474
+ let root = pending->Array.getUnsafe(next.contents)
475
+ next := next.contents + 1
476
+ declaredDependencies(root)->Array.forEach(dep =>
477
+ if !(closure->Dict.has(dep)) {
478
+ closure->Dict.set(dep, true)
479
+ switch resolvePackageRootFrom(~fromRoot=root, dep) {
480
+ | Some(depRoot) => pending->Array.push(depRoot)
481
+ | None => ()
482
+ }
483
+ }
484
+ )
485
+ }
486
+ frameworkClosureCache := Some(closure)
487
+ closure
488
+ }
489
+
490
+ /**
491
+ * Whether the deployed runtime already carries this package, so the archive
492
+ * need not: reventless-aws itself, or anything in its dependency closure.
493
+ *
494
+ * A package the *project* resolves but this does not is precisely a user
495
+ * package, which the closure walk below must treat as a starting point rather
496
+ * than as something the runtime provides.
497
+ */
498
+ let isFrameworkPackage = (pkgName: string): bool =>
499
+ pkgName == "@reventlessdev/reventless-aws" ||
500
+ layerIncludedModules->Set.has(pkgName) ||
501
+ frameworkPackages()->Dict.has(pkgName)
502
+
430
503
  /**
431
504
  * Carry every package a bundled *user* package imports into the archive, and
432
505
  * every package those import in turn.
@@ -232,22 +232,28 @@ function isRuntimeProvided(specifier, pkgName) {
232
232
  }
233
233
  }
234
234
 
235
- let frameworkPackageCache = {};
235
+ let filePath = new URL(import.meta.url).pathname;
236
236
 
237
- function isFrameworkPackage(pkgName) {
238
- let known = frameworkPackageCache[pkgName];
239
- if (known !== undefined) {
240
- return known;
237
+ let dirRef = Nodepath.dirname(filePath);
238
+
239
+ let foundRef;
240
+
241
+ while (dirRef !== "/" && Stdlib_Option.isNone(foundRef)) {
242
+ if (Nodefs.existsSync(Nodepath.join(dirRef, "package.json"))) {
243
+ foundRef = dirRef;
244
+ } else {
245
+ dirRef = Nodepath.dirname(dirRef);
241
246
  }
242
- let known$1;
247
+ };
248
+
249
+ let frameworkPackageRoot = Stdlib_Option.getOr(foundRef, Nodepath.dirname(filePath));
250
+
251
+ function declaredDependencies(packageRoot) {
243
252
  try {
244
- localRequire.resolve(pkgName + "/package.json");
245
- known$1 = true;
253
+ return Stdlib_Option.mapOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(JSON.parse(Nodefs.readFileSync(Nodepath.join(packageRoot, "package.json"), "utf8"))), o => o["dependencies"]), Stdlib_JSON.Decode.object), [], prim => Object.keys(prim));
246
254
  } catch (exn) {
247
- known$1 = false;
255
+ return [];
248
256
  }
249
- frameworkPackageCache[pkgName] = known$1;
250
- return known$1;
251
257
  }
252
258
 
253
259
  let importedPackagesCache = {};
@@ -289,6 +295,47 @@ function resolvePackageRootFrom(fromRoot, pkgName) {
289
295
  }
290
296
  }
291
297
 
298
+ let layerIncludedModules = new Set(["@rescript/runtime"]);
299
+
300
+ let frameworkClosureCache = {
301
+ contents: undefined
302
+ };
303
+
304
+ function frameworkPackages() {
305
+ let closure = frameworkClosureCache.contents;
306
+ if (closure !== undefined) {
307
+ return closure;
308
+ }
309
+ let closure$1 = {};
310
+ let pending = [frameworkPackageRoot];
311
+ let next = 0;
312
+ while (next < pending.length) {
313
+ let root = pending[next];
314
+ next = next + 1 | 0;
315
+ declaredDependencies(root).forEach(dep => {
316
+ if (dep in closure$1) {
317
+ return;
318
+ }
319
+ closure$1[dep] = true;
320
+ let depRoot = resolvePackageRootFrom(root, dep);
321
+ if (depRoot !== undefined) {
322
+ pending.push(depRoot);
323
+ return;
324
+ }
325
+ });
326
+ };
327
+ frameworkClosureCache.contents = closure$1;
328
+ return closure$1;
329
+ }
330
+
331
+ function isFrameworkPackage(pkgName) {
332
+ if (pkgName === "@reventlessdev/reventless-aws" || layerIncludedModules.has(pkgName)) {
333
+ return true;
334
+ } else {
335
+ return pkgName in frameworkPackages();
336
+ }
337
+ }
338
+
292
339
  function addImportedPackageClosure(packageDirs) {
293
340
  let pending = Object.entries(packageDirs).filter(param => !isFrameworkPackage(param[0]));
294
341
  let next = 0;
@@ -410,11 +457,15 @@ export {
410
457
  isBareSpecifier,
411
458
  nodeBuiltins,
412
459
  isRuntimeProvided,
413
- frameworkPackageCache,
414
- isFrameworkPackage,
460
+ frameworkPackageRoot,
461
+ declaredDependencies,
415
462
  importedPackagesCache,
416
463
  importedPackages,
417
464
  resolvePackageRootFrom,
465
+ layerIncludedModules,
466
+ frameworkClosureCache,
467
+ frameworkPackages,
468
+ isFrameworkPackage,
418
469
  addImportedPackageClosure,
419
470
  assertRuntimeExtensionImportsResolvable,
420
471
  buildCodeArchive,
@@ -26,8 +26,21 @@ let fromOutput = (output: Pulumi.Output.t<PulumiAws.Lambda.Function.t>) => {
26
26
  id: output->Pulumi.Output.flatMap(({id}) => id),
27
27
  name: output->Pulumi.Output.flatMap(({name}) => name),
28
28
  invokeArn: output->Pulumi.Output.flatMap(({invokeArn}) => invokeArn),
29
+ lastModified: output->Pulumi.Output.flatMap(({lastModified}) => lastModified),
29
30
  }
30
31
 
32
+ /** Resolves once the function's update has actually landed — the barrier a
33
+ deploy-time handshake needs before it may publish to that function.
34
+
35
+ It must depend on `lastModified`. The identifiers (`arn`, `id`, `name`,
36
+ `invokeArn`) are equal before and after a code update, so the engine resolves
37
+ them from existing state and a gate built on one of them opens while the
38
+ update is still in flight — measurably, about a second before it even starts.
39
+ `sourceCodeHash` is no better: it is also an input, so it can be known in
40
+ advance for the same reason. */
41
+ let updateLanded = (lambda: Pulumi.Output.t<PulumiAws.Lambda.Function.t>): Pulumi.Output.t<unit> =>
42
+ lambda->Pulumi.Output.flatMap(({lastModified}) => lastModified)->Pulumi.Output.apply(_ => ())
43
+
31
44
  let functionToResource = (~tags=?, {id, name, arn}: PulumiAws.Lambda.Function.t): ReventlessInfra.Adapter.resource =>
32
45
  ReventlessInfra.Adapter.make(
33
46
  ~name,
@@ -26,10 +26,15 @@ function fromOutput(output) {
26
26
  arn: Output$Pulumi.flatMap(output, param => param.arn),
27
27
  id: Output$Pulumi.flatMap(output, param => param.id),
28
28
  name: Output$Pulumi.flatMap(output, param => param.name),
29
- invokeArn: Output$Pulumi.flatMap(output, param => param.invokeArn)
29
+ invokeArn: Output$Pulumi.flatMap(output, param => param.invokeArn),
30
+ lastModified: Output$Pulumi.flatMap(output, param => param.lastModified)
30
31
  };
31
32
  }
32
33
 
34
+ function updateLanded(lambda) {
35
+ return Output$Pulumi.flatMap(lambda, param => param.lastModified).apply(param => {});
36
+ }
37
+
33
38
  function functionToResource(tags, param) {
34
39
  let name = param.name;
35
40
  return Adapter$ReventlessInfra.make(name, param.id, param.arn, name.apply(param => AWS$ReventlessAws.Lambda.service), undefined, undefined, undefined, Pulumi.output("aws:lambda:Function"), undefined, tags);
@@ -40,6 +45,7 @@ export {
40
45
  toResource,
41
46
  fromResource,
42
47
  fromOutput,
48
+ updateLanded,
43
49
  functionToResource,
44
50
  }
45
51
  /* @pulumi/aws Not a pure module */
@@ -110,6 +110,7 @@ let lambda: Pulumi.Output.t<PulumiAws.Lambda.Function.t> = Pulumi.Output.make({
110
110
  id: "AllStateViewSlices"->Pulumi.Output.make,
111
111
  name: "AllStateViewSlices"->Pulumi.Output.make,
112
112
  invokeArn: "arn:aws:apigateway:invoke"->Pulumi.Output.make,
113
+ lastModified: "2026-01-01T00:00:00.000+0000"->Pulumi.Output.make,
113
114
  })
114
115
 
115
116
  // ── Tests ────────────────────────────────────────────────────────────────────
@@ -66,7 +66,8 @@ let lambda = Pulumi.output({
66
66
  arn: Pulumi.output("arn:aws:lambda:eu-west-1:123456789012:function:AllStateViewSlices"),
67
67
  id: Pulumi.output("AllStateViewSlices"),
68
68
  name: Pulumi.output("AllStateViewSlices"),
69
- invokeArn: Pulumi.output("arn:aws:apigateway:invoke")
69
+ invokeArn: Pulumi.output("arn:aws:apigateway:invoke"),
70
+ lastModified: Pulumi.output("2026-01-01T00:00:00.000+0000")
70
71
  });
71
72
 
72
73
  globalThis.describe("EventCollectorChannel_Helpers.connectLambda under an unknown view table", () => {
@@ -1,26 +1,26 @@
1
1
  open JestGlobals
2
2
 
3
- // Guards what `eventCollectorReadyRef` waits for.
3
+ // Guards what `Util_Lambda.updateLanded` waits for — the barrier the deploy
4
+ // publishes its synthetic re-detect behind.
4
5
  //
5
- // The deploy publishes a synthetic re-detect once the EventCollector Lambda is
6
- // ready, because that Lambda is what ANSWERS the handshake: reached while it is
7
- // still serving the previous bundle, it replies with the previous definition,
8
- // Connect sees a definition it already holds and emits nothing, and the
9
- // registration keeps the old structure — with no second re-detect coming.
6
+ // That collector Lambda is what ANSWERS the handshake: reached while it is still
7
+ // serving the previous bundle, it replies with the previous definition, Connect
8
+ // sees a definition it already holds and emits nothing, and the registration
9
+ // keeps the old structure — with no second re-detect coming.
10
10
  //
11
- // The gate used to be `lambda->Output.apply(_ => ())`. `apply` receives the
12
- // Function record as soon as the OUTER Output carries it, which is at
13
- // construction, with every field inside still an unresolved Output — so the gate
14
- // settled while the update was in flight and the whole barrier was inert. No
15
- // deploy-shaped test catches it: the answer is only stale when a deploy changes a
16
- // structure, and the race is won more often than it is lost.
11
+ // The first attempt at this gate depended on `arn`, and failed in production
12
+ // exactly as it had before: the re-detect went out ~1.2s BEFORE the Lambda update
13
+ // even started. A Lambda's identifiers are equal on both sides of a code update,
14
+ // so the engine resolves them from existing state without waiting for it. The
15
+ // mechanism (`flatMap` awaiting the Output it returns) was never in doubt — the
16
+ // property was.
17
17
  //
18
- // So this asserts the barrier directly — the gate must not settle until the
19
- // resource's own outputs do.
18
+ // So these pin the property, not the mechanism. The middle test is the one that
19
+ // fails for an identifier-based gate.
20
20
 
21
21
  // The engine's Output constructor, so a field can be left genuinely pending
22
- // rather than faked. A resolved promise would settle on the next tick either
23
- // way, which is the difference under test.
22
+ // rather than faked. A resolved promise settles on the next tick either way,
23
+ // which is the difference under test.
24
24
  @module("@pulumi/pulumi") @new
25
25
  external makeOutput: (
26
26
  Set.t<unit>,
@@ -34,7 +34,13 @@ let pending = (value: 'a): (Pulumi.Output.t<'a>, unit => unit) => {
34
34
  let settle = ref(() => ())
35
35
  let p = Promise.make((resolve, _) => settle := () => resolve(value))
36
36
  (
37
- makeOutput(Set.make(), p, Promise.resolve(true), Promise.resolve(false), Promise.resolve(Set.make())),
37
+ makeOutput(
38
+ Set.make(),
39
+ p,
40
+ Promise.resolve(true),
41
+ Promise.resolve(false),
42
+ Promise.resolve(Set.make()),
43
+ ),
38
44
  settle.contents,
39
45
  )
40
46
  }
@@ -43,14 +49,17 @@ let ticks = async () => await Promise.make((resolve, _) => {
43
49
  let _ = setTimeout(() => resolve(), 50)
44
50
  })
45
51
 
46
- // A Lambda mid-update: the resource record exists, its outputs do not yet.
52
+ /** A collector mid-update, the way the engine presents one: the identifiers are
53
+ already known — they do not change across a code update — and only
54
+ `lastModified` is still pending. `complete` is AWS answering. */
47
55
  let deploying = (): (Pulumi.Output.t<PulumiAws.Lambda.Function.t>, unit => unit) => {
48
- let (arn, complete) = pending("arn:aws:lambda:eu-west-1:123456789012:function:CatalogPluginEventColl")
56
+ let (lastModified, complete) = pending("2026-09-08T12:13:41.891+0000")
49
57
  let fn: PulumiAws.Lambda.Function.t = {
50
- arn,
51
- id: "CatalogPluginEventColl"->Pulumi.Output.make,
52
- name: "CatalogPluginEventColl"->Pulumi.Output.make,
58
+ arn: "arn:aws:lambda:eu-west-1:123456789012:function:OrderingPluginEventColl"->Pulumi.Output.make,
59
+ id: "OrderingPluginEventColl"->Pulumi.Output.make,
60
+ name: "OrderingPluginEventColl"->Pulumi.Output.make,
53
61
  invokeArn: "arn:aws:apigateway:invoke"->Pulumi.Output.make,
62
+ lastModified,
54
63
  }
55
64
  // `apply` preserves the nested Outputs, which is how the real resource arrives.
56
65
  (()->Pulumi.Output.make->Pulumi.Output.apply(_ => fn), complete)
@@ -62,28 +71,33 @@ let observe = (gate: Pulumi.Output.t<unit>): ref<bool> => {
62
71
  settled
63
72
  }
64
73
 
65
- describe("the EventCollector readiness gate", () => {
66
- testAsync("does not settle while the function's outputs are still pending", async () => {
74
+ describe("Util_Lambda.updateLanded", () => {
75
+ // The regression. Every identifier on the record is already resolved here, so a
76
+ // gate reading any of them opens immediately — which is the production failure,
77
+ // reproduced.
78
+ testAsync("does not open while only the identifiers are known", async () => {
67
79
  let (lambda, _complete) = deploying()
68
- let settled = lambda->Pulumi.Output.flatMap(fn => fn.arn)->Pulumi.Output.apply(_ => ())->observe
80
+ let settled = lambda->Util_Lambda.updateLanded->observe
69
81
  await ticks()
70
82
  expect(settled.contents)->toBe(false)
71
83
  })
72
84
 
73
- testAsync("settles once they resolve", async () => {
85
+ testAsync("opens once AWS reports the update", async () => {
74
86
  let (lambda, complete) = deploying()
75
- let settled = lambda->Pulumi.Output.flatMap(fn => fn.arn)->Pulumi.Output.apply(_ => ())->observe
87
+ let settled = lambda->Util_Lambda.updateLanded->observe
76
88
  complete()
77
89
  await ticks()
78
90
  expect(settled.contents)->toBe(true)
79
91
  })
80
92
 
81
- // The shape that shipped, kept as the reason the one above is written the way it
82
- // is: applying to the resource settles against a Lambda still being updated.
83
- testAsync("settles immediately when taken off the resource instead", async () => {
93
+ // Kept as the reason the gate is written the way it is: both shapes that were
94
+ // tried before open against a collector still serving the previous bundle.
95
+ testAsync("the shapes that shipped before open too early", async () => {
84
96
  let (lambda, _complete) = deploying()
85
- let settled = lambda->Pulumi.Output.apply(_ => ())->observe
97
+ let onResource = lambda->Pulumi.Output.apply(_ => ())->observe
98
+ let onArn = lambda->Pulumi.Output.flatMap(fn => fn.arn)->Pulumi.Output.apply(_ => ())->observe
86
99
  await ticks()
87
- expect(settled.contents)->toBe(true)
100
+ expect(onResource.contents)->toBe(true)
101
+ expect(onArn.contents)->toBe(true)
88
102
  })
89
103
  })
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as Output$Pulumi from "@reventlessdev/rescript-pulumi-pulumi/src/Output.res.mjs";
4
4
  import * as Pulumi from "@pulumi/pulumi";
5
+ import * as Util_Lambda$ReventlessAws from "../src/util/Util_Lambda.res.mjs";
5
6
 
6
7
  function pending(value) {
7
8
  let settle = {
@@ -23,16 +24,18 @@ async function ticks() {
23
24
  }
24
25
 
25
26
  function deploying() {
26
- let match = pending("arn:aws:lambda:eu-west-1:123456789012:function:CatalogPluginEventColl");
27
- let fn_arn = match[0];
28
- let fn_id = Pulumi.output("CatalogPluginEventColl");
29
- let fn_name = Pulumi.output("CatalogPluginEventColl");
27
+ let match = pending("2026-09-08T12:13:41.891+0000");
28
+ let fn_arn = Pulumi.output("arn:aws:lambda:eu-west-1:123456789012:function:OrderingPluginEventColl");
29
+ let fn_id = Pulumi.output("OrderingPluginEventColl");
30
+ let fn_name = Pulumi.output("OrderingPluginEventColl");
30
31
  let fn_invokeArn = Pulumi.output("arn:aws:apigateway:invoke");
32
+ let fn_lastModified = match[0];
31
33
  let fn = {
32
34
  arn: fn_arn,
33
35
  id: fn_id,
34
36
  name: fn_name,
35
- invokeArn: fn_invokeArn
37
+ invokeArn: fn_invokeArn,
38
+ lastModified: fn_lastModified
36
39
  };
37
40
  return [
38
41
  Pulumi.output().apply(() => fn),
@@ -50,25 +53,28 @@ function observe(gate) {
50
53
  return settled;
51
54
  }
52
55
 
53
- globalThis.describe("the EventCollector readiness gate", () => {
54
- globalThis.test("does not settle while the function's outputs are still pending", async () => {
56
+ globalThis.describe("Util_Lambda.updateLanded", () => {
57
+ globalThis.test("does not open while only the identifiers are known", async () => {
55
58
  let match = deploying();
56
- let settled = observe(Output$Pulumi.flatMap(match[0], fn => fn.arn).apply(param => {}));
59
+ let settled = observe(Util_Lambda$ReventlessAws.updateLanded(match[0]));
57
60
  await ticks();
58
61
  globalThis.expect(settled.contents).toBe(false);
59
62
  });
60
- globalThis.test("settles once they resolve", async () => {
63
+ globalThis.test("opens once AWS reports the update", async () => {
61
64
  let match = deploying();
62
- let settled = observe(Output$Pulumi.flatMap(match[0], fn => fn.arn).apply(param => {}));
65
+ let settled = observe(Util_Lambda$ReventlessAws.updateLanded(match[0]));
63
66
  match[1]();
64
67
  await ticks();
65
68
  globalThis.expect(settled.contents).toBe(true);
66
69
  });
67
- globalThis.test("settles immediately when taken off the resource instead", async () => {
70
+ globalThis.test("the shapes that shipped before open too early", async () => {
68
71
  let match = deploying();
69
- let settled = observe(match[0].apply(param => {}));
72
+ let lambda = match[0];
73
+ let onResource = observe(lambda.apply(param => {}));
74
+ let onArn = observe(Output$Pulumi.flatMap(lambda, fn => fn.arn).apply(param => {}));
70
75
  await ticks();
71
- globalThis.expect(settled.contents).toBe(true);
76
+ globalThis.expect(onResource.contents).toBe(true);
77
+ globalThis.expect(onArn.contents).toBe(true);
72
78
  });
73
79
  });
74
80
 
@@ -100,6 +100,32 @@ describe("Util_Bundle — bundled user packages carry their imports", () => {
100
100
  expect(bundled->Array.includes("@fixture/never-installed"))->toBe(false)
101
101
  })
102
102
 
103
+ testSync("a package the framework merely resolves is still bundled", () => {
104
+ // The layer carries reventless-aws's dependency closure and nothing else.
105
+ // A consumer with a hoisted node_modules puts every package at the root,
106
+ // where the framework's own resolution reaches it by walking up — so
107
+ // resolvability said "the layer has it" about packages the layer has never
108
+ // held, and they were dropped from the archive. `sury` above is genuinely a
109
+ // framework dependency; these are not, and must ride along.
110
+ expect(Util_Bundle.isFrameworkPackage("@fixture/closure-trait"))->toBe(false)
111
+ // A real, published, @reventlessdev-scoped package that is not a dependency
112
+ // of reventless-aws: scope is not what decides this.
113
+ expect(Util_Bundle.isFrameworkPackage("@reventlessdev/reventless-local"))->toBe(false)
114
+ })
115
+
116
+ testSync("the framework's own closure is recognised", () => {
117
+ expect(Util_Bundle.isFrameworkPackage("@reventlessdev/reventless-aws"))->toBe(true)
118
+ expect(Util_Bundle.isFrameworkPackage("sury"))->toBe(true)
119
+ })
120
+
121
+ testSync("a package the layer adds outside that closure counts as the layer's", () => {
122
+ // @rescript/runtime is a transitive of `rescript`, which the layer excludes
123
+ // as a build tool, so it is absent from the production closure while the
124
+ // layer carries it explicitly. Every compiled ReScript file imports it, and
125
+ // it is 19 MB — treating it as a user package puts that in every archive.
126
+ expect(Util_Bundle.isFrameworkPackage("@rescript/runtime"))->toBe(true)
127
+ })
128
+
103
129
  testSync("framework packages are starting points, not walked", () => {
104
130
  // Walking reventless-aws would reach its deploy-time-only imports (the
105
131
  // @pulumi bindings) and add tens of megabytes to every archive.
@@ -106,6 +106,17 @@ globalThis.describe("Util_Bundle — bundled user packages carry their imports",
106
106
  let bundled = Object.keys((Util_Bundle$ReventlessAws.addImportedPackageClosure(packageDirs), packageDirs));
107
107
  globalThis.expect(bundled.includes("@fixture/never-installed")).toBe(false);
108
108
  });
109
+ globalThis.test("a package the framework merely resolves is still bundled", () => {
110
+ globalThis.expect(Util_Bundle$ReventlessAws.isFrameworkPackage("@fixture/closure-trait")).toBe(false);
111
+ globalThis.expect(Util_Bundle$ReventlessAws.isFrameworkPackage("@reventlessdev/reventless-local")).toBe(false);
112
+ });
113
+ globalThis.test("the framework's own closure is recognised", () => {
114
+ globalThis.expect(Util_Bundle$ReventlessAws.isFrameworkPackage("@reventlessdev/reventless-aws")).toBe(true);
115
+ globalThis.expect(Util_Bundle$ReventlessAws.isFrameworkPackage("sury")).toBe(true);
116
+ });
117
+ globalThis.test("a package the layer adds outside that closure counts as the layer's", () => {
118
+ globalThis.expect(Util_Bundle$ReventlessAws.isFrameworkPackage("@rescript/runtime")).toBe(true);
119
+ });
109
120
  globalThis.test("framework packages are starting points, not walked", () => {
110
121
  let packageDirs = Object.fromEntries([[
111
122
  "@reventlessdev/reventless-aws",