@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.
- package/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +128 -2
- package/cordis.patch.yml +3 -0
- package/lib/backends/database.js +578 -0
- package/lib/backfill.js +331 -0
- package/lib/client/index.js +79 -0
- package/lib/client/panel.js +299 -0
- package/lib/client/toast.js +66 -0
- package/lib/client.js +1 -0
- package/lib/compaction.js +108 -0
- package/lib/config.js +171 -0
- package/lib/ddl.js +323 -0
- package/lib/generated/mysql/browser.js +17 -0
- package/lib/generated/mysql/client.js +34 -0
- package/lib/generated/mysql/commonInputTypes.js +10 -0
- package/lib/generated/mysql/enums.js +10 -0
- package/lib/generated/mysql/internal/class.js +49 -0
- package/lib/generated/mysql/internal/prismaNamespace.js +146 -0
- package/lib/generated/mysql/internal/prismaNamespaceBrowser.js +117 -0
- package/lib/generated/mysql/models/AiChatHistory.js +1 -0
- package/lib/generated/mysql/models/AiMessage.js +1 -0
- package/lib/generated/mysql/models.js +1 -0
- package/lib/generated/postgresql/browser.js +17 -0
- package/lib/generated/postgresql/client.js +34 -0
- package/lib/generated/postgresql/commonInputTypes.js +10 -0
- package/lib/generated/postgresql/enums.js +10 -0
- package/lib/generated/postgresql/internal/class.js +49 -0
- package/lib/generated/postgresql/internal/prismaNamespace.js +131 -0
- package/lib/generated/postgresql/internal/prismaNamespaceBrowser.js +102 -0
- package/lib/generated/postgresql/models/AiChatHistory.js +1 -0
- package/lib/generated/postgresql/models/AiMessage.js +1 -0
- package/lib/generated/postgresql/models.js +1 -0
- package/lib/generated/sqlite/browser.js +17 -0
- package/lib/generated/sqlite/client.js +34 -0
- package/lib/generated/sqlite/commonInputTypes.js +10 -0
- package/lib/generated/sqlite/enums.js +10 -0
- package/lib/generated/sqlite/internal/class.js +49 -0
- package/lib/generated/sqlite/internal/prismaNamespace.js +128 -0
- package/lib/generated/sqlite/internal/prismaNamespaceBrowser.js +99 -0
- package/lib/generated/sqlite/models/AiChatHistory.js +1 -0
- package/lib/generated/sqlite/models/AiMessage.js +1 -0
- package/lib/generated/sqlite/models.js +1 -0
- package/lib/generated/sqlserver/browser.js +17 -0
- package/lib/generated/sqlserver/client.js +34 -0
- package/lib/generated/sqlserver/commonInputTypes.js +10 -0
- package/lib/generated/sqlserver/enums.js +10 -0
- package/lib/generated/sqlserver/internal/class.js +49 -0
- package/lib/generated/sqlserver/internal/prismaNamespace.js +119 -0
- package/lib/generated/sqlserver/internal/prismaNamespaceBrowser.js +90 -0
- package/lib/generated/sqlserver/models/AiChatHistory.js +1 -0
- package/lib/generated/sqlserver/models/AiMessage.js +1 -0
- package/lib/generated/sqlserver/models.js +1 -0
- package/lib/health.js +180 -0
- package/lib/index.js +676 -0
- package/lib/notify.js +56 -0
- package/lib/projector.js +191 -0
- package/lib/status-route.js +172 -0
- package/lib/types/backends/database.d.ts +78 -0
- package/lib/types/backfill.d.ts +67 -0
- package/lib/types/client/index.d.ts +12 -0
- package/lib/types/client/panel.d.ts +79 -0
- package/lib/types/client/toast.d.ts +38 -0
- package/lib/types/compaction.d.ts +3 -0
- package/lib/types/config.d.ts +52 -0
- package/lib/types/ddl.d.ts +49 -0
- package/lib/types/generated/mysql/browser.d.ts +14 -0
- package/lib/types/generated/mysql/client.d.ts +33 -0
- package/lib/types/generated/mysql/commonInputTypes.d.ts +396 -0
- package/lib/types/generated/mysql/enums.d.ts +1 -0
- package/lib/types/generated/mysql/internal/class.d.ts +143 -0
- package/lib/types/generated/mysql/internal/prismaNamespace.d.ts +709 -0
- package/lib/types/generated/mysql/internal/prismaNamespaceBrowser.d.ts +112 -0
- package/lib/types/generated/mysql/models/AiChatHistory.d.ts +1258 -0
- package/lib/types/generated/mysql/models/AiMessage.d.ts +1359 -0
- package/lib/types/generated/mysql/models.d.ts +3 -0
- package/lib/types/generated/postgresql/browser.d.ts +14 -0
- package/lib/types/generated/postgresql/client.d.ts +33 -0
- package/lib/types/generated/postgresql/commonInputTypes.d.ts +392 -0
- package/lib/types/generated/postgresql/enums.d.ts +1 -0
- package/lib/types/generated/postgresql/internal/class.d.ts +143 -0
- package/lib/types/generated/postgresql/internal/prismaNamespace.d.ts +728 -0
- package/lib/types/generated/postgresql/internal/prismaNamespaceBrowser.d.ts +95 -0
- package/lib/types/generated/postgresql/models/AiChatHistory.d.ts +1373 -0
- package/lib/types/generated/postgresql/models/AiMessage.d.ts +1496 -0
- package/lib/types/generated/postgresql/models.d.ts +3 -0
- package/lib/types/generated/sqlite/browser.d.ts +14 -0
- package/lib/types/generated/sqlite/client.d.ts +33 -0
- package/lib/types/generated/sqlite/commonInputTypes.d.ts +373 -0
- package/lib/types/generated/sqlite/enums.d.ts +1 -0
- package/lib/types/generated/sqlite/internal/class.d.ts +143 -0
- package/lib/types/generated/sqlite/internal/prismaNamespace.d.ts +705 -0
- package/lib/types/generated/sqlite/internal/prismaNamespaceBrowser.d.ts +92 -0
- package/lib/types/generated/sqlite/models/AiChatHistory.d.ts +1371 -0
- package/lib/types/generated/sqlite/models/AiMessage.d.ts +1493 -0
- package/lib/types/generated/sqlite/models.d.ts +3 -0
- package/lib/types/generated/sqlserver/browser.d.ts +14 -0
- package/lib/types/generated/sqlserver/client.d.ts +33 -0
- package/lib/types/generated/sqlserver/commonInputTypes.d.ts +333 -0
- package/lib/types/generated/sqlserver/enums.d.ts +1 -0
- package/lib/types/generated/sqlserver/internal/class.d.ts +143 -0
- package/lib/types/generated/sqlserver/internal/prismaNamespace.d.ts +669 -0
- package/lib/types/generated/sqlserver/internal/prismaNamespaceBrowser.d.ts +80 -0
- package/lib/types/generated/sqlserver/models/AiChatHistory.d.ts +1257 -0
- package/lib/types/generated/sqlserver/models/AiMessage.d.ts +1375 -0
- package/lib/types/generated/sqlserver/models.d.ts +3 -0
- package/lib/types/health.d.ts +52 -0
- package/lib/types/index.d.ts +91 -0
- package/lib/types/notify.d.ts +35 -0
- package/lib/types/projector.d.ts +51 -0
- package/lib/types/status-route.d.ts +48 -0
- package/lib/types/types.d.ts +58 -0
- package/lib/types.js +8 -0
- package/package.json +126 -3
- package/preview.png +0 -0
- package/prisma/schema.mysql.prisma +78 -0
- package/prisma/schema.postgresql.prisma +70 -0
- package/prisma/schema.sqlite.prisma +70 -0
- 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
|
+
}
|