| export * as FileMutation from "./file-mutation" |
|
|
| import { makeLocationNode } from "./effect/app-node" |
| import { Context, Effect, Layer, Schema } from "effect" |
| import { dirname } from "path" |
| import { KeyedMutex } from "./effect/keyed-mutex" |
| import { FSUtil } from "./fs-util" |
|
|
| export interface Target { |
| readonly canonical: string |
| readonly resource: string |
| } |
|
|
| export interface WriteInput { |
| readonly target: Target |
| readonly content: string | Uint8Array |
| } |
|
|
| export interface TextWriteInput { |
| readonly target: Target |
| readonly content: string |
| } |
|
|
| export interface ConditionalWriteInput extends WriteInput { |
| readonly expected: Uint8Array |
| } |
|
|
| export interface RemoveInput { |
| readonly target: Target |
| } |
|
|
| export class StaleContentError extends Schema.TaggedErrorClass<StaleContentError>()("FileMutation.StaleContentError", { |
| path: Schema.String, |
| }) {} |
|
|
| export class TargetExistsError extends Schema.TaggedErrorClass<TargetExistsError>()("FileMutation.TargetExistsError", { |
| path: Schema.String, |
| }) {} |
|
|
| export interface WriteResult { |
| readonly operation: "write" |
| readonly target: string |
| readonly resource: string |
| readonly existed: boolean |
| } |
|
|
| export interface RemoveResult { |
| readonly operation: "remove" |
| readonly target: string |
| readonly resource: string |
| readonly existed: boolean |
| } |
|
|
| export interface Interface { |
| |
| readonly create: (input: WriteInput) => Effect.Effect<WriteResult, TargetExistsError | FSUtil.Error> |
| readonly write: (input: WriteInput) => Effect.Effect<WriteResult, FSUtil.Error> |
| |
| readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect<WriteResult, FSUtil.Error> |
| |
| readonly writeIfUnchanged: ( |
| input: ConditionalWriteInput, |
| ) => Effect.Effect<WriteResult, StaleContentError | FSUtil.Error> |
| readonly remove: (input: RemoveInput) => Effect.Effect<RemoveResult, FSUtil.Error> |
| } |
|
|
| export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileMutation") {} |
|
|
| |
| |
| |
| |
| |
| const layer = Layer.effect( |
| Service, |
| Effect.gen(function* () { |
| const fs = yield* FSUtil.Service |
| const locks = KeyedMutex.makeUnsafe<string>() |
| const withTargetLock = |
| (target: Target) => |
| <A, E, R>(effect: Effect.Effect<A, E, R>) => |
| locks.withLock(target.canonical)(Effect.uninterruptible(effect)) |
|
|
| const writeResult = (target: Target, existed: boolean): WriteResult => ({ |
| operation: "write", |
| target: target.canonical, |
| resource: target.resource, |
| existed, |
| }) |
|
|
| const removeResult = (target: Target, existed: boolean): RemoveResult => ({ |
| operation: "remove", |
| target: target.canonical, |
| resource: target.resource, |
| existed, |
| }) |
|
|
| const write = Effect.fn("FileMutation.write")((input: WriteInput) => |
| withTargetLock(input.target)( |
| Effect.gen(function* () { |
| const existed = yield* fs.exists(input.target.canonical) |
| yield* fs.writeWithDirs(input.target.canonical, input.content) |
| return writeResult(input.target, existed) |
| }), |
| ), |
| ) |
|
|
| const writeTextPreservingBom = Effect.fn("FileMutation.writeTextPreservingBom")((input: TextWriteInput) => |
| withTargetLock(input.target)( |
| Effect.gen(function* () { |
| const next = splitBom(input.content) |
| const current = yield* fs |
| .readFile(input.target.canonical) |
| .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))) |
| yield* fs.writeWithDirs( |
| input.target.canonical, |
| joinBom(next.text, Boolean(current && hasUtf8Bom(current)) || next.bom), |
| ) |
| return writeResult(input.target, current !== undefined) |
| }), |
| ), |
| ) |
|
|
| const create = Effect.fn("FileMutation.create")((input: WriteInput) => |
| withTargetLock(input.target)( |
| Effect.gen(function* () { |
| const write = |
| typeof input.content === "string" |
| ? fs.writeFileString(input.target.canonical, input.content, { flag: "wx" }) |
| : fs.writeFile(input.target.canonical, input.content, { flag: "wx" }) |
| yield* write.pipe( |
| Effect.catchReason("PlatformError", "NotFound", () => |
| fs.ensureDir(dirname(input.target.canonical)).pipe(Effect.andThen(write)), |
| ), |
| Effect.catchReason("PlatformError", "AlreadyExists", () => |
| Effect.fail(new TargetExistsError({ path: input.target.canonical })), |
| ), |
| ) |
| return writeResult(input.target, false) |
| }), |
| ), |
| ) |
|
|
| const writeIfUnchanged = Effect.fn("FileMutation.writeIfUnchanged")((input: ConditionalWriteInput) => |
| withTargetLock(input.target)( |
| Effect.gen(function* () { |
| const current = yield* fs.readFile(input.target.canonical) |
| if (!sameBytes(current, input.expected)) { |
| return yield* new StaleContentError({ path: input.target.canonical }) |
| } |
| yield* typeof input.content === "string" |
| ? fs.writeFileString(input.target.canonical, input.content) |
| : fs.writeFile(input.target.canonical, input.content) |
| return writeResult(input.target, true) |
| }), |
| ), |
| ) |
|
|
| const remove = Effect.fn("FileMutation.remove")((input: RemoveInput) => |
| withTargetLock(input.target)( |
| Effect.gen(function* () { |
| const existed = yield* fs.remove(input.target.canonical).pipe( |
| Effect.as(true), |
| Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(false)), |
| ) |
| return removeResult(input.target, existed) |
| }), |
| ), |
| ) |
|
|
| return Service.of({ create, write, writeTextPreservingBom, writeIfUnchanged, remove }) |
| }), |
| ) |
|
|
| function splitBom(text: string) { |
| const stripped = text.replace(/^\uFEFF+/, "") |
| return { bom: stripped.length !== text.length, text: stripped } |
| } |
|
|
| function joinBom(text: string, bom: boolean) { |
| const stripped = splitBom(text).text |
| return bom ? `\uFEFF${stripped}` : stripped |
| } |
|
|
| function hasUtf8Bom(content: Uint8Array) { |
| return content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf |
| } |
|
|
| function sameBytes(left: Uint8Array, right: Uint8Array) { |
| if (left.length !== right.length) return false |
| return left.every((byte, index) => byte === right[index]) |
| } |
|
|
| export const locationLayer = layer |
|
|
| export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] }) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|