@reventlessdev/reventless-seed-aws 1.0.0-alpha.12 → 1.0.0-alpha.13

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 (63) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/lib/bs/.compiler.log +2 -2
  3. package/lib/bs/compiler-info.json +1 -1
  4. package/lib/bs/src/ReventlessSeedAws_Quiesce.ast +0 -0
  5. package/lib/bs/src/ReventlessSeedAws_Quiesce.cmi +0 -0
  6. package/lib/bs/src/ReventlessSeedAws_Quiesce.cmj +0 -0
  7. package/lib/bs/src/ReventlessSeedAws_Quiesce.cmt +0 -0
  8. package/lib/bs/src/ReventlessSeedAws_Quiesce.res +287 -0
  9. package/lib/bs/src/ReventlessSeedAws_Quiesce.res.mjs +205 -0
  10. package/lib/bs/src/ReventlessSeedAws_Reconcile.ast +0 -0
  11. package/lib/bs/src/ReventlessSeedAws_Reconcile.cmi +0 -0
  12. package/lib/bs/src/ReventlessSeedAws_Reconcile.cmt +0 -0
  13. package/lib/bs/src/ReventlessSeedAws_Reconcile.res +2 -6
  14. package/lib/bs/src/ReventlessSeedAws_Reconcile.res.mjs +2 -2
  15. package/lib/bs/src/ReventlessSeedAws_Reset.ast +0 -0
  16. package/lib/bs/src/ReventlessSeedAws_Reset.cmi +0 -0
  17. package/lib/bs/src/ReventlessSeedAws_Reset.cmj +0 -0
  18. package/lib/bs/src/ReventlessSeedAws_Reset.cmt +0 -0
  19. package/lib/bs/src/ReventlessSeedAws_Reset.res +223 -73
  20. package/lib/bs/src/ReventlessSeedAws_Reset.res.mjs +212 -82
  21. package/lib/bs/tests/ObjectStoreReconcileTest.cmi +0 -0
  22. package/lib/bs/tests/ObjectStoreReconcileTest.cmt +0 -0
  23. package/lib/bs/tests/ObjectStoreResetTest.cmi +0 -0
  24. package/lib/bs/tests/ObjectStoreResetTest.cmt +0 -0
  25. package/lib/bs/tests/QuiesceResetTest.ast +0 -0
  26. package/lib/bs/tests/QuiesceResetTest.cmi +0 -0
  27. package/lib/bs/tests/QuiesceResetTest.cmj +0 -0
  28. package/lib/bs/tests/QuiesceResetTest.cmt +0 -0
  29. package/lib/bs/tests/QuiesceResetTest.res +133 -0
  30. package/lib/bs/tests/QuiesceResetTest.res.mjs +141 -0
  31. package/lib/ocaml/.compiler.log +2 -2
  32. package/lib/ocaml/ObjectStoreReconcileTest.cmi +0 -0
  33. package/lib/ocaml/ObjectStoreReconcileTest.cmt +0 -0
  34. package/lib/ocaml/ObjectStoreResetTest.cmi +0 -0
  35. package/lib/ocaml/ObjectStoreResetTest.cmt +0 -0
  36. package/lib/ocaml/QuiesceResetTest.ast +0 -0
  37. package/lib/ocaml/QuiesceResetTest.cmi +0 -0
  38. package/lib/ocaml/QuiesceResetTest.cmj +0 -0
  39. package/lib/ocaml/QuiesceResetTest.cmt +0 -0
  40. package/lib/ocaml/QuiesceResetTest.res +133 -0
  41. package/lib/ocaml/ReventlessSeedAws_Quiesce.ast +0 -0
  42. package/lib/ocaml/ReventlessSeedAws_Quiesce.cmi +0 -0
  43. package/lib/ocaml/ReventlessSeedAws_Quiesce.cmj +0 -0
  44. package/lib/ocaml/ReventlessSeedAws_Quiesce.cmt +0 -0
  45. package/lib/ocaml/ReventlessSeedAws_Quiesce.res +287 -0
  46. package/lib/ocaml/ReventlessSeedAws_Reconcile.ast +0 -0
  47. package/lib/ocaml/ReventlessSeedAws_Reconcile.cmi +0 -0
  48. package/lib/ocaml/ReventlessSeedAws_Reconcile.cmt +0 -0
  49. package/lib/ocaml/ReventlessSeedAws_Reconcile.res +2 -6
  50. package/lib/ocaml/ReventlessSeedAws_Reset.ast +0 -0
  51. package/lib/ocaml/ReventlessSeedAws_Reset.cmi +0 -0
  52. package/lib/ocaml/ReventlessSeedAws_Reset.cmj +0 -0
  53. package/lib/ocaml/ReventlessSeedAws_Reset.cmt +0 -0
  54. package/lib/ocaml/ReventlessSeedAws_Reset.res +223 -73
  55. package/package.json +2 -2
  56. package/src/ReventlessSeedAws_Quiesce.res +287 -0
  57. package/src/ReventlessSeedAws_Quiesce.res.mjs +205 -0
  58. package/src/ReventlessSeedAws_Reconcile.res +2 -6
  59. package/src/ReventlessSeedAws_Reconcile.res.mjs +2 -2
  60. package/src/ReventlessSeedAws_Reset.res +223 -73
  61. package/src/ReventlessSeedAws_Reset.res.mjs +212 -82
  62. package/tests/QuiesceResetTest.res +133 -0
  63. package/tests/QuiesceResetTest.res.mjs +141 -0
