diff --git a/.changeset/add-deno-file-system.md b/.changeset/add-deno-file-system.md new file mode 100644 index 00000000000..6bd6a73dff6 --- /dev/null +++ b/.changeset/add-deno-file-system.md @@ -0,0 +1,5 @@ +--- +"@effect/platform-deno": patch +--- + +Add a Deno-backed FileSystem layer. diff --git a/packages/effect/test/FileSystem.test-utils.ts b/packages/effect/test/FileSystem.test-utils.ts new file mode 100644 index 00000000000..abb995d1869 --- /dev/null +++ b/packages/effect/test/FileSystem.test-utils.ts @@ -0,0 +1,354 @@ +import { assert, expect, it } from "@effect/vitest" +import { Array } from "effect" +import * as Effect from "effect/Effect" +import * as Fs from "effect/FileSystem" +import type * as Layer from "effect/Layer" +import * as Stream from "effect/Stream" + +export interface TestLayerOptions { + /** Whether writable access to a directory is supported. Deno's open-based access check rejects directories. Defaults to `true`. */ + readonly accessOnDirectory?: boolean + /** Whether a scoped temporary file removes its containing directory. Deno removes only the file. Defaults to `true`. */ + readonly tempFileScopedRemovesDirectory?: boolean +} + +export const testLayer = (layer: Layer.Layer, options: TestLayerOptions = {}) => { + const runPromise = (self: Effect.Effect) => + Effect.runPromise( + Effect.provide(self, layer) + ) + + it("readFile", () => + runPromise(Effect.gen(function*() { + const fs = yield* Fs.FileSystem + const data = yield* fs.readFile(`${__dirname}/fixtures/text.txt`) + const text = new TextDecoder().decode(data) + expect(text.trim()).toEqual("lorem ipsum dolar sit amet") + }))) + + it("makeTempDirectory", () => + runPromise(Effect.gen(function*() { + const fs = yield* Fs.FileSystem + let dir = "" + yield* Effect.scoped(Effect.gen(function*() { + dir = yield* fs.makeTempDirectory() + const stat = yield* fs.stat(dir) + expect(stat.type).toEqual("Directory") + })) + const stat = yield* fs.stat(dir) + expect(stat.type).toEqual("Directory") + }))) + + it("makeTempDirectoryScoped", () => + runPromise(Effect.gen(function*() { + const fs = yield* Fs.FileSystem + let dir = "" + yield* Effect.scoped( + Effect.gen(function*() { + dir = yield* fs.makeTempDirectoryScoped() + const stat = yield* fs.stat(dir) + expect(stat.type).toEqual("Directory") + }) + ) + const error = yield* Effect.flip(fs.stat(dir)) + assert(error.reason._tag === "NotFound") + }))) + + it.skipIf(options.accessOnDirectory === false)( + "access on a writable directory", + () => + runPromise(Effect.gen(function*() { + const fs = yield* Fs.FileSystem + yield* Effect.scoped(Effect.gen(function*() { + const dir = yield* fs.makeTempDirectoryScoped() + yield* fs.access(dir, { writable: true }) + })) + })) + ) + + it("makeTempFileScoped cleans up", () => + runPromise(Effect.gen(function*() { + const fs = yield* Fs.FileSystem + yield* Effect.scoped(Effect.gen(function*() { + const root = yield* fs.makeTempDirectoryScoped() + let file = "" + let dir = "" + yield* Effect.scoped(Effect.gen(function*() { + file = yield* fs.makeTempFileScoped({ directory: root }) + const separator = Math.max(file.lastIndexOf("/"), file.lastIndexOf("\\")) + assert(separator > 0, "Expected temp file path to contain a directory separator") + dir = file.slice(0, separator) + const stat = yield* fs.stat(dir) + expect(stat.type).toEqual("Directory") + })) + const fileError = yield* Effect.flip(fs.stat(file)) + assert(fileError.reason._tag === "NotFound") + if (options.tempFileScopedRemovesDirectory !== false) { + const directoryError = yield* Effect.flip(fs.stat(dir)) + assert(directoryError.reason._tag === "NotFound") + } + })) + }))) + + it("truncate", () => + runPromise(Effect.gen(function*() { + const fs = yield* Fs.FileSystem + const file = yield* fs.makeTempFile() + + const text = "hello world" + yield* fs.writeFile(file, new TextEncoder().encode(text)) + + const before = yield* Effect.map(fs.readFile(file), (_) => new TextDecoder().decode(_)) + expect(before).toEqual(text) + + yield* fs.truncate(file) + + const after = yield* Effect.map(fs.readFile(file), (_) => new TextDecoder().decode(_)) + expect(after).toEqual("") + }))) + + it("should track the cursor position when reading", () => + runPromise(Effect.gen(function*() { + const fs = yield* Fs.FileSystem + + yield* Effect.gen(function*() { + let text: string + const file = yield* fs.open(`${__dirname}/fixtures/text.txt`) + + text = yield* file.readAlloc(Fs.Size(5)).pipe( + Effect.flatMap(Effect.fromOption), + Effect.map((_) => new TextDecoder().decode(_)) + ) + expect(text).toBe("lorem") + + yield* file.seek(Fs.Size(7), "current") + text = yield* file.readAlloc(Fs.Size(5)).pipe( + Effect.flatMap(Effect.fromOption), + Effect.map((_) => new TextDecoder().decode(_)) + ) + expect(text).toBe("dolar") + + yield* file.seek(Fs.Size(1), "current") + text = yield* file.readAlloc(Fs.Size(8)).pipe( + Effect.flatMap(Effect.fromOption), + Effect.map((_) => new TextDecoder().decode(_)) + ) + expect(text).toBe("sit amet") + + yield* file.seek(Fs.Size(0), "start") + text = yield* file.readAlloc(Fs.Size(11)).pipe( + Effect.flatMap(Effect.fromOption), + Effect.map((_) => new TextDecoder().decode(_)) + ) + expect(text).toBe("lorem ipsum") + + text = yield* fs.stream(`${__dirname}/fixtures/text.txt`, { offset: Fs.Size(6), bytesToRead: Fs.Size(5) }).pipe( + Stream.map((_) => new TextDecoder().decode(_)), + Stream.runCollect, + Effect.map(Array.join("")) + ) + expect(text).toBe("ipsum") + }).pipe( + Effect.scoped + ) + }))) + + it("should read from a backwards seek", () => + runPromise(Effect.gen(function*() { + const fs = yield* Fs.FileSystem + + yield* Effect.gen(function*() { + const file = yield* fs.open(`${__dirname}/fixtures/text.txt`) + + const first = yield* file.readAlloc(Fs.Size(5)).pipe( + Effect.flatMap(Effect.fromOption), + Effect.map((_) => new TextDecoder().decode(_)) + ) + expect(first).toBe("lorem") + + yield* file.seek(Fs.Size(-3), "current") + const second = yield* file.readAlloc(Fs.Size(3)).pipe( + Effect.flatMap(Effect.fromOption), + Effect.map((_) => new TextDecoder().decode(_)) + ) + expect(second).toBe("rem") + }).pipe( + Effect.scoped + ) + }))) + + it("should read sequentially without an intervening seek", () => + runPromise(Effect.gen(function*() { + const fs = yield* Fs.FileSystem + + yield* Effect.gen(function*() { + const file = yield* fs.open(`${__dirname}/fixtures/text.txt`) + + const first = yield* file.readAlloc(Fs.Size(5)).pipe( + Effect.flatMap(Effect.fromOption), + Effect.map((_) => new TextDecoder().decode(_)) + ) + expect(first).toBe("lorem") + + const second = yield* file.readAlloc(Fs.Size(6)).pipe( + Effect.flatMap(Effect.fromOption), + Effect.map((_) => new TextDecoder().decode(_)) + ) + expect(second).toBe(" ipsum") + }).pipe( + Effect.scoped + ) + }))) + + it("should track the cursor position when writing", () => + runPromise(Effect.gen(function*() { + const fs = yield* Fs.FileSystem + + yield* Effect.gen(function*() { + let text: string + const path = yield* fs.makeTempFileScoped() + const file = yield* fs.open(path, { flag: "w+" }) + + yield* file.write(new TextEncoder().encode("lorem ipsum")) + yield* file.write(new TextEncoder().encode(" ")) + yield* file.write(new TextEncoder().encode("dolor sit amet")) + text = yield* fs.readFileString(path) + expect(text).toBe("lorem ipsum dolor sit amet") + + yield* file.seek(Fs.Size(-4), "current") + yield* file.write(new TextEncoder().encode("hello world")) + text = yield* fs.readFileString(path) + expect(text).toBe("lorem ipsum dolor sit hello world") + + yield* file.seek(Fs.Size(6), "start") + yield* file.write(new TextEncoder().encode("blabl")) + text = yield* fs.readFileString(path) + expect(text).toBe("lorem blabl dolor sit hello world") + }).pipe( + Effect.scoped + ) + }))) + + it("should maintain a read cursor in append mode", () => + runPromise(Effect.gen(function*() { + const fs = yield* Fs.FileSystem + + yield* Effect.gen(function*() { + let text: string + const path = yield* fs.makeTempFileScoped() + const file = yield* fs.open(path, { flag: "a+" }) + + yield* file.write(new TextEncoder().encode("foo")) + yield* file.seek(Fs.Size(0), "start") + + yield* file.write(new TextEncoder().encode("bar")) + text = yield* fs.readFileString(path) + expect(text).toBe("foobar") + + text = yield* file.readAlloc(Fs.Size(3)).pipe( + Effect.flatMap(Effect.fromOption), + Effect.map((_) => new TextDecoder().decode(_)) + ) + expect(text).toBe("foo") + + yield* file.write(new TextEncoder().encode("baz")) + text = yield* fs.readFileString(path) + expect(text).toBe("foobarbaz") + + text = yield* file.readAlloc(Fs.Size(6)).pipe( + Effect.flatMap(Effect.fromOption), + Effect.map((_) => new TextDecoder().decode(_)) + ) + expect(text).toBe("barbaz") + }).pipe( + Effect.scoped + ) + }))) + + it("should restore the read cursor after an append write", () => + runPromise(Effect.gen(function*() { + const fs = yield* Fs.FileSystem + + yield* Effect.gen(function*() { + const path = yield* fs.makeTempFileScoped() + const file = yield* fs.open(path, { flag: "a+" }) + + yield* file.write(new TextEncoder().encode("foo")) + yield* file.seek(Fs.Size(0), "start") + + const first = yield* file.readAlloc(Fs.Size(1)).pipe( + Effect.flatMap(Effect.fromOption), + Effect.map((_) => new TextDecoder().decode(_)) + ) + expect(first).toBe("f") + + yield* file.write(new TextEncoder().encode("bar")) + const second = yield* file.readAlloc(Fs.Size(2)).pipe( + Effect.flatMap(Effect.fromOption), + Effect.map((_) => new TextDecoder().decode(_)) + ) + expect(second).toBe("oo") + }).pipe( + Effect.scoped + ) + }))) + + it("should keep the current cursor if truncating doesn't affect it", () => + runPromise(Effect.gen(function*() { + const fs = yield* Fs.FileSystem + + yield* Effect.gen(function*() { + const path = yield* fs.makeTempFileScoped() + const file = yield* fs.open(path, { flag: "w+" }) + + yield* file.write(new TextEncoder().encode("lorem ipsum dolor sit amet")) + yield* file.seek(Fs.Size(6), "start") + yield* file.truncate(Fs.Size(11)) + + const cursor = yield* file.seek(Fs.Size(0), "current") + expect(cursor).toBe(Fs.Size(6)) + }).pipe( + Effect.scoped + ) + }))) + + it("should update the current cursor if truncating affects it", () => + runPromise(Effect.gen(function*() { + const fs = yield* Fs.FileSystem + + yield* Effect.gen(function*() { + const path = yield* fs.makeTempFileScoped() + const file = yield* fs.open(path, { flag: "w+" }) + + yield* file.write(new TextEncoder().encode("lorem ipsum dolor sit amet")) + yield* file.truncate(Fs.Size(11)) + + const cursor = yield* file.seek(Fs.Size(0), "current") + expect(cursor).toBe(Fs.Size(11)) + }).pipe( + Effect.scoped + ) + }))) + + it("should read from the clamped cursor after truncating", () => + runPromise(Effect.gen(function*() { + const fs = yield* Fs.FileSystem + + yield* Effect.gen(function*() { + const path = yield* fs.makeTempFileScoped() + const file = yield* fs.open(path, { flag: "w+" }) + + yield* file.write(new TextEncoder().encode("abcdefghij")) + yield* file.truncate(Fs.Size(5)) + yield* fs.writeFile(path, new TextEncoder().encode("xyz"), { flag: "a" }) + + const text = yield* file.readAlloc(Fs.Size(3)).pipe( + Effect.flatMap(Effect.fromOption), + Effect.map((_) => new TextDecoder().decode(_)) + ) + expect(text).toBe("xyz") + }).pipe( + Effect.scoped + ) + }))) +} diff --git a/packages/platform-node-shared/test/fixtures/text.txt b/packages/effect/test/fixtures/text.txt similarity index 100% rename from packages/platform-node-shared/test/fixtures/text.txt rename to packages/effect/test/fixtures/text.txt diff --git a/packages/platform-deno/package.json b/packages/platform-deno/package.json index 84522a83df1..d6299c8c0b3 100644 --- a/packages/platform-deno/package.json +++ b/packages/platform-deno/package.json @@ -62,6 +62,7 @@ }, "dependencies": { "@db/redis": "jsr:^0.41.2", + "@std/fs": "jsr:^1.0.24", "@std/path": "jsr:^1.1.6" }, "peerDependencies": { diff --git a/packages/platform-deno/src/DenoFileSystem.ts b/packages/platform-deno/src/DenoFileSystem.ts new file mode 100644 index 00000000000..2b2f993ec31 --- /dev/null +++ b/packages/platform-deno/src/DenoFileSystem.ts @@ -0,0 +1,484 @@ +/** + * Deno implementation of Effect's `FileSystem` service. + * + * @since 4.0.0 + */ +import { copy as denoCopy, expandGlob, walk } from "@std/fs" +import { relative } from "@std/path" +import * as Effect from "effect/Effect" +import * as FileSystem from "effect/FileSystem" +import * as Layer from "effect/Layer" +import * as Option from "effect/Option" +import * as PlatformError from "effect/PlatformError" +import * as Stream from "effect/Stream" +import { handleError } from "./internal/error.ts" + +const tryPromise = ( + method: string, + pathOrDescriptor: string | number | undefined, + evaluate: (signal: AbortSignal) => PromiseLike +) => + Effect.tryPromise({ + try: evaluate, + catch: handleError("FileSystem", method, pathOrDescriptor) + }) + +const close = (file: Deno.FsFile, method: string, pathOrDescriptor: string | number) => + Effect.orDie(Effect.try({ + try: () => file.close(), + catch: handleError("FileSystem", method, pathOrDescriptor) + })) + +const collectAsyncIterable = ( + method: string, + pathOrDescriptor: string | number | undefined, + evaluate: () => AsyncIterable +) => + Effect.tryPromise({ + try: async () => { + const values = new Array() + for await (const value of evaluate()) { + values.push(value) + } + return values + }, + catch: handleError("FileSystem", method, pathOrDescriptor) + }) + +const access: FileSystem.FileSystem["access"] = (path, options) => { + if (!options?.readable && !options?.writable) { + return Effect.asVoid(tryPromise("access", path, () => Deno.stat(path))) + } + return Effect.acquireUseRelease( + tryPromise("access", path, () => + Deno.open(path, { + ...(options.readable ? { read: true } : {}), + ...(options.writable ? { write: true } : {}) + })), + () => Effect.void, + (file) => close(file, "access", path) + ) +} + +const copy: FileSystem.FileSystem["copy"] = (fromPath, toPath, options) => + tryPromise("copy", fromPath, () => + denoCopy(fromPath, toPath, { + overwrite: options?.overwrite ?? false, + preserveTimestamps: options?.preserveTimestamps ?? false + })) + +const copyFile: FileSystem.FileSystem["copyFile"] = (fromPath, toPath) => + tryPromise("copyFile", fromPath, () => Deno.copyFile(fromPath, toPath)) + +const chmod: FileSystem.FileSystem["chmod"] = (path, mode) => tryPromise("chmod", path, () => Deno.chmod(path, mode)) + +const chown: FileSystem.FileSystem["chown"] = (path, uid, gid) => + tryPromise("chown", path, () => Deno.chown(path, uid, gid)) + +const glob: FileSystem.FileSystem["glob"] = (pattern, options) => + Effect.map( + collectAsyncIterable("glob", pattern, () => + expandGlob(pattern, { + root: options?.root ?? Deno.cwd(), + exclude: options?.exclude ? [...options.exclude] : [] + })), + (entries) => entries.map((entry) => entry.path) + ) + +const link: FileSystem.FileSystem["link"] = (existingPath, newPath) => + tryPromise("link", existingPath, () => Deno.link(existingPath, newPath)) + +const makeDirectory: FileSystem.FileSystem["makeDirectory"] = (path, options) => + tryPromise("makeDirectory", path, () => + Deno.mkdir(path, { + recursive: options?.recursive ?? false, + ...(options?.mode === undefined ? {} : { mode: options.mode }) + })) + +const makeTempDirectoryFactory = (method: string): FileSystem.FileSystem["makeTempDirectory"] => (options) => + tryPromise(method, options?.directory, () => + Deno.makeTempDir({ + ...(options?.directory === undefined ? {} : { dir: options.directory }), + ...(options?.prefix === undefined ? {} : { prefix: options.prefix }) + })) + +const makeTempDirectory = makeTempDirectoryFactory("makeTempDirectory") + +const removeFactory = (method: string): FileSystem.FileSystem["remove"] => (path, options) => { + const effect = tryPromise(method, path, () => Deno.remove(path, { recursive: options?.recursive ?? false })) + return options?.force + ? Effect.catchTag( + effect, + "PlatformError", + (error) => error.reason._tag === "NotFound" ? Effect.void : Effect.fail(error) + ) + : effect +} + +const remove = removeFactory("remove") + +const makeTempDirectoryScoped: FileSystem.FileSystem["makeTempDirectoryScoped"] = (options) => + Effect.acquireRelease( + makeTempDirectoryFactory("makeTempDirectoryScoped")(options), + (directory) => Effect.orDie(removeFactory("makeTempDirectoryScoped")(directory, { recursive: true })) + ) + +const openOptions = (flag: FileSystem.OpenFlag = "r", mode?: number): Deno.OpenOptions => { + const modeOption = mode === undefined ? {} : { mode } + switch (flag) { + case "r": + return { read: true, ...modeOption } + case "r+": + return { read: true, write: true, ...modeOption } + case "w": + return { write: true, create: true, truncate: true, ...modeOption } + case "wx": + return { write: true, createNew: true, ...modeOption } + case "w+": + return { read: true, write: true, create: true, truncate: true, ...modeOption } + case "wx+": + return { read: true, write: true, createNew: true, ...modeOption } + case "a": + return { append: true, create: true, ...modeOption } + case "ax": + return { append: true, createNew: true, ...modeOption } + case "a+": + return { read: true, append: true, create: true, ...modeOption } + case "ax+": + return { read: true, append: true, createNew: true, ...modeOption } + } +} + +const makeFileInfo = (info: Deno.FileInfo): FileSystem.File.Info => ({ + type: info.isFile ? + "File" : + info.isDirectory ? + "Directory" : + info.isSymlink ? + "SymbolicLink" : + info.isBlockDevice ? + "BlockDevice" : + info.isCharDevice ? + "CharacterDevice" : + info.isFifo ? + "FIFO" : + info.isSocket ? + "Socket" : + "Unknown", + mtime: Option.fromNullishOr(info.mtime), + atime: Option.fromNullishOr(info.atime), + birthtime: Option.fromNullishOr(info.birthtime), + dev: info.dev, + rdev: Option.fromNullishOr(info.rdev), + ino: Option.fromNullishOr(info.ino), + mode: info.mode ?? 0, + nlink: Option.fromNullishOr(info.nlink), + uid: Option.fromNullishOr(info.uid), + gid: Option.fromNullishOr(info.gid), + size: FileSystem.Size(info.size), + blksize: Option.map(Option.fromNullishOr(info.blksize), FileSystem.Size), + blocks: Option.fromNullishOr(info.blocks) +}) + +/** A file handle is stateful and must not be used concurrently. */ +class FileImpl implements FileSystem.File { + readonly [FileSystem.FileTypeId]: typeof FileSystem.FileTypeId = FileSystem.FileTypeId + private readonly file: Deno.FsFile + private readonly append: boolean + private position = BigInt(0) + private nativePosition: bigint | undefined = undefined + + constructor( + file: Deno.FsFile, + append: boolean + ) { + this.file = file + this.append = append + } + + get stat() { + return Effect.map( + tryPromise("stat", undefined, () => this.file.stat()), + makeFileInfo + ) + } + + get sync() { + return tryPromise("sync", undefined, () => this.file.sync()) + } + + seek(offset: FileSystem.SizeInput, from: FileSystem.SeekMode) { + const size = FileSystem.Size(offset) + return Effect.sync(() => { + if (from === "start") { + this.position = size + } else { + this.position += size + } + return this.position + }) + } + + private readChunk(method: string, buffer: Uint8Array) { + return Effect.suspend(() => { + const position = this.position + return Effect.map( + tryPromise( + method, + undefined, + async () => { + if (this.nativePosition !== position) { + this.file.seekSync(position, Deno.SeekMode.Start) + } + this.nativePosition = undefined + return await this.file.read(buffer) + } + ), + (bytesRead) => { + const sizeRead = FileSystem.Size(bytesRead ?? 0) + this.position = this.nativePosition = position + sizeRead + return sizeRead + } + ) + }) + } + + read(buffer: Uint8Array) { + return this.readChunk("read", buffer) + } + + readAlloc(size: FileSystem.SizeInput) { + const sizeNumber = Number(size) + return Effect.suspend(() => { + const buffer = new Uint8Array(sizeNumber) + return Effect.map(this.readChunk("readAlloc", buffer), (bytesRead) => { + if (bytesRead === BigInt(0)) { + return Option.none() + } + return Option.some(bytesRead === BigInt(sizeNumber) ? buffer : buffer.subarray(0, Number(bytesRead))) + }) + }) + } + + truncate(length?: FileSystem.SizeInput) { + const size = FileSystem.Size(length ?? 0) + return Effect.map( + tryPromise("truncate", undefined, () => this.file.truncate(Number(size))), + () => { + if (!this.append && this.position > size) { + this.position = size + } + } + ) + } + + private writeChunk(method: string, buffer: Uint8Array) { + return Effect.suspend(() => { + const position = this.position + return Effect.map( + tryPromise( + method, + undefined, + async () => { + if (!this.append && this.nativePosition !== position) { + this.file.seekSync(position, Deno.SeekMode.Start) + } + this.nativePosition = undefined + return await this.file.write(buffer) + } + ), + (bytesWritten) => { + const sizeWritten = FileSystem.Size(bytesWritten) + if (this.append) { + this.nativePosition = undefined + } else { + this.position = this.nativePosition = position + sizeWritten + } + return sizeWritten + } + ) + }) + } + + write(buffer: Uint8Array) { + return this.writeChunk("write", buffer) + } + + private writeAllChunk(buffer: Uint8Array): Effect.Effect { + return Effect.flatMap(this.writeChunk("writeAll", buffer), (bytesWritten) => { + if (bytesWritten === BigInt(0)) { + return Effect.fail(PlatformError.systemError({ + module: "FileSystem", + method: "writeAll", + _tag: "WriteZero", + description: "write returned 0 bytes written" + })) + } + return bytesWritten < buffer.length + ? this.writeAllChunk(buffer.subarray(Number(bytesWritten))) + : Effect.void + }) + } + + writeAll(buffer: Uint8Array) { + return this.writeAllChunk(buffer) + } +} + +const open: FileSystem.FileSystem["open"] = (path, options) => { + const append = options?.flag?.startsWith("a") ?? false + return Effect.map( + Effect.acquireRelease( + tryPromise("open", path, () => Deno.open(path, openOptions(options?.flag, options?.mode))), + (file) => close(file, "open", path) + ), + (file) => new FileImpl(file, append) + ) +} + +const makeTempFileFactory = (method: string): FileSystem.FileSystem["makeTempFile"] => (options) => + tryPromise(method, options?.directory, () => + Deno.makeTempFile({ + ...(options?.directory === undefined ? {} : { dir: options.directory }), + ...(options?.prefix === undefined ? {} : { prefix: options.prefix }), + ...(options?.suffix === undefined ? {} : { suffix: options.suffix }) + })) + +const makeTempFile = makeTempFileFactory("makeTempFile") + +const makeTempFileScoped: FileSystem.FileSystem["makeTempFileScoped"] = (options) => + Effect.acquireRelease( + makeTempFileFactory("makeTempFileScoped")(options), + (file) => Effect.orDie(removeFactory("makeTempFileScoped")(file, { force: true })) + ) + +const readDirectory: FileSystem.FileSystem["readDirectory"] = (path, options) => { + if (options?.recursive) { + return Effect.map( + collectAsyncIterable("readDirectory", path, () => walk(path)), + (entries) => entries.map((entry) => relative(path, entry.path)).filter(Boolean) + ) + } + return Effect.map( + collectAsyncIterable("readDirectory", path, () => Deno.readDir(path)), + (entries) => entries.map((entry) => entry.name) + ) +} + +const readFile: FileSystem.FileSystem["readFile"] = (path) => + tryPromise("readFile", path, (signal) => Deno.readFile(path, { signal })) + +const readLink: FileSystem.FileSystem["readLink"] = (path) => tryPromise("readLink", path, () => Deno.readLink(path)) + +const realPath: FileSystem.FileSystem["realPath"] = (path) => tryPromise("realPath", path, () => Deno.realPath(path)) + +const rename: FileSystem.FileSystem["rename"] = (oldPath, newPath) => + tryPromise("rename", oldPath, () => Deno.rename(oldPath, newPath)) + +const stat: FileSystem.FileSystem["stat"] = (path) => + Effect.map( + tryPromise("stat", path, () => Deno.stat(path)), + makeFileInfo + ) + +const symlink: FileSystem.FileSystem["symlink"] = (target, path) => + tryPromise("symlink", target, () => Deno.symlink(target, path)) + +const truncate: FileSystem.FileSystem["truncate"] = (path, length) => + tryPromise("truncate", path, () => Deno.truncate(path, length === undefined ? undefined : Number(length))) + +const utimes: FileSystem.FileSystem["utimes"] = (path, atime, mtime) => + tryPromise("utimes", path, () => Deno.utime(path, atime, mtime)) + +const watchNative = (path: string): Stream.Stream => + Stream.unwrap( + Effect.map( + Effect.try({ + try: () => Deno.watchFs(path, { recursive: true }), + catch: handleError("FileSystem", "watch", path) + }), + (watcher) => + Stream.fromAsyncIterable(watcher, handleError("FileSystem", "watch", path)).pipe( + Stream.flatMap((event): Stream.Stream => { + switch (event.kind) { + case "create": + return Stream.map(Stream.fromIterable(event.paths), (path) => ({ _tag: "Create" as const, path })) + case "modify": + return Stream.map(Stream.fromIterable(event.paths), (path) => ({ _tag: "Update" as const, path })) + case "remove": + return Stream.map(Stream.fromIterable(event.paths), (path) => ({ _tag: "Remove" as const, path })) + case "rename": + return Stream.mapEffect(Stream.fromIterable(event.paths), (path) => + Effect.match(stat(path), { + onFailure: () => ({ _tag: "Remove" as const, path }), + onSuccess: () => ({ _tag: "Create" as const, path }) + })) + default: + return Stream.empty + } + }) + ) + ) + ) + +const watch = (backend: Option.Option, path: string) => + stat(path).pipe( + Effect.map((info) => + backend.pipe( + Option.flatMap((backend) => backend.register(path, info)), + Option.getOrElse(() => watchNative(path)) + ) + ), + Stream.unwrap + ) + +const writeFile: FileSystem.FileSystem["writeFile"] = (path, data, options) => { + const flag = options?.flag + return tryPromise("writeFile", path, (signal) => + Deno.writeFile(path, data, { + append: flag?.startsWith("a") ?? false, + create: flag !== "r" && flag !== "r+", + createNew: flag?.includes("x") ?? false, + ...(options?.mode === undefined ? {} : { mode: options.mode }), + signal + })) +} + +const makeFileSystem = Effect.map(Effect.serviceOption(FileSystem.WatchBackend), (backend) => + FileSystem.make({ + access, + chmod, + chown, + copy, + copyFile, + glob, + link, + makeDirectory, + makeTempDirectory, + makeTempDirectoryScoped, + makeTempFile, + makeTempFileScoped, + open, + readDirectory, + readFile, + readLink, + realPath, + remove, + rename, + stat, + symlink, + truncate, + utimes, + watch(path) { + return watch(backend, path) + }, + writeFile + })) + +/** + * Provides the `FileSystem` service backed by Deno filesystem APIs. + * + * @category layers + * @since 4.0.0 + */ +export const layer: Layer.Layer = Layer.effect(FileSystem.FileSystem)(makeFileSystem) diff --git a/packages/platform-deno/src/index.ts b/packages/platform-deno/src/index.ts index 5b5e1d07a3c..53ec77dbfde 100644 --- a/packages/platform-deno/src/index.ts +++ b/packages/platform-deno/src/index.ts @@ -14,6 +14,11 @@ export * as DenoChildProcessSpawner from "./DenoChildProcessSpawner.ts" */ export * as DenoCrypto from "./DenoCrypto.ts" +/** + * @since 4.0.0 + */ +export * as DenoFileSystem from "./DenoFileSystem.ts" + /** * @since 4.0.0 */ diff --git a/packages/platform-deno/src/internal/error.ts b/packages/platform-deno/src/internal/error.ts new file mode 100644 index 00000000000..97c73ea055f --- /dev/null +++ b/packages/platform-deno/src/internal/error.ts @@ -0,0 +1,63 @@ +import type { SystemError, SystemErrorTag } from "effect/PlatformError" +import * as PlatformError from "effect/PlatformError" + +interface DenoError { + readonly code?: unknown + readonly name?: unknown +} + +export const handleError = ( + module: SystemError["module"], + method: string, + pathOrDescriptor?: string | number +) => +(error: unknown): PlatformError.PlatformError => { + const denoError = error as DenoError + let tag: SystemErrorTag = "Unknown" + + switch (denoError?.name) { + case "NotCapable": + tag = "PermissionDenied" + break + case "BadResource": + case "InvalidData": + case "TimedOut": + case "UnexpectedEof": + case "WouldBlock": + case "WriteZero": + tag = denoError.name + break + default: + switch (denoError?.code) { + case "ENOENT": + tag = "NotFound" + break + + case "EACCES": + tag = "PermissionDenied" + break + + case "EEXIST": + tag = "AlreadyExists" + break + + case "EISDIR": + case "ENOTDIR": + case "ELOOP": + tag = "BadResource" + break + + case "EBUSY": + tag = "Busy" + break + } + } + + return PlatformError.systemError({ + _tag: tag, + module, + method, + pathOrDescriptor, + cause: error + }) +} diff --git a/packages/platform-deno/test/DenoFileSystem.test.ts b/packages/platform-deno/test/DenoFileSystem.test.ts new file mode 100644 index 00000000000..96843f6c246 --- /dev/null +++ b/packages/platform-deno/test/DenoFileSystem.test.ts @@ -0,0 +1,9 @@ +import * as DenoFileSystem from "@effect/platform-deno/DenoFileSystem" +import { describe } from "@effect/vitest" +import { testLayer } from "../../effect/test/FileSystem.test-utils.ts" + +describe("FileSystem", () => + testLayer(DenoFileSystem.layer, { + accessOnDirectory: false, + tempFileScopedRemovesDirectory: false + })) diff --git a/packages/platform-deno/test/internal/error.test.ts b/packages/platform-deno/test/internal/error.test.ts new file mode 100644 index 00000000000..a31f54170e6 --- /dev/null +++ b/packages/platform-deno/test/internal/error.test.ts @@ -0,0 +1,38 @@ +import { assert, describe, it } from "@effect/vitest" +import { SystemError, type SystemErrorTag } from "effect/PlatformError" +import { handleError } from "../../src/internal/error.ts" + +const withCode = (error: Error, code: string): Error & { readonly code: string } => Object.assign(error, { code }) + +describe("handleError", () => { + const mapError = handleError("FileSystem", "test", "/tmp/test") + const cases: ReadonlyArray = [ + [withCode(new Deno.errors.NotFound(), "ENOENT"), "NotFound"], + [withCode(new Deno.errors.NotADirectory(), "ENOTDIR"), "BadResource"], + [withCode(new Deno.errors.AlreadyExists(), "EEXIST"), "AlreadyExists"], + [withCode(new Deno.errors.IsADirectory(), "EISDIR"), "BadResource"], + [withCode(new Deno.errors.PermissionDenied(), "EACCES"), "PermissionDenied"], + [new Deno.errors.NotCapable(), "PermissionDenied"], + [new Deno.errors.BadResource(), "BadResource"], + [new Deno.errors.InvalidData(), "InvalidData"], + [new Deno.errors.TimedOut(), "TimedOut"], + [new Deno.errors.UnexpectedEof(), "UnexpectedEof"], + [new Deno.errors.WouldBlock(), "WouldBlock"], + [new Deno.errors.WriteZero(), "WriteZero"], + [new Error("unrecognised"), "Unknown"] + ] + + for (const [error, tag] of cases) { + it(`maps ${error.name} to ${tag}`, () => { + const platformError = mapError(error) + const reason = platformError.reason + + assert(reason instanceof SystemError) + assert.strictEqual(reason._tag, tag) + assert.strictEqual(reason.module, "FileSystem") + assert.strictEqual(reason.method, "test") + assert.strictEqual(reason.pathOrDescriptor, "/tmp/test") + assert.strictEqual(reason.cause, error) + }) + } +}) diff --git a/packages/platform-node-shared/test/NodeFileSystem.test.ts b/packages/platform-node-shared/test/NodeFileSystem.test.ts index 017f2eee945..312b7363283 100644 --- a/packages/platform-node-shared/test/NodeFileSystem.test.ts +++ b/packages/platform-node-shared/test/NodeFileSystem.test.ts @@ -1,214 +1,5 @@ import * as NodeFileSystem from "@effect/platform-node-shared/NodeFileSystem" -import { assert, describe, expect, it } from "@effect/vitest" -import { Array } from "effect" -import * as Effect from "effect/Effect" -import * as Fs from "effect/FileSystem" -import * as Stream from "effect/Stream" +import { describe } from "@effect/vitest" +import { testLayer } from "../../effect/test/FileSystem.test-utils.ts" -const runPromise = (self: Effect.Effect) => - Effect.runPromise( - Effect.provide(self, NodeFileSystem.layer) - ) - -describe("FileSystem", () => { - it("readFile", () => - runPromise(Effect.gen(function*() { - const fs = yield* Fs.FileSystem - const data = yield* fs.readFile(`${__dirname}/fixtures/text.txt`) - const text = new TextDecoder().decode(data) - expect(text.trim()).toEqual("lorem ipsum dolar sit amet") - }))) - - it("makeTempDirectory", () => - runPromise(Effect.gen(function*() { - const fs = yield* Fs.FileSystem - let dir = "" - yield* Effect.scoped(Effect.gen(function*() { - dir = yield* fs.makeTempDirectory() - const stat = yield* fs.stat(dir) - expect(stat.type).toEqual("Directory") - })) - const stat = yield* fs.stat(dir) - expect(stat.type).toEqual("Directory") - }))) - - it("makeTempDirectoryScoped", () => - runPromise(Effect.gen(function*() { - const fs = yield* Fs.FileSystem - let dir = "" - yield* Effect.scoped( - Effect.gen(function*() { - dir = yield* fs.makeTempDirectoryScoped() - const stat = yield* fs.stat(dir) - expect(stat.type).toEqual("Directory") - }) - ) - const error = yield* Effect.flip(fs.stat(dir)) - assert(error.reason._tag === "NotFound") - }))) - - it("truncate", () => - runPromise(Effect.gen(function*() { - const fs = yield* Fs.FileSystem - const file = yield* fs.makeTempFile() - - const text = "hello world" - yield* fs.writeFile(file, new TextEncoder().encode(text)) - - const before = yield* Effect.map(fs.readFile(file), (_) => new TextDecoder().decode(_)) - expect(before).toEqual(text) - - yield* fs.truncate(file) - - const after = yield* Effect.map(fs.readFile(file), (_) => new TextDecoder().decode(_)) - expect(after).toEqual("") - }))) - - it("should track the cursor position when reading", () => - runPromise(Effect.gen(function*() { - const fs = yield* Fs.FileSystem - - yield* Effect.gen(function*() { - let text: string - const file = yield* fs.open(`${__dirname}/fixtures/text.txt`) - - text = yield* file.readAlloc(Fs.Size(5)).pipe( - Effect.flatMap(Effect.fromOption), - Effect.map((_) => new TextDecoder().decode(_)) - ) - expect(text).toBe("lorem") - - yield* file.seek(Fs.Size(7), "current") - text = yield* file.readAlloc(Fs.Size(5)).pipe( - Effect.flatMap(Effect.fromOption), - Effect.map((_) => new TextDecoder().decode(_)) - ) - expect(text).toBe("dolar") - - yield* file.seek(Fs.Size(1), "current") - text = yield* file.readAlloc(Fs.Size(8)).pipe( - Effect.flatMap(Effect.fromOption), - Effect.map((_) => new TextDecoder().decode(_)) - ) - expect(text).toBe("sit amet") - - yield* file.seek(Fs.Size(0), "start") - text = yield* file.readAlloc(Fs.Size(11)).pipe( - Effect.flatMap(Effect.fromOption), - Effect.map((_) => new TextDecoder().decode(_)) - ) - expect(text).toBe("lorem ipsum") - - text = yield* fs.stream(`${__dirname}/fixtures/text.txt`, { offset: Fs.Size(6), bytesToRead: Fs.Size(5) }).pipe( - Stream.map((_) => new TextDecoder().decode(_)), - Stream.runCollect, - Effect.map(Array.join("")) - ) - expect(text).toBe("ipsum") - }).pipe( - Effect.scoped - ) - }))) - - it("should track the cursor position when writing", () => - runPromise(Effect.gen(function*() { - const fs = yield* Fs.FileSystem - - yield* Effect.gen(function*() { - let text: string - const path = yield* fs.makeTempFileScoped() - const file = yield* fs.open(path, { flag: "w+" }) - - yield* file.write(new TextEncoder().encode("lorem ipsum")) - yield* file.write(new TextEncoder().encode(" ")) - yield* file.write(new TextEncoder().encode("dolor sit amet")) - text = yield* fs.readFileString(path) - expect(text).toBe("lorem ipsum dolor sit amet") - - yield* file.seek(Fs.Size(-4), "current") - yield* file.write(new TextEncoder().encode("hello world")) - text = yield* fs.readFileString(path) - expect(text).toBe("lorem ipsum dolor sit hello world") - - yield* file.seek(Fs.Size(6), "start") - yield* file.write(new TextEncoder().encode("blabl")) - text = yield* fs.readFileString(path) - expect(text).toBe("lorem blabl dolor sit hello world") - }).pipe( - Effect.scoped - ) - }))) - - it("should maintain a read cursor in append mode", () => - runPromise(Effect.gen(function*() { - const fs = yield* Fs.FileSystem - - yield* Effect.gen(function*() { - let text: string - const path = yield* fs.makeTempFileScoped() - const file = yield* fs.open(path, { flag: "a+" }) - - yield* file.write(new TextEncoder().encode("foo")) - yield* file.seek(Fs.Size(0), "start") - - yield* file.write(new TextEncoder().encode("bar")) - text = yield* fs.readFileString(path) - expect(text).toBe("foobar") - - text = yield* file.readAlloc(Fs.Size(3)).pipe( - Effect.flatMap(Effect.fromOption), - Effect.map((_) => new TextDecoder().decode(_)) - ) - expect(text).toBe("foo") - - yield* file.write(new TextEncoder().encode("baz")) - text = yield* fs.readFileString(path) - expect(text).toBe("foobarbaz") - - text = yield* file.readAlloc(Fs.Size(6)).pipe( - Effect.flatMap(Effect.fromOption), - Effect.map((_) => new TextDecoder().decode(_)) - ) - expect(text).toBe("barbaz") - }).pipe( - Effect.scoped - ) - }))) - - it("should keep the current cursor if truncating doesn't affect it", () => - runPromise(Effect.gen(function*() { - const fs = yield* Fs.FileSystem - - yield* Effect.gen(function*() { - const path = yield* fs.makeTempFileScoped() - const file = yield* fs.open(path, { flag: "w+" }) - - yield* file.write(new TextEncoder().encode("lorem ipsum dolor sit amet")) - yield* file.seek(Fs.Size(6), "start") - yield* file.truncate(Fs.Size(11)) - - const cursor = yield* file.seek(Fs.Size(0), "current") - expect(cursor).toBe(Fs.Size(6)) - }).pipe( - Effect.scoped - ) - }))) - - it("should update the current cursor if truncating affects it", () => - runPromise(Effect.gen(function*() { - const fs = yield* Fs.FileSystem - - yield* Effect.gen(function*() { - const path = yield* fs.makeTempFileScoped() - const file = yield* fs.open(path, { flag: "w+" }) - - yield* file.write(new TextEncoder().encode("lorem ipsum dolor sit amet")) - yield* file.truncate(Fs.Size(11)) - - const cursor = yield* file.seek(Fs.Size(0), "current") - expect(cursor).toBe(Fs.Size(11)) - }).pipe( - Effect.scoped - ) - }))) -}) +describe("FileSystem", () => testLayer(NodeFileSystem.layer)) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e23ce820913..961b1709ad9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -471,6 +471,9 @@ importers: '@db/redis': specifier: jsr:^0.41.2 version: '@jsr/db__redis@0.41.2' + '@std/fs': + specifier: jsr:^1.0.24 + version: '@jsr/std__fs@1.0.24' '@std/path': specifier: jsr:^1.1.6 version: '@jsr/std__path@1.1.6' @@ -2345,6 +2348,9 @@ packages: '@jsr/std__data-structures@1.1.1': resolution: {integrity: sha512-ceWGRdAPq3oD2If4k/JvgL/bskN5VohnM6wINlJhYLz4yCW9cmHFcGsrhRgh9HbD9xhXi9sEQoiHQG5B9caHEg==, tarball: https://npm.jsr.io/~/11/@jsr/std__data-structures/1.1.1.tgz} + '@jsr/std__fs@1.0.24': + resolution: {integrity: sha512-UunW2Rg++9sA74NJnYMGTsMVQgoFRAwPP02wRRohL0PQBNorbyxHiyCMRaxYQCtLepTLkC4nUyMtcwrGKqTR6g==, tarball: https://npm.jsr.io/~/11/@jsr/std__fs/1.0.24.tgz} + '@jsr/std__internal@1.0.14': resolution: {integrity: sha512-JT8b/t40WcR9q0GDwRZUooY7aXeXnFal8iidE/5TckdAFtvpVAsaJ71/Xgf1SfoChs41s6CZkuCYM8toaNgdvA==, tarball: https://npm.jsr.io/~/11/@jsr/std__internal/1.0.14.tgz} @@ -8227,6 +8233,11 @@ snapshots: dependencies: '@jsr/std__assert': 1.0.19 + '@jsr/std__fs@1.0.24': + dependencies: + '@jsr/std__internal': 1.0.14 + '@jsr/std__path': 1.1.6 + '@jsr/std__internal@1.0.14': {} '@jsr/std__io@0.224.5':