@hraness/sponge 0.2.3 → 0.2.5

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 (88) hide show
  1. package/README.md +53 -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/acquisition/lock.d.ts +1 -1
  9. package/dist/src/acquisition/lock.js +1 -1
  10. package/dist/src/acquisition/pins.js +3 -3
  11. package/dist/src/annotations/quote.d.ts +32 -0
  12. package/dist/src/annotations/quote.js +135 -0
  13. package/dist/src/annotations/store.d.ts +50 -0
  14. package/dist/src/annotations/store.js +89 -0
  15. package/dist/src/app/source.d.ts +2 -0
  16. package/dist/src/app/source.js +13 -17
  17. package/dist/src/cli/arguments.js +24 -7
  18. package/dist/src/cli/digest.d.ts +10 -0
  19. package/dist/src/cli/digest.js +63 -0
  20. package/dist/src/cli/import.d.ts +11 -0
  21. package/dist/src/cli/import.js +10 -0
  22. package/dist/src/cli/indexing.d.ts +3 -0
  23. package/dist/src/cli/indexing.js +5 -2
  24. package/dist/src/cli/main.js +94 -8
  25. package/dist/src/cli/runtimes.d.ts +10 -0
  26. package/dist/src/cli/runtimes.js +32 -0
  27. package/dist/src/digest/editorial.d.ts +53 -0
  28. package/dist/src/digest/editorial.js +81 -0
  29. package/dist/src/digest/endpoint.d.ts +20 -0
  30. package/dist/src/digest/endpoint.js +103 -0
  31. package/dist/src/digest/pipeline.d.ts +148 -0
  32. package/dist/src/digest/pipeline.js +410 -0
  33. package/dist/src/digest/schema.d.ts +85 -0
  34. package/dist/src/digest/schema.js +118 -0
  35. package/dist/src/digest/store.d.ts +57 -0
  36. package/dist/src/digest/store.js +77 -0
  37. package/dist/src/identity/query-policy.d.ts +21 -0
  38. package/dist/src/identity/query-policy.js +55 -0
  39. package/dist/src/identity/uri.d.ts +30 -0
  40. package/dist/src/identity/uri.js +94 -0
  41. package/dist/src/identity/visibility.d.ts +24 -0
  42. package/dist/src/identity/visibility.js +75 -0
  43. package/dist/src/importers/apply.d.ts +10 -0
  44. package/dist/src/importers/apply.js +25 -22
  45. package/dist/src/importers/jungle-digest.d.ts +97 -0
  46. package/dist/src/importers/jungle-digest.js +221 -0
  47. package/dist/src/importers/jungle-store.d.ts +45 -0
  48. package/dist/src/importers/jungle-store.js +95 -0
  49. package/dist/src/importers/jungle.d.ts +54 -0
  50. package/dist/src/importers/jungle.js +134 -0
  51. package/dist/src/indexing/index.d.ts +7 -0
  52. package/dist/src/indexing/index.js +31 -8
  53. package/dist/src/indexing/prune-run.d.ts +22 -0
  54. package/dist/src/indexing/prune-run.js +67 -0
  55. package/dist/src/indexing/prune.d.ts +30 -0
  56. package/dist/src/indexing/prune.js +95 -0
  57. package/dist/src/indexing/runner.js +5 -5
  58. package/dist/src/inference/embedding/index.d.ts +25 -0
  59. package/dist/src/inference/embedding/index.js +11 -0
  60. package/dist/src/models/artifacts.js +175 -33
  61. package/dist/src/portability/database.js +3 -0
  62. package/dist/src/publish/component.d.ts +8 -0
  63. package/dist/src/publish/component.js +114 -0
  64. package/dist/src/publish/feed.d.ts +24 -0
  65. package/dist/src/publish/feed.js +77 -0
  66. package/dist/src/publish/identity.d.ts +6 -0
  67. package/dist/src/publish/identity.js +68 -0
  68. package/dist/src/publish/index.d.ts +34 -0
  69. package/dist/src/publish/index.js +137 -0
  70. package/dist/src/publish/projection.d.ts +29 -0
  71. package/dist/src/publish/projection.js +43 -0
  72. package/dist/src/search/hybrid.js +2 -2
  73. package/dist/src/search/lexical.d.ts +11 -0
  74. package/dist/src/search/lexical.js +6 -0
  75. package/dist/src/search/query.d.ts +7 -3
  76. package/dist/src/search/query.js +52 -33
  77. package/dist/src/search/vector.d.ts +1 -1
  78. package/dist/src/search/vector.js +6 -5
  79. package/dist/src/store/changes.d.ts +42 -0
  80. package/dist/src/store/changes.js +178 -0
  81. package/dist/src/store/foundation.d.ts +7 -0
  82. package/dist/src/store/foundation.js +7 -0
  83. package/dist/src/store/index.d.ts +69 -0
  84. package/dist/src/store/index.js +134 -51
  85. package/dist/src/worker/runtime-gc.d.ts +30 -0
  86. package/dist/src/worker/runtime-gc.js +296 -0
  87. package/native/runtime-manifest.json +4 -4
  88. package/package.json +1 -1
