@platformatic/sqlite-js-stream-s3 0.3.0 → 0.4.0

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.
package/dist/index.d.ts CHANGED
@@ -5,6 +5,6 @@
5
5
  * factory, so `newReplicaClientFromURL('s3://bucket/prefix')` resolves after
6
6
  * this addon is loaded.
7
7
  */
8
- export { S3ReplicaClient, newReplicaClientFromURL, isFenced, GenerationMetadataKey, GenerationObjectName, ReplicaClientType, type GenerationFence, type GenerationState, type S3ClientLike, type S3ReplicaClientOptions, } from './replica-client.ts';
8
+ export { S3ReplicaClient, newReplicaClientFromURL, isFenced, GenerationMetadataKey, GenerationObjectName, ReplicaClientType, type GenerationFence, type GenerationState, type S3ClientLike, type S3DeletePrefixOptions, type S3ReplicaClientOptions, } from './replica-client.ts';
9
9
  export { S3Leaser } from './leaser.ts';
10
10
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EACL,eAAe,EACf,uBAAuB,EACvB,QAAQ,EACR,qBAAqB,EACrB,oBAAoB,EACpB,iBAAiB,EACjB,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,KAAK,YAAY,EACjB,KAAK,sBAAsB,GAC5B,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EACL,eAAe,EACf,uBAAuB,EACvB,QAAQ,EACR,qBAAqB,EACrB,oBAAoB,EACpB,iBAAiB,EACjB,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,KAAK,YAAY,EACjB,KAAK,qBAAqB,EAC1B,KAAK,sBAAsB,GAC5B,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC"}
@@ -65,6 +65,33 @@ export interface S3ReplicaClientOptions {
65
65
  */
66
66
  generation?: bigint | null;
67
67
  }
68
+ /** Configuration for {@link S3ReplicaClient.deletePrefix}. */
69
+ export interface S3DeletePrefixOptions {
70
+ /** S3 bucket name. Required. */
71
+ bucket: string;
72
+ /**
73
+ * Prefix to delete: the replica at this prefix and every replica under
74
+ * `prefix/` (`a` covers `a` and `a/b`, not `ab`). Leading/trailing slashes
75
+ * are normalized away. Must not be empty.
76
+ */
77
+ prefix: string;
78
+ /** AWS region. Defaults to the AWS SDK region resolution chain. */
79
+ region?: string;
80
+ /** Custom endpoint URL (e.g. for MinIO / LocalStack). */
81
+ endpoint?: string;
82
+ accessKeyId?: string;
83
+ secretAccessKey?: string;
84
+ sessionToken?: string;
85
+ /** Inject a preconfigured client. */
86
+ client?: S3ClientLike;
87
+ /**
88
+ * Fences the deletion: it fails with {@link ErrGenerationSuperseded} if a
89
+ * replica under the prefix has a higher generation, checked before deleting
90
+ * any of its objects (best effort, see {@link S3ReplicaClient.deletePrefix}).
91
+ * `null` (the default) deletes unconditionally.
92
+ */
93
+ generation?: bigint | null;
94
+ }
68
95
  /** Name of the generation object, relative to the replica prefix. */
69
96
  export declare const GenerationObjectName = "generation.json";
70
97
  /** Object metadata key carrying the generation of a fenced LTX upload. */
