@hraness/sponge 0.2.4 → 0.2.6

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 (86) hide show
  1. package/README.md +54 -0
  2. package/dist/migrations/005.d.ts +3 -0
  3. package/dist/migrations/005.js +19 -0
  4. package/dist/migrations/006.d.ts +3 -0
  5. package/dist/migrations/006.js +9 -0
  6. package/dist/migrations/007.d.ts +3 -0
  7. package/dist/migrations/007.js +10 -0
  8. package/dist/src/annotations/quote.d.ts +32 -0
  9. package/dist/src/annotations/quote.js +135 -0
  10. package/dist/src/annotations/store.d.ts +50 -0
  11. package/dist/src/annotations/store.js +89 -0
  12. package/dist/src/app/source.d.ts +2 -0
  13. package/dist/src/app/source.js +13 -17
  14. package/dist/src/cli/arguments.js +24 -7
  15. package/dist/src/cli/digest.d.ts +10 -0
  16. package/dist/src/cli/digest.js +63 -0
  17. package/dist/src/cli/import.d.ts +11 -0
  18. package/dist/src/cli/import.js +10 -0
  19. package/dist/src/cli/indexing.d.ts +3 -0
  20. package/dist/src/cli/indexing.js +5 -2
  21. package/dist/src/cli/main.js +94 -8
  22. package/dist/src/cli/runtimes.d.ts +10 -0
  23. package/dist/src/cli/runtimes.js +32 -0
  24. package/dist/src/digest/editorial.d.ts +54 -0
  25. package/dist/src/digest/editorial.js +87 -0
  26. package/dist/src/digest/endpoint.d.ts +20 -0
  27. package/dist/src/digest/endpoint.js +103 -0
  28. package/dist/src/digest/pipeline.d.ts +148 -0
  29. package/dist/src/digest/pipeline.js +399 -0
  30. package/dist/src/digest/schema.d.ts +126 -0
  31. package/dist/src/digest/schema.js +138 -0
  32. package/dist/src/digest/store.d.ts +57 -0
  33. package/dist/src/digest/store.js +79 -0
  34. package/dist/src/identity/query-policy.d.ts +21 -0
  35. package/dist/src/identity/query-policy.js +55 -0
  36. package/dist/src/identity/uri.d.ts +30 -0
  37. package/dist/src/identity/uri.js +94 -0
  38. package/dist/src/identity/visibility.d.ts +24 -0
  39. package/dist/src/identity/visibility.js +75 -0
  40. package/dist/src/importers/apply.d.ts +10 -0
  41. package/dist/src/importers/apply.js +25 -22
  42. package/dist/src/importers/jungle-digest.d.ts +107 -0
  43. package/dist/src/importers/jungle-digest.js +196 -0
  44. package/dist/src/importers/jungle-store.d.ts +45 -0
  45. package/dist/src/importers/jungle-store.js +96 -0
  46. package/dist/src/importers/jungle.d.ts +70 -0
  47. package/dist/src/importers/jungle.js +142 -0
  48. package/dist/src/indexing/index.d.ts +7 -0
  49. package/dist/src/indexing/index.js +31 -8
  50. package/dist/src/indexing/prune-run.d.ts +22 -0
  51. package/dist/src/indexing/prune-run.js +67 -0
  52. package/dist/src/indexing/prune.d.ts +30 -0
  53. package/dist/src/indexing/prune.js +95 -0
  54. package/dist/src/indexing/runner.js +5 -5
  55. package/dist/src/inference/embedding/index.d.ts +25 -0
  56. package/dist/src/inference/embedding/index.js +11 -0
  57. package/dist/src/models/artifacts.js +175 -33
  58. package/dist/src/portability/backup.js +3 -1
  59. package/dist/src/portability/database.js +3 -0
  60. package/dist/src/publish/component.d.ts +8 -0
  61. package/dist/src/publish/component.js +114 -0
  62. package/dist/src/publish/feed.d.ts +24 -0
  63. package/dist/src/publish/feed.js +77 -0
  64. package/dist/src/publish/identity.d.ts +6 -0
  65. package/dist/src/publish/identity.js +68 -0
  66. package/dist/src/publish/index.d.ts +34 -0
  67. package/dist/src/publish/index.js +137 -0
  68. package/dist/src/publish/projection.d.ts +29 -0
  69. package/dist/src/publish/projection.js +43 -0
  70. package/dist/src/search/hybrid.js +2 -2
  71. package/dist/src/search/lexical.d.ts +11 -0
  72. package/dist/src/search/lexical.js +6 -0
  73. package/dist/src/search/query.d.ts +7 -3
  74. package/dist/src/search/query.js +52 -33
  75. package/dist/src/search/vector.d.ts +1 -1
  76. package/dist/src/search/vector.js +6 -5
  77. package/dist/src/store/changes.d.ts +42 -0
  78. package/dist/src/store/changes.js +178 -0
  79. package/dist/src/store/foundation.d.ts +7 -0
  80. package/dist/src/store/foundation.js +7 -0
  81. package/dist/src/store/index.d.ts +69 -0
  82. package/dist/src/store/index.js +134 -51
  83. package/dist/src/worker/runtime-gc.d.ts +30 -0
  84. package/dist/src/worker/runtime-gc.js +296 -0
  85. package/native/runtime-manifest.json +4 -4
  86. package/package.json +1 -1
