@graftland/package-manager 0.1.0 → 0.2.0-beta.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/src/index.ts CHANGED
@@ -1,19 +1,40 @@
1
1
  /** Non-interactive package installation. Provide platform services at the application boundary. */
2
2
  import {
3
- Array as Arr,
3
+ Array,
4
+ Cause,
5
+ Data,
6
+ Ref,
4
7
  Effect,
5
8
  Exit,
6
9
  FileSystem,
7
10
  HashMap,
8
11
  Option,
9
12
  Path,
13
+ PlatformError,
10
14
  Record,
11
15
  Schema,
12
16
  Semaphore,
13
- String as Str,
17
+ String,
14
18
  } from "effect"
15
- import type { PlatformError } from "effect"
16
19
  import { ChildProcess, ChildProcessSpawner } from "effect/process"
20
+ import {
21
+ PackageManagerDeclaration,
22
+ PackageManagerName,
23
+ } from "./PackageManagerDeclaration"
24
+ export { PackageManagerDeclaration } from "./PackageManagerDeclaration"
25
+ import { terminateProcess } from "./process"
26
+ import {
27
+ writeSnapshot,
28
+ withSnapshot,
29
+ type SnapshotCleanupFailure,
30
+ inspectManagedFiles,
31
+ UnsupportedFileTypeError,
32
+ type Contents,
33
+ } from "./snapshot"
34
+ export {
35
+ UnsupportedFileTypeError,
36
+ type SnapshotCleanupFailure,
37
+ } from "./snapshot"
17
38
  import {
18
39
  applyChanges,
19
40
  Changes,
@@ -39,8 +60,48 @@ export class PackageManagerError extends Schema.TaggedError<
39
60
  message: Schema.String,
40
61
  exitCode: Schema.optionalKey(Schema.Int),
41
62
  }) {}
63
+ export class RecoveryRequiredError extends Schema.TaggedError<
64
+ RecoveryRequiredError,
65
+ { readonly brand: unique symbol }
66
+ >()("RecoveryRequiredError", {
67
+ directory: Schema.String,
68
+ snapshotDirectory: Schema.String,
69
+ operation: Schema.Literals(["install", "recover", "rollback"]),
70
+ processId: Schema.optionalKey(Schema.Int),
71
+ message: Schema.String,
72
+ terminationCause: Schema.optionalKey(
73
+ Schema.Cause(Schema.Unknown, Schema.Unknown),
74
+ ),
75
+ recoveryCause: Schema.Cause(Schema.Unknown, Schema.Unknown),
76
+ }) {}
77
+
78
+ type WriterState = Data.TaggedEnum<{
79
+ ManagedWritersIdle: {}
80
+ Running: {}
81
+ FileWrite: {}
82
+ Unconfirmed: {
83
+ readonly operation: "install" | "recover"
84
+ readonly processId: number
85
+ readonly cause: Cause.Cause<PlatformError.PlatformError>
86
+ }
87
+ }>
88
+
89
+ const WriterState = Data.taggedEnum<WriterState>()
90
+
91
+ type ManagerState = Data.TaggedEnum<{
92
+ Ready: {}
93
+ Active: { readonly writer: WriterState }
94
+ RecoveryRequired: { readonly error: RecoveryRequiredError }
95
+ }>
96
+
97
+ const ManagerState = Data.taggedEnum<ManagerState>()
98
+
42
99
  export type Error =
