@amaster.ai/dsh-storage 0.1.37 → 0.1.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +4 -0
  2. package/lib/index.js +157 -93
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -46,6 +46,10 @@ npx prisma db push --schema node_modules/@amaster.ai/dsh-storage/prisma/schema.m
46
46
  # schema.postgresql.prisma / schema.sqlite.prisma / schema.sqlserver.prisma work the same way
47
47
  ```
48
48
 
49
+ Only message, tool-result, assistant-attempt, turn-end, title, and successful compaction events enter the mirror queue; unrelated lifecycle records do not trigger database reads or retries. Failed initialization retries share a one-second delay across sessions. Valid queued events wait through that delay, and session flush/unload still drain their ordered work. Database operation failures continue to be logged and swallowed; local DSH persistence remains authoritative.
50
+
51
+ Adjacent queued tool results for the same assistant row are merged before writing, with at most 32 events per batch and no debounce timer. Different messages and intervening mirrored events preserve queue order. Session flush, archive, disposal, and plugin unload seal and drain earlier batches. Full result rows are not kept in a new persistent cache. This reduces repeated reads/writes; the queue can still grow when incoming data outpaces the database.
52
+
49
53
  ## Data model
50
54
 
51
55
  - **`ai_messages`** — user and model rows, with `thoughts`, `tokens`, `tool_calls`, `agent_id`, `metadata` JSON columns and soft-delete. A `tool/result` updates the matching model row's `tool_calls` entry by call ID; it does not create a `type=tool` row.
package/lib/index.js CHANGED
@@ -126,16 +126,23 @@ export function apply(ctx, config) {
126
126
  // Mirroring must never break the agent loop: log and swallow.
127
127
  return Promise.all(backends.map((backend) => call(backend).catch((error) => console.error(`[dsh-storage] ${backend.name} error:`, error)))).then(() => { });
128
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
+ // All sessions share init and its retry delay; queued events wait rather than skip the barrier.
131
130
  let started;
131
+ let retryAt = 0;
132
132
  function ensureStarted() {
133
133
  if (!started) {
134
- const attempt = track(Promise.all(backends.map((backend) => backend.init?.())).then(() => { }));
134
+ const attempt = track((async () => {
135
+ const delay = retryAt - Date.now();
136
+ if (delay > 0)
137
+ await new Promise((resolve) => setTimeout(resolve, delay));
138
+ await Promise.all(backends.map((backend) => backend.init?.()));
139
+ })());
135
140
  started = attempt;
136
141
  void attempt.catch(() => {
137
- if (started === attempt)
142
+ if (started === attempt) {
143
+ retryAt = Date.now() + 1000;
138
144
  started = undefined;
145
+ }
139
146
  });
140
147
  }
141
148
  return started;
@@ -152,11 +159,10 @@ export function apply(ctx, config) {
152
159
  // stored row before any write (resume: constructor seeds are not re-emitted,
153
160
  // so history must come from the database, not from zero).
154
161
  const chains = new Map();
162
+ const toolBatches = new Map();
155
163
  function enqueue(sessionId, task) {
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.
164
+ // Backend work checks init inside mirrorEvent; cleanup needs only queue ordering.
158
165
  const next = (chains.get(sessionId) ?? Promise.resolve())
159
- .then(() => ensureStarted())
160
166
  .then(task)
161
167
  .catch((error) => console.error('[dsh-storage] mirror task error:', error));
162
168
  chains.set(sessionId, next);
@@ -197,14 +203,16 @@ export function apply(ctx, config) {
197
203
  ctx.effect(() => {
198
204
  void ensureStarted().catch((error) => console.error('[dsh-storage] database error:', error));
199
205
  return async () => {
206
+ toolBatches.clear();
207
+ await Promise.allSettled([...pending]);
200
208
  sessions.clear();
201
209
  toolCallMessages.clear();
202
- await Promise.allSettled([...pending]);
203
210
  await guard(async (backend) => backend.close?.());
204
211
  };
205
212
  });
206
213
  ctx.provide('dshStorage', {
207
214
  archiveSession(sessionId) {
215
+ toolBatches.delete(sessionId);
208
216
  // Join the same per-session chain as event writes, then discard the old
209
217
  // in-memory rollup only after the database transaction commits.
210
218
  const archived = (chains.get(sessionId) ?? Promise.resolve())
@@ -229,9 +237,122 @@ export function apply(ctx, config) {
229
237
  return archived;
230
238
  },
231
239
  });
240
+ async function mirrorEvent(session, event, snapshot, batch) {
241
+ await ensureStarted();
242
+ const sessionId = session?.id ?? 'unknown';
243
+ if (snapshot) {
244
+ await fanout((backend) => backend.upsertMessage(snapshot));
245
+ return;
246
+ }
247
+ const stored = sessions.get(sessionId) ?? (await seedAccum(sessionId));
248
+ sessions.set(sessionId, stored);
249
+ // Stage every change on a copy. The live accumulator advances only
250
+ // after the message + rollup transaction commits.
251
+ const accum = { ...stored, metadata: { ...stored.metadata } };
252
+ let row = projectEvent(session, event, sessionId);
253
+ if (row && session.header?.createdAt !== undefined) {
254
+ row.metadata = { ...row.metadata, sessionCreatedAt: session.header.createdAt };
255
+ }
256
+ const storedRows = event?.type === 'tool/result'
257
+ ? await readToolResultRows(session, sessionId, event)
258
+ : row
259
+ ? await backends[0].readMessages(sessionId, row.id)
260
+ : [];
261
+ let processedThrough = batch?.index;
262
+ if (event?.type === 'tool/result') {
263
+ row = mergeToolResult(event, storedRows);
264
+ while (row &&
265
+ batch &&
266
+ processedThrough !== undefined &&
267
+ processedThrough + 1 < batch.events.length) {
268
+ const following = batch.events[processedThrough + 1];
269
+ const callId = toolResultCallId(following);
270
+ if (!callId ||
271
+ !Array.isArray(row.toolCalls) ||
272
+ !row.toolCalls.some((call) => call?.id === callId))
273
+ break;
274
+ row = mergeToolResult(following, [row]);
275
+ processedThrough++;
276
+ }
277
+ }
278
+ const existing = row && storedRows.find((stored) => stored.id === row?.id);
279
+ if (event?.type === 'assistant/message' && row && existing && Array.isArray(row.toolCalls)) {
280
+ row.toolCalls = row.toolCalls.map((call) => {
281
+ const previous = Array.isArray(existing.toolCalls)
282
+ ? existing.toolCalls.find((stored) => stored?.id === call?.id)
283
+ : undefined;
284
+ return previous && 'result' in previous
285
+ ? {
286
+ ...call,
287
+ result: previous.result,
288
+ ...(previous.error ? { error: previous.error } : {}),
289
+ }
290
+ : call;
291
+ });
292
+ }
293
+ if (!row && event?.type === 'tool/result') {
294
+ console.error('[dsh-storage] no matching model tool call for result');
295
+ }
296
+ if (row && !existing && event?.type !== 'tool/result') {
297
+ accum.messageCount += 1;
298
+ accum.firstMessageAt ??= row.createdAt;
299
+ accum.lastMessageAt = row.createdAt;
300
+ }
301
+ // Every settled attempt/message is one model call; retries on the same
302
+ // step are additive rather than replacement snapshots.
303
+ const sampled = usageSampleOf(event);
304
+ let usageDelta = 0;
305
+ if (sampled) {
306
+ const sequence = Number(sampled.key);
307
+ const previous = Number(accum.usageSample?.key);
308
+ const duplicate = Number.isSafeInteger(sequence) && Number.isSafeInteger(previous) && sequence <= previous;
309
+ if (!duplicate) {
310
+ usageDelta = sampled.sample.input + sampled.sample.output;
311
+ accum.usageSample = { key: sampled.key, ...sampled.sample };
312
+ accum.totalTokens += usageDelta;
313
+ }
314
+ }
315
+ // Latest-wins title snapshot (log-only `session/title` event; payload
316
+ // { title, messageSeqs, source } verified against dsh-session-title@0.1.6-alpha.2).
317
+ if (event?.type === 'session/title' && typeof event.data?.title === 'string') {
318
+ accum.title = event.data.title;
319
+ }
320
+ if (row || usageDelta !== 0 || event?.type === 'turn/end' || event?.type === 'session/title') {
321
+ // Merge our bookkeeping into the existing metadata instead of
322
+ // replacing the column — other fields (present or future) survive.
323
+ const metadata = {
324
+ ...accum.metadata,
325
+ ...(accum.usageSample ? { [LAST_USAGE_KEY]: accum.usageSample } : {}),
326
+ };
327
+ const sessionRow = {
328
+ sessionId,
329
+ title: accum.title ?? null,
330
+ messageCount: accum.messageCount,
331
+ totalTokens: accum.totalTokens,
332
+ firstMessageAt: accum.firstMessageAt ?? null,
333
+ lastMessageAt: accum.lastMessageAt ?? null,
334
+ ...(Object.keys(metadata).length > 0 ? { metadata } : {}),
335
+ };
336
+ await fanout((backend) => backend.upsertEvent(row, sessionRow));
337
+ }
338
+ // Advance only after commit so a failed merged write cannot skip later results.
339
+ if (batch && processedThrough !== undefined)
340
+ batch.index = processedThrough;
341
+ sessions.set(sessionId, accum);
342
+ rememberToolCalls(sessionId, row);
343
+ }
232
344
  /* eslint-disable @typescript-eslint/no-explicit-any */
233
345
  ctx.on('session/event', (session, event) => {
234
- const sessionId = session?.id ?? 'unknown';
346
+ if (![
347
+ 'user/message',
348
+ 'assistant/message',
349
+ 'assistant/attempt',
350
+ 'tool/result',
351
+ 'turn/end',
352
+ 'session/title',
353
+ 'compaction/end',
354
+ ].includes(event?.type))
355
+ return;
235
356
  let snapshot;
236
357
  try {
237
358
  // Capture the committed surface now; queued writes must not observe later steps.
@@ -241,96 +362,38 @@ export function apply(ctx, config) {
241
362
  console.error('[dsh-storage] compaction snapshot error:', error);
242
363
  return;
243
364
  }
244
- enqueue(sessionId, async () => {
245
- if (snapshot) {
246
- await fanout((backend) => backend.upsertMessage(snapshot));
365
+ if (event.type === 'compaction/end' && !snapshot)
366
+ return;
367
+ const sessionId = session?.id ?? 'unknown';
368
+ if (event.type === 'tool/result') {
369
+ const pendingBatch = toolBatches.get(sessionId);
370
+ if (pendingBatch && pendingBatch.events.length < 32) {
371
+ pendingBatch.events.push(event);
247
372
  return;
248
373
  }
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 } };
254
- let row = projectEvent(session, event, sessionId);
255
- if (row && session.header?.createdAt !== undefined) {
256
- row.metadata = { ...row.metadata, sessionCreatedAt: session.header.createdAt };
257
- }
258
- const storedRows = event?.type === 'tool/result'
259
- ? await readToolResultRows(session, sessionId, event)
260
- : row
261
- ? await backends[0].readMessages(sessionId, row.id)
262
- : [];
263
- if (event?.type === 'tool/result')
264
- row = mergeToolResult(event, storedRows);
265
- const existing = row && storedRows.find((stored) => stored.id === row?.id);
266
- if (event?.type === 'assistant/message' && row && existing && Array.isArray(row.toolCalls)) {
267
- row.toolCalls = row.toolCalls.map((call) => {
268
- const previous = Array.isArray(existing.toolCalls)
269
- ? existing.toolCalls.find((stored) => stored?.id === call?.id)
270
- : undefined;
271
- return previous && 'result' in previous
272
- ? {
273
- ...call,
274
- result: previous.result,
275
- ...(previous.error ? { error: previous.error } : {}),
276
- }
277
- : call;
278
- });
279
- }
280
- if (!row && event?.type === 'tool/result') {
281
- console.error('[dsh-storage] no matching model tool call for result');
282
- }
283
- if (row && !existing && event?.type !== 'tool/result') {
284
- accum.messageCount += 1;
285
- accum.firstMessageAt ??= row.createdAt;
286
- accum.lastMessageAt = row.createdAt;
287
- }
288
- // Every settled attempt/message is one model call; retries on the same
289
- // step are additive rather than replacement snapshots.
290
- const sampled = usageSampleOf(event);
291
- let usageDelta = 0;
292
- if (sampled) {
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;
298
- accum.usageSample = { key: sampled.key, ...sampled.sample };
299
- accum.totalTokens += usageDelta;
374
+ const batch = { events: [event], index: 0 };
375
+ toolBatches.set(sessionId, batch);
376
+ enqueue(sessionId, async () => {
377
+ if (toolBatches.get(sessionId) === batch)
378
+ toolBatches.delete(sessionId);
379
+ for (; batch.index < batch.events.length; batch.index++) {
380
+ try {
381
+ await mirrorEvent(session, batch.events[batch.index], null, batch);
382
+ }
383
+ catch (error) {
384
+ console.error('[dsh-storage] mirror task error:', error);
385
+ }
300
386
  }
301
- }
302
- // Latest-wins title snapshot (log-only `session/title` event; payload
303
- // { title, messageSeqs, source } verified against dsh-session-title@0.1.6-alpha.2).
304
- if (event?.type === 'session/title' && typeof event.data?.title === 'string') {
305
- accum.title = event.data.title;
306
- }
307
- if (row ||
308
- usageDelta !== 0 ||
309
- event?.type === 'turn/end' ||
310
- event?.type === 'session/title') {
311
- // Merge our bookkeeping into the existing metadata instead of
312
- // replacing the column — other fields (present or future) survive.
313
- const metadata = {
314
- ...accum.metadata,
315
- ...(accum.usageSample ? { [LAST_USAGE_KEY]: accum.usageSample } : {}),
316
- };
317
- const sessionRow = {
318
- sessionId,
319
- title: accum.title ?? null,
320
- messageCount: accum.messageCount,
321
- totalTokens: accum.totalTokens,
322
- firstMessageAt: accum.firstMessageAt ?? null,
323
- lastMessageAt: accum.lastMessageAt ?? null,
324
- ...(Object.keys(metadata).length > 0 ? { metadata } : {}),
325
- };
326
- await fanout((backend) => backend.upsertEvent(row, sessionRow));
327
- }
328
- sessions.set(sessionId, accum);
329
- rememberToolCalls(sessionId, row);
330
- });
387
+ });
388
+ }
389
+ else {
390
+ toolBatches.delete(sessionId);
391
+ enqueue(sessionId, () => mirrorEvent(session, event, snapshot));
392
+ }
331
393
  });
332
394
  ctx.on('session/disposed', (session) => {
333
395
  const sessionId = session?.id ?? 'unknown';
396
+ toolBatches.delete(sessionId);
334
397
  // The delete must run inside the session's chain — a synchronous delete
335
398
  // here races the queued event tasks (the map may not even have the entry
336
399
  // yet at emit time, and queued tasks would keep growing the accumulator
@@ -352,6 +415,7 @@ export function apply(ctx, config) {
352
415
  // Durability checkpoint: wait only for this session. Shutdown still drains
353
416
  // the global pending set; an unrelated slow session must not block this one.
354
417
  ctx.on('session/flush', async (session) => {
418
+ toolBatches.delete(session?.id ?? 'unknown');
355
419
  const chain = chains.get(session?.id ?? 'unknown');
356
420
  if (chain) {
357
421
  await chain;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amaster.ai/dsh-storage",
3
- "version": "0.1.37",
3
+ "version": "0.1.39",
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",