llm-relay 0.35.0 → 0.37.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 (165) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +12 -0
  3. package/THIRD_PARTY_NOTICES.md +72 -0
  4. package/config.example.json +8 -2
  5. package/dist/accounting-store-io.d.ts +124 -0
  6. package/dist/accounting-store-io.js +1105 -0
  7. package/dist/accounting-store-io.js.map +1 -0
  8. package/dist/accounting-store-schema.d.ts +299 -0
  9. package/dist/accounting-store-schema.js +1021 -0
  10. package/dist/accounting-store-schema.js.map +1 -0
  11. package/dist/accounting-store.d.ts +73 -0
  12. package/dist/accounting-store.js +1523 -0
  13. package/dist/accounting-store.js.map +1 -0
  14. package/dist/accounting.d.ts +177 -0
  15. package/dist/accounting.js +429 -0
  16. package/dist/accounting.js.map +1 -0
  17. package/dist/anthropic.d.ts +2 -2
  18. package/dist/anthropic.js +22 -5
  19. package/dist/anthropic.js.map +1 -1
  20. package/dist/authEnv.d.ts +19 -0
  21. package/dist/authEnv.js +58 -25
  22. package/dist/authEnv.js.map +1 -1
  23. package/dist/backend.d.ts +32 -3
  24. package/dist/backend.js +324 -41
  25. package/dist/backend.js.map +1 -1
  26. package/dist/candidates.d.ts +28 -3
  27. package/dist/candidates.js +86 -13
  28. package/dist/candidates.js.map +1 -1
  29. package/dist/catalog.d.ts +1 -0
  30. package/dist/catalog.js +36 -12
  31. package/dist/catalog.js.map +1 -1
  32. package/dist/circuit-breaker.d.ts +49 -129
  33. package/dist/circuit-breaker.js +166 -223
  34. package/dist/circuit-breaker.js.map +1 -1
  35. package/dist/cli.d.ts +47 -15
  36. package/dist/cli.js +475 -134
  37. package/dist/cli.js.map +1 -1
  38. package/dist/config.d.ts +23 -1
  39. package/dist/config.js +189 -19
  40. package/dist/config.js.map +1 -1
  41. package/dist/context-limits.js +2 -1
  42. package/dist/context-limits.js.map +1 -1
  43. package/dist/control-authorization.js +6 -4
  44. package/dist/control-authorization.js.map +1 -1
  45. package/dist/credential-fleet.d.ts +41 -0
  46. package/dist/credential-fleet.js +78 -0
  47. package/dist/credential-fleet.js.map +1 -0
  48. package/dist/credential-id.d.ts +13 -0
  49. package/dist/credential-id.js +22 -0
  50. package/dist/credential-id.js.map +1 -0
  51. package/dist/credential-select.d.ts +86 -0
  52. package/dist/credential-select.js +303 -0
  53. package/dist/credential-select.js.map +1 -0
  54. package/dist/dashboard/.vite/dashboard-bundle-graph.json +32 -0
  55. package/dist/dashboard/.vite/manifest.json +11 -0
  56. package/dist/dashboard/assets/index-BeZxN4Lx.css +1 -0
  57. package/dist/dashboard/assets/index-RtLgb7Cg.js +74 -0
  58. package/dist/dashboard/index.html +4 -0
  59. package/dist/dashboard-auth.d.ts +68 -0
  60. package/dist/dashboard-auth.js +225 -0
  61. package/dist/dashboard-auth.js.map +1 -0
  62. package/dist/dashboard-contract.d.ts +391 -0
  63. package/dist/dashboard-contract.js +619 -0
  64. package/dist/dashboard-contract.js.map +1 -0
  65. package/dist/dashboard-routes.d.ts +91 -0
  66. package/dist/dashboard-routes.js +688 -0
  67. package/dist/dashboard-routes.js.map +1 -0
  68. package/dist/dashboard-snapshot.d.ts +36 -0
  69. package/dist/dashboard-snapshot.js +1132 -0
  70. package/dist/dashboard-snapshot.js.map +1 -0
  71. package/dist/dashboard-static.d.ts +61 -0
  72. package/dist/dashboard-static.js +326 -0
  73. package/dist/dashboard-static.js.map +1 -0
  74. package/dist/dispatch.js +11 -6
  75. package/dist/dispatch.js.map +1 -1
  76. package/dist/dynamic-pools.js +6 -4
  77. package/dist/dynamic-pools.js.map +1 -1
  78. package/dist/kernel/contracts.d.ts +4 -0
  79. package/dist/kernel/request-lifecycle.js +4 -1
  80. package/dist/kernel/request-lifecycle.js.map +1 -1
  81. package/dist/key-checker.d.ts +3 -1
  82. package/dist/key-checker.js +35 -11
  83. package/dist/key-checker.js.map +1 -1
  84. package/dist/key-import.d.ts +29 -0
  85. package/dist/key-import.js +160 -0
  86. package/dist/key-import.js.map +1 -0
  87. package/dist/log.d.ts +20 -2
  88. package/dist/log.js +53 -2
  89. package/dist/log.js.map +1 -1
  90. package/dist/offload.d.ts +9 -0
  91. package/dist/offload.js +7 -1
  92. package/dist/offload.js.map +1 -1
  93. package/dist/onboarding.d.ts +3 -3
  94. package/dist/onboarding.js +12 -6
  95. package/dist/onboarding.js.map +1 -1
  96. package/dist/openai-dialect.d.ts +42 -0
  97. package/dist/openai-dialect.js +388 -0
  98. package/dist/openai-dialect.js.map +1 -0
  99. package/dist/ping/cadence.d.ts +12 -3
  100. package/dist/ping/cadence.js +68 -15
  101. package/dist/ping/cadence.js.map +1 -1
  102. package/dist/ping/ping.d.ts +7 -3
  103. package/dist/ping/ping.js +11 -35
  104. package/dist/ping/ping.js.map +1 -1
  105. package/dist/ping/probe-cache.d.ts +14 -6
  106. package/dist/ping/probe-cache.js +20 -5
  107. package/dist/ping/probe-cache.js.map +1 -1
  108. package/dist/ping/runtime-telemetry.d.ts +2 -1
  109. package/dist/ping/runtime-telemetry.js +103 -10
  110. package/dist/ping/runtime-telemetry.js.map +1 -1
  111. package/dist/pool-health.d.ts +2 -0
  112. package/dist/pool-health.js +29 -13
  113. package/dist/pool-health.js.map +1 -1
  114. package/dist/quota-observation.d.ts +29 -0
  115. package/dist/quota-observation.js +249 -0
  116. package/dist/quota-observation.js.map +1 -0
  117. package/dist/refusal-interpretation.d.ts +13 -7
  118. package/dist/refusal-interpretation.js +260 -33
  119. package/dist/refusal-interpretation.js.map +1 -1
  120. package/dist/registry.d.ts +13 -2
  121. package/dist/registry.js +12 -4
  122. package/dist/registry.js.map +1 -1
  123. package/dist/repair.d.ts +4 -2
  124. package/dist/repair.js +15 -1
  125. package/dist/repair.js.map +1 -1
  126. package/dist/request-log.d.ts +2 -2
  127. package/dist/request-log.js +9 -1
  128. package/dist/request-log.js.map +1 -1
  129. package/dist/reshaper.d.ts +76 -11
  130. package/dist/reshaper.js +264 -29
  131. package/dist/reshaper.js.map +1 -1
  132. package/dist/resolved-attempt.d.ts +14 -0
  133. package/dist/resolved-attempt.js +21 -0
  134. package/dist/resolved-attempt.js.map +1 -0
  135. package/dist/routes/admin.js +7 -1
  136. package/dist/routes/admin.js.map +1 -1
  137. package/dist/secret-file-acl.d.ts +25 -0
  138. package/dist/secret-file-acl.js +37 -0
  139. package/dist/secret-file-acl.js.map +1 -0
  140. package/dist/server.d.ts +28 -3
  141. package/dist/server.js +1774 -467
  142. package/dist/server.js.map +1 -1
  143. package/dist/session-pin.d.ts +35 -0
  144. package/dist/session-pin.js +136 -0
  145. package/dist/session-pin.js.map +1 -0
  146. package/dist/stream-commit.d.ts +34 -0
  147. package/dist/stream-commit.js +325 -0
  148. package/dist/stream-commit.js.map +1 -0
  149. package/dist/target-facts.d.ts +23 -136
  150. package/dist/target-facts.js +138 -179
  151. package/dist/target-facts.js.map +1 -1
  152. package/dist/telemetry.d.ts +1 -25
  153. package/dist/telemetry.js +52 -45
  154. package/dist/telemetry.js.map +1 -1
  155. package/dist/think-tags.d.ts +34 -0
  156. package/dist/think-tags.js +219 -0
  157. package/dist/think-tags.js.map +1 -0
  158. package/dist/usage-observer.d.ts +34 -0
  159. package/dist/usage-observer.js +322 -0
  160. package/dist/usage-observer.js.map +1 -0
  161. package/dist/winenv.js +26 -4
  162. package/dist/winenv.js.map +1 -1
  163. package/docs/dashboard-bundle-inventory.json +16 -0
  164. package/package.json +29 -6
  165. package/skills/llm-relay/SKILL.md +48 -9