43
- PackageManagerError | PlatformError.PlatformError | Schema.SchemaError
100
+ | PackageManagerError
101
+ | UnsupportedFileTypeError
102
+ | RecoveryRequiredError
103
+ | PlatformError.PlatformError
104
+ | Schema.SchemaError
44
105
  export interface Options {
45
106
  /** Project directory, containing package.json. Use the workspace root for workspace installs. */
46
107
  readonly directory: string
@@ -67,7 +128,37 @@ const backupFiles = [
67
128
  ".pnp.loader.mjs",
68
129
  ]
69
130
  const executable = (agent: Agent) =>
70
- Str.startsWith("yarn")(agent) ? "yarn" : agent
131
+ String.startsWith("yarn")(agent) ? "yarn" : agent
132
+
133
+ const readSource = Effect.fnUntraced(function* (
134
+ fs: FileSystem.FileSystem,
135
+ file: string,
136
+ ) {
137
+ const bytes = yield* fs.readFile(file)
138
+ return yield* Effect.try({
139
+ try: () =>
140
+ new TextDecoder("utf-8", { ignoreBOM: true, fatal: true }).decode(bytes),
141
+ catch: (cause) =>
142
+ PlatformError.badArgument({
143
+ module: "FileSystem",
144
+ method: "readManifest",
145
+ description: "The manifest must contain valid UTF-8",
146
+ cause,
147
+ }),
148
+ })
149
+ })
150
+
151
+ const Executable = PackageManagerName
152
+ const Digits = Schema.String.check(Schema.isPattern(/^\d+$/))
153
+ const decodePackageManager = Schema.decodeUnknownOption(
154
+ PackageManagerDeclaration,
155
+ )
156
+ /** `name/version ...`, as package managers set `npm_config_user_agent`. */
157
+ const decodeUserAgent = Schema.decodeUnknownOption(
158
+ Schema.TemplateLiteralParser([Executable, "/", Digits, Schema.String]),
159
+ )
160
+ const agentOf = (name: typeof Executable.Type, major: string): Agent =>
161
+ name === "yarn" ? (major === "1" ? "yarn-classic" : "yarn-berry") : name
71
162
 
72
163
  /** packageManager, then lockfile, then caller user-agent, then npm. Conflicting lockfiles fail. */
73
164
  export const detectAgent = Effect.fnUntraced(function* (options: Options) {
@@ -75,29 +166,26 @@ export const detectAgent = Effect.fnUntraced(function* (options: Options) {
75
166
  return yield* Schema.decodeEffect(Agent)(options.agent)
76
167
  const fs = yield* FileSystem.FileSystem
77
168
  const path = yield* Path.Path
78
- const manifest = yield* fs
79
- .readFileString(path.join(options.directory, "package.json"))
80
- .pipe(Effect.flatMap(decodeManifest))
169
+ const manifest = yield* readSource(
170
+ fs,
171
+ path.join(options.directory, "package.json"),
172
+ ).pipe(Effect.flatMap(decodeManifest))
81
173
  if (manifest.packageManager !== undefined) {
82
- const match = /^(npm|pnpm|yarn)@(\d+)\.\d+\.\d+(?:[-+].*)?$/.exec(
83
- manifest.packageManager,
84
- )
85
- if (match === null)
86
- return yield* PackageManagerError.make({
87
- operation: "detect",
88
- message: `Unsupported packageManager: ${manifest.packageManager}`,
89
- })
90
- return match[1] === "yarn"
91
- ? match[2] === "1"
92
- ? "yarn-classic"
93
- : "yarn-berry"
94
- : (match[1] as "npm" | "pnpm")
174
+ return yield* Option.match(decodePackageManager(manifest.packageManager), {
175
+ onNone: () =>
176
+ PackageManagerError.make({
177
+ operation: "detect",
178
+ message: `Unsupported packageManager: ${manifest.packageManager}`,
179
+ }),
180
+ onSome: ([name, , version]) =>
181
+ Effect.succeed(agentOf(name, `${version.major}`)),
182
+ })
95
183
  }
96
184
  const found = yield* Effect.filter(lockfiles, (file) =>
97
185
  fs.exists(path.join(options.directory, file)),
98
186
  )
99
- const kinds = Arr.dedupe(
100
- Arr.map(found, (file) =>
187
+ const kinds = Array.dedupe(
188
+ Array.map(found, (file) =>
101
189
  file === "pnpm-lock.yaml"
102
190
  ? "pnpm"
103
191
  : file === "yarn.lock"
@@ -117,14 +205,10 @@ export const detectAgent = Effect.fnUntraced(function* (options: Options) {
117
205
  return /^__metadata:/m.test(lock) ? "yarn-berry" : "yarn-classic"
118
206
  }
119
207
  if (kinds[0] !== undefined) return kinds[0]
120
- const caller = /^(npm|pnpm|yarn)\/(\d+)/.exec(options.userAgent ?? "")
121
- return caller?.[1] === "yarn"
122
- ? caller[2] === "1"
123
- ? "yarn-classic"
124
- : "yarn-berry"
125
- : caller?.[1] === "pnpm"
126
- ? "pnpm"
127
- : "npm"
208
+ return Option.match(decodeUserAgent(options.userAgent ?? ""), {
209
+ onNone: () => "npm" as const,
210
+ onSome: ([name, , major]) => agentOf(name, major),
211
+ })
128
212
  })
129
213
 
130
214
  export interface PackageState {
@@ -146,10 +230,10 @@ export interface StateDiff {
146
230
  readonly updated: ReadonlyArray<PackageChange>
147
231
  }
148
232
  export const diffState = (before: State, after: State): StateDiff => {
149
- const changes = Arr.map(
150
- Arr.sort(
151
- Arr.dedupe([...HashMap.keys(before), ...HashMap.keys(after)]),
152
- Str.Order,
233
+ const changes = Array.map(
234
+ Array.sort(
235
+ Array.dedupe([...HashMap.keys(before), ...HashMap.keys(after)]),
236
+ String.Order,
153
237
  ),
154
238
  (name) => ({
155
239
  name,
@@ -158,9 +242,9 @@ export const diffState = (before: State, after: State): StateDiff => {
158
242
  }),
159
243
  )
160
244
  return {
161
- added: Arr.filter(changes, (change) => change.before === undefined),
162
- removed: Arr.filter(changes, (change) => change.after === undefined),
163
- updated: Arr.filter(
245
+ added: Array.filter(changes, (change) => change.before === undefined),
246
+ removed: Array.filter(changes, (change) => change.after === undefined),
247
+ updated: Array.filter(
164
248
  changes,
165
249
  ({ before, after }) =>
166
250
  before !== undefined &&
@@ -173,19 +257,44 @@ export const diffState = (before: State, after: State): StateDiff => {
173
257
  }
174
258
  export interface InstallResult extends StateDiff {
175
259
  readonly agent: Agent
260
+ /** The install committed; retry only snapshot deletion, not installation. */
261
+ readonly snapshotCleanupFailure: Option.Option<SnapshotCleanupFailure>
176
262
  }
177
263
  export interface PackageManager {
178
264
  readonly directory: string
179
265
  readonly agent: Agent
180
266
  readonly readState: Effect.Effect<State, Error>
267
+ /** Present when manual recovery is required; the manager cannot be reused. */
268
+ readonly recoveryRequired: Effect.Effect<Option.Option<RecoveryRequiredError>>
181
269
  /** Serialized per manager. Empty changes installs the current manifest, including first installs. */
182
270
  readonly install: (changes: Changes) => Effect.Effect<InstallResult, Error>
183
271
  }
184
272
 
185
273
  /**
186
- * Own one manager per project. Failed or interrupted installs restore the exact
187
- * manifest/lockfiles, then attempt to repair node_modules when a lockfile existed.
188
- * Recovery failures are combined with the original cause. No crash recovery is promised.
274
+ * Own one manager per project. The caller must give this manager and its child
275
+ * processes exclusive write access to the project for each install, including
276
+ * recovery. Do not edit project files or run another installer concurrently.
277
+ * Calls are serialized only within this manager; no cross-process lock is taken.
278
+ * Managed paths must be regular files or absent. make and install reject symbolic
279
+ * links and other file types with UnsupportedFileTypeError before any write.
280
+ *
281
+ * Accepted file writes finish before cancellation can start rollback or release
282
+ * the lock. File writes and the original process group participate in the transaction.
283
+ * Scripts must not create escaped writers in independent groups or daemons.
284
+ * Failed or interrupted installs before commit restore backed-up package files byte-for-byte
285
+ * with recorded permissions and ownership, and remove tracked files that did not
286
+ * exist before installation. Writes made
287
+ * during installation are overwritten by rollback, never merged. When a lockfile
288
+ * existed, recovery also attempts to repair node_modules. Recovery failures are
289
+ * combined with the original cause. If process termination cannot be confirmed,
290
+ * no further restoration or recovery runs. Any failed rollback step also requires
291
+ * manual recovery, even if all writers stopped. Only successful installation or
292
+ * complete rollback permits snapshot deletion and reuse. RecoveryRequiredError
293
+ * and recoveryRequired expose the retained snapshot directory and complete cause;
294
+ * this manager rejects
295
+ * all later installs. Complete manual recovery before making a new manager.
296
+ * Committed installs report snapshot deletion failures in snapshotCleanupFailure
297
+ * rather than failing or rolling back. No crash recovery is promised.
189
298
  */
190
299
  export const make = (
191
300
  options: Options,
@@ -199,12 +308,14 @@ export const make = (
199
308
  const path = yield* Path.Path
200
309
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
201
310
  const directory = path.resolve(options.directory)
311
+ yield* inspectManagedFiles(fs, path, directory, backupFiles)
202
312
  const agent = yield* detectAgent({ ...options, directory })
203
313
  const lock = yield* Semaphore.make(1)
314
+ const transaction = yield* Ref.make<ManagerState>(ManagerState.Ready())
204
315
  const manifestFile = path.join(directory, "package.json")
205
- const readManifest = fs
206
- .readFileString(manifestFile)
207
- .pipe(Effect.flatMap(decodeManifest))
316
+ const readManifest = readSource(fs, manifestFile).pipe(
317
+ Effect.flatMap(decodeManifest),
318
+ )
208
319
  const installed = Schema.decodeUnknownEffect(
209
320
  Schema.fromJsonString(
210
321
  Schema.Struct({ version: Schema.optionalKey(Schema.String) }),
@@ -217,7 +328,7 @@ export const make = (
217
328
  message:
218
329
  "Plug'n'Play is not supported; configure Yarn with nodeLinker: node-modules",
219
330
  })
220
- const deps = Arr.reduce(
331
+ const deps = Array.reduce(
221
332
  fields,
222
333
  {} as Readonly<Record<string, string>>,
223
334
  (all, field) => ({ ...all, ...manifest[field] }),
@@ -253,6 +364,9 @@ export const make = (
253
364
  return HashMap.fromIterable<string, PackageState>(entries)
254
365
  })
255
366
  const readState = Effect.flatMap(readManifest, stateOf)
367
+ const termination: ChildProcess.KillOptions = {
368
+ forceKillAfter: "5 seconds",
369
+ }
256
370
  const run = Effect.fnUntraced(function* (operation: "install" | "recover") {
257
371
  const recovery = operation === "recover"
258
372
  const args =
@@ -278,113 +392,250 @@ export const make = (
278
392
  : []),
279
393
  ]
280
394
  : ["install", ...(recovery ? ["--immutable"] : [])]
281
- const code = yield* spawner
282
- .exitCode(
283
- ChildProcess.make(executable(agent), args, {
284
- cwd: directory,
285
- extendEnv: true,
286
- stdin: "ignore",
287
- stdout: "inherit",
288
- stderr: "inherit",
289
- env: {
290
- CI: "true",
291
- ...(agent === "yarn-berry"
292
- ? {
293
- YARN_ENABLE_IMMUTABLE_INSTALLS: recovery ? "true" : "false",
294
- YARN_NODE_LINKER: "node-modules",
295
- }
296
- : {}),
297
- },
298
- }),
299
- )
300
- .pipe(
301
- Effect.timeoutOrElse({
302
- duration: "10 minutes",
303
- orElse: () =>
304
- PackageManagerError.make({
395
+ // Acquire and install the release handler atomically, then wait interruptibly.
396
+ return yield* Effect.acquireUseRelease(
397
+ spawner
398
+ .spawn(
399
+ ChildProcess.make(executable(agent), args, {
400
+ cwd: directory,
401
+ extendEnv: true,
402
+ stdin: "ignore",
403
+ stdout: "inherit",
404
+ stderr: "inherit",
405
+ detached: true,
406
+ ...termination,
407
+ env: {
408
+ CI: "true",
409
+ ...(agent === "yarn-berry"
410
+ ? {
411
+ YARN_ENABLE_IMMUTABLE_INSTALLS: recovery
412
+ ? "true"
413
+ : "false",
414
+ YARN_NODE_LINKER: "node-modules",
415
+ }
416
+ : {}),
417
+ },
418
+ }),
419
+ )
420
+ .pipe(
421
+ Effect.tap(() =>
422
+ Ref.set(
423
+ transaction,
424
+ ManagerState.Active({ writer: WriterState.Running() }),
425
+ ),
426
+ ),
427
+ ),
428
+ (process) =>
429
+ Effect.gen(function* () {
430
+ const code = yield* process.exitCode.pipe(
431
+ Effect.timeoutOrElse({
432
+ duration: "10 minutes",
433
+ orElse: () =>
434
+ PackageManagerError.make({
435
+ operation,
436
+ message: `${executable(agent)} ${operation} timed out`,
437
+ }),
438
+ }),
439
+ )
440
+ if (code !== 0)
441
+ return yield* PackageManagerError.make({
305
442
  operation,
306
- message: `${executable(agent)} ${operation} timed out`,
443
+ exitCode: code,
444
+ message: `${executable(agent)} ${operation} exited with code ${code}`,
445
+ })
446
+ }),
447
+ // The command owns its group; a stopped leader may leave live descendants.
448
+ // Observe kill failures here rather than relying on best-effort scope cleanup.
449
+ (process) =>
450
+ Effect.gen(function* () {
451
+ const result = yield* terminateProcess(process, termination)
452
+ yield* Ref.set(
453
+ transaction,
454
+ ManagerState.Active({
455
+ writer:
456
+ result._tag === "OriginalGroupStopped"
457
+ ? WriterState.ManagedWritersIdle()
458
+ : WriterState.Unconfirmed({
459
+ operation,
460
+ processId: process.pid,
461
+ cause: result.cause,
462
+ }),
307
463
  }),
464
+ )
465
+ if (result._tag === "Unconfirmed")
466
+ return yield* Effect.failCause(result.cause)
308
467
  }),
309
- )
310
- if (code !== 0)
311
- return yield* PackageManagerError.make({
312
- operation,
313
- exitCode: code,
314
- message: `${executable(agent)} ${operation} exited with code ${code}`,
315
- })
316
- })
468
+ )
469
+ }, Effect.scoped)
470
+ // AbortSignal does not join accepted OS writes. Keep the writer owned until completion.
471
+ const withFileWriter = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
472
+ Effect.acquireUseRelease(
473
+ Ref.set(
474
+ transaction,
475
+ ManagerState.Active({ writer: WriterState.FileWrite() }),
476
+ ),
477
+ () => effect,
478
+ () =>
479
+ Ref.set(
480
+ transaction,
481
+ ManagerState.Active({ writer: WriterState.ManagedWritersIdle() }),
482
+ ),
483
+ ).pipe(Effect.uninterruptible)
484
+
317
485
  const install = (changes: Changes) =>
318
486
  lock.withPermits(1)(
319
487
  Effect.gen(function* () {
488
+ const state = yield* Ref.get(transaction)
489
+ if (state._tag === "RecoveryRequired") return yield* state.error
320
490
  const validated = yield* Schema.decodeEffect(Changes)(changes)
321
- const original = yield* fs.readFileString(manifestFile)
491
+ const inspected = yield* inspectManagedFiles(
492
+ fs,
493
+ path,
494
+ directory,
495
+ backupFiles,
496
+ )
497
+ const backups = yield* Effect.forEach(
498
+ inspected,
499
+ ({ name, file, metadata }) =>
500
+ Effect.gen(function* () {
501
+ const contents = yield* Option.match(metadata, {
502
+ onNone: () => Effect.succeedNone,
503
+ onSome: (info) =>
504
+ fs
505
+ .readFile(file)
506
+ .pipe(
507
+ Effect.map((bytes) =>
508
+ Option.some<Contents>({ ...info, bytes }),
509
+ ),
510
+ ),
511
+ })
512
+ return { name, file, contents }
513
+ }),
514
+ )
515
+ const original = yield* readSource(fs, manifestFile)
322
516
  const manifest = yield* decodeManifest(original)
323
517
  const before = yield* stateOf(manifest)
324
518
  const text = yield* encodeManifest(
325
519
  applyChanges(manifest, validated),
326
520
  original,
327
521
  )
328
- const backups = yield* Effect.forEach(backupFiles, (name) =>
329
- Effect.gen(function* () {
330
- const file = path.join(directory, name)
331
- return {
332
- name,
333
- file,
334
- bytes: (yield* fs.exists(file))
335
- ? yield* fs.readFile(file).pipe(Effect.asSome)
336
- : Option.none<Uint8Array>(),
337
- }
338
- }),
339
- )
340
- // Every file gets a restoration attempt; onExit combines failures.
341
- const restore = Arr.reduce<
342
- (typeof backups)[number],
343
- Effect.Effect<void, PlatformError.PlatformError>
344
- >(backups, Effect.void, (previous, { file, bytes }) =>
345
- previous.pipe(
346
- Effect.onExit(() =>
347
- Option.match(bytes, {
348
- onNone: () => fs.remove(file, { force: true }),
349
- onSome: (bytes) => fs.writeFile(file, bytes),
350
- }),
351
- ),
352
- ),
353
- )
522
+
354
523
  const agentLocks =
355
524
  agent === "npm"
356
525
  ? ["package-lock.json", "npm-shrinkwrap.json"]
357
526
  : agent === "pnpm"
358
527
  ? ["pnpm-lock.yaml"]
359
528
  : ["yarn.lock"]
360
- const hasLock = Arr.some(
529
+ const hasLock = Array.some(
361
530
  backups,
362
- ({ name, bytes }) =>
363
- Arr.contains(agentLocks, name) && Option.isSome(bytes),
531
+ ({ name, contents }) =>
532
+ Array.contains(agentLocks, name) && Option.isSome(contents),
364
533
  )
365
- return yield* Effect.acquireUseRelease(
366
- Effect.void,
367
- () =>
534
+ const completed = yield* withSnapshot(
535
+ fs,
536
+ Ref.get(transaction).pipe(
537
+ Effect.map((state) => state._tag === "Ready"),
538
+ ),
539
+ (snapshotDirectory) =>
368
540
  Effect.gen(function* () {
369
- yield* fs.writeFileString(manifestFile, text)
370
- yield* run("install")
371
- const after = yield* readState
372
- return { agent, ...diffState(before, after) }
373
- }),
374
- (_, exit) =>
375
- Exit.isSuccess(exit)
376
- ? Effect.void
377
- : restore.pipe(
378
- Effect.andThen(hasLock ? run("recover") : Effect.void),
379
- // Recovery itself may rewrite the manifest or lockfile, including on failure.
380
- Effect.onExit(() => restore),
541
+ const snapshot = yield* writeSnapshot(
542
+ fs,
543
+ path,
544
+ snapshotDirectory,
545
+ directory,
546
+ agent,
547
+ backups,
548
+ )
549
+ const restore = Effect.flatMap(Ref.get(transaction), (state) =>
550
+ state._tag === "Active" &&
551
+ state.writer._tag === "ManagedWritersIdle"
552
+ ? withFileWriter(snapshot.restore(state.writer))
553
+ : Effect.void,
554
+ )
555
+ return yield* Effect.acquireUseRelease(
556
+ Ref.set(
557
+ transaction,
558
+ ManagerState.Active({
559
+ writer: WriterState.ManagedWritersIdle(),
560
+ }),
381
561
  ),
562
+ () =>
563
+ Effect.gen(function* () {
564
+ yield* withFileWriter(
565
+ fs.writeFileString(manifestFile, text),
566
+ )
567
+ yield* run("install")
568
+ const after = yield* readState
569
+ return { agent, ...diffState(before, after) }
570
+ }),
571
+ (_, exit) =>
572
+ Effect.gen(function* () {
573
+ if (Exit.isFailure(exit)) {
574
+ const state = yield* Ref.get(transaction)
575
+ if (
576
+ state._tag !== "Active" ||
577
+ state.writer._tag !== "ManagedWritersIdle"
578
+ )
579
+ return
580
+ yield* withFileWriter(snapshot.restore(state.writer))
581
+ if (hasLock)
582
+ yield* run("recover").pipe(
583
+ // Even a failed recovery must restore files, once its writer has stopped.
584
+ Effect.onExit(() => restore),
585
+ )
586
+ }
587
+ // Idle managed writers alone do not prove that the transaction completed.
588
+ yield* Ref.set(transaction, ManagerState.Ready())
589
+ }),
590
+ ).pipe(
591
+ Effect.onExit((exit) =>
592
+ Effect.gen(function* () {
593
+ const state = yield* Ref.get(transaction)
594
+ if (Exit.isSuccess(exit) || state._tag !== "Active")
595
+ return
596
+ const writer = state.writer
597
+ const error = RecoveryRequiredError.make({
598
+ directory,
599
+ snapshotDirectory,
600
+ operation:
601
+ writer._tag === "Unconfirmed"
602
+ ? writer.operation
603
+ : "rollback",
604
+ ...(writer._tag === "Unconfirmed"
605
+ ? {
606
+ processId: writer.processId,
607
+ terminationCause: writer.cause,
608
+ }
609
+ : {}),
610
+ message: `Manual recovery is required. Stop all writers before restoring the snapshot at ${snapshotDirectory}`,
611
+ recoveryCause: exit.cause,
612
+ })
613
+ yield* Ref.set(
614
+ transaction,
615
+ ManagerState.RecoveryRequired({ error }),
616
+ )
617
+ return yield* error
618
+ }),
619
+ ),
620
+ )
621
+ }),
382
622
  )
623
+ return {
624
+ ...completed.value,
625
+ snapshotCleanupFailure: completed.snapshotCleanupFailure,
626
+ }
383
627
  }),
384
628
  )
385
629
  return {
386
630
  directory,
387
631
  agent,
632
+ recoveryRequired: Ref.get(transaction).pipe(
633
+ Effect.map((state) =>
634
+ state._tag === "RecoveryRequired"
635
+ ? Option.some(state.error)
636
+ : Option.none(),
637
+ ),
638
+ ),
388
639
  readState: lock.withPermits(1)(readState),
389
640
  install,
390
641
  }