@openclaw/fs-safe 0.20.0 → 0.21.0

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 (69) hide show
  1. package/CHANGELOG.md +23 -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/atomic.d.ts +1 -1
  6. package/dist/native-binding.d.ts +22 -0
  7. package/dist/replace-file-buffer.d.ts +4 -0
  8. package/dist/replace-file-buffer.js +36 -0
  9. package/dist/replace-file-copy-fallback.d.ts +2 -0
  10. package/dist/replace-file-copy-fallback.js +66 -38
  11. package/dist/replace-file-descriptor.d.ts +4 -0
  12. package/dist/replace-file-descriptor.js +9 -1
  13. package/dist/replace-file-destination.d.ts +17 -0
  14. package/dist/replace-file-destination.js +61 -0
  15. package/dist/replace-file-mutation.d.ts +26 -0
  16. package/dist/replace-file-mutation.js +47 -0
  17. package/dist/replace-file-temp-owner.d.ts +2 -2
  18. package/dist/replace-file-temp-owner.js +16 -4
  19. package/dist/replace-file-types.d.ts +55 -0
  20. package/dist/replace-file-types.js +1 -0
  21. package/dist/replace-file.d.ts +3 -55
  22. package/dist/replace-file.js +29 -10
  23. package/dist/retained-file-types.d.ts +61 -0
  24. package/dist/retained-file-types.js +1 -0
  25. package/dist/retained-file.d.ts +3 -0
  26. package/dist/retained-file.js +121 -0
  27. package/dist/root-directory-entry.d.ts +9 -0
  28. package/dist/root-directory-entry.js +28 -0
  29. package/dist/root-directory-list.d.ts +7 -1
  30. package/dist/root-directory-list.js +48 -23
  31. package/dist/root-handle-context.d.ts +4 -0
  32. package/dist/root-handle-context.js +12 -0
  33. package/dist/root-impl.d.ts +3 -3
  34. package/dist/root-impl.js +8 -2
  35. package/dist/root-walk.d.ts +19 -12
  36. package/dist/root-walk.js +49 -18
  37. package/dist/temp-target.js +3 -2
  38. package/dist/temp-workspace-admission.js +22 -21
  39. package/dist/temp-workspace-child-admission.d.ts +1 -1
  40. package/dist/temp-workspace-child-admission.js +14 -9
  41. package/dist/temp-workspace-ownership.d.ts +8 -0
  42. package/dist/temp-workspace-ownership.js +52 -0
  43. package/dist/test-hooks.d.ts +3 -0
  44. package/dist/watch-alias.d.ts +6 -0
  45. package/dist/watch-alias.js +80 -0
  46. package/dist/watch-hints.d.ts +8 -0
  47. package/dist/watch-hints.js +77 -0
  48. package/dist/watch-native.d.ts +32 -0
  49. package/dist/watch-native.js +56 -0
  50. package/dist/watch-scan.d.ts +24 -0
  51. package/dist/watch-scan.js +269 -0
  52. package/dist/watch-types.d.ts +58 -0
  53. package/dist/watch-types.js +1 -0
  54. package/dist/watch.d.ts +5 -0
  55. package/dist/watch.js +502 -0
  56. package/docs/advanced.md +1 -0
  57. package/docs/atomic.md +61 -0
  58. package/docs/contributing.md +5 -0
  59. package/docs/durability.md +7 -0
  60. package/docs/index.md +1 -0
  61. package/docs/native-helper.md +9 -0
  62. package/docs/retained-file.md +113 -0
  63. package/docs/root.md +6 -1
  64. package/docs/temp.md +24 -4
  65. package/docs/testing.md +60 -0
  66. package/docs/types.md +6 -0
  67. package/docs/walk.md +22 -1
  68. package/docs/watch.md +184 -0
  69. package/package.json +12 -8