@@ -172,6 +199,29 @@ export declare class S3ReplicaClient implements ReplicaClient {
172
199
  * (a deletion sent just before a new generation registers still applies).
173
200
  */
174
201
  deleteLtxFiles(infos: FileInfo[]): Promise<void>;
202
+ /**
203
+ * Deletes every replica under a prefix: every object whose key starts with
204
+ * `prefix/`, which covers the replica at `prefix` and every replica under
205
+ * it, generation objects (`generation.json`) and lease objects included
206
+ * (unlike {@link deleteAll}, which keeps the generation). Resolves with the
207
+ * sorted prefixes of the deleted replicas: those with an LTX file, a
208
+ * generation object or a lease object.
209
+ *
210
+ * The generation objects are deleted last, so former writers stay fenced
211
+ * while the files are deleted. Once they are gone, nothing fences those
212
+ * writers anymore: a writer that is still running can upload again and
213
+ * create the replica anew. Stop the writers first.
214
+ *
215
+ * With {@link S3DeletePrefixOptions.generation}, the deletion is fenced as
216
+ * the deletions of a fenced client are, on a best-effort basis: the
217
+ * generation object of each replica is read before deleting its first
218
+ * object, and again before deleting the generation objects, and the
219
+ * deletion fails with {@link ErrGenerationSuperseded} if it holds a higher
220
+ * generation. S3 cannot check one object and delete another atomically, so
221
+ * a generation registered between a check and a `DeleteObjects` request
222
+ * does not stop it, and the replicas already processed stay deleted.
223
+ */
224
+ static deletePrefix(opts: S3DeletePrefixOptions): Promise<string[]>;
175
225
  /**
176
226
  * Deletes every object under the prefix except the generation object, so a
177
227
  * superseded writer stays fenced. A fenced client checks its generation
@@ -1 +1 @@
1
- {"version":3,"file":"replica-client.d.ts","sourceRoot":"","sources":["../src/replica-client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"replica-client.d.ts","sourceRoot":"","sources":["../src/replica-client.ts"],"names":[],"mappings":"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"}
@@ -33,7 +33,7 @@
33
33
  */
34
34
  import { Readable } from 'node:stream';
35
35
  import { S3Client, AbortMultipartUploadCommand, CompleteMultipartUploadCommand, CreateMultipartUploadCommand, DeleteObjectsCommand, GetObjectCommand, HeadBucketCommand, HeadObjectCommand, ListObjectsV2Command, PutObjectCommand, UploadPartCommand, } from '@aws-sdk/client-s3';
36
- import { ErrGenerationSuperseded, HeaderSize, LTXError, SnapshotLevel, formatFilename, noopLogger, parseFilename, unmarshalHeader, } from '@platformatic/sqlite-js-stream';
36
+ import { DefaultLeasePath, ErrGenerationSuperseded, HeaderSize, LTXError, SnapshotLevel, formatFilename, noopLogger, parseFilename, unmarshalHeader, } from '@platformatic/sqlite-js-stream';
37
37
  import { isConditionalConflict, isNotFound, isPreconditionFailed, readBody } from "./util.js";
38
38
  /** The strategy identifier reported by {@link S3ReplicaClient.type}. */
39
39
  export const ReplicaClientType = 's3';
@@ -52,6 +52,31 @@ export function isFenced(fences, generation, maxTXID) {
52
52
  }
53
53
  return true;
54
54
  }
55
+ /** Matches the key of an LTX file, capturing the prefix of its replica. */
56
+ const LTX_KEY_RE = /^(.+)\/ltx\/\d+\/[^/]+$/;
57
+ /** Returns the prefix of the replica a key belongs to, or null if none. */
58
+ function replicaOfKey(key) {
59
+ for (const name of [GenerationObjectName, DefaultLeasePath]) {
60
+ if (key.endsWith(`/${name}`))
61
+ return key.slice(0, -(name.length + 1));
62
+ }
63
+ return LTX_KEY_RE.exec(key)?.[1] ?? null;
64
+ }
65
+ /** Builds an S3 client from connection options, unless one is injected. */
66
+ function createClient(opts) {
67
+ return (opts.client ??
68
+ new S3Client({
69
+ region: opts.region,
70
+ endpoint: opts.endpoint,
71
+ credentials: opts.accessKeyId
72
+ ? {
73
+ accessKeyId: opts.accessKeyId,
74
+ secretAccessKey: opts.secretAccessKey ?? '',
75
+ sessionToken: opts.sessionToken,
76
+ }
77
+ : undefined,
78
+ }));
79
+ }
55
80
  /** Retries of a conditional write that keeps conflicting with concurrent ones. */
56
81
  const MAX_CONDITIONAL_ATTEMPTS = 10;
57
82
  /** HeadObject requests in flight while resolving the generation of listed files. */
