sealkeep 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (274) hide show
  1. package/ARCHITECTURE.md +163 -14
  2. package/CHANGELOG.md +223 -1
  3. package/CONTROL_PLANE.md +2 -2
  4. package/LICENSE +1 -1
  5. package/README.md +108 -22
  6. package/THIRD_PARTY.md +2 -2
  7. package/THREAT_MODEL.md +23 -4
  8. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/chunk-access.d.ts +26 -4
  9. package/dist/packages/sealkeep-crypto/src/chunk-access.js +219 -0
  10. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.d.ts +11 -1
  11. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.js +58 -21
  12. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.d.ts +2 -2
  13. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.d.ts +1 -0
  14. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.js +1 -0
  15. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.js +4 -4
  16. package/dist/packages/sealkeep-crypto/src/squeeze.d.ts +6 -0
  17. package/dist/packages/sealkeep-crypto/src/squeeze.js +39 -0
  18. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.d.ts +36 -3
  19. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.js +196 -37
  20. package/dist/site/index.html +1808 -1904
  21. package/dist/site/llms.txt +67 -0
  22. package/dist/site/trust/architecture-data-flow.html +53 -0
  23. package/dist/site/trust/audit-roadmap.html +37 -0
  24. package/dist/site/trust/deployment-responsibility.html +11 -0
  25. package/dist/site/trust/dpa-sample.html +30 -0
  26. package/dist/site/trust/release-provenance.html +21 -0
  27. package/dist/site/trust/subprocessors.html +15 -0
  28. package/dist/site/trust/threat-model.html +34 -0
  29. package/dist/site/trust/toms.html +41 -0
  30. package/dist/site/trust-document.css +32 -0
  31. package/dist/site/trust.html +73 -0
  32. package/dist/site/visual/assets/index-8Nxnkc7o.js +207 -0
  33. package/dist/site/visual/assets/index-oxLy1bpy.css +1 -0
  34. package/dist/site/visual/index.html +18 -0
  35. package/dist/site.zip +0 -0
  36. package/dist/src/activity.d.ts +9 -0
  37. package/dist/src/activity.js +90 -1
  38. package/dist/src/adapters.d.ts +175 -5
  39. package/dist/src/adapters.js +961 -73
  40. package/dist/src/agent-context.d.ts +135 -0
  41. package/dist/src/agent-context.js +1059 -0
  42. package/dist/src/archive-copies.d.ts +47 -0
  43. package/dist/src/archive-copies.js +179 -0
  44. package/dist/src/audit.d.ts +1 -1
  45. package/dist/src/audit.js +29 -4
  46. package/dist/src/autopilot.d.ts +52 -7
  47. package/dist/src/autopilot.js +143 -25
  48. package/dist/src/background-bandwidth.d.ts +46 -0
  49. package/dist/src/background-bandwidth.js +301 -0
  50. package/dist/src/background-cpu.d.ts +82 -0
  51. package/dist/src/background-cpu.js +212 -0
  52. package/dist/src/background-worker-error.d.ts +12 -0
  53. package/dist/src/background-worker-error.js +18 -0
  54. package/dist/src/branding.d.ts +24 -1
  55. package/dist/src/branding.js +31 -1
  56. package/dist/src/bridge.d.ts +233 -0
  57. package/dist/src/bridge.js +604 -0
  58. package/dist/src/byte-stream.d.ts +91 -0
  59. package/dist/src/byte-stream.js +385 -0
  60. package/dist/src/chunk-store.d.ts +41 -8
  61. package/dist/src/chunk-store.js +161 -65
  62. package/dist/src/cli.js +1698 -163
  63. package/dist/src/cloud.d.ts +841 -31
  64. package/dist/src/cloud.js +3196 -277
  65. package/dist/src/context-background.d.ts +37 -0
  66. package/dist/src/context-background.js +309 -0
  67. package/dist/src/context-drain-child.d.ts +1 -0
  68. package/dist/src/context-drain-child.js +98 -0
  69. package/dist/src/context-reader.d.ts +118 -0
  70. package/dist/src/context-reader.js +447 -0
  71. package/dist/src/control-plane/auth.d.ts +32 -4
  72. package/dist/src/control-plane/auth.js +85 -24
  73. package/dist/src/control-plane/server.js +19 -6
  74. package/dist/src/control-plane.d.ts +17 -1
  75. package/dist/src/control-plane.js +32 -6
  76. package/dist/src/crypto.d.ts +1 -1
  77. package/dist/src/crypto.js +5 -5
  78. package/dist/src/daemon-lease.d.ts +70 -0
  79. package/dist/src/daemon-lease.js +420 -0
  80. package/dist/src/daemon.d.ts +94 -1
  81. package/dist/src/daemon.js +1082 -105
  82. package/dist/src/darwin-service-policy.d.ts +41 -0
  83. package/dist/src/darwin-service-policy.js +60 -0
  84. package/dist/src/dashboard-cli.js +15 -15
  85. package/dist/src/device-authorization.d.ts +37 -0
  86. package/dist/src/device-authorization.js +199 -0
  87. package/dist/src/device-enrollment.d.ts +91 -0
  88. package/dist/src/device-enrollment.js +349 -0
  89. package/dist/src/disk.d.ts +17 -12
  90. package/dist/src/disk.js +43 -17
  91. package/dist/src/doctor.d.ts +35 -1
  92. package/dist/src/doctor.js +316 -41
  93. package/dist/src/durable-ticket-lock.d.ts +24 -0
  94. package/dist/src/durable-ticket-lock.js +232 -0
  95. package/dist/src/enroll.d.ts +1 -1
  96. package/dist/src/enroll.js +13 -7
  97. package/dist/src/env.d.ts +10 -1
  98. package/dist/src/env.js +11 -3
  99. package/dist/src/errors.d.ts +8 -8
  100. package/dist/src/errors.js +6 -6
  101. package/dist/src/flush.d.ts +12 -0
  102. package/dist/src/flush.js +37 -0
  103. package/dist/src/heartbeat.d.ts +86 -12
  104. package/dist/src/heartbeat.js +415 -29
  105. package/dist/src/index-background-watchdog.d.ts +1 -0
  106. package/dist/src/index-background-watchdog.js +94 -0
  107. package/dist/src/index-background-work.d.ts +21 -0
  108. package/dist/src/index-background-work.js +25 -0
  109. package/dist/src/index-background.d.ts +64 -0
  110. package/dist/src/index-background.js +394 -0
  111. package/dist/src/index-build-child.d.ts +1 -0
  112. package/dist/src/index-build-child.js +109 -0
  113. package/dist/src/index-manifest.d.ts +52 -0
  114. package/dist/src/index-manifest.js +444 -0
  115. package/dist/src/index-publication-proof.d.ts +84 -0
  116. package/dist/src/index-publication-proof.js +380 -0
  117. package/dist/src/index-publication-state.d.ts +149 -0
  118. package/dist/src/index-publication-state.js +696 -0
  119. package/dist/src/index-publication-verifier.d.ts +89 -0
  120. package/dist/src/index-publication-verifier.js +341 -0
  121. package/dist/src/index-publish.d.ts +62 -0
  122. package/dist/src/index-publish.js +540 -0
  123. package/dist/src/index-scratch-cleanup.d.ts +19 -0
  124. package/dist/src/index-scratch-cleanup.js +166 -0
  125. package/dist/src/index-segment-types.d.ts +132 -0
  126. package/dist/src/index-segment-types.js +21 -0
  127. package/dist/src/index-segments.d.ts +9 -0
  128. package/dist/src/index-segments.js +516 -0
  129. package/dist/src/index-store.d.ts +123 -0
  130. package/dist/src/index-store.js +495 -0
  131. package/dist/src/index-sync.d.ts +91 -15
  132. package/dist/src/index-sync.js +286 -53
  133. package/dist/src/index-upgrade-publication.d.ts +30 -0
  134. package/dist/src/index-upgrade-publication.js +179 -0
  135. package/dist/src/integration-manager.d.ts +32 -0
  136. package/dist/src/integration-manager.js +394 -0
  137. package/dist/src/leakscan.js +1 -1
  138. package/dist/src/local-api.d.ts +56 -15
  139. package/dist/src/local-api.js +4979 -541
  140. package/dist/src/machine-settings.d.ts +51 -0
  141. package/dist/src/machine-settings.js +166 -0
  142. package/dist/src/managed-chunks.d.ts +5 -2
  143. package/dist/src/managed-chunks.js +14 -14
  144. package/dist/src/mcp-install.d.ts +11 -9
  145. package/dist/src/mcp-install.js +73 -28
  146. package/dist/src/mcp-workspace.d.ts +18 -0
  147. package/dist/src/mcp-workspace.js +50 -0
  148. package/dist/src/mcp.js +294 -25
  149. package/dist/src/migrate.js +27 -21
  150. package/dist/src/notify.d.ts +1 -1
  151. package/dist/src/notify.js +5 -5
  152. package/dist/src/offload.d.ts +201 -14
  153. package/dist/src/offload.js +1848 -140
  154. package/dist/src/onboarding.d.ts +8 -1
  155. package/dist/src/onboarding.js +4 -4
  156. package/dist/src/packages.d.ts +2 -2
  157. package/dist/src/packages.js +10 -2
  158. package/dist/src/passkey.d.ts +0 -1
  159. package/dist/src/passkey.js +2 -7
  160. package/dist/src/password-lock.d.ts +2 -2
  161. package/dist/src/password-lock.js +6 -6
  162. package/dist/src/paths.d.ts +2 -0
  163. package/dist/src/paths.js +2 -0
  164. package/dist/src/presence.d.ts +86 -0
  165. package/dist/src/presence.js +240 -0
  166. package/dist/src/progress-deadline.d.ts +21 -0
  167. package/dist/src/progress-deadline.js +91 -0
  168. package/dist/src/project-repair.d.ts +55 -0
  169. package/dist/src/project-repair.js +131 -0
  170. package/dist/src/providers/gcs.d.ts +28 -7
  171. package/dist/src/providers/gcs.js +35 -24
  172. package/dist/src/providers/gdrive.d.ts +71 -8
  173. package/dist/src/providers/gdrive.js +223 -50
  174. package/dist/src/providers/index.d.ts +11 -3
  175. package/dist/src/providers/index.js +30 -10
  176. package/dist/src/providers/s3.d.ts +30 -8
  177. package/dist/src/providers/s3.js +41 -30
  178. package/dist/src/providers/safe-storage-fetch.d.ts +12 -0
  179. package/dist/src/providers/safe-storage-fetch.js +72 -0
  180. package/dist/src/queue.d.ts +136 -19
  181. package/dist/src/queue.js +862 -96
  182. package/dist/src/reclaim-transaction.d.ts +156 -0
  183. package/dist/src/reclaim-transaction.js +1027 -0
  184. package/dist/src/recovery-codes.d.ts +32 -0
  185. package/dist/src/recovery-codes.js +338 -0
  186. package/dist/src/recovery.js +12 -9
  187. package/dist/src/rehydrate.d.ts +25 -22
  188. package/dist/src/rehydrate.js +319 -23
  189. package/dist/src/restore.d.ts +57 -4
  190. package/dist/src/restore.js +272 -36
  191. package/dist/src/resume-stub.d.ts +92 -0
  192. package/dist/src/resume-stub.js +417 -0
  193. package/dist/src/retention.d.ts +98 -7
  194. package/dist/src/retention.js +1066 -62
  195. package/dist/src/rotate.js +3 -3
  196. package/dist/src/search.d.ts +566 -8
  197. package/dist/src/search.js +5897 -290
  198. package/dist/src/secrets.d.ts +51 -7
  199. package/dist/src/secrets.js +316 -24
  200. package/dist/src/service.d.ts +49 -11
  201. package/dist/src/service.js +776 -35
  202. package/dist/src/share.js +3 -3
  203. package/dist/src/shared-spaces.d.ts +98 -0
  204. package/dist/src/shared-spaces.js +214 -0
  205. package/dist/src/source-reader.d.ts +73 -0
  206. package/dist/src/source-reader.js +715 -0
  207. package/dist/src/spool.d.ts +1 -1
  208. package/dist/src/spool.js +1 -1
  209. package/dist/src/start-tui.js +2 -1
  210. package/dist/src/start.js +2 -2
  211. package/dist/src/storage-endpoint.d.ts +21 -0
  212. package/dist/src/storage-endpoint.js +122 -0
  213. package/dist/src/storage-setup.js +12 -12
  214. package/dist/src/storage-targets.d.ts +109 -6
  215. package/dist/src/storage-targets.js +975 -67
  216. package/dist/src/stream-to-cloud.d.ts +5 -1
  217. package/dist/src/stream-to-cloud.js +34 -14
  218. package/dist/src/sync-rules.d.ts +31 -6
  219. package/dist/src/sync-rules.js +153 -14
  220. package/dist/src/team-backfill-scheduling.d.ts +8 -0
  221. package/dist/src/team-backfill-scheduling.js +33 -0
  222. package/dist/src/team-backfill.d.ts +116 -0
  223. package/dist/src/team-backfill.js +1429 -0
  224. package/dist/src/team-index-cache.d.ts +16 -0
  225. package/dist/src/team-index-cache.js +152 -0
  226. package/dist/src/team-offboarding.d.ts +38 -0
  227. package/dist/src/team-offboarding.js +1043 -0
  228. package/dist/src/team-presence.d.ts +127 -0
  229. package/dist/src/team-presence.js +904 -0
  230. package/dist/src/team-publication-policy.d.ts +20 -0
  231. package/dist/src/team-publication-policy.js +140 -0
  232. package/dist/src/team-realtime.d.ts +68 -0
  233. package/dist/src/team-realtime.js +816 -0
  234. package/dist/src/team-source-facts-cache.d.ts +23 -0
  235. package/dist/src/team-source-facts-cache.js +255 -0
  236. package/dist/src/trash.d.ts +1 -1
  237. package/dist/src/trash.js +2 -2
  238. package/dist/src/tui.js +11 -12
  239. package/dist/src/types.d.ts +173 -7
  240. package/dist/src/types.js +20 -0
  241. package/dist/src/ui-server.d.ts +163 -35
  242. package/dist/src/ui-server.js +712 -72
  243. package/dist/src/ui.d.ts +1 -2
  244. package/dist/src/ui.js +1 -2
  245. package/dist/src/upload.d.ts +27 -0
  246. package/dist/src/upload.js +383 -43
  247. package/dist/src/vault.d.ts +226 -30
  248. package/dist/src/vault.js +1776 -192
  249. package/dist/src/watcher.d.ts +7 -1
  250. package/dist/src/watcher.js +198 -55
  251. package/dist/src/worker.d.ts +27 -3
  252. package/dist/src/worker.js +274 -55
  253. package/package.json +33 -12
  254. package/scripts/native-reboot-rehearsal.mjs +90 -0
  255. package/web/app.js +6032 -343
  256. package/web/bootstrap.js +17 -0
  257. package/web/index.html +255 -57
  258. package/web/rail.js +317 -40
  259. package/web/retention.html +2 -2
  260. package/web/rules-view.js +188 -16
  261. package/web/sessions-view.js +485 -62
  262. package/web/sessions.html +2 -2
  263. package/web/setup-api.js +152 -29
  264. package/web/setup-logic.js +68 -9
  265. package/web/setup.html +113 -44
  266. package/web/setup.js +604 -71
  267. package/web/style.css +513 -98
  268. package/dist/packages/vaultline-crypto/src/chunk-access.js +0 -93
  269. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.d.ts +0 -0
  270. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.js +0 -0
  271. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.js +0 -0
  272. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.d.ts +0 -0
  273. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.d.ts +0 -0
  274. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.js +0 -0