package/dist/watch.js ADDED
@@ -0,0 +1,502 @@
1
+ import { FsSafeError } from "./errors.js";
2
+ import { assertSynchronousCallbackResult } from "./mutation-authority.js";
3
+ import { assertRootIdentityCurrent } from "./root-context.js";
4
+ import { rootHandleContext } from "./root-handle-context.js";
5
+ import { createSuppressedError } from "./suppressed-error.js";
6
+ import { getFsSafeTestHooks } from "./test-hooks.js";
7
+ import { admittedNativeChanges } from "./watch-alias.js";
8
+ import { changedEntries, guardedHintChanges, scopedChanges } from "./watch-hints.js";
9
+ import { watchBinding, NativeWatchBackend } from "./watch-native.js";
10
+ import { getFsSafeNativeConfig } from "./native-config.js";
11
+ import { isWatchPathError, scanWatch, watchScopes } from "./watch-scan.js";
12
+ function deferred() {
13
+ let settled = false;
14
+ let resolve;
15
+ let reject;
16
+ const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
17
+ // Ownership exists even when a consumer closes without awaiting startup.
18
+ void promise.catch(() => { });
19
+ return { promise, get settled() { return settled; },
20
+ resolve() { settled = true; resolve(); },
21
+ reject(error) { settled = true; reject(error); },
22
+ };
23
+ }
24
+ function budget(value, fallback, name, max = 1_000_000) {
25
+ const result = value ?? fallback;
26
+ if (!Number.isSafeInteger(result) || result < 1 || result > max)
27
+ throw new RangeError("invalid watch " + name);
28
+ return result;
29
+ }
30
+ const retired = () => new DOMException("Watch generation retired", "AbortError");
31
+ const coalesceMs = 25;
32
+ /** Advisory observation only. Hints never grant filesystem authority. */
33
+ export function watch(root, input) {
34
+ const context = rootHandleContext(root);
35
+ const options = { ...input };
36
+ const persistent = options.persistent !== false;
37
+ if (!["auto", "events", "poll"].includes(options.mode))
38
+ throw new TypeError("invalid watch mode");
39
+ let binding;
40
+ let selectionFailure;
41
+ try {
42
+ binding = watchBinding(options.mode);
43
+ }
44
+ catch (error) {
45
+ selectionFailure = error;
46
+ }
47
+ let mode = binding || options.mode === "events" ? "events" : "poll";
48
+ let intervalMs = budget(options.intervalMs, mode === "events" ? 30_000 : 1000, "intervalMs", 2_147_483_647);
49
+ if (intervalMs < 20)
50
+ throw new RangeError("watch intervalMs must be at least 20");
51
+ const maxDirectories = budget(options.maxDirectories, 4096, "maxDirectories");
52
+ const maxEntries = budget(options.maxEntries, 100_000, "maxEntries");
53
+ const maxPendingPaths = budget(options.maxPendingPaths, 256, "maxPendingPaths", 4096);
54
+ if (typeof options.onInvalidate !== "function")
55
+ throw new TypeError("watch requires onInvalidate");
56
+ const makeGeneration = (scopes) => ({
57
+ scopes: watchScopes(scopes), abort: new AbortController(), waiter: deferred(),
58
+ });
59
+ let current = makeGeneration(options.scopes);
60
+ const ready = current.waiter.promise;
61
+ let state = "starting";
62
+ let terminal = false;
63
+ let failure;
64
+ let failureInfo;
65
+ let retirementFailure;
66
+ const retirementErrors = new Set();
67
+ let closing;
68
+ let active;
69
+ let backend;
70
+ const registered = new Map();
71
+ let observedDirectories = 0;
72
+ let snapshot;
73
+ let resetRequested = false;
74
+ let refreshBackend = false;
75
+ let pending = false;
76
+ let pendingWaiter;
77
+ let runningWaiter;
78
+ let pendingHint = false;
79
+ let pendingChanges = new Map();
80
+ let timer;
81
+ let hintTimer;
82
+ const combinedFailure = () => {
83
+ if (!retirementFailure)
84
+ return failure;
85
+ const error = retirementFailure.error;
86
+ if (failure === undefined || error === failure || error?.suppressed === failure)
87
+ return error;
88
+ return createSuppressedError(error, failure, "watch observation and retirement failed");
89
+ };
90
+ const retainRetirement = (error) => {
91
+ if (retirementErrors.has(error))
92
+ return;
93
+ retirementErrors.add(error);
94
+ retirementFailure = { error: retirementFailure
95
+ ? createSuppressedError(error, retirementFailure.error, "watch retirement failed more than once") : error };
96
+ };
97
+ const health = () => Object.freeze({
98
+ state, mode, directories: observedDirectories,
99
+ ...(failure === undefined && !retirementFailure ? {} : {
100
+ failure: retirementFailure ? Object.freeze({ operation: "close", error: combinedFailure() }) : failureInfo,
101
+ }),
102
+ });
103
+ const check = (g) => {
104
+ g.abort.signal.throwIfAborted();
105
+ if (terminal || current !== g)
106
+ throw retired();
107
+ if (failure !== undefined)
108
+ throw failure;
109
+ if (retirementFailure)
110
+ throw retirementFailure.error;
111
+ };
112
+ const clearTimers = () => {
113
+ clearTimeout(timer);
114
+ clearTimeout(hintTimer);
115
+ timer = undefined;
116
+ hintTimer = undefined;
117
+ pending = false;
118
+ pendingHint = false;
119
+ pendingChanges = new Map();
120
+ };
121
+ const notifyHealth = () => {
122
+ try {
123
+ const result = options.onHealth?.(health());
124
+ assertSynchronousCallbackResult(result, "watch onHealth");
125
+ }
126
+ catch (cause) {
127
+ throw new FsSafeError("helper-failed", "watch health callback failed", { cause, details: { operation: "callback" } });
128
+ }
129
+ };
130
+ const dirty = (g, reason, changes) => {
131
+ check(g);
132
+ try {
133
+ const result = options.onInvalidate(Object.freeze({ reason, changes: changes && Object.freeze(changes) }));
134
+ assertSynchronousCallbackResult(result, "watch onInvalidate");
135
+ }
136
+ catch (cause) {
137
+ throw new FsSafeError("helper-failed", "watch dirty callback failed", { cause, details: { operation: "callback" } });
138
+ }
139
+ check(g);
140
+ };
141
+ const retain = (error) => {
142
+ if (failure === undefined) {
143
+ failure = error;
144
+ failureInfo = Object.freeze({ operation: "close", error });
145
+ }
146
+ else if (error !== failure)
147
+ failure = createSuppressedError(error, failure, "watch and retirement both failed");
148
+ };
149
+ const retireBackend = async () => {
150
+ const old = backend;
151
+ try {
152
+ await old?.close();
153
+ }
154
+ catch (error) {
155
+ retainRetirement(error);
156
+ }
157
+ if (backend === old) {
158
+ backend = undefined;
159
+ registered.clear();
160
+ observedDirectories = 0;
161
+ }
162
+ if (retirementFailure)
163
+ throw retirementFailure.error;
164
+ };
165
+ const lose = (error, operation = "scan") => {
166
+ if (terminal || failure !== undefined)
167
+ return;
168
+ if (error === undefined)
169
+ error = new FsSafeError("helper-failed", "watch operation failed without an error value", { details: { operation } });
170
+ retain(error);
171
+ const details = error instanceof FsSafeError ? error.details : undefined;
172
+ const code = details?.code ?? error?.code;
173
+ failureInfo = Object.freeze({ error, operation: details?.operation === "watch" ? "watch" : details?.operation === "callback" ? "callback" : details?.operation === "close" ? "close" : operation, ...(typeof code === "string" ? { code } : {}) });
174
+ current.abort.abort(error);
175
+ current.waiter.reject(error);
176
+ pendingWaiter?.reject(error);
177
+ runningWaiter?.reject(error);
178
+ pendingWaiter = undefined;
179
+ runningWaiter = undefined;
180
+ clearTimers();
181
+ state = "unavailable";
182
+ // Stop backend admission before notifying. Its join remains owned by active/close.
183
+ try {
184
+ backend?.close();
185
+ }
186
+ catch (error) {
187
+ retainRetirement(error);
188
+ }
189
+ try {
190
+ notifyHealth();
191
+ }
192
+ catch (callbackError) {
193
+ retain(callbackError);
194
+ }
195
+ };
196
+ const scheduleInterval = () => {
197
+ if (terminal || failure !== undefined)
198
+ return;
199
+ timer = setTimeout(() => { timer = undefined; void request().catch(() => { }); }, intervalMs);
200
+ if (!persistent)
201
+ timer.unref();
202
+ };
203
+ const onHint = (g, batch) => {
204
+ if (terminal || current !== g || g.abort.signal.aborted || failure !== undefined)
205
+ return;
206
+ if (batch.error === "ESTALE")
207
+ refreshBackend = true;
208
+ else if (batch.error) {
209
+ lose(new FsSafeError("helper-failed", "native watch failed", { details: { operation: "watch", code: batch.error } }));
210
+ return;
211
+ }
212
+ if (batch.overflow)
213
+ pendingChanges = undefined;
214
+ else if (pendingChanges)
215
+ for (const hint of batch.hints) {
216
+ const key = JSON.stringify([hint.directory, hint.name]);
217
+ if (!pendingChanges.has(key) && pendingChanges.size >= maxPendingPaths) {
218
+ pendingChanges = undefined;
219
+ break;
220
+ }
221
+ const previous = pendingChanges.get(key);
222
+ pendingChanges.set(key, previous?.event === "rename" ? previous : hint);
223
+ }
224
+ pendingHint = true;
225
+ pending = true;
226
+ if (hintTimer)
227
+ return;
228
+ hintTimer = setTimeout(() => {
229
+ hintTimer = undefined;
230
+ if (terminal || current !== g || failure !== undefined)
231
+ return;
232
+ // Raw backend filenames stay private. Reconcile before publishing detail.
233
+ void request().catch(() => { });
234
+ }, coalesceMs);
235
+ if (!persistent)
236
+ hintTimer.unref();
237
+ };
238
+ const fallBack = (error) => {
239
+ if (options.mode !== "auto" || getFsSafeNativeConfig().mode === "require" ||
240
+ !(error instanceof FsSafeError) || error.code !== "helper-unavailable")
241
+ return false;
242
+ mode = "poll";
243
+ binding = undefined;
244
+ intervalMs = options.intervalMs ?? 1000;
245
+ return true;
246
+ };
247
+ const observe = async (g) => {
248
+ check(g);
249
+ if (selectionFailure !== undefined)
250
+ throw new FsSafeError("helper-unavailable", "native watch events are unavailable", { cause: selectionFailure, details: { operation: "watch" } });
251
+ if (refreshBackend) {
252
+ await retireBackend();
253
+ check(g);
254
+ refreshBackend = false;
255
+ }
256
+ if (resetRequested) {
257
+ await retireBackend();
258
+ check(g);
259
+ snapshot = undefined;
260
+ resetRequested = false;
261
+ }
262
+ state = snapshot ? "reconciling" : "starting";
263
+ notifyHealth();
264
+ check(g);
265
+ const started = performance.now();
266
+ const hadHints = pendingHint;
267
+ const hints = pendingChanges;
268
+ pendingHint = false;
269
+ pendingChanges = new Map();
270
+ clearTimeout(hintTimer);
271
+ hintTimer = undefined;
272
+ if (mode === "events" && !backend && g.scopes.length) {
273
+ try {
274
+ const candidate = new NativeWatchBackend(binding, context, batch => {
275
+ if (backend === candidate)
276
+ onHint(g, batch);
277
+ }, maxPendingPaths, persistent);
278
+ backend = candidate;
279
+ const hookResult = getFsSafeTestHooks()?.afterWatchBackendCreated?.(context.rootReal, batch => {
280
+ if (backend === candidate)
281
+ onHint(g, batch);
282
+ }, (path, flags) => candidate.testEvent(path, flags));
283
+ assertSynchronousCallbackResult(hookResult, "afterWatchBackendCreated");
284
+ }
285
+ catch (error) {
286
+ if (!fallBack(error))
287
+ throw error;
288
+ }
289
+ check(g);
290
+ }
291
+ const next = await scanWatch(context, g.scopes, { exclude: options.exclude, maxDirectories, maxEntries, maxPendingPaths, admitting: !snapshot }, g.abort.signal, async (name, identity, guard) => {
292
+ check(g);
293
+ const existing = registered.get(name);
294
+ const acquire = !existing || existing.dev !== identity.dev || existing.ino !== identity.ino;
295
+ await getFsSafeTestHooks()?.beforeWatchRegistration?.(guard.realPath);
296
+ check(g);
297
+ if (acquire) {
298
+ try {
299
+ backend?.add(name, identity);
300
+ }
301
+ catch (error) {
302
+ if (snapshot || !fallBack(error))
303
+ throw error;
304
+ await retireBackend();
305
+ check(g);
306
+ }
307
+ }
308
+ await getFsSafeTestHooks()?.afterWatchRegistration?.(guard.realPath);
309
+ check(g);
310
+ if (acquire)
311
+ registered.set(name, identity);
312
+ }, retainRetirement);
313
+ check(g);
314
+ // Retire stale inventory before the next pass; that crawl installs fresh anchors first.
315
+ if ([...registered.keys()].some(name => !next.directories.has(name))) {
316
+ refreshBackend = true;
317
+ }
318
+ observedDirectories = next.directories.size;
319
+ const initial = !snapshot;
320
+ let observed = next.overflow ? undefined : changedEntries(snapshot, next, maxPendingPaths);
321
+ if (observed) {
322
+ const changes = new Map(observed.map(change => [change.path, change]));
323
+ for (const name of next.structural ?? [])
324
+ for (const change of scopedChanges(g.scopes, { path: name, type: "structural" }))
325
+ changes.set(change.path, change);
326
+ observed = changes.size > maxPendingPaths ? undefined : [...changes.values()];
327
+ }
328
+ let admittedHints = [];
329
+ try {
330
+ if (hadHints)
331
+ admittedHints = await admittedNativeChanges(context, g.scopes, snapshot, next, {
332
+ hints: hints ? [...hints.values()] : [], overflow: !hints,
333
+ }, g.abort.signal, maxPendingPaths);
334
+ }
335
+ catch (error) {
336
+ check(g);
337
+ await assertRootIdentityCurrent(context);
338
+ if (!isWatchPathError(error))
339
+ throw error;
340
+ admittedHints = undefined;
341
+ }
342
+ check(g);
343
+ await assertRootIdentityCurrent(context);
344
+ check(g);
345
+ let details = hadHints
346
+ ? guardedHintChanges(g.scopes, snapshot, next, admittedHints, observed, maxPendingPaths)
347
+ : observed;
348
+ const behind = (hadHints || pendingHint) && performance.now() - started > coalesceMs;
349
+ if (behind)
350
+ details = undefined;
351
+ snapshot = next;
352
+ // Publish before readiness; callbacks may synchronously retire this generation.
353
+ if (initial || !details || details.length)
354
+ dirty(g, initial ? "reconcile" : !details ? "overflow" : hadHints ? "event" : "reconcile", initial ? undefined : details);
355
+ check(g);
356
+ state = "ready";
357
+ notifyHealth();
358
+ check(g);
359
+ };
360
+ const pump = () => {
361
+ if (active || terminal || failure !== undefined)
362
+ return;
363
+ // Enroll before any callback. There is one active pass and one coalesced request.
364
+ active = Promise.resolve().then(async () => {
365
+ do {
366
+ const g = current;
367
+ pending = false;
368
+ if (pendingWaiter) {
369
+ // A scope replacement keeps reconcile requests alive until its baseline completes.
370
+ const previous = runningWaiter;
371
+ runningWaiter = pendingWaiter;
372
+ pendingWaiter = undefined;
373
+ if (previous)
374
+ void runningWaiter.promise.then(() => previous.resolve(), error => previous.reject(error));
375
+ }
376
+ try {
377
+ await observe(g);
378
+ check(g);
379
+ g.waiter.resolve();
380
+ runningWaiter?.resolve();
381
+ runningWaiter = undefined;
382
+ }
383
+ catch (error) {
384
+ g.waiter.reject(error);
385
+ if (!g.abort.signal.aborted && current === g && !terminal)
386
+ lose(error);
387
+ try {
388
+ await retireBackend();
389
+ }
390
+ catch (closeError) {
391
+ retainRetirement(closeError);
392
+ }
393
+ }
394
+ if (current !== g) {
395
+ await retireBackend();
396
+ snapshot = undefined;
397
+ pending = true;
398
+ }
399
+ } while (pending && !terminal && failure === undefined);
400
+ }).catch(lose).finally(() => {
401
+ active = undefined;
402
+ if (!terminal && failure === undefined) {
403
+ if (pending || !current.waiter.settled)
404
+ pump();
405
+ else
406
+ scheduleInterval();
407
+ }
408
+ });
409
+ };
410
+ const request = () => {
411
+ if (terminal)
412
+ return Promise.reject(retired());
413
+ if (failure !== undefined)
414
+ return Promise.reject(failure);
415
+ clearTimeout(timer);
416
+ timer = undefined;
417
+ pending = true;
418
+ pendingWaiter ??= deferred();
419
+ const result = pendingWaiter.promise;
420
+ pump();
421
+ return result;
422
+ };
423
+ const close = () => {
424
+ if (closing)
425
+ return closing;
426
+ terminal = true;
427
+ current.abort.abort(retired());
428
+ current.waiter.reject(retired());
429
+ pendingWaiter?.reject(retired());
430
+ runningWaiter?.reject(retired());
431
+ pendingWaiter = undefined;
432
+ runningWaiter = undefined;
433
+ clearTimers();
434
+ options.signal?.removeEventListener("abort", abort);
435
+ // Start physical stop immediately, without waiting behind an in-flight scan.
436
+ try {
437
+ backend?.close();
438
+ }
439
+ catch (error) {
440
+ retainRetirement(error);
441
+ }
442
+ closing = Promise.resolve().then(async () => {
443
+ await active;
444
+ try {
445
+ await retireBackend();
446
+ }
447
+ catch (error) {
448
+ retainRetirement(error);
449
+ }
450
+ state = "closed";
451
+ if (retirementFailure)
452
+ throw combinedFailure();
453
+ });
454
+ void closing.catch(() => { });
455
+ return closing;
456
+ };
457
+ const abort = () => { void close(); };
458
+ const subscription = {
459
+ ready, health, close, [Symbol.asyncDispose]: close,
460
+ reconcile: request,
461
+ setScopes(scopes) {
462
+ if (terminal)
463
+ return Promise.reject(retired());
464
+ if (failure !== undefined)
465
+ return Promise.reject(failure);
466
+ const previous = current;
467
+ const next = makeGeneration(scopes);
468
+ // Scope accessors may synchronously close this owner during validation.
469
+ if (terminal || failure !== undefined || current !== previous) {
470
+ next.waiter.reject(failure ?? retired());
471
+ return next.waiter.promise;
472
+ }
473
+ current.abort.abort(retired());
474
+ current.waiter.reject(retired());
475
+ current = next;
476
+ resetRequested = true;
477
+ try {
478
+ backend?.close();
479
+ }
480
+ catch (error) {
481
+ retainRetirement(error);
482
+ }
483
+ clearTimers();
484
+ state = "starting";
485
+ // If idle, retire the old backend before the next scan can admit anything.
486
+ if (!active) {
487
+ active = retireBackend().catch(error => lose(error, "close")).finally(() => {
488
+ active = undefined;
489
+ snapshot = undefined;
490
+ pump();
491
+ });
492
+ }
493
+ return next.waiter.promise;
494
+ },
495
+ };
496
+ options.signal?.addEventListener("abort", abort, { once: true });
497
+ if (options.signal?.aborted)
498
+ abort();
499
+ else
500
+ pump();
501
+ return subscription;
502
+ }
package/docs/advanced.md CHANGED
@@ -77,6 +77,7 @@ Operational filesystem failures such as permissions or I/O errors are rethrown.
77
77
  | `overwriteFileHandle`, `OverwriteFileHandleOptions` | [in-place-write.md](in-place-write.md) | Overwrite a borrowed read/write handle with prefix-only preparation and best-effort rollback; preserves its inode, cursor, and caller-owned lifetime. |