@@ -47,6 +47,8 @@ open ReventlessSeed
47
47
  module Ddb = AwsSdk.DynamoDb
48
48
  module Rgt = AwsSdk.ResourceGroupsTaggingApi
49
49
  module S3 = AwsSdk.S3
50
+ module Lambda = AwsSdk.Lambda
51
+ module Quiesce = ReventlessSeedAws_Quiesce
50
52
 
51
53
 
52
54
 
@@ -140,9 +142,16 @@ let configValue = (cfg: dict<JSON.t>, key: string): option<string> =>
140
142
  type resource =
141
143
  | Table(string)
142
144
  | Bucket(string)
145
+ | Function(string)
143
146
  | Other
144
147
 
145
148
  // arn:aws:dynamodb:<region>:<acct>:table/<name> | arn:aws:s3:::<bucket>
149
+ // | arn:aws:lambda:<region>:<acct>:function:<name>
150
+ //
151
+ // A Lambda ARN can carry a trailing `:<version|alias>` qualifier. The tagging
152
+ // API returns the unqualified form, but taking the segment after `:function:`
153
+ // up to the next colon rather than to the end of the string means a qualified
154
+ // one still yields the function name instead of a name no API accepts.
146
155
  let classify = (arn: string): resource =>
147
156
  if arn->String.startsWith("arn:aws:dynamodb:") {
148
157
  switch arn->String.split(":table/") {
@@ -151,6 +160,16 @@ let classify = (arn: string): resource =>
151
160
  }
152
161
  } else if arn->String.startsWith("arn:aws:s3:::") {
153
162
  Bucket(arn->String.slice(~start=String.length("arn:aws:s3:::"), ~end=String.length(arn)))
163
+ } else if arn->String.startsWith("arn:aws:lambda:") {
164
+ switch arn->String.split(":function:") {
165
+ | [_, rest] =>
166
+ switch rest->String.split(":") {
167
+ | [name] => Function(name)
168
+ | [name, _qualifier] => Function(name)
169
+ | _ => Other
170
+ }
171
+ | _ => Other
172
+ }
154
173
  } else {
155
174
  Other
156
175
  }
@@ -158,15 +177,29 @@ let classify = (arn: string): resource =>
158
177
  let tagValue = (tags: array<Rgt.GetResourcesCommand.tag>, key: string): option<string> =>
159
178
  tags->Array.findMap(t => t.key == key ? Some(t.value) : None)
160
179
 
180
+ /** What one project's tags resolve to: the stores to empty, and the runtimes
181
+ that have to be held still while that happens. */
182
+ type discovered = {
183
+ tables: array<string>,
184
+ buckets: array<string>,
185
+ functions: array<string>,
186
+ }
187
+
161
188
  // Discovery is scoped by BOTH tags (platform AND environment) — the Tagging API
162
189
  // ANDs multiple TagFilters — so it only ever sees the target project's own stack,
163
190
  // never a same-named stack from another project. Each returned resource is then
164
191
  // re-checked to carry both tags (a mismatch aborts the whole run rather than
165
192
  // being skipped) as a second, per-resource guard.
