@amaster.ai/dsh-storage 0.1.33 → 0.1.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/lib/backends/database.js +282 -45
- package/lib/generated/mysql/internal/class.js +1 -1
- package/lib/generated/postgresql/internal/class.js +1 -1
- package/lib/generated/sqlite/internal/class.js +1 -1
- package/lib/generated/sqlserver/internal/class.js +1 -1
- package/lib/index.js +130 -44
- package/lib/projector.js +32 -14
- package/lib/types/backends/database.d.ts +7 -1
- package/lib/types/index.d.ts +23 -23
- package/lib/types/projector.d.ts +10 -10
- package/lib/types/types.d.ts +9 -6
- package/package.json +3 -3
- package/prisma/schema.mysql.prisma +2 -0
- package/prisma/schema.postgresql.prisma +2 -0
- package/prisma/schema.sqlite.prisma +2 -0
- package/prisma/schema.sqlserver.prisma +2 -0
package/README.md
CHANGED
|
@@ -55,7 +55,7 @@ The logical message id rides in `metadata.id`; message rows use a deterministic
|
|
|
55
55
|
|
|
56
56
|
For `user/message` rows, `content` remains the text projection, while `metadata.message` retains the original user message as received, including `source` and ordered content blocks. Image/file blocks keep whatever attachment reference, path, URL, or inline data the incoming message actually contains; this mirror does not fetch or rewrite attachments. Re-projection writes the same raw message into the existing row.
|
|
57
57
|
|
|
58
|
-
The `tool_calls` array retains dsh's tool-call blocks (`id`, `name`, `arguments`); the matching entry gains the structured `result` block and, on failure, an `error` identity.
|
|
58
|
+
The `tool_calls` array retains dsh's tool-call blocks (`id`, `name`, `arguments`); the matching entry gains the structured `result` block and, on failure, an `error` identity. Live call IDs are cached, and restored calls can resolve their message ID from the model surface: both paths read one message primary key. Older calls are searched in keyset pages of at most 100 ID/tool-call candidates, matched in the plugin and discarded before fetching the next page. Tool-result lookup SQL does not search JSON, join tables, or nest SELECT statements. Duplicate results update the same row and do not increment message counts. Existing historical `type=tool` rows are left intact.
|
|
59
59
|
|
|
60
60
|
When an assistant message contains dsh `reasoning` blocks, `thoughts` is an array of `{ content }` entries in block order. If the adapter supplies a matching signature in the message's `source.replayState.blocks`, that entry also carries `thoughtSignature`. The full model `source`, including opaque `replayState`, is preserved in row metadata for lossless provider replay. The backend's `readMessages()` also accepts older `{ description }` entries and JSON string thoughts, returning them as `{ content }`. `subject` and a per-thought timestamp are not invented when the upstream message does not supply them.
|
|
61
61
|
|
package/lib/backends/database.js
CHANGED
|
@@ -24,6 +24,14 @@ import { createHash } from 'node:crypto';
|
|
|
24
24
|
function pk(value) {
|
|
25
25
|
return createHash('sha256').update(value).digest('hex').slice(0, 36);
|
|
26
26
|
}
|
|
27
|
+
const ARCHIVE_IN_PROGRESS = 'dsh-storage:archive-in-progress';
|
|
28
|
+
const TOKEN_BASELINE_KEY = 'dsh-storage:token-baseline';
|
|
29
|
+
function metadataOf(value) {
|
|
30
|
+
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
|
|
31
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
32
|
+
? parsed
|
|
33
|
+
: {};
|
|
34
|
+
}
|
|
27
35
|
/**
|
|
28
36
|
* Parse the prisma-style sqlserver URL
|
|
29
37
|
* (sqlserver://host:1433;database=d;user=u;password=p;encrypt=true;trustServerCertificate=true)
|
|
@@ -93,6 +101,11 @@ export class DatabaseBackend {
|
|
|
93
101
|
constructor(config) {
|
|
94
102
|
this.config = config;
|
|
95
103
|
}
|
|
104
|
+
client() {
|
|
105
|
+
if (!this.prisma)
|
|
106
|
+
throw new Error('dsh-storage database is not initialized');
|
|
107
|
+
return this.prisma;
|
|
108
|
+
}
|
|
96
109
|
/** Load the pre-generated client for the configured provider (seam for tests). */
|
|
97
110
|
async loadClient() {
|
|
98
111
|
const mod = await import(new URL(`../generated/${this.config.provider}/client.js`, import.meta.url).href);
|
|
@@ -103,8 +116,15 @@ export class DatabaseBackend {
|
|
|
103
116
|
this.loadClient(),
|
|
104
117
|
createAdapter(this.config),
|
|
105
118
|
]);
|
|
106
|
-
|
|
107
|
-
|
|
119
|
+
const prisma = new PrismaClient({ adapter });
|
|
120
|
+
try {
|
|
121
|
+
await prisma.$connect();
|
|
122
|
+
this.prisma = prisma;
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
await prisma.$disconnect?.().catch(() => { });
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
108
128
|
}
|
|
109
129
|
/** JSON column value, text-serialized on SQL Server (no Prisma Json type). */
|
|
110
130
|
jsonField(value) {
|
|
@@ -130,31 +150,125 @@ export class DatabaseBackend {
|
|
|
130
150
|
createdAt: row.createdAt,
|
|
131
151
|
};
|
|
132
152
|
}
|
|
133
|
-
async
|
|
134
|
-
if (!this.prisma)
|
|
135
|
-
return;
|
|
153
|
+
async writeMessage(client, row) {
|
|
136
154
|
// NUL-separated: a space-joined pair would be ambiguous ('a b','c' ≡ 'a','b c').
|
|
137
155
|
const id = pk(`message ${row.sessionId}\0${row.id}`);
|
|
138
156
|
const data = this.toDbData(row);
|
|
139
|
-
await
|
|
157
|
+
await client.aiMessage.upsert({
|
|
140
158
|
where: { id },
|
|
141
159
|
create: { id, sessionId: row.sessionId, historyId: row.historyId, ...data },
|
|
142
160
|
update: data,
|
|
143
161
|
});
|
|
144
162
|
}
|
|
163
|
+
async upsertMessage(row) {
|
|
164
|
+
await this.writeMessage(this.client(), row);
|
|
165
|
+
}
|
|
145
166
|
async readMessages(sessionId, messageId) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
167
|
+
return this.readRows({
|
|
168
|
+
sessionId,
|
|
169
|
+
deletedAt: null,
|
|
170
|
+
id: pk(`message ${sessionId}\0${messageId}`),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
async readToolCallMessages(sessionId, callId) {
|
|
174
|
+
const prisma = this.client();
|
|
175
|
+
const provider = this.config.provider;
|
|
176
|
+
const sqlserver = provider === 'sqlserver';
|
|
177
|
+
// SQL Server formats datetime2 with all seven fractional digits: a raw JS
|
|
178
|
+
// Date would lose the 100ns precision needed by keyset pagination.
|
|
179
|
+
const sortTime = provider === 'mysql'
|
|
180
|
+
? "DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s.%f')"
|
|
181
|
+
: provider === 'postgresql'
|
|
182
|
+
? "to_char(created_at, 'YYYY-MM-DD HH24:MI:SS.US')"
|
|
183
|
+
: sqlserver
|
|
184
|
+
? "FORMAT(created_at, 'yyyy-MM-ddTHH:mm:ss.fffffff')"
|
|
185
|
+
: 'CAST(created_at AS TEXT)';
|
|
186
|
+
let cursor;
|
|
187
|
+
let nullPhase = false;
|
|
188
|
+
for (;;) {
|
|
189
|
+
const params = provider === 'mysql' || provider === 'sqlite' ? [sessionId, sessionId] : [sessionId];
|
|
190
|
+
let before = '';
|
|
191
|
+
if (provider === 'postgresql') {
|
|
192
|
+
// PostgreSQL defaults to NULLS FIRST, which cannot be paginated with a
|
|
193
|
+
// single mixed-time cursor. Walk non-NULL timestamps first, then IDs
|
|
194
|
+
// of NULL rows, instead of relying on a NULLS LAST index definition.
|
|
195
|
+
if (nullPhase) {
|
|
196
|
+
before =
|
|
197
|
+
cursor?.time === null ? 'AND created_at IS NULL AND id < ?' : 'AND created_at IS NULL';
|
|
198
|
+
if (cursor)
|
|
199
|
+
params.push(cursor.id);
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
before = 'AND created_at IS NOT NULL';
|
|
203
|
+
if (cursor?.time) {
|
|
204
|
+
before += ' AND (created_at < ? OR (created_at = ? AND id < ?))';
|
|
205
|
+
params.push(cursor.time, cursor.time, cursor.id);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
else if (cursor) {
|
|
210
|
+
if (cursor.time === null) {
|
|
211
|
+
before = 'AND created_at IS NULL AND id < ?';
|
|
212
|
+
params.push(cursor.id);
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
before = 'AND (created_at < ? OR (created_at = ? AND id < ?) OR created_at IS NULL)';
|
|
216
|
+
params.push(cursor.time, cursor.time, cursor.id);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const orderBy = provider === 'postgresql' && nullPhase ? 'id DESC' : 'created_at DESC, id DESC';
|
|
220
|
+
let sql = `SELECT ${sqlserver ? 'TOP 100 ' : ''}id, ${sqlserver ? 'session_id, ' : ''}tool_calls, ${sortTime} AS sort_time
|
|
221
|
+
FROM ai_messages WHERE session_id = ?
|
|
222
|
+
${provider === 'sqlite' ? 'AND session_id COLLATE BINARY = ?' : ''}
|
|
223
|
+
${provider === 'mysql' ? 'AND BINARY session_id = BINARY ?' : ''}
|
|
224
|
+
AND deleted_at IS NULL AND history_id IS NULL AND type = 'model' AND tool_calls IS NOT NULL
|
|
225
|
+
${before} ORDER BY ${orderBy} ${sqlserver ? '' : 'LIMIT 100'}`;
|
|
226
|
+
if (sqlserver || provider === 'postgresql') {
|
|
227
|
+
let index = 0;
|
|
228
|
+
sql = sql.replaceAll('?', () => `${sqlserver ? '@P' : '$'}${++index}`);
|
|
229
|
+
}
|
|
230
|
+
const page = await prisma.$queryRawUnsafe(sql, ...params);
|
|
231
|
+
for (const row of page) {
|
|
232
|
+
// The SQL Server index can seek on session_id without a collation
|
|
233
|
+
// expression; verify exact identity here instead of losing index use.
|
|
234
|
+
if (sqlserver && row.session_id !== undefined && row.session_id !== sessionId)
|
|
235
|
+
continue;
|
|
236
|
+
let calls = row.tool_calls;
|
|
237
|
+
if (typeof calls === 'string') {
|
|
238
|
+
try {
|
|
239
|
+
calls = JSON.parse(calls);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (Array.isArray(calls) && calls.some((call) => call?.id === callId)) {
|
|
246
|
+
return this.readRows({
|
|
247
|
+
sessionId,
|
|
248
|
+
deletedAt: null,
|
|
249
|
+
historyId: null,
|
|
250
|
+
type: 'model',
|
|
251
|
+
id: row.id,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (page.length < 100) {
|
|
256
|
+
if (provider === 'postgresql' && !nullPhase) {
|
|
257
|
+
nullPhase = true;
|
|
258
|
+
cursor = undefined;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
return [];
|
|
262
|
+
}
|
|
263
|
+
const last = page.at(-1);
|
|
264
|
+
cursor = { id: last.id, time: last.sort_time };
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
async readRows(where) {
|
|
268
|
+
const rows = await this.client().aiMessage.findMany({
|
|
269
|
+
where,
|
|
157
270
|
orderBy: { createdAt: 'asc' },
|
|
271
|
+
take: 1,
|
|
158
272
|
});
|
|
159
273
|
const json = (value) => (typeof value === 'string' ? JSON.parse(value) : value);
|
|
160
274
|
return rows.map((row) => {
|
|
@@ -191,9 +305,7 @@ export class DatabaseBackend {
|
|
|
191
305
|
});
|
|
192
306
|
}
|
|
193
307
|
async readSession(sessionId) {
|
|
194
|
-
|
|
195
|
-
return null;
|
|
196
|
-
const row = await this.prisma.aiChatHistory.findFirst({
|
|
308
|
+
const row = await this.client().aiChatHistory.findFirst({
|
|
197
309
|
where: { sessionId, deletedAt: null, messages: { none: {} } },
|
|
198
310
|
});
|
|
199
311
|
if (!row)
|
|
@@ -213,14 +325,12 @@ export class DatabaseBackend {
|
|
|
213
325
|
: row.metadata,
|
|
214
326
|
};
|
|
215
327
|
}
|
|
216
|
-
async
|
|
217
|
-
if (!this.prisma)
|
|
218
|
-
return;
|
|
328
|
+
async writeSession(client, row) {
|
|
219
329
|
// Find-then-write is race-free because the plugin serializes event
|
|
220
330
|
// processing per session (see the per-session chain in index.ts). The
|
|
221
331
|
// cuid primary key matches the source project and early-scaffold rows,
|
|
222
332
|
// so existing data is continued, never duplicated.
|
|
223
|
-
const existing = await
|
|
333
|
+
const existing = await client.aiChatHistory.findFirst({
|
|
224
334
|
where: { sessionId: row.sessionId, deletedAt: null, messages: { none: {} } },
|
|
225
335
|
select: { id: true },
|
|
226
336
|
});
|
|
@@ -234,16 +344,25 @@ export class DatabaseBackend {
|
|
|
234
344
|
metadata: this.jsonField(row.metadata),
|
|
235
345
|
};
|
|
236
346
|
if (existing) {
|
|
237
|
-
await
|
|
347
|
+
await client.aiChatHistory.update({ where: { id: existing.id }, data });
|
|
238
348
|
}
|
|
239
349
|
else {
|
|
240
|
-
await
|
|
350
|
+
await client.aiChatHistory.create({ data });
|
|
241
351
|
}
|
|
242
352
|
}
|
|
353
|
+
async upsertSession(row) {
|
|
354
|
+
await this.writeSession(this.client(), row);
|
|
355
|
+
}
|
|
356
|
+
async upsertEvent(message, session) {
|
|
357
|
+
await this.client().$transaction(async (tx) => {
|
|
358
|
+
if (message)
|
|
359
|
+
await this.writeMessage(tx, message);
|
|
360
|
+
await this.writeSession(tx, session);
|
|
361
|
+
});
|
|
362
|
+
}
|
|
243
363
|
async archiveSession(sessionId) {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
return this.prisma.$transaction(async (tx) => {
|
|
364
|
+
const client = this.client();
|
|
365
|
+
const { expected, historyId } = await client.$transaction(async (tx) => {
|
|
247
366
|
const where = { sessionId, historyId: null, deletedAt: null };
|
|
248
367
|
const stats = await tx.aiMessage.aggregate({
|
|
249
368
|
where,
|
|
@@ -251,30 +370,148 @@ export class DatabaseBackend {
|
|
|
251
370
|
_min: { createdAt: true },
|
|
252
371
|
_max: { createdAt: true },
|
|
253
372
|
});
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
//
|
|
257
|
-
const
|
|
258
|
-
where: { sessionId,
|
|
259
|
-
|
|
373
|
+
const expected = stats._count._all;
|
|
374
|
+
// A previous batch may have attached messages already. Resume that marked
|
|
375
|
+
// history before considering a newer active rollup or an empty finalization.
|
|
376
|
+
const interrupted = await tx.aiMessage.findFirst({
|
|
377
|
+
where: { sessionId, historyId: { not: null }, deletedAt: null },
|
|
378
|
+
orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }],
|
|
379
|
+
select: { historyId: true },
|
|
260
380
|
});
|
|
381
|
+
if (interrupted?.historyId) {
|
|
382
|
+
const candidate = await tx.aiChatHistory.findFirst({
|
|
383
|
+
where: { id: interrupted.historyId },
|
|
384
|
+
select: { metadata: true },
|
|
385
|
+
});
|
|
386
|
+
if (metadataOf(candidate?.metadata)[ARCHIVE_IN_PROGRESS] === true)
|
|
387
|
+
return { expected, historyId: interrupted.historyId };
|
|
388
|
+
}
|
|
389
|
+
if (expected === 0)
|
|
390
|
+
return { expected, historyId: null };
|
|
261
391
|
const rollup = {
|
|
262
|
-
messageCount:
|
|
392
|
+
messageCount: expected,
|
|
263
393
|
firstMessageAt: stats._min.createdAt,
|
|
264
394
|
lastMessageAt: stats._max.createdAt,
|
|
265
395
|
};
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
:
|
|
269
|
-
|
|
270
|
-
|
|
396
|
+
// Active rollups have no attached messages; an archive gains them below.
|
|
397
|
+
const active = await tx.aiChatHistory.findFirst({
|
|
398
|
+
where: { sessionId, deletedAt: null, messages: { none: {} } },
|
|
399
|
+
select: { id: true, metadata: true, totalTokens: true },
|
|
400
|
+
});
|
|
401
|
+
if (active) {
|
|
402
|
+
await tx.aiChatHistory.update({
|
|
403
|
+
where: { id: active.id },
|
|
404
|
+
data: {
|
|
405
|
+
...rollup,
|
|
406
|
+
metadata: this.jsonField({
|
|
407
|
+
...metadataOf(active.metadata),
|
|
408
|
+
[TOKEN_BASELINE_KEY]: Number(active.totalTokens ?? 0n),
|
|
409
|
+
[ARCHIVE_IN_PROGRESS]: true,
|
|
410
|
+
}),
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
return { expected, historyId: active.id };
|
|
414
|
+
}
|
|
415
|
+
const history = await tx.aiChatHistory.create({
|
|
416
|
+
data: {
|
|
417
|
+
sessionId,
|
|
418
|
+
...rollup,
|
|
419
|
+
totalTokens: 0n,
|
|
420
|
+
metadata: this.jsonField({ [ARCHIVE_IN_PROGRESS]: true }),
|
|
421
|
+
},
|
|
422
|
+
select: { id: true },
|
|
423
|
+
});
|
|
424
|
+
return { expected, historyId: history.id };
|
|
425
|
+
});
|
|
426
|
+
if (!historyId)
|
|
427
|
+
return null;
|
|
428
|
+
// Keep each write batch short instead of holding one transaction over the
|
|
429
|
+
// entire active segment. The final aggregate is the correctness barrier.
|
|
430
|
+
let updated = 0;
|
|
431
|
+
while (updated < expected) {
|
|
432
|
+
const candidates = await client.aiMessage.findMany({
|
|
433
|
+
where: { sessionId, historyId: null, deletedAt: null },
|
|
434
|
+
select: { id: true },
|
|
435
|
+
take: 100,
|
|
436
|
+
});
|
|
437
|
+
if (candidates.length === 0)
|
|
438
|
+
break;
|
|
439
|
+
const batch = await client.aiMessage.updateMany({
|
|
440
|
+
where: { id: { in: candidates.map((row) => row.id) } },
|
|
441
|
+
data: { historyId },
|
|
442
|
+
});
|
|
443
|
+
if (batch.count === 0)
|
|
444
|
+
break;
|
|
445
|
+
updated += batch.count;
|
|
446
|
+
}
|
|
447
|
+
if (updated !== expected)
|
|
448
|
+
throw new Error('active messages changed during archive');
|
|
449
|
+
await client.$transaction(async (tx) => {
|
|
450
|
+
const stats = await tx.aiMessage.aggregate({
|
|
451
|
+
where: { historyId, deletedAt: null },
|
|
452
|
+
_count: { _all: true },
|
|
453
|
+
_min: { createdAt: true },
|
|
454
|
+
_max: { createdAt: true },
|
|
455
|
+
});
|
|
456
|
+
const current = await tx.aiChatHistory.findFirst({
|
|
457
|
+
where: { id: historyId },
|
|
458
|
+
select: { metadata: true, totalTokens: true },
|
|
459
|
+
});
|
|
460
|
+
const latestActive = await tx.aiChatHistory.findFirst({
|
|
461
|
+
where: { sessionId, id: { not: historyId }, deletedAt: null, messages: { none: {} } },
|
|
462
|
+
orderBy: { lastMessageAt: 'desc' },
|
|
463
|
+
select: { totalTokens: true, metadata: true },
|
|
464
|
+
});
|
|
465
|
+
const currentTokens = current?.totalTokens ?? 0n;
|
|
466
|
+
const baselineValue = metadataOf(latestActive?.metadata)[TOKEN_BASELINE_KEY];
|
|
467
|
+
const baseline = Number(baselineValue);
|
|
468
|
+
let totalTokens = currentTokens;
|
|
469
|
+
if (latestActive && Number.isSafeInteger(baseline) && baseline >= 0) {
|
|
470
|
+
// The baseline records the active rollup's seed total. Everything
|
|
471
|
+
// after it belongs to this archive, including restart-only deltas.
|
|
472
|
+
const delta = latestActive.totalTokens - BigInt(baseline);
|
|
473
|
+
if (delta > 0n)
|
|
474
|
+
totalTokens += delta;
|
|
475
|
+
}
|
|
476
|
+
else if (latestActive && latestActive.totalTokens > currentTokens) {
|
|
477
|
+
// Legacy rows without a baseline keep the cumulative compatibility path.
|
|
478
|
+
totalTokens = latestActive.totalTokens;
|
|
479
|
+
}
|
|
480
|
+
const metadata = { ...metadataOf(current?.metadata) };
|
|
481
|
+
delete metadata[ARCHIVE_IN_PROGRESS];
|
|
482
|
+
await tx.aiChatHistory.update({
|
|
483
|
+
where: { id: historyId },
|
|
484
|
+
data: {
|
|
485
|
+
messageCount: stats._count._all,
|
|
486
|
+
totalTokens,
|
|
487
|
+
firstMessageAt: stats._min.createdAt,
|
|
488
|
+
lastMessageAt: stats._max.createdAt,
|
|
489
|
+
metadata: this.jsonField(metadata),
|
|
490
|
+
},
|
|
491
|
+
});
|
|
492
|
+
// A recovery may have created a second active rollup during the failed
|
|
493
|
+
// batches. Do not let its pre-archive counts seed the next live segment.
|
|
494
|
+
await tx.aiChatHistory.updateMany({
|
|
495
|
+
where: { sessionId, id: { not: historyId }, deletedAt: null, messages: { none: {} } },
|
|
496
|
+
data: { messageCount: 0, totalTokens: 0n, firstMessageAt: null, lastMessageAt: null },
|
|
497
|
+
});
|
|
498
|
+
const activeRollups = await tx.aiChatHistory.findMany({
|
|
499
|
+
where: { sessionId, id: { not: historyId }, deletedAt: null, messages: { none: {} } },
|
|
500
|
+
select: { id: true, metadata: true },
|
|
501
|
+
});
|
|
502
|
+
for (const active of activeRollups ?? []) {
|
|
503
|
+
// A completed archive must not leak its retry marker or stale baseline
|
|
504
|
+
// into the next live segment, or the next archive will skip freezing.
|
|
505
|
+
const activeMetadata = { ...metadataOf(active.metadata) };
|
|
506
|
+
delete activeMetadata[ARCHIVE_IN_PROGRESS];
|
|
507
|
+
delete activeMetadata[TOKEN_BASELINE_KEY];
|
|
508
|
+
await tx.aiChatHistory.update({
|
|
509
|
+
where: { id: active.id },
|
|
510
|
+
data: { metadata: this.jsonField(activeMetadata) },
|
|
271
511
|
});
|
|
272
|
-
const updated = await tx.aiMessage.updateMany({ where, data: { historyId: history.id } });
|
|
273
|
-
if (updated.count !== stats._count._all) {
|
|
274
|
-
throw new Error('active messages changed during archive');
|
|
275
512
|
}
|
|
276
|
-
return history.id;
|
|
277
513
|
});
|
|
514
|
+
return historyId;
|
|
278
515
|
}
|
|
279
516
|
async close() {
|
|
280
517
|
await this.prisma?.$disconnect?.();
|
|
@@ -15,7 +15,7 @@ const config = {
|
|
|
15
15
|
"clientVersion": "7.9.1",
|
|
16
16
|
"engineVersion": "e922089b7d7502aff4249d5da3420f6fa55fc6ad",
|
|
17
17
|
"activeProvider": "mysql",
|
|
18
|
-
"inlineSchema": "// Prisma schema for the dsh-storage database backend — MySQL variant (Prisma 7).\n//\n// Table layout matches the source project's ai_messages / ai_chat_histories\n// (packages/core/prisma/schema.prisma) so existing data stays compatible:\n// - `type` values come from the dsh session event map\n// ('user' | 'model' | 'tool' | ...) instead of gemini-cli message roles.\n// - `agent_id` distinguishes the main agent from subagent writers.\n// - no user_id: tenancy is carried by session_id, not a column.\n//\n// The client is pre-generated at package build time (`pnpm generate`, output\n// src/generated/mysql, compiled into lib/generated) — consumers do NOT run\n// `prisma generate`. To create/upgrade tables, run `prisma db push` /\n// migrations against this file; the connection URL lives in prisma.config.ts\n// (Prisma 7 dropped datasource.url from the schema).\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../src/generated/mysql\"\n importFileExtension = \"js\"\n}\n\ndatasource db {\n provider = \"mysql\"\n}\n\n/// User and assistant messages; tool results update the assistant row's tool_calls.\nmodel AiMessage {\n id String @id @default(cuid()) @db.Char(36)\n sessionId String @map(\"session_id\") @db.VarChar(100)\n historyId String? @map(\"history_id\") @db.Char(36)\n type String @db.VarChar(50)\n content String @db.LongText\n thoughts Json?\n model String? @db.VarChar(100)\n tokens Json?\n toolCalls Json? @map(\"tool_calls\")\n agentId String? @map(\"agent_id\") @db.VarChar(100)\n metadata Json?\n createdAt DateTime @default(now()) @map(\"created_at\") @db.Timestamp(0)\n updatedAt DateTime @default(now()) @updatedAt @map(\"updated_at\") @db.Timestamp(0)\n deletedAt DateTime? @map(\"deleted_at\") @db.Timestamp(0)\n\n // Relations\n history AiChatHistory? @relation(fields: [historyId], references: [id], onDelete: NoAction)\n\n @@index([sessionId], map: \"idx_session\")\n @@index([historyId], map: \"idx_history\")\n @@index([createdAt], map: \"idx_created\")\n @@index([deletedAt], map: \"idx_deleted\")\n @@index([agentId], map: \"idx_agent\")\n @@index([sessionId, agentId, type, createdAt], map: \"idx_session_agent_type_created\")\n @@map(\"ai_messages\")\n}\n\n/// Per-session rollup, maintained incrementally by the projector.\nmodel AiChatHistory {\n id String @id @default(cuid()) @db.Char(36)\n sessionId String @map(\"session_id\") @db.VarChar(100)\n title String? @db.VarChar(255)\n summary String? @db.Text\n messageCount Int @default(0) @map(\"message_count\")\n totalTokens BigInt @default(0) @map(\"total_tokens\")\n firstMessageAt DateTime? @map(\"first_message_at\") @db.DateTime(6)\n lastMessageAt DateTime? @map(\"last_message_at\") @db.DateTime(6)\n metadata Json?\n deletedAt DateTime? @map(\"deleted_at\") @db.DateTime(6)\n\n // Relations\n messages AiMessage[]\n\n @@index([sessionId], map: \"idx_chat_history_session\")\n @@index([deletedAt], map: \"idx_chat_history_deleted\")\n @@map(\"ai_chat_histories\")\n}\n",
|
|
18
|
+
"inlineSchema": "// Prisma schema for the dsh-storage database backend — MySQL variant (Prisma 7).\n//\n// Table layout matches the source project's ai_messages / ai_chat_histories\n// (packages/core/prisma/schema.prisma) so existing data stays compatible:\n// - `type` values come from the dsh session event map\n// ('user' | 'model' | 'tool' | ...) instead of gemini-cli message roles.\n// - `agent_id` distinguishes the main agent from subagent writers.\n// - no user_id: tenancy is carried by session_id, not a column.\n//\n// The client is pre-generated at package build time (`pnpm generate`, output\n// src/generated/mysql, compiled into lib/generated) — consumers do NOT run\n// `prisma generate`. To create/upgrade tables, run `prisma db push` /\n// migrations against this file; the connection URL lives in prisma.config.ts\n// (Prisma 7 dropped datasource.url from the schema).\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../src/generated/mysql\"\n importFileExtension = \"js\"\n}\n\ndatasource db {\n provider = \"mysql\"\n}\n\n/// User and assistant messages; tool results update the assistant row's tool_calls.\nmodel AiMessage {\n id String @id @default(cuid()) @db.Char(36)\n sessionId String @map(\"session_id\") @db.VarChar(100)\n historyId String? @map(\"history_id\") @db.Char(36)\n type String @db.VarChar(50)\n content String @db.LongText\n thoughts Json?\n model String? @db.VarChar(100)\n tokens Json?\n toolCalls Json? @map(\"tool_calls\")\n agentId String? @map(\"agent_id\") @db.VarChar(100)\n metadata Json?\n createdAt DateTime @default(now()) @map(\"created_at\") @db.Timestamp(0)\n updatedAt DateTime @default(now()) @updatedAt @map(\"updated_at\") @db.Timestamp(0)\n deletedAt DateTime? @map(\"deleted_at\") @db.Timestamp(0)\n\n // Relations\n history AiChatHistory? @relation(fields: [historyId], references: [id], onDelete: NoAction)\n\n @@index([sessionId], map: \"idx_session\")\n @@index([historyId], map: \"idx_history\")\n @@index([createdAt], map: \"idx_created\")\n @@index([deletedAt], map: \"idx_deleted\")\n @@index([agentId], map: \"idx_agent\")\n @@index([sessionId, agentId, type, createdAt], map: \"idx_session_agent_type_created\")\n @@index([sessionId, historyId, deletedAt, type, createdAt, id], map: \"idx_tool_call_lookup\")\n @@index([sessionId, deletedAt, updatedAt, id], map: \"idx_archive_recovery\")\n @@map(\"ai_messages\")\n}\n\n/// Per-session rollup, maintained incrementally by the projector.\nmodel AiChatHistory {\n id String @id @default(cuid()) @db.Char(36)\n sessionId String @map(\"session_id\") @db.VarChar(100)\n title String? @db.VarChar(255)\n summary String? @db.Text\n messageCount Int @default(0) @map(\"message_count\")\n totalTokens BigInt @default(0) @map(\"total_tokens\")\n firstMessageAt DateTime? @map(\"first_message_at\") @db.DateTime(6)\n lastMessageAt DateTime? @map(\"last_message_at\") @db.DateTime(6)\n metadata Json?\n deletedAt DateTime? @map(\"deleted_at\") @db.DateTime(6)\n\n // Relations\n messages AiMessage[]\n\n @@index([sessionId], map: \"idx_chat_history_session\")\n @@index([deletedAt], map: \"idx_chat_history_deleted\")\n @@map(\"ai_chat_histories\")\n}\n",
|
|
19
19
|
"runtimeDataModel": {
|
|
20
20
|
"models": {},
|
|
21
21
|
"enums": {},
|
|
@@ -15,7 +15,7 @@ const config = {
|
|
|
15
15
|
"clientVersion": "7.9.1",
|
|
16
16
|
"engineVersion": "e922089b7d7502aff4249d5da3420f6fa55fc6ad",
|
|
17
17
|
"activeProvider": "postgresql",
|
|
18
|
-
"inlineSchema": "// Prisma schema for the dsh-storage database backend — PostgreSQL variant (Prisma 7).\n//\n// Same models as schema.mysql.prisma, with PostgreSQL native types\n// (`@db.Text` instead of `@db.LongText`, `@db.Timestamp` instead of\n// `@db.DateTime`). Client pre-generation and table creation work as described\n// in schema.mysql.prisma's header.\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../src/generated/postgresql\"\n importFileExtension = \"js\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\n/// User and assistant messages; tool results update the assistant row's tool_calls.\nmodel AiMessage {\n id String @id @default(cuid()) @db.Char(36)\n sessionId String @map(\"session_id\") @db.VarChar(100)\n historyId String? @map(\"history_id\") @db.Char(36)\n type String @db.VarChar(50)\n content String @db.Text\n thoughts Json?\n model String? @db.VarChar(100)\n tokens Json?\n toolCalls Json? @map(\"tool_calls\")\n agentId String? @map(\"agent_id\") @db.VarChar(100)\n metadata Json?\n createdAt DateTime @default(now()) @map(\"created_at\") @db.Timestamp(0)\n updatedAt DateTime @default(now()) @updatedAt @map(\"updated_at\") @db.Timestamp(0)\n deletedAt DateTime? @map(\"deleted_at\") @db.Timestamp(0)\n\n // Relations\n history AiChatHistory? @relation(fields: [historyId], references: [id], onDelete: NoAction)\n\n @@index([sessionId], map: \"idx_session\")\n @@index([historyId], map: \"idx_history\")\n @@index([createdAt], map: \"idx_created\")\n @@index([deletedAt], map: \"idx_deleted\")\n @@index([agentId], map: \"idx_agent\")\n @@index([sessionId, agentId, type, createdAt], map: \"idx_session_agent_type_created\")\n @@map(\"ai_messages\")\n}\n\n/// Per-session rollup, maintained incrementally by the projector.\nmodel AiChatHistory {\n id String @id @default(cuid()) @db.Char(36)\n sessionId String @map(\"session_id\") @db.VarChar(100)\n title String? @db.VarChar(255)\n summary String? @db.Text\n messageCount Int @default(0) @map(\"message_count\")\n totalTokens BigInt @default(0) @map(\"total_tokens\")\n firstMessageAt DateTime? @map(\"first_message_at\") @db.Timestamp(6)\n lastMessageAt DateTime? @map(\"last_message_at\") @db.Timestamp(6)\n metadata Json?\n deletedAt DateTime? @map(\"deleted_at\") @db.Timestamp(6)\n\n // Relations\n messages AiMessage[]\n\n @@index([sessionId], map: \"idx_chat_history_session\")\n @@index([deletedAt], map: \"idx_chat_history_deleted\")\n @@map(\"ai_chat_histories\")\n}\n",
|
|
18
|
+
"inlineSchema": "// Prisma schema for the dsh-storage database backend — PostgreSQL variant (Prisma 7).\n//\n// Same models as schema.mysql.prisma, with PostgreSQL native types\n// (`@db.Text` instead of `@db.LongText`, `@db.Timestamp` instead of\n// `@db.DateTime`). Client pre-generation and table creation work as described\n// in schema.mysql.prisma's header.\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../src/generated/postgresql\"\n importFileExtension = \"js\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\n/// User and assistant messages; tool results update the assistant row's tool_calls.\nmodel AiMessage {\n id String @id @default(cuid()) @db.Char(36)\n sessionId String @map(\"session_id\") @db.VarChar(100)\n historyId String? @map(\"history_id\") @db.Char(36)\n type String @db.VarChar(50)\n content String @db.Text\n thoughts Json?\n model String? @db.VarChar(100)\n tokens Json?\n toolCalls Json? @map(\"tool_calls\")\n agentId String? @map(\"agent_id\") @db.VarChar(100)\n metadata Json?\n createdAt DateTime @default(now()) @map(\"created_at\") @db.Timestamp(0)\n updatedAt DateTime @default(now()) @updatedAt @map(\"updated_at\") @db.Timestamp(0)\n deletedAt DateTime? @map(\"deleted_at\") @db.Timestamp(0)\n\n // Relations\n history AiChatHistory? @relation(fields: [historyId], references: [id], onDelete: NoAction)\n\n @@index([sessionId], map: \"idx_session\")\n @@index([historyId], map: \"idx_history\")\n @@index([createdAt], map: \"idx_created\")\n @@index([deletedAt], map: \"idx_deleted\")\n @@index([agentId], map: \"idx_agent\")\n @@index([sessionId, agentId, type, createdAt], map: \"idx_session_agent_type_created\")\n @@index([sessionId, historyId, deletedAt, type, createdAt, id], map: \"idx_tool_call_lookup\")\n @@index([sessionId, deletedAt, updatedAt, id], map: \"idx_archive_recovery\")\n @@map(\"ai_messages\")\n}\n\n/// Per-session rollup, maintained incrementally by the projector.\nmodel AiChatHistory {\n id String @id @default(cuid()) @db.Char(36)\n sessionId String @map(\"session_id\") @db.VarChar(100)\n title String? @db.VarChar(255)\n summary String? @db.Text\n messageCount Int @default(0) @map(\"message_count\")\n totalTokens BigInt @default(0) @map(\"total_tokens\")\n firstMessageAt DateTime? @map(\"first_message_at\") @db.Timestamp(6)\n lastMessageAt DateTime? @map(\"last_message_at\") @db.Timestamp(6)\n metadata Json?\n deletedAt DateTime? @map(\"deleted_at\") @db.Timestamp(6)\n\n // Relations\n messages AiMessage[]\n\n @@index([sessionId], map: \"idx_chat_history_session\")\n @@index([deletedAt], map: \"idx_chat_history_deleted\")\n @@map(\"ai_chat_histories\")\n}\n",
|
|
19
19
|
"runtimeDataModel": {
|
|
20
20
|
"models": {},
|
|
21
21
|
"enums": {},
|
|
@@ -15,7 +15,7 @@ const config = {
|
|
|
15
15
|
"clientVersion": "7.9.1",
|
|
16
16
|
"engineVersion": "e922089b7d7502aff4249d5da3420f6fa55fc6ad",
|
|
17
17
|
"activeProvider": "sqlite",
|
|
18
|
-
"inlineSchema": "// Prisma schema for the dsh-storage database backend — SQLite variant (Prisma 7).\n//\n// Same models as schema.mysql.prisma, without native type attributes\n// (SQLite has no `@db.*` vocabulary here). Client pre-generation and table\n// creation work as described in schema.mysql.prisma's header. Connection URL\n// shape: file:/absolute/path/to/db.sqlite.\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../src/generated/sqlite\"\n importFileExtension = \"js\"\n}\n\ndatasource db {\n provider = \"sqlite\"\n}\n\n/// User and assistant messages; tool results update the assistant row's tool_calls.\nmodel AiMessage {\n id String @id @default(cuid())\n sessionId String @map(\"session_id\")\n historyId String? @map(\"history_id\")\n type String\n content String\n thoughts Json?\n model String?\n tokens Json?\n toolCalls Json? @map(\"tool_calls\")\n agentId String? @map(\"agent_id\")\n metadata Json?\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @default(now()) @updatedAt @map(\"updated_at\")\n deletedAt DateTime? @map(\"deleted_at\")\n\n // Relations\n history AiChatHistory? @relation(fields: [historyId], references: [id], onDelete: NoAction)\n\n @@index([sessionId], map: \"idx_session\")\n @@index([historyId], map: \"idx_history\")\n @@index([createdAt], map: \"idx_created\")\n @@index([deletedAt], map: \"idx_deleted\")\n @@index([agentId], map: \"idx_agent\")\n @@index([sessionId, agentId, type, createdAt], map: \"idx_session_agent_type_created\")\n @@map(\"ai_messages\")\n}\n\n/// Per-session rollup, maintained incrementally by the projector.\nmodel AiChatHistory {\n id String @id @default(cuid())\n sessionId String @map(\"session_id\")\n title String?\n summary String?\n messageCount Int @default(0) @map(\"message_count\")\n totalTokens BigInt @default(0) @map(\"total_tokens\")\n firstMessageAt DateTime? @map(\"first_message_at\")\n lastMessageAt DateTime? @map(\"last_message_at\")\n metadata Json?\n deletedAt DateTime? @map(\"deleted_at\")\n\n // Relations\n messages AiMessage[]\n\n @@index([sessionId], map: \"idx_chat_history_session\")\n @@index([deletedAt], map: \"idx_chat_history_deleted\")\n @@map(\"ai_chat_histories\")\n}\n",
|
|
18
|
+
"inlineSchema": "// Prisma schema for the dsh-storage database backend — SQLite variant (Prisma 7).\n//\n// Same models as schema.mysql.prisma, without native type attributes\n// (SQLite has no `@db.*` vocabulary here). Client pre-generation and table\n// creation work as described in schema.mysql.prisma's header. Connection URL\n// shape: file:/absolute/path/to/db.sqlite.\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../src/generated/sqlite\"\n importFileExtension = \"js\"\n}\n\ndatasource db {\n provider = \"sqlite\"\n}\n\n/// User and assistant messages; tool results update the assistant row's tool_calls.\nmodel AiMessage {\n id String @id @default(cuid())\n sessionId String @map(\"session_id\")\n historyId String? @map(\"history_id\")\n type String\n content String\n thoughts Json?\n model String?\n tokens Json?\n toolCalls Json? @map(\"tool_calls\")\n agentId String? @map(\"agent_id\")\n metadata Json?\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @default(now()) @updatedAt @map(\"updated_at\")\n deletedAt DateTime? @map(\"deleted_at\")\n\n // Relations\n history AiChatHistory? @relation(fields: [historyId], references: [id], onDelete: NoAction)\n\n @@index([sessionId], map: \"idx_session\")\n @@index([historyId], map: \"idx_history\")\n @@index([createdAt], map: \"idx_created\")\n @@index([deletedAt], map: \"idx_deleted\")\n @@index([agentId], map: \"idx_agent\")\n @@index([sessionId, agentId, type, createdAt], map: \"idx_session_agent_type_created\")\n @@index([sessionId, historyId, deletedAt, type, createdAt, id], map: \"idx_tool_call_lookup\")\n @@index([sessionId, deletedAt, updatedAt, id], map: \"idx_archive_recovery\")\n @@map(\"ai_messages\")\n}\n\n/// Per-session rollup, maintained incrementally by the projector.\nmodel AiChatHistory {\n id String @id @default(cuid())\n sessionId String @map(\"session_id\")\n title String?\n summary String?\n messageCount Int @default(0) @map(\"message_count\")\n totalTokens BigInt @default(0) @map(\"total_tokens\")\n firstMessageAt DateTime? @map(\"first_message_at\")\n lastMessageAt DateTime? @map(\"last_message_at\")\n metadata Json?\n deletedAt DateTime? @map(\"deleted_at\")\n\n // Relations\n messages AiMessage[]\n\n @@index([sessionId], map: \"idx_chat_history_session\")\n @@index([deletedAt], map: \"idx_chat_history_deleted\")\n @@map(\"ai_chat_histories\")\n}\n",
|
|
19
19
|
"runtimeDataModel": {
|
|
20
20
|
"models": {},
|
|
21
21
|
"enums": {},
|
|
@@ -15,7 +15,7 @@ const config = {
|
|
|
15
15
|
"clientVersion": "7.9.1",
|
|
16
16
|
"engineVersion": "e922089b7d7502aff4249d5da3420f6fa55fc6ad",
|
|
17
17
|
"activeProvider": "sqlserver",
|
|
18
|
-
"inlineSchema": "// Prisma schema for the dsh-storage database backend — SQL Server variant (Prisma 7).\n//\n// Same models as schema.mysql.prisma, with SQL Server native types. Prisma's\n// sqlserver connector has NO Json type, so every JSON column is a plain text\n// column here — the backend serializes them on write automatically (derived\n// from `provider: sqlserver`; SQL Server's own ISJSON/JSON_VALUE still query\n// the text as JSON).\n//\n// Connection URL shape (parsed by the backend into a node-mssql config):\n// sqlserver://host:1433;database=dbname;user=sa;password=p;encrypt=true;trustServerCertificate=true\n// Client pre-generation and table creation work as described in\n// schema.mysql.prisma's header.\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../src/generated/sqlserver\"\n importFileExtension = \"js\"\n}\n\ndatasource db {\n provider = \"sqlserver\"\n}\n\n/// User and assistant messages; tool results update the assistant row's tool_calls.\nmodel AiMessage {\n id String @id @default(cuid()) @db.Char(36)\n sessionId String @map(\"session_id\") @db.VarChar(100)\n historyId String? @map(\"history_id\") @db.Char(36)\n type String @db.VarChar(50)\n content String @db.NVarChar(Max)\n thoughts String? @db.NVarChar(Max)\n model String? @db.VarChar(100)\n tokens String? @db.NVarChar(Max)\n toolCalls String? @map(\"tool_calls\") @db.NVarChar(Max)\n agentId String? @map(\"agent_id\") @db.VarChar(100)\n metadata String? @db.NVarChar(Max)\n createdAt DateTime @default(now()) @map(\"created_at\") @db.DateTime2\n updatedAt DateTime @default(now()) @updatedAt @map(\"updated_at\") @db.DateTime2\n deletedAt DateTime? @map(\"deleted_at\") @db.DateTime2\n\n // Relations\n history AiChatHistory? @relation(fields: [historyId], references: [id], onDelete: NoAction)\n\n @@index([sessionId], map: \"idx_session\")\n @@index([historyId], map: \"idx_history\")\n @@index([createdAt], map: \"idx_created\")\n @@index([deletedAt], map: \"idx_deleted\")\n @@index([agentId], map: \"idx_agent\")\n @@index([sessionId, agentId, type, createdAt], map: \"idx_session_agent_type_created\")\n @@map(\"ai_messages\")\n}\n\n/// Per-session rollup, maintained incrementally by the projector.\nmodel AiChatHistory {\n id String @id @default(cuid()) @db.Char(36)\n sessionId String @map(\"session_id\") @db.VarChar(100)\n title String? @db.VarChar(255)\n summary String? @db.NVarChar(Max)\n messageCount Int @default(0) @map(\"message_count\")\n totalTokens BigInt @default(0) @map(\"total_tokens\")\n firstMessageAt DateTime? @map(\"first_message_at\") @db.DateTime2\n lastMessageAt DateTime? @map(\"last_message_at\") @db.DateTime2\n metadata String? @db.NVarChar(Max)\n deletedAt DateTime? @map(\"deleted_at\") @db.DateTime2\n\n // Relations\n messages AiMessage[]\n\n @@index([sessionId], map: \"idx_chat_history_session\")\n @@index([deletedAt], map: \"idx_chat_history_deleted\")\n @@map(\"ai_chat_histories\")\n}\n",
|
|
18
|
+
"inlineSchema": "// Prisma schema for the dsh-storage database backend — SQL Server variant (Prisma 7).\n//\n// Same models as schema.mysql.prisma, with SQL Server native types. Prisma's\n// sqlserver connector has NO Json type, so every JSON column is a plain text\n// column here — the backend serializes them on write automatically (derived\n// from `provider: sqlserver`; SQL Server's own ISJSON/JSON_VALUE still query\n// the text as JSON).\n//\n// Connection URL shape (parsed by the backend into a node-mssql config):\n// sqlserver://host:1433;database=dbname;user=sa;password=p;encrypt=true;trustServerCertificate=true\n// Client pre-generation and table creation work as described in\n// schema.mysql.prisma's header.\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../src/generated/sqlserver\"\n importFileExtension = \"js\"\n}\n\ndatasource db {\n provider = \"sqlserver\"\n}\n\n/// User and assistant messages; tool results update the assistant row's tool_calls.\nmodel AiMessage {\n id String @id @default(cuid()) @db.Char(36)\n sessionId String @map(\"session_id\") @db.VarChar(100)\n historyId String? @map(\"history_id\") @db.Char(36)\n type String @db.VarChar(50)\n content String @db.NVarChar(Max)\n thoughts String? @db.NVarChar(Max)\n model String? @db.VarChar(100)\n tokens String? @db.NVarChar(Max)\n toolCalls String? @map(\"tool_calls\") @db.NVarChar(Max)\n agentId String? @map(\"agent_id\") @db.VarChar(100)\n metadata String? @db.NVarChar(Max)\n createdAt DateTime @default(now()) @map(\"created_at\") @db.DateTime2\n updatedAt DateTime @default(now()) @updatedAt @map(\"updated_at\") @db.DateTime2\n deletedAt DateTime? @map(\"deleted_at\") @db.DateTime2\n\n // Relations\n history AiChatHistory? @relation(fields: [historyId], references: [id], onDelete: NoAction)\n\n @@index([sessionId], map: \"idx_session\")\n @@index([historyId], map: \"idx_history\")\n @@index([createdAt], map: \"idx_created\")\n @@index([deletedAt], map: \"idx_deleted\")\n @@index([agentId], map: \"idx_agent\")\n @@index([sessionId, agentId, type, createdAt], map: \"idx_session_agent_type_created\")\n @@index([sessionId, historyId, deletedAt, type, createdAt, id], map: \"idx_tool_call_lookup\")\n @@index([sessionId, deletedAt, updatedAt, id], map: \"idx_archive_recovery\")\n @@map(\"ai_messages\")\n}\n\n/// Per-session rollup, maintained incrementally by the projector.\nmodel AiChatHistory {\n id String @id @default(cuid()) @db.Char(36)\n sessionId String @map(\"session_id\") @db.VarChar(100)\n title String? @db.VarChar(255)\n summary String? @db.NVarChar(Max)\n messageCount Int @default(0) @map(\"message_count\")\n totalTokens BigInt @default(0) @map(\"total_tokens\")\n firstMessageAt DateTime? @map(\"first_message_at\") @db.DateTime2\n lastMessageAt DateTime? @map(\"last_message_at\") @db.DateTime2\n metadata String? @db.NVarChar(Max)\n deletedAt DateTime? @map(\"deleted_at\") @db.DateTime2\n\n // Relations\n messages AiMessage[]\n\n @@index([sessionId], map: \"idx_chat_history_session\")\n @@index([deletedAt], map: \"idx_chat_history_deleted\")\n @@map(\"ai_chat_histories\")\n}\n",
|
|
19
19
|
"runtimeDataModel": {
|
|
20
20
|
"models": {},
|
|
21
21
|
"enums": {},
|
package/lib/index.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import Schema from '@deepseek-ai/schemastery';
|
|
21
21
|
import { DatabaseBackend } from './backends/database.js';
|
|
22
22
|
import { compressionSnapshot } from './compaction.js';
|
|
23
|
-
import { mergeToolResult, projectEvent, usageSampleOf } from './projector.js';
|
|
23
|
+
import { mergeToolResult, projectEvent, toolResultCallId, usageSampleOf } from './projector.js';
|
|
24
24
|
export const name = 'dsh-storage';
|
|
25
25
|
// Event-tap only: no hard service dependency. Add 'sessionPersistence' to
|
|
26
26
|
// `inject` if you want fail-fast when persistence is not composed.
|
|
@@ -35,6 +35,8 @@ export const Config = Schema.object({
|
|
|
35
35
|
});
|
|
36
36
|
/** Metadata key for our usage bookkeeping — namespaced so other writers' fields never collide with it. */
|
|
37
37
|
const LAST_USAGE_KEY = 'dsh-storage:lastUsage';
|
|
38
|
+
const TOKEN_BASELINE_KEY = 'dsh-storage:token-baseline';
|
|
39
|
+
const ARCHIVE_IN_PROGRESS = 'dsh-storage:archive-in-progress';
|
|
38
40
|
/** Shape-guarded restore of our usage sample from arbitrary metadata. */
|
|
39
41
|
function usageSampleOfMetadata(value) {
|
|
40
42
|
const v = value;
|
|
@@ -63,24 +65,85 @@ export function apply(ctx, config) {
|
|
|
63
65
|
}),
|
|
64
66
|
];
|
|
65
67
|
const sessions = new Map();
|
|
68
|
+
const toolCallMessages = new Map();
|
|
69
|
+
const TOOL_CALL_CACHE_LIMIT = 1000;
|
|
70
|
+
function rememberToolCalls(sessionId, row) {
|
|
71
|
+
if (row?.type !== 'model' || !Array.isArray(row.toolCalls))
|
|
72
|
+
return;
|
|
73
|
+
for (const call of row.toolCalls) {
|
|
74
|
+
if (typeof call?.id !== 'string')
|
|
75
|
+
continue;
|
|
76
|
+
const calls = toolCallMessages.get(sessionId) ?? new Map();
|
|
77
|
+
// Bound each session's cache; Map iteration evicts the oldest insertion first.
|
|
78
|
+
if (!calls.has(call.id) && calls.size >= TOOL_CALL_CACHE_LIMIT) {
|
|
79
|
+
const oldest = calls.keys().next().value;
|
|
80
|
+
if (oldest !== undefined)
|
|
81
|
+
calls.delete(oldest);
|
|
82
|
+
}
|
|
83
|
+
calls.set(call.id, row.id);
|
|
84
|
+
toolCallMessages.set(sessionId, calls);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async function readToolResultRows(session, sessionId, event) {
|
|
88
|
+
const callId = toolResultCallId(event);
|
|
89
|
+
if (!callId)
|
|
90
|
+
return [];
|
|
91
|
+
let messageId = toolCallMessages.get(sessionId)?.get(callId);
|
|
92
|
+
if (!messageId && typeof session?.deriveMessages === 'function') {
|
|
93
|
+
const messages = session.deriveMessages();
|
|
94
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
95
|
+
const message = messages[index];
|
|
96
|
+
if (message.role === 'assistant' &&
|
|
97
|
+
typeof message.id === 'string' &&
|
|
98
|
+
Array.isArray(message.content) &&
|
|
99
|
+
message.content.some((block) => block?.type === 'tool-call' && block.id === callId)) {
|
|
100
|
+
messageId = message.id;
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (messageId) {
|
|
106
|
+
const rows = await backends[0].readMessages(sessionId, messageId);
|
|
107
|
+
if (rows.some((row) => Array.isArray(row.toolCalls) && row.toolCalls.some((call) => call?.id === callId)))
|
|
108
|
+
return rows;
|
|
109
|
+
}
|
|
110
|
+
// Cold resume and eviction fall back to bounded database candidate pages.
|
|
111
|
+
const rows = await backends[0].readToolCallMessages(sessionId, callId);
|
|
112
|
+
for (const row of rows)
|
|
113
|
+
rememberToolCalls(sessionId, row);
|
|
114
|
+
return rows;
|
|
115
|
+
}
|
|
66
116
|
// Row writes are fire-and-forget during the run, but tracked so the
|
|
67
117
|
// session/flush checkpoint and shutdown can drain them — a one-shot
|
|
68
118
|
// (headless) run would otherwise lose the tail events to process exit.
|
|
69
119
|
const pending = new Set();
|
|
70
120
|
function track(task) {
|
|
71
121
|
pending.add(task);
|
|
72
|
-
void task.
|
|
122
|
+
void task.then(() => pending.delete(task), () => pending.delete(task));
|
|
73
123
|
return task;
|
|
74
124
|
}
|
|
75
125
|
function guard(call) {
|
|
76
126
|
// Mirroring must never break the agent loop: log and swallow.
|
|
77
127
|
return Promise.all(backends.map((backend) => call(backend).catch((error) => console.error(`[dsh-storage] ${backend.name} error:`, error)))).then(() => { });
|
|
78
128
|
}
|
|
79
|
-
//
|
|
80
|
-
//
|
|
129
|
+
// All sessions share one in-flight init. A failed attempt is cleared so the
|
|
130
|
+
// next event can retry once; no read or write passes a failed init barrier.
|
|
81
131
|
let started;
|
|
132
|
+
function ensureStarted() {
|
|
133
|
+
if (!started) {
|
|
134
|
+
const attempt = track(Promise.all(backends.map((backend) => backend.init?.())).then(() => { }));
|
|
135
|
+
started = attempt;
|
|
136
|
+
void attempt.catch(() => {
|
|
137
|
+
if (started === attempt)
|
|
138
|
+
started = undefined;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return started;
|
|
142
|
+
}
|
|
82
143
|
function fanout(call) {
|
|
83
|
-
|
|
144
|
+
// Event failures reject into the per-session queue, which is the no-throw
|
|
145
|
+
// boundary. Swallowing here would make a partial write look committed.
|
|
146
|
+
return track(ensureStarted().then(() => Promise.all(backends.map(call)).then(() => { })));
|
|
84
147
|
}
|
|
85
148
|
// Per-session serialization: all event processing for one session runs
|
|
86
149
|
// through a promise chain, so rollup writes complete in event order
|
|
@@ -90,10 +153,10 @@ export function apply(ctx, config) {
|
|
|
90
153
|
// so history must come from the database, not from zero).
|
|
91
154
|
const chains = new Map();
|
|
92
155
|
function enqueue(sessionId, task) {
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
156
|
+
// Every task rechecks the init barrier: a failed earlier attempt must not
|
|
157
|
+
// become a resolved chain that lets later events zero-seed or no-op write.
|
|
158
|
+
const next = (chains.get(sessionId) ?? Promise.resolve())
|
|
159
|
+
.then(() => ensureStarted())
|
|
97
160
|
.then(task)
|
|
98
161
|
.catch((error) => console.error('[dsh-storage] mirror task error:', error));
|
|
99
162
|
chains.set(sessionId, next);
|
|
@@ -115,7 +178,9 @@ export function apply(ctx, config) {
|
|
|
115
178
|
firstMessageAt: row.firstMessageAt ?? undefined,
|
|
116
179
|
lastMessageAt: row.lastMessageAt ?? undefined,
|
|
117
180
|
// Preserve every existing metadata field; we only own our key.
|
|
118
|
-
|
|
181
|
+
// The baseline distinguishes cumulative active totals from a
|
|
182
|
+
// restart's fresh-segment deltas during archive recovery.
|
|
183
|
+
metadata: { [TOKEN_BASELINE_KEY]: row.totalTokens, ...(row.metadata ?? {}) },
|
|
119
184
|
usageSample: usageSampleOfMetadata(row.metadata?.[LAST_USAGE_KEY]),
|
|
120
185
|
};
|
|
121
186
|
}
|
|
@@ -127,13 +192,14 @@ export function apply(ctx, config) {
|
|
|
127
192
|
}
|
|
128
193
|
if (readFailure)
|
|
129
194
|
throw readFailure;
|
|
130
|
-
return { messageCount: 0, totalTokens: 0, metadata: {} };
|
|
195
|
+
return { messageCount: 0, totalTokens: 0, metadata: { [TOKEN_BASELINE_KEY]: 0 } };
|
|
131
196
|
}
|
|
132
197
|
ctx.effect(() => {
|
|
133
|
-
|
|
198
|
+
void ensureStarted().catch((error) => console.error('[dsh-storage] database error:', error));
|
|
134
199
|
return async () => {
|
|
135
200
|
sessions.clear();
|
|
136
|
-
|
|
201
|
+
toolCallMessages.clear();
|
|
202
|
+
await Promise.allSettled([...pending]);
|
|
137
203
|
await guard(async (backend) => backend.close?.());
|
|
138
204
|
};
|
|
139
205
|
});
|
|
@@ -141,9 +207,20 @@ export function apply(ctx, config) {
|
|
|
141
207
|
archiveSession(sessionId) {
|
|
142
208
|
// Join the same per-session chain as event writes, then discard the old
|
|
143
209
|
// in-memory rollup only after the database transaction commits.
|
|
144
|
-
const archived = (chains.get(sessionId) ??
|
|
210
|
+
const archived = (chains.get(sessionId) ?? Promise.resolve())
|
|
211
|
+
.then(() => ensureStarted())
|
|
212
|
+
.then(async () => {
|
|
213
|
+
const accum = sessions.get(sessionId);
|
|
214
|
+
if (accum && !accum.metadata[ARCHIVE_IN_PROGRESS]) {
|
|
215
|
+
// Freeze inside the session queue: all earlier events have now
|
|
216
|
+
// committed, and this first baseline survives every retry until
|
|
217
|
+
// the archive actually succeeds.
|
|
218
|
+
accum.metadata[TOKEN_BASELINE_KEY] = accum.totalTokens;
|
|
219
|
+
accum.metadata[ARCHIVE_IN_PROGRESS] = true;
|
|
220
|
+
}
|
|
145
221
|
const id = await backends[0].archiveSession(sessionId);
|
|
146
222
|
sessions.delete(sessionId);
|
|
223
|
+
toolCallMessages.delete(sessionId);
|
|
147
224
|
return id;
|
|
148
225
|
});
|
|
149
226
|
const settled = archived.then(() => { }, () => { });
|
|
@@ -169,18 +246,20 @@ export function apply(ctx, config) {
|
|
|
169
246
|
await fanout((backend) => backend.upsertMessage(snapshot));
|
|
170
247
|
return;
|
|
171
248
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
249
|
+
const stored = sessions.get(sessionId) ?? (await seedAccum(sessionId));
|
|
250
|
+
sessions.set(sessionId, stored);
|
|
251
|
+
// Stage every change on a copy. The live accumulator advances only
|
|
252
|
+
// after the message + rollup transaction commits.
|
|
253
|
+
const accum = { ...stored, metadata: { ...stored.metadata } };
|
|
177
254
|
let row = projectEvent(session, event, sessionId);
|
|
178
255
|
if (row && session.header?.createdAt !== undefined) {
|
|
179
256
|
row.metadata = { ...row.metadata, sessionCreatedAt: session.header.createdAt };
|
|
180
257
|
}
|
|
181
|
-
const storedRows =
|
|
182
|
-
? await
|
|
183
|
-
:
|
|
258
|
+
const storedRows = event?.type === 'tool/result'
|
|
259
|
+
? await readToolResultRows(session, sessionId, event)
|
|
260
|
+
: row
|
|
261
|
+
? await backends[0].readMessages(sessionId, row.id)
|
|
262
|
+
: [];
|
|
184
263
|
if (event?.type === 'tool/result')
|
|
185
264
|
row = mergeToolResult(event, storedRows);
|
|
186
265
|
const existing = row && storedRows.find((stored) => stored.id === row?.id);
|
|
@@ -201,28 +280,21 @@ export function apply(ctx, config) {
|
|
|
201
280
|
if (!row && event?.type === 'tool/result') {
|
|
202
281
|
console.error('[dsh-storage] no matching model tool call for result');
|
|
203
282
|
}
|
|
204
|
-
if (row) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
accum.lastMessageAt = row.createdAt;
|
|
209
|
-
}
|
|
210
|
-
await fanout((backend) => backend.upsertMessage(row));
|
|
283
|
+
if (row && !existing && event?.type !== 'tool/result') {
|
|
284
|
+
accum.messageCount += 1;
|
|
285
|
+
accum.firstMessageAt ??= row.createdAt;
|
|
286
|
+
accum.lastMessageAt = row.createdAt;
|
|
211
287
|
}
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
// chunk↔message pair and progressive samples never double-count, and a
|
|
215
|
-
// failed step (usage chunk, no message) still counts. Samples of one
|
|
216
|
-
// step arrive adjacently, so a single latest sample is all we track.
|
|
288
|
+
// Every settled attempt/message is one model call; retries on the same
|
|
289
|
+
// step are additive rather than replacement snapshots.
|
|
217
290
|
const sampled = usageSampleOf(event);
|
|
218
291
|
let usageDelta = 0;
|
|
219
292
|
if (sampled) {
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
if (!prev || prev.key !== sampled.key || usageDelta !== 0) {
|
|
293
|
+
const sequence = Number(sampled.key);
|
|
294
|
+
const previous = Number(accum.usageSample?.key);
|
|
295
|
+
const duplicate = Number.isSafeInteger(sequence) && Number.isSafeInteger(previous) && sequence <= previous;
|
|
296
|
+
if (!duplicate) {
|
|
297
|
+
usageDelta = sampled.sample.input + sampled.sample.output;
|
|
226
298
|
accum.usageSample = { key: sampled.key, ...sampled.sample };
|
|
227
299
|
accum.totalTokens += usageDelta;
|
|
228
300
|
}
|
|
@@ -251,8 +323,10 @@ export function apply(ctx, config) {
|
|
|
251
323
|
lastMessageAt: accum.lastMessageAt ?? null,
|
|
252
324
|
...(Object.keys(metadata).length > 0 ? { metadata } : {}),
|
|
253
325
|
};
|
|
254
|
-
await fanout((backend) => backend.
|
|
326
|
+
await fanout((backend) => backend.upsertEvent(row, sessionRow));
|
|
255
327
|
}
|
|
328
|
+
sessions.set(sessionId, accum);
|
|
329
|
+
rememberToolCalls(sessionId, row);
|
|
256
330
|
});
|
|
257
331
|
});
|
|
258
332
|
ctx.on('session/disposed', (session) => {
|
|
@@ -263,6 +337,7 @@ export function apply(ctx, config) {
|
|
|
263
337
|
// the dispose was meant to drop).
|
|
264
338
|
enqueue(sessionId, async () => {
|
|
265
339
|
sessions.delete(sessionId);
|
|
340
|
+
toolCallMessages.delete(sessionId);
|
|
266
341
|
});
|
|
267
342
|
// Delete the chain entry only once it has settled AND is still the same
|
|
268
343
|
// entry — deleting earlier would let a recreated session race the
|
|
@@ -274,8 +349,19 @@ export function apply(ctx, config) {
|
|
|
274
349
|
chains.delete(sessionId);
|
|
275
350
|
});
|
|
276
351
|
});
|
|
277
|
-
// Durability checkpoint:
|
|
278
|
-
|
|
279
|
-
|
|
352
|
+
// Durability checkpoint: wait only for this session. Shutdown still drains
|
|
353
|
+
// the global pending set; an unrelated slow session must not block this one.
|
|
354
|
+
ctx.on('session/flush', async (session) => {
|
|
355
|
+
const chain = chains.get(session?.id ?? 'unknown');
|
|
356
|
+
if (chain) {
|
|
357
|
+
await chain;
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
try {
|
|
361
|
+
await ensureStarted();
|
|
362
|
+
}
|
|
363
|
+
catch (error) {
|
|
364
|
+
console.error('[dsh-storage] database error:', error);
|
|
365
|
+
}
|
|
280
366
|
});
|
|
281
367
|
}
|
package/lib/projector.js
CHANGED
|
@@ -106,12 +106,32 @@ export function projectEvent(_session, event, sessionId) {
|
|
|
106
106
|
return null;
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
|
+
/** Identify the assistant tool call answered by either supported result shape. */
|
|
110
|
+
export function toolResultCallId(event) {
|
|
111
|
+
const message = event?.data?.message;
|
|
112
|
+
const first = message?.content?.[0];
|
|
113
|
+
const callId = message?.toolCallId ??
|
|
114
|
+
message?.source?.callId ??
|
|
115
|
+
(first?.type === 'tool-result' ? first.toolCallId : undefined);
|
|
116
|
+
return typeof callId === 'string' ? callId : undefined;
|
|
117
|
+
}
|
|
109
118
|
/** Update the persisted assistant call identified by a tool result. */
|
|
110
119
|
export function mergeToolResult(event, rows) {
|
|
111
|
-
const
|
|
112
|
-
const
|
|
120
|
+
const message = event?.data?.message;
|
|
121
|
+
const content = Array.isArray(message?.content) ? message.content : [];
|
|
122
|
+
const legacy = content.length === 1 && content[0]?.type === 'tool-result' ? content[0] : undefined;
|
|
123
|
+
const callId = toolResultCallId(event);
|
|
113
124
|
if (!callId)
|
|
114
125
|
return null;
|
|
126
|
+
const result = legacy ??
|
|
127
|
+
(message
|
|
128
|
+
? {
|
|
129
|
+
type: 'tool-result',
|
|
130
|
+
toolCallId: callId,
|
|
131
|
+
content,
|
|
132
|
+
...(typeof message.isError === 'boolean' ? { isError: message.isError } : {}),
|
|
133
|
+
}
|
|
134
|
+
: null);
|
|
115
135
|
const model = [...rows]
|
|
116
136
|
.reverse()
|
|
117
137
|
.find((row) => row.type === 'model' &&
|
|
@@ -133,21 +153,19 @@ export function mergeToolResult(event, rows) {
|
|
|
133
153
|
};
|
|
134
154
|
}
|
|
135
155
|
/**
|
|
136
|
-
* Extract
|
|
137
|
-
* when the event carries none.
|
|
156
|
+
* Extract one model call's usage, keyed by its durable event sequence.
|
|
138
157
|
*
|
|
139
158
|
* V3 session log (dsh-session@0.1.6-alpha.2):
|
|
140
|
-
* - `assistant/message.data.usage` — the settled
|
|
141
|
-
* - `assistant/attempt` — a failed/retried/cancelled
|
|
159
|
+
* - `assistant/message.data.usage` — the settled call's accounting;
|
|
160
|
+
* - `assistant/attempt` — a failed/retried/cancelled call commits no surface
|
|
142
161
|
* message; the adapter's usage chunk survives as a raw `chunk` record in the
|
|
143
162
|
* embedded compact `stream` (deltas are packed, but usage is a raw type).
|
|
144
163
|
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
* dsh session logs — they are not subsets of inputTokens).
|
|
164
|
+
* Calls on one `(turn, step)` are additive: a retry is another billed model
|
|
165
|
+
* call, not a progressive snapshot. Only redelivery of the same event sequence
|
|
166
|
+
* is a duplicate. Totals include the cache buckets: cacheRead/cacheWrite are
|
|
167
|
+
* billed in their own buckets (observed cacheReadTokens ≫ inputTokens in dsh
|
|
168
|
+
* session logs — they are not subsets of inputTokens).
|
|
151
169
|
*/
|
|
152
170
|
export function usageSampleOf(event) {
|
|
153
171
|
const total = (u) => ({
|
|
@@ -167,7 +185,7 @@ export function usageSampleOf(event) {
|
|
|
167
185
|
else {
|
|
168
186
|
return null;
|
|
169
187
|
}
|
|
170
|
-
if (!usage ||
|
|
188
|
+
if (!usage || !Number.isSafeInteger(event?.seq))
|
|
171
189
|
return null;
|
|
172
|
-
return { key:
|
|
190
|
+
return { key: String(event.seq), sample: total(usage) };
|
|
173
191
|
}
|
|
@@ -24,16 +24,22 @@ export declare class DatabaseBackend implements StorageBackend {
|
|
|
24
24
|
readonly name = "database";
|
|
25
25
|
private prisma;
|
|
26
26
|
constructor(config: DatabaseBackendConfig);
|
|
27
|
+
private client;
|
|
27
28
|
/** Load the pre-generated client for the configured provider (seam for tests). */
|
|
28
29
|
protected loadClient(): Promise<any>;
|
|
29
30
|
init(): Promise<void>;
|
|
30
31
|
/** JSON column value, text-serialized on SQL Server (no Prisma Json type). */
|
|
31
32
|
private jsonField;
|
|
32
33
|
private toDbData;
|
|
34
|
+
private writeMessage;
|
|
33
35
|
upsertMessage(row: MessageRow): Promise<void>;
|
|
34
|
-
readMessages(sessionId: string, messageId
|
|
36
|
+
readMessages(sessionId: string, messageId: string): Promise<MessageRow[]>;
|
|
37
|
+
readToolCallMessages(sessionId: string, callId: string): Promise<MessageRow[]>;
|
|
38
|
+
private readRows;
|
|
35
39
|
readSession(sessionId: string): Promise<SessionRow | null>;
|
|
40
|
+
private writeSession;
|
|
36
41
|
upsertSession(row: SessionRow): Promise<void>;
|
|
42
|
+
upsertEvent(message: MessageRow | null, session: SessionRow): Promise<void>;
|
|
37
43
|
archiveSession(sessionId: string): Promise<string | null>;
|
|
38
44
|
close(): Promise<void>;
|
|
39
45
|
}
|
package/lib/types/index.d.ts
CHANGED
|
@@ -22,29 +22,29 @@ import Schema from '@deepseek-ai/schemastery';
|
|
|
22
22
|
import { type DatabaseProvider } from './backends/database.js';
|
|
23
23
|
export declare const name = "dsh-storage";
|
|
24
24
|
export declare const inject: string[];
|
|
25
|
-
export declare const Config: Schema<Schemastery.ObjectS<{
|
|
26
|
-
enabled: Schema<boolean, boolean>;
|
|
27
|
-
database: Schema<Schemastery.ObjectS<{
|
|
28
|
-
enabled: Schema<boolean, boolean>;
|
|
29
|
-
provider: Schema<"mysql" | "postgresql" | "sqlite" | "sqlserver", "mysql" | "postgresql" | "sqlite" | "sqlserver">;
|
|
30
|
-
url: Schema<string, string>;
|
|
31
|
-
}
|
|
32
|
-
enabled: Schema<boolean, boolean>;
|
|
33
|
-
provider: Schema<"mysql" | "postgresql" | "sqlite" | "sqlserver", "mysql" | "postgresql" | "sqlite" | "sqlserver">;
|
|
34
|
-
url: Schema<string, string>;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
enabled: Schema<boolean, boolean>;
|
|
38
|
-
database: Schema<Schemastery.ObjectS<{
|
|
39
|
-
enabled: Schema<boolean, boolean>;
|
|
40
|
-
provider: Schema<"mysql" | "postgresql" | "sqlite" | "sqlserver", "mysql" | "postgresql" | "sqlite" | "sqlserver">;
|
|
41
|
-
url: Schema<string, string>;
|
|
42
|
-
}
|
|
43
|
-
enabled: Schema<boolean, boolean>;
|
|
44
|
-
provider: Schema<"mysql" | "postgresql" | "sqlite" | "sqlserver", "mysql" | "postgresql" | "sqlite" | "sqlserver">;
|
|
45
|
-
url: Schema<string, string>;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
25
|
+
export declare const Config: Schema<Schemastery.ObjectS<NoInfer<{
|
|
26
|
+
enabled: Schema<boolean, boolean, "defined">;
|
|
27
|
+
database: Schema<Schemastery.ObjectS<NoInfer<{
|
|
28
|
+
enabled: Schema<boolean, boolean, "defined">;
|
|
29
|
+
provider: Schema<"mysql" | "postgresql" | "sqlite" | "sqlserver", "mysql" | "postgresql" | "sqlite" | "sqlserver", "defined">;
|
|
30
|
+
url: Schema<string, string, "defined">;
|
|
31
|
+
}>>, Schemastery.ObjectT<NoInfer<{
|
|
32
|
+
enabled: Schema<boolean, boolean, "defined">;
|
|
33
|
+
provider: Schema<"mysql" | "postgresql" | "sqlite" | "sqlserver", "mysql" | "postgresql" | "sqlite" | "sqlserver", "defined">;
|
|
34
|
+
url: Schema<string, string, "defined">;
|
|
35
|
+
}>>, "plain">;
|
|
36
|
+
}>>, Schemastery.ObjectT<NoInfer<{
|
|
37
|
+
enabled: Schema<boolean, boolean, "defined">;
|
|
38
|
+
database: Schema<Schemastery.ObjectS<NoInfer<{
|
|
39
|
+
enabled: Schema<boolean, boolean, "defined">;
|
|
40
|
+
provider: Schema<"mysql" | "postgresql" | "sqlite" | "sqlserver", "mysql" | "postgresql" | "sqlite" | "sqlserver", "defined">;
|
|
41
|
+
url: Schema<string, string, "defined">;
|
|
42
|
+
}>>, Schemastery.ObjectT<NoInfer<{
|
|
43
|
+
enabled: Schema<boolean, boolean, "defined">;
|
|
44
|
+
provider: Schema<"mysql" | "postgresql" | "sqlite" | "sqlserver", "mysql" | "postgresql" | "sqlite" | "sqlserver", "defined">;
|
|
45
|
+
url: Schema<string, string, "defined">;
|
|
46
|
+
}>>, "plain">;
|
|
47
|
+
}>>, "plain">;
|
|
48
48
|
export interface StoragePluginConfig {
|
|
49
49
|
enabled: boolean;
|
|
50
50
|
database: {
|
package/lib/types/projector.d.ts
CHANGED
|
@@ -22,6 +22,8 @@ import type { MessageRow } from './types.js';
|
|
|
22
22
|
* turn/step lifecycle, approvals, ...).
|
|
23
23
|
*/
|
|
24
24
|
export declare function projectEvent(_session: any, event: any, sessionId: string): MessageRow | null;
|
|
25
|
+
/** Identify the assistant tool call answered by either supported result shape. */
|
|
26
|
+
export declare function toolResultCallId(event: any): string | undefined;
|
|
25
27
|
/** Update the persisted assistant call identified by a tool result. */
|
|
26
28
|
export declare function mergeToolResult(event: any, rows: MessageRow[]): MessageRow | null;
|
|
27
29
|
export interface UsageSample {
|
|
@@ -29,21 +31,19 @@ export interface UsageSample {
|
|
|
29
31
|
output: number;
|
|
30
32
|
}
|
|
31
33
|
/**
|
|
32
|
-
* Extract
|
|
33
|
-
* when the event carries none.
|
|
34
|
+
* Extract one model call's usage, keyed by its durable event sequence.
|
|
34
35
|
*
|
|
35
36
|
* V3 session log (dsh-session@0.1.6-alpha.2):
|
|
36
|
-
* - `assistant/message.data.usage` — the settled
|
|
37
|
-
* - `assistant/attempt` — a failed/retried/cancelled
|
|
37
|
+
* - `assistant/message.data.usage` — the settled call's accounting;
|
|
38
|
+
* - `assistant/attempt` — a failed/retried/cancelled call commits no surface
|
|
38
39
|
* message; the adapter's usage chunk survives as a raw `chunk` record in the
|
|
39
40
|
* embedded compact `stream` (deltas are packed, but usage is a raw type).
|
|
40
41
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* dsh session logs — they are not subsets of inputTokens).
|
|
42
|
+
* Calls on one `(turn, step)` are additive: a retry is another billed model
|
|
43
|
+
* call, not a progressive snapshot. Only redelivery of the same event sequence
|
|
44
|
+
* is a duplicate. Totals include the cache buckets: cacheRead/cacheWrite are
|
|
45
|
+
* billed in their own buckets (observed cacheReadTokens ≫ inputTokens in dsh
|
|
46
|
+
* session logs — they are not subsets of inputTokens).
|
|
47
47
|
*/
|
|
48
48
|
export declare function usageSampleOf(event: any): {
|
|
49
49
|
key: string;
|
package/lib/types/types.d.ts
CHANGED
|
@@ -33,20 +33,23 @@ export interface SessionRow {
|
|
|
33
33
|
metadata?: Record<string, unknown>;
|
|
34
34
|
}
|
|
35
35
|
/**
|
|
36
|
-
* A storage backend receives already-projected rows.
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* loop.
|
|
36
|
+
* A storage backend receives already-projected rows. Backend errors reject
|
|
37
|
+
* into the plugin's per-session queue, whose catch/log boundary keeps
|
|
38
|
+
* mirroring failures out of the agent loop.
|
|
40
39
|
*/
|
|
41
40
|
export interface StorageBackend {
|
|
42
41
|
readonly name: string;
|
|
43
42
|
init?(): Promise<void>;
|
|
44
43
|
/** Read the stored session row, for seeding the live rollup on resume. */
|
|
45
44
|
readSession?(sessionId: string): Promise<SessionRow | null>;
|
|
46
|
-
/** Read persisted
|
|
47
|
-
readMessages(sessionId: string, messageId
|
|
45
|
+
/** Read one persisted logical message, including archived rows for redelivery. */
|
|
46
|
+
readMessages(sessionId: string, messageId: string): Promise<MessageRow[]>;
|
|
47
|
+
/** Find the newest active model message containing this exact tool call. */
|
|
48
|
+
readToolCallMessages(sessionId: string, callId: string): Promise<MessageRow[]>;
|
|
48
49
|
upsertMessage(row: MessageRow): Promise<void>;
|
|
49
50
|
upsertSession(row: SessionRow): Promise<void>;
|
|
51
|
+
/** Atomically persist one event's optional message and its session rollup. */
|
|
52
|
+
upsertEvent(message: MessageRow | null, session: SessionRow): Promise<void>;
|
|
50
53
|
/** Move active messages into a queryable history row; null when already empty. */
|
|
51
54
|
archiveSession(sessionId: string): Promise<string | null>;
|
|
52
55
|
close?(): Promise<void>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amaster.ai/dsh-storage",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.35",
|
|
4
4
|
"description": "Session storage mirror for DeepSeek Harness: projects the session event stream into MySQL/PostgreSQL/SQLite/SQL Server (ai_messages / ai_chat_histories) without replacing dsh's local persistence.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
}
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@deepseek-ai/schemastery": "^3.18.
|
|
36
|
+
"@deepseek-ai/schemastery": "^3.18.5"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
39
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@deepseek-ai/cordis": "4.0.5",
|
|
66
|
-
"@deepseek-ai/dsh-session": "0.2.1-rc.
|
|
66
|
+
"@deepseek-ai/dsh-session": "0.2.1-rc.5",
|
|
67
67
|
"@prisma/adapter-libsql": "^7.9.1",
|
|
68
68
|
"@prisma/adapter-mariadb": "^7.9.1",
|
|
69
69
|
"@prisma/adapter-mssql": "^7.9.1",
|
|
@@ -49,6 +49,8 @@ model AiMessage {
|
|
|
49
49
|
@@index([deletedAt], map: "idx_deleted")
|
|
50
50
|
@@index([agentId], map: "idx_agent")
|
|
51
51
|
@@index([sessionId, agentId, type, createdAt], map: "idx_session_agent_type_created")
|
|
52
|
+
@@index([sessionId, historyId, deletedAt, type, createdAt, id], map: "idx_tool_call_lookup")
|
|
53
|
+
@@index([sessionId, deletedAt, updatedAt, id], map: "idx_archive_recovery")
|
|
52
54
|
@@map("ai_messages")
|
|
53
55
|
}
|
|
54
56
|
|
|
@@ -41,6 +41,8 @@ model AiMessage {
|
|
|
41
41
|
@@index([deletedAt], map: "idx_deleted")
|
|
42
42
|
@@index([agentId], map: "idx_agent")
|
|
43
43
|
@@index([sessionId, agentId, type, createdAt], map: "idx_session_agent_type_created")
|
|
44
|
+
@@index([sessionId, historyId, deletedAt, type, createdAt, id], map: "idx_tool_call_lookup")
|
|
45
|
+
@@index([sessionId, deletedAt, updatedAt, id], map: "idx_archive_recovery")
|
|
44
46
|
@@map("ai_messages")
|
|
45
47
|
}
|
|
46
48
|
|
|
@@ -41,6 +41,8 @@ model AiMessage {
|
|
|
41
41
|
@@index([deletedAt], map: "idx_deleted")
|
|
42
42
|
@@index([agentId], map: "idx_agent")
|
|
43
43
|
@@index([sessionId, agentId, type, createdAt], map: "idx_session_agent_type_created")
|
|
44
|
+
@@index([sessionId, historyId, deletedAt, type, createdAt, id], map: "idx_tool_call_lookup")
|
|
45
|
+
@@index([sessionId, deletedAt, updatedAt, id], map: "idx_archive_recovery")
|
|
44
46
|
@@map("ai_messages")
|
|
45
47
|
}
|
|
46
48
|
|
|
@@ -47,6 +47,8 @@ model AiMessage {
|
|
|
47
47
|
@@index([deletedAt], map: "idx_deleted")
|
|
48
48
|
@@index([agentId], map: "idx_agent")
|
|
49
49
|
@@index([sessionId, agentId, type, createdAt], map: "idx_session_agent_type_created")
|
|
50
|
+
@@index([sessionId, historyId, deletedAt, type, createdAt, id], map: "idx_tool_call_lookup")
|
|
51
|
+
@@index([sessionId, deletedAt, updatedAt, id], map: "idx_archive_recovery")
|
|
50
52
|
@@map("ai_messages")
|
|
51
53
|
}
|
|
52
54
|
|