@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,55 @@
1
+ import type syncFs from "node:fs";
2
+ import type fs from "node:fs/promises";
3
+ import type { RenameIdentityPolicy } from "./pinned-write-types.js";
4
+ import type { AtomicMutationOptions } from "./replace-file-mutation.js";
5
+ import type { ReplaceFileCopyFallbackRestorePolicy, ReplaceFileDestinationHardlinkPolicy } from "./replace-file-copy-fallback.js";
6
+ export type ReplaceFileAtomicFileSystem = {
7
+ promises: Pick<typeof fs, "mkdir" | "writeFile" | "rename" | "copyFile" | "unlink" | "rm" | "open" | "stat" | "lstat"> & {
8
+ /** @deprecated Accepted for adapter compatibility but never called. */
9
+ chmod?: typeof fs.chmod;
10
+ };
11
+ };
12
+ export type ReplaceFileAtomicSyncFileSystem = Pick<typeof syncFs, "mkdirSync" | "readFileSync" | "writeFileSync" | "renameSync" | "copyFileSync" | "unlinkSync" | "rmSync" | "openSync" | "fsyncSync" | "closeSync" | "fstatSync" | "statSync" | "lstatSync" | "ftruncateSync" | "readSync" | "writeSync"> & {
13
+ /** @deprecated Accepted for adapter compatibility but never called. */
14
+ chmodSync?: typeof syncFs.chmodSync;
15
+ fchmodSync?: typeof syncFs.fchmodSync;
16
+ };
17
+ export type ReplaceFileAtomicBaseOptions = AtomicMutationOptions & {
18
+ filePath: string;
19
+ content: string | Uint8Array;
20
+ dirMode?: number;
21
+ mode?: number;
22
+ /** Inherit only rwx bits from an existing non-symlink regular file. */
23
+ preserveExistingMode?: boolean;
24
+ tempPrefix?: string;
25
+ renameMaxRetries?: number;
26
+ renameRetryBaseDelayMs?: number;
27
+ copyFallbackOnPermissionError?: boolean;
28
+ copyFallbackRestore?: ReplaceFileCopyFallbackRestorePolicy;
29
+ maxRestoreBytes?: number;
30
+ destinationHardlinks?: ReplaceFileDestinationHardlinkPolicy;
31
+ /** Strict by default; locked content verification is an explicit FUSE compatibility policy. */
32
+ renameIdentity?: RenameIdentityPolicy;
33
+ syncTempFile?: boolean;
34
+ syncParentDir?: boolean;
35
+ throwOnCleanupError?: boolean;
36
+ };
37
+ export type ReplaceFileAtomicOptions = ReplaceFileAtomicBaseOptions & {
38
+ fileSystem?: ReplaceFileAtomicFileSystem;
39
+ /** Runs while the exact staged file is retained; replacing or hardlinking it is rejected. */
40
+ beforeRename?: (params: {
41
+ filePath: string;
42
+ tempPath: string;
43
+ }) => Promise<void>;
44
+ };
45
+ export type ReplaceFileAtomicSyncOptions = ReplaceFileAtomicBaseOptions & {
46
+ fileSystem?: ReplaceFileAtomicSyncFileSystem;
47
+ /** Runs while the exact staged file is retained; replacing or hardlinking it is rejected. */
48
+ beforeRename?: (params: {
49
+ filePath: string;
50
+ tempPath: string;
51
+ }) => void;
52
+ };
53
+ export type ReplaceFileAtomicResult = {
54
+ method: "rename" | "copy-fallback";
55
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -1,58 +1,6 @@
1
- import syncFs from "node:fs";
2
- import fs from "node:fs/promises";
3
- import type { RenameIdentityPolicy } from "./pinned-write-types.js";
4
- import { type ReplaceFileCopyFallbackRestorePolicy, type ReplaceFileDestinationHardlinkPolicy } from "./replace-file-copy-fallback.js";
5
- export type ReplaceFileAtomicFileSystem = {
6
- promises: Pick<typeof fs, "mkdir" | "writeFile" | "rename" | "copyFile" | "unlink" | "rm" | "open" | "stat" | "lstat"> & {
7
- /** @deprecated Accepted for adapter compatibility but never called. */
8
- chmod?: typeof fs.chmod;
9
- };
10
- };
11
- export type ReplaceFileAtomicSyncFileSystem = Pick<typeof syncFs, "mkdirSync" | "readFileSync" | "writeFileSync" | "renameSync" | "copyFileSync" | "unlinkSync" | "rmSync" | "openSync" | "fsyncSync" | "closeSync" | "fstatSync" | "statSync" | "lstatSync" | "ftruncateSync" | "readSync" | "writeSync"> & {
12
- /** @deprecated Accepted for adapter compatibility but never called. */
13
- chmodSync?: typeof syncFs.chmodSync;
14
- fchmodSync?: typeof syncFs.fchmodSync;
15
- };
16
- type ReplaceFileAtomicBaseOptions = {
17
- filePath: string;
18
- content: string | Uint8Array;
19
- dirMode?: number;
20
- mode?: number;
21
- /** Inherit only rwx bits from an existing non-symlink regular file. */
22
- preserveExistingMode?: boolean;
23
- tempPrefix?: string;
24
- renameMaxRetries?: number;
25
- renameRetryBaseDelayMs?: number;
26
- copyFallbackOnPermissionError?: boolean;
27
- copyFallbackRestore?: ReplaceFileCopyFallbackRestorePolicy;
28
- maxRestoreBytes?: number;
29
- destinationHardlinks?: ReplaceFileDestinationHardlinkPolicy;
30
- /** Strict by default; locked content verification is an explicit FUSE compatibility policy. */
31
- renameIdentity?: RenameIdentityPolicy;
32
- syncTempFile?: boolean;
33
- syncParentDir?: boolean;
34
- throwOnCleanupError?: boolean;
35
- };
36
- export type ReplaceFileAtomicOptions = ReplaceFileAtomicBaseOptions & {
37
- fileSystem?: ReplaceFileAtomicFileSystem;
38
- /** Runs while the exact staged file is retained; replacing or hardlinking it is rejected. */
39
- beforeRename?: (params: {
40
- filePath: string;
41
- tempPath: string;
42
- }) => Promise<void>;
43
- };
44
- export type ReplaceFileAtomicSyncOptions = ReplaceFileAtomicBaseOptions & {
45
- fileSystem?: ReplaceFileAtomicSyncFileSystem;
46
- /** Runs while the exact staged file is retained; replacing or hardlinking it is rejected. */
47
- beforeRename?: (params: {
48
- filePath: string;
49
- tempPath: string;
50
- }) => void;
51
- };
52
- export type ReplaceFileAtomicResult = {
53
- method: "rename" | "copy-fallback";
54
- };
1
+ import type { ReplaceFileAtomicOptions, ReplaceFileAtomicSyncOptions, ReplaceFileAtomicResult } from "./replace-file-types.js";
2
+ export type { ReplaceFileAtomicDestinationState } from "./replace-file-mutation.js";
3
+ export type { ReplaceFileAtomicFileSystem, ReplaceFileAtomicSyncFileSystem, ReplaceFileAtomicOptions, ReplaceFileAtomicSyncOptions, ReplaceFileAtomicResult, } from "./replace-file-types.js";
55
4
  export declare function replaceFileAtomic(options: ReplaceFileAtomicOptions): Promise<ReplaceFileAtomicResult>;
56
5
  export declare function replaceFileAtomicWithDirectorySync(options: ReplaceFileAtomicOptions, syncParent?: (directoryPath: string) => Promise<unknown>): Promise<ReplaceFileAtomicResult>;
57
6
  export declare function replaceFileAtomicSync(options: ReplaceFileAtomicSyncOptions): ReplaceFileAtomicResult;
58
- export {};
@@ -13,15 +13,19 @@ import { admitStandalonePublicationPath } from "./windows-path-alias.js";
13
13
  import { sleep, sleepSync } from "./timing.js";
14
14
  import { serializePathWrite } from "./write-queue.js";
15
15
  import { hasErrorCode, readErrorCode } from "./file-cleanup.js";
16
+ import { AtomicMutation } from "./replace-file-mutation.js";
17
+ import { assertSynchronousCallbackResult } from "./mutation-authority.js";
16
18
  async function renameWithRetry(params) {
17
19
  for (let attempt = 0; attempt <= params.maxRetries; attempt++) {
18
20
  if (attempt > 0)
19
21
  await params.assertSourceCurrent();
20
22
  try {
23
+ params.mutation.assert();
21
24
  await params.fsModule.rename(params.src, params.dest);
22
25
  return { method: "rename" };
23
26
  }
24
27
  catch (error) {
28
+ params.mutation.rethrowRefusal();
25
29
  const code = readErrorCode(error);
26
30
  if (code === "EBUSY" && attempt < params.maxRetries) {
27
31
  await sleep(params.baseDelayMs * 2 ** attempt);
@@ -37,6 +41,7 @@ async function renameWithRetry(params) {
37
41
  maxRestoreBytes: params.maxRestoreBytes,
38
42
  expectedSourceIdentity: params.sourceIdentity,
39
43
  sync: params.syncFallback,
44
+ mutation: params.mutation,
40
45
  });
41
46
  return { method: "copy-fallback" };
42
47
  }
@@ -50,10 +55,12 @@ function renameWithRetrySync(params) {
50
55
  if (attempt > 0)
51
56
  params.assertSourceCurrent();
52
57
  try {
58
+ params.mutation.assert();
53
59
  params.fsModule.renameSync(params.src, params.dest);
54
60
  return { method: "rename" };
55
61
  }
56
62
  catch (error) {
63
+ params.mutation.rethrowRefusal();
57
64
  const code = readErrorCode(error);
58
65
  if (code === "EBUSY" && attempt < params.maxRetries) {
59
66
  sleepSync(params.baseDelayMs * 2 ** attempt);
@@ -70,6 +77,7 @@ function renameWithRetrySync(params) {
70
77
  expectedSourceIdentity: params.sourceIdentity,
71
78
  fchmodSync: params.fchmodSync,
72
79
  sync: params.syncFallback,
80
+ mutation: params.mutation,
73
81
  });
74
82
  return { method: "copy-fallback" };
75
83
  }
@@ -142,24 +150,27 @@ export async function replaceFileAtomic(options) {
142
150
  }
143
151
  // Internal owner hook: keep directory durability inside publication verification and serialization.
144
152
  export async function replaceFileAtomicWithDirectorySync(options, syncParent) {
153
+ const mutation = new AtomicMutation(options);
145
154
  const filePath = validateReplaceFilePath(options.filePath);
146
155
  validateRestoreOptions(options);
147
156
  const renameIdentity = options.renameIdentity;
148
157
  validateRenameIdentity(renameIdentity);
149
158
  return await serializePathWrite(path.resolve(filePath), async () => {
150
159
  if (renameIdentity !== "verify-content-with-lock") {
151
- return await replaceFileAtomicUnserialized(options, filePath, renameIdentity, syncParent);
160
+ return await replaceFileAtomicUnserialized(options, filePath, renameIdentity, mutation, syncParent);
152
161
  }
153
162
  const fsModule = options.fileSystem?.promises ?? fs;
154
163
  const dir = path.dirname(filePath);
164
+ mutation.assert();
155
165
  await fsModule.mkdir(fsModule === fs ? recursiveMkdirPath(dir) : dir, {
156
166
  recursive: true,
157
167
  mode: options.dirMode ?? 0o700,
158
168
  });
159
- return await withAtomicRenameIdentityLock(filePath, async () => await replaceFileAtomicUnserialized(options, filePath, renameIdentity, syncParent));
169
+ mutation.assert();
170
+ return await withAtomicRenameIdentityLock(filePath, async () => await replaceFileAtomicUnserialized(options, filePath, renameIdentity, mutation, syncParent));
160
171
  });
161
172
  }
162
- async function replaceFileAtomicUnserialized(options, filePath, renameIdentity, syncParent) {
173
+ async function replaceFileAtomicUnserialized(options, filePath, renameIdentity, mutation, syncParent) {
163
174
  const fsModule = options.fileSystem?.promises ?? fs;
164
175
  const dir = path.dirname(filePath);
165
176
  const dirMode = options.dirMode ?? 0o700;
@@ -169,8 +180,9 @@ async function replaceFileAtomicUnserialized(options, filePath, renameIdentity,
169
180
  const tempOwner = new AsyncAtomicTempOwner(tempPath);
170
181
  let originalFailure;
171
182
  try {
183
+ mutation.assert();
172
184
  await fsModule.mkdir(fsModule === fs ? recursiveMkdirPath(dir) : dir, { recursive: true, mode: dirMode });
173
- await applyDirectoryMode({ fsModule, dirPath: dir, mode: dirMode });
185
+ await applyDirectoryMode({ fsModule, dirPath: dir, mode: dirMode, mutation });
174
186
  tempOwner.start();
175
187
  tempOwner.adopt(await writeTempFile({
176
188
  fsModule,
@@ -179,6 +191,7 @@ async function replaceFileAtomicUnserialized(options, filePath, renameIdentity,
179
191
  mode,
180
192
  sync: options.syncTempFile === true,
181
193
  onIdentity: tempOwner.onIdentity,
194
+ mutation,
182
195
  }));
183
196
  await tempOwner.assertCurrent(fsModule);
184
197
  if (options.beforeRename) {
@@ -202,10 +215,11 @@ async function replaceFileAtomicUnserialized(options, filePath, renameIdentity,
202
215
  sourceIdentity: tempOwner.identity,
203
216
  assertSourceCurrent: () => tempOwner.assertCurrent(fsModule),
204
217
  syncFallback: options.syncTempFile === true,
218
+ mutation,
205
219
  });
206
220
  if (result.method === "rename") {
207
221
  tempOwner.markRenamed();
208
- await tempOwner.assertPublished(fsModule, filePath, expectedHash);
222
+ await tempOwner.assertPublished(fsModule, filePath, expectedHash, identity => mutation.destination("published", filePath, identity));
209
223
  }
210
224
  else {
211
225
  await tempOwner.assertCurrent(fsModule);
@@ -237,22 +251,25 @@ async function replaceFileAtomicUnserialized(options, filePath, renameIdentity,
237
251
  }
238
252
  }
239
253
  export function replaceFileAtomicSync(options) {
254
+ const mutation = new AtomicMutation(options);
240
255
  const filePath = validateReplaceFilePath(options.filePath);
241
256
  validateRestoreOptions(options);
242
257
  const renameIdentity = options.renameIdentity;
243
258
  validateRenameIdentity(renameIdentity);
244
259
  if (renameIdentity !== "verify-content-with-lock") {
245
- return replaceFileAtomicSyncUnserialized(options, filePath, renameIdentity);
260
+ return replaceFileAtomicSyncUnserialized(options, filePath, renameIdentity, mutation);
246
261
  }
247
262
  const fsModule = options.fileSystem ?? syncFs;
248
263
  const dir = path.dirname(filePath);
264
+ mutation.assert();
249
265
  fsModule.mkdirSync(fsModule === syncFs ? recursiveMkdirPath(dir) : dir, {
250
266
  recursive: true,
251
267
  mode: options.dirMode ?? 0o700,
252
268
  });
253
- return withAtomicRenameIdentityLockSync(filePath, () => replaceFileAtomicSyncUnserialized(options, filePath, renameIdentity));
269
+ mutation.assert();
270
+ return withAtomicRenameIdentityLockSync(filePath, () => replaceFileAtomicSyncUnserialized(options, filePath, renameIdentity, mutation));
254
271
  }
255
- function replaceFileAtomicSyncUnserialized(options, filePath, renameIdentity) {
272
+ function replaceFileAtomicSyncUnserialized(options, filePath, renameIdentity, mutation) {
256
273
  const fsModule = options.fileSystem ?? syncFs;
257
274
  const dir = path.dirname(filePath);
258
275
  const dirMode = options.dirMode ?? 0o700;
@@ -269,8 +286,9 @@ function replaceFileAtomicSyncUnserialized(options, filePath, renameIdentity) {
269
286
  const tempOwner = new SyncAtomicTempOwner(tempPath);
270
287
  let originalFailure;
271
288
  try {
289
+ mutation.assert();
272
290
  fsModule.mkdirSync(fsModule === syncFs ? recursiveMkdirPath(dir) : dir, { recursive: true, mode: dirMode });
273
- applyDirectoryModeSync({ fsModule, dirPath: dir, mode: dirMode, fchmodSync });
291
+ applyDirectoryModeSync({ fsModule, dirPath: dir, mode: dirMode, fchmodSync, mutation });
274
292
  tempOwner.start();
275
293
  tempOwner.adopt(writeTempFileSync({
276
294
  fsModule,
@@ -280,10 +298,11 @@ function replaceFileAtomicSyncUnserialized(options, filePath, renameIdentity) {
280
298
  fchmodSync,
281
299
  sync: options.syncTempFile === true,
282
300
  onIdentity: tempOwner.onIdentity,
301
+ mutation,
283
302
  }));
284
303
  tempOwner.assertCurrent(fsModule);
285
304
  if (options.beforeRename) {
286
- options.beforeRename({ filePath, tempPath });
305
+ assertSynchronousCallbackResult(options.beforeRename({ filePath, tempPath }), "beforeRename");
287
306
  tempOwner.assertCurrent(fsModule);
288
307
  }
289
308
  if (options.destinationHardlinks === "reject") {
@@ -304,10 +323,11 @@ function replaceFileAtomicSyncUnserialized(options, filePath, renameIdentity) {
304
323
  assertSourceCurrent: () => tempOwner.assertCurrent(fsModule),
305
324
  fchmodSync,
306
325
  syncFallback: options.syncTempFile === true,
326
+ mutation,
307
327
  });
308
328
  if (result.method === "rename") {
309
329
  tempOwner.markRenamed();
310
- tempOwner.assertPublished(fsModule, filePath, expectedHash);
330
+ tempOwner.assertPublished(fsModule, filePath, expectedHash, identity => mutation.destination("published", filePath, identity));
311
331
  }
312
332
  else {
313
333
  tempOwner.assertCurrent(fsModule);
@@ -0,0 +1,50 @@
1
+ import type { StagedFileReceipt } from "./staged-file-types.js";
2
+ /** Exact producer observations: never supply rounded Number identities. */
3
+ export type RetainedFileExpected = Readonly<Pick<StagedFileReceipt["identity"], "dev" | "ino" | "size" | "mtimeNs" | "ctimeNs"> & {
4
+ /** Lowercase SHA-256 of the producer's expected bytes. */
5
+ sha256: string;
6
+ }>;
7
+ export type RetainedFileIssue = Readonly<{
8
+ phase: string;
9
+ code: string;
10
+ message: string;
11
+ cause?: unknown;
12
+ }>;
13
+ /** Descriptive facts, not a deletion capability or a durable transaction receipt. */
14
+ export type RetainedFileResult = Readonly<{
15
+ status: "unsupported" | "not-attempted" | "preserved-mismatch" | "disposition-accepted" | "name-absent-after-settlement" | "failed" | "indeterminate";
16
+ phase: string;
17
+ /** Full native 64-bit volume serial and 128-bit file ID, when observed. */
18
+ identity?: string;
19
+ disposition: "not-attempted" | "accepted" | "rejected" | "indeterminate";
20
+ namespace: "not-observed" | "absent" | "original" | "foreign" | "unknown";
21
+ resources: "closed" | "close-failed";
22
+ persistence: "not-proven";
23
+ errors: readonly RetainedFileIssue[];
24
+ }>;
25
+ export type RetainedFileReceipt = Readonly<{
26
+ directory: string;
27
+ parent: Readonly<{
28
+ dev: bigint;
29
+ ino: bigint;
30
+ }>;
31
+ basename: string;
32
+ expected: RetainedFileExpected;
33
+ identity: string;
34
+ }>;
35
+ export interface RetainedFile extends Disposable {
36
+ readonly receipt: RetainedFileReceipt;
37
+ /** One-shot, synchronous authority admission; always settles owned resources. */
38
+ remove(): RetainedFileResult;
39
+ /** Closes only. Never requests deletion, including after an exception. */
40
+ dispose(): RetainedFileResult;
41
+ }
42
+ export type RetainedFileAdmission = Readonly<{
43
+ status: "retained";
44
+ file: RetainedFile;
45
+ }> | RetainedFileResult;
46
+ export type RetainFileInDirectoryOptions = Readonly<Pick<RetainedFileReceipt, "directory" | "parent" | "basename" | "expected"> & {
47
+ assertBeforeMutation: () => void;
48
+ /** Synchronous verification budget, default 16 MiB; maximum 64 MiB. */
49
+ maxBytes?: number;
50
+ }>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import type { RetainedFileAdmission, RetainFileInDirectoryOptions } from "./retained-file-types.js";
2
+ /** Retain one existing regular file on supported local Windows NTFS. No fallback. */
3
+ export declare function retainFileInDirectory(options: RetainFileInDirectoryOptions): RetainedFileAdmission;
@@ -0,0 +1,121 @@
1
+ import { FsSafeError } from "./errors.js";
2
+ import { getNativeBinding } from "./native.js";
3
+ import { assertSynchronousCallbackResult } from "./mutation-authority.js";
4
+ function freeze(result) {
5
+ return Object.freeze({ ...result, errors: Object.freeze(result.errors.map((error) => Object.freeze({ ...error }))) });
6
+ }
7
+ function unsupported(cause) {
8
+ return freeze({ status: "unsupported", phase: "admission", disposition: "not-attempted",
9
+ namespace: "not-observed", resources: "closed", persistence: "not-proven",
10
+ errors: [{ phase: "admission", code: "helper-unavailable",
11
+ message: "existing-file retention requires the maintained Windows native capability", cause }] });
12
+ }
13
+ function exact(value, name, zero = false) {
14
+ if (typeof value !== "bigint" || value < (zero ? 0n : 1n) || value > 0xffffffffffffffffn) {
15
+ throw new TypeError(`${name} must be an exact ${zero ? "nonnegative" : "positive"} unsigned 64-bit bigint`);
16
+ }
17
+ return value;
18
+ }
19
+ class Owner {
20
+ receipt;
21
+ #native;
22
+ #assert;
23
+ #result;
24
+ #running = false;
25
+ #reentered = false;
26
+ constructor(native, receipt, assertion) {
27
+ this.#native = native;
28
+ this.receipt = receipt;
29
+ this.#assert = assertion;
30
+ Object.freeze(this);
31
+ }
32
+ #settle(remove) {
33
+ if (this.#result)
34
+ return this.#result;
35
+ if (this.#running) {
36
+ this.#reentered = true;
37
+ throw new TypeError("retained-file settlement cannot be reentered");
38
+ }
39
+ this.#running = true;
40
+ let rejection;
41
+ if (remove) {
42
+ try {
43
+ assertSynchronousCallbackResult(this.#assert(), "assertBeforeMutation");
44
+ if (this.#reentered)
45
+ throw new TypeError("retained-file authority reentered settlement");
46
+ }
47
+ catch (cause) {
48
+ rejection = { cause };
49
+ }
50
+ }
51
+ try {
52
+ const result = this.#native.settle(remove && !rejection);
53
+ this.#result = freeze(rejection ? { ...result, phase: "authority", errors: [
54
+ { phase: "authority", code: "denied-path", message: "current mutation authority rejected removal", cause: rejection.cause },
55
+ ...result.errors,
56
+ ] } : result);
57
+ }
58
+ catch (cause) {
59
+ // An unexpected binding exception cannot establish native effect or close.
60
+ // Do not retry or release a dependency on an unknown native outcome.
61
+ this.#result = freeze({ status: "indeterminate", phase: "binding", identity: this.receipt.identity,
62
+ disposition: remove && !rejection ? "indeterminate" : "not-attempted", namespace: "unknown",
63
+ resources: "close-failed", persistence: "not-proven", errors: [
64
+ ...(rejection ? [{ phase: "authority", code: "denied-path", message: "current mutation authority rejected removal", cause: rejection.cause }] : []),
65
+ { phase: "binding", code: "helper-failed", message: "native settlement did not return a result", cause },
66
+ ] });
67
+ }
68
+ return this.#result;
69
+ }
70
+ remove() { return this.#settle(true); }
71
+ dispose() { return this.#settle(false); }
72
+ [Symbol.dispose]() {
73
+ const result = this.dispose();
74
+ if (result.resources !== "closed") {
75
+ throw new FsSafeError("helper-failed", "retained-file resource settlement is uncertain", { details: { result } });
76
+ }
77
+ }
78
+ }
79
+ /** Retain one existing regular file on supported local Windows NTFS. No fallback. */
80
+ export function retainFileInDirectory(options) {
81
+ // Snapshot once before any native resource admission or caller authority runs.
82
+ const directory = options.directory;
83
+ const basename = options.basename;
84
+ const sourceParent = options.parent;
85
+ const parent = Object.freeze({ dev: exact(sourceParent.dev, "parent.dev"), ino: exact(sourceParent.ino, "parent.ino") });
86
+ const source = options.expected;
87
+ const expected = Object.freeze({ dev: exact(source.dev, "expected.dev"), ino: exact(source.ino, "expected.ino"),
88
+ size: exact(source.size, "expected.size", true), mtimeNs: exact(source.mtimeNs, "expected.mtimeNs", true),
89
+ ctimeNs: exact(source.ctimeNs, "expected.ctimeNs", true), sha256: source.sha256 });
90
+ const assertion = options.assertBeforeMutation;
91
+ const maxBytes = options.maxBytes ?? 16 * 1024 * 1024;
92
+ if (typeof directory !== "string" || typeof basename !== "string" || typeof assertion !== "function"
93
+ || typeof expected.sha256 !== "string" || !/^[0-9a-f]{64}$/u.test(expected.sha256)
94
+ || !Number.isSafeInteger(maxBytes) || maxBytes <= 0 || maxBytes > 64 * 1024 * 1024 || expected.size > BigInt(maxBytes)) {
95
+ throw new TypeError("invalid retained-file directory, authority or bounded expected-byte contract");
96
+ }
97
+ if (process.platform !== "win32")
98
+ return unsupported();
99
+ let binding;
100
+ try {
101
+ binding = getNativeBinding();
102
+ }
103
+ catch (cause) {
104
+ return unsupported(cause);
105
+ }
106
+ if (!binding?.retainWindowsFile)
107
+ return unsupported();
108
+ let native;
109
+ try {
110
+ native = binding.retainWindowsFile(directory, basename, parent.dev, parent.ino, expected.dev, expected.ino, expected.size, expected.mtimeNs, expected.ctimeNs, expected.sha256, maxBytes);
111
+ }
112
+ catch (cause) {
113
+ // Factory errors have no returned owner; never infer absence or deletion.
114
+ return freeze({ ...unsupported(cause), status: "indeterminate", resources: "close-failed" });
115
+ }
116
+ const admission = native.admission;
117
+ if (admission.status !== "retained")
118
+ return freeze(admission);
119
+ const receipt = Object.freeze({ directory, parent, basename, expected, identity: admission.identity });
120
+ return Object.freeze({ status: "retained", file: new Owner(native, receipt, assertion) });
121
+ }
@@ -0,0 +1,9 @@
1
+ import { type RootContext } from "./root-context.js";
2
+ import { type RootDirectoryObservationGuard } from "./root-directory-list.js";
3
+ import { type ExactStatIdentity } from "./stat-observation.js";
4
+ import type { DirEntry } from "./types.js";
5
+ /** One literal entry lookup under an operation-local admitted parent; never follow the leaf. */
6
+ export declare function lookupRootDirectoryEntry(root: RootContext, guard: RootDirectoryObservationGuard, name: string): Promise<{
7
+ entry: DirEntry;
8
+ identity: ExactStatIdentity;
9
+ } | undefined>;
@@ -0,0 +1,28 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { FsSafeError } from "./errors.js";
4
+ import { isNotFoundPathError } from "./path.js";
5
+ import { assertValidRootRelativePath } from "./root-context.js";
6
+ import { assertRootDirectoryObservationGuard, pathStatFromStats } from "./root-directory-list.js";
7
+ import { inspectStatObservationSync } from "./stat-observation.js";
8
+ /** One literal entry lookup under an operation-local admitted parent; never follow the leaf. */
9
+ export async function lookupRootDirectoryEntry(root, guard, name) {
10
+ if (!name || name === "." || name === ".." || path.basename(name) !== name || path.isAbsolute(name)) {
11
+ throw new FsSafeError("invalid-path", "directory lookup requires one literal name");
12
+ }
13
+ assertValidRootRelativePath(name);
14
+ await assertRootDirectoryObservationGuard(root, guard);
15
+ try {
16
+ const pathname = path.join(guard.realPath, name);
17
+ const observed = inspectStatObservationSync(bigint => bigint
18
+ ? fs.lstatSync(pathname, { bigint: true }) : fs.lstatSync(pathname));
19
+ await assertRootDirectoryObservationGuard(root, guard);
20
+ return { entry: { name, ...pathStatFromStats(observed.stat) }, identity: observed.identity };
21
+ }
22
+ catch (error) {
23
+ await assertRootDirectoryObservationGuard(root, guard);
24
+ if (isNotFoundPathError(error))
25
+ return undefined;
26
+ throw error;
27
+ }
28
+ }
@@ -2,6 +2,7 @@ import type { BigIntStats, Stats } from "node:fs";
2
2
  import { type AsyncDirectoryGuard } from "./directory-guard.js";
3
3
  import { type RootContext } from "./root-context.js";
4
4
  import type { RootPathDirectoryObservationGuard, RootPathObservationReceipt, RootPathParentObservationReceipt } from "./root-path.js";
5
+ import { type ExactStatIdentity } from "./stat-observation.js";
5
6
  import type { DirEntry, PathStat } from "./types.js";
6
7
  export declare function pathStatFromStats(stat: Stats | BigIntStats): PathStat;
7
8
  export type RootDirectoryObservationGuard = AsyncDirectoryGuard<BigIntStats>;
@@ -21,6 +22,7 @@ export type RootDirectoryListing = {
21
22
  next(): Promise<{
22
23
  kind: "entry";
23
24
  entry: DirEntry;
25
+ identity?: ExactStatIdentity;
24
26
  } | {
25
27
  kind: "limit";
26
28
  name: string;
@@ -33,6 +35,12 @@ export type RootDirectoryListingOptions = {
33
35
  snapshot: boolean;
34
36
  maxNames?: number;
35
37
  metadataBatchSize?: number;
38
+ /** Internal exact metadata lane, supported by streaming filesystem order. */
39
+ exactIdentity?: boolean;
40
+ /** Advisory scans may omit vanished leaves after revalidating their parent. */
41
+ skipVanished?: boolean;
42
+ /** Internal owner receives cleanup failures, including acquisition rollback. */
43
+ onCleanupFailure?: (error: unknown) => void;
36
44
  admitEntry(): boolean;
37
45
  };
38
- export declare function openRootDirectoryListing(root: RootContext, directory: string, options: RootDirectoryListingOptions): Promise<RootDirectoryListing>;
46
+ export declare function openRootDirectoryListing(root: RootContext, directory: string, options: RootDirectoryListingOptions, receipt?: RootPathObservationReceipt): Promise<RootDirectoryListing>;