@reventlessdev/reventless-seed-aws 1.0.0-alpha.7 → 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 (31) 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_Reset.ast +0 -0
  5. package/lib/bs/src/ReventlessSeedAws_Reset.cmi +0 -0
  6. package/lib/bs/src/ReventlessSeedAws_Reset.cmj +0 -0
  7. package/lib/bs/src/ReventlessSeedAws_Reset.cmt +0 -0
  8. package/lib/bs/src/ReventlessSeedAws_Reset.res +261 -6
  9. package/lib/bs/src/ReventlessSeedAws_Reset.res.mjs +211 -25
  10. package/lib/bs/tests/ObjectStoreResetTest.ast +0 -0
  11. package/lib/bs/tests/ObjectStoreResetTest.cmi +0 -0
  12. package/lib/bs/tests/ObjectStoreResetTest.cmj +0 -0
  13. package/lib/bs/tests/ObjectStoreResetTest.cmt +0 -0
  14. package/lib/bs/tests/ObjectStoreResetTest.res +170 -0
  15. package/lib/bs/tests/ObjectStoreResetTest.res.mjs +211 -0
  16. package/lib/ocaml/.compiler.log +2 -2
  17. package/lib/ocaml/ObjectStoreResetTest.ast +0 -0
  18. package/lib/ocaml/ObjectStoreResetTest.cmi +0 -0
  19. package/lib/ocaml/ObjectStoreResetTest.cmj +0 -0
  20. package/lib/ocaml/ObjectStoreResetTest.cmt +0 -0
  21. package/lib/ocaml/ObjectStoreResetTest.res +170 -0
  22. package/lib/ocaml/ReventlessSeedAws_Reset.ast +0 -0
  23. package/lib/ocaml/ReventlessSeedAws_Reset.cmi +0 -0
  24. package/lib/ocaml/ReventlessSeedAws_Reset.cmj +0 -0
  25. package/lib/ocaml/ReventlessSeedAws_Reset.cmt +0 -0
  26. package/lib/ocaml/ReventlessSeedAws_Reset.res +261 -6
  27. package/package.json +1 -1
  28. package/src/ReventlessSeedAws_Reset.res +261 -6
  29. package/src/ReventlessSeedAws_Reset.res.mjs +211 -25
  30. package/tests/ObjectStoreResetTest.res +170 -0
  31. package/tests/ObjectStoreResetTest.res.mjs +211 -0
@@ -5,6 +5,7 @@ import * as S3$AwsSdk from "@reventlessdev/rescript-aws-sdk/src/S3.res.mjs";
5
5
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
6
6
  import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
7
7
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
8
+ import * as Stdlib_String from "@rescript/runtime/lib/es6/Stdlib_String.js";
8
9
  import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
9
10
  import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
10
11
  import * as ReventlessSeedAws from "./ReventlessSeedAws.res.mjs";
@@ -198,6 +199,97 @@ async function discover(region, stack, platform) {
198
199
  ];
199
200
  }
200
201
 
