@untangle-code/storage-local 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +90 -0
  3. package/README.md +226 -2
  4. package/THIRD_PARTY_LICENSES.md +24 -0
  5. package/dist/abort.d.ts +18 -0
  6. package/dist/abort.d.ts.map +1 -0
  7. package/dist/abort.js +14 -0
  8. package/dist/abort.js.map +1 -0
  9. package/dist/analysis-cache.d.ts +81 -0
  10. package/dist/analysis-cache.d.ts.map +1 -0
  11. package/dist/analysis-cache.js +322 -0
  12. package/dist/analysis-cache.js.map +1 -0
  13. package/dist/checkpoint-store.d.ts +22 -0
  14. package/dist/checkpoint-store.d.ts.map +1 -0
  15. package/dist/checkpoint-store.js +123 -0
  16. package/dist/checkpoint-store.js.map +1 -0
  17. package/dist/embedding-cache.d.ts +38 -0
  18. package/dist/embedding-cache.d.ts.map +1 -0
  19. package/dist/embedding-cache.js +229 -0
  20. package/dist/embedding-cache.js.map +1 -0
  21. package/dist/fs-errors.d.ts +4 -0
  22. package/dist/fs-errors.d.ts.map +1 -0
  23. package/dist/fs-errors.js +14 -0
  24. package/dist/fs-errors.js.map +1 -0
  25. package/dist/index.d.ts +15 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +23 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/layout.d.ts +50 -0
  30. package/dist/layout.d.ts.map +1 -0
  31. package/dist/layout.js +99 -0
  32. package/dist/layout.js.map +1 -0
  33. package/dist/local-file-system.d.ts +37 -0
  34. package/dist/local-file-system.d.ts.map +1 -0
  35. package/dist/local-file-system.js +193 -0
  36. package/dist/local-file-system.js.map +1 -0
  37. package/dist/lock-provider.d.ts +59 -0
  38. package/dist/lock-provider.d.ts.map +1 -0
  39. package/dist/lock-provider.js +318 -0
  40. package/dist/lock-provider.js.map +1 -0
  41. package/dist/real-time.d.ts +16 -0
  42. package/dist/real-time.d.ts.map +1 -0
  43. package/dist/real-time.js +19 -0
  44. package/dist/real-time.js.map +1 -0
  45. package/dist/search-journey-store.d.ts +34 -0
  46. package/dist/search-journey-store.d.ts.map +1 -0
  47. package/dist/search-journey-store.js +530 -0
  48. package/dist/search-journey-store.js.map +1 -0
  49. package/dist/storage.d.ts +42 -0
  50. package/dist/storage.d.ts.map +1 -0
  51. package/dist/storage.js +34 -0
  52. package/dist/storage.js.map +1 -0
  53. package/package.json +60 -3
  54. package/src/abort.ts +26 -0
  55. package/src/analysis-cache.ts +404 -0
  56. package/src/checkpoint-store.ts +155 -0
  57. package/src/embedding-cache.ts +282 -0
  58. package/src/fs-errors.ts +19 -0
  59. package/src/index.ts +30 -0
  60. package/src/layout.ts +149 -0
  61. package/src/local-file-system.ts +232 -0
  62. package/src/lock-provider.ts +381 -0
  63. package/src/real-time.ts +18 -0
  64. package/src/search-journey-store.ts +638 -0
  65. package/src/storage.ts +98 -0