@@ -133,19 +158,7 @@ export class S3ReplicaClient {
133
158
  this.bucket = opts.bucket;
134
159
  this.prefix = normalizePrefix(opts.prefix ?? '');
135
160
  this.generation = opts.generation ?? null;
136
- this.client =
137
- opts.client ??
138
- new S3Client({
139
- region: opts.region,
140
- endpoint: opts.endpoint,
141
- credentials: opts.accessKeyId
142
- ? {
143
- accessKeyId: opts.accessKeyId,
144
- secretAccessKey: opts.secretAccessKey ?? '',
145
- sessionToken: opts.sessionToken,
146
- }
147
- : undefined,
148
- });
161
+ this.client = createClient(opts);
149
162
  }
150
163
  type() {
151
164
  return ReplicaClientType;
@@ -643,6 +656,113 @@ export class S3ReplicaClient {
643
656
  }));
644
657
  }
645
658
  }
659
+ /**
660
+ * Deletes every replica under a prefix: every object whose key starts with
661
+ * `prefix/`, which covers the replica at `prefix` and every replica under
662
+ * it, generation objects (`generation.json`) and lease objects included
663
+ * (unlike {@link deleteAll}, which keeps the generation). Resolves with the
664
+ * sorted prefixes of the deleted replicas: those with an LTX file, a
665
+ * generation object or a lease object.
666
+ *
667
+ * The generation objects are deleted last, so former writers stay fenced
668
+ * while the files are deleted. Once they are gone, nothing fences those
669
+ * writers anymore: a writer that is still running can upload again and
670
+ * create the replica anew. Stop the writers first.
671
+ *
672
+ * With {@link S3DeletePrefixOptions.generation}, the deletion is fenced as
673
+ * the deletions of a fenced client are, on a best-effort basis: the
674
+ * generation object of each replica is read before deleting its first
675
+ * object, and again before deleting the generation objects, and the
676
+ * deletion fails with {@link ErrGenerationSuperseded} if it holds a higher
677
+ * generation. S3 cannot check one object and delete another atomically, so
678
+ * a generation registered between a check and a `DeleteObjects` request
679
+ * does not stop it, and the replicas already processed stay deleted.
680
+ */
681
+ static async deletePrefix(opts) {
682
+ if (!opts.bucket) {
683
+ throw new Error('S3 replica client: bucket is required');
684
+ }
685
+ const prefix = normalizePrefix(opts.prefix ?? '');
686
+ if (prefix === '') {
687
+ throw new Error('S3 replica client: deletePrefix requires a non-empty prefix');
688
+ }
689
+ const bucket = opts.bucket;
690
+ const generation = opts.generation ?? null;
691
+ const ownClient = opts.client === undefined;
692
+ const client = createClient(opts);
693
+ const replicas = new Set();
694
+ const checked = new Set();
695
+ const generationKeys = [];
696
+ let pending = [];
697
+ const assertReplica = async (replica) => {
698
+ if (generation === null)
699
+ return;
700
+ let res;
701
+ try {
702
+ res = await client.send(new GetObjectCommand({ Bucket: bucket, Key: `${replica}/${GenerationObjectName}` }));
703
+ }
704
+ catch (err) {
705
+ if (isNotFound(err))
706
+ return;
707
+ throw err;
708
+ }
709
+ const stored = decodeGenerationState(await readBody(res.Body)).generation;
710
+ if (stored > generation)
711
+ throw new ErrGenerationSuperseded(stored);
712
+ };
713
+ const flush = async (objects) => {
714
+ for (let i = 0; i < objects.length; i += 1000) {
715
+ const res = await client.send(new DeleteObjectsCommand({
716
+ Bucket: bucket,
717
+ Delete: { Objects: objects.slice(i, i + 1000), Quiet: true },
718
+ }));
719
+ const failed = (res?.Errors ?? []);
720
+ if (failed.length > 0) {
721
+ const e = failed[0];
722
+ throw new Error(`S3 replica client: failed to delete ${e.Key}: ${e.Code ?? ''} ${e.Message ?? ''}`.trim());
723
+ }
724
+ }
725
+ };
726
+ try {
727
+ let token;
728
+ do {
729
+ const res = await client.send(new ListObjectsV2Command({ Bucket: bucket, Prefix: `${prefix}/`, ContinuationToken: token }));
730
+ for (const obj of (res.Contents ?? [])) {
731
+ const key = obj.Key;
732
+ if (!key)
733
+ continue;
734
+ const replica = replicaOfKey(key);
735
+ if (replica !== null) {
736
+ replicas.add(replica);
737
+ if (!checked.has(replica)) {
738
+ await assertReplica(replica);
739
+ checked.add(replica);
740
+ }
741
+ }
742
+ if (key.endsWith(`/${GenerationObjectName}`)) {
743
+ generationKeys.push(key);
744
+ continue;
745
+ }
746
+ pending.push({ Key: key });
747
+ if (pending.length >= 1000) {
748
+ await flush(pending);
749
+ pending = [];
750
+ }
751
+ }
752
+ token = res.IsTruncated ? (res.NextContinuationToken ?? undefined) : undefined;
753
+ } while (token);
754
+ await flush(pending);
755
+ for (const key of generationKeys) {
756
+ await assertReplica(key.slice(0, -(GenerationObjectName.length + 1)));
757
+ }
758
+ await flush(generationKeys.map((Key) => ({ Key })));
759
+ }
760
+ finally {
761
+ if (ownClient)
762
+ client.destroy();
763
+ }
764
+ return [...replicas].sort();
765
+ }
646
766
  /**
647
767
  * Deletes every object under the prefix except the generation object, so a
648
768
  * superseded writer stays fenced. A fenced client checks its generation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/sqlite-js-stream-s3",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Amazon S3 upload strategy (replica client) for @platformatic/sqlite-js-stream",
5
5
  "type": "module",
6
6
  "engines": {
@@ -37,7 +37,7 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@aws-sdk/client-s3": "^3.1135.0",
40
- "@platformatic/sqlite-js-stream": "0.3.0"
40
+ "@platformatic/sqlite-js-stream": "0.4.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "^26.0.0",
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ export {
16
16
  type GenerationFence,
17
17
  type GenerationState,
18
18
  type S3ClientLike,
19
+ type S3DeletePrefixOptions,
19
20
  type S3ReplicaClientOptions,
20
21
  } from './replica-client.ts';
21
22
 
@@ -49,6 +49,7 @@ import {
49
49
  } from '@aws-sdk/client-s3';
50
50
 
51
51
  import {
52
+ DefaultLeasePath,
52
53
  ErrGenerationSuperseded,
53
54
  HeaderSize,
54
55
  LTXError,
@@ -102,6 +103,34 @@ export interface S3ReplicaClientOptions {
102
103
  generation?: bigint | null;
103
104
  }
104
105
 
106
+ /** Configuration for {@link S3ReplicaClient.deletePrefix}. */
107
+ export interface S3DeletePrefixOptions {
108
+ /** S3 bucket name. Required. */
109
+ bucket: string;
110
+ /**
111
+ * Prefix to delete: the replica at this prefix and every replica under
112
+ * `prefix/` (`a` covers `a` and `a/b`, not `ab`). Leading/trailing slashes
113
+ * are normalized away. Must not be empty.
114
+ */
115
+ prefix: string;
116
+ /** AWS region. Defaults to the AWS SDK region resolution chain. */
117
+ region?: string;
118
+ /** Custom endpoint URL (e.g. for MinIO / LocalStack). */
119
+ endpoint?: string;
120
+ accessKeyId?: string;
121
+ secretAccessKey?: string;
122
+ sessionToken?: string;
123
+ /** Inject a preconfigured client. */
124
+ client?: S3ClientLike;
125
+ /**
126
+ * Fences the deletion: it fails with {@link ErrGenerationSuperseded} if a
127
+ * replica under the prefix has a higher generation, checked before deleting
128
+ * any of its objects (best effort, see {@link S3ReplicaClient.deletePrefix}).
129
+ * `null` (the default) deletes unconditionally.
130
+ */
131
+ generation?: bigint | null;
132
+ }
133
+
105
134
  /** Name of the generation object, relative to the replica prefix. */