78
78
  | `openRootFile`, `openRootFileSync`, `canUseRootFileOpen`, `matchRootFileOpenFailure`, related types | – | Low-level root-bounded open; rejects every symlink component by default, with `symlinks: "follow-parents-within-root"` for contained parent aliases or `"follow-within-root"` for final links too. |
79
79
  | `appendRegularFile`, `appendRegularFileSync`, `readRegularFile`, `readRegularFileSync`, `statRegularFile`, `statRegularFileSync`, `resolveRegularFileAppendFlags`, `AppendRegularFileOptions`, `RegularFileStatResult` | [regular-file.md](regular-file.md) | Type-checked regular-file I/O. |
80
+ | `retainFileInDirectory`, `RetainedFile`, related receipt/result types | [Retained Windows files](retained-file.md) | Existing-file native handle custody and explicit removal; local NTFS, producer authority required, no persistence guarantee. |
80
81
  | `sameFileIdentity`, `FileIdentityStat` | – | Compare two stats for same-inode equality. |
81
82
  | `readDirectoryIdentity`, `assertDirectoryIdentitySync`, `DirectoryIdentity` | [directory-identity.md](directory-identity.md) | Observe exact bigint directory identity and synchronously check a caller-selected path, optionally retaining its canonical path. |
82
83
  | `pathExists`, `pathExistsSync` | – | Boolean existence check that does not throw on `ENOENT`. |
