Skip to content

Commit 7bde6cc

Browse files
authored
Restore FileSystem.watch recursive control (#6705)
1 parent a3fabe2 commit 7bde6cc

5 files changed

Lines changed: 156 additions & 19 deletions

File tree

.changeset/wise-files-watch.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@effect/platform-deno": patch
3+
"@effect/platform-node-shared": patch
4+
"effect": patch
5+
---
6+
7+
Restore the `recursive` option for `FileSystem.watch`, with non-recursive watching as the default.

packages/effect/src/FileSystem.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -341,9 +341,15 @@ export interface FileSystem {
341341
mtime: Date | number
342342
) => Effect.Effect<void, PlatformError>
343343
/**
344-
* Watch a directory or file for changes
344+
* Watch a directory or file for changes.
345+
*
346+
* **Details**
347+
*
348+
* By default, only changes to the direct children of the directory are
349+
* reported. Set the `recursive` option to `true` to watch for changes in
350+
* subdirectories as well.
345351
*/
346-
readonly watch: (path: string) => Stream.Stream<WatchEvent, PlatformError>
352+
readonly watch: (path: string, options?: WatchOptions) => Stream.Stream<WatchEvent, PlatformError>
347353
/**
348354
* Write data to a file at `path`.
349355
*/
@@ -1247,6 +1253,19 @@ export declare namespace File {
12471253
*/
12481254
export type SeekMode = "start" | "current"
12491255

1256+
/**
1257+
* Options for watching files or directories.
1258+
*
1259+
* @category models
1260+
* @since 4.0.0
1261+
*/
1262+
export interface WatchOptions {
1263+
/**
1264+
* When `true`, changes in subdirectories are also reported.
1265+
*/
1266+
readonly recursive?: boolean | undefined
1267+
}
1268+
12501269
/**
12511270
* Represents file system events emitted when watching files or directories.
12521271
*
@@ -1363,5 +1382,9 @@ export declare namespace WatchEvent {
13631382
* @since 4.0.0
13641383
*/
13651384
export class WatchBackend extends Context.Service<WatchBackend, {
1366-
readonly register: (path: string, stat: File.Info) => Option.Option<Stream.Stream<WatchEvent, PlatformError>>
1385+
readonly register: (
1386+
path: string,
1387+
stat: File.Info,
1388+
options?: WatchOptions
1389+
) => Option.Option<Stream.Stream<WatchEvent, PlatformError>>
13671390
}>()("effect/platform/FileSystem/WatchBackend") {}

packages/platform-deno/src/DenoFileSystem.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -390,11 +390,14 @@ const truncate: FileSystem.FileSystem["truncate"] = (path, length) =>
390390
const utimes: FileSystem.FileSystem["utimes"] = (path, atime, mtime) =>
391391
tryPromise("utimes", path, () => Deno.utime(path, atime, mtime))
392392

393-
const watchNative = (path: string): Stream.Stream<FileSystem.WatchEvent, PlatformError.PlatformError> =>
393+
const watchNative = (
394+
path: string,
395+
options?: FileSystem.WatchOptions
396+
): Stream.Stream<FileSystem.WatchEvent, PlatformError.PlatformError> =>
394397
Stream.unwrap(
395398
Effect.map(
396399
Effect.try({
397-
try: () => Deno.watchFs(path, { recursive: true }),
400+
try: () => Deno.watchFs(path, { recursive: options?.recursive ?? false }),
398401
catch: handleError("FileSystem", "watch", path)
399402
}),
400403
(watcher) =>
@@ -421,12 +424,16 @@ const watchNative = (path: string): Stream.Stream<FileSystem.WatchEvent, Platfor
421424
)
422425
)
423426

424-
const watch = (backend: Option.Option<FileSystem.WatchBackend["Service"]>, path: string) =>
427+
const watch = (
428+
backend: Option.Option<FileSystem.WatchBackend["Service"]>,
429+
path: string,
430+
options?: FileSystem.WatchOptions
431+
) =>
425432
stat(path).pipe(
426433
Effect.map((info) =>
427434
backend.pipe(
428-
Option.flatMap((backend) => backend.register(path, info)),
429-
Option.getOrElse(() => watchNative(path))
435+
Option.flatMap((backend) => backend.register(path, info, options)),
436+
Option.getOrElse(() => watchNative(path, options))
430437
)
431438
),
432439
Stream.unwrap
@@ -469,8 +476,8 @@ const makeFileSystem = Effect.map(Effect.serviceOption(FileSystem.WatchBackend),
469476
symlink,
470477
truncate,
471478
utimes,
472-
watch(path) {
473-
return watch(backend, path)
479+
watch(path, options) {
480+
return watch(backend, path, options)
474481
},
475482
writeFile
476483
}))

packages/platform-node-shared/src/NodeFileSystem.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -550,12 +550,12 @@ const utimes = (() => {
550550

551551
// == watch
552552

553-
const watchNode = (path: string) =>
553+
const watchNode = (path: string, options?: FileSystem.WatchOptions) =>
554554
Stream.callback<FileSystem.WatchEvent, Error.PlatformError>((queue) =>
555555
Effect.acquireRelease(
556556
Effect.sync(() => {
557557
const watcher = NFS.watch(path, {
558-
recursive: true
558+
recursive: options?.recursive ?? false
559559
}, (event, path) => {
560560
if (!path) return
561561
switch (event) {
@@ -595,12 +595,16 @@ const watchNode = (path: string) =>
595595
)
596596
)
597597

598-
const watch = (backend: Option.Option<FileSystem.WatchBackend["Service"]>, path: string) =>
598+
const watch = (
599+
backend: Option.Option<FileSystem.WatchBackend["Service"]>,
600+
path: string,
601+
options?: FileSystem.WatchOptions
602+
) =>
599603
stat(path).pipe(
600604
Effect.map((stat) =>
601605
backend.pipe(
602-
Option.flatMap((_) => _.register(path, stat)),
603-
Option.getOrElse(() => watchNode(path))
606+
Option.flatMap((_) => _.register(path, stat, options)),
607+
Option.getOrElse(() => watchNode(path, options))
604608
)
605609
),
606610
Stream.unwrap
@@ -652,8 +656,8 @@ const makeFileSystem = Effect.map(Effect.serviceOption(FileSystem.WatchBackend),
652656
symlink,
653657
truncate,
654658
utimes,
655-
watch(path) {
656-
return watch(backend, path)
659+
watch(path, options) {
660+
return watch(backend, path, options)
657661
},
658662
writeFile
659663
}))
Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,101 @@
11
import * as NodeFileSystem from "@effect/platform-node-shared/NodeFileSystem"
2-
import { describe } from "@effect/vitest"
2+
import { assert, describe, it } from "@effect/vitest"
3+
import * as Deferred from "effect/Deferred"
4+
import * as Effect from "effect/Effect"
5+
import * as Fiber from "effect/Fiber"
6+
import * as FileSystem from "effect/FileSystem"
7+
import * as Stream from "effect/Stream"
8+
import * as TestClock from "effect/testing/TestClock"
39
import { testLayer } from "../../effect/test/FileSystem.test-utils.ts"
410

5-
describe("FileSystem", () => testLayer(NodeFileSystem.layer))
11+
const startWatch = <E, R>(
12+
fs: FileSystem.FileSystem,
13+
root: string,
14+
watch: () => Stream.Stream<FileSystem.WatchEvent, E, R>
15+
) =>
16+
Effect.gen(function*() {
17+
const ready = yield* Deferred.make<void>()
18+
const readyName = ".watch-ready"
19+
const fiber = yield* watch().pipe(
20+
Stream.tap((event) =>
21+
event.path === readyName
22+
? Deferred.succeed(ready, undefined)
23+
: Effect.void
24+
),
25+
Stream.filter((event) => event.path !== readyName),
26+
Stream.runHead,
27+
Effect.flatMap(Effect.fromOption),
28+
Effect.forkChild
29+
)
30+
const signalFiber = yield* Effect.sleep("10 millis").pipe(
31+
TestClock.withLive,
32+
Effect.andThen(fs.writeFileString(`${root}/${readyName}`, "")),
33+
Effect.forever,
34+
Effect.forkChild
35+
)
36+
yield* Deferred.await(ready).pipe(
37+
Effect.raceFirst(Fiber.join(fiber).pipe(Effect.asVoid)),
38+
Effect.ensuring(Fiber.interrupt(signalFiber))
39+
)
40+
return fiber
41+
})
42+
43+
describe("FileSystem", () => {
44+
testLayer(NodeFileSystem.layer)
45+
46+
it.effect("watch does not report nested changes when recursive is false", () =>
47+
Effect.gen(function*() {
48+
const fs = yield* FileSystem.FileSystem
49+
const root = yield* fs.makeTempDirectoryScoped()
50+
const nested = `${root}/nested`
51+
yield* fs.makeDirectory(nested)
52+
53+
const fiber = yield* startWatch(fs, root, () => fs.watch(root, { recursive: false }))
54+
55+
yield* fs.writeFileString(`${nested}/nested.txt`, "")
56+
yield* fs.writeFileString(`${root}/direct.txt`, "")
57+
58+
const event = yield* Fiber.join(fiber)
59+
assert.strictEqual(event.path, "direct.txt")
60+
}).pipe(
61+
Effect.scoped,
62+
Effect.provide(NodeFileSystem.layer)
63+
))
64+
65+
it.effect("watch is non-recursive when options are omitted", () =>
66+
Effect.gen(function*() {
67+
const fs = yield* FileSystem.FileSystem
68+
const root = yield* fs.makeTempDirectoryScoped()
69+
const nested = `${root}/nested`
70+
yield* fs.makeDirectory(nested)
71+
72+
const fiber = yield* startWatch(fs, root, () => fs.watch(root))
73+
74+
yield* fs.writeFileString(`${nested}/nested.txt`, "")
75+
yield* fs.writeFileString(`${root}/direct.txt`, "")
76+
77+
const event = yield* Fiber.join(fiber)
78+
assert.strictEqual(event.path, "direct.txt")
79+
}).pipe(
80+
Effect.scoped,
81+
Effect.provide(NodeFileSystem.layer)
82+
))
83+
84+
it.effect("watch reports nested changes when recursive is true", () =>
85+
Effect.gen(function*() {
86+
const fs = yield* FileSystem.FileSystem
87+
const root = yield* fs.makeTempDirectoryScoped()
88+
const nested = `${root}/nested`
89+
yield* fs.makeDirectory(nested)
90+
91+
const fiber = yield* startWatch(fs, root, () => fs.watch(root, { recursive: true }))
92+
93+
yield* fs.writeFileString(`${nested}/nested.txt`, "")
94+
95+
const event = yield* Fiber.join(fiber)
96+
assert(event.path.endsWith("nested.txt"))
97+
}).pipe(
98+
Effect.scoped,
99+
Effect.provide(NodeFileSystem.layer)
100+
))
101+
})

0 commit comments

Comments
 (0)