166
- let discover = async (~region, ~stack, ~platform): (array<string>, array<string>) => {
193
+ //
194
+ // Lambda functions are discovered alongside the stores, under the same tags and
195
+ // the same per-resource re-check. They are never deleted from — they are what
196
+ // the wipe has to hold still, and the tag scope that decides what may be emptied
197
+ // is exactly the scope that decides what may be held.
198
+ let discover = async (~region, ~stack, ~platform): discovered => {
167
199
  let client = Rgt.client(~region, ())
168
200
  let tables = []
169
201
  let buckets = []
202
+ let functions = []
170
203
  let rec loop = async (token: option<string>): unit => {
171
204
  let out = await Rgt.GetResourcesCommand.send(
172
205
  client,
@@ -175,7 +208,7 @@ let discover = async (~region, ~stack, ~platform): (array<string>, array<string>
175
208
  {key: "reventless:platform", values: [platform]},
176
209
  {key: "reventless:environment", values: [stack]},
177
210
  ],
178
- resourceTypeFilters: ["dynamodb:table", "s3"],
211
+ resourceTypeFilters: ["dynamodb:table", "s3", "lambda:function"],
179
212
  resourcesPerPage: 100,
180
213
  paginationToken: ?token,
181
214
  }),
@@ -201,6 +234,7 @@ let discover = async (~region, ~stack, ~platform): (array<string>, array<string>
201
234
  switch classify(m.resourceARN) {
202
235
  | Table(name) => tables->Array.push(name)
203
236
  | Bucket(name) => buckets->Array.push(name)
237
+ | Function(name) => functions->Array.push(name)
204
238
  | Other => ()
205
239
  }
206
240
  })
@@ -210,7 +244,7 @@ let discover = async (~region, ~stack, ~platform): (array<string>, array<string>
210
244
  }
211
245
  }
212
246
  await loop(None)
213
- (tables, buckets)
247
+ {tables, buckets, functions}
214
248
  }
215
249
 
216
250
  // ── Declared object stores ────────────────────────────────────────────────────
@@ -517,6 +551,8 @@ type resolved = {
517
551
  tableCounts: array<int>,
518
552
  bucketCounts: array<(string, int)>,
519
553
  storeCounts: array<(objectStore, int)>,
554
+ /** The project's Lambda functions — not wiped, held still while it is. */
555
+ functions: array<string>,
520
556
  }
521
557
 
522
558
  // Picks which targets to wipe. `domain` (every domain plugin) leads and is the
@@ -598,6 +634,49 @@ let gateTarget = (~target: target, ~backend, ~stack): string => {
598
634
  }
599
635
  }
600
636
 