@@ -0,0 +1,1105 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { closeSync, constants as fsConstants, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
3
+ import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
4
+ /**
5
+ * A deliberately small durability primitive for accounting's multi-file read model.
6
+ *
7
+ * It writes a full snapshot journal before replacing any target. A later process can
8
+ * therefore replay the journal after any target-write prefix without applying a delta
9
+ * twice. This module knows nothing about accounting's schemas; callers supply already
10
+ * materialised snapshots and an explicit target allow-list. Only flat, portable direct
11
+ * children of the root are supported for targets and the journal.
12
+ *
13
+ * Threat model: the root and its parent must be trusted and non-adversarial against
14
+ * concurrent same-user replacement. Race-proof filesystem containment against an actor
15
+ * able to rename or replace the root is out of scope; root and final reparse points are
16
+ * still checked before filesystem operations. Writer ownership is enforced only among
17
+ * instances in this process; cross-process concurrent writers are unsupported.
18
+ */
19
+ export const SNAPSHOT_JOURNAL_SCHEMA = "llm-relay.snapshot-journal.v1";
20
+ export const SNAPSHOT_JOURNAL_VERSION = 1;
21
+ export const SNAPSHOT_IO_DEFAULT_MAX_FILE_BYTES = 4 * 1024 * 1024;
22
+ export const SNAPSHOT_IO_DEFAULT_MAX_JOURNAL_BYTES = 8 * 1024 * 1024;
23
+ export const SNAPSHOT_IO_DEFAULT_MAX_TARGETS = 32;
24
+ export const SNAPSHOT_IO_HARD_MAX_FILE_BYTES = 32 * 1024 * 1024;
25
+ /** Four schema-sized snapshots plus base64 and journal metadata fit below this ceiling. */
26
+ export const SNAPSHOT_IO_HARD_MAX_JOURNAL_BYTES = 128 * 1024 * 1024;
27
+ export const SNAPSHOT_IO_HARD_MAX_TARGETS = 128;
28
+ export const SNAPSHOT_IO_MAX_RELATIVE_NAME_BYTES = 256;
29
+ const JOURNAL_KEYS = ["schema", "version", "transactionId", "createdAtMs", "targets"];
30
+ const JOURNAL_TARGET_KEYS = ["name", "operation", "bytes", "sha256", "data"];
31
+ const SHA256_PATTERN = /^[a-f0-9]{64}$/;
32
+ const NONCE_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
33
+ const PORTABLE_SEGMENT_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,126}[A-Za-z0-9_-])?$/;
34
+ const RESERVED_WINDOWS_NAMES = new Set([
35
+ "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
36
+ "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
37
+ ]);
38
+ const rootWriterLeases = new Map();
39
+ function isRecord(value) {
40
+ return typeof value === "object" && value !== null && !Array.isArray(value);
41
+ }
42
+ function isExactRecord(value, keys) {
43
+ if (!isRecord(value) || Object.getOwnPropertySymbols(value).length !== 0)
44
+ return false;
45
+ const names = Object.keys(value);
46
+ return names.length === keys.length && keys.every((key) => Object.prototype.hasOwnProperty.call(value, key));
47
+ }
48
+ function safeError(error) {
49
+ const text = error instanceof Error ? error.message : String(error);
50
+ return text.replaceAll("\0", "").slice(0, 256) || "io";
51
+ }
52
+ function hasCode(error, code) {
53
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
54
+ }
55
+ function clamp(value, fallback, hardMax) {
56
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? Math.min(value, hardMax) : fallback;
57
+ }
58
+ function checkedAdd(left, right) {
59
+ return left <= Number.MAX_SAFE_INTEGER - right ? left + right : null;
60
+ }
61
+ function utf8Bytes(value) {
62
+ return Buffer.byteLength(value, "utf8");
63
+ }
64
+ function canonicalName(value) {
65
+ // Portable target names are ASCII, so this is locale-independent and models Windows aliases.
66
+ return value.toLowerCase();
67
+ }
68
+ function canonicalRootKey(rootDir) {
69
+ return process.platform === "win32" ? rootDir.toLowerCase() : rootDir;
70
+ }
71
+ function compareNames(left, right) {
72
+ return left < right ? -1 : left > right ? 1 : 0;
73
+ }
74
+ function isPortableSegment(value) {
75
+ if (!PORTABLE_SEGMENT_PATTERN.test(value) || value.endsWith(".") || value.endsWith(" "))
76
+ return false;
77
+ const stem = value.split(".", 1)[0].toUpperCase();
78
+ return !RESERVED_WINDOWS_NAMES.has(stem);
79
+ }
80
+ function isSafeDirectChildName(value) {
81
+ if (typeof value !== "string"
82
+ || value.length === 0
83
+ || value.includes("\0")
84
+ || value.includes("\\")
85
+ || value.includes("/"))
86
+ return false;
87
+ if (utf8Bytes(value) > SNAPSHOT_IO_MAX_RELATIVE_NAME_BYTES || isAbsolute(value) || value.includes(":"))
88
+ return false;
89
+ return isPortableSegment(value);
90
+ }
91
+ function inside(rootDir, candidate) {
92
+ const relation = relative(rootDir, candidate);
93
+ return relation !== "" && relation !== ".." && !relation.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) && !isAbsolute(relation);
94
+ }
95
+ function digest(data) {
96
+ return createHash("sha256").update(data).digest("hex");
97
+ }
98
+ function sameBytes(left, right) {
99
+ return left.length === right.length && Buffer.compare(Buffer.from(left), Buffer.from(right)) === 0;
100
+ }
101
+ function mutationResult(status, options = {}) {
102
+ return {
103
+ status,
104
+ transactionId: options.transactionId ?? null,
105
+ lowerBoundLoss: options.lowerBoundLoss ?? false,
106
+ error: options.error ?? null,
107
+ quarantinedPath: options.quarantinedPath ?? null,
108
+ retryable: options.retryable ?? status === "failed",
109
+ };
110
+ }
111
+ function readResult(status, value = null, error = null, quarantinedPath = null) {
112
+ return { status, value, error, quarantinedPath };
113
+ }
114
+ function makeConfig(options) {
115
+ try {
116
+ if (!isRecord(options) || typeof options.rootDir !== "string" || options.rootDir.length === 0 || options.rootDir.includes("\0")) {
117
+ return { config: null, error: "rootDir" };
118
+ }
119
+ const rootDir = resolve(options.rootDir);
120
+ const maxTargets = clamp(options.maxTargets, SNAPSHOT_IO_DEFAULT_MAX_TARGETS, SNAPSHOT_IO_HARD_MAX_TARGETS);
121
+ const maxFileBytes = clamp(options.maxFileBytes, SNAPSHOT_IO_DEFAULT_MAX_FILE_BYTES, SNAPSHOT_IO_HARD_MAX_FILE_BYTES);
122
+ const maxJournalBytes = clamp(options.maxJournalBytes, SNAPSHOT_IO_DEFAULT_MAX_JOURNAL_BYTES, SNAPSHOT_IO_HARD_MAX_JOURNAL_BYTES);
123
+ const acceptTarget = typeof options.acceptTarget === "function" ? options.acceptTarget : null;
124
+ const staticTargets = options.targets ?? [];
125
+ if (!Array.isArray(staticTargets) || (staticTargets.length === 0 && acceptTarget === null)) {
126
+ return { config: null, error: "targets" };
127
+ }
128
+ const journalName = options.journalName ?? "snapshot-journal.json";
129
+ if (!isSafeDirectChildName(journalName))
130
+ return { config: null, error: "journalName" };
131
+ const journalPath = resolve(rootDir, journalName);
132
+ if (!inside(rootDir, journalPath))
133
+ return { config: null, error: "journalName" };
134
+ const targets = new Map();
135
+ const aliases = new Set([canonicalName(journalName)]);
136
+ for (const name of staticTargets) {
137
+ const alias = typeof name === "string" ? canonicalName(name) : "";
138
+ if (!isSafeDirectChildName(name) || aliases.has(alias) || targets.has(name))
139
+ return { config: null, error: "targets" };
140
+ const path = resolve(rootDir, name);
141
+ if (!inside(rootDir, path))
142
+ return { config: null, error: "targets" };
143
+ targets.set(name, { name, path });
144
+ aliases.add(alias);
145
+ }
146
+ return {
147
+ config: {
148
+ rootDir,
149
+ leaseKey: canonicalRootKey(rootDir),
150
+ journalName,
151
+ journalPath,
152
+ targets,
153
+ targetAliases: new Set(aliases),
154
+ acceptTarget,
155
+ targetNames: Object.freeze([...targets.keys()].sort(compareNames)),
156
+ maxFileBytes,
157
+ maxJournalBytes,
158
+ maxTargets,
159
+ hooks: options.hooks ?? {},
160
+ },
161
+ error: null,
162
+ };
163
+ }
164
+ catch (error) {
165
+ return { config: null, error: safeError(error) };
166
+ }
167
+ }
168
+ class SnapshotJournalIoImpl {
169
+ config;
170
+ configError;
171
+ writerLeaseToken = {};
172
+ ownsWriterLease = false;
173
+ writerOperation = false;
174
+ pendingRecoveryLoss = null;
175
+ closed = false;
176
+ constructor(options) {
177
+ const result = makeConfig(options);
178
+ this.config = result.config;
179
+ this.configError = result.error;
180
+ }
181
+ get rootDir() {
182
+ return this.config?.rootDir ?? null;
183
+ }
184
+ get journalPath() {
185
+ return this.config?.journalPath ?? null;
186
+ }
187
+ get targetNames() {
188
+ return this.config?.targetNames ?? Object.freeze([]);
189
+ }
190
+ get maxFileBytes() {
191
+ return this.config?.maxFileBytes ?? null;
192
+ }
193
+ get maxJournalBytes() {
194
+ return this.config?.maxJournalBytes ?? null;
195
+ }
196
+ get maxTargets() {
197
+ return this.config?.maxTargets ?? null;
198
+ }
199
+ readText(name, options = {}) {
200
+ try {
201
+ const target = this.target(name);
202
+ if (!target)
203
+ return readResult("invalid-target", null, this.configError ?? "target");
204
+ const raw = this.readRaw(target.path, this.readLimit(options));
205
+ if (raw.status !== "ok" || raw.data === null) {
206
+ return this.maybeQuarantineRead(name, readResult(raw.status, null, raw.error), options);
207
+ }
208
+ const value = raw.data.toString("utf8");
209
+ if (!sameBytes(raw.data, Buffer.from(value, "utf8"))) {
210
+ return this.maybeQuarantineRead(name, readResult("corrupt", null, "utf8"), options);
211
+ }
212
+ return readResult("ok", value);
213
+ }
214
+ catch (error) {
215
+ return readResult("failed", null, safeError(error));
216
+ }
217
+ }
218
+ readJson(name, validate, options = {}) {
219
+ try {
220
+ const text = this.readText(name, options);
221
+ if (text.status !== "ok" || text.value === null)
222
+ return text;
223
+ let value;
224
+ try {
225
+ value = JSON.parse(text.value);
226
+ }
227
+ catch {
228
+ return this.maybeQuarantineRead(name, readResult("corrupt", null, "json"), options);
229
+ }
230
+ if (validate !== undefined) {
231
+ let valid = false;
232
+ try {
233
+ valid = validate(value);
234
+ }
235
+ catch {
236
+ valid = false;
237
+ }
238
+ if (!valid)
239
+ return this.maybeQuarantineRead(name, readResult("corrupt", null, "schema"), options);
240
+ }
241
+ return readResult("ok", value);
242
+ }
243
+ catch (error) {
244
+ return readResult("failed", null, safeError(error));
245
+ }
246
+ }
247
+ quarantineTarget(name) {
248
+ try {
249
+ const target = this.target(name);
250
+ if (!target)
251
+ return { status: "invalid-target", path: null, error: this.configError ?? "target" };
252
+ return this.withWriterQuarantine(() => this.quarantinePath(target.path));
253
+ }
254
+ catch (error) {
255
+ return { status: "failed", path: null, error: safeError(error) };
256
+ }
257
+ }
258
+ acquireWriter() {
259
+ try {
260
+ if (this.closed)
261
+ return { status: "closed", error: "closed", retryable: false };
262
+ const config = this.config;
263
+ if (!config)
264
+ return { status: "invalid", error: this.configError ?? "config", retryable: false };
265
+ if (this.ownsWriterLease)
266
+ return { status: "acquired", error: null, retryable: false };
267
+ const owner = rootWriterLeases.get(config.leaseKey);
268
+ if (owner !== undefined && owner !== this.writerLeaseToken) {
269
+ return { status: "busy", error: "writer-busy", retryable: true };
270
+ }
271
+ rootWriterLeases.set(config.leaseKey, this.writerLeaseToken);
272
+ this.ownsWriterLease = true;
273
+ return { status: "acquired", error: null, retryable: false };
274
+ }
275
+ catch (error) {
276
+ return { status: "failed", error: safeError(error), retryable: true };
277
+ }
278
+ }
279
+ releaseWriter() {
280
+ try {
281
+ if (this.writerOperation)
282
+ return { status: "failed", error: "writer-busy", retryable: true };
283
+ const config = this.config;
284
+ if (!config)
285
+ return { status: "invalid", error: this.configError ?? "config", retryable: false };
286
+ if (!this.ownsWriterLease)
287
+ return { status: "released", error: null, retryable: false };
288
+ if (rootWriterLeases.get(config.leaseKey) !== this.writerLeaseToken) {
289
+ return { status: "failed", error: "writer-owner", retryable: true };
290
+ }
291
+ rootWriterLeases.delete(config.leaseKey);
292
+ this.ownsWriterLease = false;
293
+ return { status: "released", error: null, retryable: false };
294
+ }
295
+ catch (error) {
296
+ return { status: "failed", error: safeError(error), retryable: true };
297
+ }
298
+ }
299
+ close() {
300
+ if (this.writerOperation)
301
+ return { status: "failed", error: "writer-busy", retryable: true };
302
+ const result = this.releaseWriter();
303
+ if (result.status === "failed")
304
+ return result;
305
+ this.closed = true;
306
+ return { status: "closed", error: null, retryable: false };
307
+ }
308
+ recover() {
309
+ return this.withWriter(() => this.recoverUnlocked());
310
+ }
311
+ commit(snapshots) {
312
+ return this.withWriter(() => this.commitUnlocked(snapshots));
313
+ }
314
+ recoverUnlocked() {
315
+ try {
316
+ const config = this.config;
317
+ if (!config)
318
+ return mutationResult("invalid", { error: this.configError ?? "config", retryable: false });
319
+ const raw = this.readRaw(config.journalPath, config.maxJournalBytes);
320
+ if (raw.status === "missing") {
321
+ return this.pendingRecoveryLoss === null
322
+ ? mutationResult("none", { retryable: false })
323
+ : this.pendingRecoveryLossResult();
324
+ }
325
+ if (raw.status === "failed") {
326
+ return this.withPendingRecoveryLoss(mutationResult("failed", { error: raw.error ?? "journal-read", retryable: true }));
327
+ }
328
+ if (raw.status !== "ok" || raw.data === null)
329
+ return this.loseJournal(raw.error ?? raw.status);
330
+ const journal = this.decodeJournal(raw.data);
331
+ if (journal === null)
332
+ return this.loseJournal("journal");
333
+ const result = this.applyJournal(journal, "recovered");
334
+ return this.withPendingRecoveryLoss(result, result.status === "recovered");
335
+ }
336
+ catch (error) {
337
+ return this.withPendingRecoveryLoss(mutationResult("failed", { error: safeError(error), retryable: true }));
338
+ }
339
+ }
340
+ commitUnlocked(snapshots) {
341
+ try {
342
+ const config = this.config;
343
+ if (!config)
344
+ return mutationResult("invalid", { error: this.configError ?? "config", retryable: false });
345
+ const recovery = this.recoverUnlocked();
346
+ if (recovery.status === "failed" || recovery.status === "invalid")
347
+ return recovery;
348
+ const lowerBoundLoss = recovery.lowerBoundLoss;
349
+ const encoded = this.encodeSnapshots(snapshots);
350
+ if (encoded === null)
351
+ return mutationResult("invalid", { error: "snapshots", lowerBoundLoss, retryable: false });
352
+ const transactionId = this.transactionId();
353
+ const payload = {
354
+ schema: SNAPSHOT_JOURNAL_SCHEMA,
355
+ version: SNAPSHOT_JOURNAL_VERSION,
356
+ transactionId,
357
+ createdAtMs: this.now(),
358
+ targets: encoded,
359
+ };
360
+ const text = JSON.stringify(payload);
361
+ if (utf8Bytes(text) > config.maxJournalBytes) {
362
+ return mutationResult("invalid", { error: "journal-size", lowerBoundLoss, retryable: false });
363
+ }
364
+ const written = this.atomicReplace(config.journalPath, Buffer.from(text, "utf8"), "journal", null);
365
+ if (!written.ok)
366
+ return this.withPendingRecoveryLoss(mutationResult("failed", { transactionId, lowerBoundLoss, error: written.error, retryable: true }));
367
+ const journal = this.decodeJournal(Buffer.from(text, "utf8"));
368
+ if (journal === null) {
369
+ // This should be unreachable; preserve the journal so a future version can inspect it.
370
+ return this.withPendingRecoveryLoss(mutationResult("failed", { transactionId, lowerBoundLoss, error: "journal-encode", retryable: true }));
371
+ }
372
+ const result = this.applyJournal(journal, "committed");
373
+ const merged = result.lowerBoundLoss === lowerBoundLoss ? result : { ...result, lowerBoundLoss };
374
+ return this.withPendingRecoveryLoss(merged, merged.status === "committed");
375
+ }
376
+ catch (error) {
377
+ return this.withPendingRecoveryLoss(mutationResult("failed", { error: safeError(error), retryable: true }));
378
+ }
379
+ }
380
+ target(name) {
381
+ const config = this.config;
382
+ if (!config || typeof name !== "string" || !isSafeDirectChildName(name))
383
+ return null;
384
+ const alias = canonicalName(name);
385
+ if (alias === canonicalName(config.journalName))
386
+ return null;
387
+ const staticTarget = config.targets.get(name);
388
+ if (staticTarget)
389
+ return staticTarget;
390
+ if (config.targetAliases.has(alias) || config.acceptTarget === null)
391
+ return null;
392
+ const path = resolve(config.rootDir, name);
393
+ if (!inside(config.rootDir, path))
394
+ return null;
395
+ let accepted = false;
396
+ try {
397
+ accepted = config.acceptTarget(name) === true;
398
+ }
399
+ catch {
400
+ accepted = false;
401
+ }
402
+ if (!accepted)
403
+ return null;
404
+ return { name, path };
405
+ }
406
+ readLimit(options) {
407
+ const config = this.config;
408
+ return config ? clamp(options.maxBytes, config.maxFileBytes, config.maxFileBytes) : SNAPSHOT_IO_DEFAULT_MAX_FILE_BYTES;
409
+ }
410
+ readRaw(path, maxBytes) {
411
+ try {
412
+ const safe = this.inspectExistingPath(path, false);
413
+ if (safe.status === "missing")
414
+ return { status: "missing", data: null, error: null };
415
+ if (safe.status === "corrupt")
416
+ return { status: "corrupt", data: null, error: safe.error };
417
+ if (safe.status !== "ok")
418
+ return { status: "failed", data: null, error: safe.error };
419
+ this.config?.hooks.beforeRead?.(path);
420
+ const descriptor = this.openReadNoFollow(path);
421
+ try {
422
+ const stat = fstatSync(descriptor);
423
+ if (!stat.isFile() || !Number.isSafeInteger(stat.size) || stat.size < 0) {
424
+ return { status: "corrupt", data: null, error: "file-type" };
425
+ }
426
+ this.config?.hooks.afterOpenRead?.(path);
427
+ const afterOpen = this.inspectExistingPath(path, false);
428
+ if (afterOpen.status !== "ok") {
429
+ return { status: afterOpen.status === "missing" || afterOpen.status === "corrupt" ? "corrupt" : "failed", data: null, error: afterOpen.error };
430
+ }
431
+ const named = statSync(path);
432
+ if (named.dev !== stat.dev || named.ino !== stat.ino) {
433
+ return { status: "failed", data: null, error: "path-race" };
434
+ }
435
+ if (stat.size > maxBytes)
436
+ return { status: "oversize", data: null, error: "size" };
437
+ // Read at most max+1 bytes through this descriptor. Chunking avoids preallocating a
438
+ // huge buffer for a tiny file while still detecting a file that grows after fstat.
439
+ const chunks = [];
440
+ let offset = 0;
441
+ while (offset <= maxBytes) {
442
+ const chunk = Buffer.alloc(Math.min(64 * 1024, maxBytes + 1 - offset));
443
+ const count = readSync(descriptor, chunk, 0, chunk.length, offset);
444
+ if (count === 0)
445
+ break;
446
+ chunks.push(chunk.subarray(0, count));
447
+ offset += count;
448
+ }
449
+ if (offset > maxBytes)
450
+ return { status: "oversize", data: null, error: "size" };
451
+ return { status: "ok", data: Buffer.concat(chunks, offset), error: null };
452
+ }
453
+ finally {
454
+ closeSync(descriptor);
455
+ }
456
+ }
457
+ catch (error) {
458
+ if (hasCode(error, "ENOENT"))
459
+ return { status: "missing", data: null, error: null };
460
+ return { status: "failed", data: null, error: safeError(error) };
461
+ }
462
+ }
463
+ inspectRoot(create) {
464
+ const config = this.config;
465
+ if (!config)
466
+ return { status: "failed", error: this.configError ?? "config" };
467
+ try {
468
+ const parsed = parse(config.rootDir);
469
+ const tail = relative(parsed.root, config.rootDir);
470
+ const pieces = tail.length === 0 ? [] : tail.split(sep).filter((piece) => piece.length > 0);
471
+ let current = parsed.root;
472
+ if (pieces.length === 0) {
473
+ const stat = lstatSync(config.rootDir);
474
+ if (this.isReparse(stat) || !stat.isDirectory())
475
+ return { status: "corrupt", error: "root" };
476
+ return { status: "ok", error: null };
477
+ }
478
+ for (const piece of pieces) {
479
+ current = join(current, piece);
480
+ let stat;
481
+ try {
482
+ stat = lstatSync(current);
483
+ }
484
+ catch (error) {
485
+ if (!hasCode(error, "ENOENT"))
486
+ throw error;
487
+ if (!create)
488
+ return { status: "missing", error: null };
489
+ mkdirSync(current);
490
+ stat = lstatSync(current);
491
+ }
492
+ if (this.isReparse(stat) || !stat.isDirectory())
493
+ return { status: "corrupt", error: "root" };
494
+ }
495
+ return { status: "ok", error: null };
496
+ }
497
+ catch (error) {
498
+ return { status: "failed", error: safeError(error) };
499
+ }
500
+ }
501
+ inspectExistingPath(path, allowFinalReparse) {
502
+ const config = this.config;
503
+ if (!config || (!inside(config.rootDir, path) && path !== config.rootDir)) {
504
+ return { status: "failed", error: "path" };
505
+ }
506
+ const root = this.inspectRoot(false);
507
+ if (root.status !== "ok")
508
+ return root;
509
+ try {
510
+ const relation = relative(config.rootDir, path);
511
+ if (relation.length === 0)
512
+ return { status: "ok", error: null };
513
+ const pieces = relation.split(sep).filter((piece) => piece.length > 0);
514
+ let current = config.rootDir;
515
+ for (let index = 0; index < pieces.length; index += 1) {
516
+ const piece = pieces[index];
517
+ const final = index === pieces.length - 1;
518
+ current = join(current, piece);
519
+ let stat;
520
+ try {
521
+ stat = lstatSync(current);
522
+ }
523
+ catch (error) {
524
+ if (hasCode(error, "ENOENT"))
525
+ return { status: "missing", error: null };
526
+ throw error;
527
+ }
528
+ if (this.isReparse(stat)) {
529
+ if (final && allowFinalReparse)
530
+ return { status: "ok", error: null };
531
+ return { status: "corrupt", error: "reparse" };
532
+ }
533
+ if ((!final && !stat.isDirectory()) || (final && !stat.isFile())) {
534
+ return { status: "corrupt", error: "file-type" };
535
+ }
536
+ }
537
+ return { status: "ok", error: null };
538
+ }
539
+ catch (error) {
540
+ return { status: "failed", error: safeError(error) };
541
+ }
542
+ }
543
+ ensureSafeParent(path, checkLeaf = true) {
544
+ const config = this.config;
545
+ if (!config || (!inside(config.rootDir, path) && path !== config.rootDir)) {
546
+ return { status: "failed", error: "path" };
547
+ }
548
+ const root = this.inspectRoot(true);
549
+ if (root.status !== "ok")
550
+ return root;
551
+ try {
552
+ const parent = dirname(path);
553
+ if (!inside(config.rootDir, parent) && parent !== config.rootDir)
554
+ return { status: "failed", error: "parent" };
555
+ const relation = relative(config.rootDir, parent);
556
+ const pieces = relation.length === 0 ? [] : relation.split(sep).filter((piece) => piece.length > 0);
557
+ let current = config.rootDir;
558
+ for (const piece of pieces) {
559
+ current = join(current, piece);
560
+ let stat;
561
+ try {
562
+ stat = lstatSync(current);
563
+ }
564
+ catch (error) {
565
+ if (!hasCode(error, "ENOENT"))
566
+ throw error;
567
+ mkdirSync(current);
568
+ stat = lstatSync(current);
569
+ }
570
+ if (this.isReparse(stat) || !stat.isDirectory())
571
+ return { status: "corrupt", error: "parent" };
572
+ }
573
+ if (checkLeaf) {
574
+ try {
575
+ const stat = lstatSync(path);
576
+ if (this.isReparse(stat) || !stat.isFile())
577
+ return { status: "corrupt", error: "target" };
578
+ }
579
+ catch (error) {
580
+ if (!hasCode(error, "ENOENT"))
581
+ throw error;
582
+ }
583
+ }
584
+ return { status: "ok", error: null };
585
+ }
586
+ catch (error) {
587
+ return { status: "failed", error: safeError(error) };
588
+ }
589
+ }
590
+ isReparse(stat) {
591
+ const possible = stat;
592
+ try {
593
+ return stat.isSymbolicLink() || possible.isReparsePoint?.() === true;
594
+ }
595
+ catch {
596
+ return true;
597
+ }
598
+ }
599
+ openReadNoFollow(path) {
600
+ const noFollow = fsConstants.O_NOFOLLOW ?? 0;
601
+ try {
602
+ return openSync(path, fsConstants.O_RDONLY | noFollow);
603
+ }
604
+ catch (error) {
605
+ // O_NOFOLLOW is not available on every Windows filesystem. Re-check every component
606
+ // immediately before the fallback, then fstat/stat-match the opened descriptor below.
607
+ const unsupported = process.platform === "win32" && noFollow !== 0 && (hasCode(error, "EINVAL") || hasCode(error, "ENOTSUP"));
608
+ if (!unsupported)
609
+ throw error;
610
+ const inspected = this.inspectExistingPath(path, false);
611
+ if (inspected.status !== "ok")
612
+ throw new Error(inspected.error ?? "path");
613
+ return openSync(path, "r");
614
+ }
615
+ }
616
+ maybeQuarantineRead(name, result, options) {
617
+ if (!options.quarantineCorrupt || (result.status !== "corrupt" && result.status !== "oversize"))
618
+ return result;
619
+ const quarantine = this.quarantineTarget(name);
620
+ if (quarantine.status === "quarantined")
621
+ return { ...result, quarantinedPath: quarantine.path };
622
+ if (quarantine.status === "missing")
623
+ return result;
624
+ return { ...result, error: quarantine.error ?? result.error };
625
+ }
626
+ encodeSnapshots(snapshots) {
627
+ const config = this.config;
628
+ if (!config || !isRecord(snapshots))
629
+ return null;
630
+ const values = [];
631
+ let totalBytes = 0;
632
+ // Bound the eventual JSON/base64 payload before building the journal string.
633
+ // The fixed allowance intentionally exceeds the schema fields and quoted metadata.
634
+ let encodedBytes = 384;
635
+ let count = 0;
636
+ const aliases = new Set([canonicalName(config.journalName)]);
637
+ for (const name of Object.keys(snapshots)) {
638
+ count += 1;
639
+ if (count > config.maxTargets)
640
+ return null;
641
+ const target = this.target(name);
642
+ const value = snapshots[name];
643
+ const alias = canonicalName(name);
644
+ if (!target || aliases.has(alias))
645
+ return null;
646
+ aliases.add(alias);
647
+ if (value === null) {
648
+ const nextEncoded = checkedAdd(encodedBytes, utf8Bytes(name) + 128);
649
+ if (nextEncoded === null || nextEncoded > config.maxJournalBytes)
650
+ return null;
651
+ encodedBytes = nextEncoded;
652
+ values.push({ name: target.name, operation: "delete", bytes: 0, sha256: null, data: null });
653
+ continue;
654
+ }
655
+ if (typeof value !== "string" && !(value instanceof Uint8Array))
656
+ return null;
657
+ const bytes = typeof value === "string" ? utf8Bytes(value) : value.byteLength;
658
+ if (!Number.isSafeInteger(bytes) || bytes > config.maxFileBytes)
659
+ return null;
660
+ const next = checkedAdd(totalBytes, bytes);
661
+ if (next === null || next > config.maxJournalBytes)
662
+ return null;
663
+ totalBytes = next;
664
+ const base64Bytes = checkedAdd(0, 4 * Math.ceil(bytes / 3));
665
+ const entryEstimate = base64Bytes === null ? null : checkedAdd(base64Bytes, utf8Bytes(name) + 160);
666
+ const nextEncoded = entryEstimate === null ? null : checkedAdd(encodedBytes, entryEstimate);
667
+ if (nextEncoded === null || nextEncoded > config.maxJournalBytes)
668
+ return null;
669
+ encodedBytes = nextEncoded;
670
+ const data = typeof value === "string" ? Buffer.from(value, "utf8") : Buffer.from(value);
671
+ values.push({ name: target.name, operation: "replace", bytes: data.length, sha256: digest(data), data: data.toString("base64") });
672
+ }
673
+ if (values.length === 0)
674
+ return null;
675
+ values.sort((left, right) => compareNames(left.name, right.name));
676
+ return values;
677
+ }
678
+ decodeJournal(data) {
679
+ const config = this.config;
680
+ if (!config || data.length > config.maxJournalBytes)
681
+ return null;
682
+ let value;
683
+ try {
684
+ const text = data.toString("utf8");
685
+ if (!sameBytes(data, Buffer.from(text, "utf8")))
686
+ return null;
687
+ value = JSON.parse(text);
688
+ }
689
+ catch {
690
+ return null;
691
+ }
692
+ if (!isExactRecord(value, JOURNAL_KEYS) || value.schema !== SNAPSHOT_JOURNAL_SCHEMA || value.version !== SNAPSHOT_JOURNAL_VERSION)
693
+ return null;
694
+ if (typeof value.transactionId !== "string" || !NONCE_PATTERN.test(value.transactionId))
695
+ return null;
696
+ if (typeof value.createdAtMs !== "number" || !Number.isSafeInteger(value.createdAtMs) || value.createdAtMs < 0)
697
+ return null;
698
+ if (!Array.isArray(value.targets) || value.targets.length === 0 || value.targets.length > config.maxTargets)
699
+ return null;
700
+ const targets = [];
701
+ let totalBytes = 0;
702
+ let previous = "";
703
+ const seen = new Set();
704
+ for (const entry of value.targets) {
705
+ if (!isExactRecord(entry, JOURNAL_TARGET_KEYS))
706
+ return null;
707
+ const name = entry.name;
708
+ const alias = typeof name === "string" ? canonicalName(name) : "";
709
+ if (typeof name !== "string" || !this.target(name) || seen.has(alias) || (previous.length > 0 && compareNames(name, previous) <= 0))
710
+ return null;
711
+ if (entry.operation === "delete") {
712
+ if (entry.bytes !== 0 || entry.sha256 !== null || entry.data !== null)
713
+ return null;
714
+ seen.add(alias);
715
+ previous = name;
716
+ targets.push({ name, data: null });
717
+ continue;
718
+ }
719
+ if (entry.operation !== "replace")
720
+ return null;
721
+ if (typeof entry.bytes !== "number" || !Number.isSafeInteger(entry.bytes) || entry.bytes < 0 || entry.bytes > config.maxFileBytes)
722
+ return null;
723
+ if (typeof entry.sha256 !== "string" || !SHA256_PATTERN.test(entry.sha256))
724
+ return null;
725
+ if (typeof entry.data !== "string" || entry.data.length > Math.ceil((config.maxFileBytes * 4) / 3) + 8)
726
+ return null;
727
+ let decoded;
728
+ try {
729
+ decoded = Buffer.from(entry.data, "base64");
730
+ }
731
+ catch {
732
+ return null;
733
+ }
734
+ // Node's base64 decoder is permissive, so require the exact canonical encoding too.
735
+ if (decoded.length !== entry.bytes || decoded.toString("base64") !== entry.data || digest(decoded) !== entry.sha256)
736
+ return null;
737
+ const next = checkedAdd(totalBytes, decoded.length);
738
+ if (next === null || next > config.maxJournalBytes)
739
+ return null;
740
+ totalBytes = next;
741
+ seen.add(alias);
742
+ previous = name;
743
+ targets.push({ name, data: decoded });
744
+ }
745
+ return { transactionId: value.transactionId, targets };
746
+ }
747
+ applyJournal(journal, status) {
748
+ const config = this.config;
749
+ if (!config)
750
+ return mutationResult("invalid", { error: this.configError ?? "config", retryable: false });
751
+ for (const snapshot of journal.targets) {
752
+ const target = this.target(snapshot.name);
753
+ if (!target)
754
+ return mutationResult("failed", { transactionId: journal.transactionId, error: "journal-target", retryable: true });
755
+ const mutation = snapshot.data === null
756
+ ? this.atomicDelete(target.path, target.name)
757
+ : this.atomicReplace(target.path, snapshot.data, "target", target.name);
758
+ if (!mutation.ok)
759
+ return mutationResult("failed", { transactionId: journal.transactionId, error: mutation.error, retryable: true });
760
+ }
761
+ const removed = this.removeJournal();
762
+ if (!removed.ok)
763
+ return mutationResult("failed", { transactionId: journal.transactionId, error: removed.error, retryable: true });
764
+ return mutationResult(status, { transactionId: journal.transactionId, retryable: false });
765
+ }
766
+ loseJournal(error) {
767
+ const config = this.config;
768
+ if (!config)
769
+ return mutationResult("invalid", { error: this.configError ?? "config", retryable: false });
770
+ const quarantine = this.quarantinePath(config.journalPath);
771
+ if (quarantine.status === "quarantined" || quarantine.status === "missing") {
772
+ this.pendingRecoveryLoss = null;
773
+ return mutationResult("recovery-loss", {
774
+ lowerBoundLoss: true,
775
+ error,
776
+ quarantinedPath: quarantine.path,
777
+ retryable: false,
778
+ });
779
+ }
780
+ if (quarantine.path !== null)
781
+ return this.rememberRecoveryLoss(quarantine.error ?? error, quarantine.path);
782
+ return this.withPendingRecoveryLoss(mutationResult("failed", { error: quarantine.error ?? error, retryable: true }));
783
+ }
784
+ rememberRecoveryLoss(error, quarantinedPath) {
785
+ this.pendingRecoveryLoss = { error, quarantinedPath };
786
+ return this.pendingRecoveryLossResult();
787
+ }
788
+ pendingRecoveryLossResult() {
789
+ const pending = this.pendingRecoveryLoss;
790
+ if (pending === null)
791
+ return mutationResult("none", { retryable: false });
792
+ return mutationResult("recovery-loss", {
793
+ lowerBoundLoss: true,
794
+ error: pending.error,
795
+ quarantinedPath: pending.quarantinedPath,
796
+ retryable: true,
797
+ });
798
+ }
799
+ withPendingRecoveryLoss(result, clearOnSuccess = false) {
800
+ const pending = this.pendingRecoveryLoss;
801
+ if (pending === null)
802
+ return result;
803
+ const withLoss = {
804
+ ...result,
805
+ lowerBoundLoss: true,
806
+ quarantinedPath: result.quarantinedPath ?? pending.quarantinedPath,
807
+ };
808
+ if (clearOnSuccess)
809
+ this.pendingRecoveryLoss = null;
810
+ return withLoss;
811
+ }
812
+ withWriter(action) {
813
+ if (this.closed)
814
+ return mutationResult("invalid", { error: "closed", retryable: false });
815
+ if (this.writerOperation)
816
+ return mutationResult("failed", { error: "writer-busy", retryable: true });
817
+ const temporary = !this.ownsWriterLease;
818
+ if (temporary) {
819
+ const acquired = this.acquireWriter();
820
+ if (acquired.status !== "acquired") {
821
+ return mutationResult(acquired.status === "invalid" || acquired.status === "closed" ? "invalid" : "failed", {
822
+ error: acquired.error,
823
+ retryable: acquired.retryable,
824
+ });
825
+ }
826
+ }
827
+ this.writerOperation = true;
828
+ try {
829
+ return action();
830
+ }
831
+ catch (error) {
832
+ return mutationResult("failed", { error: safeError(error), retryable: true });
833
+ }
834
+ finally {
835
+ this.writerOperation = false;
836
+ if (temporary)
837
+ this.releaseWriter();
838
+ }
839
+ }
840
+ withWriterQuarantine(action) {
841
+ if (this.closed)
842
+ return { status: "failed", path: null, error: "closed" };
843
+ if (this.writerOperation)
844
+ return { status: "failed", path: null, error: "writer-busy" };
845
+ const temporary = !this.ownsWriterLease;
846
+ if (temporary) {
847
+ const acquired = this.acquireWriter();
848
+ if (acquired.status !== "acquired")
849
+ return { status: "failed", path: null, error: acquired.error ?? "writer-busy" };
850
+ }
851
+ this.writerOperation = true;
852
+ try {
853
+ return action();
854
+ }
855
+ catch (error) {
856
+ return { status: "failed", path: null, error: safeError(error) };
857
+ }
858
+ finally {
859
+ this.writerOperation = false;
860
+ if (temporary)
861
+ this.releaseWriter();
862
+ }
863
+ }
864
+ atomicDelete(path, target) {
865
+ const config = this.config;
866
+ if (!config || !inside(config.rootDir, path))
867
+ return { ok: false, error: "path" };
868
+ try {
869
+ const safeParent = this.ensureSafeParent(path, true);
870
+ if (safeParent.status !== "ok")
871
+ return { ok: false, error: safeParent.error ?? safeParent.status };
872
+ let present = true;
873
+ try {
874
+ lstatSync(path);
875
+ }
876
+ catch (error) {
877
+ if (hasCode(error, "ENOENT"))
878
+ present = false;
879
+ else
880
+ throw error;
881
+ }
882
+ if (present) {
883
+ this.step("before-delete", "target", path, target, "before");
884
+ try {
885
+ unlinkSync(path);
886
+ }
887
+ catch (error) {
888
+ if (!hasCode(error, "ENOENT"))
889
+ throw error;
890
+ }
891
+ this.step("after-delete", "target", path, target, "after");
892
+ }
893
+ const synced = this.fsyncDirectory(dirname(path));
894
+ if (!synced.ok)
895
+ return synced;
896
+ return { ok: true, error: null };
897
+ }
898
+ catch (error) {
899
+ return { ok: false, error: safeError(error) };
900
+ }
901
+ }
902
+ atomicReplace(path, data, phase, target) {
903
+ let temp = null;
904
+ try {
905
+ const config = this.config;
906
+ if (!config || !inside(config.rootDir, path) || data.length > (phase === "journal" ? config.maxJournalBytes : config.maxFileBytes)) {
907
+ return { ok: false, error: "path-or-size" };
908
+ }
909
+ const parent = dirname(path);
910
+ if (!inside(config.rootDir, parent) && parent !== config.rootDir)
911
+ return { ok: false, error: "parent" };
912
+ const safeParent = this.ensureSafeParent(path);
913
+ if (safeParent.status !== "ok")
914
+ return { ok: false, error: safeParent.error ?? safeParent.status };
915
+ const nonce = this.nonce();
916
+ temp = join(parent, `tmp-${basename(path)}-${this.now()}-${nonce}`);
917
+ if (!inside(config.rootDir, temp))
918
+ return { ok: false, error: "temp" };
919
+ this.step("before-temp-write", phase, path, target, "before");
920
+ writeFileSync(temp, data, { flag: "wx" });
921
+ this.step("after-temp-write", phase, path, target, "after");
922
+ // Windows permits fsync on a writable descriptor but can reject a read-only one.
923
+ const descriptor = openSync(temp, "r+");
924
+ try {
925
+ fsyncSync(descriptor);
926
+ }
927
+ finally {
928
+ closeSync(descriptor);
929
+ }
930
+ this.step("after-file-fsync", phase, path, target, "after");
931
+ renameSync(temp, path);
932
+ temp = null;
933
+ this.step("after-rename", phase, path, target, "after");
934
+ const synced = this.fsyncDirectory(parent);
935
+ if (!synced.ok)
936
+ return synced;
937
+ this.step("after-directory-fsync", phase, path, target, "after");
938
+ return { ok: true, error: null };
939
+ }
940
+ catch (error) {
941
+ if (temp !== null) {
942
+ try {
943
+ unlinkSync(temp);
944
+ }
945
+ catch {
946
+ // The next write uses a nonce; an orphan temp cannot be a future target.
947
+ }
948
+ }
949
+ return { ok: false, error: safeError(error) };
950
+ }
951
+ }
952
+ removeJournal() {
953
+ const config = this.config;
954
+ if (!config)
955
+ return { ok: false, error: this.configError ?? "config" };
956
+ let removed = false;
957
+ try {
958
+ const safeParent = this.ensureSafeParent(config.journalPath);
959
+ if (safeParent.status !== "ok")
960
+ return { ok: false, error: safeParent.error ?? safeParent.status };
961
+ this.step("before-journal-remove", "journal", config.journalPath, null, "before");
962
+ try {
963
+ unlinkSync(config.journalPath);
964
+ removed = true;
965
+ }
966
+ catch (error) {
967
+ if (!hasCode(error, "ENOENT"))
968
+ throw error;
969
+ removed = true;
970
+ }
971
+ const synced = this.fsyncDirectory(dirname(config.journalPath));
972
+ if (!synced.ok)
973
+ return synced;
974
+ this.step("after-journal-remove", "journal", config.journalPath, null, "after");
975
+ return { ok: true, error: null };
976
+ }
977
+ catch (error) {
978
+ // Once unlink has happened, targets and journal have reached a committed state.
979
+ // Do not report a retryable failure that no longer has a journal to retry.
980
+ if (removed)
981
+ return { ok: true, error: null };
982
+ return { ok: false, error: safeError(error) };
983
+ }
984
+ }
985
+ quarantinePath(path) {
986
+ const config = this.config;
987
+ if (!config || !inside(config.rootDir, path))
988
+ return { status: "failed", path: null, error: "path" };
989
+ try {
990
+ // Validate the parent without following the final (possibly malicious) reparse point.
991
+ const safeParent = this.ensureSafeParent(path, false);
992
+ if (safeParent.status === "missing")
993
+ return { status: "missing", path: null, error: null };
994
+ if (safeParent.status !== "ok")
995
+ return { status: "failed", path: null, error: safeParent.error ?? safeParent.status };
996
+ try {
997
+ lstatSync(path);
998
+ }
999
+ catch (error) {
1000
+ if (hasCode(error, "ENOENT"))
1001
+ return { status: "missing", path: null, error: null };
1002
+ throw error;
1003
+ }
1004
+ const parent = dirname(path);
1005
+ for (let attempt = 0; attempt < 16; attempt += 1) {
1006
+ const candidate = join(parent, `${basename(path)}.corrupt-${this.now()}-${this.nonce()}`);
1007
+ if (!inside(config.rootDir, candidate))
1008
+ return { status: "failed", path: null, error: "quarantine-path" };
1009
+ try {
1010
+ lstatSync(candidate);
1011
+ continue;
1012
+ }
1013
+ catch (error) {
1014
+ if (!hasCode(error, "ENOENT"))
1015
+ throw error;
1016
+ }
1017
+ renameSync(path, candidate);
1018
+ const synced = this.fsyncDirectory(parent);
1019
+ if (!synced.ok) {
1020
+ const restored = this.restoreQuarantinePath(candidate, path);
1021
+ return { status: "failed", path: restored ? path : candidate, error: synced.error };
1022
+ }
1023
+ return { status: "quarantined", path: candidate, error: null };
1024
+ }
1025
+ return { status: "failed", path: null, error: "quarantine-collision" };
1026
+ }
1027
+ catch (error) {
1028
+ return { status: "failed", path: null, error: safeError(error) };
1029
+ }
1030
+ }
1031
+ restoreQuarantinePath(candidate, path) {
1032
+ try {
1033
+ const safeParent = this.ensureSafeParent(path, false);
1034
+ if (safeParent.status !== "ok")
1035
+ return false;
1036
+ try {
1037
+ lstatSync(path);
1038
+ return false;
1039
+ }
1040
+ catch (error) {
1041
+ if (!hasCode(error, "ENOENT"))
1042
+ return false;
1043
+ }
1044
+ renameSync(candidate, path);
1045
+ return this.fsyncDirectory(dirname(path)).ok;
1046
+ }
1047
+ catch {
1048
+ return false;
1049
+ }
1050
+ }
1051
+ fsyncDirectory(path) {
1052
+ try {
1053
+ this.config?.hooks.beforeDirectoryFsync?.(path);
1054
+ const descriptor = openSync(path, "r");
1055
+ try {
1056
+ fsyncSync(descriptor);
1057
+ }
1058
+ finally {
1059
+ closeSync(descriptor);
1060
+ }
1061
+ return { ok: true, error: null };
1062
+ }
1063
+ catch (error) {
1064
+ // Directory handles are unsupported by some Windows filesystems. Do not hide EIO,
1065
+ // EACCES, sharing violations, or any POSIX failure behind that compatibility case.
1066
+ const unsupportedWindows = process.platform === "win32" && (hasCode(error, "EPERM") || hasCode(error, "EINVAL") || hasCode(error, "ENOTSUP") || hasCode(error, "EISDIR"));
1067
+ if (unsupportedWindows)
1068
+ return { ok: true, error: null };
1069
+ return { ok: false, error: safeError(error) };
1070
+ }
1071
+ }
1072
+ now() {
1073
+ try {
1074
+ const value = this.config?.hooks.now?.();
1075
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : Date.now();
1076
+ }
1077
+ catch {
1078
+ return Date.now();
1079
+ }
1080
+ }
1081
+ nonce() {
1082
+ try {
1083
+ const candidate = this.config?.hooks.nonce?.() ?? randomBytes(16).toString("base64url");
1084
+ return typeof candidate === "string" && NONCE_PATTERN.test(candidate) ? candidate : randomBytes(16).toString("base64url");
1085
+ }
1086
+ catch {
1087
+ return "fallback";
1088
+ }
1089
+ }
1090
+ transactionId() {
1091
+ return this.nonce();
1092
+ }
1093
+ step(step, phase, path, target, when) {
1094
+ const context = { step, phase, path, target };
1095
+ if (when === "before")
1096
+ this.config?.hooks.beforeStep?.(context);
1097
+ else
1098
+ this.config?.hooks.afterStep?.(context);
1099
+ }
1100
+ }
1101
+ /** Construction is total: malformed runtime options turn later calls into explicit invalid results. */
1102
+ export function createSnapshotJournalIo(options) {
1103
+ return new SnapshotJournalIoImpl(options);
1104
+ }
1105
+ //# sourceMappingURL=accounting-store-io.js.map