106
135
  export const GenerationObjectName = 'generation.json';
107
136
 
@@ -136,6 +165,42 @@ export function isFenced(fences: readonly GenerationFence[], generation: bigint,
136
165
  return true;
137
166
  }
138
167
 
168
+ /** Matches the key of an LTX file, capturing the prefix of its replica. */
169
+ const LTX_KEY_RE = /^(.+)\/ltx\/\d+\/[^/]+$/;
170
+
171
+ /** Returns the prefix of the replica a key belongs to, or null if none. */
172
+ function replicaOfKey(key: string): string | null {
173
+ for (const name of [GenerationObjectName, DefaultLeasePath]) {
174
+ if (key.endsWith(`/${name}`)) return key.slice(0, -(name.length + 1));
175
+ }
176
+ return LTX_KEY_RE.exec(key)?.[1] ?? null;
177
+ }
178
+
179
+ /** Builds an S3 client from connection options, unless one is injected. */
180
+ function createClient(opts: {
181
+ client?: S3ClientLike;
182
+ region?: string;
183
+ endpoint?: string;
184
+ accessKeyId?: string;
185
+ secretAccessKey?: string;
186
+ sessionToken?: string;
187
+ }): S3ClientLike {
188
+ return (
189
+ opts.client ??
190
+ new S3Client({
191
+ region: opts.region,
192
+ endpoint: opts.endpoint,
193
+ credentials: opts.accessKeyId
194
+ ? {
195
+ accessKeyId: opts.accessKeyId,
196
+ secretAccessKey: opts.secretAccessKey ?? '',
197
+ sessionToken: opts.sessionToken,
198
+ }
199
+ : undefined,
200
+ })
201
+ );
202
+ }
203
+
139
204
  /** Retries of a conditional write that keeps conflicting with concurrent ones. */