package/docs/atomic.md CHANGED
@@ -60,6 +60,8 @@ type ReplaceFileAtomicOptions = {
60
60
  syncTempFile?: boolean; // fsync(temp) before rename, or the final file after copy fallback; default false
61
61
  syncParentDir?: boolean; // fsync(parent) after rename, POSIX only; default false
62
62
  throwOnCleanupError?: boolean; // report temp cleanup failure; default false
63
+ assertBeforeMutation?: () => void;
64
+ onDestinationState?: (state: ReplaceFileAtomicDestinationState) => void;
63
65
  beforeRename?: (params: { filePath: string; tempPath: string }) => Promise<void>;
64
66
  fileSystem?: ReplaceFileAtomicFileSystem; // injectable fs for tests
65
67
  };
@@ -108,6 +110,65 @@ descriptor is still closed, and a close failure remains reportable.
108
110
 
109
111
  Identity checks and pathname rename/unlink remain separate syscalls, not atomic conditional mutations. Use an approved writable parent plus cooperative locking or OS isolation when arbitrary concurrent namespace mutation is in scope.
110
112
 
113
+ ### Atomic write authority and destination state
114
+
115
+ Use `assertBeforeMutation` when a write depends on a lease or other revocable
116
+ application authority. Both atomic variants capture the callback at entry and
117
+ run it synchronously before directory creation or mode changes, compatibility
118
+ lock acquisition, staging creation and writes, each rename attempt, and fallback
119
+ removal, destination acquisition, truncation, and each content write. Asynchronous destination identity checks
120
+ finish before the final authority check; no await separates that check from the
121
+ write dispatch. A dispatched operation still owns its completion.
122
+
123
+ `onDestinationState` captures facts that a caller may need if the operation later
124
+ rejects:
125
+
126
+ ```ts
127
+ type ReplaceFileAtomicDestinationState =
128
+ | Readonly<{ state: "removed"; path: string }>
129
+ | Readonly<{
130
+ state: "writing" | "published";
131
+ path: string;
132
+ dev: bigint;
133
+ ino: bigint;
134
+ }>;
135
+ ```
136
+
137
+ - `removed` follows successful removal of an existing fallback destination.
138
+ - `writing` records the fallback's retained descriptor after exclusive creation
139
+ or the first successful truncation of an existing `restore-original` target.
140
+ Opening an existing target leaves it untouched and emits no receipt. This state
141
+ does not claim that all requested bytes were written.
142
+ - `published` follows successful rename and destination identity verification,
143
+ before parent synchronization or descriptor close can fail. Copy fallback
144
+ reports it after its content, mode, and requested file synchronization complete.
145
+ The explicit `verify-content-with-lock` policy first applies its existing
146
+ locked content verification to admit the replacement descriptor.
147
+
148
+ Receipts are frozen and use the destination spelling captured by the operation.
149
+ File identities come from retained descriptors, never from reopening a pathname
150
+ after a failed copy. Failed or indeterminate admission can leave no identity
151
+ receipt; a rename followed by replacement before verification also has no
152
+ publication receipt. Do not infer ownership from a fresh post-failure stat. A later writer
153
+ can replace the pathname, so compare the recorded identity with the current
154
+ entry and recheck application authority before compensation. Receipts do not
155
+ promise durable storage or authorize rollback.
156
+
157
+ Both callbacks must complete synchronously; Promise and thenable results are
158
+ rejected, and ordinary return values are ignored. The first callback refusal
159
+ is terminal, including falsy thrown values; an `EPERM`, `EEXIST`, or `EBUSY` code
160
+ from a callback never starts fallback or retry. Refusal during an in-place
161
+ fallback also stops new restoration writes. Final mode, synchronization, close,
162
+ and private-stage cleanup may still settle against owned identities after
163
+ revocation. Callback refusal does not delete a published destination or a
164
+ competing file at a staging name. An observer that throws must retain its receipt
165
+ first if recovery needs it.
166
+
167
+ `beforeRename` remains the hook for preparing backups. Effects performed inside
168
+ that hook remain the caller's responsibility; the authority option guards the
169
+ atomic writer's own effects. Omitting both new callbacks preserves existing
170
+ write, fallback, restoration, result, and cleanup behavior.
171
+
111
172
  ### FUSE, Windows exFAT/FAT32, and unstable rename identity
