@amaster.ai/dsh-storage 0.1.34 → 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.
@@ -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
- this.prisma = new PrismaClient({ adapter });
107
- await this.prisma.$connect();
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 upsertMessage(row) {
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 this.prisma.aiMessage.upsert({
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
- if (!this.prisma)
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
- ? 'CONVERT(varchar(33), created_at, 126)'
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 (cursor) {
169
- if (cursor.time === null) {
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
- provider === 'postgresql'
172
- ? 'AND (created_at IS NOT NULL OR (created_at IS NULL AND id < ?))'
173
- : 'AND created_at IS NULL AND id < ?';
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 = `AND (created_at < ? OR (created_at = ? AND id < ?)${provider === 'postgresql' ? '' : ' OR created_at IS NULL'})`;
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
- let sql = `SELECT ${sqlserver ? 'TOP 100 ' : ''}id, tool_calls, ${sortTime} AS sort_time
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 this.prisma.$queryRawUnsafe(sql, ...params);
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
- if (!this.prisma)
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
- if (!this.prisma)
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 upsertSession(row) {
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 this.prisma.aiChatHistory.findFirst({
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 this.prisma.aiChatHistory.update({ where: { id: existing.id }, data });
347
+ await client.aiChatHistory.update({ where: { id: existing.id }, data });
309
348
  }
310
349
  else {
311
- await this.prisma.aiChatHistory.create({ data });
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
- if (!this.prisma)
316
- throw new Error('dsh-storage database is not initialized');
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
- if (stats._count._all === 0)
326
- return null;
327
- // Active rollups have no attached messages; an archive gains them below.
328
- const active = await tx.aiChatHistory.findFirst({
329
- where: { sessionId, deletedAt: null, messages: { none: {} } },
330
- select: { id: true },
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: stats._count._all,
392
+ messageCount: expected,
334
393
  firstMessageAt: stats._min.createdAt,
335
394
  lastMessageAt: stats._max.createdAt,
336
395
  };
337
- const history = active
338
- ? await tx.aiChatHistory.update({ where: { id: active.id }, data: rollup })
339
- : await tx.aiChatHistory.create({
340
- data: { sessionId, ...rollup, totalTokens: 0n },
341
- select: { id: true },
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
- // Older calls absent from the live surface are searched in bounded database pages.
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.finally(() => pending.delete(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
- // Backend init starts at plugin load; every write chains behind it so no
128
- // row is lost to a still-connecting backend.
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
- return track(started.then(() => guard(call)));
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
- // First task of a session also chains behind backend init, so seedAccum
142
- // never reads from a still-connecting backend (prisma === null would
143
- // masquerade as "row not found" and zero-seed over the stored rollup).
144
- const next = (chains.get(sessionId) ?? started)
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
- metadata: { ...(row.metadata ?? {}) },
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
- started = track(guard(async (backend) => backend.init?.()));
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.all([...pending]);
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) ?? started).then(async () => {
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
- let accum = sessions.get(sessionId);
223
- if (!accum) {
224
- accum = await seedAccum(sessionId);
225
- sessions.set(sessionId, accum);
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
- if (!existing && event?.type !== 'tool/result') {
258
- accum.messageCount += 1;
259
- accum.firstMessageAt ??= row.createdAt;
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
- // Replacement usage accounting: the latest sample for a (turn, step)
266
- // supersedes the previous one — fold only the delta, so the
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 prev = accum.usageSample;
274
- usageDelta =
275
- sampled.sample.input +
276
- sampled.sample.output -
277
- (prev && prev.key === sampled.key ? prev.input + prev.output : 0);
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.upsertSession(sessionRow));
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: the store awaits every session/flush listener.
332
- ctx.on('session/flush', async () => {
333
- await Promise.all([...pending]);
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 callId = event?.data?.message?.source?.callId ?? event?.data?.message?.content?.[0]?.toolCallId;
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 result = event?.data?.message?.content?.[0] ?? null;
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 the usage sample carried by one event, keyed by its step, or null
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 step's final accounting;
145
- * - `assistant/attempt` — a failed/retried/cancelled step commits no surface
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
- * Samples for one `(turn, step)` are REPLACEMENTS, not additive: later
150
- * samples supersede earlier ones (a failed attempt's usage, then the settled
151
- * message's). The caller keeps the latest sample per step and folds only the
152
- * delta into its rollup. Totals include the cache buckets: cacheRead/cacheWrite
153
- * are billed in their own buckets (observed cacheReadTokens ≫ inputTokens in
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 || data?.turn == null || data?.step == null)
188
+ if (!usage || !Number.isSafeInteger(event?.seq))
175
189
  return null;
176
- return { key: `${data.turn}:${data.step}`, sample: total(usage) };
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
  }
@@ -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
- }>, Schemastery.ObjectT<{
32
- enabled: Schema<boolean, boolean>;
33
- provider: Schema<"mysql" | "postgresql" | "sqlite" | "sqlserver", "mysql" | "postgresql" | "sqlite" | "sqlserver">;
34
- url: Schema<string, string>;
35
- }>>;
36
- }>, Schemastery.ObjectT<{
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
- }>, Schemastery.ObjectT<{
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: {
@@ -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 the usage sample carried by one event, keyed by its step, or null
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 step's final accounting;
38
- * - `assistant/attempt` — a failed/retried/cancelled step commits no surface
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
- * Samples for one `(turn, step)` are REPLACEMENTS, not additive: later
43
- * samples supersede earlier ones (a failed attempt's usage, then the settled
44
- * message's). The caller keeps the latest sample per step and folds only the
45
- * delta into its rollup. Totals include the cache buckets: cacheRead/cacheWrite
46
- * are billed in their own buckets (observed cacheReadTokens ≫ inputTokens in
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;
@@ -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. Backends must be
37
- * no-throw from the plugin's perspective: implementations report errors to
38
- * the plugin logger and swallow them, mirroring must never break the agent
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 at most one active model message containing this exact tool call. */
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.34",
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.1"
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.4",
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