@superbee/core 0.1.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 (145) hide show
  1. package/dist/backend.d.ts +77 -0
  2. package/dist/backend.d.ts.map +1 -0
  3. package/dist/backend.js +380 -0
  4. package/dist/backend.js.map +1 -0
  5. package/dist/bundle.d.ts +38 -0
  6. package/dist/bundle.d.ts.map +1 -0
  7. package/dist/bundle.js +107 -0
  8. package/dist/bundle.js.map +1 -0
  9. package/dist/content-type.d.ts +72 -0
  10. package/dist/content-type.d.ts.map +1 -0
  11. package/dist/content-type.js +149 -0
  12. package/dist/content-type.js.map +1 -0
  13. package/dist/document-mutation.d.ts +83 -0
  14. package/dist/document-mutation.d.ts.map +1 -0
  15. package/dist/document-mutation.js +319 -0
  16. package/dist/document-mutation.js.map +1 -0
  17. package/dist/document-precondition.d.ts +62 -0
  18. package/dist/document-precondition.d.ts.map +1 -0
  19. package/dist/document-precondition.js +134 -0
  20. package/dist/document-precondition.js.map +1 -0
  21. package/dist/document-write-policy.d.ts +42 -0
  22. package/dist/document-write-policy.d.ts.map +1 -0
  23. package/dist/document-write-policy.js +134 -0
  24. package/dist/document-write-policy.js.map +1 -0
  25. package/dist/engine.d.ts +48 -0
  26. package/dist/engine.d.ts.map +1 -0
  27. package/dist/engine.js +209 -0
  28. package/dist/engine.js.map +1 -0
  29. package/dist/errors.d.ts +36 -0
  30. package/dist/errors.d.ts.map +1 -0
  31. package/dist/errors.js +50 -0
  32. package/dist/errors.js.map +1 -0
  33. package/dist/filesystem-identity.d.ts +208 -0
  34. package/dist/filesystem-identity.d.ts.map +1 -0
  35. package/dist/filesystem-identity.js +675 -0
  36. package/dist/filesystem-identity.js.map +1 -0
  37. package/dist/filesystem-lock.d.ts +65 -0
  38. package/dist/filesystem-lock.d.ts.map +1 -0
  39. package/dist/filesystem-lock.js +460 -0
  40. package/dist/filesystem-lock.js.map +1 -0
  41. package/dist/freshness.d.ts +31 -0
  42. package/dist/freshness.d.ts.map +1 -0
  43. package/dist/freshness.js +98 -0
  44. package/dist/freshness.js.map +1 -0
  45. package/dist/frontmatter-contract.d.ts +17 -0
  46. package/dist/frontmatter-contract.d.ts.map +1 -0
  47. package/dist/frontmatter-contract.js +29 -0
  48. package/dist/frontmatter-contract.js.map +1 -0
  49. package/dist/frontmatter.d.ts +30 -0
  50. package/dist/frontmatter.d.ts.map +1 -0
  51. package/dist/frontmatter.js +115 -0
  52. package/dist/frontmatter.js.map +1 -0
  53. package/dist/index-marker.d.ts +3 -0
  54. package/dist/index-marker.d.ts.map +1 -0
  55. package/dist/index-marker.js +3 -0
  56. package/dist/index-marker.js.map +1 -0
  57. package/dist/index-projection.d.ts +79 -0
  58. package/dist/index-projection.d.ts.map +1 -0
  59. package/dist/index-projection.js +245 -0
  60. package/dist/index-projection.js.map +1 -0
  61. package/dist/index.d.ts +68 -0
  62. package/dist/index.d.ts.map +1 -0
  63. package/dist/index.js +110 -0
  64. package/dist/index.js.map +1 -0
  65. package/dist/kinds-load.d.ts +13 -0
  66. package/dist/kinds-load.d.ts.map +1 -0
  67. package/dist/kinds-load.js +34 -0
  68. package/dist/kinds-load.js.map +1 -0
  69. package/dist/kinds.d.ts +241 -0
  70. package/dist/kinds.d.ts.map +1 -0
  71. package/dist/kinds.js +1001 -0
  72. package/dist/kinds.js.map +1 -0
  73. package/dist/links.d.ts +62 -0
  74. package/dist/links.d.ts.map +1 -0
  75. package/dist/links.js +203 -0
  76. package/dist/links.js.map +1 -0
  77. package/dist/meaningful-change-time.d.ts +11 -0
  78. package/dist/meaningful-change-time.d.ts.map +1 -0
  79. package/dist/meaningful-change-time.js +16 -0
  80. package/dist/meaningful-change-time.js.map +1 -0
  81. package/dist/memory-backend.d.ts +55 -0
  82. package/dist/memory-backend.d.ts.map +1 -0
  83. package/dist/memory-backend.js +214 -0
  84. package/dist/memory-backend.js.map +1 -0
  85. package/dist/mutation-attribution.d.ts +19 -0
  86. package/dist/mutation-attribution.d.ts.map +1 -0
  87. package/dist/mutation-attribution.js +34 -0
  88. package/dist/mutation-attribution.js.map +1 -0
  89. package/dist/mutation.d.ts +89 -0
  90. package/dist/mutation.d.ts.map +1 -0
  91. package/dist/mutation.js +94 -0
  92. package/dist/mutation.js.map +1 -0
  93. package/dist/page.d.ts +87 -0
  94. package/dist/page.d.ts.map +1 -0
  95. package/dist/page.js +151 -0
  96. package/dist/page.js.map +1 -0
  97. package/dist/paths.d.ts +78 -0
  98. package/dist/paths.d.ts.map +1 -0
  99. package/dist/paths.js +148 -0
  100. package/dist/paths.js.map +1 -0
  101. package/dist/portable-frontmatter.d.ts +8 -0
  102. package/dist/portable-frontmatter.d.ts.map +1 -0
  103. package/dist/portable-frontmatter.js +35 -0
  104. package/dist/portable-frontmatter.js.map +1 -0
  105. package/dist/publication-filesystem.d.ts +6 -0
  106. package/dist/publication-filesystem.d.ts.map +1 -0
  107. package/dist/publication-filesystem.js +6 -0
  108. package/dist/publication-filesystem.js.map +1 -0
  109. package/dist/query-filter.d.ts +12 -0
  110. package/dist/query-filter.d.ts.map +1 -0
  111. package/dist/query-filter.js +29 -0
  112. package/dist/query-filter.js.map +1 -0
  113. package/dist/query-selection.d.ts +25 -0
  114. package/dist/query-selection.d.ts.map +1 -0
  115. package/dist/query-selection.js +48 -0
  116. package/dist/query-selection.js.map +1 -0
  117. package/dist/remote-backend.d.ts +152 -0
  118. package/dist/remote-backend.d.ts.map +1 -0
  119. package/dist/remote-backend.js +505 -0
  120. package/dist/remote-backend.js.map +1 -0
  121. package/dist/remote.d.ts +4 -0
  122. package/dist/remote.d.ts.map +1 -0
  123. package/dist/remote.js +3 -0
  124. package/dist/remote.js.map +1 -0
  125. package/dist/storage.d.ts +7 -0
  126. package/dist/storage.d.ts.map +1 -0
  127. package/dist/storage.js +6 -0
  128. package/dist/storage.js.map +1 -0
  129. package/dist/types.d.ts +438 -0
  130. package/dist/types.d.ts.map +1 -0
  131. package/dist/types.js +12 -0
  132. package/dist/types.js.map +1 -0
  133. package/dist/validation.d.ts +22 -0
  134. package/dist/validation.d.ts.map +1 -0
  135. package/dist/validation.js +12 -0
  136. package/dist/validation.js.map +1 -0
  137. package/dist/version-transport.d.ts +13 -0
  138. package/dist/version-transport.d.ts.map +1 -0
  139. package/dist/version-transport.js +26 -0
  140. package/dist/version-transport.js.map +1 -0
  141. package/dist/versioning.d.ts +48 -0
  142. package/dist/versioning.d.ts.map +1 -0
  143. package/dist/versioning.js +62 -0
  144. package/dist/versioning.js.map +1 -0
  145. package/package.json +84 -0
