@earendil-works/pi-session-backend-sqlite-node 0.84.4 → 0.85.0

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.
Files changed (106) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/README.md +26 -15
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +9 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/sqlite/index.d.ts +3 -4
  7. package/dist/sqlite/index.d.ts.map +1 -1
  8. package/dist/sqlite/index.js +2 -3
  9. package/dist/sqlite/index.js.map +1 -1
  10. package/dist/sqlite/migrations/001_initial.sql +88 -89
  11. package/dist/sqlite/migrations.d.ts +1 -7
  12. package/dist/sqlite/migrations.d.ts.map +1 -1
  13. package/dist/sqlite/migrations.js +3 -35
  14. package/dist/sqlite/migrations.js.map +1 -1
  15. package/dist/sqlite/repo.d.ts +38 -38
  16. package/dist/sqlite/repo.d.ts.map +1 -1
  17. package/dist/sqlite/repo.js +353 -695
  18. package/dist/sqlite/repo.js.map +1 -1
  19. package/dist/sqlite/session/branch-entries.d.ts +6 -0
  20. package/dist/sqlite/session/branch-entries.d.ts.map +1 -0
  21. package/dist/sqlite/session/branch-entries.js +213 -0
  22. package/dist/sqlite/session/branch-entries.js.map +1 -0
  23. package/dist/sqlite/session/entries.d.ts +24 -0
  24. package/dist/sqlite/session/entries.d.ts.map +1 -0
  25. package/dist/sqlite/session/entries.js +126 -0
  26. package/dist/sqlite/session/entries.js.map +1 -0
  27. package/dist/sqlite/session/session-row.d.ts +23 -0
  28. package/dist/sqlite/session/session-row.d.ts.map +1 -0
  29. package/dist/sqlite/session/session-row.js +69 -0
  30. package/dist/sqlite/session/session-row.js.map +1 -0
  31. package/dist/sqlite/session/session-sequences.d.ts +4 -0
  32. package/dist/sqlite/session/session-sequences.d.ts.map +1 -0
  33. package/dist/sqlite/session/session-sequences.js +13 -0
  34. package/dist/sqlite/session/session-sequences.js.map +1 -0
  35. package/dist/sqlite/session/session-stats.d.ts +6 -0
  36. package/dist/sqlite/session/session-stats.d.ts.map +1 -0
  37. package/dist/sqlite/session/session-stats.js +39 -0
  38. package/dist/sqlite/session/session-stats.js.map +1 -0
  39. package/dist/sqlite/session/usage-ledger.d.ts +20 -0
  40. package/dist/sqlite/session/usage-ledger.d.ts.map +1 -0
  41. package/dist/sqlite/session/usage-ledger.js +50 -0
  42. package/dist/sqlite/session/usage-ledger.js.map +1 -0
  43. package/dist/sqlite/session/values.d.ts +23 -0
  44. package/dist/sqlite/session/values.d.ts.map +1 -0
  45. package/dist/sqlite/session/values.js +91 -0
  46. package/dist/sqlite/session/values.js.map +1 -0
  47. package/dist/sqlite/session.d.ts +42 -0
  48. package/dist/sqlite/session.d.ts.map +1 -0
  49. package/dist/sqlite/session.js +163 -0
  50. package/dist/sqlite/session.js.map +1 -0
  51. package/dist/sqlite/storage.d.ts +38 -0
  52. package/dist/sqlite/storage.d.ts.map +1 -0
  53. package/dist/sqlite/storage.js +161 -0
  54. package/dist/sqlite/storage.js.map +1 -0
  55. package/dist/sqlite/types.d.ts +6 -22
  56. package/dist/sqlite/types.d.ts.map +1 -1
  57. package/dist/sqlite/types.js.map +1 -1
  58. package/package.json +5 -4
  59. package/dist/sqlite/branch-cache.d.ts +0 -6
  60. package/dist/sqlite/branch-cache.d.ts.map +0 -1
  61. package/dist/sqlite/branch-cache.js +0 -70
  62. package/dist/sqlite/branch-cache.js.map +0 -1
  63. package/dist/sqlite/search-backend.d.ts +0 -14
  64. package/dist/sqlite/search-backend.d.ts.map +0 -1
  65. package/dist/sqlite/search-backend.js +0 -153
  66. package/dist/sqlite/search-backend.js.map +0 -1
  67. package/dist/sqlite/storage/branch-entries.d.ts +0 -41
  68. package/dist/sqlite/storage/branch-entries.d.ts.map +0 -1
  69. package/dist/sqlite/storage/branch-entries.js +0 -112
  70. package/dist/sqlite/storage/branch-entries.js.map +0 -1
  71. package/dist/sqlite/storage/branch-tips.d.ts +0 -7
  72. package/dist/sqlite/storage/branch-tips.d.ts.map +0 -1
  73. package/dist/sqlite/storage/branch-tips.js +0 -22
  74. package/dist/sqlite/storage/branch-tips.js.map +0 -1
  75. package/dist/sqlite/storage/entries.d.ts +0 -34
  76. package/dist/sqlite/storage/entries.d.ts.map +0 -1
  77. package/dist/sqlite/storage/entries.js +0 -37
  78. package/dist/sqlite/storage/entries.js.map +0 -1
  79. package/dist/sqlite/storage/facts.d.ts +0 -20
  80. package/dist/sqlite/storage/facts.d.ts.map +0 -1
  81. package/dist/sqlite/storage/facts.js +0 -38
  82. package/dist/sqlite/storage/facts.js.map +0 -1
  83. package/dist/sqlite/storage/lanes.d.ts +0 -35
  84. package/dist/sqlite/storage/lanes.d.ts.map +0 -1
  85. package/dist/sqlite/storage/lanes.js +0 -95
  86. package/dist/sqlite/storage/lanes.js.map +0 -1
  87. package/dist/sqlite/storage/records.d.ts +0 -38
  88. package/dist/sqlite/storage/records.d.ts.map +0 -1
  89. package/dist/sqlite/storage/records.js +0 -49
  90. package/dist/sqlite/storage/records.js.map +0 -1
  91. package/dist/sqlite/storage/session-sequences.d.ts +0 -7
  92. package/dist/sqlite/storage/session-sequences.d.ts.map +0 -1
  93. package/dist/sqlite/storage/session-sequences.js +0 -22
  94. package/dist/sqlite/storage/session-sequences.js.map +0 -1
  95. package/dist/sqlite/storage/session-stats.d.ts +0 -17
  96. package/dist/sqlite/storage/session-stats.d.ts.map +0 -1
  97. package/dist/sqlite/storage/session-stats.js +0 -40
  98. package/dist/sqlite/storage/session-stats.js.map +0 -1
  99. package/dist/sqlite/storage/sessions.d.ts +0 -26
  100. package/dist/sqlite/storage/sessions.d.ts.map +0 -1
  101. package/dist/sqlite/storage/sessions.js +0 -99
  102. package/dist/sqlite/storage/sessions.js.map +0 -1
  103. package/dist/sqlite/storage/writer-leases.d.ts +0 -15
  104. package/dist/sqlite/storage/writer-leases.d.ts.map +0 -1
  105. package/dist/sqlite/storage/writer-leases.js +0 -31
  106. package/dist/sqlite/storage/writer-leases.js.map +0 -1
