@openclaw/fs-safe 0.20.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 (100) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +9 -1
  3. package/dist/advanced.d.ts +2 -0
  4. package/dist/advanced.js +1 -0
  5. package/dist/archive-zip-directory.js +4 -0
  6. package/dist/archive-zip-loader.js +3 -1
  7. package/dist/archive-zip-manifest.js +3 -1
  8. package/dist/atomic.d.ts +1 -1
  9. package/dist/copy-publication.d.ts +1 -3
  10. package/dist/copy-publication.js +2 -6
  11. package/dist/deny-mutation-match.d.ts +2 -0
  12. package/dist/deny-mutation-match.js +71 -0
  13. package/dist/deny-mutations.js +4 -3
  14. package/dist/file-identity.js +10 -2
  15. package/dist/file-lock-sync-admission.js +5 -1
  16. package/dist/file-lock-sync-root-io.d.ts +2 -2
  17. package/dist/file-lock-sync-root-io.js +1 -1
  18. package/dist/file-lock-sync.js +2 -2
  19. package/dist/file-store-prune.js +4 -4
  20. package/dist/mutation-authority.js +5 -0
  21. package/dist/native-binding.d.ts +33 -0
  22. package/dist/native-pinned-write.js +8 -2
  23. package/dist/pinned-mutation-admission.js +4 -2
  24. package/dist/replace-file-buffer.d.ts +4 -0
  25. package/dist/replace-file-buffer.js +36 -0
  26. package/dist/replace-file-copy-fallback.d.ts +2 -0
  27. package/dist/replace-file-copy-fallback.js +66 -38
  28. package/dist/replace-file-descriptor.d.ts +4 -0
  29. package/dist/replace-file-descriptor.js +9 -1
  30. package/dist/replace-file-destination.d.ts +21 -0
  31. package/dist/replace-file-destination.js +123 -0
  32. package/dist/replace-file-mutation.d.ts +26 -0
  33. package/dist/replace-file-mutation.js +47 -0
  34. package/dist/replace-file-temp-owner.d.ts +2 -2
  35. package/dist/replace-file-temp-owner.js +16 -4
  36. package/dist/replace-file-types.d.ts +55 -0
  37. package/dist/replace-file-types.js +1 -0
  38. package/dist/replace-file.d.ts +3 -55
  39. package/dist/replace-file.js +31 -11
  40. package/dist/retained-file-types.d.ts +50 -0
  41. package/dist/retained-file-types.js +1 -0
  42. package/dist/retained-file.d.ts +3 -0
  43. package/dist/retained-file.js +121 -0
  44. package/dist/root-directory-entry.d.ts +9 -0
  45. package/dist/root-directory-entry.js +28 -0
  46. package/dist/root-directory-list.d.ts +9 -1
  47. package/dist/root-directory-list.js +88 -37
  48. package/dist/root-handle-context.d.ts +4 -0
  49. package/dist/root-handle-context.js +12 -0
  50. package/dist/root-impl.d.ts +3 -3
  51. package/dist/root-impl.js +8 -2
  52. package/dist/root-move-noreplace.js +3 -3
  53. package/dist/root-walk.d.ts +19 -12
  54. package/dist/root-walk.js +49 -18
  55. package/dist/sidecar-lock-acquire.js +3 -3
  56. package/dist/sidecar-lock-reclaim.d.ts +2 -2
  57. package/dist/sidecar-lock-reclaim.js +6 -6
  58. package/dist/sidecar-lock.js +4 -4
  59. package/dist/staged-symlink-types.d.ts +4 -14
  60. package/dist/temp-target.js +3 -2
  61. package/dist/temp-workspace-admission.js +22 -21
  62. package/dist/temp-workspace-child-admission.d.ts +1 -1
  63. package/dist/temp-workspace-child-admission.js +14 -9
  64. package/dist/temp-workspace-ownership.d.ts +8 -0
  65. package/dist/temp-workspace-ownership.js +52 -0
  66. package/dist/test-hooks.d.ts +4 -0
  67. package/dist/watch-alias.d.ts +6 -0
  68. package/dist/watch-alias.js +88 -0
  69. package/dist/watch-hints.d.ts +9 -0
  70. package/dist/watch-hints.js +93 -0
  71. package/dist/watch-native.d.ts +36 -0
  72. package/dist/watch-native.js +73 -0
  73. package/dist/watch-scan.d.ts +28 -0
  74. package/dist/watch-scan.js +300 -0
  75. package/dist/watch-stream.d.ts +8 -0
  76. package/dist/watch-stream.js +32 -0
  77. package/dist/watch-types.d.ts +60 -0
  78. package/dist/watch-types.js +1 -0
  79. package/dist/watch.d.ts +5 -0
  80. package/dist/watch.js +530 -0
  81. package/docs/advanced.md +1 -0
  82. package/docs/archive.md +6 -0
  83. package/docs/atomic.md +82 -0
  84. package/docs/contributing.md +74 -6
  85. package/docs/durability.md +7 -0
  86. package/docs/index.md +1 -0
  87. package/docs/install.md +2 -0
  88. package/docs/native-helper.md +9 -0
  89. package/docs/native.md +24 -6
  90. package/docs/public-api.md +23 -2
  91. package/docs/retained-file.md +115 -0
  92. package/docs/root.md +25 -8
  93. package/docs/sidecar-lock.md +1 -1
  94. package/docs/staged-symlink.md +2 -1
  95. package/docs/temp.md +24 -4
  96. package/docs/testing.md +186 -4
  97. package/docs/types.md +6 -0
  98. package/docs/walk.md +22 -1
  99. package/docs/watch.md +251 -0
  100. package/package.json +12 -8
