@reventlessdev/reventless-aws 3.0.0-alpha.291 → 3.0.0-alpha.292

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,14 @@
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.292 (2026-08-13)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **aws:** re-detect once the Lambda that answers the handshake is up ([597e174](https://github.com/ReventlessDev/reventless-core/commit/597e174059e25461d66db35118c933d2ffce7e1f))
11
+ * **aws:** retain offloaded plugin structures so refs cannot dangle ([120a1a7](https://github.com/ReventlessDev/reventless-core/commit/120a1a7228087dd79f8bcfdd2fddc7b6dbdaf296))
12
+
13
+
6
14
  # 3.0.0-alpha.291 (2026-08-13)
7
15
 
8
16
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.291",
3
+ "version": "3.0.0-alpha.292",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "dependencies": {
@@ -13,16 +13,16 @@
13
13
  "sury": "11.0.0-alpha.4",
14
14
  "uuid": "^13.0.0",
15
15
  "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.8",
16
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
17
+ "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.73",
16
18
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
17
19
  "@reventlessdev/rescript-node": "2.0.0-alpha.5",
18
- "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.72",
19
- "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
20
- "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
20
+ "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
21
21
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
22
- "@reventlessdev/reventless-infra": "3.0.0-alpha.138",
23
- "@reventlessdev/reventless-core": "3.0.0-alpha.230",
24
- "@reventlessdev/reventless-interop": "3.0.0-alpha.30",
25
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.94",
22
+ "@reventlessdev/reventless-core": "3.0.0-alpha.231",
23
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.139",
24
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.31",
25
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.95",
26
26
  "@reventlessdev/reventless-spec": "3.0.0-alpha.112"
27
27
  },
28
28
  "devDependencies": {
package/src/Platform.res CHANGED
@@ -2296,11 +2296,34 @@ module MakeWithConfig = (
2296
2296
  ->Pulumi.Output.apply(o => o->Option.getOr("OFFLOAD_BUCKET_PENDING_PLATFORM_DEPLOY"))
2297
2297
  | None => Pulumi.Output.make("OFFLOAD_BUCKET_PENDING_PLATFORM_DEPLOY")
2298
2298
  }
2299
+ // The key this deploy wrote for the plugin's structure, exported below. The
2300
+ // Plugin read model is what the manifest bake reads and it is updated
2301
+ // asynchronously, so the bake needs to know which structure "current" means;
2302
+ // an equality check against the key the stack just wrote says that without
2303
+ // timestamps, and passes immediately for a plugin whose structure did not
2304
+ // change.
2305
+ let structureOffloadKey = ref(None)
2306
+
2299
2307
  ReventlessCore.Plugin_Helpers.registerOffload((~store, ~bytes) => {
2300
2308
  let hash = NodeCrypto.sha256Hex(bytes)
2301
2309
  let key = "sha256/" ++ hash
2310
+ if store == "pluginStructures" {
2311
+ structureOffloadKey := Some(key)
2312
+ }
2302
2313
  // Content-addressed: the name and key are the hash, so re-deploying an
2303
2314
  // unchanged field writes the same object (idempotent, deduplicating).
2315
+ //
2316
+ // `retainOnDelete`: the only readers of these objects are the persisted
2317
+ // refs on the Plugin read model, and that row is updated asynchronously —
2318
+ // the deploy publishes a re-detect, the Plugin aggregate handles it, the
2319
+ // projection lands, minutes later. A changed field means a new hash, so
2320
+ // without this Pulumi DELETES the previous object as part of the same
2321
+ // update, and every reference still on the read model dangles until
2322
+ // registration catches up. S3 answers a GET for a deleted key with
2323
+ // AccessDenied (it masks the 404 unless the caller may ListBucket), so the
2324
+ // window shows up as an authorization failure in the manifest bake and in
2325
+ // the AutoUI definitions query. Content-addressed objects are immutable
2326
+ // and cost kilobytes; keeping them closes the window outright.
2304
2327
  let _ = PulumiAws.S3.BucketObject.make(
2305
2328
  ~name="offload-" ++ hash,
2306
2329
  ~args={
@@ -2309,6 +2332,7 @@ module MakeWithConfig = (
2309
2332
  content: Pulumi.Input.make(bytes),
2310
2333
  contentType: Pulumi.Input.make("application/json"),
2311
2334
  },
2335
+ ~opts={retainOnDelete: true},
2312
2336
  )
2313
2337
  {Reventless.Offload.store, key, hash, bytes: bytes->String.length}
2314
2338
  })
@@ -2361,6 +2385,23 @@ module MakeWithConfig = (
2361
2385
  let pluginOutputs = pluginComponent->ReventlessCore.Component.outputs
2362
2386
  ReventlessCore.Plugin_Helpers.exportPluginOutputs(pluginOutputs)
2363
2387
 
2388
+ // What the manifest bake compares the read model against. Keyed by the bare
2389
+ // plugin name because that is how the Plugin read model keys its rows — the
2390
+ // stack's own id carries the version, which the bake has no opinion about.
2391
+ switch structureOffloadKey.contents {
2392
+ | Some(key) =>
2393
+ Pulumi.Pulumi.export(
2394
+ "pluginStructureRef",
2395
+ pluginOutputs.id->Pulumi.Output.apply(id =>
2396
+ Dict.fromArray([
2397
+ ("plugin", JSON.Encode.string(ReventlessCore.Plugin.name(id))),
2398
+ ("key", JSON.Encode.string(key)),
2399
+ ])->JSON.Encode.object
2400
+ ),
2401
+ )
2402
+ | None => ()
2403
+ }
2404
+
2364
2405
  // ── Merged-API association (merged-api plan, Phase 4) ───────────────────
2365
2406
  // Associate this plugin's source API with the platform's merged API —
2366
2407
  // this replaces the SigV4 RegisterApiFragment handshake + reactive push +
@@ -2539,7 +2580,20 @@ module MakeWithConfig = (
2539
2580
  // synchronously always saw the initial empty config, so the publish was skipped
2540
2581
  // on every deploy. `pluginOutputs.heartbeat` derives from that same callback's
2541
2582
  // output, so applying to it is the earliest point the config is populated.
2542
- let _ = pluginOutputs.heartbeat->Pulumi.Output.apply(_ => {
2583
+ //
2584
+ // The EventCollector Lambda is the other half of the gate because it is what
2585
+ // ANSWERS the handshake: the re-detect only asks the platform to re-run it,
2586
+ // and the definition that comes back is the one compiled into that Lambda
2587
+ // (PluginConnectExtension_Mapping's UnknownPluginDetected branch). Published
2588
+ // before it carries the new code, the plugin answers with the PREVIOUS
2589
+ // deploy's definition, `Connect` sees a definition it already holds and emits
2590
+ // nothing, and the row keeps the old structure — silently, and with no second
2591
+ // re-detect coming to correct it. Waiting on the heartbeat alone gated on a
2592
+ // Lambda that has no part in the answer.
2593
+ let redetectReady =
2594
+ (pluginOutputs.heartbeat, PluginRuntime_Builder.eventCollectorReadyRef.contents)
2595
+ ->Pulumi.Output.all2
2596
+ let _ = redetectReady->Pulumi.Output.apply(_ => {
2543
2597
  let hbConfig = PluginRuntime_Builder.heartbeatConfigRef.contents
2544
2598
  switch (Pulumi.Pulumi.isDryRun(), hbConfig.epQueueUrl) {
2545
2599
  | (true, _) => () // `pulumi preview` — no deploy-time side effects
@@ -16,6 +16,7 @@ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
16
16
  import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
17
17
  import * as Plugin$ReventlessAws from "./components/Plugin.res.mjs";
18
18
  import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
19
+ import * as Plugin$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/component/Plugin.res.mjs";
19
20
  import * as AWS_Tags$ReventlessAws from "./adapter/AWS_Tags.res.mjs";
20
21
  import * as Message$ReventlessCore from "@reventlessdev/reventless-core/src/Message.res.mjs";
21
22
  import * as Scheduler$ReventlessAws from "./components/Scheduler.res.mjs";
@@ -1248,14 +1249,22 @@ function MakeWithConfig(Config) {
1248
1249
  hooksApiRef.contents = Platform_Casts$ReventlessAws.wrapHookedValue(targetApi);
1249
1250
  hooksApiRoleRef.contents = Platform_Casts$ReventlessAws.wrapHookedValue(targetApiRole);
1250
1251
  let offloadBucketName = platformStackRef !== undefined ? Primitive_option.valFromOption(platformStackRef).getOutput("offloadBucket").apply(o => Stdlib_Option.getOr(o, "OFFLOAD_BUCKET_PENDING_PLATFORM_DEPLOY")) : Pulumi.output("OFFLOAD_BUCKET_PENDING_PLATFORM_DEPLOY");
1252
+ let structureOffloadKey = {
1253
+ contents: undefined
1254
+ };
1251
1255
  Plugin_Helpers$ReventlessCore.registerOffload((store, bytes) => {
1252
1256
  let hash = NodeCrypto.sha256Hex(bytes);
1253
1257
  let key = "sha256/" + hash;
1258
+ if (store === "pluginStructures") {
1259
+ structureOffloadKey.contents = key;
1260
+ }
1254
1261
  new (Aws.s3.BucketObject)("offload-" + hash, {
1255
1262
  bucket: offloadBucketName,
1256
1263
  key: key,
1257
1264
  content: bytes,
1258
1265
  contentType: "application/json"
1266
+ }, {
1267
+ retainOnDelete: true
1259
1268
  });
1260
1269
  return {
1261
1270
  store: store,
@@ -1273,6 +1282,19 @@ function MakeWithConfig(Config) {
1273
1282
  Pulumi$Pulumi.$$export("_interopMeta", Plugin_Helpers$ReventlessCore.getInteropMeta());
1274
1283
  let pluginOutputs = Component$ReventlessCore.outputs(pluginComponent);
1275
1284
  Plugin_Helpers$ReventlessCore.exportPluginOutputs(pluginOutputs);
1285
+ let key = structureOffloadKey.contents;
1286
+ if (key !== undefined) {
1287
+ Pulumi$Pulumi.$$export("pluginStructureRef", pluginOutputs.id.apply(id => Object.fromEntries([
1288
+ [
1289
+ "plugin",
1290
+ Plugin$ReventlessCore.name(id)
1291
+ ],
1292
+ [
1293
+ "key",
1294
+ key
1295
+ ]
1296
+ ])));
1297
+ }
1276
1298
  if (platformStackRef !== undefined) {
1277
1299
  let stackRef = Primitive_option.valFromOption(platformStackRef);
1278
1300
  let defaultOutput = stackRef.getOutput("default");
@@ -1335,7 +1357,11 @@ function MakeWithConfig(Config) {
1335
1357
  if (sel !== undefined) {
1336
1358
  PgQueryResolver_Builder$ReventlessAws.provision(domainApi, sel, {});
1337
1359
  }
1338
- pluginOutputs.heartbeat.apply(param => {
1360
+ let redetectReady = Pulumi.all([
1361
+ pluginOutputs.heartbeat,
1362
+ PluginRuntime_Builder$ReventlessAws.eventCollectorReadyRef.contents
1363
+ ]);
1364
+ redetectReady.apply(param => {
1339
1365
  let hbConfig = PluginRuntime_Builder$ReventlessAws.heartbeatConfigRef.contents;
1340
1366
  let match = IndexJs.isDryRun();
1341
1367
  let match$1 = hbConfig.epQueueUrl;
@@ -2499,14 +2525,22 @@ function Make($star) {
2499
2525
  hooksApiRef.contents = Platform_Casts$ReventlessAws.wrapHookedValue(targetApi);
2500
2526
  hooksApiRoleRef.contents = Platform_Casts$ReventlessAws.wrapHookedValue(targetApiRole);
2501
2527
  let offloadBucketName = platformStackRef !== undefined ? Primitive_option.valFromOption(platformStackRef).getOutput("offloadBucket").apply(o => Stdlib_Option.getOr(o, "OFFLOAD_BUCKET_PENDING_PLATFORM_DEPLOY")) : Pulumi.output("OFFLOAD_BUCKET_PENDING_PLATFORM_DEPLOY");
2528
+ let structureOffloadKey = {
2529
+ contents: undefined
2530
+ };
2502
2531
  Plugin_Helpers$ReventlessCore.registerOffload((store, bytes) => {
2503
2532
  let hash = NodeCrypto.sha256Hex(bytes);
2504
2533
  let key = "sha256/" + hash;
2534
+ if (store === "pluginStructures") {
2535
+ structureOffloadKey.contents = key;
2536
+ }
2505
2537
  new (Aws.s3.BucketObject)("offload-" + hash, {
2506
2538
  bucket: offloadBucketName,
2507
2539
  key: key,
2508
2540
  content: bytes,
2509
2541
  contentType: "application/json"
2542
+ }, {
2543
+ retainOnDelete: true
2510
2544
  });
2511
2545
  return {
2512
2546
  store: store,
@@ -2524,6 +2558,19 @@ function Make($star) {
2524
2558
  Pulumi$Pulumi.$$export("_interopMeta", Plugin_Helpers$ReventlessCore.getInteropMeta());
2525
2559
  let pluginOutputs = Component$ReventlessCore.outputs(pluginComponent);
2526
2560
  Plugin_Helpers$ReventlessCore.exportPluginOutputs(pluginOutputs);
2561
+ let key = structureOffloadKey.contents;
2562
+ if (key !== undefined) {
2563
+ Pulumi$Pulumi.$$export("pluginStructureRef", pluginOutputs.id.apply(id => Object.fromEntries([
2564
+ [
2565
+ "plugin",
2566
+ Plugin$ReventlessCore.name(id)
2567
+ ],
2568
+ [
2569
+ "key",
2570
+ key
2571
+ ]
2572
+ ])));
2573
+ }
2527
2574
  if (platformStackRef !== undefined) {
2528
2575
  let stackRef = Primitive_option.valFromOption(platformStackRef);
2529
2576
  let defaultOutput = stackRef.getOutput("default");
@@ -2586,7 +2633,11 @@ function Make($star) {
2586
2633
  if (sel !== undefined) {
2587
2634
  PgQueryResolver_Builder$ReventlessAws.provision(domainApi, sel, {});
2588
2635
  }
2589
- pluginOutputs.heartbeat.apply(param => {
2636
+ let redetectReady = Pulumi.all([
2637
+ pluginOutputs.heartbeat,
2638
+ PluginRuntime_Builder$ReventlessAws.eventCollectorReadyRef.contents
2639
+ ]);
2640
+ redetectReady.apply(param => {
2590
2641
  let hbConfig = PluginRuntime_Builder$ReventlessAws.heartbeatConfigRef.contents;
2591
2642
  let match = IndexJs.isDryRun();
2592
2643
  let match$1 = hbConfig.epQueueUrl;
@@ -117,6 +117,16 @@ let make = (
117
117
  actions: Actions(["s3:GetObject"]),
118
118
  resources: Resource("arn:aws:s3:::" ++ offloadBucket ++ "/*"),
119
119
  },
120
+ // Read-only on the bucket itself, purely so a key that is not there
121
+ // says so: S3 answers GET for a missing key with AccessDenied unless
122
+ // the caller may list the bucket, which reports a ref the handler
123
+ // cannot resolve as an IAM problem it does not have.
124
+ {
125
+ sid: "AllowOffloadList",
126
+ effect: Allow,
127
+ actions: Actions(["s3:ListBucket"]),
128
+ resources: Resource("arn:aws:s3:::" ++ offloadBucket),
129
+ },
120
130
  ],
121
131
  )
122
132
  ->PolicyDocument.toJsonString
@@ -46,6 +46,7 @@ function make(api, pluginReadModelTableName, offloadBucketName, schemaReady, bak
46
46
  pluginReadModelTableName,
47
47
  offloadBucketName
48
48
  ]).apply(param => {
49
+ let offloadBucket = param[1];
49
50
  new (Aws.iam.RolePolicy)(name + "LambdaPolicy", {
50
51
  policy: PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, name + "LambdaPolicy", [
51
52
  {
@@ -64,7 +65,13 @@ function make(api, pluginReadModelTableName, offloadBucketName, schemaReady, bak
64
65
  Sid: "AllowOffloadGet",
65
66
  Effect: "Allow",
66
67
  Action: ["s3:GetObject"],
67
- Resource: "arn:aws:s3:::" + param[1] + "/*"
68
+ Resource: "arn:aws:s3:::" + offloadBucket + "/*"
69
+ },
70
+ {
71
+ Sid: "AllowOffloadList",
72
+ Effect: "Allow",
73
+ Action: ["s3:ListBucket"],
74
+ Resource: "arn:aws:s3:::" + offloadBucket
68
75
  }
69
76
  ])),
70
77
  role: lambdaRole.id
@@ -216,7 +216,18 @@ let resolveStructure = (
216
216
  ->Option.flatMap(JSON.Decode.string) {
217
217
  | None => Promise.resolve(item)
218
218
  | Some(key) =>
219
- fetch(key)->Promise.then(bytes => {
219
+ fetch(key)
220
+ // The bucket answers for the whole platform, so a failure here says which
221
+ // plugin's ref could not be read — otherwise the S3 error names the key
222
+ // and the bucket, and finding the row it came from is a table scan by hand.
223
+ ->Promise.catch(e => {
224
+ let plugin = item->str("name")->Option.getOr("<unnamed>")
225
+ JsError.throwWithMessage(
226
+ `offloaded structure for plugin ${plugin} is unreadable at ${key}: ` ++
227
+ e->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("unknown error"),
228
+ )
229
+ })
230
+ ->Promise.then(bytes => {
220
231
  let resolved = Dict.fromArray(item->Dict.toArray)
221
232
  resolved->Dict.set("structure", JSON.parseOrThrow(bytes))
222
233
  Promise.resolve(resolved)
@@ -337,6 +348,58 @@ let bakeTargetOf = (event: JSON.t): option<bakeTarget> =>
337
348
  }
338
349
  )
339
350
 
351
+ // ── Registration freshness ───────────────────────────────────────────────────
352
+ // The read model this bake scans is updated asynchronously: the plugin stack
353
+ // publishes a re-detect, the plugin answers with its definition, the projection
354
+ // lands. Invoked seconds after the last stack finished, the scan can still
355
+ // describe the deploy before it — and a manifest baked from that is wrong in the
356
+ // one way nothing downstream can detect, because it is a perfectly well-formed
357
+ // description of the wrong deployment.
358
+ //
359
+ // So the invocation carries the structure key each plugin stack just wrote. That
360
+ // is an equality check rather than an inference from timestamps, and it costs
361
+ // nothing for a plugin that was not redeployed: its key already matches. A caller
362
+ // that supplies no expectations bakes whatever is current — the query paths never
363
+ // send any, and a hand-run bake should not need to.
364
+ let bakeExpectations = (event: JSON.t): dict<string> =>
365
+ event
366
+ ->JSON.Decode.object
367
+ ->Option.flatMap(o => o->Dict.get("expect"))
368
+ ->Option.flatMap(JSON.Decode.object)
369
+ ->Option.mapOr(Dict.make(), o =>
370
+ o
371
+ ->Dict.toArray
372
+ ->Array.filterMap(((plugin, key)) => key->JSON.Decode.string->Option.map(k => (plugin, k)))
373
+ ->Dict.fromArray
374
+ )
375
+
376
+ // The offload key a scanned row carries. None for a structure held inline, which
377
+ // on a deployed platform means the row predates offloading — it cannot match an
378
+ // expectation, and saying so beats baking it.
379
+ let structureRefKey = (item: dict<JSON.t>): option<string> =>
380
+ item
381
+ ->Dict.get("structure")
382
+ ->Option.flatMap(JSON.Decode.object)
383
+ ->Option.flatMap(o => o->Dict.get(Reventless.Offload.sentinelKey))
384
+ ->Option.flatMap(JSON.Decode.object)
385
+ ->Option.flatMap(r => r->Dict.get("key"))
386
+ ->Option.flatMap(JSON.Decode.string)
387
+
388
+ // Compared against the collapsed latest version per plugin, the same view the
389
+ // bake itself takes — an older version's row lingering on the table is not the
390
+ // registration anyone is waiting for.
391
+ let pendingRegistrations = (items: array<dict<JSON.t>>, ~expect: dict<string>): array<string> => {
392
+ let current =
393
+ Platform_AdminScan_Ops.latestByName(
394
+ items,
395
+ ~nameVersionOf=item => item->str("name"),
396
+ ~toEntry=(item, ~name) => item->structureRefKey->Option.map(key => (name, key)),
397
+ )->Dict.fromArray
398
+ expect
399
+ ->Dict.toArray
400
+ ->Array.filterMap(((plugin, key)) => current->Dict.get(plugin) == Some(key) ? None : Some(plugin))
401
+ }
402
+
340
403
  // Every failure mode here is the deployment's own mistake — a name matching no
341
404
  // component, a structure too old to read, a bucket the function may not write —
342
405
  // and every one of them produces the same symptom if swallowed: a shop that
@@ -404,22 +467,36 @@ let handler = async (event: JSON.t): array<JSON.t> => {
404
467
  let fetch = Reventless.Offload.cachedFetch(key =>
405
468
  AwsSdk.S3.GetObjectCommand.getString(~bucket, ~key)
406
469
  )
407
- let items = await Promise.all(rawItems->Array.map(item => resolveStructure(fetch, item)))
470
+ let resolveAll = () => Promise.all(rawItems->Array.map(item => resolveStructure(fetch, item)))
408
471
  switch bakeTarget {
409
472
  | Some(target) =>
410
- // The built-in admin entry is deliberately absent: it never enters the
411
- // Plugin read model, and the in-memory bake curates the composed plugins
412
- // only. A deployment naming it gets `UnknownPlugin`, on both platforms.
413
- let structures = Platform_AdminScan_Ops.latestByName(
414
- items,
415
- ~nameVersionOf=item => item->str("name"),
416
- ~toEntry=structureOf,
417
- )
418
- await runBake(~target, ~structures)
473
+ // Checked on the raw rows: the refs are what the deploy can predict, and a
474
+ // row that is behind should not have its structure fetched at all.
475
+ switch pendingRegistrations(rawItems, ~expect=bakeExpectations(event)) {
476
+ | [] =>
477
+ // The built-in admin entry is deliberately absent: it never enters the
478
+ // Plugin read model, and the in-memory bake curates the composed plugins
479
+ // only. A deployment naming it gets `UnknownPlugin`, on both platforms.
480
+ let structures = Platform_AdminScan_Ops.latestByName(
481
+ await resolveAll(),
482
+ ~nameVersionOf=item => item->str("name"),
483
+ ~toEntry=structureOf,
484
+ )
485
+ await runBake(~target, ~structures)
486
+ | pending =>
487
+ // Not an error — the deploy just has not finished arriving. Reported so the
488
+ // caller can invoke again rather than bake the previous deployment.
489
+ [
490
+ Dict.fromArray([
491
+ ("baked", JSON.Encode.bool(false)),
492
+ ("pending", pending->Array.map(JSON.Encode.string)->JSON.Encode.array),
493
+ ])->JSON.Encode.object,
494
+ ]
495
+ }
419
496
  | None =>
420
497
  let userEntries =
421
498
  Platform_AdminScan_Ops.latestByName(
422
- items,
499
+ await resolveAll(),
423
500
  ~nameVersionOf=item => item->str("name"),
424
501
  ~toEntry,
425
502
  )
@@ -6,8 +6,11 @@ import * as S3$AwsSdk from "@reventlessdev/rescript-aws-sdk/src/S3.res.mjs";
6
6
  import * as Nodepath from "node:path";
7
7
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
8
8
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
9
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
9
10
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
10
11
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
12
+ import * as Stdlib_Promise from "@rescript/runtime/lib/es6/Stdlib_Promise.js";
13
+ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
11
14
  import * as Plugin$Reventless from "@reventlessdev/reventless-spec/src/components/Plugin.res.mjs";
12
15
  import * as ClientS3 from "@aws-sdk/client-s3";
13
16
  import * as Offload$Reventless from "@reventlessdev/reventless-spec/src/semantic/Offload.res.mjs";
@@ -216,7 +219,10 @@ function resolveStructure(fetch, item) {
216
219
  }
217
220
  let key = Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(refJson), r => r["key"]), Stdlib_JSON.Decode.string);
218
221
  if (key !== undefined) {
219
- return fetch(key).then(bytes => {
222
+ return Stdlib_Promise.$$catch(fetch(key), e => {
223
+ let plugin = Stdlib_Option.getOr(str(item, "name"), "<unnamed>");
224
+ return Stdlib_JsError.throwWithMessage(`offloaded structure for plugin ` + plugin + ` is unreadable at ` + key + `: ` + Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(e), Stdlib_JsExn.message), "unknown error"));
225
+ }).then(bytes => {
220
226
  let resolved = Object.fromEntries(Object.entries(item));
221
227
  resolved["structure"] = JSON.parse(bytes);
222
228
  return Promise.resolve(resolved);
@@ -295,6 +301,35 @@ function bakeTargetOf(event) {
295
301
  });
296
302
  }
297
303
 
304
+ function bakeExpectations(event) {
305
+ return Stdlib_Option.mapOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(event), o => o["expect"]), Stdlib_JSON.Decode.object), {}, o => Object.fromEntries(Stdlib_Array.filterMap(Object.entries(o), param => {
306
+ let plugin = param[0];
307
+ return Stdlib_Option.map(Stdlib_JSON.Decode.string(param[1]), k => [
308
+ plugin,
309
+ k
310
+ ]);
311
+ })));
312
+ }
313
+
314
+ function structureRefKey(item) {
315
+ return Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(item["structure"], Stdlib_JSON.Decode.object), o => o[Offload$Reventless.sentinelKey]), Stdlib_JSON.Decode.object), r => r["key"]), Stdlib_JSON.Decode.string);
316
+ }
317
+
318
+ function pendingRegistrations(items, expect) {
319
+ let current = Object.fromEntries(Platform_AdminScan_Ops$ReventlessAws.latestByName(items, item => str(item, "name"), (item, name) => Stdlib_Option.map(structureRefKey(item), key => [
320
+ name,
321
+ key
322
+ ])));
323
+ return Stdlib_Array.filterMap(Object.entries(expect), param => {
324
+ let plugin = param[0];
325
+ if (Primitive_object.equal(current[plugin], param[1])) {
326
+ return;
327
+ } else {
328
+ return plugin;
329
+ }
330
+ });
331
+ }
332
+
298
333
  async function runBake(target, structures) {
299
334
  let selections = bakeSelections();
300
335
  if (selections.length === 0) {
@@ -351,12 +386,25 @@ async function handler(event) {
351
386
  ]]));
352
387
  let bucket = Stdlib_Option.getOr(process.env["OFFLOAD_BUCKET"], "");
353
388
  let fetch = Offload$Reventless.cachedFetch(key => S3$AwsSdk.GetObjectCommand.getString(bucket, key));
354
- let items = await Promise.all(rawItems.map(item => resolveStructure(fetch, item)));
389
+ let resolveAll = () => Promise.all(rawItems.map(item => resolveStructure(fetch, item)));
355
390
  if (bakeTarget !== undefined) {
356
- let structures = Platform_AdminScan_Ops$ReventlessAws.latestByName(items, item => str(item, "name"), structureOf);
391
+ let pending = pendingRegistrations(rawItems, bakeExpectations(event));
392
+ if (pending.length !== 0) {
393
+ return [Object.fromEntries([
394
+ [
395
+ "baked",
396
+ false
397
+ ],
398
+ [
399
+ "pending",
400
+ pending.map(prim => prim)
401
+ ]
402
+ ])];
403
+ }
404
+ let structures = Platform_AdminScan_Ops$ReventlessAws.latestByName(await resolveAll(), item => str(item, "name"), structureOf);
357
405
  return await runBake(bakeTarget, structures);
358
406
  }
359
- let userEntries = Platform_AdminScan_Ops$ReventlessAws.latestByName(items, item => str(item, "name"), toEntry);
407
+ let userEntries = Platform_AdminScan_Ops$ReventlessAws.latestByName(await resolveAll(), item => str(item, "name"), toEntry);
360
408
  return admin.concat(userEntries);
361
409
  }
362
410
  console.error("Platform_ComponentDefinitions: PLUGIN_RM_TABLE env var not set");
@@ -386,6 +434,9 @@ export {
386
434
  bakeSelection,
387
435
  bakeSelections,
388
436
  bakeTargetOf,
437
+ bakeExpectations,
438
+ structureRefKey,
439
+ pendingRegistrations,
389
440
  runBake,
390
441
  handler,
391
442
  }
@@ -186,6 +186,20 @@ let heartbeatConfigRef: ref<heartbeatConfig> = ref({
186
186
  let registerHeartbeatConfig = (~pluginId, ~heartbeatTimeout, ~epQueueUrl=?, ()) =>
187
187
  heartbeatConfigRef := {pluginId, heartbeatTimeout, epQueueUrl}
188
188
 
189
+ // Resolves once this plugin's EventCollector Lambda exists at its new code.
190
+ // `Platform.deployPlugin` gates the deploy-time re-detect on it: the re-detect
191
+ // only asks the platform to re-run the connect handshake, and this is the Lambda
192
+ // that ANSWERS it, with the `pluginDefinition` its deployed code carries. Fired
193
+ // before this Lambda is updated, the plugin answers with the previous deploy's
194
+ // definition, `Connect` sees a definition it already holds and emits nothing, and
195
+ // the row keeps the old structure — with no second re-detect to correct it.
196
+ //
197
+ // A bare `Output<unit>` rather than an `option<Output<_>>`: an Output is a
198
+ // JS Proxy, and wrapping one in a ReScript option produces the nested-Some
199
+ // sentinel instead of the value. Empty until `forPluginEventCollector` runs,
200
+ // which the gate reads only after `P.make()` has returned.
201
+ let eventCollectorReadyRef: ref<Pulumi.Output.t<unit>> = ref(Pulumi.Output.make())
202
+
189
203
  // Per-flavor commandHandlerConfig override for the two DCB command-handler Lambdas
190
204
  // (`<Plugin>DcbCmdHandler` sync / `<Plugin>DcbAsyncCmdHandler` async); populated by
191
205
  // Platform.MakeWithConfig from `commandHandlerConfig.stateChanges.{sync,async}`.
@@ -624,6 +638,8 @@ module Make = (
624
638
  ~opts,
625
639
  )
626
640
 
641
+ eventCollectorReadyRef := runtime.parts.lambda->Pulumi.Output.apply(_ => ())
642
+
627
643
  // Admin-only cross-plugin SNS subscription permissions. The admin EC
628
644
  // Lambda's manageSubscriptions hook (Phase 3 Step 1) creates SNS
629
645
  // subscriptions at runtime as plugins connect/disconnect; that needs
@@ -149,6 +149,10 @@ function registerHeartbeatConfig(pluginId, heartbeatTimeout, epQueueUrl, param)
149
149
  };
150
150
  }
151
151
 
152
+ let eventCollectorReadyRef = {
153
+ contents: Pulumi.output()
154
+ };
155
+
152
156
  let syncStateChangesConfigRef = {
153
157
  contents: {}
154
158
  };
@@ -381,6 +385,7 @@ function Make(EventCollectorChannel) {
381
385
  eventTopics: eventTopics,
382
386
  resources: resources
383
387
  }], runtime, opts);
388
+ eventCollectorReadyRef.contents = runtime.parts.lambda.apply(param => {});
384
389
  let isAdminEventCollector = Stdlib_Option.isNone(Plugin_Helpers$ReventlessCore.eventCollectorContextRef.contents[name]);
385
390
  if (isAdminEventCollector) {
386
391
  new (Aws.iam.RolePolicy)(name + `-snsManageSubs`, {
@@ -542,6 +547,7 @@ export {
542
547
  registerDcbTableName,
543
548
  heartbeatConfigRef,
544
549
  registerHeartbeatConfig,
550
+ eventCollectorReadyRef,
545
551
  syncStateChangesConfigRef,
546
552
  asyncStateChangesConfigRef,
547
553
  setStateChangesConfig,
@@ -240,3 +240,113 @@ describe("bakeSelection", () => {
240
240
  expect(sel(`{"views": ["Orders"]}`)->Option.isNone)->toBe(true)
241
241
  )
242
242
  })
243
+
244
+ // The bake reads a read model the deploy updates asynchronously, so "is this the
245
+ // deployment I was asked to bake" is a question it has to be able to answer. The
246
+ // answer is an equality check against the key each plugin stack just wrote.
247
+ describe("pendingRegistrations", () => {
248
+ let row = (~name, ~key=?, ()) => {
249
+ let item = Dict.fromArray([("name", JSON.Encode.string(name))])
250
+ key->Option.forEach(k =>
251
+ item->Dict.set(
252
+ "structure",
253
+ JSON.parseOrThrow(`{"$offload": {"store": "pluginStructures", "key": "${k}"}}`),
254
+ )
255
+ )
256
+ item
257
+ }
258
+
259
+ let pending = (rows, expect) =>
260
+ Platform_ComponentDefinitions_Lambda_Ops.pendingRegistrations(
261
+ rows,
262
+ ~expect=Dict.fromArray(expect),
263
+ )
264
+
265
+ testSync("nothing is pending when every row carries the key the deploy wrote", () =>
266
+ expect(
267
+ pending(
268
+ [row(~name="Catalog", ~key="sha256/a", ()), row(~name="Ordering", ~key="sha256/b", ())],
269
+ [("Catalog", "sha256/a"), ("Ordering", "sha256/b")],
270
+ ),
271
+ )->toEqual([])
272
+ )
273
+
274
+ // The window this exists for: the stack wrote a new structure, the row still
275
+ // points at the one before it.
276
+ testSync("names a plugin still carrying the previous structure", () =>
277
+ expect(
278
+ pending(
279
+ [row(~name="Catalog", ~key="sha256/a", ()), row(~name="Ordering", ~key="sha256/old", ())],
280
+ [("Catalog", "sha256/a"), ("Ordering", "sha256/new")],
281
+ ),
282
+ )->toEqual(["Ordering"])
283
+ )
284
+
285
+ // A plugin that has never registered is behind, not absent — baking without it
286
+ // would ship a shop missing a section.
287
+ testSync("names a plugin with no row at all", () =>
288
+ expect(pending([], [("Ordering", "sha256/new")]))->toEqual(["Ordering"])
289
+ )
290
+
291
+ // Deployed before the stack exported its key, or invoked by hand: bake what is
292
+ // current rather than wait for an expectation nobody stated.
293
+ testSync("waits for nothing when the caller expects nothing", () =>
294
+ expect(pending([row(~name="Ordering", ~key="sha256/old", ())], []))->toEqual([])
295
+ )
296
+
297
+ testSync("reads the expectations off the invocation payload", () =>
298
+ expect(
299
+ Platform_ComponentDefinitions_Lambda_Ops.bakeExpectations(
300
+ JSON.parseOrThrow(`{"bake": true, "expect": {"Ordering": "sha256/b"}}`),
301
+ )->Dict.get("Ordering"),
302
+ )->toEqual(Some("sha256/b"))
303
+ )
304
+ })
305
+
306
+ describe("resolveStructure", () => {
307
+ let item = (structure: string) =>
308
+ Dict.fromArray([
309
+ ("name", JSON.Encode.string("Ordering")),
310
+ ("structure", JSON.parseOrThrow(structure)),
311
+ ])
312
+
313
+ let offloaded = `{"$offload": {"store": "pluginStructures", "key": "sha256/abc", "bytes": 4}}`
314
+
315
+ test("substitutes the fetched bytes for the reference", async () => {
316
+ let resolved = await Platform_ComponentDefinitions_Lambda_Ops.resolveStructure(
317
+ _ => Promise.resolve(`{"aggregates": []}`),
318
+ item(offloaded),
319
+ )
320
+ expect(resolved->Dict.get("structure")->Option.map(j => JSON.stringify(j)))->toEqual(
321
+ Some(`{"aggregates":[]}`),
322
+ )
323
+ })
324
+
325
+ // One offload bucket serves every plugin, so the S3 error names only the key —
326
+ // which plugin's row carries the unreadable reference is the part worth having.
327
+ // A missing object is the shape a deploy leaves behind while the read model
328
+ // still points at the previous structure.
329
+ test("names the plugin whose reference cannot be read", async () => {
330
+ let failed = await Platform_ComponentDefinitions_Lambda_Ops.resolveStructure(
331
+ _ => Promise.reject(JsExn.anyToExnInternal(JsError.make("AccessDenied"))),
332
+ item(offloaded),
333
+ )
334
+ ->Promise.thenResolve(_ => None)
335
+ ->Promise.catch(e =>
336
+ Promise.resolve(e->JsExn.fromException->Option.flatMap(JsExn.message))
337
+ )
338
+ expect(failed)->toEqual(
339
+ Some("offloaded structure for plugin Ordering is unreadable at sha256/abc: AccessDenied"),
340
+ )
341
+ })
342
+
343
+ test("passes an inline structure through untouched", async () => {
344
+ let resolved = await Platform_ComponentDefinitions_Lambda_Ops.resolveStructure(
345
+ _ => Promise.reject(JsExn.anyToExnInternal(JsError.make("must not fetch"))),
346
+ item(`{"aggregates": []}`),
347
+ )
348
+ expect(resolved->Dict.get("structure")->Option.map(j => JSON.stringify(j)))->toEqual(
349
+ Some(`{"aggregates":[]}`),
350
+ )
351
+ })
352
+ })
@@ -2,7 +2,10 @@
2
2
 
3
3
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
4
4
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
5
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
5
6
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
+ import * as Stdlib_Promise from "@rescript/runtime/lib/es6/Stdlib_Promise.js";
8
+ import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
6
9
  import * as Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws from "../src/adapter/Api/Platform_ComponentDefinitions_Lambda_Ops.res.mjs";
7
10
 
8
11
  function entry(filter, structure) {
@@ -151,6 +154,90 @@ globalThis.describe("bakeSelection", () => {
151
154
  });
152
155
  });
153
156
 
157
+ globalThis.describe("pendingRegistrations", () => {
158
+ let row = (name, key, param) => {
159
+ let item = Object.fromEntries([[
160
+ "name",
161
+ name
162
+ ]]);
163
+ Stdlib_Option.forEach(key, k => {
164
+ item["structure"] = JSON.parse(`{"$offload": {"store": "pluginStructures", "key": "` + k + `"}}`);
165
+ });
166
+ return item;
167
+ };
168
+ globalThis.test("nothing is pending when every row carries the key the deploy wrote", () => {
169
+ let rows = [
170
+ row("Catalog", "sha256/a", undefined),
171
+ row("Ordering", "sha256/b", undefined)
172
+ ];
173
+ globalThis.expect(Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.pendingRegistrations(rows, Object.fromEntries([
174
+ [
175
+ "Catalog",
176
+ "sha256/a"
177
+ ],
178
+ [
179
+ "Ordering",
180
+ "sha256/b"
181
+ ]
182
+ ]))).toEqual([]);
183
+ });
184
+ globalThis.test("names a plugin still carrying the previous structure", () => {
185
+ let rows = [
186
+ row("Catalog", "sha256/a", undefined),
187
+ row("Ordering", "sha256/old", undefined)
188
+ ];
189
+ globalThis.expect(Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.pendingRegistrations(rows, Object.fromEntries([
190
+ [
191
+ "Catalog",
192
+ "sha256/a"
193
+ ],
194
+ [
195
+ "Ordering",
196
+ "sha256/new"
197
+ ]
198
+ ]))).toEqual(["Ordering"]);
199
+ });
200
+ globalThis.test("names a plugin with no row at all", () => {
201
+ globalThis.expect(Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.pendingRegistrations([], Object.fromEntries([[
202
+ "Ordering",
203
+ "sha256/new"
204
+ ]]))).toEqual(["Ordering"]);
205
+ });
206
+ globalThis.test("waits for nothing when the caller expects nothing", () => {
207
+ let rows = [row("Ordering", "sha256/old", undefined)];
208
+ globalThis.expect(Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.pendingRegistrations(rows, Object.fromEntries([]))).toEqual([]);
209
+ });
210
+ globalThis.test("reads the expectations off the invocation payload", () => {
211
+ globalThis.expect(Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.bakeExpectations(JSON.parse(`{"bake": true, "expect": {"Ordering": "sha256/b"}}`))["Ordering"]).toEqual("sha256/b");
212
+ });
213
+ });
214
+
215
+ globalThis.describe("resolveStructure", () => {
216
+ let item = structure => Object.fromEntries([
217
+ [
218
+ "name",
219
+ "Ordering"
220
+ ],
221
+ [
222
+ "structure",
223
+ JSON.parse(structure)
224
+ ]
225
+ ]);
226
+ let offloaded = `{"$offload": {"store": "pluginStructures", "key": "sha256/abc", "bytes": 4}}`;
227
+ globalThis.test("substitutes the fetched bytes for the reference", async () => {
228
+ let resolved = await Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.resolveStructure(param => Promise.resolve(`{"aggregates": []}`), item(offloaded));
229
+ globalThis.expect(Stdlib_Option.map(resolved["structure"], j => JSON.stringify(j))).toEqual(`{"aggregates":[]}`);
230
+ });
231
+ globalThis.test("names the plugin whose reference cannot be read", async () => {
232
+ let failed = await Stdlib_Promise.$$catch(Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.resolveStructure(param => Promise.reject(Primitive_exceptions.internalToException(new Error("AccessDenied"))), item(offloaded)).then(param => {}), e => Promise.resolve(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(e), Stdlib_JsExn.message)));
233
+ globalThis.expect(failed).toEqual("offloaded structure for plugin Ordering is unreadable at sha256/abc: AccessDenied");
234
+ });
235
+ globalThis.test("passes an inline structure through untouched", async () => {
236
+ let resolved = await Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.resolveStructure(param => Promise.reject(Primitive_exceptions.internalToException(new Error("must not fetch"))), item(`{"aggregates": []}`));
237
+ globalThis.expect(Stdlib_Option.map(resolved["structure"], j => JSON.stringify(j))).toEqual(`{"aggregates":[]}`);
238
+ });
239
+ });
240
+
154
241
  export {
155
242
  entry,
156
243
  members,