@reventlessdev/reventless-aws 3.0.0-alpha.327 → 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 +15 -0
- package/package.json +7 -7
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda_Ops.res +153 -7
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda_Ops.res.mjs +159 -12
- package/tests/BakeConvergenceTest.res +209 -0
- package/tests/BakeConvergenceTest.res.mjs +209 -0
- package/tests/Platform_ComponentDefinitions_Lambda_OpsTest.res +123 -4
- package/tests/Platform_ComponentDefinitions_Lambda_OpsTest.res.mjs +86 -18
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,21 @@
|
|
|
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
|
+
|
|
13
|
+
# 3.0.0-alpha.328 (2026-08-27)
|
|
14
|
+
|
|
15
|
+
**Note:** Version bump only for package @reventlessdev/reventless-aws
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
6
21
|
# 3.0.0-alpha.327 (2026-08-25)
|
|
7
22
|
|
|
8
23
|
**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.
|
|
3
|
+
"version": "3.0.0-alpha.329",
|
|
4
4
|
"description": "AWS adapters for Reventless",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"bin": {
|
|
@@ -17,16 +17,16 @@
|
|
|
17
17
|
"uuid": "^13.0.0",
|
|
18
18
|
"@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.13",
|
|
19
19
|
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
20
|
+
"@reventlessdev/rescript-effect": "0.1.0-alpha.32",
|
|
20
21
|
"@reventlessdev/rescript-node": "2.0.0-alpha.8",
|
|
21
22
|
"@reventlessdev/rescript-pulumi-aws": "3.0.0-alpha.6",
|
|
22
|
-
"@reventlessdev/rescript-effect": "0.1.0-alpha.32",
|
|
23
23
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
|
|
24
24
|
"@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
|
|
25
|
-
"@reventlessdev/reventless-infra": "3.0.0-alpha.
|
|
26
|
-
"@reventlessdev/reventless-
|
|
27
|
-
"@reventlessdev/reventless-
|
|
28
|
-
"@reventlessdev/reventless-
|
|
29
|
-
"@reventlessdev/reventless-
|
|
25
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.152",
|
|
26
|
+
"@reventlessdev/reventless-interop": "3.0.0-alpha.34",
|
|
27
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.124",
|
|
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
|
|
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) =>
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
416
|
+
[
|
|
417
|
+
key,
|
|
418
|
+
rowWrittenAt(item)
|
|
419
|
+
]
|
|
376
420
|
])));
|
|
377
|
-
return
|
|
421
|
+
return Object.entries(expect).map(param => {
|
|
422
|
+
let expected = param[1];
|
|
378
423
|
let plugin = param[0];
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
|
371
|
-
Platform_ComponentDefinitions_Lambda_Ops.
|
|
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("
|
|
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
|
-
|
|
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
|
-
]))
|
|
254
|
+
])).toEqual([]);
|
|
250
255
|
});
|
|
251
256
|
globalThis.test("names a plugin still carrying the previous structure", () => {
|
|
252
|
-
|
|
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
|
-
]))
|
|
269
|
+
])).toEqual(["Ordering"]);
|
|
266
270
|
});
|
|
267
271
|
globalThis.test("names a plugin with no row at all", () => {
|
|
268
|
-
globalThis.expect(
|
|
272
|
+
globalThis.expect(pending([], [[
|
|
269
273
|
"Ordering",
|
|
270
274
|
"sha256/new"
|
|
271
|
-
]]))
|
|
275
|
+
]])).toEqual(["Ordering"]);
|
|
272
276
|
});
|
|
273
277
|
globalThis.test("waits for nothing when the caller expects nothing", () => {
|
|
274
|
-
|
|
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", () => {
|