@reventlessdev/reventless-aws 3.0.0-alpha.328 → 3.0.0-alpha.329

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,13 @@
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.329 (2026-08-27)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **aws:** report why the manifest bake is waiting, and what a pass proved ([7ba2a04](https://github.com/ReventlessDev/reventless-core/commit/7ba2a04c7dfa88bc10ca4336f9298f5fee1eb763))
11
+
12
+
6
13
  # 3.0.0-alpha.328 (2026-08-27)
7
14
 
8
15
  **Note:** Version bump only for package @reventlessdev/reventless-aws
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.328",
3
+ "version": "3.0.0-alpha.329",
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.13",
18
19
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
19
- "@reventlessdev/rescript-node": "2.0.0-alpha.8",
20
20
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
21
- "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.13",
21
+ "@reventlessdev/rescript-node": "2.0.0-alpha.8",
22
22
  "@reventlessdev/rescript-pulumi-aws": "3.0.0-alpha.6",
23
23
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
24
- "@reventlessdev/reventless-core": "3.0.0-alpha.251",
25
24
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
25
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.152",
26
26
  "@reventlessdev/reventless-interop": "3.0.0-alpha.34",
27
27
  "@reventlessdev/reventless-spec": "3.0.0-alpha.124",
28
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.115",
29
- "@reventlessdev/reventless-infra": "3.0.0-alpha.152"
28
+ "@reventlessdev/reventless-core": "3.0.0-alpha.251",
29
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.115"
30
30
  },
31
31
  "devDependencies": {
32
32
  "rescript": "12.3.0",
@@ -440,6 +440,15 @@ let bakeExpectations = (event: JSON.t): dict<string> =>
440
440
  ->Dict.fromArray
441
441
  )
442
442
 
443
+ // The instant this deploy began, when the caller names one. Optional because the
444
+ // key comparison stands without it; what it adds is telling a matching key that
445
+ // was re-registered apart from one that merely never changed.
446
+ let bakeSince = (event: JSON.t): option<string> =>
447
+ event
448
+ ->JSON.Decode.object
449
+ ->Option.flatMap(o => o->Dict.get("since"))
450
+ ->Option.flatMap(JSON.Decode.string)
451
+
443
452
  // The offload key a scanned row carries. None for a structure held inline, which
444
453
  // on a deployed platform means the row predates offloading — it cannot match an
445
454
  // expectation, and saying so beats baking it.
@@ -452,19 +461,143 @@ let structureRefKey = (item: dict<JSON.t>): option<string> =>
452
461
  ->Option.flatMap(r => r->Dict.get("key"))
453
462
  ->Option.flatMap(JSON.Decode.string)
454
463
 
464
+ // When the projection last wrote this row. Every status-moving event stamps the
465
+ // producing message's time onto `statusChange`, and a Connect carrying a changed
466
+ // definition re-emits VersionConnected — so this advances exactly when the
467
+ // registration chain completes, and stays put when it does not.
468
+ let rowWrittenAt = (item: dict<JSON.t>): option<string> =>
469
+ item
470
+ ->Dict.get("statusChange")
471
+ ->Option.flatMap(JSON.Decode.object)
472
+ ->Option.flatMap(o => o->Dict.get("at"))
473
+ ->Option.flatMap(JSON.Decode.string)
474
+
475
+ // Parsed rather than compared as strings: an offset-bearing ISO timestamp on
476
+ // either side orders wrongly lexically. An unreadable date answers false, so it
477
+ // reports a plugin as still pending rather than baking on a stamp nobody read.
478
+ let notBefore = (~at: string, ~since: string): bool => {
479
+ let a = Date.fromString(at)->Date.getTime
480
+ let s = Date.fromString(since)->Date.getTime
481
+ !Float.isNaN(a) && !Float.isNaN(s) && a >= s
482
+ }
483
+
484
+ /** What the Plugin read model says about one plugin the bake is waiting on.
485
+
486
+ Distinct outcomes rather than a boolean because a bake that reported only
487
+ "pending" left every one of these to be told apart by reading the read model
488
+ out of band, with credentials the deploy pipeline does not surface. */
489
+ type registrationState =
490
+ /** Key agrees, row written by this deploy: the chain ran, and this is the evidence. */
491
+ | Registered
492
+ /** Key agrees, nothing re-registered — a correct pass that proves nothing. */
493
+ | Unchanged
494
+ /** Key agrees, and no deploy instant to tell the two above apart. */
495
+ | Matched
496
+ /** No Connected row carries a structure. The scan filters on Connected, so a
497
+ version that dropped out mid-deploy leaves nothing to converge against. */
498
+ | Missing
499
+ /** Row holds another key and predates this deploy — the case retrying is for. */
500
+ | Behind
501
+ /** Row written by this deploy and still holding another key. Retrying cannot fix it. */
502
+ | Diverged
503
+
504
+ type registration = {
505
+ plugin: string,
506
+ expected: string,
507
+ found: option<string>,
508
+ writtenAt: option<string>,
509
+ state: registrationState,
510
+ }
511
+
512
+ let classify = (
513
+ ~expected: string,
514
+ ~found: option<string>,
515
+ ~writtenAt: option<string>,
516
+ ~since: option<string>,
517
+ ): registrationState => {
518
+ let fresh = switch (since, writtenAt) {
519
+ | (Some(since), Some(at)) => Some(notBefore(~at, ~since))
520
+ | _ => None
521
+ }
522
+ switch found {
523
+ | None => Missing
524
+ | Some(key) if key == expected =>
525
+ switch fresh {
526
+ | None => Matched
527
+ | Some(true) => Registered
528
+ | Some(false) => Unchanged
529
+ }
530
+ | Some(_) => fresh == Some(true) ? Diverged : Behind
531
+ }
532
+ }
533
+
455
534
  // Compared against the collapsed latest version per plugin, the same view the
456
535
  // bake itself takes — an older version's row lingering on the table is not the
457
536
  // registration anyone is waiting for.
458
- let pendingRegistrations = (items: array<dict<JSON.t>>, ~expect: dict<string>): array<string> => {
537
+ let registrations = (
538
+ items: array<dict<JSON.t>>,
539
+ ~expect: dict<string>,
540
+ ~since: option<string>,
541
+ ): array<registration> => {
459
542
  let current =
460
543
  Platform_AdminScan_Ops.latestByName(
461
544
  items,
462
545
  ~nameVersionOf=item => item->str("name"),
463
- ~toEntry=(item, ~name) => item->structureRefKey->Option.map(key => (name, key)),
546
+ ~toEntry=(item, ~name) =>
547
+ item->structureRefKey->Option.map(key => (name, (key, item->rowWrittenAt))),
464
548
  )->Dict.fromArray
465
549
  expect
466
550
  ->Dict.toArray
467
- ->Array.filterMap(((plugin, key)) => current->Dict.get(plugin) == Some(key) ? None : Some(plugin))
551
+ ->Array.map(((plugin, expected)) => {
552
+ let row = current->Dict.get(plugin)
553
+ let found = row->Option.map(((key, _)) => key)
554
+ let writtenAt = row->Option.flatMap(((_, at)) => at)
555
+ {plugin, expected, found, writtenAt, state: classify(~expected, ~found, ~writtenAt, ~since)}
556
+ })
557
+ }
558
+
559
+ let isPending = (r: registration): bool =>
560
+ switch r.state {
561
+ | Missing | Behind | Diverged => true
562
+ | Registered | Unchanged | Matched => false
563
+ }
564
+
565
+ let stateName = (s: registrationState): string =>
566
+ switch s {
567
+ | Registered => "registered"
568
+ | Unchanged => "unchanged"
569
+ | Matched => "matched"
570
+ | Missing => "missing"
571
+ | Behind => "behind"
572
+ | Diverged => "diverged"
573
+ }
574
+
575
+ // Both halves of the comparison and the date on the row, which is what separates
576
+ // the causes on sight. `found` and `writtenAt` travel as null rather than being
577
+ // omitted, so the line printed for a plugin with no row has a column for them too.
578
+ let encodeRegistration = (r: registration): JSON.t =>
579
+ Dict.fromArray([
580
+ ("plugin", JSON.Encode.string(r.plugin)),
581
+ ("state", JSON.Encode.string(r.state->stateName)),
582
+ ("expected", JSON.Encode.string(r.expected)),
583
+ ("found", r.found->Option.mapOr(JSON.Encode.null, JSON.Encode.string)),
584
+ ("writtenAt", r.writtenAt->Option.mapOr(JSON.Encode.null, JSON.Encode.string)),
585
+ ])->JSON.Encode.object
586
+
587
+ // What a green bake actually proved. Without the counts it says "nothing needed to
588
+ // converge" as often as it says "convergence works", and the two read identically
589
+ // — so this path can be broken across any number of green deploys and surface on
590
+ // the first one that changes a structure, maximally far from what broke it.
591
+ let encodeSummary = (rs: array<registration>): JSON.t => {
592
+ let count = f => rs->Array.filter(f)->Array.length
593
+ Dict.fromArray([
594
+ ("summary", JSON.Encode.bool(true)),
595
+ ("plugins", JSON.Encode.int(rs->Array.length)),
596
+ ("registered", JSON.Encode.int(count(r => r.state == Registered))),
597
+ ("unchanged", JSON.Encode.int(count(r => r.state == Unchanged))),
598
+ ("matched", JSON.Encode.int(count(r => r.state == Matched))),
599
+ ("registrations", rs->Array.map(encodeRegistration)->JSON.Encode.array),
600
+ ])->JSON.Encode.object
468
601
  }
469
602
 
470
603
  // Every failure mode here is the deployment's own mistake — a name matching no
@@ -561,7 +694,12 @@ let handler = async (event: JSON.t): array<JSON.t> => {
561
694
  | Some(target) =>
562
695
  // Checked on the raw rows: the refs are what the deploy can predict, and a
563
696
  // row that is behind should not have its structure fetched at all.
564
- switch pendingRegistrations(rawItems, ~expect=bakeExpectations(event)) {
697
+ let regs = registrations(
698
+ rawItems,
699
+ ~expect=bakeExpectations(event),
700
+ ~since=bakeSince(event),
701
+ )
702
+ switch regs->Array.filter(isPending) {
565
703
  | [] =>
566
704
  // The built-in admin entry is deliberately absent: it never enters the
567
705
  // Plugin read model, and the in-memory bake curates the composed plugins
@@ -571,14 +709,22 @@ let handler = async (event: JSON.t): array<JSON.t> => {
571
709
  ~nameVersionOf=item => item->str("name"),
572
710
  ~toEntry=structureOf,
573
711
  )
574
- await runBake(~target, ~structures)
712
+ let written = await runBake(~target, ~structures)
713
+ // Appended rather than merged into a file's report: the caller reads
714
+ // `[0]` to decide whether anything was written, and that stays the first
715
+ // file. What the summary adds is orthogonal to the files — it says what
716
+ // the convergence check proved, not what the bake wrote.
717
+ Array.concat(written, [encodeSummary(regs)])
575
718
  | pending =>
576
719
  // Not an error — the deploy just has not finished arriving. Reported so the
577
- // caller can invoke again rather than bake the previous deployment.
720
+ // caller can invoke again rather than bake the previous deployment, and
721
+ // reported with both keys and the row's date so that when it never does
722
+ // arrive, the job says which of the causes it was.
578
723
  [
579
724
  Dict.fromArray([
580
725
  ("baked", JSON.Encode.bool(false)),
581
- ("pending", pending->Array.map(JSON.Encode.string)->JSON.Encode.array),
726
+ ("pending", pending->Array.map(r => JSON.Encode.string(r.plugin))->JSON.Encode.array),
727
+ ("registrations", regs->Array.map(encodeRegistration)->JSON.Encode.array),
582
728
  ])->JSON.Encode.object,
583
729
  ]
584
730
  }
@@ -365,25 +365,158 @@ function bakeExpectations(event) {
365
365
  })));
366
366
  }