@@ -1,750 +1,408 @@
1
- import { Session, SessionError, } from "@earendil-works/pi-agent-core";
1
+ import { mkdir, open as openFile, readdir, realpath, rm } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { branchTip, createForkSnapshot, StorageBackedSession } from "@earendil-works/pi-agent-core";
2
4
  import { uuidv7 } from "@earendil-works/pi-ai";
3
- import { appendEntryToBranchCache, buildCachedBranch, deleteBranchCache, rebuildBranchCache } from "./branch-cache.js";
4
- import { applyMigrations } from "./migrations.js";
5
+ import { applyInitialSchema } from "./migrations.js";
6
+ import { appendEntryToBranchIndex, scanBranchEntries } from "./session/branch-entries.js";
7
+ import { decodeEntryRow, EntryRowWriter } from "./session/entries.js";
8
+ import { deleteSessionRows, hasSessionRow, insertSessionRow, metadataFromSessionRow, readAllSessionRows, readSessionRow, } from "./session/session-row.js";
9
+ import { readAllScalarValueRows, setScalarValueRow } from "./session/values.js";
10
+ import { SqliteOpenSession } from "./session.js";
5
11
  import { sql } from "./sql.js";
6
- import { queryCachedBranchRows, readCachedBranch } from "./storage/branch-entries.js";
7
- import { readBranchTipIds } from "./storage/branch-tips.js";
8
- import { deleteEntryRows, entryPayload, idExistsInEntries, insertEntryRow, readEntryRow, readEntryRows, } from "./storage/entries.js";
9
- import { appendFact, deleteFactRows, readFactRows, readLatestFact, readLatestLabelFacts } from "./storage/facts.js";
10
- import { createInitialLane, deleteLaneRows, finishLaneOperation, createLane as insertLane, readLane, readLaneHead, readLaneMoveRows, readLanes, setLaneLeaf, startLaneOperation, moveLane as updateLane, } from "./storage/lanes.js";
11
- import { appendRecordRow, deleteRecordRows, idExistsInRecords, readOpenOperationRows, readRecordRows, } from "./storage/records.js";
12
- import { advanceSequence, createSequence, deleteSequence, getNextSequence, setNextSequence, } from "./storage/session-sequences.js";
13
- import { addUsageToStats, createStats, deleteStats, incrementMessageCount, readStats, } from "./storage/session-stats.js";
14
- import { decodeSessionMetadata, deleteSessionRow, insertSessionRow, readSessionRow, readSessionRows, sessionExists, } from "./storage/sessions.js";
15
- import { acquireWriterLease, deleteWriterLease, releaseWriterLease, renewWriterLease, } from "./storage/writer-leases.js";
16
- function resolveWriterLeaseOptions(options) {
17
- const ttlMs = options?.ttlMs ?? 30_000;
18
- const heartbeatIntervalMs = options?.heartbeatIntervalMs ?? 10_000;
19
- if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0)
20
- throw new RangeError("writerLease.ttlMs must be positive");
21
- if (!Number.isSafeInteger(heartbeatIntervalMs) || heartbeatIntervalMs <= 0 || heartbeatIntervalMs >= ttlMs) {
22
- throw new RangeError("writerLease.heartbeatIntervalMs must be positive and less than ttlMs");
23
- }
24
- return { ttlMs, heartbeatIntervalMs };
12
+ import { SqliteStorage } from "./storage.js";
13
+ export const SQLITE_STORAGE_VERSION = 1;
14
+ export const SQLITE_SESSION_EXTENSION = ".sqlite";
15
+ const FIRST_AVAILABLE_COMMIT_SEQ = 1;
16
+ const SAFE_SESSION_FILE_ID = /^[A-Za-z0-9_-]+$/;
17
+ function sessionFileName(id) {
18
+ if (SAFE_SESSION_FILE_ID.test(id))
19
+ return `${id}${SQLITE_SESSION_EXTENSION}`;
20
+ const encoded = Buffer.from(id, "utf16le").toString("base64url");
21
+ return `~${encoded}${SQLITE_SESSION_EXTENSION}`;
25
22
  }
