@kar-mi/spirit-vale-tools-sqlite 0.2.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 ADDED
@@ -0,0 +1,79 @@
1
+ # @kar-mi/spirit-vale-tools-sqlite
2
+
3
+ Disposable SQLite read-model infrastructure for Spirit Vale session logs.
4
+
5
+ > **Internal package.** This package is published only because the domain
6
+ > packages depend on it at runtime; it is installed automatically alongside them
7
+ > and is not a supported public API.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ bun add @kar-mi/spirit-vale-tools-sqlite
13
+ ```
14
+
15
+ ## What it is
16
+
17
+ JSON Lines session logs stay the canonical record. This package maintains a
18
+ **cache** derived from them, by default at `<logDirectory>/cache/read-model.sqlite`.
19
+ The cache is never migrated: anything unusable is deleted and rebuilt from the
20
+ logs. Nothing here writes to, renames, or deletes a log file.
21
+
22
+ ## Usage
23
+
24
+ A domain package owns its own tables and importer; this package owns the
25
+ database, its metadata, and how far each log has been indexed.
26
+
27
+ ```ts
28
+ import { openReadModel } from "@kar-mi/spirit-vale-tools-sqlite";
29
+
30
+ const model = await openReadModel({
31
+ logDirectory,
32
+ domains: [{
33
+ name: "example",
34
+ version: 1,
35
+ createSchema: (db) => db.exec("create table if not exists example_rows (sequence integer primary key)"),
36
+ dropSchema: (db) => db.exec("drop table if exists example_rows"),
37
+ }],
38
+ onRebuild: (event) => console.error(`read model rebuilt: ${event.reason}`),
39
+ });
40
+
41
+ await model.indexStream({
42
+ sessionId,
43
+ stream: "combat",
44
+ domain: "example",
45
+ sourcePath,
46
+ apply(records, db) {
47
+ for (const record of records) {
48
+ db.query("insert or replace into example_rows (sequence) values ($sequence)").run({ sequence: record.sequence });
49
+ }
50
+ },
51
+ clear(scope, db) {
52
+ db.query("delete from example_rows").run();
53
+ },
54
+ });
55
+
56
+ model.close();
57
+ ```
58
+
59
+ `indexStream` reads only what the log has gained since the recorded byte offset,
60
+ so it is cheap to call repeatedly and resumes across process restarts.
61
+
62
+ ## Guarantees
63
+
64
+ - Rows and indexing progress commit in the same transaction, so an interrupted
65
+ pass resumes exactly rather than double-counting.
66
+ - Each transaction covers at most `batchBytes` of source (1 MiB by default) and
67
+ always ends on a record boundary.
68
+ - A truncated, replaced, or rewound log rebuilds that stream; a corrupt database
69
+ or a changed infrastructure schema rebuilds the whole file; a changed domain
70
+ `version` rebuilds only that domain.
71
+ - Use `model.bigintStatement(...)` for 64-bit values such as reward coins. A
72
+ plain read rounds anything past `Number.MAX_SAFE_INTEGER`.
73
+ - Prefer `model.statement(...)` / `model.bigintStatement(...)` for your own reads.
74
+ They reuse one prepared statement for the model's lifetime and provide the
75
+ per-statement bigint mode. Direct `database.query()` / `database.prepare()`
76
+ access remains available for advanced cases; `model.close()` force-finalizes
77
+ every outstanding statement.
78
+
79
+ See the [package guide](https://github.com/kar-mi/spirit-vale-tools/blob/main/docs/packages.md) for registry setup and usage.
@@ -0,0 +1,3 @@
1
+ /** Identifies a log file by its first line, which is immutable for an append-only log and carries the session id, sequence 1, and a timestamp. */
2
+ export declare function fingerprintSource(sourcePath: string): Promise<string | undefined>;
3
+ //# sourceMappingURL=fingerprint.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fingerprint.d.ts","sourceRoot":"","sources":["../src/fingerprint.ts"],"names":[],"mappings":"AAOA,kJAAkJ;AAClJ,wBAAsB,iBAAiB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAqBvF"}
@@ -0,0 +1,6 @@
1
+ export { deleteReadModel, openReadModel } from "./read-model.ts";
2
+ export type { OpenReadModelOptions, ReadModel } from "./read-model.ts";
3
+ export { READ_MODEL_SCHEMA_VERSION } from "./schema.ts";
4
+ export { readModelDirectory, readModelFiles, readModelPath } from "./paths.ts";
5
+ export type { IndexStreamRequest, IndexStreamResult, IndexedStreamStatus, ReadModelDomain, ReadModelHealth, ReadModelRebuild, RebuildReason, StreamRebuildReason, } from "./types.ts";
6
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AACjE,YAAY,EAAE,oBAAoB,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AACvE,OAAO,EAAE,yBAAyB,EAAE,MAAM,aAAa,CAAC;AACxD,OAAO,EAAE,kBAAkB,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAC/E,YAAY,EACV,kBAAkB,EAClB,iBAAiB,EACjB,mBAAmB,EACnB,eAAe,EACf,eAAe,EACf,gBAAgB,EAChB,aAAa,EACb,mBAAmB,GACpB,MAAM,YAAY,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,427 @@
1
+ // @bun
2
+ // src/read-model.ts
3
+ import { mkdir, rm } from "fs/promises";
4
+ import path2 from "path";
5
+ import { Database } from "bun:sqlite";
6
+ import { defaultLogDirectory as defaultLogDirectory2 } from "@kar-mi/spirit-vale-tools-logging";
7
+
8
+ // src/indexer.ts
9
+ import { stat } from "fs/promises";
10
+ import { JsonlTailReader, isLogStreamHeader, isMissing as isMissing2, parseLogRecord } from "@kar-mi/spirit-vale-tools-logging";
11
+
12
+ // src/fingerprint.ts
13
+ import { open } from "fs/promises";
14
+ import { isMissing } from "@kar-mi/spirit-vale-tools-logging";
15
+ var FINGERPRINT_BYTES = 8192;
16
+ async function fingerprintSource(sourcePath) {
17
+ let handle;
18
+ try {
19
+ handle = await open(sourcePath, "r");
20
+ } catch (error) {
21
+ if (isMissing(error))
22
+ return;
23
+ throw error;
24
+ }
25
+ try {
26
+ const bytes = Buffer.allocUnsafe(FINGERPRINT_BYTES);
27
+ const { bytesRead } = await handle.read(bytes, 0, FINGERPRINT_BYTES, 0);
28
+ if (bytesRead === 0)
29
+ return;
30
+ const read = bytes.subarray(0, bytesRead);
31
+ const newline = read.indexOf(10);
32
+ const line = newline === -1 ? read : read.subarray(0, newline + 1);
33
+ const hasher = new Bun.CryptoHasher("sha256");
34
+ hasher.update(line);
35
+ return hasher.digest("hex");
36
+ } finally {
37
+ await handle.close();
38
+ }
39
+ }
40
+
41
+ // src/indexer.ts
42
+ var DEFAULT_BATCH_BYTES = 1024 * 1024;
43
+ async function indexStream(database, request, transaction) {
44
+ const { sessionId, stream, domain, sourcePath } = request;
45
+ const batchBytes = request.batchBytes ?? DEFAULT_BATCH_BYTES;
46
+ if (!Number.isSafeInteger(batchBytes) || batchBytes < 1)
47
+ throw new RangeError("batchBytes must be a positive integer");
48
+ const row = selectProgress(database, sessionId, stream, domain);
49
+ let size;
50
+ let modifiedAt;
51
+ try {
52
+ const info = await stat(sourcePath);
53
+ size = info.size;
54
+ modifiedAt = info.mtime.toISOString();
55
+ } catch (error) {
56
+ if (!isMissing2(error))
57
+ throw error;
58
+ return unchanged(request, row, { missing: true });
59
+ }
60
+ if (row && row.byte_offset === size && row.source_modified_at === modifiedAt) {
61
+ return unchanged(request, row, { missing: false });
62
+ }
63
+ const source = { size, modifiedAt, fingerprint: await fingerprintSource(sourcePath) };
64
+ const reason = decideRebuild(row, source);
65
+ if (reason && reason !== "new")
66
+ rebuild(database, request, source, transaction);
67
+ const pass = await indexFrom(database, request, startOf(reason, row), source, batchBytes, transaction);
68
+ if (!pass.regressed) {
69
+ return {
70
+ sessionId,
71
+ stream,
72
+ missing: false,
73
+ rebuilt: reason !== undefined && reason !== "new",
74
+ ...reason ? { rebuildReason: reason } : {},
75
+ recordsIndexed: pass.recordsIndexed,
76
+ invalidLines: pass.invalidLines,
77
+ byteOffset: pass.byteOffset,
78
+ lastSequence: pass.lastSequence
79
+ };
80
+ }
81
+ rebuild(database, request, source, transaction);
82
+ const retry = await indexFrom(database, request, { byteOffset: 0, lastSequence: 0 }, source, batchBytes, transaction, false);
83
+ return {
84
+ sessionId,
85
+ stream,
86
+ missing: false,
87
+ rebuilt: true,
88
+ rebuildReason: "sequence-regression",
89
+ recordsIndexed: retry.recordsIndexed,
90
+ invalidLines: retry.invalidLines,
91
+ byteOffset: retry.byteOffset,
92
+ lastSequence: retry.lastSequence
93
+ };
94
+ }
95
+ async function indexFrom(database, request, start, source, batchBytes, transaction, allowRegression = true) {
96
+ const reader = new JsonlTailReader(request.sourcePath, { startOffset: start.byteOffset, maxReadBytes: batchBytes });
97
+ let recordsIndexed = 0;
98
+ let invalidLines = 0;
99
+ let lastSequence = start.lastSequence;
100
+ for (;; ) {
101
+ const { missing, bytesRead, lines } = await reader.read();
102
+ if (missing || bytesRead === 0)
103
+ break;
104
+ const batch = [];
105
+ for (const line of lines) {
106
+ if (!line.trim())
107
+ continue;
108
+ let value;
109
+ try {
110
+ value = JSON.parse(line);
111
+ } catch {
112
+ invalidLines += 1;
113
+ continue;
114
+ }
115
+ if (isLogStreamHeader(value))
116
+ continue;
117
+ const record = parseLogRecord(value);
118
+ if (!record) {
119
+ invalidLines += 1;
120
+ continue;
121
+ }
122
+ if (record.sequence <= lastSequence) {
123
+ if (allowRegression)
124
+ return { recordsIndexed, invalidLines, ...start, regressed: true };
125
+ invalidLines += 1;
126
+ continue;
127
+ }
128
+ lastSequence = record.sequence;
129
+ batch.push(record);
130
+ }
131
+ const position = { byteOffset: reader.offset, lastSequence };
132
+ transaction(() => {
133
+ if (batch.length > 0)
134
+ invalidLines += request.apply(batch, database) ?? 0;
135
+ writeProgress(database, request, position, source);
136
+ });
137
+ recordsIndexed += batch.length;
138
+ }
139
+ return { recordsIndexed, invalidLines, byteOffset: reader.offset, lastSequence, regressed: false };
140
+ }
141
+ function decideRebuild(row, source) {
142
+ if (!row)
143
+ return "new";
144
+ if (source.size < row.byte_offset)
145
+ return "truncated";
146
+ if (source.fingerprint !== undefined && row.source_fingerprint !== "" && source.fingerprint !== row.source_fingerprint) {
147
+ return "replaced";
148
+ }
149
+ return;
150
+ }
151
+ function startOf(reason, row) {
152
+ if (reason || !row)
153
+ return { byteOffset: 0, lastSequence: 0 };
154
+ return { byteOffset: row.byte_offset, lastSequence: row.last_sequence };
155
+ }
156
+ function rebuild(database, request, source, transaction) {
157
+ transaction(() => {
158
+ request.clear({ sessionId: request.sessionId, stream: request.stream }, database);
159
+ writeProgress(database, request, { byteOffset: 0, lastSequence: 0 }, source);
160
+ });
161
+ }
162
+ function unchanged(request, row, state) {
163
+ return {
164
+ sessionId: request.sessionId,
165
+ stream: request.stream,
166
+ missing: state.missing,
167
+ rebuilt: false,
168
+ recordsIndexed: 0,
169
+ invalidLines: 0,
170
+ byteOffset: row?.byte_offset ?? 0,
171
+ lastSequence: row?.last_sequence ?? 0
172
+ };
173
+ }
174
+ function selectProgress(database, sessionId, stream, domain) {
175
+ return database.query("select * from indexed_streams where session_id = $sessionId and stream = $stream and domain = $domain").get({ sessionId, stream, domain }) ?? undefined;
176
+ }
177
+ function writeProgress(database, request, position, source) {
178
+ database.query(`insert or replace into indexed_streams
179
+ (session_id, stream, domain, source_path, source_fingerprint, byte_offset, last_sequence, source_size, source_modified_at, indexed_at)
180
+ values ($sessionId, $stream, $domain, $sourcePath, $fingerprint, $byteOffset, $lastSequence, $size, $modifiedAt, $indexedAt)`).run({
181
+ sessionId: request.sessionId,
182
+ stream: request.stream,
183
+ domain: request.domain,
184
+ sourcePath: request.sourcePath,
185
+ fingerprint: source.fingerprint ?? "",
186
+ byteOffset: position.byteOffset,
187
+ lastSequence: position.lastSequence,
188
+ size: Math.max(source.size, position.byteOffset),
189
+ modifiedAt: source.modifiedAt,
190
+ indexedAt: new Date().toISOString()
191
+ });
192
+ }
193
+
194
+ // src/paths.ts
195
+ import path from "path";
196
+ import { defaultLogDirectory } from "@kar-mi/spirit-vale-tools-logging";
197
+ function readModelDirectory(logDirectory = defaultLogDirectory()) {
198
+ return path.join(logDirectory, "cache");
199
+ }
200
+ function readModelPath(logDirectory = defaultLogDirectory()) {
201
+ return path.join(readModelDirectory(logDirectory), "read-model.sqlite");
202
+ }
203
+ function readModelFiles(databasePath) {
204
+ return [databasePath, `${databasePath}-wal`, `${databasePath}-shm`];
205
+ }
206
+
207
+ // src/schema.ts
208
+ var READ_MODEL_SCHEMA_VERSION = 1;
209
+ var METADATA_SCHEMA = `
210
+ create table if not exists read_model_metadata (
211
+ id integer primary key check (id = 1),
212
+ schema_version integer not null,
213
+ created_at text not null,
214
+ last_rebuild_at text
215
+ );
216
+
217
+ create table if not exists read_model_domains (
218
+ domain text primary key,
219
+ version integer not null
220
+ );
221
+
222
+ create table if not exists indexed_streams (
223
+ session_id text not null,
224
+ stream text not null,
225
+ domain text not null,
226
+ source_path text not null,
227
+ source_fingerprint text not null,
228
+ byte_offset integer not null,
229
+ last_sequence integer not null,
230
+ source_size integer not null,
231
+ source_modified_at text not null,
232
+ indexed_at text not null,
233
+ primary key (session_id, stream, domain)
234
+ );
235
+
236
+ create index if not exists indexed_streams_by_domain on indexed_streams (domain);
237
+ `;
238
+ function createMetadataSchema(database) {
239
+ database.exec(METADATA_SCHEMA);
240
+ }
241
+ function readMetadata(database) {
242
+ return database.query("select schema_version, created_at, last_rebuild_at from read_model_metadata where id = 1").get() ?? undefined;
243
+ }
244
+ function writeMetadata(database, createdAt) {
245
+ database.query("insert or replace into read_model_metadata (id, schema_version, created_at, last_rebuild_at) values (1, $version, $createdAt, null)").run({ version: READ_MODEL_SCHEMA_VERSION, createdAt });
246
+ }
247
+ function markRebuilt(database, at) {
248
+ database.query("update read_model_metadata set last_rebuild_at = $at where id = 1").run({ at });
249
+ }
250
+
251
+ // src/read-model.ts
252
+ async function openReadModel(options) {
253
+ const databasePath = options.path ?? readModelPath(options.logDirectory ?? defaultLogDirectory2());
254
+ await mkdir(path2.dirname(databasePath), { recursive: true });
255
+ const duplicate = findDuplicateDomain(options.domains);
256
+ if (duplicate)
257
+ throw new Error(`domain ${duplicate} is registered more than once`);
258
+ const rebuilds = [];
259
+ let database = await openDatabase(databasePath, rebuilds);
260
+ createMetadataSchema(database);
261
+ let metadata = readMetadata(database);
262
+ if (metadata && metadata.schema_version !== READ_MODEL_SCHEMA_VERSION) {
263
+ closeQuietly(database);
264
+ await deleteReadModel(databasePath);
265
+ rebuilds.push({
266
+ reason: "metadata-version",
267
+ detail: `stored schema ${metadata.schema_version}, expected ${READ_MODEL_SCHEMA_VERSION}`
268
+ });
269
+ database = await openDatabase(databasePath, rebuilds, { skipCreatedNotice: true });
270
+ createMetadataSchema(database);
271
+ metadata = undefined;
272
+ }
273
+ if (!metadata) {
274
+ writeMetadata(database, new Date().toISOString());
275
+ metadata = readMetadata(database);
276
+ }
277
+ applyDomains(database, options.domains, rebuilds);
278
+ if (rebuilds.length > 0)
279
+ markRebuilt(database, new Date().toISOString());
280
+ for (const rebuild2 of rebuilds)
281
+ options.onRebuild?.(rebuild2);
282
+ const statements = new Map;
283
+ const openedWith = rebuilds[0];
284
+ let pending;
285
+ const runPending = database.transaction(() => pending());
286
+ const model = {
287
+ path: databasePath,
288
+ database,
289
+ statement(sql) {
290
+ return cached(statements, sql, false, database);
291
+ },
292
+ bigintStatement(sql) {
293
+ return cached(statements, sql, true, database);
294
+ },
295
+ transaction(run) {
296
+ const previous = pending;
297
+ pending = run;
298
+ try {
299
+ return runPending();
300
+ } finally {
301
+ pending = previous;
302
+ }
303
+ },
304
+ indexStream(request) {
305
+ return indexStream(database, request, (run) => model.transaction(run));
306
+ },
307
+ health() {
308
+ const stored = readMetadata(database);
309
+ return {
310
+ path: databasePath,
311
+ schemaVersion: stored.schema_version,
312
+ createdAt: stored.created_at,
313
+ ...stored.last_rebuild_at ? { lastRebuildAt: stored.last_rebuild_at } : {},
314
+ ...openedWith ? { openedWith } : {},
315
+ domains: database.query("select domain, version from read_model_domains order by domain").all().map((row) => ({ name: row.domain, version: row.version })),
316
+ streams: database.query("select * from indexed_streams order by session_id, stream, domain").all().map(toStatus)
317
+ };
318
+ },
319
+ close() {
320
+ for (const statement of statements.values())
321
+ statement.finalize();
322
+ statements.clear();
323
+ database.close(true);
324
+ }
325
+ };
326
+ return model;
327
+ }
328
+ async function deleteReadModel(databasePath) {
329
+ for (const file of readModelFiles(databasePath))
330
+ await rm(file, { force: true });
331
+ }
332
+ async function openDatabase(databasePath, rebuilds, options = {}) {
333
+ const existed = await Bun.file(databasePath).exists();
334
+ let candidate;
335
+ try {
336
+ candidate = new Database(databasePath, { create: true, strict: true });
337
+ configure(candidate);
338
+ const integrity = candidate.query("pragma integrity_check").get();
339
+ if (integrity?.integrity_check !== "ok") {
340
+ throw new Error(`integrity check reported ${integrity?.integrity_check ?? "no result"}`);
341
+ }
342
+ if (!existed && !options.skipCreatedNotice)
343
+ rebuilds.push({ reason: "created" });
344
+ const opened = candidate;
345
+ candidate = undefined;
346
+ return opened;
347
+ } catch (error) {
348
+ if (candidate)
349
+ closeQuietly(candidate);
350
+ await deleteReadModel(databasePath);
351
+ rebuilds.push({
352
+ reason: existed ? "corrupt" : "unreadable",
353
+ detail: error instanceof Error ? error.message : String(error)
354
+ });
355
+ const replacement = new Database(databasePath, { create: true, strict: true });
356
+ configure(replacement);
357
+ return replacement;
358
+ }
359
+ }
360
+ function configure(database) {
361
+ database.exec("pragma journal_mode = wal");
362
+ database.exec("pragma synchronous = normal");
363
+ database.exec("pragma foreign_keys = on");
364
+ }
365
+ function closeQuietly(database) {
366
+ try {
367
+ database.close(true);
368
+ } catch {}
369
+ }
370
+ function applyDomains(database, domains, rebuilds) {
371
+ const stored = new Map(database.query("select domain, version from read_model_domains").all().map((row) => [row.domain, row.version]));
372
+ database.transaction(() => {
373
+ for (const domain of domains) {
374
+ const current = stored.get(domain.name);
375
+ if (current === domain.version)
376
+ continue;
377
+ if (current !== undefined) {
378
+ domain.dropSchema(database);
379
+ database.query("delete from indexed_streams where domain = $domain").run({ domain: domain.name });
380
+ rebuilds.push({ reason: "domain-version", domain: domain.name, detail: `version ${current} -> ${domain.version}` });
381
+ }
382
+ domain.createSchema(database);
383
+ database.query("insert or replace into read_model_domains (domain, version) values ($domain, $version)").run({ domain: domain.name, version: domain.version });
384
+ }
385
+ })();
386
+ }
387
+ function cached(statements, sql, bigints, database) {
388
+ const key = bigints ? `bigint:${sql}` : sql;
389
+ let statement = statements.get(key);
390
+ if (!statement) {
391
+ statement = database.prepare(sql);
392
+ if (bigints)
393
+ statement.safeIntegers(true);
394
+ statements.set(key, statement);
395
+ }
396
+ return statement;
397
+ }
398
+ function findDuplicateDomain(domains) {
399
+ const seen = new Set;
400
+ for (const domain of domains) {
401
+ if (seen.has(domain.name))
402
+ return domain.name;
403
+ seen.add(domain.name);
404
+ }
405
+ return;
406
+ }
407
+ function toStatus(row) {
408
+ return {
409
+ sessionId: row.session_id,
410
+ stream: row.stream,
411
+ domain: row.domain,
412
+ sourcePath: row.source_path,
413
+ byteOffset: row.byte_offset,
414
+ lastSequence: row.last_sequence,
415
+ sourceSize: row.source_size,
416
+ sourceModifiedAt: row.source_modified_at,
417
+ indexedAt: row.indexed_at
418
+ };
419
+ }
420
+ export {
421
+ READ_MODEL_SCHEMA_VERSION,
422
+ deleteReadModel,
423
+ openReadModel,
424
+ readModelDirectory,
425
+ readModelFiles,
426
+ readModelPath
427
+ };
@@ -0,0 +1,7 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import type { IndexStreamRequest, IndexStreamResult } from "./types.ts";
3
+ type Transactional = <T>(run: () => T) => T;
4
+ /** Indexes whatever the source has gained since the recorded byte offset, rebuilding the stream from scratch when the source was truncated, replaced, or rewound. */
5
+ export declare function indexStream(database: Database, request: IndexStreamRequest, transaction: Transactional): Promise<IndexStreamResult>;
6
+ export {};
7
+ //# sourceMappingURL=indexer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"indexer.d.ts","sourceRoot":"","sources":["../src/indexer.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAK3C,OAAO,KAAK,EAAE,kBAAkB,EAAE,iBAAiB,EAAuB,MAAM,YAAY,CAAC;AAI7F,KAAK,aAAa,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC;AAQ5C,qKAAqK;AACrK,wBAAsB,WAAW,CAC/B,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,kBAAkB,EAC3B,WAAW,EAAE,aAAa,GACzB,OAAO,CAAC,iBAAiB,CAAC,CA+C5B"}
@@ -0,0 +1,6 @@
1
+ /** Cache directory beside the canonical logs. Everything here is disposable and rebuildable. */
2
+ export declare function readModelDirectory(logDirectory?: string): string;
3
+ export declare function readModelPath(logDirectory?: string): string;
4
+ /** The database file plus the write-ahead-log sidecars SQLite keeps next to it. */
5
+ export declare function readModelFiles(databasePath: string): string[];
6
+ //# sourceMappingURL=paths.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paths.d.ts","sourceRoot":"","sources":["../src/paths.ts"],"names":[],"mappings":"AAIA,gGAAgG;AAChG,wBAAgB,kBAAkB,CAAC,YAAY,SAAwB,GAAG,MAAM,CAE/E;AAED,wBAAgB,aAAa,CAAC,YAAY,SAAwB,GAAG,MAAM,CAE1E;AAED,mFAAmF;AACnF,wBAAgB,cAAc,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,EAAE,CAE7D"}
@@ -0,0 +1,29 @@
1
+ import { Database } from "bun:sqlite";
2
+ import type { Statement } from "bun:sqlite";
3
+ import type { IndexStreamRequest, IndexStreamResult, ReadModelDomain, ReadModelHealth, ReadModelRebuild } from "./types.ts";
4
+ export interface OpenReadModelOptions {
5
+ domains: readonly ReadModelDomain[];
6
+ /** Used to derive the default database path. Defaults to the shared log directory. */
7
+ logDirectory?: string;
8
+ /** Overrides the derived `<logDirectory>/cache/read-model.sqlite` path. */
9
+ path?: string;
10
+ /** Reports each rebuild performed while opening. */
11
+ onRebuild?: (event: ReadModelRebuild) => void;
12
+ }
13
+ export interface ReadModel {
14
+ readonly path: string;
15
+ readonly database: Database;
16
+ /** Prepared statement, cached for the model's lifetime and finalized by {@link close}. */
17
+ statement(sql: string): Statement;
18
+ /** As {@link statement}, but returns 64-bit integers as bigint instead of rounding them. */
19
+ bigintStatement(sql: string): Statement;
20
+ transaction<T>(run: () => T): T;
21
+ indexStream(request: IndexStreamRequest): Promise<IndexStreamResult>;
22
+ health(): ReadModelHealth;
23
+ close(): void;
24
+ }
25
+ /** Opens the disposable read model, recreating or repairing whatever is unusable. */
26
+ export declare function openReadModel(options: OpenReadModelOptions): Promise<ReadModel>;
27
+ /** Removes the database and the write-ahead-log sidecars SQLite keeps beside it. */
28
+ export declare function deleteReadModel(databasePath: string): Promise<void>;
29
+ //# sourceMappingURL=read-model.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"read-model.d.ts","sourceRoot":"","sources":["../src/read-model.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACtC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAc5C,OAAO,KAAK,EACV,kBAAkB,EAClB,iBAAiB,EAEjB,eAAe,EACf,eAAe,EACf,gBAAgB,EACjB,MAAM,YAAY,CAAC;AAEpB,MAAM,WAAW,oBAAoB;IACnC,OAAO,EAAE,SAAS,eAAe,EAAE,CAAC;IACpC,sFAAsF;IACtF,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,2EAA2E;IAC3E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oDAAoD;IACpD,SAAS,CAAC,EAAE,CAAC,KAAK,EAAE,gBAAgB,KAAK,IAAI,CAAC;CAC/C;AAED,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAC;IAC5B,0FAA0F;IAC1F,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,4FAA4F;IAC5F,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAAC;IACxC,WAAW,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC;IAChC,WAAW,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAC;IACrE,MAAM,IAAI,eAAe,CAAC;IAC1B,KAAK,IAAI,IAAI,CAAC;CACf;AAED,qFAAqF;AACrF,wBAAsB,aAAa,CAAC,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAAC,SAAS,CAAC,CA0FrF;AAED,oFAAoF;AACpF,wBAAsB,eAAe,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAEzE"}
@@ -0,0 +1,25 @@
1
+ import type { Database } from "bun:sqlite";
2
+ /** Version of the infrastructure's own tables. */
3
+ export declare const READ_MODEL_SCHEMA_VERSION = 1;
4
+ export declare function createMetadataSchema(database: Database): void;
5
+ export interface ReadModelMetadataRow {
6
+ schema_version: number;
7
+ created_at: string;
8
+ last_rebuild_at: string | null;
9
+ }
10
+ export declare function readMetadata(database: Database): ReadModelMetadataRow | undefined;
11
+ export declare function writeMetadata(database: Database, createdAt: string): void;
12
+ export declare function markRebuilt(database: Database, at: string): void;
13
+ export interface IndexedStreamRow {
14
+ session_id: string;
15
+ stream: string;
16
+ domain: string;
17
+ source_path: string;
18
+ source_fingerprint: string;
19
+ byte_offset: number;
20
+ last_sequence: number;
21
+ source_size: number;
22
+ source_modified_at: string;
23
+ indexed_at: string;
24
+ }
25
+ //# sourceMappingURL=schema.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAE3C,kDAAkD;AAClD,eAAO,MAAM,yBAAyB,IAAI,CAAC;AAgC3C,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,QAAQ,GAAG,IAAI,CAE7D;AAED,MAAM,WAAW,oBAAoB;IACnC,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;IACnB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;CAChC;AAED,wBAAgB,YAAY,CAAC,QAAQ,EAAE,QAAQ,GAAG,oBAAoB,GAAG,SAAS,CAIjF;AAED,wBAAgB,aAAa,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,CAIzE;AAED,wBAAgB,WAAW,CAAC,QAAQ,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,GAAG,IAAI,CAEhE;AAED,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,UAAU,EAAE,MAAM,CAAC;CACpB"}
@@ -0,0 +1,92 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import type { LogRecord, LogStream } from "@kar-mi/spirit-vale-tools-logging";
3
+ /** A set of tables owned by one domain package. */
4
+ export interface ReadModelDomain {
5
+ /** Stable key, e.g. "combat". Scopes rebuilds and indexing progress. */
6
+ readonly name: string;
7
+ /** Bump whenever {@link createSchema} changes; this domain is then dropped and re-indexed. */
8
+ readonly version: number;
9
+ createSchema(database: Database): void;
10
+ dropSchema(database: Database): void;
11
+ }
12
+ export type RebuildReason =
13
+ /** No database existed yet. */
14
+ "created"
15
+ /** The file failed an integrity check. */
16
+ | "corrupt"
17
+ /** The file could not be opened or configured at all. */
18
+ | "unreadable"
19
+ /** The infrastructure's own schema version moved. */
20
+ | "metadata-version"
21
+ /** One domain's registered version moved; only that domain is rebuilt. */
22
+ | "domain-version";
23
+ export interface ReadModelRebuild {
24
+ reason: RebuildReason;
25
+ /** Set only for a single-domain rebuild; absent when the whole database was recreated. */
26
+ domain?: string;
27
+ detail?: string;
28
+ }
29
+ export type StreamRebuildReason =
30
+ /** No progress was recorded for this stream yet. */
31
+ "new"
32
+ /** The source shrank below the recorded offset. */
33
+ | "truncated"
34
+ /** A different file now occupies the same path. */
35
+ | "replaced"
36
+ /** A record arrived at or below the last indexed sequence. */
37
+ | "sequence-regression";
38
+ export interface IndexStreamRequest {
39
+ sessionId: string;
40
+ stream: LogStream;
41
+ sourcePath: string;
42
+ /** The domain whose tables {@link apply} writes to. */
43
+ domain: string;
44
+ /** Applies one batch of records. */
45
+ apply: (records: readonly LogRecord[], database: Database) => void | number;
46
+ /** Removes this domain's rows for one session/stream, so a rebuild starts from empty. */
47
+ clear: (scope: {
48
+ sessionId: string;
49
+ stream: LogStream;
50
+ }, database: Database) => void;
51
+ /** Most source bytes read — and so most rows applied — per transaction. */
52
+ batchBytes?: number;
53
+ }
54
+ export interface IndexStreamResult {
55
+ sessionId: string;
56
+ stream: LogStream;
57
+ /** The source file does not exist; recorded progress was left untouched. */
58
+ missing: boolean;
59
+ /** The stream was re-read from byte 0. */
60
+ rebuilt: boolean;
61
+ rebuildReason?: StreamRebuildReason;
62
+ recordsIndexed: number;
63
+ /** Lines that were not a valid log record. Counted, never fatal. */
64
+ invalidLines: number;
65
+ byteOffset: number;
66
+ lastSequence: number;
67
+ }
68
+ export interface IndexedStreamStatus {
69
+ sessionId: string;
70
+ stream: LogStream;
71
+ domain: string;
72
+ sourcePath: string;
73
+ byteOffset: number;
74
+ lastSequence: number;
75
+ sourceSize: number;
76
+ sourceModifiedAt: string;
77
+ indexedAt: string;
78
+ }
79
+ export interface ReadModelHealth {
80
+ path: string;
81
+ schemaVersion: number;
82
+ createdAt: string;
83
+ lastRebuildAt?: string;
84
+ /** Set when opening the database had to rebuild something. */
85
+ openedWith?: ReadModelRebuild;
86
+ domains: {
87
+ name: string;
88
+ version: number;
89
+ }[];
90
+ streams: IndexedStreamStatus[];
91
+ }
92
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@kar-mi/spirit-vale-tools-sqlite",
3
+ "version": "0.2.1",
4
+ "description": "Disposable SQLite read-model infrastructure for Spirit Vale session logs.",
5
+ "license": "AGPL-3.0-only",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/kar-mi/spirit-vale-tools.git",
9
+ "directory": "packages/sqlite"
10
+ },
11
+ "type": "module",
12
+ "engines": {
13
+ "bun": ">=1.4.0"
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md"
18
+ ],
19
+ "scripts": {
20
+ "build": "bun build ./src/index.ts --outdir ./dist --target bun --format esm --external '@kar-mi/spirit-vale-tools-*' && bunx tsc --project ./tsconfig.build.json"
21
+ },
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "default": "./dist/index.js"
26
+ }
27
+ },
28
+ "dependencies": {
29
+ "@kar-mi/spirit-vale-tools-logging": "^0.9.0"
30
+ }
31
+ }