@@ -0,0 +1,73 @@
1
+ import { FsSafeError } from "./errors.js";
2
+ import { getNativeBinding } from "./native.js";
3
+ import { getFsSafeNativeConfig } from "./native-config.js";
4
+ export function watchBinding(mode) {
5
+ if (mode === "poll")
6
+ return;
7
+ const binding = getNativeBinding(); // Preserves require + missing-addon failure.
8
+ // Bun TSFN teardown remains unqualified; guarded native scans remain usable.
9
+ if (binding?.watchRegister && (process.platform !== "darwin" || binding.watchConfigure) && !process.versions.bun && !process.versions.deno && ["linux", "darwin", "win32"].includes(process.platform))
10
+ return binding;
11
+ if (mode === "events" || getFsSafeNativeConfig().mode === "require") {
12
+ throw new FsSafeError("helper-unavailable", "native watch events are unavailable", { details: { operation: "watch" } });
13
+ }
14
+ }
15
+ export class NativeWatchBackend {
16
+ binding;
17
+ root;
18
+ id;
19
+ streamPaths;
20
+ constructor(binding, root, callback, limit, persistent) {
21
+ this.binding = binding;
22
+ this.root = root;
23
+ this.streamPaths = JSON.stringify({ anchors: [root.rootReal], exclusions: [] });
24
+ try {
25
+ this.id = binding.watchRegister(root.rootReal, limit, batch => {
26
+ if (this.id !== undefined)
27
+ callback({ overflow: batch.overflow, error: batch.error, hints: batch.hints.map(hint => ({
28
+ directory: hint.directory, name: hint.name, event: hint.structural ? "rename" : "change",
29
+ })) });
30
+ }, persistent);
31
+ }
32
+ catch (cause) {
33
+ throw watchError(cause);
34
+ }
35
+ }
36
+ add(name, identity) {
37
+ try {
38
+ this.binding.watchAdd(this.id, { root: this.root.rootReal, relative: name,
39
+ rootDev: BigInt(this.root.rootIdentity.dev), rootIno: BigInt(this.root.rootIdentity.ino), ...identity });
40
+ }
41
+ catch (cause) {
42
+ throw watchError(cause);
43
+ }
44
+ }
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
+ }
61
+ close() {
62
+ const id = this.id;
63
+ this.id = undefined; // Fence queued TSFN callbacks before synchronous native join.
64
+ if (id !== undefined)
65
+ this.binding.watchUnregister(id);
66
+ }
67
+ }
68
+ function watchError(cause) {
69
+ const code = cause?.code;
70
+ return new FsSafeError(code === "ENOTSUP" ? "helper-unavailable" : ["ESTALE", "ENOTDIR", "ELOOP"].includes(code ?? "") ? "path-mismatch" : code === "ENOENT" ? "not-found" : "helper-failed", "native watch registration failed", {
71
+ cause, details: { operation: "watch", code },
72
+ });
73
+ }
@@ -0,0 +1,28 @@
1
+ import { type RootContext } from "./root-context.js";
2
+ import { type RootDirectoryObservationGuard } from "./root-directory-list.js";
3
+ import type { WatchEntry, WatchOptions, WatchScope } from "./watch-types.js";
4
+ export type DirectoryIdentity = Readonly<{
5
+ dev: bigint;
6
+ ino: bigint;
7
+ }>;
8
+ export type WatchSnapshot = {
9
+ entries: Map<string, string>;
10
+ excluded?: Map<string, WatchEntry["kind"]>;
11
+ excludedDirectories?: Map<string, string>;
12
+ directoryPaths?: Map<string, string>;
13
+ directories: Map<string, DirectoryIdentity>;
14
+ targets: Map<string, DirectoryIdentity>;
15
+ scanned: number;
16
+ structural?: Set<string>;
17
+ overflow?: boolean;
18
+ };
19
+ export declare function watchScopes(input: readonly WatchScope[]): readonly WatchScope[];
20
+ /** A descendant's unavailable metadata never grants it authority or retires the Root. */
21
+ export declare function isWatchPathError(error: unknown): boolean;
22
+ export declare function scanWatch(root: RootContext, scopes: readonly WatchScope[], options: Pick<WatchOptions, "exclude"> & {
23
+ maxEntries: number;
24
+ maxDirectories: number;
25
+ maxPendingPaths: number;
26
+ admitting: boolean;
27
+ previous?: WatchSnapshot;
28
+ }, signal: AbortSignal, register: (name: string, identity: DirectoryIdentity, guard: RootDirectoryObservationGuard) => Promise<void>, onCleanupFailure?: (error: unknown) => void): Promise<WatchSnapshot>;
@@ -0,0 +1,300 @@
1
+ import path from "node:path";
2
+ import { isWindowsReservedDeviceName } from "./device-path.js";
3
+ import { isNotFoundPathError } from "./path.js";
4
+ import { FsSafeError } from "./errors.js";
5
+ import { assertSynchronousCallbackResult } from "./mutation-authority.js";
6
+ import { validatePinnedRelativePath } from "./pinned-operation.js";
7
+ import { assertRootIdentityCurrent, assertValidRootRelativePath, resolvePathInRoot } from "./root-context.js";
8
+ import { createRootDirectoryObservationGuard, assertRootDirectoryObservationGuard, openRootDirectoryListing, pathStatFromStats } from "./root-directory-list.js";
9
+ import { createSuppressedError } from "./suppressed-error.js";
10
+ import { lookupRootDirectoryEntry } from "./root-directory-entry.js";
11
+ export function watchScopes(input) {
12
+ if (!Array.isArray(input) || input.length > 128)
13
+ throw new RangeError("watch accepts at most 128 scopes");
14
+ return Object.freeze(input.map(scope => {
15
+ const { path: suppliedPath, kind: suppliedKind, depth: suppliedDepth } = scope;
16
+ if (typeof suppliedPath !== "string" || path.isAbsolute(suppliedPath))
17
+ throw new FsSafeError("invalid-path", "watch scopes must be relative");
18
+ validatePinnedRelativePath(suppliedPath);
19
+ assertValidRootRelativePath(suppliedPath);
20
+ if (process.platform === "win32" && suppliedPath.split(/[\\/]/).some(component => component !== "." &&
21
+ (component.endsWith(".") || component.endsWith(" ") || isWindowsReservedDeviceName(component)))) {
22
+ throw new FsSafeError("invalid-path", "watch scopes must use literal Windows names");
23
+ }
24
+ if (suppliedKind !== "entry" && suppliedKind !== "tree")
25
+ throw new TypeError("invalid watch scope kind");
26
+ const depth = suppliedDepth ?? 32;
27
+ if (!Number.isSafeInteger(depth) || depth < 0 || depth > 128)
28
+ throw new RangeError("watch depth must be between 0 and 128");
29
+ // Normalize only admitted input, then remove the separator normalize preserves.
30
+ const spelling = path.normalize(suppliedPath);
31
+ const normalized = spelling.endsWith(path.sep) ? spelling.slice(0, -1) : spelling;
32
+ return Object.freeze({ path: normalized === "." ? "" : normalized, kind: suppliedKind, depth });
33
+ }));
34
+ }
35
+ function kind(entry) {
36
+ return entry.isSymbolicLink ? "symlink" : entry.isDirectory ? "directory" : entry.isFile ? "file" : "other";
37
+ }
38
+ function fingerprint(entry, identity) {
39
+ const { dev, ino } = identity;
40
+ // Metadata is advisory; identity bits are never rounded through PathStat. Directory size/mtime
41
+ // reflect children, not changes to an entry-only scope.
42
+ return entry.isDirectory
43
+ ? [kind(entry), dev, ino, entry.mode].join(":")
44
+ : [kind(entry), dev, ino, entry.size, entry.mtimeMs, entry.mode].join(":");
45
+ }
46
+ /** A descendant's unavailable metadata never grants it authority or retires the Root. */
47
+ export function isWatchPathError(error) {
48
+ if (isNotFoundPathError(error))
49
+ return true;
50
+ if (error instanceof FsSafeError) {
51
+ if (error.details?.operation === "watch" && ["EACCES", "EPERM", "EBUSY"].includes(String(error.details.code)))
52
+ return true;
53
+ return ["not-found", "path-mismatch", "not-file", "symlink", "outside-workspace", "path-alias"].includes(error.code);
54
+ }
55
+ return ["ENOTDIR", "EACCES", "EPERM", "EBUSY", "ESTALE", "EIO", "ELOOP"].includes(error?.code ?? "");
56
+ }
57
+ export async function scanWatch(root, scopes, options, signal, register, onCleanupFailure) {
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 };
61
+ const attempts = new Map();
62
+ const guards = new Map();
63
+ const walked = new Map();
64
+ const structural = new Set();
65
+ result.structural = structural;
66
+ const invalidate = (relative) => {
67
+ if (structural.size < options.maxPendingPaths)
68
+ structural.add(relative);
69
+ else
70
+ result.overflow = true;
71
+ // Discard partially observed names when their enclosing directory lost admission.
72
+ const below = (name) => name === relative || !relative || name.startsWith(relative + path.sep);
73
+ for (const map of [result.entries, exclusions, excludedDirectories, directoryPaths, result.targets, result.directories, guards, walked]) {
74
+ for (const name of map.keys())
75
+ if (below(name))
76
+ map.delete(name);
77
+ }
78
+ };
79
+ const recover = async (relative, error) => {
80
+ signal.throwIfAborted();
81
+ await assertRootIdentityCurrent(root);
82
+ const code = error instanceof FsSafeError ? error.details?.code ?? error.code : error?.code;
83
+ if (!isWatchPathError(error) || (!relative && (code === "EACCES" || code === "EPERM")))
84
+ throw error;
85
+ invalidate(relative);
86
+ };
87
+ const examined = () => {
88
+ if (++result.scanned > options.maxEntries)
89
+ throw new FsSafeError("too-large", "watch entry budget exceeded", { details: { operation: "scan" } });
90
+ };
91
+ const excluded = async (name, entry) => {
92
+ let value;
93
+ try {
94
+ value = options.exclude?.({ path: name, kind: kind(entry) });
95
+ assertSynchronousCallbackResult(value, "watch exclude");
96
+ }
97
+ catch (cause) {
98
+ throw new FsSafeError("helper-failed", "watch exclusion callback failed", { cause, details: { operation: "callback" } });
99
+ }
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
+ }
118
+ return value;
119
+ };
120
+ const directory = async (relative) => {
121
+ signal.throwIfAborted();
122
+ const prior = guards.get(relative);
123
+ if (prior) {
124
+ try {
125
+ await assertRootDirectoryObservationGuard(root, prior);
126
+ return prior;
127
+ }
128
+ catch (error) {
129
+ await recover(relative, error);
130
+ }
131
+ }
132
+ if (!attempts.has(relative) && attempts.size >= options.maxDirectories) {
133
+ throw new FsSafeError("too-large", "watch directory budget exceeded", { details: { operation: "scan" } });
134
+ }
135
+ let registrationError;
136
+ while ((attempts.get(relative) ?? 0) < 3) {
137
+ attempts.set(relative, (attempts.get(relative) ?? 0) + 1);
138
+ try {
139
+ const resolved = await resolvePathInRoot(root, relative ? "./" + relative : ".", { rejectSymlinks: true });
140
+ const guard = await createRootDirectoryObservationGuard(root, resolved.resolved);
141
+ const identity = { dev: guard.stat.dev, ino: guard.stat.ino };
142
+ result.directories.set(relative, identity);
143
+ directoryPaths.set(relative, guard.realPath);
144
+ await register(relative, identity, guard);
145
+ signal.throwIfAborted();
146
+ await assertRootDirectoryObservationGuard(root, guard);
147
+ guards.set(relative, guard);
148
+ return guard;
149
+ }
150
+ catch (error) {
151
+ registrationError = error;
152
+ await recover(relative, error);
153
+ }
154
+ }
155
+ if (!relative)
156
+ throw new FsSafeError("helper-failed", "watch Root registration could not be established", {
157
+ cause: registrationError, details: { operation: "watch", code: "registration-failed" },
158
+ });
159
+ throw new FsSafeError("path-mismatch", "watch directory changed during registration");
160
+ };
161
+ const tree = async (relative, depth) => {
162
+ if ((walked.get(relative) ?? 0) >= depth)
163
+ return;
164
+ walked.set(relative, depth);
165
+ let guard;
166
+ let listing;
167
+ for (let attempt = 0; attempt < 3; attempt++) {
168
+ try {
169
+ guard = await directory(relative);
170
+ listing = await openRootDirectoryListing(root, guard.realPath, {
171
+ order: "filesystem", snapshot: false, signal, exactIdentity: true, skipVanished: true, onCleanupFailure, admitEntry: () => { examined(); return true; },
172
+ });
173
+ // The listing has its own guard. Both identities must agree before reading names.
174
+ await assertRootDirectoryObservationGuard(root, guard);
175
+ break;
176
+ }
177
+ catch (error) {
178
+ await listing?.[Symbol.asyncDispose]();
179
+ listing = undefined;
180
+ await recover(relative, error);
181
+ }
182
+ }
183
+ if (!listing)
184
+ return;
185
+ let failed = false;
186
+ let operationError;
187
+ try {
188
+ while (true) {
189
+ const next = await listing.next();
190
+ signal.throwIfAborted();
191
+ if (!next)
192
+ break;
193
+ if (next.kind === "limit")
194
+ throw new FsSafeError("too-large", "watch entry budget exceeded");
195
+ if (!next.identity)
196
+ throw new FsSafeError("path-mismatch", "watch listing lacks exact identity");
197
+ const entry = next.entry;
198
+ const name = relative ? path.join(relative, entry.name) : entry.name;
199
+ if (await excluded(name, entry))
200
+ continue;
201
+ result.entries.set(name, fingerprint(entry, next.identity));
202
+ if (entry.isDirectory && !entry.isSymbolicLink && depth > 1)
203
+ await tree(name, depth - 1);
204
+ }
205
+ await listing.assertCurrent();
206
+ }
207
+ catch (error) {
208
+ failed = true;
209
+ operationError = error;
210
+ await recover(relative, error);
211
+ }
212
+ finally {
213
+ try {
214
+ await listing[Symbol.asyncDispose]();
215
+ }
216
+ catch (closeError) {
217
+ if (failed)
218
+ throw createSuppressedError(closeError, operationError, "watch scan and directory disposal both failed");
219
+ throw closeError;
220
+ }
221
+ }
222
+ try {
223
+ await assertRootDirectoryObservationGuard(root, guard);
224
+ }
225
+ catch (error) {
226
+ await recover(relative, error);
227
+ }
228
+ };
229
+ await assertRootIdentityCurrent(root);
230
+ for (const scope of scopes) {
231
+ signal.throwIfAborted();
232
+ if (!scope.path) {
233
+ const guard = await directory("");
234
+ result.entries.set("", fingerprint({ name: "", ...pathStatFromStats(guard.stat) }, guard.stat));
235
+ if (scope.kind === "tree" && scope.depth > 0)
236
+ await tree("", scope.depth);
237
+ continue;
238
+ }
239
+ const segments = scope.path.split(path.sep);
240
+ let relative = "";
241
+ try {
242
+ for (let i = 0; i < segments.length; i++) {
243
+ const guard = await directory(relative);
244
+ examined();
245
+ // Filesystem lookup, not lowercase/prefix matching, owns case, Unicode and
246
+ // short-name aliases. It also preserves case-sensitive Windows directories.
247
+ const found = await lookupRootDirectoryEntry(root, guard, segments[i]);
248
+ signal.throwIfAborted();
249
+ if (!found)
250
+ break;
251
+ const name = relative ? path.join(relative, segments[i]) : segments[i];
252
+ if (await excluded(name, found.entry))
253
+ break;
254
+ if (i === segments.length - 1) {
255
+ result.entries.set(name, fingerprint(found.entry, found.identity));
256
+ result.targets.set(name, found.identity);
257
+ if (scope.kind === "tree" && found.entry.isDirectory && !found.entry.isSymbolicLink && scope.depth > 0)
258
+ await tree(name, scope.depth);
259
+ break;
260
+ }
261
+ if (found.entry.isSymbolicLink) {
262
+ if (options.admitting)
263
+ throw new FsSafeError("symlink", "watch scope traverses a symbolic link; admit its target separately", { details: { operation: "scope" } });
264
+ invalidate(name);
265
+ break;
266
+ }
267
+ if (!found.entry.isDirectory)
268
+ break;
269
+ relative = name;
270
+ }
271
+ }
272
+ catch (error) {
273
+ if (error instanceof FsSafeError && error.details?.operation === "scope")
274
+ throw error;
275
+ await recover(relative, error);
276
+ }
277
+ }
278
+ for (const [relative, guard] of guards) {
279
+ try {
280
+ await assertRootDirectoryObservationGuard(root, guard);
281
+ }
282
+ catch (error) {
283
+ await recover(relative, error);
284
+ }
285
+ }
286
+ await assertRootIdentityCurrent(root);
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
+ }
299
+ return result;
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;
@@ -0,0 +1,32 @@
1
+ import path from "node:path";
2
+ function shallowest(paths, limit) {
3
+ const result = [];
4
+ const sorted = [...new Set(paths)].sort((a, b) => a.split(path.sep).length - b.split(path.sep).length || a.localeCompare(b));
5
+ for (const name of sorted) {
6
+ if (result.some(parent => name.startsWith(parent.endsWith(path.sep) ? parent : parent + path.sep)))
7
+ continue;
8
+ result.push(name);
9
+ if (result.length === limit)
10
+ break;
11
+ }
12
+ return result;
13
+ }
14
+ /** Canonical names come only from guarded directory observations, never backend hints. */
15
+ export function watchStreamPaths(snapshot, scopes) {
16
+ const anchors = [];
17
+ for (const scope of scopes) {
18
+ let name = scope.kind === "tree" && scope.depth !== 0 ? scope.path : path.dirname(scope.path);
19
+ if (name === ".")
20
+ name = "";
21
+ while (!snapshot.directoryPaths?.has(name)) {
22
+ if (!name)
23
+ break;
24
+ const parent = path.dirname(name);
25
+ name = parent === "." ? "" : parent;
26
+ }
27
+ const canonical = snapshot.directoryPaths?.get(name);
28
+ if (canonical)
29
+ anchors.push(canonical);
30
+ }
31
+ return { anchors: shallowest(anchors, 128), exclusions: shallowest(snapshot.excludedDirectories?.values() ?? [], 8) };
32
+ }
@@ -0,0 +1,60 @@
1
+ /** Literal Root-relative names. Trees include their entry; depth defaults to 32 (max 128). */
2
+ export type WatchScope = Readonly<{
3
+ path: string;
4
+ kind: "entry" | "tree";
5
+ depth?: number;
6
+ }>;
7
+ export type WatchEntry = Readonly<{
8
+ path: string;
9
+ kind: "file" | "directory" | "symlink" | "other";
10
+ }>;
11
+ export type WatchChange = Readonly<{
12
+ path: string;
13
+ type: "content" | "structural";
14
+ }>;
15
+ export type WatchInvalidation = Readonly<{
16
+ reason: "event" | "reconcile" | "overflow";
17
+ /** Bounded advisory detail. undefined => invalidate every configured scope. */
18
+ changes?: readonly WatchChange[];
19
+ }>;
20
+ export type WatchFailure = Readonly<{
21
+ operation: "watch" | "scan" | "callback" | "close";
22
+ code?: string;
23
+ error: unknown;
24
+ }>;
25
+ export type WatchHealth = Readonly<{
26
+ state: "starting" | "ready" | "reconciling" | "unavailable" | "closed";
27
+ mode: "events" | "poll";
28
+ /** Directories currently observed, for pressure warnings. */
29
+ directories: number;
30
+ failure?: WatchFailure;
31
+ }>;
32
+ export type WatchOptions = {
33
+ scopes: readonly WatchScope[];
34
+ /** Required. auto selects events when available, otherwise poll. */
35
+ mode: "auto" | "events" | "poll";
36
+ /** Keep the Node event loop alive while open. Defaults to true. */
37
+ persistent?: boolean;
38
+ /** Guarded reconciliation interval: events 30000, poll 1000; minimum 20 ms. */
39
+ intervalMs?: number;
40
+ /** Polling transport interval; overrides intervalMs in poll mode or auto fallback. Minimum 20 ms. */
41
+ pollIntervalMs?: number;
42
+ exclude?: (entry: WatchEntry) => boolean;
43
+ maxDirectories?: number;
44
+ maxEntries?: number;
45
+ maxPendingPaths?: number;
46
+ onInvalidate: (invalidation: WatchInvalidation) => void;
47
+ onHealth?: (health: WatchHealth) => void;
48
+ signal?: AbortSignal;
49
+ };
50
+ export type WatchSubscription = {
51
+ readonly ready: Promise<void>;
52
+ /** Immediately fences the old generation. Superseded calls reject AbortError. */
53
+ setScopes(scopes: readonly WatchScope[]): Promise<void>;
54
+ /** Completes a pass started after this call; concurrent pending requests coalesce. */
55
+ reconcile(): Promise<void>;
56
+ health(): WatchHealth;
57
+ /** Terminal, idempotent, joined; rejects only retirement failures. */
58
+ close(): Promise<void>;
59
+ [Symbol.asyncDispose](): Promise<void>;
60
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,5 @@
1
+ import type { Root } from "./root.js";
2
+ import type { WatchOptions, WatchSubscription } from "./watch-types.js";
3
+ export type * from "./watch-types.js";
4
+ /** Advisory observation only. Hints never grant filesystem authority. */
5
+ export declare function watch(root: Root, input: WatchOptions): WatchSubscription;