@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
@@ -4,44 +4,63 @@ import { rm } from 'node:fs/promises';
4
4
  import { acquireWorkerCustody, proveAttemptCleanup } from '../worker/custody.js';
5
5
  import { directory, writeJournal, readJournal, syncDir } from '../worker/journal.js';
6
6
  import { supervisedRunner } from '../worker/supervisor.js';
7
- import { embedBatch, embeddingFingerprint, encodeEmbeddingRequest, EmbeddingError } from '../inference/embedding/index.js';
7
+ import { embedBatch, embeddingFingerprint, embeddingTextBytes, resolveIndexSpace, projectVector, encodeEmbeddingRequest, EmbeddingError } from '../inference/embedding/index.js';
8
+ /** One custody-held helper run. Mutates coordination journals only, never library content or job tables. */
9
+ async function custodyEmbed(store, runtime, inputs, signal, options) {
10
+ const custody = await acquireWorkerCustody(store.root), attemptId = randomUUID();
11
+ let scratchRoot, cleanupUnknown = false;
12
+ try {
13
+ const attempts = await directory(join(custody.directory, 'attempts', attemptId));
14
+ await writeJournal(join(attempts, 'query.json'), { schemaVersion: 1, kind: 'query', attemptId, ownerIncarnation: custody.incarnation });
15
+ scratchRoot = await directory(join(store.root, 'staging', 'query-' + attemptId));
16
+ await writeJournal(join(scratchRoot, 'ownership.json'), { schemaVersion: 1, kind: 'query', requestId: attemptId, ownerIncarnation: custody.incarnation });
17
+ await options.onReady?.({ attemptId, ownerDirectory: custody.directory, scratchRoot });
18
+ const expected = encodeEmbeddingRequest(inputs).inputHashes;
19
+ const result = await (options.embed ?? embedBatch)(inputs, { runtime, scratchRoot, signal, runProcess: supervisedRunner(custody.directory, custody.incarnation, attemptId) });
20
+ if (result.fingerprint !== embeddingFingerprint(runtime.helperSha256) || result.inputHashes.length !== expected.length || result.inputHashes.some((h, i) => h !== expected[i]) || result.vectors.length !== expected.length)
21
+ throw new EmbeddingError('integrity');
22
+ return result.vectors;
23
+ }
24
+ catch (error) {
25
+ cleanupUnknown = error.code === 'cleanup-required';
26
+ throw error;
27
+ }
28
+ finally {
29
+ const proved = !cleanupUnknown && await proveAttemptCleanup(custody.directory, custody.incarnation, attemptId);
30
+ if (!proved)
31
+ throw new EmbeddingError('cleanup-required');
32
+ if (scratchRoot) {
33
+ const marker = await readJournal(join(scratchRoot, 'ownership.json'));
34
+ if (marker.schemaVersion !== 1 || marker.kind !== 'query' || marker.requestId !== attemptId || marker.ownerIncarnation !== custody.incarnation)
35
+ throw new EmbeddingError('cleanup-required');
36
+ await rm(scratchRoot, { recursive: true });
37
+ await syncDir(join(store.root, 'staging'));
38
+ }
39
+ await custody.release();
40
+ }
41
+ }
8
42
  /** Queries mutate coordination journals only, never library content or job tables. */
