@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.
- package/README.md +4 -0
- package/lib/index.js +157 -93
- 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
|
|
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(
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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.
|
|
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",
|