@@ -0,0 +1,7 @@
1
+ /** Sync and publishing foundation, split by concern; this barrel keeps existing imports working. See docs/decisions/sync-and-publishing.md. */
2
+ export { applyChange, append, listChanges, mutate, seedChanges, setReviewPath, sourceRefId, maxChangeBytes } from './changes.js';
3
+ export { backfillUris, itemUris, itemsForUri, setVisibility, syncUris, visibility } from '../identity/visibility.js';
4
+ export { addComment, addHighlight, deleteAnnotation, importNote, listAnnotations, markdownFor, readAnnotation, setReadingState, updateAnnotation } from '../annotations/store.js';
5
+ export { acceptedDigest, canonicalDigest, currentDigest, derivationId, findDigest, putDigest, readDigest, reviewDigest } from '../digest/store.js';
6
+ export { currentEditorial, listEditorials, publicEditorial, putEditorial, readEditorial, reviewEditorial } from '../digest/editorial.js';
7
+ export { publicProjection } from '../publish/projection.js';
@@ -1,9 +1,13 @@
1
1
  import { DatabaseSync } from 'node:sqlite';
2
+ import * as foundation from './foundation.js';
3
+ import * as jungle from '../importers/jungle-store.js';
2
4
  import type { JobCheckpoint } from '../../contracts/index.js';
3
5
  import { type AttemptToken, type OwnedAttempt, type BlobReference, type CommitRevision, type OwnerToken, type ReadableItem, type SourceSubmission, type StoreOptions, type Submission } from './types.js';
4
6
  import * as indexing from '../indexing/index.js';
7
+ import * as prune from '../indexing/prune.js';
5
8
  import { hybridSearch } from '../search/hybrid.js';
6
9
  export * from './types.js';
10
+ export declare const libraryVersion = 7;
7
11
  export declare const libraryMigrations: readonly string[];
8
12
  type Row = Record<string, any>;
9
13
  export declare function openLibrary(path: string, options?: StoreOptions): Promise<LibraryStore>;
