@jini-ai/platform 0.5.9 → 0.5.10

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 (79) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/asset-cache.js +26 -25
  3. package/dist/blob-storage.d.ts +7 -2
  4. package/dist/blob-storage.js +99 -59
  5. package/dist/child-environment.d.ts +36 -0
  6. package/dist/child-environment.js +68 -0
  7. package/dist/download.js +192 -148
  8. package/dist/fs/atomic-write.js +105 -93
  9. package/dist/fs/boot-identity.d.ts +16 -0
  10. package/dist/fs/boot-identity.js +38 -17
  11. package/dist/fs/directory-home.js +6 -0
  12. package/dist/fs/directory-registry.d.ts +1 -1
  13. package/dist/fs/directory-registry.js +23 -16
  14. package/dist/fs/directory-running-lock.d.ts +1 -1
  15. package/dist/fs/directory-running-lock.js +18 -7
  16. package/dist/fs/directory-sync.d.ts +1 -1
  17. package/dist/fs/directory-sync.js +64 -51
  18. package/dist/fs/directory-user-config.js +108 -67
  19. package/dist/fs/directory-workspace.d.ts +11 -0
  20. package/dist/fs/directory-workspace.js +16 -11
  21. package/dist/fs/durable-json.js +49 -37
  22. package/dist/fs/file-lock.js +214 -149
  23. package/dist/fs/guarded-reader.js +47 -35
  24. package/dist/fs/user-data-directories.d.ts +1 -1
  25. package/dist/fs/user-data-directories.js +25 -9
  26. package/dist/fs/verified-directory-move.d.ts +13 -11
  27. package/dist/fs/verified-directory-move.js +105 -68
  28. package/dist/fs/verified-file-relocation.d.ts +1 -1
  29. package/dist/fs/verified-file-relocation.js +111 -94
  30. package/dist/http/guarded/client.d.ts +3 -4
  31. package/dist/http/guarded/client.js +50 -60
  32. package/dist/http/guarded/fetch-adapter.d.ts +2 -3
  33. package/dist/http/guarded/fetch-adapter.js +26 -15
  34. package/dist/mail/builtin-providers.d.ts +39 -0
  35. package/dist/mail/builtin-providers.js +60 -0
  36. package/dist/mail/feedback.d.ts +84 -0
  37. package/dist/mail/feedback.js +90 -0
  38. package/dist/mail/index.d.ts +11 -0
  39. package/dist/mail/index.js +6 -0
  40. package/dist/mail/policy.d.ts +77 -0
  41. package/dist/mail/policy.js +155 -0
  42. package/dist/mail/ports.d.ts +56 -7
  43. package/dist/mail/provider.d.ts +117 -0
  44. package/dist/mail/provider.js +74 -0
  45. package/dist/mail/rate-limit.d.ts +33 -0
  46. package/dist/mail/rate-limit.js +35 -0
  47. package/dist/mail/sender-identity.d.ts +68 -0
  48. package/dist/mail/sender-identity.js +50 -0
  49. package/dist/mail/types.d.ts +9 -0
  50. package/dist/net/address.d.ts +9 -23
  51. package/dist/net/address.js +28 -133
  52. package/dist/net/endpoint-policy.d.ts +26 -2
  53. package/dist/net/endpoint-policy.js +159 -56
  54. package/dist/object-store/index.d.ts +2 -0
  55. package/dist/object-store/index.js +2 -0
  56. package/dist/object-store/local-fs.d.ts +17 -0
  57. package/dist/object-store/local-fs.js +146 -0
  58. package/dist/process.js +2 -0
  59. package/dist/proxy-env.js +131 -135
  60. package/dist/secrets/credential-sets/contracts.d.ts +1 -0
  61. package/dist/secrets/credential-sets/contracts.js +1 -0
  62. package/dist/secrets/credential-sets/index.d.ts +1 -0
  63. package/dist/secrets/credential-sets/index.js +1 -0
  64. package/dist/secrets/credential-sets/provider-matches.d.ts +61 -0
  65. package/dist/secrets/credential-sets/provider-matches.js +61 -0
  66. package/dist/secrets/credential-sets/types.d.ts +7 -0
  67. package/dist/secrets/credential-sets/vault.meta.js +58 -20
  68. package/dist/secrets/credential-sets/vault.repo.memory.d.ts +7 -0
  69. package/dist/secrets/credential-sets/vault.repo.memory.js +43 -36
  70. package/dist/secrets/credential-sets/vault.service.d.ts +9 -0
  71. package/dist/secrets/credential-sets/vault.service.js +131 -61
  72. package/dist/secrets/credential-sets/vault.types.d.ts +11 -3
  73. package/dist/secrets/credential-sets/vault.types.js +22 -8
  74. package/dist/secrets/keyring.env.js +14 -10
  75. package/dist/toolchain.js +119 -91
  76. package/dist/web/html-document.js +73 -69
  77. package/dist/web/html-to-markdown.js +127 -102
  78. package/dist/web/web-page.js +160 -80
  79. package/package.json +11 -3
