@reventlessdev/reventless-seed-aws 1.0.0-alpha.3 → 1.0.0-alpha.30

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.
Files changed (111) hide show
  1. package/CHANGELOG.md +207 -0
  2. package/lib/bs/.compiler.log +2 -2
  3. package/lib/bs/.sourcedirs.json +1 -0
  4. package/lib/bs/compiler-info.json +2 -2
  5. package/lib/bs/src/ReventlessSeedAws.ast +0 -0
  6. package/lib/bs/src/ReventlessSeedAws.cmi +0 -0
  7. package/lib/bs/src/ReventlessSeedAws.cmj +0 -0
  8. package/lib/bs/src/ReventlessSeedAws.cmt +0 -0
  9. package/lib/bs/src/ReventlessSeedAws.res +198 -134
  10. package/lib/bs/src/ReventlessSeedAws.res.mjs +57 -31
  11. package/lib/bs/src/ReventlessSeedAws_Quiesce.ast +0 -0
  12. package/lib/bs/src/ReventlessSeedAws_Quiesce.cmi +0 -0
  13. package/lib/bs/src/ReventlessSeedAws_Quiesce.cmj +0 -0
  14. package/lib/bs/src/ReventlessSeedAws_Quiesce.cmt +0 -0
  15. package/lib/bs/src/ReventlessSeedAws_Quiesce.res +307 -0
  16. package/lib/bs/src/ReventlessSeedAws_Quiesce.res.mjs +217 -0
  17. package/lib/bs/src/ReventlessSeedAws_Reconcile.ast +0 -0
  18. package/lib/bs/src/ReventlessSeedAws_Reconcile.cmi +0 -0
  19. package/lib/bs/src/ReventlessSeedAws_Reconcile.cmj +0 -0
  20. package/lib/bs/src/ReventlessSeedAws_Reconcile.cmt +0 -0
  21. package/lib/bs/src/ReventlessSeedAws_Reconcile.res +391 -0
  22. package/lib/bs/src/ReventlessSeedAws_Reconcile.res.mjs +318 -0
  23. package/lib/bs/src/ReventlessSeedAws_Reset.ast +0 -0
  24. package/lib/bs/src/ReventlessSeedAws_Reset.cmi +0 -0
  25. package/lib/bs/src/ReventlessSeedAws_Reset.cmj +0 -0
  26. package/lib/bs/src/ReventlessSeedAws_Reset.cmt +0 -0
  27. package/lib/bs/src/ReventlessSeedAws_Reset.res +1103 -0
  28. package/lib/bs/src/ReventlessSeedAws_Reset.res.mjs +991 -0
  29. package/lib/bs/tests/EndpointResolutionTest.ast +0 -0
  30. package/lib/bs/tests/EndpointResolutionTest.cmi +0 -0
  31. package/lib/bs/tests/EndpointResolutionTest.cmj +0 -0
  32. package/lib/bs/tests/EndpointResolutionTest.cmt +0 -0
  33. package/lib/bs/tests/EndpointResolutionTest.res +83 -0
  34. package/lib/bs/tests/EndpointResolutionTest.res.mjs +115 -0
  35. package/lib/bs/tests/ObjectStoreReconcileTest.ast +0 -0
  36. package/lib/bs/tests/ObjectStoreReconcileTest.cmi +0 -0
  37. package/lib/bs/tests/ObjectStoreReconcileTest.cmj +0 -0
  38. package/lib/bs/tests/ObjectStoreReconcileTest.cmt +0 -0
  39. package/lib/bs/tests/ObjectStoreReconcileTest.res +83 -0
  40. package/lib/bs/tests/ObjectStoreReconcileTest.res.mjs +102 -0
  41. package/lib/bs/tests/ObjectStoreResetTest.ast +0 -0
  42. package/lib/bs/tests/ObjectStoreResetTest.cmi +0 -0
  43. package/lib/bs/tests/ObjectStoreResetTest.cmj +0 -0
  44. package/lib/bs/tests/ObjectStoreResetTest.cmt +0 -0
  45. package/lib/bs/tests/ObjectStoreResetTest.res +191 -0
  46. package/lib/bs/tests/ObjectStoreResetTest.res.mjs +211 -0
  47. package/lib/bs/tests/QuiesceResetTest.ast +0 -0
  48. package/lib/bs/tests/QuiesceResetTest.cmi +0 -0
  49. package/lib/bs/tests/QuiesceResetTest.cmj +0 -0
  50. package/lib/bs/tests/QuiesceResetTest.cmt +0 -0
  51. package/lib/bs/tests/QuiesceResetTest.res +169 -0
  52. package/lib/bs/tests/QuiesceResetTest.res.mjs +157 -0
  53. package/lib/ocaml/.compiler.log +2 -2
  54. package/lib/ocaml/EndpointResolutionTest.ast +0 -0
  55. package/lib/ocaml/EndpointResolutionTest.cmi +0 -0
  56. package/lib/ocaml/EndpointResolutionTest.cmj +0 -0
  57. package/lib/ocaml/EndpointResolutionTest.cmt +0 -0
  58. package/lib/ocaml/EndpointResolutionTest.res +83 -0
  59. package/lib/ocaml/ObjectStoreReconcileTest.ast +0 -0
  60. package/lib/ocaml/ObjectStoreReconcileTest.cmi +0 -0
  61. package/lib/ocaml/ObjectStoreReconcileTest.cmj +0 -0
  62. package/lib/ocaml/ObjectStoreReconcileTest.cmt +0 -0
  63. package/lib/ocaml/ObjectStoreReconcileTest.res +83 -0
  64. package/lib/ocaml/ObjectStoreResetTest.ast +0 -0
  65. package/lib/ocaml/ObjectStoreResetTest.cmi +0 -0
  66. package/lib/ocaml/ObjectStoreResetTest.cmj +0 -0
  67. package/lib/ocaml/ObjectStoreResetTest.cmt +0 -0
  68. package/lib/ocaml/ObjectStoreResetTest.res +191 -0
  69. package/lib/ocaml/QuiesceResetTest.ast +0 -0
  70. package/lib/ocaml/QuiesceResetTest.cmi +0 -0
  71. package/lib/ocaml/QuiesceResetTest.cmj +0 -0
  72. package/lib/ocaml/QuiesceResetTest.cmt +0 -0
  73. package/lib/ocaml/QuiesceResetTest.res +169 -0
  74. package/lib/ocaml/ReventlessSeedAws.ast +0 -0
  75. package/lib/ocaml/ReventlessSeedAws.cmi +0 -0
  76. package/lib/ocaml/ReventlessSeedAws.cmj +0 -0
  77. package/lib/ocaml/ReventlessSeedAws.cmt +0 -0
  78. package/lib/ocaml/ReventlessSeedAws.res +198 -134
  79. package/lib/ocaml/ReventlessSeedAws_Quiesce.ast +0 -0
  80. package/lib/ocaml/ReventlessSeedAws_Quiesce.cmi +0 -0
  81. package/lib/ocaml/ReventlessSeedAws_Quiesce.cmj +0 -0
  82. package/lib/ocaml/ReventlessSeedAws_Quiesce.cmt +0 -0
  83. package/lib/ocaml/ReventlessSeedAws_Quiesce.res +307 -0
  84. package/lib/ocaml/ReventlessSeedAws_Reconcile.ast +0 -0
  85. package/lib/ocaml/ReventlessSeedAws_Reconcile.cmi +0 -0
  86. package/lib/ocaml/ReventlessSeedAws_Reconcile.cmj +0 -0
  87. package/lib/ocaml/ReventlessSeedAws_Reconcile.cmt +0 -0
  88. package/lib/ocaml/ReventlessSeedAws_Reconcile.res +391 -0
  89. package/lib/ocaml/ReventlessSeedAws_Reset.ast +0 -0
  90. package/lib/ocaml/ReventlessSeedAws_Reset.cmi +0 -0
  91. package/lib/ocaml/ReventlessSeedAws_Reset.cmj +0 -0
  92. package/lib/ocaml/ReventlessSeedAws_Reset.cmt +0 -0
  93. package/lib/ocaml/ReventlessSeedAws_Reset.res +1103 -0
  94. package/package.json +7 -4
  95. package/rescript.json +9 -1
  96. package/src/ReventlessSeedAws.res +198 -134
  97. package/src/ReventlessSeedAws.res.mjs +57 -31
  98. package/src/ReventlessSeedAws_Quiesce.res +307 -0
  99. package/src/ReventlessSeedAws_Quiesce.res.mjs +217 -0
  100. package/src/ReventlessSeedAws_Reconcile.res +391 -0
  101. package/src/ReventlessSeedAws_Reconcile.res.mjs +318 -0
  102. package/src/ReventlessSeedAws_Reset.res +1103 -0
  103. package/src/ReventlessSeedAws_Reset.res.mjs +991 -0
  104. package/tests/EndpointResolutionTest.res +83 -0
  105. package/tests/EndpointResolutionTest.res.mjs +115 -0
  106. package/tests/ObjectStoreReconcileTest.res +83 -0
  107. package/tests/ObjectStoreReconcileTest.res.mjs +102 -0
  108. package/tests/ObjectStoreResetTest.res +191 -0
  109. package/tests/ObjectStoreResetTest.res.mjs +211 -0
  110. package/tests/QuiesceResetTest.res +169 -0
  111. package/tests/QuiesceResetTest.res.mjs +157 -0
