@geravant/sinain 1.19.0 → 1.22.0
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/package.json +1 -1
- package/sinain-core/package-lock.json +439 -0
- package/sinain-core/package.json +2 -0
- package/sinain-core/src/index.ts +283 -0
- package/sinain-core/src/learning/local-curation.ts +3 -0
- package/sinain-core/src/server.ts +1570 -2
- package/sinain-core/src/web-db/schema.ts +122 -0
- package/sinain-core/src/web-db/store.ts +406 -0
- package/sinain-memory/concept_export.py +310 -0
- package/sinain-memory/concept_import.py +254 -0
- package/sinain-memory/graph_query.py +461 -4
- package/sinain-memory/knowledge_integrator.py +87 -10
- package/sinain-memory/page_renderer.py +447 -0
- package/sinain-memory/retract.py +236 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema for ~/.sinain/memory/web.db — UI metadata storage.
|
|
3
|
+
*
|
|
4
|
+
* Kept separate from triplestore (knowledge-graph.db) because triples are
|
|
5
|
+
* claims about the world (with confidence, retraction, bi-temporal validity),
|
|
6
|
+
* whereas this DB stores UI state, page caches, and undo tokens — metadata
|
|
7
|
+
* that should not be visible to the curator/distiller.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const SCHEMA_VERSION = 2;
|
|
11
|
+
|
|
12
|
+
export const SCHEMA_SQL = `
|
|
13
|
+
-- Schema version tracking (for future migrations)
|
|
14
|
+
CREATE TABLE IF NOT EXISTS schema_meta (
|
|
15
|
+
key TEXT PRIMARY KEY,
|
|
16
|
+
value TEXT NOT NULL
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
-- User bookmarks (favorite / archive / recent)
|
|
20
|
+
CREATE TABLE IF NOT EXISTS user_bookmarks (
|
|
21
|
+
entity_id TEXT PRIMARY KEY,
|
|
22
|
+
status TEXT NOT NULL CHECK (status IN ('favorite','archive','recent')),
|
|
23
|
+
note TEXT,
|
|
24
|
+
created_at INTEGER NOT NULL,
|
|
25
|
+
last_visited INTEGER NOT NULL
|
|
26
|
+
);
|
|
27
|
+
CREATE INDEX IF NOT EXISTS idx_bookmarks_status_visited
|
|
28
|
+
ON user_bookmarks(status, last_visited DESC);
|
|
29
|
+
|
|
30
|
+
-- LLM-rendered page cache. Key: (entity, max tx_id of facts that fed the render)
|
|
31
|
+
-- Implicit invalidation: new facts → tx advances → cache miss → regenerate.
|
|
32
|
+
-- Old entries kept for bi-temporal "view as of" support.
|
|
33
|
+
CREATE TABLE IF NOT EXISTS page_cache (
|
|
34
|
+
entity_id TEXT NOT NULL,
|
|
35
|
+
tx_watermark INTEGER NOT NULL,
|
|
36
|
+
page_json TEXT NOT NULL,
|
|
37
|
+
generated_at INTEGER NOT NULL,
|
|
38
|
+
tokens_in INTEGER,
|
|
39
|
+
tokens_out INTEGER,
|
|
40
|
+
cost_usd REAL,
|
|
41
|
+
PRIMARY KEY (entity_id, tx_watermark)
|
|
42
|
+
);
|
|
43
|
+
CREATE INDEX IF NOT EXISTS idx_page_cache_entity
|
|
44
|
+
ON page_cache(entity_id, generated_at DESC);
|
|
45
|
+
|
|
46
|
+
-- Undo snapshots for fact retraction. 10-minute server-side window.
|
|
47
|
+
-- Single-use: consumed_at set when restored; row not deleted (audit trail).
|
|
48
|
+
CREATE TABLE IF NOT EXISTS retraction_undo (
|
|
49
|
+
token TEXT PRIMARY KEY,
|
|
50
|
+
fact_id TEXT NOT NULL,
|
|
51
|
+
snapshot_json TEXT NOT NULL,
|
|
52
|
+
retracted_tx INTEGER NOT NULL,
|
|
53
|
+
reason TEXT,
|
|
54
|
+
actor TEXT,
|
|
55
|
+
created_at INTEGER NOT NULL,
|
|
56
|
+
expires_at INTEGER NOT NULL,
|
|
57
|
+
consumed_at INTEGER
|
|
58
|
+
);
|
|
59
|
+
CREATE INDEX IF NOT EXISTS idx_retraction_undo_expires
|
|
60
|
+
ON retraction_undo(expires_at);
|
|
61
|
+
|
|
62
|
+
-- Audit log of every retraction (kept forever, feeds eval reports).
|
|
63
|
+
CREATE TABLE IF NOT EXISTS retraction_log (
|
|
64
|
+
ts INTEGER NOT NULL,
|
|
65
|
+
fact_id TEXT NOT NULL,
|
|
66
|
+
reason TEXT,
|
|
67
|
+
actor TEXT,
|
|
68
|
+
undone_at INTEGER,
|
|
69
|
+
source_entity TEXT
|
|
70
|
+
);
|
|
71
|
+
CREATE INDEX IF NOT EXISTS idx_retraction_log_ts ON retraction_log(ts DESC);
|
|
72
|
+
|
|
73
|
+
-- Imported concepts — provenance + idempotency check via bundle_sha256.
|
|
74
|
+
CREATE TABLE IF NOT EXISTS concept_imports (
|
|
75
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
76
|
+
imported_at INTEGER NOT NULL,
|
|
77
|
+
root_entity TEXT NOT NULL,
|
|
78
|
+
source_tool TEXT,
|
|
79
|
+
source_version TEXT,
|
|
80
|
+
envelope_format TEXT NOT NULL,
|
|
81
|
+
bundle_sha256 TEXT NOT NULL,
|
|
82
|
+
conflict_mode TEXT NOT NULL,
|
|
83
|
+
triples_count INTEGER,
|
|
84
|
+
redactions_seen TEXT,
|
|
85
|
+
notes TEXT
|
|
86
|
+
);
|
|
87
|
+
CREATE INDEX IF NOT EXISTS idx_concept_imports_root
|
|
88
|
+
ON concept_imports(root_entity, imported_at DESC);
|
|
89
|
+
CREATE INDEX IF NOT EXISTS idx_concept_imports_sha
|
|
90
|
+
ON concept_imports(bundle_sha256);
|
|
91
|
+
|
|
92
|
+
-- Search log for telemetry / "what did the user search for that didn't resolve."
|
|
93
|
+
CREATE TABLE IF NOT EXISTS search_log (
|
|
94
|
+
ts INTEGER NOT NULL,
|
|
95
|
+
query TEXT NOT NULL,
|
|
96
|
+
resolved_to TEXT,
|
|
97
|
+
result_count INTEGER
|
|
98
|
+
);
|
|
99
|
+
CREATE INDEX IF NOT EXISTS idx_search_log_ts ON search_log(ts DESC);
|
|
100
|
+
|
|
101
|
+
-- Cross-machine concept share tracking (URL-fragment + WebRTC peer modes).
|
|
102
|
+
-- Persistent across SPA refresh — ShareManager reads this on load and
|
|
103
|
+
-- re-binds peer connections using the stored share_token as the peerjs ID.
|
|
104
|
+
CREATE TABLE IF NOT EXISTS shared_docs (
|
|
105
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
106
|
+
share_token TEXT NOT NULL UNIQUE,
|
|
107
|
+
entity_id TEXT NOT NULL,
|
|
108
|
+
mode TEXT NOT NULL CHECK (mode IN ('fragment','peer')),
|
|
109
|
+
status TEXT NOT NULL CHECK (status IN ('waiting','connecting','delivered','disconnected','revoked','expired')),
|
|
110
|
+
bundle_size INTEGER,
|
|
111
|
+
url TEXT NOT NULL,
|
|
112
|
+
created_at INTEGER NOT NULL,
|
|
113
|
+
delivered_at INTEGER,
|
|
114
|
+
revoked_at INTEGER,
|
|
115
|
+
recipient_hint TEXT,
|
|
116
|
+
notes TEXT
|
|
117
|
+
);
|
|
118
|
+
CREATE INDEX IF NOT EXISTS idx_shared_docs_status
|
|
119
|
+
ON shared_docs(status, created_at DESC);
|
|
120
|
+
CREATE INDEX IF NOT EXISTS idx_shared_docs_entity
|
|
121
|
+
ON shared_docs(entity_id);
|
|
122
|
+
`;
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed accessor for ~/.sinain/memory/web.db.
|
|
3
|
+
*
|
|
4
|
+
* One module owns all SQL — keeps query strings out of HTTP handlers and
|
|
5
|
+
* lets us swap out better-sqlite3 later if needed.
|
|
6
|
+
*/
|
|
7
|
+
import Database from "better-sqlite3";
|
|
8
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
9
|
+
import { dirname } from "node:path";
|
|
10
|
+
import { SCHEMA_SQL, SCHEMA_VERSION } from "./schema.js";
|
|
11
|
+
import { log } from "../log.js";
|
|
12
|
+
|
|
13
|
+
const TAG = "web-db";
|
|
14
|
+
|
|
15
|
+
export type BookmarkStatus = "favorite" | "archive" | "recent";
|
|
16
|
+
|
|
17
|
+
export interface Bookmark {
|
|
18
|
+
entity_id: string;
|
|
19
|
+
status: BookmarkStatus;
|
|
20
|
+
note: string | null;
|
|
21
|
+
created_at: number;
|
|
22
|
+
last_visited: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PageCacheRow {
|
|
26
|
+
entity_id: string;
|
|
27
|
+
tx_watermark: number;
|
|
28
|
+
page_json: string;
|
|
29
|
+
generated_at: number;
|
|
30
|
+
tokens_in: number | null;
|
|
31
|
+
tokens_out: number | null;
|
|
32
|
+
cost_usd: number | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface RetractionUndoRow {
|
|
36
|
+
token: string;
|
|
37
|
+
fact_id: string;
|
|
38
|
+
snapshot_json: string;
|
|
39
|
+
retracted_tx: number;
|
|
40
|
+
reason: string | null;
|
|
41
|
+
actor: string | null;
|
|
42
|
+
created_at: number;
|
|
43
|
+
expires_at: number;
|
|
44
|
+
consumed_at: number | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ConceptImportRow {
|
|
48
|
+
id?: number;
|
|
49
|
+
imported_at: number;
|
|
50
|
+
root_entity: string;
|
|
51
|
+
source_tool: string | null;
|
|
52
|
+
source_version: string | null;
|
|
53
|
+
envelope_format: string;
|
|
54
|
+
bundle_sha256: string;
|
|
55
|
+
conflict_mode: string;
|
|
56
|
+
triples_count: number | null;
|
|
57
|
+
redactions_seen: string | null;
|
|
58
|
+
notes: string | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type SharedDocMode = "fragment" | "peer";
|
|
62
|
+
export type SharedDocStatus =
|
|
63
|
+
| "waiting" | "connecting" | "delivered"
|
|
64
|
+
| "disconnected" | "revoked" | "expired";
|
|
65
|
+
|
|
66
|
+
export interface SharedDocRow {
|
|
67
|
+
id?: number;
|
|
68
|
+
share_token: string;
|
|
69
|
+
entity_id: string;
|
|
70
|
+
mode: SharedDocMode;
|
|
71
|
+
status: SharedDocStatus;
|
|
72
|
+
bundle_size: number | null;
|
|
73
|
+
url: string;
|
|
74
|
+
created_at: number;
|
|
75
|
+
delivered_at: number | null;
|
|
76
|
+
revoked_at: number | null;
|
|
77
|
+
recipient_hint: string | null;
|
|
78
|
+
notes: string | null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const PAGE_CACHE_LRU_CAP = 500;
|
|
82
|
+
|
|
83
|
+
export class WebDb {
|
|
84
|
+
private db: Database.Database;
|
|
85
|
+
|
|
86
|
+
constructor(dbPath: string) {
|
|
87
|
+
if (!existsSync(dirname(dbPath))) {
|
|
88
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
89
|
+
}
|
|
90
|
+
this.db = new Database(dbPath);
|
|
91
|
+
this.db.pragma("journal_mode = WAL");
|
|
92
|
+
this.db.pragma("foreign_keys = ON");
|
|
93
|
+
this.db.exec(SCHEMA_SQL);
|
|
94
|
+
this.db
|
|
95
|
+
.prepare(
|
|
96
|
+
"INSERT INTO schema_meta(key, value) VALUES('version', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
|
97
|
+
)
|
|
98
|
+
.run(String(SCHEMA_VERSION));
|
|
99
|
+
log(TAG, `web.db ready at ${dbPath} (schema v${SCHEMA_VERSION})`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
close(): void {
|
|
103
|
+
this.db.close();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Bookmarks ───────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
listBookmarks(status?: BookmarkStatus, limit = 100): Bookmark[] {
|
|
109
|
+
const sql = status
|
|
110
|
+
? "SELECT * FROM user_bookmarks WHERE status = ? ORDER BY last_visited DESC LIMIT ?"
|
|
111
|
+
: "SELECT * FROM user_bookmarks ORDER BY last_visited DESC LIMIT ?";
|
|
112
|
+
const args = status ? [status, limit] : [limit];
|
|
113
|
+
return this.db.prepare(sql).all(...args) as Bookmark[];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
upsertBookmark(entity_id: string, status: BookmarkStatus, note?: string): Bookmark {
|
|
117
|
+
const now = Date.now();
|
|
118
|
+
this.db
|
|
119
|
+
.prepare(
|
|
120
|
+
`INSERT INTO user_bookmarks(entity_id, status, note, created_at, last_visited)
|
|
121
|
+
VALUES(?, ?, ?, ?, ?)
|
|
122
|
+
ON CONFLICT(entity_id) DO UPDATE SET
|
|
123
|
+
status = excluded.status,
|
|
124
|
+
note = COALESCE(excluded.note, user_bookmarks.note),
|
|
125
|
+
last_visited = excluded.last_visited`,
|
|
126
|
+
)
|
|
127
|
+
.run(entity_id, status, note ?? null, now, now);
|
|
128
|
+
return this.db
|
|
129
|
+
.prepare("SELECT * FROM user_bookmarks WHERE entity_id = ?")
|
|
130
|
+
.get(entity_id) as Bookmark;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
deleteBookmark(entity_id: string): boolean {
|
|
134
|
+
const r = this.db.prepare("DELETE FROM user_bookmarks WHERE entity_id = ?").run(entity_id);
|
|
135
|
+
return r.changes > 0;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Bump last_visited for a bookmark; if absent, insert as 'recent'. */
|
|
139
|
+
touchVisit(entity_id: string): void {
|
|
140
|
+
const now = Date.now();
|
|
141
|
+
this.db
|
|
142
|
+
.prepare(
|
|
143
|
+
`INSERT INTO user_bookmarks(entity_id, status, note, created_at, last_visited)
|
|
144
|
+
VALUES(?, 'recent', NULL, ?, ?)
|
|
145
|
+
ON CONFLICT(entity_id) DO UPDATE SET last_visited = excluded.last_visited`,
|
|
146
|
+
)
|
|
147
|
+
.run(entity_id, now, now);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Page cache ──────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
getPageCache(entity_id: string, tx_watermark: number): PageCacheRow | null {
|
|
153
|
+
const row = this.db
|
|
154
|
+
.prepare(
|
|
155
|
+
"SELECT * FROM page_cache WHERE entity_id = ? AND tx_watermark = ?",
|
|
156
|
+
)
|
|
157
|
+
.get(entity_id, tx_watermark) as PageCacheRow | undefined;
|
|
158
|
+
return row ?? null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
putPageCache(row: Omit<PageCacheRow, "generated_at"> & { generated_at?: number }): void {
|
|
162
|
+
const generated_at = row.generated_at ?? Date.now();
|
|
163
|
+
this.db
|
|
164
|
+
.prepare(
|
|
165
|
+
`INSERT OR REPLACE INTO page_cache
|
|
166
|
+
(entity_id, tx_watermark, page_json, generated_at, tokens_in, tokens_out, cost_usd)
|
|
167
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
168
|
+
)
|
|
169
|
+
.run(
|
|
170
|
+
row.entity_id,
|
|
171
|
+
row.tx_watermark,
|
|
172
|
+
row.page_json,
|
|
173
|
+
generated_at,
|
|
174
|
+
row.tokens_in ?? null,
|
|
175
|
+
row.tokens_out ?? null,
|
|
176
|
+
row.cost_usd ?? null,
|
|
177
|
+
);
|
|
178
|
+
this.pruneCache();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** LRU prune: keep newest PAGE_CACHE_LRU_CAP entries by generated_at. */
|
|
182
|
+
private pruneCache(): void {
|
|
183
|
+
const count = (this.db.prepare("SELECT COUNT(*) as n FROM page_cache").get() as { n: number }).n;
|
|
184
|
+
if (count <= PAGE_CACHE_LRU_CAP) return;
|
|
185
|
+
const overflow = count - PAGE_CACHE_LRU_CAP;
|
|
186
|
+
this.db
|
|
187
|
+
.prepare(
|
|
188
|
+
`DELETE FROM page_cache WHERE rowid IN (
|
|
189
|
+
SELECT rowid FROM page_cache ORDER BY generated_at ASC LIMIT ?
|
|
190
|
+
)`,
|
|
191
|
+
)
|
|
192
|
+
.run(overflow);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── Retraction undo ─────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
putRetractionUndo(row: Omit<RetractionUndoRow, "consumed_at">): void {
|
|
198
|
+
this.db
|
|
199
|
+
.prepare(
|
|
200
|
+
`INSERT INTO retraction_undo
|
|
201
|
+
(token, fact_id, snapshot_json, retracted_tx, reason, actor, created_at, expires_at)
|
|
202
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
203
|
+
)
|
|
204
|
+
.run(
|
|
205
|
+
row.token,
|
|
206
|
+
row.fact_id,
|
|
207
|
+
row.snapshot_json,
|
|
208
|
+
row.retracted_tx,
|
|
209
|
+
row.reason,
|
|
210
|
+
row.actor,
|
|
211
|
+
row.created_at,
|
|
212
|
+
row.expires_at,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
getRetractionUndo(token: string): RetractionUndoRow | null {
|
|
217
|
+
const row = this.db
|
|
218
|
+
.prepare("SELECT * FROM retraction_undo WHERE token = ?")
|
|
219
|
+
.get(token) as RetractionUndoRow | undefined;
|
|
220
|
+
return row ?? null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
consumeRetractionUndo(token: string): void {
|
|
224
|
+
this.db
|
|
225
|
+
.prepare("UPDATE retraction_undo SET consumed_at = ? WHERE token = ?")
|
|
226
|
+
.run(Date.now(), token);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
pruneExpiredUndos(): number {
|
|
230
|
+
const r = this.db
|
|
231
|
+
.prepare("DELETE FROM retraction_undo WHERE expires_at < ? AND consumed_at IS NULL")
|
|
232
|
+
.run(Date.now());
|
|
233
|
+
return r.changes;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
logRetraction(fact_id: string, reason: string | null, actor: string | null, source_entity: string | null): void {
|
|
237
|
+
this.db
|
|
238
|
+
.prepare(
|
|
239
|
+
`INSERT INTO retraction_log(ts, fact_id, reason, actor, source_entity)
|
|
240
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
241
|
+
)
|
|
242
|
+
.run(Date.now(), fact_id, reason, actor, source_entity);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
markRetractionUndone(fact_id: string): void {
|
|
246
|
+
this.db
|
|
247
|
+
.prepare(
|
|
248
|
+
`UPDATE retraction_log SET undone_at = ?
|
|
249
|
+
WHERE rowid = (
|
|
250
|
+
SELECT rowid FROM retraction_log
|
|
251
|
+
WHERE fact_id = ? AND undone_at IS NULL
|
|
252
|
+
ORDER BY ts DESC LIMIT 1
|
|
253
|
+
)`,
|
|
254
|
+
)
|
|
255
|
+
.run(Date.now(), fact_id);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Concept imports ─────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
recordConceptImport(row: ConceptImportRow): number {
|
|
261
|
+
const r = this.db
|
|
262
|
+
.prepare(
|
|
263
|
+
`INSERT INTO concept_imports
|
|
264
|
+
(imported_at, root_entity, source_tool, source_version, envelope_format,
|
|
265
|
+
bundle_sha256, conflict_mode, triples_count, redactions_seen, notes)
|
|
266
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
267
|
+
)
|
|
268
|
+
.run(
|
|
269
|
+
row.imported_at,
|
|
270
|
+
row.root_entity,
|
|
271
|
+
row.source_tool,
|
|
272
|
+
row.source_version,
|
|
273
|
+
row.envelope_format,
|
|
274
|
+
row.bundle_sha256,
|
|
275
|
+
row.conflict_mode,
|
|
276
|
+
row.triples_count,
|
|
277
|
+
row.redactions_seen,
|
|
278
|
+
row.notes,
|
|
279
|
+
);
|
|
280
|
+
return Number(r.lastInsertRowid);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
findImportBySha(bundle_sha256: string): ConceptImportRow | null {
|
|
284
|
+
const row = this.db
|
|
285
|
+
.prepare("SELECT * FROM concept_imports WHERE bundle_sha256 = ? ORDER BY imported_at DESC LIMIT 1")
|
|
286
|
+
.get(bundle_sha256) as ConceptImportRow | undefined;
|
|
287
|
+
return row ?? null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ── Search log ──────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
logSearch(query: string, resolved_to: string | null, result_count: number): void {
|
|
293
|
+
this.db
|
|
294
|
+
.prepare(
|
|
295
|
+
"INSERT INTO search_log(ts, query, resolved_to, result_count) VALUES (?, ?, ?, ?)",
|
|
296
|
+
)
|
|
297
|
+
.run(Date.now(), query, resolved_to, result_count);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── Shared docs ─────────────────────────────────────────
|
|
301
|
+
// Cross-machine sharing: persistent records of share links the user
|
|
302
|
+
// produced. ShareManager (browser) reads these on SPA load to resume
|
|
303
|
+
// peer connections.
|
|
304
|
+
|
|
305
|
+
createSharedDoc(row: Omit<SharedDocRow, "id" | "created_at"> & { created_at?: number }): SharedDocRow {
|
|
306
|
+
const created_at = row.created_at ?? Date.now();
|
|
307
|
+
const r = this.db
|
|
308
|
+
.prepare(
|
|
309
|
+
`INSERT INTO shared_docs
|
|
310
|
+
(share_token, entity_id, mode, status, bundle_size, url,
|
|
311
|
+
created_at, delivered_at, revoked_at, recipient_hint, notes)
|
|
312
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
313
|
+
)
|
|
314
|
+
.run(
|
|
315
|
+
row.share_token,
|
|
316
|
+
row.entity_id,
|
|
317
|
+
row.mode,
|
|
318
|
+
row.status,
|
|
319
|
+
row.bundle_size,
|
|
320
|
+
row.url,
|
|
321
|
+
created_at,
|
|
322
|
+
row.delivered_at,
|
|
323
|
+
row.revoked_at,
|
|
324
|
+
row.recipient_hint,
|
|
325
|
+
row.notes,
|
|
326
|
+
);
|
|
327
|
+
return this.db
|
|
328
|
+
.prepare("SELECT * FROM shared_docs WHERE id = ?")
|
|
329
|
+
.get(Number(r.lastInsertRowid)) as SharedDocRow;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** List shares; default returns all non-revoked + non-expired, recent first. */
|
|
333
|
+
listSharedDocs(opts?: { statuses?: SharedDocStatus[]; limit?: number; includeArchived?: boolean }): SharedDocRow[] {
|
|
334
|
+
const limit = opts?.limit ?? 200;
|
|
335
|
+
const statuses = opts?.statuses;
|
|
336
|
+
if (statuses && statuses.length > 0) {
|
|
337
|
+
const placeholders = statuses.map(() => "?").join(",");
|
|
338
|
+
return this.db
|
|
339
|
+
.prepare(
|
|
340
|
+
`SELECT * FROM shared_docs WHERE status IN (${placeholders})
|
|
341
|
+
ORDER BY created_at DESC LIMIT ?`,
|
|
342
|
+
)
|
|
343
|
+
.all(...statuses, limit) as SharedDocRow[];
|
|
344
|
+
}
|
|
345
|
+
if (opts?.includeArchived) {
|
|
346
|
+
return this.db
|
|
347
|
+
.prepare("SELECT * FROM shared_docs ORDER BY created_at DESC LIMIT ?")
|
|
348
|
+
.all(limit) as SharedDocRow[];
|
|
349
|
+
}
|
|
350
|
+
return this.db
|
|
351
|
+
.prepare(
|
|
352
|
+
`SELECT * FROM shared_docs WHERE status NOT IN ('revoked','expired')
|
|
353
|
+
ORDER BY created_at DESC LIMIT ?`,
|
|
354
|
+
)
|
|
355
|
+
.all(limit) as SharedDocRow[];
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
getSharedDoc(share_token: string): SharedDocRow | null {
|
|
359
|
+
const row = this.db
|
|
360
|
+
.prepare("SELECT * FROM shared_docs WHERE share_token = ?")
|
|
361
|
+
.get(share_token) as SharedDocRow | undefined;
|
|
362
|
+
return row ?? null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
updateSharedDocStatus(share_token: string, status: SharedDocStatus,
|
|
366
|
+
extra?: { delivered_at?: number; revoked_at?: number; recipient_hint?: string }): boolean {
|
|
367
|
+
// Compose dynamic SET clause based on which extras are present.
|
|
368
|
+
const sets: string[] = ["status = ?"];
|
|
369
|
+
const params: any[] = [status];
|
|
370
|
+
if (extra?.delivered_at != null) { sets.push("delivered_at = ?"); params.push(extra.delivered_at); }
|
|
371
|
+
if (extra?.revoked_at != null) { sets.push("revoked_at = ?"); params.push(extra.revoked_at); }
|
|
372
|
+
if (extra?.recipient_hint != null) { sets.push("recipient_hint = ?"); params.push(extra.recipient_hint); }
|
|
373
|
+
params.push(share_token);
|
|
374
|
+
const r = this.db
|
|
375
|
+
.prepare(`UPDATE shared_docs SET ${sets.join(", ")} WHERE share_token = ?`)
|
|
376
|
+
.run(...params);
|
|
377
|
+
return r.changes > 0;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
deleteSharedDoc(share_token: string): boolean {
|
|
381
|
+
const r = this.db.prepare("DELETE FROM shared_docs WHERE share_token = ?").run(share_token);
|
|
382
|
+
return r.changes > 0;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Auto-expire stale shares: waiting/disconnected older than ttl_ms. */
|
|
386
|
+
expireStaleShares(ttl_ms: number): number {
|
|
387
|
+
const cutoff = Date.now() - ttl_ms;
|
|
388
|
+
const r = this.db
|
|
389
|
+
.prepare(
|
|
390
|
+
`UPDATE shared_docs SET status = 'expired'
|
|
391
|
+
WHERE status IN ('waiting','disconnected','connecting') AND created_at < ?`,
|
|
392
|
+
)
|
|
393
|
+
.run(cutoff);
|
|
394
|
+
return r.changes;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
countActiveShares(): number {
|
|
398
|
+
const row = this.db
|
|
399
|
+
.prepare(
|
|
400
|
+
`SELECT COUNT(*) AS n FROM shared_docs
|
|
401
|
+
WHERE status IN ('waiting','connecting','disconnected')`,
|
|
402
|
+
)
|
|
403
|
+
.get() as { n: number };
|
|
404
|
+
return row.n;
|
|
405
|
+
}
|
|
406
|
+
}
|