@reventlessdev/reventless-seed-aws 1.0.0-alpha.6 → 1.0.0-alpha.8

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 (57) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/lib/bs/.compiler.log +2 -2
  3. package/lib/bs/compiler-info.json +1 -1
  4. package/lib/bs/src/ReventlessSeedAws.ast +0 -0
  5. package/lib/bs/src/ReventlessSeedAws.cmi +0 -0
  6. package/lib/bs/src/ReventlessSeedAws.cmj +0 -0
  7. package/lib/bs/src/ReventlessSeedAws.cmt +0 -0
  8. package/lib/bs/src/ReventlessSeedAws.res +3 -47
  9. package/lib/bs/src/ReventlessSeedAws.res.mjs +1 -30
  10. package/lib/bs/src/ReventlessSeedAws_Reset.ast +0 -0
  11. package/lib/bs/src/ReventlessSeedAws_Reset.cmi +0 -0
  12. package/lib/bs/src/ReventlessSeedAws_Reset.cmj +0 -0
  13. package/lib/bs/src/ReventlessSeedAws_Reset.cmt +0 -0
  14. package/lib/bs/src/ReventlessSeedAws_Reset.res +261 -6
  15. package/lib/bs/src/ReventlessSeedAws_Reset.res.mjs +211 -25
  16. package/lib/bs/tests/EndpointResolutionTest.ast +0 -0
  17. package/lib/bs/tests/EndpointResolutionTest.cmi +0 -0
  18. package/lib/bs/tests/EndpointResolutionTest.cmj +0 -0
  19. package/lib/bs/tests/EndpointResolutionTest.cmt +0 -0
  20. package/lib/bs/tests/EndpointResolutionTest.res +12 -109
  21. package/lib/bs/tests/EndpointResolutionTest.res.mjs +4 -97
  22. package/lib/bs/tests/ObjectStoreResetTest.ast +0 -0
  23. package/lib/bs/tests/ObjectStoreResetTest.cmi +0 -0
  24. package/lib/bs/tests/ObjectStoreResetTest.cmj +0 -0
  25. package/lib/bs/tests/ObjectStoreResetTest.cmt +0 -0
  26. package/lib/bs/tests/ObjectStoreResetTest.res +170 -0
  27. package/lib/bs/tests/ObjectStoreResetTest.res.mjs +211 -0
  28. package/lib/ocaml/.compiler.log +2 -2
  29. package/lib/ocaml/EndpointResolutionTest.ast +0 -0
  30. package/lib/ocaml/EndpointResolutionTest.cmi +0 -0
  31. package/lib/ocaml/EndpointResolutionTest.cmj +0 -0
  32. package/lib/ocaml/EndpointResolutionTest.cmt +0 -0
  33. package/lib/ocaml/EndpointResolutionTest.res +12 -109
  34. package/lib/ocaml/ObjectStoreResetTest.ast +0 -0
  35. package/lib/ocaml/ObjectStoreResetTest.cmi +0 -0
  36. package/lib/ocaml/ObjectStoreResetTest.cmj +0 -0
  37. package/lib/ocaml/ObjectStoreResetTest.cmt +0 -0
  38. package/lib/ocaml/ObjectStoreResetTest.res +170 -0
  39. package/lib/ocaml/ReventlessSeedAws.ast +0 -0
  40. package/lib/ocaml/ReventlessSeedAws.cmi +0 -0
  41. package/lib/ocaml/ReventlessSeedAws.cmj +0 -0
  42. package/lib/ocaml/ReventlessSeedAws.cmt +0 -0
  43. package/lib/ocaml/ReventlessSeedAws.res +3 -47
  44. package/lib/ocaml/ReventlessSeedAws_Reset.ast +0 -0
  45. package/lib/ocaml/ReventlessSeedAws_Reset.cmi +0 -0
  46. package/lib/ocaml/ReventlessSeedAws_Reset.cmj +0 -0
  47. package/lib/ocaml/ReventlessSeedAws_Reset.cmt +0 -0
  48. package/lib/ocaml/ReventlessSeedAws_Reset.res +261 -6
  49. package/package.json +2 -2
  50. package/src/ReventlessSeedAws.res +3 -47
  51. package/src/ReventlessSeedAws.res.mjs +1 -30
  52. package/src/ReventlessSeedAws_Reset.res +261 -6
  53. package/src/ReventlessSeedAws_Reset.res.mjs +211 -25
  54. package/tests/EndpointResolutionTest.res +12 -109
  55. package/tests/EndpointResolutionTest.res.mjs +4 -97
  56. package/tests/ObjectStoreResetTest.res +170 -0
  57. package/tests/ObjectStoreResetTest.res.mjs +211 -0
