@hraness/sponge 0.2.6 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/README.md +21 -9
  2. package/bin/sponge.js +8 -7
  3. package/dist/contracts/index.js +24 -6
  4. package/dist/contracts/validate.js +106 -29
  5. package/dist/migrations/008.d.ts +3 -0
  6. package/dist/migrations/008.js +5 -0
  7. package/dist/migrations/009.d.ts +3 -0
  8. package/dist/migrations/009.js +8 -0
  9. package/dist/migrations/010.d.ts +3 -0
  10. package/dist/migrations/010.js +11 -0
  11. package/dist/migrations/index.d.ts +15 -0
  12. package/dist/migrations/index.js +39 -0
  13. package/dist/src/acquisition/capture.js +87 -28
  14. package/dist/src/acquisition/cleanup.d.ts +0 -4
  15. package/dist/src/acquisition/cleanup.js +3 -5
  16. package/dist/src/acquisition/errors.js +17 -2
  17. package/dist/src/acquisition/files.d.ts +0 -1
  18. package/dist/src/acquisition/files.js +14 -11
  19. package/dist/src/acquisition/index.d.ts +2 -3
  20. package/dist/src/acquisition/index.js +1 -2
  21. package/dist/src/acquisition/lock.js +1 -1
  22. package/dist/src/acquisition/normalize.js +57 -18
  23. package/dist/src/acquisition/pins.js +22 -4
  24. package/dist/src/acquisition/process.js +67 -29
  25. package/dist/src/acquisition/runtime.d.ts +5 -7
  26. package/dist/src/acquisition/runtime.js +129 -68
  27. package/dist/src/acquisition/verify-helper.js +10 -3
  28. package/dist/src/annotations/quote.js +40 -12
  29. package/dist/src/annotations/store.d.ts +0 -1
  30. package/dist/src/annotations/store.js +66 -14
  31. package/dist/src/app/article.d.ts +2 -1
  32. package/dist/src/app/article.js +66 -70
  33. package/dist/src/app/bundle-files.d.ts +0 -4
  34. package/dist/src/app/bundle-files.js +11 -14
  35. package/dist/src/app/markdown.js +18 -6
  36. package/dist/src/app/source.d.ts +0 -4
  37. package/dist/src/app/source.js +18 -14
  38. package/dist/src/cli/arguments.d.ts +2 -8
  39. package/dist/src/cli/arguments.js +67 -80
  40. package/dist/src/cli/commands.d.ts +42 -0
  41. package/dist/src/cli/commands.js +795 -0
  42. package/dist/src/cli/config.js +12 -9
  43. package/dist/src/cli/digest.d.ts +1 -1
  44. package/dist/src/cli/digest.js +38 -16
  45. package/dist/src/cli/import.js +25 -4
  46. package/dist/src/cli/indexing.d.ts +2 -0
  47. package/dist/src/cli/indexing.js +26 -9
  48. package/dist/src/cli/main.js +47 -386
  49. package/dist/src/cli/portability.d.ts +0 -2
  50. package/dist/src/cli/portability.js +13 -8
  51. package/dist/src/cli/queue.js +12 -8
  52. package/dist/src/cli/runtimes.d.ts +1 -3
  53. package/dist/src/cli/runtimes.js +13 -6
  54. package/dist/src/cli/terminal.js +1 -1
  55. package/dist/src/cli/worker.d.ts +7 -0
  56. package/dist/src/cli/worker.js +54 -15
  57. package/dist/src/derivations/kinds.d.ts +34 -0
  58. package/dist/src/derivations/kinds.js +14 -0
  59. package/dist/src/derivations/types.d.ts +48 -0
  60. package/dist/src/derivations/types.js +1 -0
  61. package/dist/src/digest/derivation.d.ts +25 -0
  62. package/dist/src/digest/derivation.js +124 -0
  63. package/dist/src/digest/editorial.d.ts +9 -46
  64. package/dist/src/digest/editorial.js +57 -49
  65. package/dist/src/digest/endpoint.js +63 -27
  66. package/dist/src/digest/pipeline.d.ts +49 -28
  67. package/dist/src/digest/pipeline.js +369 -102
  68. package/dist/src/digest/schema.d.ts +11 -25
  69. package/dist/src/digest/schema.js +86 -18
  70. package/dist/src/errors.d.ts +46 -0
  71. package/dist/src/errors.js +117 -0
  72. package/dist/src/identity/query-policy.d.ts +0 -5
  73. package/dist/src/identity/query-policy.js +61 -9
  74. package/dist/src/identity/uri.d.ts +6 -5
  75. package/dist/src/identity/uri.js +37 -18
  76. package/dist/src/identity/visibility.d.ts +11 -1
  77. package/dist/src/identity/visibility.js +81 -17
  78. package/dist/src/importers/apply.js +104 -36
  79. package/dist/src/importers/custody.js +27 -9
  80. package/dist/src/importers/inspect.js +125 -38
  81. package/dist/src/importers/jungle-digest.d.ts +3 -6
  82. package/dist/src/importers/jungle-digest.js +196 -69
  83. package/dist/src/importers/jungle-store.d.ts +19 -6
  84. package/dist/src/importers/jungle-store.js +124 -28
  85. package/dist/src/importers/jungle.d.ts +14 -14
  86. package/dist/src/importers/jungle.js +142 -31
  87. package/dist/src/importers/manifest.js +57 -21
  88. package/dist/src/importers/verify-helper.js +18 -4
  89. package/dist/src/indexing/image.js +29 -10
  90. package/dist/src/indexing/index.d.ts +37 -6
  91. package/dist/src/indexing/index.js +360 -124
  92. package/dist/src/indexing/jobs.js +156 -47
  93. package/dist/src/indexing/prune-run.d.ts +2 -0
  94. package/dist/src/indexing/prune-run.js +69 -26
  95. package/dist/src/indexing/prune.d.ts +12 -3
  96. package/dist/src/indexing/prune.js +85 -35
  97. package/dist/src/indexing/runner.d.ts +2 -1
  98. package/dist/src/indexing/runner.js +83 -40
  99. package/dist/src/indexing/upgrade.d.ts +9 -0
  100. package/dist/src/indexing/upgrade.js +79 -0
  101. package/dist/src/inference/asr/bridge.js +6 -2
  102. package/dist/src/inference/asr/protocol.js +109 -19
  103. package/dist/src/inference/asr/runtime.js +58 -21
  104. package/dist/src/inference/asr/windows.js +9 -5
  105. package/dist/src/inference/embedding/index.d.ts +2 -6
  106. package/dist/src/inference/embedding/index.js +89 -29
  107. package/dist/src/ingest/attempt.d.ts +29 -0
  108. package/dist/src/ingest/attempt.js +55 -0
  109. package/dist/src/media/capture-helper.js +7 -2
  110. package/dist/src/media/capture.d.ts +2 -1
  111. package/dist/src/media/capture.js +124 -43
  112. package/dist/src/media/handler.d.ts +4 -2
  113. package/dist/src/media/handler.js +88 -70
  114. package/dist/src/media/transcribe.js +63 -21
  115. package/dist/src/metadata/article.js +49 -17
  116. package/dist/src/models/artifacts.d.ts +10 -8
  117. package/dist/src/models/artifacts.js +237 -106
  118. package/dist/src/models/catalog.d.ts +2 -5
  119. package/dist/src/models/catalog.js +94 -25
  120. package/dist/src/models/download.js +55 -15
  121. package/dist/src/models/runtime.d.ts +0 -2
  122. package/dist/src/models/runtime.js +69 -33
  123. package/dist/src/pdf/capture-helper.js +3 -1
  124. package/dist/src/pdf/capture.d.ts +2 -1
  125. package/dist/src/pdf/capture.js +95 -27
  126. package/dist/src/pdf/errors.js +12 -1
  127. package/dist/src/pdf/handler.d.ts +2 -1
  128. package/dist/src/pdf/handler.js +70 -71
  129. package/dist/src/pdf/index.d.ts +1 -3
  130. package/dist/src/pdf/index.js +0 -2
  131. package/dist/src/pdf/input.js +12 -7
  132. package/dist/src/pdf/normalize.js +80 -21
  133. package/dist/src/pdf/source-identity.js +3 -2
  134. package/dist/src/pdf/source.js +12 -8
  135. package/dist/src/pdf/verify-helper.js +15 -3
  136. package/dist/src/portability/backup.d.ts +2 -9
  137. package/dist/src/portability/backup.js +106 -22
  138. package/dist/src/portability/database.js +51 -17
  139. package/dist/src/portability/export.js +35 -9
  140. package/dist/src/portability/files.d.ts +1 -4
  141. package/dist/src/portability/files.js +127 -67
  142. package/dist/src/portability/gc.js +65 -22
  143. package/dist/src/publish/component.js +1 -1
  144. package/dist/src/publish/feed.d.ts +2 -2
  145. package/dist/src/publish/feed.js +75 -29
  146. package/dist/src/publish/identity.d.ts +5 -1
  147. package/dist/src/publish/identity.js +31 -11
  148. package/dist/src/publish/index.d.ts +2 -3
  149. package/dist/src/publish/index.js +34 -21
  150. package/dist/src/publish/projection.d.ts +2 -9
  151. package/dist/src/publish/projection.js +21 -14
  152. package/dist/src/search/extension.js +3 -1
  153. package/dist/src/search/hybrid.d.ts +3 -8
  154. package/dist/src/search/hybrid.js +45 -11
  155. package/dist/src/search/lexical.js +18 -4
  156. package/dist/src/search/query.js +56 -25
  157. package/dist/src/search/vector.js +37 -10
  158. package/dist/src/service/launch-agent.d.ts +5 -3
  159. package/dist/src/service/launch-agent.js +272 -83
  160. package/dist/src/service/lifetime.js +47 -14
  161. package/dist/src/service/loop.d.ts +3 -3
  162. package/dist/src/service/loop.js +47 -19
  163. package/dist/src/store/annotations.d.ts +32 -0
  164. package/dist/src/store/annotations.js +44 -0
  165. package/dist/src/store/backups.d.ts +18 -0
  166. package/dist/src/store/backups.js +110 -0
  167. package/dist/src/store/blobs.d.ts +24 -0
  168. package/dist/src/store/blobs.js +139 -0
  169. package/dist/src/store/changes.d.ts +5 -12
  170. package/dist/src/store/changes.js +108 -56
  171. package/dist/src/store/context.d.ts +15 -0
  172. package/dist/src/store/context.js +19 -0
  173. package/dist/src/store/derivations.d.ts +42 -0
  174. package/dist/src/store/derivations.js +104 -0
  175. package/dist/src/store/files.d.ts +0 -2
  176. package/dist/src/store/files.js +19 -24
  177. package/dist/src/store/ids.d.ts +30 -0
  178. package/dist/src/store/ids.js +34 -0
  179. package/dist/src/store/imports.d.ts +26 -0
  180. package/dist/src/store/imports.js +57 -0
  181. package/dist/src/store/index.d.ts +3 -186
  182. package/dist/src/store/index.js +2 -809
  183. package/dist/src/store/lease.d.ts +20 -0
  184. package/dist/src/store/lease.js +62 -0
  185. package/dist/src/store/library.d.ts +75 -0
  186. package/dist/src/store/library.js +174 -0
  187. package/dist/src/store/open.d.ts +7 -0
  188. package/dist/src/store/open.js +183 -0
  189. package/dist/src/store/queue.d.ts +65 -0
  190. package/dist/src/store/queue.js +327 -0
  191. package/dist/src/store/revisions.d.ts +12 -0
  192. package/dist/src/store/revisions.js +189 -0
  193. package/dist/src/store/search-index.d.ts +63 -0
  194. package/dist/src/store/search-index.js +190 -0
  195. package/dist/src/store/sync-ready.d.ts +6 -0
  196. package/dist/src/store/sync-ready.js +121 -0
  197. package/dist/src/store/types.d.ts +1 -5
  198. package/dist/src/store/types.js +1 -8
  199. package/dist/src/util/canonical.d.ts +2 -0
  200. package/dist/src/util/canonical.js +17 -0
  201. package/dist/src/util/exact.d.ts +2 -0
  202. package/dist/src/util/exact.js +9 -0
  203. package/dist/src/util/fsync.d.ts +4 -0
  204. package/dist/src/util/fsync.js +22 -0
  205. package/dist/src/util/hash.d.ts +4 -0
  206. package/dist/src/util/hash.js +8 -0
  207. package/dist/src/worker/basic.d.ts +0 -1
  208. package/dist/src/worker/basic.js +23 -7
  209. package/dist/src/worker/custody.d.ts +4 -1
  210. package/dist/src/worker/custody.js +111 -38
  211. package/dist/src/worker/engine.d.ts +2 -1
  212. package/dist/src/worker/engine.js +42 -20
  213. package/dist/src/worker/identity.js +13 -5
  214. package/dist/src/worker/journal.d.ts +1 -1
  215. package/dist/src/worker/journal.js +54 -24
  216. package/dist/src/worker/lock.d.ts +18 -0
  217. package/dist/src/worker/lock.js +196 -0
  218. package/dist/src/worker/runtime-gc.d.ts +4 -3
  219. package/dist/src/worker/runtime-gc.js +68 -41
  220. package/dist/src/worker/supervisor-child.js +19 -6
  221. package/dist/src/worker/supervisor.js +41 -8
  222. package/native/runtime-manifest.json +4 -4
  223. package/package.json +10 -2
  224. package/dist/src/digest/store.d.ts +0 -57
  225. package/dist/src/digest/store.js +0 -79
  226. package/dist/src/store/foundation.d.ts +0 -7
  227. package/dist/src/store/foundation.js +0 -7