@@ -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>;
@@ -0,0 +1,296 @@
1
+ import { constants } from 'node:fs';
2
+ import { lstat, readdir, realpath, rename, rm, link, unlink, open } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+ import { StoreError } from '../store/index.js';
6
+ import { modelSetupPlan } from '../models/artifacts.js';
7
+ import { acquireWorkerCustody } from './custody.js';
8
+ import { readJournal, writeJournal, syncDir } from './journal.js';
9
+ import { processIdentity, identityAlive } from './identity.js';
10
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
11
+ const patterns = { capture: /^capture-[a-z0-9][a-z0-9.-]{0,99}$/, model: /^[a-f0-9]{64}$/, worker: uuid };
12
+ const helperNames = ['sponge-speech', 'sponge-embedding'];
13
+ const trash = /^\.gc-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
14
+ // A worker writes owner.json just after creating its directory; a young unreadable directory may be mid-acquisition.
15
+ const youngMs = 10 * 60 * 1000;
16
+ const integrity = () => new StoreError('integrity');
17
+ const missing = (error) => error.code === 'ENOENT';
18
+ /** Disk blocks removing one tree frees, never following links: each inode once, and a file only when all of its
19
+ * hard links are inside the tree (a model artifact shared with a kept package frees nothing). */
20
+ async function diskBytes(root) {
21
+ let total = 0;
22
+ const files = new Map();
23
+ const visit = async (path) => {
24
+ let stat;
25
+ try {
26
+ stat = await lstat(path);
27
+ }
28
+ catch (error) {
29
+ if (missing(error))
30
+ return;
31
+ throw error;
32
+ }
33
+ if (stat.isDirectory()) {
34
+ total += stat.blocks * 512;
35
+ for (const name of await readdir(path))
36
+ await visit(join(path, name));
37
+ return;
38
+ }
39
+ const key = `${stat.dev}:${stat.ino}`, file = files.get(key);
40
+ if (file)
41
+ file.seen++;
42
+ else
43
+ files.set(key, { blocks: stat.blocks, nlink: stat.nlink, seen: 1 });
44
+ };
45
+ await visit(root);
46
+ for (const file of files.values())
47
+ if (file.seen >= file.nlink)
48
+ total += file.blocks * 512;
49
+ return total;
50
+ }
51
+ /** The expected parent: a real, private, owned directory at its canonical path, or absent. */
52
+ async function parent(path) {
53
+ let stat;
54
+ try {
55
+ stat = await lstat(path);
56
+ }
57
+ catch (error) {
58
+ if (missing(error))
59
+ return null;
60
+ throw error;
61
+ }
62
+ if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || (stat.mode & 0o077) !== 0 || await realpath(path) !== path)
63
+ throw integrity();
64
+ return path;
65
+ }
66
+ async function exists(path) { try {
67
+ await lstat(path);
68
+ return true;
69
+ }
70
+ catch (error) {
71
+ if (missing(error))
72
+ return false;
73
+ throw error;
74
+ } }
75
+ async function fileSha256(path, maxBytes) {
76
+ let file;
77
+ try {
78
+ file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ try {
84
+ const stat = await file.stat();
85
+ if (!stat.isFile() || stat.size > maxBytes)
86
+ return null;
87
+ return createHash('sha256').update(await file.readFile()).digest('hex');
88
+ }
89
+ finally {
90
+ await file.close();
91
+ }
92
+ }
93
+ /** Direct children whose names match the kind's pattern; symlinks and foreign entries are reported, never followed. */
94
+ async function children(kind, dir, kept) {
95
+ if (!dir)
96
+ return [];
97
+ const result = [];
98
+ for (const name of (await readdir(dir)).sort()) {
99
+ if (!patterns[kind].test(name) && !trash.test(name))
100
+ continue;
101
+ const stat = await lstat(join(dir, name));
102
+ if (stat.isSymbolicLink()) {
103
+ kept.push({ kind, name, reason: 'symlink' });
104
+ continue;
105
+ }
106
+ if (!stat.isDirectory() || stat.uid !== process.getuid?.()) {
107
+ kept.push({ kind, name, reason: 'foreign' });
108
+ continue;
109
+ }
110
+ result.push({ kind, dir, name });
111
+ }
112
+ return result;
113
+ }
114
+ /** Rename first so a swapped-in link is caught before recursive removal; rm never follows links inside. */
115
+ async function remove(candidate) {
116
+ const path = join(candidate.dir, candidate.name), moved = join(candidate.dir, `.gc-${randomUUID()}`);
117
+ const before = await lstat(path);
118
+ if (before.isSymbolicLink() || !before.isDirectory())
119
+ return false;
120
+ await rename(path, moved);
121
+ const after = await lstat(moved);
122
+ if (after.isSymbolicLink() || !after.isDirectory() || after.ino !== before.ino || after.dev !== before.dev) {
123
+ await rename(moved, path);
124
+ return false;
125
+ }
126
+ await syncDir(candidate.dir);
127
+ await rm(moved, { recursive: true });
128
+ await syncDir(candidate.dir);
129
+ return true;
130
+ }
131
+ async function workerInUse(dir, name) {
132
+ try {
133
+ const owner = await readJournal(join(dir, name, 'owner.json'));
134
+ if (owner?.schemaVersion !== 1 || owner.incarnation !== name || !owner.identity)
135
+ return true;
136
+ return await identityAlive(owner.identity);
137
+ }
138
+ catch (error) {
139
+ if (!missing(error))
140
+ return true;
141
+ try {
142
+ return Date.now() - (await lstat(join(dir, name))).mtimeMs < youngMs;
143
+ }
144
+ catch {
145
+ return true;
146
+ }
147
+ }
148
+ }
149
+ /** Remove only superseded capture runtimes, model packages and settled worker journals.
150
+ * Applying holds worker custody, so no worker, capture, import verification, index run or query
151
+ * can start or be running; each model directory is additionally taken under its setup lock. */
152
+ export async function collectRuntimes(store, options) {
153
+ const dryRun = options.dryRun === true, base = await realpath(store.root), runtime = await parent(join(base, 'runtime'));
154
+ if (store.ownerSnapshot())
155
+ throw new StoreError('owner-busy');
156
+ if (store.pendingCleanupCount())
157
+ throw Object.assign(Error('cleanup-required'), { code: 'cleanup-required' });
158
+ const lockPath = join(base, 'runtime', 'worker.lock');
159
+ // Checked first so a refused collection leaves no custody journal; acquisition stays the authority.
160
+ if (await exists(lockPath))
161
+ throw new StoreError('owner-busy');
162
+ const custody = dryRun || !runtime ? undefined : await acquireWorkerCustody(base);
163
+ const removed = [], kept = [];
164
+ try {
165
+ if (!runtime)
166
+ return { dryRun, removed, kept, bytes: 0 };
167
+ // Recheck under custody: an owner that appeared before our lock still blocks.
168
+ if (store.ownerSnapshot())
169
+ throw new StoreError('owner-busy');
170
+ if (store.pendingCleanupCount())
171
+ throw Object.assign(Error('cleanup-required'), { code: 'cleanup-required' });
172
+ const dirs = { capture: await parent(join(runtime, 'capture')), model: await parent(join(runtime, 'models')), worker: await parent(join(runtime, 'workers')) };
173
+ const references = store.runtimeReferences(), models = options.models;
174
+ const modelKeep = new Set(models?.fingerprints ?? []);
175
+ for (const artifact of references.modelArtifacts) {
176
+ if (patterns.model.test(artifact.id))
177
+ modelKeep.add(artifact.id);
178
+ try {
179
+ modelKeep.add(modelSetupPlan(JSON.parse(artifact.manifest)).fingerprint);
180
+ }
181
+ catch { /* An unparsable row still pins its id. */ }
182
+ }
183
+ // A generation's helper is satisfied only by an installed kept package, never by an uninstalled catalog entry.
184
+ const helpers = new Set(references.embeddingHelpers), installed = new Set();
185
+ for (const fp of modelKeep) {
186
+ if (!dirs.model || !await exists(join(dirs.model, fp)))
187
+ continue;
188
+ for (const helper of helperNames) {
189
+ if (await exists(join(dirs.model, fp, helper)))
190
+ installed.add(helper);
191
+ }
192
+ helpers.delete(await fileSha256(join(dirs.model, fp, 'sponge-embedding'), 32 * 1024 ** 2) ?? '');
193
+ }
194
+ const take = async (candidate) => {
195
+ const bytes = await diskBytes(join(candidate.dir, candidate.name));
196
+ if (dryRun || await remove(candidate))
197
+ removed.push({ kind: candidate.kind, name: candidate.name, bytes });
198
+ else
199
+ kept.push({ kind: candidate.kind, name: candidate.name, reason: 'symlink' });
200
+ };
201
+ for (const candidate of await children('capture', dirs.capture, kept)) {
202
+ if (options.captureIds.includes(candidate.name))
203
+ kept.push({ kind: 'capture', name: candidate.name, reason: 'referenced' });
204
+ else
205
+ await take(candidate);
206
+ }
207
+ for (const candidate of await children('model', dirs.model, kept)) {
208
+ const { name, dir } = candidate;
209
+ if (trash.test(name)) {
210
+ await take(candidate);
211
+ continue;
212
+ }
213
+ if (!models) {
214
+ kept.push({ kind: 'model', name, reason: 'catalog-unavailable' });
215
+ continue;
216
+ }
217
+ if (modelKeep.has(name)) {
218
+ kept.push({ kind: 'model', name, reason: 'referenced' });
219
+ continue;
220
+ }
221
+ const helper = join(dir, name, 'sponge-embedding');
222
+ if ((references.unmappedSpaces && await exists(helper)) || helpers.size && helpers.has(await fileSha256(helper, 32 * 1024 ** 2) ?? '')) {
223
+ kept.push({ kind: 'model', name, reason: 'referenced' });
224
+ continue;
225
+ }
226
+ // Until this Sponge's package of a kind is installed, older packages of that kind are its only local copy.
227
+ let missingKind = false;
228
+ for (const helper of helperNames)
229
+ if (!installed.has(helper) && await exists(join(dir, name, helper)))
230
+ missingKind = true;
231
+ if (missingKind) {
232
+ kept.push({ kind: 'model', name, reason: 'current-missing' });
233
+ continue;
234
+ }
235
+ const lock = join(dir, `.${name}.lock`);
236
+ if (await exists(lock) || await exists(join(dir, `.${name}.recovery-lock`))) {
237
+ kept.push({ kind: 'model', name, reason: 'in-use' });
238
+ continue;
239
+ }
240
+ if (dryRun) {
241
+ await take(candidate);
242
+ continue;
243
+ }
244
+ // The model setup lock: a concurrent setup or recovery of this fingerprint sees owner-busy.
245
+ const identity = await processIdentity(process.pid);
246
+ if (!identity)
247
+ throw integrity();
248
+ const incarnation = randomUUID(), ownerFile = join(dir, `.model-owner-${incarnation}.json`);
249
+ await writeJournal(ownerFile, { schemaVersion: 1, incarnation, fingerprint: name, stage: '.model-install-' + incarnation, identity });
250
+ try {
251
+ try {
252
+ await link(ownerFile, lock);
253
+ }
254
+ catch (error) {
255
+ if (error.code === 'EEXIST') {
256
+ kept.push({ kind: 'model', name, reason: 'in-use' });
257
+ continue;
258
+ }
259
+ throw error;
260
+ }
261
+ try {
262
+ await syncDir(dir);
263
+ await take(candidate);
264
+ }
265
+ finally {
266
+ await unlink(lock);
267
+ }
268
+ }
269
+ finally {
270
+ await unlink(ownerFile);
271
+ await syncDir(dir);
272
+ }
273
+ }
274
+ for (const candidate of await children('worker', dirs.worker, kept)) {
275
+ const { name, dir } = candidate;
276
+ if (name === custody?.incarnation)
277
+ continue;
278
+ if (!trash.test(name) && await workerInUse(dir, name)) {
279
+ kept.push({ kind: 'worker', name, reason: 'in-use' });
280
+ continue;
281
+ }
282
+ await take(candidate);
283
+ }
284
+ return { dryRun, removed, kept, bytes: removed.reduce((n, e) => n + e.bytes, 0) };
285
+ }
286
+ finally {
287
+ if (custody) {
288
+ await custody.release();
289
+ try {
290
+ await rm(custody.directory, { recursive: true });
291
+ await syncDir(join(base, 'runtime', 'workers'));
292
+ }
293
+ catch { /* The next collection removes this settled journal. */ }
294
+ }
295
+ }
296
+ }
@@ -27,14 +27,14 @@
27
27
  }