package/CHANGELOG.md CHANGED
@@ -3,6 +3,20 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 1.0.0-alpha.8 (2026-08-01)
7
+
8
+ ### Features
9
+
10
+ * **seed-aws:** wipe a plugin's declared object stores in seed:reset ([2280c5f](https://github.com/ReventlessDev/reventless-core/commit/2280c5f14755d13fff0fe1ac11eb440cd0617f1d))
11
+
12
+
13
+ # 1.0.0-alpha.7 (2026-08-01)
14
+
15
+ ### Features
16
+
17
+ * **upload:** add the release half of the upload contract via the domain API ([448f887](https://github.com/ReventlessDev/reventless-core/commit/448f88714a5875be420f794a08d482fcf4ba8404))
18
+
19
+
6
20
  # 1.0.0-alpha.6 (2026-07-31)
7
21
 
8
22
  **Note:** Version bump only for package @reventlessdev/reventless-seed-aws
@@ -1,2 +1,2 @@
1
- #Start(1785535392961)
2
- #Done(1785535392990)
1
+ #Start(1785624003215)
2
+ #Done(1785624003246)
@@ -4,5 +4,5 @@
4
4
  "bsc_hash": "75e3a59c95cc953608fd3dd6ea6ed68141bba4414a20a52f114261b8a48385fa",
5
5
  "rescript_config_hash": "c18a0067c363fd1290f6e7f6fe6212d2e16e201c1702fac667962047f88ed468",
6
6
  "runtime_path": "/home/runner/work/reventless-core/reventless-core/node_modules/@rescript/runtime",
7
- "generated_at": "1785535392996"
7
+ "generated_at": "1785624003252"
8
8
  }
Binary file
Binary file
Binary file
Binary file
@@ -36,18 +36,6 @@ let asString = (json: JSON.t): option<string> =>
36
36
  | _ => None
37
37
  }
38
38
 
39
- // Non-string members are dropped rather than failing the whole map: one
40
- // malformed entry should not cost a run every endpoint it could have used.
41
- let asStringDict = (json: JSON.t): dict<string> =>
42
- switch json {
43
- | Object(obj) =>
44
- obj
45
- ->Dict.toArray
46
- ->Array.filterMap(((k, v)) => v->asString->Option.map(s => (k, s)))
47
- ->Dict.fromArray
48
- | _ => Dict.make()
49
- }
50
-
51
39
  // ── Stack discovery ───────────────────────────────────────────────────────────
52
40
 
53
41
  // Which Pulumi backend the `pulumi` subprocess reads from. When `backend` is
@@ -158,16 +146,6 @@ let resolveField = (~envKey: string, ~fromSource: option<string>, ~human: string
158
146
  }
159
147
  }
160
148
 
161
- // Same precedence, for a value whose absence is a legitimate answer rather than
162
- // a broken deployment: a stack that declares no store publishes no upload
163
- // endpoint, and that must stay a no-op at the data set instead of failing the
164
- // run before a single command is sent.
165
- let optionalField = (~envKey: string, ~fromSource: option<string>): string =>
166
- switch Seed.Prompt.envValue(envKey) {
167
- | Some(v) => v
168
- | None => fromSource->Option.getOr("")
169
- }
170
-
171
149
  // Which document publishes the deployment's endpoints. A stack that serves a
172
150
  // host shell publishes them in the shell's `config.json` under the client's key