@@ -0,0 +1,327 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { integer, safeDirectory, text } from './files.js';
3
+ import { mutate, sourceRefId } from './changes.js';
4
+ import { isFileLocator, portableLocator } from './ids.js';
5
+ import { syncUris } from '../identity/visibility.js';
6
+ import { sha256 } from '../util/hash.js';
7
+ import { checkAttempt, checkOwner, ownerSnapshot, pendingCleanup, removeStaging } from './lease.js';
8
+ import { now, transaction } from './context.js';
9
+ import { SpongeError } from '../errors.js';
10
+ const stages = ['queued', 'acquiring', 'extracting', 'indexing', 'promoting', 'cleanup', 'settled'];
11
+ const activeStates = "('queued','running','delayed','cleanup-pending')";
12
+ /** Capture jobs (acquire, extract, commit one source) and the owner lease shared with every other job kind. */
13
+ export class Queue {
14
+ c;
15
+ index;
16
+ capture;
17
+ constructor(c, index) {
18
+ this.c = c;
19
+ this.index = index;
20
+ const db = c.db;
21
+ this.capture = {
22
+ owns: (id) => !!db.prepare('SELECT 1 FROM jobs WHERE id=?').get(id),
23
+ list: () => db
24
+ .prepare('SELECT j.* FROM jobs j JOIN items i ON i.id=j.item_id WHERE i.deleted_at IS NULL ORDER BY j.created_at,j.id')
25
+ .all(),
26
+ attempts: (token) => db
27
+ .prepare('SELECT a.*,j.state,j.cancel_requested FROM attempts a JOIN jobs j ON j.id=a.job_id WHERE a.owner_incarnation=? AND a.fence=? ORDER BY a.created_at,a.id')
28
+ .all(token.ownerIncarnation, token.fence)
29
+ .map((a) => ({
30
+ ...token,
31
+ attemptId: String(a.id),
32
+ jobId: String(a.job_id),
33
+ stage: String(a.stage),
34
+ cleanup: a.cleanup,
35
+ finished: a.finished_at !== null,
36
+ jobState: String(a.state),
37
+ cancelRequested: a.cancel_requested === 1,
38
+ })),
39
+ cancel: (jobId) => transaction(db, () => {
40
+ const job = db.prepare('SELECT state FROM jobs WHERE id=?').get(jobId);
41
+ if (!job)
42
+ throw new SpongeError('unavailable');
43
+ if (['succeeded', 'cancelled'].includes(String(job.state)))
44
+ return;
45
+ db.prepare("UPDATE jobs SET cancel_requested=1,state=CASE WHEN state IN ('queued','delayed','needs-auth','needs-permission','unsupported','failed') THEN 'cancelled' ELSE state END WHERE id=?").run(jobId);
46
+ }),
47
+ retry: (jobId, options) => {
48
+ if (options.automatic !== undefined && typeof options.automatic !== 'boolean')
49
+ throw new SpongeError('invalid-input');
50
+ if (options.notBefore !== undefined &&
51
+ (typeof options.notBefore !== 'string' || !Number.isFinite(Date.parse(options.notBefore))))
52
+ throw new SpongeError('invalid-input');
53
+ transaction(db, () => {
54
+ const job = db
55
+ .prepare('SELECT j.*,i.deleted_at FROM jobs j JOIN items i ON i.id=j.item_id WHERE j.id=?')
56
+ .get(jobId);
57
+ if (!job ||
58
+ job.deleted_at ||
59
+ job.cancel_requested ||
60
+ !['failed', 'needs-auth', 'needs-permission', 'unsupported'].includes(String(job.state)))
61
+ throw new SpongeError('unavailable');
62
+ if (db
63
+ .prepare("SELECT count(*) n FROM attempts WHERE job_id=? AND (cleanup='pending' OR finished_at IS NULL)")
64
+ .get(jobId)?.n)
65
+ throw new SpongeError('owner-busy');
66
+ if (db
67
+ .prepare(`SELECT count(*) n FROM jobs WHERE item_id=? AND id<>? AND state IN ${activeStates}`)
68
+ .get(String(job.item_id), jobId)?.n)
69
+ throw new SpongeError('owner-busy');
70
+ let due = options.notBefore === undefined ? Date.now() : Date.parse(options.notBefore);
71
+ if (options.automatic) {
72
+ if (job.retryable !== 1 ||
73
+ !['unavailable', 'rate-limited'].includes(String(job.reason)) ||
74
+ Number(job.attempt_count) >= 3)
75
+ throw new SpongeError('unavailable');
76
+ const backoff = Math.max(Number(job.retry_after_ms ?? 0), 1000 * 2 ** Math.max(0, Number(job.attempt_count) - 1));
77
+ due = Math.max(due, Date.now() + backoff);
78
+ }
79
+ const notBefore = new Date(due).toISOString();
80
+ db.prepare('UPDATE jobs SET state=?,not_before=?,reason=NULL,retryable=0,retry_after_ms=NULL WHERE id=?').run(due > Date.now() ? 'delayed' : 'queued', notBefore, jobId);
81
+ });
82
+ },
83
+ cancelRequested: (jobId) => {
84
+ const row = db
85
+ .prepare('SELECT j.cancel_requested,i.deleted_at FROM jobs j JOIN items i ON i.id=j.item_id WHERE j.id=?')
86
+ .get(jobId);
87
+ if (!row)
88
+ throw new SpongeError('unavailable');
89
+ return row.cancel_requested === 1 || row.deleted_at !== null;
90
+ },
91
+ recover: (attempt) => {
92
+ removeStaging(c.root, attempt.attemptId, (path) => {
93
+ safeDirectory(path);
94
+ });
95
+ db.prepare('DELETE FROM staging_pins WHERE attempt_id=?').run(attempt.attemptId);
96
+ db.prepare("UPDATE attempts SET cleanup='settled',stage='settled',finished_at=COALESCE(finished_at,?) WHERE id=?").run(now(), attempt.attemptId);
97
+ if (!attempt.finished)
98
+ db.prepare("UPDATE jobs SET state=CASE WHEN cancel_requested=1 THEN 'cancelled' ELSE 'failed' END,reason='unclassified',retryable=0,retry_after_ms=NULL WHERE id=? AND state IN ('running','cleanup-pending')").run(attempt.jobId);
99
+ },
100
+ };
101
+ }
102
+ /** Index jobs are checked first; any other id is a capture job (which reports `unavailable` when it does not exist). */
103
+ kindOf(jobId) {
104
+ return this.index.owns(jobId) ? this.index : this.capture;
105
+ }
106
+ acquireOwner(ownerIncarnation) {
107
+ text(ownerIncarnation, 256);
108
+ const db = this.c.db;
109
+ return transaction(db, () => {
110
+ const old = db.prepare('SELECT * FROM worker_owner WHERE id=1').get();
111
+ if (old.incarnation !== null && old.incarnation !== ownerIncarnation)
112
+ throw new SpongeError('owner-busy');
113
+ if (old.incarnation === ownerIncarnation)
114
+ return { ownerIncarnation, fence: Number(old.fence) };
115
+ db.prepare('UPDATE worker_owner SET incarnation=?,fence=fence+1 WHERE id=1').run(ownerIncarnation);
116
+ return { ownerIncarnation, fence: Number(old.fence) + 1 };
117
+ });
118
+ }
119
+ releaseOwner(token) {
120
+ transaction(this.c.db, () => {
121
+ checkOwner(this.c.db, token);
122
+ if (this.ownerAttempts(token).some((attempt) => attempt.cleanup === 'pending'))
123
+ throw new SpongeError('owner-busy');
124
+ this.c.db.prepare('UPDATE worker_owner SET incarnation=NULL WHERE id=1').run();
125
+ });
126
+ }
127
+ ownerSnapshot() {
128
+ return ownerSnapshot(this.c.db);
129
+ }
130
+ /** Every attempt the owner claimed, capture first, then index. */
131
+ ownerAttempts(token) {
132
+ checkOwner(this.c.db, token);
133
+ return [...this.capture.attempts(token), ...this.index.attempts(token)];
134
+ }
135
+ /** Requires external custody proof for EVERY pending attempt, even after its job committed.
136
+ * IDs are acknowledgements from the custody owner, never evidence inferred from PID/timeouts here. */
137
+ recoverOwner(expected, provedAttemptIds) {
138
+ if (!Array.isArray(provedAttemptIds) || new Set(provedAttemptIds).size !== provedAttemptIds.length)
139
+ throw new SpongeError('invalid-input');
140
+ for (const id of provedAttemptIds)
141
+ text(id, 256);
142
+ transaction(this.c.db, () => {
143
+ checkOwner(this.c.db, expected);
144
+ const pending = this.ownerAttempts(expected).filter((a) => a.cleanup === 'pending');
145
+ const proof = new Set(provedAttemptIds);
146
+ if (proof.size !== pending.length || pending.some((a) => !proof.has(a.attemptId)))
147
+ throw new SpongeError('owner-busy');
148
+ for (const attempt of pending)
149
+ (attempt.kind === 'index' ? this.index : this.capture).recover(attempt);
150
+ this.c.db.prepare('UPDATE worker_owner SET incarnation=NULL,fence=fence+1 WHERE id=1').run();
151
+ });
152
+ }
153
+ /** Deduplicates source identity including realm and persists an idempotent submission and queued job. */
154
+ submit(input) {
155
+ text(input.sourceKey);
156
+ text(input.locator, 16384);
157
+ text(input.idempotencyKey, 256);
158
+ if (!['article', 'pdf', 'media'].includes(input.kind))
159
+ throw new SpongeError('invalid-input');
160
+ const realm = input.realm ?? '';
161
+ if (realm)
162
+ text(realm, 256);
163
+ if (input.intent !== undefined && !['save', 'refresh'].includes(input.intent))
164
+ throw new SpongeError('invalid-input');
165
+ const db = this.c.db;
166
+ return transaction(db, () => {
167
+ const prior = db
168
+ .prepare('SELECT s.*,r.source_key,r.realm_key,i.kind,i.deleted_at FROM submissions s JOIN source_refs r ON r.id=s.source_id JOIN items i ON i.id=s.item_id WHERE idempotency_key=?')
169
+ .get(input.idempotencyKey);
170
+ if (prior) {
171
+ if (prior.deleted_at)
172
+ throw new SpongeError('unavailable');
173
+ if (prior.kind !== input.kind)
174
+ throw new SpongeError('invalid-input');
175
+ if (prior.source_key !== input.sourceKey ||
176
+ prior.realm_key !== realm ||
177
+ prior.intent !== (input.intent ?? 'save'))
178
+ throw new SpongeError('invalid-input');
179
+ return { itemId: prior.item_id, jobId: prior.job_id, submissionId: prior.id, duplicate: true };
180
+ }
181
+ let source = db
182
+ .prepare('SELECT r.*,i.deleted_at,i.kind FROM source_refs r JOIN items i ON i.id=r.item_id WHERE source_key=? AND realm_key=?')
183
+ .get(input.sourceKey, realm);
184
+ if (source?.deleted_at)
185
+ throw new SpongeError('unavailable');
186
+ if (source && source.kind !== input.kind)
187
+ throw new SpongeError('invalid-input');
188
+ if (!source) {
189
+ // Devices of one library agree on the id; outsiders cannot derive it from a URL.
190
+ const library = String(db.prepare('SELECT uuid FROM library_info WHERE id=1').get()?.uuid);
191
+ const itemId = sha256(JSON.stringify(['item-v1', library, realm, input.sourceKey]));
192
+ if (db.prepare('SELECT 1 FROM items WHERE id=?').get(itemId))
193
+ throw new SpongeError('integrity');
194
+ // A local path never enters the log: the portable locator syncs, the path stays in `local_sources`.
195
+ mutate(db, 'item', itemId, 'created', {
196
+ kind: input.kind,
197
+ sourceKey: input.sourceKey,
198
+ realm,
199
+ locator: portableLocator(input.locator),
200
+ createdAt: now(),
201
+ });
202
+ syncUris(db, itemId);
203
+ source = { id: sourceRefId(itemId), item_id: itemId };
204
+ }
205
+ if (isFileLocator(input.locator))
206
+ db.prepare('INSERT INTO local_sources(item_id,locator) VALUES(?,?) ON CONFLICT(item_id) DO UPDATE SET locator=excluded.locator').run(source.item_id, input.locator);
207
+ const active = db
208
+ .prepare(`SELECT id FROM jobs WHERE item_id=? AND state IN ${activeStates} ORDER BY created_at LIMIT 1`)
209
+ .get(source.item_id);
210
+ const jobId = active ? String(active.id) : randomUUID();
211
+ if (!active)
212
+ db.prepare("INSERT INTO jobs(id,item_id,state,created_at) VALUES(?,?,'queued',?)").run(jobId, source.item_id, now());
213
+ const submissionId = randomUUID();
214
+ db.prepare('INSERT INTO submissions VALUES(?,?,?,?,?,?,?)').run(submissionId, input.idempotencyKey, source.id, source.item_id, jobId, now(), input.intent ?? 'save');
215
+ return { itemId: source.item_id, jobId, submissionId, duplicate: false };
216
+ });
217
+ }
218
+ /** Jobs of every kind, oldest first. */
219
+ list() {
220
+ return [...this.capture.list(), ...this.index.list()].sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)) || String(a.id).localeCompare(String(b.id)));
221
+ }
222
+ /** Acquisition data for a capture job. A local file's locator is its path on this device (`local_sources`), never the synced form. */
223
+ source(jobId) {
224
+ text(jobId, 256);
225
+ const row = this.c.db
226
+ .prepare('SELECT i.id itemId,i.kind,r.source_key sourceKey,COALESCE(l.locator,r.locator) locator,r.realm_key realm FROM jobs j JOIN items i ON i.id=j.item_id JOIN source_refs r ON r.item_id=i.id LEFT JOIN local_sources l ON l.item_id=i.id WHERE j.id=? AND i.deleted_at IS NULL ORDER BY r.id LIMIT 1')
227
+ .get(jobId);
228
+ return row ?? null;
229
+ }
230
+ claimNext(token) {
231
+ checkOwner(this.c.db, token);
232
+ const row = this.c.db
233
+ .prepare("SELECT j.id FROM jobs j JOIN items i ON i.id=j.item_id WHERE j.state IN ('queued','delayed') AND j.cancel_requested=0 AND i.deleted_at IS NULL AND (j.not_before IS NULL OR j.not_before<=?) ORDER BY j.priority DESC,j.created_at,j.id LIMIT 1")
234
+ .get(now());
235
+ return row ? this.claim(token, String(row.id)) : null;
236
+ }
237
+ claim(token, jobId) {
238
+ text(jobId, 256);
239
+ const db = this.c.db;
240
+ return transaction(db, () => {
241
+ checkOwner(db, token);
242
+ if (db.prepare("SELECT count(*) AS n FROM index_attempts WHERE cleanup='pending'").get()?.n)
243
+ throw new SpongeError('owner-busy');
244
+ const job = db
245
+ .prepare('SELECT j.*,i.deleted_at FROM jobs j JOIN items i ON i.id=j.item_id WHERE j.id=?')
246
+ .get(jobId);
247
+ if (!job ||
248
+ !['queued', 'delayed'].includes(String(job.state)) ||
249
+ job.cancel_requested ||
250
+ job.deleted_at ||
251
+ (job.not_before && String(job.not_before) > now()))
252
+ throw new SpongeError('unavailable');
253
+ const attemptId = randomUUID();
254
+ db.prepare("UPDATE jobs SET state='running',attempt_count=attempt_count+1 WHERE id=?").run(jobId);
255
+ db.prepare("INSERT INTO attempts(id,job_id,owner_incarnation,fence,stage,cleanup,created_at) VALUES(?,?,?,?,'acquiring','pending',?)").run(attemptId, jobId, token.ownerIncarnation, token.fence, now());
256
+ return { ...token, jobId, attemptId };
257
+ });
258
+ }
259
+ checkpoint(token, stage) {
260
+ if (!stages.includes(stage) || stage === 'settled')
261
+ throw new SpongeError('invalid-input');
262
+ transaction(this.c.db, () => {
263
+ const attempt = checkAttempt(this.c.db, token);
264
+ if (stages.indexOf(stage) < stages.indexOf(String(attempt.stage)) || !stages.includes(String(attempt.stage)))
265
+ throw new SpongeError('invalid-input');
266
+ this.c.db.prepare('UPDATE attempts SET stage=? WHERE id=?').run(stage, token.attemptId);
267
+ });
268
+ }
269
+ cancelRequested(jobId) {
270
+ text(jobId, 256);
271
+ return this.kindOf(jobId).cancelRequested(jobId);
272
+ }
273
+ retry(jobId, options = {}) {
274
+ text(jobId, 256);
275
+ this.kindOf(jobId).retry(jobId, options);
276
+ }
277
+ cancel(jobId) {
278
+ text(jobId, 256);
279
+ this.kindOf(jobId).cancel(jobId);
280
+ }
281
+ fail(token, reason = 'unclassified', options = {}) {
282
+ if (options.retryable !== undefined && typeof options.retryable !== 'boolean')
283
+ throw new SpongeError('invalid-input');
284
+ if (options.retryAfterMs !== undefined)
285
+ integer(options.retryAfterMs, 86400000);
286
+ if (!/^[a-z][a-z-]{0,63}$/.test(reason))
287
+ throw new SpongeError('invalid-input');
288
+ const db = this.c.db;
289
+ transaction(db, () => {
290
+ checkOwner(db, token);
291
+ const a = db
292
+ .prepare('SELECT a.*,j.cancel_requested FROM attempts a JOIN jobs j ON j.id=a.job_id WHERE a.id=? AND a.job_id=?')
293
+ .get(token.attemptId, token.jobId);
294
+ if (!a || a.owner_incarnation !== token.ownerIncarnation || a.fence !== token.fence || a.finished_at)
295
+ throw new SpongeError('owner-busy');
296
+ db.prepare('UPDATE jobs SET state=?,reason=?,retryable=?,retry_after_ms=? WHERE id=?').run(a.cancel_requested
297
+ ? 'cancelled'
298
+ : reason === 'repair-auth'
299
+ ? 'needs-auth'
300
+ : reason === 'grant-permission'
301
+ ? 'needs-permission'
302
+ : reason === 'unsupported'
303
+ ? 'unsupported'
304
+ : 'failed', reason, Number(options.retryable === true), options.retryAfterMs ?? null, token.jobId);
305
+ db.prepare("UPDATE attempts SET stage='cleanup',finished_at=? WHERE id=?").run(now(), token.attemptId);
306
+ });
307
+ }
308
+ /** Caller must first settle owned external children. This only releases storage staging pins. */
309
+ settle(token) {
310
+ const db = this.c.db;
311
+ transaction(db, () => {
312
+ checkOwner(db, token);
313
+ const a = db.prepare('SELECT * FROM attempts WHERE id=? AND job_id=?').get(token.attemptId, token.jobId);
314
+ if (!a || a.owner_incarnation !== token.ownerIncarnation || a.fence !== token.fence || !a.finished_at)
315
+ throw new SpongeError('owner-busy');
316
+ removeStaging(this.c.root, token.attemptId, (path) => {
317
+ safeDirectory(path);
318
+ });
319
+ db.prepare('DELETE FROM staging_pins WHERE attempt_id=?').run(token.attemptId);
320
+ db.prepare("UPDATE attempts SET cleanup='settled',stage='settled' WHERE id=?").run(token.attemptId);
321
+ });
322
+ }
323
+ /** Attempts of any kind whose cleanup is pending; GC and pruning wait for zero. */
324
+ pendingCleanup() {
325
+ return pendingCleanup(this.c.db);
326
+ }
327
+ }
@@ -0,0 +1,12 @@
1
+ import { type Context } from './context.js';
2
+ import type { CommitRevision } from './types.js';
3
+ /** Captured revisions: content-addressed, immutable once committed, and the item's readable pointer. */
4
+ export declare class Revisions {
5
+ private readonly c;
6
+ constructor(c: Context);
7
+ markdown(revisionId: string): string;
8
+ commit(input: CommitRevision): {
9
+ revisionId: string;
10
+ duplicate: boolean;
11
+ };
12
+ }
@@ -0,0 +1,189 @@
1
+ import { join } from 'node:path';
2
+ import { limits } from '../../contracts/index.js';
3
+ import { validateMetadataClaim, validateMetadataWarnings } from '../../contracts/validate.js';
4
+ import { confinedFile, hash, integer, text, verifyFile } from './files.js';
5
+ import { sha256 } from '../util/hash.js';
6
+ import { mutate } from './changes.js';
7
+ import { blockId, claimColumns, claimId, representationId } from './ids.js';
8
+ import { syncUris } from '../identity/visibility.js';
9
+ import { importNote, markdownFor, setReadingState } from '../annotations/store.js';
10
+ import { indexReadable } from '../search/lexical.js';
11
+ import { trackLibrary } from '../indexing/index.js';
12
+ import { checkAttempt } from './lease.js';
13
+ import { importReceipt } from './imports.js';
14
+ import { bumpEpoch, now, transaction } from './context.js';
15
+ import { SpongeError } from '../errors.js';
16
+ /** Captured revisions: content-addressed, immutable once committed, and the item's readable pointer. */
17
+ export class Revisions {
18
+ c;
19
+ constructor(c) {
20
+ this.c = c;
21
+ }
22
+ markdown(revisionId) {
23
+ text(revisionId, 256);
24
+ return markdownFor(this.c.db, revisionId);
25
+ }
26
+ commit(input) {
27
+ const { root, db, options } = this.c;
28
+ hash(input.bundleHash);
29
+ if (typeof input.markdown !== 'string' || Buffer.byteLength(input.markdown) > limits.markdownBytes)
30
+ throw new SpongeError('limit-exceeded');
31
+ text(input.itemId, 256);
32
+ text(input.extractor.name, 256);
33
+ text(input.extractor.version, 128);
34
+ if (input.title)
35
+ text(input.title, 4096);
36
+ if (!['complete', 'partial'].includes(input.status) || input.assets.length > limits.imagesPerItem + 1)
37
+ throw new SpongeError('invalid-input');
38
+ const metadata = input.metadata ?? [];
39
+ if (metadata.length > limits.metadataClaims || Buffer.byteLength(JSON.stringify(metadata)) > limits.metadataBytes)
40
+ throw new SpongeError('limit-exceeded');
41
+ for (const claim of metadata) {
42
+ try {
43
+ validateMetadataClaim(claim);
44
+ }
45
+ catch {
46
+ throw new SpongeError('invalid-input');
47
+ }
48
+ }
49
+ const metadataWarnings = input.metadataWarnings ?? [];
50
+ try {
51
+ validateMetadataWarnings(metadataWarnings);
52
+ }
53
+ catch {
54
+ throw new SpongeError('invalid-input');
55
+ }
56
+ const imported = input.importedState;
57
+ if (imported) {
58
+ text(imported.namespace, 256);
59
+ text(imported.sourceKey, 8192);
60
+ hash(imported.sourceHash);
61
+ text(imported.importerVersion, 128);
62
+ if (!['private', 'published'].includes(imported.privacy) ||
63
+ !Array.isArray(imported.notes) ||
64
+ imported.notes.length > 1000 ||
65
+ Buffer.byteLength(JSON.stringify(imported)) > 4 * 1024 ** 2)
66
+ throw new SpongeError('limit-exceeded');
67
+ const noteIds = new Set();
68
+ for (const note of imported.notes) {
69
+ text(note.id, 256);
70
+ text(note.body, 65536);
71
+ if (noteIds.has(note.id))
72
+ throw new SpongeError('invalid-input');
73
+ noteIds.add(note.id);
74
+ }
75
+ if (imported.readingState !== undefined &&
76
+ (!imported.readingState || Array.isArray(imported.readingState) || typeof imported.readingState !== 'object'))
77
+ throw new SpongeError('invalid-input');
78
+ }
79
+ for (const asset of input.assets) {
80
+ hash(asset.sha256);
81
+ const transcript = asset.mediaType === 'application/json' && asset.relationship === 'transcript';
82
+ integer(asset.bytes, transcript ? 4 * 1024 ** 2 : asset.mediaType === 'application/pdf' ? limits.pdfBytes : limits.imageBytes);
83
+ if (!/^image\/[a-z0-9.+-]+$/.test(asset.mediaType) && asset.mediaType !== 'application/pdf' && !transcript)
84
+ throw new SpongeError('invalid-input');
85
+ if (asset.relativePath !== `blobs/sha256/${asset.sha256.slice(0, 2)}/${asset.sha256}`)
86
+ throw new SpongeError('integrity');
87
+ verifyFile(confinedFile(root, join(root, asset.relativePath)), asset.sha256, asset.bytes);
88
+ }
89
+ const payloadHash = sha256(JSON.stringify({
90
+ markdown: input.markdown,
91
+ title: input.title ?? '',
92
+ extractor: input.extractor,
93
+ status: input.status,
94
+ assets: input.assets,
95
+ metadata,
96
+ warnings: input.warnings ?? [],
97
+ ...(metadataWarnings.length ? { metadataWarnings } : {}),
98
+ }));
99
+ const result = transaction(db, () => {
100
+ checkAttempt(db, input, input.itemId);
101
+ for (const asset of input.assets) {
102
+ const pin = db
103
+ .prepare("SELECT bytes FROM staging_pins WHERE attempt_id=? AND sha256=? AND state='published'")
104
+ .get(input.attemptId, asset.sha256);
105
+ if (!pin || pin.bytes !== asset.bytes)
106
+ throw new SpongeError('integrity');
107
+ }
108
+ const existing = db
109
+ .prepare('SELECT id,payload_hash FROM revisions WHERE item_id=? AND bundle_hash=?')
110
+ .get(input.itemId, input.bundleHash);
111
+ const item = db.prepare('SELECT current_revision,title_captured FROM items WHERE id=?').get(input.itemId);
112
+ // Content-addressed: the same capture on two devices is the same revision object.
113
+ const revisionId = existing
114
+ ? String(existing.id)
115
+ : sha256(JSON.stringify(['revision-v1', input.itemId, input.bundleHash]));
116
+ if (!existing) {
117
+ db.prepare('INSERT INTO revisions(id,item_id,parent_id,bundle_hash,extractor_name,extractor_version,captured_at,status,warnings_json,payload_hash,metadata_warnings_json) VALUES(?,?,?,?,?,?,?,?,?,?,?)').run(revisionId, input.itemId, item.current_revision ?? null, input.bundleHash, input.extractor.name, input.extractor.version, now(), input.status, JSON.stringify(input.warnings ?? []), payloadHash, JSON.stringify(metadataWarnings));
118
+ for (const [ordinal, asset] of input.assets.entries()) {
119
+ const existingAsset = db.prepare('SELECT bytes FROM assets WHERE sha256=?').get(asset.sha256);
120
+ if (existingAsset && existingAsset.bytes !== asset.bytes)
121
+ throw new SpongeError('integrity');
122
+ db.prepare('INSERT OR IGNORE INTO assets VALUES(?,?,?,?)').run(asset.sha256, asset.bytes, asset.mediaType, asset.relativePath);
123
+ db.prepare('INSERT INTO revision_assets VALUES(?,?,?,?,?,?,?)').run(revisionId, asset.sha256, ordinal, asset.relationship ?? 'image', null, asset.alt ?? null, asset.caption ?? null);
124
+ }
125
+ db.prepare('INSERT INTO representations(id,revision_id,kind,mime_type,sha256,bytes,markdown,extractor_version) VALUES(?,?,?,?,?,?,?,?)').run(representationId(revisionId, 'markdown', 'text/markdown', sha256(input.markdown)), revisionId, 'markdown', 'text/markdown', sha256(input.markdown), Buffer.byteLength(input.markdown), input.markdown, input.extractor.version);
126
+ for (const [ordinal, claim] of metadata.entries()) {
127
+ const columns = claimColumns(claim);
128
+ db.prepare('INSERT INTO metadata_claims(id,revision_id,field,value_json,source,observed_at,rule,timezone_known,observed_json,locator,selected,extraction_version,date_precision) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)').run(claimId(revisionId, ordinal, columns), revisionId, ...columns);
129
+ }
130
+ db.prepare('INSERT INTO blocks(id,revision_id,ordinal,kind,text) VALUES(?,?,0,?,?)').run(blockId(revisionId, 0, 'document', input.markdown), revisionId, 'document', input.markdown);
131
+ }
132
+ else {
133
+ if (existing.payload_hash !== payloadHash)
134
+ throw new SpongeError('integrity');
135
+ const prior = db
136
+ .prepare("SELECT sha256 FROM representations WHERE revision_id=? AND kind='markdown'")
137
+ .get(revisionId);
138
+ if (prior?.sha256 !== sha256(input.markdown))
139
+ throw new SpongeError('integrity');
140
+ }
141
+ if (item.current_revision !== revisionId) {
142
+ mutate(db, 'item', input.itemId, 'currentRevision', revisionId);
143
+ // Only the captured default: a note title or the user's override still wins (`items.title`, see changes.ts).
144
+ if (item.title_captured !== (input.title ?? ''))
145
+ mutate(db, 'item', input.itemId, 'capturedTitle', input.title ?? '');
146
+ const stored = db
147
+ .prepare("SELECT markdown FROM representations WHERE revision_id=? AND kind='markdown'")
148
+ .get(revisionId);
149
+ indexReadable(db, input.itemId, revisionId, String(db.prepare('SELECT title FROM items WHERE id=?').get(input.itemId).title), String(stored.markdown));
150
+ syncUris(db, input.itemId);
151
+ bumpEpoch(db);
152
+ // Active indexes follow the new revision; only its new chunks are queued for embedding.
153
+ trackLibrary(db);
154
+ }
155
+ if (imported) {
156
+ const receipt = importReceipt(db, imported.namespace, imported.sourceKey, imported.sourceHash);
157
+ if (receipt && receipt.itemId !== input.itemId)
158
+ throw new SpongeError('integrity');
159
+ for (const note of imported.notes)
160
+ importNote(db, {
161
+ itemId: input.itemId,
162
+ revisionId,
163
+ namespace: imported.namespace,
164
+ sourceKey: imported.sourceKey,
165
+ noteId: note.id,
166
+ body: note.body,
167
+ });
168
+ const state = JSON.stringify({ ...imported.readingState, sourcePrivacy: imported.privacy });
169
+ const prior = db.prepare('SELECT state_json FROM reading_state WHERE item_id=?').get(input.itemId);
170
+ // Existing reading choices remain authoritative; import never overwrites them.
171
+ if (!prior)
172
+ setReadingState(db, input.itemId, JSON.parse(state));
173
+ else {
174
+ const existing = JSON.parse(String(prior.state_json));
175
+ const sourcePrivacy = existing.sourcePrivacy === 'published' && imported.privacy === 'published' ? 'published' : 'private';
176
+ if (sourcePrivacy !== existing.sourcePrivacy)
177
+ setReadingState(db, input.itemId, { ...existing, sourcePrivacy });
178
+ }
179
+ db.prepare('INSERT OR IGNORE INTO import_receipts VALUES(?,?,?,?,?,?)').run(imported.namespace, imported.sourceKey, imported.sourceHash, imported.importerVersion, input.itemId, 'imported');
180
+ }
181
+ db.prepare("UPDATE jobs SET state='succeeded',reason=NULL WHERE id=?").run(input.jobId);
182
+ db.prepare("UPDATE attempts SET stage='cleanup',finished_at=? WHERE id=?").run(now(), input.attemptId);
183
+ options.fault?.('transaction');
184
+ return { revisionId, duplicate: !!existing };
185
+ });
186
+ options.fault?.('committed');
187
+ return result;
188
+ }
189
+ }
@@ -0,0 +1,63 @@
1
+ import * as indexing from '../indexing/index.js';
2
+ import * as prune from '../indexing/prune.js';
3
+ import { hybridSearch } from '../search/hybrid.js';
4
+ import { type Context } from './context.js';
5
+ import type { JobKind } from './queue.js';
6
+ import type { AttemptToken, OwnerToken } from './types.js';
7
+ /**
8
+ * Embedding index generations, the index jobs that build them under the shared worker lease, retired-generation pruning
9
+ * and hybrid search. The generation and job SQL lives in `src/indexing/`; this is its store surface.
10
+ */
11
+ export declare class SearchIndex {
12
+ private readonly c;
13
+ /** Index jobs as one `JobKind` of the queue. */
14
+ readonly jobs: JobKind;
15
+ constructor(c: Context);
16
+ create(options: Parameters<typeof indexing.createGeneration>[1]): indexing.GenerationStatus;
17
+ status(id: string): indexing.GenerationStatus;
18
+ inputs(id: string, limit?: number): indexing.IndexInput[];
19
+ writeBatch(id: string, fingerprint: string, batch: Parameters<typeof indexing.writeBatch>[3]): void;
20
+ activate(id: string): indexing.GenerationStatus;
21
+ cancel(id: string): void;
22
+ submitJob(generationId: string): string;
23
+ job(id: string): Record<string, import("node:sqlite").SQLOutputValue> | undefined;
24
+ claim(owner: OwnerToken, id?: string): AttemptToken | null;
25
+ checkpoint(token: AttemptToken, stage: string): void;
26
+ writeAttemptBatch(token: AttemptToken, fingerprint: string, batch: Parameters<typeof indexing.writeBatch>[3]): void;
27
+ /** Coverage catches up with the library before a run reads its pending inputs. */
28
+ refresh(token: AttemptToken): number;
29
+ /** A complete building generation activates; an active one stays active. Content still pending (a capture after the last
30
+ * batch) queues the job again, so the same drain picks it up. */
31
+ complete(token: AttemptToken): indexing.GenerationStatus;
32
+ /** The profile's active generation with its coverage and pending counts and its index job, or nulls when none is active. */
33
+ overview(profileId?: string): {
34
+ generation: null;
35
+ job: null;
36
+ } | {
37
+ generation: indexing.GenerationStatus;
38
+ job: {
39
+ id: string;
40
+ state: string;
41
+ reason: string | null;
42
+ } | null;
43
+ };
44
+ fail(token: AttemptToken, reason: string): void;
45
+ settle(token: AttemptToken): void;
46
+ /** An index attempt's staging directory is removed only when its private ownership marker names this attempt and owner. */
47
+ private checkStaging;
48
+ /** Retired-generation pruning; see src/indexing/prune.ts. The plan probes rollback inside a rolled-back savepoint. */
49
+ prunePlan(keep: number): {
50
+ generations: prune.PruneEntry[];
51
+ orphans: {
52
+ vectors: number;
53
+ bytes: number;
54
+ };
55
+ stats: prune.PageStats;
56
+ };
57
+ pruneStep(owner: OwnerToken, id: string, keep: number): number;
58
+ /** One bounded batch of embeddings no retained generation covers. Returns rows deleted; 0 means done. */
59
+ gcStep(owner: OwnerToken): number;
60
+ /** VACUUM cannot run in a transaction: fences are checked under the owner token immediately before it. */
61
+ vacuum(owner: OwnerToken): prune.PageStats;
62
+ search(query: string, options?: Parameters<typeof hybridSearch>[2]): Promise<import("../search/hybrid.js").SearchResult>;
63
+ }