112
173
 
113
174
  Strict source-to-destination identity is the default. Some FUSE mounts assign a different inode to the destination during rename even without concurrency. Set `renameIdentity: "verify-content-with-lock"` to accept that boundary only when the re-opened no-follow destination has the exact requested SHA-256 content under an exclusive hashed sidecar lock in the destination parent. The newly accepted descriptor and identity remain pinned through parent sync and final verification. The synchronous helper provides the same policy with the synchronous lock implementation.
@@ -167,6 +167,11 @@ pnpm check
167
167
  This runs the filesystem boundary checks, build, tests, and package
168
168
  tarball/import validation.
169
169
 
170
+ The native watch lane requires events on Linux, macOS, and Windows and reports
171
+ actual edit latency. Run `FS_SAFE_TEST_SERIAL=1 pnpm check` to isolate local timing
172
+ checks from the other filesystem stress suites. Watch fixtures use normal OS
173
+ temporary storage; session scratch trees may suppress macOS filesystem events.
174
+
170
175
  ### Method benchmarks
171
176
 
172
177
  `pnpm benchmark:methods` measures the callable library surface against synthetic
@@ -436,3 +436,10 @@ owning product boundary.
436
436
  - [Migrating to 0.5](migrating-to-0.5.md) — choosing a publication policy during upgrade.
