@openclaw/fs-safe 0.8.5 → 0.9.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 (102) hide show
  1. package/CHANGELOG.md +21 -1
  2. package/dist/archive-durability.d.ts +24 -0
  3. package/dist/archive-durability.d.ts.map +1 -0
  4. package/dist/archive-durability.js +180 -0
  5. package/dist/archive-gzip-tail.d.ts.map +1 -1
  6. package/dist/archive-gzip-tail.js +2 -1
  7. package/dist/archive-input.js +4 -4
  8. package/dist/archive-kind.d.ts.map +1 -1
  9. package/dist/archive-kind.js +3 -2
  10. package/dist/archive-merge.d.ts +1 -0
  11. package/dist/archive-merge.d.ts.map +1 -1
  12. package/dist/archive-merge.js +41 -8
  13. package/dist/archive-native.d.ts +1 -0
  14. package/dist/archive-native.d.ts.map +1 -1
  15. package/dist/archive-native.js +1 -0
  16. package/dist/archive-options.d.ts +2 -0
  17. package/dist/archive-options.d.ts.map +1 -1
  18. package/dist/archive-parser.wasm +0 -0
  19. package/dist/archive-read.d.ts.map +1 -1
  20. package/dist/archive-read.js +5 -4
  21. package/dist/archive-staging.d.ts +1 -1
  22. package/dist/archive-staging.d.ts.map +1 -1
  23. package/dist/archive-staging.js +24 -19
  24. package/dist/archive.d.ts.map +1 -1
  25. package/dist/archive.js +9 -4
  26. package/dist/copy-publication.d.ts +6 -0
  27. package/dist/copy-publication.d.ts.map +1 -0
  28. package/dist/copy-publication.js +3 -0
  29. package/dist/directory-mode-node.d.ts.map +1 -1
  30. package/dist/directory-mode-node.js +4 -4
  31. package/dist/file-hash.d.ts.map +1 -1
  32. package/dist/file-hash.js +5 -4
  33. package/dist/file-store-sync-write.d.ts +1 -0
  34. package/dist/file-store-sync-write.d.ts.map +1 -1
  35. package/dist/file-store-sync-write.js +9 -6
  36. package/dist/file-store.d.ts +4 -0
  37. package/dist/file-store.d.ts.map +1 -1
  38. package/dist/file-store.js +10 -1
  39. package/dist/fs.d.ts.map +1 -1
  40. package/dist/fs.js +1 -2
  41. package/dist/guarded-mutation.js +1 -1
  42. package/dist/install-path.js +7 -7
  43. package/dist/json-document-store.d.ts +3 -0
  44. package/dist/json-document-store.d.ts.map +1 -1
  45. package/dist/json-document-store.js +1 -0
  46. package/dist/json-durable-queue-directory.js +3 -3
  47. package/dist/json-durable-queue-ownership.js +3 -2
  48. package/dist/json-durable-queue.d.ts.map +1 -1
  49. package/dist/json-durable-queue.js +18 -13
  50. package/dist/move-path-cleanup.d.ts.map +1 -1
  51. package/dist/move-path-cleanup.js +4 -3
  52. package/dist/move-path.d.ts.map +1 -1
  53. package/dist/move-path.js +31 -18
  54. package/dist/opened-file-failure.d.ts +1 -1
  55. package/dist/opened-file-failure.d.ts.map +1 -1
  56. package/dist/opened-file-failure.js +3 -2
  57. package/dist/permissions.js +3 -3
  58. package/dist/private-temp-workspace.d.ts.map +1 -1
  59. package/dist/private-temp-workspace.js +9 -3
  60. package/dist/publish-file.js +21 -21
  61. package/dist/replace-file-copy-fallback.d.ts.map +1 -1
  62. package/dist/replace-file-copy-fallback.js +30 -17
  63. package/dist/replace-file-copy-source.d.ts.map +1 -1
  64. package/dist/replace-file-copy-source.js +10 -5
  65. package/dist/replace-file.d.ts.map +1 -1
  66. package/dist/replace-file.js +8 -3
  67. package/dist/root-impl.d.ts +1 -1
  68. package/dist/root-impl.d.ts.map +1 -1
  69. package/dist/root-impl.js +16 -2
  70. package/dist/secret-file.d.ts +1 -0
  71. package/dist/secret-file.d.ts.map +1 -1
  72. package/dist/secret-file.js +1 -0
  73. package/dist/secret-read-async.js +5 -5
  74. package/dist/secure-file.d.ts +1 -1
  75. package/dist/secure-file.d.ts.map +1 -1
  76. package/dist/secure-file.js +19 -9
  77. package/dist/sibling-staged-file.js +7 -7
  78. package/dist/sibling-temp.d.ts.map +1 -1
  79. package/dist/sibling-temp.js +16 -7
  80. package/dist/sidecar-lock-acquire.d.ts +1 -1
  81. package/dist/sidecar-lock-acquire.d.ts.map +1 -1
  82. package/dist/sidecar-lock-acquire.js +4 -3
  83. package/dist/sidecar-lock-policy.js +2 -2
  84. package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
  85. package/dist/sidecar-lock-reclaim.js +16 -4
  86. package/dist/sidecar-lock.d.ts.map +1 -1
  87. package/dist/sidecar-lock.js +2 -1
  88. package/dist/temp-target.d.ts.map +1 -1
  89. package/dist/temp-target.js +9 -4
  90. package/dist/walk.js +2 -2
  91. package/dist/write-open-flags.d.ts.map +1 -1
  92. package/dist/write-open-flags.js +1 -2
  93. package/docs/archive.md +35 -2
  94. package/docs/atomic.md +26 -1
  95. package/docs/file-store.md +48 -3
  96. package/docs/json-store.md +10 -0
  97. package/docs/reading.md +6 -0
  98. package/docs/root.md +2 -2
  99. package/docs/secret-file.md +6 -0
  100. package/docs/sidecar-lock.md +2 -0
  101. package/docs/writing.md +5 -3
  102. package/package.json +11 -11
