@hraness/sponge 0.2.4 → 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.
- package/README.md +53 -0
- package/dist/migrations/005.d.ts +3 -0
- package/dist/migrations/005.js +19 -0
- package/dist/migrations/006.d.ts +3 -0
- package/dist/migrations/006.js +9 -0
- package/dist/migrations/007.d.ts +3 -0
- package/dist/migrations/007.js +10 -0
- package/dist/src/annotations/quote.d.ts +32 -0
- package/dist/src/annotations/quote.js +135 -0
- package/dist/src/annotations/store.d.ts +50 -0
- package/dist/src/annotations/store.js +89 -0
- package/dist/src/app/source.d.ts +2 -0
- package/dist/src/app/source.js +13 -17
- package/dist/src/cli/arguments.js +24 -7
- package/dist/src/cli/digest.d.ts +10 -0
- package/dist/src/cli/digest.js +63 -0
- package/dist/src/cli/import.d.ts +11 -0
- package/dist/src/cli/import.js +10 -0
- package/dist/src/cli/indexing.d.ts +3 -0
- package/dist/src/cli/indexing.js +5 -2
- package/dist/src/cli/main.js +94 -8
- package/dist/src/cli/runtimes.d.ts +10 -0
- package/dist/src/cli/runtimes.js +32 -0
- package/dist/src/digest/editorial.d.ts +53 -0
- package/dist/src/digest/editorial.js +81 -0
- package/dist/src/digest/endpoint.d.ts +20 -0
- package/dist/src/digest/endpoint.js +103 -0
- package/dist/src/digest/pipeline.d.ts +148 -0
- package/dist/src/digest/pipeline.js +410 -0
- package/dist/src/digest/schema.d.ts +85 -0
- package/dist/src/digest/schema.js +118 -0
- package/dist/src/digest/store.d.ts +57 -0
- package/dist/src/digest/store.js +77 -0
- package/dist/src/identity/query-policy.d.ts +21 -0
- package/dist/src/identity/query-policy.js +55 -0
- package/dist/src/identity/uri.d.ts +30 -0
- package/dist/src/identity/uri.js +94 -0
- package/dist/src/identity/visibility.d.ts +24 -0
- package/dist/src/identity/visibility.js +75 -0
- package/dist/src/importers/apply.d.ts +10 -0
- package/dist/src/importers/apply.js +25 -22
- package/dist/src/importers/jungle-digest.d.ts +97 -0
- package/dist/src/importers/jungle-digest.js +221 -0
- package/dist/src/importers/jungle-store.d.ts +45 -0
- package/dist/src/importers/jungle-store.js +95 -0
- package/dist/src/importers/jungle.d.ts +54 -0
- package/dist/src/importers/jungle.js +134 -0
- package/dist/src/indexing/index.d.ts +7 -0
- package/dist/src/indexing/index.js +31 -8
- package/dist/src/indexing/prune-run.d.ts +22 -0
- package/dist/src/indexing/prune-run.js +67 -0
- package/dist/src/indexing/prune.d.ts +30 -0
- package/dist/src/indexing/prune.js +95 -0
- package/dist/src/indexing/runner.js +5 -5
- package/dist/src/inference/embedding/index.d.ts +25 -0
- package/dist/src/inference/embedding/index.js +11 -0
- package/dist/src/models/artifacts.js +175 -33
- package/dist/src/portability/database.js +3 -0
- package/dist/src/publish/component.d.ts +8 -0
- package/dist/src/publish/component.js +114 -0
- package/dist/src/publish/feed.d.ts +24 -0
- package/dist/src/publish/feed.js +77 -0
- package/dist/src/publish/identity.d.ts +6 -0
- package/dist/src/publish/identity.js +68 -0
- package/dist/src/publish/index.d.ts +34 -0
- package/dist/src/publish/index.js +137 -0
- package/dist/src/publish/projection.d.ts +29 -0
- package/dist/src/publish/projection.js +43 -0
- package/dist/src/search/hybrid.js +2 -2
- package/dist/src/search/lexical.d.ts +11 -0
- package/dist/src/search/lexical.js +6 -0
- package/dist/src/search/query.d.ts +7 -3
- package/dist/src/search/query.js +52 -33
- package/dist/src/search/vector.d.ts +1 -1
- package/dist/src/search/vector.js +6 -5
- package/dist/src/store/changes.d.ts +42 -0
- package/dist/src/store/changes.js +178 -0
- package/dist/src/store/foundation.d.ts +7 -0
- package/dist/src/store/foundation.js +7 -0
- package/dist/src/store/index.d.ts +69 -0
- package/dist/src/store/index.js +134 -51
- package/dist/src/worker/runtime-gc.d.ts +30 -0
- package/dist/src/worker/runtime-gc.js +296 -0
- package/native/runtime-manifest.json +4 -4
- package/package.json +1 -1
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { closeSync, constants, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { digest, pathExists, regularFile, safeDirectory, syncDirectory, verifyFile } from '../store/files.js';
|
|
5
|
+
import { StoreError } from '../store/types.js';
|
|
6
|
+
import { writeAll } from '../portability/files.js';
|
|
7
|
+
import { baseUrl, buildSite, manifestPath, signaturePath } from './feed.js';
|
|
8
|
+
import { publishIdentity } from './identity.js';
|
|
9
|
+
export { buildSite, canonical, xml, feedLimit } from './feed.js';
|
|
10
|
+
export const publishMarker = '.sponge-publish', markerBody = 'sponge-publish-v1\n';
|
|
11
|
+
const fail = (code = 'invalid-input') => { throw new StoreError(code); };
|
|
12
|
+
/** Drop entries listed fewer than `days` days ago, so listing dates do not reveal what is read right now. */
|
|
13
|
+
export function minAge(entries, days = 0, now = new Date()) {
|
|
14
|
+
if (!Number.isSafeInteger(days) || days < 0 || days > 3650)
|
|
15
|
+
fail();
|
|
16
|
+
if (!days)
|
|
17
|
+
return entries;
|
|
18
|
+
const cutoff = new Date(now.getTime() - days * 86_400_000).toISOString().slice(0, 10);
|
|
19
|
+
return entries.filter(e => e.listedOn <= cutoff);
|
|
20
|
+
}
|
|
21
|
+
/** Files a previous publish wrote, or a refusal: an unmarked directory or unlisted files are not ours to replace. */
|
|
22
|
+
function previousOutput(target, stat) {
|
|
23
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
24
|
+
fail('integrity');
|
|
25
|
+
const marker = join(target, publishMarker);
|
|
26
|
+
if (!pathExists(marker))
|
|
27
|
+
fail();
|
|
28
|
+
regularFile(marker);
|
|
29
|
+
verifyFile(marker, digest(markerBody), Buffer.byteLength(markerBody));
|
|
30
|
+
let listed;
|
|
31
|
+
try {
|
|
32
|
+
const manifest = join(target, manifestPath);
|
|
33
|
+
regularFile(manifest);
|
|
34
|
+
listed = new Set(JSON.parse(readFileSync(manifest, 'utf8')).files.map(f => f.path));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return fail();
|
|
38
|
+
}
|
|
39
|
+
for (const path of [manifestPath, signaturePath, publishMarker])
|
|
40
|
+
listed.add(path);
|
|
41
|
+
const walk = (dir) => { for (const name of readdirSync(dir)) {
|
|
42
|
+
const path = join(dir, name), s = lstatSync(path), rel = relative(target, path).split(sep).join('/');
|
|
43
|
+
if (s.isSymbolicLink())
|
|
44
|
+
fail('integrity');
|
|
45
|
+
if (s.isDirectory())
|
|
46
|
+
walk(path);
|
|
47
|
+
else if (!s.isFile() || !listed.has(rel))
|
|
48
|
+
fail();
|
|
49
|
+
} };
|
|
50
|
+
walk(target);
|
|
51
|
+
}
|
|
52
|
+
function writeTree(root, files) {
|
|
53
|
+
mkdirSync(root, { mode: 0o755 });
|
|
54
|
+
const dirs = new Set([root]);
|
|
55
|
+
for (const [path, bytes] of [...files, [publishMarker, Buffer.from(markerBody)]]) {
|
|
56
|
+
const target = join(root, ...path.split('/')), dir = dirname(target);
|
|
57
|
+
if (!dirs.has(dir)) {
|
|
58
|
+
mkdirSync(dir, { mode: 0o755 });
|
|
59
|
+
dirs.add(dir);
|
|
60
|
+
}
|
|
61
|
+
const fd = openSync(target, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o644);
|
|
62
|
+
try {
|
|
63
|
+
writeAll(fd, bytes);
|
|
64
|
+
fsyncSync(fd);
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
closeSync(fd);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
for (const dir of [...dirs].reverse())
|
|
71
|
+
syncDirectory(dir);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Write the signed static site for the public projection. The site is built in a temporary sibling and renamed
|
|
75
|
+
* into place; an existing directory is replaced only when it is a previous publish output with no foreign files.
|
|
76
|
+
*/
|
|
77
|
+
export function publishSite(source, options) {
|
|
78
|
+
const output = options.output;
|
|
79
|
+
if (!isAbsolute(output) || resolve(output) !== output || output === parse(output).root)
|
|
80
|
+
fail();
|
|
81
|
+
let parent;
|
|
82
|
+
try {
|
|
83
|
+
parent = safeDirectory(dirname(output));
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (error.code === 'ENOENT')
|
|
87
|
+
fail();
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
const target = join(parent, basename(output)), library = safeDirectory(source.root), inside = relative(library, target);
|
|
91
|
+
if (!inside || !inside.startsWith('..') && !isAbsolute(inside) || !relative(target, library).startsWith('..'))
|
|
92
|
+
fail();
|
|
93
|
+
const title = options.title ?? 'Reading list';
|
|
94
|
+
if (!title.trim() || title.length > 200 || /[\u0000-\u001f\u007f]/.test(title))
|
|
95
|
+
fail();
|
|
96
|
+
const base = options.baseUrl === undefined ? undefined : baseUrl(options.baseUrl);
|
|
97
|
+
let previous;
|
|
98
|
+
try {
|
|
99
|
+
previous = lstatSync(target);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (error.code !== 'ENOENT')
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
if (previous)
|
|
106
|
+
previousOutput(target, previous);
|
|
107
|
+
const projection = source.publicProjection(), entries = minAge(projection.entries, options.minAgeDays, options.now), identity = publishIdentity(library);
|
|
108
|
+
const files = buildSite(entries, identity, { title, ...(base ? { baseUrl: base } : {}) }, bytes => identity.sign(bytes));
|
|
109
|
+
const id = randomUUID(), temp = join(parent, `.${basename(target)}.${id}.tmp`);
|
|
110
|
+
try {
|
|
111
|
+
writeTree(temp, files);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
rmSync(temp, { recursive: true, force: true });
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
if (previous) {
|
|
118
|
+
const old = join(parent, `.${basename(target)}.${id}.old`);
|
|
119
|
+
renameSync(target, old);
|
|
120
|
+
try {
|
|
121
|
+
renameSync(temp, target);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
renameSync(old, target);
|
|
125
|
+
rmSync(temp, { recursive: true, force: true });
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
const moved = lstatSync(old);
|
|
129
|
+
if (moved.dev !== previous.dev || moved.ino !== previous.ino)
|
|
130
|
+
fail('integrity');
|
|
131
|
+
rmSync(old, { recursive: true });
|
|
132
|
+
}
|
|
133
|
+
else
|
|
134
|
+
renameSync(temp, target);
|
|
135
|
+
syncDirectory(parent);
|
|
136
|
+
return { output: target, entries: entries.length, held: projection.entries.length - entries.length, skipped: projection.skipped.length, files: files.size, publicKey: identity.publicKey };
|
|
137
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
/** One public list entry. Metadata only: no captured text, paths, account realms or local fingerprints. */
|
|
3
|
+
export interface PublicEntry {
|
|
4
|
+
uri: string;
|
|
5
|
+
aliases: string[];
|
|
6
|
+
kind: string;
|
|
7
|
+
title: string;
|
|
8
|
+
authors?: string;
|
|
9
|
+
published?: string;
|
|
10
|
+
site?: string;
|
|
11
|
+
listedOn: string;
|
|
12
|
+
digest?: {
|
|
13
|
+
lead?: string;
|
|
14
|
+
ideas: {
|
|
15
|
+
claim: string;
|
|
16
|
+
detail: string;
|
|
17
|
+
kind?: string;
|
|
18
|
+
quotes: string[];
|
|
19
|
+
}[];
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/** What `sponge publish` would publish now, newest first. Items without a resolvable URI are listed as skipped. */
|
|
23
|
+
export declare function publicProjection(db: DatabaseSync): {
|
|
24
|
+
entries: PublicEntry[];
|
|
25
|
+
skipped: {
|
|
26
|
+
itemId: string;
|
|
27
|
+
reason: string;
|
|
28
|
+
}[];
|
|
29
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { itemUris, visibility } from '../identity/visibility.js';
|
|
2
|
+
import { acceptedDigest, currentDigest } from '../digest/store.js';
|
|
3
|
+
import { currentEditorial, publicEditorial } from '../digest/editorial.js';
|
|
4
|
+
/** The first selected (or unmarked) claim among `fields`; string lists are joined. */
|
|
5
|
+
function selectedClaim(db, revisionId, fields) {
|
|
6
|
+
for (const field of fields) {
|
|
7
|
+
const r = db.prepare('SELECT value_json FROM metadata_claims WHERE revision_id=? AND field=? AND selected IS NOT 0 ORDER BY selected IS NOT 1,rowid LIMIT 1').get(revisionId, field);
|
|
8
|
+
const raw = r ? JSON.parse(String(r.value_json)) : undefined, v = Array.isArray(raw) && raw.every(x => typeof x === 'string') ? raw.join(', ') : raw;
|
|
9
|
+
if (typeof v === 'string' && v.trim() && v.length <= 1024)
|
|
10
|
+
return v.trim();
|
|
11
|
+
}
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The public digest: the accepted fields of the current Digest v1, else the public subset of an accepted editorial
|
|
16
|
+
* note (lead, claims, verbatim quotes within the v1 budget). Never both, and never the note's other content.
|
|
17
|
+
*/
|
|
18
|
+
function publicDigest(db, id) { const d = currentDigest(db, id); if (d)
|
|
19
|
+
return acceptedDigest(d); const e = currentEditorial(db, id); return e && publicEditorial(db, e); }
|
|
20
|
+
/** What `sponge publish` would publish now, newest first. Items without a resolvable URI are listed as skipped. */
|
|
21
|
+
export function publicProjection(db) {
|
|
22
|
+
const entries = [], skipped = [];
|
|
23
|
+
for (const item of db.prepare('SELECT id,kind,title,current_revision,created_at FROM items WHERE deleted_at IS NULL ORDER BY created_at DESC,id').all()) {
|
|
24
|
+
const id = String(item.id);
|
|
25
|
+
if (visibility(db, id).effective !== 'public')
|
|
26
|
+
continue;
|
|
27
|
+
if (!item.current_revision) {
|
|
28
|
+
skipped.push({ itemId: id, reason: 'not-captured' });
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const uris = itemUris(db, id).filter(u => u.resolvable), primary = uris.find(u => u.role === 'primary') ?? uris[0];
|
|
32
|
+
if (!primary) {
|
|
33
|
+
skipped.push({ itemId: id, reason: 'no-public-uri' });
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const revision = String(item.current_revision), authors = selectedClaim(db, revision, ['authors', 'author']), published = selectedClaim(db, revision, ['publishedAt', 'published']), accepted = publicDigest(db, id);
|
|
37
|
+
let site;
|
|
38
|
+
if (/^https?:/.test(primary.uri))
|
|
39
|
+
site = new URL(primary.uri).hostname.replace(/^www\./, '');
|
|
40
|
+
entries.push({ uri: primary.uri, aliases: uris.filter(u => u !== primary).map(u => u.uri), kind: String(item.kind), title: String(item.title), ...(authors ? { authors } : {}), ...(published ? { published } : {}), ...(site ? { site } : {}), listedOn: String(item.created_at).slice(0, 10), ...(accepted ? { digest: accepted } : {}) });
|
|
41
|
+
}
|
|
42
|
+
return { entries, skipped };
|
|
43
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { chunkDocument } from '../indexing/index.js';
|
|
2
|
+
import { lexicalSearch } from './lexical.js';
|
|
2
3
|
import { pinSearchGeneration, vectorSearch } from './vector.js';
|
|
3
|
-
function lexical(db, query, limit) { const literal = '"' + query.replaceAll('"', '""') + '"'; return db.prepare('SELECT i.id AS itemId,i.title,i.current_revision AS 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, limit); }
|
|
4
4
|
/** Reciprocal rank fusion after per-channel item collapsing; chunks cannot amplify rank. */
|
|
5
5
|
export function fuse(lexicalHits, semanticHits, limit) {
|
|
6
6
|
const hits = new Map();
|
|
@@ -106,7 +106,7 @@ export async function hybridSearch(db, query, options = {}) {
|
|
|
106
106
|
warnings.push('semantic-unavailable');
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
|
-
const result = { hits: fuse(
|
|
109
|
+
const result = { hits: fuse(lexicalSearch(db, query, Math.min(500, limit * 5)), semantic, limit), warnings, generation: pin };
|
|
110
110
|
projectAnchors(db, result.hits);
|
|
111
111
|
db.exec('COMMIT');
|
|
112
112
|
return result;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
export interface LexicalHit {
|
|
3
|
+
itemId: string;
|
|
4
|
+
revisionId: string;
|
|
5
|
+
title: string;
|
|
6
|
+
}
|
|
7
|
+
/** Contentless FTS rows are keyed by readable_fts_rows.id; items.rowid is not VACUUM-stable. Callers own the transaction. */
|
|
8
|
+
export declare function indexReadable(db: DatabaseSync, itemId: string, revisionId: string, title: string, markdown: string): void;
|
|
9
|
+
export declare function unindexReadable(db: DatabaseSync, itemId: string): void;
|
|
10
|
+
/** Literal phrase, bm25 rank then item id; excludes tombstones and superseded revisions. */
|
|
11
|
+
export declare function lexicalSearch(db: DatabaseSync, query: string, limit: number): LexicalHit[];
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Contentless FTS rows are keyed by readable_fts_rows.id; items.rowid is not VACUUM-stable. Callers own the transaction. */
|
|
2
|
+
export function indexReadable(db, itemId, revisionId, title, markdown) { unindexReadable(db, itemId); const row = db.prepare('INSERT INTO readable_fts_rows(item_id,revision_id) VALUES(?,?) RETURNING id').get(itemId, revisionId); db.prepare('INSERT INTO readable_fts(rowid,title,markdown) VALUES(?,?,?)').run(row.id, title, markdown); }
|
|
3
|
+
export function unindexReadable(db, itemId) { const row = db.prepare('DELETE FROM readable_fts_rows WHERE item_id=? RETURNING id').get(itemId); if (row)
|
|
4
|
+
db.prepare('DELETE FROM readable_fts WHERE rowid=?').run(row.id); }
|
|
5
|
+
/** Literal phrase, bm25 rank then item id; excludes tombstones and superseded revisions. */
|
|
6
|
+
export function lexicalSearch(db, query, limit) { const literal = '"' + query.replaceAll('"', '""') + '"'; return db.prepare('SELECT i.id AS itemId,i.title,i.current_revision AS revisionId FROM readable_fts f JOIN readable_fts_rows m ON m.id=f.rowid JOIN items i ON i.id=m.item_id WHERE readable_fts MATCH ? AND i.deleted_at IS NULL AND i.current_revision=m.revision_id ORDER BY rank,i.id LIMIT ?').all(literal, limit); }
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import type { LibraryStore } from '../store/index.js';
|
|
2
2
|
import { embedBatch, type EmbeddingRuntime } from '../inference/embedding/index.js';
|
|
3
3
|
import type { QueryEmbedder } from './hybrid.js';
|
|
4
|
-
|
|
5
|
-
export declare function createQueryEmbedder(store: LibraryStore, runtime: EmbeddingRuntime, options?: {
|
|
4
|
+
type EmbedOptions = {
|
|
6
5
|
embed?: typeof embedBatch;
|
|
7
6
|
onReady?: (context: {
|
|
8
7
|
attemptId: string;
|
|
9
8
|
ownerDirectory: string;
|
|
10
9
|
scratchRoot: string;
|
|
11
10
|
}) => Promise<void>;
|
|
12
|
-
}
|
|
11
|
+
};
|
|
12
|
+
/** Queries mutate coordination journals only, never library content or job tables. */
|
|
13
|
+
export declare function createQueryEmbedder(store: LibraryStore, runtime: EmbeddingRuntime, options?: EmbedOptions): QueryEmbedder;
|
|
14
|
+
/** 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. */
|
|
15
|
+
export declare function createTextEmbedder(store: LibraryStore, runtime: EmbeddingRuntime, options?: EmbedOptions): (texts: string[], signal: AbortSignal) => Promise<number[][]>;
|
|
16
|
+
export {};
|
package/dist/src/search/query.js
CHANGED
|
@@ -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
|
-
|
|
47
|
+
const space = resolveIndexSpace(runtime.helperSha256, pin.fingerprint);
|
|
48
|
+
if (!space || space.dimensions !== pin.dimensions)
|
|
14
49
|
throw new EmbeddingError('integrity');
|
|
15
|
-
const
|
|
16
|
-
|
|
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,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
|
-
|
|
17
|
-
|
|
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,
|
|
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';
|