202
+ function splitQualified(key) {
203
+ return Stdlib_Option.map(Stdlib_String.indexOfOpt(key, "."), i => [
204
+ key.slice(0, i),
205
+ key.slice(i + 1 | 0, key.length)
206
+ ]);
207
+ }
208
+
209
+ function parseObjectStores(json) {
210
+ if (json !== undefined) {
211
+ if (typeof json === "object" && json !== null && !Array.isArray(json)) {
212
+ return Stdlib_Array.reduce(Object.entries(json), {
213
+ TAG: "Ok",
214
+ _0: []
215
+ }, (acc, param) => {
216
+ let entry = param[1];
217
+ let qualified = param[0];
218
+ if (acc.TAG !== "Ok") {
219
+ return acc;
220
+ }
221
+ let match = splitQualified(qualified);
222
+ let match$1 = Stdlib_Option.flatMap(field(entry, "bucketName"), asString);
223
+ let match$2 = Stdlib_Option.flatMap(field(entry, "keyPrefix"), asString);
224
+ if (match !== undefined && match$1 !== undefined && match$2 !== undefined) {
225
+ return {
226
+ TAG: "Ok",
227
+ _0: acc._0.concat([{
228
+ qualified: qualified,
229
+ plugin: match[0],
230
+ store: match[1],
231
+ bucketName: match$1,
232
+ keyPrefix: match$2
233
+ }])
234
+ };
235
+ }
236
+ return {
237
+ TAG: "Error",
238
+ _0: `the platform stack's \`objectStores\` output has a malformed entry for "` + qualified + `" — expected a {plugin}.{store} key carrying bucketName and keyPrefix.`
239
+ };
240
+ });
241
+ } else {
242
+ return {
243
+ TAG: "Error",
244
+ _0: "the platform stack's `objectStores` output is not an object."
245
+ };
246
+ }
247
+ } else {
248
+ return {
249
+ TAG: "Ok",
250
+ _0: []
251
+ };
252
+ }
253
+ }
254
+
255
+ function validateStores(stores) {
256
+ let s = stores.find(s => {
257
+ if (s.keyPrefix === "") {
258
+ return true;
259
+ } else {
260
+ return s.store.includes("/");
261
+ }
262
+ });
263
+ if (s !== undefined) {
264
+ return {
265
+ TAG: "Error",
266
+ _0: `store "` + s.qualified + `" has an unusable key prefix ("` + s.keyPrefix + `") — a store name may not be empty or contain "/".`
267
+ };
268
+ }
269
+ let message = Stdlib_Array.findMap(stores, a => Stdlib_Array.findMap(stores, b => {
270
+ if (a.qualified === b.qualified || a.bucketName !== b.bucketName) {
271
+ return;
272
+ } else if (a.keyPrefix === b.keyPrefix) {
273
+ return `stores "` + a.qualified + `" and "` + b.qualified + `" both live at ` + (a.bucketName + `/` + a.keyPrefix + `/ — a prefix-scoped wipe cannot tell their objects `) + `apart. Rename one store, or qualify the \`@storageRef\` annotation if they were meant to be one shared store.`;
274
+ } else if (b.keyPrefix.startsWith(a.keyPrefix + "/")) {
275
+ return `store "` + a.qualified + `" (` + a.bucketName + `/` + a.keyPrefix + `/) encloses "` + b.qualified + `" ` + (`(` + b.keyPrefix + `/) — wiping the first would delete the second's objects. Rename one.`);
276
+ } else {
277
+ return;
278
+ }
279
+ }));
280
+ if (message !== undefined) {
281
+ return {
282
+ TAG: "Error",
283
+ _0: message
284
+ };
285
+ } else {
286
+ return {
287
+ TAG: "Ok",
288
+ _0: undefined
289
+ };
290
+ }
291
+ }
292
+
201
293
  async function countTable(table) {
202
294
  let loop = async (start, acc) => {
203
295
  let out = await DynamoDb_DocumentClient$AwsSdk.ScanCommand.send(new LibDynamodb.ScanCommand({
@@ -216,10 +308,11 @@ async function countTable(table) {
216
308
  return await loop(undefined, 0);
217
309
  }
218
310
 
219
- async function countBucket(bucket) {
311
+ async function countBucket(bucket, prefix) {
220
312
  let loop = async (keyMarker, versionMarker, acc) => {
221
313
  let out = await S3$AwsSdk.ListObjectVersionsCommand.send(new ClientS3.ListObjectVersionsCommand({
222
314
  Bucket: bucket,
315
+ Prefix: prefix,
223
316
  KeyMarker: keyMarker,
224
317
  VersionIdMarker: versionMarker
225
318
  }));
@@ -306,10 +399,11 @@ async function truncateTable(table) {
306
399
  return await loop(undefined);
307
400
  }
308
401
 
309
- async function emptyBucket(bucket) {
402
+ async function emptyBucket(bucket, prefix) {
310
403
  let loop = async (keyMarker, versionMarker) => {
311
404
  let out = await S3$AwsSdk.ListObjectVersionsCommand.send(new ClientS3.ListObjectVersionsCommand({
312
405
  Bucket: bucket,
406
+ Prefix: prefix,
313
407
  KeyMarker: keyMarker,
314
408
  VersionIdMarker: versionMarker
315
409
  }));
@@ -341,6 +435,10 @@ async function emptyBucket(bucket) {
341
435
  return await loop(undefined, undefined);
342
436
  }
343
437
 
438
+ function pluginOf(t) {
439
+ return Stdlib_Option.getOr(t.plugin, t.label);
440
+ }
441
+
344
442
  async function chooseScope(targets) {
345
443
  let domain = targets.filter(t => t.group === "Domain");
346
444
  let platform = targets.filter(t => t.group === "Platform");
@@ -479,6 +577,15 @@ function reportAll(resolvedList, stack, region) {
479
577
  });
480
578
  if (r.bucketCounts.length === 0) {
481
579
  console.log(" (none)");
580
+ }
581
+ if (r.storeCounts.length !== 0) {
582
+ console.log(" Object stores:");
583
+ r.storeCounts.forEach(param => {
584
+ let c = param[1];
585
+ let s = param[0];
586
+ total.contents = total.contents + c | 0;
587
+ console.log(` ` + c.toString().padStart(8, " ") + ` ` + s.qualified + ` ` + (s.bucketName + `/` + s.keyPrefix + `/`));
588
+ });
482
589
  return;
483
590
  }
484
591
  });
@@ -507,7 +614,38 @@ function run(stack, backend, targets, param) {
507
614
  };
508
615
  }
509
616
  let selected = await chooseScope(targets);
510
- let regions = selected.map(t => gateTarget(t, backend$1, stack$1));
617
+ let platformTarget = targets.find(t => t.group === "Platform");
618
+ let allStores;
619
+ if (platformTarget !== undefined) {
620
+ let output = ReventlessSeedAws.stackOutputs(platformTarget.projectDir, backend$1, stack$1);
621
+ let stores = parseObjectStores(field(output, "objectStores"));
622
+ if (stores.TAG === "Ok") {
623
+ allStores = stores._0;
624
+ } else {
625
+ throw {
626
+ RE_EXN_ID: Seed$ReventlessSeed.Failed,
627
+ _1: stores._0,
628
+ Error: new Error()
629
+ };
630
+ }
631
+ } else {
632
+ console.log("");
633
+ console.log("Note: no `platform` target is declared, so declared object stores could not be resolved — any uploaded objects will be left in place.");
634
+ allStores = [];
635
+ }
636
+ let message = validateStores(allStores);
637
+ if (message.TAG !== "Ok") {
638
+ throw {
639
+ RE_EXN_ID: Seed$ReventlessSeed.Failed,
640
+ _1: `refusing: ` + message._0,
641
+ Error: new Error()
642
+ };
643
+ }
644
+ let storeBucketNames = allStores.map(s => s.bucketName);
645
+ let selectedStores = allStores.filter(s => selected.some(t => pluginOf(t) === s.plugin));
646
+ let match = selectedStores.length !== 0;
647
+ let gated = match && platformTarget !== undefined && !selected.some(t => t.projectDir === platformTarget.projectDir) ? selected.concat([platformTarget]) : selected;
648
+ let regions = gated.map(t => gateTarget(t, backend$1, stack$1));
511
649
  let region = regions[0];
512
650
  if (regions.some(r => r !== region)) {
513
651
  throw {
@@ -517,14 +655,29 @@ function run(stack, backend, targets, param) {
517
655
  };
518
656
  }
519
657
  process.env["AWS_REGION"] = region;
658
+ if (selectedStores.length !== 0 && platformTarget !== undefined) {
659
+ let platformProject = projectName(platformTarget.projectDir);
660
+ let match$1 = await discover(region, stack$1, platformProject);
661
+ let platformBuckets = match$1[1];
662
+ selectedStores.forEach(s => {
663
+ if (platformBuckets.includes(s.bucketName)) {
664
+ return;
665
+ }
666
+ throw {
667
+ RE_EXN_ID: Seed$ReventlessSeed.Failed,
668
+ _1: `refusing: store "` + s.qualified + `" names bucket ` + s.bucketName + `, which does not ` + (`carry reventless:platform=` + platformProject + ` + reventless:environment=` + stack$1 + `.`),
669
+ Error: new Error()
670
+ };
671
+ });
672
+ }
520
673
  let resolvedList = [];
521
674
  for (let i = 0, i_finish = selected.length; i < i_finish; ++i) {
522
675
  let target = selected[i];
523
676
  if (target !== undefined) {
524
677
  let platform = projectName(target.projectDir);
525
- let match = await discover(region, stack$1, platform);
526
- let tables = match[0].toSorted(Primitive_string.compare);
527
- let buckets = match[1].toSorted(Primitive_string.compare);
678
+ let match$2 = await discover(region, stack$1, platform);
679
+ let tables = match$2[0].toSorted(Primitive_string.compare);
680
+ let buckets = match$2[1].filter(b => !storeBucketNames.includes(b)).toSorted(Primitive_string.compare);
528
681
  let tableCounts = [];
529
682
  for (let j = 0, j_finish = tables.length; j < j_finish; ++j) {
530
683
  let t = tables[j];
@@ -538,7 +691,18 @@ function run(stack, backend, targets, param) {
538
691
  if (b !== undefined) {
539
692
  bucketCounts.push([
540
693
  b,
541
- await countBucket(b)
694
+ await countBucket(b, undefined)
695
+ ]);
696
+ }
697
+ }
698
+ let stores$1 = selectedStores.filter(s => s.plugin === pluginOf(target));
699
+ let storeCounts = [];
700
+ for (let j$2 = 0, j_finish$2 = stores$1.length; j$2 < j_finish$2; ++j$2) {
701
+ let s = stores$1[j$2];
702
+ if (s !== undefined) {
703
+ storeCounts.push([
704
+ s,
705
+ await countBucket(s.bucketName, s.keyPrefix + `/`)
542
706
  ]);
543
707
  }
544
708
  }
@@ -547,7 +711,8 @@ function run(stack, backend, targets, param) {
547
711
  platform: platform,
548
712
  tables: tables,
549
713
  tableCounts: tableCounts,
550
- bucketCounts: bucketCounts
714
+ bucketCounts: bucketCounts,
715
+ storeCounts: storeCounts
551
716
  });
552
717
  }
553
718
  }
@@ -576,37 +741,54 @@ function run(stack, backend, targets, param) {
576
741
  let r = resolvedList[i$1];
577
742
  if (r !== undefined) {
578
743
  Seed_Runner$ReventlessSeed.heading(`Emptying ` + r.target.label + ` …`);
579
- for (let j$2 = 0, j_finish$2 = r.tables.length; j$2 < j_finish$2; ++j$2) {
580
- let t$1 = r.tables[j$2];
744
+ for (let j$3 = 0, j_finish$3 = r.tables.length; j$3 < j_finish$3; ++j$3) {
745
+ let t$1 = r.tables[j$3];
581
746
  if (t$1 !== undefined) {
582
747
  await truncateTable(t$1);
583
748
  console.log(` truncated ` + t$1);
584
749
  }
585
750
  }
586
- for (let j$3 = 0, j_finish$3 = r.bucketCounts.length; j$3 < j_finish$3; ++j$3) {
587
- let match$1 = r.bucketCounts[j$3];
588
- if (match$1 !== undefined) {
589
- let b$1 = match$1[0];
590
- await emptyBucket(b$1);
751
+ for (let j$4 = 0, j_finish$4 = r.bucketCounts.length; j$4 < j_finish$4; ++j$4) {
752
+ let match$3 = r.bucketCounts[j$4];
753
+ if (match$3 !== undefined) {
754
+ let b$1 = match$3[0];
755
+ await emptyBucket(b$1, undefined);
591
756
  console.log(` emptied ` + b$1);
592
757
  }
593
758
  }
759
+ for (let j$5 = 0, j_finish$5 = r.storeCounts.length; j$5 < j_finish$5; ++j$5) {
760
+ let match$4 = r.storeCounts[j$5];
761
+ if (match$4 !== undefined) {
762
+ let s$1 = match$4[0];
763
+ await emptyBucket(s$1.bucketName, s$1.keyPrefix + `/`);
764
+ console.log(` emptied ` + s$1.qualified + ` — ` + match$4[1].toString() + ` object(s) removed from ` + (s$1.bucketName + `/` + s$1.keyPrefix + `/`));
765
+ }
766
+ }
594
767
  }
595
768
  }
596
769
  let remaining = 0;
597
770
  for (let i$2 = 0, i_finish$2 = resolvedList.length; i$2 < i_finish$2; ++i$2) {
598
771
  let r$1 = resolvedList[i$2];
599
772
  if (r$1 !== undefined) {
600
- for (let j$4 = 0, j_finish$4 = r$1.tables.length; j$4 < j_finish$4; ++j$4) {
601
- let t$2 = r$1.tables[j$4];
773
+ for (let j$6 = 0, j_finish$6 = r$1.tables.length; j$6 < j_finish$6; ++j$6) {
774
+ let t$2 = r$1.tables[j$6];
602
775
  if (t$2 !== undefined) {
603
776
  remaining = remaining + await countTable(t$2) | 0;
604
777
  }
605
778
  }
606
- for (let j$5 = 0, j_finish$5 = r$1.bucketCounts.length; j$5 < j_finish$5; ++j$5) {
607
- let match$2 = r$1.bucketCounts[j$5];
608
- if (match$2 !== undefined) {
609
- remaining = remaining + await countBucket(match$2[0]) | 0;
779
+ for (let j$7 = 0, j_finish$7 = r$1.bucketCounts.length; j$7 < j_finish$7; ++j$7) {
780
+ let match$5 = r$1.bucketCounts[j$7];
781
+ if (match$5 !== undefined) {
782
+ remaining = remaining + await countBucket(match$5[0], undefined) | 0;
783
+ }
784
+ }
785
+ for (let j$8 = 0, j_finish$8 = r$1.storeCounts.length; j$8 < j_finish$8; ++j$8) {
786
+ let match$6 = r$1.storeCounts[j$8];
787
+ if (match$6 !== undefined) {
788
+ let s$2 = match$6[0];
789
+ let left = await countBucket(s$2.bucketName, s$2.keyPrefix + `/`);
790
+ remaining = remaining + left | 0;
791
+ console.log(` verified empty: ` + s$2.qualified + ` — ` + left.toString() + ` object(s) remain under ` + (s$2.bucketName + `/` + s$2.keyPrefix + `/`));
610
792
  }
611
793
  }
612
794
  }
@@ -623,17 +805,17 @@ function run(stack, backend, targets, param) {
623
805
  process.exit(0);
624
806
  return;
625
807
  } catch (raw_message) {
626
- let message = Primitive_exceptions.internalToException(raw_message);
627
- if (message.RE_EXN_ID === Seed$ReventlessSeed.Failed) {
808
+ let message$1 = Primitive_exceptions.internalToException(raw_message);
809
+ if (message$1.RE_EXN_ID === Seed$ReventlessSeed.Failed) {
628
810
  Seed_Prompt$ReventlessSeed.close();
629
811
  console.error("");
630
- console.error(`Reset aborted — ` + message._1);
812
+ console.error(`Reset aborted — ` + message$1._1);
631
813
  process.exit(1);
632
814
  } else {
633
815
  Seed_Prompt$ReventlessSeed.close();
634
816
  console.error("");
635
817
  console.error("Reset aborted with an unexpected error:");
636
- console.error(Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(message), Stdlib_JsExn.message), "unknown"));
818
+ console.error(Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(message$1), Stdlib_JsExn.message), "unknown"));
637
819
  process.exit(1);
638
820
  }
639
821
  return;
@@ -662,11 +844,15 @@ export {
662
844
  classify,
663
845
  tagValue,
664
846
  discover,
847
+ splitQualified,
848
+ parseObjectStores,
849
+ validateStores,
665
850
  countTable,
666
851
  countBucket,
667
852
  sendBatch,
668
853
  truncateTable,
669
854
  emptyBucket,
855
+ pluginOf,
670
856
  chooseScope,
671
857
  gateTarget,
672
858
  reportAll,
@@ -0,0 +1,170 @@
1
+ // How the reset learns which uploaded objects belong to which plugin, and what
2
+ // it refuses.
3
+ //
4
+ // A declared store's bucket is built by the PLATFORM deploy, so its tags say
5
+ // which project built it, not whose data is in it. Ownership therefore comes
6
+ // from the platform's `objectStores` stack output, and these pin the pure half
7
+ // of that: parsing the output, and refusing any store set a prefix-scoped wipe
8
+ // could not separate. The impure half is a `pulumi stack output` subprocess and
9
+ // the S3 calls, and the decisions under test are in neither.
10
+
11
+ open JestGlobals
12
+
13
+ module Reset = ReventlessSeedAws_Reset
14
+
15
+ let obj = entries => JSON.Encode.object(entries->Dict.fromArray)
16
+ let str = JSON.Encode.string
17
+
18
+ let storeEntry = (~bucket, ~prefix) =>
19
+ obj([("bucketName", str(bucket)), ("keyPrefix", str(prefix))])
20
+
21
+ let output = entries => Some(obj(entries))
22
+
23
+ let store = (~qualified, ~bucket, ~prefix): Reset.objectStore => {
24
+ let (plugin, name) = Reset.splitQualified(qualified)->Option.getOr(("", qualified))
25
+ {qualified, plugin, store: name, bucketName: bucket, keyPrefix: prefix}
26
+ }
27
+
28
+ describe("parseObjectStores", () => {
29
+ testSync("splits the qualified key into plugin and store", () =>
30
+ switch Reset.parseObjectStores(
31
+ output([("Catalog.productImages", storeEntry(~bucket="alpha-stores", ~prefix="productImages"))]),
32
+ ) {
33
+ | Ok([s]) =>
34
+ expect((s.plugin, s.store, s.bucketName, s.keyPrefix))->toEqual((
35
+ "Catalog",
36
+ "productImages",
37
+ "alpha-stores",
38
+ "productImages",
39
+ ))
40
+ | other => fail(`expected one parsed store, got ${other->JSON.stringifyAny->Option.getOr("?")}`)
41
+ }
42
+ )
43
+
44
+ // A store name may contain a dot; a registered plugin name may not — so the
45
+ // split is at the first one, matching how the key is composed.
46
+ testSync("splits at the first dot only", () =>
47
+ switch Reset.splitQualified("Catalog.product.images") {
48
+ | Some((plugin, name)) => expect((plugin, name))->toEqual(("Catalog", "product.images"))
49
+ | None => fail("expected a split")
50
+ }
51
+ )
52
+
53
+ // A platform that declares no stores is ordinary, not an error.
54
+ testSync("absent output yields no stores", () =>
55
+ switch Reset.parseObjectStores(None) {
56
+ | Ok(stores) => expect(stores->Array.length)->toBe(0)
57
+ | Error(message) => fail(message)
58
+ }
59
+ )
60
+
61
+ // A store the reset cannot read is a store it would silently leave behind, so
62
+ // a malformed entry fails the run rather than being skipped.
63
+ testSync("a malformed entry is an error, not a skip", () =>
64
+ switch Reset.parseObjectStores(output([("Catalog.productImages", obj([("keyPrefix", str("x"))]))])) {
65
+ | Ok(_) => fail("expected a malformed entry to be refused")
66
+ | Error(message) => expect(message->String.includes("Catalog.productImages"))->toBe(true)
67
+ }
68
+ )
69
+
70
+ testSync("an unqualified key is an error", () =>
71
+ switch Reset.parseObjectStores(
72
+ output([("productImages", storeEntry(~bucket="alpha-stores", ~prefix="productImages"))]),
73
+ ) {
74
+ | Ok(_) => fail("expected an unqualified key to be refused")
75
+ | Error(message) => expect(message->String.includes("productImages"))->toBe(true)
76
+ }
77
+ )
78
+ })
79
+
80
+ describe("validateStores", () => {
81
+ testSync("distinct prefixes in one bucket are fine", () =>
82
+ expect(
83
+ Reset.validateStores([
84
+ store(~qualified="Catalog.productImages", ~bucket="alpha-stores", ~prefix="productImages"),
85
+ store(~qualified="Ordering.labels", ~bucket="alpha-stores", ~prefix="labels"),
86
+ ]),
87
+ )->toEqual(Ok())
88
+ )
89
+
90
+ // The cross-plugin collision: one prefix, two owners, nothing to tell their
91
+ // objects apart.
92
+ testSync("two plugins on one prefix are refused", () =>
93
+ switch Reset.validateStores([
94
+ store(~qualified="Catalog.productImages", ~bucket="alpha-stores", ~prefix="productImages"),
95
+ store(~qualified="Ordering.productImages", ~bucket="alpha-stores", ~prefix="productImages"),
96
+ ]) {
97
+ | Ok() => fail("expected a collision to be refused")
98
+ | Error(message) =>
99
+ expect(
100
+ message->String.includes("Catalog.productImages") &&
101
+ message->String.includes("Ordering.productImages"),
102
+ )->toBe(true)
103
+ }
104
+ )
105
+
106
+ // The same shape one level up: `Catalog` encloses `Catalog/productImages`, so
107
+ // wiping the first would delete the second's objects.
108
+ testSync("an enclosing prefix is refused", () =>
109
+ switch Reset.validateStores([
110
+ store(~qualified="Legacy.catalog", ~bucket="alpha-stores", ~prefix="Catalog"),
111
+ store(~qualified="Catalog.productImages", ~bucket="alpha-stores", ~prefix="Catalog/productImages"),
112
+ ]) {
113
+ | Ok() => fail("expected an enclosing prefix to be refused")
114
+ | Error(message) => expect(message->String.includes("encloses"))->toBe(true)
115
+ }
116
+ )
117
+
118
+ // Different buckets cannot reach each other, so an equal prefix there is not a
119
+ // collision — which is what the per-store layout produces in production.
120
+ testSync("one prefix in two different buckets is fine", () =>
121
+ expect(
122
+ Reset.validateStores([
123
+ store(~qualified="Catalog.productImages", ~bucket="catalog-productImages", ~prefix="productImages"),
124
+ store(~qualified="Ordering.productImages", ~bucket="ordering-productImages", ~prefix="productImages"),
125
+ ]),
126
+ )->toEqual(Ok())
127
+ )
128
+
129
+ // A shared prefix that merely starts with another's characters is not nesting:
130
+ // the delete prefix carries a trailing slash, so `images` cannot reach
131
+ // `images-archive`.
132
+ testSync("a sibling prefix sharing a character run is fine", () =>
133
+ expect(
134
+ Reset.validateStores([
135
+ store(~qualified="Catalog.images", ~bucket="alpha-stores", ~prefix="images"),
136
+ store(~qualified="Catalog.imagesArchive", ~bucket="alpha-stores", ~prefix="images-archive"),
137
+ ]),
138
+ )->toEqual(Ok())
139
+ )
140
+
141
+ testSync("an empty or slash-bearing store name is refused", () => {
142
+ switch Reset.validateStores([
143
+ store(~qualified="Catalog.productImages", ~bucket="alpha-stores", ~prefix=""),
144
+ ]) {
145
+ | Ok() => fail("expected an empty prefix to be refused")
146
+ | Error(_) => ()
147
+ }
148
+ switch Reset.validateStores([
149
+ store(~qualified="Catalog.a/b", ~bucket="alpha-stores", ~prefix="a/b"),
150
+ ]) {
151
+ | Ok() => fail("expected a store name containing a slash to be refused")
152
+ | Error(_) => ()
153
+ }
154
+ })
155
+ })
156
+
157
+ describe("pluginOf", () => {
158
+ // The `objectStores` keys carry the REGISTERED plugin name; the menu carries
159
+ // the operator-facing label. Attribution uses the declared name so the reset
160
+ // never has to guess the relation between the two.
161
+ testSync("prefers the declared plugin name over the label", () =>
162
+ expect(
163
+ Reset.pluginOf({projectDir: "../catalog-aws", label: "catalog", group: Domain, plugin: "Catalog"}),
164
+ )->toBe("Catalog")
165
+ )
166
+
167
+ testSync("falls back to the label when no plugin name is declared", () =>
168
+ expect(Reset.pluginOf({projectDir: ".", label: "platform", group: Platform}))->toBe("platform")
169
+ )
170
+ })