@@ -0,0 +1,516 @@
1
+ /**
2
+ * One immutable sealed index segment: write it once, then open it and answer
3
+ * term lookups by reading only the blocks that hold the answer.
4
+ *
5
+ * The whole-index file this replaces made every seal rewrite the entire
6
+ * ciphertext and forced a lookup to decrypt the whole thing before a 64-row
7
+ * directory could narrow it down. A segment applies the same "seal in
8
+ * independent chunks, index the chunk boundaries" trick search.ts already
9
+ * uses for the legacy index, but at the grain of one write instead of the
10
+ * whole vault: nothing already sealed is ever read back, decrypted, or
11
+ * rewritten to add more. See docs/design/index-segments.md.
12
+ *
13
+ * File layout under `<dataDir>/index/segments/`:
14
+ * `<segmentId>.skseg` the sealed, chunk-compressed body (JSONL rows)
15
+ * `<segmentId>.skseg.json` that body's envelope, in the clear (it carries
16
+ * no plaintext — same shape as content-index.json)
17
+ * `<segmentId>.skdir` the sealed directory sidecar (byte offsets)
18
+ *
19
+ * Every file is published temp-then-rename, and a failed write removes
20
+ * everything this call produced — a half-written segment must never be
21
+ * mistaken for a real one.
22
+ */
23
+ import { createHash, randomUUID } from "node:crypto";
24
+ import { mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
25
+ import { join } from "node:path";
26
+ import { decryptArchive as openEnvelope, encryptArchive as sealEnvelope, sealArchiveToFile, } from "../packages/sealkeep-crypto/src/index.js";
27
+ import { chunkWindowForRange, openChunkWindow, supportsChunkAccess, unwrapWindowKey } from "../packages/sealkeep-crypto/src/chunk-access.js";
28
+ import { fail } from "./errors.js";
29
+ import { SEGMENT_DIRECTORY_STRIDE, } from "./index-segment-types.js";
30
+ const SEGMENT_ID_PATTERN = /^[0-9a-f-]{36}$/;
31
+ // Rows below this size ride the same coalesced write writeContentIndexCheckpoint
32
+ // uses; a handful of write() syscalls beats one per row on a 600-token segment.
33
+ const COALESCE_BYTES = 1024 * 1024;
34
+ // ~4 MiB raw per rows() window: bounded memory regardless of segment size.
35
+ const ROWS_WINDOW_BYTES = 4 * 1024 * 1024;
36
+ // 8 x stride rows bounds a wide prefix the same way MAX_RANGED_WINDOWS_PER_TERM
37
+ // bounds it for the legacy whole-index directory.
38
+ const MAX_LOOKUP_WINDOWS_PER_TERM = 8;
39
+ const segmentsDir = (dataDir) => join(dataDir, "index", "segments");
40
+ const openedSegments = new Map();
41
+ const OPENED_SEGMENTS_MAX = 64;
42
+ const phraseDigest = (phrase) => createHash("sha256").update(phrase, "utf8").digest("hex");
43
+ function rememberOpenedSegment(cacheKey, opened) {
44
+ if (openedSegments.size >= OPENED_SEGMENTS_MAX) {
45
+ const oldest = openedSegments.keys().next().value;
46
+ if (oldest !== undefined) {
47
+ openedSegments.get(oldest)?.archiveKey.fill(0);
48
+ openedSegments.delete(oldest);
49
+ }
50
+ }
51
+ openedSegments.set(cacheKey, opened);
52
+ }
53
+ function assertSegmentId(segmentId) {
54
+ if (!SEGMENT_ID_PATTERN.test(segmentId))
55
+ fail("invalid_argument", "Segment id must be a UUID", { segmentId });
56
+ }
57
+ function isEnoent(error) {
58
+ return error?.code === "ENOENT";
59
+ }
60
+ const isArchiveRow = (row) => row[0] === "a";
61
+ const isTokenRow = (row) => row[0] === "t";
62
+ const isMetaRow = (row) => row[0] === "m";
63
+ /** The last directory entry whose token is at or before `term`, or -1. */
64
+ function directoryPosition(entries, term) {
65
+ let low = 0;
66
+ let high = entries.length - 1;
67
+ let found = -1;
68
+ while (low <= high) {
69
+ const mid = (low + high) >> 1;
70
+ if (entries[mid][0] <= term) {
71
+ found = mid;
72
+ low = mid + 1;
73
+ }
74
+ else
75
+ high = mid - 1;
76
+ }
77
+ return found;
78
+ }
79
+ /** Normalizes the writer's row source: callers may hand over either kind of iterable. */
80
+ async function* asRowStream(rows) {
81
+ if (Symbol.asyncIterator in rows)
82
+ yield* rows;
83
+ else
84
+ yield* rows;
85
+ }
86
+ /** Atomic publication that never leaves this call's temp behind on error. Same
87
+ * pattern as the private writeIndexFile in search.ts, reimplemented here since
88
+ * that helper is not exported. */
89
+ async function writeSegmentFile(target, contents) {
90
+ const temp = `${target}.${randomUUID()}.tmp`;
91
+ try {
92
+ await writeFile(temp, contents, { mode: 0o600 });
93
+ await rename(temp, target);
94
+ }
95
+ finally {
96
+ await rm(temp, { force: true }).catch(() => undefined);
97
+ }
98
+ }
99
+ async function writeSealedDirectory(directoryPath, phrase, directory) {
100
+ const plaintext = Buffer.from(JSON.stringify(directory), "utf8");
101
+ let sealed;
102
+ try {
103
+ sealed = sealEnvelope(plaintext, {
104
+ recipients: [{ kind: "phrase", phrase }],
105
+ compression: "zstd",
106
+ adapter: { agent: "sealkeep-index-segment-directory", version: "1" },
107
+ });
108
+ }
109
+ finally {
110
+ plaintext.fill(0);
111
+ }
112
+ try {
113
+ await writeSegmentFile(directoryPath, `${JSON.stringify({ version: 1, envelope: sealed.envelope, ciphertext: sealed.ciphertext.toString("base64") })}\n`);
114
+ }
115
+ finally {
116
+ sealed.ciphertext.fill(0);
117
+ }
118
+ }
119
+ export async function segmentPaths(dataDir, segmentId) {
120
+ assertSegmentId(segmentId);
121
+ const dir = segmentsDir(dataDir);
122
+ return {
123
+ body: join(dir, `${segmentId}.skseg`),
124
+ envelope: join(dir, `${segmentId}.skseg.json`),
125
+ directory: join(dir, `${segmentId}.skdir`),
126
+ };
127
+ }
128
+ export async function removeSegment(dataDir, segmentId) {
129
+ const paths = await segmentPaths(dataDir, segmentId);
130
+ await Promise.all([
131
+ rm(paths.body, { force: true }),
132
+ rm(paths.envelope, { force: true }),
133
+ rm(paths.directory, { force: true }),
134
+ ]);
135
+ }
136
+ const REGION_RANK = { a: 0, t: 1, m: 2 };
137
+ const REGION_NAME = { 0: "archives", 1: "tokens", 2: "meta" };
138
+ export const writeSegment = async (dataDir, phrase, input, options = {}) => {
139
+ const paths = await segmentPaths(dataDir, input.segmentId);
140
+ const dir = segmentsDir(dataDir);
141
+ await mkdir(dir, { recursive: true, mode: 0o700 });
142
+ const generation = randomUUID();
143
+ const plaintextTemp = join(dir, `.${input.segmentId}.${generation}.jsonl`);
144
+ const bodyTemp = `${paths.body}.${generation}.tmp`;
145
+ const state = {
146
+ offset: 0,
147
+ lastRank: -1,
148
+ lastKey: undefined,
149
+ regions: { archives: [0, 0], tokens: [0, 0], meta: [0, 0] },
150
+ entries: [],
151
+ archives: 0,
152
+ tokens: 0,
153
+ tokenRowIndex: 0,
154
+ };
155
+ try {
156
+ const handle = await open(plaintextTemp, "wx", 0o600);
157
+ try {
158
+ const pending = [];
159
+ let pendingBytes = 0;
160
+ let rowsSeen = 0;
161
+ const flush = async () => {
162
+ if (pending.length === 0)
163
+ return;
164
+ options.signal?.throwIfAborted();
165
+ await handle.writeFile(pending.join(""), { encoding: "utf8" });
166
+ pending.length = 0;
167
+ pendingBytes = 0;
168
+ };
169
+ // Written by the writer, not supplied: every reader can trust it without
170
+ // asking whoever produced the rows to have gotten it right.
171
+ const header = { version: 4, segmentId: input.segmentId, builtAt: input.builtAt };
172
+ const headerLine = `${JSON.stringify(header)}\n`;
173
+ await handle.writeFile(headerLine, { encoding: "utf8" });
174
+ state.offset = Buffer.byteLength(headerLine, "utf8");
175
+ for await (const row of asRowStream(input.rows)) {
176
+ options.signal?.throwIfAborted();
177
+ const kind = row[0];
178
+ const key = row[1];
179
+ const rank = REGION_RANK[kind];
180
+ if (rank < state.lastRank) {
181
+ fail("invalid_argument", `Segment rows are out of order: a "${kind}" row followed a row from a later region`, { segmentId: input.segmentId });
182
+ }
183
+ if (rank !== state.lastRank) {
184
+ if (state.lastRank !== -1)
185
+ state.regions[REGION_NAME[state.lastRank]][1] = state.offset;
186
+ state.regions[REGION_NAME[rank]] = [state.offset, state.offset];
187
+ state.lastRank = rank;
188
+ state.lastKey = undefined;
189
+ }
190
+ if (state.lastKey !== undefined && key <= state.lastKey) {
191
+ fail("invalid_argument", `Segment rows are out of order: "${key}" did not sort after "${state.lastKey}"`, { segmentId: input.segmentId });
192
+ }
193
+ state.lastKey = key;
194
+ if (kind === "t") {
195
+ if (state.tokenRowIndex % SEGMENT_DIRECTORY_STRIDE === 0)
196
+ state.entries.push([key, state.offset]);
197
+ state.tokenRowIndex += 1;
198
+ state.tokens += 1;
199
+ }
200
+ else if (kind === "a") {
201
+ state.archives += 1;
202
+ }
203
+ const serialized = `${JSON.stringify(row)}\n`;
204
+ const bytes = Buffer.byteLength(serialized, "utf8");
205
+ if (pendingBytes > 0 && pendingBytes + bytes > COALESCE_BYTES)
206
+ await flush();
207
+ if (bytes > COALESCE_BYTES)
208
+ await handle.writeFile(serialized, { encoding: "utf8" });
209
+ else {
210
+ pending.push(serialized);
211
+ pendingBytes += bytes;
212
+ }
213
+ state.offset += bytes;
214
+ rowsSeen += 1;
215
+ if (rowsSeen % 2_048 === 0) {
216
+ if (options.onYield)
217
+ await options.onYield();
218
+ else
219
+ await new Promise((resolve) => setImmediate(resolve));
220
+ }
221
+ }
222
+ if (state.lastRank !== -1)
223
+ state.regions[REGION_NAME[state.lastRank]][1] = state.offset;
224
+ await flush();
225
+ await handle.sync();
226
+ }
227
+ finally {
228
+ await handle.close();
229
+ }
230
+ const sealedFile = await sealArchiveToFile({ path: plaintextTemp }, bodyTemp, {
231
+ recipients: [{ kind: "phrase", phrase }],
232
+ // Per chunk, not whole-file: the point of a segment is that a lookup
233
+ // opens two chunks of it, never the whole body.
234
+ compression: "zstd-chunk",
235
+ adapter: { agent: "sealkeep-index-segment", version: "1" },
236
+ signal: options.signal,
237
+ onYield: options.onYield,
238
+ });
239
+ try {
240
+ await rename(bodyTemp, paths.body);
241
+ await writeSegmentFile(paths.envelope, `${JSON.stringify(sealedFile.envelope, null, 2)}\n`);
242
+ const directory = {
243
+ version: 1,
244
+ segmentId: input.segmentId,
245
+ envelopeArchiveId: sealedFile.envelope.archiveId,
246
+ builtAt: input.builtAt,
247
+ stride: SEGMENT_DIRECTORY_STRIDE,
248
+ regions: state.regions,
249
+ entries: state.entries,
250
+ archives: state.archives,
251
+ tokens: state.tokens,
252
+ };
253
+ await writeSealedDirectory(paths.directory, phrase, directory);
254
+ }
255
+ catch (error) {
256
+ await Promise.all([
257
+ rm(paths.body, { force: true }),
258
+ rm(paths.envelope, { force: true }),
259
+ rm(paths.directory, { force: true }),
260
+ ]);
261
+ throw error;
262
+ }
263
+ const bytes = sealedFile.envelope.chunks.reduce((total, chunk) => total + chunk.storedBytes, 0);
264
+ return { id: input.segmentId, builtAt: input.builtAt, archives: state.archives, tokens: state.tokens, bytes };
265
+ }
266
+ finally {
267
+ await rm(plaintextTemp, { force: true }).catch(() => undefined);
268
+ await rm(bodyTemp, { force: true }).catch(() => undefined);
269
+ }
270
+ };
271
+ async function readSegmentEnvelope(envelopePath, segmentId) {
272
+ let text;
273
+ try {
274
+ text = await readFile(envelopePath, "utf8");
275
+ }
276
+ catch (error) {
277
+ if (isEnoent(error))
278
+ return fail("invalid_argument", `No segment ${segmentId} envelope at ${envelopePath}`, { segmentId });
279
+ throw error;
280
+ }
281
+ try {
282
+ return JSON.parse(text);
283
+ }
284
+ catch {
285
+ return fail("invalid_argument", `Segment ${segmentId} envelope is not valid JSON`, { segmentId });
286
+ }
287
+ }
288
+ async function readSegmentDirectory(directoryPath, phrase, segmentId) {
289
+ let text;
290
+ try {
291
+ text = await readFile(directoryPath, "utf8");
292
+ }
293
+ catch (error) {
294
+ if (isEnoent(error))
295
+ return fail("invalid_argument", `No segment ${segmentId} directory at ${directoryPath}`, { segmentId });
296
+ throw error;
297
+ }
298
+ let stored;
299
+ try {
300
+ stored = JSON.parse(text);
301
+ }
302
+ catch {
303
+ return fail("invalid_argument", `Segment ${segmentId} directory is not valid JSON`, { segmentId });
304
+ }
305
+ if (stored.version !== 1 || !stored.envelope || typeof stored.ciphertext !== "string") {
306
+ return fail("invalid_argument", `Segment ${segmentId} directory is malformed`, { segmentId });
307
+ }
308
+ // A wrong phrase and a damaged directory ciphertext cannot be told apart
309
+ // from outside; reporting the phrase is the actionable answer for nearly
310
+ // every case that reaches this branch, exactly as the legacy whole-index
311
+ // reader in search.ts treats any decrypt failure of its own directory.
312
+ let plaintext;
313
+ try {
314
+ plaintext = openEnvelope(stored.envelope, Buffer.from(stored.ciphertext, "base64"), { phrase });
315
+ }
316
+ catch {
317
+ return fail("recovery_phrase_mismatch", `Segment ${segmentId} directory would not open with this phrase`, { segmentId });
318
+ }
319
+ try {
320
+ const parsed = JSON.parse(plaintext.toString("utf8"));
321
+ if (parsed.version !== 1
322
+ || typeof parsed.segmentId !== "string"
323
+ || typeof parsed.envelopeArchiveId !== "string"
324
+ || typeof parsed.builtAt !== "string"
325
+ || typeof parsed.stride !== "number"
326
+ || !parsed.regions
327
+ || !Array.isArray(parsed.entries)
328
+ || typeof parsed.archives !== "number"
329
+ || typeof parsed.tokens !== "number") {
330
+ return fail("ciphertext_integrity_failed", `Segment ${segmentId} directory contents are invalid`, { segmentId });
331
+ }
332
+ return parsed;
333
+ }
334
+ finally {
335
+ plaintext.fill(0);
336
+ }
337
+ }
338
+ export async function openSegment(dataDir, phrase, segmentId) {
339
+ const paths = await segmentPaths(dataDir, segmentId);
340
+ const envelope = await readSegmentEnvelope(paths.envelope, segmentId);
341
+ if (!supportsChunkAccess(envelope)) {
342
+ return fail("ciphertext_integrity_failed", `Segment ${segmentId} body is not chunk-addressable`, { segmentId });
343
+ }
344
+ const directoryIdentity = await stat(paths.directory)
345
+ .then((info) => `${info.size}:${info.mtimeMs}`)
346
+ .catch((error) => (isEnoent(error) ? fail("invalid_argument", `No segment ${segmentId} directory at ${paths.directory}`, { segmentId }) : Promise.reject(error)));
347
+ const cacheKey = `${segmentId}:${phraseDigest(phrase)}`;
348
+ let opened = openedSegments.get(cacheKey);
349
+ if (opened && (opened.envelopeArchiveId !== envelope.archiveId || opened.directoryIdentity !== directoryIdentity)) {
350
+ opened.archiveKey.fill(0);
351
+ openedSegments.delete(cacheKey);
352
+ opened = undefined;
353
+ }
354
+ if (!opened) {
355
+ const directory = await readSegmentDirectory(paths.directory, phrase, segmentId);
356
+ // Binds the sealed sidecar to the exact sealed body: a directory copied from
357
+ // another segment decrypts fine under the same phrase but names a body that
358
+ // is not this one.
359
+ if (directory.envelopeArchiveId !== envelope.archiveId) {
360
+ return fail("ciphertext_integrity_failed", `Segment ${segmentId} directory does not match its sealed body`, { segmentId });
361
+ }
362
+ // The directory opened under this phrase, so a body that will not is a
363
+ // damaged envelope, not a wrong phrase.
364
+ let archiveKey;
365
+ try {
366
+ archiveKey = unwrapWindowKey(envelope, { phrase });
367
+ }
368
+ catch {
369
+ return fail("ciphertext_integrity_failed", `Segment ${segmentId} body key would not unwrap under the phrase that opened its directory`, { segmentId });
370
+ }
371
+ opened = { envelopeArchiveId: envelope.archiveId, directoryIdentity, directory, archiveKey };
372
+ rememberOpenedSegment(cacheKey, opened);
373
+ }
374
+ const { directory, archiveKey } = opened;
375
+ const expectedBytes = envelope.chunks.reduce((total, chunk) => total + chunk.storedBytes, 0);
376
+ let bodySize;
377
+ try {
378
+ bodySize = (await stat(paths.body)).size;
379
+ }
380
+ catch (error) {
381
+ if (isEnoent(error))
382
+ return fail("invalid_argument", `Segment ${segmentId} has no sealed body in ${dataDir}`, { segmentId });
383
+ throw error;
384
+ }
385
+ if (bodySize !== expectedBytes) {
386
+ return fail("ciphertext_integrity_failed", `Segment ${segmentId} body is ${bodySize} bytes; its envelope describes ${expectedBytes}`, { segmentId });
387
+ }
388
+ const handle = await open(paths.body, "r");
389
+ return new SealedSegmentReader(segmentId, directory, envelope, phrase, archiveKey, handle);
390
+ }
391
+ class SealedSegmentReader {
392
+ id;
393
+ directory;
394
+ #envelope;
395
+ #phrase;
396
+ /** Shared with the per-process cache; never zeroized by a reader. */
397
+ #archiveKey;
398
+ #handle;
399
+ #closed = false;
400
+ #fixed = null;
401
+ constructor(id, directory, envelope, phrase, archiveKey, handle) {
402
+ this.id = id;
403
+ this.directory = directory;
404
+ this.#envelope = envelope;
405
+ this.#phrase = phrase;
406
+ this.#archiveKey = archiveKey;
407
+ this.#handle = handle;
408
+ }
409
+ async #readRaw(start, end, signal) {
410
+ if (end <= start)
411
+ return Buffer.alloc(0);
412
+ signal?.throwIfAborted();
413
+ const window = chunkWindowForRange(this.#envelope, start, end);
414
+ const slice = Buffer.allocUnsafe(window.storedLength);
415
+ const { bytesRead } = await this.#handle.read(slice, 0, window.storedLength, window.storedOffset);
416
+ if (bytesRead !== window.storedLength) {
417
+ return fail("ciphertext_integrity_failed", `Segment ${this.id} body is shorter than its envelope describes`, { segmentId: this.id });
418
+ }
419
+ return openChunkWindow(this.#envelope, window, slice, { phrase: this.#phrase }, { archiveKey: this.#archiveKey, signal });
420
+ }
421
+ async #readRows(start, end, signal) {
422
+ const raw = await this.#readRaw(start, end, signal);
423
+ if (raw.length === 0)
424
+ return [];
425
+ const rows = [];
426
+ for (const line of raw.toString("utf8").split("\n"))
427
+ if (line)
428
+ rows.push(JSON.parse(line));
429
+ return rows;
430
+ }
431
+ async #fixedRegions(signal) {
432
+ if (this.#fixed)
433
+ return this.#fixed;
434
+ const [archiveRows, metaRows] = await Promise.all([
435
+ this.#readRows(this.directory.regions.archives[0], this.directory.regions.archives[1], signal),
436
+ this.#readRows(this.directory.regions.meta[0], this.directory.regions.meta[1], signal),
437
+ ]);
438
+ const archives = {};
439
+ for (const row of archiveRows)
440
+ if (isArchiveRow(row))
441
+ archives[row[1]] = row.slice(2);
442
+ const meta = {};
443
+ for (const row of metaRows)
444
+ if (isMetaRow(row))
445
+ meta[row[1]] = row[2];
446
+ this.#fixed = { archives, meta };
447
+ return this.#fixed;
448
+ }
449
+ async lookup(terms, options = {}) {
450
+ const prefixKeep = options.prefixKeep ?? 64;
451
+ const signal = options.signal;
452
+ signal?.throwIfAborted();
453
+ const { archives, meta } = await this.#fixedRegions(signal);
454
+ const tokens = {};
455
+ const prefixSeen = new Map();
456
+ const { entries } = this.directory;
457
+ for (const term of new Set(terms)) {
458
+ if (entries.length === 0)
459
+ continue;
460
+ const position = Math.max(0, directoryPosition(entries, term));
461
+ let windows = 0;
462
+ for (let k = position; k < entries.length && windows < MAX_LOOKUP_WINDOWS_PER_TERM; k += 1, windows += 1) {
463
+ const start = entries[k][1];
464
+ const end = k + 1 < entries.length ? entries[k + 1][1] : this.directory.regions.tokens[1];
465
+ for (const row of await this.#readRows(start, end, signal)) {
466
+ if (!isTokenRow(row))
467
+ continue;
468
+ const token = row[1];
469
+ if (token === term) {
470
+ tokens[token] = row[2];
471
+ continue;
472
+ }
473
+ if (token.startsWith(term)) {
474
+ const seen = prefixSeen.get(term) ?? 0;
475
+ if (seen < prefixKeep) {
476
+ tokens[token] = row[2];
477
+ prefixSeen.set(term, seen + 1);
478
+ }
479
+ }
480
+ }
481
+ // Rows are sorted: once the next stride entry's token no longer
482
+ // carries this prefix, nothing after it can either.
483
+ const next = entries[k + 1]?.[0];
484
+ if (next === undefined || !next.startsWith(term))
485
+ break;
486
+ }
487
+ }
488
+ return { archives: { ...archives }, tokens, meta: { ...meta } };
489
+ }
490
+ async *rows(region, options = {}) {
491
+ const [regionStart, regionEnd] = this.directory.regions[region];
492
+ const signal = options.signal;
493
+ let cursor = regionStart;
494
+ let carry = "";
495
+ while (cursor < regionEnd) {
496
+ signal?.throwIfAborted();
497
+ const windowEnd = Math.min(cursor + ROWS_WINDOW_BYTES, regionEnd);
498
+ const raw = await this.#readRaw(cursor, windowEnd, signal);
499
+ cursor = windowEnd;
500
+ const text = carry + raw.toString("utf8");
501
+ const lines = text.split("\n");
502
+ carry = lines.pop() ?? "";
503
+ for (const line of lines)
504
+ if (line)
505
+ yield JSON.parse(line);
506
+ }
507
+ if (carry)
508
+ yield JSON.parse(carry);
509
+ }
510
+ async close() {
511
+ if (this.#closed)
512
+ return;
513
+ this.#closed = true;
514
+ await this.#handle.close();
515
+ }
516
+ }
@@ -0,0 +1,123 @@
1
+ import { type SegmentEntry, type SegmentManifest, type SegmentRow } from "./index-segment-types.js";
2
+ import { applyArchiveTokens, type ContentIndex } from "./search.js";
3
+ import { type RemoteIndexStore } from "./index-sync.js";
4
+ type IndexableRecord = Parameters<typeof applyArchiveTokens>[1];
5
+ /** Sorted "a", then "t", then "m" rows — the region order every segment
6
+ * writer requires. Declared synchronous (not `async`, despite the design
7
+ * doc's signature): an `async function` may only return a `Promise`, and
8
+ * every caller here — `writeSegment`'s `rows` parameter — accepts a plain
9
+ * `Iterable<SegmentRow>` directly, so there is nothing for a Promise to buy. */
10
+ export declare function segmentRowsFromIndex(index: ContentIndex): Iterable<SegmentRow>;
11
+ /**
12
+ * Segments mode is a one-way door for a vault: once `sealkeep index migrate`
13
+ * has run, it must never silently fall back to the legacy whole-blob build
14
+ * (and its whole-index-in-memory cost) just because `dropContentIndex`
15
+ * happened to empty the manifest. So this is a FILE-existence check, not a
16
+ * segment-count one — `dropContentIndex` deliberately leaves behind an empty
17
+ * manifest (see `resetSegments`) rather than removing it, precisely so this
18
+ * keeps answering true.
19
+ */
20
+ export declare function segmentsEnabled(dataDir: string): Promise<boolean>;
21
+ /** The archive ids segments cover on this machine: the union of every
22
+ * segment's `archiveIds` minus tombstones — exactly what `coverage.json`
23
+ * holds, computed straight from the plaintext manifest. No segment body is
24
+ * opened and no phrase is needed. */
25
+ export declare function manifestCoverage(dataDir: string): Promise<Set<string>>;
26
+ /** Archives another machine sealed whose rows a segment here still carries — searchable, never this machine's to prune. */
27
+ export declare function manifestRemoteCoverage(dataDir: string): Promise<Set<string>>;
28
+ /**
29
+ * `coverage.json` exactly as `saveLocal` writes it in search.ts: the unsealed
30
+ * sidecar `index status`, doctor, and the daemon read to answer "what is
31
+ * searchable" without the recovery phrase. Always re-derived from the
32
+ * manifest, so it can never drift from what the segments actually cover.
33
+ */
34
+ export declare function writeCoverageFromManifest(dataDir: string): Promise<void>;
35
+ /**
36
+ * Empties a vault's segment set back to nothing while keeping it IN segments
37
+ * mode (the manifest file itself survives, freshly empty) — the counterpart
38
+ * `dropContentIndex` needs so a drop-then-rebuild never regresses to the
39
+ * legacy blob, and the extraction-policy bump needs so a stale segment set
40
+ * can be discarded wholesale rather than patched in place (segments are
41
+ * immutable; there is no way to re-extract one that already exists).
42
+ */
43
+ export declare function resetSegments(dataDir: string): Promise<SegmentManifest>;
44
+ /**
45
+ * `planCompaction` over the current manifest; when it has work, stream the
46
+ * merge and replace the merged segments with the single result, then bring
47
+ * `coverage.json` and any stray `.skseg` files left by an interrupted prior
48
+ * attempt back in line with the manifest.
49
+ */
50
+ export declare function maybeCompact(dataDir: string, phrase: string, options?: {
51
+ signal?: AbortSignal;
52
+ onYield?: () => void | Promise<void>;
53
+ smallOnly?: boolean;
54
+ }): Promise<SegmentEntry | null>;
55
+ /** One archive, sealed at seal time: no whole-index rewrite, just a new
56
+ * one-archive segment. Also publishes it (step 2): a segment is exactly the
57
+ * unit a peer wants as soon as possible, not just at the next background
58
+ * build. A publish failure never fails the seal that produced the segment —
59
+ * it is already durable locally — so it is caught and named for the next
60
+ * successful publish (a build's, or this machine's next seal) to pick up. */
61
+ export declare function appendArchiveSegment(dataDir: string, phrase: string, record: IndexableRecord, tokensByChunk: ReadonlyMap<number, ReadonlySet<string>>, options?: {
62
+ sync?: boolean;
63
+ signal?: AbortSignal;
64
+ remoteIdleMs?: number;
65
+ maxBytesPerSecond?: number;
66
+ }): Promise<SegmentEntry>;
67
+ /** The catch-up build's per-batch equivalent: one segment for however many
68
+ * archives a checkpoint's worth of indexing collected. */
69
+ export declare function appendBatchSegment(dataDir: string, phrase: string, batch: ContentIndex, options?: {
70
+ signal?: AbortSignal;
71
+ onYield?: () => void | Promise<void>;
72
+ }): Promise<SegmentEntry | null>;
73
+ export declare function retireArchiveIds(dataDir: string, archiveIds: readonly string[]): Promise<void>;
74
+ /**
75
+ * Open every segment, answer `terms` from each concurrently, and merge into
76
+ * one `ContentIndex` a query-time caller reads exactly like the legacy blob:
77
+ * archives keep the richer (longer) row, meta merges field by field with the
78
+ * later (newest-last) segment winning except a real `summary` over scaffold
79
+ * and unioned `commits`/`files`, and token postings union, drop anything
80
+ * tombstoned, and rank chronologically before the usual per-token cap.
81
+ */
82
+ export declare function lookupSegments(dataDir: string, phrase: string, terms: readonly string[], options?: {
83
+ prefixKeep?: number;
84
+ signal?: AbortSignal;
85
+ onYield?: () => void | Promise<void>;
86
+ }): Promise<ContentIndex>;
87
+ /**
88
+ * The blob for cloud publication, produced from segments without ever
89
+ * holding more than the streaming merge needs: `mergeSegmentRows` folds every
90
+ * segment into archive/token/meta rows with tombstones already applied, and
91
+ * `persistContentIndex` seals and pushes exactly as the legacy build does.
92
+ * `maxTokens` is the background child's guard — a merge that would exceed it
93
+ * is abandoned (nothing written) rather than produced and then rejected.
94
+ *
95
+ * The blob-fallback publish path (step 2 addendum): a store with no generic
96
+ * keyed storage (managed today) cannot hold segments, so this is still how
97
+ * such a vault reaches the cloud at all. A successful push also records
98
+ * `published-blob.json`, so `publishPending` knows the manifest it merged is
99
+ * no longer waiting, and a peer without segments support (`pullSegments`'s
100
+ * own blob fallback) can tell its own publication apart from one worth
101
+ * converting.
102
+ */
103
+ export declare function publishFromSegments(dataDir: string, phrase: string, options: {
104
+ store: RemoteIndexStore | null;
105
+ signal?: AbortSignal;
106
+ onYield?: () => void | Promise<void>;
107
+ maxTokens?: number;
108
+ }): Promise<boolean>;
109
+ /**
110
+ * One-time move of the existing whole-index blob into a single segment. The
111
+ * blob files are left exactly where they are — the cloud path still reads
112
+ * them until the next publish, and a vault with neither blob nor manifest is
113
+ * simply "build one" (unchanged). Accepts a raw or already-canonical phrase,
114
+ * like the other public entry points in search.ts (`buildContentIndex`,
115
+ * `loadContentIndex`): `sealkeep index migrate` hands it the CLI's raw
116
+ * `--recovery-phrase` value directly.
117
+ */
118
+ export declare function migrateBlobToSegments(dataDir: string, rawPhrase: string): Promise<{
119
+ migrated: boolean;
120
+ archives: number;
121
+ tokens: number;
122
+ }>;
123
+ export {};