28
28
  ],
29
29
  "build": {
30
- "asrSourceSha256": "d0047cce6c54d23aea9921b6de0a94c40853191fca65a3709bbc83db20a46187",
30
+ "asrSourceSha256": "1cb743b39e76666adc814306e06c02ae1c59ca3b784a6bb8f186270387f59ee9",
31
31
  "clangVersion": "Apple clang version 21.0.0 (clang-2100.1.1.101)",
32
- "embeddingSourceSha256": "7849bd7a82f90daac544f342002f07e624c0486ea452f2d8437a2dbdeca02bc1",
32
+ "embeddingSourceSha256": "7e499ea896cd9805daca51bb6489c0856acad4edbaf7e76b07ff1e63d5766457",
33
33
  "swiftVersion": "Apple Swift version 6.3.3 (swiftlang-6.3.3.1.3 clang-2100.1.1.101)"
34
34
  },
35
35
  "minimumMacOS": "26.0",
36
- "packageVersion": "0.2.3",
36
+ "packageVersion": "0.2.5",
37
37
  "platform": "darwin",
38
38
  "schemaVersion": 1,
39
- "sourceCommit": "8a81581be84426c678295f8ec928bf7d306d0cb0"
39
+ "sourceCommit": "e69bea289723e18288322257ffff269b7fe16bac"
40
40
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hraness/sponge",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "A local library for captured articles and verified offline assets.",
5
5
  "repository": {
6
6
  "type": "git",