9
43
  export function createQueryEmbedder(store, runtime, options = {}) {
10
44
  return async (query, pin, signal) => {
11
45
  if (signal.aborted)
12
46
  throw new EmbeddingError('cancelled');
13
- if (pin.fingerprint !== embeddingFingerprint(runtime.helperSha256))
47
+ const space = resolveIndexSpace(runtime.helperSha256, pin.fingerprint);
48
+ if (!space || space.dimensions !== pin.dimensions)
14
49
  throw new EmbeddingError('integrity');
15
- const custody = await acquireWorkerCustody(store.root), attemptId = randomUUID();
16
- let scratchRoot, cleanupUnknown = false;
17
- try {
18
- const attempts = await directory(join(custody.directory, 'attempts', attemptId));
19
- await writeJournal(join(attempts, 'query.json'), { schemaVersion: 1, kind: 'query', attemptId, ownerIncarnation: custody.incarnation });
20
- scratchRoot = await directory(join(store.root, 'staging', 'query-' + attemptId));
21
- await writeJournal(join(scratchRoot, 'ownership.json'), { schemaVersion: 1, kind: 'query', requestId: attemptId, ownerIncarnation: custody.incarnation });
22
- await options.onReady?.({ attemptId, ownerDirectory: custody.directory, scratchRoot });
23
- const inputs = [{ kind: 'query', text: query }], expected = encodeEmbeddingRequest(inputs).inputHashes[0];
24
- const result = await (options.embed ?? embedBatch)(inputs, { runtime, scratchRoot, signal, runProcess: supervisedRunner(custody.directory, custody.incarnation, attemptId) });
25
- if (result.fingerprint !== pin.fingerprint || result.inputHashes.length !== 1 || result.inputHashes[0] !== expected || result.vectors.length !== 1)
26
- throw new EmbeddingError('integrity');
27
- return { fingerprint: result.fingerprint, vector: result.vectors[0] };
28
- }
29
- catch (error) {
30
- cleanupUnknown = error.code === 'cleanup-required';
31
- throw error;
32
- }
33
- finally {
34
- const proved = !cleanupUnknown && await proveAttemptCleanup(custody.directory, custody.incarnation, attemptId);
35
- if (!proved)
36
- throw new EmbeddingError('cleanup-required');
37
- if (scratchRoot) {
38
- const marker = await readJournal(join(scratchRoot, 'ownership.json'));
39
- if (marker.schemaVersion !== 1 || marker.kind !== 'query' || marker.requestId !== attemptId || marker.ownerIncarnation !== custody.incarnation)
40
- throw new EmbeddingError('cleanup-required');
41
- await rm(scratchRoot, { recursive: true });
42
- await syncDir(join(store.root, 'staging'));
43
- }
44
- await custody.release();
45
- }
50
+ const [vector] = await custodyEmbed(store, runtime, [{ kind: 'query', text: query }], signal, options);
51
+ return { fingerprint: pin.fingerprint, vector: projectVector(vector, space.dimensions) };
46
52
  };
47
53
  }
54
+ /** Unit 768-dimension document vectors for short texts, 16 per helper run; text is clipped to the helper's byte budget on a character boundary. */
55
+ export function createTextEmbedder(store, runtime, options = {}) {
56
+ const room = embeddingTextBytes - Buffer.byteLength('title: none | text: '), clip = (t) => { let out = ''; for (const ch of t) {
57
+ if (Buffer.byteLength(out + ch) > room)
58
+ break;
59
+ out += ch;
60
+ } return out; };
61
+ return async (texts, signal) => { const out = []; for (let i = 0; i < texts.length; i += 16) {
62
+ if (signal.aborted)
63
+ throw new EmbeddingError('cancelled');
64
+ out.push(...await custodyEmbed(store, runtime, texts.slice(i, i + 16).map(t => ({ kind: 'document', text: clip(t) })), signal, options));
65
+ } return out; };
66
+ }
@@ -1,5 +1,5 @@
1
1
  import type { DatabaseSync } from 'node:sqlite';
