@codelia/storage 0.1.2 → 0.1.3

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