26
- function activeWriterError(sessionId) {
27
- return new SessionError("storage", `SQLite session ${sessionId} already has an active writer`);
23
+ function sessionPath(directory, id) {
24
+ return join(directory, sessionFileName(id));
28
25
  }
29
- function lostWriterError(sessionId) {
30
- return new SessionError("storage", `SQLite session ${sessionId} writer lease was lost`);
26
+ function storageIdentity(path, sessionId) {
27
+ return JSON.stringify([path, sessionId]);
31
28
  }
32
- function claimWriterLease(db, sessionId, options) {
33
- const now = Date.now();
34
- const lease = acquireWriterLease(db, sessionId, uuidv7(), now, now + options.ttlMs);
35
- if (!lease)
36
- throw activeWriterError(sessionId);
37
- return lease;
29
+ function isErrorWithCode(error, code) {
30
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
38
31
  }
39
- class SerialOperationQueue {
40
- tail = Promise.resolve();
41
- enqueue(operation) {
42
- const result = this.tail.then(operation);
43
- this.tail = result.then(() => undefined, () => undefined);
44
- return result;
45
- }
46
- async drain() {
47
- await this.tail;
48
- }
49
- }
50
- function resultOrThrow(result, message) {
51
- if (!result.ok) {
52
- const code = result.error.code === "not_found" ? "not_found" : "storage";
53
- throw new SessionError(code, `${message}: ${result.error.message}`, result.error);
54
- }
55
- return result.value;
32
+ async function removeSessionFiles(path, options) {
33
+ await rm(path, { force: options.force });
34
+ await rm(`${path}-wal`, { force: true });
35
+ await rm(`${path}-shm`, { force: true });
56
36
  }
57
- function getParentPath(path) {
58
- const normalized = path.replace(/[\\/]+$/, "");
59
- const lastSlash = Math.max(normalized.lastIndexOf("/"), normalized.lastIndexOf("\\"));
60
- if (lastSlash < 0)
61
- return ".";
62
- if (lastSlash === 0)
63
- return normalized.slice(0, 1);
64
- return normalized.slice(0, lastSlash);
37
+ function configureWritableConnection(db) {
38
+ db.exec("PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;");
65
39
  }
66
- function configureSqliteDatabase(db) {
67
- sql `PRAGMA journal_mode=WAL`.exec(db);
68
- sql `PRAGMA synchronous=FULL`.exec(db);
69
- sql `PRAGMA busy_timeout=5000`.exec(db);
40
+ function configureReadOnlyConnection(db) {
41
+ db.exec("PRAGMA busy_timeout = 5000;");
70
42
  }
71
- function entryRowFromCached(row) {
72
- return { ...row, seq: row.entry_seq, type: row.type };
73
- }
74
- function readObjectPayload(row) {
75
- const payload = JSON.parse(row.payload);
76
- if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
77
- throw new Error("Payload is not an object");
78
- }
79
- return payload;
80
- }
81
- function decodeEntry(row) {
82
- try {
83
- const payload = readObjectPayload(row);
84
- const base = { id: row.id, seq: row.seq, parentId: row.parent_id, timestamp: row.timestamp };
85
- switch (row.type) {
86
- case "message":
87
- if (typeof payload.message !== "object" || payload.message === null)
88
- throw new Error("Missing message");
89
- return {
90
- ...base,
91
- type: "message",
92
- message: payload.message,
93
- ...(payload.terminate === true ? { terminate: true } : {}),
94
- };
95
- case "model_change":
96
- if (typeof payload.provider !== "string" || typeof payload.modelId !== "string") {
97
- throw new Error("Invalid model_change payload");
98
- }
99
- return { ...base, type: "model_change", provider: payload.provider, modelId: payload.modelId };
100
- case "thinking_level_change":
101
- if (typeof payload.thinkingLevel !== "string")
102
- throw new Error("Invalid thinking_level_change payload");
103
- return { ...base, type: "thinking_level_change", thinkingLevel: payload.thinkingLevel };
104
- case "active_tools_change":
105
- if (!Array.isArray(payload.activeToolNames))
106
- throw new Error("Invalid active_tools_change payload");
107
- if (payload.activeToolNames.some((value) => typeof value !== "string")) {
108
- throw new Error("Invalid active_tools_change payload");
109
- }
110
- return { ...base, type: "active_tools_change", activeToolNames: payload.activeToolNames };
111
- case "compaction":
112
- if (typeof payload.summary !== "string" ||
113
- !Array.isArray(payload.retainedTail) ||
114
- typeof payload.tokensBefore !== "number") {
115
- throw new Error("Invalid compaction payload");
116
- }
117
- return {
118
- ...base,
119
- type: "compaction",
120
- summary: payload.summary,
121
- retainedTail: payload.retainedTail,
122
- tokensBefore: payload.tokensBefore,
123
- ...(Object.hasOwn(payload, "details") ? { details: payload.details } : {}),
124
- ...(Object.hasOwn(payload, "usage")
125
- ? { usage: payload.usage }
126
- : {}),
127
- };
128
- case "branch_summary":
129
- if (typeof payload.fromId !== "string" || typeof payload.summary !== "string") {
130
- throw new Error("Invalid branch_summary payload");
131
- }
132
- return {
133
- ...base,
134
- type: "branch_summary",
135
- fromId: payload.fromId,
136
- summary: payload.summary,
137
- ...(Object.hasOwn(payload, "details") ? { details: payload.details } : {}),
138
- ...(Object.hasOwn(payload, "usage")
139
- ? { usage: payload.usage }
140
- : {}),
141
- };
142
- case "custom":
143
- if (typeof payload.customType !== "string")
144
- throw new Error("Invalid custom payload");
145
- return {
146
- ...base,
147
- type: "custom",
148
- customType: payload.customType,
149
- ...(Object.hasOwn(payload, "data") ? { data: payload.data } : {}),
150
- };
151
- }
152
- }
153
- catch (error) {
154
- throw new SessionError("invalid_entry", `Invalid SQLite session entry ${row.id}: failed to decode entry ${row.id}`, error instanceof Error ? error : undefined);
155
- }
43
+ function readSourceEntries(db, sessionId) {
44
+ return sql `SELECT id, parent_id, seq, type, custom_type, timestamp, payload
45
+ FROM entries WHERE session_id = ${sessionId} ORDER BY seq ASC`
46
+ .all(db)
47
+ .map(decodeEntryRow);
156
48
  }
157
- function recordRunId(record) {
158
- return record.type === "operation_started" ? record.id : "runId" in record ? record.runId : undefined;
49
+ function buildForkSnapshot(source, options) {
50
+ const snapshot = createForkSnapshot({
51
+ entries: source.entries,
52
+ scalarValues: source.scalarValues,
53
+ entriesComplete: source.entriesComplete,
54
+ }, options);
55
+ const entries = [...snapshot.entries.values()].sort((left, right) => left.seq - right.seq);
56
+ return {
57
+ entries,
58
+ scalarValues: snapshot.scalarValues,
59
+ messageCount: entries.filter((entry) => entry.type === "message").length,
60
+ nextSeq: snapshot.nextSeq,
61
+ };
159
62
  }
160
- function recordOpKind(record) {
161
- return record.type === "operation_started" ? record.intent.kind : undefined;
63
+ // TODO(WP08): Remove this snapshot path when SQLite forks use streaming staging.
64
+ function readForkSourceEntries(db, sessionId, scalarValues, options) {
65
+ if (options.scope === "tree")
66
+ return readSourceEntries(db, sessionId);
67
+ const sourceAddress = branchTip(options.branch);
68
+ const sourceTip = scalarValues.find((stored) => stored.address.namespace === sourceAddress.namespace && stored.address.key === sourceAddress.key);
69
+ if (sourceTip === undefined)
70
+ throw new Error(`Unknown source branch: ${options.branch}`);
71
+ return sourceTip.value === null
72
+ ? []
73
+ : scanBranchEntries(db, sessionId, { start: sourceTip.value, order: "oldestFirst" });
162
74
  }
163
- function decodeRecord(row) {
75
+ function createSqliteForkSnapshot(sourceDb, source, options) {
76
+ sourceDb.exec("BEGIN");
77
+ let committed = false;
164
78
  try {
165
- return {
166
- ...JSON.parse(row.payload),
167
- seq: row.seq,
168
- timestamp: row.timestamp,
169
- };
79
+ metadataFromSessionRow(source.path, readSessionRow(sourceDb, source.id), SQLITE_STORAGE_VERSION);
80
+ const scalarValues = readAllScalarValueRows(sourceDb, source.id);
81
+ const snapshot = buildForkSnapshot({
82
+ entries: readForkSourceEntries(sourceDb, source.id, scalarValues, options),
83
+ scalarValues,
84
+ entriesComplete: options.scope === "tree",
85
+ }, options);
86
+ sourceDb.exec("COMMIT");
87
+ committed = true;
88
+ return snapshot;
170
89
  }
171
90
  catch (error) {
172
- throw new SessionError("storage", `Invalid SQLite session record at sequence ${row.seq}: failed to decode payload`, error instanceof Error ? error : undefined);
91
+ if (!committed)
92
+ sourceDb.exec("ROLLBACK");
93
+ throw error;
173
94
  }
174
95
  }
175
- function validateCachedBranchRows(rows, query) {
176
- if (rows.length === 0 || query.type !== undefined || query.customType !== undefined)
177
- return;
178
- const path = [...rows].sort((left, right) => left.entry_seq - right.entry_seq);
179
- const shouldIncludeRoot = query.stopAtId === undefined &&
180
- query.stopAtType === undefined &&
181
- query.cursor === undefined &&
182
- (query.order === "oldestFirst" || query.limit === undefined);
183
- if (shouldIncludeRoot && path[0]?.parent_id !== null) {
184
- throw new SessionError("invalid_entry", `Entry ${path[0]?.parent_id} not found`);
185
- }
186
- for (let index = 1; index < path.length; index++) {
187
- const previous = path[index - 1];
188
- const current = path[index];
189
- if (current.parent_id !== previous.id) {
190
- throw new SessionError("invalid_entry", `Entry ${current.parent_id} not found`);
191
- }
192
- }
96
+ function insertForkValue(db, sessionId, stored) {
97
+ setScalarValueRow(db, sessionId, stored.address.namespace, stored.address.key, stored.seq, stored.value);
193
98
  }
194
- function matchesEntryQuery(entry, query) {
195
- return ((query.type === undefined || entry.type === query.type) &&
196
- (query.customType === undefined || (entry.type === "custom" && entry.customType === query.customType)) &&
197
- (query.cursor === undefined ||
198
- (query.order === "oldestFirst" ? entry.seq > query.cursor.afterSeq : entry.seq < query.cursor.afterSeq)));
199
- }
200
- function assertUnusedId(db, sessionId, id) {
201
- if (idExistsInEntries(db, sessionId, id) || idExistsInRecords(db, sessionId, id)) {
202
- throw new SessionError("already_exists", `ID already exists: ${id}`);
203
- }
99
+ function updateForkSessionStats(db, sessionId, messageCount) {
100
+ sql `UPDATE sessions SET message_count = ${messageCount} WHERE id = ${sessionId}`.run(db);
204
101
  }
205
- function requireSessionRow(db, sessionId) {
206
- const row = readSessionRow(db, sessionId);
207
- if (!row)
208
- throw new SessionError("not_found", `Session not found: ${sessionId}`);
209
- return row;
210
- }
211
- class SqliteSessionStorage {
212
- db;
213
- metadata;
214
- lease;
215
- leaseOptions;
216
- onRelease;
217
- operations = new SerialOperationQueue();
218
- heartbeatTimer;
219
- leaseError;
220
- closing = false;
221
- releasePromise;
222
- constructor(db, metadata, lease, leaseOptions, onRelease) {
223
- this.db = db;
224
- this.metadata = metadata;
225
- this.lease = lease;
226
- this.leaseOptions = leaseOptions;
227
- this.onRelease = onRelease;
228
- this.scheduleHeartbeat();
229
- }
230
- async release() {
231
- this.releasePromise ??= this.finishRelease();
232
- await this.releasePromise;
233
- }
234
- async finishRelease() {
235
- this.closing = true;
236
- if (this.heartbeatTimer !== undefined)
237
- clearTimeout(this.heartbeatTimer);
102
+ export class SqliteSessionRepo {
103
+ directory;
104
+ databasePath;
105
+ databaseFactory;
106
+ now;
107
+ pendingIds = new Set();
108
+ openStorages = new Map();
109
+ openSessions = new Set();
110
+ closed = false;
111
+ closePromise;
112
+ constructor(options) {
113
+ this.directory = options.directory;
114
+ this.databasePath = options.databasePath;
115
+ this.databaseFactory = options.databaseFactory;
116
+ this.now = options.now ?? Date.now;
117
+ }
118
+ async create(options, _context) {
119
+ this.assertOpen();
120
+ options ??= {};
121
+ const createdAt = this.now();
122
+ const id = options.id ?? uuidv7(createdAt);
123
+ this.reserveId(id);
124
+ const path = this.pathForSession(id);
125
+ let db;
126
+ let reservedFile = false;
127
+ let initialized = false;
128
+ let session;
238
129
  try {
239
- await this.operations.enqueue(() => this.db.transaction(() => releaseWriterLease(this.db, this.metadata.id, this.lease)));
130
+ await mkdir(dirname(path), { recursive: true });
131
+ if (!this.usesSharedDatabase()) {
132
+ const file = await openFile(path, "wx");
133
+ await file.close();
134
+ reservedFile = true;
135
+ }
136
+ const activeDb = await this.databaseFactory.open(path);
137
+ db = activeDb;
138
+ configureWritableConnection(activeDb);
139
+ await applyInitialSchema(activeDb);
140
+ const canonicalPath = await realpath(path);
141
+ const metadata = {
142
+ id,
143
+ createdAt,
144
+ storageVersion: SQLITE_STORAGE_VERSION,
145
+ ...(options.parentSessionId === undefined ? {} : { parentSessionId: options.parentSessionId }),
146
+ path: canonicalPath,
147
+ };
148
+ activeDb.transaction(() => {
149
+ if (hasSessionRow(activeDb, id))
150
+ throw new Error(`SQLite session already exists: ${id}`);
151
+ insertSessionRow(activeDb, metadata, SQLITE_STORAGE_VERSION, FIRST_AVAILABLE_COMMIT_SEQ);
152
+ });
153
+ initialized = true;
154
+ session = this.openStorageBackedSession(metadata, activeDb);
155
+ return session;
156
+ }
157
+ catch (error) {
158
+ if (reservedFile && !initialized)
159
+ await removeSessionFiles(path, { force: true });
160
+ throw error;
240
161
  }
241
162
  finally {
242
- this.onRelease();
163
+ if (session === undefined) {
164
+ try {
165
+ db?.close();
166
+ }
167
+ finally {
168
+ this.pendingIds.delete(id);
169
+ }
170
+ }
243
171
  }
244
172
  }
245
- enqueueWrite(operation) {
246
- if (this.closing)
247
- return Promise.reject(new SessionError("storage", `SQLite session ${this.metadata.id} is closed`));
248
- return this.operations.enqueue(() => {
249
- if (this.leaseError)
250
- throw this.leaseError;
251
- return this.db.transaction(() => {
252
- const now = Date.now();
253
- if (!renewWriterLease(this.db, this.metadata.id, this.lease, now, now + this.leaseOptions.ttlMs)) {
254
- this.leaseError = lostWriterError(this.metadata.id);
255
- if (this.heartbeatTimer !== undefined)
256
- clearTimeout(this.heartbeatTimer);
257
- throw this.leaseError;
173
+ async open(metadata, _context) {
174
+ this.assertOpen();
175
+ this.reserveId(metadata.id);
176
+ let db;
177
+ let session;
178
+ try {
179
+ const path = await this.repositoryPathForMetadata(metadata);
180
+ const activeDb = await this.databaseFactory.openExisting(path);
181
+ db = activeDb;
182
+ configureWritableConnection(activeDb);
183
+ const stored = metadataFromSessionRow(path, readSessionRow(activeDb, metadata.id), SQLITE_STORAGE_VERSION);
184
+ session = this.openStorageBackedSession(stored, activeDb);
185
+ return session;
186
+ }
187
+ finally {
188
+ if (session === undefined) {
189
+ try {
190
+ db?.close();
258
191
  }
259
- return operation();
260
- });
261
- });
192
+ finally {
193
+ this.pendingIds.delete(metadata.id);
194
+ }
195
+ }
196
+ }
262
197
  }
263
- scheduleHeartbeat() {
264
- if (this.closing || this.leaseError)
265
- return;
266
- this.heartbeatTimer = setTimeout(async () => {
267
- this.heartbeatTimer = undefined;
198
+ async list(_options, _context) {
199
+ this.assertOpen();
200
+ let paths;
201
+ if (this.usesSharedDatabase()) {
202
+ paths = [this.databasePath];
203
+ }
204
+ else {
205
+ let names;
268
206
  try {
269
- await this.operations.enqueue(() => {
270
- if (this.closing || this.leaseError)
271
- return;
272
- this.db.transaction(() => {
273
- const now = Date.now();
274
- if (!renewWriterLease(this.db, this.metadata.id, this.lease, now, now + this.leaseOptions.ttlMs)) {
275
- this.leaseError = lostWriterError(this.metadata.id);
276
- }
277
- });
278
- });
207
+ names = await readdir(this.directory);
208
+ }
209
+ catch (error) {
210
+ if (isErrorWithCode(error, "ENOENT"))
211
+ return [];
212
+ throw error;
213
+ }
214
+ paths = names
215
+ .filter((name) => name.endsWith(SQLITE_SESSION_EXTENSION))
216
+ .map((name) => join(this.directory, name));
217
+ }
218
+ const sessions = [];
219
+ for (const path of paths) {
220
+ let db;
221
+ try {
222
+ const canonicalPath = await realpath(path);
223
+ db = await this.databaseFactory.openReadOnly(canonicalPath);
224
+ configureReadOnlyConnection(db);
225
+ for (const row of readAllSessionRows(db)) {
226
+ sessions.push(metadataFromSessionRow(canonicalPath, row, SQLITE_STORAGE_VERSION));
227
+ }
279
228
  }
280
229
  catch {
281
- // A transient heartbeat failure is retried. Every write still verifies ownership transactionally.
230
+ // Discovery is best-effort: corrupt files, incompatible versions, and
231
+ // unrelated *.sqlite files are reported when explicitly opened.
282
232
  }
283
233
  finally {
284
- this.scheduleHeartbeat();
234
+ db?.close();
285
235
  }
286
- }, this.leaseOptions.heartbeatIntervalMs);
287
- this.heartbeatTimer.unref();
288
- }
289
- async getMetadata() {
290
- return decodeSessionMetadata(requireSessionRow(this.db, this.metadata.id), this.metadata.path);
291
- }
292
- isForSession(sessionId) {
293
- return this.metadata.id === sessionId;
294
- }
295
- async getLanes() {
296
- return readLanes(this.db, this.metadata.id).map((row) => ({ lane: row.lane, leafId: row.leaf_id }));
236
+ }
237
+ return sessions.sort((left, right) => right.createdAt - left.createdAt);
297
238
  }
298
- async createLane(lane, at) {
299
- return this.enqueueWrite(() => {
300
- if (readLane(this.db, this.metadata.id, lane)) {
301
- throw new SessionError("already_exists", `Lane already exists: ${lane}`);
302
- }
303
- if (at !== null && !readEntryRow(this.db, this.metadata.id, at)) {
304
- throw new SessionError("not_found", `Entry not found: ${at}`);
239
+ async delete(metadata, _context) {
240
+ this.assertOpen();
241
+ this.reserveId(metadata.id);
242
+ try {
243
+ const path = await this.repositoryPathForMetadata(metadata);
244
+ const db = await this.databaseFactory.openExisting(path);
245
+ try {
246
+ configureWritableConnection(db);
247
+ if (this.usesSharedDatabase()) {
248
+ db.transaction(() => {
249
+ metadataFromSessionRow(path, readSessionRow(db, metadata.id), SQLITE_STORAGE_VERSION);
250
+ deleteSessionRows(db, metadata.id);
251
+ });
252
+ }
253
+ else {
254
+ metadataFromSessionRow(path, readSessionRow(db, metadata.id), SQLITE_STORAGE_VERSION);
255
+ }
305
256
  }
306
- const seq = getNextSequence(this.db, this.metadata.id);
307
- insertLane(this.db, this.metadata.id, seq, lane, at);
308
- advanceSequence(this.db, this.metadata.id, seq);
309
- });
310
- }
311
- async moveLane(lane, to) {
312
- return this.enqueueWrite(() => {
313
- if (!readLane(this.db, this.metadata.id, lane))
314
- throw new SessionError("invalid_lane", `Lane not found: ${lane}`);
315
- if (to !== null && !readEntryRow(this.db, this.metadata.id, to)) {
316
- throw new SessionError("not_found", `Entry not found: ${to}`);
257
+ finally {
258
+ db.close();
317
259
  }
318
- const seq = getNextSequence(this.db, this.metadata.id);
319
- updateLane(this.db, this.metadata.id, seq, lane, to);
320
- advanceSequence(this.db, this.metadata.id, seq);
321
- });
322
- }
323
- async appendEntry(entry, lane) {
324
- return this.enqueueWrite(() => {
325
- const parentId = readLaneHead(this.db, this.metadata.id, lane).leafId;
326
- assertUnusedId(this.db, this.metadata.id, entry.id);
327
- const seq = getNextSequence(this.db, this.metadata.id);
328
- const committed = { ...entry, parentId, seq, timestamp: Date.now() };
329
- insertEntryRow(this.db, this.metadata.id, {
330
- seq,
331
- id: committed.id,
332
- parentId: committed.parentId,
333
- type: committed.type,
334
- timestamp: committed.timestamp,
335
- payload: JSON.stringify(entryPayload(committed)),
336
- });
337
- setLaneLeaf(this.db, this.metadata.id, lane, committed.id);
338
- appendEntryToBranchCache(this.db, this.metadata.id, committed.id, seq, committed.type, committed.type === "custom" ? committed.customType : null, committed.parentId);
339
- if (committed.type === "message")
340
- incrementMessageCount(this.db, this.metadata.id);
341
- advanceSequence(this.db, this.metadata.id, seq);
342
- return structuredClone(committed);
343
- });
260
+ if (!this.usesSharedDatabase())
261
+ await removeSessionFiles(path, { force: false });
262
+ }
263
+ finally {
264
+ this.pendingIds.delete(metadata.id);
265
+ }
344
266
  }
345
- async appendRecord(record) {
346
- return this.enqueueWrite(() => {
347
- if (!readLane(this.db, this.metadata.id, record.lane)) {
348
- throw new SessionError("invalid_lane", `Lane not found: ${record.lane}`);
349
- }
350
- assertUnusedId(this.db, this.metadata.id, record.id);
351
- const seq = getNextSequence(this.db, this.metadata.id);
352
- const committed = { ...record, seq, timestamp: Date.now() };
353
- if (record.type === "operation_started") {
354
- startLaneOperation(this.db, this.metadata.id, record.lane, record.id);
267
+ async fork(source, options, context) {
268
+ this.assertOpen();
269
+ const createdAt = this.now();
270
+ const id = options.id ?? uuidv7(createdAt);
271
+ this.reserveId(id);
272
+ const sourceStorage = this.openStorages.get(storageIdentity(source.path, source.id));
273
+ const activeSourceSnapshot = sourceStorage?.snapshot(options, context);
274
+ void activeSourceSnapshot?.catch(() => undefined);
275
+ const path = this.pathForSession(id);
276
+ let db;
277
+ let reservedFile = false;
278
+ let initialized = false;
279
+ let session;
280
+ try {
281
+ await mkdir(dirname(path), { recursive: true });
282
+ if (!this.usesSharedDatabase()) {
283
+ const file = await openFile(path, "wx");
284
+ await file.close();
285
+ reservedFile = true;
355
286
  }
356
- appendRecordRow(this.db, this.metadata.id, {
357
- seq,
358
- id: record.id,
359
- lane: record.lane,
360
- runId: recordRunId(record),
361
- type: record.type,
362
- opKind: recordOpKind(record),
363
- timestamp: committed.timestamp,
364
- payload: JSON.stringify(record),
287
+ const snapshot = activeSourceSnapshot === undefined
288
+ ? await this.createForkSnapshotFromExternalSource(source, options)
289
+ : buildForkSnapshot(await activeSourceSnapshot, options);
290
+ const activeDb = await this.databaseFactory.open(path);
291
+ db = activeDb;
292
+ configureWritableConnection(activeDb);
293
+ await applyInitialSchema(activeDb);
294
+ const canonicalPath = await realpath(path);
295
+ const metadata = {
296
+ id,
297
+ createdAt,
298
+ storageVersion: SQLITE_STORAGE_VERSION,
299
+ parentSessionId: source.id,
300
+ path: canonicalPath,
301
+ };
302
+ activeDb.transaction(() => {
303
+ if (hasSessionRow(activeDb, id))
304
+ throw new Error(`SQLite session already exists: ${id}`);
305
+ insertSessionRow(activeDb, metadata, SQLITE_STORAGE_VERSION, snapshot.nextSeq);
306
+ const entryWriter = new EntryRowWriter(activeDb, id);
307
+ for (const entry of snapshot.entries) {
308
+ entryWriter.insert(entry);
309
+ appendEntryToBranchIndex(activeDb, id, entry);
310
+ }
311
+ for (const stored of snapshot.scalarValues)
312
+ insertForkValue(activeDb, id, stored);
313
+ updateForkSessionStats(activeDb, id, snapshot.messageCount);
365
314
  });
366
- if (record.type === "operation_finished") {
367
- finishLaneOperation(this.db, this.metadata.id, record.lane, record.runId);
368
- }
369
- if (record.type === "usage")
370
- addUsageToStats(this.db, this.metadata.id, record.usage);
371
- advanceSequence(this.db, this.metadata.id, seq);
372
- return structuredClone(committed);
373
- });
374
- }
375
- async getEntry(id) {
376
- const row = readEntryRow(this.db, this.metadata.id, id);
377
- return row ? decodeEntry(row) : undefined;
378
- }
379
- async findEntries(query = {}) {
380
- const sqlType = query.type ?? (query.customType === undefined ? undefined : "custom");
381
- const sqlLimit = query.customType === undefined ? query.limit : undefined;
382
- const rows = readEntryRows(this.db, this.metadata.id, {
383
- cursor: query.cursor,
384
- limit: sqlLimit,
385
- order: query.order,
386
- type: sqlType,
387
- });
388
- const entries = rows.map(decodeEntry).filter((entry) => matchesEntryQuery(entry, query));
389
- return query.limit === undefined ? entries : entries.slice(0, query.limit);
390
- }
391
- async findEntriesOnBranch(query) {
392
- const cached = readCachedBranch(this.db, this.metadata.id, query.start);
393
- if (!cached) {
394
- if (!readEntryRow(this.db, this.metadata.id, query.start))
395
- throw new SessionError("not_found", `Entry not found: ${query.start}`);
396
- throw new SessionError("invalid_entry", `Branch cache missing entry ${query.start}`);
315
+ initialized = true;
316
+ session = this.openStorageBackedSession(metadata, activeDb);
317
+ return session;
397
318
  }
398
- const rows = queryCachedBranchRows(this.db, this.metadata.id, cached, query);
399
- validateCachedBranchRows(rows, query);
400
- const entries = rows
401
- .map(entryRowFromCached)
402
- .map(decodeEntry)
403
- .filter((entry) => matchesEntryQuery(entry, query));
404
- return query.limit === undefined ? entries : entries.slice(0, query.limit);
405
- }
406
- async findRecords(query = {}) {
407
- const rows = readRecordRows(this.db, this.metadata.id, query);
408
- return rows.map(decodeRecord);
409
- }
410
- async findOpenOperations(lane, options) {
411
- const rows = readOpenOperationRows(this.db, this.metadata.id, lane, options);
412
- return rows.map((row) => {
413
- const record = decodeRecord(row);
414
- if (record.type !== "operation_started") {
415
- throw new SessionError("storage", "Expected operation_started record");
416
- }
417
- return record;
418
- });
419
- }
420
- async getLog(options = {}) {
421
- const afterSeq = options.afterSeq ?? 0;
422
- const limit = options.limit;
423
- const entryRows = readEntryRows(this.db, this.metadata.id, { afterSeq, order: "oldestFirst", limit });
424
- const recordRows = readRecordRows(this.db, this.metadata.id, { afterSeq, order: "oldestFirst", limit });
425
- const laneRows = readLaneMoveRows(this.db, this.metadata.id, { afterSeq, limit });
426
- const factRows = readFactRows(this.db, this.metadata.id, { afterSeq, limit });
427
- const logRows = [
428
- ...entryRows.map((row) => ({
429
- seq: row.seq,
430
- decode: () => ({ kind: "entry", seq: row.seq, entry: decodeEntry(row) }),
431
- })),
432
- ...recordRows.map((row) => ({
433
- seq: row.seq,
434
- decode: () => ({ kind: "record", seq: row.seq, record: decodeRecord(row) }),
435
- })),
436
- ...laneRows.map((row) => ({
437
- seq: row.seq,
438
- decode: () => ({ kind: "lane", seq: row.seq, lane: row.lane, leafId: row.leaf_id }),
439
- })),
440
- ...factRows.map((row) => ({
441
- seq: row.seq,
442
- decode: () => {
443
- if (row.kind === "name")
444
- return {
445
- kind: "fact",
446
- seq: row.seq,
447
- fact: "name",
448
- name: row.value === null ? undefined : JSON.parse(row.value),
449
- };
450
- return {
451
- kind: "fact",
452
- seq: row.seq,
453
- fact: "label",
454
- targetId: row.key ?? "",
455
- label: row.value === null ? undefined : JSON.parse(row.value),
456
- };
457
- },
458
- })),
459
- ].sort((left, right) => left.seq - right.seq);
460
- const selectedRows = options.limit === undefined ? logRows : logRows.slice(0, options.limit);
461
- return selectedRows.map((row) => row.decode());
462
- }
463
- async getName() {
464
- const row = readLatestFact(this.db, this.metadata.id, "name", null);
465
- return row?.value === undefined || row.value === null ? undefined : JSON.parse(row.value);
466
- }
467
- async setName(name) {
468
- return this.enqueueWrite(() => {
469
- const seq = getNextSequence(this.db, this.metadata.id);
470
- appendFact(this.db, this.metadata.id, seq, "name", null, name === undefined ? null : JSON.stringify(name));
471
- advanceSequence(this.db, this.metadata.id, seq);
472
- });
473
- }
474
- async getLabel(id) {
475
- const row = readLatestFact(this.db, this.metadata.id, "label", id);
476
- return row?.value === undefined || row.value === null ? undefined : JSON.parse(row.value);
477
- }
478
- async setLabel(id, label) {
479
- return this.enqueueWrite(() => {
480
- if (!readEntryRow(this.db, this.metadata.id, id)) {
481
- throw new SessionError("not_found", `Entry not found: ${id}`);
319
+ catch (error) {
320
+ if (reservedFile && !initialized)
321
+ await removeSessionFiles(path, { force: true });
322
+ throw error;
323
+ }
324
+ finally {
325
+ if (session === undefined) {
326
+ try {
327
+ db?.close();
328
+ }
329
+ finally {
330
+ this.pendingIds.delete(id);
331
+ }
482
332
  }
483
- const seq = getNextSequence(this.db, this.metadata.id);
484
- appendFact(this.db, this.metadata.id, seq, "label", id, label === undefined ? null : JSON.stringify(label));
485
- advanceSequence(this.db, this.metadata.id, seq);
486
- });
487
- }
488
- async getStats() {
489
- return readStats(this.db, this.metadata.id);
490
- }
491
- }
492
- function claimStorage(db, metadata, leaseOptions, onRelease) {
493
- requireSessionRow(db, metadata.id);
494
- const claimed = db.transaction(() => {
495
- const lease = claimWriterLease(db, metadata.id, leaseOptions);
496
- const row = requireSessionRow(db, metadata.id);
497
- readLanes(db, metadata.id);
498
- return { lease, row };
499
- });
500
- return new SqliteSessionStorage(db, decodeSessionMetadata(claimed.row, metadata.path), claimed.lease, leaseOptions, onRelease);
501
- }
502
- export class SqliteSessionRepository {
503
- databasePath;
504
- database;
505
- databasePromise;
506
- operations = new SerialOperationQueue();
507
- activeStorages = new Set();
508
- options;
509
- leaseOptions;
510
- constructor(options) {
511
- this.options = options;
512
- this.leaseOptions = resolveWriterLeaseOptions(options.writerLease);
513
- }
514
- async releaseStoragesForSession(sessionId) {
515
- for (const storage of [...this.activeStorages]) {
516
- if (storage.isForSession(sessionId))
517
- await storage.release();
518
333
  }
519
334
  }
520
- sessionFromLease(db, metadata, lease) {
521
- let storage;
522
- storage = new SqliteSessionStorage(db, metadata, lease, this.leaseOptions, () => {
523
- this.activeStorages.delete(storage);
524
- });
525
- this.activeStorages.add(storage);
526
- return new Session(storage);
335
+ close(context) {
336
+ if (this.closePromise !== undefined)
337
+ return this.closePromise;
338
+ this.closed = true;
339
+ this.closePromise = this.closeOpenSessions(context);
340
+ return this.closePromise;
527
341
  }
528
- claimSession(db, metadata) {
529
- const active = [...this.activeStorages].find((storage) => storage.isForSession(metadata.id));
530
- if (active) {
531
- readLanes(db, metadata.id);
532
- return new Session(active);
342
+ async createForkSnapshotFromExternalSource(source, options) {
343
+ const path = await realpath(source.path);
344
+ const sourceDb = await this.databaseFactory.openReadOnly(path);
345
+ try {
346
+ configureReadOnlyConnection(sourceDb);
347
+ return createSqliteForkSnapshot(sourceDb, { ...source, path }, options);
348
+ }
349
+ finally {
350
+ sourceDb.close();
533
351
  }
534
- let storage;
535
- storage = claimStorage(db, metadata, this.leaseOptions, () => {
536
- this.activeStorages.delete(storage);
537
- });
538
- this.activeStorages.add(storage);
539
- return new Session(storage);
540
- }
541
- async create(options) {
542
- return this.operations.enqueue(async () => {
543
- const db = await this.getDatabase();
544
- const path = await this.getDatabasePath();
545
- const id = options.id ?? uuidv7();
546
- if (sessionExists(db, id))
547
- throw new SessionError("already_exists", `Session already exists: ${id}`);
548
- const createdAt = Date.now();
549
- const lease = db.transaction(() => {
550
- insertSessionRow(db, {
551
- id,
552
- createdAt,
553
- cwd: options.cwd,
554
- parentSessionId: options.parentSessionId,
555
- metadata: options.metadata,
556
- });
557
- createSequence(db, id);
558
- createStats(db, id);
559
- createInitialLane(db, id);
560
- return claimWriterLease(db, id, this.leaseOptions);
561
- });
562
- const row = requireSessionRow(db, id);
563
- return this.sessionFromLease(db, decodeSessionMetadata(row, path), lease);
564
- });
565
- }
566
- async open(metadata) {
567
- return this.operations.enqueue(async () => this.claimSession(await this.getDatabase(), metadata));
568
- }
569
- /** Rebuilds this session's private branch-read cache from canonical entry parent links. */
570
- async repairBranchCache(metadata) {
571
- return this.operations.enqueue(async () => {
572
- await this.releaseStoragesForSession(metadata.id);
573
- const db = await this.getDatabase();
574
- db.transaction(() => {
575
- const lease = claimWriterLease(db, metadata.id, this.leaseOptions);
576
- requireSessionRow(db, metadata.id);
577
- rebuildBranchCache(db, metadata.id);
578
- releaseWriterLease(db, metadata.id, lease);
579
- });
580
- });
581
- }
582
- /** Reads the session catalog without acquiring or renewing per-session writer leases. */
583
- async list(options = {}) {
584
- return this.operations.enqueue(async () => {
585
- const path = await this.getDatabasePath();
586
- if (!resultOrThrow(await this.options.env.exists(path), `Failed to check database ${path}`))
587
- return [];
588
- const db = await this.getDatabase();
589
- const rows = readSessionRows(db, options);
590
- return rows.map((row) => decodeSessionMetadata(row, path));
591
- });
592
- }
593
- async delete(metadata) {
594
- return this.operations.enqueue(async () => {
595
- await this.releaseStoragesForSession(metadata.id);
596
- const db = await this.getDatabase();
597
- db.transaction(() => {
598
- if (!sessionExists(db, metadata.id)) {
599
- deleteWriterLease(db, metadata.id);
600
- return;
601
- }
602
- claimWriterLease(db, metadata.id, this.leaseOptions);
603
- deleteBranchCache(db, metadata.id);
604
- deleteFactRows(db, metadata.id);
605
- deleteLaneRows(db, metadata.id);
606
- deleteRecordRows(db, metadata.id);
607
- deleteEntryRows(db, metadata.id);
608
- deleteWriterLease(db, metadata.id);
609
- deleteStats(db, metadata.id);
610
- deleteSequence(db, metadata.id);
611
- deleteSessionRow(db, metadata.id);
612
- });
613
- });
614
352
  }
615
- async fork(source, options) {
616
- return this.operations.enqueue(async () => {
617
- const db = await this.getDatabase();
618
- const path = await this.getDatabasePath();
619
- const sourceMetadata = decodeSessionMetadata(requireSessionRow(db, source.id), path);
620
- const id = options.id ?? uuidv7();
621
- if (sessionExists(db, id))
622
- throw new SessionError("already_exists", `Session already exists: ${id}`);
623
- const entries = [];
624
- const lanes = [];
625
- const branchTips = [];
626
- let branchForkTargetId = null;
627
- if (options.scope === "tree") {
628
- entries.push(...readEntryRows(db, source.id, { order: "oldestFirst" }));
629
- lanes.push(...readLanes(db, source.id).map((row) => ({ lane: row.lane, leafId: row.leaf_id })));
630
- branchTips.push(...readBranchTipIds(db, source.id));
631
- }
632
- else {
633
- const main = readLane(db, source.id, "main");
634
- if (!main)
635
- throw new SessionError("invalid_lane", "Lane not found: main");
636
- const selectedEntryId = options.entryId ?? main.leaf_id;
637
- if (selectedEntryId !== null) {
638
- const target = readEntryRow(db, source.id, selectedEntryId);
639
- if (!target || target.type !== "message") {
640
- throw new SessionError("invalid_fork_target", `Fork target is not a message entry: ${selectedEntryId}`);
641
- }
642
- const position = options.position ?? (options.entryId === undefined ? "at" : "before");
643
- branchForkTargetId = position === "at" ? target.id : target.parent_id;
353
+ async closeOpenSessions(context) {
354
+ const results = await Promise.allSettled([...this.openSessions].map((session) => session.close(context)));
355
+ const errors = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
356
+ if (errors.length === 1)
357
+ throw errors[0];
358
+ if (errors.length > 1)
359
+ throw new AggregateError(errors, "Failed to close SQLite Sessions");
360
+ }
361
+ openStorageBackedSession(metadata, db) {
362
+ const key = storageIdentity(metadata.path, metadata.id);
363
+ const storage = new SqliteStorage(db, { sessionId: metadata.id, now: this.now });
364
+ this.openStorages.set(key, storage);
365
+ const session = new StorageBackedSession(metadata, storage);
366
+ const openSession = new SqliteOpenSession(session, {
367
+ onClose: () => {
368
+ try {
369
+ db.close();
644
370
  }
645
- lanes.push({ lane: "main", leafId: branchForkTargetId });
646
- if (branchForkTargetId !== null) {
647
- const cached = readCachedBranch(db, source.id, branchForkTargetId);
648
- if (!cached) {
649
- throw new SessionError("invalid_fork_target", `Fork target is not on a cached branch: ${branchForkTargetId}`);
650
- }
651
- const rows = queryCachedBranchRows(db, source.id, cached, { order: "oldestFirst" });
652
- entries.push(...rows.map(entryRowFromCached));
653
- branchTips.push(branchForkTargetId);
371
+ finally {
372
+ if (this.openStorages.get(key) === storage)
373
+ this.openStorages.delete(key);
374
+ this.openSessions.delete(openSession);
375
+ this.pendingIds.delete(metadata.id);
654
376
  }
655
- }
656
- const copiedIds = new Set(entries.map((entry) => entry.id));
657
- const latestName = readLatestFact(db, source.id, "name", null);
658
- const latestLabels = readLatestLabelFacts(db, source.id);
659
- const labelsToCopy = latestLabels.filter((row) => options.scope === "tree" || (row.key !== null && copiedIds.has(row.key)));
660
- const createdAt = Date.now();
661
- const metadata = options.metadata ?? sourceMetadata.metadata;
662
- let lease;
663
- try {
664
- lease = db.transaction(() => {
665
- insertSessionRow(db, {
666
- id,
667
- createdAt,
668
- cwd: options.cwd,
669
- parentSessionId: options.parentSessionId ?? source.id,
670
- metadata,
671
- });
672
- createSequence(db, id);
673
- createStats(db, id, entries.filter((entry) => entry.type === "message").length);
674
- let nextSeq = 1;
675
- const allocateSeq = () => nextSeq++;
676
- for (const entry of entries) {
677
- insertEntryRow(db, id, {
678
- seq: allocateSeq(),
679
- id: entry.id,
680
- parentId: entry.parent_id,
681
- type: entry.type,
682
- timestamp: entry.timestamp,
683
- payload: entry.payload,
684
- });
685
- }
686
- if (options.scope === "tree") {
687
- for (const lane of lanes)
688
- insertLane(db, id, allocateSeq(), lane.lane, lane.leafId);
689
- }
690
- else {
691
- createInitialLane(db, id, "main", branchForkTargetId);
692
- }
693
- if (latestName?.value !== undefined && latestName.value !== null) {
694
- appendFact(db, id, allocateSeq(), "name", null, latestName.value);
695
- }
696
- for (const label of labelsToCopy)
697
- appendFact(db, id, allocateSeq(), "label", label.key, label.value);
698
- setNextSequence(db, id, nextSeq);
699
- for (const tip of branchTips)
700
- buildCachedBranch(db, id, tip);
701
- return claimWriterLease(db, id, this.leaseOptions);
702
- });
703
- }
704
- catch (error) {
705
- if (error instanceof SessionError)
706
- throw error;
707
- throw new SessionError("storage", `Failed to fork SQLite session ${id}`, error instanceof Error ? error : undefined);
708
- }
709
- const row = requireSessionRow(db, id);
710
- return this.sessionFromLease(db, decodeSessionMetadata(row, path), lease);
377
+ },
711
378
  });
379
+ this.openSessions.add(openSession);
380
+ return openSession;
381
+ }
382
+ async repositoryPathForMetadata(metadata) {
383
+ const [expected, actual] = await Promise.all([
384
+ realpath(this.pathForSession(metadata.id)),
385
+ realpath(metadata.path),
386
+ ]);
387
+ if (expected !== actual) {
388
+ throw new Error(`SQLite session metadata path is outside this repository: ${metadata.path}`);
389
+ }
390
+ return actual;
712
391
  }
713
- async close() {
714
- await this.operations.drain();
715
- for (const storage of [...this.activeStorages])
716
- await storage.release();
717
- if (this.database)
718
- this.database.close();
719
- this.database = undefined;
720
- this.databasePromise = undefined;
721
- }
722
- async [Symbol.asyncDispose]() {
723
- await this.close();
392
+ reserveId(id) {
393
+ if (this.pendingIds.has(id))
394
+ throw new Error(`Session is already open: ${id}`);
395
+ this.pendingIds.add(id);
724
396
  }
725
- async getDatabasePath() {
726
- this.databasePath ??= resultOrThrow(await this.options.env.absolutePath(this.options.databasePath), `Failed to resolve SQLite sessions database ${this.options.databasePath}`);
727
- return this.databasePath;
397
+ pathForSession(id) {
398
+ return this.databasePath ?? sessionPath(this.directory, id);
728
399
  }
729
- async getDatabase() {
730
- if (!this.databasePromise)
731
- this.databasePromise = this.openDatabase();
732
- this.database = await this.databasePromise;
733
- return this.database;
400
+ usesSharedDatabase() {
401
+ return this.databasePath !== undefined;
734
402
  }
735
- async openDatabase() {
736
- const path = await this.getDatabasePath();
737
- resultOrThrow(await this.options.env.createDir(getParentPath(path), { recursive: true }), `Failed to create SQLite sessions directory ${path}`);
738
- const db = await this.options.sqlite.open(path);
739
- try {
740
- configureSqliteDatabase(db);
741
- await applyMigrations(db);
742
- return db;
743
- }
744
- catch (error) {
745
- db.close();
746
- throw error;
747
- }
403
+ assertOpen() {
404
+ if (this.closed)
405
+ throw new Error("SqliteSessionRepo is closed");
748
406
  }
749
407
  }
750
408
  //# sourceMappingURL=repo.js.map