@codelia/storage 0.1.2 → 0.1.10

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/dist/index.cjs CHANGED
@@ -255,8 +255,12 @@ var StoragePathServiceImpl = class {
255
255
  var import_node_fs3 = require("fs");
256
256
  var import_node_path3 = __toESM(require("path"), 1);
257
257
  var import_core = require("@codelia/core");
258
- var STATE_DIRNAME = "state";
259
- var resolveStateDir = (paths) => import_node_path3.default.join(paths.sessionsDir, STATE_DIRNAME);
258
+ var LEGACY_STATE_DIRNAME = "state";
259
+ var MESSAGES_DIRNAME = "messages";
260
+ var STATE_DB_FILENAME = "state.db";
261
+ var resolveLegacyStateDir = (paths) => import_node_path3.default.join(paths.sessionsDir, LEGACY_STATE_DIRNAME);
262
+ var resolveMessagesDir = (paths) => import_node_path3.default.join(paths.sessionsDir, MESSAGES_DIRNAME);
263
+ var resolveStateDbPath = (paths) => import_node_path3.default.join(paths.sessionsDir, STATE_DB_FILENAME);
260
264
  var extractLastUserMessage = (messages) => {
261
265
  for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
262
266
  const message = messages[idx];
@@ -276,69 +280,384 @@ var toSummary = (state) => ({
276
280
  message_count: Array.isArray(state.messages) ? state.messages.length : void 0,
277
281
  last_user_message: Array.isArray(state.messages) ? extractLastUserMessage(state.messages) : void 0
278
282
  });
283
+ var fromSummaryRow = (row) => ({
284
+ session_id: row.session_id,
285
+ updated_at: row.updated_at,
286
+ run_id: row.run_id ?? void 0,
287
+ message_count: row.message_count ?? void 0,
288
+ last_user_message: row.last_user_message ?? void 0
289
+ });
290
+ var serializeMessages = (messages) => {
291
+ if (messages.length === 0) return "";
292
+ return `${messages.map((message) => JSON.stringify(message)).join("\n")}
293
+ `;
294
+ };
295
+ var deserializeMessages = (payload) => {
296
+ if (!payload.trim()) return [];
297
+ const messages = [];
298
+ const lines = payload.split(/\r?\n/);
299
+ for (const rawLine of lines) {
300
+ const line = rawLine.trim();
301
+ if (!line) continue;
302
+ const parsed = JSON.parse(line);
303
+ if (!parsed || typeof parsed !== "object") {
304
+ throw new Error("Invalid session message entry");
305
+ }
306
+ messages.push(parsed);
307
+ }
308
+ return messages;
309
+ };
310
+ var atomicWriteFile = async (filePath, payload) => {
311
+ const dirname = import_node_path3.default.dirname(filePath);
312
+ const basename = import_node_path3.default.basename(filePath);
313
+ const tempFile = import_node_path3.default.join(
314
+ dirname,
315
+ `${basename}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
316
+ );
317
+ let wroteTemp = false;
318
+ try {
319
+ await import_node_fs3.promises.writeFile(tempFile, payload, "utf8");
320
+ wroteTemp = true;
321
+ await import_node_fs3.promises.rename(tempFile, filePath);
322
+ } catch (error) {
323
+ if (wroteTemp) {
324
+ await import_node_fs3.promises.rm(tempFile, { force: true }).catch(() => {
325
+ });
326
+ }
327
+ throw error;
328
+ }
329
+ };
279
330
  var SessionStateStoreImpl = class {
280
- stateDir;
281
- ensureDir;
331
+ legacyStateDir;
332
+ messagesDir;
333
+ stateDbPath;
334
+ ensureDirs;
335
+ db;
336
+ schemaInit;
282
337
  onError;
338
+ lastDbUnavailableError;
283
339
  constructor(options = {}) {
284
340
  const paths = options.paths ?? resolveStoragePaths();
285
- this.stateDir = resolveStateDir(paths);
286
- this.ensureDir = import_node_fs3.promises.mkdir(this.stateDir, { recursive: true }).then(() => {
341
+ this.legacyStateDir = resolveLegacyStateDir(paths);
342
+ this.messagesDir = resolveMessagesDir(paths);
343
+ this.stateDbPath = resolveStateDbPath(paths);
344
+ this.ensureDirs = Promise.all([
345
+ import_node_fs3.promises.mkdir(paths.sessionsDir, { recursive: true }),
346
+ import_node_fs3.promises.mkdir(this.legacyStateDir, { recursive: true }),
347
+ import_node_fs3.promises.mkdir(this.messagesDir, { recursive: true })
348
+ ]).then(() => {
287
349
  });
288
350
  this.onError = options.onError;
351
+ this.db = null;
352
+ this.schemaInit = null;
353
+ this.lastDbUnavailableError = null;
289
354
  }
290
- resolvePath(sessionId) {
291
- return import_node_path3.default.join(this.stateDir, `${sessionId}.json`);
355
+ getOrCreateDb() {
356
+ if (!this.db) {
357
+ this.db = this.openDatabase();
358
+ }
359
+ return this.db;
292
360
  }
293
- async load(sessionId) {
361
+ resolveLegacyPath(sessionId) {
362
+ return import_node_path3.default.join(this.legacyStateDir, `${sessionId}.json`);
363
+ }
364
+ resolveMessagePath(sessionId) {
365
+ return import_node_path3.default.join(this.messagesDir, `${sessionId}.jsonl`);
366
+ }
367
+ async openDatabase() {
368
+ await this.ensureDirs;
369
+ if (process.versions.bun) {
370
+ const bunSqliteSpecifier = "bun:sqlite";
371
+ const { Database } = await import(bunSqliteSpecifier);
372
+ const db2 = new Database(this.stateDbPath, { create: true });
373
+ return {
374
+ exec: (sql) => {
375
+ db2.exec(sql);
376
+ },
377
+ run: (sql, params = []) => {
378
+ db2.query(sql).run(...params);
379
+ },
380
+ get: (sql, params = []) => {
381
+ const row = db2.query(sql).get(...params);
382
+ return row ?? void 0;
383
+ },
384
+ all: (sql, params = []) => db2.query(sql).all(...params)
385
+ };
386
+ }
387
+ const betterSqliteSpecifier = "better-sqlite3";
388
+ const betterSqliteModule = await import(betterSqliteSpecifier);
389
+ const BetterSqlite3 = betterSqliteModule.default ?? betterSqliteModule;
390
+ const db = new BetterSqlite3(this.stateDbPath);
391
+ return {
392
+ exec: (sql) => {
393
+ db.exec(sql);
394
+ },
395
+ run: (sql, params = []) => {
396
+ db.prepare(sql).run(...params);
397
+ },
398
+ get: (sql, params = []) => {
399
+ const row = db.prepare(sql).get(...params);
400
+ return row ?? void 0;
401
+ },
402
+ all: (sql, params = []) => db.prepare(sql).all(...params)
403
+ };
404
+ }
405
+ initDatabaseSchema(db) {
406
+ db.exec("PRAGMA journal_mode = WAL;");
407
+ db.exec(`
408
+ CREATE TABLE IF NOT EXISTS session_state (
409
+ session_id TEXT PRIMARY KEY,
410
+ updated_at TEXT NOT NULL,
411
+ run_id TEXT,
412
+ invoke_seq INTEGER,
413
+ schema_version INTEGER NOT NULL,
414
+ meta_json TEXT,
415
+ message_count INTEGER,
416
+ last_user_message TEXT
417
+ );
418
+ `);
419
+ db.exec(`
420
+ CREATE INDEX IF NOT EXISTS idx_session_state_updated_at
421
+ ON session_state(updated_at DESC);
422
+ `);
423
+ }
424
+ async tryGetDb(action, detail) {
294
425
  try {
295
- const file = await import_node_fs3.promises.readFile(this.resolvePath(sessionId), "utf8");
426
+ const db = await this.getOrCreateDb();
427
+ if (!this.schemaInit) {
428
+ this.schemaInit = Promise.resolve().then(() => {
429
+ this.initDatabaseSchema(db);
430
+ });
431
+ }
432
+ await this.schemaInit;
433
+ this.lastDbUnavailableError = null;
434
+ return db;
435
+ } catch (error) {
436
+ this.db = null;
437
+ this.schemaInit = null;
438
+ this.lastDbUnavailableError = error;
439
+ this.onError?.(error, { action: `${action}.db_unavailable`, detail });
440
+ return null;
441
+ }
442
+ }
443
+ async requireDb(action, detail) {
444
+ const db = await this.tryGetDb(action, detail);
445
+ if (!db) {
446
+ const reason = this.lastDbUnavailableError ? `: ${String(this.lastDbUnavailableError)}` : "";
447
+ throw new Error(`Session index database unavailable${reason}`);
448
+ }
449
+ return db;
450
+ }
451
+ async loadLegacy(sessionId) {
452
+ try {
453
+ const file = await import_node_fs3.promises.readFile(this.resolveLegacyPath(sessionId), "utf8");
296
454
  const parsed = JSON.parse(file);
297
455
  if (!parsed || typeof parsed !== "object") return null;
298
- return parsed;
456
+ if (!parsed.session_id || !parsed.updated_at) return null;
457
+ const messages = Array.isArray(parsed.messages) ? parsed.messages : [];
458
+ return {
459
+ schema_version: typeof parsed.schema_version === "number" ? parsed.schema_version : 1,
460
+ session_id: parsed.session_id,
461
+ updated_at: parsed.updated_at,
462
+ run_id: parsed.run_id,
463
+ invoke_seq: parsed.invoke_seq,
464
+ messages,
465
+ meta: parsed.meta && typeof parsed.meta === "object" ? parsed.meta : void 0
466
+ };
299
467
  } catch (error) {
300
468
  if (error.code === "ENOENT") {
301
469
  return null;
302
470
  }
303
- this.onError?.(error, { action: "load", detail: sessionId });
471
+ this.onError?.(error, { action: "legacy.load", detail: sessionId });
304
472
  throw error;
305
473
  }
306
474
  }
475
+ async loadFromIndex(sessionId, db) {
476
+ let row;
477
+ try {
478
+ row = db.get(
479
+ `SELECT session_id, updated_at, run_id, invoke_seq, schema_version, meta_json
480
+ FROM session_state
481
+ WHERE session_id = ?`,
482
+ [sessionId]
483
+ );
484
+ } catch (error) {
485
+ this.onError?.(error, { action: "index.load", detail: sessionId });
486
+ throw error;
487
+ }
488
+ if (!row) return null;
489
+ let messages;
490
+ try {
491
+ const payload = await import_node_fs3.promises.readFile(
492
+ this.resolveMessagePath(sessionId),
493
+ "utf8"
494
+ );
495
+ messages = deserializeMessages(payload);
496
+ } catch (error) {
497
+ if (error.code === "ENOENT") {
498
+ messages = [];
499
+ } else {
500
+ this.onError?.(error, { action: "messages.load", detail: sessionId });
501
+ throw error;
502
+ }
503
+ }
504
+ let meta;
505
+ if (row.meta_json) {
506
+ try {
507
+ const parsed = JSON.parse(row.meta_json);
508
+ if (parsed && typeof parsed === "object") {
509
+ meta = parsed;
510
+ }
511
+ } catch (error) {
512
+ this.onError?.(error, {
513
+ action: "index.meta.parse",
514
+ detail: sessionId
515
+ });
516
+ }
517
+ }
518
+ return {
519
+ schema_version: 1,
520
+ session_id: row.session_id,
521
+ updated_at: row.updated_at,
522
+ run_id: row.run_id ?? void 0,
523
+ invoke_seq: row.invoke_seq ?? void 0,
524
+ messages,
525
+ meta
526
+ };
527
+ }
528
+ async saveToIndex(state, db) {
529
+ const messagePayload = serializeMessages(
530
+ Array.isArray(state.messages) ? state.messages : []
531
+ );
532
+ await atomicWriteFile(
533
+ this.resolveMessagePath(state.session_id),
534
+ messagePayload
535
+ );
536
+ const summary = toSummary(state);
537
+ db.run(
538
+ `INSERT INTO session_state (
539
+ session_id,
540
+ updated_at,
541
+ run_id,
542
+ invoke_seq,
543
+ schema_version,
544
+ meta_json,
545
+ message_count,
546
+ last_user_message
547
+ )
548
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
549
+ ON CONFLICT(session_id) DO UPDATE SET
550
+ updated_at = excluded.updated_at,
551
+ run_id = excluded.run_id,
552
+ invoke_seq = excluded.invoke_seq,
553
+ schema_version = excluded.schema_version,
554
+ meta_json = excluded.meta_json,
555
+ message_count = excluded.message_count,
556
+ last_user_message = excluded.last_user_message`,
557
+ [
558
+ state.session_id,
559
+ state.updated_at,
560
+ state.run_id ?? null,
561
+ state.invoke_seq ?? null,
562
+ state.schema_version ?? 1,
563
+ state.meta ? JSON.stringify(state.meta) : null,
564
+ summary.message_count ?? null,
565
+ summary.last_user_message ?? null
566
+ ]
567
+ );
568
+ }
569
+ async load(sessionId) {
570
+ const db = await this.requireDb("load", sessionId);
571
+ try {
572
+ const indexed = await this.loadFromIndex(sessionId, db);
573
+ if (indexed) return indexed;
574
+ } catch (error) {
575
+ this.onError?.(error, { action: "load.index", detail: sessionId });
576
+ throw error;
577
+ }
578
+ const legacy = await this.loadLegacy(sessionId);
579
+ if (!legacy) return null;
580
+ if (db) {
581
+ try {
582
+ await this.saveToIndex(legacy, db);
583
+ } catch (error) {
584
+ this.onError?.(error, {
585
+ action: "load.migrate_legacy",
586
+ detail: sessionId
587
+ });
588
+ }
589
+ }
590
+ return legacy;
591
+ }
307
592
  async save(state) {
308
- await this.ensureDir;
309
- const payload = `${JSON.stringify(state)}
310
- `;
311
- await import_node_fs3.promises.writeFile(this.resolvePath(state.session_id), payload, "utf8");
593
+ await this.ensureDirs;
594
+ const db = await this.requireDb("save", state.session_id);
595
+ try {
596
+ await this.saveToIndex(state, db);
597
+ } catch (error) {
598
+ this.onError?.(error, { action: "save.index", detail: state.session_id });
599
+ throw error;
600
+ }
312
601
  }
313
- async list() {
314
- await this.ensureDir;
602
+ async listLegacySummaries() {
315
603
  let entries;
316
604
  try {
317
- entries = await import_node_fs3.promises.readdir(this.stateDir, {
605
+ entries = await import_node_fs3.promises.readdir(this.legacyStateDir, {
318
606
  withFileTypes: true,
319
607
  encoding: "utf8"
320
608
  });
321
609
  } catch (error) {
322
- this.onError?.(error, { action: "list" });
610
+ this.onError?.(error, { action: "legacy.list" });
323
611
  throw error;
324
612
  }
325
613
  const summaries = [];
326
614
  for (const entry of entries) {
327
615
  if (!entry.isFile()) continue;
328
616
  if (!entry.name.endsWith(".json")) continue;
329
- const filePath = import_node_path3.default.join(this.stateDir, entry.name);
617
+ const filePath = import_node_path3.default.join(this.legacyStateDir, entry.name);
330
618
  try {
331
619
  const contents = await import_node_fs3.promises.readFile(filePath, "utf8");
332
620
  const parsed = JSON.parse(contents);
333
621
  if (!parsed || typeof parsed !== "object") continue;
334
622
  if (!parsed.session_id || !parsed.updated_at) continue;
335
- summaries.push(toSummary(parsed));
623
+ summaries.push(
624
+ toSummary({
625
+ ...parsed,
626
+ schema_version: typeof parsed.schema_version === "number" ? parsed.schema_version : 1,
627
+ messages: Array.isArray(parsed.messages) ? parsed.messages : []
628
+ })
629
+ );
336
630
  } catch (error) {
337
- this.onError?.(error, { action: "parse", detail: filePath });
631
+ this.onError?.(error, { action: "legacy.parse", detail: filePath });
338
632
  }
339
633
  }
340
634
  return summaries;
341
635
  }
636
+ async list() {
637
+ await this.ensureDirs;
638
+ const summaries = /* @__PURE__ */ new Map();
639
+ const db = await this.requireDb("list");
640
+ try {
641
+ const rows = db.all(
642
+ `SELECT session_id, updated_at, run_id, message_count, last_user_message
643
+ FROM session_state
644
+ ORDER BY updated_at DESC`
645
+ );
646
+ for (const row of rows) {
647
+ summaries.set(row.session_id, fromSummaryRow(row));
648
+ }
649
+ } catch (error) {
650
+ this.onError?.(error, { action: "list.index" });
651
+ throw error;
652
+ }
653
+ const legacy = await this.listLegacySummaries();
654
+ for (const item of legacy) {
655
+ if (!summaries.has(item.session_id)) {
656
+ summaries.set(item.session_id, item);
657
+ }
658
+ }
659
+ return [...summaries.values()];
660
+ }
342
661
  };
343
662
 
344
663
  // src/tool-output-cache.ts
package/dist/index.d.cts CHANGED
@@ -40,13 +40,28 @@ type SessionStateStoreOptions = {
40
40
  }) => void;
41
41
  };
42
42
  declare class SessionStateStoreImpl implements SessionStateStore {
43
- private readonly stateDir;
44
- private readonly ensureDir;
43
+ private readonly legacyStateDir;
44
+ private readonly messagesDir;
45
+ private readonly stateDbPath;
46
+ private readonly ensureDirs;
47
+ private db;
48
+ private schemaInit;
45
49
  private readonly onError?;
50
+ private lastDbUnavailableError;
46
51
  constructor(options?: SessionStateStoreOptions);
47
- private resolvePath;
52
+ private getOrCreateDb;
53
+ private resolveLegacyPath;
54
+ private resolveMessagePath;
55
+ private openDatabase;
56
+ private initDatabaseSchema;
57
+ private tryGetDb;
58
+ private requireDb;
59
+ private loadLegacy;
60
+ private loadFromIndex;
61
+ private saveToIndex;
48
62
  load(sessionId: string): Promise<SessionState | null>;
49
63
  save(state: SessionState): Promise<void>;
64
+ private listLegacySummaries;
50
65
  list(): Promise<SessionStateSummary[]>;
51
66
  }
52
67
 
package/dist/index.d.ts CHANGED
@@ -40,13 +40,28 @@ type SessionStateStoreOptions = {
40
40
  }) => void;
41
41
  };
42
42
  declare class SessionStateStoreImpl implements SessionStateStore {
43
- private readonly stateDir;
44
- private readonly ensureDir;
43
+ private readonly legacyStateDir;
44
+ private readonly messagesDir;
45
+ private readonly stateDbPath;
46
+ private readonly ensureDirs;
47
+ private db;
48
+ private schemaInit;
45
49
  private readonly onError?;
50
+ private lastDbUnavailableError;
46
51
  constructor(options?: SessionStateStoreOptions);
47
- private resolvePath;
52
+ private getOrCreateDb;
53
+ private resolveLegacyPath;
54
+ private resolveMessagePath;
55
+ private openDatabase;
56
+ private initDatabaseSchema;
57
+ private tryGetDb;
58
+ private requireDb;
59
+ private loadLegacy;
60
+ private loadFromIndex;
61
+ private saveToIndex;
48
62
  load(sessionId: string): Promise<SessionState | null>;
49
63
  save(state: SessionState): Promise<void>;
64
+ private listLegacySummaries;
50
65
  list(): Promise<SessionStateSummary[]>;
51
66
  }
52
67
 
package/dist/index.js CHANGED
@@ -212,8 +212,12 @@ var StoragePathServiceImpl = class {
212
212
  import { promises as fs3 } from "fs";
213
213
  import path3 from "path";
214
214
  import { stringifyContent } from "@codelia/core";
215
- var STATE_DIRNAME = "state";
216
- var resolveStateDir = (paths) => path3.join(paths.sessionsDir, STATE_DIRNAME);
215
+ var LEGACY_STATE_DIRNAME = "state";
216
+ var MESSAGES_DIRNAME = "messages";
217
+ var STATE_DB_FILENAME = "state.db";
218
+ var resolveLegacyStateDir = (paths) => path3.join(paths.sessionsDir, LEGACY_STATE_DIRNAME);
219
+ var resolveMessagesDir = (paths) => path3.join(paths.sessionsDir, MESSAGES_DIRNAME);
220
+ var resolveStateDbPath = (paths) => path3.join(paths.sessionsDir, STATE_DB_FILENAME);
217
221
  var extractLastUserMessage = (messages) => {
218
222
  for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
219
223
  const message = messages[idx];
@@ -233,69 +237,384 @@ var toSummary = (state) => ({
233
237
  message_count: Array.isArray(state.messages) ? state.messages.length : void 0,
234
238
  last_user_message: Array.isArray(state.messages) ? extractLastUserMessage(state.messages) : void 0
235
239
  });
240
+ var fromSummaryRow = (row) => ({
241
+ session_id: row.session_id,
242
+ updated_at: row.updated_at,
243
+ run_id: row.run_id ?? void 0,
244
+ message_count: row.message_count ?? void 0,
245
+ last_user_message: row.last_user_message ?? void 0
246
+ });
247
+ var serializeMessages = (messages) => {
248
+ if (messages.length === 0) return "";
249
+ return `${messages.map((message) => JSON.stringify(message)).join("\n")}
250
+ `;
251
+ };
252
+ var deserializeMessages = (payload) => {
253
+ if (!payload.trim()) return [];
254
+ const messages = [];
255
+ const lines = payload.split(/\r?\n/);
256
+ for (const rawLine of lines) {
257
+ const line = rawLine.trim();
258
+ if (!line) continue;
259
+ const parsed = JSON.parse(line);
260
+ if (!parsed || typeof parsed !== "object") {
261
+ throw new Error("Invalid session message entry");
262
+ }
263
+ messages.push(parsed);
264
+ }
265
+ return messages;
266
+ };
267
+ var atomicWriteFile = async (filePath, payload) => {
268
+ const dirname = path3.dirname(filePath);
269
+ const basename = path3.basename(filePath);
270
+ const tempFile = path3.join(
271
+ dirname,
272
+ `${basename}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
273
+ );
274
+ let wroteTemp = false;
275
+ try {
276
+ await fs3.writeFile(tempFile, payload, "utf8");
277
+ wroteTemp = true;
278
+ await fs3.rename(tempFile, filePath);
279
+ } catch (error) {
280
+ if (wroteTemp) {
281
+ await fs3.rm(tempFile, { force: true }).catch(() => {
282
+ });
283
+ }
284
+ throw error;
285
+ }
286
+ };
236
287
  var SessionStateStoreImpl = class {
237
- stateDir;
238
- ensureDir;
288
+ legacyStateDir;
289
+ messagesDir;
290
+ stateDbPath;
291
+ ensureDirs;
292
+ db;
293
+ schemaInit;
239
294
  onError;
295
+ lastDbUnavailableError;
240
296
  constructor(options = {}) {
241
297
  const paths = options.paths ?? resolveStoragePaths();
242
- this.stateDir = resolveStateDir(paths);
243
- this.ensureDir = fs3.mkdir(this.stateDir, { recursive: true }).then(() => {
298
+ this.legacyStateDir = resolveLegacyStateDir(paths);
299
+ this.messagesDir = resolveMessagesDir(paths);
300
+ this.stateDbPath = resolveStateDbPath(paths);
301
+ this.ensureDirs = Promise.all([
302
+ fs3.mkdir(paths.sessionsDir, { recursive: true }),
303
+ fs3.mkdir(this.legacyStateDir, { recursive: true }),
304
+ fs3.mkdir(this.messagesDir, { recursive: true })
305
+ ]).then(() => {
244
306
  });
245
307
  this.onError = options.onError;
308
+ this.db = null;
309
+ this.schemaInit = null;
310
+ this.lastDbUnavailableError = null;
246
311
  }
247
- resolvePath(sessionId) {
248
- return path3.join(this.stateDir, `${sessionId}.json`);
312
+ getOrCreateDb() {
313
+ if (!this.db) {
314
+ this.db = this.openDatabase();
315
+ }
316
+ return this.db;
249
317
  }
250
- async load(sessionId) {
318
+ resolveLegacyPath(sessionId) {
319
+ return path3.join(this.legacyStateDir, `${sessionId}.json`);
320
+ }
321
+ resolveMessagePath(sessionId) {
322
+ return path3.join(this.messagesDir, `${sessionId}.jsonl`);
323
+ }
324
+ async openDatabase() {
325
+ await this.ensureDirs;
326
+ if (process.versions.bun) {
327
+ const bunSqliteSpecifier = "bun:sqlite";
328
+ const { Database } = await import(bunSqliteSpecifier);
329
+ const db2 = new Database(this.stateDbPath, { create: true });
330
+ return {
331
+ exec: (sql) => {
332
+ db2.exec(sql);
333
+ },
334
+ run: (sql, params = []) => {
335
+ db2.query(sql).run(...params);
336
+ },
337
+ get: (sql, params = []) => {
338
+ const row = db2.query(sql).get(...params);
339
+ return row ?? void 0;
340
+ },
341
+ all: (sql, params = []) => db2.query(sql).all(...params)
342
+ };
343
+ }
344
+ const betterSqliteSpecifier = "better-sqlite3";
345
+ const betterSqliteModule = await import(betterSqliteSpecifier);
346
+ const BetterSqlite3 = betterSqliteModule.default ?? betterSqliteModule;
347
+ const db = new BetterSqlite3(this.stateDbPath);
348
+ return {
349
+ exec: (sql) => {
350
+ db.exec(sql);
351
+ },
352
+ run: (sql, params = []) => {
353
+ db.prepare(sql).run(...params);
354
+ },
355
+ get: (sql, params = []) => {
356
+ const row = db.prepare(sql).get(...params);
357
+ return row ?? void 0;
358
+ },
359
+ all: (sql, params = []) => db.prepare(sql).all(...params)
360
+ };
361
+ }
362
+ initDatabaseSchema(db) {
363
+ db.exec("PRAGMA journal_mode = WAL;");
364
+ db.exec(`
365
+ CREATE TABLE IF NOT EXISTS session_state (
366
+ session_id TEXT PRIMARY KEY,
367
+ updated_at TEXT NOT NULL,
368
+ run_id TEXT,
369
+ invoke_seq INTEGER,
370
+ schema_version INTEGER NOT NULL,
371
+ meta_json TEXT,
372
+ message_count INTEGER,
373
+ last_user_message TEXT
374
+ );
375
+ `);
376
+ db.exec(`
377
+ CREATE INDEX IF NOT EXISTS idx_session_state_updated_at
378
+ ON session_state(updated_at DESC);
379
+ `);
380
+ }
381
+ async tryGetDb(action, detail) {
251
382
  try {
252
- const file = await fs3.readFile(this.resolvePath(sessionId), "utf8");
383
+ const db = await this.getOrCreateDb();
384
+ if (!this.schemaInit) {
385
+ this.schemaInit = Promise.resolve().then(() => {
386
+ this.initDatabaseSchema(db);
387
+ });
388
+ }
389
+ await this.schemaInit;
390
+ this.lastDbUnavailableError = null;
391
+ return db;
392
+ } catch (error) {
393
+ this.db = null;
394
+ this.schemaInit = null;
395
+ this.lastDbUnavailableError = error;
396
+ this.onError?.(error, { action: `${action}.db_unavailable`, detail });
397
+ return null;
398
+ }
399
+ }
400
+ async requireDb(action, detail) {
401
+ const db = await this.tryGetDb(action, detail);
402
+ if (!db) {
403
+ const reason = this.lastDbUnavailableError ? `: ${String(this.lastDbUnavailableError)}` : "";
404
+ throw new Error(`Session index database unavailable${reason}`);
405
+ }
406
+ return db;
407
+ }
408
+ async loadLegacy(sessionId) {
409
+ try {
410
+ const file = await fs3.readFile(this.resolveLegacyPath(sessionId), "utf8");
253
411
  const parsed = JSON.parse(file);
254
412
  if (!parsed || typeof parsed !== "object") return null;
255
- return parsed;
413
+ if (!parsed.session_id || !parsed.updated_at) return null;
414
+ const messages = Array.isArray(parsed.messages) ? parsed.messages : [];
415
+ return {
416
+ schema_version: typeof parsed.schema_version === "number" ? parsed.schema_version : 1,
417
+ session_id: parsed.session_id,
418
+ updated_at: parsed.updated_at,
419
+ run_id: parsed.run_id,
420
+ invoke_seq: parsed.invoke_seq,
421
+ messages,
422
+ meta: parsed.meta && typeof parsed.meta === "object" ? parsed.meta : void 0
423
+ };
256
424
  } catch (error) {
257
425
  if (error.code === "ENOENT") {
258
426
  return null;
259
427
  }
260
- this.onError?.(error, { action: "load", detail: sessionId });
428
+ this.onError?.(error, { action: "legacy.load", detail: sessionId });
261
429
  throw error;
262
430
  }
263
431
  }
432
+ async loadFromIndex(sessionId, db) {
433
+ let row;
434
+ try {
435
+ row = db.get(
436
+ `SELECT session_id, updated_at, run_id, invoke_seq, schema_version, meta_json
437
+ FROM session_state
438
+ WHERE session_id = ?`,
439
+ [sessionId]
440
+ );
441
+ } catch (error) {
442
+ this.onError?.(error, { action: "index.load", detail: sessionId });
443
+ throw error;
444
+ }
445
+ if (!row) return null;
446
+ let messages;
447
+ try {
448
+ const payload = await fs3.readFile(
449
+ this.resolveMessagePath(sessionId),
450
+ "utf8"
451
+ );
452
+ messages = deserializeMessages(payload);
453
+ } catch (error) {
454
+ if (error.code === "ENOENT") {
455
+ messages = [];
456
+ } else {
457
+ this.onError?.(error, { action: "messages.load", detail: sessionId });
458
+ throw error;
459
+ }
460
+ }
461
+ let meta;
462
+ if (row.meta_json) {
463
+ try {
464
+ const parsed = JSON.parse(row.meta_json);
465
+ if (parsed && typeof parsed === "object") {
466
+ meta = parsed;
467
+ }
468
+ } catch (error) {
469
+ this.onError?.(error, {
470
+ action: "index.meta.parse",
471
+ detail: sessionId
472
+ });
473
+ }
474
+ }
475
+ return {
476
+ schema_version: 1,
477
+ session_id: row.session_id,
478
+ updated_at: row.updated_at,
479
+ run_id: row.run_id ?? void 0,
480
+ invoke_seq: row.invoke_seq ?? void 0,
481
+ messages,
482
+ meta
483
+ };
484
+ }
485
+ async saveToIndex(state, db) {
486
+ const messagePayload = serializeMessages(
487
+ Array.isArray(state.messages) ? state.messages : []
488
+ );
489
+ await atomicWriteFile(
490
+ this.resolveMessagePath(state.session_id),
491
+ messagePayload
492
+ );
493
+ const summary = toSummary(state);
494
+ db.run(
495
+ `INSERT INTO session_state (
496
+ session_id,
497
+ updated_at,
498
+ run_id,
499
+ invoke_seq,
500
+ schema_version,
501
+ meta_json,
502
+ message_count,
503
+ last_user_message
504
+ )
505
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
506
+ ON CONFLICT(session_id) DO UPDATE SET
507
+ updated_at = excluded.updated_at,
508
+ run_id = excluded.run_id,
509
+ invoke_seq = excluded.invoke_seq,
510
+ schema_version = excluded.schema_version,
511
+ meta_json = excluded.meta_json,
512
+ message_count = excluded.message_count,
513
+ last_user_message = excluded.last_user_message`,
514
+ [
515
+ state.session_id,
516
+ state.updated_at,
517
+ state.run_id ?? null,
518
+ state.invoke_seq ?? null,
519
+ state.schema_version ?? 1,
520
+ state.meta ? JSON.stringify(state.meta) : null,
521
+ summary.message_count ?? null,
522
+ summary.last_user_message ?? null
523
+ ]
524
+ );
525
+ }
526
+ async load(sessionId) {
527
+ const db = await this.requireDb("load", sessionId);
528
+ try {
529
+ const indexed = await this.loadFromIndex(sessionId, db);
530
+ if (indexed) return indexed;
531
+ } catch (error) {
532
+ this.onError?.(error, { action: "load.index", detail: sessionId });
533
+ throw error;
534
+ }
535
+ const legacy = await this.loadLegacy(sessionId);
536
+ if (!legacy) return null;
537
+ if (db) {
538
+ try {
539
+ await this.saveToIndex(legacy, db);
540
+ } catch (error) {
541
+ this.onError?.(error, {
542
+ action: "load.migrate_legacy",
543
+ detail: sessionId
544
+ });
545
+ }
546
+ }
547
+ return legacy;
548
+ }
264
549
  async save(state) {
265
- await this.ensureDir;
266
- const payload = `${JSON.stringify(state)}
267
- `;
268
- await fs3.writeFile(this.resolvePath(state.session_id), payload, "utf8");
550
+ await this.ensureDirs;
551
+ const db = await this.requireDb("save", state.session_id);
552
+ try {
553
+ await this.saveToIndex(state, db);
554
+ } catch (error) {
555
+ this.onError?.(error, { action: "save.index", detail: state.session_id });
556
+ throw error;
557
+ }
269
558
  }
270
- async list() {
271
- await this.ensureDir;
559
+ async listLegacySummaries() {
272
560
  let entries;
273
561
  try {
274
- entries = await fs3.readdir(this.stateDir, {
562
+ entries = await fs3.readdir(this.legacyStateDir, {
275
563
  withFileTypes: true,
276
564
  encoding: "utf8"
277
565
  });
278
566
  } catch (error) {
279
- this.onError?.(error, { action: "list" });
567
+ this.onError?.(error, { action: "legacy.list" });
280
568
  throw error;
281
569
  }
282
570
  const summaries = [];
283
571
  for (const entry of entries) {
284
572
  if (!entry.isFile()) continue;
285
573
  if (!entry.name.endsWith(".json")) continue;
286
- const filePath = path3.join(this.stateDir, entry.name);
574
+ const filePath = path3.join(this.legacyStateDir, entry.name);
287
575
  try {
288
576
  const contents = await fs3.readFile(filePath, "utf8");
289
577
  const parsed = JSON.parse(contents);
290
578
  if (!parsed || typeof parsed !== "object") continue;
291
579
  if (!parsed.session_id || !parsed.updated_at) continue;
292
- summaries.push(toSummary(parsed));
580
+ summaries.push(
581
+ toSummary({
582
+ ...parsed,
583
+ schema_version: typeof parsed.schema_version === "number" ? parsed.schema_version : 1,
584
+ messages: Array.isArray(parsed.messages) ? parsed.messages : []
585
+ })
586
+ );
293
587
  } catch (error) {
294
- this.onError?.(error, { action: "parse", detail: filePath });
588
+ this.onError?.(error, { action: "legacy.parse", detail: filePath });
295
589
  }
296
590
  }
297
591
  return summaries;
298
592
  }
593
+ async list() {
594
+ await this.ensureDirs;
595
+ const summaries = /* @__PURE__ */ new Map();
596
+ const db = await this.requireDb("list");
597
+ try {
598
+ const rows = db.all(
599
+ `SELECT session_id, updated_at, run_id, message_count, last_user_message
600
+ FROM session_state
601
+ ORDER BY updated_at DESC`
602
+ );
603
+ for (const row of rows) {
604
+ summaries.set(row.session_id, fromSummaryRow(row));
605
+ }
606
+ } catch (error) {
607
+ this.onError?.(error, { action: "list.index" });
608
+ throw error;
609
+ }
610
+ const legacy = await this.listLegacySummaries();
611
+ for (const item of legacy) {
612
+ if (!summaries.has(item.session_id)) {
613
+ summaries.set(item.session_id, item);
614
+ }
615
+ }
616
+ return [...summaries.values()];
617
+ }
299
618
  };
300
619
 
301
620
  // src/tool-output-cache.ts
package/package.json CHANGED
@@ -1,7 +1,10 @@
1
1
  {
2
2
  "name": "@codelia/storage",
3
- "version": "0.1.2",
3
+ "version": "0.1.10",
4
4
  "type": "module",
5
+ "engines": {
6
+ "node": ">=20 <25"
7
+ },
5
8
  "files": [
6
9
  "dist"
7
10
  ],
@@ -20,7 +23,8 @@
20
23
  "typecheck": "tsc --noEmit"
21
24
  },
22
25
  "dependencies": {
23
- "@codelia/core": "0.1.2"
26
+ "@codelia/core": "0.1.10",
27
+ "better-sqlite3": "^12.6.2"
24
28
  },
25
29
  "publishConfig": {
26
30
  "access": "public"