@xmoxmo/bncr 0.6.2 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -1
- package/package.json +1 -1
- package/scripts/check-pack.mjs +3 -0
- package/scripts/selfcheck.mjs +3 -0
- package/src/channel.ts +134 -7
- package/src/messaging/inbound/commands.ts +62 -26
- package/src/messaging/inbound/conversation-history-serial.ts +258 -0
- package/src/messaging/inbound/conversation-history.ts +174 -60
- package/src/messaging/inbound/dispatch.ts +557 -137
- package/src/messaging/inbound/history-shard-worker.ts +180 -0
- package/src/messaging/inbound/native-command.ts +89 -33
- package/src/messaging/inbound/outbound-replay-cache.ts +128 -19
- package/src/messaging/inbound/reply-dispatch.ts +7 -6
- package/src/messaging/inbound/scene-admin.ts +17 -12
- package/src/messaging/inbound/turn-context.ts +2 -9
- package/src/plugin/bridge-lifecycle.ts +8 -0
- package/src/plugin/bridge-outbox-facade.ts +6 -1
- package/src/plugin/channel-runtime-constants.ts +2 -0
- package/src/plugin/outbox-drain-post-push.ts +3 -0
- package/src/plugin/sqlite-state.ts +2109 -0
- package/src/plugin/state-store.ts +704 -46
- package/src/plugin/state-transient-runtime-group.ts +14 -0
|
@@ -0,0 +1,2109 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
3
|
+
import {
|
|
4
|
+
dumpRegisterDriftSnapshot,
|
|
5
|
+
normalizeRegisterDriftSnapshot,
|
|
6
|
+
type RegisterDriftSnapshot,
|
|
7
|
+
} from '../core/register-trace.ts';
|
|
8
|
+
import type { BncrRoute, OutboxEntry } from '../core/types.ts';
|
|
9
|
+
import type {
|
|
10
|
+
BncrGroupReplyMode,
|
|
11
|
+
BncrPersistedAccountTimestamp,
|
|
12
|
+
BncrPersistedConversationHistoryBucket,
|
|
13
|
+
BncrPersistedConversationHistoryEntry,
|
|
14
|
+
BncrPersistedLastSession,
|
|
15
|
+
BncrPersistedOutboundReplayBucket,
|
|
16
|
+
BncrPersistedOutboundReplayEntry,
|
|
17
|
+
BncrPersistedSessionRoute,
|
|
18
|
+
BncrSceneRecord,
|
|
19
|
+
} from './channel-runtime-types.ts';
|
|
20
|
+
|
|
21
|
+
export type BncrSqliteStoreMode = 'json' | 'dual' | 'sqlite';
|
|
22
|
+
|
|
23
|
+
export type BncrSqliteControlState = {
|
|
24
|
+
sessionRoutes: BncrPersistedSessionRoute[];
|
|
25
|
+
sceneRegistry: BncrSceneRecord[];
|
|
26
|
+
lastSessionByAccount: BncrPersistedLastSession[];
|
|
27
|
+
lastActivityByAccount: BncrPersistedAccountTimestamp[];
|
|
28
|
+
lastInboundByAccount: BncrPersistedAccountTimestamp[];
|
|
29
|
+
lastOutboundByAccount: BncrPersistedAccountTimestamp[];
|
|
30
|
+
lastDriftSnapshot: RegisterDriftSnapshot | null;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type BncrSqliteHistoryState = {
|
|
34
|
+
historyBuckets: BncrPersistedConversationHistoryBucket[];
|
|
35
|
+
replayBuckets: BncrPersistedOutboundReplayBucket[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type BncrSqliteOutboundState = {
|
|
39
|
+
outbox: OutboxEntry[];
|
|
40
|
+
deadLetter: OutboxEntry[];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type BncrHistoryShardCreateInput = {
|
|
44
|
+
historyKey: string;
|
|
45
|
+
accountId?: string;
|
|
46
|
+
payloadJson: string;
|
|
47
|
+
messageIds: readonly string[];
|
|
48
|
+
bufferKeys: readonly string[];
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type BncrHistoryShardCreateResult = {
|
|
52
|
+
shardId: number;
|
|
53
|
+
created: boolean;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type BncrHistoryShardRow = {
|
|
57
|
+
id: number;
|
|
58
|
+
historyKey: string;
|
|
59
|
+
accountId: string | null;
|
|
60
|
+
status: string;
|
|
61
|
+
attempts: number;
|
|
62
|
+
payloadJson: string;
|
|
63
|
+
messageIds: string[];
|
|
64
|
+
bufferKeys: string[];
|
|
65
|
+
lastError: string | null;
|
|
66
|
+
nextAttemptAt: number | null;
|
|
67
|
+
owner?: string;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export type BncrHistoryShardFailureResult = {
|
|
71
|
+
attempts: number;
|
|
72
|
+
terminal: boolean;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export type BncrHistoryShardQueue = {
|
|
76
|
+
createHistoryShard: (input: BncrHistoryShardCreateInput) => BncrHistoryShardCreateResult | null;
|
|
77
|
+
claimNextHistoryShard: (owner?: string) => BncrHistoryShardRow | null;
|
|
78
|
+
reconcileHistoryMemory?: (historyKey?: string) => void | Promise<void>;
|
|
79
|
+
markHistoryShardProcessing: (shardId: number, owner?: string) => boolean;
|
|
80
|
+
markHistoryShardFailed: (
|
|
81
|
+
shardId: number,
|
|
82
|
+
error: unknown,
|
|
83
|
+
owner?: string,
|
|
84
|
+
) => BncrHistoryShardFailureResult;
|
|
85
|
+
markHistoryShardCompleted: (shardId: number, owner?: string) => boolean;
|
|
86
|
+
renewHistoryShardLease: (shardId: number, owner?: string) => boolean;
|
|
87
|
+
completeHistoryShard: (shardId: number, owner?: string) => void;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export type BncrSqliteStateDatabase = {
|
|
91
|
+
getPath: () => string;
|
|
92
|
+
close: () => void;
|
|
93
|
+
getStoreMode: () => BncrSqliteStoreMode | null;
|
|
94
|
+
isControlStateImported: () => boolean;
|
|
95
|
+
isHistoryImported: () => boolean;
|
|
96
|
+
setStoreMode: (mode: BncrSqliteStoreMode) => void;
|
|
97
|
+
getMeta: (key: string) => string | null;
|
|
98
|
+
setMeta: (key: string, value: string) => void;
|
|
99
|
+
ensureMigrated: () => void;
|
|
100
|
+
importControlState: (
|
|
101
|
+
state: BncrSqliteControlState,
|
|
102
|
+
options?: {
|
|
103
|
+
storeMode?: BncrSqliteStoreMode;
|
|
104
|
+
legacyJsonPath?: string;
|
|
105
|
+
legacyJsonSha256?: string;
|
|
106
|
+
},
|
|
107
|
+
) => void;
|
|
108
|
+
loadControlState: () => BncrSqliteControlState;
|
|
109
|
+
saveControlState: (state: BncrSqliteControlState) => void;
|
|
110
|
+
importHistoryState: (
|
|
111
|
+
historyBuckets: BncrPersistedConversationHistoryBucket[],
|
|
112
|
+
replayBuckets: BncrPersistedOutboundReplayBucket[],
|
|
113
|
+
) => void;
|
|
114
|
+
loadHistoryState: (skipHistoryKeys?: readonly string[], owner?: string) => BncrSqliteHistoryState;
|
|
115
|
+
getHistoryStateRevision: () => number;
|
|
116
|
+
saveHistoryState: (
|
|
117
|
+
historyBuckets: BncrPersistedConversationHistoryBucket[],
|
|
118
|
+
replayBuckets: BncrPersistedOutboundReplayBucket[],
|
|
119
|
+
expectedRevision?: number,
|
|
120
|
+
) => void;
|
|
121
|
+
isOutboundImported: () => boolean;
|
|
122
|
+
importOutboundState: (outbox: OutboxEntry[], deadLetter: OutboxEntry[]) => void;
|
|
123
|
+
loadOutboundState: () => BncrSqliteOutboundState;
|
|
124
|
+
saveOutboundState: (outbox: OutboxEntry[], deadLetter: OutboxEntry[]) => void;
|
|
125
|
+
createHistoryShard: (input: BncrHistoryShardCreateInput) => BncrHistoryShardCreateResult;
|
|
126
|
+
markHistoryShardProcessing: (shardId: number, owner?: string) => boolean;
|
|
127
|
+
markHistoryShardFailed: (
|
|
128
|
+
shardId: number,
|
|
129
|
+
error: unknown,
|
|
130
|
+
owner?: string,
|
|
131
|
+
) => BncrHistoryShardFailureResult;
|
|
132
|
+
markHistoryShardCompleted: (shardId: number, owner?: string) => boolean;
|
|
133
|
+
renewHistoryShardLease: (shardId: number, owner?: string) => boolean;
|
|
134
|
+
completeHistoryShard: (shardId: number, owner?: string) => void;
|
|
135
|
+
recoverHistoryShards: (skipHistoryKeys?: readonly string[], owner?: string) => number;
|
|
136
|
+
recoverInFlightHistoryShards: (skipHistoryKeys?: readonly string[], owner?: string) => number;
|
|
137
|
+
restoreTerminalHistoryShardsToActive: () => BncrSqliteHistoryState;
|
|
138
|
+
claimNextHistoryShard: (
|
|
139
|
+
skipHistoryKeys?: readonly string[],
|
|
140
|
+
owner?: string,
|
|
141
|
+
) => BncrHistoryShardRow | null;
|
|
142
|
+
cleanupCompletedHistoryShards: () => number;
|
|
143
|
+
listHistoryShards: () => BncrHistoryShardRow[];
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
type Migration = {
|
|
147
|
+
version: number;
|
|
148
|
+
name: string;
|
|
149
|
+
up: string;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const SCHEMA_MIGRATIONS = `
|
|
153
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
154
|
+
version INTEGER PRIMARY KEY,
|
|
155
|
+
name TEXT NOT NULL,
|
|
156
|
+
checksum TEXT NOT NULL,
|
|
157
|
+
applied_at INTEGER NOT NULL
|
|
158
|
+
);
|
|
159
|
+
`;
|
|
160
|
+
|
|
161
|
+
const MIGRATIONS: Migration[] = [
|
|
162
|
+
{
|
|
163
|
+
version: 1,
|
|
164
|
+
name: 'bncr-sqlite-control-state',
|
|
165
|
+
up: `
|
|
166
|
+
CREATE TABLE state_meta (
|
|
167
|
+
meta_key TEXT PRIMARY KEY,
|
|
168
|
+
meta_value TEXT NOT NULL,
|
|
169
|
+
updated_at INTEGER NOT NULL
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
CREATE TABLE routes (
|
|
173
|
+
id INTEGER PRIMARY KEY,
|
|
174
|
+
route_key TEXT NOT NULL UNIQUE,
|
|
175
|
+
platform TEXT NOT NULL,
|
|
176
|
+
group_id TEXT NOT NULL DEFAULT '0',
|
|
177
|
+
user_id TEXT NOT NULL DEFAULT '0',
|
|
178
|
+
created_at INTEGER NOT NULL,
|
|
179
|
+
updated_at INTEGER NOT NULL
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
CREATE INDEX idx_routes_identity ON routes(platform, group_id, user_id);
|
|
183
|
+
|
|
184
|
+
CREATE TABLE session_routes (
|
|
185
|
+
session_key TEXT PRIMARY KEY,
|
|
186
|
+
account_id TEXT NOT NULL,
|
|
187
|
+
route_id INTEGER NOT NULL REFERENCES routes(id) ON DELETE RESTRICT,
|
|
188
|
+
platform TEXT NOT NULL,
|
|
189
|
+
group_id TEXT NOT NULL DEFAULT '0',
|
|
190
|
+
user_id TEXT NOT NULL DEFAULT '0',
|
|
191
|
+
updated_at INTEGER NOT NULL,
|
|
192
|
+
created_at INTEGER NOT NULL
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
CREATE INDEX idx_session_routes_account ON session_routes(account_id, updated_at);
|
|
196
|
+
CREATE INDEX idx_session_routes_route ON session_routes(platform, group_id, user_id, account_id);
|
|
197
|
+
|
|
198
|
+
CREATE TABLE scenes (
|
|
199
|
+
id INTEGER PRIMARY KEY,
|
|
200
|
+
scene_key TEXT NOT NULL UNIQUE,
|
|
201
|
+
kind TEXT NOT NULL CHECK (kind IN ('direct','group')),
|
|
202
|
+
status TEXT NOT NULL CHECK (status IN ('pending','allowed','denied')),
|
|
203
|
+
platform TEXT NOT NULL,
|
|
204
|
+
user_id TEXT,
|
|
205
|
+
user_name TEXT,
|
|
206
|
+
group_id TEXT,
|
|
207
|
+
group_name TEXT,
|
|
208
|
+
agent_id TEXT,
|
|
209
|
+
group_reply_mode TEXT CHECK (group_reply_mode IN ('admin','mention','hybrid','all')),
|
|
210
|
+
history_limit INTEGER CHECK (history_limit IS NULL OR history_limit >= 2),
|
|
211
|
+
history_force INTEGER CHECK (history_force IN (0,1)),
|
|
212
|
+
download_media INTEGER CHECK (download_media IN (0,1)),
|
|
213
|
+
last_seen_at INTEGER NOT NULL,
|
|
214
|
+
created_at INTEGER NOT NULL,
|
|
215
|
+
updated_at INTEGER NOT NULL
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
CREATE INDEX idx_scenes_status ON scenes(status, kind, platform);
|
|
219
|
+
CREATE INDEX idx_scenes_last_seen ON scenes(last_seen_at);
|
|
220
|
+
|
|
221
|
+
CREATE TABLE account_activity (
|
|
222
|
+
account_id TEXT NOT NULL,
|
|
223
|
+
activity_type TEXT NOT NULL CHECK (
|
|
224
|
+
activity_type IN ('last_session','last_activity','last_inbound','last_outbound')
|
|
225
|
+
),
|
|
226
|
+
session_key TEXT,
|
|
227
|
+
session_scope TEXT,
|
|
228
|
+
event_at INTEGER NOT NULL,
|
|
229
|
+
updated_at INTEGER NOT NULL,
|
|
230
|
+
PRIMARY KEY (account_id, activity_type)
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
CREATE INDEX idx_account_activity_updated ON account_activity(updated_at);
|
|
234
|
+
|
|
235
|
+
CREATE TABLE history_shards (
|
|
236
|
+
id INTEGER PRIMARY KEY,
|
|
237
|
+
account_id TEXT,
|
|
238
|
+
history_key TEXT NOT NULL,
|
|
239
|
+
status TEXT NOT NULL DEFAULT 'queued' CHECK (
|
|
240
|
+
status IN ('queued','claimed','processing','completed','failed','abandoned')
|
|
241
|
+
),
|
|
242
|
+
payload_json TEXT NOT NULL,
|
|
243
|
+
message_ids_json TEXT NOT NULL DEFAULT '[]',
|
|
244
|
+
buffer_keys_json TEXT NOT NULL DEFAULT '[]',
|
|
245
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
246
|
+
last_error TEXT,
|
|
247
|
+
next_attempt_at INTEGER,
|
|
248
|
+
claim_token TEXT UNIQUE,
|
|
249
|
+
claim_owner TEXT,
|
|
250
|
+
claimed_at INTEGER,
|
|
251
|
+
lease_expires_at INTEGER,
|
|
252
|
+
created_at INTEGER NOT NULL,
|
|
253
|
+
queued_at INTEGER NOT NULL,
|
|
254
|
+
upload_started_at INTEGER,
|
|
255
|
+
upload_completed_at INTEGER,
|
|
256
|
+
cleanup_started_at INTEGER,
|
|
257
|
+
cleanup_completed_at INTEGER,
|
|
258
|
+
completed_at INTEGER,
|
|
259
|
+
updated_at INTEGER NOT NULL
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
CREATE INDEX idx_history_shards_ready ON history_shards(status, next_attempt_at, id);
|
|
263
|
+
CREATE INDEX idx_history_shards_key ON history_shards(account_id, history_key, id);
|
|
264
|
+
|
|
265
|
+
CREATE TABLE conversation_messages (
|
|
266
|
+
id INTEGER PRIMARY KEY,
|
|
267
|
+
storage_kind TEXT NOT NULL CHECK (storage_kind IN ('history','replay')),
|
|
268
|
+
history_key TEXT NOT NULL,
|
|
269
|
+
buffer_key TEXT NOT NULL,
|
|
270
|
+
account_id TEXT,
|
|
271
|
+
sender TEXT NOT NULL,
|
|
272
|
+
sender_id TEXT,
|
|
273
|
+
role TEXT CHECK (role IN ('user','assistant','system')),
|
|
274
|
+
body TEXT NOT NULL,
|
|
275
|
+
message_ts INTEGER,
|
|
276
|
+
message_id TEXT,
|
|
277
|
+
media_json TEXT NOT NULL DEFAULT '[]',
|
|
278
|
+
session_key TEXT,
|
|
279
|
+
replay_status TEXT CHECK (replay_status IN ('pushed','acked')),
|
|
280
|
+
outbound_type TEXT,
|
|
281
|
+
media_url TEXT,
|
|
282
|
+
outbound_created_at INTEGER,
|
|
283
|
+
route_platform TEXT,
|
|
284
|
+
route_group_id TEXT,
|
|
285
|
+
route_user_id TEXT,
|
|
286
|
+
shard_id INTEGER REFERENCES history_shards(id) ON DELETE SET NULL,
|
|
287
|
+
created_at INTEGER NOT NULL,
|
|
288
|
+
updated_at INTEGER NOT NULL
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
CREATE INDEX idx_conv_pending ON conversation_messages(history_key, storage_kind, shard_id, id);
|
|
292
|
+
CREATE INDEX idx_conv_buffer ON conversation_messages(buffer_key, storage_kind, id);
|
|
293
|
+
CREATE INDEX idx_conv_message_id ON conversation_messages(history_key, message_id);
|
|
294
|
+
CREATE INDEX idx_conv_shard ON conversation_messages(shard_id);
|
|
295
|
+
|
|
296
|
+
CREATE TABLE outbound_messages (
|
|
297
|
+
id INTEGER PRIMARY KEY,
|
|
298
|
+
message_id TEXT NOT NULL UNIQUE,
|
|
299
|
+
account_id TEXT NOT NULL,
|
|
300
|
+
session_key TEXT NOT NULL,
|
|
301
|
+
route_platform TEXT NOT NULL,
|
|
302
|
+
route_group_id TEXT NOT NULL DEFAULT '0',
|
|
303
|
+
route_user_id TEXT NOT NULL DEFAULT '0',
|
|
304
|
+
payload_json TEXT NOT NULL,
|
|
305
|
+
queue_state TEXT NOT NULL DEFAULT 'queued' CHECK (
|
|
306
|
+
queue_state IN ('queued','claimed','dead','completed','removed')
|
|
307
|
+
),
|
|
308
|
+
retry_count INTEGER NOT NULL DEFAULT 0,
|
|
309
|
+
next_attempt_at INTEGER NOT NULL,
|
|
310
|
+
last_attempt_at INTEGER,
|
|
311
|
+
last_error TEXT,
|
|
312
|
+
last_push_at INTEGER,
|
|
313
|
+
last_push_conn_id TEXT,
|
|
314
|
+
last_push_client_id TEXT,
|
|
315
|
+
route_attempt_conn_ids_json TEXT NOT NULL DEFAULT '[]',
|
|
316
|
+
route_attempt_round INTEGER NOT NULL DEFAULT 0,
|
|
317
|
+
fast_reroute_pending INTEGER NOT NULL DEFAULT 0,
|
|
318
|
+
awaiting_retry_push INTEGER NOT NULL DEFAULT 0,
|
|
319
|
+
created_at INTEGER NOT NULL,
|
|
320
|
+
updated_at INTEGER NOT NULL
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
CREATE INDEX idx_outbound_ready ON outbound_messages(queue_state, next_attempt_at, id);
|
|
324
|
+
CREATE INDEX idx_outbound_account ON outbound_messages(account_id, created_at);
|
|
325
|
+
CREATE INDEX idx_outbound_dead ON outbound_messages(queue_state, created_at);
|
|
326
|
+
|
|
327
|
+
CREATE TABLE register_drift (
|
|
328
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
329
|
+
captured_at INTEGER NOT NULL,
|
|
330
|
+
register_count INTEGER NOT NULL,
|
|
331
|
+
api_generation INTEGER NOT NULL,
|
|
332
|
+
post_warmup_register_count INTEGER NOT NULL,
|
|
333
|
+
api_instance_id TEXT NOT NULL,
|
|
334
|
+
registry_fingerprint TEXT NOT NULL,
|
|
335
|
+
dominant_bucket TEXT,
|
|
336
|
+
source_buckets_json TEXT NOT NULL DEFAULT '{}',
|
|
337
|
+
trace_window_size INTEGER NOT NULL,
|
|
338
|
+
trace_recent_json TEXT NOT NULL DEFAULT '[]',
|
|
339
|
+
updated_at INTEGER NOT NULL
|
|
340
|
+
);
|
|
341
|
+
`,
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
version: 2,
|
|
345
|
+
name: 'bncr-conversation-active-message-dedupe',
|
|
346
|
+
up: `
|
|
347
|
+
DELETE FROM conversation_messages
|
|
348
|
+
WHERE shard_id IS NULL
|
|
349
|
+
AND message_id IS NOT NULL
|
|
350
|
+
AND message_id != ''
|
|
351
|
+
AND id NOT IN (
|
|
352
|
+
SELECT MIN(id)
|
|
353
|
+
FROM conversation_messages
|
|
354
|
+
WHERE shard_id IS NULL
|
|
355
|
+
AND message_id IS NOT NULL
|
|
356
|
+
AND message_id != ''
|
|
357
|
+
GROUP BY storage_kind, message_id
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
CREATE UNIQUE INDEX idx_conv_dedupe_active
|
|
361
|
+
ON conversation_messages(storage_kind, message_id)
|
|
362
|
+
WHERE shard_id IS NULL AND message_id IS NOT NULL AND message_id != '';
|
|
363
|
+
`,
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
version: 3,
|
|
367
|
+
name: 'bncr-history-shard-dedupe-key',
|
|
368
|
+
up: `
|
|
369
|
+
ALTER TABLE history_shards ADD COLUMN dedupe_key TEXT;
|
|
370
|
+
UPDATE history_shards SET dedupe_key = 'legacy:' || id WHERE dedupe_key IS NULL;
|
|
371
|
+
CREATE UNIQUE INDEX idx_history_shards_dedupe_key ON history_shards(dedupe_key);
|
|
372
|
+
`,
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
version: 4,
|
|
376
|
+
name: 'bncr-conversation-scoped-active-dedupe',
|
|
377
|
+
up: `
|
|
378
|
+
DELETE FROM conversation_messages
|
|
379
|
+
WHERE shard_id IS NULL
|
|
380
|
+
AND message_id IS NOT NULL
|
|
381
|
+
AND message_id != ''
|
|
382
|
+
AND id NOT IN (
|
|
383
|
+
SELECT MIN(id)
|
|
384
|
+
FROM conversation_messages
|
|
385
|
+
WHERE shard_id IS NULL
|
|
386
|
+
AND message_id IS NOT NULL
|
|
387
|
+
AND message_id != ''
|
|
388
|
+
GROUP BY storage_kind,
|
|
389
|
+
CASE WHEN storage_kind = 'history' THEN history_key ELSE buffer_key END,
|
|
390
|
+
message_id
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
DROP INDEX IF EXISTS idx_conv_dedupe_active;
|
|
394
|
+
CREATE UNIQUE INDEX idx_conv_history_dedupe_active
|
|
395
|
+
ON conversation_messages(history_key, message_id)
|
|
396
|
+
WHERE storage_kind = 'history'
|
|
397
|
+
AND shard_id IS NULL
|
|
398
|
+
AND message_id IS NOT NULL
|
|
399
|
+
AND message_id != '';
|
|
400
|
+
CREATE UNIQUE INDEX idx_conv_replay_dedupe_active
|
|
401
|
+
ON conversation_messages(buffer_key, message_id)
|
|
402
|
+
WHERE storage_kind = 'replay'
|
|
403
|
+
AND shard_id IS NULL
|
|
404
|
+
AND message_id IS NOT NULL
|
|
405
|
+
AND message_id != '';
|
|
406
|
+
`,
|
|
407
|
+
},
|
|
408
|
+
{
|
|
409
|
+
version: 5,
|
|
410
|
+
name: 'bncr-history-consumed-message-tombstones',
|
|
411
|
+
up: `
|
|
412
|
+
CREATE TABLE history_message_consumed (
|
|
413
|
+
id INTEGER PRIMARY KEY,
|
|
414
|
+
storage_kind TEXT NOT NULL CHECK (storage_kind IN ('history','replay')),
|
|
415
|
+
history_key TEXT NOT NULL,
|
|
416
|
+
buffer_key TEXT NOT NULL,
|
|
417
|
+
message_id TEXT NOT NULL,
|
|
418
|
+
consumed_at INTEGER NOT NULL,
|
|
419
|
+
created_at INTEGER NOT NULL,
|
|
420
|
+
UNIQUE (storage_kind, history_key, buffer_key, message_id)
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
CREATE INDEX idx_history_message_consumed_at ON history_message_consumed(consumed_at);
|
|
424
|
+
`,
|
|
425
|
+
},
|
|
426
|
+
];
|
|
427
|
+
|
|
428
|
+
const HISTORY_SHARD_LEASE_MS = 5 * 60 * 1000;
|
|
429
|
+
const HISTORY_SHARD_MAX_ATTEMPTS = 8;
|
|
430
|
+
const HISTORY_SHARD_RETRY_BASE_MS = 5_000;
|
|
431
|
+
const HISTORY_SHARD_RETRY_MAX_MS = 5 * 60 * 1000;
|
|
432
|
+
const HISTORY_CONSUMED_TOMBSTONE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
433
|
+
|
|
434
|
+
function migrationChecksum(migration: Migration): string {
|
|
435
|
+
return createHash('sha256')
|
|
436
|
+
.update(`${migration.version}:${migration.name}:${migration.up}`)
|
|
437
|
+
.digest('hex');
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function applyMigrations(db: DatabaseSync, now: () => number): void {
|
|
441
|
+
db.exec(SCHEMA_MIGRATIONS);
|
|
442
|
+
|
|
443
|
+
for (const migration of MIGRATIONS) {
|
|
444
|
+
const checksum = migrationChecksum(migration);
|
|
445
|
+
db.exec('BEGIN IMMEDIATE');
|
|
446
|
+
try {
|
|
447
|
+
const applied = new Set<number>(
|
|
448
|
+
(
|
|
449
|
+
db.prepare('SELECT version, checksum FROM schema_migrations').all() as Array<{
|
|
450
|
+
version: number;
|
|
451
|
+
checksum: string;
|
|
452
|
+
}>
|
|
453
|
+
).map((row) => row.version),
|
|
454
|
+
);
|
|
455
|
+
if (applied.has(migration.version)) {
|
|
456
|
+
db.exec('COMMIT');
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
db.exec(migration.up);
|
|
460
|
+
db.prepare(
|
|
461
|
+
`INSERT INTO schema_migrations (version, name, checksum, applied_at)
|
|
462
|
+
VALUES (?, ?, ?, ?)`,
|
|
463
|
+
).run(migration.version, migration.name, checksum, now());
|
|
464
|
+
db.exec('COMMIT');
|
|
465
|
+
} catch (error) {
|
|
466
|
+
db.exec('ROLLBACK');
|
|
467
|
+
throw error;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function buildRouteKey(route: BncrRoute): string {
|
|
473
|
+
const platform = String(route?.platform || '').trim();
|
|
474
|
+
const groupId = String(route?.groupId || '0').trim() || '0';
|
|
475
|
+
const userId = String(route?.userId || '0').trim() || '0';
|
|
476
|
+
return `${platform}:${groupId}:${userId}`;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function replaceControlState(
|
|
480
|
+
db: DatabaseSync,
|
|
481
|
+
state: BncrSqliteControlState,
|
|
482
|
+
now: () => number,
|
|
483
|
+
): void {
|
|
484
|
+
db.exec('BEGIN IMMEDIATE');
|
|
485
|
+
try {
|
|
486
|
+
db.exec('DELETE FROM session_routes');
|
|
487
|
+
db.exec('DELETE FROM routes');
|
|
488
|
+
db.exec('DELETE FROM scenes');
|
|
489
|
+
db.exec('DELETE FROM account_activity');
|
|
490
|
+
db.exec('DELETE FROM register_drift');
|
|
491
|
+
|
|
492
|
+
const insertRoute = db.prepare(
|
|
493
|
+
`INSERT INTO routes (route_key, platform, group_id, user_id, created_at, updated_at)
|
|
494
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
495
|
+
ON CONFLICT(route_key) DO UPDATE SET updated_at = excluded.updated_at`,
|
|
496
|
+
);
|
|
497
|
+
const selectRoute = db.prepare('SELECT id FROM routes WHERE route_key = ?');
|
|
498
|
+
const insertSessionRoute = db.prepare(
|
|
499
|
+
`INSERT INTO session_routes (
|
|
500
|
+
session_key, account_id, route_id, platform, group_id, user_id, updated_at, created_at
|
|
501
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
const timestamp = now();
|
|
505
|
+
for (const entry of state.sessionRoutes) {
|
|
506
|
+
const route = entry.route;
|
|
507
|
+
if (!route?.platform) continue;
|
|
508
|
+
const routeKey = buildRouteKey(route);
|
|
509
|
+
const created = Number(entry.updatedAt) || timestamp;
|
|
510
|
+
insertRoute.run(
|
|
511
|
+
routeKey,
|
|
512
|
+
route.platform,
|
|
513
|
+
route.groupId || '0',
|
|
514
|
+
route.userId || '0',
|
|
515
|
+
created,
|
|
516
|
+
created,
|
|
517
|
+
);
|
|
518
|
+
const routeRow = selectRoute.get(routeKey) as { id: number } | undefined;
|
|
519
|
+
if (!routeRow) continue;
|
|
520
|
+
insertSessionRoute.run(
|
|
521
|
+
entry.sessionKey,
|
|
522
|
+
entry.accountId,
|
|
523
|
+
routeRow.id,
|
|
524
|
+
route.platform,
|
|
525
|
+
route.groupId || '0',
|
|
526
|
+
route.userId || '0',
|
|
527
|
+
Number(entry.updatedAt) || timestamp,
|
|
528
|
+
created,
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const insertScene = db.prepare(
|
|
533
|
+
`INSERT INTO scenes (
|
|
534
|
+
scene_key, kind, status, platform, user_id, user_name, group_id, group_name,
|
|
535
|
+
agent_id, group_reply_mode, history_limit, history_force, download_media,
|
|
536
|
+
last_seen_at, created_at, updated_at
|
|
537
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
538
|
+
);
|
|
539
|
+
for (const scene of state.sceneRegistry) {
|
|
540
|
+
insertScene.run(
|
|
541
|
+
scene.sceneKey,
|
|
542
|
+
scene.kind,
|
|
543
|
+
scene.status,
|
|
544
|
+
scene.platform,
|
|
545
|
+
scene.userId ?? null,
|
|
546
|
+
scene.userName ?? null,
|
|
547
|
+
scene.groupId ?? null,
|
|
548
|
+
scene.groupName ?? null,
|
|
549
|
+
scene.agentId ?? null,
|
|
550
|
+
scene.groupReplyMode ?? null,
|
|
551
|
+
scene.historyLimit ?? null,
|
|
552
|
+
scene.historyForce === undefined ? null : scene.historyForce ? 1 : 0,
|
|
553
|
+
scene.downloadMedia === undefined ? null : scene.downloadMedia ? 1 : 0,
|
|
554
|
+
Number(scene.lastSeenAt) || timestamp,
|
|
555
|
+
timestamp,
|
|
556
|
+
timestamp,
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const insertActivity = db.prepare(
|
|
561
|
+
`INSERT INTO account_activity (
|
|
562
|
+
account_id, activity_type, session_key, session_scope, event_at, updated_at
|
|
563
|
+
) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
564
|
+
);
|
|
565
|
+
const insertActivityAt = (accountId: string, activityType: string, value: number) => {
|
|
566
|
+
insertActivity.run(
|
|
567
|
+
accountId,
|
|
568
|
+
activityType,
|
|
569
|
+
null,
|
|
570
|
+
null,
|
|
571
|
+
Number(value) || timestamp,
|
|
572
|
+
timestamp,
|
|
573
|
+
);
|
|
574
|
+
};
|
|
575
|
+
for (const entry of state.lastSessionByAccount) {
|
|
576
|
+
insertActivity.run(
|
|
577
|
+
entry.accountId,
|
|
578
|
+
'last_session',
|
|
579
|
+
entry.sessionKey,
|
|
580
|
+
entry.scope,
|
|
581
|
+
Number(entry.updatedAt) || timestamp,
|
|
582
|
+
timestamp,
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
for (const entry of state.lastActivityByAccount)
|
|
586
|
+
insertActivityAt(entry.accountId, 'last_activity', entry.updatedAt);
|
|
587
|
+
for (const entry of state.lastInboundByAccount)
|
|
588
|
+
insertActivityAt(entry.accountId, 'last_inbound', entry.updatedAt);
|
|
589
|
+
for (const entry of state.lastOutboundByAccount)
|
|
590
|
+
insertActivityAt(entry.accountId, 'last_outbound', entry.updatedAt);
|
|
591
|
+
|
|
592
|
+
const drift = dumpRegisterDriftSnapshot(state.lastDriftSnapshot);
|
|
593
|
+
if (drift) {
|
|
594
|
+
db.prepare(
|
|
595
|
+
`INSERT INTO register_drift (
|
|
596
|
+
id, captured_at, register_count, api_generation, post_warmup_register_count,
|
|
597
|
+
api_instance_id, registry_fingerprint, dominant_bucket, source_buckets_json,
|
|
598
|
+
trace_window_size, trace_recent_json, updated_at
|
|
599
|
+
) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
600
|
+
).run(
|
|
601
|
+
drift.capturedAt,
|
|
602
|
+
drift.registerCount,
|
|
603
|
+
drift.apiGeneration,
|
|
604
|
+
drift.postWarmupRegisterCount,
|
|
605
|
+
drift.apiInstanceId,
|
|
606
|
+
drift.registryFingerprint,
|
|
607
|
+
drift.dominantBucket,
|
|
608
|
+
JSON.stringify(drift.sourceBuckets),
|
|
609
|
+
drift.traceWindowSize,
|
|
610
|
+
JSON.stringify(drift.traceRecent),
|
|
611
|
+
timestamp,
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
db.exec('COMMIT');
|
|
616
|
+
} catch (error) {
|
|
617
|
+
db.exec('ROLLBACK');
|
|
618
|
+
throw error;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function loadControlState(db: DatabaseSync): BncrSqliteControlState {
|
|
623
|
+
const sessionRoutes = (
|
|
624
|
+
db
|
|
625
|
+
.prepare(
|
|
626
|
+
`SELECT sr.session_key, sr.account_id, sr.platform, sr.group_id, sr.user_id, sr.updated_at
|
|
627
|
+
FROM session_routes sr
|
|
628
|
+
ORDER BY sr.updated_at ASC, sr.session_key ASC`,
|
|
629
|
+
)
|
|
630
|
+
.all() as Array<{
|
|
631
|
+
session_key: string;
|
|
632
|
+
account_id: string;
|
|
633
|
+
platform: string;
|
|
634
|
+
group_id: string;
|
|
635
|
+
user_id: string;
|
|
636
|
+
updated_at: number;
|
|
637
|
+
}>
|
|
638
|
+
).map((row) => ({
|
|
639
|
+
sessionKey: row.session_key,
|
|
640
|
+
accountId: row.account_id,
|
|
641
|
+
route: {
|
|
642
|
+
platform: row.platform,
|
|
643
|
+
groupId: row.group_id || '0',
|
|
644
|
+
userId: row.user_id || '0',
|
|
645
|
+
},
|
|
646
|
+
updatedAt: row.updated_at,
|
|
647
|
+
}));
|
|
648
|
+
|
|
649
|
+
const sceneRegistry = (
|
|
650
|
+
db
|
|
651
|
+
.prepare(
|
|
652
|
+
`SELECT scene_key, kind, status, platform, user_id, user_name, group_id, group_name,
|
|
653
|
+
agent_id, group_reply_mode, history_limit, history_force, download_media, last_seen_at
|
|
654
|
+
FROM scenes
|
|
655
|
+
ORDER BY last_seen_at ASC, scene_key ASC`,
|
|
656
|
+
)
|
|
657
|
+
.all() as Array<{
|
|
658
|
+
scene_key: string;
|
|
659
|
+
kind: BncrSceneRecord['kind'];
|
|
660
|
+
status: BncrSceneRecord['status'];
|
|
661
|
+
platform: string;
|
|
662
|
+
user_id: string | null;
|
|
663
|
+
user_name: string | null;
|
|
664
|
+
group_id: string | null;
|
|
665
|
+
group_name: string | null;
|
|
666
|
+
agent_id: string | null;
|
|
667
|
+
group_reply_mode: BncrGroupReplyMode | null;
|
|
668
|
+
history_limit: number | null;
|
|
669
|
+
history_force: number | null;
|
|
670
|
+
download_media: number | null;
|
|
671
|
+
last_seen_at: number;
|
|
672
|
+
}>
|
|
673
|
+
).map((row) => {
|
|
674
|
+
const scene: BncrSceneRecord = {
|
|
675
|
+
sceneKey: row.scene_key,
|
|
676
|
+
kind: row.kind,
|
|
677
|
+
status: row.status,
|
|
678
|
+
platform: row.platform,
|
|
679
|
+
lastSeenAt: row.last_seen_at,
|
|
680
|
+
};
|
|
681
|
+
if (row.user_id !== null) scene.userId = row.user_id;
|
|
682
|
+
if (row.user_name !== null) scene.userName = row.user_name;
|
|
683
|
+
if (row.group_id !== null) scene.groupId = row.group_id;
|
|
684
|
+
if (row.group_name !== null) scene.groupName = row.group_name;
|
|
685
|
+
if (row.agent_id !== null) scene.agentId = row.agent_id;
|
|
686
|
+
if (row.group_reply_mode !== null) scene.groupReplyMode = row.group_reply_mode;
|
|
687
|
+
if (row.history_limit !== null) scene.historyLimit = row.history_limit;
|
|
688
|
+
if (row.history_force !== null) scene.historyForce = row.history_force === 1;
|
|
689
|
+
if (row.download_media !== null) scene.downloadMedia = row.download_media === 1;
|
|
690
|
+
return scene;
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
const activity = db
|
|
694
|
+
.prepare(
|
|
695
|
+
`SELECT account_id, activity_type, session_key, session_scope, event_at
|
|
696
|
+
FROM account_activity
|
|
697
|
+
ORDER BY updated_at ASC, account_id ASC`,
|
|
698
|
+
)
|
|
699
|
+
.all() as Array<{
|
|
700
|
+
account_id: string;
|
|
701
|
+
activity_type: string;
|
|
702
|
+
session_key: string | null;
|
|
703
|
+
session_scope: string | null;
|
|
704
|
+
event_at: number;
|
|
705
|
+
}>;
|
|
706
|
+
|
|
707
|
+
const lastSessionByAccount: BncrPersistedLastSession[] = [];
|
|
708
|
+
const lastActivityByAccount: BncrPersistedAccountTimestamp[] = [];
|
|
709
|
+
const lastInboundByAccount: BncrPersistedAccountTimestamp[] = [];
|
|
710
|
+
const lastOutboundByAccount: BncrPersistedAccountTimestamp[] = [];
|
|
711
|
+
for (const row of activity) {
|
|
712
|
+
const item = { accountId: row.account_id, updatedAt: row.event_at };
|
|
713
|
+
if (row.activity_type === 'last_session' && row.session_key) {
|
|
714
|
+
lastSessionByAccount.push({
|
|
715
|
+
accountId: row.account_id,
|
|
716
|
+
sessionKey: row.session_key,
|
|
717
|
+
scope: row.session_scope ?? '',
|
|
718
|
+
updatedAt: row.event_at,
|
|
719
|
+
});
|
|
720
|
+
} else if (row.activity_type === 'last_activity') {
|
|
721
|
+
lastActivityByAccount.push(item);
|
|
722
|
+
} else if (row.activity_type === 'last_inbound') {
|
|
723
|
+
lastInboundByAccount.push(item);
|
|
724
|
+
} else if (row.activity_type === 'last_outbound') {
|
|
725
|
+
lastOutboundByAccount.push(item);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const driftRow = db
|
|
730
|
+
.prepare(
|
|
731
|
+
`SELECT captured_at, register_count, api_generation, post_warmup_register_count,
|
|
732
|
+
api_instance_id, registry_fingerprint, dominant_bucket, source_buckets_json,
|
|
733
|
+
trace_window_size, trace_recent_json
|
|
734
|
+
FROM register_drift
|
|
735
|
+
WHERE id = 1`,
|
|
736
|
+
)
|
|
737
|
+
.get() as
|
|
738
|
+
| {
|
|
739
|
+
captured_at: number;
|
|
740
|
+
register_count: number;
|
|
741
|
+
api_generation: number;
|
|
742
|
+
post_warmup_register_count: number;
|
|
743
|
+
api_instance_id: string;
|
|
744
|
+
registry_fingerprint: string;
|
|
745
|
+
dominant_bucket: string | null;
|
|
746
|
+
source_buckets_json: string;
|
|
747
|
+
trace_window_size: number;
|
|
748
|
+
trace_recent_json: string;
|
|
749
|
+
}
|
|
750
|
+
| undefined;
|
|
751
|
+
|
|
752
|
+
const lastDriftSnapshot = driftRow
|
|
753
|
+
? normalizeRegisterDriftSnapshot({
|
|
754
|
+
capturedAt: driftRow.captured_at,
|
|
755
|
+
registerCount: driftRow.register_count,
|
|
756
|
+
apiGeneration: driftRow.api_generation,
|
|
757
|
+
postWarmupRegisterCount: driftRow.post_warmup_register_count,
|
|
758
|
+
apiInstanceId: driftRow.api_instance_id,
|
|
759
|
+
registryFingerprint: driftRow.registry_fingerprint,
|
|
760
|
+
dominantBucket: driftRow.dominant_bucket,
|
|
761
|
+
sourceBuckets: JSON.parse(driftRow.source_buckets_json ?? '{}'),
|
|
762
|
+
traceWindowSize: driftRow.trace_window_size,
|
|
763
|
+
traceRecent: JSON.parse(driftRow.trace_recent_json ?? '[]'),
|
|
764
|
+
})
|
|
765
|
+
: null;
|
|
766
|
+
|
|
767
|
+
return {
|
|
768
|
+
sessionRoutes,
|
|
769
|
+
sceneRegistry,
|
|
770
|
+
lastSessionByAccount,
|
|
771
|
+
lastActivityByAccount,
|
|
772
|
+
lastInboundByAccount,
|
|
773
|
+
lastOutboundByAccount,
|
|
774
|
+
lastDriftSnapshot,
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function deriveHistoryKeyFromRoute(args: {
|
|
779
|
+
platform?: string;
|
|
780
|
+
groupId?: string;
|
|
781
|
+
userId?: string;
|
|
782
|
+
}): string | null {
|
|
783
|
+
const platform = String(args.platform || '').trim();
|
|
784
|
+
const groupId = String(args.groupId || '0').trim() || '0';
|
|
785
|
+
const userId = String(args.userId || '0').trim() || '0';
|
|
786
|
+
if (!platform) return null;
|
|
787
|
+
if (groupId !== '0') return `${platform}:${groupId}`;
|
|
788
|
+
if (userId !== '0') return `${platform}:${userId}`;
|
|
789
|
+
return null;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function parseMediaJson(value: string) {
|
|
793
|
+
try {
|
|
794
|
+
const parsed = JSON.parse(value ?? '[]');
|
|
795
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
796
|
+
} catch {
|
|
797
|
+
return [];
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function replaceHistoryMessages(
|
|
802
|
+
db: DatabaseSync,
|
|
803
|
+
historyBuckets: BncrPersistedConversationHistoryBucket[],
|
|
804
|
+
replayBuckets: BncrPersistedOutboundReplayBucket[],
|
|
805
|
+
now: () => number,
|
|
806
|
+
expectedRevision?: number,
|
|
807
|
+
): void {
|
|
808
|
+
const timestamp = now();
|
|
809
|
+
db.exec('BEGIN IMMEDIATE');
|
|
810
|
+
try {
|
|
811
|
+
const currentRevisionRow = db
|
|
812
|
+
.prepare("SELECT meta_value FROM state_meta WHERE meta_key = 'history_revision'")
|
|
813
|
+
.get() as { meta_value: string } | undefined;
|
|
814
|
+
const currentRevision = Number(currentRevisionRow?.meta_value ?? 0);
|
|
815
|
+
if (expectedRevision !== undefined && currentRevision !== expectedRevision) {
|
|
816
|
+
throw new Error(
|
|
817
|
+
`bncr sqlite history revision conflict: expected ${expectedRevision}, got ${currentRevision}`,
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
db.exec('DELETE FROM conversation_messages WHERE shard_id IS NULL');
|
|
821
|
+
|
|
822
|
+
const insert = db.prepare(
|
|
823
|
+
`INSERT INTO conversation_messages (
|
|
824
|
+
storage_kind, history_key, buffer_key, account_id, sender, sender_id, role,
|
|
825
|
+
body, message_ts, message_id, media_json, session_key, replay_status,
|
|
826
|
+
outbound_type, media_url, outbound_created_at, route_platform,
|
|
827
|
+
route_group_id, route_user_id, created_at, updated_at
|
|
828
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
829
|
+
);
|
|
830
|
+
const isShardOwnedHistoryMessage = db.prepare(
|
|
831
|
+
`SELECT 1 FROM conversation_messages
|
|
832
|
+
WHERE shard_id IS NOT NULL
|
|
833
|
+
AND storage_kind = ?
|
|
834
|
+
AND history_key = ?
|
|
835
|
+
AND message_id = ?
|
|
836
|
+
LIMIT 1`,
|
|
837
|
+
);
|
|
838
|
+
const isShardOwnedReplayMessage = db.prepare(
|
|
839
|
+
`SELECT 1 FROM conversation_messages
|
|
840
|
+
WHERE shard_id IS NOT NULL
|
|
841
|
+
AND storage_kind = ?
|
|
842
|
+
AND buffer_key = ?
|
|
843
|
+
AND message_id = ?
|
|
844
|
+
LIMIT 1`,
|
|
845
|
+
);
|
|
846
|
+
const isConsumedHistoryMessage = db.prepare(
|
|
847
|
+
`SELECT 1 FROM history_message_consumed
|
|
848
|
+
WHERE storage_kind = 'history'
|
|
849
|
+
AND history_key = ?
|
|
850
|
+
AND message_id = ?
|
|
851
|
+
LIMIT 1`,
|
|
852
|
+
);
|
|
853
|
+
const isConsumedReplayMessage = db.prepare(
|
|
854
|
+
`SELECT 1 FROM history_message_consumed
|
|
855
|
+
WHERE storage_kind = 'replay'
|
|
856
|
+
AND buffer_key = ?
|
|
857
|
+
AND message_id = ?
|
|
858
|
+
LIMIT 1`,
|
|
859
|
+
);
|
|
860
|
+
const seenHistoryMessageIds = new Map<string, Set<string>>();
|
|
861
|
+
const seenReplayMessageIds = new Map<string, Set<string>>();
|
|
862
|
+
|
|
863
|
+
for (const bucket of historyBuckets) {
|
|
864
|
+
const historyKey = String(bucket.key || '').trim();
|
|
865
|
+
if (!historyKey) continue;
|
|
866
|
+
for (const entry of Array.isArray(bucket.entries) ? bucket.entries : []) {
|
|
867
|
+
const sender = String(entry?.sender || '').trim();
|
|
868
|
+
const body = String(entry?.body || '').trim();
|
|
869
|
+
if (!sender || !body) continue;
|
|
870
|
+
if (entry.messageId) {
|
|
871
|
+
const seen = seenHistoryMessageIds.get(historyKey) ?? new Set<string>();
|
|
872
|
+
if (seen.has(entry.messageId)) continue;
|
|
873
|
+
seen.add(entry.messageId);
|
|
874
|
+
seenHistoryMessageIds.set(historyKey, seen);
|
|
875
|
+
}
|
|
876
|
+
if (
|
|
877
|
+
entry.messageId &&
|
|
878
|
+
isShardOwnedHistoryMessage.get('history', historyKey, entry.messageId)
|
|
879
|
+
) {
|
|
880
|
+
continue;
|
|
881
|
+
}
|
|
882
|
+
if (entry.messageId && isConsumedHistoryMessage.get(historyKey, entry.messageId)) {
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
insert.run(
|
|
886
|
+
'history',
|
|
887
|
+
historyKey,
|
|
888
|
+
historyKey,
|
|
889
|
+
null,
|
|
890
|
+
sender,
|
|
891
|
+
entry.senderId ?? null,
|
|
892
|
+
entry.role ?? null,
|
|
893
|
+
body,
|
|
894
|
+
typeof entry.timestamp === 'number' ? entry.timestamp : null,
|
|
895
|
+
entry.messageId ?? null,
|
|
896
|
+
JSON.stringify(entry.media ?? []),
|
|
897
|
+
null,
|
|
898
|
+
null,
|
|
899
|
+
null,
|
|
900
|
+
null,
|
|
901
|
+
null,
|
|
902
|
+
null,
|
|
903
|
+
null,
|
|
904
|
+
null,
|
|
905
|
+
typeof entry.timestamp === 'number' ? entry.timestamp || timestamp : timestamp,
|
|
906
|
+
timestamp,
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
for (const bucket of replayBuckets) {
|
|
912
|
+
const bufferKey = String(bucket.key || '').trim();
|
|
913
|
+
if (!bufferKey) continue;
|
|
914
|
+
for (const entry of Array.isArray(bucket.entries) ? bucket.entries : []) {
|
|
915
|
+
const sender = String(entry?.sender || '').trim();
|
|
916
|
+
const body = String(entry?.body || '').trim();
|
|
917
|
+
if (!sender || !body) continue;
|
|
918
|
+
if (entry.messageId) {
|
|
919
|
+
const seen = seenReplayMessageIds.get(bufferKey) ?? new Set<string>();
|
|
920
|
+
if (seen.has(entry.messageId)) continue;
|
|
921
|
+
seen.add(entry.messageId);
|
|
922
|
+
seenReplayMessageIds.set(bufferKey, seen);
|
|
923
|
+
}
|
|
924
|
+
if (
|
|
925
|
+
entry.messageId &&
|
|
926
|
+
isShardOwnedReplayMessage.get('replay', bufferKey, entry.messageId)
|
|
927
|
+
) {
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
if (entry.messageId && isConsumedReplayMessage.get(bufferKey, entry.messageId)) {
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
933
|
+
const historyKey = deriveHistoryKeyFromRoute({
|
|
934
|
+
platform: entry.route?.platform,
|
|
935
|
+
groupId: entry.route?.groupId,
|
|
936
|
+
userId: entry.route?.userId,
|
|
937
|
+
});
|
|
938
|
+
insert.run(
|
|
939
|
+
'replay',
|
|
940
|
+
historyKey ?? '',
|
|
941
|
+
bufferKey,
|
|
942
|
+
entry.accountId ?? null,
|
|
943
|
+
sender,
|
|
944
|
+
entry.senderId ?? null,
|
|
945
|
+
'assistant',
|
|
946
|
+
body,
|
|
947
|
+
typeof entry.timestamp === 'number' ? entry.timestamp : null,
|
|
948
|
+
entry.messageId ?? null,
|
|
949
|
+
JSON.stringify(entry.media ?? []),
|
|
950
|
+
entry.sessionKey ?? null,
|
|
951
|
+
entry.status ?? null,
|
|
952
|
+
entry.type ?? null,
|
|
953
|
+
entry.mediaUrl ?? null,
|
|
954
|
+
typeof entry.createdAt === 'number' ? entry.createdAt : null,
|
|
955
|
+
entry.route?.platform ?? null,
|
|
956
|
+
entry.route?.groupId ?? '0',
|
|
957
|
+
entry.route?.userId ?? '0',
|
|
958
|
+
typeof entry.createdAt === 'number' ? entry.createdAt || timestamp : timestamp,
|
|
959
|
+
timestamp,
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
db.prepare(
|
|
964
|
+
`INSERT INTO state_meta (meta_key, meta_value, updated_at)
|
|
965
|
+
VALUES ('history_imported_at', ?, ?)
|
|
966
|
+
ON CONFLICT(meta_key) DO NOTHING`,
|
|
967
|
+
).run(String(timestamp), timestamp);
|
|
968
|
+
db.prepare(
|
|
969
|
+
`INSERT INTO state_meta (meta_key, meta_value, updated_at)
|
|
970
|
+
VALUES ('history_revision', ?, ?)
|
|
971
|
+
ON CONFLICT(meta_key) DO UPDATE SET meta_value = excluded.meta_value, updated_at = excluded.updated_at`,
|
|
972
|
+
).run(String(currentRevision + 1), timestamp);
|
|
973
|
+
db.exec('COMMIT');
|
|
974
|
+
} catch (error) {
|
|
975
|
+
db.exec('ROLLBACK');
|
|
976
|
+
throw error;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
type BncrHistoryMessageRow = {
|
|
981
|
+
id: number;
|
|
982
|
+
storage_kind: string;
|
|
983
|
+
history_key: string;
|
|
984
|
+
buffer_key: string;
|
|
985
|
+
account_id: string | null;
|
|
986
|
+
sender: string;
|
|
987
|
+
sender_id: string | null;
|
|
988
|
+
role: 'user' | 'assistant' | 'system' | null;
|
|
989
|
+
body: string;
|
|
990
|
+
message_ts: number | null;
|
|
991
|
+
message_id: string | null;
|
|
992
|
+
media_json: string;
|
|
993
|
+
session_key: string | null;
|
|
994
|
+
replay_status: 'pushed' | 'acked' | null;
|
|
995
|
+
outbound_type: string | null;
|
|
996
|
+
media_url: string | null;
|
|
997
|
+
outbound_created_at: number | null;
|
|
998
|
+
route_platform: string | null;
|
|
999
|
+
route_group_id: string | null;
|
|
1000
|
+
route_user_id: string | null;
|
|
1001
|
+
};
|
|
1002
|
+
|
|
1003
|
+
function historyStateFromRows(rows: readonly BncrHistoryMessageRow[]): BncrSqliteHistoryState {
|
|
1004
|
+
const historyBuckets = new Map<string, BncrPersistedConversationHistoryBucket>();
|
|
1005
|
+
const replayBuckets = new Map<string, BncrPersistedOutboundReplayBucket>();
|
|
1006
|
+
for (const row of rows) {
|
|
1007
|
+
if (row.storage_kind === 'history') {
|
|
1008
|
+
const key = row.history_key;
|
|
1009
|
+
if (!key) continue;
|
|
1010
|
+
const media = parseMediaJson(row.media_json);
|
|
1011
|
+
const entry: BncrPersistedConversationHistoryEntry = {
|
|
1012
|
+
sender: row.sender,
|
|
1013
|
+
body: row.body,
|
|
1014
|
+
};
|
|
1015
|
+
if (row.sender_id !== null) entry.senderId = row.sender_id;
|
|
1016
|
+
if (row.role !== null) entry.role = row.role;
|
|
1017
|
+
if (row.message_ts !== null) entry.timestamp = row.message_ts;
|
|
1018
|
+
if (row.message_id !== null) entry.messageId = row.message_id;
|
|
1019
|
+
if (media.length > 0) entry.media = media;
|
|
1020
|
+
const bucket = historyBuckets.get(key) ?? { key, entries: [] };
|
|
1021
|
+
bucket.entries.push(entry);
|
|
1022
|
+
historyBuckets.set(key, bucket);
|
|
1023
|
+
continue;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
if (row.storage_kind === 'replay') {
|
|
1027
|
+
const key = row.buffer_key;
|
|
1028
|
+
if (!key) continue;
|
|
1029
|
+
const media = parseMediaJson(row.media_json);
|
|
1030
|
+
const entry: BncrPersistedOutboundReplayEntry = {
|
|
1031
|
+
sender: row.sender,
|
|
1032
|
+
body: row.body,
|
|
1033
|
+
route: {
|
|
1034
|
+
platform: row.route_platform ?? '',
|
|
1035
|
+
groupId: row.route_group_id ?? '0',
|
|
1036
|
+
userId: row.route_user_id ?? '0',
|
|
1037
|
+
},
|
|
1038
|
+
};
|
|
1039
|
+
if (row.sender_id !== null) entry.senderId = row.sender_id;
|
|
1040
|
+
if (row.message_ts !== null) entry.timestamp = row.message_ts;
|
|
1041
|
+
if (row.message_id !== null) entry.messageId = row.message_id;
|
|
1042
|
+
if (media.length > 0) entry.media = media;
|
|
1043
|
+
if (row.account_id !== null) entry.accountId = row.account_id;
|
|
1044
|
+
if (row.session_key !== null) entry.sessionKey = row.session_key;
|
|
1045
|
+
if (row.replay_status !== null) entry.status = row.replay_status;
|
|
1046
|
+
if (row.outbound_type !== null) entry.type = row.outbound_type;
|
|
1047
|
+
if (row.media_url !== null) entry.mediaUrl = row.media_url;
|
|
1048
|
+
if (row.outbound_created_at !== null) entry.createdAt = row.outbound_created_at;
|
|
1049
|
+
const bucket = replayBuckets.get(key) ?? { key, entries: [] };
|
|
1050
|
+
bucket.entries.push(entry);
|
|
1051
|
+
replayBuckets.set(key, bucket);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
return {
|
|
1055
|
+
historyBuckets: Array.from(historyBuckets.values()),
|
|
1056
|
+
replayBuckets: Array.from(replayBuckets.values()),
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function restoreTerminalHistoryShardsToActive(db: DatabaseSync): BncrSqliteHistoryState {
|
|
1061
|
+
db.exec('BEGIN IMMEDIATE');
|
|
1062
|
+
try {
|
|
1063
|
+
const terminal = (
|
|
1064
|
+
db
|
|
1065
|
+
.prepare(
|
|
1066
|
+
`SELECT id
|
|
1067
|
+
FROM history_shards
|
|
1068
|
+
WHERE status = 'failed'
|
|
1069
|
+
AND attempts >= ?
|
|
1070
|
+
AND next_attempt_at IS NULL`,
|
|
1071
|
+
)
|
|
1072
|
+
.all(HISTORY_SHARD_MAX_ATTEMPTS) as Array<{ id: number }>
|
|
1073
|
+
).map((row) => row.id);
|
|
1074
|
+
if (terminal.length === 0) {
|
|
1075
|
+
db.exec('COMMIT');
|
|
1076
|
+
return { historyBuckets: [], replayBuckets: [] };
|
|
1077
|
+
}
|
|
1078
|
+
const placeholders = terminal.map(() => '?').join(', ');
|
|
1079
|
+
const rows = db
|
|
1080
|
+
.prepare(
|
|
1081
|
+
`SELECT cm.id, cm.storage_kind, cm.history_key, cm.buffer_key, cm.account_id,
|
|
1082
|
+
cm.sender, cm.sender_id, cm.role, cm.body, cm.message_ts, cm.message_id,
|
|
1083
|
+
cm.media_json, cm.session_key, cm.replay_status, cm.outbound_type,
|
|
1084
|
+
cm.media_url, cm.outbound_created_at, cm.route_platform,
|
|
1085
|
+
cm.route_group_id, cm.route_user_id
|
|
1086
|
+
FROM conversation_messages cm
|
|
1087
|
+
WHERE cm.shard_id IN (${placeholders})
|
|
1088
|
+
ORDER BY cm.id ASC`,
|
|
1089
|
+
)
|
|
1090
|
+
.all(...terminal) as BncrHistoryMessageRow[];
|
|
1091
|
+
const restoreMessages = db.prepare(
|
|
1092
|
+
`UPDATE conversation_messages SET shard_id = NULL WHERE shard_id IN (${placeholders})`,
|
|
1093
|
+
);
|
|
1094
|
+
const deleteShards = db.prepare(`DELETE FROM history_shards WHERE id IN (${placeholders})`);
|
|
1095
|
+
const deleteConsumed = db.prepare(
|
|
1096
|
+
`DELETE FROM conversation_messages
|
|
1097
|
+
WHERE shard_id IN (${placeholders})
|
|
1098
|
+
AND message_id IS NOT NULL
|
|
1099
|
+
AND message_id != ''
|
|
1100
|
+
AND EXISTS (
|
|
1101
|
+
SELECT 1 FROM history_message_consumed t
|
|
1102
|
+
WHERE t.storage_kind = conversation_messages.storage_kind
|
|
1103
|
+
AND t.history_key = conversation_messages.history_key
|
|
1104
|
+
AND t.buffer_key = conversation_messages.buffer_key
|
|
1105
|
+
AND t.message_id = conversation_messages.message_id
|
|
1106
|
+
)`,
|
|
1107
|
+
);
|
|
1108
|
+
deleteConsumed.run(...terminal);
|
|
1109
|
+
restoreMessages.run(...terminal);
|
|
1110
|
+
// Once the messages are active again the terminal shard row has no further
|
|
1111
|
+
// queueing value and must not block later SQLite-only cutover.
|
|
1112
|
+
deleteShards.run(...terminal);
|
|
1113
|
+
const revisionRow = db
|
|
1114
|
+
.prepare("SELECT meta_value FROM state_meta WHERE meta_key = 'history_revision'")
|
|
1115
|
+
.get() as { meta_value: string } | undefined;
|
|
1116
|
+
const nextRevision = Number(revisionRow?.meta_value ?? 0) + 1;
|
|
1117
|
+
const restoredAt = Date.now();
|
|
1118
|
+
db.prepare(
|
|
1119
|
+
`INSERT INTO state_meta (meta_key, meta_value, updated_at)
|
|
1120
|
+
VALUES ('history_revision', ?, ?)
|
|
1121
|
+
ON CONFLICT(meta_key) DO UPDATE SET meta_value = excluded.meta_value, updated_at = excluded.updated_at`,
|
|
1122
|
+
).run(String(nextRevision), restoredAt);
|
|
1123
|
+
db.exec('COMMIT');
|
|
1124
|
+
const isConsumed = db.prepare(
|
|
1125
|
+
`SELECT 1 FROM history_message_consumed
|
|
1126
|
+
WHERE storage_kind = ?
|
|
1127
|
+
AND history_key = ?
|
|
1128
|
+
AND buffer_key = ?
|
|
1129
|
+
AND message_id = ?
|
|
1130
|
+
LIMIT 1`,
|
|
1131
|
+
);
|
|
1132
|
+
return historyStateFromRows(
|
|
1133
|
+
rows.filter(
|
|
1134
|
+
(row) => !isConsumed.get(row.storage_kind, row.history_key, row.buffer_key, row.message_id),
|
|
1135
|
+
),
|
|
1136
|
+
);
|
|
1137
|
+
} catch (error) {
|
|
1138
|
+
db.exec('ROLLBACK');
|
|
1139
|
+
throw error;
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function loadHistoryState(db: DatabaseSync, now: number = Date.now()): BncrSqliteHistoryState {
|
|
1144
|
+
cleanupExpiredHistoryMessageTombstones(db, now);
|
|
1145
|
+
cleanupCompletedHistoryShards(db);
|
|
1146
|
+
restoreTerminalHistoryShardsToActive(db);
|
|
1147
|
+
const rows = db
|
|
1148
|
+
.prepare(
|
|
1149
|
+
`SELECT cm.id, cm.storage_kind, cm.history_key, cm.buffer_key, cm.account_id,
|
|
1150
|
+
cm.sender, cm.sender_id, cm.role, cm.body, cm.message_ts, cm.message_id,
|
|
1151
|
+
cm.media_json, cm.session_key, cm.replay_status, cm.outbound_type,
|
|
1152
|
+
cm.media_url, cm.outbound_created_at, cm.route_platform,
|
|
1153
|
+
cm.route_group_id, cm.route_user_id
|
|
1154
|
+
FROM conversation_messages cm
|
|
1155
|
+
WHERE cm.shard_id IS NULL
|
|
1156
|
+
ORDER BY cm.id ASC`,
|
|
1157
|
+
)
|
|
1158
|
+
.all() as BncrHistoryMessageRow[];
|
|
1159
|
+
return historyStateFromRows(rows);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function cleanupExpiredHistoryMessageTombstones(
|
|
1163
|
+
db: DatabaseSync,
|
|
1164
|
+
now: number,
|
|
1165
|
+
retentionMs = HISTORY_CONSUMED_TOMBSTONE_RETENTION_MS,
|
|
1166
|
+
): number {
|
|
1167
|
+
const result = db
|
|
1168
|
+
.prepare('DELETE FROM history_message_consumed WHERE consumed_at < ?')
|
|
1169
|
+
.run(now - retentionMs);
|
|
1170
|
+
return Number(result.changes);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
function cleanupCompletedHistoryShards(db: DatabaseSync): number {
|
|
1174
|
+
let completedCount = 0;
|
|
1175
|
+
db.exec('BEGIN IMMEDIATE');
|
|
1176
|
+
try {
|
|
1177
|
+
const completed = (
|
|
1178
|
+
db.prepare(`SELECT id FROM history_shards WHERE status = 'completed'`).all() as Array<{
|
|
1179
|
+
id: number;
|
|
1180
|
+
}>
|
|
1181
|
+
).map((row) => row.id);
|
|
1182
|
+
if (completed.length === 0) {
|
|
1183
|
+
db.exec('COMMIT');
|
|
1184
|
+
return 0;
|
|
1185
|
+
}
|
|
1186
|
+
const deleteMessages = db.prepare('DELETE FROM conversation_messages WHERE shard_id = ?');
|
|
1187
|
+
const deleteShard = db.prepare('DELETE FROM history_shards WHERE id = ?');
|
|
1188
|
+
for (const id of completed) {
|
|
1189
|
+
recordConsumedHistoryShardMessages(db, id, Date.now());
|
|
1190
|
+
deleteMessages.run(id);
|
|
1191
|
+
deleteShard.run(id);
|
|
1192
|
+
}
|
|
1193
|
+
completedCount = completed.length;
|
|
1194
|
+
db.exec('COMMIT');
|
|
1195
|
+
return completedCount;
|
|
1196
|
+
} catch (error) {
|
|
1197
|
+
db.exec('ROLLBACK');
|
|
1198
|
+
throw error;
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function recordConsumedHistoryShardMessages(
|
|
1203
|
+
db: DatabaseSync,
|
|
1204
|
+
shardId: number,
|
|
1205
|
+
timestamp: number,
|
|
1206
|
+
): number {
|
|
1207
|
+
const rows = db
|
|
1208
|
+
.prepare(
|
|
1209
|
+
`SELECT DISTINCT storage_kind, history_key, buffer_key, message_id
|
|
1210
|
+
FROM conversation_messages
|
|
1211
|
+
WHERE shard_id = ?
|
|
1212
|
+
AND message_id IS NOT NULL
|
|
1213
|
+
AND message_id != ''`,
|
|
1214
|
+
)
|
|
1215
|
+
.all(shardId) as Array<{
|
|
1216
|
+
storage_kind: 'history' | 'replay';
|
|
1217
|
+
history_key: string;
|
|
1218
|
+
buffer_key: string;
|
|
1219
|
+
message_id: string;
|
|
1220
|
+
}>;
|
|
1221
|
+
const consumed = rows.length;
|
|
1222
|
+
const insert = db.prepare(
|
|
1223
|
+
`INSERT INTO history_message_consumed (
|
|
1224
|
+
storage_kind, history_key, buffer_key, message_id, consumed_at, created_at
|
|
1225
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
1226
|
+
ON CONFLICT(storage_kind, history_key, buffer_key, message_id) DO NOTHING`,
|
|
1227
|
+
);
|
|
1228
|
+
const deleteConsumedHistory = db.prepare(
|
|
1229
|
+
`DELETE FROM conversation_messages
|
|
1230
|
+
WHERE shard_id IS NULL
|
|
1231
|
+
AND storage_kind = 'history'
|
|
1232
|
+
AND history_key = ?
|
|
1233
|
+
AND message_id = ?`,
|
|
1234
|
+
);
|
|
1235
|
+
const deleteConsumedReplay = db.prepare(
|
|
1236
|
+
`DELETE FROM conversation_messages
|
|
1237
|
+
WHERE shard_id IS NULL
|
|
1238
|
+
AND storage_kind = 'replay'
|
|
1239
|
+
AND buffer_key = ?
|
|
1240
|
+
AND message_id = ?`,
|
|
1241
|
+
);
|
|
1242
|
+
const insertConsumed = (
|
|
1243
|
+
storageKind: 'history' | 'replay',
|
|
1244
|
+
historyKey: string,
|
|
1245
|
+
bufferKey: string,
|
|
1246
|
+
messageId: string,
|
|
1247
|
+
) => {
|
|
1248
|
+
if (!historyKey || !bufferKey || !messageId) return;
|
|
1249
|
+
insert.run(storageKind, historyKey, bufferKey, messageId, timestamp, timestamp);
|
|
1250
|
+
};
|
|
1251
|
+
for (const row of rows) {
|
|
1252
|
+
insertConsumed(row.storage_kind, row.history_key, row.buffer_key, row.message_id);
|
|
1253
|
+
}
|
|
1254
|
+
const shard = db
|
|
1255
|
+
.prepare(
|
|
1256
|
+
`SELECT history_key, message_ids_json, buffer_keys_json
|
|
1257
|
+
FROM history_shards
|
|
1258
|
+
WHERE id = ?`,
|
|
1259
|
+
)
|
|
1260
|
+
.get(shardId) as
|
|
1261
|
+
| { history_key: string; message_ids_json: string; buffer_keys_json: string }
|
|
1262
|
+
| undefined;
|
|
1263
|
+
if (shard) {
|
|
1264
|
+
const historyKey = String(shard.history_key || '').trim();
|
|
1265
|
+
const messageIds = parseOutboundStringArray(shard.message_ids_json);
|
|
1266
|
+
const bufferKeys = parseOutboundStringArray(shard.buffer_keys_json);
|
|
1267
|
+
for (const messageId of messageIds) {
|
|
1268
|
+
if (historyKey) {
|
|
1269
|
+
insertConsumed('history', historyKey, historyKey, messageId);
|
|
1270
|
+
deleteConsumedHistory.run(historyKey, messageId);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
for (const bufferKey of bufferKeys.length > 0 ? bufferKeys : [historyKey]) {
|
|
1274
|
+
for (const messageId of messageIds) {
|
|
1275
|
+
insertConsumed('replay', historyKey || bufferKey, bufferKey, messageId);
|
|
1276
|
+
deleteConsumedReplay.run(bufferKey, messageId);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
return consumed;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
function parseOutboundPayloadJson(value: string): Record<string, unknown> {
|
|
1284
|
+
try {
|
|
1285
|
+
const parsed = JSON.parse(value ?? '{}') as unknown;
|
|
1286
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
1287
|
+
? (parsed as Record<string, unknown>)
|
|
1288
|
+
: {};
|
|
1289
|
+
} catch {
|
|
1290
|
+
return {};
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
function parseOutboundStringArray(value: string): string[] {
|
|
1295
|
+
try {
|
|
1296
|
+
const parsed = JSON.parse(value ?? '[]') as unknown;
|
|
1297
|
+
return Array.isArray(parsed) ? parsed.map((item) => String(item)).filter(Boolean) : [];
|
|
1298
|
+
} catch {
|
|
1299
|
+
return [];
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
function replaceOutboundState(
|
|
1304
|
+
db: DatabaseSync,
|
|
1305
|
+
outbox: readonly OutboxEntry[],
|
|
1306
|
+
deadLetter: readonly OutboxEntry[],
|
|
1307
|
+
now: () => number,
|
|
1308
|
+
): void {
|
|
1309
|
+
const timestamp = now();
|
|
1310
|
+
db.exec('BEGIN IMMEDIATE');
|
|
1311
|
+
try {
|
|
1312
|
+
db.exec('DELETE FROM outbound_messages');
|
|
1313
|
+
const insert = db.prepare(
|
|
1314
|
+
`INSERT INTO outbound_messages (
|
|
1315
|
+
message_id, account_id, session_key, route_platform, route_group_id,
|
|
1316
|
+
route_user_id, payload_json, queue_state, retry_count, next_attempt_at,
|
|
1317
|
+
last_attempt_at, last_error, last_push_at, last_push_conn_id,
|
|
1318
|
+
last_push_client_id, route_attempt_conn_ids_json, route_attempt_round,
|
|
1319
|
+
fast_reroute_pending, awaiting_retry_push, created_at, updated_at
|
|
1320
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1321
|
+
);
|
|
1322
|
+
const write = (entry: OutboxEntry, queueState: 'queued' | 'dead') => {
|
|
1323
|
+
insert.run(
|
|
1324
|
+
entry.messageId,
|
|
1325
|
+
entry.accountId,
|
|
1326
|
+
entry.sessionKey,
|
|
1327
|
+
entry.route.platform,
|
|
1328
|
+
entry.route.groupId || '0',
|
|
1329
|
+
entry.route.userId || '0',
|
|
1330
|
+
JSON.stringify(entry.payload ?? {}),
|
|
1331
|
+
queueState,
|
|
1332
|
+
Number(entry.retryCount) || 0,
|
|
1333
|
+
Number(entry.nextAttemptAt) || timestamp,
|
|
1334
|
+
typeof entry.lastAttemptAt === 'number' ? entry.lastAttemptAt : null,
|
|
1335
|
+
entry.lastError ?? null,
|
|
1336
|
+
typeof entry.lastPushAt === 'number' ? entry.lastPushAt : null,
|
|
1337
|
+
entry.lastPushConnId ?? null,
|
|
1338
|
+
entry.lastPushClientId ?? null,
|
|
1339
|
+
JSON.stringify(Array.isArray(entry.routeAttemptConnIds) ? entry.routeAttemptConnIds : []),
|
|
1340
|
+
typeof entry.routeAttemptRound === 'number' ? entry.routeAttemptRound : 0,
|
|
1341
|
+
entry.fastReroutePending ? 1 : 0,
|
|
1342
|
+
entry.awaitingRetryPush ? 1 : 0,
|
|
1343
|
+
Number(entry.createdAt) || timestamp,
|
|
1344
|
+
timestamp,
|
|
1345
|
+
);
|
|
1346
|
+
};
|
|
1347
|
+
|
|
1348
|
+
for (const entry of outbox) write(entry, 'queued');
|
|
1349
|
+
for (const entry of deadLetter) write(entry, 'dead');
|
|
1350
|
+
db.prepare(
|
|
1351
|
+
`INSERT INTO state_meta (meta_key, meta_value, updated_at)
|
|
1352
|
+
VALUES ('outbound_imported_at', ?, ?)
|
|
1353
|
+
ON CONFLICT(meta_key) DO NOTHING`,
|
|
1354
|
+
).run(String(timestamp), timestamp);
|
|
1355
|
+
db.exec('COMMIT');
|
|
1356
|
+
} catch (error) {
|
|
1357
|
+
db.exec('ROLLBACK');
|
|
1358
|
+
throw error;
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
function loadOutboundState(db: DatabaseSync): BncrSqliteOutboundState {
|
|
1363
|
+
const rows = db
|
|
1364
|
+
.prepare(
|
|
1365
|
+
`SELECT message_id, account_id, session_key, route_platform, route_group_id,
|
|
1366
|
+
route_user_id, payload_json, queue_state, retry_count, next_attempt_at,
|
|
1367
|
+
last_attempt_at, last_error, last_push_at, last_push_conn_id,
|
|
1368
|
+
last_push_client_id, route_attempt_conn_ids_json, route_attempt_round,
|
|
1369
|
+
fast_reroute_pending, awaiting_retry_push, created_at
|
|
1370
|
+
FROM outbound_messages
|
|
1371
|
+
ORDER BY id ASC`,
|
|
1372
|
+
)
|
|
1373
|
+
.all() as Array<{
|
|
1374
|
+
message_id: string;
|
|
1375
|
+
account_id: string;
|
|
1376
|
+
session_key: string;
|
|
1377
|
+
route_platform: string;
|
|
1378
|
+
route_group_id: string;
|
|
1379
|
+
route_user_id: string;
|
|
1380
|
+
payload_json: string;
|
|
1381
|
+
queue_state: string;
|
|
1382
|
+
retry_count: number;
|
|
1383
|
+
next_attempt_at: number;
|
|
1384
|
+
last_attempt_at: number | null;
|
|
1385
|
+
last_error: string | null;
|
|
1386
|
+
last_push_at: number | null;
|
|
1387
|
+
last_push_conn_id: string | null;
|
|
1388
|
+
last_push_client_id: string | null;
|
|
1389
|
+
route_attempt_conn_ids_json: string;
|
|
1390
|
+
route_attempt_round: number | null;
|
|
1391
|
+
fast_reroute_pending: number;
|
|
1392
|
+
awaiting_retry_push: number;
|
|
1393
|
+
created_at: number;
|
|
1394
|
+
}>;
|
|
1395
|
+
|
|
1396
|
+
const outbox: OutboxEntry[] = [];
|
|
1397
|
+
const deadLetter: OutboxEntry[] = [];
|
|
1398
|
+
for (const row of rows) {
|
|
1399
|
+
const entry: OutboxEntry = {
|
|
1400
|
+
messageId: row.message_id,
|
|
1401
|
+
accountId: row.account_id,
|
|
1402
|
+
sessionKey: row.session_key,
|
|
1403
|
+
route: {
|
|
1404
|
+
platform: row.route_platform,
|
|
1405
|
+
groupId: row.route_group_id || '0',
|
|
1406
|
+
userId: row.route_user_id || '0',
|
|
1407
|
+
},
|
|
1408
|
+
payload: parseOutboundPayloadJson(row.payload_json),
|
|
1409
|
+
createdAt: row.created_at,
|
|
1410
|
+
retryCount: row.retry_count,
|
|
1411
|
+
nextAttemptAt: row.next_attempt_at,
|
|
1412
|
+
};
|
|
1413
|
+
if (row.last_attempt_at !== null) entry.lastAttemptAt = row.last_attempt_at;
|
|
1414
|
+
if (row.last_error !== null) entry.lastError = row.last_error;
|
|
1415
|
+
if (row.last_push_at !== null) entry.lastPushAt = row.last_push_at;
|
|
1416
|
+
if (row.last_push_conn_id !== null) entry.lastPushConnId = row.last_push_conn_id;
|
|
1417
|
+
if (row.last_push_client_id !== null) entry.lastPushClientId = row.last_push_client_id;
|
|
1418
|
+
const routeAttemptConnIds = parseOutboundStringArray(row.route_attempt_conn_ids_json);
|
|
1419
|
+
if (routeAttemptConnIds.length > 0) entry.routeAttemptConnIds = routeAttemptConnIds;
|
|
1420
|
+
if (row.route_attempt_round !== null) entry.routeAttemptRound = row.route_attempt_round;
|
|
1421
|
+
entry.fastReroutePending = row.fast_reroute_pending === 1;
|
|
1422
|
+
entry.awaitingRetryPush = row.awaiting_retry_push === 1;
|
|
1423
|
+
if (row.queue_state === 'dead') deadLetter.push(entry);
|
|
1424
|
+
else outbox.push(entry);
|
|
1425
|
+
}
|
|
1426
|
+
return { outbox, deadLetter };
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
export function createBncrSqliteStateDatabase(
|
|
1430
|
+
dbPath: string,
|
|
1431
|
+
options: {
|
|
1432
|
+
now?: () => number;
|
|
1433
|
+
storeMode?: BncrSqliteStoreMode;
|
|
1434
|
+
} = {},
|
|
1435
|
+
): BncrSqliteStateDatabase {
|
|
1436
|
+
const now = options.now ?? (() => Date.now());
|
|
1437
|
+
const db = new DatabaseSync(dbPath);
|
|
1438
|
+
db.exec('PRAGMA journal_mode=WAL');
|
|
1439
|
+
db.exec('PRAGMA synchronous=NORMAL');
|
|
1440
|
+
db.exec('PRAGMA foreign_keys=ON');
|
|
1441
|
+
db.exec('PRAGMA busy_timeout=5000');
|
|
1442
|
+
applyMigrations(db, now);
|
|
1443
|
+
|
|
1444
|
+
if (options.storeMode) {
|
|
1445
|
+
setMeta('store_mode', options.storeMode);
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
function getMeta(key: string): string | null {
|
|
1449
|
+
const row = db.prepare('SELECT meta_value FROM state_meta WHERE meta_key = ?').get(key) as
|
|
1450
|
+
| { meta_value: string }
|
|
1451
|
+
| undefined;
|
|
1452
|
+
return row?.meta_value ?? null;
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
function setMeta(key: string, value: string): void {
|
|
1456
|
+
db.prepare(
|
|
1457
|
+
`INSERT INTO state_meta (meta_key, meta_value, updated_at)
|
|
1458
|
+
VALUES (?, ?, ?)
|
|
1459
|
+
ON CONFLICT(meta_key) DO UPDATE SET meta_value = excluded.meta_value, updated_at = excluded.updated_at`,
|
|
1460
|
+
).run(key, value, now());
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
function getStoreMode(): BncrSqliteStoreMode | null {
|
|
1464
|
+
const mode = getMeta('store_mode');
|
|
1465
|
+
return mode === 'dual' || mode === 'sqlite' || mode === 'json' ? mode : null;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
function assertWritable(mode: BncrSqliteStoreMode | null): asserts mode is 'dual' | 'sqlite' {
|
|
1469
|
+
if (mode !== 'dual' && mode !== 'sqlite') {
|
|
1470
|
+
throw new Error(`bncr sqlite state is not writable in store_mode=${String(mode)}`);
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
function buildHistoryShardDedupeKey(
|
|
1475
|
+
historyKey: string,
|
|
1476
|
+
accountId: string | null,
|
|
1477
|
+
bufferKeys: readonly string[],
|
|
1478
|
+
messageIds: readonly string[],
|
|
1479
|
+
): string {
|
|
1480
|
+
const normalized = Array.from(new Set(messageIds.map((id) => String(id || '').trim())))
|
|
1481
|
+
.filter(Boolean)
|
|
1482
|
+
.sort();
|
|
1483
|
+
return createHash('sha256')
|
|
1484
|
+
.update(
|
|
1485
|
+
JSON.stringify({
|
|
1486
|
+
historyKey,
|
|
1487
|
+
accountId,
|
|
1488
|
+
bufferKeys: [...bufferKeys].sort(),
|
|
1489
|
+
messageIds: normalized,
|
|
1490
|
+
}),
|
|
1491
|
+
)
|
|
1492
|
+
.digest('hex');
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
function createHistoryShard(input: BncrHistoryShardCreateInput): BncrHistoryShardCreateResult {
|
|
1496
|
+
const mode = getStoreMode();
|
|
1497
|
+
assertWritable(mode);
|
|
1498
|
+
const historyKey = String(input.historyKey || '').trim();
|
|
1499
|
+
if (!historyKey) throw new Error('bncr sqlite history shard requires historyKey');
|
|
1500
|
+
const timestamp = now();
|
|
1501
|
+
const accountId = String(input.accountId || '').trim() || null;
|
|
1502
|
+
const bufferKeys = Array.from(
|
|
1503
|
+
new Set(input.bufferKeys.map((key) => String(key || '').trim()).filter(Boolean)),
|
|
1504
|
+
).sort();
|
|
1505
|
+
const messageIds = Array.from(
|
|
1506
|
+
new Set(
|
|
1507
|
+
input.messageIds
|
|
1508
|
+
.map((messageId) => String(messageId || '').trim())
|
|
1509
|
+
.filter((messageId) => Boolean(messageId)),
|
|
1510
|
+
),
|
|
1511
|
+
);
|
|
1512
|
+
const dedupeKey =
|
|
1513
|
+
messageIds.length > 0
|
|
1514
|
+
? buildHistoryShardDedupeKey(historyKey, accountId, bufferKeys, messageIds)
|
|
1515
|
+
: `empty:${timestamp}:${Math.random().toString(36).slice(2)}`;
|
|
1516
|
+
const messagePlaceholders = messageIds.map(() => '?').join(', ') || 'NULL';
|
|
1517
|
+
db.exec('BEGIN IMMEDIATE');
|
|
1518
|
+
try {
|
|
1519
|
+
const existing = db
|
|
1520
|
+
.prepare(
|
|
1521
|
+
`SELECT id FROM history_shards
|
|
1522
|
+
WHERE dedupe_key = ?
|
|
1523
|
+
OR (
|
|
1524
|
+
history_key = ?
|
|
1525
|
+
AND message_ids_json = ?
|
|
1526
|
+
AND buffer_keys_json = ?
|
|
1527
|
+
AND ((? IS NULL AND account_id IS NULL) OR account_id = ?)
|
|
1528
|
+
)`,
|
|
1529
|
+
)
|
|
1530
|
+
.get(
|
|
1531
|
+
dedupeKey,
|
|
1532
|
+
historyKey,
|
|
1533
|
+
JSON.stringify(messageIds),
|
|
1534
|
+
JSON.stringify(bufferKeys),
|
|
1535
|
+
accountId,
|
|
1536
|
+
accountId,
|
|
1537
|
+
) as { id: number } | undefined;
|
|
1538
|
+
if (existing) {
|
|
1539
|
+
const shardId = Number(existing.id);
|
|
1540
|
+
const bufferPlaceholders = bufferKeys.map(() => '?').join(', ') || 'NULL';
|
|
1541
|
+
const historyCondition =
|
|
1542
|
+
messageIds.length > 0
|
|
1543
|
+
? `(storage_kind = 'history' AND history_key = ? AND message_id IN (${messagePlaceholders}))`
|
|
1544
|
+
: `0 = 1`;
|
|
1545
|
+
const replayAccountCondition = accountId
|
|
1546
|
+
? ` AND (account_id IS NULL OR account_id = ?)`
|
|
1547
|
+
: '';
|
|
1548
|
+
const replayCondition =
|
|
1549
|
+
bufferKeys.length > 0 && messageIds.length > 0
|
|
1550
|
+
? `(storage_kind = 'replay' AND buffer_key IN (${bufferPlaceholders})${replayAccountCondition} AND message_id IN (${messagePlaceholders}))`
|
|
1551
|
+
: `0 = 1`;
|
|
1552
|
+
const historyParams = messageIds.length > 0 ? [historyKey, ...messageIds] : [];
|
|
1553
|
+
const replayParams =
|
|
1554
|
+
messageIds.length > 0 && bufferKeys.length > 0
|
|
1555
|
+
? accountId
|
|
1556
|
+
? [...bufferKeys, accountId, ...messageIds]
|
|
1557
|
+
: [...bufferKeys, ...messageIds]
|
|
1558
|
+
: [];
|
|
1559
|
+
db.prepare(
|
|
1560
|
+
`UPDATE conversation_messages SET shard_id = ?
|
|
1561
|
+
WHERE shard_id IS NULL
|
|
1562
|
+
AND (
|
|
1563
|
+
${historyCondition}
|
|
1564
|
+
OR ${replayCondition}
|
|
1565
|
+
)`,
|
|
1566
|
+
).run(shardId, ...historyParams, ...replayParams);
|
|
1567
|
+
db.exec('COMMIT');
|
|
1568
|
+
return { shardId, created: false };
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
const insert = db
|
|
1572
|
+
.prepare(
|
|
1573
|
+
`INSERT INTO history_shards (
|
|
1574
|
+
dedupe_key, account_id, history_key, payload_json, message_ids_json,
|
|
1575
|
+
buffer_keys_json, created_at, queued_at, updated_at
|
|
1576
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1577
|
+
)
|
|
1578
|
+
.run(
|
|
1579
|
+
dedupeKey,
|
|
1580
|
+
accountId,
|
|
1581
|
+
historyKey,
|
|
1582
|
+
input.payloadJson,
|
|
1583
|
+
JSON.stringify(messageIds),
|
|
1584
|
+
JSON.stringify(bufferKeys),
|
|
1585
|
+
timestamp,
|
|
1586
|
+
timestamp,
|
|
1587
|
+
timestamp,
|
|
1588
|
+
);
|
|
1589
|
+
const shardId = Number(insert.lastInsertRowid);
|
|
1590
|
+
const bufferPlaceholders = bufferKeys.map(() => '?').join(', ') || 'NULL';
|
|
1591
|
+
const historyCondition =
|
|
1592
|
+
messageIds.length > 0
|
|
1593
|
+
? `(storage_kind = 'history' AND history_key = ? AND message_id IN (${messagePlaceholders}))`
|
|
1594
|
+
: `0 = 1`;
|
|
1595
|
+
const replayAccountCondition = accountId ? ` AND (account_id IS NULL OR account_id = ?)` : '';
|
|
1596
|
+
const replayCondition =
|
|
1597
|
+
bufferKeys.length > 0 && messageIds.length > 0
|
|
1598
|
+
? `(storage_kind = 'replay' AND buffer_key IN (${bufferPlaceholders})${replayAccountCondition} AND message_id IN (${messagePlaceholders}))`
|
|
1599
|
+
: `0 = 1`;
|
|
1600
|
+
const historyParams = messageIds.length > 0 ? [historyKey, ...messageIds] : [];
|
|
1601
|
+
const replayParams =
|
|
1602
|
+
messageIds.length > 0 && bufferKeys.length > 0
|
|
1603
|
+
? accountId
|
|
1604
|
+
? [...bufferKeys, accountId, ...messageIds]
|
|
1605
|
+
: [...bufferKeys, ...messageIds]
|
|
1606
|
+
: [];
|
|
1607
|
+
db.prepare(
|
|
1608
|
+
`UPDATE conversation_messages SET shard_id = ?
|
|
1609
|
+
WHERE shard_id IS NULL
|
|
1610
|
+
AND (
|
|
1611
|
+
${historyCondition}
|
|
1612
|
+
OR ${replayCondition}
|
|
1613
|
+
)`,
|
|
1614
|
+
).run(shardId, ...historyParams, ...replayParams);
|
|
1615
|
+
db.exec('COMMIT');
|
|
1616
|
+
return { shardId, created: true };
|
|
1617
|
+
} catch (error) {
|
|
1618
|
+
db.exec('ROLLBACK');
|
|
1619
|
+
throw error;
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
function markHistoryShardProcessing(shardId: number, owner = 'bncr-node'): boolean {
|
|
1624
|
+
const timestamp = now();
|
|
1625
|
+
const claimToken = `bncr:${shardId}:${timestamp}:${Math.random().toString(36).slice(2)}`;
|
|
1626
|
+
const ownerCondition = owner
|
|
1627
|
+
? ` AND (claim_owner IS NULL OR claim_owner = ? OR status IN ('queued','failed','abandoned'))`
|
|
1628
|
+
: '';
|
|
1629
|
+
const result = db
|
|
1630
|
+
.prepare(
|
|
1631
|
+
`UPDATE history_shards
|
|
1632
|
+
SET status = 'processing',
|
|
1633
|
+
claim_token = ?,
|
|
1634
|
+
claim_owner = ?,
|
|
1635
|
+
claimed_at = ?,
|
|
1636
|
+
lease_expires_at = ?,
|
|
1637
|
+
upload_started_at = ?,
|
|
1638
|
+
updated_at = ?
|
|
1639
|
+
WHERE id = ? AND status IN ('queued', 'failed', 'abandoned', 'claimed')${ownerCondition}`,
|
|
1640
|
+
)
|
|
1641
|
+
.run(
|
|
1642
|
+
claimToken,
|
|
1643
|
+
owner,
|
|
1644
|
+
timestamp,
|
|
1645
|
+
timestamp + HISTORY_SHARD_LEASE_MS,
|
|
1646
|
+
timestamp,
|
|
1647
|
+
timestamp,
|
|
1648
|
+
shardId,
|
|
1649
|
+
...(owner ? [owner] : []),
|
|
1650
|
+
);
|
|
1651
|
+
return Number(result.changes) > 0;
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
function markHistoryShardFailed(
|
|
1655
|
+
shardId: number,
|
|
1656
|
+
error: unknown,
|
|
1657
|
+
owner?: string,
|
|
1658
|
+
): BncrHistoryShardFailureResult {
|
|
1659
|
+
const timestamp = now();
|
|
1660
|
+
const message =
|
|
1661
|
+
typeof error === 'string' ? error : error instanceof Error ? error.message : String(error);
|
|
1662
|
+
const existing = db
|
|
1663
|
+
.prepare('SELECT attempts, claim_owner FROM history_shards WHERE id = ?')
|
|
1664
|
+
.get(shardId) as { attempts: number; claim_owner: string | null } | undefined;
|
|
1665
|
+
if (owner && existing?.claim_owner && existing.claim_owner !== owner) {
|
|
1666
|
+
return {
|
|
1667
|
+
attempts: existing.attempts,
|
|
1668
|
+
terminal: existing.attempts >= HISTORY_SHARD_MAX_ATTEMPTS,
|
|
1669
|
+
};
|
|
1670
|
+
}
|
|
1671
|
+
const attempts = (existing?.attempts ?? 0) + 1;
|
|
1672
|
+
const backoffMs = Math.min(
|
|
1673
|
+
HISTORY_SHARD_RETRY_BASE_MS * 2 ** Math.max(0, attempts - 1),
|
|
1674
|
+
HISTORY_SHARD_RETRY_MAX_MS,
|
|
1675
|
+
);
|
|
1676
|
+
const ownerCondition = owner ? ' AND (claim_owner IS NULL OR claim_owner = ?)' : '';
|
|
1677
|
+
const updateResult = db
|
|
1678
|
+
.prepare(
|
|
1679
|
+
`UPDATE history_shards
|
|
1680
|
+
SET status = 'failed',
|
|
1681
|
+
attempts = ?,
|
|
1682
|
+
last_error = ?,
|
|
1683
|
+
claim_token = NULL,
|
|
1684
|
+
claim_owner = NULL,
|
|
1685
|
+
claimed_at = NULL,
|
|
1686
|
+
lease_expires_at = NULL,
|
|
1687
|
+
next_attempt_at = ?,
|
|
1688
|
+
updated_at = ?
|
|
1689
|
+
WHERE id = ? AND status IN ('queued', 'claimed', 'processing')${ownerCondition}`,
|
|
1690
|
+
)
|
|
1691
|
+
.run(
|
|
1692
|
+
attempts,
|
|
1693
|
+
message,
|
|
1694
|
+
attempts >= HISTORY_SHARD_MAX_ATTEMPTS ? null : timestamp + backoffMs,
|
|
1695
|
+
timestamp,
|
|
1696
|
+
shardId,
|
|
1697
|
+
...(owner ? [owner] : []),
|
|
1698
|
+
);
|
|
1699
|
+
if (owner && Number(updateResult.changes) === 0) {
|
|
1700
|
+
const current = db.prepare('SELECT attempts FROM history_shards WHERE id = ?').get(shardId) as
|
|
1701
|
+
| { attempts: number }
|
|
1702
|
+
| undefined;
|
|
1703
|
+
const currentAttempts = Number(current?.attempts ?? 0);
|
|
1704
|
+
return {
|
|
1705
|
+
attempts: currentAttempts,
|
|
1706
|
+
terminal: currentAttempts >= HISTORY_SHARD_MAX_ATTEMPTS,
|
|
1707
|
+
};
|
|
1708
|
+
}
|
|
1709
|
+
return {
|
|
1710
|
+
attempts,
|
|
1711
|
+
terminal: attempts >= HISTORY_SHARD_MAX_ATTEMPTS,
|
|
1712
|
+
};
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
function markHistoryShardCompleted(shardId: number, owner?: string): boolean {
|
|
1716
|
+
const timestamp = now();
|
|
1717
|
+
const ownerCondition = owner ? ' AND (claim_owner IS NULL OR claim_owner = ?)' : '';
|
|
1718
|
+
const result = db
|
|
1719
|
+
.prepare(
|
|
1720
|
+
`UPDATE history_shards
|
|
1721
|
+
SET status = 'completed',
|
|
1722
|
+
claim_token = NULL,
|
|
1723
|
+
claim_owner = NULL,
|
|
1724
|
+
claimed_at = NULL,
|
|
1725
|
+
lease_expires_at = NULL,
|
|
1726
|
+
upload_completed_at = ?,
|
|
1727
|
+
completed_at = ?,
|
|
1728
|
+
updated_at = ?
|
|
1729
|
+
WHERE id = ? AND status = 'processing'${ownerCondition}`,
|
|
1730
|
+
)
|
|
1731
|
+
.run(timestamp, timestamp, timestamp, shardId, ...(owner ? [owner] : []));
|
|
1732
|
+
return Number(result.changes) > 0;
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
function renewHistoryShardLease(shardId: number, owner?: string): boolean {
|
|
1736
|
+
const timestamp = now();
|
|
1737
|
+
const ownerCondition = owner ? ' AND (claim_owner IS NULL OR claim_owner = ?)' : '';
|
|
1738
|
+
const result = db
|
|
1739
|
+
.prepare(
|
|
1740
|
+
`UPDATE history_shards
|
|
1741
|
+
SET lease_expires_at = ?,
|
|
1742
|
+
updated_at = ?
|
|
1743
|
+
WHERE id = ?
|
|
1744
|
+
AND status IN ('claimed', 'processing')${ownerCondition}`,
|
|
1745
|
+
)
|
|
1746
|
+
.run(timestamp + HISTORY_SHARD_LEASE_MS, timestamp, shardId, ...(owner ? [owner] : []));
|
|
1747
|
+
return Number(result.changes) > 0;
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
function completeHistoryShard(shardId: number, owner?: string): void {
|
|
1751
|
+
const isOwnedByCurrentOwner = () => {
|
|
1752
|
+
if (!owner) return true;
|
|
1753
|
+
const row = db.prepare('SELECT claim_owner FROM history_shards WHERE id = ?').get(shardId) as
|
|
1754
|
+
| { claim_owner: string | null }
|
|
1755
|
+
| undefined;
|
|
1756
|
+
return !row?.claim_owner || row.claim_owner === owner;
|
|
1757
|
+
};
|
|
1758
|
+
if (!isOwnedByCurrentOwner()) return;
|
|
1759
|
+
let markedCompleted = false;
|
|
1760
|
+
try {
|
|
1761
|
+
// Persist the upload-completed fact before deleting the shard. If cleanup
|
|
1762
|
+
// fails, recovery sees 'completed' and only retries cleanup, never upload.
|
|
1763
|
+
markedCompleted = markHistoryShardCompleted(shardId, owner);
|
|
1764
|
+
} catch {
|
|
1765
|
+
// The caller only reaches this point after upload has settled. A failed
|
|
1766
|
+
// marker write must not leave the shard retryable or it would upload the
|
|
1767
|
+
// same snapshot twice, so cleanup still proceeds below.
|
|
1768
|
+
}
|
|
1769
|
+
if (owner && !isOwnedByCurrentOwner()) return;
|
|
1770
|
+
try {
|
|
1771
|
+
db.exec('BEGIN IMMEDIATE');
|
|
1772
|
+
const currentShard = db
|
|
1773
|
+
.prepare(
|
|
1774
|
+
`SELECT status, claim_owner
|
|
1775
|
+
FROM history_shards
|
|
1776
|
+
WHERE id = ?`,
|
|
1777
|
+
)
|
|
1778
|
+
.get(shardId) as { status: string; claim_owner: string | null } | undefined;
|
|
1779
|
+
if (
|
|
1780
|
+
!currentShard ||
|
|
1781
|
+
(owner && currentShard.claim_owner && currentShard.claim_owner !== owner) ||
|
|
1782
|
+
(currentShard.status !== 'processing' && currentShard.status !== 'completed')
|
|
1783
|
+
) {
|
|
1784
|
+
db.exec('COMMIT');
|
|
1785
|
+
return;
|
|
1786
|
+
}
|
|
1787
|
+
recordConsumedHistoryShardMessages(db, shardId, now());
|
|
1788
|
+
db.prepare('DELETE FROM conversation_messages WHERE shard_id = ?').run(shardId);
|
|
1789
|
+
db.prepare('DELETE FROM history_shards WHERE id = ?').run(shardId);
|
|
1790
|
+
db.exec('COMMIT');
|
|
1791
|
+
} catch (error) {
|
|
1792
|
+
db.exec('ROLLBACK');
|
|
1793
|
+
if (!markedCompleted) {
|
|
1794
|
+
try {
|
|
1795
|
+
markHistoryShardCompleted(shardId, owner);
|
|
1796
|
+
} catch {
|
|
1797
|
+
// Preserve the original cleanup error.
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
throw error;
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
function recoverHistoryShards(skipHistoryKeys: readonly string[] = [], owner?: string): number {
|
|
1805
|
+
const skipped = normalizeHistoryKeySkipList(skipHistoryKeys);
|
|
1806
|
+
return recoverHistoryShardsWithSkip(skipped, owner);
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
function normalizeHistoryKeySkipList(skipHistoryKeys: readonly string[] | undefined): string[] {
|
|
1810
|
+
return Array.from(
|
|
1811
|
+
new Set(
|
|
1812
|
+
(skipHistoryKeys ?? [])
|
|
1813
|
+
.map((key) => String(key || '').trim())
|
|
1814
|
+
.filter((key) => Boolean(key)),
|
|
1815
|
+
),
|
|
1816
|
+
);
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
function buildHistoryKeySkipCondition(skipped: readonly string[]): string {
|
|
1820
|
+
return skipped.length > 0
|
|
1821
|
+
? ` AND history_key NOT IN (${skipped.map(() => '?').join(', ')})`
|
|
1822
|
+
: '';
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
function recoverHistoryShardsWithSkip(skipped: readonly string[], owner?: string): number {
|
|
1826
|
+
const timestamp = now();
|
|
1827
|
+
const skipCondition = buildHistoryKeySkipCondition(skipped);
|
|
1828
|
+
const ownerCondition = owner
|
|
1829
|
+
? ` AND (claim_owner IS NULL OR claim_owner = 'bncr-node' OR claim_owner = ? OR claim_owner LIKE ?)`
|
|
1830
|
+
: '';
|
|
1831
|
+
const ownerParams = owner ? [owner, `${String(owner).split(':')[0]}:%`] : [];
|
|
1832
|
+
const result = db
|
|
1833
|
+
.prepare(
|
|
1834
|
+
`UPDATE history_shards
|
|
1835
|
+
SET status = 'queued',
|
|
1836
|
+
last_error = 'stale lease recovered',
|
|
1837
|
+
next_attempt_at = NULL,
|
|
1838
|
+
claim_token = NULL,
|
|
1839
|
+
claim_owner = NULL,
|
|
1840
|
+
claimed_at = NULL,
|
|
1841
|
+
lease_expires_at = NULL,
|
|
1842
|
+
updated_at = ?
|
|
1843
|
+
WHERE status IN ('claimed', 'processing')
|
|
1844
|
+
AND lease_expires_at IS NOT NULL
|
|
1845
|
+
AND lease_expires_at < ?${skipCondition}${ownerCondition}`,
|
|
1846
|
+
)
|
|
1847
|
+
.run(timestamp, timestamp, ...skipped, ...ownerParams);
|
|
1848
|
+
return Number(result.changes);
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
function recoverInFlightHistoryShards(
|
|
1852
|
+
skipHistoryKeys: readonly string[] = [],
|
|
1853
|
+
owner?: string,
|
|
1854
|
+
): number {
|
|
1855
|
+
const timestamp = now();
|
|
1856
|
+
const skipped = normalizeHistoryKeySkipList(skipHistoryKeys);
|
|
1857
|
+
const skipCondition = buildHistoryKeySkipCondition(skipped);
|
|
1858
|
+
const ownerCondition = owner
|
|
1859
|
+
? ` AND (claim_owner IS NULL OR claim_owner = 'bncr-node' OR claim_owner = ? OR claim_owner LIKE ?)`
|
|
1860
|
+
: '';
|
|
1861
|
+
const ownerParams = owner ? [owner, `${String(owner).split(':')[0]}:%`] : [];
|
|
1862
|
+
const result = db
|
|
1863
|
+
.prepare(
|
|
1864
|
+
`UPDATE history_shards
|
|
1865
|
+
SET status = 'queued',
|
|
1866
|
+
last_error = 'startup in-flight recovered',
|
|
1867
|
+
next_attempt_at = NULL,
|
|
1868
|
+
claim_token = NULL,
|
|
1869
|
+
claim_owner = NULL,
|
|
1870
|
+
claimed_at = NULL,
|
|
1871
|
+
lease_expires_at = NULL,
|
|
1872
|
+
updated_at = ?
|
|
1873
|
+
WHERE status IN ('claimed', 'processing', 'abandoned')${skipCondition}${ownerCondition}`,
|
|
1874
|
+
)
|
|
1875
|
+
.run(timestamp, ...skipped, ...ownerParams);
|
|
1876
|
+
return Number(result.changes);
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
function claimNextHistoryShard(
|
|
1880
|
+
skipHistoryKeys: readonly string[] = [],
|
|
1881
|
+
owner = 'bncr-node',
|
|
1882
|
+
): BncrHistoryShardRow | null {
|
|
1883
|
+
const timestamp = now();
|
|
1884
|
+
const skipped = normalizeHistoryKeySkipList(skipHistoryKeys);
|
|
1885
|
+
const skipCondition = buildHistoryKeySkipCondition(skipped);
|
|
1886
|
+
const ownerCondition = owner
|
|
1887
|
+
? ` AND (claim_owner IS NULL OR claim_owner = 'bncr-node' OR claim_owner = ? OR claim_owner LIKE ?)`
|
|
1888
|
+
: '';
|
|
1889
|
+
const ownerParams = owner ? [owner, `${String(owner).split(':')[0]}:%`] : [];
|
|
1890
|
+
db.exec('BEGIN IMMEDIATE');
|
|
1891
|
+
try {
|
|
1892
|
+
db.prepare(
|
|
1893
|
+
`UPDATE history_shards
|
|
1894
|
+
SET status = 'queued',
|
|
1895
|
+
last_error = 'stale lease recovered',
|
|
1896
|
+
next_attempt_at = NULL,
|
|
1897
|
+
claim_token = NULL,
|
|
1898
|
+
claim_owner = NULL,
|
|
1899
|
+
claimed_at = NULL,
|
|
1900
|
+
lease_expires_at = NULL,
|
|
1901
|
+
updated_at = ?
|
|
1902
|
+
WHERE status IN ('claimed', 'processing')
|
|
1903
|
+
AND lease_expires_at IS NOT NULL
|
|
1904
|
+
AND lease_expires_at < ?${skipCondition}${ownerCondition}`,
|
|
1905
|
+
).run(timestamp, timestamp, ...skipped, ...ownerParams);
|
|
1906
|
+
|
|
1907
|
+
const row = db
|
|
1908
|
+
.prepare(
|
|
1909
|
+
`SELECT id, history_key, account_id, status, attempts, payload_json,
|
|
1910
|
+
message_ids_json, buffer_keys_json, last_error, next_attempt_at,
|
|
1911
|
+
claim_owner
|
|
1912
|
+
FROM history_shards
|
|
1913
|
+
WHERE status IN ('queued', 'failed')
|
|
1914
|
+
AND (
|
|
1915
|
+
(status = 'queued' AND next_attempt_at IS NULL AND attempts < ?)
|
|
1916
|
+
OR (next_attempt_at IS NOT NULL AND next_attempt_at <= ?)
|
|
1917
|
+
)${skipCondition}
|
|
1918
|
+
ORDER BY queued_at ASC, id ASC
|
|
1919
|
+
LIMIT 1`,
|
|
1920
|
+
)
|
|
1921
|
+
.get(HISTORY_SHARD_MAX_ATTEMPTS, timestamp, ...skipped) as
|
|
1922
|
+
| {
|
|
1923
|
+
id: number;
|
|
1924
|
+
history_key: string;
|
|
1925
|
+
account_id: string | null;
|
|
1926
|
+
status: string;
|
|
1927
|
+
attempts: number;
|
|
1928
|
+
payload_json: string;
|
|
1929
|
+
message_ids_json: string;
|
|
1930
|
+
buffer_keys_json: string;
|
|
1931
|
+
last_error: string | null;
|
|
1932
|
+
next_attempt_at: number | null;
|
|
1933
|
+
claim_owner: string | null;
|
|
1934
|
+
}
|
|
1935
|
+
| undefined;
|
|
1936
|
+
if (!row) {
|
|
1937
|
+
db.exec('COMMIT');
|
|
1938
|
+
return null;
|
|
1939
|
+
}
|
|
1940
|
+
const claimToken = `bncr:${row.id}:${timestamp}:${Math.random().toString(36).slice(2)}`;
|
|
1941
|
+
db.prepare(
|
|
1942
|
+
`UPDATE history_shards
|
|
1943
|
+
SET status = 'claimed',
|
|
1944
|
+
claim_token = ?,
|
|
1945
|
+
claim_owner = ?,
|
|
1946
|
+
claimed_at = ?,
|
|
1947
|
+
lease_expires_at = ?,
|
|
1948
|
+
updated_at = ?
|
|
1949
|
+
WHERE id = ? AND status IN ('queued', 'failed')`,
|
|
1950
|
+
).run(claimToken, owner, timestamp, timestamp + HISTORY_SHARD_LEASE_MS, timestamp, row.id);
|
|
1951
|
+
db.exec('COMMIT');
|
|
1952
|
+
return {
|
|
1953
|
+
id: row.id,
|
|
1954
|
+
historyKey: row.history_key,
|
|
1955
|
+
accountId: row.account_id,
|
|
1956
|
+
status: 'claimed',
|
|
1957
|
+
attempts: row.attempts,
|
|
1958
|
+
payloadJson: row.payload_json,
|
|
1959
|
+
messageIds: parseOutboundStringArray(row.message_ids_json),
|
|
1960
|
+
bufferKeys: parseOutboundStringArray(row.buffer_keys_json),
|
|
1961
|
+
lastError: row.last_error,
|
|
1962
|
+
nextAttemptAt: row.next_attempt_at,
|
|
1963
|
+
...(owner ? { owner } : {}),
|
|
1964
|
+
};
|
|
1965
|
+
} catch (error) {
|
|
1966
|
+
db.exec('ROLLBACK');
|
|
1967
|
+
throw error;
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
function listHistoryShards() {
|
|
1972
|
+
const rows = db
|
|
1973
|
+
.prepare(
|
|
1974
|
+
`SELECT id, history_key, account_id, status, attempts, payload_json,
|
|
1975
|
+
message_ids_json, buffer_keys_json, last_error, next_attempt_at,
|
|
1976
|
+
claim_owner
|
|
1977
|
+
FROM history_shards
|
|
1978
|
+
ORDER BY id ASC`,
|
|
1979
|
+
)
|
|
1980
|
+
.all() as Array<{
|
|
1981
|
+
id: number;
|
|
1982
|
+
history_key: string;
|
|
1983
|
+
account_id: string | null;
|
|
1984
|
+
status: string;
|
|
1985
|
+
attempts: number;
|
|
1986
|
+
payload_json: string;
|
|
1987
|
+
message_ids_json: string;
|
|
1988
|
+
buffer_keys_json: string;
|
|
1989
|
+
last_error: string | null;
|
|
1990
|
+
next_attempt_at: number | null;
|
|
1991
|
+
claim_owner: string | null;
|
|
1992
|
+
}>;
|
|
1993
|
+
return rows.map((row) => ({
|
|
1994
|
+
id: row.id,
|
|
1995
|
+
historyKey: row.history_key,
|
|
1996
|
+
accountId: row.account_id,
|
|
1997
|
+
status: row.status,
|
|
1998
|
+
attempts: row.attempts,
|
|
1999
|
+
payloadJson: row.payload_json,
|
|
2000
|
+
messageIds: parseOutboundStringArray(row.message_ids_json),
|
|
2001
|
+
bufferKeys: parseOutboundStringArray(row.buffer_keys_json),
|
|
2002
|
+
lastError: row.last_error,
|
|
2003
|
+
nextAttemptAt: row.next_attempt_at,
|
|
2004
|
+
...(row.claim_owner ? { owner: row.claim_owner } : {}),
|
|
2005
|
+
}));
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
return {
|
|
2009
|
+
getPath: () => dbPath,
|
|
2010
|
+
close: () => db.close(),
|
|
2011
|
+
getStoreMode,
|
|
2012
|
+
isControlStateImported: () => getMeta('legacy_json_imported_at') !== null,
|
|
2013
|
+
isHistoryImported: () => getMeta('history_imported_at') !== null,
|
|
2014
|
+
setStoreMode: (mode) => setMeta('store_mode', mode),
|
|
2015
|
+
getMeta,
|
|
2016
|
+
setMeta,
|
|
2017
|
+
ensureMigrated: () => applyMigrations(db, now),
|
|
2018
|
+
importControlState: (state, importOptions = {}) => {
|
|
2019
|
+
replaceControlState(db, state, now);
|
|
2020
|
+
setMeta('legacy_json_path', importOptions.legacyJsonPath ?? '');
|
|
2021
|
+
if (importOptions.legacyJsonSha256)
|
|
2022
|
+
setMeta('legacy_json_sha256', importOptions.legacyJsonSha256);
|
|
2023
|
+
setMeta('legacy_json_imported_at', String(now()));
|
|
2024
|
+
setMeta('store_mode', importOptions.storeMode ?? 'dual');
|
|
2025
|
+
},
|
|
2026
|
+
loadControlState: () => loadControlState(db),
|
|
2027
|
+
saveControlState: (state) => {
|
|
2028
|
+
const mode = getStoreMode();
|
|
2029
|
+
assertWritable(mode);
|
|
2030
|
+
replaceControlState(db, state, now);
|
|
2031
|
+
},
|
|
2032
|
+
importHistoryState: (historyBuckets, replayBuckets) => {
|
|
2033
|
+
const mode = getStoreMode();
|
|
2034
|
+
assertWritable(mode);
|
|
2035
|
+
replaceHistoryMessages(db, historyBuckets, replayBuckets, now);
|
|
2036
|
+
},
|
|
2037
|
+
loadHistoryState: (skipHistoryKeys?: readonly string[], owner?: string) => {
|
|
2038
|
+
recoverHistoryShards(skipHistoryKeys, owner);
|
|
2039
|
+
return loadHistoryState(db, now());
|
|
2040
|
+
},
|
|
2041
|
+
getHistoryStateRevision: () => {
|
|
2042
|
+
const row = db
|
|
2043
|
+
.prepare("SELECT meta_value FROM state_meta WHERE meta_key = 'history_revision'")
|
|
2044
|
+
.get() as { meta_value: string } | undefined;
|
|
2045
|
+
return Number(row?.meta_value ?? 0);
|
|
2046
|
+
},
|
|
2047
|
+
saveHistoryState: (historyBuckets, replayBuckets, expectedRevision) => {
|
|
2048
|
+
const mode = getStoreMode();
|
|
2049
|
+
assertWritable(mode);
|
|
2050
|
+
replaceHistoryMessages(db, historyBuckets, replayBuckets, now, expectedRevision);
|
|
2051
|
+
},
|
|
2052
|
+
isOutboundImported: () => getMeta('outbound_imported_at') !== null,
|
|
2053
|
+
importOutboundState: (outbox, deadLetter) => {
|
|
2054
|
+
const mode = getStoreMode();
|
|
2055
|
+
assertWritable(mode);
|
|
2056
|
+
replaceOutboundState(db, outbox, deadLetter, now);
|
|
2057
|
+
},
|
|
2058
|
+
loadOutboundState: () => loadOutboundState(db),
|
|
2059
|
+
saveOutboundState: (outbox, deadLetter) => {
|
|
2060
|
+
const mode = getStoreMode();
|
|
2061
|
+
assertWritable(mode);
|
|
2062
|
+
replaceOutboundState(db, outbox, deadLetter, now);
|
|
2063
|
+
},
|
|
2064
|
+
createHistoryShard,
|
|
2065
|
+
markHistoryShardProcessing,
|
|
2066
|
+
markHistoryShardFailed,
|
|
2067
|
+
markHistoryShardCompleted,
|
|
2068
|
+
renewHistoryShardLease,
|
|
2069
|
+
completeHistoryShard,
|
|
2070
|
+
recoverHistoryShards,
|
|
2071
|
+
recoverInFlightHistoryShards,
|
|
2072
|
+
restoreTerminalHistoryShardsToActive: () => restoreTerminalHistoryShardsToActive(db),
|
|
2073
|
+
claimNextHistoryShard,
|
|
2074
|
+
cleanupCompletedHistoryShards: () => cleanupCompletedHistoryShards(db),
|
|
2075
|
+
listHistoryShards,
|
|
2076
|
+
};
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
export function validateSqliteMigrationChecksums(dbPath: string): string[] {
|
|
2080
|
+
const db = new DatabaseSync(dbPath, { readOnly: true });
|
|
2081
|
+
try {
|
|
2082
|
+
return (
|
|
2083
|
+
db
|
|
2084
|
+
.prepare('SELECT version, checksum FROM schema_migrations ORDER BY version ASC')
|
|
2085
|
+
.all() as Array<{ version: number; checksum: string }>
|
|
2086
|
+
).map((row) => {
|
|
2087
|
+
const migration = MIGRATIONS.find((item) => item.version === row.version);
|
|
2088
|
+
const expected = migration ? migrationChecksum(migration) : null;
|
|
2089
|
+
if (expected && expected !== row.checksum) {
|
|
2090
|
+
throw new Error(`bncr sqlite migration ${row.version} checksum mismatch`);
|
|
2091
|
+
}
|
|
2092
|
+
return `${row.version}:${expected ?? row.checksum}`;
|
|
2093
|
+
});
|
|
2094
|
+
} finally {
|
|
2095
|
+
db.close();
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
export function createEmptyBncrSqliteControlState(): BncrSqliteControlState {
|
|
2100
|
+
return {
|
|
2101
|
+
sessionRoutes: [],
|
|
2102
|
+
sceneRegistry: [],
|
|
2103
|
+
lastSessionByAccount: [],
|
|
2104
|
+
lastActivityByAccount: [],
|
|
2105
|
+
lastInboundByAccount: [],
|
|
2106
|
+
lastOutboundByAccount: [],
|
|
2107
|
+
lastDriftSnapshot: null,
|
|
2108
|
+
};
|
|
2109
|
+
}
|