@jini-ai/platform 0.5.2 → 0.5.3

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 (57) hide show
  1. package/dist/asset-cache.d.ts +12 -1
  2. package/dist/asset-cache.js +9 -5
  3. package/dist/child-environment.d.ts +18 -0
  4. package/dist/child-environment.js +26 -0
  5. package/dist/fs/boot-identity.d.ts +10 -0
  6. package/dist/fs/boot-identity.js +34 -0
  7. package/dist/fs/directory-home.d.ts +15 -0
  8. package/dist/fs/directory-home.js +17 -0
  9. package/dist/fs/directory-registry.d.ts +50 -0
  10. package/dist/fs/directory-registry.js +99 -0
  11. package/dist/fs/directory-running-lock.d.ts +43 -0
  12. package/dist/fs/directory-running-lock.js +66 -0
  13. package/dist/fs/directory-sync.d.ts +20 -0
  14. package/dist/fs/directory-sync.js +87 -0
  15. package/dist/fs/directory-user-config.d.ts +79 -0
  16. package/dist/fs/directory-user-config.js +183 -0
  17. package/dist/fs/directory-workspace.d.ts +132 -0
  18. package/dist/fs/directory-workspace.js +266 -0
  19. package/dist/fs/durable-json.d.ts +21 -0
  20. package/dist/fs/durable-json.js +59 -4
  21. package/dist/fs/file-lock.d.ts +3 -0
  22. package/dist/fs/file-lock.js +16 -7
  23. package/dist/fs/index.d.ts +6 -0
  24. package/dist/fs/index.js +6 -0
  25. package/dist/fs/user-data-directories.d.ts +28 -0
  26. package/dist/fs/user-data-directories.js +53 -0
  27. package/dist/fs/verified-directory-move.d.ts +37 -0
  28. package/dist/fs/verified-directory-move.js +138 -0
  29. package/dist/fs/verified-file-relocation.d.ts +27 -0
  30. package/dist/fs/verified-file-relocation.js +115 -0
  31. package/dist/fs/workspace-directory.d.ts +19 -0
  32. package/dist/fs/workspace-directory.js +23 -0
  33. package/dist/index.d.ts +2 -1
  34. package/dist/index.js +2 -1
  35. package/dist/secrets/credential-sets/index.d.ts +5 -0
  36. package/dist/secrets/credential-sets/index.js +5 -0
  37. package/dist/secrets/credential-sets/vault.aad.d.ts +4 -0
  38. package/dist/secrets/credential-sets/vault.aad.js +8 -0
  39. package/dist/secrets/credential-sets/vault.meta.d.ts +10 -0
  40. package/dist/secrets/credential-sets/vault.meta.js +52 -0
  41. package/dist/secrets/credential-sets/vault.repo.memory.d.ts +51 -0
  42. package/dist/secrets/credential-sets/vault.repo.memory.js +148 -0
  43. package/dist/secrets/credential-sets/vault.service.d.ts +130 -0
  44. package/dist/secrets/credential-sets/vault.service.js +310 -0
  45. package/dist/secrets/credential-sets/vault.types.d.ts +150 -0
  46. package/dist/secrets/credential-sets/vault.types.js +28 -0
  47. package/dist/web/html-document.d.ts +70 -0
  48. package/dist/web/html-document.js +268 -0
  49. package/dist/web/html-resource.d.ts +15 -0
  50. package/dist/web/html-resource.js +12 -0
  51. package/dist/web/html-to-markdown.d.ts +11 -0
  52. package/dist/web/html-to-markdown.js +245 -0
  53. package/dist/web/index.d.ts +5 -0
  54. package/dist/web/index.js +5 -0
  55. package/dist/web/web-page.d.ts +157 -0
  56. package/dist/web/web-page.js +310 -0
  57. package/package.json +14 -3
@@ -5,6 +5,7 @@
5
5
  * Recovery policy stays with each store: salvage, restart, or leave untouched is not decided here.
6
6
  * Native effects are Electron-free so desktop, CLI, and bridge processes can share the mechanism.
7
7
  */
8
+ import { readBootIdentity } from "./boot-identity.js";
8
9
  import nodeFs from "node:fs";
9
10
  import path from "node:path";
10
11
  import { randomBytes } from "node:crypto";
