@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
@@ -62,6 +62,9 @@ export function neutralizeRestoredDatabase(path) {
62
62
  db.exec("UPDATE index_jobs SET state='failed',reason='restore-reconciliation-required' WHERE state NOT IN ('succeeded','failed','cancelled')");
63
63
  db.prepare("UPDATE index_attempts SET stage='restored-unexecuted',cleanup='settled',finished_at=COALESCE(finished_at,?) WHERE cleanup='pending' OR finished_at IS NULL").run(new Date().toISOString());
64
64
  }
65
+ // A restored copy is a new device of the same library: a fresh device id keeps its HLCs distinct; clock_ms only moves forward.
66
+ if (inspected.schemaVersion >= 5)
67
+ db.exec('UPDATE device SET device_id=lower(hex(randomblob(16))) WHERE id=1');
65
68
  db.exec('COMMIT; PRAGMA journal_mode=DELETE');
66
69
  return { incarnation, heldJobs: Number(held.changes) };
67
70
  }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * `<sponge-list src="feed.json" limit="20" verify>`: a dependency-free custom element. It builds DOM from a
3
+ * fixed tag set with textContent only and links only http(s) URLs. `verify` checks the Ed25519-signed manifest
4
+ * with WebCrypto first; `verify="KEY"` pins the publisher's key. The source stays ASCII so any charset serves it.
5
+ */
6
+ export declare const componentSource: string;
7
+ /** A complete static page around the component; it carries the feed title only. */
8
+ export declare function indexHtml(title: string): string;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * `<sponge-list src="feed.json" limit="20" verify>`: a dependency-free custom element. It builds DOM from a
3
+ * fixed tag set with textContent only and links only http(s) URLs. `verify` checks the Ed25519-signed manifest
4
+ * with WebCrypto first; `verify="KEY"` pins the publisher's key. The source stays ASCII so any charset serves it.
5
+ */
6
+ export const componentSource = String.raw `/* sponge-list: renders a Sponge reading-list feed. */
7
+ (() => {
8
+ 'use strict';
9
+ const web = u => { if (typeof u !== 'string') return null; try { const x = new URL(u); return x.protocol === 'https:' || x.protocol === 'http:' ? x.href : null; } catch (e) { return null; } };
10
+ const str = v => typeof v === 'string' && v.length > 0;
11
+ const obj = v => v && typeof v === 'object' && !Array.isArray(v) ? v : {};
12
+ const list = v => Array.isArray(v) ? v : [];
13
+ /** Feed JSON to a plain node tree: { t: tag, p: part, a: attributes, x: text, c: children }. */
14
+ function view(feed, limit) {
15
+ const items = list(obj(feed).items).filter(i => str(obj(i).title)).slice(0, limit);
16
+ return { t: 'ol', p: 'list', c: items.map(i => {
17
+ const s = obj(i._sponge), d = obj(s.digest), href = web(i.url), day = str(i.date_published) ? i.date_published.slice(0, 10) : null;
18
+ const meta = [str(s.site) ? { t: 'span', p: 'site', x: s.site } : null, day ? { t: 'time', p: 'date', a: { datetime: day }, x: day } : null].filter(Boolean);
19
+ const ideas = list(d.ideas).filter(x => str(obj(x).claim)).map(x => {
20
+ const more = [str(x.detail) ? { t: 'p', p: 'detail', x: x.detail } : null, ...list(x.quotes).filter(str).map(q => ({ t: 'blockquote', p: 'quote', x: q }))].filter(Boolean);
21
+ return { t: 'li', p: 'idea', c: [more.length ? { t: 'details', p: 'idea-body', c: [{ t: 'summary', p: 'claim', x: x.claim }, ...more] } : { t: 'span', p: 'claim', x: x.claim }] };
22
+ });
23
+ return { t: 'li', p: 'item', c: [href ? { t: 'a', p: 'title', a: { href }, x: i.title } : { t: 'span', p: 'title', x: i.title }, ...(meta.length ? [{ t: 'div', p: 'meta', c: meta }] : []), ...(str(d.lead) ? [{ t: 'p', p: 'lead', x: d.lead }] : []), ...(ideas.length ? [{ t: 'ul', p: 'ideas', c: ideas }] : [])] };
24
+ }) };
25
+ }
26
+ const tags = new Set(['ol', 'ul', 'li', 'a', 'span', 'div', 'time', 'p', 'details', 'summary', 'blockquote']);
27
+ /** Only known tags and attributes; text goes through textContent, never markup. */
28
+ function build(doc, n) {
29
+ if (!tags.has(n.t)) throw new Error('tag');
30
+ const e = doc.createElement(n.t), a = obj(n.a);
31
+ e.setAttribute('part', n.p);
32
+ if (n.t === 'a') { const h = web(a.href); if (!h) throw new Error('href'); e.setAttribute('href', h); e.setAttribute('rel', 'noopener noreferrer'); }
33
+ if (n.t === 'time' && str(a.datetime)) e.setAttribute('datetime', a.datetime);
34
+ if (n.x !== undefined) e.textContent = String(n.x); else for (const c of list(n.c)) e.appendChild(build(doc, c));
35
+ return e;
36
+ }
37
+ const bytes64 = s => { const v = String(s).trim().replace(/-/g, '+').replace(/_/g, '/'), b = atob(v + '='.repeat((4 - v.length % 4) % 4)); return Uint8Array.from(b, c => c.charCodeAt(0)); };
38
+ const hex = b => Array.from(new Uint8Array(b), x => x.toString(16).padStart(2, '0')).join('');
39
+ /** True only when the signature over the exact manifest bytes holds and the feed's bytes match its entry. */
40
+ async function verify(subtle, manifestBytes, signature, feedBytes, feedPath, pinned) {
41
+ try { return await check(subtle, manifestBytes, signature, feedBytes, feedPath, pinned); } catch (e) { return false; }
42
+ }
43
+ async function check(subtle, manifestBytes, signature, feedBytes, feedPath, pinned) {
44
+ const m = obj(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(manifestBytes)));
45
+ if (m.algorithm !== 'Ed25519' || !str(m.publicKey) || (pinned && pinned !== m.publicKey)) return false;
46
+ const key = await subtle.importKey('raw', bytes64(m.publicKey), { name: 'Ed25519' }, false, ['verify']);
47
+ if (!await subtle.verify({ name: 'Ed25519' }, key, bytes64(signature), manifestBytes)) return false;
48
+ const entry = list(m.files).find(f => obj(f).path === feedPath);
49
+ return !!entry && entry.bytes === feedBytes.byteLength && entry.sha256 === hex(await subtle.digest('SHA-256', feedBytes));
50
+ }
51
+ const css = ':host{display:block;font:var(--sponge-font,inherit);color:var(--sponge-color,inherit)}' +
52
+ 'ol{list-style:none;margin:0;padding:0;display:grid;gap:var(--sponge-gap,1.25em)}ul{margin:.4em 0 0;padding-left:1.1em}' +
53
+ '[part~=title]{font-weight:600;color:var(--sponge-link,inherit)}[part~=meta]{color:var(--sponge-muted,#666);font-size:.875em}' +
54
+ '[part~=meta]>*+*::before{content:" ' + String.fromCharCode(183) + ' "}[part~=lead]{margin:.4em 0}summary{cursor:pointer}[part~=detail]{margin:.3em 0}' +
55
+ 'blockquote{margin:.3em 0;padding-left:.75em;border-left:2px solid var(--sponge-rule,#ccc);color:var(--sponge-muted,#666)}';
56
+ const Base = typeof HTMLElement === 'undefined' ? class {} : HTMLElement;
57
+ class SpongeList extends Base {
58
+ static get observedAttributes() { return ['src', 'limit', 'verify', 'manifest']; }
59
+ connectedCallback() { this.schedule(); }
60
+ attributeChangedCallback() { this.schedule(); }
61
+ /** Upgrading reports each attribute, then the connection; load once for all of them. */
62
+ schedule() { if (this.pending) return; this.pending = true; queueMicrotask(() => { this.pending = false; if (this.isConnected) this.load(); }); }
63
+ async load() {
64
+ const run = this.run = (this.run || 0) + 1, root = this.shadowRoot || this.attachShadow({ mode: 'open' });
65
+ const fail = reason => { if (run !== this.run) return; root.replaceChildren(); this.dispatchEvent(new CustomEvent('error', { detail: { reason } })); };
66
+ try {
67
+ const get = async u => { const r = await fetch(u, { credentials: 'omit' }); if (!r.ok) throw new Error('fetch'); return new Uint8Array(await r.arrayBuffer()); };
68
+ const src = new URL(this.getAttribute('src') || 'feed.json', document.baseURI), n = Number(this.getAttribute('limit') || 20), limit = Number.isInteger(n) && n > 0 ? Math.min(n, 1000) : 20;
69
+ const body = await get(src), feed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(body));
70
+ if (this.hasAttribute('verify')) {
71
+ const at = this.getAttribute('manifest') || (str(obj(obj(feed)._sponge).manifest) ? feed._sponge.manifest : 'index.json'), manifest = new URL(at, src), dir = new URL('.', manifest).href;
72
+ if (src.search || src.hash || !src.href.startsWith(dir)) return fail('verify');
73
+ const [m, sig] = await Promise.all([get(manifest), get(new URL(manifest.pathname + '.sig', manifest))]);
74
+ if (!await verify(crypto.subtle, m, new TextDecoder().decode(sig), body, src.href.slice(dir.length), this.getAttribute('verify') || null)) return fail('verify');
75
+ }
76
+ if (run !== this.run) return;
77
+ const style = document.createElement('style');
78
+ style.textContent = css;
79
+ root.replaceChildren(style, build(document, view(feed, limit)));
80
+ } catch (e) { fail(e && e.message === 'fetch' ? 'fetch' : 'invalid'); }
81
+ }
82
+ }
83
+ SpongeList.view = view; SpongeList.build = build; SpongeList.verify = verify; SpongeList.safeHref = web;
84
+ if (typeof customElements !== 'undefined' && !customElements.get('sponge-list')) customElements.define('sponge-list', SpongeList);
85
+ })();
86
+ `;
87
+ const html = (s) => s.replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
88
+ /** A complete static page around the component; it carries the feed title only. */
89
+ export function indexHtml(title) {
90
+ return `<!doctype html>
91
+ <html lang="en">
92
+ <head>
93
+ <meta charset="utf-8">
94
+ <meta name="viewport" content="width=device-width, initial-scale=1">
95
+ <title>${html(title)}</title>
96
+ <link rel="alternate" type="application/feed+json" href="feed.json">
97
+ <link rel="alternate" type="application/atom+xml" href="feed.xml">
98
+ <style>
99
+ :root{color-scheme:light dark;--sponge-muted:#6b6b6b}
100
+ @media (prefers-color-scheme:dark){:root{--sponge-muted:#a0a0a0;--sponge-rule:#444}}
101
+ body{margin:0 auto;max-width:42rem;padding:2rem 1rem;font:16px/1.5 system-ui,sans-serif}
102
+ h1{font-size:1.5rem}footer{margin-top:3rem;font-size:.875rem;color:var(--sponge-muted)}
103
+ </style>
104
+ </head>
105
+ <body>
106
+ <h1>${html(title)}</h1>
107
+ <sponge-list src="feed.json" limit="100" verify></sponge-list>
108
+ <noscript><p><a href="feed.json">JSON Feed</a> · <a href="feed.xml">Atom</a></p></noscript>
109
+ <footer><a href="feed.xml">Atom</a> · <a href="feed.json">JSON Feed</a> · <a href="identity.json">Signing key</a></footer>
110
+ <script src="sponge-list.js" defer></script>
111
+ </body>
112
+ </html>
113
+ `;
114
+ }
@@ -0,0 +1,24 @@
1
+ import type { PublicEntry } from '../store/foundation.js';
2
+ /** feed.json and feed.xml carry the newest entries; every entry also lives in its month's shard. */
3
+ export declare const feedLimit = 100;
4
+ export declare const manifestPath = "index.json", signaturePath = "index.json.sig";
5
+ export interface PublicIdentity {
6
+ algorithm: 'Ed25519';
7
+ publicKey: string;
8
+ created: string;
9
+ }
10
+ export interface SiteOptions {
11
+ title: string;
12
+ baseUrl?: string;
13
+ }
14
+ /** Sorted keys, no whitespace, undefined fields dropped: equal values always give equal bytes. */
15
+ export declare function canonical(value: unknown): string;
16
+ /** An https origin and path, with no credentials, query or fragment, ending in `/`. */
17
+ export declare function baseUrl(value: string): string;
18
+ /** XML 1.0 text and attribute escaping; characters XML cannot carry become U+FFFD. */
19
+ export declare function xml(value: string): string;
20
+ /**
21
+ * Every file of the static site, keyed by path, from the projection alone. Pure and deterministic:
22
+ * no clock, no randomness, sorted keys. The manifest lists each file's sha256 and is signed once.
23
+ */
24
+ export declare function buildSite(entries: PublicEntry[], identity: PublicIdentity, options: SiteOptions, sign: (bytes: Uint8Array) => Uint8Array): Map<string, Buffer>;
@@ -0,0 +1,77 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { StoreError } from '../store/types.js';
3
+ import { componentSource, indexHtml } from './component.js';
4
+ /** feed.json and feed.xml carry the newest entries; every entry also lives in its month's shard. */
5
+ export const feedLimit = 100;
6
+ export const manifestPath = 'index.json', signaturePath = 'index.json.sig';
7
+ const about = 'https://github.com/hraness/sponge/blob/main/docs/decisions/publish-feed.md';
8
+ const fail = (code = 'integrity') => { throw new StoreError(code); };
9
+ /** Sorted keys, no whitespace, undefined fields dropped: equal values always give equal bytes. */
10
+ export function canonical(value) {
11
+ if (Array.isArray(value))
12
+ return '[' + value.map(canonical).join(',') + ']';
13
+ if (value && typeof value === 'object')
14
+ return '{' + Object.keys(value).sort().filter(k => value[k] !== undefined).map(k => JSON.stringify(k) + ':' + canonical(value[k])).join(',') + '}';
15
+ if (typeof value === 'number' && !Number.isSafeInteger(value) || !['string', 'number', 'boolean'].includes(typeof value) && value !== null)
16
+ fail();
17
+ return JSON.stringify(value);
18
+ }
19
+ /** An https origin and path, with no credentials, query or fragment, ending in `/`. */
20
+ export function baseUrl(value) {
21
+ let url;
22
+ try {
23
+ url = new URL(value);
24
+ }
25
+ catch { /* rejected below */ }
26
+ if (!url || url.protocol !== 'https:' || url.username || url.password || /[?#]/.test(value) || value.length > 2048)
27
+ fail('invalid-input');
28
+ return url.href.endsWith('/') ? url.href : url.href + '/';
29
+ }
30
+ const web = (uri) => /^https?:\/\//.test(uri);
31
+ const xmlInvalid = /[^\t\n\r -퟿-�\u{10000}-\u{10ffff}]/gu;
32
+ const xmlEntities = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' };
33
+ /** XML 1.0 text and attribute escaping; characters XML cannot carry become U+FFFD. */
34
+ export function xml(value) { return value.replace(xmlInvalid, '�').replace(/[&<>"']/g, c => xmlEntities[c]); }
35
+ const date = (e) => /^\d{4}-\d{2}-\d{2}$/.test(e.listedOn) ? e.listedOn + 'T00:00:00Z' : fail();
36
+ const summaryText = (e) => [e.digest?.lead, ...(e.digest?.ideas ?? []).map(i => '- ' + i.claim)].filter(Boolean).join('\n') || e.title;
37
+ function item(e) {
38
+ return { id: e.uri, url: web(e.uri) ? e.uri : undefined, title: e.title, content_text: summaryText(e), summary: e.digest?.lead, date_published: date(e), authors: e.authors ? [{ name: e.authors }] : undefined,
39
+ _sponge: { uri: e.uri, aliases: e.aliases, kind: e.kind, site: e.site, published: e.published, digest: e.digest } };
40
+ }
41
+ function atom(entries, identity, options) {
42
+ const base = options.baseUrl, updated = entries.length ? date(entries.reduce((a, b) => b.listedOn > a.listedOn ? b : a)) : identity.created + 'T00:00:00Z';
43
+ const lines = ['<?xml version="1.0" encoding="utf-8"?>', '<feed xmlns="http://www.w3.org/2005/Atom">', `<id>tag:sponge.computer,2026:feed/${xml(identity.publicKey)}</id>`, `<title>${xml(options.title)}</title>`, `<updated>${updated}</updated>`, `<author><name>${xml(options.title)}</name></author>`, '<generator>Sponge</generator>',
44
+ ...(base ? [`<link rel="self" type="application/atom+xml" href="${xml(base + 'feed.xml')}"/>`, `<link rel="alternate" type="text/html" href="${xml(base)}"/>`] : [])];
45
+ for (const e of entries)
46
+ lines.push('<entry>', `<id>${xml(e.uri)}</id>`, `<title>${xml(e.title)}</title>`, ...(web(e.uri) ? [`<link rel="alternate" href="${xml(e.uri)}"/>`] : []), `<updated>${date(e)}</updated>`, ...(e.authors ? [`<author><name>${xml(e.authors)}</name></author>`] : []), ...(e.digest?.lead ? [`<summary>${xml(e.digest.lead)}</summary>`] : []), `<content type="text">${xml(summaryText(e))}</content>`, '</entry>');
47
+ return lines.concat('</feed>', '').join('\n');
48
+ }
49
+ /**
50
+ * Every file of the static site, keyed by path, from the projection alone. Pure and deterministic:
51
+ * no clock, no randomness, sorted keys. The manifest lists each file's sha256 and is signed once.
52
+ */
53
+ export function buildSite(entries, identity, options, sign) {
54
+ if (identity.algorithm !== 'Ed25519' || !/^[A-Za-z0-9_-]{43}$/.test(identity.publicKey) || !/^\d{4}-\d{2}-\d{2}$/.test(identity.created))
55
+ fail();
56
+ const base = options.baseUrl, link = (from, to) => base ? base + to : from.includes('/') && to.startsWith(from.slice(0, from.lastIndexOf('/') + 1)) ? to.slice(from.lastIndexOf('/') + 1) : to;
57
+ const months = new Map();
58
+ for (const e of entries) {
59
+ const month = date(e).slice(0, 7);
60
+ months.set(month, [...months.get(month) ?? [], e]);
61
+ }
62
+ const order = [...months.keys()].sort().reverse(), shard = (month) => `items/${month}.json`;
63
+ const signed = { about, schemaVersion: 1, algorithm: identity.algorithm, publicKey: identity.publicKey };
64
+ const feed = (path, list, next, extra) => Buffer.from(canonical({ version: 'https://jsonfeed.org/version/1.1', title: options.title, home_page_url: base, feed_url: base && base + path, next_url: next && link(path, next), items: list.map(item), _sponge: { ...signed, manifest: link(path, manifestPath), ...extra } }) + '\n');
65
+ const files = new Map(), head = entries.slice(0, feedLimit), omitted = entries[feedLimit];
66
+ files.set('feed.json', feed('feed.json', head, omitted && shard(date(omitted).slice(0, 7)), { total: entries.length, shards: order.map(m => ({ month: m, url: link('feed.json', shard(m)), items: months.get(m).length })) }));
67
+ order.forEach((m, i) => files.set(shard(m), feed(shard(m), months.get(m), order[i + 1] && shard(order[i + 1]), { month: m })));
68
+ files.set('feed.xml', Buffer.from(atom(head, identity, options)));
69
+ files.set('identity.json', Buffer.from(canonical({ schemaVersion: 1, algorithm: identity.algorithm, publicKey: identity.publicKey, created: identity.created }) + '\n'));
70
+ files.set('sponge-list.js', Buffer.from(componentSource));
71
+ files.set('index.html', Buffer.from(indexHtml(options.title)));
72
+ const listed = [...files.keys()].sort().map(path => ({ path, bytes: files.get(path).length, sha256: createHash('sha256').update(files.get(path)).digest('hex') }));
73
+ const manifest = Buffer.from(canonical({ ...signed, files: listed }) + '\n');
74
+ files.set(manifestPath, manifest);
75
+ files.set(signaturePath, Buffer.from(Buffer.from(sign(manifest)).toString('base64url') + '\n'));
76
+ return new Map([...files.entries()].sort(([a], [b]) => a < b ? -1 : 1));
77
+ }
@@ -0,0 +1,6 @@
1
+ import type { PublicIdentity } from './feed.js';
2
+ export interface Identity extends PublicIdentity {
3
+ sign(bytes: Uint8Array): Buffer;
4
+ }
5
+ /** The publishing key never leaves the library: `keys/publish-ed25519.json`, mode 0600 inside a 0700 directory. */
6
+ export declare function publishIdentity(root: string): Identity;
@@ -0,0 +1,68 @@
1
+ import { chmodSync, closeSync, constants, fstatSync, fsyncSync, linkSync, openSync, readFileSync, unlinkSync } from 'node:fs';
2
+ import { createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID, sign } from 'node:crypto';
3
+ import { join } from 'node:path';
4
+ import { pathExists, regularFile, safeDirectory, syncDirectory } from '../store/files.js';
5
+ import { StoreError } from '../store/types.js';
6
+ import { writeAll } from '../portability/files.js';
7
+ const fail = () => { throw new StoreError('integrity'); };
8
+ const b64 = (v, n) => typeof v === 'string' && new RegExp(`^[A-Za-z0-9_-]{${n}}$`).test(v) ? v : fail();
9
+ /** The publishing key never leaves the library: `keys/publish-ed25519.json`, mode 0600 inside a 0700 directory. */
10
+ export function publishIdentity(root) {
11
+ const dir = safeDirectory(join(root, 'keys'), true);
12
+ chmodSync(dir, 0o700);
13
+ const path = join(dir, 'publish-ed25519.json');
14
+ if (!pathExists(path))
15
+ create(dir, path);
16
+ return load(path);
17
+ }
18
+ /** Write a temporary file, then hard-link it into place, so a concurrent first publish keeps one key. */
19
+ function create(dir, path) {
20
+ const jwk = generateKeyPairSync('ed25519').privateKey.export({ format: 'jwk' });
21
+ const body = JSON.stringify({ schemaVersion: 1, algorithm: 'Ed25519', created: new Date().toISOString().slice(0, 10), privateKey: { kty: jwk.kty, crv: jwk.crv, x: jwk.x, d: jwk.d } }) + '\n';
22
+ const temp = join(dir, randomUUID() + '.tmp'), fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
23
+ try {
24
+ writeAll(fd, Buffer.from(body));
25
+ fsyncSync(fd);
26
+ }
27
+ finally {
28
+ closeSync(fd);
29
+ }
30
+ try {
31
+ linkSync(temp, path);
32
+ }
33
+ catch (error) {
34
+ if (error.code !== 'EEXIST') {
35
+ unlinkSync(temp);
36
+ throw error;
37
+ }
38
+ }
39
+ unlinkSync(temp);
40
+ syncDirectory(dir);
41
+ }
42
+ function load(path) {
43
+ regularFile(path);
44
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
45
+ let raw;
46
+ try {
47
+ const stat = fstatSync(fd);
48
+ if (!stat.isFile() || (stat.mode & 0o777) !== 0o600 || stat.size > 4096 || stat.uid !== process.getuid?.())
49
+ fail();
50
+ raw = readFileSync(fd, 'utf8');
51
+ }
52
+ finally {
53
+ closeSync(fd);
54
+ }
55
+ let value;
56
+ try {
57
+ value = JSON.parse(raw);
58
+ }
59
+ catch {
60
+ fail();
61
+ }
62
+ if (!value || value.schemaVersion !== 1 || value.algorithm !== 'Ed25519' || typeof value.created !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value.created) || value.privateKey?.kty !== 'OKP' || value.privateKey?.crv !== 'Ed25519')
63
+ fail();
64
+ const x = b64(value.privateKey.x, 43), key = createPrivateKey({ key: { kty: 'OKP', crv: 'Ed25519', x, d: b64(value.privateKey.d, 43) }, format: 'jwk' });
65
+ if (createPublicKey(key).export({ format: 'jwk' }).x !== x)
66
+ fail();
67
+ return { algorithm: 'Ed25519', created: value.created, publicKey: x, sign: bytes => sign(null, bytes, key) };
68
+ }
@@ -0,0 +1,34 @@
1
+ import type { PublicEntry } from '../store/foundation.js';
2
+ export { buildSite, canonical, xml, feedLimit } from './feed.js';
3
+ export declare const publishMarker = ".sponge-publish", markerBody = "sponge-publish-v1\n";
4
+ export interface PublishOptions {
5
+ output: string;
6
+ title?: string;
7
+ baseUrl?: string;
8
+ minAgeDays?: number;
9
+ now?: Date;
10
+ }
11
+ export interface PublishSource {
12
+ root: string;
13
+ publicProjection(): {
14
+ entries: PublicEntry[];
15
+ skipped: {
16
+ itemId: string;
17
+ reason: string;
18
+ }[];
19
+ };
20
+ }
21
+ /** Drop entries listed fewer than `days` days ago, so listing dates do not reveal what is read right now. */
22
+ export declare function minAge(entries: PublicEntry[], days?: number, now?: Date): PublicEntry[];
23
+ /**
24
+ * Write the signed static site for the public projection. The site is built in a temporary sibling and renamed
25
+ * into place; an existing directory is replaced only when it is a previous publish output with no foreign files.
26
+ */
27
+ export declare function publishSite(source: PublishSource, options: PublishOptions): {
28
+ output: string;
29
+ entries: number;
30
+ held: number;
31
+ skipped: number;
32
+ files: number;
33
+ publicKey: string;
34
+ };
@@ -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(lexical(db, query, Math.min(500, limit * 5)), semantic, limit), warnings, generation: pin };
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
- /** Queries mutate coordination journals only, never library content or job tables. */
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
- }): QueryEmbedder;
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 {};