From fa8d505fbecbb7745c19fe39006e4cc697e42be5 Mon Sep 17 00:00:00 2001 From: Tyler Gibbs <104282235+tylergibbs1@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:57:41 -0500 Subject: [PATCH 1/5] Restore FileSystem watch recursion control --- .changeset/wise-files-watch.md | 7 +++ packages/effect/src/FileSystem.ts | 27 ++++++++- packages/platform-deno/src/DenoFileSystem.ts | 21 ++++--- .../src/NodeFileSystem.ts | 18 +++--- .../test/NodeFileSystem.test.ts | 57 ++++++++++++++++++- 5 files changed, 111 insertions(+), 19 deletions(-) create mode 100644 .changeset/wise-files-watch.md diff --git a/.changeset/wise-files-watch.md b/.changeset/wise-files-watch.md new file mode 100644 index 00000000000..41cd84d9aef --- /dev/null +++ b/.changeset/wise-files-watch.md @@ -0,0 +1,7 @@ +--- +"@effect/platform-deno": patch +"@effect/platform-node-shared": patch +"effect": patch +--- + +Restore the `recursive` option for `FileSystem.watch`, with non-recursive watching as the default. diff --git a/packages/effect/src/FileSystem.ts b/packages/effect/src/FileSystem.ts index f607f13bfd9..b3d451d7d50 100644 --- a/packages/effect/src/FileSystem.ts +++ b/packages/effect/src/FileSystem.ts @@ -341,9 +341,13 @@ export interface FileSystem { mtime: Date | number ) => Effect.Effect /** - * Watch a directory or file for changes + * Watch a directory or file for changes. + * + * By default, only changes to the direct children of the directory are + * reported. Set the `recursive` option to `true` to watch for changes in + * subdirectories as well. */ - readonly watch: (path: string) => Stream.Stream + readonly watch: (path: string, options?: WatchOptions) => Stream.Stream /** * Write data to a file at `path`. */ @@ -1247,6 +1251,19 @@ export declare namespace File { */ export type SeekMode = "start" | "current" +/** + * Options for watching files or directories. + * + * @category models + * @since 4.0.0 + */ +export interface WatchOptions { + /** + * When `true`, changes in subdirectories are also reported. + */ + readonly recursive?: boolean | undefined +} + /** * Represents file system events emitted when watching files or directories. * @@ -1363,5 +1380,9 @@ export declare namespace WatchEvent { * @since 4.0.0 */ export class WatchBackend extends Context.Service Option.Option> + readonly register: ( + path: string, + stat: File.Info, + options?: WatchOptions + ) => Option.Option> }>()("effect/platform/FileSystem/WatchBackend") {} diff --git a/packages/platform-deno/src/DenoFileSystem.ts b/packages/platform-deno/src/DenoFileSystem.ts index 2b2f993ec31..69d0df7e642 100644 --- a/packages/platform-deno/src/DenoFileSystem.ts +++ b/packages/platform-deno/src/DenoFileSystem.ts @@ -390,11 +390,14 @@ const truncate: FileSystem.FileSystem["truncate"] = (path, length) => const utimes: FileSystem.FileSystem["utimes"] = (path, atime, mtime) => tryPromise("utimes", path, () => Deno.utime(path, atime, mtime)) -const watchNative = (path: string): Stream.Stream => +const watchNative = ( + path: string, + options?: FileSystem.WatchOptions +): Stream.Stream => Stream.unwrap( Effect.map( Effect.try({ - try: () => Deno.watchFs(path, { recursive: true }), + try: () => Deno.watchFs(path, { recursive: options?.recursive ?? false }), catch: handleError("FileSystem", "watch", path) }), (watcher) => @@ -421,12 +424,16 @@ const watchNative = (path: string): Stream.Stream, path: string) => +const watch = ( + backend: Option.Option, + path: string, + options?: FileSystem.WatchOptions +) => stat(path).pipe( Effect.map((info) => backend.pipe( - Option.flatMap((backend) => backend.register(path, info)), - Option.getOrElse(() => watchNative(path)) + Option.flatMap((backend) => backend.register(path, info, options)), + Option.getOrElse(() => watchNative(path, options)) ) ), Stream.unwrap @@ -469,8 +476,8 @@ const makeFileSystem = Effect.map(Effect.serviceOption(FileSystem.WatchBackend), symlink, truncate, utimes, - watch(path) { - return watch(backend, path) + watch(path, options) { + return watch(backend, path, options) }, writeFile })) diff --git a/packages/platform-node-shared/src/NodeFileSystem.ts b/packages/platform-node-shared/src/NodeFileSystem.ts index b7a5bc1735c..d60489f8772 100644 --- a/packages/platform-node-shared/src/NodeFileSystem.ts +++ b/packages/platform-node-shared/src/NodeFileSystem.ts @@ -550,12 +550,12 @@ const utimes = (() => { // == watch -const watchNode = (path: string) => +const watchNode = (path: string, options?: FileSystem.WatchOptions) => Stream.callback((queue) => Effect.acquireRelease( Effect.sync(() => { const watcher = NFS.watch(path, { - recursive: true + recursive: options?.recursive ?? false }, (event, path) => { if (!path) return switch (event) { @@ -595,12 +595,16 @@ const watchNode = (path: string) => ) ) -const watch = (backend: Option.Option, path: string) => +const watch = ( + backend: Option.Option, + path: string, + options?: FileSystem.WatchOptions +) => stat(path).pipe( Effect.map((stat) => backend.pipe( - Option.flatMap((_) => _.register(path, stat)), - Option.getOrElse(() => watchNode(path)) + Option.flatMap((_) => _.register(path, stat, options)), + Option.getOrElse(() => watchNode(path, options)) ) ), Stream.unwrap @@ -652,8 +656,8 @@ const makeFileSystem = Effect.map(Effect.serviceOption(FileSystem.WatchBackend), symlink, truncate, utimes, - watch(path) { - return watch(backend, path) + watch(path, options) { + return watch(backend, path, options) }, writeFile })) diff --git a/packages/platform-node-shared/test/NodeFileSystem.test.ts b/packages/platform-node-shared/test/NodeFileSystem.test.ts index 312b7363283..ff1c7a0ca1e 100644 --- a/packages/platform-node-shared/test/NodeFileSystem.test.ts +++ b/packages/platform-node-shared/test/NodeFileSystem.test.ts @@ -1,5 +1,58 @@ import * as NodeFileSystem from "@effect/platform-node-shared/NodeFileSystem" -import { describe } from "@effect/vitest" +import { assert, describe, it } from "@effect/vitest" +import * as Effect from "effect/Effect" +import * as Fiber from "effect/Fiber" +import * as FileSystem from "effect/FileSystem" +import * as Stream from "effect/Stream" import { testLayer } from "../../effect/test/FileSystem.test-utils.ts" -describe("FileSystem", () => testLayer(NodeFileSystem.layer)) +describe("FileSystem", () => { + testLayer(NodeFileSystem.layer) + + it.effect("watch does not report nested changes when recursive is false", () => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const root = yield* fs.makeTempDirectoryScoped() + const nested = `${root}/nested` + yield* fs.makeDirectory(nested) + + const fiber = yield* fs.watch(root, { recursive: false }).pipe( + Stream.runHead, + Effect.flatMap(Effect.fromOption), + Effect.fork + ) + yield* Effect.yieldNow + + yield* fs.writeFileString(`${nested}/nested.txt`, "") + yield* fs.writeFileString(`${root}/direct.txt`, "") + + const event = yield* Fiber.join(fiber) + assert.strictEqual(event.path, "direct.txt") + }).pipe( + Effect.scoped, + Effect.provide(NodeFileSystem.layer) + )) + + it.effect("watch reports nested changes when recursive is true", () => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const root = yield* fs.makeTempDirectoryScoped() + const nested = `${root}/nested` + yield* fs.makeDirectory(nested) + + const fiber = yield* fs.watch(root, { recursive: true }).pipe( + Stream.runHead, + Effect.flatMap(Effect.fromOption), + Effect.fork + ) + yield* Effect.yieldNow + + yield* fs.writeFileString(`${nested}/nested.txt`, "") + + const event = yield* Fiber.join(fiber) + assert(event.path.endsWith("nested.txt")) + }).pipe( + Effect.scoped, + Effect.provide(NodeFileSystem.layer) + )) +}) From 977d23e18b25af155c9985baff959abe7ba22de2 Mon Sep 17 00:00:00 2001 From: Tyler Gibbs <104282235+tylergibbs1@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:24:06 -0500 Subject: [PATCH 2/5] Fix watch regression tests and docs --- packages/effect/src/FileSystem.ts | 2 ++ packages/platform-node-shared/test/NodeFileSystem.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/effect/src/FileSystem.ts b/packages/effect/src/FileSystem.ts index b3d451d7d50..1be14b2984b 100644 --- a/packages/effect/src/FileSystem.ts +++ b/packages/effect/src/FileSystem.ts @@ -343,6 +343,8 @@ export interface FileSystem { /** * Watch a directory or file for changes. * + * **Details** + * * By default, only changes to the direct children of the directory are * reported. Set the `recursive` option to `true` to watch for changes in * subdirectories as well. diff --git a/packages/platform-node-shared/test/NodeFileSystem.test.ts b/packages/platform-node-shared/test/NodeFileSystem.test.ts index ff1c7a0ca1e..fd728af39c7 100644 --- a/packages/platform-node-shared/test/NodeFileSystem.test.ts +++ b/packages/platform-node-shared/test/NodeFileSystem.test.ts @@ -19,7 +19,7 @@ describe("FileSystem", () => { const fiber = yield* fs.watch(root, { recursive: false }).pipe( Stream.runHead, Effect.flatMap(Effect.fromOption), - Effect.fork + Effect.forkChild ) yield* Effect.yieldNow @@ -43,7 +43,7 @@ describe("FileSystem", () => { const fiber = yield* fs.watch(root, { recursive: true }).pipe( Stream.runHead, Effect.flatMap(Effect.fromOption), - Effect.fork + Effect.forkChild ) yield* Effect.yieldNow From 5724b7c6b242ac40ab593483e7e654a54178afb3 Mon Sep 17 00:00:00 2001 From: Tyler Gibbs <104282235+tylergibbs1@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:25:59 -0500 Subject: [PATCH 3/5] Stabilize file watcher tests --- .../test/NodeFileSystem.test.ts | 63 +++++++++++++++---- 1 file changed, 51 insertions(+), 12 deletions(-) diff --git a/packages/platform-node-shared/test/NodeFileSystem.test.ts b/packages/platform-node-shared/test/NodeFileSystem.test.ts index fd728af39c7..9b21a7ccfbe 100644 --- a/packages/platform-node-shared/test/NodeFileSystem.test.ts +++ b/packages/platform-node-shared/test/NodeFileSystem.test.ts @@ -1,11 +1,41 @@ import * as NodeFileSystem from "@effect/platform-node-shared/NodeFileSystem" import { assert, describe, it } from "@effect/vitest" +import * as Deferred from "effect/Deferred" import * as Effect from "effect/Effect" import * as Fiber from "effect/Fiber" import * as FileSystem from "effect/FileSystem" import * as Stream from "effect/Stream" import { testLayer } from "../../effect/test/FileSystem.test-utils.ts" +const startWatch = ( + fs: FileSystem.FileSystem, + root: string, + watch: () => Stream.Stream +) => + Effect.gen(function*() { + const ready = yield* Deferred.make() + const readyName = ".watch-ready" + const fiber = yield* watch().pipe( + Stream.tap((event) => + event.path === readyName + ? Deferred.succeed(ready, undefined) + : Effect.void + ), + Stream.filter((event) => event.path !== readyName), + Stream.runHead, + Effect.flatMap(Effect.fromOption), + Effect.forkChild + ) + const signalFiber = yield* fs.writeFileString(`${root}/${readyName}`, "").pipe( + Effect.andThen(Effect.sleep("10 millis")), + Effect.forever, + Effect.forkChild + ) + yield* Deferred.await(ready) + yield* Fiber.interrupt(signalFiber) + return fiber + }) + describe("FileSystem", () => { testLayer(NodeFileSystem.layer) @@ -16,12 +46,26 @@ describe("FileSystem", () => { const nested = `${root}/nested` yield* fs.makeDirectory(nested) - const fiber = yield* fs.watch(root, { recursive: false }).pipe( - Stream.runHead, - Effect.flatMap(Effect.fromOption), - Effect.forkChild - ) - yield* Effect.yieldNow + const fiber = yield* startWatch(fs, root, () => fs.watch(root, { recursive: false })) + + yield* fs.writeFileString(`${nested}/nested.txt`, "") + yield* fs.writeFileString(`${root}/direct.txt`, "") + + const event = yield* Fiber.join(fiber) + assert.strictEqual(event.path, "direct.txt") + }).pipe( + Effect.scoped, + Effect.provide(NodeFileSystem.layer) + )) + + it.effect("watch is non-recursive when options are omitted", () => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const root = yield* fs.makeTempDirectoryScoped() + const nested = `${root}/nested` + yield* fs.makeDirectory(nested) + + const fiber = yield* startWatch(fs, root, () => fs.watch(root)) yield* fs.writeFileString(`${nested}/nested.txt`, "") yield* fs.writeFileString(`${root}/direct.txt`, "") @@ -40,12 +84,7 @@ describe("FileSystem", () => { const nested = `${root}/nested` yield* fs.makeDirectory(nested) - const fiber = yield* fs.watch(root, { recursive: true }).pipe( - Stream.runHead, - Effect.flatMap(Effect.fromOption), - Effect.forkChild - ) - yield* Effect.yieldNow + const fiber = yield* startWatch(fs, root, () => fs.watch(root, { recursive: true })) yield* fs.writeFileString(`${nested}/nested.txt`, "") From d67acc01ed6a47eaa175242071de9c9f5e652cfe Mon Sep 17 00:00:00 2001 From: Tyler Gibbs <104282235+tylergibbs1@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:23:25 -0500 Subject: [PATCH 4/5] Propagate watcher startup failures --- packages/platform-node-shared/test/NodeFileSystem.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/platform-node-shared/test/NodeFileSystem.test.ts b/packages/platform-node-shared/test/NodeFileSystem.test.ts index 9b21a7ccfbe..324d570c25b 100644 --- a/packages/platform-node-shared/test/NodeFileSystem.test.ts +++ b/packages/platform-node-shared/test/NodeFileSystem.test.ts @@ -31,8 +31,10 @@ const startWatch = ( Effect.forever, Effect.forkChild ) - yield* Deferred.await(ready) - yield* Fiber.interrupt(signalFiber) + yield* Deferred.await(ready).pipe( + Effect.raceFirst(Fiber.join(fiber).pipe(Effect.asVoid)), + Effect.ensuring(Fiber.interrupt(signalFiber)) + ) return fiber }) From a15fc71657ebe7d3c6eb431a65cd9aa2d2735a4c Mon Sep 17 00:00:00 2001 From: Tyler Gibbs Date: Tue, 28 Jul 2026 23:42:15 -0500 Subject: [PATCH 5/5] fix watcher test readiness timing --- packages/platform-node-shared/test/NodeFileSystem.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/platform-node-shared/test/NodeFileSystem.test.ts b/packages/platform-node-shared/test/NodeFileSystem.test.ts index 324d570c25b..eba48fb8d88 100644 --- a/packages/platform-node-shared/test/NodeFileSystem.test.ts +++ b/packages/platform-node-shared/test/NodeFileSystem.test.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect" import * as Fiber from "effect/Fiber" import * as FileSystem from "effect/FileSystem" import * as Stream from "effect/Stream" +import * as TestClock from "effect/testing/TestClock" import { testLayer } from "../../effect/test/FileSystem.test-utils.ts" const startWatch = ( @@ -26,8 +27,9 @@ const startWatch = ( Effect.flatMap(Effect.fromOption), Effect.forkChild ) - const signalFiber = yield* fs.writeFileString(`${root}/${readyName}`, "").pipe( - Effect.andThen(Effect.sleep("10 millis")), + const signalFiber = yield* Effect.sleep("10 millis").pipe( + TestClock.withLive, + Effect.andThen(fs.writeFileString(`${root}/${readyName}`, "")), Effect.forever, Effect.forkChild )