637
+ /** Opt out of holding the platform still for the wipe. The hold needs Lambda
638
+ permissions the reset did not previously ask for, so credentials that lack
639
+ them have a way through — at the cost of the emptiness guarantee, which the
640
+ note printed alongside it says plainly. */
641
+ let noQuiesce = (): bool =>
642
+ switch Seed.Prompt.envValue("SEED_RESET_NO_QUIESCE")->Option.map(v =>
643
+ v->String.trim->String.toLowerCase
644
+ ) {
645
+ | Some("1") | Some("true") | Some("yes") => true
646
+ | _ => false
647
+ }
648
+
649
+ /** What to say when a store is not empty after the wipe.
650
+
651
+ The old message reported only a total and advised re-running. That advice was
652
+ wrong in exactly the case that occurs: a runtime holding its state in memory
653
+ rewrites the store on every invocation, so every re-run reproduces the same
654
+ failure and the message sends the operator round the loop again.
655
+
656
+ So name the stores, and separate the two situations that are actually
657
+ different. Under a hold, nothing could start during the wipe, so the only
658
+ possible writer is an invocation that was already in flight — and a re-run
659
+ does clear that. Without a hold there is no such bound, and re-running is the
660
+ wrong advice. */
661
+ let refillMessage = (~refilled: array<(string, int)>, ~heldCount: int): string => {
662
+ let total = refilled->Array.reduce(0, (acc, (_, n)) => acc + n)
663
+ let lines =
664
+ refilled
665
+ ->Array.map(((label, n)) => `\n ${n->Int.toString->String.padStart(8, " ")} ${label}`)
666
+ ->Array.join("")
667
+ let diagnosis = if heldCount > 0 {
668
+ `\n\n The wipe deleted these and something wrote them back. All ${heldCount->Int.toString} runtime(s) ` ++
669
+ `in scope were held at zero concurrency for the whole wipe, so the writer was an invocation already ` ++
670
+ `in flight when the hold took effect. Re-running clears it.`
671
+ } else {
672
+ `\n\n The wipe deleted these and a running runtime wrote them back. The stack was NOT held ` ++
673
+ `(SEED_RESET_NO_QUIESCE), so nothing stopped it, and re-running will not help: the writer keeps the ` ++
674
+ `state in memory and restores it on every invocation, identical each time. Re-run without ` ++
675
+ `SEED_RESET_NO_QUIESCE so the runtimes are held and recycled.`
676
+ }
677
+ `${total->Int.toString} item(s)/object(s) came back after the wipe:${lines}${diagnosis}`
678
+ }
679
+
601
680
  let reportAll = (resolvedList: array<resolved>, ~stack, ~region): int => {
602
681
  Seed.Runner.heading(`Reset target: stack "${stack}" in ${region}`)
603
682
  let total = ref(0)
@@ -747,9 +826,9 @@ let run = (~stack=?, ~backend=?, ~targets: array<target>, ()): unit => {
747
826
  switch platformTarget {
748
827
  | Some(pt) =>
749
828
  let platformProject = projectName(~projectDir=pt.projectDir)
750
- let (_, platformBuckets) = await discover(~region, ~stack, ~platform=platformProject)
829
+ let platformFound = await discover(~region, ~stack, ~platform=platformProject)
751
830
  selectedStores->Array.forEach(s =>
752
- if !(platformBuckets->Array.includes(s.bucketName)) {
831
+ if !(platformFound.buckets->Array.includes(s.bucketName)) {
753
832
  throw(
754
833
  Seed.Failed(
755
834
  `refusing: store "${s.qualified}" names bucket ${s.bucketName}, which does not ` ++
@@ -769,10 +848,10 @@ let run = (~stack=?, ~backend=?, ~targets: array<target>, ()): unit => {
769
848
  switch selected->Array.get(i) {
770
849
  | Some(target) =>
771
850
  let platform = projectName(~projectDir=target.projectDir)
772
- let (tables, buckets) = await discover(~region, ~stack, ~platform)
773
- let tables = tables->Array.toSorted(String.compare)
851
+ let found = await discover(~region, ~stack, ~platform)
852
+ let tables = found.tables->Array.toSorted(String.compare)
774
853
  let buckets =
775
- buckets
854
+ found.buckets
776
855
  ->Array.filter(b => !(storeBucketNames->Array.includes(b)))
777
856
  ->Array.toSorted(String.compare)
778
857
  let tableCounts = []
@@ -808,6 +887,7 @@ let run = (~stack=?, ~backend=?, ~targets: array<target>, ()): unit => {
808
887
  tableCounts,
809
888
  bucketCounts,
810
889
  storeCounts,
890
+ functions: found.functions->Array.toSorted(String.compare),
811
891
  })
812
892
  | None => ()
813
893
  }
@@ -848,85 +928,155 @@ let run = (~stack=?, ~backend=?, ~targets: array<target>, ()): unit => {
848
928
  NodeProcess.exit(0)
849
929
  }
850
930
 
851
- for i in 0 to resolvedList->Array.length - 1 {
852
- switch resolvedList->Array.get(i) {
853
- | Some(r) =>
854
- Seed.Runner.heading(`Emptying ${r.target.label} …`)
855
- for j in 0 to r.tables->Array.length - 1 {
856
- switch r.tables->Array.get(j) {
857
- | Some(t) =>
858
- await truncateTable(t)
859
- Console.log(` truncated ${t}`)
860
- | None => ()
931
+ // Hold the platform still before the first delete. A truncate is not
932
+ // durable while the runtimes that own the data are running — see
933
+ // ReventlessSeedAws_Quiesce for the mechanism and for why emptying the
934
+ // upstream stores first does not substitute for this.
935
+ let functionNames = resolvedList->Array.reduce([], (acc, r) =>
936
+ Array.concat(acc, r.functions->Array.filter(f => !(acc->Array.includes(f))))
937
+ )
938
+ let quiesce = !noQuiesce() && functionNames->Array.length > 0
939
+ let lambdaClient = Lambda.client(~region, ())
940
+ let held = if quiesce {
941
+ Seed.Runner.heading(
942
+ `Holding ${functionNames->Array.length->Int.toString} runtime(s) at zero concurrency …`,
943
+ )
944
+ await Quiesce.hold(~client=lambdaClient, ~functionNames)
945
+ } else {
946
+ if functionNames->Array.length > 0 {
947
+ Console.log("")
948
+ Console.log(
949
+ "Note: SEED_RESET_NO_QUIESCE is set — the stack's runtimes keep running through the wipe. " ++
950
+ "A runtime that holds state across invocations can write it straight back over an emptied store.",
951
+ )
952
+ }
953
+ []
954
+ }
955
+
956
+ // From here the reset owns the platform's concurrency, so every exit has
957
+ // to run through the release below — including the abort path. ReScript
958
+ // has no `finally`, so the wipe's outcome is captured as a value and
959
+ // re-raised after the stack has been handed back.
960
+ let outcome = try {
961
+ for i in 0 to resolvedList->Array.length - 1 {
962
+ switch resolvedList->Array.get(i) {
963
+ | Some(r) =>
964
+ Seed.Runner.heading(`Emptying ${r.target.label} …`)
965
+ for j in 0 to r.tables->Array.length - 1 {
966
+ switch r.tables->Array.get(j) {
967
+ | Some(t) =>
968
+ await truncateTable(t)
969
+ Console.log(` truncated ${t}`)
970
+ | None => ()
971
+ }
861
972
  }
862
- }
863
- for j in 0 to r.bucketCounts->Array.length - 1 {
864
- switch r.bucketCounts->Array.get(j) {
865
- | Some((b, _)) =>
866
- await emptyBucket(b)
867
- Console.log(` emptied ${b}`)
868
- | None => ()
973
+ for j in 0 to r.bucketCounts->Array.length - 1 {
974
+ switch r.bucketCounts->Array.get(j) {
975
+ | Some((b, _)) =>
976
+ await emptyBucket(b)
977
+ Console.log(` emptied ${b}`)
978
+ | None => ()
979
+ }
869
980
  }
870
- }
871
- // Report the count with the store, not just in the run total: "how
872
- // many images went" is the question an operator is actually asking.
873
- for j in 0 to r.storeCounts->Array.length - 1 {
874
- switch r.storeCounts->Array.get(j) {
875
- | Some((s, count)) =>
876
- await emptyBucket(s.bucketName, ~prefix=`${s.keyPrefix}/`)
877
- Console.log(
878
- ` emptied ${s.qualified} — ${count->Int.toString} object(s) removed from ` ++
879
- `${s.bucketName}/${s.keyPrefix}/`,
880
- )
881
- | None => ()
981
+ // Report the count with the store, not just in the run total: "how
982
+ // many images went" is the question an operator is actually asking.
983
+ for j in 0 to r.storeCounts->Array.length - 1 {
984
+ switch r.storeCounts->Array.get(j) {
985
+ | Some((s, count)) =>
986
+ await emptyBucket(s.bucketName, ~prefix=`${s.keyPrefix}/`)
987
+ Console.log(
988
+ ` emptied ${s.qualified} — ${count->Int.toString} object(s) removed from ` ++
989
+ `${s.bucketName}/${s.keyPrefix}/`,
990
+ )
991
+ | None => ()
992
+ }
882
993
  }
994
+ | None => ()
883
995
  }
884
- | None => ()
885
996
  }
886
- }
887
997
 
888
- // Prove empty — the inverse of the post-seed verify.
889
- let remaining = ref(0)
890
- for i in 0 to resolvedList->Array.length - 1 {
891
- switch resolvedList->Array.get(i) {
892
- | Some(r) =>
893
- for j in 0 to r.tables->Array.length - 1 {
894
- switch r.tables->Array.get(j) {
895
- | Some(t) => remaining := remaining.contents + (await countTable(t))
896
- | None => ()
998
+ // Prove empty — the inverse of the post-seed verify. Every store that
999
+ // still reads non-zero is named, because "12 items remain" without
1000
+ // saying where is the difference between a diagnosis and a mystery.
1001
+ let refilled = []
1002
+ for i in 0 to resolvedList->Array.length - 1 {
1003
+ switch resolvedList->Array.get(i) {
1004
+ | Some(r) =>
1005
+ for j in 0 to r.tables->Array.length - 1 {
1006
+ switch r.tables->Array.get(j) {
1007
+ | Some(t) =>
1008
+ let left = await countTable(t)
1009
+ if left != 0 {
1010
+ refilled->Array.push((t, left))
1011
+ }
1012
+ | None => ()
1013
+ }
897
1014
  }
898
- }
899
- for j in 0 to r.bucketCounts->Array.length - 1 {
900
- switch r.bucketCounts->Array.get(j) {
901
- | Some((b, _)) => remaining := remaining.contents + (await countBucket(b))
902
- | None => ()
1015
+ for j in 0 to r.bucketCounts->Array.length - 1 {
1016
+ switch r.bucketCounts->Array.get(j) {
1017
+ | Some((b, _)) =>
1018
+ let left = await countBucket(b)
1019
+ if left != 0 {
1020
+ refilled->Array.push((b, left))
1021
+ }
1022
+ | None => ()
1023
+ }
903
1024
  }
904
- }
905
- // Per-store confirmation, so "every uploaded object is gone" is an
906
- // observed fact rather than an inference from a global total.
907
- for j in 0 to r.storeCounts->Array.length - 1 {
908
- switch r.storeCounts->Array.get(j) {
909
- | Some((s, _)) =>
910
- let left = await countBucket(s.bucketName, ~prefix=`${s.keyPrefix}/`)
911
- remaining := remaining.contents + left
912
- Console.log(
913
- ` verified empty: ${s.qualified} — ${left->Int.toString} object(s) remain under ` ++
914
- `${s.bucketName}/${s.keyPrefix}/`,
915
- )
916
- | None => ()
1025
+ // Per-store confirmation, so "every uploaded object is gone" is an
1026
+ // observed fact rather than an inference from a global total.
1027
+ for j in 0 to r.storeCounts->Array.length - 1 {
1028
+ switch r.storeCounts->Array.get(j) {
1029
+ | Some((s, _)) =>
1030
+ let left = await countBucket(s.bucketName, ~prefix=`${s.keyPrefix}/`)
1031
+ if left != 0 {
1032
+ refilled->Array.push((`${s.qualified} (${s.bucketName}/${s.keyPrefix}/)`, left))
1033
+ }
1034
+ Console.log(
1035
+ ` verified empty: ${s.qualified} — ${left->Int.toString} object(s) remain under ` ++
1036
+ `${s.bucketName}/${s.keyPrefix}/`,
1037
+ )
1038
+ | None => ()
1039
+ }
917
1040
  }
1041
+ | None => ()
918
1042
  }
919
- | None => ()
920
1043
  }
921
- }
922
- if remaining.contents != 0 {
923
- throw(
924
- Seed.Failed(
925
- `${remaining.contents->Int.toString} item(s)/object(s) remain after the wipe — re-run to finish.`,
926
- ),
1044
+ Ok(refilled)
1045
+ } catch {
1046
+ | Seed.Failed(message) => Error(message)
1047
+ | exn =>
1048
+ Error(
1049
+ `the wipe failed: ${exn
1050
+ ->JsExn.fromException
1051
+ ->Option.flatMap(JsExn.message)
1052
+ ->Option.getOr("unknown error")}`,
927
1053
  )
928
1054
  }
929
1055
 
1056
+ // Recycle while still held, so the container that eventually serves
1057
+ // traffic is created after the stores were emptied — then hand the stack
1058
+ // back. Both are best-effort and report rather than raise: they run on the
1059
+ // abort path too, where throwing would bury the reason the reset failed.
1060
+ let warnings = if held->Array.length > 0 {
1061
+ Seed.Runner.heading(`Recycling ${held->Array.length->Int.toString} runtime(s) …`)
1062
+ let recycled = await Quiesce.recycle(~client=lambdaClient, ~held)
1063
+ let released = await Quiesce.release(~client=lambdaClient, ~held)
1064
+ Array.concat(recycled, released)
1065
+ } else {
1066
+ []
1067
+ }
1068
+ if warnings->Array.length > 0 {
1069
+ Console.error("")
1070
+ warnings->Array.forEach(w => Console.error(` warning: ${w}`))
1071
+ }
1072
+
1073
+ switch outcome {
1074
+ | Error(message) => throw(Seed.Failed(message))
1075
+ | Ok(refilled) if refilled->Array.length > 0 =>
1076
+ throw(Seed.Failed(refillMessage(~refilled, ~heldCount=held->Array.length)))
1077
+ | Ok(_) => ()
1078
+ }
1079
+
930
1080
  Console.log("")
931
1081
  Console.log(`Reset complete — the selected scope of "${stack}" reads empty and is re-seedable.`)
932
1082
  NodeProcess.exit(0)