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