@amaster.ai/dsh-storage 0.1.34 → 0.1.36
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/lib/backends/database.js +220 -54
- 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 +74 -42
- package/lib/projector.js +28 -14
- package/lib/types/backends/database.d.ts +4 -0
- package/lib/types/index.d.ts +23 -23
- package/lib/types/projector.d.ts +9 -10
- package/lib/types/types.d.ts +6 -5
- 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/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,18 +150,19 @@ 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
167
|
return this.readRows({
|
|
147
168
|
sessionId,
|
|
@@ -150,50 +171,68 @@ export class DatabaseBackend {
|
|
|
150
171
|
});
|
|
151
172
|
}
|
|
152
173
|
async readToolCallMessages(sessionId, callId) {
|
|
153
|
-
|
|
154
|
-
return [];
|
|
174
|
+
const prisma = this.client();
|
|
155
175
|
const provider = this.config.provider;
|
|
156
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.
|
|
157
179
|
const sortTime = provider === 'mysql'
|
|
158
180
|
? "DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s.%f')"
|
|
159
181
|
: provider === 'postgresql'
|
|
160
182
|
? "to_char(created_at, 'YYYY-MM-DD HH24:MI:SS.US')"
|
|
161
183
|
: sqlserver
|
|
162
|
-
?
|
|
184
|
+
? "FORMAT(created_at, 'yyyy-MM-ddTHH:mm:ss.fffffff')"
|
|
163
185
|
: 'CAST(created_at AS TEXT)';
|
|
164
186
|
let cursor;
|
|
187
|
+
let nullPhase = false;
|
|
165
188
|
for (;;) {
|
|
166
189
|
const params = provider === 'mysql' || provider === 'sqlite' ? [sessionId, sessionId] : [sessionId];
|
|
167
190
|
let before = '';
|
|
168
|
-
if (
|
|
169
|
-
|
|
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) {
|
|
170
196
|
before =
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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 < ?';
|
|
174
212
|
params.push(cursor.id);
|
|
175
213
|
}
|
|
176
214
|
else {
|
|
177
|
-
before =
|
|
215
|
+
before = 'AND (created_at < ? OR (created_at = ? AND id < ?) OR created_at IS NULL)';
|
|
178
216
|
params.push(cursor.time, cursor.time, cursor.id);
|
|
179
217
|
}
|
|
180
218
|
}
|
|
181
|
-
|
|
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
|
|
182
221
|
FROM ai_messages WHERE session_id = ?
|
|
183
222
|
${provider === 'sqlite' ? 'AND session_id COLLATE BINARY = ?' : ''}
|
|
184
223
|
${provider === 'mysql' ? 'AND BINARY session_id = BINARY ?' : ''}
|
|
185
224
|
AND deleted_at IS NULL AND history_id IS NULL AND type = 'model' AND tool_calls IS NOT NULL
|
|
186
|
-
${sqlserver
|
|
187
|
-
? `AND session_id COLLATE Latin1_General_100_BIN2 = @P1
|
|
188
|
-
AND DATALENGTH(CAST(session_id AS nvarchar(max))) = DATALENGTH(CAST(@P1 AS nvarchar(max)))`
|
|
189
|
-
: ''}
|
|
190
|
-
${before} ORDER BY created_at DESC, id DESC ${sqlserver ? '' : 'LIMIT 100'}`;
|
|
225
|
+
${before} ORDER BY ${orderBy} ${sqlserver ? '' : 'LIMIT 100'}`;
|
|
191
226
|
if (sqlserver || provider === 'postgresql') {
|
|
192
227
|
let index = 0;
|
|
193
228
|
sql = sql.replaceAll('?', () => `${sqlserver ? '@P' : '$'}${++index}`);
|
|
194
229
|
}
|
|
195
|
-
const page = await
|
|
230
|
+
const page = await prisma.$queryRawUnsafe(sql, ...params);
|
|
196
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;
|
|
197
236
|
let calls = row.tool_calls;
|
|
198
237
|
if (typeof calls === 'string') {
|
|
199
238
|
try {
|
|
@@ -213,16 +252,20 @@ export class DatabaseBackend {
|
|
|
213
252
|
});
|
|
214
253
|
}
|
|
215
254
|
}
|
|
216
|
-
if (page.length < 100)
|
|
255
|
+
if (page.length < 100) {
|
|
256
|
+
if (provider === 'postgresql' && !nullPhase) {
|
|
257
|
+
nullPhase = true;
|
|
258
|
+
cursor = undefined;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
217
261
|
return [];
|
|
262
|
+
}
|
|
218
263
|
const last = page.at(-1);
|
|
219
264
|
cursor = { id: last.id, time: last.sort_time };
|
|
220
265
|
}
|
|
221
266
|
}
|
|
222
267
|
async readRows(where) {
|
|
223
|
-
|
|
224
|
-
return [];
|
|
225
|
-
const rows = await this.prisma.aiMessage.findMany({
|
|
268
|
+
const rows = await this.client().aiMessage.findMany({
|
|
226
269
|
where,
|
|
227
270
|
orderBy: { createdAt: 'asc' },
|
|
228
271
|
take: 1,
|
|
@@ -262,9 +305,7 @@ export class DatabaseBackend {
|
|
|
262
305
|
});
|
|
263
306
|
}
|
|
264
307
|
async readSession(sessionId) {
|
|
265
|
-
|
|
266
|
-
return null;
|
|
267
|
-
const row = await this.prisma.aiChatHistory.findFirst({
|
|
308
|
+
const row = await this.client().aiChatHistory.findFirst({
|
|
268
309
|
where: { sessionId, deletedAt: null, messages: { none: {} } },
|
|
269
310
|
});
|
|
270
311
|
if (!row)
|
|
@@ -284,14 +325,12 @@ export class DatabaseBackend {
|
|
|
284
325
|
: row.metadata,
|
|
285
326
|
};
|
|
286
327
|
}
|
|
287
|
-
async
|
|
288
|
-
if (!this.prisma)
|
|
289
|
-
return;
|
|
328
|
+
async writeSession(client, row) {
|
|
290
329
|
// Find-then-write is race-free because the plugin serializes event
|
|
291
330
|
// processing per session (see the per-session chain in index.ts). The
|
|
292
331
|
// cuid primary key matches the source project and early-scaffold rows,
|
|
293
332
|
// so existing data is continued, never duplicated.
|
|
294
|
-
const existing = await
|
|
333
|
+
const existing = await client.aiChatHistory.findFirst({
|
|
295
334
|
where: { sessionId: row.sessionId, deletedAt: null, messages: { none: {} } },
|
|
296
335
|
select: { id: true },
|
|
297
336
|
});
|
|
@@ -305,16 +344,25 @@ export class DatabaseBackend {
|
|
|
305
344
|
metadata: this.jsonField(row.metadata),
|
|
306
345
|
};
|
|
307
346
|
if (existing) {
|
|
308
|
-
await
|
|
347
|
+
await client.aiChatHistory.update({ where: { id: existing.id }, data });
|
|
309
348
|
}
|
|
310
349
|
else {
|
|
311
|
-
await
|
|
350
|
+
await client.aiChatHistory.create({ data });
|
|
312
351
|
}
|
|
313
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
|
+
}
|
|
314
363
|
async archiveSession(sessionId) {
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
return this.prisma.$transaction(async (tx) => {
|
|
364
|
+
const client = this.client();
|
|
365
|
+
const { expected, historyId } = await client.$transaction(async (tx) => {
|
|
318
366
|
const where = { sessionId, historyId: null, deletedAt: null };
|
|
319
367
|
const stats = await tx.aiMessage.aggregate({
|
|
320
368
|
where,
|
|
@@ -322,30 +370,148 @@ export class DatabaseBackend {
|
|
|
322
370
|
_min: { createdAt: true },
|
|
323
371
|
_max: { createdAt: true },
|
|
324
372
|
});
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
//
|
|
328
|
-
const
|
|
329
|
-
where: { sessionId,
|
|
330
|
-
|
|
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 },
|
|
331
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 };
|
|
332
391
|
const rollup = {
|
|
333
|
-
messageCount:
|
|
392
|
+
messageCount: expected,
|
|
334
393
|
firstMessageAt: stats._min.createdAt,
|
|
335
394
|
lastMessageAt: stats._max.createdAt,
|
|
336
395
|
};
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
:
|
|
340
|
-
|
|
341
|
-
|
|
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) },
|
|
342
511
|
});
|
|
343
|
-
const updated = await tx.aiMessage.updateMany({ where, data: { historyId: history.id } });
|
|
344
|
-
if (updated.count !== stats._count._all) {
|
|
345
|
-
throw new Error('active messages changed during archive');
|
|
346
512
|
}
|
|
347
|
-
return history.id;
|
|
348
513
|
});
|
|
514
|
+
return historyId;
|
|
349
515
|
}
|
|
350
516
|
async close() {
|
|
351
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
|
@@ -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;
|
|
@@ -105,7 +107,7 @@ export function apply(ctx, config) {
|
|
|
105
107
|
if (rows.some((row) => Array.isArray(row.toolCalls) && row.toolCalls.some((call) => call?.id === callId)))
|
|
106
108
|
return rows;
|
|
107
109
|
}
|
|
108
|
-
//
|
|
110
|
+
// Cold resume and eviction fall back to bounded database candidate pages.
|
|
109
111
|
const rows = await backends[0].readToolCallMessages(sessionId, callId);
|
|
110
112
|
for (const row of rows)
|
|
111
113
|
rememberToolCalls(sessionId, row);
|
|
@@ -117,18 +119,31 @@ export function apply(ctx, config) {
|
|
|
117
119
|
const pending = new Set();
|
|
118
120
|
function track(task) {
|
|
119
121
|
pending.add(task);
|
|
120
|
-
void task.
|
|
122
|
+
void task.then(() => pending.delete(task), () => pending.delete(task));
|
|
121
123
|
return task;
|
|
122
124
|
}
|
|
123
125
|
function guard(call) {
|
|
124
126
|
// Mirroring must never break the agent loop: log and swallow.
|
|
125
127
|
return Promise.all(backends.map((backend) => call(backend).catch((error) => console.error(`[dsh-storage] ${backend.name} error:`, error)))).then(() => { });
|
|
126
128
|
}
|
|
127
|
-
//
|
|
128
|
-
//
|
|
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.
|
|
129
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
|
+
}
|
|
130
143
|
function fanout(call) {
|
|
131
|
-
|
|
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(() => { })));
|
|
132
147
|
}
|
|
133
148
|
// Per-session serialization: all event processing for one session runs
|
|
134
149
|
// through a promise chain, so rollup writes complete in event order
|
|
@@ -138,10 +153,10 @@ export function apply(ctx, config) {
|
|
|
138
153
|
// so history must come from the database, not from zero).
|
|
139
154
|
const chains = new Map();
|
|
140
155
|
function enqueue(sessionId, task) {
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
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())
|
|
145
160
|
.then(task)
|
|
146
161
|
.catch((error) => console.error('[dsh-storage] mirror task error:', error));
|
|
147
162
|
chains.set(sessionId, next);
|
|
@@ -163,7 +178,9 @@ export function apply(ctx, config) {
|
|
|
163
178
|
firstMessageAt: row.firstMessageAt ?? undefined,
|
|
164
179
|
lastMessageAt: row.lastMessageAt ?? undefined,
|
|
165
180
|
// Preserve every existing metadata field; we only own our key.
|
|
166
|
-
|
|
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 ?? {}) },
|
|
167
184
|
usageSample: usageSampleOfMetadata(row.metadata?.[LAST_USAGE_KEY]),
|
|
168
185
|
};
|
|
169
186
|
}
|
|
@@ -175,14 +192,14 @@ export function apply(ctx, config) {
|
|
|
175
192
|
}
|
|
176
193
|
if (readFailure)
|
|
177
194
|
throw readFailure;
|
|
178
|
-
return { messageCount: 0, totalTokens: 0, metadata: {} };
|
|
195
|
+
return { messageCount: 0, totalTokens: 0, metadata: { [TOKEN_BASELINE_KEY]: 0 } };
|
|
179
196
|
}
|
|
180
197
|
ctx.effect(() => {
|
|
181
|
-
|
|
198
|
+
void ensureStarted().catch((error) => console.error('[dsh-storage] database error:', error));
|
|
182
199
|
return async () => {
|
|
183
200
|
sessions.clear();
|
|
184
201
|
toolCallMessages.clear();
|
|
185
|
-
await Promise.
|
|
202
|
+
await Promise.allSettled([...pending]);
|
|
186
203
|
await guard(async (backend) => backend.close?.());
|
|
187
204
|
};
|
|
188
205
|
});
|
|
@@ -190,7 +207,17 @@ export function apply(ctx, config) {
|
|
|
190
207
|
archiveSession(sessionId) {
|
|
191
208
|
// Join the same per-session chain as event writes, then discard the old
|
|
192
209
|
// in-memory rollup only after the database transaction commits.
|
|
193
|
-
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
|
+
}
|
|
194
221
|
const id = await backends[0].archiveSession(sessionId);
|
|
195
222
|
sessions.delete(sessionId);
|
|
196
223
|
toolCallMessages.delete(sessionId);
|
|
@@ -219,11 +246,11 @@ export function apply(ctx, config) {
|
|
|
219
246
|
await fanout((backend) => backend.upsertMessage(snapshot));
|
|
220
247
|
return;
|
|
221
248
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
}
|
|
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 } };
|
|
227
254
|
let row = projectEvent(session, event, sessionId);
|
|
228
255
|
if (row && session.header?.createdAt !== undefined) {
|
|
229
256
|
row.metadata = { ...row.metadata, sessionCreatedAt: session.header.createdAt };
|
|
@@ -253,29 +280,21 @@ export function apply(ctx, config) {
|
|
|
253
280
|
if (!row && event?.type === 'tool/result') {
|
|
254
281
|
console.error('[dsh-storage] no matching model tool call for result');
|
|
255
282
|
}
|
|
256
|
-
if (row) {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
accum.lastMessageAt = row.createdAt;
|
|
261
|
-
}
|
|
262
|
-
await fanout((backend) => backend.upsertMessage(row));
|
|
263
|
-
rememberToolCalls(sessionId, row);
|
|
283
|
+
if (row && !existing && event?.type !== 'tool/result') {
|
|
284
|
+
accum.messageCount += 1;
|
|
285
|
+
accum.firstMessageAt ??= row.createdAt;
|
|
286
|
+
accum.lastMessageAt = row.createdAt;
|
|
264
287
|
}
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
// chunk↔message pair and progressive samples never double-count, and a
|
|
268
|
-
// failed step (usage chunk, no message) still counts. Samples of one
|
|
269
|
-
// 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.
|
|
270
290
|
const sampled = usageSampleOf(event);
|
|
271
291
|
let usageDelta = 0;
|
|
272
292
|
if (sampled) {
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
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;
|
|
279
298
|
accum.usageSample = { key: sampled.key, ...sampled.sample };
|
|
280
299
|
accum.totalTokens += usageDelta;
|
|
281
300
|
}
|
|
@@ -304,8 +323,10 @@ export function apply(ctx, config) {
|
|
|
304
323
|
lastMessageAt: accum.lastMessageAt ?? null,
|
|
305
324
|
...(Object.keys(metadata).length > 0 ? { metadata } : {}),
|
|
306
325
|
};
|
|
307
|
-
await fanout((backend) => backend.
|
|
326
|
+
await fanout((backend) => backend.upsertEvent(row, sessionRow));
|
|
308
327
|
}
|
|
328
|
+
sessions.set(sessionId, accum);
|
|
329
|
+
rememberToolCalls(sessionId, row);
|
|
309
330
|
});
|
|
310
331
|
});
|
|
311
332
|
ctx.on('session/disposed', (session) => {
|
|
@@ -328,8 +349,19 @@ export function apply(ctx, config) {
|
|
|
328
349
|
chains.delete(sessionId);
|
|
329
350
|
});
|
|
330
351
|
});
|
|
331
|
-
// Durability checkpoint:
|
|
332
|
-
|
|
333
|
-
|
|
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
|
+
}
|
|
334
366
|
});
|
|
335
367
|
}
|
package/lib/projector.js
CHANGED
|
@@ -106,16 +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. */
|
|
109
110
|
export function toolResultCallId(event) {
|
|
110
|
-
const
|
|
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);
|
|
111
116
|
return typeof callId === 'string' ? callId : undefined;
|
|
112
117
|
}
|
|
113
118
|
/** Update the persisted assistant call identified by a tool result. */
|
|
114
119
|
export function mergeToolResult(event, rows) {
|
|
115
|
-
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;
|
|
116
123
|
const callId = toolResultCallId(event);
|
|
117
124
|
if (!callId)
|
|
118
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);
|
|
119
135
|
const model = [...rows]
|
|
120
136
|
.reverse()
|
|
121
137
|
.find((row) => row.type === 'model' &&
|
|
@@ -137,21 +153,19 @@ export function mergeToolResult(event, rows) {
|
|
|
137
153
|
};
|
|
138
154
|
}
|
|
139
155
|
/**
|
|
140
|
-
* Extract
|
|
141
|
-
* when the event carries none.
|
|
156
|
+
* Extract one model call's usage, keyed by its durable event sequence.
|
|
142
157
|
*
|
|
143
158
|
* V3 session log (dsh-session@0.1.6-alpha.2):
|
|
144
|
-
* - `assistant/message.data.usage` — the settled
|
|
145
|
-
* - `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
|
|
146
161
|
* message; the adapter's usage chunk survives as a raw `chunk` record in the
|
|
147
162
|
* embedded compact `stream` (deltas are packed, but usage is a raw type).
|
|
148
163
|
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
* 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).
|
|
155
169
|
*/
|
|
156
170
|
export function usageSampleOf(event) {
|
|
157
171
|
const total = (u) => ({
|
|
@@ -171,7 +185,7 @@ export function usageSampleOf(event) {
|
|
|
171
185
|
else {
|
|
172
186
|
return null;
|
|
173
187
|
}
|
|
174
|
-
if (!usage ||
|
|
188
|
+
if (!usage || !Number.isSafeInteger(event?.seq))
|
|
175
189
|
return null;
|
|
176
|
-
return { key:
|
|
190
|
+
return { key: String(event.seq), sample: total(usage) };
|
|
177
191
|
}
|
|
@@ -24,18 +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
36
|
readMessages(sessionId: string, messageId: string): Promise<MessageRow[]>;
|
|
35
37
|
readToolCallMessages(sessionId: string, callId: string): Promise<MessageRow[]>;
|
|
36
38
|
private readRows;
|
|
37
39
|
readSession(sessionId: string): Promise<SessionRow | null>;
|
|
40
|
+
private writeSession;
|
|
38
41
|
upsertSession(row: SessionRow): Promise<void>;
|
|
42
|
+
upsertEvent(message: MessageRow | null, session: SessionRow): Promise<void>;
|
|
39
43
|
archiveSession(sessionId: string): Promise<string | null>;
|
|
40
44
|
close(): Promise<void>;
|
|
41
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,7 @@ 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. */
|
|
25
26
|
export declare function toolResultCallId(event: any): string | undefined;
|
|
26
27
|
/** Update the persisted assistant call identified by a tool result. */
|
|
27
28
|
export declare function mergeToolResult(event: any, rows: MessageRow[]): MessageRow | null;
|
|
@@ -30,21 +31,19 @@ export interface UsageSample {
|
|
|
30
31
|
output: number;
|
|
31
32
|
}
|
|
32
33
|
/**
|
|
33
|
-
* Extract
|
|
34
|
-
* when the event carries none.
|
|
34
|
+
* Extract one model call's usage, keyed by its durable event sequence.
|
|
35
35
|
*
|
|
36
36
|
* V3 session log (dsh-session@0.1.6-alpha.2):
|
|
37
|
-
* - `assistant/message.data.usage` — the settled
|
|
38
|
-
* - `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
|
|
39
39
|
* message; the adapter's usage chunk survives as a raw `chunk` record in the
|
|
40
40
|
* embedded compact `stream` (deltas are packed, but usage is a raw type).
|
|
41
41
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* 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).
|
|
48
47
|
*/
|
|
49
48
|
export declare function usageSampleOf(event: any): {
|
|
50
49
|
key: string;
|
package/lib/types/types.d.ts
CHANGED
|
@@ -33,10 +33,9 @@ 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;
|
|
@@ -45,10 +44,12 @@ export interface StorageBackend {
|
|
|
45
44
|
readSession?(sessionId: string): Promise<SessionRow | null>;
|
|
46
45
|
/** Read one persisted logical message, including archived rows for redelivery. */
|
|
47
46
|
readMessages(sessionId: string, messageId: string): Promise<MessageRow[]>;
|
|
48
|
-
/** Find
|
|
47
|
+
/** Find the newest active model message containing this exact tool call. */
|
|
49
48
|
readToolCallMessages(sessionId: string, callId: string): Promise<MessageRow[]>;
|
|
50
49
|
upsertMessage(row: MessageRow): Promise<void>;
|
|
51
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>;
|
|
52
53
|
/** Move active messages into a queryable history row; null when already empty. */
|
|
53
54
|
archiveSession(sessionId: string): Promise<string | null>;
|
|
54
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.36",
|
|
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
|
|