@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,1103 @@
1
+ // AWS "reset" for the seed harness: truncate a deployed stack's durable stores
2
+ // — every DynamoDB table (EventLog, DcbEventLog, QueryDb) and every S3 bucket
3
+ // (tasks, served images) the framework created for the stack — leaving the
4
+ // infrastructure in place so the stack reads empty and is re-seedable. It is the
5
+ // exact inverse of seeding: `Seed.Runner.assertStoreEmpty` refuses to seed a
6
+ // non-empty store; this makes a non-empty store empty again.
7
+ //
8
+ // A wipe is irreversible, so every step is fail-closed and production must be
9
+ // unreachable at several at once (the separate-AWS-account guarantee is the one
10
+ // deferred layer — see docs/plans/done/seed-reset-and-fresh-store-guard.md):
11
+ //
12
+ // 1. name allowlist — the stack name must match ^(alpha|dev|pr-.+)$
13
+ // 2. wipeable flag — the target's own `Pulumi.<stack>.yaml` must declare
14
+ // `reventless:wipeable: true` (read via `pulumi config`)
15
+ // 3. tag-scoped discovery — targets are found ONLY through the framework's
16
+ // `reventless:platform=<project>` + `environment=<stack>`
17
+ // tags (Resource Groups Tagging API). Scoping on BOTH
18
+ // matters: `environment` is the stack NAME alone, so two
19
+ // projects sharing a stack name (e.g. `alpha`) in one
20
+ // account collide on it; `platform` (the Pulumi project)
21
+ // keeps a wipe inside the one project. No name globbing.
22
+ // 4. per-resource tag re-check — every discovered resource is re-verified to
23
+ // carry both tags before a single delete is issued
24
+ // 5. dry-run default + typed confirm — lists what it would empty and stops.
25
+ // Interactively, re-typing the exact stack name confirms
26
+ // a real wipe (no env var). Without a TTY (CI),
27
+ // REVENTLESS_WIPE_CONFIRM=<stack> is the equivalent.
28
+ //
29
+ // Gates 1 and 2 are AND-ed: a stray `wipeable: true` on an unlisted name is
30
+ // still refused, and a listed name without the flag is still refused. Production
31
+ // stacks satisfy neither.
32
+ //
33
+ // A deployment is usually several Pulumi projects sharing a stack name — the
34
+ // platform project plus one per domain plugin, each its own `reventless:platform`.
35
+ // The caller passes those projects as `targets`; the operator picks a scope
36
+ // (`domain` = all plugins, a single plugin, `platform`, or `everything`), and
37
+ // gate 2 + discovery run per selected project. Wiping domain data alone leaves
38
+ // the platform's plugin registry intact, so the store stays re-seedable.
39
+ //
40
+ // Stack resolution reuses `ReventlessSeedAws` (same `pulumi` subprocess, same
41
+ // backend pinning). AWS credentials come from the ambient chain (env / profile /
42
+ // SSO); only the region is resolved explicitly and pinned on the environment, so
43
+ // every SDK client targets the same account/region the tags were read from.
44
+
45
+ open ReventlessSeed
46
+
47
+ module Ddb = AwsSdk.DynamoDb
48
+ module Rgt = AwsSdk.ResourceGroupsTaggingApi
49
+ module S3 = AwsSdk.S3
50
+ module Lambda = AwsSdk.Lambda
51
+ module Quiesce = ReventlessSeedAws_Quiesce
52
+
53
+ // The Pulumi project name, which the framework stamps as `reventless:platform` on
54
+ // every resource (`Plugin.res`: `platformName = getProjectName()`). Discovery
55
+ // MUST scope on this as well as the stack: `reventless:environment` carries only
56
+ // the stack *name*, so two different Pulumi projects deployed with the same stack
57
+ // name (e.g. `alpha`) in one account/region share that tag. Filtering by platform
58
+ // too keeps a wipe inside the one project the operator is standing in. Read from
59
+ // the target project's Pulumi.yaml — the same dir the pulumi subprocess uses.
60
+ let projectName = (~projectDir: string): string => {
61
+ let path = `${projectDir}/Pulumi.yaml`
62
+ let raw = try NodeFs.readFileSync(path) catch {
63
+ | _ => throw(Seed.Failed(`could not read ${path} to scope the wipe to this Pulumi project.`))
64
+ }
65
+ switch raw
66
+ ->String.split("\n")
67
+ ->Array.find(line => line->String.trim->String.startsWith("name:")) {
68
+ | Some(line) =>
69
+ let trimmed = line->String.trim
70
+ trimmed
71
+ ->String.slice(~start=String.length("name:"), ~end=String.length(trimmed))
72
+ ->String.trim
73
+ ->String.replaceRegExp(/^[\"']|[\"']$/g, "")
74
+ | None => throw(Seed.Failed(`could not find a \`name:\` field in ${path}.`))
75
+ }
76
+ }
77
+
78
+ // A read of a JSON object field — items come back from the document client as
79
+ // already-unmarshalled JSON values, so a delete key is just the key attributes
80
+ // picked back out.
81
+ let field = (json: JSON.t, key: string): option<JSON.t> =>
82
+ switch json {
83
+ | Object(obj) => obj->Dict.get(key)
84
+ | _ => None
85
+ }
86
+
87
+ let asString = (json: JSON.t): option<string> =>
88
+ switch json {
89
+ | String(s) => Some(s)
90
+ | _ => None
91
+ }
92
+
93
+ let chunk = (arr: array<'a>, size: int): array<array<'a>> => {
94
+ let out = []
95
+ let i = ref(0)
96
+ while i.contents < arr->Array.length {
97
+ out->Array.push(arr->Array.slice(~start=i.contents, ~end=i.contents + size))
98
+ i := i.contents + size
99
+ }
100
+ out
101
+ }
102
+
103
+ // ── Gates ───────────────────────────────────────────────────────────────────
104
+
105
+ // Fail-closed name allowlist. A denylist ("everything except prod") fails open
106
+ // the day a new prod-like stack is added and forgotten; this fails closed.
107
+ let nameAllowlist = /^(alpha|dev|pr-.+)$/
108
+
109
+ // The stack's fully-resolved Pulumi config, read once. Reading it whole (rather
110
+ // than `pulumi config get <key>`, which exits non-zero for BOTH a missing key
111
+ // and a missing/unreachable stack) lets a refusal say *which* it was — an
112
+ // unreadable config is a pulumi/backend problem; an absent flag is a missing
113
+ // opt-in. `pulumi config --json` maps each key to `{value, secret}`.
114
+ let readStackConfig = (~projectDir, ~backend, ~stack): result<dict<JSON.t>, string> =>
115
+ switch try Some(
116
+ ReventlessSeedAws.pulumi(~projectDir, ~backend, ["config", "--json", "--stack", stack]),
117
+ ) catch {
118
+ | _ => None
119
+ } {
120
+ | None =>
121
+ Error(
122
+ `could not read the Pulumi config for stack "${stack}" — is pulumi logged in to the right backend, and is the stack deployed?`,
123
+ )
124
+ | Some(raw) =>
125
+ switch try Some(JSON.parseOrThrow(raw)) catch {
126
+ | _ => None
127
+ } {
128
+ | Some(Object(obj)) => Ok(obj)
129
+ | _ => Error(`could not parse \`pulumi config --json\` output for stack "${stack}".`)
130
+ }
131
+ }
132
+
133
+ let configValue = (cfg: dict<JSON.t>, key: string): option<string> =>
134
+ cfg->Dict.get(key)->Option.flatMap(v => v->field("value"))->Option.flatMap(asString)
135
+
136
+ // ── Discovery ─────────────────────────────────────────────────────────────────
137
+
138
+ type resource =
139
+ | Table(string)
140
+ | Bucket(string)
141
+ | Function(string)
142
+ | Other
143
+
144
+ // arn:aws:dynamodb:<region>:<acct>:table/<name> | arn:aws:s3:::<bucket>
145
+ // | arn:aws:lambda:<region>:<acct>:function:<name>
146
+ //
147
+ // A Lambda ARN can carry a trailing `:<version|alias>` qualifier. The tagging
148
+ // API returns the unqualified form, but taking the segment after `:function:`
149
+ // up to the next colon rather than to the end of the string means a qualified
150
+ // one still yields the function name instead of a name no API accepts.
151
+ let classify = (arn: string): resource =>
152
+ if arn->String.startsWith("arn:aws:dynamodb:") {
153
+ switch arn->String.split(":table/") {
154
+ | [_, name] => Table(name)
155
+ | _ => Other
156
+ }
157
+ } else if arn->String.startsWith("arn:aws:s3:::") {
158
+ Bucket(arn->String.slice(~start=String.length("arn:aws:s3:::"), ~end=String.length(arn)))
159
+ } else if arn->String.startsWith("arn:aws:lambda:") {
160
+ switch arn->String.split(":function:") {
161
+ | [_, rest] =>
162
+ switch rest->String.split(":") {
163
+ | [name] => Function(name)
164
+ | [name, _qualifier] => Function(name)
165
+ | _ => Other
166
+ }
167
+ | _ => Other
168
+ }
169
+ } else {
170
+ Other
171
+ }
172
+
173
+ let tagValue = (tags: array<Rgt.GetResourcesCommand.tag>, key: string): option<string> =>
174
+ tags->Array.findMap(t => t.key == key ? Some(t.value) : None)
175
+
176
+ /** What one project's tags resolve to: the stores to empty, and the runtimes
177
+ that have to be held still while that happens. */
178
+ type discovered = {
179
+ tables: array<string>,
180
+ buckets: array<string>,
181
+ functions: array<string>,
182
+ }
183
+
184
+ // Discovery is scoped by BOTH tags (platform AND environment) — the Tagging API
185
+ // ANDs multiple TagFilters — so it only ever sees the target project's own stack,
186
+ // never a same-named stack from another project. Each returned resource is then
187
+ // re-checked to carry both tags (a mismatch aborts the whole run rather than
188
+ // being skipped) as a second, per-resource guard.
189
+ //
190
+ // Lambda functions are discovered alongside the stores, under the same tags and
191
+ // the same per-resource re-check. They are never deleted from — they are what
192
+ // the wipe has to hold still, and the tag scope that decides what may be emptied
193
+ // is exactly the scope that decides what may be held.
194
+ let discover = async (~region, ~stack, ~platform): discovered => {
195
+ let client = Rgt.client(~region, ())
196
+ let tables = []
197
+ let buckets = []
198
+ let functions = []
199
+ let rec loop = async (token: option<string>): unit => {
200
+ let out = await Rgt.GetResourcesCommand.send(
201
+ client,
202
+ Rgt.GetResourcesCommand.make({
203
+ tagFilters: [
204
+ {key: "reventless:platform", values: [platform]},
205
+ {key: "reventless:environment", values: [stack]},
206
+ ],
207
+ resourceTypeFilters: ["dynamodb:table", "s3", "lambda:function"],
208
+ resourcesPerPage: 100,
209
+ paginationToken: ?token,
210
+ }),
211
+ )
212
+ out.resourceTagMappingList
213
+ ->Option.getOr([])
214
+ ->Array.forEach(m => {
215
+ let tags = m.tags->Option.getOr([])
216
+ let assertTag = (key, expected) =>
217
+ switch tagValue(tags, key) {
218
+ | Some(v) if v == expected => ()
219
+ | other =>
220
+ throw(
221
+ Seed.Failed(
222
+ `refusing: discovered resource ${m.resourceARN} carries ${key}=${other->Option.getOr(
223
+ "<none>",
224
+ )}, not "${expected}".`,
225
+ ),
226
+ )
227
+ }
228
+ assertTag("reventless:platform", platform)
229
+ assertTag("reventless:environment", stack)
230
+ switch classify(m.resourceARN) {
231
+ | Table(name) => tables->Array.push(name)
232
+ | Bucket(name) => buckets->Array.push(name)
233
+ | Function(name) => functions->Array.push(name)
234
+ | Other => ()
235
+ }
236
+ })
237
+ switch out.paginationToken {
238
+ | Some(t) if t != "" => await loop(Some(t))
239
+ | _ => ()
240
+ }
241
+ }
242
+ await loop(None)
243
+ {tables, buckets, functions}
244
+ }
245
+
246
+ // ── Declared object stores ────────────────────────────────────────────────────
247
+ //
248
+ // A store declared by a `@storageRef` field is provisioned by the PLATFORM
249
+ // deploy — the serving CDN and the presign services live there, and a
250
+ // shared-layout bucket holds several plugins' stores, so no plugin stack can own
251
+ // it. Its bucket therefore carries the platform project's `reventless:platform`
252
+ // tag, which makes it invisible to a domain-scoped tag discovery and, worse,
253
+ // wholesale-emptiable by a platform-scoped one.
254
+ //
255
+ // Tag discovery cannot fix that: the tag says which project *built* the bucket,
256
+ // and the answer needed here is which plugin's data is *in* it — at prefix
257
+ // granularity, since one bucket holds several plugins' stores. The platform
258
+ // already publishes exactly that mapping as its `objectStores` stack output, so
259
+ // the reset reads ownership from the declaration rather than inferring it.
260
+
261
+ /** One provisioned store: which plugin owns it, and where its objects live. */
262
+ type objectStore = {
263
+ qualified: string,
264
+ plugin: string,
265
+ store: string,
266
+ bucketName: string,
267
+ keyPrefix: string,
268
+ }
269
+
270
+ // A store's key is `{plugin}.{store}`, split at the FIRST dot: a registered
271
+ // plugin name cannot contain one, a store name could in principle.
272
+ let splitQualified = (key: string): option<(string, string)> =>
273
+ key
274
+ ->String.indexOfOpt(".")
275
+ ->Option.map(i => (
276
+ key->String.slice(~start=0, ~end=i),
277
+ key->String.slice(~start=i + 1, ~end=key->String.length),
278
+ ))
279
+
280
+ /** Parse the platform stack's `objectStores` output. Absent is normal — a
281
+ platform may declare no stores — so only a malformed entry is an error, and
282
+ it is an error rather than a skip because a store the reset cannot read is a
283
+ store it would silently leave behind. */
284
+ let parseObjectStores = (json: option<JSON.t>): result<array<objectStore>, string> =>
285
+ switch json {
286
+ | None => Ok([])
287
+ | Some(Object(entries)) =>
288
+ entries
289
+ ->Dict.toArray
290
+ ->Array.reduce(Ok([]), (acc, (qualified, entry)) =>
291
+ switch acc {
292
+ | Error(_) as failed => failed
293
+ | Ok(stores) =>
294
+ switch (
295
+ splitQualified(qualified),
296
+ entry->field("bucketName")->Option.flatMap(asString),
297
+ entry->field("keyPrefix")->Option.flatMap(asString),
298
+ ) {
299
+ | (Some((plugin, store)), Some(bucketName), Some(keyPrefix)) =>
300
+ Ok(Array.concat(stores, [{qualified, plugin, store, bucketName, keyPrefix}]))
301
+ | _ =>
302
+ Error(
303
+ `the platform stack's \`objectStores\` output has a malformed entry for "${qualified}" — ` ++ `expected a {plugin}.{store} key carrying bucketName and keyPrefix.`,
304
+ )
305
+ }
306
+ }
307
+ )
308
+ | Some(_) => Error("the platform stack's `objectStores` output is not an object.")
309
+ }
310
+
311
+ /** Refuse any store set a prefix-scoped wipe cannot separate.
312
+
313
+ Equality is the cross-plugin collision: two plugins declaring one store name
314
+ land on one prefix inside a shared bucket, and nothing distinguishes their
315
+ objects. Containment is the same problem one level up — the delete prefix is
316
+ `{keyPrefix}/`, so a store rooted at `a` encloses one rooted at `a/b`.
317
+ Comparing containment rather than equality is what keeps this correct once a
318
+ prefix carries path structure.
319
+
320
+ Fail-closed, and before anything is counted: this is the tool that destroys
321
+ data, so it refuses rather than skipping, and it does not assume an upstream
322
+ deploy-time check ran. */
323
+ let validateStores = (stores: array<objectStore>): result<unit, string> =>
324
+ switch stores->Array.find(s => s.keyPrefix == "" || s.store->String.includes("/")) {
325
+ | Some(s) =>
326
+ Error(
327
+ `store "${s.qualified}" has an unusable key prefix ("${s.keyPrefix}") — ` ++ `a store name may not be empty or contain "/".`,
328
+ )
329
+ | None =>
330
+ switch stores->Array.findMap(a =>
331
+ stores->Array.findMap(b =>
332
+ if a.qualified == b.qualified || a.bucketName != b.bucketName {
333
+ None
334
+ } else if a.keyPrefix == b.keyPrefix {
335
+ Some(
336
+ `stores "${a.qualified}" and "${b.qualified}" both live at ` ++
337
+ `${a.bucketName}/${a.keyPrefix}/ — a prefix-scoped wipe cannot tell their objects ` ++
338
+ `apart. Rename one store, or qualify the \`@storageRef\` annotation if they were ` ++ `meant to be one shared store.`,
339
+ )
340
+ } else if b.keyPrefix->String.startsWith(a.keyPrefix ++ "/") {
341
+ Some(
342
+ `store "${a.qualified}" (${a.bucketName}/${a.keyPrefix}/) encloses "${b.qualified}" ` ++
343
+ `(${b.keyPrefix}/) — wiping the first would delete the second's objects. Rename one.`,
344
+ )
345
+ } else {
346
+ None
347
+ }
348
+ )
349
+ ) {
350
+ | Some(message) => Error(message)
351
+ | None => Ok()
352
+ }
353
+ }
354
+
355
+ // ── Counting (dry-run) ─────────────────────────────────────────────────────────
356
+
357
+ let countTable = async (table: string): int => {
358
+ let rec loop = async (start: option<dict<JSON.t>>, acc: int): int => {
359
+ let out = await Ddb.DocumentClient.ScanCommand.send(
360
+ Ddb.DocumentClient.ScanCommand.make({
361
+ tableName: table,
362
+ select: #COUNT,
363
+ exclusiveStartKey: ?start,
364
+ }),
365
+ )
366
+ let acc = acc + out.count->Option.getOr(0)
367
+ switch out.lastEvaluatedKey {
368
+ | Some(k) => await loop(Some(k), acc)
369
+ | None => acc
370
+ }
371
+ }
372
+ await loop(None, 0)
373
+ }
374
+
375
+ // `~prefix` narrows the count to one declared store inside a shared bucket;
376
+ // omitted, it counts the whole bucket.
377
+ let countBucket = async (bucket: string, ~prefix: option<string>=?): int => {
378
+ let rec loop = async (keyMarker, versionMarker, acc): int => {
379
+ let out = await S3.ListObjectVersionsCommand.send(
380
+ S3.ListObjectVersionsCommand.make({
381
+ bucket,
382
+ ?prefix,
383
+ ?keyMarker,
384
+ versionIdMarker: ?versionMarker,
385
+ }),
386
+ )
387
+ let n =
388
+ out.versions->Option.getOr([])->Array.length +
389
+ out.deleteMarkers->Option.getOr([])->Array.length
390
+ if out.isTruncated->Option.getOr(false) {
391
+ await loop(out.nextKeyMarker, out.nextVersionIdMarker, acc + n)
392
+ } else {
393
+ acc + n
394
+ }
395
+ }
396
+ await loop(None, None, 0)
397
+ }
398
+
399
+ // ── Wiping ──────────────────────────────────────────────────────────────────
400
+
401
+ // BatchWrite takes ≤ 25 requests and may return some UnprocessedItems under
402
+ // throttling; resend those. Capped so a persistently-failing table surfaces as
403
+ // an error rather than looping forever.
404
+ let rec sendBatch = async (
405
+ table: string,
406
+ requests: array<Ddb.DocumentClient.BatchWriteCommand.writeRequest>,
407
+ ~attempt: int,
408
+ ): unit =>
409
+ if requests->Array.length > 0 {
410
+ if attempt > 8 {
411
+ throw(
412
+ Seed.Failed(
413
+ `table ${table}: ${requests
414
+ ->Array.length
415
+ ->Int.toString} item(s) still unprocessed after 8 retries.`,
416
+ ),
417
+ )
418
+ }
419
+ let out = await Ddb.DocumentClient.BatchWriteCommand.send(
420
+ Ddb.DocumentClient.BatchWriteCommand.make({
421
+ requestItems: Dict.fromArray([(table, requests)]),
422
+ }),
423
+ )
424
+ let unprocessed =
425
+ out.unprocessedItems->Option.flatMap(d => d->Dict.get(table))->Option.getOr([])
426
+ if unprocessed->Array.length > 0 {
427
+ await sendBatch(table, unprocessed, ~attempt=attempt + 1)
428
+ }
429
+ }
430
+
431
+ let truncateTable = async (table: string): unit => {
432
+ let desc = await Ddb.DynamoDb.DescribeTableCommand.send(
433
+ Ddb.DynamoDb.DescribeTableCommand.make({tableName: table}),
434
+ )
435
+ let keyAttrs =
436
+ desc.table->Option.flatMap(t => t.keySchema)->Option.getOr([])->Array.map(k => k.attributeName)
437
+ if keyAttrs->Array.length == 0 {
438
+ throw(Seed.Failed(`could not read a key schema for table ${table}.`))
439
+ }
440
+ // Project only the key attributes (aliased to dodge reserved words) so the
441
+ // scan carries just what a delete needs.
442
+ let names = keyAttrs->Array.mapWithIndex((name, i) => (`#k${i->Int.toString}`, name))
443
+ let projection = names->Array.map(((alias, _)) => alias)->Array.join(", ")
444
+ let rec loop = async (start: option<dict<JSON.t>>): unit => {
445
+ let out = await Ddb.DocumentClient.ScanCommand.send(
446
+ Ddb.DocumentClient.ScanCommand.make({
447
+ tableName: table,
448
+ exclusiveStartKey: ?start,
449
+ projectionExpression: projection,
450
+ expressionAttributeNames: names->Dict.fromArray,
451
+ }),
452
+ )
453
+ let requests =
454
+ out.items
455
+ ->Option.getOr([])
456
+ ->Array.map(item => {
457
+ let key =
458
+ keyAttrs
459
+ ->Array.filterMap(attr => item->field(attr)->Option.map(v => (attr, v)))
460
+ ->Dict.fromArray
461
+ ({deleteRequest: {key: key}}: Ddb.DocumentClient.BatchWriteCommand.writeRequest)
462
+ })
463
+ let batches = chunk(requests, Ddb.DocumentClient.BatchWriteCommand.maxBatchSize)
464
+ for i in 0 to batches->Array.length - 1 {
465
+ switch batches->Array.get(i) {
466
+ | Some(b) => await sendBatch(table, b, ~attempt=1)
467
+ | None => ()
468
+ }
469
+ }
470
+ switch out.lastEvaluatedKey {
471
+ | Some(k) => await loop(Some(k))
472
+ | None => ()
473
+ }
474
+ }
475
+ await loop(None)
476
+ }
477
+
478
+ // One ListObjectVersions page returns ≤ 1000 entries (versions + delete
479
+ // markers), and DeleteObjects takes ≤ 1000, so one list page maps to one delete.
480
+ let emptyBucket = async (bucket: string, ~prefix: option<string>=?): unit => {
481
+ let rec loop = async (keyMarker, versionMarker): unit => {
482
+ let out = await S3.ListObjectVersionsCommand.send(
483
+ S3.ListObjectVersionsCommand.make({
484
+ bucket,
485
+ ?prefix,
486
+ ?keyMarker,
487
+ versionIdMarker: ?versionMarker,
488
+ }),
489
+ )
490
+ let ids =
491
+ Array.concat(out.versions->Option.getOr([]), out.deleteMarkers->Option.getOr([]))->Array.map((
492
+ v
493
+ ): S3.DeleteObjectsCommand.objectIdentifier => {key: v.key, versionId: v.versionId})
494
+ if ids->Array.length > 0 {
495
+ let res = await S3.DeleteObjectsCommand.send(
496
+ S3.DeleteObjectsCommand.make({
497
+ bucket,
498
+ delete: {objects: ids, quiet: true},
499
+ }),
500
+ )
501
+ switch res.errors {
502
+ | Some(errs) if errs->Array.length > 0 =>
503
+ throw(
504
+ Seed.Failed(
505
+ `failed to delete ${errs->Array.length->Int.toString} object(s) from ${bucket}: ${errs
506
+ ->Array.get(0)
507
+ ->Option.flatMap(e => e.message)
508
+ ->Option.getOr("unknown")}`,
509
+ ),
510
+ )
511
+ | _ => ()
512
+ }
513
+ }
514
+ if out.isTruncated->Option.getOr(false) {
515
+ await loop(out.nextKeyMarker, out.nextVersionIdMarker)
516
+ }
517
+ }
518
+ await loop(None, None)
519
+ }
520
+
521
+ // ── Targets & scope ───────────────────────────────────────────────────────────
522
+
523
+ // A deployment is several Pulumi projects sharing a stack name — the platform
524
+ // project plus one per domain plugin — each a separate `reventless:platform`. A
525
+ // target names one such project by the directory its `Pulumi.<stack>.yaml` lives
526
+ // in (relative to the seed cwd), plus a menu label and whether it holds domain
527
+ // data or platform bookkeeping. The caller declares them; the reset never guesses
528
+ // the topology.
529
+ type group =
530
+ | Domain
531
+ | Platform
532
+
533
+ type target = {
534
+ projectDir: string,
535
+ label: string,
536
+ group: group,
537
+ // The name this project's plugin REGISTERS, when it differs from the
538
+ // operator-facing `label` (`Catalog` against `catalog`). It is what the
539
+ // platform's `objectStores` keys are qualified by, so it is how a declared
540
+ // store is attributed to a target. Declared rather than case-folded from the
541
+ // label: the caller states the topology, the reset never guesses it.
542
+ plugin?: string,
543
+ }
544
+
545
+ let pluginOf = (t: target): string => t.plugin->Option.getOr(t.label)
546
+
547
+ // A target resolved to its discovered, counted stores, ready to report and wipe.
548
+ type resolved = {
549
+ target: target,
550
+ platform: string,
551
+ tables: array<string>,
552
+ tableCounts: array<int>,
553
+ bucketCounts: array<(string, int)>,
554
+ storeCounts: array<(objectStore, int)>,
555
+ /** The project's Lambda functions — not wiped, held still while it is. */
556
+ functions: array<string>,
557
+ }
558
+
559
+ // Picks which targets to wipe. `domain` (every domain plugin) leads and is the
560
+ // default; each single domain plugin follows so one plugin's data can be wiped
561
+ // alone; then `platform`; then `everything`. `SEED_RESET_SCOPE` (domain |
562
+ // platform | everything | a plugin label) selects non-interactively.
563
+ let chooseScope = async (~targets: array<target>): array<target> => {
564
+ let domain = targets->Array.filter(t => t.group == Domain)
565
+ let platform = targets->Array.filter(t => t.group == Platform)
566
+ let labelsOf = ts => ts->Array.map(t => t.label)->Array.join(", ")
567
+ switch Seed.Prompt.envValue("SEED_RESET_SCOPE")->Option.map(String.toLowerCase) {
568
+ | Some("domain") => domain
569
+ | Some("platform") => platform
570
+ | Some("all") | Some("everything") | Some("both") => targets
571
+ | Some(other) =>
572
+ switch targets->Array.find(t => t.label->String.toLowerCase == other) {
573
+ | Some(t) => [t]
574
+ | None =>
575
+ throw(
576
+ Seed.Failed(
577
+ `SEED_RESET_SCOPE="${other}" is not a scope — use domain, platform, everything, or a plugin label (${labelsOf(
578
+ domain,
579
+ )}).`,
580
+ ),
581
+ )
582
+ }
583
+ | None =>
584
+ let options = []
585
+ if domain->Array.length > 0 {
586
+ options->Array.push((`domain — ${labelsOf(domain)}`, domain))
587
+
588
+ // Single-plugin entries only when there is more than one, else they just
589
+ // duplicate the `domain` entry.
590
+ if domain->Array.length > 1 {
591
+ domain->Array.forEach(t => options->Array.push((t.label, [t])))
592
+ }
593
+ }
594
+ if platform->Array.length > 0 {
595
+ options->Array.push((`platform — ${labelsOf(platform)}`, platform))
596
+ }
597
+ if domain->Array.length > 0 && platform->Array.length > 0 {
598
+ options->Array.push((`everything — ${labelsOf(targets)}`, targets))
599
+ }
600
+ await Seed.Prompt.select(~title="Reset scope:", ~options)
601
+ }
602
+ }
603
+
604
+ // ── Orchestration ─────────────────────────────────────────────────────────────
605
+
606
+ // Gate 2 per target: its own `Pulumi.<stack>.yaml` must declare wipeable, and
607
+ // must resolve a region. Reasons are distinct (config unreadable vs opt-in
608
+ // missing vs region missing), each saying what to fix. Returns the target's
609
+ // region so the caller can insist every selected target shares one.
610
+ let gateTarget = (~target: target, ~backend, ~stack): string => {
611
+ let cfg = switch readStackConfig(~projectDir=target.projectDir, ~backend, ~stack) {
612
+ | Ok(c) => c
613
+ | Error(message) => throw(Seed.Failed(message))
614
+ }
615
+ switch configValue(cfg, "reventless:wipeable")->Option.map(v =>
616
+ v->String.trim->String.toLowerCase
617
+ ) {
618
+ | Some("true") => ()
619
+ | _ =>
620
+ throw(
621
+ Seed.Failed(
622
+ `${target.label}: stack "${stack}" does not declare \`reventless:wipeable: "true"\` in its Pulumi.${stack}.yaml — refusing. Add that line only on disposable dev stacks (see the alpha example configs).`,
623
+ ),
624
+ )
625
+ }
626
+ switch Seed.Prompt.envValue("AWS_REGION") {
627
+ | Some(r) if r != "" => r
628
+ | _ =>
629
+ switch configValue(cfg, "aws:region") {
630
+ | Some(r) if r != "" => r
631
+ | _ =>
632
+ throw(
633
+ Seed.Failed(
634
+ `${target.label}: could not resolve the AWS region — set AWS_REGION or \`aws:region\` in the stack config.`,
635
+ ),
636
+ )
637
+ }
638
+ }
639
+ }
640
+
641
+ /** Opt out of holding the platform still for the wipe. The hold needs Lambda
642
+ permissions the reset did not previously ask for, so credentials that lack
643
+ them have a way through — at the cost of the emptiness guarantee, which the
644
+ note printed alongside it says plainly. */
645
+ let noQuiesce = (): bool =>
646
+ switch Seed.Prompt.envValue("SEED_RESET_NO_QUIESCE")->Option.map(v =>
647
+ v->String.trim->String.toLowerCase
648
+ ) {
649
+ | Some("1") | Some("true") | Some("yes") => true
650
+ | _ => false
651
+ }
652
+
653
+ /** What to say when a store is not empty after the wipe.
654
+
655
+ The old message reported only a total and advised re-running. That advice was
656
+ wrong in exactly the case that occurs: a runtime holding its state in memory
657
+ rewrites the store on every invocation, so every re-run reproduces the same
658
+ failure and the message sends the operator round the loop again.
659
+
660
+ So name the stores, and separate the two situations that are actually
661
+ different. Under a hold, nothing could start during the wipe, so the only
662
+ possible writer is an invocation that was already in flight — and a re-run
663
+ does clear that. Without a hold there is no such bound, and re-running is the
664
+ wrong advice. */
665
+ let refillMessage = (~refilled: array<(string, int)>, ~heldCount: int): string => {
666
+ let total = refilled->Array.reduce(0, (acc, (_, n)) => acc + n)
667
+ let lines =
668
+ refilled
669
+ ->Array.map(((label, n)) => `\n ${n->Int.toString->String.padStart(8, " ")} ${label}`)
670
+ ->Array.join("")
671
+ let diagnosis = if heldCount > 0 {
672
+ `\n\n The wipe deleted these and something wrote them back. All ${heldCount->Int.toString} runtime(s) ` ++
673
+ `in scope were held at zero concurrency for the whole wipe, so the writer was an invocation already ` ++ `in flight when the hold took effect. Re-running clears it.`
674
+ } else {
675
+ `\n\n The wipe deleted these and a running runtime wrote them back. The stack was NOT held ` ++
676
+ `(SEED_RESET_NO_QUIESCE), so nothing stopped it, and re-running will not help: the writer keeps the ` ++
677
+ `state in memory and restores it on every invocation, identical each time. Re-run without ` ++ `SEED_RESET_NO_QUIESCE so the runtimes are held and recycled.`
678
+ }
679
+ `${total->Int.toString} item(s)/object(s) came back after the wipe:${lines}${diagnosis}`
680
+ }
681
+
682
+ let reportAll = (resolvedList: array<resolved>, ~stack, ~region): int => {
683
+ Seed.Runner.heading(`Reset target: stack "${stack}" in ${region}`)
684
+ let total = ref(0)
685
+ resolvedList->Array.forEach(r => {
686
+ Console.log("")
687
+ Console.log(` ${r.target.label} (${r.platform})`)
688
+ Console.log(" DynamoDB tables:")
689
+ r.tables->Array.forEachWithIndex((t, i) => {
690
+ let c = r.tableCounts->Array.get(i)->Option.getOr(0)
691
+ total := total.contents + c
692
+ Console.log(` ${c->Int.toString->String.padStart(8, " ")} ${t}`)
693
+ })
694
+ if r.tables->Array.length == 0 {
695
+ Console.log(" (none)")
696
+ }
697
+ Console.log(" S3 buckets:")
698
+ r.bucketCounts->Array.forEach(((b, c)) => {
699
+ total := total.contents + c
700
+ Console.log(` ${c->Int.toString->String.padStart(8, " ")} ${b}`)
701
+ })
702
+ if r.bucketCounts->Array.length == 0 {
703
+ Console.log(" (none)")
704
+ }
705
+
706
+ // Declared stores get their own section rather than being folded in with the
707
+ // plain buckets: the unit is a prefix inside a bucket that other plugins also
708
+ // write to, and the operator needs to see which is which before confirming.
709
+ if r.storeCounts->Array.length > 0 {
710
+ Console.log(" Object stores:")
711
+ r.storeCounts->Array.forEach(((s, c)) => {
712
+ total := total.contents + c
713
+ Console.log(
714
+ ` ${c->Int.toString->String.padStart(8, " ")} ${s.qualified} ` ++
715
+ `${s.bucketName}/${s.keyPrefix}/`,
716
+ )
717
+ })
718
+ }
719
+ })
720
+ total.contents
721
+ }
722
+
723
+ /**
724
+ * Reset a deployed stack across one or more of its Pulumi projects. Resolves the
725
+ * shared stack name, refuses unless it is on the name allowlist, lets the
726
+ * operator pick a scope (domain / a single plugin / platform / everything), then
727
+ * for each chosen project: refuses unless it declares itself wipeable, discovers
728
+ * its stores by `platform`+`environment` tag, and reports what it would empty.
729
+ * Only on a matching `REVENTLESS_WIPE_CONFIRM` plus a re-typed stack name does it
730
+ * truncate every table and empty every bucket, then verify empty.
731
+ *
732
+ * `targets` are the deployment's projects (the caller knows the topology);
733
+ * `stack` and `backend` mirror `ReventlessSeedAws.connect`.
734
+ */
735
+ let run = (~stack=?, ~backend=?, ~targets: array<target>, ()): unit => {
736
+ let go = async () => {
737
+ try {
738
+ if targets->Array.length == 0 {
739
+ throw(Seed.Failed("no targets were declared — nothing to reset."))
740
+ }
741
+ let backend = switch Seed.Prompt.envValue("SEED_PULUMI_BACKEND") {
742
+ | Some(url) => Some(url)
743
+ | None => backend
744
+ }
745
+ // Any target's dir lists the shared stack; prefer the platform project.
746
+ let baseTarget =
747
+ targets->Array.find(t => t.group == Platform)->Option.getOr(targets->Array.getUnsafe(0))
748
+ let stack = await ReventlessSeedAws.resolveStack(
749
+ ~projectDir=baseTarget.projectDir,
750
+ ~backend,
751
+ ~stack,
752
+ )
753
+
754
+ if !(nameAllowlist->RegExp.test(stack)) {
755
+ throw(
756
+ Seed.Failed(
757
+ `stack "${stack}" is not on the wipe name-allowlist (alpha, dev, pr-*) — refusing.`,
758
+ ),
759
+ )
760
+ }
761
+
762
+ let selected = await chooseScope(~targets)
763
+
764
+ // Declared object stores live in a bucket the PLATFORM project owns, so
765
+ // resolving them reads the platform's stack output whichever scope was
766
+ // picked. Reading is side-effect free; the gates below decide whether
767
+ // anything may be deleted from it.
768
+ let platformTarget = targets->Array.find(t => t.group == Platform)
769
+ let allStores = switch platformTarget {
770
+ | None =>
771
+ // Not silent: a topology with no platform target is exactly the case
772
+ // where declared stores would be missed, and missing them is the bug
773
+ // this resolution exists to fix.
774
+ Console.log("")
775
+ Console.log(
776
+ "Note: no `platform` target is declared, so declared object stores could not be " ++ "resolved — any uploaded objects will be left in place.",
777
+ )
778
+ []
779
+ | Some(pt) =>
780
+ let output = ReventlessSeedAws.stackOutputs(~projectDir=pt.projectDir, ~backend, stack)
781
+ switch parseObjectStores(output->field("objectStores")) {
782
+ | Ok(stores) => stores
783
+ | Error(message) => throw(Seed.Failed(message))
784
+ }
785
+ }
786
+ switch validateStores(allStores) {
787
+ | Ok() => ()
788
+ | Error(message) => throw(Seed.Failed(`refusing: ${message}`))
789
+ }
790
+
791
+ // Every bucket that holds a declared store, selected or not. These are
792
+ // excluded from the plain per-target bucket lists below so a store is
793
+ // reachable ONLY through the plugin that declared it — otherwise the
794
+ // platform scope would empty every plugin's objects wholesale.
795
+ let storeBucketNames = allStores->Array.map(s => s.bucketName)
796
+ let selectedStores =
797
+ allStores->Array.filter(s => selected->Array.some(t => pluginOf(t) == s.plugin))
798
+
799
+ // Gate every selected target and collect its region; all must agree, since
800
+ // the DynamoDB/S3 clients read one region from the environment. When a
801
+ // store is in scope, the platform's own stack must also declare itself
802
+ // wipeable — the objects belong to a plugin, but the bucket is the
803
+ // platform's, and both consents are needed to delete from it.
804
+ let gated = switch (selectedStores->Array.length > 0, platformTarget) {
805
+ | (true, Some(pt)) if !(selected->Array.some(t => t.projectDir == pt.projectDir)) =>
806
+ Array.concat(selected, [pt])
807
+ | _ => selected
808
+ }
809
+ let regions = gated->Array.map(t => gateTarget(~target=t, ~backend, ~stack))
810
+ let region = regions->Array.getUnsafe(0)
811
+ if regions->Array.some(r => r != region) {
812
+ throw(
813
+ Seed.Failed(
814
+ `selected targets span more than one region (${regions->Array.join(
815
+ ", ",
816
+ )}) — reset them one region at a time.`,
817
+ ),
818
+ )
819
+ }
820
+ NodeProcess.env->Dict.set("AWS_REGION", region)
821
+
822
+ // A store bucket arrives by stack output, not by tag discovery, so the
823
+ // per-resource tag re-check has to be applied to it explicitly: confirm it
824
+ // carries the platform project's own `platform`+`environment` tags before
825
+ // anything is deleted from it. One extra tagging-API call, and only when a
826
+ // store is actually in scope.
827
+ if selectedStores->Array.length > 0 {
828
+ switch platformTarget {
829
+ | Some(pt) =>
830
+ let platformProject = projectName(~projectDir=pt.projectDir)
831
+ let platformFound = await discover(~region, ~stack, ~platform=platformProject)
832
+ selectedStores->Array.forEach(s =>
833
+ if !(platformFound.buckets->Array.includes(s.bucketName)) {
834
+ throw(
835
+ Seed.Failed(
836
+ `refusing: store "${s.qualified}" names bucket ${s.bucketName}, which does not ` ++
837
+ `carry reventless:platform=${platformProject} + reventless:environment=${stack}.`,
838
+ ),
839
+ )
840
+ }
841
+ )
842
+ | None => ()
843
+ }
844
+ }
845
+
846
+ // Discover + count each target, scoped to its own project via the platform
847
+ // tag so a same-named stack from another project is never touched.
848
+ let resolvedList = []
849
+ for i in 0 to selected->Array.length - 1 {
850
+ switch selected->Array.get(i) {
851
+ | Some(target) =>
852
+ let platform = projectName(~projectDir=target.projectDir)
853
+ let found = await discover(~region, ~stack, ~platform)
854
+ let tables = found.tables->Array.toSorted(String.compare)
855
+ let buckets =
856
+ found.buckets
857
+ ->Array.filter(b => !(storeBucketNames->Array.includes(b)))
858
+ ->Array.toSorted(String.compare)
859
+ let tableCounts = []
860
+ for j in 0 to tables->Array.length - 1 {
861
+ switch tables->Array.get(j) {
862
+ | Some(t) => tableCounts->Array.push(await countTable(t))
863
+ | None => ()
864
+ }
865
+ }
866
+ let bucketCounts = []
867
+ for j in 0 to buckets->Array.length - 1 {
868
+ switch buckets->Array.get(j) {
869
+ | Some(b) => bucketCounts->Array.push((b, await countBucket(b)))
870
+ | None => ()
871
+ }
872
+ }
873
+ let stores = selectedStores->Array.filter(s => s.plugin == pluginOf(target))
874
+ let storeCounts = []
875
+ for j in 0 to stores->Array.length - 1 {
876
+ switch stores->Array.get(j) {
877
+ | Some(s) =>
878
+ storeCounts->Array.push((
879
+ s,
880
+ await countBucket(s.bucketName, ~prefix=`${s.keyPrefix}/`),
881
+ ))
882
+ | None => ()
883
+ }
884
+ }
885
+ resolvedList->Array.push({
886
+ target,
887
+ platform,
888
+ tables,
889
+ tableCounts,
890
+ bucketCounts,
891
+ storeCounts,
892
+ functions: found.functions->Array.toSorted(String.compare),
893
+ })
894
+ | None => ()
895
+ }
896
+ }
897
+
898
+ let total = reportAll(resolvedList, ~stack, ~region)
899
+ if total == 0 {
900
+ Seed.Prompt.close()
901
+ Console.log("")
902
+ Console.log(`Nothing to reset — the selected scope already reads empty in ${region}.`)
903
+ NodeProcess.exit(0)
904
+ }
905
+
906
+ // Confirmation. Interactively, re-typing the exact stack name IS the
907
+ // confirmation — the validation above (allowlist + wipeable + tag checks)
908
+ // plus a deliberate keystroke is enough, so no env var is needed. Without a
909
+ // TTY (CI/scripts) there is nothing to type into, so
910
+ // REVENTLESS_WIPE_CONFIRM=<stack> is the equivalent opt-in. Dry-run is the
911
+ // default either way.
912
+ let interactive = NodeProcess.stdin->NodeProcess.isTTY->Option.getOr(false)
913
+ let confirmed = if interactive {
914
+ let typed = await Seed.Prompt.ask(
915
+ `About to permanently empty ${total->Int.toString} item(s)/object(s) across the selected scope of "${stack}". ` ++ `Type the stack name to confirm, or press Enter to keep this a dry run: `,
916
+ )
917
+ typed->String.trim == stack
918
+ } else {
919
+ Seed.Prompt.envValue("REVENTLESS_WIPE_CONFIRM") == Some(stack)
920
+ }
921
+ Seed.Prompt.close()
922
+ if !confirmed {
923
+ Console.log("")
924
+ Console.log(
925
+ interactive
926
+ ? "Dry run — nothing was deleted."
927
+ : `Dry run — nothing was deleted. Set REVENTLESS_WIPE_CONFIRM=${stack} to empty this scope non-interactively.`,
928
+ )
929
+ NodeProcess.exit(0)
930
+ }
931
+
932
+ // Hold the platform still before the first delete. A truncate is not
933
+ // durable while the runtimes that own the data are running — see
934
+ // ReventlessSeedAws_Quiesce for the mechanism and for why emptying the
935
+ // upstream stores first does not substitute for this.
936
+ let functionNames =
937
+ resolvedList->Array.reduce([], (acc, r) =>
938
+ Array.concat(acc, r.functions->Array.filter(f => !(acc->Array.includes(f))))
939
+ )
940
+ let quiesce = !noQuiesce() && functionNames->Array.length > 0
941
+ let lambdaClient = Lambda.client(~region, ())
942
+ let held = if quiesce {
943
+ Seed.Runner.heading(
944
+ `Holding ${functionNames->Array.length->Int.toString} runtime(s) at zero concurrency …`,
945
+ )
946
+ await Quiesce.hold(~client=lambdaClient, ~functionNames)
947
+ } else {
948
+ if functionNames->Array.length > 0 {
949
+ Console.log("")
950
+ Console.log(
951
+ "Note: SEED_RESET_NO_QUIESCE is set — the stack's runtimes keep running through the wipe. " ++ "A runtime that holds state across invocations can write it straight back over an emptied store.",
952
+ )
953
+ }
954
+ []
955
+ }
956
+
957
+ // From here the reset owns the platform's concurrency, so every exit has
958
+ // to run through the release below — including the abort path. ReScript
959
+ // has no `finally`, so the wipe's outcome is captured as a value and
960
+ // re-raised after the stack has been handed back.
961
+ let outcome = try {
962
+ for i in 0 to resolvedList->Array.length - 1 {
963
+ switch resolvedList->Array.get(i) {
964
+ | Some(r) =>
965
+ Seed.Runner.heading(`Emptying ${r.target.label} …`)
966
+ for j in 0 to r.tables->Array.length - 1 {
967
+ switch r.tables->Array.get(j) {
968
+ | Some(t) =>
969
+ await truncateTable(t)
970
+ Console.log(` truncated ${t}`)
971
+ | None => ()
972
+ }
973
+ }
974
+ for j in 0 to r.bucketCounts->Array.length - 1 {
975
+ switch r.bucketCounts->Array.get(j) {
976
+ | Some((b, _)) =>
977
+ await emptyBucket(b)
978
+ Console.log(` emptied ${b}`)
979
+ | None => ()
980
+ }
981
+ }
982
+ // Report the count with the store, not just in the run total: "how
983
+ // many images went" is the question an operator is actually asking.
984
+ for j in 0 to r.storeCounts->Array.length - 1 {
985
+ switch r.storeCounts->Array.get(j) {
986
+ | Some((s, count)) =>
987
+ await emptyBucket(s.bucketName, ~prefix=`${s.keyPrefix}/`)
988
+ Console.log(
989
+ ` emptied ${s.qualified} — ${count->Int.toString} object(s) removed from ` ++
990
+ `${s.bucketName}/${s.keyPrefix}/`,
991
+ )
992
+ | None => ()
993
+ }
994
+ }
995
+ | None => ()
996
+ }
997
+ }
998
+
999
+ // Prove empty — the inverse of the post-seed verify. Every store that
1000
+ // still reads non-zero is named, because "12 items remain" without
1001
+ // saying where is the difference between a diagnosis and a mystery.
1002
+ let refilled = []
1003
+ for i in 0 to resolvedList->Array.length - 1 {
1004
+ switch resolvedList->Array.get(i) {
1005
+ | Some(r) =>
1006
+ for j in 0 to r.tables->Array.length - 1 {
1007
+ switch r.tables->Array.get(j) {
1008
+ | Some(t) =>
1009
+ let left = await countTable(t)
1010
+ if left != 0 {
1011
+ refilled->Array.push((t, left))
1012
+ }
1013
+ | None => ()
1014
+ }
1015
+ }
1016
+ for j in 0 to r.bucketCounts->Array.length - 1 {
1017
+ switch r.bucketCounts->Array.get(j) {
1018
+ | Some((b, _)) =>
1019
+ let left = await countBucket(b)
1020
+ if left != 0 {
1021
+ refilled->Array.push((b, left))
1022
+ }
1023
+ | None => ()
1024
+ }
1025
+ }
1026
+ // Per-store confirmation, so "every uploaded object is gone" is an
1027
+ // observed fact rather than an inference from a global total.
1028
+ for j in 0 to r.storeCounts->Array.length - 1 {
1029
+ switch r.storeCounts->Array.get(j) {
1030
+ | Some((s, _)) =>
1031
+ let left = await countBucket(s.bucketName, ~prefix=`${s.keyPrefix}/`)
1032
+ if left != 0 {
1033
+ refilled->Array.push((`${s.qualified} (${s.bucketName}/${s.keyPrefix}/)`, left))
1034
+ }
1035
+ Console.log(
1036
+ ` verified empty: ${s.qualified} — ${left->Int.toString} object(s) remain under ` ++
1037
+ `${s.bucketName}/${s.keyPrefix}/`,
1038
+ )
1039
+ | None => ()
1040
+ }
1041
+ }
1042
+ | None => ()
1043
+ }
1044
+ }
1045
+ Ok(refilled)
1046
+ } catch {
1047
+ | Seed.Failed(message) => Error(message)
1048
+ | exn =>
1049
+ Error(
1050
+ `the wipe failed: ${exn
1051
+ ->JsExn.fromException
1052
+ ->Option.flatMap(JsExn.message)
1053
+ ->Option.getOr("unknown error")}`,
1054
+ )
1055
+ }
1056
+
1057
+ // Recycle while still held, so the container that eventually serves
1058
+ // traffic is created after the stores were emptied — then hand the stack
1059
+ // back. Both are best-effort and report rather than raise: they run on the
1060
+ // abort path too, where throwing would bury the reason the reset failed.
1061
+ let warnings = if held->Array.length > 0 {
1062
+ Seed.Runner.heading(`Recycling ${held->Array.length->Int.toString} runtime(s) …`)
1063
+ let recycled = await Quiesce.recycle(~client=lambdaClient, ~held)
1064
+ let released = await Quiesce.release(~client=lambdaClient, ~held)
1065
+ Array.concat(recycled, released)
1066
+ } else {
1067
+ []
1068
+ }
1069
+ if warnings->Array.length > 0 {
1070
+ Console.error("")
1071
+ warnings->Array.forEach(w => Console.error(` warning: ${w}`))
1072
+ }
1073
+
1074
+ switch outcome {
1075
+ | Error(message) => throw(Seed.Failed(message))
1076
+ | Ok(refilled) if refilled->Array.length > 0 =>
1077
+ throw(Seed.Failed(refillMessage(~refilled, ~heldCount=held->Array.length)))
1078
+ | Ok(_) => ()
1079
+ }
1080
+
1081
+ Console.log("")
1082
+ Console.log(
1083
+ `Reset complete — the selected scope of "${stack}" reads empty and is re-seedable.`,
1084
+ )
1085
+ NodeProcess.exit(0)
1086
+ } catch {
1087
+ | Seed.Failed(message) =>
1088
+ Seed.Prompt.close()
1089
+ Console.error("")
1090
+ Console.error(`Reset aborted — ${message}`)
1091
+ NodeProcess.exit(1)
1092
+ | exn =>
1093
+ Seed.Prompt.close()
1094
+ Console.error("")
1095
+ Console.error("Reset aborted with an unexpected error:")
1096
+ Console.error(
1097
+ exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("unknown"),
1098
+ )
1099
+ NodeProcess.exit(1)
1100
+ }
1101
+ }
1102
+ go()->ignore
1103
+ }