@deepseek-ai/dsh-session-query-sqlite 0.0.1-rc.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/LICENSE +28 -0
- package/README.i18n.yaml +6 -0
- package/README.md +57 -0
- package/README.zh.md +57 -0
- package/lib/index.js +1129 -0
- package/lib/invariant.js +23 -0
- package/lib/types/index.d.ts +103 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/query.d.ts +106 -0
- package/lib/types/schema.d.ts +16 -0
- package/package.json +58 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1129 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
3
|
+
import z from "@deepseek-ai/schemastery";
|
|
4
|
+
import SessionQueryService, { SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY, SESSION_QUERY_READ_WINDOW_MAX, SessionQueryError, SessionSearchCursor, assertSessionHeadersCompatible, buildSessionEventSearchDocuments, materializeSessionEventResultFilters, materializeSessionResultFilters } from "@deepseek-ai/dsh-session-query";
|
|
5
|
+
import { mkdir, open } from "node:fs/promises";
|
|
6
|
+
import { dirname, resolve } from "node:path";
|
|
7
|
+
//#region lib/types/schema.js
|
|
8
|
+
/** SQLite schema for the disposable session full-text read model. */
|
|
9
|
+
/** Current derived-index schema version. Incompatible versions reset in place. */
|
|
10
|
+
const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 8;
|
|
11
|
+
/** SQLite application id protecting unrelated databases from derived resets. */
|
|
12
|
+
const SESSION_QUERY_SQLITE_APPLICATION_ID = 1146308689;
|
|
13
|
+
const DERIVED_USER_TABLES = new Set([
|
|
14
|
+
"search_state",
|
|
15
|
+
"persisted_sessions",
|
|
16
|
+
"persisted_docs",
|
|
17
|
+
"persisted_docs_data",
|
|
18
|
+
"persisted_docs_idx",
|
|
19
|
+
"persisted_docs_content",
|
|
20
|
+
"persisted_docs_docsize",
|
|
21
|
+
"persisted_docs_config"
|
|
22
|
+
]);
|
|
23
|
+
/**
|
|
24
|
+
* Exclusively create a missing database file with owner-only permissions.
|
|
25
|
+
* Existing files retain their modes, and errors other than `EEXIST` propagate.
|
|
26
|
+
*/
|
|
27
|
+
async function createDatabaseFile(path) {
|
|
28
|
+
try {
|
|
29
|
+
await (await open(path, "wx", 384)).close();
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error.code !== "EEXIST") throw error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Open, validate, and initialize persistent and connection-local schemas.
|
|
36
|
+
* @param path - dedicated derived-index path or `:memory:`; missing filesystem paths are created owner-only.
|
|
37
|
+
* @param journalMode - validated SQLite journal mode.
|
|
38
|
+
* @returns initialized database handle owned by the search service.
|
|
39
|
+
*/
|
|
40
|
+
async function openSearchDatabase(path, journalMode) {
|
|
41
|
+
const actual = path === ":memory:" ? path : resolve(path);
|
|
42
|
+
if (actual !== ":memory:") {
|
|
43
|
+
await mkdir(dirname(actual), {
|
|
44
|
+
recursive: true,
|
|
45
|
+
mode: 448
|
|
46
|
+
});
|
|
47
|
+
await createDatabaseFile(actual);
|
|
48
|
+
}
|
|
49
|
+
const { DatabaseSync } = await import("node:sqlite");
|
|
50
|
+
const db = new DatabaseSync(actual);
|
|
51
|
+
try {
|
|
52
|
+
const { application_id: applicationId } = db.prepare("PRAGMA application_id").get();
|
|
53
|
+
const { user_version: version } = db.prepare("PRAGMA user_version").get();
|
|
54
|
+
const userTables = listUserTables(db);
|
|
55
|
+
if (applicationId !== 0 && applicationId !== 1146308689) throw new Error(`session-search database at "${actual}" belongs to another application`);
|
|
56
|
+
if (applicationId === 0 && userTables.length > 0) throw new Error(`session-search database at "${actual}" is not an empty or recognized derived index`);
|
|
57
|
+
if (applicationId === 1146308689) {
|
|
58
|
+
assertDerivedUserTables(actual, userTables);
|
|
59
|
+
if (version !== 8) resetDerivedSchema(db, userTables);
|
|
60
|
+
}
|
|
61
|
+
db.exec(`PRAGMA journal_mode = ${journalMode.toUpperCase()}`);
|
|
62
|
+
ensurePersistentSchema(db);
|
|
63
|
+
ensureTemporarySchema(db);
|
|
64
|
+
return db;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
db.close();
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function listUserTables(db) {
|
|
71
|
+
return db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT GLOB 'sqlite_*' ORDER BY name").all().map((row) => row.name);
|
|
72
|
+
}
|
|
73
|
+
function assertDerivedUserTables(path, userTables) {
|
|
74
|
+
const unknownTables = userTables.filter((name) => !DERIVED_USER_TABLES.has(name));
|
|
75
|
+
if (unknownTables.length > 0) throw new Error(`session-search database at "${path}" has unrecognized user tables: ${unknownTables.join(", ")}`);
|
|
76
|
+
}
|
|
77
|
+
function resetDerivedSchema(db, userTables) {
|
|
78
|
+
for (const name of userTables) db.exec(`DROP TABLE IF EXISTS ${quoteIdentifier(name)}`);
|
|
79
|
+
db.exec("PRAGMA user_version = 0");
|
|
80
|
+
}
|
|
81
|
+
function ensurePersistentSchema(db) {
|
|
82
|
+
db.exec(`PRAGMA application_id = ${SESSION_QUERY_SQLITE_APPLICATION_ID}`);
|
|
83
|
+
db.exec(`
|
|
84
|
+
CREATE TABLE IF NOT EXISTS search_state (
|
|
85
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
86
|
+
global_generation INTEGER NOT NULL
|
|
87
|
+
) STRICT
|
|
88
|
+
`);
|
|
89
|
+
db.exec("INSERT OR IGNORE INTO search_state (singleton, global_generation) VALUES (1, 0)");
|
|
90
|
+
db.exec(`
|
|
91
|
+
CREATE TABLE IF NOT EXISTS persisted_sessions (
|
|
92
|
+
id TEXT PRIMARY KEY,
|
|
93
|
+
version INTEGER NOT NULL,
|
|
94
|
+
created_at INTEGER NOT NULL,
|
|
95
|
+
cwd TEXT,
|
|
96
|
+
parent_session TEXT,
|
|
97
|
+
seed_length INTEGER,
|
|
98
|
+
delegation_depth INTEGER,
|
|
99
|
+
agent_preset TEXT,
|
|
100
|
+
revision TEXT NOT NULL,
|
|
101
|
+
generation INTEGER NOT NULL
|
|
102
|
+
) STRICT
|
|
103
|
+
`);
|
|
104
|
+
db.exec(`
|
|
105
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS persisted_docs USING fts5(
|
|
106
|
+
text,
|
|
107
|
+
session_id UNINDEXED,
|
|
108
|
+
seq UNINDEXED,
|
|
109
|
+
type UNINDEXED,
|
|
110
|
+
time UNINDEXED,
|
|
111
|
+
surface UNINDEXED,
|
|
112
|
+
codepoint_length UNINDEXED,
|
|
113
|
+
tokenize = 'unicode61'
|
|
114
|
+
)
|
|
115
|
+
`);
|
|
116
|
+
db.exec(`PRAGMA user_version = 8`);
|
|
117
|
+
}
|
|
118
|
+
function ensureTemporarySchema(db) {
|
|
119
|
+
db.exec(`
|
|
120
|
+
CREATE TEMP TABLE IF NOT EXISTS live_sessions (
|
|
121
|
+
id TEXT PRIMARY KEY,
|
|
122
|
+
version INTEGER NOT NULL,
|
|
123
|
+
created_at INTEGER NOT NULL,
|
|
124
|
+
cwd TEXT,
|
|
125
|
+
parent_session TEXT,
|
|
126
|
+
seed_length INTEGER,
|
|
127
|
+
delegation_depth INTEGER,
|
|
128
|
+
agent_preset TEXT,
|
|
129
|
+
fingerprint TEXT NOT NULL,
|
|
130
|
+
persisted INTEGER NOT NULL CHECK (persisted IN (0, 1)),
|
|
131
|
+
generation INTEGER NOT NULL
|
|
132
|
+
) STRICT
|
|
133
|
+
`);
|
|
134
|
+
db.exec(`
|
|
135
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS temp.live_docs USING fts5(
|
|
136
|
+
text,
|
|
137
|
+
session_id UNINDEXED,
|
|
138
|
+
seq UNINDEXED,
|
|
139
|
+
type UNINDEXED,
|
|
140
|
+
time UNINDEXED,
|
|
141
|
+
surface UNINDEXED,
|
|
142
|
+
codepoint_length UNINDEXED,
|
|
143
|
+
tokenize = 'unicode61'
|
|
144
|
+
)
|
|
145
|
+
`);
|
|
146
|
+
}
|
|
147
|
+
function quoteIdentifier(value) {
|
|
148
|
+
return `"${value.replaceAll("\"", "\"\"")}"`;
|
|
149
|
+
}
|
|
150
|
+
/** Largest page size whose internal lookahead remains an exact SQLite integer binding. */
|
|
151
|
+
const SQLITE_MAX_PAGE_LIMIT = Number.MAX_SAFE_INTEGER - 1;
|
|
152
|
+
/** Portable host-parameter ceiling shared by predicate and statement builders. */
|
|
153
|
+
const SQLITE_PORTABLE_VARIABLE_LIMIT = 32766;
|
|
154
|
+
/**
|
|
155
|
+
* Reject prospective SQLite binding growth beyond the portable ceiling.
|
|
156
|
+
* @param count - binding count at the current construction boundary.
|
|
157
|
+
*/
|
|
158
|
+
function assertPortableBindingCount(count) {
|
|
159
|
+
if (count > 32766) throw new SessionQueryError(`session-search request exceeds SQLite's portable ${SQLITE_PORTABLE_VARIABLE_LIMIT}-variable limit; reduce filter values`, "SESSION_QUERY_INVALID_FILTER");
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Reject compiled outer predicates beyond the supported FTS5 planner budget.
|
|
163
|
+
* @param count - predicate count including fixed statement predicates.
|
|
164
|
+
*/
|
|
165
|
+
function assertFts5OuterPredicateCount(count) {
|
|
166
|
+
if (count > 14) throw new SessionQueryError(`session-search request exceeds the supported SQLite FTS5 outer-predicate budget of 14; reduce filters`, "SESSION_QUERY_INVALID_FILTER");
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Validate and canonicalize a cross-session request.
|
|
170
|
+
* @param request - caller-provided query, filters, limit, and cursor.
|
|
171
|
+
* @param limits - configured default and maximum page sizes.
|
|
172
|
+
* @returns normalized request with explicit arrays and limit.
|
|
173
|
+
*/
|
|
174
|
+
function normalizeSessionRequest(request, limits) {
|
|
175
|
+
const sessionFilters = materializeSessionResultFilters(request.sessionFilters ?? []);
|
|
176
|
+
const eventFilters = materializeMetadataFilters(request.eventFilters ?? []);
|
|
177
|
+
const cursor = materializeCursor(request.cursor);
|
|
178
|
+
return {
|
|
179
|
+
query: normalizeQuery(request.query),
|
|
180
|
+
sessionFilters,
|
|
181
|
+
eventFilters,
|
|
182
|
+
limit: normalizeLimit(request.limit, limits),
|
|
183
|
+
...cursor === void 0 ? {} : { cursor }
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Validate and canonicalize a within-session request.
|
|
188
|
+
* @param request - caller-provided target, query, filters, limit, and cursor.
|
|
189
|
+
* @param limits - configured default and maximum page sizes.
|
|
190
|
+
* @returns normalized request with an explicit filter array and limit.
|
|
191
|
+
*/
|
|
192
|
+
function normalizeEventRequest(request, limits) {
|
|
193
|
+
if (typeof request.sessionId !== "string") throw new SessionQueryError("session-search session id must be text", "SESSION_QUERY_INVALID_FILTER");
|
|
194
|
+
const filters = materializeMetadataFilters(request.filters ?? []);
|
|
195
|
+
const cursor = materializeCursor(request.cursor);
|
|
196
|
+
return {
|
|
197
|
+
sessionId: request.sessionId,
|
|
198
|
+
query: normalizeQuery(request.query),
|
|
199
|
+
filters,
|
|
200
|
+
limit: normalizeLimit(request.limit, limits),
|
|
201
|
+
...cursor === void 0 ? {} : { cursor }
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Compile logical-session predicates against selected-document columns.
|
|
206
|
+
* @param filters - validated ANDed logical-session clauses.
|
|
207
|
+
* @returns parameterized SQL fragment and ordered bindings.
|
|
208
|
+
*/
|
|
209
|
+
function buildSessionWhere(filters) {
|
|
210
|
+
const clauses = [];
|
|
211
|
+
const params = [];
|
|
212
|
+
for (const filter of filters) switch (filter.kind) {
|
|
213
|
+
case "id":
|
|
214
|
+
addList(clauses, params, "session_id", filter.values);
|
|
215
|
+
break;
|
|
216
|
+
case "cwd":
|
|
217
|
+
addNullableList(clauses, params, "cwd", filter.values);
|
|
218
|
+
break;
|
|
219
|
+
case "created-at":
|
|
220
|
+
addRange(clauses, params, "created_at", filter);
|
|
221
|
+
break;
|
|
222
|
+
case "parent":
|
|
223
|
+
addNullableList(clauses, params, "parent_session", filter.values);
|
|
224
|
+
break;
|
|
225
|
+
case "availability": {
|
|
226
|
+
const availability = [...new Set(filter.values)];
|
|
227
|
+
if (availability.length === 0) clauses.push("0");
|
|
228
|
+
else if (availability.length === 1) {
|
|
229
|
+
const value = availability[0];
|
|
230
|
+
switch (value) {
|
|
231
|
+
case "live":
|
|
232
|
+
clauses.push("live = 1");
|
|
233
|
+
break;
|
|
234
|
+
case "persisted":
|
|
235
|
+
clauses.push("persisted = 1");
|
|
236
|
+
break;
|
|
237
|
+
default: unknownAvailability(value);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
default: unknownFilter(filter);
|
|
243
|
+
}
|
|
244
|
+
assertFts5OuterPredicateCount(clauses.length);
|
|
245
|
+
return {
|
|
246
|
+
sql: clauses.join(" AND "),
|
|
247
|
+
params,
|
|
248
|
+
predicateCount: clauses.length
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Compile event metadata predicates against selected-document columns.
|
|
253
|
+
* @param filters - validated ANDed event metadata clauses.
|
|
254
|
+
* @returns parameterized SQL fragment and ordered bindings.
|
|
255
|
+
*/
|
|
256
|
+
function buildEventWhere(filters) {
|
|
257
|
+
const clauses = [];
|
|
258
|
+
const params = [];
|
|
259
|
+
for (const filter of filters) switch (filter.kind) {
|
|
260
|
+
case "seq":
|
|
261
|
+
addRange(clauses, params, "seq", filter);
|
|
262
|
+
break;
|
|
263
|
+
case "time":
|
|
264
|
+
addRange(clauses, params, "time", filter);
|
|
265
|
+
break;
|
|
266
|
+
case "type":
|
|
267
|
+
addList(clauses, params, "type", filter.values);
|
|
268
|
+
break;
|
|
269
|
+
case "surface":
|
|
270
|
+
addList(clauses, params, "surface", filter.values);
|
|
271
|
+
break;
|
|
272
|
+
default: unknownFilter(filter);
|
|
273
|
+
}
|
|
274
|
+
assertFts5OuterPredicateCount(clauses.length);
|
|
275
|
+
return {
|
|
276
|
+
sql: clauses.join(" AND "),
|
|
277
|
+
params,
|
|
278
|
+
predicateCount: clauses.length
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Quote caller text as one FTS5 phrase so query syntax remains inert data.
|
|
283
|
+
* @param query - normalized caller query.
|
|
284
|
+
* @returns FTS5 expression containing one escaped literal phrase.
|
|
285
|
+
*/
|
|
286
|
+
function quoteFtsData(query) {
|
|
287
|
+
return `"${query.replaceAll("\"", "\"\"")}"`;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Remove reserved marker collisions before text enters FTS5 or MATCH.
|
|
291
|
+
* @param text - extracted document text or normalized caller query.
|
|
292
|
+
* @returns text with reserved noncharacters mapped to replacement characters.
|
|
293
|
+
*/
|
|
294
|
+
function sanitizeFtsText(text) {
|
|
295
|
+
return text.replaceAll("\0", "�").replaceAll("", "�").replaceAll("", "�");
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Build the stable normalized request identity stored in opaque cursors.
|
|
299
|
+
* @param request - normalized request whose filter ordering is canonicalized.
|
|
300
|
+
* @returns deterministic JSON identity for cursor binding.
|
|
301
|
+
*/
|
|
302
|
+
function requestFingerprint(request) {
|
|
303
|
+
if ("sessionId" in request) return JSON.stringify({
|
|
304
|
+
scope: "events",
|
|
305
|
+
sessionId: request.sessionId,
|
|
306
|
+
query: request.query,
|
|
307
|
+
filters: canonicalFilters(request.filters),
|
|
308
|
+
limit: request.limit
|
|
309
|
+
});
|
|
310
|
+
return JSON.stringify({
|
|
311
|
+
scope: "sessions",
|
|
312
|
+
query: request.query,
|
|
313
|
+
sessionFilters: canonicalFilters(request.sessionFilters),
|
|
314
|
+
eventFilters: canonicalFilters(request.eventFilters),
|
|
315
|
+
limit: request.limit
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Build a whitespace-normalized excerpt no longer than `maxChars`.
|
|
320
|
+
* @param markedText - complete document with FTS5 `highlight()` markers.
|
|
321
|
+
* @param maxChars - maximum result length in Unicode code points.
|
|
322
|
+
* @returns bounded plain-text snippet.
|
|
323
|
+
*/
|
|
324
|
+
function makeSnippet(markedText, maxChars) {
|
|
325
|
+
const { text: clean, matchStart } = normalizeMarkedText(markedText);
|
|
326
|
+
const characters = Array.from(clean);
|
|
327
|
+
if (characters.length <= maxChars) return clean;
|
|
328
|
+
if (maxChars === 1) return "…";
|
|
329
|
+
const matchedIndex = Math.min(matchStart, characters.length - 1);
|
|
330
|
+
let start = Math.max(0, matchedIndex - Math.floor(maxChars / 3));
|
|
331
|
+
const prefix = start > 0 ? "…" : "";
|
|
332
|
+
let suffix = "…";
|
|
333
|
+
let contentLength = maxChars - prefix.length - suffix.length;
|
|
334
|
+
if (contentLength < 1) {
|
|
335
|
+
start = matchedIndex;
|
|
336
|
+
suffix = "";
|
|
337
|
+
contentLength = maxChars - prefix.length - suffix.length;
|
|
338
|
+
} else if (matchedIndex >= start + contentLength) start = matchedIndex - contentLength + 1;
|
|
339
|
+
let end = Math.min(characters.length, start + contentLength);
|
|
340
|
+
if (end === characters.length) {
|
|
341
|
+
suffix = "";
|
|
342
|
+
contentLength = maxChars - prefix.length;
|
|
343
|
+
start = Math.max(0, end - contentLength);
|
|
344
|
+
}
|
|
345
|
+
end = Math.min(characters.length, start + contentLength);
|
|
346
|
+
return `${prefix}${characters.slice(start, end).join("")}${suffix}`;
|
|
347
|
+
}
|
|
348
|
+
function normalizeMarkedText(markedText) {
|
|
349
|
+
const characters = [];
|
|
350
|
+
let matchStart;
|
|
351
|
+
for (const character of markedText) {
|
|
352
|
+
if (character === "") {
|
|
353
|
+
matchStart ??= characters.length;
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (character === "") continue;
|
|
357
|
+
if (/\s/u.test(character)) {
|
|
358
|
+
if (characters.length > 0 && characters.at(-1) !== " ") characters.push(" ");
|
|
359
|
+
} else characters.push(character);
|
|
360
|
+
}
|
|
361
|
+
if (characters.at(-1) === " ") characters.pop();
|
|
362
|
+
return {
|
|
363
|
+
text: characters.join(""),
|
|
364
|
+
matchStart: matchStart ?? 0
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
function normalizeQuery(value) {
|
|
368
|
+
if (typeof value !== "string") throw new SessionQueryError("session-search query must be text", "SESSION_QUERY_INVALID_QUERY");
|
|
369
|
+
const query = value.trim().replace(/\s+/gu, " ");
|
|
370
|
+
if (query.length === 0) throw new SessionQueryError("session-search query must contain non-whitespace text", "SESSION_QUERY_INVALID_QUERY");
|
|
371
|
+
if (query.includes("\0")) throw new SessionQueryError("session-search query must not contain NUL", "SESSION_QUERY_INVALID_QUERY");
|
|
372
|
+
return sanitizeFtsText(query);
|
|
373
|
+
}
|
|
374
|
+
function materializeCursor(cursor) {
|
|
375
|
+
if (cursor === void 0) return void 0;
|
|
376
|
+
if (typeof cursor !== "string") throw new SessionQueryError("session-search cursor must be text", "SESSION_QUERY_INVALID_CURSOR");
|
|
377
|
+
return cursor;
|
|
378
|
+
}
|
|
379
|
+
function materializeMetadataFilters(filters) {
|
|
380
|
+
const candidates = filters;
|
|
381
|
+
for (const filter of candidates) switch (filter.kind) {
|
|
382
|
+
case "seq":
|
|
383
|
+
case "time":
|
|
384
|
+
case "type":
|
|
385
|
+
case "surface": break;
|
|
386
|
+
case "text": throw new SessionQueryError("session-search metadata filters do not accept text clauses", "SESSION_QUERY_INVALID_FILTER");
|
|
387
|
+
default: unknownFilter(filter);
|
|
388
|
+
}
|
|
389
|
+
return materializeSessionEventResultFilters(filters);
|
|
390
|
+
}
|
|
391
|
+
function normalizeLimit(value, limits) {
|
|
392
|
+
const limit = value ?? limits.defaultLimit;
|
|
393
|
+
const maxLimit = Math.min(limits.maxLimit, SQLITE_MAX_PAGE_LIMIT);
|
|
394
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > maxLimit) throw new SessionQueryError(`session-search limit must be an integer between 1 and ${maxLimit}`, "SESSION_QUERY_INVALID_LIMIT");
|
|
395
|
+
return limit;
|
|
396
|
+
}
|
|
397
|
+
function addList(clauses, params, column, values) {
|
|
398
|
+
if (values.length === 0) {
|
|
399
|
+
clauses.push("0");
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
clauses.push(`${column} IN (${appendListBindings(params, values)})`);
|
|
403
|
+
}
|
|
404
|
+
function addNullableList(clauses, params, column, values) {
|
|
405
|
+
if (values.length === 0) {
|
|
406
|
+
clauses.push("0");
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const concrete = values.filter((value) => value !== null);
|
|
410
|
+
const parts = [];
|
|
411
|
+
if (concrete.length > 0) parts.push(`${column} IN (${appendListBindings(params, concrete)})`);
|
|
412
|
+
if (values.includes(null)) parts.push(`${column} IS NULL`);
|
|
413
|
+
clauses.push(`(${parts.join(" OR ")})`);
|
|
414
|
+
}
|
|
415
|
+
function addRange(clauses, params, column, range) {
|
|
416
|
+
if (range.from !== void 0) {
|
|
417
|
+
assertPortableBindingCount(params.length + 1);
|
|
418
|
+
clauses.push(`CAST(${column} AS INTEGER) >= ?`);
|
|
419
|
+
params.push(range.from);
|
|
420
|
+
}
|
|
421
|
+
if (range.to !== void 0) {
|
|
422
|
+
assertPortableBindingCount(params.length + 1);
|
|
423
|
+
clauses.push(`CAST(${column} AS INTEGER) <= ?`);
|
|
424
|
+
params.push(range.to);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
function appendListBindings(params, values) {
|
|
428
|
+
assertPortableBindingCount(params.length + values.length);
|
|
429
|
+
for (const value of values) params.push(value);
|
|
430
|
+
return values.map(() => "?").join(", ");
|
|
431
|
+
}
|
|
432
|
+
function canonicalFilters(filters) {
|
|
433
|
+
return filters.map((filter) => {
|
|
434
|
+
if ("values" in filter) return {
|
|
435
|
+
...filter,
|
|
436
|
+
values: [...filter.values].sort(compareNullable)
|
|
437
|
+
};
|
|
438
|
+
return {
|
|
439
|
+
kind: filter.kind,
|
|
440
|
+
from: filter.from ?? null,
|
|
441
|
+
to: filter.to ?? null
|
|
442
|
+
};
|
|
443
|
+
}).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
|
|
444
|
+
}
|
|
445
|
+
function compareNullable(a, b) {
|
|
446
|
+
if (a === b) return 0;
|
|
447
|
+
if (a === null) return -1;
|
|
448
|
+
if (b === null) return 1;
|
|
449
|
+
return a.localeCompare(b);
|
|
450
|
+
}
|
|
451
|
+
function unknownAvailability(value) {
|
|
452
|
+
throw new SessionQueryError(`session availability filter contains unknown value "${String(value)}"`, "SESSION_QUERY_INVALID_FILTER");
|
|
453
|
+
}
|
|
454
|
+
function unknownFilter(filter) {
|
|
455
|
+
const kind = filter.kind;
|
|
456
|
+
throw new SessionQueryError(`session filter contains unknown kind ${typeof kind === "string" ? `"${kind}"` : "(missing)"}`, "SESSION_QUERY_INVALID_FILTER");
|
|
457
|
+
}
|
|
458
|
+
//#endregion
|
|
459
|
+
//#region lib/types/index.js
|
|
460
|
+
/**
|
|
461
|
+
* Concrete session-query service with SQLite FTS5 over the live-preferred corpus.
|
|
462
|
+
*
|
|
463
|
+
* @module @deepseek-ai/dsh-session-query-sqlite
|
|
464
|
+
*/
|
|
465
|
+
/** Boot-context slot for a launcher-owned absolute path to this process's derived query index. */
|
|
466
|
+
const SESSION_QUERY_SQLITE_PATH_KEY = "launcherSessionQueryPath";
|
|
467
|
+
/** Default result page size. */
|
|
468
|
+
const SESSION_QUERY_SQLITE_DEFAULT_LIMIT = 20;
|
|
469
|
+
/** Maximum accepted result page size. */
|
|
470
|
+
const SESSION_QUERY_SQLITE_MAX_LIMIT = 100;
|
|
471
|
+
/** Default maximum snippet length in Unicode code points. */
|
|
472
|
+
const SESSION_QUERY_SQLITE_SNIPPET_CHARS = 240;
|
|
473
|
+
const STABLE_OBSERVATION_ATTEMPTS = 2;
|
|
474
|
+
/** Concrete SQLite owner of the combined `ctx.sessionQuery` service. */
|
|
475
|
+
var SessionQuerySqlite = class extends SessionQueryService {
|
|
476
|
+
static inject = ["sessions"];
|
|
477
|
+
static Config = z.object({
|
|
478
|
+
path: z.string().required(),
|
|
479
|
+
openAt: z.union(["startup", "first-search"]).default("startup"),
|
|
480
|
+
journalMode: z.union([
|
|
481
|
+
"wal",
|
|
482
|
+
"delete",
|
|
483
|
+
"truncate",
|
|
484
|
+
"persist"
|
|
485
|
+
]).default("wal"),
|
|
486
|
+
defaultLimit: z.number().step(1).min(1).max(SQLITE_MAX_PAGE_LIMIT).default(20),
|
|
487
|
+
maxLimit: z.number().step(1).min(1).max(SQLITE_MAX_PAGE_LIMIT).default(100),
|
|
488
|
+
snippetChars: z.number().step(1).min(1).default(240),
|
|
489
|
+
readWindowMax: z.number().step(1).min(0).default(SESSION_QUERY_READ_WINDOW_MAX),
|
|
490
|
+
persistedInspectConcurrency: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY)
|
|
491
|
+
});
|
|
492
|
+
/** Validated and defaulted backend configuration. */
|
|
493
|
+
config;
|
|
494
|
+
_instance = randomUUID();
|
|
495
|
+
_ready;
|
|
496
|
+
_db;
|
|
497
|
+
_persistenceBinding = { identity: Symbol() };
|
|
498
|
+
_lastPersistenceIdentity;
|
|
499
|
+
_persistenceEpoch = 0;
|
|
500
|
+
_globalGeneration = 0;
|
|
501
|
+
_localGeneration = 0;
|
|
502
|
+
_tail = Promise.resolve();
|
|
503
|
+
_closed = false;
|
|
504
|
+
_closePromise;
|
|
505
|
+
_optionalPersistenceFiber;
|
|
506
|
+
constructor(ctx, config) {
|
|
507
|
+
super(ctx, config = resolveConfig(config));
|
|
508
|
+
this.config = config;
|
|
509
|
+
this._optionalPersistenceFiber = ctx.inject(["sessionPersistence"], (childCtx) => {
|
|
510
|
+
const service = childCtx.sessionPersistence;
|
|
511
|
+
const binding = {
|
|
512
|
+
identity: Symbol(),
|
|
513
|
+
service
|
|
514
|
+
};
|
|
515
|
+
this._persistenceBinding = binding;
|
|
516
|
+
childCtx.effect(() => () => {
|
|
517
|
+
/* v8 ignore next -- a stale optional-service disposer cannot clear a replacement */
|
|
518
|
+
if (this._persistenceBinding !== binding) return;
|
|
519
|
+
this._persistenceBinding = { identity: Symbol() };
|
|
520
|
+
}, "sessionQuerySqlite.persistenceBinding");
|
|
521
|
+
});
|
|
522
|
+
ctx.effect(() => {
|
|
523
|
+
return () => this._optionalPersistenceFiber.dispose();
|
|
524
|
+
}, "sessionQuerySqlite.optionalPersistence");
|
|
525
|
+
ctx.effect(() => async () => this.close(), "sessionQuerySqlite.close");
|
|
526
|
+
}
|
|
527
|
+
/** Open eagerly only when activation owns the configured readiness boundary. */
|
|
528
|
+
async [Service.init]() {
|
|
529
|
+
if (this.config.openAt === "startup") await this._ensureReady(void 0);
|
|
530
|
+
}
|
|
531
|
+
async searchSessions(request, exec) {
|
|
532
|
+
const normalized = normalizeSessionRequest(request, this.config);
|
|
533
|
+
const signal = exec?.signal;
|
|
534
|
+
return this._serialized(signal, async () => {
|
|
535
|
+
await this._ensureReady(signal);
|
|
536
|
+
const persistenceBinding = await this._reconcile(signal);
|
|
537
|
+
assertNotAborted(signal);
|
|
538
|
+
const generation = String(this._globalGeneration);
|
|
539
|
+
const fingerprint = requestFingerprint(normalized);
|
|
540
|
+
const offset = normalized.cursor === void 0 ? 0 : decodeCursor(normalized.cursor, this._instance, "sessions", fingerprint, generation);
|
|
541
|
+
return page(this._querySessions(normalized, offset, persistenceBinding), normalized.limit, (row) => this._sessionHit(row), (cursorOffset) => encodeCursor({
|
|
542
|
+
version: 1,
|
|
543
|
+
instance: this._instance,
|
|
544
|
+
scope: "sessions",
|
|
545
|
+
fingerprint,
|
|
546
|
+
generation,
|
|
547
|
+
offset: cursorOffset
|
|
548
|
+
}), offset);
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
async searchEvents(request, exec) {
|
|
552
|
+
const normalized = normalizeEventRequest(request, this.config);
|
|
553
|
+
const signal = exec?.signal;
|
|
554
|
+
return this._serialized(signal, async () => {
|
|
555
|
+
await this._ensureReady(signal);
|
|
556
|
+
const persistenceBinding = await this._reconcile(signal);
|
|
557
|
+
assertNotAborted(signal);
|
|
558
|
+
const target = this._targetObservation(normalized.sessionId, persistenceBinding);
|
|
559
|
+
const fingerprint = requestFingerprint(normalized);
|
|
560
|
+
const offset = normalized.cursor === void 0 ? 0 : decodeCursor(normalized.cursor, this._instance, "events", fingerprint, target.generation);
|
|
561
|
+
const rows = this._queryEvents(normalized, offset, persistenceBinding);
|
|
562
|
+
return {
|
|
563
|
+
session: target.header,
|
|
564
|
+
...page(rows, normalized.limit, (row) => this._eventHit(row), (cursorOffset) => encodeCursor({
|
|
565
|
+
version: 1,
|
|
566
|
+
instance: this._instance,
|
|
567
|
+
scope: "events",
|
|
568
|
+
fingerprint,
|
|
569
|
+
generation: target.generation,
|
|
570
|
+
offset: cursorOffset
|
|
571
|
+
}), offset)
|
|
572
|
+
};
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
/** Close the database after every accepted operation reaches quiescence. */
|
|
576
|
+
close() {
|
|
577
|
+
this._closePromise ??= this._close();
|
|
578
|
+
return this._closePromise;
|
|
579
|
+
}
|
|
580
|
+
async _close() {
|
|
581
|
+
this._closed = true;
|
|
582
|
+
await this._tail;
|
|
583
|
+
if (this._ready !== void 0) try {
|
|
584
|
+
await this._ready;
|
|
585
|
+
} catch {}
|
|
586
|
+
this._db?.close();
|
|
587
|
+
this._db = void 0;
|
|
588
|
+
}
|
|
589
|
+
async _open() {
|
|
590
|
+
this._db = await openSearchDatabase(this.config.path, this.config.journalMode);
|
|
591
|
+
const state = this._db.prepare("SELECT global_generation FROM search_state WHERE singleton = 1").get();
|
|
592
|
+
this._globalGeneration = state.global_generation;
|
|
593
|
+
this._localGeneration = state.global_generation;
|
|
594
|
+
}
|
|
595
|
+
async _ensureReady(signal) {
|
|
596
|
+
this._ready ??= this._open();
|
|
597
|
+
try {
|
|
598
|
+
await waitWithAbort(this._ready, signal);
|
|
599
|
+
} catch (error) {
|
|
600
|
+
if (isAbort(error)) throw error;
|
|
601
|
+
throw new SessionQueryError(`session-search SQLite index failed to open: ${errorMessage(error)}`, "SESSION_QUERY_INDEX_FAILED", { cause: error });
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
async _serialized(signal, operation) {
|
|
605
|
+
if (this._isClosed()) throw indexClosed();
|
|
606
|
+
let release;
|
|
607
|
+
const gate = new Promise((resolve) => {
|
|
608
|
+
release = resolve;
|
|
609
|
+
});
|
|
610
|
+
const prior = this._tail;
|
|
611
|
+
this._tail = prior.then(() => gate);
|
|
612
|
+
try {
|
|
613
|
+
await waitWithAbort(prior, signal);
|
|
614
|
+
} catch (error) {
|
|
615
|
+
release();
|
|
616
|
+
throw error;
|
|
617
|
+
}
|
|
618
|
+
if (this._isClosed()) {
|
|
619
|
+
release();
|
|
620
|
+
throw indexClosed();
|
|
621
|
+
}
|
|
622
|
+
try {
|
|
623
|
+
assertNotAborted(signal);
|
|
624
|
+
return await operation();
|
|
625
|
+
} finally {
|
|
626
|
+
release();
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
async _reconcile(signal) {
|
|
630
|
+
assertNotAborted(signal);
|
|
631
|
+
const db = this._requireDb();
|
|
632
|
+
const persistedRows = db.prepare("SELECT id, revision, generation FROM persisted_sessions").all();
|
|
633
|
+
const liveRows = db.prepare("SELECT id, fingerprint, persisted, generation FROM temp.live_sessions").all();
|
|
634
|
+
const persistedById = new Map(persistedRows.map((row) => [row.id, row]));
|
|
635
|
+
const liveById = new Map(liveRows.map((row) => [row.id, row]));
|
|
636
|
+
const observation = await this._observeStable(persistedById, signal);
|
|
637
|
+
assertNotAborted(signal);
|
|
638
|
+
const persistentChanges = observation.persistenceBinding.service === void 0 ? [] : [...observation.persisted.values()].filter((entry) => entry.loaded !== void 0);
|
|
639
|
+
const persistentDeletes = observation.persistenceBinding.service === void 0 ? [] : persistedRows.filter((row) => !observation.persisted.has(row.id));
|
|
640
|
+
const liveChanges = [...observation.live.values()].filter((entry) => {
|
|
641
|
+
const indexed = liveById.get(entry.header.id);
|
|
642
|
+
const persisted = observation.persisted.has(entry.header.id) ? 1 : 0;
|
|
643
|
+
return indexed?.fingerprint !== entry.fingerprint || indexed.persisted !== persisted;
|
|
644
|
+
});
|
|
645
|
+
const liveDeletes = liveRows.filter((row) => !observation.live.has(row.id));
|
|
646
|
+
const pointerChanged = this._lastPersistenceIdentity !== void 0 && this._lastPersistenceIdentity !== observation.persistenceBinding.identity;
|
|
647
|
+
const hasWrites = persistentChanges.length > 0 || persistentDeletes.length > 0 || liveChanges.length > 0 || liveDeletes.length > 0;
|
|
648
|
+
let nextMainGeneration = this._mainGeneration();
|
|
649
|
+
let nextLocalGeneration = this._localGeneration;
|
|
650
|
+
if (persistentChanges.length > 0 || persistentDeletes.length > 0) nextMainGeneration += 1;
|
|
651
|
+
const liveReplacements = liveChanges.map((entry) => {
|
|
652
|
+
nextLocalGeneration = Math.max(nextLocalGeneration, nextMainGeneration) + 1;
|
|
653
|
+
return {
|
|
654
|
+
entry,
|
|
655
|
+
generation: nextLocalGeneration,
|
|
656
|
+
persisted: observation.persisted.has(entry.header.id)
|
|
657
|
+
};
|
|
658
|
+
});
|
|
659
|
+
if (hasWrites) {
|
|
660
|
+
let began = false;
|
|
661
|
+
try {
|
|
662
|
+
db.exec("BEGIN IMMEDIATE");
|
|
663
|
+
began = true;
|
|
664
|
+
for (const row of persistentDeletes) this._deleteSession("persisted", row.id);
|
|
665
|
+
for (const entry of persistentChanges) {
|
|
666
|
+
/* v8 ignore next -- observation loads every entry whose revision differs */
|
|
667
|
+
if (entry.loaded === void 0) throw new Error(`missing loaded revision for session "${entry.header.id}"`);
|
|
668
|
+
this._replacePersistedSession(entry.loaded, entry.revision, nextMainGeneration);
|
|
669
|
+
}
|
|
670
|
+
if (persistentChanges.length > 0 || persistentDeletes.length > 0) db.prepare("UPDATE search_state SET global_generation = ? WHERE singleton = 1").run(nextMainGeneration);
|
|
671
|
+
for (const row of liveDeletes) this._deleteSession("live", row.id);
|
|
672
|
+
for (const { entry, generation, persisted } of liveReplacements) this._replaceLiveSession(entry, generation, persisted);
|
|
673
|
+
db.exec("COMMIT");
|
|
674
|
+
} catch (error) {
|
|
675
|
+
/* v8 ignore next -- a BEGIN failure has no transaction to roll back; the common wrapper still reports it. */
|
|
676
|
+
if (began)
|
|
677
|
+
/* v8 ignore next 5 -- ROLLBACK failure requires a SQLite double fault; the original failure remains actionable. */
|
|
678
|
+
try {
|
|
679
|
+
db.exec("ROLLBACK");
|
|
680
|
+
} catch {}
|
|
681
|
+
throw new SessionQueryError(`session-search reconciliation failed: ${errorMessage(error)}`, "SESSION_QUERY_INDEX_FAILED", { cause: error });
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
if (hasWrites || pointerChanged) this._globalGeneration += 1;
|
|
685
|
+
if (pointerChanged) this._persistenceEpoch += 1;
|
|
686
|
+
this._localGeneration = nextLocalGeneration;
|
|
687
|
+
this._lastPersistenceIdentity = observation.persistenceBinding.identity;
|
|
688
|
+
return observation.persistenceBinding;
|
|
689
|
+
}
|
|
690
|
+
async _observeStable(indexed, signal) {
|
|
691
|
+
for (let attempt = 0; attempt < STABLE_OBSERVATION_ATTEMPTS; attempt += 1) {
|
|
692
|
+
assertNotAborted(signal);
|
|
693
|
+
const persistenceBinding = this._persistenceBinding;
|
|
694
|
+
const persistence = persistenceBinding.service;
|
|
695
|
+
const initiallyLive = new Set(this.ctx.sessions.list().map((session) => session.id));
|
|
696
|
+
let persisted = /* @__PURE__ */ new Map();
|
|
697
|
+
if (persistence !== void 0) try {
|
|
698
|
+
const canReuseIndexed = this._lastPersistenceIdentity === void 0 || this._lastPersistenceIdentity === persistenceBinding.identity;
|
|
699
|
+
const before = await persistence.listSnapshots(signal);
|
|
700
|
+
assertNotAborted(signal);
|
|
701
|
+
persisted = materializePersistenceSnapshots(before);
|
|
702
|
+
for (const entry of persisted.values()) {
|
|
703
|
+
if (canReuseIndexed && indexed.get(entry.header.id)?.revision === entry.revision) continue;
|
|
704
|
+
if (initiallyLive.has(entry.header.id) || this.ctx.sessions.get(entry.header.id) !== void 0) continue;
|
|
705
|
+
assertNotAborted(signal);
|
|
706
|
+
const loaded = await persistence.inspect(entry.header.id, signal);
|
|
707
|
+
assertNotAborted(signal);
|
|
708
|
+
assertSessionHeadersCompatible(entry.header, loaded.meta);
|
|
709
|
+
entry.loaded = observeSession(loaded.meta, loaded.events);
|
|
710
|
+
}
|
|
711
|
+
assertNotAborted(signal);
|
|
712
|
+
const afterSnapshots = await persistence.listSnapshots(signal);
|
|
713
|
+
assertNotAborted(signal);
|
|
714
|
+
const after = materializePersistenceSnapshots(afterSnapshots);
|
|
715
|
+
if (!samePersistenceSnapshots(persisted, after)) continue;
|
|
716
|
+
if (this._persistenceBinding !== persistenceBinding) continue;
|
|
717
|
+
} catch (error) {
|
|
718
|
+
if (isAbort(error) || signal?.aborted) throw new SessionQueryError("session-search aborted", "SESSION_QUERY_ABORTED", { cause: error });
|
|
719
|
+
if (this._persistenceBinding !== persistenceBinding) continue;
|
|
720
|
+
if (error instanceof SessionQueryError) throw error;
|
|
721
|
+
throw new SessionQueryError(`session-search persistence observation failed: ${errorMessage(error)}`, "SESSION_QUERY_PERSISTENCE_FAILED", { cause: error });
|
|
722
|
+
}
|
|
723
|
+
const live = /* @__PURE__ */ new Map();
|
|
724
|
+
for (const session of this.ctx.sessions.list()) {
|
|
725
|
+
const observed = observeLive(session);
|
|
726
|
+
const durable = persisted.get(session.id);
|
|
727
|
+
if (durable !== void 0) assertSessionHeadersCompatible(observed.header, durable.header);
|
|
728
|
+
live.set(session.id, observed);
|
|
729
|
+
}
|
|
730
|
+
if (!sameSessionIds(initiallyLive, live)) continue;
|
|
731
|
+
return {
|
|
732
|
+
persistenceBinding,
|
|
733
|
+
persisted,
|
|
734
|
+
live
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
throw new SessionQueryError("session-search persistence observation did not stabilize after one retry", "SESSION_QUERY_PERSISTENCE_FAILED");
|
|
738
|
+
}
|
|
739
|
+
_mainGeneration() {
|
|
740
|
+
return this._requireDb().prepare("SELECT global_generation FROM search_state WHERE singleton = 1").get().global_generation;
|
|
741
|
+
}
|
|
742
|
+
_deleteSession(source, id) {
|
|
743
|
+
const db = this._requireDb();
|
|
744
|
+
if (source === "persisted") {
|
|
745
|
+
db.prepare("DELETE FROM persisted_docs WHERE session_id = ?").run(id);
|
|
746
|
+
db.prepare("DELETE FROM persisted_sessions WHERE id = ?").run(id);
|
|
747
|
+
} else {
|
|
748
|
+
db.prepare("DELETE FROM temp.live_docs WHERE session_id = ?").run(id);
|
|
749
|
+
db.prepare("DELETE FROM temp.live_sessions WHERE id = ?").run(id);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
_replacePersistedSession(entry, revision, generation) {
|
|
753
|
+
this._deleteSession("persisted", entry.header.id);
|
|
754
|
+
const db = this._requireDb();
|
|
755
|
+
db.prepare(`
|
|
756
|
+
INSERT INTO persisted_sessions
|
|
757
|
+
(id, version, created_at, cwd, parent_session, seed_length, delegation_depth, agent_preset, revision, generation)
|
|
758
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
759
|
+
`).run(...headerBindings(entry.header), revision, generation);
|
|
760
|
+
const insert = db.prepare(`
|
|
761
|
+
INSERT INTO persisted_docs (text, session_id, seq, type, time, surface, codepoint_length)
|
|
762
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
763
|
+
`);
|
|
764
|
+
for (const document of entry.documents) {
|
|
765
|
+
const text = sanitizeFtsText(document.text);
|
|
766
|
+
insert.run(text, document.sessionId, document.seq, document.type, document.time, document.surface, Array.from(text).length);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
_replaceLiveSession(entry, generation, persisted) {
|
|
770
|
+
this._deleteSession("live", entry.header.id);
|
|
771
|
+
const db = this._requireDb();
|
|
772
|
+
db.prepare(`
|
|
773
|
+
INSERT INTO temp.live_sessions
|
|
774
|
+
(id, version, created_at, cwd, parent_session, seed_length, delegation_depth, agent_preset, fingerprint, persisted, generation)
|
|
775
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
776
|
+
`).run(...headerBindings(entry.header), entry.fingerprint, persisted ? 1 : 0, generation);
|
|
777
|
+
const insert = db.prepare(`
|
|
778
|
+
INSERT INTO temp.live_docs (text, session_id, seq, type, time, surface, codepoint_length)
|
|
779
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
780
|
+
`);
|
|
781
|
+
for (const document of entry.documents) {
|
|
782
|
+
const text = sanitizeFtsText(document.text);
|
|
783
|
+
insert.run(text, document.sessionId, document.seq, document.type, document.time, document.surface, Array.from(text).length);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
_querySessions(request, offset, persistenceBinding) {
|
|
787
|
+
const selected = selectedDocumentsSql();
|
|
788
|
+
const sessionWhere = buildSessionWhere(request.sessionFilters);
|
|
789
|
+
const eventWhere = buildEventWhere(request.eventFilters);
|
|
790
|
+
assertFts5OuterPredicateCount(sessionWhere.predicateCount + eventWhere.predicateCount);
|
|
791
|
+
const where = [sessionWhere.sql, eventWhere.sql].filter(Boolean).join(" AND ");
|
|
792
|
+
const bindings = [
|
|
793
|
+
...selectedDocumentsParams(request.query, persistenceBinding.service !== void 0),
|
|
794
|
+
...sessionWhere.params,
|
|
795
|
+
...eventWhere.params,
|
|
796
|
+
request.limit + 1,
|
|
797
|
+
offset
|
|
798
|
+
];
|
|
799
|
+
assertPortableBindingCount(bindings.length);
|
|
800
|
+
return this._requireDb().prepare(`
|
|
801
|
+
${selected.sql},
|
|
802
|
+
filtered AS (
|
|
803
|
+
SELECT * FROM matched ${where.length === 0 ? "" : `WHERE ${where}`}
|
|
804
|
+
),
|
|
805
|
+
ranked AS (
|
|
806
|
+
SELECT *, ROW_NUMBER() OVER (
|
|
807
|
+
PARTITION BY session_id
|
|
808
|
+
ORDER BY match_count DESC, document_length ASC, time DESC, seq DESC
|
|
809
|
+
) AS event_rank
|
|
810
|
+
FROM filtered
|
|
811
|
+
)
|
|
812
|
+
SELECT * FROM ranked
|
|
813
|
+
WHERE event_rank = 1
|
|
814
|
+
ORDER BY match_count DESC, document_length ASC, time DESC, session_id ASC, seq DESC
|
|
815
|
+
LIMIT ? OFFSET ?
|
|
816
|
+
`).all(...bindings);
|
|
817
|
+
}
|
|
818
|
+
_queryEvents(request, offset, persistenceBinding) {
|
|
819
|
+
const selected = selectedDocumentsSql();
|
|
820
|
+
const eventWhere = buildEventWhere(request.filters);
|
|
821
|
+
assertFts5OuterPredicateCount(1 + eventWhere.predicateCount);
|
|
822
|
+
const where = ["session_id = ?", eventWhere.sql].filter(Boolean).join(" AND ");
|
|
823
|
+
const bindings = [
|
|
824
|
+
...selectedDocumentsParams(request.query, persistenceBinding.service !== void 0),
|
|
825
|
+
request.sessionId,
|
|
826
|
+
...eventWhere.params,
|
|
827
|
+
request.limit + 1,
|
|
828
|
+
offset
|
|
829
|
+
];
|
|
830
|
+
assertPortableBindingCount(bindings.length);
|
|
831
|
+
return this._requireDb().prepare(`
|
|
832
|
+
${selected.sql}
|
|
833
|
+
SELECT * FROM matched
|
|
834
|
+
WHERE ${where}
|
|
835
|
+
ORDER BY match_count DESC, document_length ASC, time DESC, seq DESC
|
|
836
|
+
LIMIT ? OFFSET ?
|
|
837
|
+
`).all(...bindings);
|
|
838
|
+
}
|
|
839
|
+
_targetObservation(sessionId, persistenceBinding) {
|
|
840
|
+
const db = this._requireDb();
|
|
841
|
+
const live = db.prepare(`SELECT
|
|
842
|
+
id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, agent_preset, generation
|
|
843
|
+
FROM temp.live_sessions
|
|
844
|
+
WHERE id = ?`).get(sessionId);
|
|
845
|
+
if (live !== void 0) return {
|
|
846
|
+
header: rowHeader(live),
|
|
847
|
+
generation: `live:${live.generation}`
|
|
848
|
+
};
|
|
849
|
+
if (persistenceBinding.service !== void 0) {
|
|
850
|
+
const persisted = db.prepare(`SELECT
|
|
851
|
+
id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, agent_preset, generation
|
|
852
|
+
FROM persisted_sessions
|
|
853
|
+
WHERE id = ?`).get(sessionId);
|
|
854
|
+
if (persisted !== void 0) return {
|
|
855
|
+
header: rowHeader(persisted),
|
|
856
|
+
generation: `persisted:${this._persistenceEpoch}:${persisted.generation}`
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
throw new SessionQueryError(`session "${sessionId}" not found`, "SESSION_QUERY_SESSION_NOT_FOUND");
|
|
860
|
+
}
|
|
861
|
+
_sessionHit(row) {
|
|
862
|
+
return {
|
|
863
|
+
header: rowHeader(row),
|
|
864
|
+
live: row.live === 1,
|
|
865
|
+
persisted: row.persisted === 1,
|
|
866
|
+
bestMatch: this._eventHit(row)
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
_eventHit(row) {
|
|
870
|
+
return {
|
|
871
|
+
sessionId: row.session_id,
|
|
872
|
+
seq: row.seq,
|
|
873
|
+
type: row.type,
|
|
874
|
+
time: row.time,
|
|
875
|
+
surface: row.surface,
|
|
876
|
+
snippet: makeSnippet(row.marked_text, this.config.snippetChars)
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
_requireDb() {
|
|
880
|
+
/* v8 ignore next -- callers await `_ready`; this guards lifecycle misuse */
|
|
881
|
+
if (this._db === void 0) throw indexClosed();
|
|
882
|
+
return this._db;
|
|
883
|
+
}
|
|
884
|
+
_isClosed() {
|
|
885
|
+
return this._closed;
|
|
886
|
+
}
|
|
887
|
+
};
|
|
888
|
+
/**
|
|
889
|
+
* The header columns both session upserts bind, in the order their INSERT
|
|
890
|
+
* lists them. The two statements differ only in what they append after these.
|
|
891
|
+
* @param header - the session header being written.
|
|
892
|
+
* @returns one bound value per header column.
|
|
893
|
+
*/
|
|
894
|
+
function headerBindings(header) {
|
|
895
|
+
return [
|
|
896
|
+
header.id,
|
|
897
|
+
header.version,
|
|
898
|
+
header.createdAt,
|
|
899
|
+
header.cwd ?? null,
|
|
900
|
+
header.parentSession ?? null,
|
|
901
|
+
header.seedLength ?? null,
|
|
902
|
+
header.delegationDepth ?? null,
|
|
903
|
+
header.agentPreset ?? null
|
|
904
|
+
];
|
|
905
|
+
}
|
|
906
|
+
function selectedDocumentsSql() {
|
|
907
|
+
return { sql: `WITH candidates AS (
|
|
908
|
+
SELECT
|
|
909
|
+
pd.session_id AS session_id,
|
|
910
|
+
ps.version AS version,
|
|
911
|
+
ps.created_at AS created_at,
|
|
912
|
+
ps.cwd AS cwd,
|
|
913
|
+
ps.parent_session AS parent_session,
|
|
914
|
+
ps.seed_length AS seed_length,
|
|
915
|
+
ps.delegation_depth AS delegation_depth,
|
|
916
|
+
ps.agent_preset AS agent_preset,
|
|
917
|
+
0 AS live,
|
|
918
|
+
1 AS persisted,
|
|
919
|
+
CAST(pd.seq AS INTEGER) AS seq,
|
|
920
|
+
pd.type AS type,
|
|
921
|
+
CAST(pd.time AS INTEGER) AS time,
|
|
922
|
+
pd.surface AS surface,
|
|
923
|
+
highlight(persisted_docs, 0, ?, ?) AS marked_text,
|
|
924
|
+
CAST(pd.codepoint_length AS INTEGER) AS document_length
|
|
925
|
+
FROM persisted_docs AS pd
|
|
926
|
+
JOIN persisted_sessions AS ps ON ps.id = pd.session_id
|
|
927
|
+
WHERE persisted_docs MATCH ?
|
|
928
|
+
AND ? = 1
|
|
929
|
+
AND NOT EXISTS (SELECT 1 FROM temp.live_sessions AS ls WHERE ls.id = pd.session_id)
|
|
930
|
+
UNION ALL
|
|
931
|
+
SELECT
|
|
932
|
+
ld.session_id AS session_id,
|
|
933
|
+
ls.version AS version,
|
|
934
|
+
ls.created_at AS created_at,
|
|
935
|
+
ls.cwd AS cwd,
|
|
936
|
+
ls.parent_session AS parent_session,
|
|
937
|
+
ls.seed_length AS seed_length,
|
|
938
|
+
ls.delegation_depth AS delegation_depth,
|
|
939
|
+
ls.agent_preset AS agent_preset,
|
|
940
|
+
1 AS live,
|
|
941
|
+
CASE WHEN ? = 1 THEN ls.persisted ELSE 0 END AS persisted,
|
|
942
|
+
CAST(ld.seq AS INTEGER) AS seq,
|
|
943
|
+
ld.type AS type,
|
|
944
|
+
CAST(ld.time AS INTEGER) AS time,
|
|
945
|
+
ld.surface AS surface,
|
|
946
|
+
highlight(live_docs, 0, ?, ?) AS marked_text,
|
|
947
|
+
CAST(ld.codepoint_length AS INTEGER) AS document_length
|
|
948
|
+
FROM temp.live_docs AS ld
|
|
949
|
+
JOIN temp.live_sessions AS ls ON ls.id = ld.session_id
|
|
950
|
+
WHERE live_docs MATCH ?
|
|
951
|
+
), matched AS (
|
|
952
|
+
SELECT *,
|
|
953
|
+
(
|
|
954
|
+
length(CAST(marked_text AS BLOB))
|
|
955
|
+
- length(CAST(replace(marked_text, ?, '') AS BLOB))
|
|
956
|
+
) / ? AS match_count
|
|
957
|
+
FROM candidates
|
|
958
|
+
)` };
|
|
959
|
+
}
|
|
960
|
+
function selectedDocumentsParams(query, persistenceVisible) {
|
|
961
|
+
const expression = quoteFtsData(query);
|
|
962
|
+
const visible = persistenceVisible ? 1 : 0;
|
|
963
|
+
return [
|
|
964
|
+
"",
|
|
965
|
+
"",
|
|
966
|
+
expression,
|
|
967
|
+
visible,
|
|
968
|
+
visible,
|
|
969
|
+
"",
|
|
970
|
+
"",
|
|
971
|
+
expression,
|
|
972
|
+
"",
|
|
973
|
+
Buffer.byteLength("", "utf8")
|
|
974
|
+
];
|
|
975
|
+
}
|
|
976
|
+
function observeLive(session) {
|
|
977
|
+
return observeSession(session.header, session.events);
|
|
978
|
+
}
|
|
979
|
+
function observeSession(header, events) {
|
|
980
|
+
const detachedHeader = structuredClone(header);
|
|
981
|
+
const detachedEvents = events.map((event) => structuredClone(event));
|
|
982
|
+
return {
|
|
983
|
+
header: detachedHeader,
|
|
984
|
+
documents: buildSessionEventSearchDocuments(detachedHeader.id, detachedEvents),
|
|
985
|
+
fingerprint: createHash("sha256").update(JSON.stringify({
|
|
986
|
+
header: detachedHeader,
|
|
987
|
+
events: detachedEvents
|
|
988
|
+
})).digest("base64url")
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
function materializePersistenceSnapshots(snapshots) {
|
|
992
|
+
if (!isRuntimeArray(snapshots)) throw new Error("persistence snapshots must be an array");
|
|
993
|
+
const result = /* @__PURE__ */ new Map();
|
|
994
|
+
for (const snapshot of snapshots) {
|
|
995
|
+
if (typeof snapshot.revision !== "string") throw new Error("persistence snapshot revision must be a string");
|
|
996
|
+
const header = structuredClone(snapshot.header);
|
|
997
|
+
if (result.has(header.id)) throw new Error(`persistence listed duplicate session "${header.id}"`);
|
|
998
|
+
result.set(header.id, {
|
|
999
|
+
header,
|
|
1000
|
+
revision: snapshot.revision
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
return result;
|
|
1004
|
+
}
|
|
1005
|
+
function samePersistenceSnapshots(before, after) {
|
|
1006
|
+
if (before.size !== after.size) return false;
|
|
1007
|
+
for (const [id, first] of before) {
|
|
1008
|
+
const second = after.get(id);
|
|
1009
|
+
if (second === void 0 || first.revision !== second.revision || !sameHeader(first.header, second.header)) return false;
|
|
1010
|
+
}
|
|
1011
|
+
return true;
|
|
1012
|
+
}
|
|
1013
|
+
function sameSessionIds(before, after) {
|
|
1014
|
+
if (before.size !== after.size) return false;
|
|
1015
|
+
for (const id of before) if (!after.has(id)) return false;
|
|
1016
|
+
return true;
|
|
1017
|
+
}
|
|
1018
|
+
function sameHeader(a, b) {
|
|
1019
|
+
return a.version === b.version && a.id === b.id && a.createdAt === b.createdAt && a.cwd === b.cwd && a.parentSession === b.parentSession && a.seedLength === b.seedLength && (a.delegationDepth ?? 0) === (b.delegationDepth ?? 0) && a.agentPreset === b.agentPreset;
|
|
1020
|
+
}
|
|
1021
|
+
function rowHeader(row) {
|
|
1022
|
+
return {
|
|
1023
|
+
version: row.version,
|
|
1024
|
+
id: row.session_id,
|
|
1025
|
+
createdAt: row.created_at,
|
|
1026
|
+
...row.cwd === null ? {} : { cwd: row.cwd },
|
|
1027
|
+
...row.parent_session === null ? {} : { parentSession: row.parent_session },
|
|
1028
|
+
...row.seed_length === null ? {} : { seedLength: row.seed_length },
|
|
1029
|
+
...row.delegation_depth === null ? {} : { delegationDepth: row.delegation_depth },
|
|
1030
|
+
...row.agent_preset === null ? {} : { agentPreset: row.agent_preset }
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
function page(rows, limit, convert, nextCursor, offset) {
|
|
1034
|
+
const hasMore = rows.length > limit;
|
|
1035
|
+
return {
|
|
1036
|
+
items: rows.slice(0, limit).map(convert),
|
|
1037
|
+
...hasMore ? { nextCursor: nextCursor(offset + limit) } : {}
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
function encodeCursor(payload) {
|
|
1041
|
+
return SessionSearchCursor(Buffer.from(JSON.stringify(payload), "utf8").toString("base64url"));
|
|
1042
|
+
}
|
|
1043
|
+
function decodeCursor(cursor, instance, scope, fingerprint, generation) {
|
|
1044
|
+
let decoded;
|
|
1045
|
+
try {
|
|
1046
|
+
decoded = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
1047
|
+
} catch (error) {
|
|
1048
|
+
throw invalidCursor(error);
|
|
1049
|
+
}
|
|
1050
|
+
if (decoded.version !== 1 || decoded.instance !== instance || decoded.scope !== scope || decoded.fingerprint !== fingerprint || !Number.isSafeInteger(decoded.offset) || decoded.offset === void 0 || decoded.offset < 0) throw invalidCursor(/* @__PURE__ */ new Error("cursor does not belong to this normalized request"));
|
|
1051
|
+
if (decoded.generation !== generation) throw new SessionQueryError("session-search cursor is stale because its relevant corpus changed", "SESSION_QUERY_STALE_CURSOR");
|
|
1052
|
+
return decoded.offset;
|
|
1053
|
+
}
|
|
1054
|
+
function invalidCursor(cause) {
|
|
1055
|
+
return new SessionQueryError("session-search cursor is invalid", "SESSION_QUERY_INVALID_CURSOR", { cause });
|
|
1056
|
+
}
|
|
1057
|
+
function resolveConfig(config) {
|
|
1058
|
+
const resolved = {
|
|
1059
|
+
path: config.path,
|
|
1060
|
+
openAt: config.openAt ?? "startup",
|
|
1061
|
+
journalMode: config.journalMode ?? "wal",
|
|
1062
|
+
defaultLimit: config.defaultLimit ?? 20,
|
|
1063
|
+
maxLimit: config.maxLimit ?? 100,
|
|
1064
|
+
snippetChars: config.snippetChars ?? 240,
|
|
1065
|
+
readWindowMax: config.readWindowMax ?? SESSION_QUERY_READ_WINDOW_MAX,
|
|
1066
|
+
persistedInspectConcurrency: config.persistedInspectConcurrency ?? SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY
|
|
1067
|
+
};
|
|
1068
|
+
if (typeof resolved.path !== "string" || resolved.path.trim().length === 0) throw invalidConfig("path must not be blank");
|
|
1069
|
+
if (!["startup", "first-search"].includes(resolved.openAt)) throw invalidConfig("openAt is not supported");
|
|
1070
|
+
assertPageLimit("defaultLimit", resolved.defaultLimit);
|
|
1071
|
+
assertPageLimit("maxLimit", resolved.maxLimit);
|
|
1072
|
+
assertPositiveInteger("snippetChars", resolved.snippetChars);
|
|
1073
|
+
if (!Number.isInteger(resolved.readWindowMax) || resolved.readWindowMax < 0) throw invalidConfig("readWindowMax must be a non-negative integer");
|
|
1074
|
+
if (!Number.isSafeInteger(resolved.persistedInspectConcurrency) || resolved.persistedInspectConcurrency < 1) throw invalidConfig("persistedInspectConcurrency must be a positive safe integer");
|
|
1075
|
+
if (resolved.defaultLimit > resolved.maxLimit) throw invalidConfig("defaultLimit must be less than or equal to maxLimit");
|
|
1076
|
+
if (![
|
|
1077
|
+
"wal",
|
|
1078
|
+
"delete",
|
|
1079
|
+
"truncate",
|
|
1080
|
+
"persist"
|
|
1081
|
+
].includes(resolved.journalMode)) throw invalidConfig("journalMode is not supported");
|
|
1082
|
+
return resolved;
|
|
1083
|
+
}
|
|
1084
|
+
function assertPositiveInteger(name, value) {
|
|
1085
|
+
if (!Number.isInteger(value) || value < 1) throw invalidConfig(`${name} must be a positive integer`);
|
|
1086
|
+
}
|
|
1087
|
+
function assertPageLimit(name, value) {
|
|
1088
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > SQLITE_MAX_PAGE_LIMIT) throw invalidConfig(`${name} must be an integer between 1 and ${SQLITE_MAX_PAGE_LIMIT}`);
|
|
1089
|
+
}
|
|
1090
|
+
function invalidConfig(detail) {
|
|
1091
|
+
return new SessionQueryError(`session-search SQLite config: ${detail}`, "SESSION_QUERY_INVALID_CONFIG");
|
|
1092
|
+
}
|
|
1093
|
+
function indexClosed() {
|
|
1094
|
+
return new SessionQueryError("session-search SQLite index is closed", "SESSION_QUERY_INDEX_FAILED");
|
|
1095
|
+
}
|
|
1096
|
+
function assertNotAborted(signal) {
|
|
1097
|
+
if (signal?.aborted) throw new SessionQueryError("session-search aborted", "SESSION_QUERY_ABORTED");
|
|
1098
|
+
}
|
|
1099
|
+
function waitWithAbort(promise, signal) {
|
|
1100
|
+
if (signal === void 0) return promise;
|
|
1101
|
+
if (signal.aborted) return Promise.reject(new SessionQueryError("session-search aborted", "SESSION_QUERY_ABORTED"));
|
|
1102
|
+
return new Promise((resolve, reject) => {
|
|
1103
|
+
const onAbort = () => {
|
|
1104
|
+
reject(new SessionQueryError("session-search aborted", "SESSION_QUERY_ABORTED"));
|
|
1105
|
+
};
|
|
1106
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1107
|
+
promise.then((value) => {
|
|
1108
|
+
signal.removeEventListener("abort", onAbort);
|
|
1109
|
+
resolve(value);
|
|
1110
|
+
}, (error) => {
|
|
1111
|
+
signal.removeEventListener("abort", onAbort);
|
|
1112
|
+
reject(asError(error));
|
|
1113
|
+
});
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
function isAbort(error) {
|
|
1117
|
+
return error instanceof SessionQueryError && error.code === "SESSION_QUERY_ABORTED";
|
|
1118
|
+
}
|
|
1119
|
+
function asError(error) {
|
|
1120
|
+
return error instanceof Error ? error : new Error("session-search dependency rejected with a non-Error value", { cause: error });
|
|
1121
|
+
}
|
|
1122
|
+
function errorMessage(error) {
|
|
1123
|
+
return error instanceof Error ? error.message : "unknown error";
|
|
1124
|
+
}
|
|
1125
|
+
function isRuntimeArray(value) {
|
|
1126
|
+
return Array.isArray(value);
|
|
1127
|
+
}
|
|
1128
|
+
//#endregion
|
|
1129
|
+
export { SESSION_QUERY_SQLITE_APPLICATION_ID, SESSION_QUERY_SQLITE_DEFAULT_LIMIT, SESSION_QUERY_SQLITE_MAX_LIMIT, SESSION_QUERY_SQLITE_PATH_KEY, SESSION_QUERY_SQLITE_SCHEMA_VERSION, SESSION_QUERY_SQLITE_SNIPPET_CHARS, SessionQuerySqlite, SessionQuerySqlite as default };
|