@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,118 @@
|
|
|
1
|
+
import { foldText, locateAll, textQuote, words } from '../annotations/quote.js';
|
|
2
|
+
/**
|
|
3
|
+
* Digest v1: a one-sentence gist and 3-5 ideas, each a claim with detail and 1-2 verbatim quotes.
|
|
4
|
+
* The model proposes; this validator decides. Nothing here calls a model.
|
|
5
|
+
*/
|
|
6
|
+
export const ideaKinds = ['finding', 'argument', 'method', 'evidence', 'recommendation', 'counterpoint'];
|
|
7
|
+
export const forms = ['article', 'video', 'paper', 'thread', 'pdf'];
|
|
8
|
+
export const digestLimits = { leadChars: 155, contextWords: 50, minIdeas: 3, maxIdeas: 5, claimWords: 20, detailWords: 60, detailSentences: 3, maxQuotesPerIdea: 2, minQuoteWords: 4, quoteWords: 25, totalQuoteWords: 150, sourceQuoteShare: 0.1, maxTags: 3 };
|
|
9
|
+
const object = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
10
|
+
const keys = (v, allowed) => Object.keys(v).every(k => allowed.includes(k));
|
|
11
|
+
const str = (v, max = 2000) => typeof v === 'string' && v.trim().length > 0 && v.length <= max && !/[\u0000-\u0008\u000b-\u001f\u007f]/.test(v);
|
|
12
|
+
/** Sentence terminators followed by more text; abbreviations are rare enough in a 155-character lead to reject. */
|
|
13
|
+
const sentences = (v) => foldText(v).split(/(?<=[.!?])\s+(?=\S)/).length;
|
|
14
|
+
/** Parse an untrusted model output into a DigestV1 shape, or report why not. */
|
|
15
|
+
export function parseDigest(value) {
|
|
16
|
+
const issues = [], bad = (path, code = 'shape') => { issues.push({ path, code }); };
|
|
17
|
+
if (!object(value) || !keys(value, ['schemaVersion', 'form', 'gist', 'ideas', 'tags']) || value.schemaVersion !== 1)
|
|
18
|
+
return { issues: [{ path: '', code: 'shape' }] };
|
|
19
|
+
if (!forms.includes(value.form))
|
|
20
|
+
bad('form');
|
|
21
|
+
const gist = value.gist;
|
|
22
|
+
if (!object(gist) || !keys(gist, ['lead', 'context']) || !str(gist.lead) || gist.context !== undefined && !str(gist.context))
|
|
23
|
+
bad('gist');
|
|
24
|
+
if (!Array.isArray(value.ideas) || value.ideas.length > 8)
|
|
25
|
+
bad('ideas');
|
|
26
|
+
else
|
|
27
|
+
value.ideas.forEach((idea, i) => {
|
|
28
|
+
const at = `ideas.${i}`;
|
|
29
|
+
if (!object(idea) || !keys(idea, ['claim', 'detail', 'kind', 'voice', 'quotes']) || !str(idea.claim) || !str(idea.detail) || !ideaKinds.includes(idea.kind))
|
|
30
|
+
return bad(at);
|
|
31
|
+
const voice = idea.voice;
|
|
32
|
+
if (!object(voice) || !(voice.type === 'author' && keys(voice, ['type']) || (voice.type === 'speaker' || voice.type === 'reported') && keys(voice, ['type', 'name']) && str(voice.name, 200)))
|
|
33
|
+
bad(at + '.voice');
|
|
34
|
+
if (!Array.isArray(idea.quotes) || idea.quotes.length > 4)
|
|
35
|
+
return bad(at + '.quotes');
|
|
36
|
+
idea.quotes.forEach((quote, j) => {
|
|
37
|
+
if (!object(quote) || !keys(quote, ['text', 'speaker', 'locator']) || !str(quote.text, 600) || quote.speaker !== undefined && !str(quote.speaker, 200))
|
|
38
|
+
return bad(`${at}.quotes.${j}`);
|
|
39
|
+
const loc = quote.locator;
|
|
40
|
+
if (loc !== undefined && !(object(loc) && (loc.type === 'timestamp' && keys(loc, ['type', 'ms']) && Number.isSafeInteger(loc.ms) && loc.ms >= 0 || loc.type === 'page' && keys(loc, ['type', 'page']) && Number.isSafeInteger(loc.page) && loc.page >= 1)))
|
|
41
|
+
bad(`${at}.quotes.${j}.locator`);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
if (!Array.isArray(value.tags) || value.tags.some(t => !str(t, 64)))
|
|
45
|
+
bad('tags');
|
|
46
|
+
return issues.length ? { issues } : { digest: value, issues };
|
|
47
|
+
}
|
|
48
|
+
/** Check every v1 rule that code can decide against the captured Markdown. */
|
|
49
|
+
export function checkDigest(digest, source, options = {}) {
|
|
50
|
+
const issues = [], located = [], issue = (path, code) => { issues.push({ path, code }); }, L = digestLimits;
|
|
51
|
+
if (digest.gist.lead.length > L.leadChars)
|
|
52
|
+
issue('gist.lead', 'too-long');
|
|
53
|
+
if (sentences(digest.gist.lead) !== 1)
|
|
54
|
+
issue('gist.lead', 'one-sentence');
|
|
55
|
+
if (digest.gist.context !== undefined && words(digest.gist.context) > L.contextWords)
|
|
56
|
+
issue('gist.context', 'too-long');
|
|
57
|
+
if (digest.ideas.length < L.minIdeas || digest.ideas.length > L.maxIdeas)
|
|
58
|
+
issue('ideas', 'count');
|
|
59
|
+
const claims = new Set();
|
|
60
|
+
let quoteWords = 0;
|
|
61
|
+
digest.ideas.forEach((idea, i) => {
|
|
62
|
+
const at = `ideas.${i}`;
|
|
63
|
+
if (words(idea.claim) > L.claimWords)
|
|
64
|
+
issue(at + '.claim', 'too-long');
|
|
65
|
+
if (sentences(idea.claim) !== 1)
|
|
66
|
+
issue(at + '.claim', 'one-sentence');
|
|
67
|
+
if (claims.has(foldText(idea.claim).toLowerCase()))
|
|
68
|
+
issue(at + '.claim', 'duplicate');
|
|
69
|
+
claims.add(foldText(idea.claim).toLowerCase());
|
|
70
|
+
if (words(idea.detail) > L.detailWords)
|
|
71
|
+
issue(at + '.detail', 'too-long');
|
|
72
|
+
if (sentences(idea.detail) > L.detailSentences)
|
|
73
|
+
issue(at + '.detail', 'too-many-sentences');
|
|
74
|
+
if (idea.quotes.length < 1 || idea.quotes.length > L.maxQuotesPerIdea)
|
|
75
|
+
issue(at + '.quotes', 'count');
|
|
76
|
+
idea.quotes.forEach((quote, j) => {
|
|
77
|
+
const path = `${at}.quotes.${j}`, count = words(quote.text);
|
|
78
|
+
quoteWords += count;
|
|
79
|
+
if (count > L.quoteWords)
|
|
80
|
+
issue(path, 'too-long');
|
|
81
|
+
if (count < L.minQuoteWords)
|
|
82
|
+
issue(path, 'too-short');
|
|
83
|
+
if (idea.voice.type !== 'author' && !quote.speaker)
|
|
84
|
+
issue(path, 'speaker-required');
|
|
85
|
+
const matches = locateAll(source, quote.text);
|
|
86
|
+
if (!matches.length)
|
|
87
|
+
return issue(path, 'not-verbatim');
|
|
88
|
+
// Prefer the first occurrence not already claimed by an earlier quote.
|
|
89
|
+
const match = matches.find(m => !located.some(q => m.start < q.anchor.end && q.anchor.start < m.end));
|
|
90
|
+
if (!match)
|
|
91
|
+
return issue(path, 'overlap');
|
|
92
|
+
located.push({ idea: i, quote: j, anchor: textQuote(source, match.start, match.end) });
|
|
93
|
+
if (options.supports && options.supports(idea.claim, quote.text) < (options.supportThreshold ?? 0.5))
|
|
94
|
+
issue(path, 'unsupported');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
// Ideas follow the source: each idea's earliest located quote starts no earlier than the previous idea's.
|
|
98
|
+
let previous = -1;
|
|
99
|
+
digest.ideas.forEach((_, i) => {
|
|
100
|
+
const starts = located.filter(q => q.idea === i).map(q => q.anchor.start);
|
|
101
|
+
if (!starts.length)
|
|
102
|
+
return;
|
|
103
|
+
const first = Math.min(...starts);
|
|
104
|
+
if (first < previous)
|
|
105
|
+
issue(`ideas.${i}`, 'out-of-order');
|
|
106
|
+
previous = first;
|
|
107
|
+
});
|
|
108
|
+
if (quoteWords > L.totalQuoteWords)
|
|
109
|
+
issue('ideas', 'quote-budget');
|
|
110
|
+
// The 10% share has a 25-word floor: an exemption so a short source can still carry one full quote.
|
|
111
|
+
if (quoteWords > Math.max(L.quoteWords, Math.floor(words(source) * L.sourceQuoteShare)))
|
|
112
|
+
issue('ideas', 'quote-share');
|
|
113
|
+
if (digest.tags.length < 1 || digest.tags.length > L.maxTags || new Set(digest.tags).size !== digest.tags.length)
|
|
114
|
+
issue('tags', 'count');
|
|
115
|
+
digest.tags.forEach((tag, i) => { if (options.tags ? !options.tags.includes(tag) : !/^[a-z0-9][a-z0-9-]{0,63}$/.test(tag))
|
|
116
|
+
issue(`tags.${i}`, 'vocabulary'); });
|
|
117
|
+
return { ok: issues.length === 0, issues, quotes: located };
|
|
118
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { type DigestOptions, type DigestV1 } from './schema.js';
|
|
3
|
+
import { type ReviewStatus } from '../store/changes.js';
|
|
4
|
+
export type { ReviewStatus };
|
|
5
|
+
/** Per-field review of a digest. Only accepted fields can ever be published. */
|
|
6
|
+
export interface DigestReview {
|
|
7
|
+
gist?: ReviewStatus;
|
|
8
|
+
ideas?: (ReviewStatus | null)[];
|
|
9
|
+
quotes?: (ReviewStatus | null)[][];
|
|
10
|
+
}
|
|
11
|
+
export interface Derivation {
|
|
12
|
+
id: string;
|
|
13
|
+
itemId: string;
|
|
14
|
+
revisionId: string;
|
|
15
|
+
kind: 'digest';
|
|
16
|
+
producer: unknown;
|
|
17
|
+
inputHash: string;
|
|
18
|
+
output: DigestV1;
|
|
19
|
+
review: DigestReview;
|
|
20
|
+
createdAt: string;
|
|
21
|
+
}
|
|
22
|
+
/** A parsed digest serialized in fixed key order, so key order never makes a different object. */
|
|
23
|
+
export declare function canonicalDigest(d: DigestV1): string;
|
|
24
|
+
/** Content address of a derivation: the same output from the same input is the same immutable object on every device. */
|
|
25
|
+
export declare const derivationId: (revisionId: string, kind: string, inputHash: string, output: string) => string;
|
|
26
|
+
/**
|
|
27
|
+
* Store a draft digest only when every v1 rule passes against the revision it was made from.
|
|
28
|
+
* Drafts are immutable data, not change-log entries; a different output is a new object.
|
|
29
|
+
*/
|
|
30
|
+
export declare function putDigest(db: DatabaseSync, input: {
|
|
31
|
+
itemId: string;
|
|
32
|
+
revisionId: string;
|
|
33
|
+
producer: Record<string, unknown>;
|
|
34
|
+
inputHash: string;
|
|
35
|
+
output: unknown;
|
|
36
|
+
options?: DigestOptions;
|
|
37
|
+
}): {
|
|
38
|
+
id: string;
|
|
39
|
+
duplicate: boolean;
|
|
40
|
+
};
|
|
41
|
+
/** The newest digest made from the item's current revision. */
|
|
42
|
+
export declare function currentDigest(db: DatabaseSync, itemId: string): Derivation | undefined;
|
|
43
|
+
export declare function readDigest(db: DatabaseSync, id: string): Derivation | undefined;
|
|
44
|
+
/** The newest stored digest for exactly these inputs, so a rerun reuses it instead of asking the model again. */
|
|
45
|
+
export declare function findDigest(db: DatabaseSync, revisionId: string, inputHash: string): Derivation | undefined;
|
|
46
|
+
/** Review fields of a digest. Each changed path is its own change: `review.gist`, `review.ideas.<i>`, `review.quotes.<i>.<j>`. */
|
|
47
|
+
export declare function reviewDigest(db: DatabaseSync, id: string, review: DigestReview): Derivation;
|
|
48
|
+
/** The accepted subset of a digest. Rejected or unreviewed fields never leave the device. */
|
|
49
|
+
export declare function acceptedDigest(d: Derivation): {
|
|
50
|
+
lead?: string;
|
|
51
|
+
ideas: {
|
|
52
|
+
claim: string;
|
|
53
|
+
detail: string;
|
|
54
|
+
kind: string;
|
|
55
|
+
quotes: string[];
|
|
56
|
+
}[];
|
|
57
|
+
} | undefined;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { checkDigest, parseDigest } from './schema.js';
|
|
2
|
+
import { markdownFor } from '../annotations/store.js';
|
|
3
|
+
import { mutate } from '../store/changes.js';
|
|
4
|
+
import { StoreError } from '../store/types.js';
|
|
5
|
+
import { digest, hash } from '../store/files.js';
|
|
6
|
+
const now = () => new Date().toISOString();
|
|
7
|
+
const fail = (code = 'invalid-input') => { throw new StoreError(code); };
|
|
8
|
+
/** A parsed digest serialized in fixed key order, so key order never makes a different object. */
|
|
9
|
+
export function canonicalDigest(d) {
|
|
10
|
+
return JSON.stringify({ schemaVersion: d.schemaVersion, form: d.form, gist: { lead: d.gist.lead, ...(d.gist.context !== undefined ? { context: d.gist.context } : {}) }, ideas: d.ideas.map(i => ({ claim: i.claim, detail: i.detail, kind: i.kind, voice: i.voice.type === 'author' ? { type: 'author' } : { type: i.voice.type, name: i.voice.name }, quotes: i.quotes.map(q => ({ text: q.text, ...(q.speaker !== undefined ? { speaker: q.speaker } : {}), ...(q.locator ? { locator: q.locator.type === 'timestamp' ? { type: 'timestamp', ms: q.locator.ms } : { type: 'page', page: q.locator.page } } : {}) })) })), tags: [...d.tags] });
|
|
11
|
+
}
|
|
12
|
+
/** Content address of a derivation: the same output from the same input is the same immutable object on every device. */
|
|
13
|
+
export const derivationId = (revisionId, kind, inputHash, output) => digest(JSON.stringify(['derivation-v1', revisionId, kind, inputHash, output]));
|
|
14
|
+
/**
|
|
15
|
+
* Store a draft digest only when every v1 rule passes against the revision it was made from.
|
|
16
|
+
* Drafts are immutable data, not change-log entries; a different output is a new object.
|
|
17
|
+
*/
|
|
18
|
+
export function putDigest(db, input) {
|
|
19
|
+
hash(input.inputHash);
|
|
20
|
+
const producer = JSON.stringify(input.producer);
|
|
21
|
+
if (!producer || Buffer.byteLength(producer) > 4096)
|
|
22
|
+
fail();
|
|
23
|
+
const revision = db.prepare('SELECT item_id FROM revisions WHERE id=?').get(input.revisionId);
|
|
24
|
+
if (revision?.item_id !== input.itemId)
|
|
25
|
+
fail('unavailable');
|
|
26
|
+
const parsed = parseDigest(input.output);
|
|
27
|
+
if (!parsed.digest)
|
|
28
|
+
throw Object.assign(new StoreError('invalid-input'), { issues: parsed.issues });
|
|
29
|
+
const check = checkDigest(parsed.digest, markdownFor(db, input.revisionId), input.options);
|
|
30
|
+
if (!check.ok)
|
|
31
|
+
throw Object.assign(new StoreError('invalid-input'), { issues: check.issues });
|
|
32
|
+
const output = canonicalDigest(parsed.digest);
|
|
33
|
+
if (Buffer.byteLength(output) > 65536)
|
|
34
|
+
fail();
|
|
35
|
+
const id = derivationId(input.revisionId, 'digest', input.inputHash, output);
|
|
36
|
+
if (db.prepare('SELECT 1 FROM derivations WHERE id=?').get(id))
|
|
37
|
+
return { id, duplicate: true };
|
|
38
|
+
db.prepare("INSERT INTO derivations(id,item_id,revision_id,kind,schema_version,producer_json,input_hash,output_json,created_at) VALUES(?,?,?,'digest',1,?,?,?,?)").run(id, input.itemId, input.revisionId, producer, input.inputHash, output, now());
|
|
39
|
+
return { id, duplicate: false };
|
|
40
|
+
}
|
|
41
|
+
function derivationRow(r) { return { id: String(r.id), itemId: String(r.item_id), revisionId: String(r.revision_id), kind: 'digest', producer: JSON.parse(String(r.producer_json)), inputHash: String(r.input_hash), output: JSON.parse(String(r.output_json)), review: JSON.parse(String(r.review_json)), createdAt: String(r.created_at) }; }
|
|
42
|
+
/** The newest digest made from the item's current revision. */
|
|
43
|
+
export function currentDigest(db, itemId) {
|
|
44
|
+
const r = db.prepare("SELECT d.* FROM derivations d JOIN items i ON i.id=d.item_id AND i.current_revision=d.revision_id WHERE d.item_id=? AND d.kind='digest' ORDER BY d.created_at DESC,d.rowid DESC LIMIT 1").get(itemId);
|
|
45
|
+
return r ? derivationRow(r) : undefined;
|
|
46
|
+
}
|
|
47
|
+
export function readDigest(db, id) { const r = db.prepare("SELECT * FROM derivations WHERE id=? AND kind='digest'").get(id); return r ? derivationRow(r) : undefined; }
|
|
48
|
+
/** The newest stored digest for exactly these inputs, so a rerun reuses it instead of asking the model again. */
|
|
49
|
+
export function findDigest(db, revisionId, inputHash) { hash(inputHash); const r = db.prepare("SELECT * FROM derivations WHERE revision_id=? AND kind='digest' AND input_hash=? ORDER BY created_at DESC,rowid DESC LIMIT 1").get(revisionId, inputHash); return r ? derivationRow(r) : undefined; }
|
|
50
|
+
/** Review fields of a digest. Each changed path is its own change: `review.gist`, `review.ideas.<i>`, `review.quotes.<i>.<j>`. */
|
|
51
|
+
export function reviewDigest(db, id, review) {
|
|
52
|
+
const row = db.prepare("SELECT * FROM derivations WHERE id=? AND kind='digest'").get(id) ?? fail('unavailable'), d = derivationRow(row);
|
|
53
|
+
const status = (v) => v === null || v === 'accepted' || v === 'rejected';
|
|
54
|
+
if (typeof review !== 'object' || review === null || Object.keys(review).some(k => !['gist', 'ideas', 'quotes'].includes(k)))
|
|
55
|
+
fail();
|
|
56
|
+
if (review.gist !== undefined && review.gist !== 'accepted' && review.gist !== 'rejected')
|
|
57
|
+
fail();
|
|
58
|
+
if (review.ideas !== undefined && (!Array.isArray(review.ideas) || review.ideas.length !== d.output.ideas.length || !review.ideas.every(status)))
|
|
59
|
+
fail();
|
|
60
|
+
if (review.quotes !== undefined && (!Array.isArray(review.quotes) || review.quotes.length !== d.output.ideas.length || review.quotes.some((q, i) => !Array.isArray(q) || q.length !== d.output.ideas[i].quotes.length || !q.every(status))))
|
|
61
|
+
fail();
|
|
62
|
+
const paths = [];
|
|
63
|
+
if (review.gist !== undefined)
|
|
64
|
+
paths.push(['review.gist', review.gist, d.review.gist ?? null]);
|
|
65
|
+
review.ideas?.forEach((v, i) => paths.push([`review.ideas.${i}`, v, d.review.ideas?.[i] ?? null]));
|
|
66
|
+
review.quotes?.forEach((q, i) => q.forEach((v, j) => paths.push([`review.quotes.${i}.${j}`, v, d.review.quotes?.[i]?.[j] ?? null])));
|
|
67
|
+
for (const [path, next, prior] of paths)
|
|
68
|
+
if (next !== prior)
|
|
69
|
+
mutate(db, 'derivation', id, path, next);
|
|
70
|
+
return derivationRow(db.prepare('SELECT * FROM derivations WHERE id=?').get(id));
|
|
71
|
+
}
|
|
72
|
+
/** The accepted subset of a digest. Rejected or unreviewed fields never leave the device. */
|
|
73
|
+
export function acceptedDigest(d) {
|
|
74
|
+
const ideas = d.output.ideas.flatMap((idea, i) => d.review.ideas?.[i] === 'accepted' ? [{ claim: idea.claim, detail: idea.detail, kind: idea.kind, quotes: idea.quotes.filter((_, j) => d.review.quotes?.[i]?.[j] === 'accepted').map(q => q.text) }] : []);
|
|
75
|
+
const lead = d.review.gist === 'accepted' ? d.output.gist.lead : undefined;
|
|
76
|
+
return lead || ideas.length ? { ...(lead ? { lead } : {}), ideas } : undefined;
|
|
77
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one query-field policy shared by source keys (src/app/source.ts) and shareable URIs (uri.ts).
|
|
3
|
+
* Source keys drop only `campaignFields`; changing that list changes every existing source key.
|
|
4
|
+
* Shareable URIs also drop `clickTrackers` and every credential-like field, so a published URI
|
|
5
|
+
* never carries a capability. See docs/decisions/sync-and-publishing.md.
|
|
6
|
+
*/
|
|
7
|
+
export declare const campaignFields: ReadonlySet<string>;
|
|
8
|
+
export declare const clickTrackers: ReadonlySet<string>;
|
|
9
|
+
/** Credential-like query keys, compared case-insensitively. Presence makes a capture private by default. */
|
|
10
|
+
export declare const secretFields: ReadonlySet<string>;
|
|
11
|
+
export declare const secretPrefixes: readonly string[];
|
|
12
|
+
/** The decoded, lowercased key of one raw `a=b` field, or undefined when it does not decode. */
|
|
13
|
+
export declare function fieldKey(field: string): string | undefined;
|
|
14
|
+
export declare function isSecretField(key: string): boolean;
|
|
15
|
+
/** Filter raw fields: URLSearchParams mutation rewrites unknown signed query bytes. */
|
|
16
|
+
export declare function filterQuery(search: string, drop: (key: string) => boolean): string;
|
|
17
|
+
/**
|
|
18
|
+
* A link that grants access by itself: userinfo, a credential-like query key, or a known
|
|
19
|
+
* anyone-with-the-link path. Deliberately short; documented in the decision record.
|
|
20
|
+
*/
|
|
21
|
+
export declare function isCapabilityUrl(locator: string): boolean;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one query-field policy shared by source keys (src/app/source.ts) and shareable URIs (uri.ts).
|
|
3
|
+
* Source keys drop only `campaignFields`; changing that list changes every existing source key.
|
|
4
|
+
* Shareable URIs also drop `clickTrackers` and every credential-like field, so a published URI
|
|
5
|
+
* never carries a capability. See docs/decisions/sync-and-publishing.md.
|
|
6
|
+
*/
|
|
7
|
+
export const campaignFields = new Set(['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'utm_id']);
|
|
8
|
+
export const clickTrackers = new Set(['fbclid', 'gclid', 'dclid', 'msclkid', 'mc_cid', 'mc_eid', 'igshid']);
|
|
9
|
+
/** Credential-like query keys, compared case-insensitively. Presence makes a capture private by default. */
|
|
10
|
+
export const secretFields = new Set(['token', 'key', 'sig', 'signature', 'auth', 'access_token', 'code', 'rlkey', 'session', 'password', 'secret', 'api_key', 'apikey', 'auth_token', 'id_token', 'refresh_token', 'client_secret']);
|
|
11
|
+
export const secretPrefixes = ['x-amz-', 'x-goog-'];
|
|
12
|
+
/** The decoded, lowercased key of one raw `a=b` field, or undefined when it does not decode. */
|
|
13
|
+
export function fieldKey(field) {
|
|
14
|
+
try {
|
|
15
|
+
return decodeURIComponent(field.split('=', 1)[0].replaceAll('+', ' ')).toLowerCase();
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function isSecretField(key) { return secretFields.has(key) || secretPrefixes.some(p => key.startsWith(p)); }
|
|
22
|
+
/** Filter raw fields: URLSearchParams mutation rewrites unknown signed query bytes. */
|
|
23
|
+
export function filterQuery(search, drop) {
|
|
24
|
+
if (!search)
|
|
25
|
+
return '';
|
|
26
|
+
const fields = search.slice(1).split('&').filter(field => { const key = fieldKey(field); return key === undefined || !drop(key); });
|
|
27
|
+
return fields.length ? '?' + fields.join('&') : '';
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A link that grants access by itself: userinfo, a credential-like query key, or a known
|
|
31
|
+
* anyone-with-the-link path. Deliberately short; documented in the decision record.
|
|
32
|
+
*/
|
|
33
|
+
export function isCapabilityUrl(locator) {
|
|
34
|
+
let url;
|
|
35
|
+
try {
|
|
36
|
+
url = new URL(locator);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
if (!['http:', 'https:'].includes(url.protocol))
|
|
42
|
+
return false;
|
|
43
|
+
if (url.username || url.password)
|
|
44
|
+
return true;
|
|
45
|
+
if (url.search && url.search.slice(1).split('&').some(field => { const key = fieldKey(field); return key !== undefined && isSecretField(key); }))
|
|
46
|
+
return true;
|
|
47
|
+
const host = url.hostname.replace(/^www\./, ''), path = url.pathname;
|
|
48
|
+
// Google Docs, Sheets, Slides, Forms, Drawings and Drive files shared "anyone with the link".
|
|
49
|
+
if ((host === 'docs.google.com' && /^\/(document|spreadsheets|presentation|forms|drawings)\/d\/[^/]+/.test(path) && !/^\/[^/]+\/d\/e\//.test(path) || host === 'drive.google.com' && /^\/(file\/d|drive\/folders)\/[^/]+/.test(path)) && url.searchParams.get('usp') === 'sharing')
|
|
50
|
+
return true;
|
|
51
|
+
// Dropbox shared links are capabilities with or without rlkey.
|
|
52
|
+
if ((host === 'dropbox.com' || host === 'dl.dropboxusercontent.com') && /^\/(s|sh|scl)\//.test(path))
|
|
53
|
+
return true;
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** One shareable identifier. Resolvable URIs may be published; fingerprints and local ids never are by default. */
|
|
2
|
+
export interface ItemUri {
|
|
3
|
+
uri: string;
|
|
4
|
+
role: 'primary' | 'alias';
|
|
5
|
+
resolvable: boolean;
|
|
6
|
+
origin: 'locator' | 'revision';
|
|
7
|
+
}
|
|
8
|
+
/** `capability`: a public-realm or imported link that grants access by itself (see query-policy.ts). */
|
|
9
|
+
export type AccessClass = 'public' | 'account' | 'local' | 'imported' | 'capability';
|
|
10
|
+
export declare const maxUriBytes = 8192;
|
|
11
|
+
/** Same-work identity drops fragments, click trackers and credential-like fields that the source key keeps for capture fidelity. */
|
|
12
|
+
export declare function shareableWebUri(locator: string): string;
|
|
13
|
+
/** Provider identifiers name the same work across URL spellings, hosts and mirrors. */
|
|
14
|
+
export declare function providerUris(href: string): string[];
|
|
15
|
+
export declare function isResolvable(uri: string): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Derive an item's URIs from its first source and, once captured, its source PDF. Total: a
|
|
18
|
+
* locator that cannot be parsed, carries userinfo or is too long yields only `sponge:item/<id>`.
|
|
19
|
+
* A local file never exposes its path: before capture it is `sponge:item/<id>`, afterwards
|
|
20
|
+
* `urn:sha256:<file hash>`, which matches the same file anywhere but is not resolvable.
|
|
21
|
+
*/
|
|
22
|
+
export declare function deriveUris(input: {
|
|
23
|
+
itemId: string;
|
|
24
|
+
locator: string;
|
|
25
|
+
sourcePdfSha256?: string;
|
|
26
|
+
}): ItemUri[];
|
|
27
|
+
/** How the first source was reached decides the default visibility; see docs/decisions/sync-and-publishing.md. */
|
|
28
|
+
export declare function accessClass(realm: string, locator: string): AccessClass;
|
|
29
|
+
/** Public only when the source was public. Imports keep their source's publication decision. */
|
|
30
|
+
export declare function defaultVisibility(access: AccessClass, importedPrivacy?: string): 'public' | 'private';
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { normalizeWebLocator } from '../app/source.js';
|
|
2
|
+
import { clickTrackers, filterQuery, isCapabilityUrl, isSecretField } from './query-policy.js';
|
|
3
|
+
export const maxUriBytes = 8192;
|
|
4
|
+
/** Same-work identity drops fragments, click trackers and credential-like fields that the source key keeps for capture fidelity. */
|
|
5
|
+
export function shareableWebUri(locator) {
|
|
6
|
+
const url = new URL(normalizeWebLocator(locator));
|
|
7
|
+
url.hash = '';
|
|
8
|
+
if (url.search)
|
|
9
|
+
url.search = filterQuery(url.search, key => clickTrackers.has(key) || isSecretField(key));
|
|
10
|
+
return url.href;
|
|
11
|
+
}
|
|
12
|
+
const youtubeId = /^[A-Za-z0-9_-]{11}$/;
|
|
13
|
+
const decoded = (value) => { try {
|
|
14
|
+
return decodeURIComponent(value);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
} };
|
|
19
|
+
/** Provider identifiers name the same work across URL spellings, hosts and mirrors. */
|
|
20
|
+
export function providerUris(href) {
|
|
21
|
+
const url = new URL(href), host = url.hostname.replace(/^(www|m|mobile)\./, ''), parts = url.pathname.split('/').filter(Boolean), out = [];
|
|
22
|
+
if (host === 'youtube.com' || host === 'music.youtube.com') {
|
|
23
|
+
const id = url.pathname === '/watch' ? url.searchParams.get('v') : ['shorts', 'live', 'embed'].includes(parts[0]) ? parts[1] : undefined;
|
|
24
|
+
if (id && youtubeId.test(id))
|
|
25
|
+
out.push('youtube:' + id);
|
|
26
|
+
}
|
|
27
|
+
if (host === 'youtu.be' && youtubeId.test(parts[0] ?? ''))
|
|
28
|
+
out.push('youtube:' + parts[0]);
|
|
29
|
+
if ((host === 'x.com' || host === 'twitter.com') && parts[1] === 'status' && /^\d{1,25}$/.test(parts[2] ?? ''))
|
|
30
|
+
out.push('x:status/' + parts[2]);
|
|
31
|
+
if (host === 'arxiv.org' && ['abs', 'pdf'].includes(parts[0]) && parts[1]) {
|
|
32
|
+
const id = parts.slice(1).join('/').replace(/\.pdf$/, '').replace(/v\d+$/, '');
|
|
33
|
+
if (/^(\d{4}\.\d{4,5}|[a-z-]+(\.[A-Z]{2})?\/\d{7})$/.test(id))
|
|
34
|
+
out.push('arxiv:' + id);
|
|
35
|
+
}
|
|
36
|
+
if ((host === 'doi.org' || host === 'dx.doi.org') && /^10\.\d{4,9}$/.test(parts[0] ?? '') && parts.length > 1) {
|
|
37
|
+
const doi = decoded(parts.join('/'));
|
|
38
|
+
if (doi && !/[\u0000-\u001f\u007f]/.test(doi))
|
|
39
|
+
out.push('doi:' + doi.toLowerCase());
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
export function isResolvable(uri) { return /^(https?:|doi:|arxiv:|isbn:|youtube:|x:)/.test(uri); }
|
|
44
|
+
/**
|
|
45
|
+
* Derive an item's URIs from its first source and, once captured, its source PDF. Total: a
|
|
46
|
+
* locator that cannot be parsed, carries userinfo or is too long yields only `sponge:item/<id>`.
|
|
47
|
+
* A local file never exposes its path: before capture it is `sponge:item/<id>`, afterwards
|
|
48
|
+
* `urn:sha256:<file hash>`, which matches the same file anywhere but is not resolvable.
|
|
49
|
+
*/
|
|
50
|
+
export function deriveUris(input) {
|
|
51
|
+
const out = [], add = (uri, origin) => { if (uri.length >= 3 && Buffer.byteLength(uri) <= maxUriBytes && !out.some(u => u.uri === uri))
|
|
52
|
+
out.push({ uri, role: out.length ? 'alias' : 'primary', resolvable: isResolvable(uri), origin }); };
|
|
53
|
+
if (/^https?:/i.test(input.locator)) {
|
|
54
|
+
let web;
|
|
55
|
+
try {
|
|
56
|
+
web = shareableWebUri(input.locator);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
web = undefined;
|
|
60
|
+
}
|
|
61
|
+
if (web && Buffer.byteLength(web) <= maxUriBytes) {
|
|
62
|
+
add(web, 'locator');
|
|
63
|
+
try {
|
|
64
|
+
for (const uri of providerUris(web))
|
|
65
|
+
add(uri, 'locator');
|
|
66
|
+
}
|
|
67
|
+
catch { /* a provider alias is optional */ }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (input.sourcePdfSha256)
|
|
71
|
+
add('urn:sha256:' + input.sourcePdfSha256, 'revision');
|
|
72
|
+
if (!out.length)
|
|
73
|
+
add('sponge:item/' + input.itemId, 'locator');
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
/** How the first source was reached decides the default visibility; see docs/decisions/sync-and-publishing.md. */
|
|
77
|
+
export function accessClass(realm, locator) {
|
|
78
|
+
if (locator.startsWith('file:'))
|
|
79
|
+
return 'local';
|
|
80
|
+
if (realm.startsWith('connection:'))
|
|
81
|
+
return 'account';
|
|
82
|
+
// A guard on top of the source rule: a link that is itself a credential is never public by default.
|
|
83
|
+
if (isCapabilityUrl(locator))
|
|
84
|
+
return 'capability';
|
|
85
|
+
if (realm.startsWith('import:'))
|
|
86
|
+
return 'imported';
|
|
87
|
+
return realm === '' || realm === 'public' ? 'public' : 'account';
|
|
88
|
+
}
|
|
89
|
+
/** Public only when the source was public. Imports keep their source's publication decision. */
|
|
90
|
+
export function defaultVisibility(access, importedPrivacy) {
|
|
91
|
+
if (access === 'public')
|
|
92
|
+
return 'public';
|
|
93
|
+
return access === 'imported' && importedPrivacy === 'published' ? 'public' : 'private';
|
|
94
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { type AccessClass, type ItemUri } from './uri.js';
|
|
3
|
+
export type Visibility = 'public' | 'private';
|
|
4
|
+
/** Recompute derived URIs; metadata and user URIs are left alone. Idempotent. */
|
|
5
|
+
export declare function syncUris(db: DatabaseSync, itemId: string): void;
|
|
6
|
+
/**
|
|
7
|
+
* Fill URIs for items created before schema 5, inside migration 5. Each item is its own savepoint:
|
|
8
|
+
* one hostile locator leaves that item without URIs instead of making the library unopenable.
|
|
9
|
+
*/
|
|
10
|
+
export declare function backfillUris(db: DatabaseSync): {
|
|
11
|
+
filled: number;
|
|
12
|
+
skipped: number;
|
|
13
|
+
};
|
|
14
|
+
export declare function itemUris(db: DatabaseSync, itemId: string): ItemUri[];
|
|
15
|
+
/** Items sharing any URI with `uri`: the local half of "who else is reading this". */
|
|
16
|
+
export declare function itemsForUri(db: DatabaseSync, uri: string): string[];
|
|
17
|
+
export interface VisibilityState {
|
|
18
|
+
access: AccessClass;
|
|
19
|
+
default: Visibility;
|
|
20
|
+
override: Visibility | null;
|
|
21
|
+
effective: Visibility;
|
|
22
|
+
}
|
|
23
|
+
export declare function visibility(db: DatabaseSync, itemId: string): VisibilityState;
|
|
24
|
+
export declare function setVisibility(db: DatabaseSync, itemId: string, value: Visibility | null): VisibilityState;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { accessClass, defaultVisibility, deriveUris } from './uri.js';
|
|
2
|
+
import { mutate } from '../store/changes.js';
|
|
3
|
+
import { StoreError } from '../store/types.js';
|
|
4
|
+
import { text } from '../store/files.js';
|
|
5
|
+
const fail = (code = 'invalid-input') => { throw new StoreError(code); };
|
|
6
|
+
function firstSource(db, itemId) { return db.prepare('SELECT realm_key,locator FROM source_refs WHERE item_id=? ORDER BY rowid LIMIT 1').get(itemId); }
|
|
7
|
+
/** Recompute derived URIs; metadata and user URIs are left alone. Idempotent. */
|
|
8
|
+
export function syncUris(db, itemId) {
|
|
9
|
+
const source = firstSource(db, itemId);
|
|
10
|
+
if (!source)
|
|
11
|
+
return;
|
|
12
|
+
const pdf = db.prepare("SELECT ra.sha256 FROM items i JOIN revision_assets ra ON ra.revision_id=i.current_revision WHERE i.id=? AND ra.relationship='source-pdf' ORDER BY ra.ordinal LIMIT 1").get(itemId);
|
|
13
|
+
const uris = deriveUris({ itemId, locator: String(source.locator), ...(pdf ? { sourcePdfSha256: String(pdf.sha256) } : {}) });
|
|
14
|
+
const current = db.prepare("SELECT uri,role,resolvable,origin FROM item_uris WHERE item_id=? AND origin IN ('locator','revision') ORDER BY uri").all(itemId).map(r => `${r.uri}|${r.role}|${r.resolvable}|${r.origin}`).join('\n');
|
|
15
|
+
const next = uris.map(u => `${u.uri}|${u.role}|${Number(u.resolvable)}|${u.origin}`).sort().join('\n');
|
|
16
|
+
if (current === next)
|
|
17
|
+
return;
|
|
18
|
+
db.prepare("DELETE FROM item_uris WHERE item_id=? AND origin IN ('locator','revision')").run(itemId);
|
|
19
|
+
// A derived primary replaces any other primary; keep later metadata/user URIs as aliases.
|
|
20
|
+
db.prepare("UPDATE item_uris SET role='alias' WHERE item_id=?").run(itemId);
|
|
21
|
+
for (const u of uris)
|
|
22
|
+
db.prepare('INSERT INTO item_uris VALUES(?,?,?,?,?) ON CONFLICT(item_id,uri) DO UPDATE SET role=excluded.role,resolvable=excluded.resolvable,origin=excluded.origin').run(itemId, u.uri, u.role, Number(u.resolvable), u.origin);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Fill URIs for items created before schema 5, inside migration 5. Each item is its own savepoint:
|
|
26
|
+
* one hostile locator leaves that item without URIs instead of making the library unopenable.
|
|
27
|
+
*/
|
|
28
|
+
export function backfillUris(db) {
|
|
29
|
+
const missing = db.prepare("SELECT i.id FROM items i WHERE NOT EXISTS (SELECT 1 FROM item_uris u WHERE u.item_id=i.id AND u.role='primary') AND EXISTS (SELECT 1 FROM source_refs r WHERE r.item_id=i.id)").all().map(r => String(r.id));
|
|
30
|
+
let skipped = 0;
|
|
31
|
+
for (const id of missing) {
|
|
32
|
+
db.exec('SAVEPOINT backfill_uri');
|
|
33
|
+
try {
|
|
34
|
+
syncUris(db, id);
|
|
35
|
+
db.exec('RELEASE backfill_uri');
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
db.exec('ROLLBACK TO backfill_uri; RELEASE backfill_uri');
|
|
39
|
+
skipped++;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return { filled: missing.length - skipped, skipped };
|
|
43
|
+
}
|
|
44
|
+
export function itemUris(db, itemId) {
|
|
45
|
+
return db.prepare("SELECT uri,role,resolvable,origin FROM item_uris WHERE item_id=? ORDER BY role='alias',uri").all(itemId).map(r => ({ uri: String(r.uri), role: r.role, resolvable: !!r.resolvable, origin: r.origin }));
|
|
46
|
+
}
|
|
47
|
+
/** Items sharing any URI with `uri`: the local half of "who else is reading this". */
|
|
48
|
+
export function itemsForUri(db, uri) {
|
|
49
|
+
text(uri, 8192);
|
|
50
|
+
return db.prepare('SELECT DISTINCT u.item_id FROM item_uris u JOIN items i ON i.id=u.item_id WHERE u.uri=? AND i.deleted_at IS NULL ORDER BY u.item_id').all(uri).map(r => String(r.item_id));
|
|
51
|
+
}
|
|
52
|
+
export function visibility(db, itemId) {
|
|
53
|
+
const item = db.prepare('SELECT visibility,deleted_at FROM items WHERE id=?').get(itemId) ?? fail('unavailable'), source = firstSource(db, itemId);
|
|
54
|
+
const access = source ? accessClass(String(source.realm_key), String(source.locator)) : 'local';
|
|
55
|
+
const reading = db.prepare('SELECT state_json FROM reading_state WHERE item_id=?').get(itemId);
|
|
56
|
+
let importedPrivacy;
|
|
57
|
+
try {
|
|
58
|
+
importedPrivacy = reading ? JSON.parse(String(reading.state_json))?.sourcePrivacy : undefined;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
importedPrivacy = undefined;
|
|
62
|
+
}
|
|
63
|
+
const base = defaultVisibility(access, importedPrivacy), override = (item.visibility ?? null);
|
|
64
|
+
return { access, default: base, override, effective: item.deleted_at ? 'private' : override ?? base };
|
|
65
|
+
}
|
|
66
|
+
export function setVisibility(db, itemId, value) {
|
|
67
|
+
if (value !== null && value !== 'public' && value !== 'private')
|
|
68
|
+
fail();
|
|
69
|
+
const item = db.prepare('SELECT visibility,deleted_at FROM items WHERE id=?').get(itemId);
|
|
70
|
+
if (!item || item.deleted_at)
|
|
71
|
+
fail('unavailable');
|
|
72
|
+
if ((item.visibility ?? null) !== value)
|
|
73
|
+
mutate(db, 'item', itemId, 'visibility', value);
|
|
74
|
+
return visibility(db, itemId);
|
|
75
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { LibraryStore } from '../store/index.js';
|
|
2
|
+
import { type ImportEntry } from './manifest.js';
|
|
2
3
|
import { type BundleVerifier } from './inspect.js';
|
|
3
4
|
import type { CaptureRuntime } from '../acquisition/runtime.js';
|
|
4
5
|
export interface ImportOptions {
|
|
@@ -25,5 +26,14 @@ export interface ImportReport {
|
|
|
25
26
|
}[];
|
|
26
27
|
nextOffset: number | null;
|
|
27
28
|
}
|
|
29
|
+
/** One verified bundle: a receipt short-circuits, a dry run stops after verification, otherwise the capture is promoted through the queue. */
|
|
30
|
+
export declare function importBundleEntry(store: LibraryStore, root: string, namespace: string, entry: ImportEntry, verify: BundleVerifier, dryRun: boolean, signal?: AbortSignal): Promise<{
|
|
31
|
+
status: 'ready' | 'imported' | 'duplicate';
|
|
32
|
+
sourceHash: string;
|
|
33
|
+
historicalIntegrity: 'verified' | 'unavailable';
|
|
34
|
+
itemId?: string;
|
|
35
|
+
}>;
|
|
36
|
+
/** The revision a bundle import committed: content-addressed by its source hash. */
|
|
37
|
+
export declare const importBundleHash: (sourceHash: string) => string;
|
|
28
38
|
/** Additive bounded batches. Every applied entry is reverified; source bundles are read-only. */
|
|
29
39
|
export declare function importManifest(store: LibraryStore, path: string, options: ImportOptions): Promise<ImportReport>;
|