140
205
  const MAX_CONDITIONAL_ATTEMPTS = 10;
141
206
 
@@ -234,19 +299,7 @@ export class S3ReplicaClient implements ReplicaClient {
234
299
  this.bucket = opts.bucket;
235
300
  this.prefix = normalizePrefix(opts.prefix ?? '');
236
301
  this.generation = opts.generation ?? null;
237
- this.client =
238
- opts.client ??
239
- new S3Client({
240
- region: opts.region,
241
- endpoint: opts.endpoint,
242
- credentials: opts.accessKeyId
243
- ? {
244
- accessKeyId: opts.accessKeyId,
245
- secretAccessKey: opts.secretAccessKey ?? '',
246
- sessionToken: opts.sessionToken,
247
- }
248
- : undefined,
249
- });
302
+ this.client = createClient(opts);
250
303
  }
251
304
 
252
305
  type(): string {
@@ -776,6 +829,118 @@ export class S3ReplicaClient implements ReplicaClient {
776
829
  }
777
830
  }
778
831
 
832
+ /**
833
+ * Deletes every replica under a prefix: every object whose key starts with
834
+ * `prefix/`, which covers the replica at `prefix` and every replica under
835
+ * it, generation objects (`generation.json`) and lease objects included
836
+ * (unlike {@link deleteAll}, which keeps the generation). Resolves with the
837
+ * sorted prefixes of the deleted replicas: those with an LTX file, a
838
+ * generation object or a lease object.
839
+ *
840
+ * The generation objects are deleted last, so former writers stay fenced
841
+ * while the files are deleted. Once they are gone, nothing fences those
842
+ * writers anymore: a writer that is still running can upload again and
843
+ * create the replica anew. Stop the writers first.
844
+ *
845
+ * With {@link S3DeletePrefixOptions.generation}, the deletion is fenced as
846
+ * the deletions of a fenced client are, on a best-effort basis: the
847
+ * generation object of each replica is read before deleting its first
848
+ * object, and again before deleting the generation objects, and the
849
+ * deletion fails with {@link ErrGenerationSuperseded} if it holds a higher
850
+ * generation. S3 cannot check one object and delete another atomically, so
851
+ * a generation registered between a check and a `DeleteObjects` request
852
+ * does not stop it, and the replicas already processed stay deleted.
853
+ */
854
+ static async deletePrefix(opts: S3DeletePrefixOptions): Promise<string[]> {
855
+ if (!opts.bucket) {
856
+ throw new Error('S3 replica client: bucket is required');
857
+ }
858
+ const prefix = normalizePrefix(opts.prefix ?? '');
859
+ if (prefix === '') {
860
+ throw new Error('S3 replica client: deletePrefix requires a non-empty prefix');
861
+ }
862
+ const bucket = opts.bucket;
863
+ const generation = opts.generation ?? null;
864
+ const ownClient = opts.client === undefined;
865
+ const client = createClient(opts);
866
+
867
+ const replicas = new Set<string>();
868
+ const checked = new Set<string>();
869
+ const generationKeys: string[] = [];
870
+ let pending: Array<{ Key: string }> = [];
871
+
872
+ const assertReplica = async (replica: string): Promise<void> => {
873
+ if (generation === null) return;
874
+ let res;
875
+ try {
876
+ res = await client.send(
877
+ new GetObjectCommand({ Bucket: bucket, Key: `${replica}/${GenerationObjectName}` }),
878
+ );
879
+ } catch (err) {
880
+ if (isNotFound(err)) return;
881
+ throw err;
882
+ }
883
+ const stored = decodeGenerationState(await readBody(res.Body)).generation;
884
+ if (stored > generation) throw new ErrGenerationSuperseded(stored);
885
+ };
886
+
887
+ const flush = async (objects: Array<{ Key: string }>): Promise<void> => {
888
+ for (let i = 0; i < objects.length; i += 1000) {
889
+ const res = await client.send(
890
+ new DeleteObjectsCommand({
891
+ Bucket: bucket,
892
+ Delete: { Objects: objects.slice(i, i + 1000), Quiet: true },
893
+ }),
894
+ );
895
+ const failed = (res?.Errors ?? []) as Array<{ Key?: string; Code?: string; Message?: string }>;
896
+ if (failed.length > 0) {
897
+ const e = failed[0];
898
+ throw new Error(`S3 replica client: failed to delete ${e.Key}: ${e.Code ?? ''} ${e.Message ?? ''}`.trim());
899
+ }
900
+ }
901
+ };
902
+
903
+ try {
904
+ let token: string | undefined;
905
+ do {
906
+ const res = await client.send(
907
+ new ListObjectsV2Command({ Bucket: bucket, Prefix: `${prefix}/`, ContinuationToken: token }),
908
+ );
909
+ for (const obj of (res.Contents ?? []) as Array<{ Key?: string }>) {
910
+ const key = obj.Key;
911
+ if (!key) continue;
912
+ const replica = replicaOfKey(key);
913
+ if (replica !== null) {
914
+ replicas.add(replica);
915
+ if (!checked.has(replica)) {
916
+ await assertReplica(replica);
917
+ checked.add(replica);
918
+ }
919
+ }
920
+ if (key.endsWith(`/${GenerationObjectName}`)) {
921
+ generationKeys.push(key);
922
+ continue;
923
+ }
924
+ pending.push({ Key: key });
925
+ if (pending.length >= 1000) {
926
+ await flush(pending);
927
+ pending = [];
928
+ }
929
+ }
930
+ token = res.IsTruncated ? (res.NextContinuationToken ?? undefined) : undefined;
931
+ } while (token);
932
+ await flush(pending);
933
+
934
+ for (const key of generationKeys) {
935
+ await assertReplica(key.slice(0, -(GenerationObjectName.length + 1)));
936
+ }
937
+ await flush(generationKeys.map((Key) => ({ Key })));
938
+ } finally {
939
+ if (ownClient) (client as S3Client).destroy();
940
+ }
941
+ return [...replicas].sort();
942
+ }
943
+
779
944
  /**
780
945
  * Deletes every object under the prefix except the generation object, so a
781
946
  * superseded writer stays fenced. A fenced client checks its generation