@@ -29,20 +33,76 @@ export declare class LibraryStore {
29
33
  writeIndexBatch(id: string, fingerprint: string, batch: Parameters<typeof indexing.writeBatch>[3]): void;
30
34
  activateIndex(id: string): indexing.GenerationStatus;
31
35
  cancelIndex(id: string): void;
36
+ /** Retired-generation pruning; see src/indexing/prune.ts. The plan probes rollback inside a rolled-back savepoint. */
37
+ pruneIndexPlan(keep: number): {
38
+ generations: prune.PruneEntry[];
39
+ stats: prune.PageStats;
40
+ };
41
+ pruneIndexStep(owner: OwnerToken, id: string, keep: number): number;
42
+ /** VACUUM cannot run in a transaction: fences are checked under the owner token immediately before it. */
43
+ vacuumLibrary(owner: OwnerToken): prune.PageStats;
32
44
  searchHybrid(query: string, options?: Parameters<typeof hybridSearch>[2]): Promise<import("../search/hybrid.js").SearchResult>;
45
+ /** Export's view: the one annotation reader, without the redundant item id, plus reading state. */
33
46
  readAnnotations(itemId: string): {
34
47
  annotations: {
35
48
  id: string;
49
+ kind: "note" | "highlight" | "comment";
36
50
  revisionId: string | null;
51
+ parentId: string | null;
37
52
  anchor: unknown;
38
53
  body: string;
39
54
  createdAt: string;
55
+ updatedAt: string | null;
56
+ anchored?: boolean;
40
57
  }[];
41
58
  readingState: {
42
59
  state: unknown;
43
60
  updatedAt: string;
44
61
  } | null;
45
62
  };
63
+ /** Sync and publishing foundation; see docs/decisions/sync-and-publishing.md. */
64
+ identity(itemId: string): {
65
+ uris: import("../identity/uri.js").ItemUri[];
66
+ visibility: foundation.VisibilityState;
67
+ };
68
+ itemsForUri(uri: string): string[];
69
+ setVisibility(itemId: string, value: foundation.Visibility | null): foundation.VisibilityState;
70
+ addHighlight(input: Parameters<typeof foundation.addHighlight>[1]): foundation.Annotation;
71
+ addComment(input: Parameters<typeof foundation.addComment>[1]): foundation.Annotation;
72
+ updateAnnotation(id: string, body: string): foundation.Annotation;
73
+ deleteAnnotation(id: string): void;
74
+ listAnnotations(itemId: string): (foundation.Annotation & {
75
+ anchored?: boolean;
76
+ })[];
77
+ putDigest(input: Parameters<typeof foundation.putDigest>[1]): {
78
+ id: string;
79
+ duplicate: boolean;
80
+ };
81
+ currentDigest(itemId: string): foundation.Derivation | undefined;
82
+ readDigest(id: string): foundation.Derivation | undefined;
83
+ findDigest(revisionId: string, inputHash: string): foundation.Derivation | undefined;
84
+ reviewDigest(id: string, review: foundation.DigestReview): foundation.Derivation;
85
+ currentEditorial(itemId: string): foundation.Editorial | undefined;
86
+ readEditorial(id: string): foundation.Editorial | undefined;
87
+ reviewEditorial(id: string, status: foundation.ReviewStatus | null): foundation.Editorial;
88
+ /** The item an importer realm holds for one source key, with its current revision; deleted items are reported, not hidden. */
89
+ importedItem(realm: string, sourceKey: string): {
90
+ itemId: string;
91
+ deleted: boolean;
92
+ revisionId: string | null;
93
+ } | null;
94
+ importedRevision(itemId: string, bundleHash: string): string;
95
+ revisionMarkdown(revisionId: string): string;
96
+ planJungleNote(input: jungle.JungleNoteInput): jungle.JunglePlan;
97
+ applyJungleNote(input: jungle.JungleNoteInput): jungle.JunglePlan;
98
+ listChanges(after?: number, limit?: number): foundation.LoggedChange[];
99
+ publicProjection(): {
100
+ entries: foundation.PublicEntry[];
101
+ skipped: {
102
+ itemId: string;
103
+ reason: string;
104
+ }[];
105
+ };
46
106
  lookupImportReceipt(namespace: string, sourceKey: string, sourceHash: string): {
47
107
  itemId: string;
48
108
  outcome: string;
@@ -112,6 +172,15 @@ export declare class LibraryStore {
112
172
  finishBackup(id: string): void;
113
173
  /** Hold the SQLite writer fence across synchronous GC inspection and removal. */
114
174
  withGcFence<T>(expectedEpoch: number, callback: (references: readonly BlobReference[]) => T): T;
175
+ /** Runtime GC pins: recorded model artifacts and embedding helpers of active or building generations. */
176
+ runtimeReferences(): {
177
+ modelArtifacts: {
178
+ id: string;
179
+ manifest: string;
180
+ }[];
181
+ embeddingHelpers: string[];
182
+ unmappedSpaces: number;
183
+ };
115
184
  /** GC must honor both durable staging pins and active backup fences. */
116
185
  pendingCleanupCount(): number;
117
186
  hasGcFence(): boolean;
@@ -5,18 +5,26 @@ import { randomUUID, createHash } from 'node:crypto';
5
5
  import { sql as sql1 } from '../../migrations/001.js';
6
6
  import { sql as sql2 } from '../../migrations/002.js';
7
7
  import { sql as sql3 } from '../../migrations/003.js';
8
- import { sql as sql4, version } from '../../migrations/004.js';
8
+ import { sql as sql4 } from '../../migrations/004.js';
9
+ import { sql as sql5 } from '../../migrations/005.js';
10
+ import { sql as sql6 } from '../../migrations/006.js';
11
+ import { sql as sql7, version } from '../../migrations/007.js';
12
+ import * as foundation from './foundation.js';
13
+ import * as jungle from '../importers/jungle-store.js';
9
14
  import * as indexJobs from '../indexing/jobs.js';
10
15
  import { limits } from '../../contracts/index.js';
11
16
  import { validateMetadataClaim, validateMetadataWarnings } from '../../contracts/validate.js';
12
17
  import { confinedFile, digest, hash, integer, regularFile, safeDirectory, syncDirectory, text, verifyFile, pathExists, readVerifiedFile } from './files.js';
13
18
  import { StoreError } from './types.js';
14
19
  import * as indexing from '../indexing/index.js';
20
+ import * as prune from '../indexing/prune.js';
15
21
  import { hybridSearch } from '../search/hybrid.js';
22
+ import { indexReadable, unindexReadable, lexicalSearch } from '../search/lexical.js';
16
23
  import { initializeVectorExtension } from '../search/extension.js';
17
24
  export * from './types.js';
18
25
  const now = () => new Date().toISOString();
19
- const migrations = [sql1, sql2, sql3, sql4];
26
+ const migrations = [sql1, sql2, sql3, sql4, sql5, sql6, sql7];
27
+ export const libraryVersion = version;
20
28
  export const libraryMigrations = Object.freeze([...migrations]);
21
29
  export async function openLibrary(path, options = {}) {
22
30
  if (path === parse(path).root)
@@ -90,35 +98,51 @@ export async function openLibrary(path, options = {}) {
90
98
  }
91
99
  if (actual > 0 && db.prepare('SELECT schema_version FROM library_info WHERE id=1').get()?.schema_version !== actual)
92
100
  throw new StoreError('integrity');
93
- if (actual < version) {
94
- // SQLite backup incorporates WAL; never copy the main file alone.
95
- const backupPath = join(root, 'backups', `before-schema-${version}-${randomUUID()}.sqlite`);
96
- await backup(db, backupPath);
97
- chmodSync(backupPath, 0o600);
98
- const fd = openSync(backupPath, constants.O_RDONLY);
99
- try {
100
- fsyncSync(fd);
101
+ let backupPath;
102
+ try {
103
+ if (actual < version) {
104
+ // SQLite backup incorporates WAL; never copy the main file alone.
105
+ backupPath = join(root, 'backups', `before-schema-${version}-${randomUUID()}.sqlite`);
106
+ await backup(db, backupPath);
107
+ chmodSync(backupPath, 0o600);
108
+ const fd = openSync(backupPath, constants.O_RDONLY);
109
+ try {
110
+ fsyncSync(fd);
111
+ }
112
+ finally {
113
+ closeSync(fd);
114
+ }
115
+ syncDirectory(dirname(backupPath));
101
116
  }
102
- finally {
103
- closeSync(fd);
117
+ for (let target = actual + 1; target <= version; target++) {
118
+ db.exec('BEGIN IMMEDIATE');
119
+ try {
120
+ db.exec(migrations[target - 1]);
121
+ // Code steps are total per item: one hostile row never makes the library unopenable.
122
+ if (target === 5) {
123
+ foundation.backfillUris(db);
124
+ foundation.seedChanges(db);
125
+ }
126
+ if (target === 1)
127
+ db.prepare('INSERT INTO library_info(id,uuid,schema_version,created_by) VALUES(1,?,?,?)').run(randomUUID(), target, options.appVersion ?? 'development');
128
+ else
129
+ db.prepare('UPDATE library_info SET schema_version=? WHERE id=1').run(target);
130
+ db.prepare('INSERT INTO schema_migrations VALUES(?,?,?)').run(target, digest(migrations[target - 1]), now());
131
+ db.exec(`PRAGMA user_version=${target}; COMMIT`);
132
+ }
133
+ catch (error) {
134
+ db.exec('ROLLBACK');
135
+ throw error;
136
+ }
104
137
  }
105
- syncDirectory(dirname(backupPath));
106
138
  }
107
- for (let target = actual + 1; target <= version; target++) {
108
- db.exec('BEGIN IMMEDIATE');
109
- try {
110
- db.exec(migrations[target - 1]);
111
- if (target === 1)
112
- db.prepare('INSERT INTO library_info(id,uuid,schema_version,created_by) VALUES(1,?,?,?)').run(randomUUID(), target, options.appVersion ?? 'development');
113
- else
114
- db.prepare('UPDATE library_info SET schema_version=? WHERE id=1').run(target);
115
- db.prepare('INSERT INTO schema_migrations VALUES(?,?,?)').run(target, digest(migrations[target - 1]), now());
116
- db.exec(`PRAGMA user_version=${target}; COMMIT`);
117
- }
118
- catch (error) {
119
- db.exec('ROLLBACK');
120
- throw error;
139
+ catch (error) {
140
+ // Nothing committed, so the library is byte-for-byte what the backup holds: drop the copy.
141
+ if (backupPath && Number(db.prepare('PRAGMA user_version').get()?.user_version) === actual) {
142
+ rmSync(backupPath, { force: true });
143
+ syncDirectory(dirname(backupPath));
121
144
  }
145
+ throw error;
122
146
  }
123
147
  }
124
148
  finally {
@@ -185,8 +209,43 @@ export class LibraryStore {
185
209
  writeIndexBatch(id, fingerprint, batch) { indexing.writeBatch(this.db, id, fingerprint, batch); }
186
210
  activateIndex(id) { return indexing.activateGeneration(this.db, id); }
187
211
  cancelIndex(id) { indexing.cancelGeneration(this.db, id); }
212
+ /** Retired-generation pruning; see src/indexing/prune.ts. The plan probes rollback inside a rolled-back savepoint. */
213
+ pruneIndexPlan(keep) { return this.transaction(() => ({ generations: prune.planPrune(this.db, keep), stats: prune.pageStats(this.db) })); }
214
+ pruneIndexStep(owner, id, keep) { text(id, 256); return this.transaction(() => { this.checkOwner(owner); if (this.pendingCleanupCount() || this.hasGcFence())
215
+ throw new StoreError('owner-busy'); return prune.pruneStep(this.db, id, keep); }); }
216
+ /** VACUUM cannot run in a transaction: fences are checked under the owner token immediately before it. */
217
+ vacuumLibrary(owner) { const stats = this.transaction(() => { this.checkOwner(owner); if (this.pendingCleanupCount() || this.hasGcFence())
218
+ throw new StoreError('owner-busy'); return prune.pageStats(this.db); }); prune.vacuumSpace(this.root, stats.bytes); this.db.exec('VACUUM'); this.db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); return prune.pageStats(this.db); }
188
219
  searchHybrid(query, options = {}) { return hybridSearch(this.db, query, options); }
189
- readAnnotations(itemId) { text(itemId, 256); const annotations = this.db.prepare('SELECT id,revision_id,anchor_json,body,created_at FROM annotations WHERE item_id=? ORDER BY created_at,id').all(itemId).map(row => ({ id: String(row.id), revisionId: row.revision_id === null ? null : String(row.revision_id), anchor: JSON.parse(String(row.anchor_json)), body: String(row.body), createdAt: String(row.created_at) })); const reading = this.db.prepare('SELECT state_json,updated_at FROM reading_state WHERE item_id=?').get(itemId); return { annotations, readingState: reading ? { state: JSON.parse(String(reading.state_json)), updatedAt: String(reading.updated_at) } : null }; }
220
+ /** Export's view: the one annotation reader, without the redundant item id, plus reading state. */
221
+ readAnnotations(itemId) { text(itemId, 256); const annotations = foundation.listAnnotations(this.db, itemId).map(({ itemId: _, ...a }) => a); const reading = this.db.prepare('SELECT state_json,updated_at FROM reading_state WHERE item_id=?').get(itemId); return { annotations, readingState: reading ? { state: JSON.parse(String(reading.state_json)), updatedAt: String(reading.updated_at) } : null }; }
222
+ /** Sync and publishing foundation; see docs/decisions/sync-and-publishing.md. */
223
+ identity(itemId) { text(itemId, 256); return { uris: foundation.itemUris(this.db, itemId), visibility: foundation.visibility(this.db, itemId) }; }
224
+ itemsForUri(uri) { return foundation.itemsForUri(this.db, uri); }
225
+ setVisibility(itemId, value) { text(itemId, 256); return this.transaction(() => foundation.setVisibility(this.db, itemId, value)); }
226
+ addHighlight(input) { text(input.itemId, 256); return this.transaction(() => foundation.addHighlight(this.db, input)); }
227
+ addComment(input) { text(input.itemId, 256); if (input.parentId !== undefined)
228
+ text(input.parentId, 256); return this.transaction(() => foundation.addComment(this.db, input)); }
229
+ updateAnnotation(id, body) { text(id, 256); return this.transaction(() => foundation.updateAnnotation(this.db, id, body)); }
230
+ deleteAnnotation(id) { text(id, 256); this.transaction(() => foundation.deleteAnnotation(this.db, id)); }
231
+ listAnnotations(itemId) { text(itemId, 256); return foundation.listAnnotations(this.db, itemId); }
232
+ putDigest(input) { text(input.itemId, 256); text(input.revisionId, 256); return this.transaction(() => foundation.putDigest(this.db, input)); }
233
+ currentDigest(itemId) { text(itemId, 256); return foundation.currentDigest(this.db, itemId); }
234
+ readDigest(id) { text(id, 256); return foundation.readDigest(this.db, id); }
235
+ findDigest(revisionId, inputHash) { text(revisionId, 256); return foundation.findDigest(this.db, revisionId, inputHash); }
236
+ reviewDigest(id, review) { text(id, 256); return this.transaction(() => foundation.reviewDigest(this.db, id, review)); }
237
+ currentEditorial(itemId) { text(itemId, 256); return foundation.currentEditorial(this.db, itemId); }
238
+ readEditorial(id) { text(id, 256); return foundation.readEditorial(this.db, id); }
239
+ reviewEditorial(id, status) { text(id, 256); return this.transaction(() => foundation.reviewEditorial(this.db, id, status)); }
240
+ /** The item an importer realm holds for one source key, with its current revision; deleted items are reported, not hidden. */
241
+ importedItem(realm, sourceKey) { text(realm, 256); text(sourceKey, 8192); const r = this.db.prepare('SELECT i.id,i.deleted_at,i.current_revision FROM source_refs s JOIN items i ON i.id=s.item_id WHERE s.source_key=? AND s.realm_key=?').get(sourceKey, realm); return r ? { itemId: String(r.id), deleted: r.deleted_at !== null, revisionId: r.current_revision === null ? null : String(r.current_revision) } : null; }
242
+ importedRevision(itemId, bundleHash) { text(itemId, 256); hash(bundleHash); const r = this.db.prepare('SELECT id FROM revisions WHERE item_id=? AND bundle_hash=?').get(itemId, bundleHash); if (!r)
243
+ throw new StoreError('unavailable'); return String(r.id); }
244
+ revisionMarkdown(revisionId) { text(revisionId, 256); return foundation.markdownFor(this.db, revisionId); }
245
+ planJungleNote(input) { text(input.itemId, 256); text(input.revisionId, 256); return jungle.planJungleNote(this.db, input); }
246
+ applyJungleNote(input) { text(input.itemId, 256); text(input.revisionId, 256); return this.transaction(() => jungle.applyJungleNote(this.db, input)); }
247
+ listChanges(after = 0, limit = 500) { return foundation.listChanges(this.db, after, limit); }
248
+ publicProjection() { return foundation.publicProjection(this.db); }
190
249
  lookupImportReceipt(namespace, sourceKey, sourceHash) {
191
250
  text(namespace, 256);
192
251
  text(sourceKey, 8192);
@@ -366,10 +425,14 @@ export class LibraryStore {
366
425
  if (source && source.kind !== input.kind)
367
426
  throw new StoreError('invalid-input');
368
427
  if (!source) {
369
- const itemId = randomUUID(), sourceId = randomUUID();
370
- this.db.prepare('INSERT INTO items(id,kind,created_at) VALUES(?,?,?)').run(itemId, input.kind, now());
371
- this.db.prepare('INSERT INTO source_refs(id,item_id,source_key,realm_key,locator) VALUES(?,?,?,?,?)').run(sourceId, itemId, input.sourceKey, realm, input.locator);
372
- source = { id: sourceId, item_id: itemId };
428
+ // Devices of one library agree on the id; outsiders cannot derive it from a URL.
429
+ const library = String(this.db.prepare('SELECT uuid FROM library_info WHERE id=1').get()?.uuid);
430
+ const itemId = digest(JSON.stringify(['item-v1', library, realm, input.sourceKey]));
431
+ if (this.db.prepare('SELECT 1 FROM items WHERE id=?').get(itemId))
432
+ throw new StoreError('integrity');
433
+ foundation.mutate(this.db, 'item', itemId, 'created', { kind: input.kind, sourceKey: input.sourceKey, realm, locator: input.locator, createdAt: now() });
434
+ foundation.syncUris(this.db, itemId);
435
+ source = { id: foundation.sourceRefId(itemId), item_id: itemId };
373
436
  }
374
437
  const active = this.db.prepare("SELECT id FROM jobs WHERE item_id=? AND state IN ('queued','running','delayed','cleanup-pending') ORDER BY created_at LIMIT 1").get(source.item_id);
375
438
  const jobId = active ? String(active.id) : randomUUID();
@@ -593,8 +656,9 @@ export class LibraryStore {
593
656
  throw new StoreError('integrity');
594
657
  }
595
658
  const existing = this.db.prepare('SELECT id,payload_hash FROM revisions WHERE item_id=? AND bundle_hash=?').get(input.itemId, input.bundleHash);
596
- const item = this.db.prepare('SELECT current_revision FROM items WHERE id=?').get(input.itemId);
597
- const revisionId = existing ? String(existing.id) : randomUUID();
659
+ const item = this.db.prepare('SELECT current_revision,title FROM items WHERE id=?').get(input.itemId);
660
+ // Content-addressed: the same capture on two devices is the same revision object.
661
+ const revisionId = existing ? String(existing.id) : digest(JSON.stringify(['revision-v1', input.itemId, input.bundleHash]));
598
662
  if (!existing) {
599
663
  this.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));
600
664
  for (const [ordinal, asset] of input.assets.entries()) {
@@ -617,33 +681,30 @@ export class LibraryStore {
617
681
  throw new StoreError('integrity');
618
682
  }
619
683
  if (item.current_revision !== revisionId) {
620
- this.db.prepare('UPDATE items SET current_revision=?,title=? WHERE id=?').run(revisionId, input.title ?? '', input.itemId);
621
- this.db.prepare('DELETE FROM readable_fts WHERE item_id=?').run(input.itemId);
684
+ foundation.mutate(this.db, 'item', input.itemId, 'currentRevision', revisionId);
685
+ if (item.title !== (input.title ?? ''))
686
+ foundation.mutate(this.db, 'item', input.itemId, 'title', input.title ?? '');
622
687
  const stored = this.db.prepare("SELECT markdown FROM representations WHERE revision_id=? AND kind='markdown'").get(revisionId);
623
- this.db.prepare('INSERT INTO readable_fts(item_id,revision_id,title,markdown) VALUES(?,?,?,?)').run(input.itemId, revisionId, input.title ?? '', String(stored.markdown));
688
+ indexReadable(this.db, input.itemId, revisionId, input.title ?? '', String(stored.markdown));
689
+ foundation.syncUris(this.db, input.itemId);
624
690
  this.bumpEpoch();
625
691
  }
626
692
  if (imported) {
627
693
  const receipt = this.lookupImportReceipt(imported.namespace, imported.sourceKey, imported.sourceHash);
628
694
  if (receipt && receipt.itemId !== input.itemId)
629
695
  throw new StoreError('integrity');
630
- for (const note of imported.notes) {
631
- const id = digest(JSON.stringify(['import-note-v1', imported.namespace, imported.sourceKey, note.id]));
632
- const prior = this.db.prepare('SELECT item_id,body FROM annotations WHERE id=?').get(id);
633
- if (prior && (prior.item_id !== input.itemId || prior.body !== note.body))
634
- throw new StoreError('integrity');
635
- if (!prior)
636
- this.db.prepare('INSERT INTO annotations VALUES(?,?,?,?,?,?)').run(id, input.itemId, revisionId, JSON.stringify({ kind: 'imported-note', sourceId: note.id }), note.body, now());
637
- }
696
+ for (const note of imported.notes)
697
+ foundation.importNote(this.db, { itemId: input.itemId, revisionId, namespace: imported.namespace, sourceKey: imported.sourceKey, noteId: note.id, body: note.body });
638
698
  const state = JSON.stringify({ ...imported.readingState, sourcePrivacy: imported.privacy });
639
699
  const prior = this.db.prepare('SELECT state_json FROM reading_state WHERE item_id=?').get(input.itemId);
640
700
  // Existing reading choices remain authoritative; import never overwrites them.
641
701
  if (!prior)
642
- this.db.prepare('INSERT INTO reading_state VALUES(?,?,?)').run(input.itemId, state, now());
702
+ foundation.setReadingState(this.db, input.itemId, JSON.parse(state));
643
703
  else {
644
704
  const existing = JSON.parse(String(prior.state_json));
645
705
  const sourcePrivacy = existing.sourcePrivacy === 'published' && imported.privacy === 'published' ? 'published' : 'private';
646
- this.db.prepare('UPDATE reading_state SET state_json=?,updated_at=? WHERE item_id=?').run(JSON.stringify({ ...existing, sourcePrivacy }), now(), input.itemId);
706
+ if (sourcePrivacy !== existing.sourcePrivacy)
707
+ foundation.setReadingState(this.db, input.itemId, { ...existing, sourcePrivacy });
647
708
  }
648
709
  this.db.prepare('INSERT OR IGNORE INTO import_receipts VALUES(?,?,?,?,?,?)').run(imported.namespace, imported.sourceKey, imported.sourceHash, imported.importerVersion, input.itemId, 'imported');
649
710
  }
@@ -675,13 +736,14 @@ export class LibraryStore {
675
736
  const path = confinedFile(this.root, join(this.root, reference.relativePath));
676
737
  return readVerifiedFile(path, reference.sha256, reference.bytes);
677
738
  }
678
- search(query, count = 20) { text(query, 4096); integer(count, 100); const literal = '"' + query.replaceAll('"', '""') + '"'; return this.db.prepare('SELECT i.id itemId,i.title,i.current_revision revisionId FROM readable_fts f JOIN items i ON i.id=f.item_id WHERE readable_fts MATCH ? AND i.deleted_at IS NULL AND i.current_revision=f.revision_id ORDER BY rank,i.id LIMIT ?').all(literal, count); }
739
+ search(query, count = 20) { text(query, 4096); integer(count, 100); return lexicalSearch(this.db, query, count).map(({ itemId, title, revisionId }) => ({ itemId, title, revisionId })); }
679
740
  delete(itemId) {
680
741
  text(itemId, 256);
681
742
  this.transaction(() => {
682
- const row = this.db.prepare('UPDATE items SET deleted_at=? WHERE id=? AND deleted_at IS NULL').run(now(), itemId);
683
- if (row.changes) {
684
- this.db.prepare('DELETE FROM readable_fts WHERE item_id=?').run(itemId);
743
+ const row = this.db.prepare('SELECT 1 FROM items WHERE id=? AND deleted_at IS NULL').get(itemId);
744
+ if (row) {
745
+ foundation.mutate(this.db, 'item', itemId, 'deleted', now());
746
+ unindexReadable(this.db, itemId);
685
747
  this.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 item_id=? AND state NOT IN ('succeeded','failed','cancelled')").run(itemId);
686
748
  this.bumpEpoch();
687
749
  }
@@ -721,6 +783,27 @@ export class LibraryStore {
721
783
  return callback(references);
722
784
  });
723
785
  }
786
+ /** Runtime GC pins: recorded model artifacts and embedding helpers of active or building generations. */
787
+ runtimeReferences() {
788
+ const modelArtifacts = this.db.prepare('SELECT id,manifest_json FROM model_artifacts ORDER BY id').all().map(r => ({ id: String(r.id), manifest: String(r.manifest_json) }));
789
+ const helpers = new Set();
790
+ let unmapped = 0;
791
+ for (const row of this.db.prepare("SELECT DISTINCT s.identity_json FROM embedding_spaces s JOIN index_generations g ON g.fingerprint=s.fingerprint WHERE g.state='building' OR g.id IN (SELECT generation_id FROM active_indexes)").all()) {
792
+ let identity;
793
+ try {
794
+ identity = JSON.parse(String(row.identity_json));
795
+ }
796
+ catch {
797
+ throw new StoreError('integrity');
798
+ }
799
+ const sha = identity?.helperSha256;
800
+ if (typeof sha === 'string' && /^[a-f0-9]{64}$/.test(sha))
801
+ helpers.add(sha);
802
+ else
803
+ unmapped++;
804
+ }
805
+ return { modelArtifacts, embeddingHelpers: [...helpers].sort(), unmappedSpaces: unmapped };
806
+ }
724
807
  /** GC must honor both durable staging pins and active backup fences. */
725
808
  pendingCleanupCount() { return Number(this.db.prepare("SELECT (SELECT count(*) FROM attempts WHERE cleanup='pending')+(SELECT count(*) FROM index_attempts WHERE cleanup='pending') AS n").get()?.n); }
726
809
  hasGcFence() { return Number(this.db.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").get()?.n) > 0; }
@@ -0,0 +1,30 @@
1
+ import { type LibraryStore } from '../store/index.js';
2
+ export type RuntimeKind = 'capture' | 'model' | 'worker';
3
+ export interface RuntimeEntry {
4
+ kind: RuntimeKind;
5
+ name: string;
6
+ bytes: number;
7
+ }
8
+ export interface RuntimeKept {
9
+ kind: RuntimeKind;
10
+ name: string;
11
+ reason: 'referenced' | 'in-use' | 'symlink' | 'foreign' | 'catalog-unavailable' | 'current-missing';
12
+ }
13
+ export interface RuntimeGcReport {
14
+ dryRun: boolean;
15
+ removed: RuntimeEntry[];
16
+ kept: RuntimeKept[];
17
+ bytes: number;
18
+ }
19
+ /** What the installed Sponge can load. `models: null` (catalog unavailable) keeps every model directory. */
20
+ export interface RuntimeGcOptions {
21
+ dryRun?: boolean;
22
+ captureIds: readonly string[];
23
+ models: {
24
+ fingerprints: readonly string[];
25
+ } | null;
26
+ }
27
+ /** Remove only superseded capture runtimes, model packages and settled worker journals.
28
+ * Applying holds worker custody, so no worker, capture, import verification, index run or query
29
+ * can start or be running; each model directory is additionally taken under its setup lock. */
30
+ export declare function collectRuntimes(store: LibraryStore, options: RuntimeGcOptions): Promise<RuntimeGcReport>;