@wfm-cn/dsh-storage 0.0.0-stage → 0.2.0

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 (119) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/LICENSE +21 -0
  3. package/README.md +128 -2
  4. package/cordis.patch.yml +3 -0
  5. package/lib/backends/database.js +578 -0
  6. package/lib/backfill.js +331 -0
  7. package/lib/client/index.js +79 -0
  8. package/lib/client/panel.js +299 -0
  9. package/lib/client/toast.js +66 -0
  10. package/lib/client.js +1 -0
  11. package/lib/compaction.js +108 -0
  12. package/lib/config.js +171 -0
  13. package/lib/ddl.js +323 -0
  14. package/lib/generated/mysql/browser.js +17 -0
  15. package/lib/generated/mysql/client.js +34 -0
  16. package/lib/generated/mysql/commonInputTypes.js +10 -0
  17. package/lib/generated/mysql/enums.js +10 -0
  18. package/lib/generated/mysql/internal/class.js +49 -0
  19. package/lib/generated/mysql/internal/prismaNamespace.js +146 -0
  20. package/lib/generated/mysql/internal/prismaNamespaceBrowser.js +117 -0
  21. package/lib/generated/mysql/models/AiChatHistory.js +1 -0
  22. package/lib/generated/mysql/models/AiMessage.js +1 -0
  23. package/lib/generated/mysql/models.js +1 -0
  24. package/lib/generated/postgresql/browser.js +17 -0
  25. package/lib/generated/postgresql/client.js +34 -0
  26. package/lib/generated/postgresql/commonInputTypes.js +10 -0
  27. package/lib/generated/postgresql/enums.js +10 -0
  28. package/lib/generated/postgresql/internal/class.js +49 -0
  29. package/lib/generated/postgresql/internal/prismaNamespace.js +131 -0
  30. package/lib/generated/postgresql/internal/prismaNamespaceBrowser.js +102 -0
  31. package/lib/generated/postgresql/models/AiChatHistory.js +1 -0
  32. package/lib/generated/postgresql/models/AiMessage.js +1 -0
  33. package/lib/generated/postgresql/models.js +1 -0
  34. package/lib/generated/sqlite/browser.js +17 -0
  35. package/lib/generated/sqlite/client.js +34 -0
  36. package/lib/generated/sqlite/commonInputTypes.js +10 -0
  37. package/lib/generated/sqlite/enums.js +10 -0
  38. package/lib/generated/sqlite/internal/class.js +49 -0
  39. package/lib/generated/sqlite/internal/prismaNamespace.js +128 -0
  40. package/lib/generated/sqlite/internal/prismaNamespaceBrowser.js +99 -0
  41. package/lib/generated/sqlite/models/AiChatHistory.js +1 -0
  42. package/lib/generated/sqlite/models/AiMessage.js +1 -0
  43. package/lib/generated/sqlite/models.js +1 -0
  44. package/lib/generated/sqlserver/browser.js +17 -0
  45. package/lib/generated/sqlserver/client.js +34 -0
  46. package/lib/generated/sqlserver/commonInputTypes.js +10 -0
  47. package/lib/generated/sqlserver/enums.js +10 -0
  48. package/lib/generated/sqlserver/internal/class.js +49 -0
  49. package/lib/generated/sqlserver/internal/prismaNamespace.js +119 -0
  50. package/lib/generated/sqlserver/internal/prismaNamespaceBrowser.js +90 -0
  51. package/lib/generated/sqlserver/models/AiChatHistory.js +1 -0
  52. package/lib/generated/sqlserver/models/AiMessage.js +1 -0
  53. package/lib/generated/sqlserver/models.js +1 -0
  54. package/lib/health.js +180 -0
  55. package/lib/index.js +676 -0
  56. package/lib/notify.js +56 -0
  57. package/lib/projector.js +191 -0
  58. package/lib/status-route.js +172 -0
  59. package/lib/types/backends/database.d.ts +78 -0
  60. package/lib/types/backfill.d.ts +67 -0
  61. package/lib/types/client/index.d.ts +12 -0
  62. package/lib/types/client/panel.d.ts +79 -0
  63. package/lib/types/client/toast.d.ts +38 -0
  64. package/lib/types/compaction.d.ts +3 -0
  65. package/lib/types/config.d.ts +52 -0
  66. package/lib/types/ddl.d.ts +49 -0
  67. package/lib/types/generated/mysql/browser.d.ts +14 -0
  68. package/lib/types/generated/mysql/client.d.ts +33 -0
  69. package/lib/types/generated/mysql/commonInputTypes.d.ts +396 -0
  70. package/lib/types/generated/mysql/enums.d.ts +1 -0
  71. package/lib/types/generated/mysql/internal/class.d.ts +143 -0
  72. package/lib/types/generated/mysql/internal/prismaNamespace.d.ts +709 -0
  73. package/lib/types/generated/mysql/internal/prismaNamespaceBrowser.d.ts +112 -0
  74. package/lib/types/generated/mysql/models/AiChatHistory.d.ts +1258 -0
  75. package/lib/types/generated/mysql/models/AiMessage.d.ts +1359 -0
  76. package/lib/types/generated/mysql/models.d.ts +3 -0
  77. package/lib/types/generated/postgresql/browser.d.ts +14 -0
  78. package/lib/types/generated/postgresql/client.d.ts +33 -0
  79. package/lib/types/generated/postgresql/commonInputTypes.d.ts +392 -0
  80. package/lib/types/generated/postgresql/enums.d.ts +1 -0
  81. package/lib/types/generated/postgresql/internal/class.d.ts +143 -0
  82. package/lib/types/generated/postgresql/internal/prismaNamespace.d.ts +728 -0
  83. package/lib/types/generated/postgresql/internal/prismaNamespaceBrowser.d.ts +95 -0
  84. package/lib/types/generated/postgresql/models/AiChatHistory.d.ts +1373 -0
  85. package/lib/types/generated/postgresql/models/AiMessage.d.ts +1496 -0
  86. package/lib/types/generated/postgresql/models.d.ts +3 -0
  87. package/lib/types/generated/sqlite/browser.d.ts +14 -0
  88. package/lib/types/generated/sqlite/client.d.ts +33 -0
  89. package/lib/types/generated/sqlite/commonInputTypes.d.ts +373 -0
  90. package/lib/types/generated/sqlite/enums.d.ts +1 -0
  91. package/lib/types/generated/sqlite/internal/class.d.ts +143 -0
  92. package/lib/types/generated/sqlite/internal/prismaNamespace.d.ts +705 -0
  93. package/lib/types/generated/sqlite/internal/prismaNamespaceBrowser.d.ts +92 -0
  94. package/lib/types/generated/sqlite/models/AiChatHistory.d.ts +1371 -0
  95. package/lib/types/generated/sqlite/models/AiMessage.d.ts +1493 -0
  96. package/lib/types/generated/sqlite/models.d.ts +3 -0
  97. package/lib/types/generated/sqlserver/browser.d.ts +14 -0
  98. package/lib/types/generated/sqlserver/client.d.ts +33 -0
  99. package/lib/types/generated/sqlserver/commonInputTypes.d.ts +333 -0
  100. package/lib/types/generated/sqlserver/enums.d.ts +1 -0
  101. package/lib/types/generated/sqlserver/internal/class.d.ts +143 -0
  102. package/lib/types/generated/sqlserver/internal/prismaNamespace.d.ts +669 -0
  103. package/lib/types/generated/sqlserver/internal/prismaNamespaceBrowser.d.ts +80 -0
  104. package/lib/types/generated/sqlserver/models/AiChatHistory.d.ts +1257 -0
  105. package/lib/types/generated/sqlserver/models/AiMessage.d.ts +1375 -0
  106. package/lib/types/generated/sqlserver/models.d.ts +3 -0
  107. package/lib/types/health.d.ts +52 -0
  108. package/lib/types/index.d.ts +91 -0
  109. package/lib/types/notify.d.ts +35 -0
  110. package/lib/types/projector.d.ts +51 -0
  111. package/lib/types/status-route.d.ts +48 -0
  112. package/lib/types/types.d.ts +58 -0
  113. package/lib/types.js +8 -0
  114. package/package.json +126 -3
  115. package/preview.png +0 -0
  116. package/prisma/schema.mysql.prisma +78 -0
  117. package/prisma/schema.postgresql.prisma +70 -0
  118. package/prisma/schema.sqlite.prisma +70 -0
  119. package/prisma/schema.sqlserver.prisma +76 -0
