@davideasden/pi-undo 0.1.2 → 0.2.1

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/src/quarantine.ts CHANGED
@@ -9,6 +9,8 @@ import type { MutationJournal } from "./mutation-journal.ts";
9
9
  import type { MutationRecord } from "./model.ts";
10
10
  import { assertNoSymlinkEscape, relativeSafePath } from "./path-safety.ts";
11
11
 
12
+ const BATCH_FILE_IO_CONCURRENCY = 32;
13
+
12
14
  export interface ReplaceFileRequest {
13
15
  readonly path: string;
14
16
  readonly targetBytes: Uint8Array;
@@ -18,6 +20,14 @@ export interface ReplaceFileRequest {
18
20
  readonly beforeInstall?: () => void | Promise<void>;
19
21
  }
20
22
 
23
+ interface PreparedFileReplacement {
24
+ readonly request: ReplaceFileRequest;
25
+ readonly sourceArtifact: string;
26
+ readonly targetArtifact: string;
27
+ readonly targetAbsolutePath: string;
28
+ readonly intent: MutationRecord;
29
+ }
30
+
21
31
  export interface ReplaceSymlinkRequest {
22
32
  readonly path: string;
23
33
  readonly targetLinkText: string;
@@ -89,7 +99,7 @@ export class QuarantineManager {
89
99
  this.beforeTargetCreate = options.beforeTargetCreate;
90
100
  }
91
101
 
92
- async replaceFile(request: ReplaceFileRequest): Promise<void> {
102
+ async replaceFile(request: ReplaceFileRequest): Promise<MutationRecord> {
93
103
  await this.assertWorkspaceIdentity();
94
104
  await this.assertPath(request.path);
95
105
  assertFingerprint(request.sourceFingerprint);
@@ -106,7 +116,12 @@ export class QuarantineManager {
106
116
  const targetArtifact = this.absolute(artifacts.targetArtifact!);
107
117
  await this.beforeTargetCreate?.();
108
118
  await this.assertMutationPaths(request.path, artifacts.targetArtifact!);
109
- await writeBytesExclusive(targetArtifact, request.targetBytes, request.targetMode);
119
+ await writeBytesExclusive(
120
+ targetArtifact,
121
+ request.targetBytes,
122
+ request.targetMode,
123
+ { syncDirectory: false },
124
+ );
110
125
  await this.assertFingerprint(targetArtifact, request.path, request.targetFingerprint);
111
126
  await this.quarantineSource(intent);
112
127
  await request.beforeInstall?.();
@@ -121,15 +136,196 @@ export class QuarantineManager {
121
136
  throw error;
122
137
  }
123
138
  await fsyncDirectory(dirname(this.absolute(request.path)));
124
- await this.journal.advance(intent.ordinal, "TARGET_INSTALLED");
125
139
  await this.assertFingerprint(this.absolute(request.path), request.path, request.targetFingerprint);
126
140
  await this.assertArtifactPath(request.path, artifacts.targetArtifact!);
141
+ const targetStates = await this.journal.advanceMany(
142
+ intent.ordinal,
143
+ ["SOURCE_QUARANTINED", "SOURCE_VERIFIED", "TARGET_INSTALLED", "TARGET_VERIFIED"],
144
+ );
127
145
  await unlink(targetArtifact);
128
- await fsyncDirectory(dirname(targetArtifact));
129
- await this.journal.advance(intent.ordinal, "TARGET_VERIFIED");
146
+ return targetStates.at(-1)!;
147
+ }
148
+
149
+ async replaceFiles(requests: readonly ReplaceFileRequest[]): Promise<void> {
150
+ if (requests.length === 0) return;
151
+ const prepared: Array<Omit<PreparedFileReplacement, "intent">> = [];
152
+ for (const request of requests) {
153
+ await this.assertWorkspaceIdentity();
154
+ await this.assertPath(request.path);
155
+ assertFingerprint(request.sourceFingerprint);
156
+ assertFingerprint(request.targetFingerprint);
157
+ assertMode(request.targetMode);
158
+ const artifacts = await this.artifactPaths(request.path, true);
159
+ prepared.push({
160
+ request,
161
+ sourceArtifact: artifacts.sourceArtifact,
162
+ targetArtifact: artifacts.targetArtifact!,
163
+ targetAbsolutePath: this.absolute(artifacts.targetArtifact!),
164
+ });
165
+ }
166
+ const intents = await this.journal.beginMany(prepared.map(({ request, sourceArtifact, targetArtifact }) => ({
167
+ kind: "write" as const,
168
+ path: request.path,
169
+ sourceArtifact,
170
+ targetArtifact,
171
+ sourceFingerprint: request.sourceFingerprint,
172
+ targetFingerprint: request.targetFingerprint,
173
+ })));
174
+ const replacements: PreparedFileReplacement[] = prepared.map((replacement, index) => ({
175
+ ...replacement,
176
+ intent: intents[index]!,
177
+ }));
178
+ const createTarget = async (replacement: PreparedFileReplacement): Promise<void> => {
179
+ await this.beforeTargetCreate?.();
180
+ await this.assertMutationPaths(replacement.request.path, replacement.targetArtifact);
181
+ await writeBytesExclusive(
182
+ replacement.targetAbsolutePath,
183
+ replacement.request.targetBytes,
184
+ replacement.request.targetMode,
185
+ { syncDirectory: false },
186
+ );
187
+ await this.assertFingerprint(
188
+ replacement.targetAbsolutePath,
189
+ replacement.request.path,
190
+ replacement.request.targetFingerprint,
191
+ );
192
+ };
193
+ if (this.beforeTargetCreate === undefined) {
194
+ await mapConcurrentFailClosed(replacements, BATCH_FILE_IO_CONCURRENCY, createTarget);
195
+ } else {
196
+ for (const replacement of replacements) await createTarget(replacement);
197
+ }
198
+ await this.quarantineFileSources(replacements);
199
+ const installDirectories = new Set<string>();
200
+ for (const replacement of replacements) {
201
+ const { request, targetArtifact, targetAbsolutePath } = replacement;
202
+ await request.beforeInstall?.();
203
+ await this.assertMutationPaths(request.path, targetArtifact);
204
+ await this.assertFingerprint(targetAbsolutePath, request.path, request.targetFingerprint);
205
+ try {
206
+ await this.linkFile(targetAbsolutePath, this.absolute(request.path));
207
+ } catch (error) {
208
+ if (hasErrorCode(error, "EEXIST")) {
209
+ throw new QuarantineError("external_concurrency", `检测到外部并发修改:${request.path}`);
210
+ }
211
+ throw error;
212
+ }
213
+ installDirectories.add(dirname(this.absolute(request.path)));
214
+ }
215
+ await syncDirectories(installDirectories);
216
+ for (const { request, targetArtifact, targetAbsolutePath } of replacements) {
217
+ await this.assertArtifactPath(request.path, targetArtifact);
218
+ await this.assertSameFileIdentity(this.absolute(request.path), targetAbsolutePath, request.path);
219
+ await this.assertFingerprint(targetAbsolutePath, request.path, request.targetFingerprint);
220
+ }
221
+ const targetRecords = await this.journal.advanceBatch(replacements.map(({ intent }) => ({
222
+ ordinal: intent.ordinal,
223
+ states: ["SOURCE_QUARANTINED", "SOURCE_VERIFIED", "TARGET_INSTALLED", "TARGET_VERIFIED"],
224
+ })));
225
+ const verifiedByOrdinal = new Map(
226
+ targetRecords.filter((record) => record.state === "TARGET_VERIFIED")
227
+ .map((record) => [record.ordinal, record]),
228
+ );
229
+ const verifiedRecords: MutationRecord[] = [];
230
+ for (const replacement of replacements) {
231
+ const verified = verifiedByOrdinal.get(replacement.intent.ordinal);
232
+ if (verified === undefined) throw new Error("批量 TARGET_VERIFIED record 缺失");
233
+ verifiedRecords.push(await this.assertOwnedRecord(verified));
234
+ }
235
+ await this.cleanupVerifiedArtifactsBatch(verifiedRecords);
236
+ await this.journal.advanceBatch(replacements.map(({ intent }) => ({
237
+ ordinal: intent.ordinal,
238
+ states: ["CLEANED"],
239
+ })));
240
+ }
241
+
242
+ private async quarantineFileSources(
243
+ replacements: readonly { readonly intent: MutationRecord }[],
244
+ ): Promise<void> {
245
+ const capturedDirectories = new Set<string>();
246
+ for (const { intent } of replacements) {
247
+ const original = this.absolute(intent.path);
248
+ const source = this.absolute(intent.sourceArtifact);
249
+ await this.assertFingerprint(original, intent.path, intent.sourceFingerprint);
250
+ if (intent.sourceFingerprint === fingerprintAbsent(intent.path)) {
251
+ if (await exists(source)) {
252
+ throw new QuarantineError("unsafe_artifact", `absent source 不应存在 artifact:${intent.path}`);
253
+ }
254
+ continue;
255
+ }
256
+ const metadata = await lstat(original);
257
+ if (!metadata.isFile()) throw new QuarantineError("unsafe_artifact", "批量 quarantine 只支持普通文件");
258
+ await this.beforeSourceCapture?.();
259
+ await this.assertMutationPaths(intent.path, intent.sourceArtifact);
260
+ try {
261
+ await link(original, source);
262
+ } catch (error) {
263
+ if (hasErrorCode(error, "EEXIST")) {
264
+ throw new QuarantineError("external_concurrency", `source artifact 被抢占:${intent.path}`);
265
+ }
266
+ throw error;
267
+ }
268
+ capturedDirectories.add(dirname(original));
269
+ }
270
+ await syncDirectories(capturedDirectories);
271
+
272
+ const removedDirectories = new Set<string>();
273
+ for (const { intent } of replacements) {
274
+ if (intent.sourceFingerprint === fingerprintAbsent(intent.path)) continue;
275
+ const original = this.absolute(intent.path);
276
+ const source = this.absolute(intent.sourceArtifact);
277
+ await this.beforeSourceRemove?.();
278
+ await this.assertMutationPaths(intent.path, intent.sourceArtifact);
279
+ await this.assertFingerprint(source, intent.path, intent.sourceFingerprint);
280
+ await this.assertFingerprint(original, intent.path, intent.sourceFingerprint);
281
+ const [sourceMetadata, originalMetadata] = await Promise.all([lstat(source), lstat(original)]);
282
+ if (sourceMetadata.dev !== originalMetadata.dev || sourceMetadata.ino !== originalMetadata.ino) {
283
+ throw new QuarantineError("external_concurrency", `source 隔离前原路径已变化:${intent.path}`);
284
+ }
285
+ await unlink(original);
286
+ removedDirectories.add(dirname(original));
287
+ }
288
+ await syncDirectories(removedDirectories);
289
+ for (const { intent } of replacements) {
290
+ await this.assertFingerprint(this.absolute(intent.path), intent.path, fingerprintAbsent(intent.path));
291
+ if (intent.sourceFingerprint !== fingerprintAbsent(intent.path)) {
292
+ await this.assertFingerprint(
293
+ this.absolute(intent.sourceArtifact),
294
+ intent.path,
295
+ intent.sourceFingerprint,
296
+ );
297
+ }
298
+ }
299
+ }
300
+
301
+ private async cleanupVerifiedArtifactsBatch(records: readonly MutationRecord[]): Promise<void> {
302
+ const directories = new Set<string>();
303
+ for (const owned of records) {
304
+ if (owned.state !== "TARGET_VERIFIED") {
305
+ throw new QuarantineError("unsafe_artifact", "只有 TARGET_VERIFIED mutation 可以批量清理");
306
+ }
307
+ await this.assertFingerprint(this.absolute(owned.path), owned.path, owned.targetFingerprint);
308
+ const source = this.absolute(owned.sourceArtifact);
309
+ if (await exists(source)) {
310
+ await this.assertArtifactPath(owned.path, owned.sourceArtifact);
311
+ await this.assertFingerprint(source, owned.path, owned.sourceFingerprint);
312
+ await unlink(source);
313
+ directories.add(dirname(source));
314
+ }
315
+ if (owned.targetArtifact !== null) {
316
+ const target = this.absolute(owned.targetArtifact);
317
+ if (await exists(target)) {
318
+ await this.assertArtifactPath(owned.path, owned.targetArtifact);
319
+ await this.assertFingerprint(target, owned.path, owned.targetFingerprint);
320
+ await unlink(target);
321
+ directories.add(dirname(target));
322
+ }
323
+ }
324
+ }
325
+ await syncDirectories(directories);
130
326
  }
131
327
 
132
- async replaceSymlink(request: ReplaceSymlinkRequest): Promise<void> {
328
+ async replaceSymlink(request: ReplaceSymlinkRequest): Promise<MutationRecord> {
133
329
  await this.assertWorkspaceIdentity();
134
330
  await this.assertPath(request.path);
135
331
  assertFingerprint(request.sourceFingerprint);
@@ -157,12 +353,14 @@ export class QuarantineManager {
157
353
  throw error;
158
354
  }
159
355
  await fsyncDirectory(dirname(this.absolute(request.path)));
160
- await this.journal.advance(intent.ordinal, "TARGET_INSTALLED");
161
356
  await this.assertFingerprint(this.absolute(request.path), request.path, request.targetFingerprint);
162
- await this.journal.advance(intent.ordinal, "TARGET_VERIFIED");
357
+ return (await this.journal.advanceMany(
358
+ intent.ordinal,
359
+ ["SOURCE_QUARANTINED", "SOURCE_VERIFIED", "TARGET_INSTALLED", "TARGET_VERIFIED"],
360
+ )).at(-1)!;
163
361
  }
164
362
 
165
- async deleteLeaf(request: DeleteLeafRequest): Promise<void> {
363
+ async deleteLeaf(request: DeleteLeafRequest): Promise<MutationRecord> {
166
364
  await this.assertWorkspaceIdentity();
167
365
  await this.assertPath(request.path);
168
366
  assertFingerprint(request.sourceFingerprint);
@@ -179,9 +377,61 @@ export class QuarantineManager {
179
377
  targetFingerprint: request.targetFingerprint,
180
378
  });
181
379
  await this.quarantineSource(intent);
182
- await this.journal.advance(intent.ordinal, "TARGET_INSTALLED");
183
380
  await this.assertFingerprint(this.absolute(request.path), request.path, request.targetFingerprint);
184
- await this.journal.advance(intent.ordinal, "TARGET_VERIFIED");
381
+ return (await this.journal.advanceMany(
382
+ intent.ordinal,
383
+ ["SOURCE_QUARANTINED", "SOURCE_VERIFIED", "TARGET_INSTALLED", "TARGET_VERIFIED"],
384
+ )).at(-1)!;
385
+ }
386
+
387
+ async deleteFiles(requests: readonly DeleteLeafRequest[]): Promise<void> {
388
+ if (requests.length === 0) return;
389
+ const prepared: Array<{
390
+ readonly request: DeleteLeafRequest;
391
+ readonly sourceArtifact: string;
392
+ }> = [];
393
+ for (const request of requests) {
394
+ await this.assertWorkspaceIdentity();
395
+ await this.assertPath(request.path);
396
+ assertFingerprint(request.sourceFingerprint);
397
+ assertFingerprint(request.targetFingerprint);
398
+ if (request.targetFingerprint !== fingerprintAbsent(request.path)) {
399
+ throw new QuarantineError("fingerprint_mismatch", `删除目标 fingerprint 不是 absent:${request.path}`);
400
+ }
401
+ const artifacts = await this.artifactPaths(request.path, false);
402
+ prepared.push({ request, sourceArtifact: artifacts.sourceArtifact });
403
+ }
404
+ const intents = await this.journal.beginMany(prepared.map(({ request, sourceArtifact }) => ({
405
+ kind: "delete" as const,
406
+ path: request.path,
407
+ sourceArtifact,
408
+ targetArtifact: null,
409
+ sourceFingerprint: request.sourceFingerprint,
410
+ targetFingerprint: request.targetFingerprint,
411
+ })));
412
+ const mutations = intents.map((intent) => ({ intent }));
413
+ await this.quarantineFileSources(mutations);
414
+ for (const { request } of prepared) {
415
+ await this.assertFingerprint(this.absolute(request.path), request.path, request.targetFingerprint);
416
+ }
417
+ const targetRecords = await this.journal.advanceBatch(intents.map((intent) => ({
418
+ ordinal: intent.ordinal,
419
+ states: ["SOURCE_QUARANTINED", "SOURCE_VERIFIED", "TARGET_INSTALLED", "TARGET_VERIFIED"],
420
+ })));
421
+ const verifiedRecords: MutationRecord[] = [];
422
+ for (const record of targetRecords) {
423
+ if (record.state === "TARGET_VERIFIED") {
424
+ verifiedRecords.push(await this.assertOwnedRecord(record));
425
+ }
426
+ }
427
+ if (verifiedRecords.length !== intents.length) {
428
+ throw new Error("批量 delete TARGET_VERIFIED record 缺失");
429
+ }
430
+ await this.cleanupVerifiedArtifactsBatch(verifiedRecords);
431
+ await this.journal.advanceBatch(intents.map((intent) => ({
432
+ ordinal: intent.ordinal,
433
+ states: ["CLEANED"],
434
+ })));
185
435
  }
186
436
 
187
437
  async restoreMutation(record: MutationRecord): Promise<void> {
@@ -199,11 +449,20 @@ export class QuarantineManager {
199
449
  await this.assertTargetArtifactAbsent(owned);
200
450
  return;
201
451
  }
452
+ if (
453
+ owned.state === "TARGET_VERIFIED" && !sourceWasAbsent && !sourceExists &&
454
+ originalFingerprint === owned.targetFingerprint
455
+ ) {
456
+ await this.cleanupRollbackTarget(owned);
457
+ await this.journal.markRollbackCleaned(owned.ordinal);
458
+ return;
459
+ }
202
460
  if (sourceWasAbsent) {
203
461
  if (sourceExists) {
204
462
  throw new QuarantineError("unsafe_artifact", `absent source 不应存在 artifact:${owned.path}`);
205
463
  }
206
464
  if (originalFingerprint === owned.targetFingerprint) {
465
+ await this.assertRollbackTargetOwned(owned);
207
466
  await this.assertFingerprint(original, owned.path, owned.targetFingerprint);
208
467
  await unlink(original);
209
468
  await fsyncDirectory(dirname(original));
@@ -234,6 +493,7 @@ export class QuarantineManager {
234
493
  originalFingerprint === owned.targetFingerprint
235
494
  ) {
236
495
  await this.assertMutationPaths(owned.path, owned.sourceArtifact);
496
+ await this.assertRollbackTargetOwned(owned);
237
497
  await this.assertFingerprint(original, owned.path, owned.targetFingerprint);
238
498
  await unlink(original);
239
499
  await fsyncDirectory(dirname(original));
@@ -272,6 +532,35 @@ export class QuarantineManager {
272
532
 
273
533
  async rollForwardMutation(record: MutationRecord): Promise<void> {
274
534
  let owned = await this.assertOwnedRecord(record);
535
+ if (owned.state === "INTENT") {
536
+ const source = this.absolute(owned.sourceArtifact);
537
+ const original = this.absolute(owned.path);
538
+ const originalFingerprint = await fingerprintLeaf(original, owned.path);
539
+ if (owned.sourceFingerprint === fingerprintAbsent(owned.path)) {
540
+ if (await exists(source) || originalFingerprint !== owned.sourceFingerprint) {
541
+ throw new QuarantineError("external_concurrency", `INTENT absent source 现场冲突:${owned.path}`);
542
+ }
543
+ } else {
544
+ await this.assertFingerprint(source, owned.path, owned.sourceFingerprint);
545
+ if (originalFingerprint === owned.sourceFingerprint) {
546
+ const [sourceMetadata, originalMetadata] = await Promise.all([lstat(source), lstat(original)]);
547
+ if (
548
+ sourceMetadata.isFile() &&
549
+ (sourceMetadata.dev !== originalMetadata.dev || sourceMetadata.ino !== originalMetadata.ino)
550
+ ) {
551
+ throw new QuarantineError("external_concurrency", `INTENT source inode 冲突:${owned.path}`);
552
+ }
553
+ await unlink(original);
554
+ await fsyncDirectory(dirname(original));
555
+ } else if (originalFingerprint !== fingerprintAbsent(owned.path)) {
556
+ throw new QuarantineError("external_concurrency", `INTENT original 现场冲突:${owned.path}`);
557
+ }
558
+ }
559
+ owned = (await this.journal.advanceMany(
560
+ owned.ordinal,
561
+ ["SOURCE_QUARANTINED", "SOURCE_VERIFIED"],
562
+ )).at(-1)!;
563
+ }
275
564
  if (owned.state === "SOURCE_QUARANTINED") {
276
565
  if (owned.sourceFingerprint === fingerprintAbsent(owned.path)) {
277
566
  if (await exists(this.absolute(owned.sourceArtifact))) {
@@ -323,9 +612,15 @@ export class QuarantineManager {
323
612
  async cleanupMutation(record: MutationRecord): Promise<void> {
324
613
  const owned = await this.assertOwnedRecord(record);
325
614
  if (owned.state === "CLEANED") return;
615
+ await this.cleanupVerifiedArtifacts(owned);
616
+ await this.journal.advance(owned.ordinal, "CLEANED");
617
+ }
618
+
619
+ private async cleanupVerifiedArtifacts(owned: MutationRecord): Promise<void> {
326
620
  if (owned.state !== "TARGET_VERIFIED") {
327
621
  throw new QuarantineError("unsafe_artifact", "只有 TARGET_VERIFIED mutation 可以清理");
328
622
  }
623
+ await this.assertFingerprint(this.absolute(owned.path), owned.path, owned.targetFingerprint);
329
624
  const source = this.absolute(owned.sourceArtifact);
330
625
  if (await exists(source)) {
331
626
  await this.assertArtifactPath(owned.path, owned.sourceArtifact);
@@ -342,7 +637,6 @@ export class QuarantineManager {
342
637
  await fsyncDirectory(dirname(target));
343
638
  }
344
639
  }
345
- await this.journal.advance(owned.ordinal, "CLEANED");
346
640
  }
347
641
 
348
642
  async inspectArtifacts(): Promise<readonly QuarantineArtifact[]> {
@@ -373,9 +667,7 @@ export class QuarantineManager {
373
667
  if (await exists(source)) {
374
668
  throw new QuarantineError("unsafe_artifact", `absent source 不应存在 artifact:${record.path}`);
375
669
  }
376
- await this.journal.advance(record.ordinal, "SOURCE_QUARANTINED");
377
670
  await this.assertFingerprint(original, record.path, record.sourceFingerprint);
378
- await this.journal.advance(record.ordinal, "SOURCE_VERIFIED");
379
671
  return;
380
672
  }
381
673
  const metadata = await lstat(original);
@@ -409,10 +701,7 @@ export class QuarantineManager {
409
701
  }
410
702
  }
411
703
  await unlink(original);
412
- await fsyncDirectory(dirname(original));
413
- await this.journal.advance(record.ordinal, "SOURCE_QUARANTINED");
414
704
  await this.assertFingerprint(source, record.path, record.sourceFingerprint);
415
- await this.journal.advance(record.ordinal, "SOURCE_VERIFIED");
416
705
  }
417
706
 
418
707
  private async cleanupRollbackTarget(record: MutationRecord): Promise<void> {
@@ -484,6 +773,27 @@ export class QuarantineManager {
484
773
  }
485
774
  }
486
775
 
776
+ private async assertRollbackTargetOwned(owned: MutationRecord): Promise<void> {
777
+ if (owned.targetArtifact === null) return;
778
+ const target = this.absolute(owned.targetArtifact);
779
+ if (!await exists(target)) return;
780
+ await this.assertArtifactPath(owned.path, owned.targetArtifact);
781
+ await this.assertFingerprint(target, owned.path, owned.targetFingerprint);
782
+ await this.assertSameFileIdentity(this.absolute(owned.path), target, owned.path);
783
+ }
784
+
785
+ private async assertSameFileIdentity(original: string, artifact: string, path: string): Promise<void> {
786
+ const [originalMetadata, artifactMetadata] = await Promise.all([lstat(original), lstat(artifact)]);
787
+ if (
788
+ !originalMetadata.isFile() ||
789
+ !artifactMetadata.isFile() ||
790
+ originalMetadata.dev !== artifactMetadata.dev ||
791
+ originalMetadata.ino !== artifactMetadata.ino
792
+ ) {
793
+ throw new QuarantineError("external_concurrency", `target artifact ownership 已变化:${path}`);
794
+ }
795
+ }
796
+
487
797
  private async assertFingerprint(absolutePath: string, logicalPath: string, expected: string): Promise<void> {
488
798
  const actual = await fingerprintLeaf(absolutePath, logicalPath);
489
799
  if (actual !== expected) {
@@ -576,6 +886,36 @@ function immutableMutation(record: MutationRecord): string {
576
886
  });
577
887
  }
578
888
 
889
+ async function mapConcurrentFailClosed<T>(
890
+ values: readonly T[],
891
+ concurrency: number,
892
+ operation: (value: T) => Promise<void>,
893
+ ): Promise<void> {
894
+ let nextIndex = 0;
895
+ let failed = false;
896
+ let failure: unknown;
897
+ async function worker(): Promise<void> {
898
+ while (!failed && nextIndex < values.length) {
899
+ const index = nextIndex;
900
+ nextIndex += 1;
901
+ try {
902
+ await operation(values[index]!);
903
+ } catch (error) {
904
+ if (!failed) failure = error;
905
+ failed = true;
906
+ }
907
+ }
908
+ }
909
+ await Promise.all(
910
+ Array.from({ length: Math.min(concurrency, values.length) }, () => worker()),
911
+ );
912
+ if (failed) throw failure;
913
+ }
914
+
915
+ async function syncDirectories(directories: ReadonlySet<string>): Promise<void> {
916
+ for (const directory of directories) await fsyncDirectory(directory);
917
+ }
918
+
579
919
  async function exists(path: string): Promise<boolean> {
580
920
  try {
581
921
  await lstat(path);