@@ -18,7 +19,7 @@ export function createNodeDurableJsonPorts(_required) {
18
19
  clock: { nowMs: () => Date.now() },
19
20
  // Synchronous store/IPC writers cannot await lock polling without changing all callers' ABI.
20
21
  sleep: { sleep: ({ durationMs }) => { Atomics.wait(cell, 0, 0, durationMs); } },
21
- process: { pid: process.pid, isAlive: ({ pid }) => {
22
+ process: { pid: process.pid, bootIdentity: () => readBootIdentity({}), isAlive: ({ pid }) => {
22
23
  // Signal zero sends nothing. Only ESRCH proves death; permission failure must count as alive
23
24
  // because a false "dead" authorizes breaking a lock still owned by another process.
24
25
  try {
@@ -40,9 +41,9 @@ export function createNodeDurableJsonPorts(_required) {
40
41
  export function createDurableJsonFile({ filePath, filesystem: fs, clock, sleep, process, random, report, notices }, { pollMs = 10, staleMs = 30000 } = {}) {
41
42
  if (!filePath)
42
43
  throw new TypeError("filePath is required");
43
- for (const value of [pollMs, staleMs])
44
- if (!Number.isSafeInteger(value) || value <= 0)
45
- throw new TypeError("lock intervals must be positive safe integers");
44
+ if (!Number.isSafeInteger(pollMs) || pollMs <= 0 ||
45
+ ((!Number.isSafeInteger(staleMs) && staleMs !== Infinity) || staleMs <= 0))
46
+ throw new TypeError("lock intervals must be positive safe integers");
46
47
  // A synchronous read-modify-write normally takes milliseconds; five seconds allows contention
47
48
  // while refusing a wedged change. Short ten-millisecond polling fits those short critical sections.
48
49
  // Thirty-second staleness also handles pid reuse after a crash:
@@ -163,4 +164,58 @@ function endOfString(text, start) {
163
164
  }
164
165
  return text.length;
165
166
  }
167
+ /** Update related JSON pointers under their existing locks, with atomic replacement per file.
168
+ * Compute every change before writing; a failed write restores prior bytes before releasing locks.
169
+ * The host supplies schema transforms, so this owner knows nothing about product catalogs.
170
+ * @throws Unreadable input, transform, lock or persistence failures (including rollback failures).
171
+ * @example updateDurableJsonFiles({ changes, fileAt, remove });
172
+ * @complexity O(total JSON bytes), O(total JSON bytes) rollback storage, one lock per file.
173
+ */
174
+ export function updateDurableJsonFiles({ changes, fileAt, remove }, _options = {}) {
175
+ if (new Set(changes.map((change) => change.filePath)).size !== changes.length)
176
+ throw new Error("Duplicate JSON transaction path.");
177
+ const ordered = [...changes].sort((a, b) => a.filePath.localeCompare(b.filePath));
178
+ const files = ordered.map((change) => fileAt(change.filePath));
179
+ const lock = (index) => {
180
+ if (index < files.length) {
181
+ files[index].withLock({ critical: () => lock(index + 1) });
182
+ return;
183
+ }
184
+ const updates = ordered.map((change, i) => {
185
+ const before = files[i].read({});
186
+ if (before.state === "unreadable")
187
+ throw new Error(`JSON reference is unreadable: ${change.filePath}. Nothing was moved.`);
188
+ const previous = before.state === "ok" ? before.value : undefined;
189
+ return { file: files[i], filePath: change.filePath, previous, next: change.transform(previous) };
190
+ }).filter((item) => JSON.stringify(item.previous) !== JSON.stringify(item.next));
191
+ const attempted = [];
192
+ try {
193
+ for (const item of updates) {
194
+ attempted.push(item); // A write can fail after its rename; include it in rollback.
195
+ if (item.next === undefined)
196
+ remove({ filePath: item.filePath });
197
+ else
198
+ item.file.write({ value: item.next });
199
+ }
200
+ }
201
+ catch (failure) {
202
+ const errors = [failure];
203
+ for (const item of attempted.reverse()) {
204
+ try {
205
+ if (item.previous === undefined)
206
+ remove({ filePath: item.filePath });
207
+ else
208
+ item.file.write({ value: item.previous });
209
+ }
210
+ catch (error) {
211
+ errors.push(error);
212
+ }
213
+ }
214
+ if (errors.length > 1)
215
+ throw new AggregateError(errors, "JSON reference rollback failed.");
216
+ throw failure;
217
+ }
218
+ };
219
+ lock(0);
220
+ }
166
221
  //# sourceMappingURL=durable-json.js.map
@@ -10,6 +10,7 @@
10
10
  * multi-process races and are deliberately avoided. Atomic file replacement independently prevents
11
11
  * torn content. Hosts whose operations can legitimately exceed the stale budget use staleMs: Infinity.
12
12
  */
13
+ import { type BootIdentity } from "./boot-identity.js";
13
14
  import fs from "node:fs";
14
15
  import * as asyncFs from "node:fs/promises";
15
16
  import { type Clock } from "@jini-ai/core/primitives";
@@ -21,6 +22,7 @@ export interface FileLockHolder {
21
22
  readonly hostname: string;
22
23
  readonly token: string;
23
24
  readonly acquiredAt: string;
25
+ readonly bootId?: string;
24
26
  }
25
27
  export interface HeldFileLock {
26
28
  readonly lockPath: string;
@@ -50,6 +52,7 @@ export interface FileLockOptions {
50
52
  isAlive(required: {
51
53
  pid: number;
52
54
  }): boolean;
55
+ bootIdentity?: () => BootIdentity | undefined;
53
56
  };
54
57
  token?: () => string;
55
58
  hostname?: () => string;
@@ -10,6 +10,7 @@
10
10
  * multi-process races and are deliberately avoided. Atomic file replacement independently prevents
11
11
  * torn content. Hosts whose operations can legitimately exceed the stale budget use staleMs: Infinity.
12
12
  */
13
+ import { readBootIdentity } from "./boot-identity.js";
13
14
  import fs from "node:fs";
14
15
  import * as asyncFs from "node:fs/promises";
15
16
  import { randomUUID } from "node:crypto";
@@ -52,7 +53,7 @@ function parseHolder(raw) {
52
53
  const value = JSON.parse(raw);
53
54
  if (typeof value !== "object" || value === null || !("pid" in value) || !validPid(value.pid) || !("hostname" in value) || typeof value.hostname !== "string" || !("token" in value) || typeof value.token !== "string" || !("acquiredAt" in value) || typeof value.acquiredAt !== "string")
54
55
  return undefined;
55
- return { pid: value.pid, hostname: value.hostname, token: value.token, acquiredAt: value.acquiredAt };
56
+ return { pid: value.pid, hostname: value.hostname, token: value.token, acquiredAt: value.acquiredAt, ...("bootId" in value && typeof value.bootId === "string" ? { bootId: value.bootId } : {}) };
56
57
  }
57
58
  catch {
58
59
  return undefined;
@@ -67,7 +68,7 @@ function settings(options) {
67
68
  const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS, staleMs = options.staleMs ?? DEFAULT_LOCK_STALE_AFTER_MS, pollMs = options.pollMs ?? 10;
68
69
  if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 0 || (!Number.isSafeInteger(staleMs) && staleMs !== Infinity) || staleMs <= 0 || !Number.isSafeInteger(pollMs) || pollMs <= 0)
69
70
  throw new RangeError((options.messages ?? defaultPlatformMessages).fileLockIntervalsInvalid());
70
- const processPort = options.process ?? { pid: process.pid, isAlive: ({ pid }) => { try {
71
+ const processPort = options.process ?? { pid: process.pid, bootIdentity: () => readBootIdentity({}), isAlive: ({ pid }) => { try {
71
72
  process.kill(pid, 0);
72
73
  return true;
73
74
  }
@@ -76,19 +77,27 @@ function settings(options) {
76
77
  } } };
77
78
  const clock = options.clock ?? createSystemClock();
78
79
  const monotonicMs = options.monotonicMs ?? (() => clock.nowMs());
79
- return { timeoutMs, staleMs, pollMs, processPort, clock, monotonicMs, hostname: (options.hostname ?? nodeHostname)(), token: (options.token ?? randomUUID)() };
80
+ return { timeoutMs, staleMs, pollMs, processPort, clock, monotonicMs, boot: processPort.bootIdentity?.(), hostname: (options.hostname ?? nodeHostname)(), token: (options.token ?? randomUUID)() };
80
81
  }
81
82
  function ownership(s, options) {
82
- return options.format === "token" ? `${s.processPort.pid}:${s.token}` : JSON.stringify({ pid: s.processPort.pid, hostname: s.hostname, token: s.token, acquiredAt: new Date(s.clock.nowMs()).toISOString() });
83
+ return options.format === "token" ? `${s.processPort.pid}:${s.token}${s.boot ? ":" + s.boot.id : ""}` : JSON.stringify({ pid: s.processPort.pid, hostname: s.hostname, token: s.token, acquiredAt: new Date(s.clock.nowMs()).toISOString(), ...(s.boot ? { bootId: s.boot.id } : {}) });
83
84
  }
84
85
  function ownerAlive(snapshot, s) {
85
86
  const holder = snapshot.holder;
86
- if (holder)
87
- return holder.hostname === s.hostname ? s.processPort.isAlive({ pid: holder.pid }) : undefined;
87
+ if (holder) {
88
+ if (holder.hostname !== s.hostname)
89
+ return undefined;
90
+ if (s.boot && (holder.bootId !== undefined ? holder.bootId !== s.boot.id : Date.parse(holder.acquiredAt) < s.boot.startedAtMs))
91
+ return false;
92
+ return s.processPort.isAlive({ pid: holder.pid });
93
+ }
88
94
  // Legacy synchronous stores wrote pid:random rather than JSON. Malformed pids remain unknown.
89
95
  if (!/^\d+:/.test(snapshot.raw))
90
96
  return undefined;
91
- const pid = Number(snapshot.raw.split(":", 1)[0]);
97
+ const parts = snapshot.raw.split(":");
98
+ const pid = Number(parts[0]);
99
+ if (validPid(pid) && s.boot && (parts[2] ? parts[2] !== s.boot.id : snapshot.mtimeMs < s.boot.startedAtMs))
100
+ return false;
92
101
  return validPid(pid) ? s.processPort.isAlive({ pid }) : undefined;
93
102
  }
94
103
  function unchanged(a, b) { return a.ino === b.ino && a.dev === b.dev && a.raw === b.raw && a.mtimeMs === b.mtimeMs; }
@@ -5,4 +5,10 @@ export * from "./strict-containment.js";
5
5
  export * from "./guarded-reader.js";
6
6
  export * from "./durable-json.js";
7
7
  export { withFileLock, withFileLockSync, isLockStale } from "./file-lock.js";
8
+ export * from "./directory-home.js";
9
+ export * from "./directory-registry.js";
10
+ export * from "./directory-running-lock.js";
11
+ export * from "./directory-workspace.js";
12
+ export * from "./workspace-directory.js";
13
+ export * from "./verified-directory-move.js";
8
14
  //# sourceMappingURL=index.d.ts.map
package/dist/fs/index.js CHANGED
@@ -6,4 +6,10 @@ export * from "./strict-containment.js";
6
6
  export * from "./guarded-reader.js";
7
7
  export * from "./durable-json.js";
8
8
  export { withFileLock, withFileLockSync, isLockStale } from "./file-lock.js";
9
+ export * from "./directory-home.js";
10
+ export * from "./directory-registry.js";
11
+ export * from "./directory-running-lock.js";
12
+ export * from "./directory-workspace.js";
13
+ export * from "./workspace-directory.js";
14
+ export * from "./verified-directory-move.js";
9
15
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,28 @@
1
+ export interface UserDirectoryOptions {
2
+ /** Resolved host profile directory; owns config/catalog/DB/lease paths without an app-name suffix. */
3
+ appDataDir?: string;
4
+ platform?: NodeJS.Platform;
5
+ env?: NodeJS.ProcessEnv;
6
+ readText?: (file: string) => string | undefined;
7
+ windowsDocuments?: () => string | undefined;
8
+ windowsOneDriveRoots?: () => readonly string[];
9
+ }
10
+ /** Electron-free OS directory rules. Windows uses roaming app-data and the redirected Personal
11
+ * known folder; Linux honors XDG user-dirs. No directories are created by this resolver.
12
+ * env-paths is intentionally not used: its Windows data directory is LocalAppData/<app>/Data.
13
+ * @complexity O(1) bounded configuration reads.
14
+ */
15
+ export declare function resolveUserDataDirectories({ homeDir, appName }: {
16
+ homeDir: string;
17
+ appName: string;
18
+ }, { platform, env, appDataDir, readText, windowsDocuments }?: UserDirectoryOptions): {
19
+ appData: string;
20
+ documents: string;
21
+ };
22
+ /** Native reads are isolated so cross-platform fixtures never execute registry commands. */
23
+ export declare function readOptionalText({ file }: {
24
+ file: string;
25
+ }, _options?: {}): string | undefined;
26
+ /** OneDrive publishes account mount paths in HKCU; absent accounts are a normal empty result. */
27
+ export declare function readWindowsOneDriveRoots(_required: Record<string, never>, _options?: {}): readonly string[];
28
+ //# sourceMappingURL=user-data-directories.d.ts.map
@@ -0,0 +1,53 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs";
3
+ import { execFileSync } from "node:child_process";
4
+ /** Electron-free OS directory rules. Windows uses roaming app-data and the redirected Personal
5
+ * known folder; Linux honors XDG user-dirs. No directories are created by this resolver.
6
+ * env-paths is intentionally not used: its Windows data directory is LocalAppData/<app>/Data.
7
+ * @complexity O(1) bounded configuration reads.
8
+ */
9
+ export function resolveUserDataDirectories({ homeDir, appName }, { platform = process.platform, env = process.env, appDataDir, readText = (file) => readOptionalText({ file }), windowsDocuments = readWindowsDocuments } = {}) {
10
+ const paths = platform === "win32" ? path.win32 : path.posix;
11
+ if (appDataDir !== undefined && (!appDataDir.trim() || !paths.isAbsolute(appDataDir)))
12
+ throw new Error("appDataDir must be an absolute profile directory.");
13
+ if (platform === "win32") {
14
+ const personal = windowsDocuments();
15
+ const documents = personal?.replace(/%([^%]+)%/g, (match, key) => env[key] ?? match);
16
+ return { appData: appDataDir ?? paths.join(env.APPDATA ?? paths.join(homeDir, "AppData", "Roaming"), appName),
17
+ documents: documents ?? paths.join(homeDir, "Documents") };
18
+ }
19
+ if (platform === "darwin")
20
+ return { appData: appDataDir ?? paths.join(homeDir, "Library", "Application Support", appName), documents: paths.join(homeDir, "Documents") };
21
+ const userDirs = readText(paths.join(env.XDG_CONFIG_HOME ?? paths.join(homeDir, ".config"), "user-dirs.dirs"));
22
+ const configured = /^XDG_DOCUMENTS_DIR="([^"\n]*)"/m.exec(userDirs ?? "")?.[1]?.replace(/\$HOME/g, homeDir);
23
+ return { appData: appDataDir ?? paths.join(env.XDG_DATA_HOME ?? paths.join(homeDir, ".local", "share"), appName),
24
+ documents: configured && paths.isAbsolute(configured) ? configured : paths.join(homeDir, "Documents") };
25
+ }
26
+ /** Native reads are isolated so cross-platform fixtures never execute registry commands. */
27
+ export function readOptionalText({ file }, _options = {}) {
28
+ try {
29
+ return fs.readFileSync(file, "utf8");
30
+ }
31
+ catch (error) {
32
+ if (error.code === "ENOENT")
33
+ return undefined;
34
+ throw error;
35
+ }
36
+ }
37
+ function readWindowsDocuments() {
38
+ const output = execFileSync("reg.exe", ["query", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\User Shell Folders", "/v", "Personal"], { encoding: "utf8", timeout: 3000, windowsHide: true });
39
+ return /Personal\s+REG_(?:EXPAND_)?SZ\s+(.+)/.exec(output)?.[1]?.trim();
40
+ }
41
+ /** OneDrive publishes account mount paths in HKCU; absent accounts are a normal empty result. */
42
+ export function readWindowsOneDriveRoots(_required, _options = {}) {
43
+ try {
44
+ const output = execFileSync("reg.exe", ["query", "HKCU\\Software\\Microsoft\\OneDrive\\Accounts", "/s", "/v", "UserFolder"], { encoding: "utf8", timeout: 3000, windowsHide: true });
45
+ return [...output.matchAll(/UserFolder\s+REG_(?:EXPAND_)?SZ\s+([^\r\n]+)/g)].map((match) => match[1].trim());
46
+ }
47
+ catch (error) {
48
+ if (error.status === 1)
49
+ return [];
50
+ throw error;
51
+ }
52
+ }
53
+ //# sourceMappingURL=user-data-directories.js.map
@@ -0,0 +1,37 @@
1
+ import { type VerifiedFileGroup } from "./verified-file-relocation.js";
2
+ import fs from "node:fs";
3
+ /** Fingerprint relative names, entry kinds, symlink targets and file bytes without following links.
4
+ * This is verification, never a log of file contents. Lease files are excluded by the host.
5
+ * @complexity O(bytes + entries log entries), O(entries + a fixed 64 KiB read buffer) space.
6
+ */
7
+ export declare function directoryFingerprint({ dir }: {
8
+ dir: string;
9
+ }, { excluded, filesystem }?: {
10
+ excluded?: readonly string[];
11
+ filesystem?: typeof fs;
12
+ }): string;
13
+ /** Move only into an absent target. Verify before registry commit or source deletion.
14
+ * EXDEV uses copy/verify/retire/commit; failure restores the source and removes only our new copy.
15
+ * Same-filesystem failures roll the rename back. Commit must atomically update the host registry.
16
+ * @complexity O(bytes + entries log entries), with two verification passes.
17
+ */
18
+ export declare function moveDirectoryVerified({ from, to, commit }: {
19
+ from: string;
20
+ to: string;
21
+ commit: () => void;
22
+ }, { excluded, filesystem, prepareDestination, relocateFiles, verifyFile, backupId }?: {
23
+ excluded?: readonly string[];
24
+ filesystem?: typeof fs;
25
+ prepareDestination?: (required: {
26
+ path: string;
27
+ }) => void;
28
+ relocateFiles?: (required: {
29
+ source: string;
30
+ target: string;
31
+ }) => readonly VerifiedFileGroup[];
32
+ verifyFile?: (required: {
33
+ path: string;
34
+ }) => string;
35
+ backupId?: () => string;
36
+ }): void;
37
+ //# sourceMappingURL=verified-directory-move.d.ts.map
@@ -0,0 +1,138 @@
1
+ import { stageVerifiedFileRelocation } from "./verified-file-relocation.js";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ /** Fingerprint relative names, entry kinds, symlink targets and file bytes without following links.
6
+ * This is verification, never a log of file contents. Lease files are excluded by the host.
7
+ * @complexity O(bytes + entries log entries), O(entries + a fixed 64 KiB read buffer) space.
8
+ */
9
+ export function directoryFingerprint({ dir }, { excluded = [], filesystem = fs } = {}) {
10
+ const digest = createHash("sha256");
11
+ function visit(relative) {
12
+ for (const entry of filesystem.readdirSync(path.join(dir, relative), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
13
+ if (!relative && excluded.includes(entry.name))
14
+ continue;
15
+ const name = path.join(relative, entry.name), absolute = path.join(dir, name);
16
+ const stat = filesystem.lstatSync(absolute);
17
+ const kind = stat.isSymbolicLink() ? "link" : stat.isDirectory() ? "directory" : stat.isFile() ? "file" : "unsupported";
18
+ if (kind === "unsupported")
19
+ throw new Error(`Cannot move special filesystem entry: ${name}`);
20
+ digest.update(JSON.stringify([name, kind, stat.mode & 0o777]));
21
+ if (kind === "directory")
22
+ visit(name);
23
+ else if (kind === "link")
24
+ digest.update(JSON.stringify(filesystem.readlinkSync(absolute)));
25
+ else {
26
+ const fileDigest = createHash("sha256"), buffer = Buffer.allocUnsafe(64 * 1024);
27
+ const descriptor = filesystem.openSync(absolute, "r");
28
+ try {
29
+ let bytes;
30
+ while ((bytes = filesystem.readSync(descriptor, buffer, 0, buffer.length, null)) > 0)
31
+ fileDigest.update(buffer.subarray(0, bytes));
32
+ }
33
+ finally {
34
+ filesystem.closeSync(descriptor);
35
+ }
36
+ digest.update(fileDigest.digest());
37
+ }
38
+ }
39
+ }
40
+ visit("");
41
+ return digest.digest("hex");
42
+ }
43
+ /** Move only into an absent target. Verify before registry commit or source deletion.
44
+ * EXDEV uses copy/verify/retire/commit; failure restores the source and removes only our new copy.
45
+ * Same-filesystem failures roll the rename back. Commit must atomically update the host registry.
46
+ * @complexity O(bytes + entries log entries), with two verification passes.
47
+ */
48
+ export function moveDirectoryVerified({ from, to, commit }, { excluded = [], filesystem = fs, prepareDestination, relocateFiles, verifyFile, backupId = randomUUID } = {}) {
49
+ const source = filesystem.realpathSync(from);
50
+ const target = path.resolve(to);
51
+ if (target === source || target.startsWith(source + path.sep))
52
+ throw new Error("A directory cannot be moved into itself.");
53
+ try {
54
+ filesystem.lstatSync(target);
55
+ throw new Error(`Move target already exists: ${target}`);
56
+ }
57
+ catch (error) {
58
+ if (error.code !== "ENOENT")
59
+ throw error;
60
+ }
61
+ const before = directoryFingerprint({ dir: source }, { excluded, filesystem });
62
+ let copied = false;
63
+ try {
64
+ filesystem.renameSync(source, target);
65
+ }
66
+ catch (error) {
67
+ if (error.code !== "EXDEV")
68
+ throw error;
69
+ filesystem.mkdirSync(target);
70
+ copied = true;
71
+ try {
72
+ // The host may reserve the empty destination before any commit marker or database is copied.
73
+ prepareDestination?.({ path: target });
74
+ filesystem.cpSync(source, target, { recursive: true, force: false, errorOnExist: true, preserveTimestamps: true, verbatimSymlinks: true,
75
+ filter: (candidate) => candidate === source || !excluded.includes(path.relative(source, candidate).split(path.sep)[0]),
76
+ });
77
+ }
78
+ catch (failure) {
79
+ filesystem.rmSync(target, { recursive: true });
80
+ throw failure;
81
+ }
82
+ }
83
+ let retiredSource;
84
+ let staged;
85
+ try {
86
+ if (directoryFingerprint({ dir: target }, { excluded, filesystem }) !== before)
87
+ throw new Error("Moved directory verification failed.");
88
+ if (relocateFiles) {
89
+ if (!verifyFile)
90
+ throw new Error("File relocation requires a verification port.");
91
+ staged = stageVerifiedFileRelocation({ groups: relocateFiles({ source, target }), verify: verifyFile }, { filesystem, backupId, backupCovered: ({ from }) => copied && path.resolve(from).startsWith(target + path.sep) });
92
+ }
93
+ if (copied) {
94
+ const backupRoot = path.join(path.dirname(source), `.relocation-${backupId()}-backup`);
95
+ const backup = path.join(backupRoot, path.basename(source));
96
+ if (filesystem.existsSync(backup))
97
+ throw new Error(`Move backup target already exists: ${backup}`);
98
+ // The EXDEV original becomes a recovery folder before catalog commit, never a second live
99
+ // site at the old path if cleanup fails. An interrupted apply leaves explicit backups.
100
+ filesystem.mkdirSync(backupRoot);
101
+ filesystem.renameSync(source, backup);
102
+ retiredSource = backup;
103
+ const fd = filesystem.openSync(backupRoot, "r");
104
+ try {
105
+ filesystem.fsyncSync(fd);
106
+ }
107
+ finally {
108
+ filesystem.closeSync(fd);
109
+ }
110
+ }
111
+ // Persist directory-name changes for same-filesystem renames as well as EXDEV retirement.
112
+ for (const directory of new Set([path.dirname(source), path.dirname(target)])) {
113
+ const fd = filesystem.openSync(directory, "r");
114
+ try {
115
+ filesystem.fsyncSync(fd);
116
+ }
117
+ finally {
118
+ filesystem.closeSync(fd);
119
+ }
120
+ }
121
+ commit();
122
+ }
123
+ catch (error) {
124
+ if (retiredSource)
125
+ filesystem.renameSync(retiredSource, source);
126
+ staged?.rollback();
127
+ if (copied)
128
+ filesystem.rmSync(target, { recursive: true });
129
+ else
130
+ filesystem.renameSync(target, source);
131
+ throw error;
132
+ }
133
+ // If deletion fails after commit, the verified target and registry remain usable; old DB live
134
+ // names were already retired before commit. Never roll back a registry whose commit succeeded.
135
+ staged?.finalize();
136
+ // Keep the retired EXDEV recovery folder; it is never a registered/live site path.
137
+ }
138
+ //# sourceMappingURL=verified-directory-move.js.map
@@ -0,0 +1,27 @@
1
+ import fs from "node:fs";
2
+ export interface VerifiedFileGroup {
3
+ from: string;
4
+ to: string;
5
+ sidecars: readonly string[];
6
+ }
7
+ /** Stage related files together while both directory identities are reserved by the host. Raw
8
+ * sources remain intact through inspection so even WAL/SHM bytes are retained. Verification opens only
9
+ * stopped databases through an injected read-only inspector; the filesystem owner never uses SQL.
10
+ * @complexity O(total bytes + verification cost), bounded memory via native file copies.
11
+ */
12
+ export declare function stageVerifiedFileRelocation({ groups, verify }: {
13
+ groups: readonly VerifiedFileGroup[];
14
+ verify: (required: {
15
+ path: string;
16
+ }) => string;
17
+ }, { filesystem, backupCovered, backupId }?: {
18
+ filesystem?: typeof fs;
19
+ backupCovered?: (required: {
20
+ from: string;
21
+ }) => boolean;
22
+ backupId?: () => string;
23
+ }): {
24
+ rollback: () => void;
25
+ finalize: () => void;
26
+ };
27
+ //# sourceMappingURL=verified-file-relocation.d.ts.map
@@ -0,0 +1,115 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ /** Stage related files together while both directory identities are reserved by the host. Raw
5
+ * sources remain intact through inspection so even WAL/SHM bytes are retained. Verification opens only
6
+ * stopped databases through an injected read-only inspector; the filesystem owner never uses SQL.
7
+ * @complexity O(total bytes + verification cost), bounded memory via native file copies.
8
+ */
9
+ export function stageVerifiedFileRelocation({ groups, verify }, { filesystem = fs, backupCovered = () => false, backupId = randomUUID } = {}) {
10
+ const created = [], retired = [], markers = [], redundantBackups = [];
11
+ const backupSuffix = `.relocation-${backupId()}-backup`;
12
+ const copyExclusive = (from, to) => {
13
+ // Reserve before copying so a disk-full failure is still ours to clean up. COPYFILE_EXCL
14
+ // alone may create a partial file before throwing, which cannot be tracked after the fact.
15
+ const fd = filesystem.openSync(to, "wx");
16
+ created.push(to);
17
+ filesystem.closeSync(fd);
18
+ filesystem.copyFileSync(from, to);
19
+ const copied = filesystem.openSync(to, "r+");
20
+ try {
21
+ filesystem.fsyncSync(copied);
22
+ }
23
+ finally {
24
+ filesystem.closeSync(copied);
25
+ }
26
+ syncParent(to);
27
+ };
28
+ const syncParent = (file) => {
29
+ // Directory fsync persists retirement/marker ordering before the host commits its catalog.
30
+ const fd = filesystem.openSync(path.dirname(file), "r");
31
+ try {
32
+ filesystem.fsyncSync(fd);
33
+ }
34
+ finally {
35
+ filesystem.closeSync(fd);
36
+ }
37
+ };
38
+ const rollback = () => {
39
+ for (const item of [...retired].reverse()) {
40
+ filesystem.renameSync(item.backup, item.from);
41
+ syncParent(item.from);
42
+ }
43
+ for (const file of [...created].reverse()) {
44
+ filesystem.rmSync(file, { force: true });
45
+ syncParent(file);
46
+ }
47
+ };
48
+ try {
49
+ for (const group of groups) {
50
+ const pairs = ["", ...group.sidecars].filter((suffix) => filesystem.existsSync(group.from + suffix))
51
+ .map((suffix) => ({ from: group.from + suffix, to: group.to + suffix }));
52
+ for (const pair of pairs)
53
+ if (!filesystem.lstatSync(pair.from).isFile())
54
+ throw new Error(`Database move requires a regular file: ${pair.from}`);
55
+ filesystem.mkdirSync(path.dirname(group.to), { recursive: true });
56
+ const moving = path.resolve(group.from) !== path.resolve(group.to);
57
+ // A directory rename already carries unchanged DB paths atomically; verify those bytes
58
+ // without copying them. For a file move the original inode becomes the sole backup after
59
+ // verification: another full destination backup doubles storage without rollback benefit.
60
+ // Cross-volume directory moves already retain the untouched original directory. A copied
61
+ // DB inside that destination needs no second full backup of the same bytes.
62
+ if (moving) {
63
+ const marker = group.from + ".home2-relocation.json";
64
+ const fd = filesystem.openSync(marker, "wx");
65
+ created.push(marker);
66
+ markers.push(marker);
67
+ try {
68
+ filesystem.writeFileSync(fd, JSON.stringify({ to: group.to, backup: group.from + backupSuffix }));
69
+ filesystem.fsyncSync(fd);
70
+ }
71
+ finally {
72
+ filesystem.closeSync(fd);
73
+ }
74
+ syncParent(marker);
75
+ }
76
+ const before = verify({ path: group.from });
77
+ for (const pair of pairs) {
78
+ if (path.resolve(pair.from) === path.resolve(pair.to))
79
+ continue;
80
+ // A destination collision aborts; never overwrite another site's data or an old move.
81
+ copyExclusive(pair.from, pair.to);
82
+ }
83
+ if (verify({ path: group.to }) !== before)
84
+ throw new Error(`Database row counts changed while moving ${path.basename(group.to)}.`);
85
+ // Retire old live names BEFORE commit. Finalization removes recovery markers and redundant copies covered by the retained original: a crash
86
+ // or cleanup failure cannot leave two independently writable database names after commit.
87
+ if (moving)
88
+ for (const pair of pairs) {
89
+ const backup = pair.from + backupSuffix;
90
+ if (filesystem.existsSync(backup))
91
+ throw new Error(`Database backup target already exists: ${backup}`);
92
+ filesystem.renameSync(pair.from, backup);
93
+ retired.push({ from: pair.from, backup });
94
+ if (backupCovered({ from: pair.from }))
95
+ redundantBackups.push(backup);
96
+ syncParent(pair.from);
97
+ }
98
+ }
99
+ }
100
+ catch (error) {
101
+ rollback();
102
+ throw error;
103
+ }
104
+ return { rollback, finalize: () => {
105
+ for (const backup of redundantBackups) {
106
+ filesystem.rmSync(backup);
107
+ syncParent(backup);
108
+ }
109
+ for (const marker of markers) {
110
+ filesystem.rmSync(marker);
111
+ syncParent(marker);
112
+ }
113
+ } };
114
+ }
115
+ //# sourceMappingURL=verified-file-relocation.js.map
@@ -0,0 +1,19 @@
1
+ import type { DirectoryRecord } from "./directory-registry.js";
2
+ /** Shared selection policy for CLI/dev hosts. A registry ID disambiguates adopted paths;
3
+ * a basename selects the home first. No initialized home site preserves the supplied fallback.
4
+ * @complexity O(n) registered/child directories and validation probes.
5
+ */
6
+ export declare function selectWorkspaceDirectory({ home, entries, fallback, selected, roots, listDirectories, isInitialized }: {
7
+ home: string;
8
+ entries: readonly DirectoryRecord[];
9
+ fallback: string;
10
+ selected?: string | undefined;
11
+ roots?: readonly string[];
12
+ listDirectories: (required: {
13
+ root: string;
14
+ }) => readonly string[];
15
+ isInitialized: (required: {
16
+ path: string;
17
+ }) => boolean;
18
+ }, _options?: {}): string;
19
+ //# sourceMappingURL=workspace-directory.d.ts.map
@@ -0,0 +1,23 @@
1
+ import path from "node:path";
2
+ /** Shared selection policy for CLI/dev hosts. A registry ID disambiguates adopted paths;
3
+ * a basename selects the home first. No initialized home site preserves the supplied fallback.
4
+ * @complexity O(n) registered/child directories and validation probes.
5
+ */
6
+ export function selectWorkspaceDirectory({ home, entries, fallback, selected, roots = [home], listDirectories, isInitialized }, _options = {}) {
7
+ if (selected !== undefined) {
8
+ const byId = entries.find((entry) => entry.id === selected);
9
+ if (byId)
10
+ return byId.path;
11
+ const inHome = path.join(home, selected);
12
+ if (isInitialized({ path: inHome }))
13
+ return inHome;
14
+ const matches = [...new Set([...entries.filter((entry) => path.basename(entry.path) === selected).map((entry) => entry.path),
15
+ ...roots.filter((root) => root !== home).map((root) => path.join(root, selected)).filter((dir) => isInitialized({ path: dir }))])];
16
+ if (matches.length > 1)
17
+ throw new Error("More than one registered directory has this name. Select its registry ID.");
18
+ return matches[0] ?? path.join(path.dirname(fallback), selected);
19
+ }
20
+ const candidates = [...entries.filter((entry) => path.dirname(entry.path) === home).sort((a, b) => (b.lastOpened ? Date.parse(b.lastOpened) : 0) - (a.lastOpened ? Date.parse(a.lastOpened) : 0)).map((entry) => entry.path), ...roots.flatMap((root) => listDirectories({ root }))];
21
+ return candidates.find((dir) => isInitialized({ path: dir })) ?? fallback;
22
+ }
23
+ //# sourceMappingURL=workspace-directory.js.map