@lelouchhe/webagent 0.3.0 → 0.5.1
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 +59 -23
- package/bin/webagent.mjs +119 -8
- package/config.toml +105 -3
- package/dist/fonts/temml/Temml.woff2 +0 -0
- package/dist/index.html +64 -41
- package/dist/js/app.3OEVQXHK.js +4 -0
- package/dist/js/chunk.AJZBJBMO.js +1 -0
- package/dist/js/chunk.D4ZYHJAM.js +1 -0
- package/dist/js/chunk.IJM5DBCO.js +173 -0
- package/dist/js/chunk.VZXGXFNN.js +5 -0
- package/dist/js/login.PYIK52HN.js +1 -0
- package/dist/js/viewer.FCSSTUVY.js +1 -0
- package/dist/login.html +49 -0
- package/dist/share-viewer.00gubshk.css +114 -0
- package/dist/share-viewer.html +54 -0
- package/dist/styles.00xfh3e6.css +1848 -0
- package/dist/sw.js +79 -27
- package/dist/theme-init.js +6 -0
- package/lib/agent-detect.js +110 -0
- package/lib/atomic-write.js +50 -0
- package/lib/attachment-dispatch.js +86 -0
- package/lib/attachment-interceptor.js +130 -0
- package/lib/attachment-labels.js +139 -0
- package/lib/attachments.js +154 -0
- package/lib/auth-middleware.js +105 -0
- package/lib/auth-store.js +269 -0
- package/lib/auth.js +89 -0
- package/lib/bootstrap.js +70 -0
- package/lib/bridge-event-config.js +29 -0
- package/lib/bridge.js +244 -93
- package/lib/client-registry.js +149 -0
- package/lib/config.js +130 -9
- package/lib/daemon.js +175 -41
- package/lib/event-handler.js +209 -91
- package/lib/image-dimensions.js +64 -0
- package/lib/log-fmt.js +67 -0
- package/lib/log.js +83 -0
- package/lib/message-cleanup.js +48 -0
- package/lib/mode-bucket.js +62 -0
- package/lib/model-picker.js +17 -0
- package/lib/preflight.js +214 -0
- package/lib/push-service.js +297 -52
- package/lib/routes.js +1315 -145
- package/lib/server.js +149 -37
- package/lib/session-manager.js +164 -18
- package/lib/session-state.js +160 -0
- package/lib/sessions-anchor.js +28 -0
- package/lib/share/cleanup.js +45 -0
- package/lib/share/routes.js +972 -0
- package/lib/share/sanitize.js +179 -0
- package/lib/sse-manager.js +94 -8
- package/lib/sse-ticket.js +45 -0
- package/lib/startup-checks.js +95 -0
- package/lib/store.js +636 -30
- package/lib/title-service.js +26 -9
- package/lib/tokens.js +50 -0
- package/lib/types.js +23 -0
- package/package.json +39 -4
- package/dist/js/app.2562YGRO.js +0 -10
- package/dist/styles.008ve1hx.css +0 -669
- package/lib/shared/constants.js +0 -17
package/lib/store.js
CHANGED
|
@@ -8,6 +8,9 @@ export class Store {
|
|
|
8
8
|
this.db = new Database(join(dataDir, "webagent.db"));
|
|
9
9
|
this.db.pragma("journal_mode = WAL");
|
|
10
10
|
this.migrate();
|
|
11
|
+
// Enforce foreign keys *after* migrate() so the one-time orphan cleanup
|
|
12
|
+
// can run without pragma interfering with legacy cleanup queries.
|
|
13
|
+
this.db.pragma("foreign_keys = ON");
|
|
11
14
|
}
|
|
12
15
|
migrate() {
|
|
13
16
|
this.db.exec(`
|
|
@@ -37,7 +40,7 @@ export class Store {
|
|
|
37
40
|
`);
|
|
38
41
|
// Migrate existing tables: add columns if missing
|
|
39
42
|
const cols = this.db.prepare("PRAGMA table_info(sessions)").all();
|
|
40
|
-
const colNames = new Set(cols.map(c => c.name));
|
|
43
|
+
const colNames = new Set(cols.map((c) => c.name));
|
|
41
44
|
if (!colNames.has("title")) {
|
|
42
45
|
this.db.exec("ALTER TABLE sessions ADD COLUMN title TEXT");
|
|
43
46
|
}
|
|
@@ -58,8 +61,47 @@ export class Store {
|
|
|
58
61
|
if (!colNames.has("source")) {
|
|
59
62
|
this.db.exec("ALTER TABLE sessions ADD COLUMN source TEXT NOT NULL DEFAULT 'auto'");
|
|
60
63
|
}
|
|
64
|
+
if (!colNames.has("deleted_at")) {
|
|
65
|
+
this.db.exec("ALTER TABLE sessions ADD COLUMN deleted_at INTEGER");
|
|
66
|
+
}
|
|
67
|
+
// messages — pending unbound notifications. POST /api/v1/messages with
|
|
68
|
+
// `to = "user"` lands here; consumeMessageTx transactionally moves the
|
|
69
|
+
// content into a new session's events and deletes the row. Bound
|
|
70
|
+
// messages (to = session id) skip this table entirely and go straight
|
|
71
|
+
// to `events`.
|
|
72
|
+
this.db.exec(`
|
|
73
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
74
|
+
id TEXT PRIMARY KEY,
|
|
75
|
+
from_ref TEXT NOT NULL,
|
|
76
|
+
from_label TEXT,
|
|
77
|
+
to_ref TEXT NOT NULL,
|
|
78
|
+
deliver TEXT NOT NULL DEFAULT 'push',
|
|
79
|
+
dedup_key TEXT,
|
|
80
|
+
title TEXT NOT NULL,
|
|
81
|
+
body TEXT NOT NULL,
|
|
82
|
+
cwd TEXT,
|
|
83
|
+
created_at INTEGER NOT NULL
|
|
84
|
+
);
|
|
85
|
+
CREATE INDEX IF NOT EXISTS idx_messages_created ON messages (created_at);
|
|
86
|
+
CREATE INDEX IF NOT EXISTS idx_messages_dedup ON messages (to_ref, dedup_key);
|
|
87
|
+
`);
|
|
88
|
+
// client-server-split M2: idempotency for mutating REST calls. Stores
|
|
89
|
+
// the cached response per (session_id, client_op_id) so retries (after
|
|
90
|
+
// network/SSE reconnect) return the same result instead of re-executing
|
|
91
|
+
// side effects.
|
|
92
|
+
this.db.exec(`
|
|
93
|
+
CREATE TABLE IF NOT EXISTS client_ops (
|
|
94
|
+
session_id TEXT NOT NULL,
|
|
95
|
+
client_op_id TEXT NOT NULL,
|
|
96
|
+
result_json TEXT NOT NULL,
|
|
97
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')),
|
|
98
|
+
PRIMARY KEY (session_id, client_op_id)
|
|
99
|
+
);
|
|
100
|
+
`);
|
|
61
101
|
// recent_paths: LRU path list for /new menu
|
|
62
|
-
const rpExists = this.db
|
|
102
|
+
const rpExists = this.db
|
|
103
|
+
.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='recent_paths'")
|
|
104
|
+
.get();
|
|
63
105
|
if (!rpExists) {
|
|
64
106
|
this.db.exec(`
|
|
65
107
|
CREATE TABLE recent_paths (
|
|
@@ -74,56 +116,275 @@ export class Store {
|
|
|
74
116
|
FROM sessions GROUP BY cwd;
|
|
75
117
|
`);
|
|
76
118
|
}
|
|
119
|
+
// events.from_ref — origin marker for every event row.
|
|
120
|
+
// Values: 'user' | 'system' | 'agent' | 'msg:<id>'. The 'msg:<id>'
|
|
121
|
+
// form is reserved for events authored by consuming an inbox message
|
|
122
|
+
// (see C7+). Bucketed backfill runs once for legacy rows.
|
|
123
|
+
const eventCols = this.db
|
|
124
|
+
.prepare("PRAGMA table_info(events)")
|
|
125
|
+
.all();
|
|
126
|
+
const eventColNames = new Set(eventCols.map((c) => c.name));
|
|
127
|
+
if (!eventColNames.has("from_ref")) {
|
|
128
|
+
this.db.exec("ALTER TABLE events ADD COLUMN from_ref TEXT");
|
|
129
|
+
// Buckets:
|
|
130
|
+
// user — user-authored input
|
|
131
|
+
// system — client-originated side-channel actions + host responses
|
|
132
|
+
// (permission responses, local bash, system messages)
|
|
133
|
+
// agent — everything else (assistant_message, thinking, tool_call,
|
|
134
|
+
// tool_call_update, plan, prompt_done, permission_request,
|
|
135
|
+
// etc.)
|
|
136
|
+
this.db.exec(`
|
|
137
|
+
UPDATE events SET from_ref = CASE
|
|
138
|
+
WHEN type = 'user_message' THEN 'user'
|
|
139
|
+
WHEN type IN ('permission_response', 'bash_command', 'bash_result',
|
|
140
|
+
'system_message') THEN 'system'
|
|
141
|
+
ELSE 'agent'
|
|
142
|
+
END
|
|
143
|
+
WHERE from_ref IS NULL
|
|
144
|
+
`);
|
|
145
|
+
}
|
|
146
|
+
// One-time orphan cleanup: rows whose session_id no longer exists in
|
|
147
|
+
// `sessions`. Pre-FK writes could leave these behind (a session DELETE
|
|
148
|
+
// that didn't cascade because the FK pragma was off). Must run before
|
|
149
|
+
// enabling FK pragma.
|
|
150
|
+
this.db.exec("DELETE FROM events WHERE session_id NOT IN (SELECT id FROM sessions)");
|
|
151
|
+
// Secondary index for events queried by (session_id, type, created_at)
|
|
152
|
+
// -- used by upcoming inbox/message consume queries.
|
|
153
|
+
this.db.exec("CREATE INDEX IF NOT EXISTS idx_events_type ON events(session_id, type, created_at)");
|
|
154
|
+
// shares — public read-only share links (share-plan §4.1).
|
|
155
|
+
// State machine: preview (shared_at NULL) → active (shared_at set).
|
|
156
|
+
// Revocation = hard-delete the row (no audit trail kept).
|
|
157
|
+
// Multiple active siblings per session allowed (v4 multi-share).
|
|
158
|
+
// Partial unique index enforces at most one un-activated preview per
|
|
159
|
+
// session at any time.
|
|
160
|
+
this.db.exec(`
|
|
161
|
+
CREATE TABLE IF NOT EXISTS shares (
|
|
162
|
+
token TEXT PRIMARY KEY,
|
|
163
|
+
session_id TEXT NOT NULL REFERENCES sessions(id),
|
|
164
|
+
shared_at INTEGER,
|
|
165
|
+
share_snapshot_seq INTEGER NOT NULL,
|
|
166
|
+
ttl_hours INTEGER,
|
|
167
|
+
display_name TEXT,
|
|
168
|
+
owner_label TEXT,
|
|
169
|
+
created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER) * 1000),
|
|
170
|
+
last_accessed_at INTEGER
|
|
171
|
+
);
|
|
172
|
+
CREATE INDEX IF NOT EXISTS idx_shares_session ON shares(session_id, created_at DESC);
|
|
173
|
+
`);
|
|
174
|
+
// Migrate: drop revoked_at column from existing tables (v0.5+).
|
|
175
|
+
// Revocation is now hard-delete; kept rows are always live.
|
|
176
|
+
const shareCols = this.db
|
|
177
|
+
.prepare("PRAGMA table_info(shares)")
|
|
178
|
+
.all();
|
|
179
|
+
const shareColNames = new Set(shareCols.map((c) => c.name));
|
|
180
|
+
if (shareColNames.has("revoked_at")) {
|
|
181
|
+
// Hard-delete any pre-existing revoked rows so the migration
|
|
182
|
+
// doesn't resurrect them as "live" shares after dropping the column.
|
|
183
|
+
this.db.exec("DELETE FROM shares WHERE revoked_at IS NOT NULL");
|
|
184
|
+
// Drop the partial unique index that references revoked_at, then
|
|
185
|
+
// the column, then recreate the index without the revoked_at clause.
|
|
186
|
+
this.db.exec("DROP INDEX IF EXISTS shares_one_active_preview");
|
|
187
|
+
this.db.exec("ALTER TABLE shares DROP COLUMN revoked_at");
|
|
188
|
+
}
|
|
189
|
+
this.db.exec(`
|
|
190
|
+
CREATE UNIQUE INDEX IF NOT EXISTS shares_one_active_preview
|
|
191
|
+
ON shares(session_id)
|
|
192
|
+
WHERE shared_at IS NULL;
|
|
193
|
+
`);
|
|
194
|
+
// attachments — server-managed file uploads bound to a session.
|
|
195
|
+
// Lifecycle = session lifecycle: FK CASCADE removes the row when the
|
|
196
|
+
// session row is deleted (hard-delete path). Tombstoned (soft-deleted)
|
|
197
|
+
// sessions keep the row alive so the share viewer can still resolve
|
|
198
|
+
// file references for active shares.
|
|
199
|
+
//
|
|
200
|
+
// upload_seq = MAX(events.seq) at upload time. The share proxy uses
|
|
201
|
+
// `upload_seq <= shares.share_snapshot_seq` to refuse files uploaded
|
|
202
|
+
// after the share was published, without growing a second seq axis.
|
|
203
|
+
//
|
|
204
|
+
// realpath is stored after fs.realpath so the bridge / permission
|
|
205
|
+
// interceptor can compare paths without re-resolving symlinks on
|
|
206
|
+
// every request.
|
|
207
|
+
this.db.exec(`
|
|
208
|
+
CREATE TABLE IF NOT EXISTS attachments (
|
|
209
|
+
id TEXT PRIMARY KEY,
|
|
210
|
+
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
211
|
+
kind TEXT NOT NULL,
|
|
212
|
+
name TEXT NOT NULL,
|
|
213
|
+
mime TEXT NOT NULL,
|
|
214
|
+
size INTEGER NOT NULL,
|
|
215
|
+
realpath TEXT NOT NULL,
|
|
216
|
+
upload_seq INTEGER NOT NULL,
|
|
217
|
+
width INTEGER,
|
|
218
|
+
height INTEGER,
|
|
219
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now'))
|
|
220
|
+
);
|
|
221
|
+
CREATE INDEX IF NOT EXISTS idx_attachments_session ON attachments(session_id);
|
|
222
|
+
`);
|
|
223
|
+
const attachmentCols = this.db
|
|
224
|
+
.prepare("PRAGMA table_info(attachments)")
|
|
225
|
+
.all();
|
|
226
|
+
const attachmentColNames = new Set(attachmentCols.map((c) => c.name));
|
|
227
|
+
if (!attachmentColNames.has("width")) {
|
|
228
|
+
this.db.exec("ALTER TABLE attachments ADD COLUMN width INTEGER");
|
|
229
|
+
}
|
|
230
|
+
if (!attachmentColNames.has("height")) {
|
|
231
|
+
this.db.exec("ALTER TABLE attachments ADD COLUMN height INTEGER");
|
|
232
|
+
}
|
|
233
|
+
// owner_prefs — key-value store for owner-scoped defaults (display_name,
|
|
234
|
+
// last /by selection, etc). Single-user model = single owner scope.
|
|
235
|
+
// Stored as plain key/value so we don't grow a new table per pref.
|
|
236
|
+
this.db.exec(`
|
|
237
|
+
CREATE TABLE IF NOT EXISTS owner_prefs (
|
|
238
|
+
key TEXT PRIMARY KEY,
|
|
239
|
+
value TEXT NOT NULL,
|
|
240
|
+
updated_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER) * 1000)
|
|
241
|
+
);
|
|
242
|
+
`);
|
|
77
243
|
}
|
|
78
244
|
createSession(id, cwd, source = "auto") {
|
|
79
|
-
this.db
|
|
80
|
-
|
|
245
|
+
this.db
|
|
246
|
+
.prepare("INSERT INTO sessions (id, cwd, source) VALUES (?, ?, ?)")
|
|
247
|
+
.run(id, cwd, source);
|
|
248
|
+
return this.db
|
|
249
|
+
.prepare("SELECT * FROM sessions WHERE id = ?")
|
|
250
|
+
.get(id);
|
|
81
251
|
}
|
|
82
252
|
listSessions(opts) {
|
|
83
253
|
if (opts?.source) {
|
|
84
|
-
return this.db
|
|
254
|
+
return this.db
|
|
255
|
+
.prepare("SELECT * FROM sessions WHERE source = ? AND deleted_at IS NULL ORDER BY COALESCE(last_active_at, created_at) DESC")
|
|
256
|
+
.all(opts.source);
|
|
85
257
|
}
|
|
86
|
-
return this.db
|
|
258
|
+
return this.db
|
|
259
|
+
.prepare("SELECT * FROM sessions WHERE deleted_at IS NULL ORDER BY COALESCE(last_active_at, created_at) DESC")
|
|
260
|
+
.all();
|
|
87
261
|
}
|
|
262
|
+
/** Returns live sessions only. Soft-deleted (tombstone) rows are hidden. */
|
|
88
263
|
getSession(id) {
|
|
264
|
+
return this.db
|
|
265
|
+
.prepare("SELECT * FROM sessions WHERE id = ? AND deleted_at IS NULL")
|
|
266
|
+
.get(id);
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Returns a session row even if soft-deleted. Used by the public share
|
|
270
|
+
* viewer, which must keep working after the owner deletes the source
|
|
271
|
+
* session (events stay alive as long as any active share references them).
|
|
272
|
+
*/
|
|
273
|
+
getSessionIncludingDeleted(id) {
|
|
89
274
|
return this.db.prepare("SELECT * FROM sessions WHERE id = ?").get(id);
|
|
90
275
|
}
|
|
276
|
+
/**
|
|
277
|
+
* Delete a session. If any active (published) shares reference it, the
|
|
278
|
+
* session row + events are kept (soft-delete via deleted_at) so the
|
|
279
|
+
* shared snapshot remains viewable. Otherwise everything is hard-
|
|
280
|
+
* deleted. Preview shares (shared_at IS NULL) are always cleared:
|
|
281
|
+
* unpublished drafts share the session's lifecycle.
|
|
282
|
+
*
|
|
283
|
+
* Returns "hard" if the row + events were physically removed, "soft"
|
|
284
|
+
* if the row was tombstoned because shares still reference it. Callers
|
|
285
|
+
* use this to decide whether to clean up filesystem artefacts (images).
|
|
286
|
+
*/
|
|
91
287
|
deleteSession(id) {
|
|
288
|
+
// Drop preview shares regardless — they are owner-only drafts and
|
|
289
|
+
// share the session's lifecycle by design.
|
|
290
|
+
this.db
|
|
291
|
+
.prepare("DELETE FROM shares WHERE session_id = ? AND shared_at IS NULL")
|
|
292
|
+
.run(id);
|
|
293
|
+
const activeShareCount = this.db
|
|
294
|
+
.prepare("SELECT COUNT(*) AS n FROM shares WHERE session_id = ? AND shared_at IS NOT NULL")
|
|
295
|
+
.get(id).n;
|
|
296
|
+
this.db.prepare("DELETE FROM client_ops WHERE session_id = ?").run(id);
|
|
297
|
+
if (activeShareCount > 0) {
|
|
298
|
+
// Soft-delete: keep events + sessions row so public share viewers
|
|
299
|
+
// can still resolve. revokeShare() / reapTombstoneIfOrphaned()
|
|
300
|
+
// finishes the job once the last share is gone.
|
|
301
|
+
this.db
|
|
302
|
+
.prepare("UPDATE sessions SET deleted_at = ? WHERE id = ?")
|
|
303
|
+
.run(Date.now(), id);
|
|
304
|
+
return "soft";
|
|
305
|
+
}
|
|
92
306
|
this.db.prepare("DELETE FROM events WHERE session_id = ?").run(id);
|
|
93
307
|
this.db.prepare("DELETE FROM sessions WHERE id = ?").run(id);
|
|
308
|
+
return "hard";
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Hard-delete events + sessions row for a session that has been soft-
|
|
312
|
+
* deleted and whose last share was just revoked. No-op if the session
|
|
313
|
+
* is still live (deleted_at IS NULL) or still has active shares.
|
|
314
|
+
* Returns true if a tombstone was reaped.
|
|
315
|
+
*/
|
|
316
|
+
reapTombstoneIfOrphaned(sessionId) {
|
|
317
|
+
const sess = this.db
|
|
318
|
+
.prepare("SELECT id FROM sessions WHERE id = ? AND deleted_at IS NOT NULL")
|
|
319
|
+
.get(sessionId);
|
|
320
|
+
if (!sess)
|
|
321
|
+
return false;
|
|
322
|
+
const remaining = this.db
|
|
323
|
+
.prepare("SELECT COUNT(*) AS n FROM shares WHERE session_id = ?")
|
|
324
|
+
.get(sessionId).n;
|
|
325
|
+
if (remaining > 0)
|
|
326
|
+
return false;
|
|
327
|
+
this.db.prepare("DELETE FROM events WHERE session_id = ?").run(sessionId);
|
|
328
|
+
this.db.prepare("DELETE FROM sessions WHERE id = ?").run(sessionId);
|
|
329
|
+
return true;
|
|
94
330
|
}
|
|
95
331
|
/** Delete sessions that have zero events and are older than minAgeS seconds. Returns IDs deleted. */
|
|
96
332
|
deleteEmptySessions(minAgeS) {
|
|
97
|
-
const empties = this.db
|
|
333
|
+
const empties = this.db
|
|
334
|
+
.prepare(`
|
|
98
335
|
SELECT s.id FROM sessions s
|
|
99
336
|
LEFT JOIN events e ON e.session_id = s.id
|
|
100
337
|
WHERE e.id IS NULL
|
|
101
338
|
AND strftime('%s', 'now') - strftime('%s', s.created_at) >= ?
|
|
102
|
-
`)
|
|
339
|
+
`)
|
|
340
|
+
.all(minAgeS);
|
|
103
341
|
if (empties.length === 0)
|
|
104
342
|
return [];
|
|
105
343
|
const del = this.db.prepare("DELETE FROM sessions WHERE id = ?");
|
|
106
344
|
for (const r of empties)
|
|
107
345
|
del.run(r.id);
|
|
108
|
-
return empties.map(r => r.id);
|
|
346
|
+
return empties.map((r) => r.id);
|
|
109
347
|
}
|
|
110
348
|
updateSessionTitle(id, title) {
|
|
111
|
-
this.db
|
|
349
|
+
this.db
|
|
350
|
+
.prepare("UPDATE sessions SET title = ? WHERE id = ?")
|
|
351
|
+
.run(title, id);
|
|
112
352
|
}
|
|
113
353
|
updateSessionLastActive(id) {
|
|
114
|
-
this.db
|
|
354
|
+
this.db
|
|
355
|
+
.prepare("UPDATE sessions SET last_active_at = strftime('%Y-%m-%d %H:%M:%f', 'now') WHERE id = ?")
|
|
356
|
+
.run(id);
|
|
115
357
|
}
|
|
116
358
|
/** Update a config option value (model, mode, reasoning_effort) for a session. */
|
|
117
359
|
updateSessionConfig(id, configId, value) {
|
|
118
|
-
const column = {
|
|
360
|
+
const column = {
|
|
361
|
+
model: "model",
|
|
362
|
+
mode: "mode",
|
|
363
|
+
reasoning_effort: "reasoning_effort",
|
|
364
|
+
}[configId];
|
|
119
365
|
if (!column)
|
|
120
366
|
return;
|
|
121
|
-
this.db
|
|
367
|
+
this.db
|
|
368
|
+
.prepare(`UPDATE sessions SET ${column} = ? WHERE id = ?`)
|
|
369
|
+
.run(value, id);
|
|
122
370
|
}
|
|
123
|
-
saveEvent(sessionId, type, data = {}) {
|
|
124
|
-
const seq = this.db
|
|
125
|
-
|
|
126
|
-
|
|
371
|
+
saveEvent(sessionId, type, data = {}, opts) {
|
|
372
|
+
const seq = this.db
|
|
373
|
+
.prepare("SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM events WHERE session_id = ?")
|
|
374
|
+
.get(sessionId).next;
|
|
375
|
+
// Origin marker is required. Every writer must pass an explicit value;
|
|
376
|
+
// missing/empty fails loudly so a forgotten retrofit can't silently
|
|
377
|
+
// mis-bucket a row in production. Valid values:
|
|
378
|
+
// 'user' | 'system' | 'agent' | 'msg:<id>'.
|
|
379
|
+
const fromRef = opts?.from_ref;
|
|
380
|
+
if (!fromRef) {
|
|
381
|
+
throw new Error(`saveEvent: from_ref is required (type=${type} session=${sessionId.slice(0, 8)}) — pass { from_ref: 'user' | 'system' | 'agent' | 'msg:<id>' }`);
|
|
382
|
+
}
|
|
383
|
+
this.db
|
|
384
|
+
.prepare("INSERT INTO events (session_id, seq, type, data, from_ref) VALUES (?, ?, ?, ?, ?)")
|
|
385
|
+
.run(sessionId, seq, type, JSON.stringify(data), fromRef);
|
|
386
|
+
return this.db
|
|
387
|
+
.prepare("SELECT * FROM events WHERE session_id = ? AND seq = ?")
|
|
127
388
|
.get(sessionId, seq);
|
|
128
389
|
}
|
|
129
390
|
getEvents(sessionId, opts) {
|
|
@@ -149,7 +410,9 @@ export class Store {
|
|
|
149
410
|
params.push(opts.limit);
|
|
150
411
|
return this.db.prepare(sql).all(...params);
|
|
151
412
|
}
|
|
152
|
-
return this.db
|
|
413
|
+
return this.db
|
|
414
|
+
.prepare(`SELECT * FROM events WHERE ${where} ORDER BY seq`)
|
|
415
|
+
.all(...params);
|
|
153
416
|
}
|
|
154
417
|
getEventCount(sessionId, opts) {
|
|
155
418
|
let query = "SELECT COUNT(*) as count FROM events WHERE session_id = ?";
|
|
@@ -159,9 +422,17 @@ export class Store {
|
|
|
159
422
|
}
|
|
160
423
|
return this.db.prepare(query).get(...params).count;
|
|
161
424
|
}
|
|
425
|
+
/** Highest seq of any stored event for this session (0 when empty). */
|
|
426
|
+
getLastEventSeq(sessionId) {
|
|
427
|
+
const row = this.db
|
|
428
|
+
.prepare("SELECT COALESCE(MAX(seq), 0) AS seq FROM events WHERE session_id = ?")
|
|
429
|
+
.get(sessionId);
|
|
430
|
+
return row.seq;
|
|
431
|
+
}
|
|
162
432
|
/** Check if the most recent agent turn was interrupted (user_message without a following prompt_done). */
|
|
163
433
|
hasInterruptedTurn(sessionId) {
|
|
164
|
-
const row = this.db
|
|
434
|
+
const row = this.db
|
|
435
|
+
.prepare(`
|
|
165
436
|
SELECT 1 FROM events
|
|
166
437
|
WHERE session_id = ? AND type = 'user_message'
|
|
167
438
|
AND seq > COALESCE(
|
|
@@ -169,42 +440,377 @@ export class Store {
|
|
|
169
440
|
0
|
|
170
441
|
)
|
|
171
442
|
LIMIT 1
|
|
172
|
-
`)
|
|
173
|
-
|
|
443
|
+
`)
|
|
444
|
+
.get(sessionId, sessionId);
|
|
445
|
+
return Boolean(row);
|
|
174
446
|
}
|
|
175
447
|
// --- Push subscriptions ---
|
|
176
448
|
saveSubscription(endpoint, auth, p256dh) {
|
|
177
|
-
this.db
|
|
449
|
+
this.db
|
|
450
|
+
.prepare(`INSERT INTO push_subscriptions (endpoint, auth, p256dh)
|
|
178
451
|
VALUES (?, ?, ?)
|
|
179
|
-
ON CONFLICT(endpoint) DO UPDATE SET auth = excluded.auth, p256dh = excluded.p256dh`)
|
|
452
|
+
ON CONFLICT(endpoint) DO UPDATE SET auth = excluded.auth, p256dh = excluded.p256dh`)
|
|
453
|
+
.run(endpoint, auth, p256dh);
|
|
180
454
|
}
|
|
181
455
|
removeSubscription(endpoint) {
|
|
182
|
-
this.db
|
|
456
|
+
this.db
|
|
457
|
+
.prepare("DELETE FROM push_subscriptions WHERE endpoint = ?")
|
|
458
|
+
.run(endpoint);
|
|
183
459
|
}
|
|
184
460
|
getAllSubscriptions() {
|
|
185
|
-
return this.db
|
|
461
|
+
return this.db
|
|
462
|
+
.prepare("SELECT * FROM push_subscriptions")
|
|
463
|
+
.all();
|
|
186
464
|
}
|
|
187
465
|
// --- Recent paths ---
|
|
188
466
|
touchRecentPath(cwd) {
|
|
189
|
-
this.db
|
|
467
|
+
this.db
|
|
468
|
+
.prepare(`INSERT INTO recent_paths (cwd, last_used_at)
|
|
190
469
|
VALUES (?, strftime('%Y-%m-%d %H:%M:%f', 'now'))
|
|
191
|
-
ON CONFLICT(cwd) DO UPDATE SET last_used_at = strftime('%Y-%m-%d %H:%M:%f', 'now')`)
|
|
470
|
+
ON CONFLICT(cwd) DO UPDATE SET last_used_at = strftime('%Y-%m-%d %H:%M:%f', 'now')`)
|
|
471
|
+
.run(cwd);
|
|
192
472
|
}
|
|
193
473
|
listRecentPaths(opts) {
|
|
194
474
|
const ttl = opts?.ttlDays ?? 0;
|
|
195
475
|
if (ttl > 0) {
|
|
196
|
-
this.db
|
|
476
|
+
this.db
|
|
477
|
+
.prepare("DELETE FROM recent_paths WHERE last_used_at < strftime('%Y-%m-%d %H:%M:%f', 'now', ?)")
|
|
478
|
+
.run(`-${ttl} days`);
|
|
197
479
|
}
|
|
198
480
|
const limit = opts?.limit;
|
|
199
481
|
if (limit && limit > 0) {
|
|
200
|
-
return this.db
|
|
482
|
+
return this.db
|
|
483
|
+
.prepare("SELECT cwd, last_used_at FROM recent_paths ORDER BY last_used_at DESC LIMIT ?")
|
|
484
|
+
.all(limit);
|
|
201
485
|
}
|
|
202
|
-
return this.db
|
|
486
|
+
return this.db
|
|
487
|
+
.prepare("SELECT cwd, last_used_at FROM recent_paths ORDER BY last_used_at DESC")
|
|
488
|
+
.all();
|
|
203
489
|
}
|
|
204
490
|
deleteRecentPath(cwd) {
|
|
205
491
|
this.db.prepare("DELETE FROM recent_paths WHERE cwd = ?").run(cwd);
|
|
206
492
|
}
|
|
493
|
+
// ===== messages (pending unbound notifications) =====
|
|
494
|
+
createMessage(input) {
|
|
495
|
+
this.db
|
|
496
|
+
.prepare(`INSERT INTO messages
|
|
497
|
+
(id, from_ref, from_label, to_ref, deliver, dedup_key, title, body, cwd, created_at)
|
|
498
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
499
|
+
.run(input.id, input.from_ref, input.from_label, input.to_ref, input.deliver, input.dedup_key, input.title, input.body, input.cwd, input.created_at);
|
|
500
|
+
}
|
|
501
|
+
getMessage(id) {
|
|
502
|
+
return this.db.prepare("SELECT * FROM messages WHERE id = ?").get(id);
|
|
503
|
+
}
|
|
504
|
+
listUnprocessed() {
|
|
505
|
+
return this.db
|
|
506
|
+
.prepare("SELECT * FROM messages ORDER BY created_at DESC")
|
|
507
|
+
.all();
|
|
508
|
+
}
|
|
509
|
+
deleteMessage(id) {
|
|
510
|
+
const info = this.db.prepare("DELETE FROM messages WHERE id = ?").run(id);
|
|
511
|
+
return info.changes;
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Delete unprocessed messages whose created_at is older than the given
|
|
515
|
+
* epoch-ms threshold. Returns the number of rows removed.
|
|
516
|
+
*/
|
|
517
|
+
deleteOlderThan(thresholdMs) {
|
|
518
|
+
const info = this.db
|
|
519
|
+
.prepare("DELETE FROM messages WHERE created_at < ?")
|
|
520
|
+
.run(thresholdMs);
|
|
521
|
+
return info.changes;
|
|
522
|
+
}
|
|
523
|
+
/** Find an existing unprocessed message matching (to_ref, dedup_key) for server-side supersede. */
|
|
524
|
+
findBySupersede(to_ref, dedup_key) {
|
|
525
|
+
if (!dedup_key)
|
|
526
|
+
return undefined;
|
|
527
|
+
return this.db
|
|
528
|
+
.prepare("SELECT * FROM messages WHERE to_ref = ? AND dedup_key = ? LIMIT 1")
|
|
529
|
+
.get(to_ref, dedup_key);
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Atomic consume: create a session, append a `message` event whose data
|
|
533
|
+
* includes `message_id`, and delete the messages row -- all in a single
|
|
534
|
+
* transaction. If the row is already gone, returns the prior session id
|
|
535
|
+
* by looking up the historic `message` event; callers can treat this as
|
|
536
|
+
* idempotent.
|
|
537
|
+
*/
|
|
538
|
+
consumeMessageTx(messageId, opts) {
|
|
539
|
+
// Fast idempotency pre-check outside the tx to avoid the cost of
|
|
540
|
+
// opening one for an already-resolved message.
|
|
541
|
+
const existing = this.findMessageEventSession(messageId);
|
|
542
|
+
if (existing) {
|
|
543
|
+
return { sessionId: existing, alreadyConsumed: true };
|
|
544
|
+
}
|
|
545
|
+
const row = this.getMessage(messageId);
|
|
546
|
+
if (!row) {
|
|
547
|
+
throw new Error(`consumeMessageTx: message not found (id=${messageId})`);
|
|
548
|
+
}
|
|
549
|
+
const tx = this.db.transaction(() => {
|
|
550
|
+
this.db
|
|
551
|
+
.prepare("INSERT INTO sessions (id, cwd, source) VALUES (?, ?, ?)")
|
|
552
|
+
.run(opts.sessionId, opts.cwd ?? row.cwd ?? "", "message");
|
|
553
|
+
// Append message event via saveEvent so seq logic applies.
|
|
554
|
+
this.saveEvent(opts.sessionId, "message", {
|
|
555
|
+
message_id: row.id,
|
|
556
|
+
from_ref: row.from_ref,
|
|
557
|
+
from_label: row.from_label,
|
|
558
|
+
title: row.title,
|
|
559
|
+
body: row.body,
|
|
560
|
+
cwd: row.cwd,
|
|
561
|
+
}, { from_ref: row.from_ref });
|
|
562
|
+
const del = this.db
|
|
563
|
+
.prepare("DELETE FROM messages WHERE id = ?")
|
|
564
|
+
.run(messageId);
|
|
565
|
+
if (del.changes === 0) {
|
|
566
|
+
// Should never happen -- we just fetched the row above. If it does,
|
|
567
|
+
// roll back via throw.
|
|
568
|
+
throw new Error(`consumeMessageTx: row vanished mid-tx (id=${messageId})`);
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
tx();
|
|
572
|
+
return { sessionId: opts.sessionId, alreadyConsumed: false };
|
|
573
|
+
}
|
|
574
|
+
findMessageEventSession(messageId) {
|
|
575
|
+
const row = this.db
|
|
576
|
+
.prepare(`SELECT session_id FROM events
|
|
577
|
+
WHERE type = 'message'
|
|
578
|
+
AND json_extract(data, '$.message_id') = ?
|
|
579
|
+
LIMIT 1`)
|
|
580
|
+
.get(messageId);
|
|
581
|
+
return row?.session_id;
|
|
582
|
+
}
|
|
583
|
+
// --- client-server-split M2: client_ops idempotency ---
|
|
584
|
+
/**
|
|
585
|
+
* Look up a previously-cached response for (sessionId, clientOpId).
|
|
586
|
+
* Returns the parsed result or null if no cached entry exists.
|
|
587
|
+
*/
|
|
588
|
+
getClientOp(sessionId, clientOpId) {
|
|
589
|
+
const row = this.db
|
|
590
|
+
.prepare("SELECT result_json FROM client_ops WHERE session_id = ? AND client_op_id = ?")
|
|
591
|
+
.get(sessionId, clientOpId);
|
|
592
|
+
if (!row)
|
|
593
|
+
return null;
|
|
594
|
+
try {
|
|
595
|
+
return JSON.parse(row.result_json);
|
|
596
|
+
}
|
|
597
|
+
catch {
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Cache a successful response for (sessionId, clientOpId). Uses
|
|
603
|
+
* INSERT OR IGNORE so a concurrent winner is preserved.
|
|
604
|
+
*/
|
|
605
|
+
saveClientOp(sessionId, clientOpId, result) {
|
|
606
|
+
this.db
|
|
607
|
+
.prepare("INSERT OR IGNORE INTO client_ops (session_id, client_op_id, result_json) VALUES (?, ?, ?)")
|
|
608
|
+
.run(sessionId, clientOpId, JSON.stringify(result));
|
|
609
|
+
}
|
|
610
|
+
/** Prune client_ops rows older than `maxAgeMs` (milliseconds). Returns rows deleted. */
|
|
611
|
+
pruneClientOps(maxAgeMs) {
|
|
612
|
+
const seconds = Math.floor(maxAgeMs / 1000);
|
|
613
|
+
const info = this.db
|
|
614
|
+
.prepare("DELETE FROM client_ops WHERE strftime('%s','now') - strftime('%s', created_at) >= ?")
|
|
615
|
+
.run(seconds);
|
|
616
|
+
return info.changes;
|
|
617
|
+
}
|
|
618
|
+
// ===== attachments (uploads-plan v2.6 §1.2) =====
|
|
619
|
+
/**
|
|
620
|
+
* Insert a new attachment row. upload_seq is computed as
|
|
621
|
+
* `COALESCE(MAX(events.seq), 0)` for the session at insert time. Callers
|
|
622
|
+
* must have already written the file under
|
|
623
|
+
* <data_dir>/sessions/<sid>/attachments/<id>.<ext> and resolved its
|
|
624
|
+
* realpath. The row is bound by FK CASCADE to its session.
|
|
625
|
+
*/
|
|
626
|
+
insertAttachment(input) {
|
|
627
|
+
const seqRow = this.db
|
|
628
|
+
.prepare("SELECT COALESCE(MAX(seq), 0) AS s FROM events WHERE session_id = ?")
|
|
629
|
+
.get(input.sessionId);
|
|
630
|
+
const uploadSeq = seqRow.s;
|
|
631
|
+
this.db
|
|
632
|
+
.prepare(`INSERT INTO attachments
|
|
633
|
+
(id, session_id, kind, name, mime, size, realpath, upload_seq, width, height)
|
|
634
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
635
|
+
.run(input.id, input.sessionId, input.kind, input.name, input.mime, input.size, input.realpath, uploadSeq, input.width ?? null, input.height ?? null);
|
|
636
|
+
return this.db
|
|
637
|
+
.prepare("SELECT * FROM attachments WHERE id = ?")
|
|
638
|
+
.get(input.id);
|
|
639
|
+
}
|
|
640
|
+
/** Look up an attachment row by (session_id, id). */
|
|
641
|
+
getAttachment(sessionId, id) {
|
|
642
|
+
return this.db
|
|
643
|
+
.prepare("SELECT * FROM attachments WHERE session_id = ? AND id = ?")
|
|
644
|
+
.get(sessionId, id);
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* For the permission interceptor: list all attachment realpaths for a
|
|
648
|
+
* session so we can compare against `toolCall.locations[].path` after
|
|
649
|
+
* realpath-ing each side. The set is small (≤ a few hundred per
|
|
650
|
+
* session) so we hand back an in-memory array.
|
|
651
|
+
*/
|
|
652
|
+
listAttachmentRealpaths(sessionId) {
|
|
653
|
+
const rows = this.db
|
|
654
|
+
.prepare("SELECT realpath FROM attachments WHERE session_id = ?")
|
|
655
|
+
.all(sessionId);
|
|
656
|
+
return rows.map((r) => r.realpath);
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* For the egress label-rewrite (CLAUDE.md "Attachment label egress
|
|
660
|
+
* rewrite"): list each attachment's id, user-supplied name, and
|
|
661
|
+
* realpath for a session. Caller (session-manager label cache)
|
|
662
|
+
* derives the label string `<name> [#<id4>]`. Pure DB read; no
|
|
663
|
+
* realpath syscalls (the stored realpath is already resolved at
|
|
664
|
+
* upload time).
|
|
665
|
+
*/
|
|
666
|
+
listAttachmentLabels(sessionId) {
|
|
667
|
+
const rows = this.db
|
|
668
|
+
.prepare("SELECT id, name, realpath FROM attachments WHERE session_id = ?")
|
|
669
|
+
.all(sessionId);
|
|
670
|
+
return rows;
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* For the share viewer / GET serve path: look up an attachment by the
|
|
674
|
+
* filename portion of its URL (`<id>.<ext>`). The id is the uuid prefix
|
|
675
|
+
* of the file segment.
|
|
676
|
+
*/
|
|
677
|
+
getAttachmentByFile(sessionId, file) {
|
|
678
|
+
const dot = file.indexOf(".");
|
|
679
|
+
const id = dot === -1 ? file : file.slice(0, dot);
|
|
680
|
+
return this.getAttachment(sessionId, id);
|
|
681
|
+
}
|
|
207
682
|
close() {
|
|
208
683
|
this.db.close();
|
|
209
684
|
}
|
|
685
|
+
// ===== shares (share-plan §4.1) =====
|
|
686
|
+
/**
|
|
687
|
+
* Insert a new preview row. Caller must have flushed buffered chunks
|
|
688
|
+
* and computed snapshotSeq in the same synchronous tick (share-plan
|
|
689
|
+
* §4.3 R1-c2). Returns the inserted row.
|
|
690
|
+
*
|
|
691
|
+
* May throw SQLITE_CONSTRAINT_UNIQUE on shares_one_active_preview;
|
|
692
|
+
* callers handle via findActivePreviewBySession fallback (§4.3 R2-c2).
|
|
693
|
+
*/
|
|
694
|
+
insertSharePreview(input) {
|
|
695
|
+
this.db
|
|
696
|
+
.prepare(`INSERT INTO shares (token, session_id, share_snapshot_seq, ttl_hours, display_name, owner_label)
|
|
697
|
+
VALUES (?, ?, ?, ?, ?, ?)`)
|
|
698
|
+
.run(input.token, input.sessionId, input.snapshotSeq, input.ttlHours ?? null, input.displayName ?? null, input.ownerLabel ?? null);
|
|
699
|
+
return this.getShareByToken(input.token);
|
|
700
|
+
}
|
|
701
|
+
/** SELECT the single un-activated preview for this session (partial unique). */
|
|
702
|
+
findActivePreviewBySession(sessionId) {
|
|
703
|
+
return this.db
|
|
704
|
+
.prepare(`SELECT * FROM shares
|
|
705
|
+
WHERE session_id = ? AND shared_at IS NULL
|
|
706
|
+
ORDER BY created_at DESC LIMIT 1`)
|
|
707
|
+
.get(sessionId);
|
|
708
|
+
}
|
|
709
|
+
getShareByToken(token) {
|
|
710
|
+
return this.db
|
|
711
|
+
.prepare("SELECT * FROM shares WHERE token = ?")
|
|
712
|
+
.get(token);
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Activate preview: shared_at NULL → now(). Returns true if row moved
|
|
716
|
+
* (0 → 1 rows affected). False if preview already activated, revoked,
|
|
717
|
+
* or token doesn't exist.
|
|
718
|
+
*/
|
|
719
|
+
activateShare(token, opts) {
|
|
720
|
+
const now = Date.now();
|
|
721
|
+
let sql = "UPDATE shares SET shared_at = ?";
|
|
722
|
+
const params = [now];
|
|
723
|
+
if (opts && "displayName" in opts) {
|
|
724
|
+
sql += ", display_name = ?";
|
|
725
|
+
params.push(opts.displayName ?? null);
|
|
726
|
+
}
|
|
727
|
+
if (opts && "ownerLabel" in opts) {
|
|
728
|
+
sql += ", owner_label = ?";
|
|
729
|
+
params.push(opts.ownerLabel ?? null);
|
|
730
|
+
}
|
|
731
|
+
sql += " WHERE token = ? AND shared_at IS NULL";
|
|
732
|
+
params.push(token);
|
|
733
|
+
const info = this.db.prepare(sql).run(...params);
|
|
734
|
+
return info.changes > 0;
|
|
735
|
+
}
|
|
736
|
+
/** Hard-delete a share row. Returns true if the row existed. */
|
|
737
|
+
revokeShare(token) {
|
|
738
|
+
const info = this.db
|
|
739
|
+
.prepare("DELETE FROM shares WHERE token = ?")
|
|
740
|
+
.run(token);
|
|
741
|
+
return info.changes > 0;
|
|
742
|
+
}
|
|
743
|
+
/** Update only owner_label (PATCH route). Caller validates the value first. */
|
|
744
|
+
updateShareOwnerLabel(token, label) {
|
|
745
|
+
const info = this.db
|
|
746
|
+
.prepare("UPDATE shares SET owner_label = ? WHERE token = ?")
|
|
747
|
+
.run(label, token);
|
|
748
|
+
return info.changes > 0;
|
|
749
|
+
}
|
|
750
|
+
/** Update only display_name (PATCH route). Caller validates the value first. */
|
|
751
|
+
updateShareDisplayName(token, name) {
|
|
752
|
+
const info = this.db
|
|
753
|
+
.prepare("UPDATE shares SET display_name = ? WHERE token = ?")
|
|
754
|
+
.run(name, token);
|
|
755
|
+
return info.changes > 0;
|
|
756
|
+
}
|
|
757
|
+
/** Owner list — every share row (preview + active). */
|
|
758
|
+
listOwnerShares() {
|
|
759
|
+
return this.db
|
|
760
|
+
.prepare(`SELECT
|
|
761
|
+
s.token AS token,
|
|
762
|
+
s.session_id AS session_id,
|
|
763
|
+
sess.title AS session_title,
|
|
764
|
+
s.shared_at AS shared_at,
|
|
765
|
+
s.created_at AS created_at,
|
|
766
|
+
s.display_name AS display_name,
|
|
767
|
+
s.owner_label AS owner_label,
|
|
768
|
+
s.share_snapshot_seq AS share_snapshot_seq,
|
|
769
|
+
s.ttl_hours AS ttl_hours,
|
|
770
|
+
s.last_accessed_at AS last_accessed_at
|
|
771
|
+
FROM shares s
|
|
772
|
+
LEFT JOIN sessions sess ON sess.id = s.session_id
|
|
773
|
+
ORDER BY s.created_at DESC`)
|
|
774
|
+
.all();
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* One-time write of last_accessed_at (share-plan §4.1 R2 ENG-6a +
|
|
778
|
+
* OPS-R2-1): only fire when currently NULL to avoid write amplification.
|
|
779
|
+
* Returns true if the field was set by this call.
|
|
780
|
+
*/
|
|
781
|
+
touchShareAccessed(token) {
|
|
782
|
+
const info = this.db
|
|
783
|
+
.prepare("UPDATE shares SET last_accessed_at = ? WHERE token = ? AND last_accessed_at IS NULL")
|
|
784
|
+
.run(Date.now(), token);
|
|
785
|
+
return info.changes > 0;
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Lazy prune of preview rows older than 24h (share-plan §4.1).
|
|
789
|
+
* `now` is injectable for tests. Activated rows (shared_at set) are
|
|
790
|
+
* NEVER touched — only orphaned previews are GC'd.
|
|
791
|
+
*/
|
|
792
|
+
pruneStalePreviews(now = Date.now()) {
|
|
793
|
+
const cutoff = now - 24 * 60 * 60 * 1000;
|
|
794
|
+
const info = this.db
|
|
795
|
+
.prepare("DELETE FROM shares WHERE shared_at IS NULL AND created_at < ?")
|
|
796
|
+
.run(cutoff);
|
|
797
|
+
return info.changes;
|
|
798
|
+
}
|
|
799
|
+
// ===== owner_prefs =====
|
|
800
|
+
getOwnerPref(key) {
|
|
801
|
+
const row = this.db
|
|
802
|
+
.prepare("SELECT value FROM owner_prefs WHERE key = ?")
|
|
803
|
+
.get(key);
|
|
804
|
+
return row?.value;
|
|
805
|
+
}
|
|
806
|
+
setOwnerPref(key, value) {
|
|
807
|
+
this.db
|
|
808
|
+
.prepare(`INSERT INTO owner_prefs (key, value, updated_at)
|
|
809
|
+
VALUES (?, ?, ?)
|
|
810
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`)
|
|
811
|
+
.run(key, value, Date.now());
|
|
812
|
+
}
|
|
813
|
+
clearOwnerPref(key) {
|
|
814
|
+
this.db.prepare("DELETE FROM owner_prefs WHERE key = ?").run(key);
|
|
815
|
+
}
|
|
210
816
|
}
|