@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,20 @@
1
+ import type { DatabaseSync } from 'node:sqlite';
2
+ import type { AttemptToken, OwnerToken } from './types.js';
3
+ import type { Row } from './context.js';
4
+ /**
5
+ * The one worker lease every job kind shares: `worker_owner` names the incarnation that may run attempts, and its `fence`
6
+ * increases on every acquisition and recovery. Every attempt records the owner and fence that claimed it, so a stale owner's
7
+ * write fails `owner-busy`. Storage CAS authority only: the worker separately establishes OS/process custody.
8
+ */
9
+ export declare function ownerMatches(db: DatabaseSync, token: OwnerToken): void;
10
+ /** `ownerMatches` for a caller-supplied token: its shape is checked first. */
11
+ export declare function checkOwner(db: DatabaseSync, token: OwnerToken): void;
12
+ export declare function ownerSnapshot(db: DatabaseSync): OwnerToken | null;
13
+ /** A running capture attempt of the current owner, not cancelled and (with `itemId`) for that item. */
14
+ export declare function checkAttempt(db: DatabaseSync, token: AttemptToken, itemId?: string): Row;
15
+ /** Attempts of any kind whose cleanup is still pending. */
16
+ export declare const pendingCleanup: (db: DatabaseSync) => number;
17
+ /** GC must honor durable staging pins, active backup fences and every index attempt or job still in flight. */
18
+ export declare const hasGcFence: (db: DatabaseSync) => boolean;
19
+ /** Remove an attempt's staging directory after `check` accepts it. */
20
+ export declare function removeStaging(root: string, attemptId: string, check: (path: string) => void): void;
@@ -0,0 +1,62 @@
1
+ import { rmSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { integer, pathExists, text } from './files.js';
4
+ import { syncDirectory } from '../util/fsync.js';
5
+ import { SpongeError } from '../errors.js';
6
+ /**
7
+ * The one worker lease every job kind shares: `worker_owner` names the incarnation that may run attempts, and its `fence`
8
+ * increases on every acquisition and recovery. Every attempt records the owner and fence that claimed it, so a stale owner's
9
+ * write fails `owner-busy`. Storage CAS authority only: the worker separately establishes OS/process custody.
10
+ */
11
+ export function ownerMatches(db, token) {
12
+ const owner = db.prepare('SELECT * FROM worker_owner WHERE id=1').get();
13
+ if (owner?.incarnation !== token.ownerIncarnation || owner?.fence !== token.fence)
14
+ throw new SpongeError('owner-busy');
15
+ }
16
+ /** `ownerMatches` for a caller-supplied token: its shape is checked first. */
17
+ export function checkOwner(db, token) {
18
+ text(token.ownerIncarnation, 256);
19
+ integer(token.fence);
20
+ ownerMatches(db, token);
21
+ }
22
+ export function ownerSnapshot(db) {
23
+ const row = db.prepare('SELECT incarnation,fence FROM worker_owner WHERE id=1').get();
24
+ return row.incarnation === null ? null : { ownerIncarnation: String(row.incarnation), fence: Number(row.fence) };
25
+ }
26
+ /** A running capture attempt of the current owner, not cancelled and (with `itemId`) for that item. */
27
+ export function checkAttempt(db, token, itemId) {
28
+ checkOwner(db, token);
29
+ text(token.attemptId, 256);
30
+ text(token.jobId, 256);
31
+ const row = db
32
+ .prepare('SELECT a.*,j.item_id,j.state,j.cancel_requested,i.deleted_at FROM attempts a JOIN jobs j ON j.id=a.job_id JOIN items i ON i.id=j.item_id WHERE a.id=? AND a.job_id=?')
33
+ .get(token.attemptId, token.jobId);
34
+ if (!row ||
35
+ row.owner_incarnation !== token.ownerIncarnation ||
36
+ row.fence !== token.fence ||
37
+ row.finished_at ||
38
+ row.state !== 'running')
39
+ throw new SpongeError('owner-busy');
40
+ if (row.cancel_requested || row.deleted_at)
41
+ throw new SpongeError('cancelled');
42
+ if (itemId && row.item_id !== itemId)
43
+ throw new SpongeError('invalid-input');
44
+ return row;
45
+ }
46
+ /** Attempts of any kind whose cleanup is still pending. */
47
+ export const pendingCleanup = (db) => Number(db
48
+ .prepare("SELECT (SELECT count(*) FROM attempts WHERE cleanup='pending')+(SELECT count(*) FROM index_attempts WHERE cleanup='pending') AS n")
49
+ .get()?.n);
50
+ /** GC must honor durable staging pins, active backup fences and every index attempt or job still in flight. */
51
+ export const hasGcFence = (db) => Number(db
52
+ .prepare("SELECT (SELECT count(*) FROM staging_pins)+(SELECT count(*) FROM backup_fences WHERE state='active')+(SELECT count(*) FROM index_attempts WHERE cleanup='pending')+(SELECT count(*) FROM index_jobs WHERE state IN ('running','cleanup-pending')) n")
53
+ .get()?.n) > 0;
54
+ /** Remove an attempt's staging directory after `check` accepts it. */
55
+ export function removeStaging(root, attemptId, check) {
56
+ const path = join(root, 'staging', attemptId);
57
+ if (pathExists(path)) {
58
+ check(path);
59
+ rmSync(path, { recursive: true });
60
+ syncDirectory(dirname(path));
61
+ }
62
+ }
@@ -0,0 +1,75 @@
1
+ import type { DatabaseSync } from 'node:sqlite';
2
+ import { type Visibility } from '../identity/visibility.js';
3
+ import { Queue } from './queue.js';
4
+ import { Blobs } from './blobs.js';
5
+ import { Revisions } from './revisions.js';
6
+ import { Derivations } from './derivations.js';
7
+ import { Annotations } from './annotations.js';
8
+ import { SearchIndex } from './search-index.js';
9
+ import { Backups } from './backups.js';
10
+ import { Imports } from './imports.js';
11
+ import type { ReadableItem, StoreOptions } from './types.js';
12
+ export interface TitleState {
13
+ title: string;
14
+ override: string | null;
15
+ default: string;
16
+ source: 'override' | 'note' | 'capture';
17
+ }
18
+ /**
19
+ * The library: items at the top level, and one module per concern, each owning its SQL. Every module shares the one
20
+ * connection; user-state writes go through `mutate` (src/store/changes.ts) inside the module's own transaction.
21
+ * Storage CAS authority only. The worker separately establishes OS/process custody.
22
+ */
23
+ export declare class LibraryStore {
24
+ readonly root: string;
25
+ private closed;
26
+ private readonly db;
27
+ /** Capture jobs and the worker lease shared by every job kind. */
28
+ readonly queue: Queue;
29
+ readonly blobs: Blobs;
30
+ readonly revisions: Revisions;
31
+ readonly derivations: Derivations;
32
+ readonly annotations: Annotations;
33
+ /** Embedding index generations, index jobs, pruning and hybrid search. */
34
+ readonly index: SearchIndex;
35
+ readonly backups: Backups;
36
+ readonly imports: Imports;
37
+ constructor(root: string, db: DatabaseSync, options: StoreOptions);
38
+ close(): void;
39
+ /** Increases with readable revision changes and tombstones, never with an identical refresh. */
40
+ get revisionEpoch(): number;
41
+ list(): {
42
+ itemId: string;
43
+ kind: string;
44
+ title: string;
45
+ revisionId: string | null;
46
+ }[];
47
+ read(itemId: string): ReadableItem | null;
48
+ /** Lexical search; the query is a literal FTS phrase. Hybrid search is `index.search`. */
49
+ search(query: string, count?: number): {
50
+ itemId: string;
51
+ title: string;
52
+ revisionId: string;
53
+ }[];
54
+ /** Tombstone the item and cancel its open jobs. Blobs stay until GC. */
55
+ delete(itemId: string): void;
56
+ /** Sync and publishing foundation; see docs/decisions/sync-and-publishing.md. */
57
+ identity(itemId: string): {
58
+ uris: import("../identity/uri.js").ItemUri[];
59
+ visibility: import("../identity/visibility.js").VisibilityState;
60
+ };
61
+ itemsForUri(uri: string): string[];
62
+ setVisibility(itemId: string, value: Visibility | null): import("../identity/visibility.js").VisibilityState;
63
+ /** The shown title and where it comes from: the user's override, else the importer's note title, else the captured title. */
64
+ title(itemId: string): TitleState;
65
+ /** Set the user's title (`null` restores the default). Logged as `item.titleOverride`; refresh and import never change it. */
66
+ setTitle(itemId: string, value: string | null): TitleState;
67
+ listChanges(after?: number, limit?: number): import("./changes.js").LoggedChange[];
68
+ publicProjection(): {
69
+ entries: import("../publish/projection.js").PublicEntry[];
70
+ skipped: {
71
+ itemId: string;
72
+ reason: string;
73
+ }[];
74
+ };
75
+ }
@@ -0,0 +1,174 @@
1
+ import { integer, text } from './files.js';
2
+ import { listChanges, mutate } from './changes.js';
3
+ import { itemUris, itemsForUri, setVisibility, visibility } from '../identity/visibility.js';
4
+ import { publicProjection } from '../publish/projection.js';
5
+ import { lexicalSearch, unindexReadable } from '../search/lexical.js';
6
+ import { trackLibrary } from '../indexing/index.js';
7
+ import { bumpEpoch, now, revisionEpoch, transaction } from './context.js';
8
+ import { Queue } from './queue.js';
9
+ import { Blobs } from './blobs.js';
10
+ import { Revisions } from './revisions.js';
11
+ import { Derivations } from './derivations.js';
12
+ import { Annotations } from './annotations.js';
13
+ import { SearchIndex } from './search-index.js';
14
+ import { Backups } from './backups.js';
15
+ import { Imports } from './imports.js';
16
+ import { validateMetadataWarnings } from '../../contracts/validate.js';
17
+ import { SpongeError } from '../errors.js';
18
+ /**
19
+ * The library: items at the top level, and one module per concern, each owning its SQL. Every module shares the one
20
+ * connection; user-state writes go through `mutate` (src/store/changes.ts) inside the module's own transaction.
21
+ * Storage CAS authority only. The worker separately establishes OS/process custody.
22
+ */
23
+ export class LibraryStore {
24
+ root;
25
+ closed = false;
26
+ db;
27
+ /** Capture jobs and the worker lease shared by every job kind. */
28
+ queue;
29
+ blobs;
30
+ revisions;
31
+ derivations;
32
+ annotations;
33
+ /** Embedding index generations, index jobs, pruning and hybrid search. */
34
+ index;
35
+ backups;
36
+ imports;
37
+ constructor(root, db, options) {
38
+ this.root = root;
39
+ const c = { root, db, options };
40
+ this.db = db;
41
+ this.index = new SearchIndex(c);
42
+ this.queue = new Queue(c, this.index.jobs);
43
+ this.blobs = new Blobs(c);
44
+ this.revisions = new Revisions(c);
45
+ this.derivations = new Derivations(c);
46
+ this.annotations = new Annotations(c);
47
+ this.backups = new Backups(c);
48
+ this.imports = new Imports(c);
49
+ }
50
+ close() {
51
+ if (!this.closed) {
52
+ this.db.close();
53
+ this.closed = true;
54
+ }
55
+ }
56
+ /** Increases with readable revision changes and tombstones, never with an identical refresh. */
57
+ get revisionEpoch() {
58
+ return revisionEpoch(this.db);
59
+ }
60
+ list() {
61
+ return this.db
62
+ .prepare('SELECT id itemId,kind,title,current_revision revisionId FROM items WHERE deleted_at IS NULL ORDER BY created_at,id')
63
+ .all();
64
+ }
65
+ read(itemId) {
66
+ text(itemId, 256);
67
+ const item = this.db
68
+ .prepare("SELECT i.id itemId,i.kind,i.title,r.id revisionId,r.status,p.markdown FROM items i JOIN revisions r ON r.id=i.current_revision JOIN representations p ON p.revision_id=r.id AND p.kind='markdown' WHERE i.id=? AND i.deleted_at IS NULL")
69
+ .get(itemId);
70
+ if (!item)
71
+ return null;
72
+ const assets = this.db
73
+ .prepare('SELECT a.*,r.relationship,r.alt,r.caption FROM revision_assets r JOIN assets a ON a.sha256=r.sha256 WHERE r.revision_id=? ORDER BY r.ordinal')
74
+ .all(item.revisionId)
75
+ .map((a) => ({
76
+ sha256: String(a.sha256),
77
+ bytes: Number(a.bytes),
78
+ relativePath: String(a.relative_path),
79
+ mediaType: String(a.media_type),
80
+ relationship: String(a.relationship),
81
+ ...(a.alt ? { alt: String(a.alt) } : {}),
82
+ ...(a.caption ? { caption: String(a.caption) } : {}),
83
+ }));
84
+ const metadata = this.db
85
+ .prepare('SELECT * FROM metadata_claims WHERE revision_id=? ORDER BY rowid')
86
+ .all(item.revisionId)
87
+ .map((c) => ({
88
+ field: String(c.field),
89
+ value: JSON.parse(String(c.value_json)),
90
+ source: String(c.source),
91
+ observedAt: String(c.observed_at),
92
+ ...(c.rule ? { rule: String(c.rule) } : {}),
93
+ ...(c.timezone_known === null ? {} : { timezoneKnown: !!c.timezone_known }),
94
+ ...(c.observed_json === null ? {} : { observed: JSON.parse(String(c.observed_json)) }),
95
+ ...(c.locator === null ? {} : { locator: String(c.locator) }),
96
+ ...(c.selected === null ? {} : { selected: !!c.selected }),
97
+ ...(c.extraction_version === null ? {} : { extractionVersion: String(c.extraction_version) }),
98
+ ...(c.date_precision === null
99
+ ? {}
100
+ : { datePrecision: String(c.date_precision) }),
101
+ }));
102
+ const metadataWarnings = JSON.parse(String(this.db.prepare('SELECT metadata_warnings_json FROM revisions WHERE id=?').get(item.revisionId)
103
+ ?.metadata_warnings_json));
104
+ validateMetadataWarnings(metadataWarnings);
105
+ return { ...item, assets, metadata, metadataWarnings };
106
+ }
107
+ /** Lexical search; the query is a literal FTS phrase. Hybrid search is `index.search`. */
108
+ search(query, count = 20) {
109
+ text(query, 4096);
110
+ integer(count, 100);
111
+ return lexicalSearch(this.db, query, count).map(({ itemId, title, revisionId }) => ({ itemId, title, revisionId }));
112
+ }
113
+ /** Tombstone the item and cancel its open jobs. Blobs stay until GC. */
114
+ delete(itemId) {
115
+ text(itemId, 256);
116
+ transaction(this.db, () => {
117
+ const row = this.db.prepare('SELECT 1 FROM items WHERE id=? AND deleted_at IS NULL').get(itemId);
118
+ if (row) {
119
+ mutate(this.db, 'item', itemId, 'deleted', now());
120
+ unindexReadable(this.db, itemId);
121
+ this.db
122
+ .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 item_id=? AND state NOT IN ('succeeded','failed','cancelled')")
123
+ .run(itemId);
124
+ bumpEpoch(this.db);
125
+ trackLibrary(this.db);
126
+ }
127
+ });
128
+ }
129
+ /** Sync and publishing foundation; see docs/decisions/sync-and-publishing.md. */
130
+ identity(itemId) {
131
+ text(itemId, 256);
132
+ return { uris: itemUris(this.db, itemId), visibility: visibility(this.db, itemId) };
133
+ }
134
+ itemsForUri(uri) {
135
+ return itemsForUri(this.db, uri);
136
+ }
137
+ setVisibility(itemId, value) {
138
+ text(itemId, 256);
139
+ return transaction(this.db, () => setVisibility(this.db, itemId, value));
140
+ }
141
+ /** The shown title and where it comes from: the user's override, else the importer's note title, else the captured title. */
142
+ title(itemId) {
143
+ text(itemId, 256);
144
+ const r = this.db
145
+ .prepare('SELECT title,title_captured,title_note,title_override FROM items WHERE id=? AND deleted_at IS NULL')
146
+ .get(itemId);
147
+ if (!r)
148
+ throw new SpongeError('unavailable');
149
+ return {
150
+ title: String(r.title),
151
+ override: r.title_override === null ? null : String(r.title_override),
152
+ default: String(r.title_note ?? r.title_captured),
153
+ source: r.title_override !== null ? 'override' : r.title_note !== null ? 'note' : 'capture',
154
+ };
155
+ }
156
+ /** Set the user's title (`null` restores the default). Logged as `item.titleOverride`; refresh and import never change it. */
157
+ setTitle(itemId, value) {
158
+ text(itemId, 256);
159
+ const title = value === null ? null : value.trim();
160
+ if (title !== null)
161
+ text(title, 4096);
162
+ return transaction(this.db, () => {
163
+ if (this.title(itemId).override !== title)
164
+ mutate(this.db, 'item', itemId, 'titleOverride', title);
165
+ return this.title(itemId);
166
+ });
167
+ }
168
+ listChanges(after = 0, limit = 500) {
169
+ return listChanges(this.db, after, limit);
170
+ }
171
+ publicProjection() {
172
+ return publicProjection(this.db);
173
+ }
174
+ }
@@ -0,0 +1,7 @@
1
+ import { LibraryStore } from './library.js';
2
+ import type { StoreOptions } from './types.js';
3
+ /** Pre-migration snapshots kept after a successful upgrade; older ones are pruned only then. */
4
+ export declare const keptSchemaBackups = 3;
5
+ /** The schema version this build reads and writes. */
6
+ export declare const libraryVersion: number;
7
+ export declare function openLibrary(path: string, options?: StoreOptions): Promise<LibraryStore>;
@@ -0,0 +1,183 @@
1
+ import { DatabaseSync, backup } from 'node:sqlite';
2
+ import { chmodSync, closeSync, constants, fsyncSync, lstatSync, openSync, readdirSync, rmSync, writeFileSync, } from 'node:fs';
3
+ import { join, dirname, parse } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { migrations, schemaVersion } from '../../migrations/index.js';
6
+ import { confinedFile, pathExists, regularFile, safeDirectory, verifyFile } from './files.js';
7
+ import { sha256 } from '../util/hash.js';
8
+ import { syncDirectory } from '../util/fsync.js';
9
+ import { now } from './context.js';
10
+ import { LibraryStore } from './library.js';
11
+ import { initializeVectorExtension } from '../search/extension.js';
12
+ import { acquireIdentityLock } from '../worker/lock.js';
13
+ import { SpongeError } from '../errors.js';
14
+ /** Pre-migration snapshots kept after a successful upgrade; older ones are pruned only then. */
15
+ export const keptSchemaBackups = 3;
16
+ /** The schema version this build reads and writes. */
17
+ export const libraryVersion = schemaVersion;
18
+ const checksum = (version) => sha256(migrations[version - 1].sql);
19
+ export async function openLibrary(path, options = {}) {
20
+ if (path === parse(path).root)
21
+ throw new SpongeError('invalid-input');
22
+ const root = safeDirectory(path, true);
23
+ if (root === parse(root).root)
24
+ throw new SpongeError('invalid-input');
25
+ const marker = join(root, '.sponge-library'), dbPath = confinedFile(root, join(root, 'library.sqlite'));
26
+ if (pathExists(marker)) {
27
+ regularFile(marker);
28
+ verifyFile(marker, sha256('sponge-library-v1\n'), Buffer.byteLength('sponge-library-v1\n'));
29
+ }
30
+ else if (readdirSync(root).length)
31
+ throw new SpongeError('invalid-input');
32
+ for (const suffix of ['', '-wal', '-shm'])
33
+ if (pathExists(dbPath + suffix))
34
+ regularFile(dbPath + suffix);
35
+ if (pathExists(dbPath)) {
36
+ const inspect = new DatabaseSync(dbPath, { readOnly: true });
37
+ try {
38
+ if (Number(inspect.prepare('PRAGMA user_version').get()?.user_version) > schemaVersion)
39
+ throw new SpongeError('schema-newer');
40
+ }
41
+ finally {
42
+ inspect.close();
43
+ }
44
+ }
45
+ chmodSync(root, 0o700);
46
+ if (!pathExists(marker)) {
47
+ writeFileSync(marker, 'sponge-library-v1\n', { flag: 'wx', mode: 0o600 });
48
+ const fd = openSync(marker, constants.O_RDONLY | constants.O_NOFOLLOW);
49
+ try {
50
+ fsyncSync(fd);
51
+ }
52
+ finally {
53
+ closeSync(fd);
54
+ }
55
+ syncDirectory(root);
56
+ }
57
+ for (const part of ['runtime', 'staging', 'blobs', 'blobs/sha256', 'backups']) {
58
+ const p = safeDirectory(join(root, part), true);
59
+ chmodSync(p, 0o700);
60
+ }
61
+ const db = new DatabaseSync(dbPath, { allowExtension: true });
62
+ chmodSync(dbPath, 0o600);
63
+ try {
64
+ db.exec('PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;');
65
+ const current = Number(db.prepare('PRAGMA user_version').get()?.user_version);
66
+ if (current > schemaVersion)
67
+ throw new SpongeError('schema-newer');
68
+ if (current < schemaVersion) {
69
+ // The lock names its holder; only exact proof that the holder died lets a later opener take it over.
70
+ let lock;
71
+ try {
72
+ lock = await acquireIdentityLock(join(root, 'runtime', 'migration.lock'));
73
+ }
74
+ catch (error) {
75
+ const code = error.code;
76
+ if (code === 'owner-busy' || code === 'unavailable')
77
+ throw new SpongeError(code);
78
+ throw error;
79
+ }
80
+ let migrated = false;
81
+ try {
82
+ options.fault?.('migration');
83
+ // Recheck under the migration lock: another opener may already have migrated.
84
+ const actual = Number(db.prepare('PRAGMA user_version').get()?.user_version);
85
+ if (actual > schemaVersion)
86
+ throw new SpongeError('schema-newer');
87
+ if (actual === 0 && db.prepare("SELECT name FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%'").all().length)
88
+ throw new SpongeError('integrity');
89
+ for (let prior = 1; prior <= actual; prior++) {
90
+ if (db.prepare('SELECT checksum FROM schema_migrations WHERE version=?').get(prior)?.checksum !==
91
+ checksum(prior))
92
+ throw new SpongeError('integrity');
93
+ }
94
+ if (actual > 0 &&
95
+ db.prepare('SELECT schema_version FROM library_info WHERE id=1').get()?.schema_version !== actual)
96
+ throw new SpongeError('integrity');
97
+ let backupPath;
98
+ try {
99
+ if (actual < schemaVersion) {
100
+ // SQLite backup incorporates WAL; never copy the main file alone.
101
+ backupPath = join(root, 'backups', `before-schema-${schemaVersion}-${randomUUID()}.sqlite`);
102
+ await backup(db, backupPath);
103
+ chmodSync(backupPath, 0o600);
104
+ const fd = openSync(backupPath, constants.O_RDONLY);
105
+ try {
106
+ fsyncSync(fd);
107
+ }
108
+ finally {
109
+ closeSync(fd);
110
+ }
111
+ syncDirectory(dirname(backupPath));
112
+ }
113
+ for (const migration of migrations.slice(actual)) {
114
+ const target = migration.version;
115
+ db.exec('BEGIN IMMEDIATE');
116
+ try {
117
+ // A taken-over lock is a CAS on files only; the schema step itself must still start from target-1.
118
+ if (Number(db.prepare('PRAGMA user_version').get()?.user_version) !== target - 1)
119
+ throw new SpongeError('owner-busy');
120
+ db.exec(migration.sql);
121
+ migration.step?.(db);
122
+ if (target === 1)
123
+ db.prepare('INSERT INTO library_info(id,uuid,schema_version,created_by) VALUES(1,?,?,?)').run(randomUUID(), target, options.appVersion ?? 'development');
124
+ else
125
+ db.prepare('UPDATE library_info SET schema_version=? WHERE id=1').run(target);
126
+ db.prepare('INSERT INTO schema_migrations VALUES(?,?,?)').run(target, checksum(target), now());
127
+ db.exec(`PRAGMA user_version=${target}; COMMIT`);
128
+ }
129
+ catch (error) {
130
+ db.exec('ROLLBACK');
131
+ throw error;
132
+ }
133
+ }
134
+ migrated = true;
135
+ }
136
+ catch (error) {
137
+ // Nothing committed, so the library is byte-for-byte what the backup holds: drop the copy.
138
+ if (backupPath && Number(db.prepare('PRAGMA user_version').get()?.user_version) === actual) {
139
+ rmSync(backupPath, { force: true });
140
+ syncDirectory(dirname(backupPath));
141
+ }
142
+ throw error;
143
+ }
144
+ }
145
+ finally {
146
+ await lock.release();
147
+ }
148
+ if (migrated)
149
+ pruneSchemaBackups(root);
150
+ }
151
+ for (let applied = 1; applied <= schemaVersion; applied++)
152
+ if (db.prepare('SELECT checksum FROM schema_migrations WHERE version=?').get(applied)?.checksum !==
153
+ checksum(applied))
154
+ throw new SpongeError('integrity');
155
+ if (db.prepare('SELECT schema_version FROM library_info WHERE id=1').get()?.schema_version !== schemaVersion)
156
+ throw new SpongeError('integrity');
157
+ db.exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;');
158
+ for (const suffix of ['', '-wal', '-shm'])
159
+ if (pathExists(dbPath + suffix))
160
+ chmodSync(dbPath + suffix, 0o600);
161
+ syncDirectory(root);
162
+ initializeVectorExtension(db);
163
+ const store = new LibraryStore(root, db, options);
164
+ await store.backups.expireFences();
165
+ return store;
166
+ }
167
+ catch (error) {
168
+ db.close();
169
+ throw error;
170
+ }
171
+ }
172
+ /** Keep the newest `keptSchemaBackups` pre-migration snapshots (by mtime, then name); remove older ones and their sidecars. */
173
+ function pruneSchemaBackups(root) {
174
+ const dir = join(root, 'backups'), files = readdirSync(dir)
175
+ .filter((n) => /^before-schema-\d+-[0-9a-f-]{36}\.sqlite$/.test(n))
176
+ .map((name) => ({ name, mtime: lstatSync(join(dir, name)).mtimeMs }));
177
+ files.sort((a, b) => b.mtime - a.mtime || b.name.localeCompare(a.name));
178
+ for (const { name } of files.slice(keptSchemaBackups))
179
+ for (const suffix of ['', '-wal', '-shm'])
180
+ rmSync(join(dir, name + suffix), { force: true });
181
+ if (files.length > keptSchemaBackups)
182
+ syncDirectory(dir);
183
+ }
@@ -0,0 +1,65 @@
1
+ import type { JobCheckpoint } from '../../contracts/index.js';
2
+ import { type Context, type Row } from './context.js';
3
+ import type { AttemptToken, OwnedAttempt, OwnerToken, SourceSubmission, Submission } from './types.js';
4
+ export interface RetryOptions {
5
+ notBefore?: string;
6
+ automatic?: boolean;
7
+ }
8
+ /**
9
+ * One kind of job under the shared worker lease. Capture jobs live here; index jobs in `search-index.ts`. The queue dispatches
10
+ * listing, cancellation, retry and owner recovery through this interface, so a new job kind plugs in without new branches.
11
+ */
12
+ export interface JobKind {
13
+ owns(jobId: string): boolean;
14
+ list(): Row[];
15
+ /** Attempts claimed by `token` (the owner is already checked). */
16
+ attempts(token: OwnerToken): OwnedAttempt[];
17
+ cancel(jobId: string): void;
18
+ retry(jobId: string, options: RetryOptions): void;
19
+ cancelRequested(jobId: string): boolean;
20
+ /** Inside `recoverOwner`'s transaction, once custody of the attempt is proved: remove its staging and settle it. */
21
+ recover(attempt: OwnedAttempt): void;
22
+ }
23
+ /** Capture jobs (acquire, extract, commit one source) and the owner lease shared with every other job kind. */
24
+ export declare class Queue {
25
+ private readonly c;
26
+ private readonly index;
27
+ private readonly capture;
28
+ constructor(c: Context, index: JobKind);
29
+ /** Index jobs are checked first; any other id is a capture job (which reports `unavailable` when it does not exist). */
30
+ private kindOf;
31
+ acquireOwner(ownerIncarnation: string): OwnerToken;
32
+ releaseOwner(token: OwnerToken): void;
33
+ ownerSnapshot(): OwnerToken | null;
34
+ /** Every attempt the owner claimed, capture first, then index. */
35
+ ownerAttempts(token: OwnerToken): OwnedAttempt[];
36
+ /** Requires external custody proof for EVERY pending attempt, even after its job committed.
37
+ * IDs are acknowledgements from the custody owner, never evidence inferred from PID/timeouts here. */
38
+ recoverOwner(expected: OwnerToken, provedAttemptIds: readonly string[]): void;
39
+ /** Deduplicates source identity including realm and persists an idempotent submission and queued job. */
40
+ submit(input: SourceSubmission): Submission;
41
+ /** Jobs of every kind, oldest first. */
42
+ list(): Row[];
43
+ /** Acquisition data for a capture job. A local file's locator is its path on this device (`local_sources`), never the synced form. */
44
+ source(jobId: string): {
45
+ itemId: string;
46
+ kind: string;
47
+ sourceKey: string;
48
+ locator: string;
49
+ realm: string;
50
+ } | null;
51
+ claimNext(token: OwnerToken): AttemptToken | null;
52
+ claim(token: OwnerToken, jobId: string): AttemptToken;
53
+ checkpoint(token: AttemptToken, stage: JobCheckpoint['stage']): void;
54
+ cancelRequested(jobId: string): boolean;
55
+ retry(jobId: string, options?: RetryOptions): void;
56
+ cancel(jobId: string): void;
57
+ fail(token: AttemptToken, reason?: string, options?: {
58
+ retryable?: boolean;
59
+ retryAfterMs?: number;
60
+ }): void;
61
+ /** Caller must first settle owned external children. This only releases storage staging pins. */
62
+ settle(token: AttemptToken): void;
63
+ /** Attempts of any kind whose cleanup is pending; GC and pruning wait for zero. */
64
+ pendingCleanup(): number;
65
+ }