@@ -1,70 +1,62 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { readOptionalText, readWindowsOneDriveRoots } from "./user-data-directories.js";
4
- /** Detect provider roots, never infer syncing just because a provider is installed. Canonical
5
- * containment catches redirected Documents, while marker files cover older client layouts.
6
- * Dropbox publishes info.json paths; macOS File Provider mounts identify their provider.
7
- * @complexity O(a + p) small account/mount lists and ancestor probes, no recursive site scan.
8
- */
9
- export function detectDirectorySync({ dir, homeDir, documents }, { env = process.env, platform = process.platform, readText = (file) => readOptionalText({ file }), canonicalPath = ({ path: value }) => { try {
4
+ const MOUNT_PROVIDERS = [["OneDrive-", "OneDrive"], ["GoogleDrive-", "Google Drive"], ["Dropbox", "Dropbox"]];
5
+ function canonicalOrResolved({ path: value }) { try {
10
6
  return fs.realpathSync(value);
11
7
  }
12
8
  catch {
13
9
  return path.resolve(value);
14
- } }, listDirectories = (root) => { try {
10
+ } }
11
+ function listDirectoriesOrEmpty(root) { try {
15
12
  return fs.readdirSync(root);
16
13
  }
17
14
  catch (error) {
18
15
  if (error.code === "ENOENT")
19
16
  return [];
20
17
  throw error;
21
- } }, exists = fs.existsSync, oneDriveRoots = [], windowsOneDriveRoots = () => readWindowsOneDriveRoots({}), } = {}) {
22
- const paths = platform === "win32" ? path.win32 : path.posix;
23
- const roots = [];
24
- const add = (root, provider) => { if (root)
25
- roots.push({ root, provider }); };
26
- if (platform === "darwin") {
27
- const cloud = paths.join(homeDir, "Library", "Mobile Documents", "com~apple~CloudDocs");
28
- add(cloud, "iCloud");
29
- // Documents need not be a symlink on newer macOS: the cloud Documents entry is evidence
30
- // of Desktop & Documents being enabled, rather than merely iCloud Drive being installed.
31
- if (exists(paths.join(cloud, "Documents")))
32
- add(documents, "iCloud");
33
- const mounts = paths.join(homeDir, "Library", "CloudStorage");
34
- for (const name of listDirectories(mounts)) {
35
- if (name.startsWith("OneDrive-"))
36
- add(paths.join(mounts, name), "OneDrive");
37
- else if (name.startsWith("GoogleDrive-"))
38
- add(paths.join(mounts, name), "Google Drive");
39
- else if (name.startsWith("Dropbox"))
40
- add(paths.join(mounts, name), "Dropbox");
41
- }
18
+ } }
19
+ function macRoots({ homeDir, documents }, { paths, exists, listDirectories }) {
20
+ const cloud = paths.join(homeDir, "Library", "Mobile Documents", "com~apple~CloudDocs");
21
+ const roots = [{ root: cloud, provider: "iCloud" }];
22
+ // Documents need not be a symlink on newer macOS: the cloud Documents entry is evidence
23
+ // of Desktop & Documents being enabled, rather than merely iCloud Drive being installed.
24
+ if (exists(paths.join(cloud, "Documents")) && documents)
25
+ roots.push({ root: documents, provider: "iCloud" });
26
+ const mounts = paths.join(homeDir, "Library", "CloudStorage");
27
+ for (const name of listDirectories(mounts)) {
28
+ const provider = MOUNT_PROVIDERS.find(([prefix]) => name.startsWith(prefix))?.[1];
29
+ if (provider)
30
+ roots.push({ root: paths.join(mounts, name), provider });
42
31
  }
43
- for (const root of [env.OneDrive, env.OneDriveConsumer, env.OneDriveCommercial, ...oneDriveRoots])
44
- add(root, "OneDrive");
45
- if (platform === "win32" && !env.OneDrive && !env.OneDriveConsumer && !env.OneDriveCommercial) {
46
- for (const root of windowsOneDriveRoots())
47
- add(root, "OneDrive");
32
+ return roots;
33
+ }
34
+ function oneDriveRoots({ env, platform, oneDriveRoots: extra, windowsOneDriveRoots }) {
35
+ const fromEnv = [env.OneDrive, env.OneDriveConsumer, env.OneDriveCommercial];
36
+ const fromAccounts = platform === "win32" && !fromEnv.some(Boolean) ? windowsOneDriveRoots() : [];
37
+ return [...fromEnv, ...extra, ...fromAccounts];
38
+ }
39
+ function dropboxInfoFiles({ homeDir }, { env, platform }, paths) {
40
+ if (platform !== "win32")
41
+ return [paths.join(homeDir, ".dropbox", "info.json")];
42
+ return [env.APPDATA, env.LOCALAPPDATA].filter((root) => Boolean(root)).map((root) => paths.join(root, "Dropbox", "info.json"));
43
+ }
44
+ function dropboxAccountPaths(text) {
45
+ if (!text)
46
+ return [];
47
+ // Provider-owned metadata can be torn/corrupt; it is optional evidence, never a boot gate.
48
+ let accounts;
49
+ try {
50
+ accounts = JSON.parse(text);
48
51
  }
49
- const dropboxFiles = platform === "win32" ? [env.APPDATA, env.LOCALAPPDATA].filter((root) => Boolean(root)).map((root) => paths.join(root, "Dropbox", "info.json")) : [paths.join(homeDir, ".dropbox", "info.json")];
50
- for (const file of dropboxFiles) {
51
- const text = readText(file);
52
- if (!text)
53
- continue;
54
- // Provider-owned metadata can be torn/corrupt; it is optional evidence, never a boot gate.
55
- let accounts;
56
- try {
57
- accounts = JSON.parse(text);
58
- }
59
- catch {
60
- continue;
61
- }
62
- if (!accounts || typeof accounts !== "object" || Array.isArray(accounts))
63
- continue;
64
- for (const account of Object.values(accounts))
65
- if (typeof account?.path === "string")
66
- add(account.path, "Dropbox");
52
+ catch {
53
+ return [];
67
54
  }
55
+ if (!accounts || typeof accounts !== "object" || Array.isArray(accounts))
56
+ return [];
57
+ return Object.values(accounts).map((account) => account?.path).filter((root) => typeof root === "string");
58
+ }
59
+ function containingProvider({ dir, roots }, { canonicalPath, platform }, paths) {
68
60
  const canonical = (value) => { const resolved = canonicalPath({ path: value }); return platform === "win32" ? resolved.toLowerCase() : resolved; };
69
61
  const target = canonical(dir);
70
62
  for (const { root, provider } of roots) {
@@ -72,6 +64,9 @@ catch (error) {
72
64
  if (target === resolved || target.startsWith(resolved + paths.sep))
73
65
  return provider;
74
66
  }
67
+ return null;
68
+ }
69
+ function ancestorMarkerProvider({ dir }, { exists }, paths) {
75
70
  let ancestor = paths.resolve(dir);
76
71
  while (true) {
77
72
  if (exists(paths.join(ancestor, ".dropbox.cache")))
@@ -84,4 +79,22 @@ catch (error) {
84
79
  ancestor = parent;
85
80
  }
86
81
  }
82
+ function providerRoots(provider, roots) {
83
+ return roots.filter((root) => Boolean(root)).map((root) => ({ root, provider }));
84
+ }
85
+ function detectWithPorts({ dir, homeDir, documents }, ports) {
86
+ const paths = ports.platform === "win32" ? path.win32 : path.posix;
87
+ const mac = ports.platform === "darwin" ? macRoots({ homeDir, documents }, { paths, exists: ports.exists, listDirectories: ports.listDirectories }) : [];
88
+ const oneDrive = providerRoots("OneDrive", oneDriveRoots(ports));
89
+ const dropbox = providerRoots("Dropbox", dropboxInfoFiles({ homeDir }, ports, paths).flatMap((file) => dropboxAccountPaths(ports.readText(file))));
90
+ return containingProvider({ dir, roots: [...mac, ...oneDrive, ...dropbox] }, ports, paths) ?? ancestorMarkerProvider({ dir }, ports, paths);
91
+ }
92
+ /** Detect provider roots, never infer syncing just because a provider is installed. Canonical
93
+ * containment catches redirected Documents, while marker files cover older client layouts.
94
+ * Dropbox publishes info.json paths; macOS File Provider mounts identify their provider.
95
+ * @complexity O(a + p) small account/mount lists and ancestor probes, no recursive site scan.
96
+ */
97
+ export function detectDirectorySync(required, { env = process.env, platform = process.platform, readText = (file) => readOptionalText({ file }), canonicalPath = canonicalOrResolved, listDirectories = listDirectoriesOrEmpty, exists = fs.existsSync, oneDriveRoots = [], windowsOneDriveRoots = () => readWindowsOneDriveRoots({}), } = {}) {
98
+ return detectWithPorts(required, { env, platform, readText, canonicalPath, listDirectories, exists, oneDriveRoots, windowsOneDriveRoots });
99
+ }
87
100
  //# sourceMappingURL=directory-sync.js.map
@@ -3,6 +3,23 @@ import { createHash } from "node:crypto";
3
3
  import { resolveDirectoryHome } from "./directory-home.js";
4
4
  import { resolveUserDataDirectories } from "./user-data-directories.js";
5
5
  import { detectDirectorySync } from "./directory-sync.js";
6
+ const SAFE_SITE_ID = /^[a-zA-Z0-9_-]+$/;
7
+ const invalidMarker = (markerPath) => new Error(`Interrupted database move has an invalid recovery marker: ${markerPath}. Restore the pre-move backup before starting.`);
8
+ function assertDatabaseFilename(filename) {
9
+ if (path.basename(filename) !== filename || !filename.endsWith(".db"))
10
+ throw new Error("A database filename must be a .db basename.");
11
+ }
12
+ /** iCloud keeps data.nosync out of sync; every other provider syncs live databases. */
13
+ function syncsDatabases(sync) { return sync !== null && sync !== "iCloud"; }
14
+ /** Old markers use the fixed home2 name; new moves identify their unique backup. Admit
15
+ * only a sibling of this source so malformed metadata cannot supply unrelated evidence. */
16
+ function markerBackup(source, namedBackup) {
17
+ if (namedBackup === undefined)
18
+ return source + ".home2-backup";
19
+ if (typeof namedBackup !== "string" || path.dirname(namedBackup) !== path.dirname(source) || !namedBackup.startsWith(source + ".relocation-") || !namedBackup.endsWith("-backup"))
20
+ return undefined;
21
+ return namedBackup;
22
+ }
6
23
  /** One atomic settings owner for multiple processes. Existing DB files always win over preferences:
7
24
  * changing placement controls new databases, never silently moves or abandons existing ones.
8
25
  * @complexity O(n) canonicalizations for n roots; DB lookup probes three fixed locations.
@@ -47,11 +64,37 @@ export function createDirectoryUserConfig({ homeDir, defaultFolder, envName, pol
47
64
  const nestedHome = legacyHomes.includes(savedHome) ? paths.join(savedHome, policy.defaultSubfolder) : savedHome;
48
65
  const sitesHome = expand(env[envName] ?? nestedHome);
49
66
  const sync = syncProvider(sitesHome);
50
- return {
51
- sitesHome,
52
- siteRoots: distinct(env[policy.rootsEnvName] !== undefined ? env[policy.rootsEnvName].split(",").map((root) => root.trim()).filter(Boolean) : saved.siteRoots ?? policy.sourceRoots ?? []),
53
- databases: saved.databases ?? (sync && sync !== "iCloud" ? "appData" : "inSite"),
67
+ return { sitesHome, siteRoots: distinct(configuredRoots(saved)), databases: saved.databases ?? (syncsDatabases(sync) ? "appData" : "inSite") };
68
+ }
69
+ function configuredRoots(saved) {
70
+ const fromEnv = env[policy.rootsEnvName];
71
+ return fromEnv !== undefined ? fromEnv.split(",").map((root) => root.trim()).filter(Boolean) : saved.siteRoots ?? policy.sourceRoots ?? [];
72
+ }
73
+ // Prefer explicit settings, never coerce an existing regular file into a directory.
74
+ function assertDirectories(checked) {
75
+ for (const directory of [checked.sitesHome, ...(checked.siteRoots ?? [])]) {
76
+ if (directory === undefined)
77
+ continue;
78
+ const expanded = expand(directory);
79
+ if (exists(expanded) && !isDirectory(expanded))
80
+ throw new Error(`Sites folder is not a directory: ${directory}`);
81
+ }
82
+ }
83
+ function assertNotOverridden(checked) {
84
+ if (checked.sitesHome !== undefined && env[envName] !== undefined)
85
+ throw new Error(`${envName} overrides this setting. Unset it before changing the directory home.`);
86
+ if (checked.siteRoots !== undefined && env[policy.rootsEnvName] !== undefined)
87
+ throw new Error(`${policy.rootsEnvName} overrides this setting. Unset it before changing site roots.`);
88
+ }
89
+ function merged(previous, checked, incremental) {
90
+ const next = { ...previous, ...checked,
91
+ ...(checked.sitesHome !== undefined ? { sitesHome: expand(checked.sitesHome) } : {}),
92
+ ...(checked.siteRoots !== undefined ? { siteRoots: distinct(checked.siteRoots) } : {}),
54
93
  };
94
+ // Empty incremental extras restore implicit defaults instead of persisting those defaults.
95
+ if (incremental && checked.siteRoots?.length === 0)
96
+ delete next.siteRoots;
97
+ return next;
55
98
  }
56
99
  function update({ patch }, { transform } = {}) {
57
100
  // Compute incremental root changes inside the same lock as the write: a sibling process's
@@ -59,25 +102,9 @@ export function createDirectoryUserConfig({ homeDir, defaultFolder, envName, pol
59
102
  file.withLock({ critical: () => {
60
103
  const previous = stored(true);
61
104
  const checked = validateDirectoryUserConfig({ value: transform ? transform(read({}), previous) : patch });
62
- // Prefer explicit settings, never coerce an existing regular file into a directory.
63
- for (const directory of [checked.sitesHome, ...(checked.siteRoots ?? [])]) {
64
- if (directory === undefined)
65
- continue;
66
- const expanded = expand(directory);
67
- if (exists(expanded) && !isDirectory(expanded))
68
- throw new Error(`Sites folder is not a directory: ${directory}`);
69
- }
70
- if (checked.sitesHome !== undefined && env[envName] !== undefined)
71
- throw new Error(`${envName} overrides this setting. Unset it before changing the directory home.`);
72
- if (checked.siteRoots !== undefined && env[policy.rootsEnvName] !== undefined)
73
- throw new Error(`${policy.rootsEnvName} overrides this setting. Unset it before changing site roots.`);
74
- const next = { ...previous, ...checked,
75
- ...(checked.sitesHome !== undefined ? { sitesHome: expand(checked.sitesHome) } : {}),
76
- ...(checked.siteRoots !== undefined ? { siteRoots: distinct(checked.siteRoots) } : {}),
77
- };
78
- // Empty incremental extras restore implicit defaults instead of persisting those defaults.
79
- if (transform && checked.siteRoots?.length === 0)
80
- delete next.siteRoots;
105
+ assertDirectories(checked);
106
+ assertNotOverridden(checked);
107
+ const next = merged(previous, checked, transform !== undefined);
81
108
  // With no other saved settings, removing the final extra also restores an absent config.
82
109
  // The Node assembly supplies removal; non-Node adapters may retain the equivalent {}.
83
110
  if (transform && Object.keys(next).length === 0 && removeFile)
@@ -94,50 +121,62 @@ export function createDirectoryUserConfig({ homeDir, defaultFolder, envName, pol
94
121
  return { siteRoots: distinct([...(saved.siteRoots ?? []), ...add]).filter((root) => !excluded.has(root) && !protectedRoots.has(root)) };
95
122
  } });
96
123
  }
97
- function databasePath({ dir, id, filename, placement }, _options = {}) {
98
- if (path.basename(filename) !== filename || !filename.endsWith(".db"))
99
- throw new Error("A database filename must be a .db basename.");
100
- const inSite = path.join(dir, "data.nosync", filename), legacy = path.join(dir, filename);
101
- if (!/^[a-zA-Z0-9_-]+$/.test(id)) {
102
- if (!placement && exists(legacy))
103
- return legacy;
104
- throw new Error("A database directory requires a safe, stable site identity.");
105
- }
106
- const appData = path.join(directories.appData, "databases", id, filename);
107
- if (placement)
108
- return placement === "inSite" ? inSite : appData;
109
- const candidates = [legacy, inSite, appData];
110
- // A move journal is written before copying; the old name stays authoritative until verification
111
- // retires it. Recovery never guesses from two DB sizes/timestamps or asks users to delete data.
124
+ // A move journal is written before copying; the old name stays authoritative until verification
125
+ // retires it. Recovery never guesses from two DB sizes/timestamps or asks users to delete data.
126
+ function moveMarker(source, candidates) {
127
+ const markerPath = source + ".home2-relocation.json", marker = fileAt(markerPath).read({});
128
+ if (marker.state === "missing")
129
+ return undefined;
130
+ const value = marker.state === "ok" ? marker.value : undefined;
131
+ const target = value?.to, backup = markerBackup(source, value?.backup);
132
+ if (typeof target !== "string" || !candidates.includes(target) || backup === undefined)
133
+ throw invalidMarker(markerPath);
134
+ return { markerPath, target, backup };
135
+ }
136
+ function authoritativeCopy(source, { markerPath, target, backup }) {
137
+ if (exists(source))
138
+ return source;
139
+ if (exists(backup) && exists(target))
140
+ return target;
141
+ throw new Error(`Interrupted database move needs recovery: ${markerPath}. Restore the pre-move backup before starting.`);
142
+ }
143
+ function recoveredMove(candidates) {
112
144
  for (const source of candidates) {
113
- const markerPath = source + ".home2-relocation.json", marker = fileAt(markerPath).read({});
114
- if (marker.state === "missing")
145
+ const marker = moveMarker(source, candidates);
146
+ if (!marker)
115
147
  continue;
116
- const target = marker.state === "ok" ? marker.value?.to : undefined;
117
- if (typeof target !== "string" || !candidates.includes(target))
118
- throw new Error(`Interrupted database move has an invalid recovery marker: ${markerPath}. Restore the pre-move backup before starting.`);
119
- const namedBackup = marker.state === "ok" ? marker.value?.backup : undefined;
120
- // Old markers use the fixed home2 name; new moves identify their unique backup. Admit
121
- // only a sibling of this source so malformed metadata cannot supply unrelated evidence.
122
- if (namedBackup !== undefined && (typeof namedBackup !== "string" || path.dirname(namedBackup) !== path.dirname(source) || !namedBackup.startsWith(source + ".relocation-") || !namedBackup.endsWith("-backup")))
123
- throw new Error(`Interrupted database move has an invalid recovery marker: ${markerPath}. Restore the pre-move backup before starting.`);
124
- const backup = typeof namedBackup === "string" ? namedBackup : source + ".home2-backup";
125
- const authoritative = exists(source) ? source : exists(backup) && exists(target) ? target : undefined;
126
- if (!authoritative)
127
- throw new Error(`Interrupted database move needs recovery: ${markerPath}. Restore the pre-move backup before starting.`);
128
- report(`Recovered interrupted database move using ${authoritative}. Keep the backup files (${backup}); finish the move before changing database placement.`);
148
+ const authoritative = authoritativeCopy(source, marker);
149
+ report(`Recovered interrupted database move using ${authoritative}. Keep the backup files (${marker.backup}); finish the move before changing database placement.`);
129
150
  return authoritative;
130
151
  }
152
+ return undefined;
153
+ }
154
+ function existingCopy(candidates, filename) {
131
155
  const present = candidates.filter(exists);
132
156
  if (present.length > 1)
133
157
  throw new Error(`Multiple copies of ${filename} exist for this site. Choose the authoritative database before starting it.`);
134
- if (present[0])
135
- return present[0];
158
+ return present[0];
159
+ }
160
+ function unsafeIdentityPath(legacy, placement) {
161
+ if (!placement && exists(legacy))
162
+ return legacy;
163
+ throw new Error("A database directory requires a safe, stable site identity.");
164
+ }
165
+ function databasePath({ dir, id, filename, placement }, _options = {}) {
166
+ assertDatabaseFilename(filename);
167
+ const inSite = path.join(dir, "data.nosync", filename), legacy = path.join(dir, filename);
168
+ if (!SAFE_SITE_ID.test(id))
169
+ return unsafeIdentityPath(legacy, placement);
170
+ const appData = path.join(directories.appData, "databases", id, filename);
171
+ if (placement)
172
+ return placement === "inSite" ? inSite : appData;
173
+ const candidates = [legacy, inSite, appData];
174
+ const found = recoveredMove(candidates) ?? existingCopy(candidates, filename);
175
+ if (found)
176
+ return found;
136
177
  // A missing chat DB follows an existing content DB even after the user changes preferences.
137
- if (filename !== "content.db") {
138
- const content = databasePath({ dir, id, filename: "content.db" });
139
- return path.join(path.dirname(content), filename);
140
- }
178
+ if (filename !== "content.db")
179
+ return path.join(path.dirname(databasePath({ dir, id, filename: "content.db" })), filename);
141
180
  return read({}).databases === "inSite" ? inSite : appData;
142
181
  }
143
182
  return { read, update, updateRoots, databasePath, filePath, ...directories,
@@ -157,12 +196,17 @@ export function createDirectoryUserConfig({ homeDir, defaultFolder, envName, pol
157
196
  syncWarning: (_required, _options = {}) => {
158
197
  const config = read({});
159
198
  const sync = syncProvider(config.sitesHome);
160
- if (!sync || sync === "iCloud")
199
+ if (!syncsDatabases(sync))
161
200
  return null;
162
201
  return { provider: sync, message: config.databases === "appData" ? `${sync} syncs the sites folder. New databases use OS app-data for safety. Site folders are not self-contained: include their app-data databases when transferring them.` : `${sync} syncs the sites folder. It does not honor data.nosync; syncing live SQLite databases can corrupt them. Use app-data databases or a local sites folder.`, localHome };
163
202
  },
164
203
  };
165
204
  }
205
+ const SETTING_RULES = [
206
+ ["sitesHome", (value) => typeof value === "string" && Boolean(value.trim()), "sitesHome must name a directory."],
207
+ ["siteRoots", (value) => Array.isArray(value) && value.every((root) => typeof root === "string" && root.trim()), "siteRoots must be an array of directory paths."],
208
+ ["databases", (value) => value === "inSite" || value === "appData", 'databases must be "inSite" or "appData".'],
209
+ ];
166
210
  /** Validate only the three public settings; reject arrays, unknown fields and invalid enums.
167
211
  * @complexity O(n) root strings. No I/O or mutation.
168
212
  */
@@ -170,14 +214,11 @@ export function validateDirectoryUserConfig({ value }, _options = {}) {
170
214
  if (!value || typeof value !== "object" || Array.isArray(value))
171
215
  throw new Error("User config must be an object.");
172
216
  const row = value;
173
- if (Object.keys(row).some((key) => !["sitesHome", "siteRoots", "databases"].includes(key)))
217
+ if (Object.keys(row).some((key) => !SETTING_RULES.some(([known]) => known === key)))
174
218
  throw new Error("Unknown user config setting.");
175
- if (row.sitesHome !== undefined && (typeof row.sitesHome !== "string" || !row.sitesHome.trim()))
176
- throw new Error("sitesHome must name a directory.");
177
- if (row.siteRoots !== undefined && (!Array.isArray(row.siteRoots) || !row.siteRoots.every((root) => typeof root === "string" && root.trim())))
178
- throw new Error("siteRoots must be an array of directory paths.");
179
- if (row.databases !== undefined && row.databases !== "inSite" && row.databases !== "appData")
180
- throw new Error('databases must be "inSite" or "appData".');
219
+ for (const [key, valid, message] of SETTING_RULES)
220
+ if (row[key] !== undefined && !valid(row[key]))
221
+ throw new Error(message);
181
222
  return row;
182
223
  }
183
224
  //# sourceMappingURL=directory-user-config.js.map
@@ -129,4 +129,15 @@ export declare function createNodeDirectoryWorkspace({ homeDir, defaultFolder, e
129
129
  record: import("./directory-running-lock.js").DirectoryRunningRecord;
130
130
  };
131
131
  };
132
+ /** Canonicalize existing ancestors without creating the selected directory.
133
+ * The default canonicalizer of {@link createNodeDirectoryWorkspace}. `realpath` is the native
134
+ * resolver by default; it is injectable so a missing filesystem root (an unmounted Windows drive)
135
+ * can be exercised on any host. A missing root resolves to its absolute form.
136
+ * @complexity O(d) realpath probes for d path segments.
137
+ */
138
+ export declare function canonicalNodeDirectory({ path: dir }: {
139
+ path: string;
140
+ }, { realpath }?: {
141
+ realpath?: (dir: string) => string;
142
+ }): string;
132
143
  //# sourceMappingURL=directory-workspace.d.ts.map
@@ -94,6 +94,9 @@ export function createNodeDirectoryWorkspace({ homeDir, defaultFolder, envName,
94
94
  const previous = oldCatalogs();
95
95
  current.merge({ entries: previous.flatMap((catalog) => catalog.list({})), migrations: previous.flatMap((catalog) => catalog.migrations({})), migration: "app-data-catalog-v1" });
96
96
  };
97
+ // One lease adapter for both the app-data and the site-local (legacy) lease locations.
98
+ const runningLockAt = (filePath) => createDirectoryRunningLock({ file: fileAt(filePath), isAlive: ports.process.isAlive,
99
+ remove: () => ports.filesystem.rmSync(filePath, { force: true }) }, { ...(ports.process.bootIdentity ? { bootIdentity: ports.process.bootIdentity } : {}) });
97
100
  const mutate = (operation) => settings.withLock({ critical: () => { importOldCatalog(); return operation(); } });
98
101
  // Serialize pointer changes with catalog mutations. A caller that obtained a registry before
99
102
  // settings changed must still write into the current home, never strand a late registration.
@@ -181,7 +184,6 @@ export function createNodeDirectoryWorkspace({ homeDir, defaultFolder, envName,
181
184
  const staged = createDirectoryRegistry({ canonicalPath, file: {
182
185
  read: () => state === undefined ? { state: "missing" } : { state: "ok", value: state },
183
186
  write: ({ value: next }) => { state = next; }, withLock: ({ critical }) => critical(),
184
- tempPath: () => "", quarantine: () => false,
185
187
  } });
186
188
  if (!staged.hasMigration({ migration: "app-data-catalog-v1" })) {
187
189
  const previous = oldCatalogs();
@@ -216,14 +218,12 @@ export function createNodeDirectoryWorkspace({ homeDir, defaultFolder, envName,
216
218
  },
217
219
  registry: (_required, _options = {}) => registry,
218
220
  running: ({ path: dir }, _options = {}) => {
219
- const filePath = config?.runningPath({ dir }) ?? path.join(canonicalPath({ path: dir }), runningName);
220
- const current = createDirectoryRunningLock({ file: fileAt(filePath), isAlive: ports.process.isAlive,
221
- remove: () => ports.filesystem.rmSync(filePath, { force: true }) }, { ...(ports.process.bootIdentity ? { bootIdentity: ports.process.bootIdentity } : {}) });
221
+ const legacyPath = path.join(canonicalPath({ path: dir }), runningName);
222
+ const current = runningLockAt(config?.runningPath({ dir }) ?? legacyPath);
222
223
  if (!config)
223
224
  return current;
224
- const legacyPath = path.join(canonicalPath({ path: dir }), runningName);
225
- const legacy = createDirectoryRunningLock({ file: fileAt(legacyPath), isAlive: ports.process.isAlive,
226
- remove: () => ports.filesystem.rmSync(legacyPath, { force: true }) }, { ...(ports.process.bootIdentity ? { bootIdentity: ports.process.bootIdentity } : {}) });
225
+ // The legacy lease is only ever peeked, so its removal callback is never invoked here.
226
+ const legacy = runningLockAt(legacyPath);
227
227
  return { ...current,
228
228
  // Older servers may still own a site-local lease. Observe it without moving/editing it;
229
229
  // claim serializes against new clients and refuses the old live owner.
@@ -249,10 +249,15 @@ export function createNodeDirectoryWorkspace({ homeDir, defaultFolder, envName,
249
249
  overrides: { sitesHome: env[envName] !== undefined, siteRoots: env[userConfig.rootsEnvName] !== undefined } };
250
250
  }
251
251
  }
252
- /** Canonicalize existing ancestors without creating the selected directory. */
253
- function canonicalNodeDirectory({ path: dir }, _options = {}) {
252
+ /** Canonicalize existing ancestors without creating the selected directory.
253
+ * The default canonicalizer of {@link createNodeDirectoryWorkspace}. `realpath` is the native
254
+ * resolver by default; it is injectable so a missing filesystem root (an unmounted Windows drive)
255
+ * can be exercised on any host. A missing root resolves to its absolute form.
256
+ * @complexity O(d) realpath probes for d path segments.
257
+ */
258
+ export function canonicalNodeDirectory({ path: dir }, { realpath = fs.realpathSync } = {}) {
254
259
  try {
255
- return fs.realpathSync(dir);
260
+ return realpath(dir);
256
261
  }
257
262
  catch (error) {
258
263
  if (error.code !== "ENOENT")
@@ -260,7 +265,7 @@ function canonicalNodeDirectory({ path: dir }, _options = {}) {
260
265
  // Resolve the nearest existing ancestor too: a not-yet-created home may sit below a
261
266
  // symlink (notably /var on macOS) and must compare equal before and after creation.
262
267
  const absolute = path.resolve(dir), parent = path.dirname(absolute);
263
- return parent === absolute ? absolute : path.join(canonicalNodeDirectory({ path: parent }), path.basename(absolute));
268
+ return parent === absolute ? absolute : path.join(canonicalNodeDirectory({ path: parent }, { realpath }), path.basename(absolute));
264
269
  }
265
270
  }
266
271
  //# sourceMappingURL=directory-workspace.js.map
@@ -179,46 +179,58 @@ export function updateDurableJsonFiles({ changes, fileAt, remove }, _options = {
179
179
  throw new Error("Duplicate JSON transaction path.");
180
180
  const ordered = [...changes].sort((a, b) => a.filePath.localeCompare(b.filePath));
181
181
  const files = ordered.map((change) => fileAt(change.filePath));
182
- const lock = (index) => {
183
- if (index < files.length) {
184
- files[index].withLock({ critical: () => lock(index + 1) });
185
- return;
182
+ withLocksInOrder({ files, critical: () => applyJsonUpdates({ updates: planJsonUpdates({ ordered, files }), remove }) });
183
+ }
184
+ /** Nests each file's lock in path order, then runs the critical section with all of them held. */
185
+ function withLocksInOrder({ files, critical }, { index = 0 } = {}) {
186
+ if (index >= files.length) {
187
+ critical();
188
+ return;
189
+ }
190
+ files[index].withLock({ critical: () => withLocksInOrder({ files, critical }, { index: index + 1 }) });
191
+ }
192
+ /** Every transform runs before any write, so a refusal leaves all participants untouched. */
193
+ function planJsonUpdates({ ordered, files }) {
194
+ return ordered.map((change, i) => {
195
+ const before = files[i].read({});
196
+ if (before.state === "unreadable")
197
+ throw new Error(`JSON reference is unreadable: ${change.filePath}. Nothing was moved.`);
198
+ const previous = before.state === "ok" ? before.value : undefined;
199
+ return { file: files[i], filePath: change.filePath, previous, next: change.transform(previous) };
200
+ }).filter((item) => JSON.stringify(item.previous) !== JSON.stringify(item.next));
201
+ }
202
+ /** Undefined means the file is absent, so it is removed rather than written. */
203
+ function persistJsonValue({ item, value, remove }) {
204
+ if (value === undefined)
205
+ remove({ filePath: item.filePath });
206
+ else
207
+ item.file.write({ value });
208
+ }
209
+ function applyJsonUpdates({ updates, remove }) {
210
+ const attempted = [];
211
+ try {
212
+ for (const item of updates) {
213
+ attempted.push(item); // A write can fail after its rename; include it in rollback.
214
+ persistJsonValue({ item, value: item.next, remove });
186
215
  }
187
- const updates = ordered.map((change, i) => {
188
- const before = files[i].read({});
189
- if (before.state === "unreadable")
190
- throw new Error(`JSON reference is unreadable: ${change.filePath}. Nothing was moved.`);
191
- const previous = before.state === "ok" ? before.value : undefined;
192
- return { file: files[i], filePath: change.filePath, previous, next: change.transform(previous) };
193
- }).filter((item) => JSON.stringify(item.previous) !== JSON.stringify(item.next));
194
- const attempted = [];
216
+ }
217
+ catch (failure) {
218
+ rollbackJsonUpdates({ attempted, remove, failure });
219
+ }
220
+ }
221
+ /** Restores prior bytes newest-first; keeps going past individual rollback failures. */
222
+ function rollbackJsonUpdates({ attempted, remove, failure }) {
223
+ const errors = [failure];
224
+ for (const item of attempted.reverse()) {
195
225
  try {
196
- for (const item of updates) {
197
- attempted.push(item); // A write can fail after its rename; include it in rollback.
198
- if (item.next === undefined)
199
- remove({ filePath: item.filePath });
200
- else
201
- item.file.write({ value: item.next });
202
- }
226
+ persistJsonValue({ item, value: item.previous, remove });
203
227
  }
204
- catch (failure) {
205
- const errors = [failure];
206
- for (const item of attempted.reverse()) {
207
- try {
208
- if (item.previous === undefined)
209
- remove({ filePath: item.filePath });
210
- else
211
- item.file.write({ value: item.previous });
212
- }
213
- catch (error) {
214
- errors.push(error);
215
- }
216
- }
217
- if (errors.length > 1)
218
- throw new AggregateError(errors, "JSON reference rollback failed.");
219
- throw failure;
228
+ catch (error) {
229
+ errors.push(error);
220
230
  }
221
- };
222
- lock(0);
231
+ }
232
+ if (errors.length > 1)
233
+ throw new AggregateError(errors, "JSON reference rollback failed.");
234
+ throw failure;
223
235
  }
224
236
  //# sourceMappingURL=durable-json.js.map