173
151
  // names; one that does not publishes them as stack outputs under Pulumi's. The
@@ -177,11 +155,6 @@ type source = HostShellConfig(JSON.t) | StackOutputs(JSON.t)
177
155
 
178
156
  type endpoints = {
179
157
  graphql: string,
180
- // The legacy single presign service; "" when the deployment publishes none.
181
- uploadEndpoint: string,
182
- // Qualified `{plugin}.{store}` → that store's presign endpoint. Empty when the
183
- // deployment declares no store.
184
- uploadEndpoints: dict<string>,
185
158
  cognitoRegion: string,
186
159
  cognitoClientId: string,
187
160
  }
@@ -190,15 +163,9 @@ type endpoints = {
190
163
  * The endpoints a source document publishes — pure, so the branch selection and
191
164
  * every key it reads can be exercised with synthetic documents.
192
165
  *
193
- * `uploadEndpoints` is read on **both** arms. It was read on neither: the
194
- * host-shell arm looked only at the singular `uploadEndpoint`, and the
195
- * stack-output arm had no upload lookup at all, so a platform that publishes its
196
- * stores and no host shell resolved "" and reported itself as serving no
197
- * uploads.
198
- *
199
- * `REVENTLESS_UPLOAD_ENDPOINT` still overrides the singular endpoint and does
200
- * not touch the map: one string cannot express a map, so it could only ever
201
- * override one store and would need a naming convention to say which.
166
+ * Upload endpoints are no longer resolved here: under route B the seed mints through
167
+ * the domain API's `Upload_Presign` mutation on `graphql`, passing the store per asset,
168
+ * so there is no per-store URL to discover.
202
169
  */
203
170
  let endpointsFrom = (~stack: string, source: source): endpoints =>
204
171
  switch source {
@@ -210,11 +177,6 @@ let endpointsFrom = (~stack: string, source: source): endpoints =>
210
177
  ~fromSource=fromCfg("apiEndpoint"),
211
178
  ~human="apiEndpoint",
212
179
  ),
213
- uploadEndpoint: optionalField(
214
- ~envKey="REVENTLESS_UPLOAD_ENDPOINT",
215
- ~fromSource=fromCfg("uploadEndpoint"),
216
- ),
217
- uploadEndpoints: cfg->field("uploadEndpoints")->Option.mapOr(Dict.make(), asStringDict),
218
180
  cognitoRegion: resolveField(
219
181
  ~envKey="AWS_REGION",
220
182
  ~fromSource=fromCfg("region"),
@@ -242,10 +204,6 @@ let endpointsFrom = (~stack: string, source: source): endpoints =>
242
204
  ),
243
205
  )
244
206
  },
245
- // No stack output carries the legacy single service — it exists only in a
246
- // host shell's config.json — so here the env override is its only source.
247
- uploadEndpoint: optionalField(~envKey="REVENTLESS_UPLOAD_ENDPOINT", ~fromSource=None),
248
- uploadEndpoints: outputs->field("uploadEndpoints")->Option.mapOr(Dict.make(), asStringDict),
249
207
  cognitoRegion: resolveField(
250
208
  ~envKey="AWS_REGION",
251
209
  ~fromSource=out("cognitoRegion"),
@@ -365,8 +323,6 @@ let connect = (~projectDir: string=".", ~stack=?, ~backend=?, ()): (
365
323
  await Seed.Connect.make(
366
324
  ~label=stackName,
367
325
  ~endpoint=eps.graphql,
368
- ~uploadEndpoint=eps.uploadEndpoint,
369
- ~uploadEndpoints=eps.uploadEndpoints,
370
326
  ~login=cognito(~region=eps.cognitoRegion, ~clientId=eps.cognitoClientId),
371
327
  )
372
328
  }
@@ -19,20 +19,6 @@ function asString(json) {
19
19
  }
20
20
  }
21
21
 