@@ -0,0 +1,83 @@
1
+ // The half of reconciliation that decides whether an object is referenced.
2
+ //
3
+ // Everything else in the report is counting; `collectRefs` is the judgement, and
4
+ // it is judgement in one direction only. A ref it misses turns a live object
5
+ // into an "unreferenced, tagged pending" row — which is the row an operator
6
+ // reads as permission to enable an expiry rule. A ref it collects too eagerly
7
+ // only ever leaves an object alive.
8
+ //
9
+ // So these tests are about reach: nesting, arrays, records, and the prefix
10
+ // boundary that separates one store from another.
11
+
12
+ open JestGlobals
13
+
14
+ module Reconcile = ReventlessSeedAws_Reconcile
15
+
16
+ let prefix = "Catalog/productImages"
17
+ let refIn = "/Catalog/productImages/user-1/uuid/photo.png"
18
+ let refOther = "/Ordering/receipts/user-1/uuid/r.pdf"
19
+
20
+ let collect = (json: JSON.t): array<string> => {
21
+ let out = Set.make()
22
+ Reconcile.collectRefs(~prefix, json, out)
23
+ out->Set.toArray
24
+ }
25
+
26
+ let obj = (fields: array<(string, JSON.t)>) => fields->Dict.fromArray->JSON.Encode.object
27
+
28
+ describe("ReventlessSeedAws_Reconcile.collectRefs", () => {
29
+ testSync("finds a ref on a top-level field", () =>
30
+ expect(collect(obj([("imageUrl", JSON.Encode.string(refIn))])))->toEqual([refIn])
31
+ )
32
+
33
+ // Deliberately not declaration-driven: the claim component reads `@storageRef`
34
+ // annotations, so a report reading the same list would inherit the same blind
35
+ // spot and agree precisely where both are wrong. An unannotated field holding a
36
+ // ref has to show up here, as the thing to fix before expiry goes on.
37
+ testSync("finds a ref on a field no annotation would have named", () =>
38
+ expect(collect(obj([("someUndeclaredField", JSON.Encode.string(refIn))])))->toEqual([refIn])
39
+ )
40
+
41
+ testSync("finds refs inside an array", () =>
42
+ expect(collect(obj([("images", JSON.Encode.array([JSON.Encode.string(refIn)]))])))->toEqual([
43
+ refIn,
44
+ ])
45
+ )
46
+
47
+ testSync("finds a ref nested inside a record", () =>
48
+ expect(collect(obj([("variant", obj([("photo", JSON.Encode.string(refIn))]))])))->toEqual([
49
+ refIn,
50
+ ])
51
+ )
52
+
53
+ // Store isolation: one bucket holds several stores under distinct prefixes, so
54
+ // a reconciliation scoped to one store must not count another's refs — that
55
+ // would mark a genuinely abandoned object as referenced and hide it forever.
56
+ testSync("ignores a ref belonging to another store", () =>
57
+ expect(collect(obj([("receipt", JSON.Encode.string(refOther))])))->toEqual([])
58
+ )
59
+
60
+ // The prefix boundary is a path segment, not a string prefix.
61
+ testSync("ignores a store whose name merely starts the same way", () =>
62
+ expect(
63
+ collect(obj([("x", JSON.Encode.string("/Catalog/productImagesArchive/u/i/p.png"))])),
64
+ )->toEqual([])
65
+ )
66
+
67
+ testSync("ignores strings that are not refs at all", () =>
68
+ expect(
69
+ collect(
70
+ obj([
71
+ ("name", JSON.Encode.string("A product")),
72
+ ("external", JSON.Encode.string("https://example.com/Catalog/productImages/p.png")),
73
+ ("count", JSON.Encode.int(3)),
74
+ ]),
75
+ ),
76
+ )->toEqual([])
77
+ )
78
+
79
+ testSync("collects each distinct ref once", () => {
80
+ let json = obj([("a", JSON.Encode.string(refIn)), ("b", JSON.Encode.string(refIn))])
81
+ expect(collect(json)->Array.length)->toBe(1)
82
+ })
83
+ })
@@ -0,0 +1,191 @@
1
+ // How the reset learns which uploaded objects belong to which plugin, and what
2
+ // it refuses.
3
+ //
4
+ // A declared store's bucket is built by the PLATFORM deploy, so its tags say
5
+ // which project built it, not whose data is in it. Ownership therefore comes
6
+ // from the platform's `objectStores` stack output, and these pin the pure half
7
+ // of that: parsing the output, and refusing any store set a prefix-scoped wipe
8
+ // could not separate. The impure half is a `pulumi stack output` subprocess and
9
+ // the S3 calls, and the decisions under test are in neither.
10
+
11
+ open JestGlobals
12
+
13
+ module Reset = ReventlessSeedAws_Reset
14
+
15
+ let obj = entries => JSON.Encode.object(entries->Dict.fromArray)
16
+ let str = JSON.Encode.string
17
+
18
+ let storeEntry = (~bucket, ~prefix) =>
19
+ obj([("bucketName", str(bucket)), ("keyPrefix", str(prefix))])
20
+
21
+ let output = entries => Some(obj(entries))
22
+
23
+ let store = (~qualified, ~bucket, ~prefix): Reset.objectStore => {
24
+ let (plugin, name) = Reset.splitQualified(qualified)->Option.getOr(("", qualified))
25
+ {qualified, plugin, store: name, bucketName: bucket, keyPrefix: prefix}
26
+ }
27
+
28
+ describe("parseObjectStores", () => {
29
+ testSync("splits the qualified key into plugin and store", () =>
30
+ switch Reset.parseObjectStores(
31
+ output([
32
+ ("Catalog.productImages", storeEntry(~bucket="alpha-stores", ~prefix="productImages")),
33
+ ]),
34
+ ) {
35
+ | Ok([s]) =>
36
+ expect((s.plugin, s.store, s.bucketName, s.keyPrefix))->toEqual((
37
+ "Catalog",
38
+ "productImages",
39
+ "alpha-stores",
40
+ "productImages",
41
+ ))
42
+ | other => fail(`expected one parsed store, got ${other->JSON.stringifyAny->Option.getOr("?")}`)
43
+ }
44
+ )
45
+
46
+ // A store name may contain a dot; a registered plugin name may not — so the
47
+ // split is at the first one, matching how the key is composed.
48
+ testSync("splits at the first dot only", () =>
49
+ switch Reset.splitQualified("Catalog.product.images") {
50
+ | Some((plugin, name)) => expect((plugin, name))->toEqual(("Catalog", "product.images"))
51
+ | None => fail("expected a split")
52
+ }
53
+ )
54
+
55
+ // A platform that declares no stores is ordinary, not an error.
56
+ testSync("absent output yields no stores", () =>
57
+ switch Reset.parseObjectStores(None) {
58
+ | Ok(stores) => expect(stores->Array.length)->toBe(0)
59
+ | Error(message) => fail(message)
60
+ }
61
+ )
62
+
63
+ // A store the reset cannot read is a store it would silently leave behind, so
64
+ // a malformed entry fails the run rather than being skipped.
65
+ testSync("a malformed entry is an error, not a skip", () =>
66
+ switch Reset.parseObjectStores(
67
+ output([("Catalog.productImages", obj([("keyPrefix", str("x"))]))]),
68
+ ) {
69
+ | Ok(_) => fail("expected a malformed entry to be refused")
70
+ | Error(message) => expect(message->String.includes("Catalog.productImages"))->toBe(true)
71
+ }
72
+ )
73
+
74
+ testSync("an unqualified key is an error", () =>
75
+ switch Reset.parseObjectStores(
76
+ output([("productImages", storeEntry(~bucket="alpha-stores", ~prefix="productImages"))]),
77
+ ) {
78
+ | Ok(_) => fail("expected an unqualified key to be refused")
79
+ | Error(message) => expect(message->String.includes("productImages"))->toBe(true)
80
+ }
81
+ )
82
+ })
83
+
84
+ describe("validateStores", () => {
85
+ testSync("distinct prefixes in one bucket are fine", () =>
86
+ expect(
87
+ Reset.validateStores([
88
+ store(~qualified="Catalog.productImages", ~bucket="alpha-stores", ~prefix="productImages"),
89
+ store(~qualified="Ordering.labels", ~bucket="alpha-stores", ~prefix="labels"),
90
+ ]),
91
+ )->toEqual(Ok())
92
+ )
93
+
94
+ // The cross-plugin collision: one prefix, two owners, nothing to tell their
95
+ // objects apart.
96
+ testSync("two plugins on one prefix are refused", () =>
97
+ switch Reset.validateStores([
98
+ store(~qualified="Catalog.productImages", ~bucket="alpha-stores", ~prefix="productImages"),
99
+ store(~qualified="Ordering.productImages", ~bucket="alpha-stores", ~prefix="productImages"),
100
+ ]) {
101
+ | Ok() => fail("expected a collision to be refused")
102
+ | Error(message) =>
103
+ expect(
104
+ message->String.includes("Catalog.productImages") &&
105
+ message->String.includes("Ordering.productImages"),
106
+ )->toBe(true)
107
+ }
108
+ )
109
+
110
+ // The same shape one level up: `Catalog` encloses `Catalog/productImages`, so
111
+ // wiping the first would delete the second's objects.
112
+ testSync("an enclosing prefix is refused", () =>
113
+ switch Reset.validateStores([
114
+ store(~qualified="Legacy.catalog", ~bucket="alpha-stores", ~prefix="Catalog"),
115
+ store(
116
+ ~qualified="Catalog.productImages",
117
+ ~bucket="alpha-stores",
118
+ ~prefix="Catalog/productImages",
119
+ ),
120
+ ]) {
121
+ | Ok() => fail("expected an enclosing prefix to be refused")
122
+ | Error(message) => expect(message->String.includes("encloses"))->toBe(true)
123
+ }
124
+ )
125
+
126
+ // Different buckets cannot reach each other, so an equal prefix there is not a
127
+ // collision — which is what the per-store layout produces in production.
128
+ testSync("one prefix in two different buckets is fine", () =>
129
+ expect(
130
+ Reset.validateStores([
131
+ store(
132
+ ~qualified="Catalog.productImages",
133
+ ~bucket="catalog-productImages",
134
+ ~prefix="productImages",
135
+ ),
136
+ store(
137
+ ~qualified="Ordering.productImages",
138
+ ~bucket="ordering-productImages",
139
+ ~prefix="productImages",
140
+ ),
141
+ ]),
142
+ )->toEqual(Ok())
143
+ )
144
+
145
+ // A shared prefix that merely starts with another's characters is not nesting:
146
+ // the delete prefix carries a trailing slash, so `images` cannot reach
147
+ // `images-archive`.
148
+ testSync("a sibling prefix sharing a character run is fine", () =>
149
+ expect(
150
+ Reset.validateStores([
151
+ store(~qualified="Catalog.images", ~bucket="alpha-stores", ~prefix="images"),
152
+ store(~qualified="Catalog.imagesArchive", ~bucket="alpha-stores", ~prefix="images-archive"),
153
+ ]),
154
+ )->toEqual(Ok())
155
+ )
156
+
157
+ testSync("an empty or slash-bearing store name is refused", () => {
158
+ switch Reset.validateStores([
159
+ store(~qualified="Catalog.productImages", ~bucket="alpha-stores", ~prefix=""),
160
+ ]) {
161
+ | Ok() => fail("expected an empty prefix to be refused")
162
+ | Error(_) => ()
163
+ }
164
+ switch Reset.validateStores([
165
+ store(~qualified="Catalog.a/b", ~bucket="alpha-stores", ~prefix="a/b"),
166
+ ]) {
167
+ | Ok() => fail("expected a store name containing a slash to be refused")
168
+ | Error(_) => ()
169
+ }
170
+ })
171
+ })
172
+
173
+ describe("pluginOf", () => {
174
+ // The `objectStores` keys carry the REGISTERED plugin name; the menu carries
175
+ // the operator-facing label. Attribution uses the declared name so the reset
176
+ // never has to guess the relation between the two.
177
+ testSync("prefers the declared plugin name over the label", () =>
178
+ expect(
179
+ Reset.pluginOf({
180
+ projectDir: "../catalog-aws",
181
+ label: "catalog",
182
+ group: Domain,
183
+ plugin: "Catalog",
184
+ }),
185
+ )->toBe("Catalog")
186
+ )
187
+
188
+ testSync("falls back to the label when no plugin name is declared", () =>
189
+ expect(Reset.pluginOf({projectDir: ".", label: "platform", group: Platform}))->toBe("platform")
190
+ )
191
+ })
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,169 @@
1
+ // Holding a stack's runtimes still for the length of a wipe: what gets held, and
2
+ // what the reset says when a store comes back anyway.
3
+ //
4
+ // A truncate is not durable while the runtimes that own the data are running — a
5
+ // slice runtime keeps its TODO list in memory for the life of its execution
6
+ // environment and re-saves every row it holds at the end of each invocation,
7
+ // including the scheduled sweep that carries no events. So the reset now
8
+ // discovers the stack's Lambda functions alongside its stores and holds them at
9
+ // zero concurrency across the wipe.
10
+ //
11
+ // The pure half of that is what these pin: which ARNs resolve to a function to
12
+ // hold, the bounded fan-out the control-plane calls go through, and the failure
13
+ // message — which used to say "re-run to finish" for a condition re-running
14
+ // could not clear. The impure half is the Lambda control-plane calls themselves.
15
+
16
+ open JestGlobals
17
+
18
+ module Reset = ReventlessSeedAws_Reset
19
+ module Quiesce = ReventlessSeedAws_Quiesce
20
+
21
+ describe("classify", () => {
22
+ testSync("reads the function name out of a Lambda ARN", () =>
23
+ expect(
24
+ Reset.classify("arn:aws:lambda:eu-west-1:123456789012:function:AllAutomationSlices-28b116c"),
25
+ )->toEqual(Reset.Function("AllAutomationSlices-28b116c"))
26
+ )
27
+
28
+ // The tagging API returns unqualified ARNs, but a qualified one must still
29
+ // yield the function name — every Lambda control-plane call the hold makes
30
+ // takes a name, and "Name:1" is not one.
31
+ testSync("drops a version or alias qualifier", () =>
32
+ expect(
33
+ Reset.classify("arn:aws:lambda:eu-west-1:123456789012:function:AllReadModels-0287438:42"),
34
+ )->toEqual(Reset.Function("AllReadModels-0287438"))
35
+ )
36
+
37
+ testSync("still classifies tables and buckets", () => {
38
+ expect(
39
+ Reset.classify("arn:aws:dynamodb:eu-west-1:123456789012:table/Products-0ba6849"),
40
+ )->toEqual(Reset.Table("Products-0ba6849"))
41
+ expect(Reset.classify("arn:aws:s3:::alpha-product-images"))->toEqual(
42
+ Reset.Bucket("alpha-product-images"),
43
+ )
44
+ })
45
+
46
+ testSync("ignores anything else the tag scope returns", () =>
47
+ expect(
48
+ Reset.classify("arn:aws:sqs:eu-west-1:123456789012:OrderingDcbCmdTopic-4b3658c"),
49
+ )->toEqual(Reset.Other)
50
+ )
51
+ })
52
+
53
+ describe("mapBounded", () => {
54
+ // Results must line up with their inputs regardless of which worker finished
55
+ // first: the hold pairs each result with the function it came from, and a
56
+ // shuffled array would restore the wrong concurrency to the wrong function.
57
+ test("keeps results in input order under out-of-order completion", async () => {
58
+ let delays = [40, 0, 30, 10, 20, 5]
59
+ let out = await delays->Quiesce.mapBounded(
60
+ ~limit=3,
61
+ async ms => {
62
+ await ReventlessSeed.Seed.Client.sleep(ms)
63
+ ms
64
+ },
65
+ )
66
+ expect(out)->toEqual(delays)
67
+ })
68
+
69
+ test("never runs more than the limit at once", async () => {
70
+ let inFlight = ref(0)
71
+ let peak = ref(0)
72
+ let _ = await Array.make(~length=12, 0)->Quiesce.mapBounded(
73
+ ~limit=4,
74
+ async _ => {
75
+ inFlight := inFlight.contents + 1
76
+ peak := Math.Int.max(peak.contents, inFlight.contents)
77
+ await ReventlessSeed.Seed.Client.sleep(5)
78
+ inFlight := inFlight.contents - 1
79
+ },
80
+ )
81
+ expect(peak.contents)->toBe(4)
82
+ })
83
+
84
+ test("an empty list spawns no workers and returns nothing", async () => {
85
+ let out = await []->Quiesce.mapBounded(~limit=4, async x => x)
86
+ expect(out->Array.length)->toBe(0)
87
+ })
88
+ })
89
+
90
+ describe("refillMessage", () => {
91
+ // The whole point of the change: the operator has to be able to see WHICH
92
+ // store came back, not just a total.
93
+ testSync("names every store that came back, with its count", () => {
94
+ let message = Reset.refillMessage(
95
+ ~refilled=[("AutoShipOrderTodo-61f39a2", 12), ("GeocodeCustomerAddressTodo-50d9488", 1)],
96
+ ~heldCount=41,
97
+ )
98
+ expect(message->String.includes("AutoShipOrderTodo-61f39a2"))->toBe(true)
99
+ expect(message->String.includes("GeocodeCustomerAddressTodo-50d9488"))->toBe(true)
100
+ expect(message->String.startsWith("13 item(s)/object(s) came back"))->toBe(true)
101
+ })
102
+
103
+ // Under a hold nothing could START during the wipe, so the only writer left is
104
+ // an invocation already running when the hold landed — and that one really
105
+ // does go away. This is the only case where "re-run" is honest advice.
106
+ testSync("advises re-running only when the runtimes were held", () => {
107
+ let message = Reset.refillMessage(~refilled=[("AutoShipOrderTodo-61f39a2", 12)], ~heldCount=41)
108
+ expect(message->String.includes("already in flight"))->toBe(true)
109
+ expect(message->String.includes("Re-running clears it"))->toBe(true)
110
+ })
111
+
112
+ // Without a hold, the writer holds the rows in memory and restores them every
113
+ // invocation — the exact condition the old "re-run to finish" sent operators
114
+ // round in circles on. It must not appear here.
115
+ testSync("refuses to advise re-running when the stack was not held", () => {
116
+ let message = Reset.refillMessage(~refilled=[("AutoShipOrderTodo-61f39a2", 12)], ~heldCount=0)
117
+ expect(message->String.includes("re-running will not help"->String.toLowerCase))->toBe(true)
118
+ expect(message->String.includes("SEED_RESET_NO_QUIESCE"))->toBe(true)
119
+ })
120
+ })
121
+
122
+ describe("isThrottle", () => {
123
+ // A hold that fails because the control plane is busy and a hold that fails
124
+ // because the credentials are short a policy read identically at the call
125
+ // site, and the fixes point in opposite directions: wait, versus go and edit
126
+ // an IAM policy. Sending an operator to the policy for a throttle costs them
127
+ // the whole trip.
128
+ testSync("recognises the wordings a throttled hold actually comes back with", () =>
129
+ [
130
+ "Rate exceeded",
131
+ "TooManyRequestsException: Rate exceeded",
132
+ "Request throttled, please try again",
133
+ ]->Array.forEach(reason => expect(Quiesce.isThrottle(reason))->toBe(true))
134
+ )
135
+
136
+ testSync("leaves a permission failure to the IAM advice", () => {
137
+ expect(
138
+ Quiesce.isThrottle(
139
+ "AccessDeniedException: User is not authorized to perform: lambda:PutFunctionConcurrency",
140
+ ),
141
+ )->toBe(false)
142
+ expect(Quiesce.isThrottle("ResourceNotFoundException: Function not found"))->toBe(false)
143
+ })
144
+ })
145
+
146
+ describe("noQuiesce", () => {
147
+ let set = v => NodeProcess.env->Dict.set("SEED_RESET_NO_QUIESCE", v)
148
+
149
+ // Fail-closed: the hold is what makes the wipe durable, so anything other than
150
+ // a deliberate opt-out keeps it on.
151
+ testSync("defaults to holding the platform", () => {
152
+ set("")
153
+ expect(Reset.noQuiesce())->toBe(false)
154
+ set("0")
155
+ expect(Reset.noQuiesce())->toBe(false)
156
+ set("no")
157
+ expect(Reset.noQuiesce())->toBe(false)
158
+ })
159
+
160
+ testSync("accepts the spellings an operator would actually type", () => {
161
+ ["1", "true", "TRUE", " yes "]->Array.forEach(
162
+ v => {
163
+ set(v)
164
+ expect(Reset.noQuiesce())->toBe(true)
165
+ },
166
+ )
167
+ set("")
168
+ })
169
+ })
Binary file
Binary file
Binary file
Binary file