@openclaw/fs-safe 0.21.0 → 0.21.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/archive-zip-directory.js +4 -0
  3. package/dist/archive-zip-loader.js +3 -1
  4. package/dist/archive-zip-manifest.js +3 -1
  5. package/dist/copy-publication.d.ts +1 -3
  6. package/dist/copy-publication.js +2 -6
  7. package/dist/deny-mutation-match.d.ts +2 -0
  8. package/dist/deny-mutation-match.js +71 -0
  9. package/dist/deny-mutations.js +4 -3
  10. package/dist/file-identity.js +10 -2
  11. package/dist/file-lock-sync-admission.js +5 -1
  12. package/dist/file-lock-sync-root-io.d.ts +2 -2
  13. package/dist/file-lock-sync-root-io.js +1 -1
  14. package/dist/file-lock-sync.js +2 -2
  15. package/dist/file-store-prune.js +4 -4
  16. package/dist/mutation-authority.js +5 -0
  17. package/dist/native-binding.d.ts +11 -0
  18. package/dist/native-pinned-write.js +8 -2
  19. package/dist/pinned-mutation-admission.js +4 -2
  20. package/dist/replace-file-copy-fallback.js +4 -4
  21. package/dist/replace-file-destination.d.ts +4 -0
  22. package/dist/replace-file-destination.js +67 -5
  23. package/dist/replace-file.js +2 -1
  24. package/dist/retained-file-types.d.ts +3 -14
  25. package/dist/root-directory-list.d.ts +2 -0
  26. package/dist/root-directory-list.js +46 -20
  27. package/dist/root-move-noreplace.js +3 -3
  28. package/dist/sidecar-lock-acquire.js +3 -3
  29. package/dist/sidecar-lock-reclaim.d.ts +2 -2
  30. package/dist/sidecar-lock-reclaim.js +6 -6
  31. package/dist/sidecar-lock.js +4 -4
  32. package/dist/staged-symlink-types.d.ts +4 -14
  33. package/dist/test-hooks.d.ts +1 -0
  34. package/dist/watch-alias.js +11 -3
  35. package/dist/watch-hints.d.ts +2 -1
  36. package/dist/watch-hints.js +17 -1
  37. package/dist/watch-native.d.ts +4 -0
  38. package/dist/watch-native.js +18 -1
  39. package/dist/watch-scan.d.ts +5 -1
  40. package/dist/watch-scan.js +37 -6
  41. package/dist/watch-stream.d.ts +8 -0
  42. package/dist/watch-stream.js +32 -0
  43. package/dist/watch-types.d.ts +2 -0
  44. package/dist/watch.js +35 -7
  45. package/docs/archive.md +6 -0
  46. package/docs/atomic.md +23 -2
  47. package/docs/contributing.md +69 -6
  48. package/docs/install.md +2 -0
  49. package/docs/native.md +24 -6
  50. package/docs/public-api.md +23 -2
  51. package/docs/retained-file.md +4 -2
  52. package/docs/root.md +19 -7
  53. package/docs/sidecar-lock.md +1 -1
  54. package/docs/staged-symlink.md +2 -1
  55. package/docs/testing.md +132 -10
  56. package/docs/watch.md +77 -10
  57. package/package.json +8 -8