22
- function asStringDict(json) {
23
- if (typeof json === "object" && json !== null && !Array.isArray(json)) {
24
- return Object.fromEntries(Stdlib_Array.filterMap(Object.entries(json), param => {
25
- let k = param[0];
26
- return Stdlib_Option.map(asString(param[1]), s => [
27
- k,
28
- s
29
- ]);
30
- }));
31
- } else {
32
- return {};
33
- }
34
- }
35
-
36
22
  function envForBackend(backend) {
37
23
  if (backend === undefined) {
38
24
  return;
@@ -166,23 +152,12 @@ function resolveField(envKey, fromSource, human) {
166
152
  };
167
153
  }
168
154
 
169
- function optionalField(envKey, fromSource) {
170
- let v = Seed_Prompt$ReventlessSeed.envValue(envKey);
171
- if (v !== undefined) {
172
- return v;
173
- } else {
174
- return Stdlib_Option.getOr(fromSource, "");
175
- }
176
- }
177
-
178
155
  function endpointsFrom(stack, source) {
179
156
  if (source.TAG === "HostShellConfig") {
180
157
  let cfg = source._0;
181
158
  let fromCfg = key => Stdlib_Option.flatMap(field(cfg, key), asString);
182
159
  return {
183
160
  graphql: resolveField("REVENTLESS_GRAPHQL_ENDPOINT", fromCfg("apiEndpoint"), "apiEndpoint"),
184
- uploadEndpoint: optionalField("REVENTLESS_UPLOAD_ENDPOINT", fromCfg("uploadEndpoint")),
185
- uploadEndpoints: Stdlib_Option.mapOr(field(cfg, "uploadEndpoints"), {}, asStringDict),
186
161
  cognitoRegion: resolveField("AWS_REGION", fromCfg("region"), "region"),
187
162
  cognitoClientId: resolveField("COGNITO_CLIENT_ID", fromCfg("cognitoClientId"), "cognitoClientId")
188
163
  };
@@ -208,8 +183,6 @@ function endpointsFrom(stack, source) {
208
183
  }
209
184
  return {
210
185
  graphql: tmp,
211
- uploadEndpoint: optionalField("REVENTLESS_UPLOAD_ENDPOINT", undefined),
212
- uploadEndpoints: Stdlib_Option.mapOr(field(outputs, "uploadEndpoints"), {}, asStringDict),
213
186
  cognitoRegion: resolveField("AWS_REGION", out("cognitoRegion"), "cognitoRegion"),
214
187
  cognitoClientId: resolveField("COGNITO_CLIENT_ID", out("cognitoUserPoolClientId"), "cognitoUserPoolClientId")
215
188
  };
@@ -313,14 +286,13 @@ function connect($staropt$star, stack, backend, param) {
313
286
  let backend$1 = url !== undefined ? url : backend;
314
287
  let stackName = await resolveStack(projectDir, backend$1, stack);
315
288
  let eps = await resolveEndpoints(projectDir, backend$1, stackName);
316
- return await Seed_Connect$ReventlessSeed.make(stackName, eps.graphql, eps.uploadEndpoint, eps.uploadEndpoints, cognito(eps.cognitoRegion, eps.cognitoClientId), undefined);
289
+ return await Seed_Connect$ReventlessSeed.make(stackName, eps.graphql, cognito(eps.cognitoRegion, eps.cognitoClientId), undefined);
317
290
  };
318
291
  }
319
292
 
320
293
  export {
321
294
  field,
322
295
  asString,
323
- asStringDict,
324
296
  envForBackend,
325
297
  pulumi,
326
298
  deployedStacks,
@@ -329,7 +301,6 @@ export {
329
301
  resolveStack,
330
302
  fetchConfig,
331
303
  resolveField,
332
- optionalField,
333
304
  endpointsFrom,
334
305
  resolveEndpoints,
335
306
  cognito,
@@ -213,6 +213,118 @@ let discover = async (~region, ~stack, ~platform): (array<string>, array<string>
213
213
  (tables, buckets)
214
214
  }
215
215
 
216
+ // ── Declared object stores ────────────────────────────────────────────────────
217
+ //
218
+ // A store declared by a `@storageRef` field is provisioned by the PLATFORM
219
+ // deploy — the serving CDN and the presign services live there, and a
220
+ // shared-layout bucket holds several plugins' stores, so no plugin stack can own
221
+ // it. Its bucket therefore carries the platform project's `reventless:platform`
222
+ // tag, which makes it invisible to a domain-scoped tag discovery and, worse,
223
+ // wholesale-emptiable by a platform-scoped one.
224
+ //
225
+ // Tag discovery cannot fix that: the tag says which project *built* the bucket,
226
+ // and the answer needed here is which plugin's data is *in* it — at prefix
227
+ // granularity, since one bucket holds several plugins' stores. The platform
228
+ // already publishes exactly that mapping as its `objectStores` stack output, so
229
+ // the reset reads ownership from the declaration rather than inferring it.
230
+
231
+ /** One provisioned store: which plugin owns it, and where its objects live. */
232
+ type objectStore = {
233
+ qualified: string,
234
+ plugin: string,
235
+ store: string,
236
+ bucketName: string,
237
+ keyPrefix: string,
238
+ }
239
+
240
+ // A store's key is `{plugin}.{store}`, split at the FIRST dot: a registered
241
+ // plugin name cannot contain one, a store name could in principle.
242
+ let splitQualified = (key: string): option<(string, string)> =>
243
+ key
244
+ ->String.indexOfOpt(".")
245
+ ->Option.map(i => (
246
+ key->String.slice(~start=0, ~end=i),
247
+ key->String.slice(~start=i + 1, ~end=key->String.length),
248
+ ))
249
+
250
+ /** Parse the platform stack's `objectStores` output. Absent is normal — a
251
+ platform may declare no stores — so only a malformed entry is an error, and
252
+ it is an error rather than a skip because a store the reset cannot read is a
253
+ store it would silently leave behind. */
254
+ let parseObjectStores = (json: option<JSON.t>): result<array<objectStore>, string> =>
255
+ switch json {
256
+ | None => Ok([])
257
+ | Some(Object(entries)) =>
258
+ entries
259
+ ->Dict.toArray
260
+ ->Array.reduce(Ok([]), (acc, (qualified, entry)) =>
261
+ switch acc {
262
+ | Error(_) as failed => failed
263
+ | Ok(stores) =>
264
+ switch (
265
+ splitQualified(qualified),
266
+ entry->field("bucketName")->Option.flatMap(asString),
267
+ entry->field("keyPrefix")->Option.flatMap(asString),
268
+ ) {
269
+ | (Some((plugin, store)), Some(bucketName), Some(keyPrefix)) =>
270
+ Ok(Array.concat(stores, [{qualified, plugin, store, bucketName, keyPrefix}]))
271
+ | _ =>
272
+ Error(
273
+ `the platform stack's \`objectStores\` output has a malformed entry for "${qualified}" — ` ++
274
+ `expected a {plugin}.{store} key carrying bucketName and keyPrefix.`,
275
+ )
276
+ }
277
+ }
278
+ )
279
+ | Some(_) => Error("the platform stack's `objectStores` output is not an object.")
280
+ }
281
+
282
+ /** Refuse any store set a prefix-scoped wipe cannot separate.
283
+
284
+ Equality is the cross-plugin collision: two plugins declaring one store name
285
+ land on one prefix inside a shared bucket, and nothing distinguishes their
286
+ objects. Containment is the same problem one level up — the delete prefix is
287
+ `{keyPrefix}/`, so a store rooted at `a` encloses one rooted at `a/b`.
288
+ Comparing containment rather than equality is what keeps this correct once a
289
+ prefix carries path structure.
290
+
291
+ Fail-closed, and before anything is counted: this is the tool that destroys
292
+ data, so it refuses rather than skipping, and it does not assume an upstream
293
+ deploy-time check ran. */
294
+ let validateStores = (stores: array<objectStore>): result<unit, string> =>
295
+ switch stores->Array.find(s => s.keyPrefix == "" || s.store->String.includes("/")) {
296
+ | Some(s) =>
297
+ Error(
298
+ `store "${s.qualified}" has an unusable key prefix ("${s.keyPrefix}") — ` ++
299
+ `a store name may not be empty or contain "/".`,
300
+ )
301
+ | None =>
302
+ switch stores->Array.findMap(a =>
303
+ stores->Array.findMap(b =>
304
+ if a.qualified == b.qualified || a.bucketName != b.bucketName {
305
+ None
306
+ } else if a.keyPrefix == b.keyPrefix {
307
+ Some(
308
+ `stores "${a.qualified}" and "${b.qualified}" both live at ` ++
309
+ `${a.bucketName}/${a.keyPrefix}/ — a prefix-scoped wipe cannot tell their objects ` ++
310
+ `apart. Rename one store, or qualify the \`@storageRef\` annotation if they were ` ++
311
+ `meant to be one shared store.`,
312
+ )
313
+ } else if b.keyPrefix->String.startsWith(a.keyPrefix ++ "/") {
314
+ Some(
315
+ `store "${a.qualified}" (${a.bucketName}/${a.keyPrefix}/) encloses "${b.qualified}" ` ++
316
+ `(${b.keyPrefix}/) — wiping the first would delete the second's objects. Rename one.`,
317
+ )
318
+ } else {
319
+ None
320
+ }
321
+ )
322
+ ) {
323
+ | Some(message) => Error(message)
324
+ | None => Ok()
325
+ }
326
+ }
327
+
216
328
  // ── Counting (dry-run) ─────────────────────────────────────────────────────────
217
329
 
218
330
  let countTable = async (table: string): int => {
@@ -233,11 +345,14 @@ let countTable = async (table: string): int => {
233
345
  await loop(None, 0)
234
346
  }
235
347
 
236
- let countBucket = async (bucket: string): int => {
348
+ // `~prefix` narrows the count to one declared store inside a shared bucket;
349
+ // omitted, it counts the whole bucket.
350
+ let countBucket = async (bucket: string, ~prefix: option<string>=?): int => {
237
351
  let rec loop = async (keyMarker, versionMarker, acc): int => {
238
352
  let out = await S3.ListObjectVersionsCommand.send(
239
353
  S3.ListObjectVersionsCommand.make({
240
354
  bucket,
355
+ prefix: ?prefix,
241
356
  keyMarker: ?keyMarker,
242
357
  versionIdMarker: ?versionMarker,
243
358
  }),
@@ -327,11 +442,12 @@ let truncateTable = async (table: string): unit => {
327
442
 
328
443
  // One ListObjectVersions page returns ≤ 1000 entries (versions + delete
329
444
  // markers), and DeleteObjects takes ≤ 1000, so one list page maps to one delete.
330
- let emptyBucket = async (bucket: string): unit => {
445
+ let emptyBucket = async (bucket: string, ~prefix: option<string>=?): unit => {
331
446
  let rec loop = async (keyMarker, versionMarker): unit => {
332
447
  let out = await S3.ListObjectVersionsCommand.send(
333
448
  S3.ListObjectVersionsCommand.make({
334
449
  bucket,
450
+ prefix: ?prefix,
335
451
  keyMarker: ?keyMarker,
336
452
  versionIdMarker: ?versionMarker,
337
453
  }),
@@ -383,8 +499,16 @@ type target = {
383
499
  projectDir: string,
384
500
  label: string,
385
501
  group: group,
502
+ // The name this project's plugin REGISTERS, when it differs from the
503
+ // operator-facing `label` (`Catalog` against `catalog`). It is what the
504
+ // platform's `objectStores` keys are qualified by, so it is how a declared
505
+ // store is attributed to a target. Declared rather than case-folded from the
506
+ // label: the caller states the topology, the reset never guesses it.
507
+ plugin?: string,
386
508
  }
387
509
 
510
+ let pluginOf = (t: target): string => t.plugin->Option.getOr(t.label)
511
+
388
512
  // A target resolved to its discovered, counted stores, ready to report and wipe.
389
513
  type resolved = {
390
514
  target: target,
@@ -392,6 +516,7 @@ type resolved = {
392
516
  tables: array<string>,
393
517
  tableCounts: array<int>,
394
518
  bucketCounts: array<(string, int)>,
519
+ storeCounts: array<(objectStore, int)>,
395
520
  }
396
521
 
397
522
  // Picks which targets to wipe. `domain` (every domain plugin) leads and is the
@@ -496,6 +621,19 @@ let reportAll = (resolvedList: array<resolved>, ~stack, ~region): int => {
496
621
  if r.bucketCounts->Array.length == 0 {
497
622
  Console.log(" (none)")
498
623
  }
624
+ // Declared stores get their own section rather than being folded in with the
625
+ // plain buckets: the unit is a prefix inside a bucket that other plugins also
626
+ // write to, and the operator needs to see which is which before confirming.
627
+ if r.storeCounts->Array.length > 0 {
628
+ Console.log(" Object stores:")
629
+ r.storeCounts->Array.forEach(((s, c)) => {
630
+ total := total.contents + c
631
+ Console.log(
632
+ ` ${c->Int.toString->String.padStart(8, " ")} ${s.qualified} ` ++
633
+ `${s.bucketName}/${s.keyPrefix}/`,
634
+ )
635
+ })
636
+ }
499
637
  })
500
638
  total.contents
501
639
  }
@@ -541,9 +679,53 @@ let run = (~stack=?, ~backend=?, ~targets: array<target>, ()): unit => {
541
679
 
542
680
  let selected = await chooseScope(~targets)
543
681
 
682
+ // Declared object stores live in a bucket the PLATFORM project owns, so
683
+ // resolving them reads the platform's stack output whichever scope was
684
+ // picked. Reading is side-effect free; the gates below decide whether
685
+ // anything may be deleted from it.
686
+ let platformTarget = targets->Array.find(t => t.group == Platform)
687
+ let allStores = switch platformTarget {
688
+ | None =>
689
+ // Not silent: a topology with no platform target is exactly the case
690
+ // where declared stores would be missed, and missing them is the bug
691
+ // this resolution exists to fix.
692
+ Console.log("")
693
+ Console.log(
694
+ "Note: no `platform` target is declared, so declared object stores could not be " ++
695
+ "resolved — any uploaded objects will be left in place.",
696
+ )
697
+ []
698
+ | Some(pt) =>
699
+ let output = ReventlessSeedAws.stackOutputs(~projectDir=pt.projectDir, ~backend, stack)
700
+ switch parseObjectStores(output->field("objectStores")) {
701
+ | Ok(stores) => stores
702
+ | Error(message) => throw(Seed.Failed(message))
703
+ }
704
+ }
705
+ switch validateStores(allStores) {
706
+ | Ok() => ()
707
+ | Error(message) => throw(Seed.Failed(`refusing: ${message}`))
708
+ }
709
+
710
+ // Every bucket that holds a declared store, selected or not. These are
711
+ // excluded from the plain per-target bucket lists below so a store is
712
+ // reachable ONLY through the plugin that declared it — otherwise the
713
+ // platform scope would empty every plugin's objects wholesale.
714
+ let storeBucketNames = allStores->Array.map(s => s.bucketName)
715
+ let selectedStores =
716
+ allStores->Array.filter(s => selected->Array.some(t => pluginOf(t) == s.plugin))
717
+
544
718
  // Gate every selected target and collect its region; all must agree, since
545
- // the DynamoDB/S3 clients read one region from the environment.
546
- let regions = selected->Array.map(t => gateTarget(~target=t, ~backend, ~stack))
719
+ // the DynamoDB/S3 clients read one region from the environment. When a
720
+ // store is in scope, the platform's own stack must also declare itself
721
+ // wipeable — the objects belong to a plugin, but the bucket is the
722
+ // platform's, and both consents are needed to delete from it.
723
+ let gated = switch (selectedStores->Array.length > 0, platformTarget) {
724
+ | (true, Some(pt)) if !(selected->Array.some(t => t.projectDir == pt.projectDir)) =>
725
+ Array.concat(selected, [pt])
726
+ | _ => selected
727
+ }
728
+ let regions = gated->Array.map(t => gateTarget(~target=t, ~backend, ~stack))
547
729
  let region = regions->Array.getUnsafe(0)
548
730
  if regions->Array.some(r => r != region) {
549
731
  throw(
@@ -556,6 +738,30 @@ let run = (~stack=?, ~backend=?, ~targets: array<target>, ()): unit => {
556
738
  }
557
739
  NodeProcess.env->Dict.set("AWS_REGION", region)
558
740
 
741
+ // A store bucket arrives by stack output, not by tag discovery, so the
742
+ // per-resource tag re-check has to be applied to it explicitly: confirm it
743
+ // carries the platform project's own `platform`+`environment` tags before
744
+ // anything is deleted from it. One extra tagging-API call, and only when a
745
+ // store is actually in scope.
746
+ if selectedStores->Array.length > 0 {
747
+ switch platformTarget {
748
+ | Some(pt) =>
749
+ let platformProject = projectName(~projectDir=pt.projectDir)
750
+ let (_, platformBuckets) = await discover(~region, ~stack, ~platform=platformProject)
751
+ selectedStores->Array.forEach(s =>
752
+ if !(platformBuckets->Array.includes(s.bucketName)) {
753
+ throw(
754
+ Seed.Failed(
755
+ `refusing: store "${s.qualified}" names bucket ${s.bucketName}, which does not ` ++
756
+ `carry reventless:platform=${platformProject} + reventless:environment=${stack}.`,
757
+ ),
758
+ )
759
+ }
760
+ )
761
+ | None => ()
762
+ }
763
+ }
764
+
559
765
  // Discover + count each target, scoped to its own project via the platform
560
766
  // tag so a same-named stack from another project is never touched.
561
767
  let resolvedList = []
@@ -565,7 +771,10 @@ let run = (~stack=?, ~backend=?, ~targets: array<target>, ()): unit => {
565
771
  let platform = projectName(~projectDir=target.projectDir)
566
772
  let (tables, buckets) = await discover(~region, ~stack, ~platform)
567
773
  let tables = tables->Array.toSorted(String.compare)
568
- let buckets = buckets->Array.toSorted(String.compare)
774
+ let buckets =
775
+ buckets
776
+ ->Array.filter(b => !(storeBucketNames->Array.includes(b)))
777
+ ->Array.toSorted(String.compare)
569
778
  let tableCounts = []
570
779
  for j in 0 to tables->Array.length - 1 {
571
780
  switch tables->Array.get(j) {
@@ -580,7 +789,26 @@ let run = (~stack=?, ~backend=?, ~targets: array<target>, ()): unit => {
580
789
  | None => ()
581
790
  }
582
791
  }
583
- resolvedList->Array.push({target, platform, tables, tableCounts, bucketCounts})
792
+ let stores = selectedStores->Array.filter(s => s.plugin == pluginOf(target))
793
+ let storeCounts = []
794
+ for j in 0 to stores->Array.length - 1 {
795
+ switch stores->Array.get(j) {
796
+ | Some(s) =>
797
+ storeCounts->Array.push((
798
+ s,
799
+ await countBucket(s.bucketName, ~prefix=`${s.keyPrefix}/`),
800
+ ))
801
+ | None => ()
802
+ }
803
+ }
804
+ resolvedList->Array.push({
805
+ target,
806
+ platform,
807
+ tables,
808
+ tableCounts,
809
+ bucketCounts,
810
+ storeCounts,
811
+ })
584
812
  | None => ()
585
813
  }
586
814
  }
@@ -640,6 +868,19 @@ let run = (~stack=?, ~backend=?, ~targets: array<target>, ()): unit => {
640
868
  | None => ()
641
869
  }
642
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 => ()
882
+ }
883
+ }
643
884
  | None => ()
644
885
  }
645
886
  }
@@ -661,6 +902,20 @@ let run = (~stack=?, ~backend=?, ~targets: array<target>, ()): unit => {
661
902
  | None => ()
662
903
  }
663
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 => ()
917
+ }
918
+ }
664
919
  | None => ()
665
920
  }
666
921
  }