2
- import type { IndexInput } from '../indexing/index.js';
2
+ import { type IndexInput } from '../indexing/index.js';
3
3
  export interface SearchGeneration {
4
4
  id: string;
5
5
  fingerprint: string;
@@ -1,3 +1,4 @@
1
+ import { encodeVector, spaceEncoding } from '../indexing/index.js';
1
2
  export function pinSearchGeneration(db, profileId) {
2
3
  const row = db.prepare('SELECT g.id,g.fingerprint,s.dimensions FROM active_indexes a JOIN index_generations g ON g.id=a.generation_id JOIN embedding_spaces s ON s.fingerprint=g.fingerprint WHERE a.profile_id=?').get(profileId);
3
4
  if (!row)
@@ -10,12 +11,12 @@ export function vectorSearch(db, pin, query, limit = 50) {
10
11
  throw Error('invalid-search-vector');
11
12
  if (db.prepare('SELECT vec_version() AS version').get()?.version !== 'v0.1.9')
12
13
  throw Error('unsupported-vector-runtime');
13
- const generation = db.prepare('SELECT fingerprint FROM index_generations WHERE id=?').get(pin.id);
14
- if (!generation || generation.fingerprint !== pin.fingerprint)
14
+ const generation = db.prepare('SELECT g.fingerprint,s.dimensions,s.identity_json FROM index_generations g JOIN embedding_spaces s ON s.fingerprint=g.fingerprint WHERE g.id=?').get(pin.id);
15
+ if (!generation || generation.fingerprint !== pin.fingerprint || Number(generation.dimensions) !== pin.dimensions)
15
16
  throw Error('search-generation-missing');
16
- const bytes = Buffer.alloc(query.vector.length * 4);
17
- query.vector.forEach((v, i) => bytes.writeFloatLE(v, i * 4));
18
- const rows = db.prepare('SELECT v.source_id,v.revision_id,v.derivation_hash,v.asset_sha256,i.id AS item_id,i.title,vec_distance_cosine(v.vector,?) AS distance FROM vectors v JOIN revisions r ON r.id=v.revision_id JOIN items i ON i.id=r.item_id WHERE v.generation_id=? AND i.deleted_at IS NULL AND i.current_revision=v.revision_id ORDER BY distance,v.source_id LIMIT ?').all(bytes, pin.id, limit);
17
+ // int8 spaces quantize the query the same way; cosine over codes is the cosine of the dequantized vectors because per-vector scales cancel.
18
+ const encoding = spaceEncoding(JSON.parse(String(generation.identity_json))), bytes = encodeVector(query.vector, encoding), distance = encoding === 'int8' ? 'vec_distance_cosine(vec_int8(v.vector),vec_int8(?))' : 'vec_distance_cosine(v.vector,?)';
19
+ const rows = db.prepare('SELECT v.source_id,v.revision_id,v.derivation_hash,v.asset_sha256,i.id AS item_id,i.title,' + distance + ' AS distance FROM vectors v JOIN revisions r ON r.id=v.revision_id JOIN items i ON i.id=r.item_id WHERE v.generation_id=? AND i.deleted_at IS NULL AND i.current_revision=v.revision_id ORDER BY distance,v.source_id LIMIT ?').all(bytes, pin.id, limit);
19
20
  return rows.map(row => { const descriptor = JSON.parse(String(row.source_id)), distance = Number(row.distance); if (!Array.isArray(descriptor) || descriptor[0] !== row.revision_id || !['document', 'image'].includes(String(descriptor[1])) || typeof descriptor[2] !== 'string' || !Number.isFinite(distance))
20
21
  throw Error('invalid-search-index'); const source = { sourceId: String(row.source_id), itemId: String(row.item_id), revisionId: String(row.revision_id), derivationHash: String(row.derivation_hash), kind: descriptor[1], ...(descriptor[1] === 'image' ? { assetSha256: String(row.asset_sha256) } : { representationId: descriptor[2], ordinal: Number(descriptor[3]) }) }; return { itemId: String(row.item_id), revisionId: String(row.revision_id), title: String(row.title), source, distance }; });
21
22
  }
@@ -0,0 +1,42 @@
1
+ import type { DatabaseSync } from 'node:sqlite';
2
+ type Row = Record<string, any>;
3
+ export type Entity = 'item' | 'annotation' | 'reading' | 'derivation';
4
+ /** One field-level write to user state. The sync contract: replaying every change in order rebuilds user state. */
5
+ export interface Change {
6
+ entity: Entity;
7
+ entityId: string;
8
+ field: string;
9
+ value: unknown;
10
+ }
11
+ export interface LoggedChange extends Change {
12
+ seq: number;
13
+ id: string;
14
+ deviceId: string;
15
+ hlc: string;
16
+ }
17
+ export type ReviewStatus = 'accepted' | 'rejected';
18
+ /** Bound on one change's JSON; imported note bodies (64 KiB) plus anchors fit with room for escaping. */
19
+ export declare const maxChangeBytes = 262144;
20
+ /** Append without applying: for state that already exists (seeding). Callers hold the write transaction. */
21
+ export declare function append(db: DatabaseSync, change: Change): void;
22
+ /** The only write path for user state: tick the clock, apply the row update, append the change. */
23
+ export declare function mutate(db: DatabaseSync, entity: Entity, entityId: string, field: string, value: unknown): void;
24
+ export declare function listChanges(db: DatabaseSync, after?: number, limit?: number): LoggedChange[];
25
+ /** Deterministic source-ref id, so replayed item creation yields the same row on every device. */
26
+ export declare const sourceRefId: (itemId: string) => string;
27
+ /**
28
+ * Review JSON in one canonical shape, whatever order its paths were written in. A digest is reviewed per field;
29
+ * an editorial derivation (a reviewed source note kept whole) has one path, `review.note`.
30
+ */
31
+ export declare function setReviewPath(review: Row, output: Row, path: string, value: ReviewStatus | null, kind?: string): Row;
32
+ /**
33
+ * Map one change onto its row. Table-driven: every user-state field Sponge writes is listed here,
34
+ * and nothing else writes those columns. Objects (revisions, derivation outputs) must already exist.
35
+ */
36
+ export declare function applyChange(db: DatabaseSync, change: Change): void;
37
+ /**
38
+ * Seed the log with user state written before schema 5, so another device can rebuild it.
39
+ * Idempotent: a field that already has a change is never seeded again. Rows already exist, so this appends only.
40
+ */
41
+ export declare function seedChanges(db: DatabaseSync): number;
42
+ export {};
@@ -0,0 +1,178 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { StoreError } from './types.js';
3
+ import { digest } from './files.js';
4
+ const now = () => new Date().toISOString();
5
+ const fail = (code = 'invalid-input') => { throw new StoreError(code); };
6
+ /** Bound on one change's JSON; imported note bodies (64 KiB) plus anchors fit with room for escaping. */
7
+ export const maxChangeBytes = 262144;
8
+ /** Hybrid logical clock `ms.counter.device`: zero-padded so every device sorts the strings alike. */
9
+ function tick(db) {
10
+ const row = db.prepare('SELECT device_id,clock_ms,clock_counter FROM device WHERE id=1').get() ?? fail('integrity');
11
+ const wall = Date.now(), prior = Number(row.clock_ms), ms = Math.max(wall, prior), counter = ms === prior ? Number(row.clock_counter) + 1 : 0;
12
+ db.prepare('UPDATE device SET clock_ms=?,clock_counter=? WHERE id=1').run(ms, counter);
13
+ return { deviceId: String(row.device_id), hlc: `${String(ms).padStart(15, '0')}.${String(counter).padStart(9, '0')}.${row.device_id}` };
14
+ }
15
+ /** Append without applying: for state that already exists (seeding). Callers hold the write transaction. */
16
+ export function append(db, change) {
17
+ const { deviceId, hlc } = tick(db);
18
+ db.prepare('INSERT INTO changes(id,device_id,hlc,entity,entity_id,field,value_json,created_at) VALUES(?,?,?,?,?,?,?,?)').run(randomUUID(), deviceId, hlc, change.entity, change.entityId, change.field, JSON.stringify(change.value ?? null), now());
19
+ }
20
+ /** The only write path for user state: tick the clock, apply the row update, append the change. */
21
+ export function mutate(db, entity, entityId, field, value) {
22
+ const change = { entity, entityId, field, value: value ?? null };
23
+ if (Buffer.byteLength(JSON.stringify(change.value)) > maxChangeBytes)
24
+ fail();
25
+ applyChange(db, change);
26
+ append(db, change);
27
+ }
28
+ export function listChanges(db, after = 0, limit = 500) {
29
+ if (!Number.isSafeInteger(after) || after < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 5000)
30
+ fail();
31
+ return db.prepare('SELECT * FROM changes WHERE seq>? ORDER BY seq LIMIT ?').all(after, limit).map(r => ({ seq: Number(r.seq), id: String(r.id), deviceId: String(r.device_id), hlc: String(r.hlc), entity: r.entity, entityId: String(r.entity_id), field: String(r.field), value: JSON.parse(String(r.value_json)) }));
32
+ }
33
+ const object = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
34
+ const str = (v, max = 65536) => typeof v === 'string' && v.length > 0 && Buffer.byteLength(v) <= max;
35
+ const optional = (v, max = 256) => v === null || str(v, max);
36
+ const status = (v) => v === null || v === 'accepted' || v === 'rejected';
37
+ /** Deterministic source-ref id, so replayed item creation yields the same row on every device. */
38
+ export const sourceRefId = (itemId) => digest(JSON.stringify(['source-ref-v1', itemId]));
39
+ const parsed = (json) => { try {
40
+ return JSON.parse(String(json));
41
+ }
42
+ catch {
43
+ return String(json);
44
+ } };
45
+ const changed = (r) => { if (!Number(r.changes))
46
+ fail('unavailable'); };
47
+ /**
48
+ * Review JSON in one canonical shape, whatever order its paths were written in. A digest is reviewed per field;
49
+ * an editorial derivation (a reviewed source note kept whole) has one path, `review.note`.
50
+ */
51
+ export function setReviewPath(review, output, path, value, kind = 'digest') {
52
+ if (kind === 'editorial') {
53
+ if (path !== 'review.note')
54
+ fail();
55
+ return value ? { note: value } : {};
56
+ }
57
+ if (kind !== 'digest')
58
+ fail();
59
+ const ideas = output.ideas.map((_, i) => review.ideas?.[i] ?? null);
60
+ const quotes = output.ideas.map((idea, i) => idea.quotes.map((_, j) => review.quotes?.[i]?.[j] ?? null));
61
+ let gist = review.gist ?? null;
62
+ const m = /^review\.(?:(gist)|ideas\.(\d{1,2})|quotes\.(\d{1,2})\.(\d{1,2}))$/.exec(path) ?? fail();
63
+ if (m[1])
64
+ gist = value;
65
+ else if (m[2] !== undefined) {
66
+ const i = Number(m[2]);
67
+ if (i >= ideas.length)
68
+ fail();
69
+ ideas[i] = value;
70
+ }
71
+ else {
72
+ const i = Number(m[3]), j = Number(m[4]);
73
+ if (i >= quotes.length || j >= quotes[i].length)
74
+ fail();
75
+ quotes[i][j] = value;
76
+ }
77
+ return { ...(gist ? { gist } : {}), ...(ideas.some(v => v) ? { ideas } : {}), ...(quotes.some(q => q.some(v => v)) ? { quotes } : {}) };
78
+ }
79
+ /**
80
+ * Map one change onto its row. Table-driven: every user-state field Sponge writes is listed here,
81
+ * and nothing else writes those columns. Objects (revisions, derivation outputs) must already exist.
82
+ */
83
+ export function applyChange(db, change) {
84
+ // Untrusted once a transport delivers it: every case validates before writing.
85
+ const { entity, entityId: id, field } = change, v = change.value;
86
+ if (!str(id, 256))
87
+ fail();
88
+ switch (`${entity}.${field.startsWith('review.') ? 'review' : field}`) {
89
+ case 'item.created': {
90
+ if (!object(v) || !['article', 'pdf', 'media'].includes(v.kind) || !str(v.createdAt, 64) || !optional(v.sourceKey, 8192) || !(v.realm === null || typeof v.realm === 'string' && Buffer.byteLength(v.realm) <= 256) || !optional(v.locator, 16384))
91
+ fail();
92
+ db.prepare('INSERT INTO items(id,kind,created_at) VALUES(?,?,?) ON CONFLICT(id) DO NOTHING').run(id, v.kind, v.createdAt);
93
+ if (v.sourceKey !== null && v.locator !== null)
94
+ db.prepare('INSERT INTO source_refs(id,item_id,source_key,realm_key,locator) VALUES(?,?,?,?,?) ON CONFLICT DO NOTHING').run(sourceRefId(id), id, v.sourceKey, v.realm ?? '', v.locator);
95
+ return;
96
+ }
97
+ case 'item.currentRevision':
98
+ if (!optional(v))
99
+ fail();
100
+ if (v !== null && !db.prepare('SELECT 1 FROM revisions WHERE id=? AND item_id=?').get(v, id))
101
+ fail('unavailable');
102
+ return changed(db.prepare('UPDATE items SET current_revision=? WHERE id=?').run(v, id));
103
+ case 'item.title':
104
+ if (typeof v !== 'string' || Buffer.byteLength(v) > 4096)
105
+ fail();
106
+ return changed(db.prepare('UPDATE items SET title=? WHERE id=?').run(v, id));
107
+ case 'item.visibility':
108
+ if (v !== null && v !== 'public' && v !== 'private')
109
+ fail();
110
+ return changed(db.prepare('UPDATE items SET visibility=? WHERE id=?').run(v, id));
111
+ case 'item.deleted':
112
+ if (!str(v, 64))
113
+ fail();
114
+ return changed(db.prepare('UPDATE items SET deleted_at=COALESCE(deleted_at,?) WHERE id=?').run(v, id));
115
+ case 'annotation.created':
116
+ if (!object(v) || !str(v.itemId, 256) || !['note', 'highlight', 'comment'].includes(v.kind) || !optional(v.revisionId) || !optional(v.parentId) || typeof v.body !== 'string' || !str(v.createdAt, 64) || !('anchor' in v))
117
+ fail();
118
+ db.prepare('INSERT INTO annotations(id,item_id,revision_id,anchor_json,body,created_at,kind,parent_id) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(id) DO NOTHING').run(id, v.itemId, v.revisionId, JSON.stringify(v.anchor), v.body, v.createdAt, v.kind, v.parentId);
119
+ return;
120
+ case 'annotation.body':
121
+ if (!object(v) || typeof v.body !== 'string' || !str(v.updatedAt, 64))
122
+ fail();
123
+ return changed(db.prepare('UPDATE annotations SET body=?,updated_at=? WHERE id=?').run(v.body, v.updatedAt, id));
124
+ case 'annotation.deleted':
125
+ if (!str(v, 64))
126
+ fail();
127
+ return changed(db.prepare('UPDATE annotations SET deleted_at=COALESCE(deleted_at,?) WHERE id=?').run(v, id));
128
+ case 'reading.state':
129
+ if (!object(v) || v.state === undefined || !str(v.updatedAt, 64))
130
+ fail();
131
+ db.prepare('INSERT INTO reading_state(item_id,state_json,updated_at) VALUES(?,?,?) ON CONFLICT(item_id) DO UPDATE SET state_json=excluded.state_json,updated_at=excluded.updated_at').run(id, JSON.stringify(v.state), v.updatedAt);
132
+ return;
133
+ case 'derivation.review': {
134
+ if (!status(v))
135
+ fail();
136
+ const row = db.prepare('SELECT kind,output_json,review_json FROM derivations WHERE id=?').get(id) ?? fail('unavailable');
137
+ db.prepare('UPDATE derivations SET review_json=? WHERE id=?').run(JSON.stringify(setReviewPath(JSON.parse(String(row.review_json)), JSON.parse(String(row.output_json)), field, v, String(row.kind))), id);
138
+ return;
139
+ }
140
+ default: fail();
141
+ }
142
+ }
143
+ /**
144
+ * Seed the log with user state written before schema 5, so another device can rebuild it.
145
+ * Idempotent: a field that already has a change is never seeded again. Rows already exist, so this appends only.
146
+ */
147
+ export function seedChanges(db) {
148
+ let count = 0;
149
+ const seed = (entity, entityId, field, value) => {
150
+ if (db.prepare('SELECT 1 FROM changes WHERE entity=? AND entity_id=? AND field=? LIMIT 1').get(entity, entityId, field))
151
+ return;
152
+ append(db, { entity, entityId, field, value });
153
+ count++;
154
+ };
155
+ for (const i of db.prepare('SELECT * FROM items ORDER BY created_at,rowid').all()) {
156
+ const id = String(i.id), source = db.prepare('SELECT source_key,realm_key,locator FROM source_refs WHERE item_id=? ORDER BY rowid LIMIT 1').get(id);
157
+ seed('item', id, 'created', { kind: i.kind, sourceKey: source ? String(source.source_key) : null, realm: source ? String(source.realm_key) : null, locator: source ? String(source.locator) : null, createdAt: String(i.created_at) });
158
+ if (i.current_revision !== null)
159
+ seed('item', id, 'currentRevision', String(i.current_revision));
160
+ if (i.title !== '')
161
+ seed('item', id, 'title', String(i.title));
162
+ if (i.visibility !== null && i.visibility !== undefined)
163
+ seed('item', id, 'visibility', i.visibility);
164
+ if (i.deleted_at !== null)
165
+ seed('item', id, 'deleted', String(i.deleted_at));
166
+ }
167
+ for (const a of db.prepare('SELECT * FROM annotations ORDER BY parent_id IS NOT NULL,created_at,rowid').all()) {
168
+ const id = String(a.id);
169
+ seed('annotation', id, 'created', { itemId: String(a.item_id), kind: a.kind, revisionId: a.revision_id ?? null, parentId: a.parent_id ?? null, anchor: parsed(a.anchor_json), body: String(a.body), createdAt: String(a.created_at) });
170
+ if (a.updated_at !== null)
171
+ seed('annotation', id, 'body', { body: String(a.body), updatedAt: String(a.updated_at) });
172
+ if (a.deleted_at !== null)
173
+ seed('annotation', id, 'deleted', String(a.deleted_at));
174
+ }
175
+ for (const r of db.prepare('SELECT * FROM reading_state ORDER BY rowid').all())
176
+ seed('reading', String(r.item_id), 'state', { state: parsed(r.state_json), updatedAt: String(r.updated_at) });
177
+ return count;
178
+ }
@@ -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, type Change, type Entity, type LoggedChange } from './changes.js';
3
+ export { backfillUris, itemUris, itemsForUri, setVisibility, syncUris, visibility, type Visibility, type VisibilityState } from '../identity/visibility.js';
4
+ export { addComment, addHighlight, deleteAnnotation, importNote, listAnnotations, markdownFor, readAnnotation, setReadingState, updateAnnotation, type Annotation } from '../annotations/store.js';
5
+ export { acceptedDigest, canonicalDigest, currentDigest, derivationId, findDigest, putDigest, readDigest, reviewDigest, type Derivation, type DigestReview, type ReviewStatus } from '../digest/store.js';
6
+ export { currentEditorial, listEditorials, publicEditorial, putEditorial, readEditorial, reviewEditorial, type Editorial } from '../digest/editorial.js';
7
+ export { publicProjection, type PublicEntry } from '../publish/projection.js';
@@ -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;