437
437
  - [Native architecture](native.md) — clone/copy/hash mechanisms and fallback guarantees.
438
438
  - [Errors](errors.md) — typed operational failure handling.
439
+
440
+ ## Existing Windows file retirement
441
+
442
+ [`retainFileInDirectory`](retained-file.md) describes identity-bound native
443
+ disposition and resource settlement separately from persistence. Its result is
444
+ always `persistence: "not-proven"`; neither accepted disposition nor observed
445
+ namespace absence is a directory/volume barrier or an application commit.
package/docs/index.md CHANGED
@@ -65,6 +65,7 @@ await fs.remove("notes/archive/today.txt");
65
65
  | [Private file-store mode](private-file-store.md) | `fileStore({ private: true })` for private JSON/text state at 0600 under 0700 dirs. |
66
66
  | [`tempWorkspace`](temp.md) | 0700 scratch dir with auto-cleanup. |
67
67
  | [`readSecureFile`](secure-file.md) | Absolute file reads with fd pinning, permissions, owner, size, and timeout checks. |
68
+ | [`watch`](watch.md) | Guarded filesystem observation with advisory native hints and polling. |
68
69
  | [`walkDirectory` / `Root.walk`](walk.md) | Standalone inventories plus root-bounded pruning, budgets, and partial-error reporting. |
69
70
  | [`Root.entries`](entries.md) | Guarded nonrecursive entries, bounded name collection, and caller-owned symlink validation. |
70
71
  | [`extractArchive`](archive.md) | Policy-driven ZIP/TAR extraction with clamp/filter, metadata/path-depth, link, count, and byte limits. |
@@ -182,3 +182,12 @@ consumer performs its 0.5 upgrade.
182
182
  - [Durability](durability.md)
183
183
  - [Migrating to 0.5](migrating-to-0.5.md)
184
184
  - [Migrating to 0.6](migrating-to-0.6.md)
185
+
186
+ ### Retained existing Windows file
187
+
188
+ The maintained Windows helper supports the public
189
+ [`retainFileInDirectory`](retained-file.md) lifecycle on fixed local NTFS.
190
+ Private native handles, exact identity/generation checks, writable-section
191
+ admission and explicit close results stay behind that public API. Older helpers
192
+ without this capability are unsupported; there is no pathname deletion fallback.
193
+ No Windows namespace persistence barrier is provided.