@@ -37,6 +37,8 @@ export type RootDirectoryListingOptions = {
37
37
  metadataBatchSize?: number;
38
38
  /** Internal exact metadata lane, supported by streaming filesystem order. */
39
39
  exactIdentity?: boolean;
40
+ /** Advisory scans may omit vanished leaves after revalidating their parent. */
41
+ skipVanished?: boolean;
40
42
  /** Internal owner receives cleanup failures, including acquisition rollback. */
41
43
  onCleanupFailure?: (error: unknown) => void;
42
44
  admitEntry(): boolean;
@@ -1,3 +1,4 @@
1
+ import { isUtf8 } from "node:buffer";
1
2
  import fsSync from "node:fs";
2
3
  import fs from "node:fs/promises";
3
4
  import path from "node:path";
@@ -14,6 +15,22 @@ import { realpathSync } from "./realpath.js";
14
15
  import { inspectStatObservationSync } from "./stat-observation.js";
15
16
  import { getFsSafeTestHooks } from "./test-hooks.js";
16
17
  const METADATA_BATCH_SIZE = 32;
18
+ function directoryEntryName(bytes) {
19
+ if (!isUtf8(bytes)) {
20
+ throw new FsSafeError("invalid-path", "directory entry name is not valid UTF-8");
21
+ }
22
+ return bytes.toString("utf8");
23
+ }
24
+ async function readDirectoryEntryName(handle) {
25
+ const entry = await handle.read();
26
+ // Bun returns the raw Buffer directly; Node returns a Dirent whose name is a Buffer.
27
+ // Node's Dir types still declare only string names.
28
+ return entry === null ? undefined
29
+ : directoryEntryName(Buffer.isBuffer(entry) ? entry : entry.name);
30
+ }
31
+ function openDirectoryNames(directory) {
32
+ return fs.opendir(directory, { bufferSize: 1, encoding: "buffer" });
33
+ }
17
34
  export function pathStatFromStats(stat) {
18
35
  const mtimeMs = typeof stat.mtimeMs === "bigint"
19
36
  ? "mtimeNs" in stat && typeof stat.mtimeNs === "bigint"
@@ -202,7 +219,7 @@ async function listGuardedDirectoryPath(root, guard, withFileTypes, receipt) {
202
219
  const beforeObservation = getFsSafeTestHooks()?.beforeRootListObservation;
203
220
  if (beforeObservation)
204
221
  await beforeObservation(guard.realPath, withFileTypes);
205
- const names = (await fs.readdir(guard.realPath)).sort();
222
+ const names = (await fs.readdir(guard.realPath, { encoding: "buffer" })).map(directoryEntryName).sort();
206
223
  entries = withFileTypes
207
224
  ? names.map(name => ({
208
225
  name,
@@ -275,17 +292,17 @@ export async function openRootDirectoryListing(root, directory, options, receipt
275
292
  await assertCurrent();
276
293
  if (options.order === "filesystem") {
277
294
  // A one-entry buffer keeps the truncation lookahead independent of width.
278
- handle = await fs.opendir(guard.realPath, { bufferSize: 1 });
295
+ handle = await openDirectoryNames(guard.realPath);
279
296
  }
280
297
  else if (options.snapshot) {
281
298
  snapshot = await listGuardedDirectoryPath(root, guard, true, receipt);
282
299
  }
283
300
  else if (options.maxNames !== undefined) {
284
301
  names = [];
285
- handle = await fs.opendir(guard.realPath, { bufferSize: 1 });
302
+ handle = await openDirectoryNames(guard.realPath);
286
303
  while (true) {
287
304
  await assertCurrent();
288
- const name = (await handle.read())?.name;
305
+ const name = await readDirectoryEntryName(handle);
289
306
  await assertCurrent();
290
307
  if (name === undefined)
291
308
  break;
@@ -297,7 +314,7 @@ export async function openRootDirectoryListing(root, directory, options, receipt
297
314
  await close();
298
315
  }
299
316
  else {
300
- names = await fs.readdir(guard.realPath);
317
+ names = (await fs.readdir(guard.realPath, { encoding: "buffer" })).map(directoryEntryName);
301
318
  }
302
319
  await assertCurrent();
303
320
  names?.sort();
@@ -382,21 +399,30 @@ export async function openRootDirectoryListing(root, directory, options, receipt
382
399
  return { kind: "limit", name: pendingLimit };
383
400
  return;
384
401
  }
385
- await assertCurrent();
386
- const name = (await handle.read())?.name;
387
- await assertCurrent();
388
- if (name === undefined)
389
- return;
390
- if (!options.admitEntry())
391
- return { kind: "limit", name };
392
- // The stream's post-read fence is also the pre-stat fence in this owned operation.
393
- const pathname = path.join(guard.realPath, name);
394
- const observed = options.exactIdentity ? inspectStatObservationSync(bigint => bigint
395
- ? fsSync.lstatSync(pathname, { bigint: true }) : fsSync.lstatSync(pathname)) : undefined;
396
- const stat = observed?.stat ?? fsSync.lstatSync(pathname);
397
- await assertCurrent();
398
- const entry = { name, ...pathStatFromStats(stat) };
399
- return observed ? { kind: "entry", entry, identity: observed.identity } : { kind: "entry", entry };
402
+ for (;;) {
403
+ await assertCurrent();
404
+ const name = await readDirectoryEntryName(handle);
405
+ await assertCurrent();
406
+ if (name === undefined)
407
+ return;
408
+ if (!options.admitEntry())
409
+ return { kind: "limit", name };
410
+ // The stream's post-read fence is also the pre-stat fence in this owned operation.
411
+ const pathname = path.join(guard.realPath, name);
412
+ let observed;
413
+ try {
414
+ observed = options.exactIdentity ? inspectStatObservationSync(bigint => bigint
415
+ ? fsSync.lstatSync(pathname, { bigint: true }) : fsSync.lstatSync(pathname)) : { stat: fsSync.lstatSync(pathname) };
416
+ }
417
+ catch (error) {
418
+ await assertCurrent();
419
+ if (options.skipVanished && isNotFoundPathError(error))
420
+ continue;
421
+ throw error;
422
+ }
423
+ await assertCurrent();
424
+ return { kind: "entry", entry: { name, ...pathStatFromStats(observed.stat) }, identity: observed.identity };
425
+ }
400
426
  }
401
427
  catch (error) {
402
428
  throw normalizeDirectoryError(error);
@@ -4,7 +4,7 @@ import { assertAsyncDirectoryGuard, assertSyncDirectoryGuard } from "./directory
4
4
  import { FsSafeError } from "./errors.js";
5
5
  import { assertMutationNotDenied } from "./deny-mutations.js";
6
6
  import { openNativeParentAdmission, openNativeRootAdmission, } from "./native-parent-admission.js";
7
- import { getNativeBinding } from "./native.js";
7
+ import { requireNativeBinding } from "./native.js";
8
8
  import { isNotFoundPathError } from "./path.js";
9
9
  import { assertRootIdentityCurrent, assertRootIdentityCurrentSync } from "./root-context.js";
10
10
  import { resolveRootPathSync } from "./root-path.js";
@@ -78,8 +78,8 @@ export async function movePathNoReplaceNative(root, params, paths) {
78
78
  throw error;
79
79
  // Advisory fast rejection only. renameNoReplace owns the collision decision.
80
80
  }
81
- const binding = getNativeBinding();
82
- if (!binding || typeof binding.renameNoReplace !== "function") {
81
+ const binding = requireNativeBinding();
82
+ if (typeof binding.renameNoReplace !== "function") {
83
83
  throw new FsSafeError("helper-unavailable", "native no-replace move is unavailable");
84
84
  }
85
85
  const rootAdmission = await openNativeRootAdmission(binding, {
@@ -290,9 +290,9 @@ export async function acquireSidecarLock(options, context) {
290
290
  }
291
291
  await handle.writeFile(raw, "utf8");
292
292
  }
293
- const snapshot = { raw, payload, stat: fsSync.fstatSync(handle.fd), ownershipToken };
293
+ const snapshot = { raw, payload, stat: fsSync.fstatSync(handle.fd, { bigint: true }), ownershipToken };
294
294
  createdSnapshot = snapshot;
295
- if (snapshot.stat.nlink === 0) {
295
+ if (snapshot.stat.nlink === 0n) {
296
296
  await handle.close();
297
297
  handle = null;
298
298
  await waitForRetry();
@@ -361,7 +361,7 @@ export async function acquireSidecarLock(options, context) {
361
361
  if (handle) {
362
362
  failedSnapshot ??= { payload: null };
363
363
  try {
364
- failedSnapshot.stat = fsSync.fstatSync(handle.fd);
364
+ failedSnapshot.stat = fsSync.fstatSync(handle.fd, { bigint: true });
365
365
  }
366
366
  catch {
367
367
  // Best-effort cleanup of a failed exclusive create.
@@ -1,4 +1,4 @@
1
- import { type Stats } from "node:fs";
1
+ import { type BigIntStats, type Stats } from "node:fs";
2
2
  import type { Root } from "./root-impl.js";
3
3
  export type SidecarLockStaleSnapshot = {
4
4
  lockPath: string;
@@ -9,7 +9,7 @@ export type SidecarLockStaleSnapshot = {
9
9
  export type SidecarLockSnapshot = {
10
10
  raw?: string;
11
11
  payload: unknown;
12
- stat?: Stats;
12
+ stat?: Stats | BigIntStats;
13
13
  ownershipToken?: string;
14
14
  };
15
15
  type SidecarLockRawSnapshot = Omit<SidecarLockSnapshot, "payload"> & {
@@ -90,7 +90,7 @@ export async function readSidecarLockRawSnapshot(lockPath, options = {}) {
90
90
  const raw = (await readFileHandleBounded(opened.handle, MAX_LOCK_PAYLOAD_BYTES)).toString("utf8");
91
91
  return {
92
92
  raw,
93
- stat: opened.stat,
93
+ stat: fsSync.fstatSync(opened.handle.fd, { bigint: true }),
94
94
  };
95
95
  }
96
96
  finally {
@@ -99,7 +99,7 @@ export async function readSidecarLockRawSnapshot(lockPath, options = {}) {
99
99
  }
100
100
  let before;
101
101
  try {
102
- before = fsSync.lstatSync(lockPath);
102
+ before = fsSync.lstatSync(lockPath, { bigint: true });
103
103
  }
104
104
  catch (error) {
105
105
  before = missingSnapshotPath(error);
@@ -132,7 +132,7 @@ export async function readSidecarLockRawSnapshot(lockPath, options = {}) {
132
132
  options.onOpenFailure?.(error);
133
133
  throw error;
134
134
  }
135
- const opened = fsSync.fstatSync(handle.fd);
135
+ const opened = fsSync.fstatSync(handle.fd, { bigint: true });
136
136
  if (!opened.isFile()) {
137
137
  if (options.rejectNonFile) {
138
138
  throw new FsSafeError("not-file", `sidecar lock is not a regular file: ${lockPath}`);
@@ -144,7 +144,7 @@ export async function readSidecarLockRawSnapshot(lockPath, options = {}) {
144
144
  const raw = (await readFileHandleBounded(handle, MAX_LOCK_PAYLOAD_BYTES)).toString("utf8");
145
145
  let after;
146
146
  try {
147
- after = fsSync.lstatSync(lockPath);
147
+ after = fsSync.lstatSync(lockPath, { bigint: true });
148
148
  }
149
149
  catch (error) {
150
150
  after = missingSnapshotPath(error);
@@ -159,7 +159,7 @@ export async function readSidecarLockRawSnapshot(lockPath, options = {}) {
159
159
  }
160
160
  function lstatSidecarLockSync(lockPath) {
161
161
  try {
162
- return fsSync.lstatSync(lockPath);
162
+ return fsSync.lstatSync(lockPath, { bigint: true });
163
163
  }
164
164
  catch (error) {
165
165
  return missingSnapshotPath(error);
@@ -189,7 +189,7 @@ export function readSidecarLockRawSnapshotSync(lockPath, options = {}) {
189
189
  options.onOpenFailure?.(error);
190
190
  throw error;
191
191
  }
192
- const opened = fsSync.fstatSync(fd);
192
+ const opened = fsSync.fstatSync(fd, { bigint: true });
193
193
  if (!opened.isFile()) {
194
194
  if (options.rejectNonFile) {
195
195
  throw new FsSafeError("not-file", `sidecar lock is not a regular file: ${lockPath}`);
@@ -50,7 +50,7 @@ function resolveManagerState(key) {
50
50
  function snapshotMatchesSync(lockPath, observed) {
51
51
  let fd;
52
52
  try {
53
- const beforeStat = fsSync.lstatSync(lockPath);
53
+ const beforeStat = fsSync.lstatSync(lockPath, { bigint: true });
54
54
  if (!beforeStat.isFile()) {
55
55
  return false;
56
56
  }
@@ -60,17 +60,17 @@ function snapshotMatchesSync(lockPath, observed) {
60
60
  : 0) |
61
61
  (typeof fsSync.constants.O_NONBLOCK === "number" ? fsSync.constants.O_NONBLOCK : 0);
62
62
  fd = fsSync.openSync(lockPath, openFlags);
63
- const openedStat = fsSync.fstatSync(fd);
63
+ const openedStat = fsSync.fstatSync(fd, { bigint: true });
64
64
  // Token-owned files can have different descriptor/path identities on VirtioFS.
65
65
  // Require a known descriptor identity without rejecting that supported drift.
66
66
  if (!openedStat.isFile() || !sameFileIdentityForCleanup(openedStat, openedStat)) {
67
67
  return false;
68
68
  }
69
- if (observed.raw !== undefined && openedStat.size !== Buffer.byteLength(observed.raw)) {
69
+ if (observed.raw !== undefined && openedStat.size !== BigInt(Buffer.byteLength(observed.raw))) {
70
70
  return false;
71
71
  }
72
72
  const raw = fsSync.readFileSync(fd, "utf8");
73
- const afterStat = fsSync.lstatSync(lockPath);
73
+ const afterStat = fsSync.lstatSync(lockPath, { bigint: true });
74
74
  if (!afterStat.isFile() || !sameFileIdentityForCleanup(beforeStat, afterStat)) {
75
75
  return false;
76
76
  }
@@ -1,20 +1,13 @@
1
- import type { StagedFileReceipt } from "./staged-file-types.js";
1
+ import type { PublishedFileReceipt, StagedFileCleanupReceipt, StagedFileReceipt } from "./staged-file-types.js";
2
2
  /** Caller-captured admission evidence, never a same-target ownership heuristic. */
3
- export type StagedSymlinkExpected = Readonly<{
4
- dev: bigint;
5
- ino: bigint;
6
- uid: number;
7
- gid: number;
8
- ctimeNs: bigint;
3
+ export type StagedSymlinkExpected = Readonly<Pick<StagedFileReceipt["identity"], "dev" | "ino" | "uid" | "gid" | "ctimeNs"> & {
9
4
  target: string;
10
5
  }>;
11
6
  export type StagedSymlinkReceipt = StagedFileReceipt & Readonly<{
12
7
  target: string;
13
8
  }>;
14
- export type PublishedSymlinkReceipt = Readonly<{
15
- status: "published";
9
+ export type PublishedSymlinkReceipt = Readonly<Pick<PublishedFileReceipt, "status" | "basename"> & {
16
10
  staged: StagedSymlinkReceipt;
17
- basename: string;
18
11
  overwrite: false;
19
12
  }>;
20
13
  export type StagedSymlinkPublication = Readonly<{
@@ -25,11 +18,8 @@ export type StagedSymlinkPublication = Readonly<{
25
18
  overwrite: false;
26
19
  }>;
27
20
  export type StagedSymlinkRemoval = "removed" | "name-absent" | "preserved";
28
- export type StagedSymlinkCleanupReceipt = Readonly<{
29
- temporaryBasename: string;
21
+ export type StagedSymlinkCleanupReceipt = Readonly<Pick<StagedFileCleanupReceipt, "temporaryBasename" | "status" | "resources"> & {
30
22
  publication: StagedSymlinkPublication;
31
- status: StagedSymlinkRemoval | "failed" | "not-needed";
32
- resources: "closed" | "close-failed";
33
23
  }>;
34
24
  export type StagedSymlinkFailureDetails = Readonly<{
35
25
  phase: "prepare" | "publish" | "remove-published" | "cleanup";
@@ -3,6 +3,7 @@ import type { FileIdentityStat } from "./file-identity.js";
3
3
  export type FsSafeTestHooks = {
4
4
  beforeWatchRegistration?: (path: string) => void | Promise<void>;
5
5
  afterWatchRegistration?: (path: string) => void | Promise<void>;
6
+ afterWatchBackendOverflow?: (root: string, phase: "received" | "reconciled") => void;
6
7
  afterWatchBackendCreated?: (root: string, emit: (batch: import("./watch-native.js").NativeWatchBatch) => void, nativeEvent?: (path: string, flags: number) => void) => void;
7
8
  afterPreOpenLstat?: (filePath: string) => Promise<void> | void;
8
9
  beforeOpen?: (filePath: string, flags: number) => Promise<void> | void;
@@ -4,10 +4,10 @@ import { isNotFoundPathError } from "./path.js";
4
4
  import { assertRootIdentityCurrent, resolvePathInRoot } from "./root-context.js";
5
5
  import { createRootDirectoryObservationGuard, assertRootDirectoryObservationGuard } from "./root-directory-list.js";
6
6
  import { lookupRootDirectoryEntry } from "./root-directory-entry.js";
7
- import { nativeChanges, scopedChanges } from "./watch-hints.js";
7
+ import { excludedWatchPath, nativeChanges, scopedChanges } from "./watch-hints.js";
8
8
  /** Resolve native spelling aliases without treating case folding as identity. */
9
9
  export async function admittedNativeChanges(root, scopes, before, after, batch, signal, limit) {
10
- if (!nativeChanges(scopes, before, batch, limit))
10
+ if (!nativeChanges(scopes, before, batch, limit, after))
11
11
  return undefined;
12
12
  const result = new Map();
13
13
  const candidates = new Map([...before?.targets ?? [], ...after.targets, ...after.directories]);
@@ -22,6 +22,9 @@ export async function admittedNativeChanges(root, scopes, before, after, batch,
22
22
  signal.throwIfAborted();
23
23
  const name = hint.name; // nativeChanges rejected unknown or non-literal names.
24
24
  let parent = hint.directory;
25
+ const relative = parent ? path.join(parent, name) : name;
26
+ if (excludedWatchPath(before, relative) || excludedWatchPath(after, relative))
27
+ continue;
25
28
  let guard;
26
29
  let expected = after.directories.get(parent);
27
30
  if (!expected) {
@@ -44,6 +47,8 @@ export async function admittedNativeChanges(root, scopes, before, after, batch,
44
47
  [parent, expected] = admitted;
45
48
  }
46
49
  const candidate = parent ? path.join(parent, name) : name;
50
+ if (excludedWatchPath(before, candidate) || excludedWatchPath(after, candidate))
51
+ continue;
47
52
  const selected = scopedChanges(scopes, { path: candidate,
48
53
  type: hint.event === "change" && before?.entries.get(candidate)?.startsWith("file:") ? "content" : "structural" });
49
54
  if (selected.length) {
@@ -61,8 +66,11 @@ export async function admittedNativeChanges(root, scopes, before, after, batch,
61
66
  }
62
67
  const found = await lookupRootDirectoryEntry(root, guard, name);
63
68
  signal.throwIfAborted();
69
+ // A missing unselected sibling is not evidence of lost selected detail.
70
+ // Previously observed selected deletions remain visible in the snapshot diff;
71
+ // an unseen, already-gone name has no identity proving a selected alias.
64
72
  if (!found)
65
- return undefined; // Could be a deleted short-name/case alias.
73
+ continue;
66
74
  for (const [relative, identity] of candidates) {
67
75
  if (!relative || (path.dirname(relative) === "." ? "" : path.dirname(relative)) !== parent)
68
76
  continue;
@@ -1,8 +1,9 @@
1
1
  import type { NativeWatchBatch } from "./watch-native.js";
2
2
  import type { WatchSnapshot } from "./watch-scan.js";
3
3
  import type { WatchChange, WatchScope } from "./watch-types.js";
4
+ export declare function excludedWatchPath(snapshot: WatchSnapshot | undefined, name: string): boolean;
4
5
  export declare function scopedChanges(scopes: readonly WatchScope[], change: WatchChange): WatchChange[];
5
- export declare function nativeChanges(scopes: readonly WatchScope[], snapshot: WatchSnapshot | undefined, batch: NativeWatchBatch, limit?: number): WatchChange[] | undefined;
6
+ export declare function nativeChanges(scopes: readonly WatchScope[], snapshot: WatchSnapshot | undefined, batch: NativeWatchBatch, limit?: number, after?: WatchSnapshot): WatchChange[] | undefined;
6
7
  export declare function changedEntries(before: WatchSnapshot | undefined, after: WatchSnapshot, limit: number): WatchChange[] | undefined;
7
8
  /** Backend names never establish authority to publish a pathname. */
8
9
  export declare function guardedHintChanges(scopes: readonly WatchScope[], before: WatchSnapshot | undefined, after: WatchSnapshot, hints: readonly WatchChange[] | undefined, observed: readonly WatchChange[] | undefined, limit: number): WatchChange[] | undefined;
@@ -5,6 +5,18 @@ function below(parent, child) {
5
5
  function distance(parent, child) {
6
6
  return (parent === "" ? child : child.slice(parent.length + 1)).split(path.sep).length;
7
7
  }
8
+ export function excludedWatchPath(snapshot, name) {
9
+ const exclusions = snapshot?.excluded;
10
+ if (exclusions?.has(name))
11
+ return true;
12
+ while (name) {
13
+ const parent = path.dirname(name);
14
+ name = parent === "." ? "" : parent;
15
+ if (exclusions?.get(name) === "directory")
16
+ return true;
17
+ }
18
+ return false;
19
+ }
8
20
  export function scopedChanges(scopes, change) {
9
21
  const result = new Map();
10
22
  for (const scope of scopes) {
@@ -18,7 +30,7 @@ export function scopedChanges(scopes, change) {
18
30
  }
19
31
  return [...result.values()];
20
32
  }
21
- export function nativeChanges(scopes, snapshot, batch, limit = 256) {
33
+ export function nativeChanges(scopes, snapshot, batch, limit = 256, after) {
22
34
  if (batch.overflow)
23
35
  return undefined;
24
36
  const result = new Map();
@@ -28,6 +40,8 @@ export function nativeChanges(scopes, snapshot, batch, limit = 256) {
28
40
  if (typeof name !== "string" || !name || name === "." || name === ".." || name.includes("\0") || name.includes("/") || (process.platform === "win32" && /[\\:]/.test(name)))
29
41
  return undefined;
30
42
  const relative = hint.directory ? path.join(hint.directory, name) : name;
43
+ if (excludedWatchPath(snapshot, relative) || excludedWatchPath(after, relative))
44
+ continue;
31
45
  for (const change of scopedChanges(scopes, {
32
46
  path: relative,
33
47
  type: hint.event === "change" && snapshot?.entries.get(relative)?.startsWith("file:") ? "content" : "structural",
@@ -68,6 +82,8 @@ export function guardedHintChanges(scopes, before, after, hints, observed, limit
68
82
  // A stale/misdirected inode watch may report outside names: erase its detail.
69
83
  if (!before?.entries.has(hint.path) && !after.entries.has(hint.path) && !scopes.some(scope => scope.path === hint.path))
70
84
  return undefined;
85
+ // Equal snapshots cannot exclude an intermediate change and restoration (ABA).
86
+ // Preserve admitted hints, including setup activity delivered after ready.
71
87
  if (!result.has(hint.path) && result.size >= limit)
72
88
  return undefined;
73
89
  const prior = result.get(hint.path);
@@ -1,6 +1,7 @@
1
1
  import { type NativeBinding } from "./native.js";
2
2
  import type { RootContext } from "./root-context.js";
3
3
  import type { DirectoryIdentity } from "./watch-scan.js";
4
+ import type { WatchStreamPaths } from "./watch-stream.js";
4
5
  export type NativeWatchHint = {
5
6
  directory: string;
6
7
  name: string;
@@ -16,6 +17,7 @@ export type NativeWatchWireBatch = {
16
17
  directory: string;
17
18
  name: string;
18
19
  structural: boolean;
20
+ flags?: number;
19
21
  }[];
20
22
  overflow: boolean;
21
23
  error?: string;
@@ -25,8 +27,10 @@ export declare class NativeWatchBackend {
25
27
  private binding;
26
28
  private root;
27
29
  private id;
30
+ private streamPaths;
28
31
  constructor(binding: NativeBinding, root: RootContext, callback: (batch: NativeWatchBatch) => void, limit: number, persistent: boolean);
29
32
  add(name: string, identity: DirectoryIdentity): void;
30
33
  testEvent(path: string, flags: number): void;
34
+ configure(paths: WatchStreamPaths): boolean;
31
35
  close(): void;
32
36
  }
@@ -6,7 +6,7 @@ export function watchBinding(mode) {
6
6
  return;
7
7
  const binding = getNativeBinding(); // Preserves require + missing-addon failure.
8
8
  // Bun TSFN teardown remains unqualified; guarded native scans remain usable.
9
- if (binding?.watchRegister && !process.versions.bun && !process.versions.deno && ["linux", "darwin", "win32"].includes(process.platform))
9
+ if (binding?.watchRegister && (process.platform !== "darwin" || binding.watchConfigure) && !process.versions.bun && !process.versions.deno && ["linux", "darwin", "win32"].includes(process.platform))
10
10
  return binding;
11
11
  if (mode === "events" || getFsSafeNativeConfig().mode === "require") {
12
12
  throw new FsSafeError("helper-unavailable", "native watch events are unavailable", { details: { operation: "watch" } });
@@ -16,9 +16,11 @@ export class NativeWatchBackend {
16
16
  binding;
17
17
  root;
18
18
  id;
19
+ streamPaths;
19
20
  constructor(binding, root, callback, limit, persistent) {
20
21
  this.binding = binding;
21
22
  this.root = root;
23
+ this.streamPaths = JSON.stringify({ anchors: [root.rootReal], exclusions: [] });
22
24
  try {
23
25
  this.id = binding.watchRegister(root.rootReal, limit, batch => {
24
26
  if (this.id !== undefined)
@@ -41,6 +43,21 @@ export class NativeWatchBackend {
41
43
  }
42
44
  }
43
45
  testEvent(path, flags) { this.binding.watchTestEvent(this.id, path, flags); }
46
+ configure(paths) {
47
+ if (process.platform !== "darwin")
48
+ return false;
49
+ const key = JSON.stringify(paths);
50
+ if (this.streamPaths === key)
51
+ return false;
52
+ try {
53
+ this.binding.watchConfigure(this.id, paths.anchors, paths.exclusions);
54
+ }
55
+ catch (cause) {
56
+ throw watchError(cause);
57
+ }
58
+ this.streamPaths = key;
59
+ return true;
60
+ }
44
61
  close() {
45
62
  const id = this.id;
46
63
  this.id = undefined; // Fence queued TSFN callbacks before synchronous native join.
@@ -1,12 +1,15 @@
1
1
  import { type RootContext } from "./root-context.js";
2
2
  import { type RootDirectoryObservationGuard } from "./root-directory-list.js";
3
- import type { WatchOptions, WatchScope } from "./watch-types.js";
3
+ import type { WatchEntry, WatchOptions, WatchScope } from "./watch-types.js";
4
4
  export type DirectoryIdentity = Readonly<{
5
5
  dev: bigint;
6
6
  ino: bigint;
7
7
  }>;
8
8
  export type WatchSnapshot = {
9
9
  entries: Map<string, string>;
10
+ excluded?: Map<string, WatchEntry["kind"]>;
11
+ excludedDirectories?: Map<string, string>;
12
+ directoryPaths?: Map<string, string>;
10
13
  directories: Map<string, DirectoryIdentity>;
11
14
  targets: Map<string, DirectoryIdentity>;
12
15
  scanned: number;
@@ -21,4 +24,5 @@ export declare function scanWatch(root: RootContext, scopes: readonly WatchScope
21
24
  maxDirectories: number;
22
25
  maxPendingPaths: number;
23
26
  admitting: boolean;
27
+ previous?: WatchSnapshot;
24
28
  }, signal: AbortSignal, register: (name: string, identity: DirectoryIdentity, guard: RootDirectoryObservationGuard) => Promise<void>, onCleanupFailure?: (error: unknown) => void): Promise<WatchSnapshot>;
@@ -55,7 +55,9 @@ export function isWatchPathError(error) {
55
55
  return ["ENOTDIR", "EACCES", "EPERM", "EBUSY", "ESTALE", "EIO", "ELOOP"].includes(error?.code ?? "");
56
56
  }
57
57
  export async function scanWatch(root, scopes, options, signal, register, onCleanupFailure) {
58
- const result = { entries: new Map(), directories: new Map(), targets: new Map(), scanned: 0 };
58
+ const exclusions = new Map();
59
+ const excludedDirectories = new Map(), directoryPaths = new Map();
60
+ const result = { entries: new Map(), excluded: exclusions, excludedDirectories, directoryPaths, directories: new Map(), targets: new Map(), scanned: 0 };
59
61
  const attempts = new Map();
60
62
  const guards = new Map();
61
63
  const walked = new Map();
@@ -68,7 +70,7 @@ export async function scanWatch(root, scopes, options, signal, register, onClean
68
70
  result.overflow = true;
69
71
  // Discard partially observed names when their enclosing directory lost admission.
70
72
  const below = (name) => name === relative || !relative || name.startsWith(relative + path.sep);
71
- for (const map of [result.entries, result.targets, result.directories, guards, walked]) {
73
+ for (const map of [result.entries, exclusions, excludedDirectories, directoryPaths, result.targets, result.directories, guards, walked]) {
72
74
  for (const name of map.keys())
73
75
  if (below(name))
74
76
  map.delete(name);
@@ -86,7 +88,7 @@ export async function scanWatch(root, scopes, options, signal, register, onClean
86
88
  if (++result.scanned > options.maxEntries)
87
89
  throw new FsSafeError("too-large", "watch entry budget exceeded", { details: { operation: "scan" } });
88
90
  };
89
- const excluded = (name, entry) => {
91
+ const excluded = async (name, entry) => {
90
92
  let value;
91
93
  try {
92
94
  value = options.exclude?.({ path: name, kind: kind(entry) });
@@ -96,6 +98,23 @@ export async function scanWatch(root, scopes, options, signal, register, onClean
96
98
  throw new FsSafeError("helper-failed", "watch exclusion callback failed", { cause, details: { operation: "callback" } });
97
99
  }
98
100
  signal.throwIfAborted();
101
+ if (value) {
102
+ exclusions.set(name, kind(entry));
103
+ if (process.platform === "darwin" && entry.isDirectory && !entry.isSymbolicLink) {
104
+ try {
105
+ const resolved = await resolvePathInRoot(root, "./" + name, { rejectSymlinks: true });
106
+ const guard = await createRootDirectoryObservationGuard(root, resolved.resolved);
107
+ await assertRootDirectoryObservationGuard(root, guard);
108
+ excludedDirectories.set(name, guard.realPath);
109
+ }
110
+ catch (error) {
111
+ signal.throwIfAborted();
112
+ await assertRootIdentityCurrent(root);
113
+ if (!isWatchPathError(error))
114
+ throw error;
115
+ }
116
+ }
117
+ }
99
118
  return value;
100
119
  };
101
120
  const directory = async (relative) => {
@@ -121,6 +140,7 @@ export async function scanWatch(root, scopes, options, signal, register, onClean
121
140
  const guard = await createRootDirectoryObservationGuard(root, resolved.resolved);
122
141
  const identity = { dev: guard.stat.dev, ino: guard.stat.ino };
123
142
  result.directories.set(relative, identity);
143
+ directoryPaths.set(relative, guard.realPath);
124
144
  await register(relative, identity, guard);
125
145
  signal.throwIfAborted();
126
146
  await assertRootDirectoryObservationGuard(root, guard);
@@ -148,7 +168,7 @@ export async function scanWatch(root, scopes, options, signal, register, onClean
148
168
  try {
149
169
  guard = await directory(relative);
150
170
  listing = await openRootDirectoryListing(root, guard.realPath, {
151
- order: "filesystem", snapshot: false, signal, exactIdentity: true, onCleanupFailure, admitEntry: () => { examined(); return true; },
171
+ order: "filesystem", snapshot: false, signal, exactIdentity: true, skipVanished: true, onCleanupFailure, admitEntry: () => { examined(); return true; },
152
172
  });
153
173
  // The listing has its own guard. Both identities must agree before reading names.
154
174
  await assertRootDirectoryObservationGuard(root, guard);
@@ -176,7 +196,7 @@ export async function scanWatch(root, scopes, options, signal, register, onClean
176
196
  throw new FsSafeError("path-mismatch", "watch listing lacks exact identity");
177
197
  const entry = next.entry;
178
198
  const name = relative ? path.join(relative, entry.name) : entry.name;
179
- if (excluded(name, entry))
199
+ if (await excluded(name, entry))
180
200
  continue;
181
201
  result.entries.set(name, fingerprint(entry, next.identity));
182
202
  if (entry.isDirectory && !entry.isSymbolicLink && depth > 1)
@@ -229,7 +249,7 @@ export async function scanWatch(root, scopes, options, signal, register, onClean
229
249
  if (!found)
230
250
  break;
231
251
  const name = relative ? path.join(relative, segments[i]) : segments[i];
232
- if (excluded(name, found.entry))
252
+ if (await excluded(name, found.entry))
233
253
  break;
234
254
  if (i === segments.length - 1) {
235
255
  result.entries.set(name, fingerprint(found.entry, found.identity));
@@ -265,5 +285,16 @@ export async function scanWatch(root, scopes, options, signal, register, onClean
265
285
  }
266
286
  await assertRootIdentityCurrent(root);
267
287
  signal.throwIfAborted();
288
+ // Native deletion hints can arrive after a later scan. Keep bounded tombstones
289
+ // until the name is admitted again; they never grant authority to publish it.
290
+ for (const [name, kind] of options.previous?.excluded ?? []) {
291
+ if (exclusions.size >= options.maxEntries)
292
+ break;
293
+ if (!result.entries.has(name) && !result.directories.has(name) && !exclusions.has(name))
294
+ exclusions.set(name, kind);
295
+ const priorPath = options.previous?.excludedDirectories?.get(name);
296
+ if (kind === "directory" && exclusions.get(name) === "directory" && priorPath && !excludedDirectories.has(name))
297
+ excludedDirectories.set(name, priorPath);
298
+ }
268
299
  return result;
269
300
  }
@@ -0,0 +1,8 @@
1
+ import type { WatchSnapshot } from "./watch-scan.js";
2
+ import type { WatchScope } from "./watch-types.js";
3
+ export type WatchStreamPaths = {
4
+ anchors: string[];
5
+ exclusions: string[];
6
+ };
7
+ /** Canonical names come only from guarded directory observations, never backend hints. */
8
+ export declare function watchStreamPaths(snapshot: WatchSnapshot, scopes: readonly WatchScope[]): WatchStreamPaths;