@@ -0,0 +1,578 @@
1
+ /**
2
+ * MySQL/PostgreSQL/SQLite/SQL Server backend via Prisma 7. The clients are
3
+ * pre-generated at package build time (`pnpm generate`, see
4
+ * scripts/prisma-generate.mjs) from the schema variants in prisma/ and
5
+ * shipped compiled in lib/generated — consumers never run `prisma generate`.
6
+ *
7
+ * Prisma 7 requires a driver adapter per database; `@prisma/client` and the
8
+ * adapter for your provider are optional peer dependencies (sqlite uses
9
+ * `@prisma/adapter-libsql` — no native build scripts, unlike
10
+ * `@prisma/adapter-better-sqlite3`). Table creation
11
+ * (`prisma db push` / migrations) uses the shipped schema variant with the
12
+ * connection URL in the user's prisma.config.ts.
13
+ */
14
+ /* eslint-disable @typescript-eslint/no-explicit-any */
15
+ import { createHash } from 'node:crypto';
16
+ import { defaultDevice } from '../config.js';
17
+ /**
18
+ * Parse mysql://user:password@host[:port]/database into discrete connection
19
+ * options for the mariadb driver adapter. Passing the URI string itself
20
+ * pool-times-out in production (verified against a real MySQL 8.4);
21
+ * discrete host/port/user/password/database options work. Components are
22
+ * percent-decoded — credentials with @ : / arrive encoded in the URL.
23
+ */
24
+ export function parseMysqlUrl(url) {
25
+ const decode = (value) => {
26
+ try {
27
+ return decodeURIComponent(value);
28
+ }
29
+ catch {
30
+ return value; // plain value with a raw '%' — pass through unchanged
31
+ }
32
+ };
33
+ const u = new URL(url);
34
+ const port = u.port ? Number(u.port) : undefined;
35
+ return {
36
+ host: u.hostname,
37
+ ...(port !== undefined ? { port } : {}),
38
+ user: decode(u.username),
39
+ password: decode(u.password),
40
+ database: decode(u.pathname.replace(/^\//, '')),
41
+ };
42
+ }
43
+ /**
44
+ * Deterministic primary key for a row: one logical id maps to one row, so
45
+ * redelivery (plugin reload, resume edge cases) updates in place instead of
46
+ * duplicating. A PK upsert works on every Prisma connector — the source
47
+ * project's `metadata.id` JSON-path lookup does not (advanced JSON filtering
48
+ * is PostgreSQL/MySQL only). `metadata.id` is still written for
49
+ * source-compatible read paths.
50
+ */
51
+ function pk(value) {
52
+ return createHash('sha256').update(value).digest('hex').slice(0, 36);
53
+ }
54
+ const ARCHIVE_IN_PROGRESS = 'dsh-storage:archive-in-progress';
55
+ const TOKEN_BASELINE_KEY = 'dsh-storage:token-baseline';
56
+ function metadataOf(value) {
57
+ const parsed = typeof value === 'string' ? JSON.parse(value) : value;
58
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
59
+ ? parsed
60
+ : {};
61
+ }
62
+ /**
63
+ * Parse the prisma-style sqlserver URL
64
+ * (sqlserver://host:1433;database=d;user=u;password=p;encrypt=true;trustServerCertificate=true)
65
+ * into a node-mssql config object. Values are URL-decoded — credentials with
66
+ * special characters arrive percent-encoded.
67
+ */
68
+ function mssqlConfig(url) {
69
+ const decode = (value) => {
70
+ try {
71
+ return decodeURIComponent(value);
72
+ }
73
+ catch {
74
+ return value; // plain value with a raw '%' — pass through unchanged
75
+ }
76
+ };
77
+ const [authority, ...pairs] = url.replace(/^sqlserver:\/\//, '').split(';');
78
+ const [server, port] = authority.split(':');
79
+ const params = Object.fromEntries(pairs
80
+ .filter((pair) => pair.includes('='))
81
+ .map((pair) => [pair.slice(0, pair.indexOf('=')), decode(pair.slice(pair.indexOf('=') + 1))]));
82
+ for (const key of ['database', 'user', 'password']) {
83
+ // Redact everything from 'password=' on: this error surfaces in logs via
84
+ // the no-throw seam, and a malformed URL must not leak any fragment.
85
+ if (params[key] == null)
86
+ throw new Error(`sqlserver url is missing '${key}': ${url.replace(/(password=).*$/i, '$1***')}`);
87
+ }
88
+ return {
89
+ server,
90
+ ...(port ? { port: Number(port) } : {}),
91
+ database: params.database,
92
+ user: params.user,
93
+ password: params.password,
94
+ options: {
95
+ encrypt: params.encrypt === 'true',
96
+ trustServerCertificate: params.trustServerCertificate === 'true',
97
+ },
98
+ };
99
+ }
100
+ export async function createAdapter(config) {
101
+ switch (config.provider) {
102
+ case 'mysql': {
103
+ // Discrete options (verified against a real MySQL 8.4): the URI-string
104
+ // construction pool-times-out, and the timezone option is how the
105
+ // driver writes DATETIME values in the configured zone.
106
+ const { PrismaMariaDb } = await import('@prisma/adapter-mariadb');
107
+ return new PrismaMariaDb({
108
+ ...parseMysqlUrl(config.url),
109
+ timezone: config.timezone ?? '+08:00',
110
+ });
111
+ }
112
+ case 'postgresql': {
113
+ const { PrismaPg } = await import('@prisma/adapter-pg');
114
+ return new PrismaPg({ connectionString: config.url });
115
+ }
116
+ case 'sqlite': {
117
+ // adapter-libsql (not adapter-better-sqlite3): its native binding ships
118
+ // as platform optionalDependencies with no install scripts, so it works
119
+ // under pnpm's default build-script block (dsh profiles install that way).
120
+ const { PrismaLibSql } = await import('@prisma/adapter-libsql');
121
+ return new PrismaLibSql({ url: config.url });
122
+ }
123
+ case 'sqlserver': {
124
+ const { PrismaMssql } = await import('@prisma/adapter-mssql');
125
+ return new PrismaMssql(mssqlConfig(config.url));
126
+ }
127
+ }
128
+ }
129
+ export class DatabaseBackend {
130
+ config;
131
+ name = 'database';
132
+ prisma = null;
133
+ constructor(config) {
134
+ this.config = config;
135
+ }
136
+ /** Device identifier stamped on rows written through this backend. */
137
+ get device() {
138
+ return this.config.device || defaultDevice();
139
+ }
140
+ client() {
141
+ if (!this.prisma)
142
+ throw new Error('dsh-storage database is not initialized');
143
+ return this.prisma;
144
+ }
145
+ /** Load the pre-generated client for the configured provider (seam for tests). */
146
+ async loadClient() {
147
+ const mod = await import(new URL(`../generated/${this.config.provider}/client.js`, import.meta.url).href);
148
+ return mod.PrismaClient;
149
+ }
150
+ async init() {
151
+ const [PrismaClient, adapter] = await Promise.all([
152
+ this.loadClient(),
153
+ createAdapter(this.config),
154
+ ]);
155
+ const prisma = new PrismaClient({ adapter });
156
+ try {
157
+ await prisma.$connect();
158
+ this.prisma = prisma;
159
+ }
160
+ catch (error) {
161
+ await prisma.$disconnect?.().catch(() => { });
162
+ throw error;
163
+ }
164
+ }
165
+ /** JSON column value, text-serialized on SQL Server (no Prisma Json type). */
166
+ jsonField(value) {
167
+ return value == null
168
+ ? undefined
169
+ : this.config.provider === 'sqlserver'
170
+ ? JSON.stringify(value)
171
+ : value;
172
+ }
173
+ toDbData(row) {
174
+ // SQL Server has no Prisma Json type: its variant uses text columns, so
175
+ // JSON fields are serialized on write (derived from the provider — the
176
+ // only valid combination, not a user knob).
177
+ return {
178
+ type: row.type,
179
+ content: row.content,
180
+ thoughts: this.jsonField(row.thoughts),
181
+ model: row.model ?? undefined,
182
+ tokens: this.jsonField(row.tokens),
183
+ toolCalls: this.jsonField(row.toolCalls),
184
+ agentId: row.agentId ?? undefined,
185
+ metadata: this.jsonField({ id: row.id, ...(row.metadata ?? {}) }),
186
+ createdAt: row.createdAt,
187
+ device: this.device,
188
+ };
189
+ }
190
+ async writeMessage(client, row) {
191
+ // NUL-separated: a space-joined pair would be ambiguous ('a b','c' ≡ 'a','b c').
192
+ const id = pk(`message ${row.sessionId}\0${row.id}`);
193
+ const data = this.toDbData(row);
194
+ await client.aiMessage.upsert({
195
+ where: { id },
196
+ create: { id, sessionId: row.sessionId, historyId: row.historyId, ...data },
197
+ update: data,
198
+ });
199
+ }
200
+ async upsertMessage(row) {
201
+ await this.writeMessage(this.client(), row);
202
+ }
203
+ async readMessages(sessionId, messageId) {
204
+ return this.readRows({
205
+ sessionId,
206
+ deletedAt: null,
207
+ id: pk(`message ${sessionId}\0${messageId}`),
208
+ });
209
+ }
210
+ async readToolCallMessages(sessionId, callId) {
211
+ const prisma = this.client();
212
+ const provider = this.config.provider;
213
+ const sqlserver = provider === 'sqlserver';
214
+ // SQL Server formats datetime2 with all seven fractional digits: a raw JS
215
+ // Date would lose the 100ns precision needed by keyset pagination.
216
+ const sortTime = provider === 'mysql'
217
+ ? "DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s.%f')"
218
+ : provider === 'postgresql'
219
+ ? "to_char(created_at, 'YYYY-MM-DD HH24:MI:SS.US')"
220
+ : sqlserver
221
+ ? "FORMAT(created_at, 'yyyy-MM-ddTHH:mm:ss.fffffff')"
222
+ : 'CAST(created_at AS TEXT)';
223
+ let cursor;
224
+ let nullPhase = false;
225
+ for (;;) {
226
+ const params = provider === 'mysql' || provider === 'sqlite' ? [sessionId, sessionId] : [sessionId];
227
+ let before = '';
228
+ if (provider === 'postgresql') {
229
+ // PostgreSQL defaults to NULLS FIRST, which cannot be paginated with a
230
+ // single mixed-time cursor. Walk non-NULL timestamps first, then IDs
231
+ // of NULL rows, instead of relying on a NULLS LAST index definition.
232
+ if (nullPhase) {
233
+ before =
234
+ cursor?.time === null ? 'AND created_at IS NULL AND id < ?' : 'AND created_at IS NULL';
235
+ if (cursor)
236
+ params.push(cursor.id);
237
+ }
238
+ else {
239
+ before = 'AND created_at IS NOT NULL';
240
+ if (cursor?.time) {
241
+ before += ' AND (created_at < ? OR (created_at = ? AND id < ?))';
242
+ params.push(cursor.time, cursor.time, cursor.id);
243
+ }
244
+ }
245
+ }
246
+ else if (cursor) {
247
+ if (cursor.time === null) {
248
+ before = 'AND created_at IS NULL AND id < ?';
249
+ params.push(cursor.id);
250
+ }
251
+ else {
252
+ before = 'AND (created_at < ? OR (created_at = ? AND id < ?) OR created_at IS NULL)';
253
+ params.push(cursor.time, cursor.time, cursor.id);
254
+ }
255
+ }
256
+ const orderBy = provider === 'postgresql' && nullPhase ? 'id DESC' : 'created_at DESC, id DESC';
257
+ let sql = `SELECT ${sqlserver ? 'TOP 100 ' : ''}id, ${sqlserver ? 'session_id, ' : ''}tool_calls, ${sortTime} AS sort_time
258
+ FROM ai_messages WHERE session_id = ?
259
+ ${provider === 'sqlite' ? 'AND session_id COLLATE BINARY = ?' : ''}
260
+ ${provider === 'mysql' ? 'AND BINARY session_id = BINARY ?' : ''}
261
+ AND deleted_at IS NULL AND history_id IS NULL AND type = 'model' AND tool_calls IS NOT NULL
262
+ ${before} ORDER BY ${orderBy} ${sqlserver ? '' : 'LIMIT 100'}`;
263
+ if (sqlserver || provider === 'postgresql') {
264
+ let index = 0;
265
+ sql = sql.replaceAll('?', () => `${sqlserver ? '@P' : '$'}${++index}`);
266
+ }
267
+ const page = await prisma.$queryRawUnsafe(sql, ...params);
268
+ for (const row of page) {
269
+ // The SQL Server index can seek on session_id without a collation
270
+ // expression; verify exact identity here instead of losing index use.
271
+ if (sqlserver && row.session_id !== undefined && row.session_id !== sessionId)
272
+ continue;
273
+ let calls = row.tool_calls;
274
+ if (typeof calls === 'string') {
275
+ try {
276
+ calls = JSON.parse(calls);
277
+ }
278
+ catch {
279
+ continue;
280
+ }
281
+ }
282
+ if (Array.isArray(calls) && calls.some((call) => call?.id === callId)) {
283
+ return this.readRows({
284
+ sessionId,
285
+ deletedAt: null,
286
+ historyId: null,
287
+ type: 'model',
288
+ id: row.id,
289
+ });
290
+ }
291
+ }
292
+ if (page.length < 100) {
293
+ if (provider === 'postgresql' && !nullPhase) {
294
+ nullPhase = true;
295
+ cursor = undefined;
296
+ continue;
297
+ }
298
+ return [];
299
+ }
300
+ const last = page.at(-1);
301
+ cursor = { id: last.id, time: last.sort_time };
302
+ }
303
+ }
304
+ async readRows(where) {
305
+ const rows = await this.client().aiMessage.findMany({
306
+ where,
307
+ orderBy: { createdAt: 'asc' },
308
+ take: 1,
309
+ });
310
+ const json = (value) => (typeof value === 'string' ? JSON.parse(value) : value);
311
+ return rows.map((row) => {
312
+ const metadata = json(row.metadata) ?? {};
313
+ const storedThoughts = json(row.thoughts);
314
+ const thoughts = Array.isArray(storedThoughts)
315
+ ? storedThoughts.map((thought) => {
316
+ if (thought &&
317
+ typeof thought === 'object' &&
318
+ !('content' in thought) &&
319
+ 'description' in thought) {
320
+ const { description, ...rest } = thought;
321
+ return { ...rest, content: description };
322
+ }
323
+ return thought;
324
+ })
325
+ : typeof storedThoughts === 'string'
326
+ ? [{ content: storedThoughts }]
327
+ : storedThoughts;
328
+ return {
329
+ id: typeof metadata.id === 'string' ? metadata.id : row.id,
330
+ sessionId: row.sessionId,
331
+ historyId: row.historyId,
332
+ type: row.type,
333
+ content: row.content,
334
+ thoughts,
335
+ model: row.model,
336
+ tokens: json(row.tokens),
337
+ toolCalls: json(row.toolCalls),
338
+ agentId: row.agentId,
339
+ metadata,
340
+ createdAt: row.createdAt,
341
+ };
342
+ });
343
+ }
344
+ async readSession(sessionId) {
345
+ const row = await this.client().aiChatHistory.findFirst({
346
+ where: { sessionId, deletedAt: null, messages: { none: {} } },
347
+ });
348
+ if (!row)
349
+ return null;
350
+ return {
351
+ sessionId: row.sessionId,
352
+ title: row.title,
353
+ messageCount: row.messageCount,
354
+ totalTokens: Number(row.totalTokens),
355
+ firstMessageAt: row.firstMessageAt,
356
+ lastMessageAt: row.lastMessageAt,
357
+ // SQL Server stores JSON columns as text — parse them back on read.
358
+ metadata: row.metadata == null
359
+ ? undefined
360
+ : typeof row.metadata === 'string'
361
+ ? JSON.parse(row.metadata)
362
+ : row.metadata,
363
+ };
364
+ }
365
+ /**
366
+ * Max created_at per session over active message rows — the database-side
367
+ * watermark for backfill gap detection (sessions with newer local logs
368
+ * have missing events).
369
+ */
370
+ async latestPerSession() {
371
+ const rows = await this.client().aiMessage.groupBy({
372
+ by: ['sessionId'],
373
+ _max: { createdAt: true },
374
+ where: { deletedAt: null },
375
+ });
376
+ const latest = new Map();
377
+ for (const row of rows) {
378
+ if (row?._max?.createdAt)
379
+ latest.set(row.sessionId, new Date(row._max.createdAt));
380
+ }
381
+ return latest;
382
+ }
383
+ async writeSession(client, row) {
384
+ // Find-then-write is race-free because the plugin serializes event
385
+ // processing per session (see the per-session chain in index.ts). The
386
+ // cuid primary key matches the source project and early-scaffold rows,
387
+ // so existing data is continued, never duplicated.
388
+ const existing = await client.aiChatHistory.findFirst({
389
+ where: { sessionId: row.sessionId, deletedAt: null, messages: { none: {} } },
390
+ select: { id: true },
391
+ });
392
+ const data = {
393
+ sessionId: row.sessionId,
394
+ title: row.title ?? undefined,
395
+ messageCount: row.messageCount,
396
+ totalTokens: BigInt(row.totalTokens),
397
+ firstMessageAt: row.firstMessageAt ?? undefined,
398
+ lastMessageAt: row.lastMessageAt ?? undefined,
399
+ metadata: this.jsonField(row.metadata),
400
+ device: this.device,
401
+ };
402
+ if (existing) {
403
+ await client.aiChatHistory.update({ where: { id: existing.id }, data });
404
+ }
405
+ else {
406
+ await client.aiChatHistory.create({ data });
407
+ }
408
+ }
409
+ async upsertSession(row) {
410
+ await this.writeSession(this.client(), row);
411
+ }
412
+ async upsertEvent(message, session) {
413
+ await this.client().$transaction(async (tx) => {
414
+ if (message)
415
+ await this.writeMessage(tx, message);
416
+ await this.writeSession(tx, session);
417
+ });
418
+ }
419
+ async archiveSession(sessionId) {
420
+ const client = this.client();
421
+ const { expected, historyId } = await client.$transaction(async (tx) => {
422
+ const where = { sessionId, historyId: null, deletedAt: null };
423
+ const stats = await tx.aiMessage.aggregate({
424
+ where,
425
+ _count: { _all: true },
426
+ _min: { createdAt: true },
427
+ _max: { createdAt: true },
428
+ });
429
+ const expected = stats._count._all;
430
+ // A previous batch may have attached messages already. Resume that marked
431
+ // history before considering a newer active rollup or an empty finalization.
432
+ const interrupted = await tx.aiMessage.findFirst({
433
+ where: { sessionId, historyId: { not: null }, deletedAt: null },
434
+ orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }],
435
+ select: { historyId: true },
436
+ });
437
+ if (interrupted?.historyId) {
438
+ const candidate = await tx.aiChatHistory.findFirst({
439
+ where: { id: interrupted.historyId },
440
+ select: { metadata: true },
441
+ });
442
+ if (metadataOf(candidate?.metadata)[ARCHIVE_IN_PROGRESS] === true)
443
+ return { expected, historyId: interrupted.historyId };
444
+ }
445
+ if (expected === 0)
446
+ return { expected, historyId: null };
447
+ const rollup = {
448
+ messageCount: expected,
449
+ firstMessageAt: stats._min.createdAt,
450
+ lastMessageAt: stats._max.createdAt,
451
+ };
452
+ // Active rollups have no attached messages; an archive gains them below.
453
+ const active = await tx.aiChatHistory.findFirst({
454
+ where: { sessionId, deletedAt: null, messages: { none: {} } },
455
+ select: { id: true, metadata: true, totalTokens: true },
456
+ });
457
+ if (active) {
458
+ await tx.aiChatHistory.update({
459
+ where: { id: active.id },
460
+ data: {
461
+ ...rollup,
462
+ device: this.device,
463
+ metadata: this.jsonField({
464
+ ...metadataOf(active.metadata),
465
+ [TOKEN_BASELINE_KEY]: Number(active.totalTokens ?? 0n),
466
+ [ARCHIVE_IN_PROGRESS]: true,
467
+ }),
468
+ },
469
+ });
470
+ return { expected, historyId: active.id };
471
+ }
472
+ const history = await tx.aiChatHistory.create({
473
+ data: {
474
+ sessionId,
475
+ ...rollup,
476
+ totalTokens: 0n,
477
+ device: this.device,
478
+ metadata: this.jsonField({ [ARCHIVE_IN_PROGRESS]: true }),
479
+ },
480
+ select: { id: true },
481
+ });
482
+ return { expected, historyId: history.id };
483
+ });
484
+ if (!historyId)
485
+ return null;
486
+ // Keep each write batch short instead of holding one transaction over the
487
+ // entire active segment. The final aggregate is the correctness barrier.
488
+ let updated = 0;
489
+ while (updated < expected) {
490
+ const candidates = await client.aiMessage.findMany({
491
+ where: { sessionId, historyId: null, deletedAt: null },
492
+ select: { id: true },
493
+ take: 100,
494
+ });
495
+ if (candidates.length === 0)
496
+ break;
497
+ const batch = await client.aiMessage.updateMany({
498
+ where: { id: { in: candidates.map((row) => row.id) } },
499
+ data: { historyId },
500
+ });
501
+ if (batch.count === 0)
502
+ break;
503
+ updated += batch.count;
504
+ }
505
+ if (updated !== expected)
506
+ throw new Error('active messages changed during archive');
507
+ await client.$transaction(async (tx) => {
508
+ const stats = await tx.aiMessage.aggregate({
509
+ where: { historyId, deletedAt: null },
510
+ _count: { _all: true },
511
+ _min: { createdAt: true },
512
+ _max: { createdAt: true },
513
+ });
514
+ const current = await tx.aiChatHistory.findFirst({
515
+ where: { id: historyId },
516
+ select: { metadata: true, totalTokens: true },
517
+ });
518
+ const latestActive = await tx.aiChatHistory.findFirst({
519
+ where: { sessionId, id: { not: historyId }, deletedAt: null, messages: { none: {} } },
520
+ orderBy: { lastMessageAt: 'desc' },
521
+ select: { totalTokens: true, metadata: true },
522
+ });
523
+ const currentTokens = current?.totalTokens ?? 0n;
524
+ const baselineValue = metadataOf(latestActive?.metadata)[TOKEN_BASELINE_KEY];
525
+ const baseline = Number(baselineValue);
526
+ let totalTokens = currentTokens;
527
+ if (latestActive && Number.isSafeInteger(baseline) && baseline >= 0) {
528
+ // The baseline records the active rollup's seed total. Everything
529
+ // after it belongs to this archive, including restart-only deltas.
530
+ const delta = latestActive.totalTokens - BigInt(baseline);
531
+ if (delta > 0n)
532
+ totalTokens += delta;
533
+ }
534
+ else if (latestActive && latestActive.totalTokens > currentTokens) {
535
+ // Legacy rows without a baseline keep the cumulative compatibility path.
536
+ totalTokens = latestActive.totalTokens;
537
+ }
538
+ const metadata = { ...metadataOf(current?.metadata) };
539
+ delete metadata[ARCHIVE_IN_PROGRESS];
540
+ await tx.aiChatHistory.update({
541
+ where: { id: historyId },
542
+ data: {
543
+ messageCount: stats._count._all,
544
+ totalTokens,
545
+ firstMessageAt: stats._min.createdAt,
546
+ lastMessageAt: stats._max.createdAt,
547
+ device: this.device,
548
+ metadata: this.jsonField(metadata),
549
+ },
550
+ });
551
+ // A recovery may have created a second active rollup during the failed
552
+ // batches. Do not let its pre-archive counts seed the next live segment.
553
+ await tx.aiChatHistory.updateMany({
554
+ where: { sessionId, id: { not: historyId }, deletedAt: null, messages: { none: {} } },
555
+ data: { messageCount: 0, totalTokens: 0n, firstMessageAt: null, lastMessageAt: null },
556
+ });
557
+ const activeRollups = await tx.aiChatHistory.findMany({
558
+ where: { sessionId, id: { not: historyId }, deletedAt: null, messages: { none: {} } },
559
+ select: { id: true, metadata: true },
560
+ });
561
+ for (const active of activeRollups ?? []) {
562
+ // A completed archive must not leak its retry marker or stale baseline
563
+ // into the next live segment, or the next archive will skip freezing.
564
+ const activeMetadata = { ...metadataOf(active.metadata) };
565
+ delete activeMetadata[ARCHIVE_IN_PROGRESS];
566
+ delete activeMetadata[TOKEN_BASELINE_KEY];
567
+ await tx.aiChatHistory.update({
568
+ where: { id: active.id },
569
+ data: { metadata: this.jsonField(activeMetadata) },
570
+ });
571
+ }
572
+ });
573
+ return historyId;
574
+ }
575
+ async close() {
576
+ await this.prisma?.$disconnect?.();
577
+ }
578
+ }