@@ -0,0 +1,675 @@
1
+ /**
2
+ * Exact filesystem identity for the filesystem adapter.
3
+ *
4
+ * A bundle-relative path `rel` names ONE physical entry only when every segment's on-disk
5
+ * spelling equals the requested spelling. On a case- or normalization-insensitive filesystem
6
+ * (case-insensitive APFS, NTFS, ext4 casefold, normalization-insensitive APFS) two distinct
7
+ * canonical ids would otherwise reach one file, so reads, compare-and-swap, and deletes would
8
+ * alias. This module is the single owner of that decision:
9
+ *
10
+ * - Observations record every `rel` segment's inode, verify each segment's spelling by listing
11
+ * before and after use, confirm any would-be alias by probing the requested spelling against
12
+ * the recorded inode, and hold the read handle open through the post-walk so the bytes stay
13
+ * bound to a live inode. They hold no lock and write nothing, so an absent bundle root stays
14
+ * absent. A symbolic link observed by any probe is refused; a link swapped in and out
15
+ * entirely between two probes of one observation can be traversed by the operating system
16
+ * during a listing or open, but its bytes can never be returned as EXACT, because the leaf
17
+ * witness at the post-walk must match the entry the requested path reaches.
18
+ * - Mutations hold one cross-process lock keyed by a pure fold of the root and `rel`, decide
19
+ * inside it, create directories one segment at a time with a spelling check, and publish a
20
+ * first-created leaf with `link` so the host's own equivalence refuses a second spelling the
21
+ * fold did not equate; a first-creation race cannot succeed with the wrong spelling and cannot
22
+ * silently replace the winner.
23
+ *
24
+ * An observation's linearization point is the post-walk that validated the bytes: what it returns
25
+ * is a state of THIS identity at that instant, never a foreign one and never bytes straddling two
26
+ * states. A read concurrent with a legitimate same-spelling replacement may therefore return the
27
+ * old value or the new one, both real; a caller that needs one state across several reads uses
28
+ * versions and compare-and-swap, as mutations already do. Sustained interference is bounded
29
+ * rather than resolved: after MAX_RESTARTS the observation refuses with
30
+ * {@link ConcurrentReplacementError} instead of attributing bytes to a state it cannot name.
31
+ * This rests on published bytes being immutable per inode — every write publishes a complete temp
32
+ * file with `link` or `rename` and never edits a live one — since the bytes are read BEFORE the
33
+ * post-walk. A non-core writer editing a published file in place is the case it excludes.
34
+ *
35
+ * Exactness applies to `rel` segments only. The bundle root is the declared storage context:
36
+ * a root (tail) segment that already exists as a directory under any spelling is accepted,
37
+ * and the root's spelling is folded into the lock key rather than verified.
38
+ *
39
+ * Every filesystem call goes through a {@link FilesystemIdentityPort} passed to the protocol
40
+ * functions. Production binds {@link nodeFilesystemIdentityPort} once, as a module constant;
41
+ * nothing on a backend instance can substitute it. Normalizing stores that rewrite names on
42
+ * write (legacy HFS+) are unsupported: an id whose NFD form differs from its own form is written
43
+ * and then refused as an alias on every later observation, never silently aliased.
44
+ */
45
+ import { promises as fs } from "node:fs";
46
+ import path from "node:path";
47
+ import { createHash, randomUUID } from "node:crypto";
48
+ import { ConcurrentReplacementError, FilesystemIdentityAliasError, InvalidInputError } from "./errors.js";
49
+ import { acquireFilesystemIdentityLock } from "./filesystem-lock.js";
50
+ const WINDOWS_REPLACE_CONFLICT_CODES = new Set(["EACCES", "EBUSY", "EPERM"]);
51
+ /**
52
+ * Windows can transiently deny replacement while another process is closing a read handle or a
53
+ * scanner holds the destination. An in-place rename retry cannot remain bound to the complete
54
+ * parent-and-leaf observation: either can change after any final check. Abort this attempt instead,
55
+ * mapping only the errno values Node uses for a Windows replacement sharing violation to the typed
56
+ * contention signal owned by the higher-level fresh read/decide/CAS loop. Every other platform and
57
+ * errno remains the original immediate failure.
58
+ */
59
+ async function replaceExistingLeaf(port, tmp, target, rel) {
60
+ try {
61
+ await port.rename(tmp, target);
62
+ }
63
+ catch (err) {
64
+ const code = err.code ?? "";
65
+ if ((port.platform ?? process.platform) === "win32" && WINDOWS_REPLACE_CONFLICT_CODES.has(code)) {
66
+ throw new ConcurrentReplacementError(rel, 1, 10);
67
+ }
68
+ throw err;
69
+ }
70
+ }
71
+ /**
72
+ * A symbolic link at a `rel` segment. The walk never follows links: a link's own inode is not the
73
+ * inode of what opening it would reach, so no exactness verdict is possible. Refused as caller
74
+ * input (the layout is unsupported) rather than reported as a runtime failure.
75
+ */
76
+ export class FilesystemSymlinkEntryError extends InvalidInputError {
77
+ rel;
78
+ segment;
79
+ constructor(rel, segment) {
80
+ super(`Path '${rel}' reaches a symbolic link at segment '${segment}'; symlinked entries inside a bundle are unsupported.`);
81
+ this.name = "FilesystemSymlinkEntryError";
82
+ this.rel = rel;
83
+ this.segment = segment;
84
+ }
85
+ }
86
+ /**
87
+ * A regular file where a directory is needed (or the reverse) at an exactly spelled segment.
88
+ * A runtime state of the bundle, not a caller-input error, so it is not an `InvalidInputError`.
89
+ */
90
+ export class FilesystemShapeMismatchError extends Error {
91
+ rel;
92
+ segment;
93
+ constructor(rel, segment) {
94
+ super(`Path '${rel}' needs a directory at segment '${segment}' but a regular file exists there.`);
95
+ this.name = "FilesystemShapeMismatchError";
96
+ this.rel = rel;
97
+ this.segment = segment;
98
+ }
99
+ }
100
+ const ABSENT_PATH_CODES = new Set(["ENOENT", "ENOTDIR"]);
101
+ function isAbsentPathError(err) {
102
+ return ABSENT_PATH_CODES.has(err?.code ?? "");
103
+ }
104
+ /**
105
+ * Split `rel` into its exact segments. Ids and keys are validated upstream by `paths.ts`; this
106
+ * repeats the containment guard so no caller can escape the bundle root even if it skipped
107
+ * that guard. Lone `.` segments are dropped, exactly as `path.resolve` used to collapse them.
108
+ */
109
+ function relSegments(rel) {
110
+ if (typeof rel !== "string" || rel.startsWith("/")) {
111
+ throw new InvalidInputError(`Path '${String(rel)}' resolves outside the bundle root.`);
112
+ }
113
+ const segments = rel.split("/").filter((segment) => segment !== "" && segment !== ".");
114
+ if (segments.length === 0 || segments.some((segment) => segment === "..")) {
115
+ throw new InvalidInputError(`Path '${rel}' resolves outside the bundle root.`);
116
+ }
117
+ return segments;
118
+ }
119
+ // ── identity key ──────────────────────────────────────────────────────────────
120
+ /**
121
+ * NFKD, then Unicode FULL case folding applied as lower-upper-lower (locale-independent). A plain
122
+ * lower-case fold misses the pairs a case-insensitive host equates through full case mapping
123
+ * (ß/ss, ẞ/SS, final sigma ς/σ, iota-subscript ᾈ/ἀι and its family): two such spellings would get
124
+ * different keys, both first-creation writers would realize ABSENT, and the second rename would
125
+ * silently replace the first document. The round trip through upper case expands every such
126
+ * pair to a common form, so the fold is a superset of every supported host's equivalence;
127
+ * over-folding (dotless ı with i) only adds serialization, never removes exclusion.
128
+ */
129
+ export function foldSegment(segment) {
130
+ return segment.normalize("NFKD").toLowerCase().toUpperCase().toLowerCase();
131
+ }
132
+ /** The physical path of the longest existing ancestor of `resolved`, plus the absent remainder. */
133
+ async function existingAnchor(resolved) {
134
+ const tail = [];
135
+ let candidate = resolved;
136
+ for (;;) {
137
+ try {
138
+ return { anchor: await fs.realpath(candidate), tail };
139
+ }
140
+ catch (err) {
141
+ if (!isAbsentPathError(err))
142
+ throw err;
143
+ }
144
+ const parent = path.dirname(candidate);
145
+ if (parent === candidate)
146
+ return { anchor: candidate, tail };
147
+ tail.unshift(path.basename(candidate));
148
+ candidate = parent;
149
+ }
150
+ }
151
+ /**
152
+ * The lock key for one logical identity `(root, rel)`: a digest of the fully folded physical
153
+ * root path and the folded `rel`. The WHOLE existing ancestor chain is resolved and folded, so
154
+ * the key is identical whether it is computed before or after any part of the root is created,
155
+ * and identical for every case or normalization spelling of any segment. It depends on nothing
156
+ * else: not the environment, the clock, the process, or the host name.
157
+ */
158
+ export async function identityKey(root, rel) {
159
+ const foldedRel = relSegments(rel).map(foldSegment).join("/");
160
+ const { anchor, tail } = await existingAnchor(path.resolve(root));
161
+ const anchorSegments = anchor.split(path.sep).filter((segment, index) => index === 0 || segment !== "");
162
+ const foldedRoot = [...anchorSegments, ...tail].map(foldSegment).join("/");
163
+ return createHash("sha256").update(`superbee-identity/v1\0${foldedRoot}\0${foldedRel}`).digest("hex");
164
+ }
165
+ // ── pure decisions ────────────────────────────────────────────────────────────
166
+ function sameWitness(a, b) {
167
+ return a.dev === b.dev && a.ino === b.ino;
168
+ }
169
+ /**
170
+ * The confirmed-witness rule for one `rel` segment: a fresh probe of the requested spelling is
171
+ * compared with the inode recorded for that segment earlier in the same observation, whether or
172
+ * not the exact spelling is present in the parent listing. A listing on its own is never a
173
+ * verdict: an entry can be REPLACED under its own spelling (a non-core actor swapping a whole
174
+ * directory) without the listing changing at all, and that must restart the observation rather
175
+ * than let records from two different states pass as one EXACT read (I6). ALIASED requires the
176
+ * probe to reach the recorded inode while the exact spelling is absent; a different inode is
177
+ * ambiguous (replaced under either spelling, or deleted and recreated) and restarts; a
178
+ * same-spelling replacement is NOT an alias.
179
+ */
180
+ export function confirmAlias(listingHasExact, probe, recorded) {
181
+ if (probe === null)
182
+ return "absent";
183
+ if (!sameWitness(probe, recorded))
184
+ return "restart";
185
+ return listingHasExact ? "continue" : "aliased";
186
+ }
187
+ /**
188
+ * Post-use verdict for the leaf, over the six cells of (listed, probe): the bytes came from the
189
+ * handle's inode, which must still be what the requested spelling reaches, under its own name.
190
+ */
191
+ export function classifyLeaf(snapshot) {
192
+ if (snapshot.probe === null)
193
+ return "absent";
194
+ if (!sameWitness(snapshot.probe, snapshot.handle))
195
+ return "replaced";
196
+ return snapshot.listed ? "exact" : "aliased";
197
+ }
198
+ /**
199
+ * Verdict for one non-recursive `mkdir` outcome against the parent listing. A `rel` segment
200
+ * that already exists is exact only under the requested spelling and only as a directory; a
201
+ * tail (root) segment that already exists as a directory is accepted under any spelling.
202
+ */
203
+ export function classifyMkdir(outcome, listing, segment, isTail) {
204
+ if (outcome === "created")
205
+ return "created";
206
+ const exact = listing.find((entry) => entry.name === segment);
207
+ if (exact === undefined)
208
+ return isTail ? "exact" : "aliased";
209
+ return exact.kind === "directory" ? "exact" : "shape-mismatch";
210
+ }
211
+ // ── walks ─────────────────────────────────────────────────────────────────────
212
+ /** `probe` that refuses a symbolic link at any `rel` segment (the walk never follows links). */
213
+ async function probeSegment(port, candidate, rel, segment) {
214
+ const probed = await port.probe(candidate);
215
+ if (probed !== null && probed.kind === "symlink")
216
+ throw new FilesystemSymlinkEntryError(rel, segment);
217
+ return probed;
218
+ }
219
+ function hasExact(listing, segment) {
220
+ return listing.some((entry) => entry.name === segment);
221
+ }
222
+ /**
223
+ * M-REALIZE: per segment, root to leaf, it must exist and the parent listing must carry its exact
224
+ * spelling. A listing-only alias decision is sound here because the identity lock is held: no
225
+ * same-key operation can remove the leaf concurrently, and core never removes directories.
226
+ */
227
+ async function walkExact(port, rootResolved, segments, rel) {
228
+ let parent = rootResolved;
229
+ for (let index = 0; index < segments.length; index++) {
230
+ const segment = segments[index];
231
+ const candidate = path.join(parent, segment);
232
+ const probed = await probeSegment(port, candidate, rel, segment);
233
+ if (probed === null)
234
+ return { state: "absent", depth: index };
235
+ const listing = await port.entries(parent);
236
+ if (listing === null)
237
+ return { state: "absent", depth: index };
238
+ if (!hasExact(listing, segment))
239
+ throw new FilesystemIdentityAliasError(rel, segment);
240
+ if (index === segments.length - 1)
241
+ return { state: "exact", leaf: probed };
242
+ if (probed.kind !== "directory")
243
+ return { state: "shape-mismatch", segment };
244
+ parent = candidate;
245
+ }
246
+ throw new InvalidInputError(`Path '${rel}' resolves outside the bundle root.`);
247
+ }
248
+ // ── observation ───────────────────────────────────────────────────────────────
249
+ const MAX_RESTARTS = 3;
250
+ /** O-PROBE: walk `rel` root to leaf recording each segment's witness; `null` when any segment is absent. */
251
+ async function recordWalk(port, rootResolved, segments, rel) {
252
+ const recorded = [];
253
+ let parent = rootResolved;
254
+ for (let index = 0; index < segments.length; index++) {
255
+ const segment = segments[index];
256
+ const candidate = path.join(parent, segment);
257
+ const probed = await probeSegment(port, candidate, rel, segment);
258
+ if (probed === null)
259
+ return null;
260
+ if (index < segments.length - 1 && probed.kind !== "directory")
261
+ return null;
262
+ recorded.push(probed);
263
+ parent = candidate;
264
+ }
265
+ return recorded;
266
+ }
267
+ /**
268
+ * One listing walk under the confirmed-witness rule. Every DIRECTORY segment is probed and
269
+ * compared with its recorded inode even when its exact spelling is listed, so a directory
270
+ * replaced under the same spelling between walks restarts the observation instead of letting the
271
+ * replacement's contents pass as EXACT (I6: under non-core directory renames the protocol
272
+ * restarts or refuses; A11 narrows only the case where a rename away and back restores the SAME
273
+ * inode between two probes of one walk, which no witness can distinguish from no change). The
274
+ * LEAF of a handle-bearing pre-walk is deliberately exempt when its spelling is listed: a
275
+ * same-spelling document replacement is a legitimate concurrent update, and the open handle plus
276
+ * the post-walk's six-cell leaf table bind the returned bytes to one entry. O-POSTVERIFY
277
+ * (`handle` given) applies that leaf table against the handle's inode, probing the leaf
278
+ * unconditionally.
279
+ */
280
+ async function confirmedWalk(port, rootResolved, segments, rel, recorded, handle) {
281
+ let parent = rootResolved;
282
+ for (let index = 0; index < segments.length; index++) {
283
+ const segment = segments[index];
284
+ const candidate = path.join(parent, segment);
285
+ const listing = await port.entries(parent);
286
+ if (listing === null)
287
+ return "absent";
288
+ const listed = hasExact(listing, segment);
289
+ if (handle !== null && index === segments.length - 1) {
290
+ const verdict = classifyLeaf({ listed, probe: await probeSegment(port, candidate, rel, segment), handle });
291
+ if (verdict === "aliased")
292
+ throw new FilesystemIdentityAliasError(rel, segment);
293
+ return verdict === "replaced" ? "restart" : verdict;
294
+ }
295
+ if (index < segments.length - 1 || !listed) {
296
+ const verdict = confirmAlias(listed, await probeSegment(port, candidate, rel, segment), recorded[index]);
297
+ if (verdict === "aliased")
298
+ throw new FilesystemIdentityAliasError(rel, segment);
299
+ if (verdict !== "continue")
300
+ return verdict;
301
+ }
302
+ parent = candidate;
303
+ }
304
+ return "continue";
305
+ }
306
+ function countRestart(restarts, rel) {
307
+ if (restarts >= MAX_RESTARTS)
308
+ throw new ConcurrentReplacementError(rel, restarts + 1);
309
+ return restarts + 1;
310
+ }
311
+ /**
312
+ * Observe `(root, rel)` without a lock and without writing anything, reading through one open
313
+ * handle. Every segment is probed and recorded (O-PROBE), verified by listing with inode
314
+ * confirmation (O-VERIFY), opened and read, then re-verified with fresh listings while the handle
315
+ * is still open so its inode cannot be recycled (O-POSTVERIFY). Every ambiguous outcome restarts
316
+ * from O-PROBE, bounded; the handle is closed before any restart opens another.
317
+ */
318
+ export async function observeExact(port, root, rel, read) {
319
+ const segments = relSegments(rel);
320
+ const rootResolved = path.resolve(root);
321
+ const target = path.join(rootResolved, ...segments);
322
+ for (let restarts = 0;;) {
323
+ const recorded = await recordWalk(port, rootResolved, segments, rel);
324
+ if (recorded === null)
325
+ return { state: "absent" };
326
+ const verified = await confirmedWalk(port, rootResolved, segments, rel, recorded, null);
327
+ if (verified === "absent")
328
+ return { state: "absent" };
329
+ if (verified === "restart") {
330
+ restarts = countRestart(restarts, rel);
331
+ continue;
332
+ }
333
+ let opened;
334
+ try {
335
+ opened = await port.open(target);
336
+ }
337
+ catch (err) {
338
+ if (isAbsentPathError(err))
339
+ return { state: "absent" };
340
+ // Win32 can report EPERM when a concurrent delete/replace briefly denies an open. That is
341
+ // an uncertain generation, not a durable permission verdict; re-run the witnessed walk and
342
+ // let the existing bound turn persistent contention into ConcurrentReplacementError.
343
+ if (process.platform === "win32" && err.code === "EPERM") {
344
+ restarts = countRestart(restarts, rel);
345
+ continue;
346
+ }
347
+ throw err;
348
+ }
349
+ let value;
350
+ let verdict;
351
+ try {
352
+ value = await read(opened.handle, target);
353
+ if (value === null)
354
+ return { state: "absent" };
355
+ verdict = await confirmedWalk(port, rootResolved, segments, rel, recorded, opened);
356
+ }
357
+ finally {
358
+ await port.close(opened.handle);
359
+ }
360
+ if (verdict === "exact")
361
+ return { state: "exact", value };
362
+ if (verdict === "absent")
363
+ return { state: "absent" };
364
+ restarts = countRestart(restarts, rel);
365
+ }
366
+ }
367
+ /**
368
+ * Handle-less observation of presence: the same walks, with the recorded leaf inode standing in
369
+ * for an open handle. Yields the leaf's kind and witness as verified by the post-walk.
370
+ */
371
+ export async function probeExact(port, root, rel) {
372
+ const segments = relSegments(rel);
373
+ const rootResolved = path.resolve(root);
374
+ for (let restarts = 0;;) {
375
+ const recorded = await recordWalk(port, rootResolved, segments, rel);
376
+ if (recorded === null)
377
+ return { state: "absent" };
378
+ const leaf = recorded[recorded.length - 1];
379
+ const verified = await confirmedWalk(port, rootResolved, segments, rel, recorded, null);
380
+ const verdict = verified === "continue" ? await confirmedWalk(port, rootResolved, segments, rel, recorded, leaf) : verified;
381
+ if (verdict === "exact")
382
+ return { state: "exact", value: leaf };
383
+ if (verdict === "absent")
384
+ return { state: "absent" };
385
+ restarts = countRestart(restarts, rel);
386
+ }
387
+ }
388
+ // ── mutation ──────────────────────────────────────────────────────────────────
389
+ /**
390
+ * Roots (resolved) whose filesystem refused a hard link. Learned lazily on the first creation
391
+ * that hit the unsupported error class, never by a probe write, and remembered per root so later
392
+ * first creations there go straight to `rename`. On such filesystems (exFAT, FAT32, some FUSE
393
+ * mounts) the host's own EEXIST cannot arbitrate a fold gap, so the residual "two concurrent
394
+ * first-creation writers of a host-equated pair the fold misses are not excluded" applies there
395
+ * and only there.
396
+ */
397
+ const linkUnsupportedRoots = new Set();
398
+ /**
399
+ * Process-local queue per identity key, shared by every `FilesystemBackend` instance: the
400
+ * engine constructs a fresh adapter per bundle operation, so an instance-level map would
401
+ * serialize nothing. The entry is dropped once it drains and no newer waiter replaced it.
402
+ */
403
+ const queues = new Map();
404
+ function enqueue(key, fn) {
405
+ const tail = queues.get(key) ?? Promise.resolve();
406
+ const run = tail.then(fn, fn);
407
+ const settled = run.then(() => undefined, () => undefined);
408
+ queues.set(key, settled);
409
+ void settled.then(() => {
410
+ if (queues.get(key) === settled)
411
+ queues.delete(key);
412
+ });
413
+ return run;
414
+ }
415
+ const ABSENT_FILE_CODES = new Set(["ENOENT", "ENOTDIR", "EISDIR"]);
416
+ function absentDirectoryError(dir) {
417
+ const err = new Error(`ENOENT: no such file or directory, scandir '${dir}'`);
418
+ err.code = "ENOENT";
419
+ err.syscall = "scandir";
420
+ err.path = dir;
421
+ return err;
422
+ }
423
+ /** Open, read every byte, close; `null` for the absent-file error class. */
424
+ async function readWhole(port, target) {
425
+ let opened;
426
+ try {
427
+ opened = await port.open(target);
428
+ }
429
+ catch (err) {
430
+ if (ABSENT_FILE_CODES.has(err?.code ?? ""))
431
+ return null;
432
+ throw err;
433
+ }
434
+ try {
435
+ return await port.readAll(opened.handle);
436
+ }
437
+ catch (err) {
438
+ if (ABSENT_FILE_CODES.has(err?.code ?? ""))
439
+ return null;
440
+ throw err;
441
+ }
442
+ finally {
443
+ await port.close(opened.handle);
444
+ }
445
+ }
446
+ async function mkdirExact(port, parent, segment, isTail, rel) {
447
+ const outcome = await port.mkdir(path.join(parent, segment));
448
+ if (outcome === "created")
449
+ return;
450
+ const listing = await port.entries(parent);
451
+ if (listing === null)
452
+ throw absentDirectoryError(parent);
453
+ if (listing.some((entry) => entry.name === segment && entry.kind === "symlink")) {
454
+ throw new FilesystemSymlinkEntryError(rel, segment);
455
+ }
456
+ const verdict = classifyMkdir(outcome, listing, segment, isTail);
457
+ if (verdict === "aliased")
458
+ throw new FilesystemIdentityAliasError(rel, segment);
459
+ if (verdict === "shape-mismatch")
460
+ throw new FilesystemShapeMismatchError(rel, segment);
461
+ }
462
+ /**
463
+ * Create every missing directory from the deepest existing one down to the leaf's parent, one
464
+ * non-recursive `mkdir` at a time. `mkdir` is atomic and core never renames directories, so a
465
+ * segment this process created keeps its spelling; an EEXIST on a `rel` segment is accepted
466
+ * only when the parent listing carries the exact spelling.
467
+ */
468
+ async function ensureExactDirectories(port, rootResolved, segments, existingDepth, rel) {
469
+ if (existingDepth === 0 && (await port.probe(rootResolved)) === null) {
470
+ const tail = [];
471
+ let candidate = rootResolved;
472
+ for (;;) {
473
+ const parent = path.dirname(candidate);
474
+ if (parent === candidate)
475
+ break;
476
+ tail.unshift(path.basename(candidate));
477
+ candidate = parent;
478
+ if ((await port.probe(candidate)) !== null)
479
+ break;
480
+ }
481
+ for (const segment of tail) {
482
+ await mkdirExact(port, candidate, segment, true, rel);
483
+ candidate = path.join(candidate, segment);
484
+ }
485
+ }
486
+ let parent = path.join(rootResolved, ...segments.slice(0, existingDepth));
487
+ for (const segment of segments.slice(existingDepth, -1)) {
488
+ await mkdirExact(port, parent, segment, false, rel);
489
+ parent = path.join(parent, segment);
490
+ }
491
+ }
492
+ /**
493
+ * First creation of a leaf that M-REALIZE found ABSENT: publish the complete temp file under the
494
+ * exact name with `link`, which is atomic and fails EEXIST for ANY entry the host equates with
495
+ * the name, whatever the identity fold thought. The identity lock prevents spurious refusals
496
+ * between writers of one identity; the host's EEXIST is what prevents a silent overwrite when
497
+ * two writers hold different keys for names the host equates. On EEXIST the parent listing
498
+ * decides: our exact spelling absent means an equated entry won (refused as an alias); present
499
+ * means a same-spelling entry appeared under our key, which core cannot do (fail closed).
500
+ * A successful link leaves the temp name as a second hard link to the same inode; removing it can
501
+ * fail without unmaking the write, so that failure is not reported (the leftover is the
502
+ * dot-prefixed crash-residue class listings already skip).
503
+ */
504
+ async function createExactLeaf(port, tmp, target, rel, leaf, rootResolved) {
505
+ const outcome = await port.link(tmp, target);
506
+ if (outcome === "linked") {
507
+ await port.unlink(tmp).catch(() => { });
508
+ return;
509
+ }
510
+ if (outcome === "unsupported") {
511
+ linkUnsupportedRoots.add(rootResolved);
512
+ await port.rename(tmp, target);
513
+ return;
514
+ }
515
+ const listing = (await port.entries(path.dirname(target))) ?? [];
516
+ if (hasExact(listing, leaf))
517
+ throw new ConcurrentReplacementError(rel, 1);
518
+ throw new FilesystemIdentityAliasError(rel, leaf);
519
+ }
520
+ /**
521
+ * Mutate `(root, rel)` inside its identity lock: claim the key, realize the exact state, and let
522
+ * `body` decide (compare-and-swap, delete-of-absent) before anything is written. An alias at
523
+ * any segment, a conflict, or an early return leaves the bundle untouched; the bundle root and
524
+ * missing directories are created only by `replace`. The lock is released on every path.
525
+ */
526
+ export async function mutateExact(port, root, rel, body) {
527
+ const segments = relSegments(rel);
528
+ const rootResolved = path.resolve(root);
529
+ const target = path.join(rootResolved, ...segments);
530
+ const leaf = segments[segments.length - 1];
531
+ const key = await identityKey(root, rel);
532
+ return enqueue(key, async () => {
533
+ const release = await port.claim(key, { root: rootResolved, rel });
534
+ try {
535
+ const realized = await walkExact(port, rootResolved, segments, rel);
536
+ if (realized.state === "shape-mismatch")
537
+ throw new FilesystemShapeMismatchError(rel, realized.segment);
538
+ const existingDepth = realized.state === "absent" ? realized.depth : segments.length - 1;
539
+ const context = {
540
+ state: realized.state,
541
+ async current() {
542
+ return realized.state === "exact" ? readWhole(port, target) : null;
543
+ },
544
+ async replace(bytes) {
545
+ await ensureExactDirectories(port, rootResolved, segments, existingDepth, rel);
546
+ const dir = path.dirname(target);
547
+ // Unique per call, not per process: one process can issue two writes to the same
548
+ // target within a millisecond, and the second's temp file must not clobber the first's.
549
+ const tmpName = `.${leaf}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
550
+ const tmp = path.join(dir, tmpName);
551
+ await port.writeTemp(dir, tmpName, bytes);
552
+ try {
553
+ if (realized.state === "exact") {
554
+ // Replace: the target exists under our exact spelling, so any writer of an
555
+ // equated spelling is refused at its own M-REALIZE; an atomic rename is safe.
556
+ await replaceExistingLeaf(port, tmp, target, rel);
557
+ return;
558
+ }
559
+ if (linkUnsupportedRoots.has(rootResolved)) {
560
+ await port.rename(tmp, target);
561
+ return;
562
+ }
563
+ await createExactLeaf(port, tmp, target, rel, leaf, rootResolved);
564
+ }
565
+ catch (err) {
566
+ await port.unlink(tmp).catch(() => { });
567
+ throw err;
568
+ }
569
+ },
570
+ async remove() {
571
+ await port.unlink(target);
572
+ },
573
+ };
574
+ return await body(context);
575
+ }
576
+ finally {
577
+ await release();
578
+ }
579
+ });
580
+ }
581
+ // ── production port ───────────────────────────────────────────────────────────
582
+ function kindOf(stats) {
583
+ if (stats.isSymbolicLink())
584
+ return "symlink";
585
+ if (stats.isDirectory())
586
+ return "directory";
587
+ if (stats.isFile())
588
+ return "file";
589
+ return "other";
590
+ }
591
+ /** The one production binding: `node:fs`, the temp-file convention, and the identity lock. */
592
+ export const nodeFilesystemIdentityPort = Object.freeze({
593
+ platform: process.platform,
594
+ async probe(target) {
595
+ let lstats;
596
+ try {
597
+ lstats = await fs.lstat(target);
598
+ }
599
+ catch (err) {
600
+ if (isAbsentPathError(err))
601
+ return null;
602
+ throw err;
603
+ }
604
+ return { kind: kindOf(lstats), dev: lstats.dev, ino: lstats.ino };
605
+ },
606
+ async entries(dir) {
607
+ try {
608
+ const dirents = await fs.readdir(dir, { withFileTypes: true });
609
+ return dirents.map((dirent) => ({ name: dirent.name, kind: kindOf(dirent) }));
610
+ }
611
+ catch (err) {
612
+ if (isAbsentPathError(err))
613
+ return null;
614
+ throw err;
615
+ }
616
+ },
617
+ async open(target) {
618
+ const handle = await fs.open(target, "r");
619
+ try {
620
+ const stats = await handle.stat();
621
+ return { handle, dev: stats.dev, ino: stats.ino };
622
+ }
623
+ catch (err) {
624
+ await handle.close().catch(() => { });
625
+ throw err;
626
+ }
627
+ },
628
+ readAll(handle) {
629
+ return handle.readFile();
630
+ },
631
+ close(handle) {
632
+ return handle.close();
633
+ },
634
+ async stat(target) {
635
+ return { mtime: (await fs.stat(target)).mtime };
636
+ },
637
+ async mkdir(dir) {
638
+ try {
639
+ await fs.mkdir(dir);
640
+ return "created";
641
+ }
642
+ catch (err) {
643
+ if (err.code === "EEXIST")
644
+ return "exists";
645
+ throw err;
646
+ }
647
+ },
648
+ async writeTemp(dir, name, bytes) {
649
+ await fs.writeFile(path.join(dir, name), bytes);
650
+ },
651
+ async link(from, to) {
652
+ try {
653
+ await fs.link(from, to);
654
+ return "linked";
655
+ }
656
+ catch (err) {
657
+ const code = err.code;
658
+ if (code === "EEXIST")
659
+ return "exists";
660
+ if (code === "EPERM" || code === "ENOTSUP" || code === "EOPNOTSUPP" || code === "EXDEV")
661
+ return "unsupported";
662
+ throw err;
663
+ }
664
+ },
665
+ async rename(from, to) {
666
+ await fs.rename(from, to);
667
+ },
668
+ async unlink(target) {
669
+ await fs.unlink(target);
670
+ },
671
+ claim(key, identity) {
672
+ return acquireFilesystemIdentityLock(key, `${identity.root}:${identity.rel}`, { portableRoot: identity.root });
673
+ },
674
+ });
675
+ //# sourceMappingURL=filesystem-identity.js.map