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