367
367
 
368
+ function bakeSince(event) {
369
+ return Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(event), o => o["since"]), Stdlib_JSON.Decode.string);
370
+ }
371
+
368
372
  function structureRefKey(item) {
369
373
  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);
370
374
  }
371
375
 
372
- function pendingRegistrations(items, expect) {
376
+ function rowWrittenAt(item) {
377
+ return Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(item["statusChange"], Stdlib_JSON.Decode.object), o => o["at"]), Stdlib_JSON.Decode.string);
378
+ }
379
+
380
+ function notBefore(at, since) {
381
+ let a = new Date(at).getTime();
382
+ let s = new Date(since).getTime();
383
+ if (!Number.isNaN(a) && !Number.isNaN(s)) {
384
+ return a >= s;
385
+ } else {
386
+ return false;
387
+ }
388
+ }
389
+
390
+ function classify(expected, found, writtenAt, since) {
391
+ let fresh = since !== undefined && writtenAt !== undefined ? notBefore(writtenAt, since) : undefined;
392
+ if (found !== undefined) {
393
+ if (found === expected) {
394
+ if (fresh !== undefined) {
395
+ if (fresh) {
396
+ return "Registered";
397
+ } else {
398
+ return "Unchanged";
399
+ }
400
+ } else {
401
+ return "Matched";
402
+ }
403
+ } else if (Primitive_object.equal(fresh, true)) {
404
+ return "Diverged";
405
+ } else {
406
+ return "Behind";
407
+ }
408
+ } else {
409
+ return "Missing";
410
+ }
411
+ }
412
+
413
+ function registrations(items, expect, since) {
373
414
  let current = Object.fromEntries(Platform_AdminScan_Ops$ReventlessAws.latestByName(items, item => str(item, "name"), (item, name) => Stdlib_Option.map(structureRefKey(item), key => [
374
415
  name,
375
- key
416
+ [
417
+ key,
418
+ rowWrittenAt(item)
419
+ ]
376
420
  ])));
377
- return Stdlib_Array.filterMap(Object.entries(expect), param => {
421
+ return Object.entries(expect).map(param => {
422
+ let expected = param[1];
378
423
  let plugin = param[0];
379
- if (Primitive_object.equal(current[plugin], param[1])) {
380
- return;
381
- } else {
382
- return plugin;
383
- }
424
+ let row = current[plugin];
425
+ let found = Stdlib_Option.map(row, param => param[0]);
426
+ let writtenAt = Stdlib_Option.flatMap(row, param => param[1]);
427
+ return {
428
+ plugin: plugin,
429
+ expected: expected,
430
+ found: found,
431
+ writtenAt: writtenAt,
432
+ state: classify(expected, found, writtenAt, since)
433
+ };
384
434
  });
385
435
  }
386
436
 
437
+ function isPending(r) {
438
+ let match = r.state;
439
+ switch (match) {
440
+ case "Missing" :
441
+ case "Behind" :
442
+ case "Diverged" :
443
+ return true;
444
+ default:
445
+ return false;
446
+ }
447
+ }
448
+
449
+ function stateName(s) {
450
+ switch (s) {
451
+ case "Registered" :
452
+ return "registered";
453
+ case "Unchanged" :
454
+ return "unchanged";
455
+ case "Matched" :
456
+ return "matched";
457
+ case "Missing" :
458
+ return "missing";
459
+ case "Behind" :
460
+ return "behind";
461
+ case "Diverged" :
462
+ return "diverged";
463
+ }
464
+ }
465
+
466
+ function encodeRegistration(r) {
467
+ return Object.fromEntries([
468
+ [
469
+ "plugin",
470
+ r.plugin
471
+ ],
472
+ [
473
+ "state",
474
+ stateName(r.state)
475
+ ],
476
+ [
477
+ "expected",
478
+ r.expected
479
+ ],
480
+ [
481
+ "found",
482
+ Stdlib_Option.mapOr(r.found, null, prim => prim)
483
+ ],
484
+ [
485
+ "writtenAt",
486
+ Stdlib_Option.mapOr(r.writtenAt, null, prim => prim)
487
+ ]
488
+ ]);
489
+ }
490
+
491
+ function encodeSummary(rs) {
492
+ return Object.fromEntries([
493
+ [
494
+ "summary",
495
+ true
496
+ ],
497
+ [
498
+ "plugins",
499
+ rs.length
500
+ ],
501
+ [
502
+ "registered",
503
+ rs.filter(r => r.state === "Registered").length
504
+ ],
505
+ [
506
+ "unchanged",
507
+ rs.filter(r => r.state === "Unchanged").length
508
+ ],
509
+ [
510
+ "matched",
511
+ rs.filter(r => r.state === "Matched").length
512
+ ],
513
+ [
514
+ "registrations",
515
+ rs.map(encodeRegistration)
516
+ ]
517
+ ]);
518
+ }
519
+
387
520
  async function runBake(target, structures) {
388
521
  let selections = bakeSelections();
389
522
  if (selections.length === 0) {
@@ -466,7 +599,8 @@ async function handler(event) {
466
599
  let fetch = Offload$Reventless.cachedFetch(key => S3$AwsSdk.GetObjectCommand.getString(bucket, key));
467
600
  let resolveAll = () => Promise.all(rawItems.map(item => resolveStructure(fetch, item)));
468
601
  if (bakeTarget !== undefined) {
469
- let pending = pendingRegistrations(rawItems, bakeExpectations(event));
602
+ let regs = registrations(rawItems, bakeExpectations(event), bakeSince(event));
603
+ let pending = regs.filter(isPending);
470
604
  if (pending.length !== 0) {
471
605
  return [Object.fromEntries([
472
606
  [
@@ -475,12 +609,17 @@ async function handler(event) {
475
609
  ],
476
610
  [
477
611
  "pending",
478
- pending.map(prim => prim)
612
+ pending.map(r => r.plugin)
613
+ ],
614
+ [
615
+ "registrations",
616
+ regs.map(encodeRegistration)
479
617
  ]
480
618
  ])];
481
619
  }
482
620
  let structures = Platform_AdminScan_Ops$ReventlessAws.latestByName(await resolveAll(), item => str(item, "name"), structureOf);
483
- return await runBake(bakeTarget, structures);
621
+ let written = await runBake(bakeTarget, structures);
622
+ return written.concat([encodeSummary(regs)]);
484
623
  }
485
624
  let userEntries = Platform_AdminScan_Ops$ReventlessAws.latestByName(await resolveAll(), item => str(item, "name"), toEntry);
486
625
  return admin.concat(userEntries);
@@ -518,8 +657,16 @@ export {
518
657
  bakeJourneys,
519
658
  bakeTargetOf,
520
659
  bakeExpectations,
660
+ bakeSince,
521
661
  structureRefKey,
522
- pendingRegistrations,
662
+ rowWrittenAt,
663
+ notBefore,
664
+ classify,
665
+ registrations,
666
+ isPending,
667
+ stateName,
668
+ encodeRegistration,
669
+ encodeSummary,
523
670
  runBake,
524
671
  handler,
525
672
  }
@@ -0,0 +1,209 @@
1
+ // A plugin's structure CHANGES between two deploys and the bake is required to
2
+ // converge.
3
+ //
4
+ // The rest of the bake's coverage cannot catch this class. An unchanged structure
5
+ // converges trivially — the key already matches — so a test that never mutates the
6
+ // structure passes against a completely broken re-detect → answer → project chain.
7
+ // That is also how the chain can be broken across any number of green deploys and
8
+ // surface on the first one that changes a structure.
9
+ //
10
+ // So this drives the chain a redeploy actually runs: the deploy hashes the
11
+ // structure through the offload hook and exports the key, the SAME definition is
12
+ // serialized into `pluginDefinition.json` and decoded back the way the
13
+ // EventCollector decodes it at cold start, the Plugin aggregate decides on Redetect
14
+ // and Connect, and the projection builds the row the bake scans.
15
+
16
+ open JestGlobals
17
+
18
+ // Mirrors the deploy-time hook in aws/src/Platform.res: content-addressed key, and
19
+ // the key the stack exports as `pluginStructureRef` is the one the hook returned.
20
+ let deployStructure = (
21
+ structure: Reventless.Plugin.pluginStructure,
22
+ ): (string, Reventless.Offload.payload<Reventless.Plugin.pluginStructure>) => {
23
+ let exported = ref("")
24
+ ReventlessCore.Plugin_Helpers.registerOffload((~store, ~bytes) => {
25
+ let hash = NodeCrypto.sha256Hex(bytes)
26
+ let key = "sha256/" ++ hash
27
+ if store == "pluginStructures" {
28
+ exported := key
29
+ }
30
+ {Reventless.Offload.store, key, hash, bytes: bytes->String.length}
31
+ })
32
+ let payload = ReventlessCore.Plugin_Helpers.offloadPayload(
33
+ structure,
34
+ ~schema=Reventless.Plugin.pluginStructureSchema,
35
+ ~store="pluginStructures",
36
+ )
37
+ ReventlessCore.Plugin_Helpers.clearOffload()
38
+ (exported.contents, payload)
39
+ }
40
+
41
+ let emptyStructure: Reventless.Plugin.pluginStructure = {
42
+ readModels: [],
43
+ stateViewSlices: [],
44
+ stateChangeSlices: [],
45
+ aggregates: [],
46
+ automationSlices: [],
47
+ outboundTranslationSlices: [],
48
+ inboundTranslationSlices: [],
49
+ extensions: [],
50
+ extensionPoints: None,
51
+ requiredStores: None,
52
+ requiredStoreDeclarations: None,
53
+ }
54
+
55
+ let definition = (~structure): Reventless.Plugin.pluginDefinition => {
56
+ id: "Ordering@1.0.0",
57
+ name: "Ordering",
58
+ version: "1.0.0",
59
+ extensionPoints: [],
60
+ extensions: [],
61
+ eventCollector: "arn:aws:sqs:eu-west-1:0:Ordering",
62
+ extensionProtocols: [],
63
+ apiSchemaFragment: None,
64
+ apiTarget: None,
65
+ structure: Some(structure),
66
+ dcbEventLog: None,
67
+ kind: Domain,
68
+ }
69
+
70
+ // What the deploy ships as `pluginDefinition.json` and the EventCollector decodes
71
+ // at cold start. Round-tripped rather than handed over directly, because it is the
72
+ // artifact the registration carries — a key that did not survive this encode is a
73
+ // pair of hashes that can never converge.
74
+ let shipped = (def: Reventless.Plugin.pluginDefinition): Reventless.Plugin.pluginDefinition =>
75
+ def
76
+ ->Reventless.Util_Sury.toJsonString(Reventless.Plugin.pluginDefinitionSchema)
77
+ ->Reventless.Util_Sury.fromJsonString(Reventless.Plugin.pluginDefinitionSchema)
78
+
79
+ // The row the projection writes, as the bake's scan sees it: QueryDb marshals the
80
+ // raw ReScript value, so the record's runtime shape IS the stored item.
81
+ let row = (def: Reventless.Plugin.pluginDefinition, ~at: string): dict<JSON.t> =>
82
+ ReventlessCore.PluginsProjection.displayState(
83
+ def,
84
+ ReventlessCore.PluginsReadModelSpec.Connected,
85
+ {Reventless.Message.at, by: "deploy"},
86
+ [],
87
+ )
88
+ ->JSON.stringifyAny
89
+ ->Option.getOr("{}")
90
+ ->JSON.parseOrThrow
91
+ ->JSON.Decode.object
92
+ ->Option.getOr(Dict.make())
93
+
94
+ let apply = (state, events) => events->Array.reduce(state, ReventlessCore.PluginBehavior.evolve)
95
+
96
+ let connect = (state, def) =>
97
+ switch ReventlessCore.PluginBehavior.decide(state, ReventlessCore.PluginSpec.Connect(def)) {
98
+ | Ok(events) => events
99
+ | Error(_) => []
100
+ }
101
+
102
+ let stateOf = (rows, ~expect, ~since) =>
103
+ Platform_ComponentDefinitions_Lambda_Ops.registrations(
104
+ rows,
105
+ ~expect=Dict.fromArray(expect),
106
+ ~since=Some(since),
107
+ )
108
+ ->Array.get(0)
109
+ ->Option.map(r => Platform_ComponentDefinitions_Lambda_Ops.stateName(r.state))
110
+
111
+ let deployedAt = "2026-08-27T10:00:00Z"
112
+ let beforeDeploy = "2026-08-27T09:00:00Z"
113
+ let afterDeploy = "2026-08-27T10:05:00Z"
114
+
115
+ describe("the bake converges when a plugin's structure changes", () => {
116
+ let (keyA, structureA) = deployStructure(emptyStructure)
117
+ let (keyB, structureB) = deployStructure({
118
+ ...emptyStructure,
119
+ requiredStores: Some(["Ordering.receipts"]),
120
+ })
121
+ let defA = definition(~structure=structureA)
122
+ let defB = definition(~structure=structureB)
123
+
124
+ // If this ever stops holding, the two keys can never converge and every retry is
125
+ // spent waiting for something that cannot happen.
126
+ testSync("a changed structure hashes to a different key", () =>
127
+ expect(keyA == keyB)->toBe(false)
128
+ )
129
+
130
+ // The invariant convergence rests on: the structure the deploy hashes and the
131
+ // structure the plugin's registration carries are the same bytes, because they
132
+ // are the same object — the runtime does not recompute it, it ships the
133
+ // deploy's reference and hands it back.
134
+ testSync("the key the deploy exported is the key the registration carries", () =>
135
+ expect(
136
+ Platform_ComponentDefinitions_Lambda_Ops.structureRefKey(
137
+ row(shipped(defB), ~at=afterDeploy),
138
+ ),
139
+ )->toEqual(Some(keyB))
140
+ )
141
+
142
+ testSync("before the redeploy's registration lands, the plugin reads as behind", () =>
143
+ expect(
144
+ stateOf(
145
+ [row(shipped(defA), ~at=beforeDeploy)],
146
+ ~expect=[("Ordering", keyB)],
147
+ ~since=deployedAt,
148
+ ),
149
+ )->toEqual(Some("behind"))
150
+ )
151
+
152
+ // The link the bake is actually waiting on: a Connect carrying a changed
153
+ // definition must re-emit VersionConnected. `decide` is idempotent on an
154
+ // unchanged one, so this is the step a broken chain silently skips.
155
+ testSync("a redeploy with a changed structure re-emits VersionConnected", () => {
156
+ let connected = ReventlessCore.PluginBehavior.initialState->apply(connect(
157
+ ReventlessCore.PluginBehavior.initialState,
158
+ shipped(defA),
159
+ ))
160
+ expect(connect(connected, shipped(defB))->Array.length)->toBe(1)
161
+ })
162
+
163
+ testSync("once it lands, the plugin reads as registered by this deploy", () => {
164
+ let connected = ReventlessCore.PluginBehavior.initialState->apply(connect(
165
+ ReventlessCore.PluginBehavior.initialState,
166
+ shipped(defA),
167
+ ))
168
+ let redeployed = connected->apply(connect(connected, shipped(defB)))
169
+ let def = switch redeployed.known->Dict.get("1.0.0") {
170
+ | Some({definition}) => definition
171
+ | None => shipped(defA)
172
+ }
173
+ expect(
174
+ stateOf([row(def, ~at=afterDeploy)], ~expect=[("Ordering", keyB)], ~since=deployedAt),
175
+ )->toEqual(Some("registered"))
176
+ })
177
+
178
+ // Defect 1, as a test: a plugin whose structure did not change never
179
+ // re-registers, so its row is never rewritten and it passes the check without
180
+ // exercising the chain at all. Correct, and no evidence — which is why the bake
181
+ // counts it apart from a plugin that did register.
182
+ testSync("an unchanged structure passes without re-registering", () => {
183
+ let connected = ReventlessCore.PluginBehavior.initialState->apply(connect(
184
+ ReventlessCore.PluginBehavior.initialState,
185
+ shipped(defA),
186
+ ))
187
+ expect(connect(connected, shipped(defA)))->toEqual([])
188
+ expect(
189
+ stateOf(
190
+ [row(shipped(defA), ~at=beforeDeploy)],
191
+ ~expect=[("Ordering", keyA)],
192
+ ~since=deployedAt,
193
+ ),
194
+ )->toEqual(Some("unchanged"))
195
+ })
196
+
197
+ // Deploy-time re-detect is what starts the chain for an already-connected
198
+ // version; without it a redeploy of the same version never re-runs the handshake
199
+ // and the changed structure never reaches the read model.
200
+ testSync("a redeploy re-detects an already-connected version", () => {
201
+ let connected = ReventlessCore.PluginBehavior.initialState->apply(connect(
202
+ ReventlessCore.PluginBehavior.initialState,
203
+ shipped(defA),
204
+ ))
205
+ expect(
206
+ ReventlessCore.PluginBehavior.decide(connected, ReventlessCore.PluginSpec.Redetect("1.0.0")),
207
+ )->toEqual(Ok([ReventlessCore.PluginSpec.VersionDetected("1.0.0")]))
208
+ })
209
+ })
@@ -0,0 +1,209 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as NodeCrypto from "@reventlessdev/rescript-node/src/NodeCrypto.res.mjs";
4
+ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
5
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
6
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
+ import * as Plugin$Reventless from "@reventlessdev/reventless-spec/src/components/Plugin.res.mjs";
8
+ import * as Util_Sury$Reventless from "@reventlessdev/reventless-spec/src/util/Util_Sury.res.mjs";
9
+ import * as PluginBehavior$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/lifecycle/PluginBehavior.res.mjs";
10
+ import * as Plugin_Helpers$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/component/Plugin_Helpers.res.mjs";
11
+ import * as PluginsProjection$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/lifecycle/PluginsProjection.res.mjs";
12
+ import * as Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws from "../src/adapter/Api/Platform_ComponentDefinitions_Lambda_Ops.res.mjs";
13
+
14
+ function deployStructure(structure) {
15
+ let exported = {
16
+ contents: ""
17
+ };
18
+ Plugin_Helpers$ReventlessCore.registerOffload((store, bytes) => {
19
+ let hash = NodeCrypto.sha256Hex(bytes);
20
+ let key = "sha256/" + hash;
21
+ if (store === "pluginStructures") {
22
+ exported.contents = key;
23
+ }
24
+ return {
25
+ store: store,
26
+ key: key,
27
+ hash: hash,
28
+ bytes: bytes.length
29
+ };
30
+ });
31
+ let payload = Plugin_Helpers$ReventlessCore.offloadPayload(structure, Plugin$Reventless.pluginStructureSchema, "pluginStructures");
32
+ Plugin_Helpers$ReventlessCore.clearOffload();
33
+ return [
34
+ exported.contents,
35
+ payload
36
+ ];
37
+ }
38
+
39
+ let emptyStructure_readModels = [];
40
+
41
+ let emptyStructure_stateViewSlices = [];
42
+
43
+ let emptyStructure_stateChangeSlices = [];
44
+
45
+ let emptyStructure_aggregates = [];
46
+
47
+ let emptyStructure_automationSlices = [];
48
+
49
+ let emptyStructure_outboundTranslationSlices = [];
50
+
51
+ let emptyStructure_inboundTranslationSlices = [];
52
+
53
+ let emptyStructure_extensions = [];
54
+
55
+ let emptyStructure = {
56
+ readModels: emptyStructure_readModels,
57
+ stateViewSlices: emptyStructure_stateViewSlices,
58
+ stateChangeSlices: emptyStructure_stateChangeSlices,
59
+ aggregates: emptyStructure_aggregates,
60
+ automationSlices: emptyStructure_automationSlices,
61
+ outboundTranslationSlices: emptyStructure_outboundTranslationSlices,
62
+ inboundTranslationSlices: emptyStructure_inboundTranslationSlices,
63
+ extensions: emptyStructure_extensions,
64
+ extensionPoints: undefined,
65
+ requiredStores: undefined,
66
+ requiredStoreDeclarations: undefined
67
+ };
68
+
69
+ function definition(structure) {
70
+ return {
71
+ id: "Ordering@1.0.0",
72
+ name: "Ordering",
73
+ version: "1.0.0",
74
+ extensionPoints: [],
75
+ extensions: [],
76
+ eventCollector: "arn:aws:sqs:eu-west-1:0:Ordering",
77
+ extensionProtocols: [],
78
+ apiSchemaFragment: undefined,
79
+ apiTarget: undefined,
80
+ structure: structure,
81
+ dcbEventLog: undefined,
82
+ kind: "Domain"
83
+ };
84
+ }
85
+
86
+ function shipped(def) {
87
+ return Util_Sury$Reventless.fromJsonString(Util_Sury$Reventless.toJsonString(def, Plugin$Reventless.pluginDefinitionSchema, undefined), Plugin$Reventless.pluginDefinitionSchema);
88
+ }
89
+
90
+ function row(def, at) {
91
+ return Stdlib_Option.getOr(Stdlib_JSON.Decode.object(JSON.parse(Stdlib_Option.getOr(JSON.stringify(PluginsProjection$ReventlessCore.displayState(def, "Connected", {
92
+ at: at,
93
+ by: "deploy"
94
+ }, [])), "{}"))), {});
95
+ }
96
+
97
+ function apply(state, events) {
98
+ return Stdlib_Array.reduce(events, state, PluginBehavior$ReventlessCore.evolve);
99
+ }
100
+
101
+ function connect(state, def) {
102
+ let events = PluginBehavior$ReventlessCore.decide(state, {
103
+ TAG: "Connect",
104
+ _0: def
105
+ });
106
+ if (events.TAG === "Ok") {
107
+ return events._0;
108
+ } else {
109
+ return [];
110
+ }
111
+ }
112
+
113
+ function stateOf(rows, expect, since) {
114
+ return Stdlib_Option.map(Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.registrations(rows, Object.fromEntries(expect), since)[0], r => Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.stateName(r.state));
115
+ }
116
+
117
+ let deployedAt = "2026-08-27T10:00:00Z";
118
+
119
+ let beforeDeploy = "2026-08-27T09:00:00Z";
120
+
121
+ let afterDeploy = "2026-08-27T10:05:00Z";
122
+
123
+ globalThis.describe("the bake converges when a plugin's structure changes", () => {
124
+ let match = deployStructure(emptyStructure);
125
+ let keyA = match[0];
126
+ let match$1 = deployStructure({
127
+ readModels: emptyStructure_readModels,
128
+ stateViewSlices: emptyStructure_stateViewSlices,
129
+ stateChangeSlices: emptyStructure_stateChangeSlices,
130
+ aggregates: emptyStructure_aggregates,
131
+ automationSlices: emptyStructure_automationSlices,
132
+ outboundTranslationSlices: emptyStructure_outboundTranslationSlices,
133
+ inboundTranslationSlices: emptyStructure_inboundTranslationSlices,
134
+ extensions: emptyStructure_extensions,
135
+ extensionPoints: undefined,
136
+ requiredStores: ["Ordering.receipts"],
137
+ requiredStoreDeclarations: undefined
138
+ });
139
+ let keyB = match$1[0];
140
+ let defA = definition(match[1]);
141
+ let defB = definition(match$1[1]);
142
+ globalThis.test("a changed structure hashes to a different key", () => {
143
+ globalThis.expect(keyA === keyB).toBe(false);
144
+ });
145
+ globalThis.test("the key the deploy exported is the key the registration carries", () => {
146
+ globalThis.expect(Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.structureRefKey(row(shipped(defB), afterDeploy))).toEqual(keyB);
147
+ });
148
+ globalThis.test("before the redeploy's registration lands, the plugin reads as behind", () => {
149
+ globalThis.expect(stateOf([row(shipped(defA), beforeDeploy)], [[
150
+ "Ordering",
151
+ keyB
152
+ ]], deployedAt)).toEqual("behind");
153
+ });
154
+ globalThis.test("a redeploy with a changed structure re-emits VersionConnected", () => {
155
+ let events = connect(PluginBehavior$ReventlessCore.initialState, shipped(defA));
156
+ let connected = Stdlib_Array.reduce(events, PluginBehavior$ReventlessCore.initialState, PluginBehavior$ReventlessCore.evolve);
157
+ globalThis.expect(connect(connected, shipped(defB)).length).toBe(1);
158
+ });
159
+ globalThis.test("once it lands, the plugin reads as registered by this deploy", () => {
160
+ let events = connect(PluginBehavior$ReventlessCore.initialState, shipped(defA));
161
+ let connected = Stdlib_Array.reduce(events, PluginBehavior$ReventlessCore.initialState, PluginBehavior$ReventlessCore.evolve);
162
+ let events$1 = connect(connected, shipped(defB));
163
+ let redeployed = Stdlib_Array.reduce(events$1, connected, PluginBehavior$ReventlessCore.evolve);
164
+ let match = redeployed.known["1.0.0"];
165
+ let def = match !== undefined ? match.definition : shipped(defA);
166
+ globalThis.expect(stateOf([row(def, afterDeploy)], [[
167
+ "Ordering",
168
+ keyB
169
+ ]], deployedAt)).toEqual("registered");
170
+ });
171
+ globalThis.test("an unchanged structure passes without re-registering", () => {
172
+ let events = connect(PluginBehavior$ReventlessCore.initialState, shipped(defA));
173
+ let connected = Stdlib_Array.reduce(events, PluginBehavior$ReventlessCore.initialState, PluginBehavior$ReventlessCore.evolve);
174
+ globalThis.expect(connect(connected, shipped(defA))).toEqual([]);
175
+ globalThis.expect(stateOf([row(shipped(defA), beforeDeploy)], [[
176
+ "Ordering",
177
+ keyA
178
+ ]], deployedAt)).toEqual("unchanged");
179
+ });
180
+ globalThis.test("a redeploy re-detects an already-connected version", () => {
181
+ let events = connect(PluginBehavior$ReventlessCore.initialState, shipped(defA));
182
+ let connected = Stdlib_Array.reduce(events, PluginBehavior$ReventlessCore.initialState, PluginBehavior$ReventlessCore.evolve);
183
+ globalThis.expect(PluginBehavior$ReventlessCore.decide(connected, {
184
+ TAG: "Redetect",
185
+ _0: "1.0.0"
186
+ })).toEqual({
187
+ TAG: "Ok",
188
+ _0: [{
189
+ TAG: "VersionDetected",
190
+ _0: "1.0.0"
191
+ }]
192
+ });
193
+ });
194
+ });
195
+
196
+ export {
197
+ deployStructure,
198
+ emptyStructure,
199
+ definition,
200
+ shipped,
201
+ row,
202
+ apply,
203
+ connect,
204
+ stateOf,
205
+ deployedAt,
206
+ beforeDeploy,
207
+ afterDeploy,
208
+ }
209
+ /* Not a pure module */
@@ -355,8 +355,8 @@ describe("bakeJourney", () => {
355
355
  // The bake reads a read model the deploy updates asynchronously, so "is this the
356
356
  // deployment I was asked to bake" is a question it has to be able to answer. The
357
357
  // answer is an equality check against the key each plugin stack just wrote.
358
- describe("pendingRegistrations", () => {
359
- let row = (~name, ~key=?, ()) => {
358
+ describe("registrations", () => {
359
+ let row = (~name, ~key=?, ~at=?, ()) => {
360
360
  let item = Dict.fromArray([("name", JSON.Encode.string(name))])
361
361
  key->Option.forEach(k =>
362
362
  item->Dict.set(
@@ -364,15 +364,29 @@ describe("pendingRegistrations", () => {
364
364
  JSON.parseOrThrow(`{"$offload": {"store": "pluginStructures", "key": "${k}"}}`),
365
365
  )
366
366
  )
367
+ at->Option.forEach(t =>
368
+ item->Dict.set("statusChange", JSON.parseOrThrow(`{"at": "${t}", "by": "deploy"}`))
369
+ )
367
370
  item
368
371
  }
369
372
 
370
- let pending = (rows, expect) =>
371
- Platform_ComponentDefinitions_Lambda_Ops.pendingRegistrations(
373
+ let regs = (rows, expect, ~since=?) =>
374
+ Platform_ComponentDefinitions_Lambda_Ops.registrations(
372
375
  rows,
373
376
  ~expect=Dict.fromArray(expect),
377
+ ~since,
374
378
  )
375
379
 
380
+ let pending = (rows, expect) =>
381
+ regs(rows, expect)
382
+ ->Array.filter(Platform_ComponentDefinitions_Lambda_Ops.isPending)
383
+ ->Array.map(r => r.plugin)
384
+
385
+ let stateOf = (rows, expect, ~since=?) =>
386
+ regs(rows, expect, ~since?)
387
+ ->Array.get(0)
388
+ ->Option.map(r => Platform_ComponentDefinitions_Lambda_Ops.stateName(r.state))
389
+
376
390
  testSync("nothing is pending when every row carries the key the deploy wrote", () =>
377
391
  expect(
378
392
  pending(
@@ -412,6 +426,111 @@ describe("pendingRegistrations", () => {
412
426
  )->Dict.get("Ordering"),
413
427
  )->toEqual(Some("sha256/b"))
414
428
  )
429
+
430
+ testSync("reads the deploy instant off the invocation payload", () =>
431
+ expect(
432
+ Platform_ComponentDefinitions_Lambda_Ops.bakeSince(
433
+ JSON.parseOrThrow(`{"bake": true, "since": "2026-08-27T10:00:00Z"}`),
434
+ ),
435
+ )->toEqual(Some("2026-08-27T10:00:00Z"))
436
+ )
437
+
438
+ // Both halves of the comparison and the row's date, which is the whole point:
439
+ // the failure used to report a name and nothing else, and the four causes it
440
+ // could have had were told apart by reading the read model out of band.
441
+ testSync("reports the expected key, the found key and when the row was written", () => {
442
+ let r = regs(
443
+ [row(~name="Ordering", ~key="sha256/old", ~at="2026-08-27T09:00:00Z", ())],
444
+ [("Ordering", "sha256/new")],
445
+ )->Array.get(0)
446
+ expect((
447
+ r->Option.map(r => r.expected),
448
+ r->Option.flatMap(r => r.found),
449
+ r->Option.flatMap(r => r.writtenAt),
450
+ ))->toEqual((Some("sha256/new"), Some("sha256/old"), Some("2026-08-27T09:00:00Z")))
451
+ })
452
+
453
+ describe("dated against the deploy", () => {
454
+ let since = "2026-08-27T10:00:00Z"
455
+ let before = "2026-08-27T09:00:00Z"
456
+ let after = "2026-08-27T10:05:00Z"
457
+
458
+ // The distinction §4 exists for: this plugin proves the chain works.
459
+ testSync("a matching key on a row this deploy wrote is registered", () =>
460
+ expect(
461
+ stateOf(
462
+ [row(~name="Ordering", ~key="sha256/a", ~at=after, ())],
463
+ [("Ordering", "sha256/a")],
464
+ ~since,
465
+ ),
466
+ )->toEqual(Some("registered"))
467
+ )
468
+
469
+ // Correct, and vacuous — an unchanged structure converges against a
470
+ // completely broken chain, which is why it is not counted as evidence.
471
+ testSync("a matching key on a row that predates the deploy is unchanged", () =>
472
+ expect(
473
+ stateOf(
474
+ [row(~name="Ordering", ~key="sha256/a", ~at=before, ())],
475
+ [("Ordering", "sha256/a")],
476
+ ~since,
477
+ ),
478
+ )->toEqual(Some("unchanged"))
479
+ )
480
+
481
+ testSync("a matching key with no deploy instant to date it against is matched", () =>
482
+ expect(
483
+ stateOf(
484
+ [row(~name="Ordering", ~key="sha256/a", ~at=after, ())],
485
+ [("Ordering", "sha256/a")],
486
+ ),
487
+ )->toEqual(Some("matched"))
488
+ )
489
+
490
+ // Retrying is for this one.
491
+ testSync("a stale row that predates the deploy is behind", () =>
492
+ expect(
493
+ stateOf(
494
+ [row(~name="Ordering", ~key="sha256/old", ~at=before, ())],
495
+ [("Ordering", "sha256/new")],
496
+ ~since,
497
+ ),
498
+ )->toEqual(Some("behind"))
499
+ )
500
+
501
+ // And never for this one: the plugin answered during this deploy and produced
502
+ // a structure other than the one the deploy hashed, so the keys cannot
503
+ // converge and every remaining attempt is spent on something that cannot happen.
504
+ testSync("a stale row this deploy wrote is diverged", () =>
505
+ expect(
506
+ stateOf(
507
+ [row(~name="Ordering", ~key="sha256/other", ~at=after, ())],
508
+ [("Ordering", "sha256/new")],
509
+ ~since,
510
+ ),
511
+ )->toEqual(Some("diverged"))
512
+ )
513
+
514
+ // The scan filters on Connected, so a version that dropped out mid-deploy
515
+ // leaves no row to compare against — indistinguishable, before this, from a
516
+ // projection that had merely not landed yet, and waited on for the full
517
+ // retry budget either way.
518
+ testSync("no Connected row at all is missing, not behind", () =>
519
+ expect(stateOf([], [("Ordering", "sha256/new")], ~since))->toEqual(Some("missing"))
520
+ )
521
+
522
+ // An unparseable stamp must not read as fresh: reporting `registered` off a
523
+ // date nobody could read would credit the chain for work it may not have done.
524
+ testSync("an unreadable row date does not count as written by this deploy", () =>
525
+ expect(
526
+ stateOf(
527
+ [row(~name="Ordering", ~key="sha256/a", ~at="not-a-date", ())],
528
+ [("Ordering", "sha256/a")],
529
+ ~since,
530
+ ),
531
+ )->toEqual(Some("unchanged"))
532
+ )
533
+ })
415
534
  })
416
535
 
417
536
  describe("resolveStructure", () => {
@@ -221,8 +221,8 @@ globalThis.describe("bakeJourney", () => {
221
221
  });
222
222
  });
223
223
 
224
- globalThis.describe("pendingRegistrations", () => {
225
- let row = (name, key, param) => {
224
+ globalThis.describe("registrations", () => {
225
+ let row = (name, key, at, param) => {
226
226
  let item = Object.fromEntries([[
227
227
  "name",
228
228
  name
@@ -230,14 +230,19 @@ globalThis.describe("pendingRegistrations", () => {
230
230
  Stdlib_Option.forEach(key, k => {
231
231
  item["structure"] = JSON.parse(`{"$offload": {"store": "pluginStructures", "key": "` + k + `"}}`);
232
232
  });
233
+ Stdlib_Option.forEach(at, t => {
234
+ item["statusChange"] = JSON.parse(`{"at": "` + t + `", "by": "deploy"}`);
235
+ });
233
236
  return item;
234
237
  };
238
+ let regs = (rows, expect, since) => Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.registrations(rows, Object.fromEntries(expect), since);
239
+ let pending = (rows, expect) => regs(rows, expect, undefined).filter(Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.isPending).map(r => r.plugin);
240
+ let stateOf = (rows, expect, since) => Stdlib_Option.map(regs(rows, expect, since)[0], r => Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.stateName(r.state));
235
241
  globalThis.test("nothing is pending when every row carries the key the deploy wrote", () => {
236
- let rows = [
237
- row("Catalog", "sha256/a", undefined),
238
- row("Ordering", "sha256/b", undefined)
239
- ];
240
- globalThis.expect(Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.pendingRegistrations(rows, Object.fromEntries([
242
+ globalThis.expect(pending([
243
+ row("Catalog", "sha256/a", undefined, undefined),
244
+ row("Ordering", "sha256/b", undefined, undefined)
245
+ ], [
241
246
  [
242
247
  "Catalog",
243
248
  "sha256/a"
@@ -246,14 +251,13 @@ globalThis.describe("pendingRegistrations", () => {
246
251
  "Ordering",
247
252
  "sha256/b"
248
253
  ]
249
- ]))).toEqual([]);
254
+ ])).toEqual([]);
250
255
  });
251
256
  globalThis.test("names a plugin still carrying the previous structure", () => {
252
- let rows = [
253
- row("Catalog", "sha256/a", undefined),
254
- row("Ordering", "sha256/old", undefined)
255
- ];
256
- globalThis.expect(Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.pendingRegistrations(rows, Object.fromEntries([
257
+ globalThis.expect(pending([
258
+ row("Catalog", "sha256/a", undefined, undefined),
259
+ row("Ordering", "sha256/old", undefined, undefined)
260
+ ], [
257
261
  [
258
262
  "Catalog",
259
263
  "sha256/a"
@@ -262,21 +266,85 @@ globalThis.describe("pendingRegistrations", () => {
262
266
  "Ordering",
263
267
  "sha256/new"
264
268
  ]
265
- ]))).toEqual(["Ordering"]);
269
+ ])).toEqual(["Ordering"]);
266
270
  });
267
271
  globalThis.test("names a plugin with no row at all", () => {
268
- globalThis.expect(Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.pendingRegistrations([], Object.fromEntries([[
272
+ globalThis.expect(pending([], [[
269
273
  "Ordering",
270
274
  "sha256/new"
271
- ]]))).toEqual(["Ordering"]);
275
+ ]])).toEqual(["Ordering"]);
272
276
  });
273
277
  globalThis.test("waits for nothing when the caller expects nothing", () => {
274
- let rows = [row("Ordering", "sha256/old", undefined)];
275
- globalThis.expect(Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.pendingRegistrations(rows, Object.fromEntries([]))).toEqual([]);
278
+ globalThis.expect(pending([row("Ordering", "sha256/old", undefined, undefined)], [])).toEqual([]);
276
279
  });
277
280
  globalThis.test("reads the expectations off the invocation payload", () => {
278
281
  globalThis.expect(Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.bakeExpectations(JSON.parse(`{"bake": true, "expect": {"Ordering": "sha256/b"}}`))["Ordering"]).toEqual("sha256/b");
279
282
  });
283
+ globalThis.test("reads the deploy instant off the invocation payload", () => {
284
+ globalThis.expect(Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.bakeSince(JSON.parse(`{"bake": true, "since": "2026-08-27T10:00:00Z"}`))).toEqual("2026-08-27T10:00:00Z");
285
+ });
286
+ globalThis.test("reports the expected key, the found key and when the row was written", () => {
287
+ let r = regs([row("Ordering", "sha256/old", "2026-08-27T09:00:00Z", undefined)], [[
288
+ "Ordering",
289
+ "sha256/new"
290
+ ]], undefined)[0];
291
+ globalThis.expect([
292
+ Stdlib_Option.map(r, r => r.expected),
293
+ Stdlib_Option.flatMap(r, r => r.found),
294
+ Stdlib_Option.flatMap(r, r => r.writtenAt)
295
+ ]).toEqual([
296
+ "sha256/new",
297
+ "sha256/old",
298
+ "2026-08-27T09:00:00Z"
299
+ ]);
300
+ });
301
+ globalThis.describe("dated against the deploy", () => {
302
+ let since = "2026-08-27T10:00:00Z";
303
+ let before = "2026-08-27T09:00:00Z";
304
+ let after = "2026-08-27T10:05:00Z";
305
+ globalThis.test("a matching key on a row this deploy wrote is registered", () => {
306
+ globalThis.expect(stateOf([row("Ordering", "sha256/a", after, undefined)], [[
307
+ "Ordering",
308
+ "sha256/a"
309
+ ]], since)).toEqual("registered");
310
+ });
311
+ globalThis.test("a matching key on a row that predates the deploy is unchanged", () => {
312
+ globalThis.expect(stateOf([row("Ordering", "sha256/a", before, undefined)], [[
313
+ "Ordering",
314
+ "sha256/a"
315
+ ]], since)).toEqual("unchanged");
316
+ });
317
+ globalThis.test("a matching key with no deploy instant to date it against is matched", () => {
318
+ globalThis.expect(stateOf([row("Ordering", "sha256/a", after, undefined)], [[
319
+ "Ordering",
320
+ "sha256/a"
321
+ ]], undefined)).toEqual("matched");
322
+ });
323
+ globalThis.test("a stale row that predates the deploy is behind", () => {
324
+ globalThis.expect(stateOf([row("Ordering", "sha256/old", before, undefined)], [[
325
+ "Ordering",
326
+ "sha256/new"
327
+ ]], since)).toEqual("behind");
328
+ });
329
+ globalThis.test("a stale row this deploy wrote is diverged", () => {
330
+ globalThis.expect(stateOf([row("Ordering", "sha256/other", after, undefined)], [[
331
+ "Ordering",
332
+ "sha256/new"
333
+ ]], since)).toEqual("diverged");
334
+ });
335
+ globalThis.test("no Connected row at all is missing, not behind", () => {
336
+ globalThis.expect(stateOf([], [[
337
+ "Ordering",
338
+ "sha256/new"
339
+ ]], since)).toEqual("missing");
340
+ });
341
+ globalThis.test("an unreadable row date does not count as written by this deploy", () => {
342
+ globalThis.expect(stateOf([row("Ordering", "sha256/a", "not-a-date", undefined)], [[
343
+ "Ordering",
344
+ "sha256/a"
345
+ ]], since)).toEqual("unchanged");
346
+ });
347
+ });
280
348
  });
281
349
 
282
350
  globalThis.describe("resolveStructure", () => {