@@ -90,7 +90,13 @@ export async function readSidecarLockRawSnapshot(lockPath, options = {}) {
90
90
  await opened.handle.close().catch(() => undefined);
91
91
  }
92
92
  }
93
- const before = await fs.lstat(lockPath).catch(missingSnapshotPath);
93
+ let before;
94
+ try {
95
+ before = fsSync.lstatSync(lockPath);
96
+ }
97
+ catch (error) {
98
+ before = missingSnapshotPath(error);
99
+ }
94
100
  if (!before)
95
101
  return null;
96
102
  if (!before.isFile() || before.isSymbolicLink()) {
@@ -119,7 +125,7 @@ export async function readSidecarLockRawSnapshot(lockPath, options = {}) {
119
125
  options.onOpenFailure?.(error);
120
126
  throw error;
121
127
  }
122
- const opened = await handle.stat();
128
+ const opened = fsSync.fstatSync(handle.fd);
123
129
  if (!opened.isFile()) {
124
130
  if (options.rejectNonFile) {
125
131
  throw new FsSafeError("not-file", `sidecar lock is not a regular file: ${lockPath}`);
@@ -129,7 +135,13 @@ export async function readSidecarLockRawSnapshot(lockPath, options = {}) {
129
135
  if (!options.allowDescriptorIdentityDrift && !sameFileIdentity(before, opened))
130
136
  return null;
131
137
  const raw = (await readFileHandleBounded(handle, MAX_LOCK_PAYLOAD_BYTES)).toString("utf8");
132
- const after = await fs.lstat(lockPath).catch(missingSnapshotPath);
138
+ let after;
139
+ try {
140
+ after = fsSync.lstatSync(lockPath);
141
+ }
142
+ catch (error) {
143
+ after = missingSnapshotPath(error);
144
+ }
133
145
  if (!after || !after.isFile() || !sameFileIdentity(before, after))
134
146
  return null;
135
147
  return { raw, stat: after };
@@ -241,7 +253,7 @@ export async function sidecarLockSnapshotStillPresent(lockPath, observed, option
241
253
  }
242
254
  export async function sidecarReclaimGuardExists(pathname) {
243
255
  try {
244
- await fs.lstat(pathname);
256
+ fsSync.lstatSync(pathname);
245
257
  return true;
246
258
  }
247
259
  catch (err) {
@@ -1 +1 @@
1
- {"version":3,"file":"sidecar-lock.d.ts","sourceRoot":"","sources":["../src/sidecar-lock.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EACV,yBAAyB,EACzB,iBAAiB,EACjB,oBAAoB,EACpB,sBAAsB,EACvB,MAAM,yBAAyB,CAAC;AACjC,YAAY,EAAE,wBAAwB,EAAE,MAAM,2BAA2B,CAAC;AAC1E,YAAY,EACV,yBAAyB,EACzB,0BAA0B,EAC1B,iBAAiB,EACjB,oBAAoB,EACpB,uBAAuB,EACvB,wBAAwB,EACxB,sBAAsB,GACvB,MAAM,yBAAyB,CAAC;AAqIjC,0FAA0F;AAC1F,wBAAgB,uBAAuB,IAAI,OAAO,CAMjD;AAuFD,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM;cAU3B,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,WACpD,yBAAyB,CAAC,QAAQ,CAAC,KAC3C,OAAO,CAAC,iBAAiB,CAAC;eAkBL,CAAC,EAAE,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,WACxD,yBAAyB,CAAC,QAAQ,CAAC,MACxC,MAAM,OAAO,CAAC,CAAC,CAAC,KACnB,OAAO,CAAC,CAAC,CAAC;iBAqBW,OAAO,CAAC,IAAI,CAAC;iBAQnB,IAAI;uBAIE,oBAAoB,EAAE;EAW/C;AAED,wBAAsB,eAAe,CAAC,CAAC,EAAE,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/E,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,sBAAsB,CAAC,QAAQ,CAAC,EACzC,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GACnB,OAAO,CAAC,CAAC,CAAC,CAMZ"}
1
+ {"version":3,"file":"sidecar-lock.d.ts","sourceRoot":"","sources":["../src/sidecar-lock.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EACV,yBAAyB,EACzB,iBAAiB,EACjB,oBAAoB,EACpB,sBAAsB,EACvB,MAAM,yBAAyB,CAAC;AACjC,YAAY,EAAE,wBAAwB,EAAE,MAAM,2BAA2B,CAAC;AAC1E,YAAY,EACV,yBAAyB,EACzB,0BAA0B,EAC1B,iBAAiB,EACjB,oBAAoB,EACpB,uBAAuB,EACvB,wBAAwB,EACxB,sBAAsB,GACvB,MAAM,yBAAyB,CAAC;AAsIjC,0FAA0F;AAC1F,wBAAgB,uBAAuB,IAAI,OAAO,CAMjD;AAuFD,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM;cAU3B,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,WACpD,yBAAyB,CAAC,QAAQ,CAAC,KAC3C,OAAO,CAAC,iBAAiB,CAAC;eAkBL,CAAC,EAAE,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,WACxD,yBAAyB,CAAC,QAAQ,CAAC,MACxC,MAAM,OAAO,CAAC,CAAC,CAAC,KACnB,OAAO,CAAC,CAAC,CAAC;iBAqBW,OAAO,CAAC,IAAI,CAAC;iBAQnB,IAAI;uBAIE,oBAAoB,EAAE;EAW/C;AAED,wBAAsB,eAAe,CAAC,CAAC,EAAE,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/E,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,sBAAsB,CAAC,QAAQ,CAAC,EACzC,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GACnB,OAAO,CAAC,CAAC,CAAC,CAMZ"}
@@ -85,7 +85,8 @@ function snapshotMatchesSync(lockPath, observed) {
85
85
  }
86
86
  }
87
87
  function releaseAllReclaimGuardsSync(state) {
88
- for (const reclaimGuardPath of state.reclaimGuards) {
88
+ // Exit cleanup also visits managers created by older copies and never reopened here.
89
+ for (const reclaimGuardPath of state.reclaimGuards ?? []) {
89
90
  try {
90
91
  fsSync.rmdirSync(reclaimGuardPath);
91
92
  state.reclaimGuards.delete(reclaimGuardPath);
@@ -1 +1 @@
1
- {"version":3,"file":"temp-target.d.ts","sourceRoot":"","sources":["../src/temp-target.ts"],"names":[],"mappings":"AAQA,MAAM,MAAM,QAAQ,GAAG;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAChC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7B,CAAC,MAAM,CAAC,YAAY,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxC,CAAC;AA8DF,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAI7D;AAED,wBAAgB,uBAAuB,CAAC,MAAM,EAAE;IAC9C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,GAAG,MAAM,CAaT;AAsCD,wBAAsB,QAAQ,CAAC,MAAM,EAAE;IACrC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CAC3C,GAAG,OAAO,CAAC,QAAQ,CAAC,CAwBpB;AAED,wBAAsB,YAAY,CAAC,CAAC,EAClC,MAAM,EAAE;IACN,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CAC3C,EACD,EAAE,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,GAClC,OAAO,CAAC,CAAC,CAAC,CAOZ"}
1
+ {"version":3,"file":"temp-target.d.ts","sourceRoot":"","sources":["../src/temp-target.ts"],"names":[],"mappings":"AASA,MAAM,MAAM,QAAQ,GAAG;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAChC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7B,CAAC,MAAM,CAAC,YAAY,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxC,CAAC;AA8DF,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAI7D;AAED,wBAAgB,uBAAuB,CAAC,MAAM,EAAE;IAC9C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,GAAG,MAAM,CAaT;AAyCD,wBAAsB,QAAQ,CAAC,MAAM,EAAE;IACrC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CAC3C,GAAG,OAAO,CAAC,QAAQ,CAAC,CAwBpB;AAED,wBAAsB,YAAY,CAAC,CAAC,EAClC,MAAM,EAAE;IACN,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CAC3C,EACD,EAAE,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,GAClC,OAAO,CAAC,CAAC,CAAC,CAOZ"}
@@ -1,4 +1,5 @@
1
1
  import crypto from "node:crypto";
2
+ import fsSync from "node:fs";
2
3
  import fs from "node:fs/promises";
3
4
  import path from "node:path";
4
5
  import { sameFileIdentityForCleanup } from "./file-identity.js";
@@ -81,12 +82,16 @@ function isNodeErrorWithCode(err, code) {
81
82
  }
82
83
  async function cleanupTempDir(dir, identity, onCleanupError) {
83
84
  try {
84
- const current = await fs.lstat(dir, { bigint: true }).catch((error) => {
85
+ let current;
86
+ try {
87
+ current = fsSync.lstatSync(dir, { bigint: true });
88
+ }
89
+ catch (error) {
85
90
  if (isNodeErrorWithCode(error, "ENOENT")) {
86
- return undefined;
91
+ return;
87
92
  }
88
93
  throw error;
89
- });
94
+ }
90
95
  if (!current || !sameFileIdentityForCleanup(current, identity)) {
91
96
  return;
92
97
  }
@@ -107,7 +112,7 @@ export async function tempFile(params) {
107
112
  const dir = await fs.mkdtemp(path.join(rootDir, prefix));
108
113
  // Windows file indexes can exceed Number.MAX_SAFE_INTEGER. Cleanup receipts
109
114
  // must retain the exact identity or adjacent directories can compare equal.
110
- const identity = await fs.lstat(dir, { bigint: true });
115
+ const identity = fsSync.lstatSync(dir, { bigint: true });
111
116
  const unregisterTempDir = registerTempPathForExit(dir, { recursive: true, identity });
112
117
  const file = (fileName) => path.join(dir, sanitizeTempFileName(fileName ?? params.fileName ?? "download.bin"));
113
118
  const cleanup = async () => {
package/dist/walk.js CHANGED
@@ -76,7 +76,7 @@ async function resolveAsyncKind(fullPath, dirent, symlinks) {
76
76
  if (symlinks === "include")
77
77
  return "symlink";
78
78
  try {
79
- const stat = await fs.stat(fullPath);
79
+ const stat = fsSync.statSync(fullPath);
80
80
  if (stat.isDirectory())
81
81
  return "directory";
82
82
  if (stat.isFile())
@@ -162,7 +162,7 @@ export async function walkDirectory(rootDir, options = {}) {
162
162
  return;
163
163
  let realDir;
164
164
  try {
165
- realDir = await fs.realpath(dir);
165
+ realDir = fsSync.realpathSync.native(dir);
166
166
  }
167
167
  catch (error) {
168
168
  recordFailedDir(result, root, dir, depth, error);
@@ -1 +1 @@
1
- {"version":3,"file":"write-open-flags.d.ts","sourceRoot":"","sources":["../src/write-open-flags.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,SAAS,CAAC;AAI7B,wBAAgB,2BAA2B,CACzC,SAAS,GAAE,OAAO,CAAC,IAAI,CAAC,OAAO,MAAM,CAAC,SAAS,EAAE,YAAY,CAAC,CAAoB,GACjF,MAAM,CAIR;AAUD,wBAAsB,0BAA0B,CAC9C,KAAK,EAAE,OAAO,EACd,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,OAAO,CAAC,CAOlB;AAED,wBAAgB,8BAA8B,CAC5C,KAAK,EAAE,OAAO,EACd,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,GACZ,OAAO,CAOT"}
1
+ {"version":3,"file":"write-open-flags.d.ts","sourceRoot":"","sources":["../src/write-open-flags.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,SAAS,CAAC;AAG7B,wBAAgB,2BAA2B,CACzC,SAAS,GAAE,OAAO,CAAC,IAAI,CAAC,OAAO,MAAM,CAAC,SAAS,EAAE,YAAY,CAAC,CAAoB,GACjF,MAAM,CAIR;AAUD,wBAAsB,0BAA0B,CAC9C,KAAK,EAAE,OAAO,EACd,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,OAAO,CAAC,CAOlB;AAED,wBAAgB,8BAA8B,CAC5C,KAAK,EAAE,OAAO,EACd,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,GACZ,OAAO,CAOT"}
@@ -1,5 +1,4 @@
1
1
  import fsSync from "node:fs";
2
- import fs from "node:fs/promises";
3
2
  import { hasNodeErrorCode } from "./path.js";
4
3
  export function resolveNonblockingWriteFlag(constants = fsSync.constants) {
5
4
  return process.platform !== "win32" && typeof constants.O_NONBLOCK === "number"
@@ -17,7 +16,7 @@ export async function isNonRegularWriteOpenError(error, filePath, flags) {
17
16
  if (!isNonblockingWriteEnxio(error, flags))
18
17
  return false;
19
18
  try {
20
- return !(await fs.lstat(filePath)).isFile();
19
+ return !fsSync.lstatSync(filePath).isFile();
21
20
  }
22
21
  catch {
23
22
  return false;
package/docs/archive.md CHANGED
@@ -43,6 +43,7 @@ type ExtractArchiveOptions = {
43
43
  archivePath: string; // absolute path to the archive
44
44
  destDir: string; // absolute destination directory; must already exist
45
45
  timeoutMs: number; // positive wall-clock budget; <= 0/non-finite disables it
46
+ durable?: boolean; // false; opt into syncing published files and directories before completion
46
47
  kind?: ArchiveKind; // "zip" | "tar" | "tar-zstd" | "tar-bzip2"
47
48
  stripComponents?: number; // strip N leading dirs from entry paths
48
49
  tarGzip?: boolean; // when archive is .tar.gz/.tgz
@@ -55,6 +56,36 @@ type ExtractArchiveOptions = {
55
56
  };
56
57
  ```
57
58
 
59
+ `durable` defaults to `false`. Private staging never fsyncs, and publication copies
60
+ defer opted-in durability until the complete merge succeeds. With `durable: true`, the final pass syncs each
61
+ published file once (at most eight concurrently), then each published directory
62
+ once, deepest first, and finally the destination directory. All work stays inside
63
+ the extraction deadline; active syncs are joined before rejection. File sync
64
+ failures use the same error surface as `Root.copyIn()`; directory I/O failures
65
+ also reject, with the existing platform limitations on directory flushing.
66
+ Files whose final mode prevents reading, including `0o000` and write-only files,
67
+ sync once through the copy's retained descriptor during publication. Permissions
68
+ are never widened to reopen them. Directory modes are finalized after the file
69
+ pass, with a descriptor pinned before chmod for syncing restrictive directories.
70
+ Existing inaccessible directories are never widened; an existing search-only
71
+ directory must become readable in its final mode if no readable sync descriptor
72
+ can be acquired before chmod.
73
+
74
+ The default suits extractions into temporary or reconstructible locations.
75
+ It skips all file and directory syncs while preserving atomic file publication,
76
+ mode enforcement, identity checks, and containment checks. Successful `durable: true`
77
+ extraction syncs file contents and directory entries before returning; failures
78
+ can leave a partially published tree as described below.
79
+
80
+ For a crash-safe install workflow, extract with the default into a scratch
81
+ directory, then apply the caller's durability policy: sync the staged files and
82
+ directories before publishing with [`replaceDirectoryAtomic`](atomic.md#replacedirectoryatomic),
83
+ and sync the affected parent directories afterward. The directory swap alone
84
+ does not sync the staged tree. Alternatively, pass `durable: true` when extracted
85
+ files must be on stable storage before the extraction call returns, subject to
86
+ the platform's flushing guarantees. A plain fsync on macOS does not flush the
87
+ drive cache.
88
+
58
89
  `entryModes` defaults to `"clamp"`: directories become `0o755`; files become
59
90
  `0o644`, or `0o755` when the archived owner-execute bit is set. `"preserve"`
60
91
  keeps archived read/write/execute bits. Both policies strip setuid, setgid, and
@@ -201,11 +232,13 @@ before publication preserves a pre-existing file, and rejection does not grant
201
232
  authority to delete a substituted file or alias. Failed extraction does not
202
233
  restore overwritten contents. Active destination mutations and their guarded
203
234
  cleanup still finish before rejection; no later destination mutation begins.
204
- New directories whose postorder finalization was never reached can retain their
235
+ New directories whose finalization was never reached can retain their
205
236
  private working mode after failure. Failure cleanup closes retained descriptors;
206
- it does not run a final chmod sweep or roll back the archive. The public merge
237
+ it does not run a cleanup chmod sweep or roll back the archive. The public merge
207
238
  helper still derives modes from its external source tree and must be able to
208
239
  read that source; it never chmods an unreadable external source to admit it.
240
+ That helper retains per-copy durability and immediate postorder directory-mode
241
+ finalization; the deferred pass described above belongs to `extractArchive()`.
209
242
 
210
243
  ### Limits
211
244
 
package/docs/atomic.md CHANGED
@@ -75,12 +75,37 @@ If `beforeRename` throws, the rename is skipped and the owned temp file is remov
75
75
 
76
76
  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.
77
77
 
78
- ### FUSE mounts and unstable rename identity
78
+ ### FUSE, Windows exFAT/FAT32, and unstable rename identity
79
79
 
80
80
  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.
81
81
 
82
82
  This is the same explicit weaker contract available on `Root` writes: cooperating writers are serialized, stale locks fail closed, and mismatched content is rejected after publication without rollback. A same-authority actor that ignores the advisory lock can still substitute another file with identical bytes, so do not use this compatibility policy in directories writable by untrusted same-UID processes.
83
83
 
84
+ Windows exFAT/FAT32 volumes can also change file identity during rename, depending
85
+ on the source and destination names. The destination may already contain the
86
+ requested bytes when strict verification reports `path-mismatch`. A short
87
+ temporary name alone does not guarantee stable identity for a longer destination.
88
+ For an application-controlled directory on such a volume, callers can explicitly
89
+ select `renameIdentity: "verify-content-with-lock"` on `replaceFileAtomic` or
90
+ `replaceFileAtomicSync`. Keep strict mode for directories that require the
91
+ stronger identity contract; do not automatically retry every `path-mismatch`
92
+ with the weaker policy. Staging names remain random, including custom prefixes.
93
+
94
+ To verify the built package against an actual volume, run
95
+ `node scripts/atomic-rename-compat-proof.mjs EXISTING_PARENT` after `pnpm build`.
96
+ Use a trusted parent directory that no other process can rename or modify during
97
+ the entire run, including cleanup; do not point the probe at a shared writable
98
+ volume root. Cleanup checks the created directory's identity before recursive
99
+ removal, but the check and removal are not atomic. The probe does not test safety
100
+ against hostile concurrent namespace mutation.
101
+ The probe creates and cleans up its own child directory and emits JSON without
102
+ local paths. It compares default, strict, and locked policies in both async and
103
+ sync calls, checks real rename identities and file contents, injects different-
104
+ and identical-content substitutions, checks lock cleanup, and exercises custom
105
+ prefix isolation and validation. Callback staging remains strict and can still
106
+ report identity drift; the probe records that result separately. Filesystem type
107
+ must be recorded independently; the probe does not infer it from a drive letter.
108
+
84
109
  ### `EPERM` and copy fallback
85
110
 
86
111
  On systems where `rename` fails with `EPERM`/`EEXIST`, pass
@@ -28,9 +28,18 @@ const cache = fileStore({
28
28
  dirMode: 0o700, // mode for parent directories created on demand (default 0o700)
29
29
  maxBytes: 64 * 1024 * 1024, // optional: refuse writes/reads larger than this
30
30
  private: true, // use secret-file atomic writes for private state
31
+ durable: true, // sync file and parent directory (default true)
31
32
  });
32
33
  ```
33
34
 
35
+ | `FileStoreOptions` option | Default | Purpose |
36
+ |---|---|---|
37
+ | `rootDir` | Required | Store directory. |
38
+ | `private` | `false` | Use the secret-file atomic path. |
39
+ | `mode` / `dirMode` | `0o600` / `0o700` | File and parent-directory modes. |
40
+ | `maxBytes` | Unset | Store read/write byte limit. |
41
+ | `durable` | `true` | Sync the written file and its parent directory; see method support below. |
42
+
34
43
  Store and per-call `maxBytes` values must be non-negative safe integers or positive `Infinity`. Zero is an active zero-byte cap; `Infinity` disables the cap. An omitted or explicitly `undefined` per-call value preserves the store-level limit. The same rule applies to buffer writes, streams, copies, async reads, and synchronous reads/writes.
35
44
 
36
45
  Use `private: true` for credentials, auth profiles, tokens, and other private
@@ -101,7 +110,22 @@ therefore does not imply that no filesystem access or serialization occurred.
101
110
 
102
111
  ## Writes
103
112
 
104
- Every write goes through `writeSiblingTempFile` — temp + rename, mode applied to file and parent dir, both `fsync`'d.
113
+ Writes use guarded sibling-temp publication: apply file and directory modes,
114
+ then rename into place. By default, the file and parent directory are synced
115
+ where supported by the platform and writer.
116
+
117
+ `durable: false` keeps the sibling-temp replace/rename behavior but skips the
118
+ temp-file and parent-directory `fsync` calls. Use it only for reconstructible
119
+ metadata where lower latency matters more than crash-durability. Per-call
120
+ `durable` overrides the store option, which defaults to `true`; an omitted or
121
+ `undefined` override preserves the store default. Modes, path confinement,
122
+ and publication identity checks are unchanged.
123
+
124
+ | Method | Durability support |
125
+ |---|---|
126
+ | `write`, `writeText`, `writeJson` (async and sync) | Per-call option overrides store default. |
127
+ | `writeStream`, `copyIn` (either private mode) | Per-call option overrides store default. |
128
+ | JSON `write`, `update`, `updateOr` | JSON handle option overrides file-store default. |
105
129
 
106
130
  ### `write(rel, data, options?)`
107
131
 
@@ -118,7 +142,7 @@ Convenience wrappers over `write`. `writeJson` pretty-prints with a trailing new
118
142
  ### `json<T>(rel, options?)`
119
143
 
120
144
  Returns a typed single-file JSON state helper for a file under this store. It
121
- inherits the store's root, mode, max-size, and private-write policy, then adds
145
+ inherits the store's root, mode, max-size, durability, and private-write policy, then adds
122
146
  `readOr`, `readRequired`, `update`, `updateOr`, and optional sidecar locking:
123
147
 
124
148
  ```ts
@@ -129,6 +153,9 @@ await state.updateOr(defaultState, (current) => ({ ...current, enabled: true }))
129
153
  Use this when one JSON file owns one piece of state. `jsonStore({ filePath })`
130
154
  is the absolute-path convenience wrapper for the same primitive.
131
155
 
156
+ Pass `{ durable: false }` or `{ durable: true }` to `json()` to override the
157
+ parent store's durability for all mutations of that JSON handle.
158
+
132
159
  ### `writeStream(rel, stream, options?)`
133
160
 
134
161
  ```ts
@@ -138,6 +165,9 @@ const path = await cache.writeStream("downloads/blob.bin", Readable.from(remoteF
138
165
 
139
166
  Streams into a sibling temp with a running byte budget. Aborts the source stream with `too-large` if `maxBytes` is exceeded mid-stream — partial writes are cleaned up.
140
167
 
168
+ Streams honor `durable` in both private modes. Non-private streams stage their
169
+ input and forward the resolved durability option to Root `copyIn` for publication.
170
+
141
171
  ### `copyIn(rel, sourcePath, options?)`
142
172
 
143
173
  ```ts
@@ -146,12 +176,16 @@ const path = await cache.copyIn("ingest/upload.bin", "/tmp/upload.bin");
146
176
 
147
177
  One-shot ingest from an absolute source path. Source is checked for symlink/non-regular before copy. Same mode rules as `write`.
148
178
 
179
+ `copyIn` honors per-call and store-level `durable` values in both private modes,
180
+ with the same precedence as `write`.
181
+
149
182
  ### `FileStoreWriteOptions`
150
183
 
151
184
  Per-call overrides for the store-level defaults:
152
185
 
153
186
  ```ts
154
187
  type FileStoreWriteOptions = {
188
+ durable?: boolean; // store default, otherwise true
155
189
  dirMode?: number;
156
190
  mode?: number;
157
191
  maxBytes?: number;
@@ -159,6 +193,17 @@ type FileStoreWriteOptions = {
159
193
  };
160
194
  ```
161
195
 
196
+ | `FileStoreWriteOptions` option | Default |
197
+ |---|---|
198
+ | `durable` | Store option, otherwise `true`. |
199
+ | `dirMode` / `mode` | Store directory/file modes. |
200
+ | `maxBytes` | Store byte limit. |
201
+ | `tempPrefix` | Writer-specific temporary prefix. |
202
+
203
+ The same durability precedence applies to `fileStoreSync().write`,
204
+ `writeText`, and `writeJson`. The synchronous store has no `writeStream` or
205
+ `copyIn` methods.
206
+
162
207
  ## Reads
163
208
 
164
209
  `open`, `read`, `readBytes`, `readText`, and `readJson` delegate to a fresh `Root` with `hardlinks: "reject"` and the store's `maxBytes`. Same return shapes as `Root`.
@@ -254,5 +299,5 @@ await root.move(`pending/${id}`, `done/${id}`);
254
299
 
255
300
  - [`root()`](root.md) — the boundary `FileStore` is built on; reach for it when you need move/list/append.
256
301
  - [JSON store](json-store.md) — the JSON-state-file equivalent of this surface.
257
- - [Atomic writes](atomic.md) — `writeSiblingTempFile` is what every write goes through.
302
+ - [Atomic writes](atomic.md) — lower-level sibling-temp publication helpers.
258
303
  - [Temp workspaces](temp.md) — private scratch directories backed by `FileStore`.
@@ -45,6 +45,7 @@ type JsonStoreOptions<T> = {
45
45
  filePath: string;
46
46
  dirMode?: number; // default 0o700
47
47
  mode?: number; // default 0o600
48
+ durable?: boolean; // default true
48
49
  trailingNewline?: boolean; // default true
49
50
  lock?: boolean | JsonStoreLockOptions; // false / undefined = no lock
50
51
  };
@@ -71,6 +72,15 @@ type JsonStore<T> = {
71
72
  `jsonStore({ filePath })` resolves `rootDir = dirname(filePath)` and calls
72
73
  `fileStore({ rootDir, private: true }).json(basename(filePath), options)`.
73
74
 
75
+ `durable: false` keeps sibling-temp replace/rename behavior but skips the
76
+ temp-file and parent-directory `fsync` calls. Use it only for reconstructible
77
+ metadata where lower latency matters more than crash-durability. The default
78
+ is `true`, subject to platform sync support. This store-level policy applies
79
+ to `write`, `update`, and `updateOr`; these methods have no per-call options.
80
+ For `fileStore(...).json(rel, options)`, `options.durable` overrides the parent
81
+ file store's durability, while omission or `undefined` inherits it. Modes,
82
+ identity checks, mutation serialization, and sidecar locking are unchanged.
83
+
74
84
  The store does **not** validate the parsed value against `T` at runtime — the cast is unchecked. Wrap with a schema (zod/valibot) if the file might be hand-edited or written by another process you don't control.
75
85
 
76
86
  ## `read()`
package/docs/reading.md CHANGED
@@ -14,6 +14,12 @@ const opened = await fs.open("large.log"); // FileHandle for strea
14
14
 
15
15
  Identity and containment checks use brief synchronous metadata calls, like Node's module resolution, while file data is read asynchronously. Canonical paths retain Node's native realpath spelling, including expansion of Windows short paths.
16
16
 
17
+ The same observation rule applies to archive extraction, copy, publication, move,
18
+ directory modes, and supporting lock, queue, and secret-file operations. Opens,
19
+ data transfers, durability syncs, filesystem mutations, and closes retain their
20
+ existing asynchronous behavior. Custom filesystem adapters retain their async
21
+ metadata interface.
22
+
17
23
  Regardless of shape, every read goes through the same boundary checks:
18
24
 
19
25
  1. Resolve the input lexically against the canonical real root.
package/docs/root.md CHANGED
@@ -18,7 +18,7 @@ const fs = await root("/srv/workspace", {
18
18
  function root(rootDir: string, defaults?: RootDefaults): Promise<Root>;
19
19
 
20
20
  type RootDefaults = {
21
- durable?: boolean; // fsync write/create/writeJson/createJson/append; default true
21
+ durable?: boolean; // fsync write/create/writeJson/createJson/append/copyIn; default true
22
22
  hardlinks?: "reject" | "allow"; // refuse files with nlink > 1 on read; defaults to "reject"
23
23
  denyMutations?: DenyMutationPolicy; // absolute paths/prefixes mutation methods may not change
24
24
  maxBytes?: number; // refuse reads larger than this many bytes; defaults to 16 MiB
@@ -111,7 +111,7 @@ fs.ensureRoot(options?) // accepts "" / "." as the root itself
111
111
 
112
112
  `write`, `create`, `append`, `writeJson`, and `createJson` accept `mode?: number`; use `0o600` for credentials and other private state. `writeJson` also accepts the same options as `JSON.stringify` plus `trailingNewline?: boolean` (defaults `true` so the file ends in `\n`).
113
113
 
114
- These five methods also accept `durable?: boolean`: the per-call value overrides
114
+ These five methods and `copyIn` also accept `durable?: boolean`: the per-call value overrides
115
115
  `Root.defaults.durable`, which defaults to `true` when omitted. An explicitly
116
116
  `undefined` per-call value preserves the root default. `durable: false` keeps
117
117
  the existing publication behavior, modes, and identity checks but skips file
@@ -172,9 +172,15 @@ type WriteSecretFileParams = {
172
172
  content: string | Uint8Array;
173
173
  mode?: number; // file mode for the new file (default PRIVATE_SECRET_FILE_MODE = 0o600)
174
174
  dirMode?: number; // mode for the root and intermediate dirs (default PRIVATE_SECRET_DIR_MODE = 0o700)
175
+ durable?: boolean; // default true; false skips file and parent fsync
175
176
  };
176
177
  ```
177
178
 
179
+ `durable: false` preserves atomic publication, modes, and identity checks while
180
+ skipping file and parent-directory `fsync` calls. Use it only for reconstructible
181
+ data where lower latency matters more than crash-durability; private and JSON
182
+ stores forward their durability policy here.
183
+
178
184
  The full POSIX directory mode is asserted on each component along the path: `rootDir`, then any intermediate dirs, then the parent. Existing directories, including another creator's `EEXIST` winner, must already match `dirMode` exactly or the write fails with `insecure-permissions`; they are never chmod-repaired. An explicitly requested directory mode such as `0o2750` preserves its setgid bit. Audit and adjust existing secret directories yourself. The admitted directory guards are retained through traversal and the final writer/lock handoff; a fresh pathname lookup cannot silently authorize a replacement. The caller must still trust the selected root and its owners; matching permission bits alone do not establish that trust.
179
185
 
180
186
  Directory admission and its retained guards use lossless bigint identities, including through private locks and native writes. On Windows, an unknown zero device or inode gets one reinspection that retains known components; a definite mismatch or persistent ambiguity fails with `path-mismatch` rather than authorizing a replacement.
@@ -25,6 +25,8 @@ On natural event-loop shutdown, a globally deduplicated `process.on("beforeExit"
25
25
 
26
26
  Always release locks in a `finally` block. Application-managed graceful shutdown can await `release()` or `manager.drain()` before terminating. Explicit `process.exit()`, uncaught failures, crashes, default signal handling, and fatal termination (including `SIGKILL`) do not reliably run asynchronous Root cleanup and may leave sidecars. Recover only after an application-owned liveness policy proves the holder cannot still be writing.
27
27
 
28
+ Exit cleanup tolerates shared managers created by older package copies that lack reclaim-guard state, and continues through later manager domains. Handler ownership remains first-registration-wins: loading an updated copy does not replace an older copy's registered handler. Restart with the updated copy registering first to obtain the fix. After building, `node scripts/legacy-lock-exit-proof.mjs` checks clean exit, removal of legacy and modern raw locks, and preservation of a retained lock using synthetic temporary files.
29
+
28
30
  Each new sidecar also carries an internal random ownership token encoded as JSON trailing whitespace. `JSON.parse()` and every payload callback still see exactly the caller-provided object. Only the process that successfully created the sidecar keeps that token as release authority; merely reading token-shaped bytes from disk does not enable this mode. Release compares the in-memory token and exact serialized bytes, and requires the pathname to remain a regular file, instead of requiring an opened descriptor and pathname lookup to report the same inode identity. This preserves ownership checks on filesystems such as Docker Desktop VirtioFS where those two views can legitimately differ. Sidecars created by older releases have no token and retain the legacy identity-plus-content check.
29
31
 
30
32
  The raw sidecar bytes are not a canonical JSON representation: tools that trim or rewrite the trailing whitespace invalidate the ownership token, so release leaves the changed sidecar in place and fails closed. The token distinguishes cooperating acquisitions; it is not a secret and does not make pathname compare-and-remove atomic against a hostile process that can replace files outside the lock protocol.
package/docs/writing.md CHANGED
@@ -91,14 +91,14 @@ await fs.write("notes/today.txt", "hello\n", { encoding: "utf8" });
91
91
  | `overwrite` | `boolean` | `true`; `false` is create-only. |
92
92
  | `renameIdentity` | `RenameIdentityPolicy` | `"strict"`. |
93
93
 
94
- `write`, `create`, `writeJson`, `createJson`, and `append` accept `durable`.
94
+ `write`, `create`, `writeJson`, `createJson`, `append`, and `copyIn` accept `durable`.
95
95
  Precedence is per-call option, then `Root.defaults.durable`, then `true`;
96
96
  an explicitly `undefined` call option preserves the root default.
97
97
  `durable: false` keeps the sibling-temp replace/rename behavior of replacement
98
98
  writes but skips file and parent-directory fsync calls. Create-only and append
99
99
  publication behavior, permissions, identity checks, and error codes are unchanged.
100
100
  Use it only for reconstructible data: a crash may lose the write or leave the
101
- previous file. `copyIn`, `move`, and streaming `openWritable` do not use this option.
101
+ previous file. `move` and streaming `openWritable` do not use this option.
102
102
  The existing pure-JavaScript Windows writer performs no fsync calls in either
103
103
  setting; native Windows writes honor the option. Directory sync remains best-effort.
104
104
 
@@ -185,7 +185,9 @@ await fs.copyIn("inbox/upload.bin", "/tmp/incoming.bin", {
185
185
  });
186
186
  ```
187
187
 
188
- Options are `{ denyMutations?, maxBytes?, mkdir?, mode?, sourceHardlinks? }`.
188
+ Options are `{ denyMutations?, durable?, maxBytes?, mkdir?, mode?, sourceHardlinks? }`.
189
+ `durable` follows the root default and is `true` when omitted at both levels;
190
+ set it to `false` to skip file and parent-directory syncs for reconstructible data.
189
191
  Use `sourceHardlinks: "reject"` to refuse if the source itself is a hardlinked
190
192
  alias. There is no encoding option: copying preserves source bytes.
191
193
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/fs-safe",
3
- "version": "0.8.5",
3
+ "version": "0.9.0",
4
4
  "description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
5
5
  "keywords": [
6
6
  "filesystem",
@@ -156,19 +156,19 @@
156
156
  "archive:producer-smoke": "node scripts/archive-producer-smoke.mjs"
157
157
  },
158
158
  "optionalDependencies": {
159
- "@openclaw/fs-safe-darwin-arm64": "0.8.5",
160
- "@openclaw/fs-safe-darwin-x64": "0.8.5",
161
- "@openclaw/fs-safe-linux-arm64-gnu": "0.8.5",
162
- "@openclaw/fs-safe-linux-arm64-musl": "0.8.5",
163
- "@openclaw/fs-safe-linux-x64-gnu": "0.8.5",
164
- "@openclaw/fs-safe-linux-x64-musl": "0.8.5",
165
- "@openclaw/fs-safe-win32-x64-msvc": "0.8.5",
166
- "jszip": "^3.10.1"
159
+ "@openclaw/fs-safe-darwin-arm64": "0.9.0",
160
+ "@openclaw/fs-safe-darwin-x64": "0.9.0",
161
+ "@openclaw/fs-safe-linux-arm64-gnu": "0.9.0",
162
+ "@openclaw/fs-safe-linux-arm64-musl": "0.9.0",
163
+ "@openclaw/fs-safe-linux-x64-gnu": "0.9.0",
164
+ "@openclaw/fs-safe-linux-x64-musl": "0.9.0",
165
+ "@openclaw/fs-safe-win32-x64-msvc": "0.9.0",
166
+ "jszip": "^3.10.2"
167
167
  },
168
168
  "devDependencies": {
169
- "@emnapi/runtime": "2.0.0-alpha.4",
169
+ "@emnapi/runtime": "2.0.0-alpha.5",
170
170
  "@napi-rs/cli": "3.9.0",
171
- "@types/node": "^26.4.1",
171
+ "@types/node": "^26.5.1",
172
172
  "@vitest/coverage-v8": "5.0.0",
173
173
  "fast-check": "^4.9.0",
174
174
  "istanbul-lib-coverage": "3.2.2",