@@ -0,0 +1,232 @@
1
+ /**
2
+ * `FileSystemPort` adapter backed by `node:fs/promises`, scoped to one or
3
+ * more workspace/state roots (architecture.md §2.1, §23.1; docs/BUILD_PLAN.md
4
+ * WP-12 acceptance: "Pfad-Sandbox, Symlink-Prüfung").
5
+ *
6
+ * Every method resolves its path through `assertWithinWorkspace` (domain)
7
+ * BEFORE touching the filesystem — a lexical check that rejects `../` escapes
8
+ * regardless of whether the path exists yet (needed for `writeFile`/`mkdir`
9
+ * of not-yet-existing paths). Where the path (or its parent, for a
10
+ * not-yet-existing leaf) already exists on disk, the adapter additionally
11
+ * resolves the real, symlink-free path and re-asserts against it — the
12
+ * "adapters are responsible for the symlink-aware part of the sandbox"
13
+ * contract from `ports.ts`'s `FileSystemPort` doc comment. See README
14
+ * "Entscheidungen" for the precise guarantee and its documented limits.
15
+ */
16
+ import * as nodeFs from "node:fs/promises";
17
+ import * as nodePath from "node:path";
18
+
19
+ import type {
20
+ DirEntryV1,
21
+ FileStatV1,
22
+ FileSystemPort,
23
+ FileWriteOptions,
24
+ PortCallOptions,
25
+ } from "@untangle-code/domain";
26
+ import { assertWithinWorkspace, UntangleError } from "@untangle-code/domain";
27
+
28
+ import { assertNotAborted } from "./abort.js";
29
+ import { isNotFoundError } from "./fs-errors.js";
30
+
31
+ interface NodeStatsLike {
32
+ isDirectory(): boolean;
33
+ isFile(): boolean;
34
+ isSymbolicLink(): boolean;
35
+ size: number;
36
+ mtimeMs: number;
37
+ }
38
+
39
+ /**
40
+ * Resolves `candidate` as far as the real filesystem allows: walks up to the
41
+ * nearest EXISTING ancestor, `realpath`s that ancestor, then re-appends the
42
+ * not-yet-existing suffix segments lexically. This is what makes the
43
+ * symlink guard correct even for paths that do not exist yet (a `writeFile`
44
+ * target, a directory `mkdir` is about to create) AND for roots that
45
+ * themselves contain an unresolved symlink segment (e.g. macOS'
46
+ * `/var` → `/private/var`) — comparing an already-realpath-resolved
47
+ * candidate against a NOT-resolved root would otherwise falsely report
48
+ * every path under that root as an escape (the same class of bug
49
+ * `@untangle-code/scanner`'s README "Entscheidungen" #7 documents and fixes
50
+ * for its own, narrower realpath use). Returns `candidate` unchanged if NO
51
+ * ancestor exists at all (e.g. the whole tree is hypothetical).
52
+ */
53
+ async function resolveAsFarAsPossible(candidate: string): Promise<string> {
54
+ const pendingSuffix: string[] = [];
55
+ let probe = candidate;
56
+ for (;;) {
57
+ try {
58
+ const real = await nodeFs.realpath(probe);
59
+ return pendingSuffix.length === 0 ? real : nodePath.join(real, ...pendingSuffix.reverse());
60
+ } catch (error) {
61
+ if (!isNotFoundError(error)) {
62
+ throw error;
63
+ }
64
+ const parent = nodePath.dirname(probe);
65
+ if (parent === probe) {
66
+ // Reached the filesystem root without finding an existing ancestor.
67
+ return candidate;
68
+ }
69
+ pendingSuffix.push(nodePath.basename(probe));
70
+ probe = parent;
71
+ }
72
+ }
73
+ }
74
+
75
+ function kindOf(stats: NodeStatsLike): FileStatV1["kind"] {
76
+ if (stats.isSymbolicLink()) {
77
+ return "symlink";
78
+ }
79
+ if (stats.isDirectory()) {
80
+ return "directory";
81
+ }
82
+ if (stats.isFile()) {
83
+ return "file";
84
+ }
85
+ return "other";
86
+ }
87
+
88
+ function toFileStat(path: string, stats: NodeStatsLike): FileStatV1 {
89
+ return {
90
+ path,
91
+ sizeBytes: stats.size,
92
+ mtimeEpochMs: Math.trunc(stats.mtimeMs),
93
+ kind: kindOf(stats),
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Node-backed, path-sandboxed `FileSystemPort`. Construct one per set of
99
+ * roots that should be reachable (typically just the `.untangle` state
100
+ * directory for this package's own callers).
101
+ */
102
+ export class LocalFileSystem implements FileSystemPort {
103
+ private readonly roots: readonly string[];
104
+
105
+ public constructor(roots: readonly string[]) {
106
+ if (roots.length === 0) {
107
+ throw new UntangleError("PATH_OUTSIDE_WORKSPACE", "LocalFileSystem needs at least one root", {
108
+ operation: "local_file_system_init",
109
+ });
110
+ }
111
+ this.roots = roots;
112
+ }
113
+
114
+ /**
115
+ * Lexical sandbox check (throws `PATH_OUTSIDE_WORKSPACE` on escape), plus a
116
+ * real-path re-check via {@link resolveAsFarAsPossible} on BOTH the
117
+ * candidate and the configured roots — needed on both sides: the candidate
118
+ * may not exist yet (`writeFile`/`mkdir` of a new entry) and a root itself
119
+ * may contain an unresolved symlink segment (e.g. macOS'
120
+ * `/var` → `/private/var`) that would otherwise make every path under it
121
+ * look like an escape. Recomputed per call rather than cached, since which
122
+ * prefix of a root already exists changes over the adapter's lifetime
123
+ * (the root itself usually does not exist yet on first use).
124
+ */
125
+ private async guard(candidatePath: string): Promise<string> {
126
+ const lexical = assertWithinWorkspace(candidatePath, this.roots);
127
+ const resolvedCandidate = await resolveAsFarAsPossible(lexical);
128
+ const resolvedRoots = await Promise.all(this.roots.map(resolveAsFarAsPossible));
129
+ assertWithinWorkspace(resolvedCandidate, resolvedRoots);
130
+ return lexical;
131
+ }
132
+
133
+ public async stat(path: string, options?: PortCallOptions): Promise<FileStatV1 | null> {
134
+ assertNotAborted(options, "stat");
135
+ const resolved = await this.guard(path);
136
+ try {
137
+ const stats = await nodeFs.stat(resolved);
138
+ return toFileStat(path, stats);
139
+ } catch (error) {
140
+ if (isNotFoundError(error)) {
141
+ return null;
142
+ }
143
+ throw error;
144
+ }
145
+ }
146
+
147
+ public async lstat(path: string, options?: PortCallOptions): Promise<FileStatV1 | null> {
148
+ assertNotAborted(options, "lstat");
149
+ const resolved = await this.guard(path);
150
+ try {
151
+ const stats = await nodeFs.lstat(resolved);
152
+ return toFileStat(path, stats);
153
+ } catch (error) {
154
+ if (isNotFoundError(error)) {
155
+ return null;
156
+ }
157
+ throw error;
158
+ }
159
+ }
160
+
161
+ public async readFile(path: string, options?: PortCallOptions): Promise<Uint8Array> {
162
+ assertNotAborted(options, "read_file");
163
+ const resolved = await this.guard(path);
164
+ const buffer = await nodeFs.readFile(resolved, { signal: options?.signal });
165
+ return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
166
+ }
167
+
168
+ public async readTextFile(path: string, options?: PortCallOptions): Promise<string> {
169
+ assertNotAborted(options, "read_text_file");
170
+ const resolved = await this.guard(path);
171
+ return await nodeFs.readFile(resolved, { encoding: "utf8", signal: options?.signal });
172
+ }
173
+
174
+ public async readDir(path: string, options?: PortCallOptions): Promise<DirEntryV1[]> {
175
+ assertNotAborted(options, "read_dir");
176
+ const resolved = await this.guard(path);
177
+ const entries = await nodeFs.readdir(resolved, { withFileTypes: true });
178
+ return entries.map((entry) => {
179
+ const kind: DirEntryV1["kind"] = entry.isSymbolicLink()
180
+ ? "symlink"
181
+ : entry.isDirectory()
182
+ ? "directory"
183
+ : entry.isFile()
184
+ ? "file"
185
+ : "other";
186
+ return { name: entry.name, path: nodePath.join(path, entry.name), kind };
187
+ });
188
+ }
189
+
190
+ public async realPath(path: string, options?: PortCallOptions): Promise<string> {
191
+ assertNotAborted(options, "real_path");
192
+ const lexical = assertWithinWorkspace(path, this.roots);
193
+ const real = await nodeFs.realpath(lexical);
194
+ const resolvedRoots = await Promise.all(this.roots.map(resolveAsFarAsPossible));
195
+ return assertWithinWorkspace(real, resolvedRoots);
196
+ }
197
+
198
+ public async writeFile(
199
+ path: string,
200
+ data: Uint8Array,
201
+ options?: FileWriteOptions,
202
+ ): Promise<void> {
203
+ assertNotAborted(options, "write_file");
204
+ const resolved = await this.guard(path);
205
+ await nodeFs.writeFile(resolved, data, { signal: options?.signal, mode: options?.mode });
206
+ }
207
+
208
+ public async mkdir(
209
+ path: string,
210
+ options?: PortCallOptions & { recursive?: boolean },
211
+ ): Promise<void> {
212
+ assertNotAborted(options, "mkdir");
213
+ const resolved = await this.guard(path);
214
+ await nodeFs.mkdir(resolved, { recursive: options?.recursive ?? false });
215
+ }
216
+
217
+ public async rename(from: string, to: string, options?: PortCallOptions): Promise<void> {
218
+ assertNotAborted(options, "rename");
219
+ const resolvedFrom = await this.guard(from);
220
+ const resolvedTo = await this.guard(to);
221
+ await nodeFs.rename(resolvedFrom, resolvedTo);
222
+ }
223
+
224
+ public async remove(
225
+ path: string,
226
+ options?: PortCallOptions & { recursive?: boolean },
227
+ ): Promise<void> {
228
+ assertNotAborted(options, "remove");
229
+ const resolved = await this.guard(path);
230
+ await nodeFs.rm(resolved, { recursive: options?.recursive ?? false, force: true });
231
+ }
232
+ }
@@ -0,0 +1,381 @@
1
+ /**
2
+ * File-based `LockProvider` (architecture.md §21.1–§21.2; docs/BUILD_PLAN.md
3
+ * WP-12 acceptance criteria).
4
+ *
5
+ * One lock = one directory named `sha256(key)` under the configured locks
6
+ * root, containing a single `record.json`. `FileSystemPort.mkdir` without
7
+ * `recursive` is the exclusivity primitive: Node's `mkdir` atomically fails
8
+ * with `EEXIST` when the directory already exists, so "did I just create the
9
+ * directory" is a genuine, OS-atomic exclusive-acquire test — no separate
10
+ * locking mechanism is layered on top of it.
11
+ *
12
+ * Stale-lease takeover (an expired holder's lock being stolen) is NOT fully
13
+ * race-free across processes with plain directory operations — see README
14
+ * "Entscheidungen" for the exact, documented race window and why it is an
15
+ * acceptable MVP tradeoff (architecture.md §21.2 explicitly scopes file
16
+ * locks to MVP; "Remote Team Mode" gets a real distributed lease). What IS
17
+ * fully race-free, because it needs no takeover step at all, is contention
18
+ * over a lock nobody holds yet — the common case, and the one this file's
19
+ * own "two providers race for the same lock" test exercises.
20
+ *
21
+ * `record.json` itself is written temp-file-then-rename (mirroring
22
+ * `checkpoint-store.ts`'s "every write is temp-file-then-rename" in this
23
+ * same package) — never by a direct `writeFile` onto the live path. A
24
+ * reader can therefore never observe a half-written record: the file on
25
+ * disk is always either the previous good record or the fully-formed new
26
+ * one, whether the write is happening on the initial `tryAcquire()` or on
27
+ * `renew()`. Before this, a reader racing a writer could see a torn record,
28
+ * fail to parse it, and — because "corrupt" was treated as immediately
29
+ * takeover-eligible — steal a lock that was still validly held (WP-80).
30
+ * See `DEFAULT_CORRUPT_GRACE_MS`'s doc comment and README "Entscheidungen"
31
+ * for how a *genuinely* corrupt record (external tampering, disk fault —
32
+ * atomic rename rules out "caught mid-write") is now handled instead.
33
+ */
34
+ import * as os from "node:os";
35
+ import * as nodePath from "node:path";
36
+
37
+ import type {
38
+ Clock,
39
+ FileSystemPort,
40
+ LockHandle,
41
+ LockProvider,
42
+ LockRecordV1,
43
+ PortCallOptions,
44
+ Random,
45
+ } from "@untangle-code/domain";
46
+ import { sha256Hex, UntangleError } from "@untangle-code/domain";
47
+
48
+ import { assertNotAborted } from "./abort.js";
49
+ import { isAlreadyExistsError, isNotFoundError } from "./fs-errors.js";
50
+ import { realNowEpochMs } from "./real-time.js";
51
+
52
+ const DEFAULT_LEASE_MS = 30_000;
53
+ const POLL_INTERVAL_MS = 20;
54
+ const RECORD_FILE_NAME = "record.json";
55
+ /** Bounds the stale-takeover retry loop so pathological contention returns
56
+ * "held" rather than spinning forever. */
57
+ const MAX_TAKEOVER_ATTEMPTS = 16;
58
+ /**
59
+ * How long a `record.json` that fails to parse must sit untouched (by
60
+ * mtime) before this provider will treat it as takeover-eligible.
61
+ *
62
+ * Every `record.json` write is now temp-file-then-rename (see the file doc
63
+ * comment), so a reader can no longer see a half-written record — the file
64
+ * on disk is always either the previous good record or the new one, never
65
+ * a torn mix of both. That removes the one legitimate reason the old code
66
+ * had for treating "corrupt" as an automatic, immediate takeover signal.
67
+ * What remains, once "caught mid-write" is off the table, is a genuinely
68
+ * unparsable file: a manual edit, a disk fault, a record written by some
69
+ * future/incompatible format. That is an error condition, not a routine
70
+ * lifecycle state — and unlike an expired lease (which carries its own
71
+ * trustworthy `leaseExpiresAt`), a corrupt record carries no information at
72
+ * all about how long ago it stopped being valid. Immediately stealing it
73
+ * would repeat exactly the WP-80 mistake (treating "I can't explain this" as
74
+ * "safe to take"), just with a different root cause.
75
+ *
76
+ * The chosen policy (documented in README "Entscheidungen"): require a
77
+ * conservative grace period — much longer than any expected lease or write
78
+ * latency — to pass since the file's own last-modified time before a corrupt
79
+ * record becomes takeover-eligible, exactly like an expired lease. Until
80
+ * then `tryAcquire()` treats it like a held lock (returns `null`), never
81
+ * throwing and never stealing. This self-heals without an operator having
82
+ * to intervene, while giving any in-flight (however slow) legitimate write
83
+ * — or a human investigating — a wide window to land or act first.
84
+ */
85
+ const DEFAULT_CORRUPT_GRACE_MS = 5 * 60_000;
86
+
87
+ /**
88
+ * On-disk shape: the exact public `LockRecordV1` plus a diagnostic field
89
+ * architecture.md §21.2 also asks the record to carry ("Startzeit" — process
90
+ * start time). It is NOT part of domain's `LockRecordV1` (that shape is
91
+ * pinned by the port contract and never used for staleness — see the file
92
+ * doc comment: lease expiry alone decides staleness), so it never appears on
93
+ * `LockHandle.record`; it exists purely for a human or another tool
94
+ * inspecting `record.json` on disk to see which OS process wrote it.
95
+ */
96
+ interface PersistedLockRecord extends LockRecordV1 {
97
+ processStartedAtEpochMs: number;
98
+ }
99
+
100
+ type RecordReadState =
101
+ | { kind: "absent" }
102
+ /** `mtimeEpochMs` backs the corruption grace period — see
103
+ * `DEFAULT_CORRUPT_GRACE_MS`'s doc comment. */
104
+ | { kind: "corrupt"; mtimeEpochMs: number }
105
+ | { kind: "valid"; record: PersistedLockRecord };
106
+
107
+ function encode(text: string): Uint8Array {
108
+ return new TextEncoder().encode(text);
109
+ }
110
+
111
+ function toPublicRecord(record: PersistedLockRecord): LockRecordV1 {
112
+ return {
113
+ key: record.key,
114
+ pid: record.pid,
115
+ processInstanceId: record.processInstanceId,
116
+ host: record.host,
117
+ acquiredAt: record.acquiredAt,
118
+ leaseExpiresAt: record.leaseExpiresAt,
119
+ };
120
+ }
121
+
122
+ /** File-based `LockProvider` rooted at one `locks/` directory. */
123
+ export class FileLockProvider implements LockProvider {
124
+ private readonly host: string;
125
+ /** Set once `locksDir` is known to exist, to skip a redundant `mkdir` on
126
+ * every `tryAcquire()` call — real disk I/O is not free, and the lease
127
+ * contract tests run with tight (double-digit millisecond) timing
128
+ * margins that a spurious extra round trip can eat into. */
129
+ private locksDirEnsured = false;
130
+ private readonly corruptGraceMs: number;
131
+
132
+ public constructor(
133
+ private readonly fs: FileSystemPort,
134
+ private readonly locksDir: string,
135
+ private readonly clock: Clock,
136
+ private readonly random: Random,
137
+ host?: string,
138
+ /** Overrides `DEFAULT_CORRUPT_GRACE_MS` — exposed mainly so tests can
139
+ * exercise the grace-period boundary without a real multi-minute wait. */
140
+ corruptGraceMs?: number,
141
+ ) {
142
+ this.host = host ?? os.hostname();
143
+ this.corruptGraceMs = corruptGraceMs ?? DEFAULT_CORRUPT_GRACE_MS;
144
+ }
145
+
146
+ private lockDirFor(key: string): string {
147
+ return nodePath.join(this.locksDir, sha256Hex(key));
148
+ }
149
+
150
+ private recordPathFor(lockDir: string): string {
151
+ return nodePath.join(lockDir, RECORD_FILE_NAME);
152
+ }
153
+
154
+ private isExpired(record: LockRecordV1, nowEpochMs: number): boolean {
155
+ return Date.parse(record.leaseExpiresAt) <= nowEpochMs;
156
+ }
157
+
158
+ private buildRecord(key: string, leaseMs: number): PersistedLockRecord {
159
+ const nowMs = this.clock.nowEpochMs();
160
+ return {
161
+ key,
162
+ pid: process.pid,
163
+ processInstanceId: this.random.uuid(),
164
+ host: this.host,
165
+ acquiredAt: this.clock.nowIso(),
166
+ leaseExpiresAt: new Date(nowMs + leaseMs).toISOString(),
167
+ processStartedAtEpochMs: Math.round(nowMs - process.uptime() * 1000),
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Reads the current state of a lock directory:
173
+ * - `"absent"` — the directory (or its `record.json`) does not exist yet,
174
+ * INCLUDING the tiny window where a concurrent winner's `mkdir` has
175
+ * landed but its `record.json` write has not — that window is treated as
176
+ * "busy", never as stale, so it can never be mistaken for a takeover
177
+ * opportunity (that would break exclusivity for the very case the
178
+ * directory-mkdir primitive exists to make airtight);
179
+ * - `"corrupt"` — `record.json` exists but does not parse. Because every
180
+ * write goes through temp-file-then-rename (see the file doc comment),
181
+ * this can no longer be an in-flight write caught mid-write — it is a
182
+ * genuine anomaly. The caller still has to check `mtimeEpochMs` against
183
+ * the corruption grace period before treating it as takeover-eligible
184
+ * (see `DEFAULT_CORRUPT_GRACE_MS`) — corrupt is NOT auto-stale;
185
+ * - `"valid"` — a well-formed record, which the caller still has to check
186
+ * for lease expiry itself.
187
+ */
188
+ private async readRecordState(
189
+ lockDir: string,
190
+ options?: PortCallOptions,
191
+ ): Promise<RecordReadState> {
192
+ const recordPath = this.recordPathFor(lockDir);
193
+ let text: string;
194
+ try {
195
+ text = await this.fs.readTextFile(recordPath, options);
196
+ } catch (error) {
197
+ if (isNotFoundError(error)) {
198
+ return { kind: "absent" };
199
+ }
200
+ throw error;
201
+ }
202
+ try {
203
+ return { kind: "valid", record: JSON.parse(text) as PersistedLockRecord };
204
+ } catch {
205
+ // `record.json` exists (the readTextFile above succeeded) but does not
206
+ // parse — stat it for the corruption-grace-period check. A `null`
207
+ // stat here means a concurrent takeover removed it between our read
208
+ // and this stat; fall back to "now" so that race resolves toward
209
+ // "not yet eligible" rather than throwing on a missing file.
210
+ const stat = await this.fs.stat(recordPath, options);
211
+ return { kind: "corrupt", mtimeEpochMs: stat?.mtimeEpochMs ?? this.clock.nowEpochMs() };
212
+ }
213
+ }
214
+
215
+ private isCorruptGraceElapsed(mtimeEpochMs: number, nowEpochMs: number): boolean {
216
+ return nowEpochMs - mtimeEpochMs >= this.corruptGraceMs;
217
+ }
218
+
219
+ /**
220
+ * Writes `record.json` atomically — temp file, then a single `rename` onto
221
+ * the live path — mirroring `checkpoint-store.ts`'s `writeAtomic` in this
222
+ * same package. Used by both the initial `tryAcquire()` write and every
223
+ * `renew()`, so a reader can never observe a half-written record (WP-80).
224
+ */
225
+ private async writeRecordAtomic(
226
+ lockDir: string,
227
+ record: PersistedLockRecord,
228
+ options?: PortCallOptions,
229
+ ): Promise<void> {
230
+ const finalPath = this.recordPathFor(lockDir);
231
+ const tempPath = `${finalPath}.${this.random.uuid()}.tmp`;
232
+ await this.fs.writeFile(tempPath, encode(JSON.stringify(record)), options);
233
+ await this.fs.rename(tempPath, finalPath, options);
234
+ }
235
+
236
+ private makeHandle(lockDir: string, record: PersistedLockRecord, leaseMs: number): LockHandle {
237
+ let current = record;
238
+ return {
239
+ get key() {
240
+ return current.key;
241
+ },
242
+ get record() {
243
+ return toPublicRecord(current);
244
+ },
245
+ renew: async (options?: PortCallOptions): Promise<void> => {
246
+ assertNotAborted(options, "lock_renew");
247
+ const state = await this.readRecordState(lockDir, options);
248
+ if (
249
+ state.kind !== "valid" ||
250
+ state.record.processInstanceId !== current.processInstanceId
251
+ ) {
252
+ throw new UntangleError("LOCK_HELD", `Lock "${current.key}" was lost before renew`, {
253
+ operation: "lock_renew",
254
+ context: { key: current.key },
255
+ });
256
+ }
257
+ const renewed = this.buildRecord(current.key, leaseMs);
258
+ await this.writeRecordAtomic(lockDir, renewed, options);
259
+ current = renewed;
260
+ },
261
+ release: async (): Promise<void> => {
262
+ const state = await this.readRecordState(lockDir);
263
+ if (
264
+ state.kind === "valid" &&
265
+ state.record.processInstanceId === current.processInstanceId
266
+ ) {
267
+ await this.fs.remove(lockDir, { recursive: true });
268
+ }
269
+ // Otherwise: this handle already lost the lock to another holder —
270
+ // a safe no-op, never removing the new holder's directory.
271
+ },
272
+ };
273
+ }
274
+
275
+ public async tryAcquire(
276
+ key: string,
277
+ options?: PortCallOptions & { leaseMs?: number },
278
+ ): Promise<LockHandle | null> {
279
+ assertNotAborted(options, "lock_try_acquire");
280
+ const leaseMs = options?.leaseMs ?? DEFAULT_LEASE_MS;
281
+ const lockDir = this.lockDirFor(key);
282
+ const mkdirOptions = options?.signal ? { signal: options.signal } : undefined;
283
+
284
+ // Lets this provider be used before any explicit `ensureStateDirLayout()`
285
+ // call (e.g. in a focused unit test) — but only paid once per instance
286
+ // (see `locksDirEnsured`'s doc comment).
287
+ if (!this.locksDirEnsured) {
288
+ await this.fs.mkdir(this.locksDir, { recursive: true, ...mkdirOptions });
289
+ this.locksDirEnsured = true;
290
+ }
291
+
292
+ for (let attempt = 0; attempt < MAX_TAKEOVER_ATTEMPTS; attempt += 1) {
293
+ assertNotAborted(options, "lock_try_acquire");
294
+ try {
295
+ await this.fs.mkdir(lockDir, mkdirOptions);
296
+ } catch (error) {
297
+ if (!isAlreadyExistsError(error)) {
298
+ throw error;
299
+ }
300
+ const state = await this.readRecordState(lockDir, options);
301
+ if (state.kind === "absent") {
302
+ // Someone else's mkdir landed but their record.json has not yet —
303
+ // busy, not stale. Report "held" rather than racing to remove a
304
+ // directory that is mid-creation by its legitimate owner.
305
+ return null;
306
+ }
307
+ if (state.kind === "valid" && !this.isExpired(state.record, this.clock.nowEpochMs())) {
308
+ return null; // genuinely held
309
+ }
310
+ if (
311
+ state.kind === "corrupt" &&
312
+ !this.isCorruptGraceElapsed(state.mtimeEpochMs, this.clock.nowEpochMs())
313
+ ) {
314
+ // Unparsable, but too recently modified to trust as abandoned —
315
+ // could still be a legitimate (if unusually slow) in-flight write,
316
+ // or fresh external damage nobody has had a chance to react to
317
+ // yet. Treat exactly like "genuinely held": never steal it before
318
+ // the corruption grace period elapses (see
319
+ // `DEFAULT_CORRUPT_GRACE_MS`; this is the WP-80 fix).
320
+ return null;
321
+ }
322
+ // Either a really-expired lease, or a record.json that has stayed
323
+ // unparsable for longer than the corruption grace period: attempt
324
+ // takeover by removing the stale directory, then race for mkdir
325
+ // again from the top of the loop.
326
+ try {
327
+ await this.fs.remove(lockDir, { recursive: true, ...mkdirOptions });
328
+ } catch (removeError) {
329
+ if (!isNotFoundError(removeError)) {
330
+ throw removeError;
331
+ }
332
+ // Someone else already removed it — loop back and race for mkdir.
333
+ }
334
+ continue;
335
+ }
336
+ // Our mkdir won: we are the exclusive owner of a brand-new directory.
337
+ const record = this.buildRecord(key, leaseMs);
338
+ await this.writeRecordAtomic(lockDir, record, options);
339
+ return this.makeHandle(lockDir, record, leaseMs);
340
+ }
341
+ return null;
342
+ }
343
+
344
+ public async acquire(
345
+ key: string,
346
+ options?: PortCallOptions & { leaseMs?: number; waitMs?: number },
347
+ ): Promise<LockHandle> {
348
+ const waitMs = options?.waitMs ?? 0;
349
+ // Bounded by REAL elapsed time, not the injected `Clock` — see
350
+ // `real-time.ts` for why (mirrors `InMemoryLockProvider`'s identical
351
+ // reasoning in `@untangle-code/testkit`).
352
+ const startedAtRealMs = realNowEpochMs();
353
+ for (;;) {
354
+ const handle = await this.tryAcquire(key, options);
355
+ if (handle) {
356
+ return handle;
357
+ }
358
+ const elapsedRealMs = realNowEpochMs() - startedAtRealMs;
359
+ if (elapsedRealMs >= waitMs) {
360
+ throw new UntangleError("LOCK_HELD", `Lock "${key}" is held`, {
361
+ operation: "lock_acquire",
362
+ retryable: true,
363
+ context: { key },
364
+ });
365
+ }
366
+ assertNotAborted(options, "lock_acquire");
367
+ await new Promise((resolve) =>
368
+ setTimeout(resolve, Math.min(POLL_INTERVAL_MS, waitMs - elapsedRealMs)),
369
+ );
370
+ }
371
+ }
372
+
373
+ public async inspect(key: string, options?: PortCallOptions): Promise<LockRecordV1 | null> {
374
+ assertNotAborted(options, "lock_inspect");
375
+ const state = await this.readRecordState(this.lockDirFor(key), options);
376
+ if (state.kind !== "valid" || this.isExpired(state.record, this.clock.nowEpochMs())) {
377
+ return null;
378
+ }
379
+ return toPublicRecord(state.record);
380
+ }
381
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The single, isolated real-wall-clock read in this package, used ONLY to
3
+ * bound `FileLockProvider.acquire()`'s retry-polling loop — a real file lock
4
+ * has no injectable `Clock` for "how long have we actually been waiting",
5
+ * since that loop sleeps via real `setTimeout` regardless of what the
6
+ * injected `Clock` reports (see `lock-provider.ts` for the full reasoning;
7
+ * this mirrors `@untangle-code/testkit`'s own `SystemClock`, which exists for
8
+ * exactly the same reason and carries the same isolation comment).
9
+ *
10
+ * Lease/record timestamps themselves (`acquiredAt`, `leaseExpiresAt`, cache
11
+ * and checkpoint `savedAt`/access times) always go through the injected
12
+ * `Clock` port, never this function — this is deliberately the ONE place in
13
+ * the package's non-test code that reads real time directly.
14
+ */
15
+ export function realNowEpochMs(): number {
16
+ // eslint-disable-next-line no-restricted-syntax -- see file doc comment above.
17
+ return Date.now();
18
+ }