@ykurilov/agent-chat-storage-sqlite 0.0.0-stage → 0.56.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 +1300 -0
- package/LICENSE +21 -0
- package/README.md +33 -2
- package/dist/index.js +817 -0
- package/dist/index.js.map +1 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/schema.d.ts +1797 -0
- package/dist/types/sqlite-storage.d.ts +15 -0
- package/drizzle/0000_init.sql +29 -0
- package/drizzle/0001_message_seq.sql +5 -0
- package/drizzle/0002_chat_reasoning.sql +1 -0
- package/drizzle/0003_compactions.sql +17 -0
- package/drizzle/0004_branch_selections.sql +11 -0
- package/drizzle/0005_chat_title.sql +22 -0
- package/drizzle/0006_usage_records.sql +19 -0
- package/drizzle/0007_user_model_choices.sql +7 -0
- package/drizzle/0008_voice_dialog.sql +7 -0
- package/drizzle/0009_memory_notes.sql +11 -0
- package/drizzle/0010_memory_optimization.sql +15 -0
- package/drizzle/0011_usage_estimated.sql +1 -0
- package/drizzle/0012_message_alive_at.sql +1 -0
- package/drizzle/0013_spending_limit_states.sql +6 -0
- package/drizzle/0014_chat_origin.sql +1 -0
- package/drizzle/0015_chat_subject_after_turn.sql +5 -0
- package/drizzle/0016_chat_closed.sql +1 -0
- package/drizzle/0017_chat_turn_leases.sql +5 -0
- package/drizzle/meta/0000_snapshot.json +211 -0
- package/drizzle/meta/0001_snapshot.json +227 -0
- package/drizzle/meta/0002_snapshot.json +234 -0
- package/drizzle/meta/0003_snapshot.json +352 -0
- package/drizzle/meta/0004_snapshot.json +428 -0
- package/drizzle/meta/0005_snapshot.json +435 -0
- package/drizzle/meta/0006_snapshot.json +560 -0
- package/drizzle/meta/0007_snapshot.json +605 -0
- package/drizzle/meta/0008_snapshot.json +643 -0
- package/drizzle/meta/0009_snapshot.json +712 -0
- package/drizzle/meta/0010_snapshot.json +810 -0
- package/drizzle/meta/0011_snapshot.json +818 -0
- package/drizzle/meta/0012_snapshot.json +825 -0
- package/drizzle/meta/0013_snapshot.json +863 -0
- package/drizzle/meta/0014_snapshot.json +870 -0
- package/drizzle/meta/0015_snapshot.json +907 -0
- package/drizzle/meta/0016_snapshot.json +914 -0
- package/drizzle/meta/0017_snapshot.json +945 -0
- package/drizzle/meta/_journal.json +132 -0
- package/package.json +44 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
// src/sqlite-storage.ts
|
|
2
|
+
import { fileURLToPath } from "url";
|
|
3
|
+
import {
|
|
4
|
+
aliveAtByLocalClock,
|
|
5
|
+
assertChatDataFilter,
|
|
6
|
+
assertKnownFormatVersion,
|
|
7
|
+
ChatAlreadyExistsError,
|
|
8
|
+
chatDataFields,
|
|
9
|
+
chatDataOfInput,
|
|
10
|
+
DuplicateChatSubjectError,
|
|
11
|
+
DuplicateMessageError,
|
|
12
|
+
isOptimizationBlocked,
|
|
13
|
+
mergeChatData,
|
|
14
|
+
MessageAlreadyFinishedError,
|
|
15
|
+
MessageNotAwaitingError,
|
|
16
|
+
revertRefusal,
|
|
17
|
+
sameMessageParts,
|
|
18
|
+
silenceBoundaryByLocalClock
|
|
19
|
+
} from "@ykurilov/agent-chat-core";
|
|
20
|
+
import Database from "better-sqlite3";
|
|
21
|
+
import { and, asc, desc, eq, gte, inArray, isNotNull, isNull, sql as sql2 } from "drizzle-orm";
|
|
22
|
+
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
23
|
+
import { readMigrationFiles } from "drizzle-orm/migrator";
|
|
24
|
+
|
|
25
|
+
// src/schema.ts
|
|
26
|
+
import { sql } from "drizzle-orm";
|
|
27
|
+
import { index, integer, real, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
|
28
|
+
var chats = sqliteTable(
|
|
29
|
+
"chats",
|
|
30
|
+
{
|
|
31
|
+
id: text("id").primaryKey(),
|
|
32
|
+
ownerId: text("owner_id").notNull(),
|
|
33
|
+
modelId: text("model_id").notNull(),
|
|
34
|
+
providerId: text("provider_id").notNull(),
|
|
35
|
+
reasoning: text("reasoning"),
|
|
36
|
+
/** Название для списка чатов; пусто — чат без названия. */
|
|
37
|
+
title: text("title"),
|
|
38
|
+
/** Мессенджер, из которого создан чат; пусто — чат создан в вебе. */
|
|
39
|
+
origin: text("origin", { enum: ["telegram", "vk", "max"] }),
|
|
40
|
+
/** Ключ объекта приложения; пусто — обычный чат. У владельца ключ не повторяется. */
|
|
41
|
+
subjectKey: text("subject_key"),
|
|
42
|
+
/** Данные объекта (JSON-объект); пусто — данных нет. */
|
|
43
|
+
subjectData: text("subject_data", { mode: "json" }).$type(),
|
|
44
|
+
/** Закрытие чата (JSON `{ code, message }`); пусто — чат открыт. */
|
|
45
|
+
closed: text("closed", { mode: "json" }).$type(),
|
|
46
|
+
createdAt: text("created_at").notNull(),
|
|
47
|
+
updatedAt: text("updated_at").notNull()
|
|
48
|
+
},
|
|
49
|
+
(table) => [
|
|
50
|
+
index("chats_owner_updated_idx").on(table.ownerId, table.updatedAt),
|
|
51
|
+
uniqueIndex("chats_owner_subject_idx").on(table.ownerId, table.subjectKey)
|
|
52
|
+
]
|
|
53
|
+
);
|
|
54
|
+
var messages = sqliteTable(
|
|
55
|
+
"messages",
|
|
56
|
+
{
|
|
57
|
+
id: text("id").primaryKey(),
|
|
58
|
+
chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
|
|
59
|
+
parentId: text("parent_id"),
|
|
60
|
+
role: text("role", { enum: ["user", "assistant"] }).notNull(),
|
|
61
|
+
parts: text("parts", { mode: "json" }).$type().notNull(),
|
|
62
|
+
formatVersion: integer("format_version").notNull(),
|
|
63
|
+
status: text("status").$type().notNull(),
|
|
64
|
+
modelId: text("model_id"),
|
|
65
|
+
providerId: text("provider_id"),
|
|
66
|
+
usage: text("usage", { mode: "json" }).$type(),
|
|
67
|
+
reasoningDurationsMs: text("reasoning_durations_ms", { mode: "json" }).$type().notNull(),
|
|
68
|
+
error: text("error", { mode: "json" }).$type(),
|
|
69
|
+
/** Отметка обрыва озвучки (голосовой режим); пусто — озвучку не обрывали. */
|
|
70
|
+
voiceCut: text("voice_cut", { mode: "json" }).$type(),
|
|
71
|
+
/** Состояние хука итога хода; пусто — хук не зовётся. */
|
|
72
|
+
afterTurn: text("after_turn", { enum: ["pending", "done", "failed"] }),
|
|
73
|
+
createdAt: text("created_at").notNull(),
|
|
74
|
+
/** Последний знак жизни идущего ответа (ISO); пусто — время создания. Только для восстановления, при чтении не отдаётся. */
|
|
75
|
+
aliveAt: text("alive_at"),
|
|
76
|
+
/** Порядок записи: при равном `created_at` сообщения читаются по возрастанию `seq`. Задаётся при вставке. */
|
|
77
|
+
seq: integer("seq").notNull().default(0)
|
|
78
|
+
},
|
|
79
|
+
(table) => [
|
|
80
|
+
index("messages_chat_created_idx").on(table.chatId, table.createdAt, table.seq),
|
|
81
|
+
uniqueIndex("messages_seq_idx").on(table.seq),
|
|
82
|
+
index("messages_streaming_idx").on(table.status).where(sql`${table.status} = 'streaming'`),
|
|
83
|
+
index("messages_after_turn_pending_idx").on(table.afterTurn).where(sql`${table.afterTurn} = 'pending'`)
|
|
84
|
+
]
|
|
85
|
+
);
|
|
86
|
+
var compactions = sqliteTable(
|
|
87
|
+
"compactions",
|
|
88
|
+
{
|
|
89
|
+
id: text("id").primaryKey(),
|
|
90
|
+
chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
|
|
91
|
+
fromMessageId: text("from_message_id").notNull(),
|
|
92
|
+
throughMessageId: text("through_message_id").notNull(),
|
|
93
|
+
summary: text("summary").notNull(),
|
|
94
|
+
summaryTokens: integer("summary_tokens").notNull(),
|
|
95
|
+
modelId: text("model_id").notNull(),
|
|
96
|
+
providerId: text("provider_id").notNull(),
|
|
97
|
+
usage: text("usage", { mode: "json" }).$type(),
|
|
98
|
+
createdAt: text("created_at").notNull(),
|
|
99
|
+
seq: integer("seq").notNull().default(0)
|
|
100
|
+
},
|
|
101
|
+
(table) => [
|
|
102
|
+
index("compactions_chat_seq_idx").on(table.chatId, table.seq),
|
|
103
|
+
uniqueIndex("compactions_seq_idx").on(table.seq)
|
|
104
|
+
]
|
|
105
|
+
);
|
|
106
|
+
var branchSelections = sqliteTable(
|
|
107
|
+
"branch_selections",
|
|
108
|
+
{
|
|
109
|
+
id: text("id").primaryKey(),
|
|
110
|
+
chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
|
|
111
|
+
messageId: text("message_id").notNull(),
|
|
112
|
+
selectedAt: text("selected_at").notNull(),
|
|
113
|
+
seq: integer("seq").notNull().default(0)
|
|
114
|
+
},
|
|
115
|
+
(table) => [
|
|
116
|
+
index("branch_selections_chat_seq_idx").on(table.chatId, table.seq),
|
|
117
|
+
uniqueIndex("branch_selections_seq_idx").on(table.seq)
|
|
118
|
+
]
|
|
119
|
+
);
|
|
120
|
+
var usageRecords = sqliteTable(
|
|
121
|
+
"usage_records",
|
|
122
|
+
{
|
|
123
|
+
id: text("id").primaryKey(),
|
|
124
|
+
ownerId: text("owner_id").notNull(),
|
|
125
|
+
chatId: text("chat_id").notNull(),
|
|
126
|
+
messageId: text("message_id"),
|
|
127
|
+
source: text("source").$type().notNull(),
|
|
128
|
+
modelId: text("model_id").notNull(),
|
|
129
|
+
providerId: text("provider_id").notNull(),
|
|
130
|
+
inputTokens: integer("input_tokens").notNull(),
|
|
131
|
+
cachedInputTokens: integer("cached_input_tokens").notNull(),
|
|
132
|
+
cacheWriteTokens: integer("cache_write_tokens").notNull(),
|
|
133
|
+
outputTokens: integer("output_tokens").notNull(),
|
|
134
|
+
reasoningTokens: integer("reasoning_tokens").notNull(),
|
|
135
|
+
costUsd: real("cost_usd").notNull(),
|
|
136
|
+
createdAt: text("created_at").notNull(),
|
|
137
|
+
/** Числа «примерно»: прикинуты по длине текста, а не сообщены провайдером. */
|
|
138
|
+
estimated: integer("estimated", { mode: "boolean" }).notNull().default(false)
|
|
139
|
+
},
|
|
140
|
+
(table) => [
|
|
141
|
+
index("usage_records_owner_created_idx").on(table.ownerId, table.createdAt),
|
|
142
|
+
index("usage_records_chat_created_idx").on(table.chatId, table.createdAt)
|
|
143
|
+
]
|
|
144
|
+
);
|
|
145
|
+
var userModelChoices = sqliteTable("user_model_choices", {
|
|
146
|
+
ownerId: text("owner_id").primaryKey(),
|
|
147
|
+
modelId: text("model_id").notNull(),
|
|
148
|
+
providerId: text("provider_id").notNull(),
|
|
149
|
+
/** Пусто — у модели нет уровней рассуждений. */
|
|
150
|
+
reasoning: text("reasoning"),
|
|
151
|
+
updatedAt: text("updated_at").notNull()
|
|
152
|
+
});
|
|
153
|
+
var voiceChoices = sqliteTable("voice_choices", {
|
|
154
|
+
ownerId: text("owner_id").primaryKey(),
|
|
155
|
+
voiceId: text("voice_id").notNull(),
|
|
156
|
+
updatedAt: text("updated_at").notNull()
|
|
157
|
+
});
|
|
158
|
+
var spendingLimitStates = sqliteTable("spending_limit_states", {
|
|
159
|
+
key: text("key").primaryKey(),
|
|
160
|
+
value: text("value").notNull(),
|
|
161
|
+
version: integer("version").notNull(),
|
|
162
|
+
updatedAt: text("updated_at").notNull()
|
|
163
|
+
});
|
|
164
|
+
var chatTurnLeases = sqliteTable("chat_turn_leases", {
|
|
165
|
+
chatId: text("chat_id").primaryKey(),
|
|
166
|
+
holder: text("holder").notNull(),
|
|
167
|
+
until: integer("until").notNull()
|
|
168
|
+
});
|
|
169
|
+
var memoryNotes = sqliteTable(
|
|
170
|
+
"memory_notes",
|
|
171
|
+
{
|
|
172
|
+
id: text("id").primaryKey(),
|
|
173
|
+
ownerId: text("owner_id").notNull(),
|
|
174
|
+
categories: text("categories", { mode: "json" }).$type().notNull(),
|
|
175
|
+
content: text("content").notNull(),
|
|
176
|
+
version: integer("version").notNull().default(1),
|
|
177
|
+
createdAt: integer("created_at").notNull(),
|
|
178
|
+
updatedAt: integer("updated_at").notNull(),
|
|
179
|
+
/** Оптимизация дублей: id записи-результата у скрытого оригинала слияния; у обычных записей пусто. */
|
|
180
|
+
mergedInto: text("merged_into")
|
|
181
|
+
},
|
|
182
|
+
(table) => [
|
|
183
|
+
index("memory_notes_owner_updated_idx").on(table.ownerId, table.updatedAt),
|
|
184
|
+
index("memory_notes_merged_into_idx").on(table.mergedInto)
|
|
185
|
+
]
|
|
186
|
+
);
|
|
187
|
+
var memoryOptimizationRuns = sqliteTable(
|
|
188
|
+
"memory_optimization_runs",
|
|
189
|
+
{
|
|
190
|
+
id: text("id").primaryKey(),
|
|
191
|
+
ownerId: text("owner_id").notNull(),
|
|
192
|
+
startedAt: integer("started_at").notNull(),
|
|
193
|
+
finishedAt: integer("finished_at"),
|
|
194
|
+
status: text("status").$type().notNull(),
|
|
195
|
+
groups: text("groups", { mode: "json" }).$type().notNull().default(sql`'[]'`),
|
|
196
|
+
error: text("error"),
|
|
197
|
+
usageRecordId: text("usage_record_id")
|
|
198
|
+
},
|
|
199
|
+
(table) => [
|
|
200
|
+
index("memory_optimization_runs_owner_started_idx").on(table.ownerId, table.startedAt),
|
|
201
|
+
uniqueIndex("memory_optimization_runs_one_running_idx").on(table.ownerId).where(sql`${table.status} = 'running'`)
|
|
202
|
+
]
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
// src/sqlite-storage.ts
|
|
206
|
+
var migrationsFolder = fileURLToPath(new URL("../drizzle", import.meta.url));
|
|
207
|
+
var MIGRATIONS_TABLE = "__agent_chat_migrations";
|
|
208
|
+
var SHARED_MIGRATIONS_TABLE = "__drizzle_migrations";
|
|
209
|
+
var ForeignChatTablesError = class extends Error {
|
|
210
|
+
constructor(filePath, tables) {
|
|
211
|
+
super(
|
|
212
|
+
`\u0412 \u0444\u0430\u0439\u043B\u0435 ${filePath} \u0447\u0443\u0436\u0438\u0435 \u0442\u0430\u0431\u043B\u0438\u0446\u044B ${tables.join(" \u0438 ")}: \u0438\u0445 \u0441\u043E\u0437\u0434\u0430\u043B\u043E \u043D\u0435 SDK \u0447\u0430\u0442\u0430. \u0414\u0430\u0439\u0442\u0435 SDK \u043E\u0442\u0434\u0435\u043B\u044C\u043D\u044B\u0439 \u0444\u0430\u0439\u043B (\u043D\u0430\u043F\u0440\u0438\u043C\u0435\u0440, data/chat.sqlite): \u043E\u0431\u0449\u0438\u0439 \u0444\u0430\u0439\u043B \u0441 \u0442\u0430\u0431\u043B\u0438\u0446\u0430\u043C\u0438 \u043F\u0440\u0438\u043B\u043E\u0436\u0435\u043D\u0438\u044F \u043D\u0435 \u043F\u043E\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044F. \u0424\u0430\u0439\u043B \u043D\u0435 \u0442\u0440\u043E\u043D\u0443\u0442.`
|
|
213
|
+
);
|
|
214
|
+
this.filePath = filePath;
|
|
215
|
+
this.tables = tables;
|
|
216
|
+
this.name = "ForeignChatTablesError";
|
|
217
|
+
}
|
|
218
|
+
filePath;
|
|
219
|
+
tables;
|
|
220
|
+
};
|
|
221
|
+
function assertNoForeignChatTables(sqlite, filePath) {
|
|
222
|
+
const names = tableNames(sqlite);
|
|
223
|
+
const found = ["chats", "messages"].filter((name) => names.has(name));
|
|
224
|
+
if (found.length === 0) return;
|
|
225
|
+
const firstMigrationHash = readMigrationFiles({ migrationsFolder })[0]?.hash;
|
|
226
|
+
const ours = [MIGRATIONS_TABLE, SHARED_MIGRATIONS_TABLE].some((table) => {
|
|
227
|
+
try {
|
|
228
|
+
return names.has(table) && sqlite.prepare(`select 1 from "${table}" where hash = ?`).get(firstMigrationHash) !== void 0;
|
|
229
|
+
} catch {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
if (!ours) throw new ForeignChatTablesError(filePath, found);
|
|
234
|
+
}
|
|
235
|
+
function tableNames(sqlite) {
|
|
236
|
+
return new Set(sqlite.prepare("select name from sqlite_master where type = 'table'").pluck().all());
|
|
237
|
+
}
|
|
238
|
+
function moveSharedMigrationJournal(sqlite) {
|
|
239
|
+
sqlite.transaction(() => {
|
|
240
|
+
const names = tableNames(sqlite);
|
|
241
|
+
if (names.has(MIGRATIONS_TABLE) || !names.has(SHARED_MIGRATIONS_TABLE)) return;
|
|
242
|
+
let rows;
|
|
243
|
+
try {
|
|
244
|
+
rows = sqlite.prepare(`select hash, created_at from "${SHARED_MIGRATIONS_TABLE}"`).all();
|
|
245
|
+
} catch {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const sdk = readMigrationFiles({ migrationsFolder });
|
|
249
|
+
const ours = rows.filter((row) => sdk.some((migration) => migration.hash === row.hash || migration.folderMillis === Number(row.created_at)));
|
|
250
|
+
if (ours.length === 0) return;
|
|
251
|
+
sqlite.exec(`create table "${MIGRATIONS_TABLE}" (id SERIAL PRIMARY KEY, hash text NOT NULL, created_at numeric)`);
|
|
252
|
+
const insert = sqlite.prepare(`insert into "${MIGRATIONS_TABLE}" (hash, created_at) values (?, ?)`);
|
|
253
|
+
for (const row of ours) insert.run(row.hash, row.created_at);
|
|
254
|
+
}).immediate();
|
|
255
|
+
}
|
|
256
|
+
function applyMigrations(sqlite) {
|
|
257
|
+
const migrations = readMigrationFiles({ migrationsFolder });
|
|
258
|
+
sqlite.transaction(() => {
|
|
259
|
+
sqlite.exec(`create table if not exists "${MIGRATIONS_TABLE}" (id SERIAL PRIMARY KEY, hash text NOT NULL, created_at numeric)`);
|
|
260
|
+
const rows = sqlite.prepare(`select hash, created_at from "${MIGRATIONS_TABLE}"`).all();
|
|
261
|
+
const hashes = new Set(rows.map((row) => row.hash));
|
|
262
|
+
const times = new Set(rows.map((row) => Number(row.created_at)));
|
|
263
|
+
const record = sqlite.prepare(`insert into "${MIGRATIONS_TABLE}" (hash, created_at) values (?, ?)`);
|
|
264
|
+
for (const migration of migrations) {
|
|
265
|
+
if (hashes.has(migration.hash) || times.has(migration.folderMillis)) continue;
|
|
266
|
+
for (const statement of migration.sql) if (statement.trim()) sqlite.exec(statement);
|
|
267
|
+
record.run(migration.hash, migration.folderMillis);
|
|
268
|
+
}
|
|
269
|
+
}).immediate();
|
|
270
|
+
}
|
|
271
|
+
function createSqliteChatStorage(options) {
|
|
272
|
+
const sqlite = new Database(options.filePath);
|
|
273
|
+
try {
|
|
274
|
+
assertNoForeignChatTables(sqlite, options.filePath);
|
|
275
|
+
moveSharedMigrationJournal(sqlite);
|
|
276
|
+
} catch (error) {
|
|
277
|
+
sqlite.close();
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
sqlite.pragma("journal_mode = WAL");
|
|
281
|
+
sqlite.pragma("foreign_keys = ON");
|
|
282
|
+
const db = drizzle(sqlite, { schema: { chats, messages, compactions, branchSelections, usageRecords, userModelChoices, voiceChoices, spendingLimitStates, memoryNotes, memoryOptimizationRuns } });
|
|
283
|
+
applyMigrations(sqlite);
|
|
284
|
+
function visibleNotes(tx, ownerId) {
|
|
285
|
+
return tx.select().from(memoryNotes).where(and(eq(memoryNotes.ownerId, ownerId), isNull(memoryNotes.mergedInto))).orderBy(desc(memoryNotes.updatedAt), desc(memoryNotes.createdAt), desc(memoryNotes.id)).all().map(toMemoryNote);
|
|
286
|
+
}
|
|
287
|
+
function insertNotes(tx, ownerId, inputs) {
|
|
288
|
+
const now = Date.now();
|
|
289
|
+
const rows = inputs.map(({ categories, content }) => ({
|
|
290
|
+
id: crypto.randomUUID(),
|
|
291
|
+
ownerId,
|
|
292
|
+
categories,
|
|
293
|
+
content,
|
|
294
|
+
version: 1,
|
|
295
|
+
createdAt: now,
|
|
296
|
+
updatedAt: now
|
|
297
|
+
}));
|
|
298
|
+
for (const row of rows) tx.insert(memoryNotes).values(row).run();
|
|
299
|
+
return rows.map((row) => toMemoryNote(row));
|
|
300
|
+
}
|
|
301
|
+
function updateNote(tx, ownerId, id, expectedVersion, input) {
|
|
302
|
+
const row = tx.select().from(memoryNotes).where(ownerNote(ownerId, id)).get();
|
|
303
|
+
if (!row) return "not_found";
|
|
304
|
+
if (row.version !== expectedVersion) return "stale_version";
|
|
305
|
+
const updated = tx.update(memoryNotes).set({
|
|
306
|
+
categories: input.categories,
|
|
307
|
+
content: input.content,
|
|
308
|
+
version: row.version + 1,
|
|
309
|
+
// Часы не отстают от записи: время правки не убывает.
|
|
310
|
+
updatedAt: Math.max(Date.now(), row.updatedAt)
|
|
311
|
+
}).where(and(ownerNote(ownerId, id), eq(memoryNotes.version, expectedVersion))).returning().get();
|
|
312
|
+
return updated ? toMemoryNote(updated) : "stale_version";
|
|
313
|
+
}
|
|
314
|
+
const aliveAtColumn = (alive) => alive ? { aliveAt: aliveAtByLocalClock(alive) } : {};
|
|
315
|
+
function updateStreaming(messageId, { alive, ...values }) {
|
|
316
|
+
const result = db.update(messages).set({ ...values, ...aliveAtColumn(alive) }).where(and(eq(messages.id, messageId), eq(messages.status, "streaming"))).run();
|
|
317
|
+
if (result.changes > 0) return;
|
|
318
|
+
const existing = db.select({ id: messages.id }).from(messages).where(eq(messages.id, messageId)).get();
|
|
319
|
+
if (!existing) throw new Error(`\u0421\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u044F ${messageId} \u043D\u0435\u0442`);
|
|
320
|
+
throw new MessageAlreadyFinishedError(messageId);
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
async createChat({ createdAt, ...input }) {
|
|
324
|
+
const at = createdAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
325
|
+
const row = chatRow({ ...input, createdAt: at, updatedAt: at });
|
|
326
|
+
try {
|
|
327
|
+
db.insert(chats).values(row).run();
|
|
328
|
+
} catch (error) {
|
|
329
|
+
if (isSubjectConflict(error)) throw new DuplicateChatSubjectError(input.ownerId, input.subjectKey);
|
|
330
|
+
throw error;
|
|
331
|
+
}
|
|
332
|
+
return toChat(row);
|
|
333
|
+
},
|
|
334
|
+
async findChatBySubject(ownerId, subjectKey) {
|
|
335
|
+
const row = db.select().from(chats).where(and(eq(chats.ownerId, ownerId), eq(chats.subjectKey, subjectKey))).get();
|
|
336
|
+
return row ? toChat(row) : null;
|
|
337
|
+
},
|
|
338
|
+
async importChat(chat, imported) {
|
|
339
|
+
db.transaction((tx) => {
|
|
340
|
+
try {
|
|
341
|
+
tx.insert(chats).values(chatRow(chat)).run();
|
|
342
|
+
} catch (error) {
|
|
343
|
+
if (isSubjectConflict(error)) throw new DuplicateChatSubjectError(chat.ownerId, chat.subjectKey);
|
|
344
|
+
if (isPrimaryKeyConflict(error)) throw new ChatAlreadyExistsError(chat.id);
|
|
345
|
+
throw error;
|
|
346
|
+
}
|
|
347
|
+
for (const message of imported) {
|
|
348
|
+
const nextSeq = sql2`(select coalesce(max(${messages.seq}), 0) + 1 from ${messages})`;
|
|
349
|
+
const { alive: _alive, ...stored } = message;
|
|
350
|
+
try {
|
|
351
|
+
tx.insert(messages).values({ ...stored, seq: nextSeq }).run();
|
|
352
|
+
} catch (error) {
|
|
353
|
+
if (isPrimaryKeyConflict(error)) throw new DuplicateMessageError(message.id);
|
|
354
|
+
throw error;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}, { behavior: "immediate" });
|
|
358
|
+
},
|
|
359
|
+
async setAfterTurnState(messageId, state, options2) {
|
|
360
|
+
return db.transaction(
|
|
361
|
+
(tx) => {
|
|
362
|
+
const existing = tx.select({ parts: messages.parts, afterTurn: messages.afterTurn }).from(messages).where(eq(messages.id, messageId)).get();
|
|
363
|
+
if (!existing || state !== "pending" && existing.afterTurn !== "pending") return false;
|
|
364
|
+
tx.update(messages).set({
|
|
365
|
+
afterTurn: state,
|
|
366
|
+
...options2?.appendParts && { parts: [...existing.parts, ...options2.appendParts] },
|
|
367
|
+
// Отметка ожидания хука — в колонке знака жизни: у законченного ответа она больше ни для чего.
|
|
368
|
+
...state === "pending" && aliveAtColumn("now")
|
|
369
|
+
}).where(eq(messages.id, messageId)).run();
|
|
370
|
+
return true;
|
|
371
|
+
},
|
|
372
|
+
{ behavior: "immediate" }
|
|
373
|
+
);
|
|
374
|
+
},
|
|
375
|
+
async listPendingAfterTurn(filter) {
|
|
376
|
+
return db.select({ id: messages.id }).from(messages).where(
|
|
377
|
+
filter?.pendingForMs === void 0 ? eq(messages.afterTurn, "pending") : and(
|
|
378
|
+
eq(messages.afterTurn, "pending"),
|
|
379
|
+
sql2`coalesce(${messages.aliveAt}, ${messages.createdAt}) < ${silenceBoundaryByLocalClock(filter.pendingForMs)}`
|
|
380
|
+
)
|
|
381
|
+
).all().map((row) => row.id);
|
|
382
|
+
},
|
|
383
|
+
async getChat(chatId) {
|
|
384
|
+
const row = db.select().from(chats).where(eq(chats.id, chatId)).get();
|
|
385
|
+
return row ? toChat(row) : null;
|
|
386
|
+
},
|
|
387
|
+
async updateChatModel(chatId, model) {
|
|
388
|
+
db.update(chats).set({ modelId: model.modelId, providerId: model.providerId, reasoning: model.reasoning ?? null }).where(eq(chats.id, chatId)).run();
|
|
389
|
+
},
|
|
390
|
+
async updateChatData(chatId, patch, { maxBytes }) {
|
|
391
|
+
return db.transaction(
|
|
392
|
+
(tx) => {
|
|
393
|
+
const row = tx.select().from(chats).where(eq(chats.id, chatId)).get();
|
|
394
|
+
if (!row) return null;
|
|
395
|
+
const merged = mergeChatData(row.subjectData ?? void 0, patch, maxBytes);
|
|
396
|
+
tx.update(chats).set({ subjectData: merged ?? null }).where(eq(chats.id, chatId)).run();
|
|
397
|
+
return toChat({ ...row, subjectData: merged ?? null });
|
|
398
|
+
},
|
|
399
|
+
{ behavior: "immediate" }
|
|
400
|
+
);
|
|
401
|
+
},
|
|
402
|
+
async listChats(ownerId, options2) {
|
|
403
|
+
if (options2?.data) assertChatDataFilter(options2.data);
|
|
404
|
+
const rows = db.select().from(chats).where(and(eq(chats.ownerId, ownerId), subjectCondition(options2?.subjects), ...dataConditions(options2?.data))).orderBy(desc(chats.updatedAt), desc(chats.createdAt), desc(chats.id)).all();
|
|
405
|
+
return rows.map(toChat);
|
|
406
|
+
},
|
|
407
|
+
async renameChat(chatId, title) {
|
|
408
|
+
db.update(chats).set({ title }).where(eq(chats.id, chatId)).run();
|
|
409
|
+
},
|
|
410
|
+
async setChatClosed(chatId, closed) {
|
|
411
|
+
db.update(chats).set({ closed: closed && { code: closed.code, message: closed.message } }).where(eq(chats.id, chatId)).run();
|
|
412
|
+
},
|
|
413
|
+
async deleteChat(chatId) {
|
|
414
|
+
db.transaction((tx) => {
|
|
415
|
+
tx.delete(compactions).where(eq(compactions.chatId, chatId)).run();
|
|
416
|
+
tx.delete(branchSelections).where(eq(branchSelections.chatId, chatId)).run();
|
|
417
|
+
tx.delete(messages).where(eq(messages.chatId, chatId)).run();
|
|
418
|
+
tx.delete(chats).where(eq(chats.id, chatId)).run();
|
|
419
|
+
tx.delete(chatTurnLeases).where(eq(chatTurnLeases.chatId, chatId)).run();
|
|
420
|
+
}, { behavior: "immediate" });
|
|
421
|
+
},
|
|
422
|
+
async claimChatTurn(chatId, holder, leaseMs) {
|
|
423
|
+
const now = Date.now();
|
|
424
|
+
const claimed = db.insert(chatTurnLeases).values({ chatId, holder, until: now + leaseMs }).onConflictDoUpdate({
|
|
425
|
+
target: chatTurnLeases.chatId,
|
|
426
|
+
set: { holder, until: now + leaseMs },
|
|
427
|
+
setWhere: sql2`${chatTurnLeases.holder} = ${holder} or ${chatTurnLeases.until} <= ${now}`
|
|
428
|
+
}).returning({ chatId: chatTurnLeases.chatId }).all();
|
|
429
|
+
return claimed.length > 0;
|
|
430
|
+
},
|
|
431
|
+
async releaseChatTurn(chatId, holder) {
|
|
432
|
+
db.delete(chatTurnLeases).where(and(eq(chatTurnLeases.chatId, chatId), eq(chatTurnLeases.holder, holder))).run();
|
|
433
|
+
},
|
|
434
|
+
async clearChatTurnLeases() {
|
|
435
|
+
db.delete(chatTurnLeases).run();
|
|
436
|
+
},
|
|
437
|
+
async getMessage(messageId) {
|
|
438
|
+
const row = db.select().from(messages).where(eq(messages.id, messageId)).get();
|
|
439
|
+
if (!row) return null;
|
|
440
|
+
const message = toMessage(row);
|
|
441
|
+
assertKnownFormatVersion(message);
|
|
442
|
+
return message;
|
|
443
|
+
},
|
|
444
|
+
// При равном времени порядок записи задаёт `seq`: `rowid` VACUUM вправе перенумеровать.
|
|
445
|
+
async listMessages(chatId) {
|
|
446
|
+
const rows = db.select().from(messages).where(eq(messages.chatId, chatId)).orderBy(asc(messages.createdAt), messages.seq).all();
|
|
447
|
+
const found = rows.map(toMessage);
|
|
448
|
+
found.forEach(assertKnownFormatVersion);
|
|
449
|
+
return found;
|
|
450
|
+
},
|
|
451
|
+
async listMessageLinks(chatId) {
|
|
452
|
+
return db.select({ id: messages.id, parentId: messages.parentId, createdAt: messages.createdAt }).from(messages).where(eq(messages.chatId, chatId)).orderBy(asc(messages.createdAt), messages.seq).all();
|
|
453
|
+
},
|
|
454
|
+
// Части нужных типов выбираются внутри базы: тексты и выводы инструментов в процесс не идут.
|
|
455
|
+
async listMessagePartsOfTypes(chatId, types) {
|
|
456
|
+
const wanted = JSON.stringify([...types]);
|
|
457
|
+
const rows = db.select({
|
|
458
|
+
id: messages.id,
|
|
459
|
+
parentId: messages.parentId,
|
|
460
|
+
createdAt: messages.createdAt,
|
|
461
|
+
parts: sql2`(select coalesce(json_group_array(json(picked.value)), '[]') from (select part.value from json_each(${messages.parts}) as part where json_extract(part.value, '$.type') in (select value from json_each(${wanted})) order by part.key) as picked)`
|
|
462
|
+
}).from(messages).where(eq(messages.chatId, chatId)).orderBy(asc(messages.createdAt), messages.seq).all();
|
|
463
|
+
return rows.map((row) => ({ ...row, parts: JSON.parse(row.parts) }));
|
|
464
|
+
},
|
|
465
|
+
async insertMessage(message) {
|
|
466
|
+
try {
|
|
467
|
+
db.transaction((tx) => {
|
|
468
|
+
const nextSeq = sql2`(select coalesce(max(${messages.seq}), 0) + 1 from ${messages})`;
|
|
469
|
+
const { alive, ...stored } = message;
|
|
470
|
+
tx.insert(messages).values({ ...stored, ...aliveAtColumn(alive), seq: nextSeq }).run();
|
|
471
|
+
tx.update(chats).set({ updatedAt: message.createdAt }).where(eq(chats.id, message.chatId)).run();
|
|
472
|
+
}, { behavior: "immediate" });
|
|
473
|
+
} catch (error) {
|
|
474
|
+
if (isPrimaryKeyConflict(error)) throw new DuplicateMessageError(message.id);
|
|
475
|
+
throw error;
|
|
476
|
+
}
|
|
477
|
+
},
|
|
478
|
+
updateStreamingMessage: async (messageId, patch) => updateStreaming(messageId, patch),
|
|
479
|
+
markAnswerAlive: async (messageId) => updateStreaming(messageId, { alive: "now" }),
|
|
480
|
+
finishMessage: async (messageId, final) => updateStreaming(messageId, { ...final, ...final.afterTurn === "pending" && { alive: "now" } }),
|
|
481
|
+
async updateAwaitingMessage(messageId, patch) {
|
|
482
|
+
db.transaction(
|
|
483
|
+
(tx) => {
|
|
484
|
+
const existing = tx.select({ status: messages.status, parts: messages.parts }).from(messages).where(eq(messages.id, messageId)).get();
|
|
485
|
+
if (!existing) throw new Error(`\u0421\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u044F ${messageId} \u043D\u0435\u0442`);
|
|
486
|
+
if (existing.status !== "awaiting_approval") throw new MessageNotAwaitingError(messageId);
|
|
487
|
+
if (patch.expectedParts && !sameMessageParts(existing.parts, patch.expectedParts)) throw new MessageNotAwaitingError(messageId);
|
|
488
|
+
tx.update(messages).set({ parts: patch.parts, ...patch.status && { status: patch.status }, ...aliveAtColumn(patch.alive) }).where(eq(messages.id, messageId)).run();
|
|
489
|
+
},
|
|
490
|
+
{ behavior: "immediate" }
|
|
491
|
+
);
|
|
492
|
+
},
|
|
493
|
+
async listStreamingMessageIds() {
|
|
494
|
+
return db.select({ id: messages.id }).from(messages).where(eq(messages.status, "streaming")).all().map((row) => row.id);
|
|
495
|
+
},
|
|
496
|
+
async listSilentAnswerIds({ silentForMs, chatId }) {
|
|
497
|
+
const silent = sql2`coalesce(${messages.aliveAt}, ${messages.createdAt}) < ${silenceBoundaryByLocalClock(silentForMs)}`;
|
|
498
|
+
return db.select({ id: messages.id }).from(messages).where(and(eq(messages.status, "streaming"), silent, chatId === void 0 ? void 0 : eq(messages.chatId, chatId))).all().map((row) => row.id);
|
|
499
|
+
},
|
|
500
|
+
async insertCompaction(record) {
|
|
501
|
+
const nextSeq = sql2`(select coalesce(max(${compactions.seq}), 0) + 1 from ${compactions})`;
|
|
502
|
+
db.insert(compactions).values({ ...record, seq: nextSeq }).run();
|
|
503
|
+
},
|
|
504
|
+
async listCompactions(chatId) {
|
|
505
|
+
const rows = db.select().from(compactions).where(eq(compactions.chatId, chatId)).orderBy(asc(compactions.seq)).all();
|
|
506
|
+
return rows.map(({ seq: _seq, ...record }) => record);
|
|
507
|
+
},
|
|
508
|
+
async insertBranchSelection(record) {
|
|
509
|
+
const nextSeq = sql2`(select coalesce(max(${branchSelections.seq}), 0) + 1 from ${branchSelections})`;
|
|
510
|
+
db.insert(branchSelections).values({ ...record, seq: nextSeq }).run();
|
|
511
|
+
},
|
|
512
|
+
async listBranchSelections(chatId) {
|
|
513
|
+
const rows = db.select().from(branchSelections).where(eq(branchSelections.chatId, chatId)).orderBy(asc(branchSelections.seq)).all();
|
|
514
|
+
return rows.map(({ seq: _seq, ...record }) => record);
|
|
515
|
+
},
|
|
516
|
+
async insertUsageRecord(record) {
|
|
517
|
+
db.insert(usageRecords).values(record).run();
|
|
518
|
+
},
|
|
519
|
+
async hasUsageRecord(id) {
|
|
520
|
+
return db.select({ id: usageRecords.id }).from(usageRecords).where(eq(usageRecords.id, id)).get() !== void 0;
|
|
521
|
+
},
|
|
522
|
+
async sumUsage({ ownerId, chatId, messageId, source, since }) {
|
|
523
|
+
const filters = [
|
|
524
|
+
...ownerId !== void 0 ? [eq(usageRecords.ownerId, ownerId)] : [],
|
|
525
|
+
...chatId !== void 0 ? [eq(usageRecords.chatId, chatId)] : [],
|
|
526
|
+
...messageId !== void 0 ? [eq(usageRecords.messageId, messageId)] : [],
|
|
527
|
+
...source !== void 0 ? [eq(usageRecords.source, source)] : [],
|
|
528
|
+
...since !== void 0 ? [gte(usageRecords.createdAt, since)] : []
|
|
529
|
+
];
|
|
530
|
+
return db.select({
|
|
531
|
+
modelId: usageRecords.modelId,
|
|
532
|
+
providerId: usageRecords.providerId,
|
|
533
|
+
inputTokens: sql2`sum(${usageRecords.inputTokens})`.mapWith(Number),
|
|
534
|
+
cachedInputTokens: sql2`sum(${usageRecords.cachedInputTokens})`.mapWith(Number),
|
|
535
|
+
cacheWriteTokens: sql2`sum(${usageRecords.cacheWriteTokens})`.mapWith(Number),
|
|
536
|
+
outputTokens: sql2`sum(${usageRecords.outputTokens})`.mapWith(Number),
|
|
537
|
+
reasoningTokens: sql2`sum(${usageRecords.reasoningTokens})`.mapWith(Number),
|
|
538
|
+
costUsd: sql2`sum(${usageRecords.costUsd})`.mapWith(Number)
|
|
539
|
+
}).from(usageRecords).where(and(...filters)).groupBy(usageRecords.modelId, usageRecords.providerId).all();
|
|
540
|
+
},
|
|
541
|
+
async sumUsageByChat({ chatIds, ownerId, source, since }) {
|
|
542
|
+
if (chatIds.length === 0) return [];
|
|
543
|
+
const filters = [
|
|
544
|
+
inArray(usageRecords.chatId, [...chatIds]),
|
|
545
|
+
...ownerId !== void 0 ? [eq(usageRecords.ownerId, ownerId)] : [],
|
|
546
|
+
...source !== void 0 ? [eq(usageRecords.source, source)] : [],
|
|
547
|
+
...since !== void 0 ? [gte(usageRecords.createdAt, since)] : []
|
|
548
|
+
];
|
|
549
|
+
return db.select({
|
|
550
|
+
chatId: usageRecords.chatId,
|
|
551
|
+
modelId: usageRecords.modelId,
|
|
552
|
+
providerId: usageRecords.providerId,
|
|
553
|
+
inputTokens: sql2`sum(${usageRecords.inputTokens})`.mapWith(Number),
|
|
554
|
+
cachedInputTokens: sql2`sum(${usageRecords.cachedInputTokens})`.mapWith(Number),
|
|
555
|
+
cacheWriteTokens: sql2`sum(${usageRecords.cacheWriteTokens})`.mapWith(Number),
|
|
556
|
+
outputTokens: sql2`sum(${usageRecords.outputTokens})`.mapWith(Number),
|
|
557
|
+
reasoningTokens: sql2`sum(${usageRecords.reasoningTokens})`.mapWith(Number),
|
|
558
|
+
costUsd: sql2`sum(${usageRecords.costUsd})`.mapWith(Number)
|
|
559
|
+
}).from(usageRecords).where(and(...filters)).groupBy(usageRecords.chatId, usageRecords.modelId, usageRecords.providerId).all();
|
|
560
|
+
},
|
|
561
|
+
async getUserModelChoice(ownerId) {
|
|
562
|
+
const row = db.select().from(userModelChoices).where(eq(userModelChoices.ownerId, ownerId)).get();
|
|
563
|
+
if (!row) return null;
|
|
564
|
+
return { modelId: row.modelId, providerId: row.providerId, ...row.reasoning !== null && { reasoning: row.reasoning } };
|
|
565
|
+
},
|
|
566
|
+
async saveUserModelChoice(ownerId, choice) {
|
|
567
|
+
const values = { modelId: choice.modelId, providerId: choice.providerId, reasoning: choice.reasoning ?? null, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
568
|
+
db.insert(userModelChoices).values({ ownerId, ...values }).onConflictDoUpdate({ target: userModelChoices.ownerId, set: values }).run();
|
|
569
|
+
},
|
|
570
|
+
async setVoiceCut({ ownerId, chatId, messageId, voiceCut }) {
|
|
571
|
+
db.update(messages).set({ voiceCut }).where(
|
|
572
|
+
and(
|
|
573
|
+
eq(messages.id, messageId),
|
|
574
|
+
eq(messages.chatId, chatId),
|
|
575
|
+
sql2`exists (select 1 from ${chats} where ${chats.id} = ${chatId} and ${chats.ownerId} = ${ownerId})`
|
|
576
|
+
)
|
|
577
|
+
).run();
|
|
578
|
+
},
|
|
579
|
+
async getSpendingLimitState(key) {
|
|
580
|
+
const row = db.select().from(spendingLimitStates).where(eq(spendingLimitStates.key, key)).get();
|
|
581
|
+
return row ? { value: row.value, version: row.version } : null;
|
|
582
|
+
},
|
|
583
|
+
// Одна команда без чтения: вставка или правка срабатывает только при совпавшей версии, лишнего «вклинивания» нет.
|
|
584
|
+
async saveSpendingLimitState(key, value, expectedVersion) {
|
|
585
|
+
const updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
586
|
+
if (expectedVersion === null) {
|
|
587
|
+
return db.insert(spendingLimitStates).values({ key, value, version: 1, updatedAt }).onConflictDoNothing().run().changes === 1;
|
|
588
|
+
}
|
|
589
|
+
const changed = db.update(spendingLimitStates).set({ value, version: expectedVersion + 1, updatedAt }).where(and(eq(spendingLimitStates.key, key), eq(spendingLimitStates.version, expectedVersion))).run().changes;
|
|
590
|
+
return changed === 1;
|
|
591
|
+
},
|
|
592
|
+
async deleteSpendingLimitStates(keys) {
|
|
593
|
+
if (keys.length > 0) db.delete(spendingLimitStates).where(inArray(spendingLimitStates.key, keys)).run();
|
|
594
|
+
},
|
|
595
|
+
async getVoiceChoice(ownerId) {
|
|
596
|
+
return db.select().from(voiceChoices).where(eq(voiceChoices.ownerId, ownerId)).get()?.voiceId ?? null;
|
|
597
|
+
},
|
|
598
|
+
async setVoiceChoice(ownerId, voiceId) {
|
|
599
|
+
const values = { voiceId, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
600
|
+
db.insert(voiceChoices).values({ ownerId, ...values }).onConflictDoUpdate({ target: voiceChoices.ownerId, set: values }).run();
|
|
601
|
+
},
|
|
602
|
+
async listMemoryNotes(ownerId) {
|
|
603
|
+
return db.transaction((tx) => visibleNotes(tx, ownerId));
|
|
604
|
+
},
|
|
605
|
+
// Пакет вставляется одной транзакцией: негодная запись (NOT NULL) откатывает и остальные.
|
|
606
|
+
async insertMemoryNotes(ownerId, inputs) {
|
|
607
|
+
return db.transaction((tx) => insertNotes(tx, ownerId, inputs), { behavior: "immediate" });
|
|
608
|
+
},
|
|
609
|
+
async insertMemoryNotesChecked(ownerId, inputs, guard) {
|
|
610
|
+
return db.transaction(
|
|
611
|
+
(tx) => {
|
|
612
|
+
const error = guard(visibleNotes(tx, ownerId));
|
|
613
|
+
return error ?? insertNotes(tx, ownerId, inputs);
|
|
614
|
+
},
|
|
615
|
+
{ behavior: "immediate" }
|
|
616
|
+
);
|
|
617
|
+
},
|
|
618
|
+
async updateMemoryNote(ownerId, id, expectedVersion, input) {
|
|
619
|
+
return db.transaction((tx) => updateNote(tx, ownerId, id, expectedVersion, input), { behavior: "immediate" });
|
|
620
|
+
},
|
|
621
|
+
async updateMemoryNoteChecked(ownerId, id, expectedVersion, input, guard) {
|
|
622
|
+
return db.transaction((tx) => guard(visibleNotes(tx, ownerId)) ?? updateNote(tx, ownerId, id, expectedVersion, input), { behavior: "immediate" });
|
|
623
|
+
},
|
|
624
|
+
async deleteMemoryNote(ownerId, id, expectedVersion) {
|
|
625
|
+
return db.transaction((tx) => {
|
|
626
|
+
const row = tx.select({ version: memoryNotes.version }).from(memoryNotes).where(ownerNote(ownerId, id)).get();
|
|
627
|
+
if (!row) return "not_found";
|
|
628
|
+
if (row.version !== expectedVersion) return "stale_version";
|
|
629
|
+
const result = tx.delete(memoryNotes).where(and(ownerNote(ownerId, id), eq(memoryNotes.version, expectedVersion))).run();
|
|
630
|
+
if (result.changes === 0) return "stale_version";
|
|
631
|
+
tx.delete(memoryNotes).where(and(eq(memoryNotes.ownerId, ownerId), eq(memoryNotes.mergedInto, id))).run();
|
|
632
|
+
return "ok";
|
|
633
|
+
}, { behavior: "immediate" });
|
|
634
|
+
},
|
|
635
|
+
async clearMemoryNotes(ownerId) {
|
|
636
|
+
return db.transaction((tx) => {
|
|
637
|
+
const deleted = tx.delete(memoryNotes).where(eq(memoryNotes.ownerId, ownerId)).returning({ mergedInto: memoryNotes.mergedInto }).all();
|
|
638
|
+
tx.delete(memoryOptimizationRuns).where(eq(memoryOptimizationRuns.ownerId, ownerId)).run();
|
|
639
|
+
return deleted.filter((row) => row.mergedInto === null).length;
|
|
640
|
+
}, { behavior: "immediate" });
|
|
641
|
+
},
|
|
642
|
+
// Взятие запуска — одна транзакция: проверка правил и вставка не разрываются (SQLite пишет по одному).
|
|
643
|
+
async claimMemoryOptimizationRun(ownerId, rules, now) {
|
|
644
|
+
const nowMs = Date.parse(now);
|
|
645
|
+
return db.transaction((tx) => {
|
|
646
|
+
const runs = tx.select().from(memoryOptimizationRuns).where(eq(memoryOptimizationRuns.ownerId, ownerId)).all();
|
|
647
|
+
for (const run of runs) {
|
|
648
|
+
const leaseEnd = run.startedAt + rules.leaseMs;
|
|
649
|
+
if (run.status !== "running" || nowMs < leaseEnd) continue;
|
|
650
|
+
tx.update(memoryOptimizationRuns).set({ status: "failed", error: "lease_expired", finishedAt: leaseEnd }).where(eq(memoryOptimizationRuns.id, run.id)).run();
|
|
651
|
+
Object.assign(run, { status: "failed", finishedAt: leaseEnd });
|
|
652
|
+
}
|
|
653
|
+
const blocked = isOptimizationBlocked(
|
|
654
|
+
runs.map((run) => ({ status: run.status, startedAtMs: run.startedAt, endedAtMs: run.finishedAt })),
|
|
655
|
+
rules,
|
|
656
|
+
nowMs
|
|
657
|
+
);
|
|
658
|
+
if (blocked) return null;
|
|
659
|
+
const row = { id: crypto.randomUUID(), ownerId, startedAt: nowMs, finishedAt: null, status: "running", groups: [], error: null, usageRecordId: null };
|
|
660
|
+
tx.insert(memoryOptimizationRuns).values(row).run();
|
|
661
|
+
return toOptimizationRun(row);
|
|
662
|
+
}, { behavior: "immediate" });
|
|
663
|
+
},
|
|
664
|
+
async finishMemoryOptimizationRun(ownerId, runId, outcome) {
|
|
665
|
+
const result = db.update(memoryOptimizationRuns).set({ status: outcome.status, finishedAt: Date.parse(outcome.finishedAt), error: outcome.error ?? null, usageRecordId: outcome.usageRecordId ?? null }).where(and(eq(memoryOptimizationRuns.id, runId), eq(memoryOptimizationRuns.ownerId, ownerId), eq(memoryOptimizationRuns.status, "running"))).run();
|
|
666
|
+
return result.changes > 0 ? "ok" : "not_found";
|
|
667
|
+
},
|
|
668
|
+
// Слияние — одна транзакция: любая неудача (в том числе негодный результат) откатывает всё.
|
|
669
|
+
async applyMemoryMerge(ownerId, merge) {
|
|
670
|
+
const { merges } = merge;
|
|
671
|
+
if (merges.length === 0) throw new TypeError("\u0421\u043B\u0438\u044F\u043D\u0438\u0435 \u0431\u0435\u0437 \u0441\u043A\u043B\u0435\u0435\u043A");
|
|
672
|
+
const seen = /* @__PURE__ */ new Set();
|
|
673
|
+
for (const { originals } of merges) {
|
|
674
|
+
if (originals.length === 0) throw new TypeError("\u0421\u043A\u043B\u0435\u0439\u043A\u0430 \u0431\u0435\u0437 \u043E\u0440\u0438\u0433\u0438\u043D\u0430\u043B\u043E\u0432");
|
|
675
|
+
for (const { id } of originals) {
|
|
676
|
+
if (seen.has(id)) throw new TypeError(`\u0417\u0430\u043F\u0438\u0441\u044C ${id} \u043D\u0430\u0437\u0432\u0430\u043D\u0430 \u0432 \u0434\u0432\u0443\u0445 \u0441\u043A\u043B\u0435\u0439\u043A\u0430\u0445`);
|
|
677
|
+
seen.add(id);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
return db.transaction((tx) => {
|
|
681
|
+
const run = tx.select().from(memoryOptimizationRuns).where(and(eq(memoryOptimizationRuns.id, merge.runId), eq(memoryOptimizationRuns.ownerId, ownerId))).get();
|
|
682
|
+
if (run?.status !== "running") return "run_not_active";
|
|
683
|
+
for (const { originals } of merges) {
|
|
684
|
+
for (const { id, version } of originals) {
|
|
685
|
+
const original = tx.select({ version: memoryNotes.version }).from(memoryNotes).where(ownerNote(ownerId, id)).get();
|
|
686
|
+
if (original?.version !== version) return "stale_version";
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
const now = Date.now();
|
|
690
|
+
const groups = merges.map(({ result, originals }) => {
|
|
691
|
+
const created = { id: crypto.randomUUID(), ownerId, categories: result.categories, content: result.content, version: 1, createdAt: now, updatedAt: now };
|
|
692
|
+
tx.insert(memoryNotes).values(created).run();
|
|
693
|
+
for (const { id } of originals) tx.update(memoryNotes).set({ mergedInto: created.id }).where(ownerNote(ownerId, id)).run();
|
|
694
|
+
return { resultId: created.id, originalIds: originals.map(({ id }) => id), resultVersion: 1 };
|
|
695
|
+
});
|
|
696
|
+
const finished = { status: "applied", finishedAt: Date.parse(merge.finishedAt), groups, usageRecordId: merge.usageRecordId ?? null };
|
|
697
|
+
tx.update(memoryOptimizationRuns).set(finished).where(eq(memoryOptimizationRuns.id, run.id)).run();
|
|
698
|
+
return toOptimizationRun({ ...run, ...finished });
|
|
699
|
+
}, { behavior: "immediate" });
|
|
700
|
+
},
|
|
701
|
+
async revertMemoryMerge(ownerId, runId, guard) {
|
|
702
|
+
return db.transaction((tx) => {
|
|
703
|
+
const run = tx.select().from(memoryOptimizationRuns).where(and(eq(memoryOptimizationRuns.id, runId), eq(memoryOptimizationRuns.ownerId, ownerId))).get();
|
|
704
|
+
if (run?.status !== "applied") return "not_found";
|
|
705
|
+
for (const { resultId, originalIds, resultVersion } of run.groups) {
|
|
706
|
+
const result = tx.select({ version: memoryNotes.version }).from(memoryNotes).where(ownerNote(ownerId, resultId)).get();
|
|
707
|
+
if (result?.version !== resultVersion) return "conflict";
|
|
708
|
+
const hidden = tx.select({ id: memoryNotes.id }).from(memoryNotes).where(and(eq(memoryNotes.ownerId, ownerId), eq(memoryNotes.mergedInto, resultId), inArray(memoryNotes.id, originalIds))).all();
|
|
709
|
+
if (hidden.length !== originalIds.length) return "conflict";
|
|
710
|
+
}
|
|
711
|
+
if (guard) {
|
|
712
|
+
const resultIds = run.groups.map(({ resultId }) => resultId);
|
|
713
|
+
const remaining = visibleNotes(tx, ownerId).filter((note) => !resultIds.includes(note.id));
|
|
714
|
+
const restored = tx.select().from(memoryNotes).where(and(eq(memoryNotes.ownerId, ownerId), inArray(memoryNotes.mergedInto, resultIds))).orderBy(desc(memoryNotes.updatedAt), desc(memoryNotes.createdAt), desc(memoryNotes.id)).all().map(toMemoryNote);
|
|
715
|
+
const error = guard(remaining, restored);
|
|
716
|
+
if (error) return revertRefusal(error);
|
|
717
|
+
}
|
|
718
|
+
for (const { resultId } of run.groups) {
|
|
719
|
+
tx.update(memoryNotes).set({ mergedInto: null }).where(and(eq(memoryNotes.ownerId, ownerId), eq(memoryNotes.mergedInto, resultId))).run();
|
|
720
|
+
tx.delete(memoryNotes).where(ownerNote(ownerId, resultId)).run();
|
|
721
|
+
}
|
|
722
|
+
tx.update(memoryOptimizationRuns).set({ status: "reverted" }).where(eq(memoryOptimizationRuns.id, run.id)).run();
|
|
723
|
+
return "ok";
|
|
724
|
+
}, { behavior: "immediate" });
|
|
725
|
+
},
|
|
726
|
+
async listMemoryOptimizationRuns(ownerId, filter) {
|
|
727
|
+
const rows = db.select().from(memoryOptimizationRuns).where(and(eq(memoryOptimizationRuns.ownerId, ownerId), ...filter?.status ? [inArray(memoryOptimizationRuns.status, filter.status)] : [])).orderBy(desc(memoryOptimizationRuns.startedAt), sql2`rowid desc`).limit(filter?.limit ?? -1).all();
|
|
728
|
+
return rows.map(toOptimizationRun);
|
|
729
|
+
},
|
|
730
|
+
async listMergedMemoryNotes(ownerId) {
|
|
731
|
+
const rows = db.select().from(memoryNotes).where(and(eq(memoryNotes.ownerId, ownerId), isNotNull(memoryNotes.mergedInto))).orderBy(desc(memoryNotes.updatedAt), desc(memoryNotes.createdAt), desc(memoryNotes.id)).all();
|
|
732
|
+
return rows.map((row) => ({ ...toMemoryNote(row), mergedInto: row.mergedInto }));
|
|
733
|
+
},
|
|
734
|
+
close() {
|
|
735
|
+
sqlite.close();
|
|
736
|
+
}
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
function chatRow(chat) {
|
|
740
|
+
return {
|
|
741
|
+
id: chat.id,
|
|
742
|
+
ownerId: chat.ownerId,
|
|
743
|
+
modelId: chat.modelId,
|
|
744
|
+
providerId: chat.providerId,
|
|
745
|
+
reasoning: chat.reasoning ?? null,
|
|
746
|
+
title: chat.title ?? null,
|
|
747
|
+
origin: chat.origin ?? null,
|
|
748
|
+
subjectKey: chat.subjectKey ?? null,
|
|
749
|
+
subjectData: chatDataOfInput(chat) ?? null,
|
|
750
|
+
closed: chat.closed ?? null,
|
|
751
|
+
createdAt: chat.createdAt,
|
|
752
|
+
updatedAt: chat.updatedAt
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
function toChat(row) {
|
|
756
|
+
const { reasoning, title, origin, subjectKey, subjectData, closed, ...chat } = row;
|
|
757
|
+
return {
|
|
758
|
+
...chat,
|
|
759
|
+
...reasoning !== null && { reasoning },
|
|
760
|
+
...title !== null && { title },
|
|
761
|
+
...origin !== null && { origin },
|
|
762
|
+
...subjectKey !== null && { subjectKey },
|
|
763
|
+
...chatDataFields(subjectData),
|
|
764
|
+
...closed !== null && { closed }
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
function subjectCondition(subjects = "exclude") {
|
|
768
|
+
if (subjects === "include") return void 0;
|
|
769
|
+
return subjects === "only" ? isNotNull(chats.subjectKey) : isNull(chats.subjectKey);
|
|
770
|
+
}
|
|
771
|
+
function dataConditions(filter) {
|
|
772
|
+
return Object.entries(filter ?? {}).map(([key, value]) => {
|
|
773
|
+
const typed = value === null ? sql2`type = 'null'` : typeof value === "string" ? sql2`type = 'text' and value = ${value}` : typeof value === "boolean" ? sql2`type = ${value ? "true" : "false"}` : sql2`type in ('integer', 'real') and value = ${value}`;
|
|
774
|
+
return sql2`exists (select 1 from json_each(${chats.subjectData}) where key = ${key} and ${typed})`;
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
function toMessage(row) {
|
|
778
|
+
const { seq: _seq, aliveAt: _aliveAt, afterTurn, ...message } = row;
|
|
779
|
+
return { ...message, ...afterTurn !== null && { afterTurn } };
|
|
780
|
+
}
|
|
781
|
+
function ownerNote(ownerId, id) {
|
|
782
|
+
return and(eq(memoryNotes.ownerId, ownerId), eq(memoryNotes.id, id), isNull(memoryNotes.mergedInto));
|
|
783
|
+
}
|
|
784
|
+
function toOptimizationRun(row) {
|
|
785
|
+
return {
|
|
786
|
+
id: row.id,
|
|
787
|
+
startedAt: new Date(row.startedAt).toISOString(),
|
|
788
|
+
finishedAt: row.finishedAt === null ? null : new Date(row.finishedAt).toISOString(),
|
|
789
|
+
status: row.status,
|
|
790
|
+
groups: row.groups.map((group) => ({ ...group, originalIds: [...group.originalIds] })),
|
|
791
|
+
error: row.error,
|
|
792
|
+
usageRecordId: row.usageRecordId
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
function toMemoryNote(row) {
|
|
796
|
+
return {
|
|
797
|
+
id: row.id,
|
|
798
|
+
categories: [...row.categories],
|
|
799
|
+
content: row.content,
|
|
800
|
+
version: row.version,
|
|
801
|
+
createdAt: new Date(row.createdAt).toISOString(),
|
|
802
|
+
updatedAt: new Date(row.updatedAt).toISOString()
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
function isSubjectConflict(error) {
|
|
806
|
+
const { code, message, cause } = error ?? {};
|
|
807
|
+
return [code, cause?.code].includes("SQLITE_CONSTRAINT_UNIQUE") && [message, cause?.message].some((text2) => typeof text2 === "string" && text2.includes("chats.subject_key"));
|
|
808
|
+
}
|
|
809
|
+
function isPrimaryKeyConflict(error) {
|
|
810
|
+
const { code, cause } = error ?? {};
|
|
811
|
+
return [code, cause?.code].includes("SQLITE_CONSTRAINT_PRIMARYKEY");
|
|
812
|
+
}
|
|
813
|
+
export {
|
|
814
|
+
ForeignChatTablesError,
|
|
815
|
+
createSqliteChatStorage
|
|
816
|
+
};
|
|
817
|
+
//# sourceMappingURL=index.js.map
|