@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.
- package/dist/backend.d.ts +77 -0
- package/dist/backend.d.ts.map +1 -0
- package/dist/backend.js +380 -0
- package/dist/backend.js.map +1 -0
- package/dist/bundle.d.ts +38 -0
- package/dist/bundle.d.ts.map +1 -0
- package/dist/bundle.js +107 -0
- package/dist/bundle.js.map +1 -0
- package/dist/content-type.d.ts +72 -0
- package/dist/content-type.d.ts.map +1 -0
- package/dist/content-type.js +149 -0
- package/dist/content-type.js.map +1 -0
- package/dist/document-mutation.d.ts +83 -0
- package/dist/document-mutation.d.ts.map +1 -0
- package/dist/document-mutation.js +319 -0
- package/dist/document-mutation.js.map +1 -0
- package/dist/document-precondition.d.ts +62 -0
- package/dist/document-precondition.d.ts.map +1 -0
- package/dist/document-precondition.js +134 -0
- package/dist/document-precondition.js.map +1 -0
- package/dist/document-write-policy.d.ts +42 -0
- package/dist/document-write-policy.d.ts.map +1 -0
- package/dist/document-write-policy.js +134 -0
- package/dist/document-write-policy.js.map +1 -0
- package/dist/engine.d.ts +48 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +209 -0
- package/dist/engine.js.map +1 -0
- package/dist/errors.d.ts +36 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +50 -0
- package/dist/errors.js.map +1 -0
- package/dist/filesystem-identity.d.ts +208 -0
- package/dist/filesystem-identity.d.ts.map +1 -0
- package/dist/filesystem-identity.js +675 -0
- package/dist/filesystem-identity.js.map +1 -0
- package/dist/filesystem-lock.d.ts +65 -0
- package/dist/filesystem-lock.d.ts.map +1 -0
- package/dist/filesystem-lock.js +460 -0
- package/dist/filesystem-lock.js.map +1 -0
- package/dist/freshness.d.ts +31 -0
- package/dist/freshness.d.ts.map +1 -0
- package/dist/freshness.js +98 -0
- package/dist/freshness.js.map +1 -0
- package/dist/frontmatter-contract.d.ts +17 -0
- package/dist/frontmatter-contract.d.ts.map +1 -0
- package/dist/frontmatter-contract.js +29 -0
- package/dist/frontmatter-contract.js.map +1 -0
- package/dist/frontmatter.d.ts +30 -0
- package/dist/frontmatter.d.ts.map +1 -0
- package/dist/frontmatter.js +115 -0
- package/dist/frontmatter.js.map +1 -0
- package/dist/index-marker.d.ts +3 -0
- package/dist/index-marker.d.ts.map +1 -0
- package/dist/index-marker.js +3 -0
- package/dist/index-marker.js.map +1 -0
- package/dist/index-projection.d.ts +79 -0
- package/dist/index-projection.d.ts.map +1 -0
- package/dist/index-projection.js +245 -0
- package/dist/index-projection.js.map +1 -0
- package/dist/index.d.ts +68 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +110 -0
- package/dist/index.js.map +1 -0
- package/dist/kinds-load.d.ts +13 -0
- package/dist/kinds-load.d.ts.map +1 -0
- package/dist/kinds-load.js +34 -0
- package/dist/kinds-load.js.map +1 -0
- package/dist/kinds.d.ts +241 -0
- package/dist/kinds.d.ts.map +1 -0
- package/dist/kinds.js +1001 -0
- package/dist/kinds.js.map +1 -0
- package/dist/links.d.ts +62 -0
- package/dist/links.d.ts.map +1 -0
- package/dist/links.js +203 -0
- package/dist/links.js.map +1 -0
- package/dist/meaningful-change-time.d.ts +11 -0
- package/dist/meaningful-change-time.d.ts.map +1 -0
- package/dist/meaningful-change-time.js +16 -0
- package/dist/meaningful-change-time.js.map +1 -0
- package/dist/memory-backend.d.ts +55 -0
- package/dist/memory-backend.d.ts.map +1 -0
- package/dist/memory-backend.js +214 -0
- package/dist/memory-backend.js.map +1 -0
- package/dist/mutation-attribution.d.ts +19 -0
- package/dist/mutation-attribution.d.ts.map +1 -0
- package/dist/mutation-attribution.js +34 -0
- package/dist/mutation-attribution.js.map +1 -0
- package/dist/mutation.d.ts +89 -0
- package/dist/mutation.d.ts.map +1 -0
- package/dist/mutation.js +94 -0
- package/dist/mutation.js.map +1 -0
- package/dist/page.d.ts +87 -0
- package/dist/page.d.ts.map +1 -0
- package/dist/page.js +151 -0
- package/dist/page.js.map +1 -0
- package/dist/paths.d.ts +78 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +148 -0
- package/dist/paths.js.map +1 -0
- package/dist/portable-frontmatter.d.ts +8 -0
- package/dist/portable-frontmatter.d.ts.map +1 -0
- package/dist/portable-frontmatter.js +35 -0
- package/dist/portable-frontmatter.js.map +1 -0
- package/dist/publication-filesystem.d.ts +6 -0
- package/dist/publication-filesystem.d.ts.map +1 -0
- package/dist/publication-filesystem.js +6 -0
- package/dist/publication-filesystem.js.map +1 -0
- package/dist/query-filter.d.ts +12 -0
- package/dist/query-filter.d.ts.map +1 -0
- package/dist/query-filter.js +29 -0
- package/dist/query-filter.js.map +1 -0
- package/dist/query-selection.d.ts +25 -0
- package/dist/query-selection.d.ts.map +1 -0
- package/dist/query-selection.js +48 -0
- package/dist/query-selection.js.map +1 -0
- package/dist/remote-backend.d.ts +152 -0
- package/dist/remote-backend.d.ts.map +1 -0
- package/dist/remote-backend.js +505 -0
- package/dist/remote-backend.js.map +1 -0
- package/dist/remote.d.ts +4 -0
- package/dist/remote.d.ts.map +1 -0
- package/dist/remote.js +3 -0
- package/dist/remote.js.map +1 -0
- package/dist/storage.d.ts +7 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +6 -0
- package/dist/storage.js.map +1 -0
- package/dist/types.d.ts +438 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +12 -0
- package/dist/types.js.map +1 -0
- package/dist/validation.d.ts +22 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +12 -0
- package/dist/validation.js.map +1 -0
- package/dist/version-transport.d.ts +13 -0
- package/dist/version-transport.d.ts.map +1 -0
- package/dist/version-transport.js +26 -0
- package/dist/version-transport.js.map +1 -0
- package/dist/versioning.d.ts +48 -0
- package/dist/versioning.d.ts.map +1 -0
- package/dist/versioning.js +62 -0
- package/dist/versioning.js.map +1 -0
- 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
|