@lazyingart/agent-web 0.1.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +438 -0
  3. package/docs/architecture.md +503 -0
  4. package/package.json +43 -0
  5. package/src/aginti-adapter.js +602 -0
  6. package/src/chat-context.js +1020 -0
  7. package/src/chat-migrations.js +947 -0
  8. package/src/chat-store.js +3308 -0
  9. package/src/cli.js +134 -0
  10. package/src/cloud-server.js +2043 -0
  11. package/src/contracts.js +103 -0
  12. package/src/deterministic-context-summarizer.js +254 -0
  13. package/src/direct-chat-capability-limits.js +66 -0
  14. package/src/direct-chat-contract.js +3 -0
  15. package/src/errors.js +50 -0
  16. package/src/http-contract.js +592 -0
  17. package/src/index.js +88 -0
  18. package/src/localllm-connector.js +667 -0
  19. package/src/migrations.js +231 -0
  20. package/src/operator-health.js +184 -0
  21. package/src/password-verifier.js +131 -0
  22. package/src/service-config.js +547 -0
  23. package/src/service.js +408 -0
  24. package/src/sqlite-health.js +83 -0
  25. package/src/storage-path.js +130 -0
  26. package/src/store.js +914 -0
  27. package/src/validation.js +181 -0
  28. package/src/vision-attachment.js +404 -0
  29. package/src/web/aginti-client.js +552 -0
  30. package/src/web/aginti-protocol.js +1146 -0
  31. package/src/web/asset-map.js +462 -0
  32. package/src/web/browser-app.js +6491 -0
  33. package/src/web/cloud-session-client.js +427 -0
  34. package/src/web/direct-chat-client.js +1482 -0
  35. package/src/web/index.js +10 -0
  36. package/src/web/presentation-state.js +107 -0
  37. package/src/web/pwa-assets.js +854 -0
  38. package/src/web/pwa-update-handoff-store.js +179 -0
  39. package/src/web/safe-rendering.js +836 -0
  40. package/src/web/vision-image-client.js +546 -0
  41. package/src/web/vision-image-sanitizer.js +168 -0
  42. package/src/web/web-release.js +28 -0
@@ -0,0 +1,947 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ import { StorageCorruptionError, UnsupportedSchemaError } from './errors.js';
4
+
5
+ // "LADC" -- deliberately different from the Agent presentation-index database.
6
+ export const CHAT_SQLITE_APPLICATION_ID = 0x4c414443;
7
+
8
+ const INITIAL_CHAT_SCHEMA = `
9
+ CREATE TABLE chat_schema_migrations (
10
+ version INTEGER PRIMARY KEY,
11
+ name TEXT NOT NULL UNIQUE,
12
+ checksum TEXT NOT NULL CHECK (
13
+ length(checksum) = 64 AND checksum NOT GLOB '*[^0-9a-f]*'
14
+ ),
15
+ applied_at TEXT NOT NULL
16
+ ) STRICT;
17
+
18
+ CREATE TABLE direct_chat_threads (
19
+ account_id TEXT NOT NULL CHECK (length(account_id) BETWEEN 1 AND 128),
20
+ thread_id TEXT NOT NULL CHECK (length(thread_id) BETWEEN 1 AND 128),
21
+ title TEXT NOT NULL DEFAULT '',
22
+ title_bytes INTEGER NOT NULL DEFAULT 0 CHECK (
23
+ title_bytes BETWEEN 0 AND 512
24
+ AND length(CAST(title AS BLOB)) = title_bytes
25
+ ),
26
+ model_alias TEXT NOT NULL CHECK (length(model_alias) BETWEEN 1 AND 64),
27
+ ledger_revision INTEGER NOT NULL DEFAULT 0 CHECK (ledger_revision >= 0),
28
+ ledger_hash TEXT,
29
+ message_count INTEGER NOT NULL DEFAULT 0 CHECK (message_count >= 0),
30
+ ledger_bytes INTEGER NOT NULL DEFAULT 0 CHECK (ledger_bytes >= 0),
31
+ generation_count INTEGER NOT NULL DEFAULT 0 CHECK (generation_count BETWEEN 0 AND 1024),
32
+ journal_delta_count INTEGER NOT NULL DEFAULT 0 CHECK (journal_delta_count BETWEEN 0 AND 32768),
33
+ journal_bytes INTEGER NOT NULL DEFAULT 0 CHECK (journal_bytes BETWEEN 0 AND 16777216),
34
+ current_generation_id TEXT CHECK (
35
+ current_generation_id IS NULL OR length(current_generation_id) BETWEEN 1 AND 128
36
+ ),
37
+ created_at TEXT NOT NULL,
38
+ updated_at TEXT NOT NULL,
39
+ PRIMARY KEY (account_id, thread_id),
40
+ CHECK (message_count = ledger_revision),
41
+ CHECK (
42
+ (ledger_revision = 0 AND ledger_hash IS NULL)
43
+ OR (
44
+ ledger_revision > 0
45
+ AND length(ledger_hash) = 64
46
+ AND ledger_hash NOT GLOB '*[^0-9a-f]*'
47
+ )
48
+ )
49
+ ) STRICT;
50
+
51
+ CREATE INDEX direct_chat_threads_owner_updated
52
+ ON direct_chat_threads(account_id, updated_at DESC, thread_id DESC);
53
+
54
+ CREATE TABLE direct_chat_messages (
55
+ account_id TEXT NOT NULL,
56
+ thread_id TEXT NOT NULL,
57
+ message_id TEXT NOT NULL CHECK (length(message_id) BETWEEN 1 AND 128),
58
+ revision INTEGER NOT NULL CHECK (revision >= 1),
59
+ role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
60
+ content TEXT NOT NULL,
61
+ content_bytes INTEGER NOT NULL CHECK (
62
+ content_bytes BETWEEN 1 AND 65536
63
+ AND length(CAST(content AS BLOB)) = content_bytes
64
+ ),
65
+ previous_hash TEXT,
66
+ message_hash TEXT NOT NULL CHECK (
67
+ length(message_hash) = 64 AND message_hash NOT GLOB '*[^0-9a-f]*'
68
+ ),
69
+ generation_id TEXT CHECK (
70
+ generation_id IS NULL OR length(generation_id) BETWEEN 1 AND 128
71
+ ),
72
+ created_at TEXT NOT NULL,
73
+ PRIMARY KEY (account_id, thread_id, message_id),
74
+ UNIQUE (account_id, thread_id, revision),
75
+ FOREIGN KEY (account_id, thread_id)
76
+ REFERENCES direct_chat_threads(account_id, thread_id) ON DELETE RESTRICT,
77
+ CHECK (
78
+ (revision = 1 AND previous_hash IS NULL)
79
+ OR (
80
+ revision > 1
81
+ AND length(previous_hash) = 64
82
+ AND previous_hash NOT GLOB '*[^0-9a-f]*'
83
+ )
84
+ ),
85
+ CHECK (
86
+ (role = 'user' AND generation_id IS NULL)
87
+ OR (role = 'assistant' AND generation_id IS NOT NULL)
88
+ )
89
+ ) STRICT;
90
+
91
+ CREATE INDEX direct_chat_messages_owner_revision
92
+ ON direct_chat_messages(account_id, thread_id, revision);
93
+
94
+ CREATE TRIGGER direct_chat_messages_no_update
95
+ BEFORE UPDATE ON direct_chat_messages
96
+ BEGIN
97
+ SELECT RAISE(ABORT, 'direct chat messages are append-only');
98
+ END;
99
+
100
+ CREATE TRIGGER direct_chat_messages_no_delete
101
+ BEFORE DELETE ON direct_chat_messages
102
+ BEGIN
103
+ SELECT RAISE(ABORT, 'direct chat messages are append-only');
104
+ END;
105
+
106
+ CREATE TABLE direct_chat_generations (
107
+ account_id TEXT NOT NULL,
108
+ thread_id TEXT NOT NULL,
109
+ generation_id TEXT NOT NULL CHECK (length(generation_id) BETWEEN 1 AND 128),
110
+ assistant_message_id TEXT NOT NULL CHECK (length(assistant_message_id) BETWEEN 1 AND 128),
111
+ status TEXT NOT NULL CHECK (status IN ('in_progress', 'completed', 'cancelled', 'failed')),
112
+ model_alias TEXT NOT NULL CHECK (length(model_alias) BETWEEN 1 AND 64),
113
+ source_revision INTEGER NOT NULL CHECK (source_revision >= 1),
114
+ source_hash TEXT NOT NULL CHECK (
115
+ length(source_hash) = 64 AND source_hash NOT GLOB '*[^0-9a-f]*'
116
+ ),
117
+ delta_count INTEGER NOT NULL DEFAULT 0 CHECK (delta_count BETWEEN 0 AND 8192),
118
+ delta_bytes INTEGER NOT NULL DEFAULT 0 CHECK (delta_bytes BETWEEN 0 AND 65536),
119
+ last_delta_hash TEXT,
120
+ final_revision INTEGER CHECK (final_revision IS NULL OR final_revision >= 2),
121
+ final_hash TEXT CHECK (
122
+ final_hash IS NULL OR (
123
+ length(final_hash) = 64 AND final_hash NOT GLOB '*[^0-9a-f]*'
124
+ )
125
+ ),
126
+ failure_code TEXT CHECK (
127
+ failure_code IS NULL OR failure_code IN (
128
+ 'provider_unavailable', 'timeout', 'internal_error',
129
+ 'response_limit', 'content_rejected'
130
+ )
131
+ ),
132
+ started_at TEXT NOT NULL,
133
+ updated_at TEXT NOT NULL,
134
+ terminal_at TEXT,
135
+ deltas_pruned INTEGER NOT NULL DEFAULT 0 CHECK (deltas_pruned IN (0, 1)),
136
+ pruned_at TEXT,
137
+ PRIMARY KEY (account_id, thread_id, generation_id),
138
+ UNIQUE (account_id, thread_id, assistant_message_id),
139
+ FOREIGN KEY (account_id, thread_id)
140
+ REFERENCES direct_chat_threads(account_id, thread_id) ON DELETE RESTRICT,
141
+ CHECK (
142
+ (delta_count = 0 AND last_delta_hash IS NULL)
143
+ OR (
144
+ delta_count > 0
145
+ AND length(last_delta_hash) = 64
146
+ AND last_delta_hash NOT GLOB '*[^0-9a-f]*'
147
+ )
148
+ ),
149
+ CHECK (
150
+ (status = 'in_progress' AND terminal_at IS NULL AND final_revision IS NULL
151
+ AND final_hash IS NULL AND failure_code IS NULL
152
+ AND deltas_pruned = 0 AND pruned_at IS NULL)
153
+ OR (status = 'completed' AND terminal_at IS NOT NULL AND final_revision IS NOT NULL
154
+ AND final_hash IS NOT NULL AND failure_code IS NULL
155
+ AND ((deltas_pruned = 0 AND pruned_at IS NULL) OR (deltas_pruned = 1 AND pruned_at IS NOT NULL)))
156
+ OR (status = 'cancelled' AND terminal_at IS NOT NULL AND final_revision IS NULL
157
+ AND final_hash IS NULL AND failure_code IS NULL
158
+ AND deltas_pruned = 0 AND pruned_at IS NULL)
159
+ OR (status = 'failed' AND terminal_at IS NOT NULL AND final_revision IS NULL
160
+ AND final_hash IS NULL AND failure_code IS NOT NULL
161
+ AND deltas_pruned = 0 AND pruned_at IS NULL)
162
+ )
163
+ ) STRICT;
164
+
165
+ CREATE UNIQUE INDEX direct_chat_one_active_generation
166
+ ON direct_chat_generations(account_id, thread_id)
167
+ WHERE status = 'in_progress';
168
+
169
+ CREATE INDEX direct_chat_generations_owner_started
170
+ ON direct_chat_generations(account_id, thread_id, started_at DESC, generation_id DESC);
171
+
172
+ CREATE TRIGGER direct_chat_generations_no_delete
173
+ BEFORE DELETE ON direct_chat_generations
174
+ BEGIN
175
+ SELECT RAISE(ABORT, 'direct chat generation records are durable');
176
+ END;
177
+
178
+ CREATE TRIGGER direct_chat_generations_prune_only_superseded
179
+ BEFORE UPDATE OF deltas_pruned, pruned_at ON direct_chat_generations
180
+ WHEN OLD.deltas_pruned = 0 AND NEW.deltas_pruned = 1 AND NOT EXISTS (
181
+ SELECT 1 FROM direct_chat_generations AS newer
182
+ WHERE newer.account_id = OLD.account_id
183
+ AND newer.thread_id = OLD.thread_id
184
+ AND newer.status = 'completed'
185
+ AND (
186
+ newer.terminal_at > OLD.terminal_at
187
+ OR (newer.terminal_at = OLD.terminal_at AND newer.generation_id > OLD.generation_id)
188
+ )
189
+ )
190
+ BEGIN
191
+ SELECT RAISE(ABORT, 'only superseded completed generation deltas may be pruned');
192
+ END;
193
+
194
+ CREATE TRIGGER direct_chat_generations_prune_irreversible
195
+ BEFORE UPDATE OF deltas_pruned, pruned_at ON direct_chat_generations
196
+ WHEN OLD.deltas_pruned = 1 AND (
197
+ NEW.deltas_pruned != OLD.deltas_pruned OR NEW.pruned_at IS NOT OLD.pruned_at
198
+ )
199
+ BEGIN
200
+ SELECT RAISE(ABORT, 'direct chat generation pruning is irreversible');
201
+ END;
202
+
203
+ CREATE TABLE direct_chat_deltas (
204
+ account_id TEXT NOT NULL,
205
+ thread_id TEXT NOT NULL,
206
+ generation_id TEXT NOT NULL,
207
+ sequence INTEGER NOT NULL CHECK (sequence >= 1),
208
+ content TEXT NOT NULL,
209
+ content_bytes INTEGER NOT NULL CHECK (
210
+ content_bytes BETWEEN 1 AND 16384
211
+ AND length(CAST(content AS BLOB)) = content_bytes
212
+ ),
213
+ previous_hash TEXT,
214
+ delta_hash TEXT NOT NULL CHECK (
215
+ length(delta_hash) = 64 AND delta_hash NOT GLOB '*[^0-9a-f]*'
216
+ ),
217
+ created_at TEXT NOT NULL,
218
+ PRIMARY KEY (account_id, thread_id, generation_id, sequence),
219
+ FOREIGN KEY (account_id, thread_id, generation_id)
220
+ REFERENCES direct_chat_generations(account_id, thread_id, generation_id) ON DELETE RESTRICT,
221
+ CHECK (
222
+ (sequence = 1 AND previous_hash IS NULL)
223
+ OR (
224
+ sequence > 1
225
+ AND length(previous_hash) = 64
226
+ AND previous_hash NOT GLOB '*[^0-9a-f]*'
227
+ )
228
+ )
229
+ ) STRICT;
230
+
231
+ CREATE INDEX direct_chat_deltas_replay
232
+ ON direct_chat_deltas(account_id, thread_id, generation_id, sequence);
233
+
234
+ CREATE TRIGGER direct_chat_deltas_no_update
235
+ BEFORE UPDATE ON direct_chat_deltas
236
+ BEGIN
237
+ SELECT RAISE(ABORT, 'direct chat deltas are append-only');
238
+ END;
239
+
240
+ CREATE TRIGGER direct_chat_deltas_no_delete
241
+ BEFORE DELETE ON direct_chat_deltas
242
+ WHEN NOT EXISTS (
243
+ SELECT 1 FROM direct_chat_generations
244
+ WHERE account_id = OLD.account_id
245
+ AND thread_id = OLD.thread_id
246
+ AND generation_id = OLD.generation_id
247
+ AND status = 'completed'
248
+ AND deltas_pruned = 1
249
+ AND EXISTS (
250
+ SELECT 1 FROM direct_chat_generations AS newer
251
+ WHERE newer.account_id = OLD.account_id
252
+ AND newer.thread_id = OLD.thread_id
253
+ AND newer.status = 'completed'
254
+ AND (
255
+ newer.terminal_at > direct_chat_generations.terminal_at
256
+ OR (
257
+ newer.terminal_at = direct_chat_generations.terminal_at
258
+ AND newer.generation_id > direct_chat_generations.generation_id
259
+ )
260
+ )
261
+ )
262
+ )
263
+ BEGIN
264
+ SELECT RAISE(ABORT, 'only safely retained terminal direct chat deltas may be pruned');
265
+ END;
266
+
267
+ CREATE TABLE direct_chat_compactions (
268
+ account_id TEXT NOT NULL,
269
+ thread_id TEXT NOT NULL,
270
+ snapshot_id TEXT NOT NULL CHECK (length(snapshot_id) BETWEEN 1 AND 128),
271
+ source_start_revision INTEGER NOT NULL CHECK (source_start_revision >= 1),
272
+ source_start_hash TEXT NOT NULL CHECK (
273
+ length(source_start_hash) = 64 AND source_start_hash NOT GLOB '*[^0-9a-f]*'
274
+ ),
275
+ source_end_revision INTEGER NOT NULL CHECK (source_end_revision >= source_start_revision),
276
+ source_end_hash TEXT NOT NULL CHECK (
277
+ length(source_end_hash) = 64 AND source_end_hash NOT GLOB '*[^0-9a-f]*'
278
+ ),
279
+ summary_text TEXT NOT NULL,
280
+ summary_bytes INTEGER NOT NULL CHECK (
281
+ summary_bytes BETWEEN 1 AND 262144
282
+ AND length(CAST(summary_text AS BLOB)) = summary_bytes
283
+ ),
284
+ summary_hash TEXT NOT NULL CHECK (
285
+ length(summary_hash) = 64 AND summary_hash NOT GLOB '*[^0-9a-f]*'
286
+ ),
287
+ created_at TEXT NOT NULL,
288
+ PRIMARY KEY (account_id, thread_id, snapshot_id),
289
+ FOREIGN KEY (account_id, thread_id)
290
+ REFERENCES direct_chat_threads(account_id, thread_id) ON DELETE RESTRICT
291
+ ) STRICT;
292
+
293
+ CREATE INDEX direct_chat_compactions_owner_range
294
+ ON direct_chat_compactions(
295
+ account_id, thread_id, source_end_revision DESC, created_at DESC, snapshot_id DESC
296
+ );
297
+
298
+ CREATE TRIGGER direct_chat_compactions_no_update
299
+ BEFORE UPDATE ON direct_chat_compactions
300
+ BEGIN
301
+ SELECT RAISE(ABORT, 'direct chat compaction snapshots are immutable');
302
+ END;
303
+
304
+ CREATE TRIGGER direct_chat_compactions_no_delete
305
+ BEFORE DELETE ON direct_chat_compactions
306
+ WHEN NOT EXISTS (
307
+ SELECT 1 FROM direct_chat_compactions AS newer
308
+ WHERE newer.account_id = OLD.account_id
309
+ AND newer.thread_id = OLD.thread_id
310
+ AND (
311
+ newer.source_end_revision > OLD.source_end_revision
312
+ OR (
313
+ newer.source_end_revision = OLD.source_end_revision
314
+ AND newer.created_at > OLD.created_at
315
+ )
316
+ OR (
317
+ newer.source_end_revision = OLD.source_end_revision
318
+ AND newer.created_at = OLD.created_at
319
+ AND newer.snapshot_id > OLD.snapshot_id
320
+ )
321
+ )
322
+ )
323
+ BEGIN
324
+ SELECT RAISE(ABORT, 'the current direct chat compaction snapshot cannot be pruned');
325
+ END;
326
+
327
+ CREATE TABLE direct_chat_idempotency (
328
+ account_id TEXT NOT NULL,
329
+ operation TEXT NOT NULL CHECK (operation IN (
330
+ 'thread.create', 'message.user.append', 'generation.start',
331
+ 'generation.finalize', 'generation.cancel',
332
+ 'generation.fail', 'compaction.create'
333
+ )),
334
+ key_hash TEXT NOT NULL CHECK (
335
+ length(key_hash) = 64 AND key_hash NOT GLOB '*[^0-9a-f]*'
336
+ ),
337
+ request_hash TEXT NOT NULL CHECK (
338
+ length(request_hash) = 64 AND request_hash NOT GLOB '*[^0-9a-f]*'
339
+ ),
340
+ resource_kind TEXT NOT NULL CHECK (
341
+ resource_kind IN ('thread', 'message', 'generation', 'compaction')
342
+ ),
343
+ thread_id TEXT NOT NULL CHECK (length(thread_id) BETWEEN 1 AND 128),
344
+ resource_id TEXT NOT NULL CHECK (length(resource_id) BETWEEN 1 AND 128),
345
+ resource_version INTEGER NOT NULL CHECK (resource_version >= 0),
346
+ result_digest TEXT NOT NULL CHECK (
347
+ length(result_digest) = 64 AND result_digest NOT GLOB '*[^0-9a-f]*'
348
+ ),
349
+ created_at TEXT NOT NULL,
350
+ expires_at TEXT NOT NULL,
351
+ PRIMARY KEY (account_id, operation, key_hash),
352
+ FOREIGN KEY (account_id, thread_id)
353
+ REFERENCES direct_chat_threads(account_id, thread_id) ON DELETE RESTRICT,
354
+ CHECK (
355
+ (operation = 'thread.create' AND resource_kind = 'thread')
356
+ OR (operation = 'message.user.append' AND resource_kind = 'message')
357
+ OR (operation IN (
358
+ 'generation.start', 'generation.finalize', 'generation.cancel', 'generation.fail'
359
+ ) AND resource_kind = 'generation')
360
+ OR (operation = 'compaction.create' AND resource_kind = 'compaction')
361
+ )
362
+ ) STRICT;
363
+
364
+ CREATE INDEX direct_chat_idempotency_expiry
365
+ ON direct_chat_idempotency(expires_at);
366
+
367
+ CREATE INDEX direct_chat_idempotency_owner_created
368
+ ON direct_chat_idempotency(account_id, created_at DESC);
369
+ `;
370
+
371
+ const GENERATION_DISPATCH_LEASE_SCHEMA = `
372
+ CREATE TABLE direct_chat_generation_leases (
373
+ account_id TEXT NOT NULL,
374
+ thread_id TEXT NOT NULL,
375
+ generation_id TEXT NOT NULL,
376
+ owner_hash TEXT NOT NULL CHECK (
377
+ length(owner_hash) = 64 AND owner_hash NOT GLOB '*[^0-9a-f]*'
378
+ ),
379
+ fence INTEGER NOT NULL CHECK (fence BETWEEN 1 AND 9007199254740991),
380
+ phase TEXT NOT NULL CHECK (
381
+ phase IN ('claimed', 'dispatch_started', 'released', 'interrupted')
382
+ ),
383
+ expires_at TEXT NOT NULL,
384
+ claimed_at TEXT NOT NULL,
385
+ dispatch_started_at TEXT,
386
+ updated_at TEXT NOT NULL,
387
+ released_at TEXT,
388
+ PRIMARY KEY (account_id, thread_id, generation_id),
389
+ FOREIGN KEY (account_id, thread_id, generation_id)
390
+ REFERENCES direct_chat_generations(account_id, thread_id, generation_id) ON DELETE RESTRICT,
391
+ CHECK (
392
+ (phase IN ('claimed', 'dispatch_started') AND released_at IS NULL)
393
+ OR (phase IN ('released', 'interrupted') AND released_at = expires_at)
394
+ ),
395
+ CHECK (
396
+ (phase = 'claimed' AND dispatch_started_at IS NULL)
397
+ OR (phase = 'released')
398
+ OR (phase IN ('dispatch_started', 'interrupted') AND dispatch_started_at IS NOT NULL)
399
+ )
400
+ ) STRICT;
401
+
402
+ -- LocalLLM inference is a workstation-wide scarce resource for this service.
403
+ -- A partial unique index on the fixed dispatch phase makes the one-active-
404
+ -- inference policy durable across overlapping BFF processes, not merely an
405
+ -- in-memory per-process convention.
406
+ CREATE UNIQUE INDEX direct_chat_one_active_dispatch
407
+ ON direct_chat_generation_leases(phase)
408
+ WHERE phase = 'dispatch_started' AND released_at IS NULL;
409
+
410
+ CREATE TRIGGER direct_chat_generation_leases_fence_monotonic
411
+ BEFORE UPDATE ON direct_chat_generation_leases
412
+ WHEN NEW.fence < OLD.fence
413
+ OR NEW.fence > OLD.fence + 1
414
+ OR (NEW.owner_hash != OLD.owner_hash AND NEW.fence != OLD.fence + 1)
415
+ OR (OLD.released_at IS NOT NULL AND NEW.released_at IS NULL AND NEW.fence != OLD.fence + 1)
416
+ OR (OLD.dispatch_started_at IS NOT NULL AND NEW.dispatch_started_at IS NULL)
417
+ BEGIN
418
+ SELECT RAISE(ABORT, 'direct chat generation lease fence must advance monotonically');
419
+ END;
420
+
421
+ CREATE TRIGGER direct_chat_generation_leases_phase_monotonic
422
+ BEFORE UPDATE ON direct_chat_generation_leases
423
+ WHEN NOT (
424
+ (OLD.phase = 'claimed' AND NEW.phase IN ('claimed', 'dispatch_started', 'released'))
425
+ OR (OLD.phase = 'dispatch_started' AND NEW.phase IN ('dispatch_started', 'released', 'interrupted'))
426
+ OR (
427
+ OLD.phase = 'released' AND NEW.phase = 'released'
428
+ )
429
+ OR (
430
+ OLD.phase = 'released' AND OLD.dispatch_started_at IS NULL
431
+ AND NEW.phase = 'claimed' AND NEW.fence = OLD.fence + 1
432
+ )
433
+ OR (OLD.phase = 'interrupted' AND NEW.phase = 'interrupted')
434
+ )
435
+ BEGIN
436
+ SELECT RAISE(ABORT, 'direct chat generation lease phase must advance monotonically');
437
+ END;
438
+
439
+ CREATE TRIGGER direct_chat_generation_leases_no_delete
440
+ BEFORE DELETE ON direct_chat_generation_leases
441
+ BEGIN
442
+ SELECT RAISE(ABORT, 'direct chat generation lease fences are durable');
443
+ END;
444
+ `;
445
+
446
+ const VISION_ATTACHMENT_SCHEMA = `
447
+ CREATE TABLE direct_chat_attachments (
448
+ account_id TEXT NOT NULL,
449
+ thread_id TEXT NOT NULL,
450
+ attachment_id TEXT NOT NULL CHECK (length(attachment_id) BETWEEN 1 AND 128),
451
+ message_id TEXT NOT NULL CHECK (length(message_id) BETWEEN 1 AND 128),
452
+ media_type TEXT NOT NULL CHECK (media_type IN ('image/jpeg', 'image/png')),
453
+ byte_length INTEGER NOT NULL CHECK (byte_length BETWEEN 1 AND 4194304),
454
+ width INTEGER NOT NULL CHECK (width BETWEEN 1 AND 4096),
455
+ height INTEGER NOT NULL CHECK (height BETWEEN 1 AND 4096),
456
+ content_sha256 TEXT NOT NULL CHECK (
457
+ length(content_sha256) = 64 AND content_sha256 NOT GLOB '*[^0-9a-f]*'
458
+ ),
459
+ content BLOB NOT NULL CHECK (length(content) = byte_length),
460
+ created_at TEXT NOT NULL,
461
+ PRIMARY KEY (account_id, thread_id, attachment_id),
462
+ UNIQUE (account_id, thread_id, message_id),
463
+ FOREIGN KEY (account_id, thread_id, message_id)
464
+ REFERENCES direct_chat_messages(account_id, thread_id, message_id) ON DELETE RESTRICT,
465
+ CHECK (width * height <= 16777216)
466
+ ) STRICT;
467
+
468
+ CREATE INDEX direct_chat_attachments_owner_latest
469
+ ON direct_chat_attachments(account_id, thread_id, created_at DESC, attachment_id DESC);
470
+
471
+ CREATE TRIGGER direct_chat_attachments_no_update
472
+ BEFORE UPDATE ON direct_chat_attachments
473
+ BEGIN
474
+ SELECT RAISE(ABORT, 'direct chat attachments are immutable');
475
+ END;
476
+
477
+ CREATE TRIGGER direct_chat_attachments_no_delete
478
+ BEFORE DELETE ON direct_chat_attachments
479
+ BEGIN
480
+ SELECT RAISE(ABORT, 'direct chat attachments are durable');
481
+ END;
482
+
483
+ CREATE TRIGGER direct_chat_generations_model_immutable
484
+ BEFORE UPDATE OF model_alias, source_revision, source_hash ON direct_chat_generations
485
+ WHEN NEW.model_alias != OLD.model_alias
486
+ OR NEW.source_revision != OLD.source_revision
487
+ OR NEW.source_hash != OLD.source_hash
488
+ BEGIN
489
+ SELECT RAISE(ABORT, 'direct chat generation inference authority is immutable');
490
+ END;
491
+ `;
492
+
493
+ const MULTI_VISION_ATTACHMENT_SCHEMA = `
494
+ DROP TRIGGER direct_chat_attachments_no_update;
495
+ DROP TRIGGER direct_chat_attachments_no_delete;
496
+ DROP INDEX direct_chat_attachments_owner_latest;
497
+
498
+ ALTER TABLE direct_chat_attachments RENAME TO direct_chat_attachments_v3;
499
+
500
+ CREATE TABLE direct_chat_attachments (
501
+ account_id TEXT NOT NULL,
502
+ thread_id TEXT NOT NULL,
503
+ attachment_id TEXT NOT NULL CHECK (length(attachment_id) BETWEEN 1 AND 128),
504
+ message_id TEXT NOT NULL CHECK (length(message_id) BETWEEN 1 AND 128),
505
+ position INTEGER NOT NULL CHECK (position BETWEEN 0 AND 3),
506
+ media_type TEXT NOT NULL CHECK (media_type IN ('image/jpeg', 'image/png')),
507
+ byte_length INTEGER NOT NULL CHECK (byte_length BETWEEN 1 AND 4194304),
508
+ width INTEGER NOT NULL CHECK (width BETWEEN 1 AND 4096),
509
+ height INTEGER NOT NULL CHECK (height BETWEEN 1 AND 4096),
510
+ content_sha256 TEXT NOT NULL CHECK (
511
+ length(content_sha256) = 64 AND content_sha256 NOT GLOB '*[^0-9a-f]*'
512
+ ),
513
+ content BLOB NOT NULL CHECK (length(content) = byte_length),
514
+ created_at TEXT NOT NULL,
515
+ PRIMARY KEY (account_id, thread_id, attachment_id),
516
+ UNIQUE (account_id, thread_id, message_id, position),
517
+ FOREIGN KEY (account_id, thread_id, message_id)
518
+ REFERENCES direct_chat_messages(account_id, thread_id, message_id) ON DELETE RESTRICT,
519
+ CHECK (width * height <= 16777216)
520
+ ) STRICT;
521
+
522
+ INSERT INTO direct_chat_attachments(
523
+ account_id, thread_id, attachment_id, message_id, position, media_type,
524
+ byte_length, width, height, content_sha256, content, created_at
525
+ )
526
+ SELECT account_id, thread_id, attachment_id, message_id, 0, media_type,
527
+ byte_length, width, height, content_sha256, content, created_at
528
+ FROM direct_chat_attachments_v3;
529
+
530
+ DROP TABLE direct_chat_attachments_v3;
531
+
532
+ CREATE INDEX direct_chat_attachments_owner_latest
533
+ ON direct_chat_attachments(account_id, thread_id, created_at DESC, attachment_id DESC);
534
+
535
+ CREATE TRIGGER direct_chat_attachments_no_update
536
+ BEFORE UPDATE ON direct_chat_attachments
537
+ BEGIN
538
+ SELECT RAISE(ABORT, 'direct chat attachments are immutable');
539
+ END;
540
+
541
+ CREATE TRIGGER direct_chat_attachments_no_delete
542
+ BEFORE DELETE ON direct_chat_attachments
543
+ BEGIN
544
+ SELECT RAISE(ABORT, 'direct chat attachments are durable');
545
+ END;
546
+ `;
547
+
548
+ const SAFE_THREAD_DELETION_SCHEMA = `
549
+ CREATE TABLE direct_chat_thread_deletions (
550
+ account_id TEXT NOT NULL CHECK (length(account_id) BETWEEN 1 AND 128),
551
+ thread_id TEXT NOT NULL CHECK (length(thread_id) BETWEEN 1 AND 128),
552
+ key_hash TEXT NOT NULL CHECK (
553
+ length(key_hash) = 64 AND key_hash NOT GLOB '*[^0-9a-f]*'
554
+ ),
555
+ request_hash TEXT NOT NULL CHECK (
556
+ length(request_hash) = 64 AND request_hash NOT GLOB '*[^0-9a-f]*'
557
+ ),
558
+ deleted_revision INTEGER NOT NULL CHECK (deleted_revision BETWEEN 0 AND 2000),
559
+ deleted_hash TEXT,
560
+ result_digest TEXT NOT NULL CHECK (
561
+ length(result_digest) = 64 AND result_digest NOT GLOB '*[^0-9a-f]*'
562
+ ),
563
+ deleted_at TEXT NOT NULL,
564
+ PRIMARY KEY (account_id, key_hash),
565
+ UNIQUE (account_id, thread_id),
566
+ CHECK (
567
+ (deleted_revision = 0 AND deleted_hash IS NULL)
568
+ OR (
569
+ deleted_revision > 0
570
+ AND length(deleted_hash) = 64
571
+ AND deleted_hash NOT GLOB '*[^0-9a-f]*'
572
+ )
573
+ )
574
+ ) STRICT;
575
+
576
+ CREATE TRIGGER direct_chat_thread_deletions_no_update
577
+ BEFORE UPDATE ON direct_chat_thread_deletions
578
+ BEGIN
579
+ SELECT RAISE(ABORT, 'direct chat deletion receipts are immutable');
580
+ END;
581
+
582
+ CREATE TRIGGER direct_chat_thread_deletions_no_delete
583
+ BEFORE DELETE ON direct_chat_thread_deletions
584
+ BEGIN
585
+ SELECT RAISE(ABORT, 'direct chat deletion receipts are durable');
586
+ END;
587
+
588
+ CREATE TRIGGER direct_chat_threads_authorized_delete
589
+ BEFORE DELETE ON direct_chat_threads
590
+ WHEN NOT EXISTS (
591
+ SELECT 1 FROM direct_chat_thread_deletions
592
+ WHERE account_id = OLD.account_id AND thread_id = OLD.thread_id
593
+ )
594
+ BEGIN
595
+ SELECT RAISE(ABORT, 'direct chat threads require durable deletion authority');
596
+ END;
597
+
598
+ DROP TRIGGER direct_chat_messages_no_delete;
599
+ CREATE TRIGGER direct_chat_messages_no_delete
600
+ BEFORE DELETE ON direct_chat_messages
601
+ WHEN NOT EXISTS (
602
+ SELECT 1 FROM direct_chat_thread_deletions
603
+ WHERE account_id = OLD.account_id AND thread_id = OLD.thread_id
604
+ )
605
+ BEGIN
606
+ SELECT RAISE(ABORT, 'direct chat messages are append-only');
607
+ END;
608
+
609
+ DROP TRIGGER direct_chat_generations_no_delete;
610
+ CREATE TRIGGER direct_chat_generations_no_delete
611
+ BEFORE DELETE ON direct_chat_generations
612
+ WHEN NOT EXISTS (
613
+ SELECT 1 FROM direct_chat_thread_deletions
614
+ WHERE account_id = OLD.account_id AND thread_id = OLD.thread_id
615
+ )
616
+ BEGIN
617
+ SELECT RAISE(ABORT, 'direct chat generation records are durable');
618
+ END;
619
+
620
+ DROP TRIGGER direct_chat_generation_leases_no_delete;
621
+ CREATE TRIGGER direct_chat_generation_leases_no_delete
622
+ BEFORE DELETE ON direct_chat_generation_leases
623
+ WHEN NOT EXISTS (
624
+ SELECT 1 FROM direct_chat_thread_deletions
625
+ WHERE account_id = OLD.account_id AND thread_id = OLD.thread_id
626
+ )
627
+ BEGIN
628
+ SELECT RAISE(ABORT, 'direct chat generation lease fences are durable');
629
+ END;
630
+
631
+ DROP TRIGGER direct_chat_attachments_no_delete;
632
+ CREATE TRIGGER direct_chat_attachments_no_delete
633
+ BEFORE DELETE ON direct_chat_attachments
634
+ WHEN NOT EXISTS (
635
+ SELECT 1 FROM direct_chat_thread_deletions
636
+ WHERE account_id = OLD.account_id AND thread_id = OLD.thread_id
637
+ )
638
+ BEGIN
639
+ SELECT RAISE(ABORT, 'direct chat attachments are durable');
640
+ END;
641
+
642
+ DROP TRIGGER direct_chat_deltas_no_delete;
643
+ CREATE TRIGGER direct_chat_deltas_no_delete
644
+ BEFORE DELETE ON direct_chat_deltas
645
+ WHEN NOT EXISTS (
646
+ SELECT 1 FROM direct_chat_thread_deletions
647
+ WHERE account_id = OLD.account_id AND thread_id = OLD.thread_id
648
+ )
649
+ AND NOT EXISTS (
650
+ SELECT 1 FROM direct_chat_generations
651
+ WHERE account_id = OLD.account_id
652
+ AND thread_id = OLD.thread_id
653
+ AND generation_id = OLD.generation_id
654
+ AND status = 'completed'
655
+ AND deltas_pruned = 1
656
+ AND EXISTS (
657
+ SELECT 1 FROM direct_chat_generations AS newer
658
+ WHERE newer.account_id = OLD.account_id
659
+ AND newer.thread_id = OLD.thread_id
660
+ AND newer.status = 'completed'
661
+ AND (
662
+ newer.terminal_at > direct_chat_generations.terminal_at
663
+ OR (
664
+ newer.terminal_at = direct_chat_generations.terminal_at
665
+ AND newer.generation_id > direct_chat_generations.generation_id
666
+ )
667
+ )
668
+ )
669
+ )
670
+ BEGIN
671
+ SELECT RAISE(ABORT, 'only safely retained terminal direct chat deltas may be pruned');
672
+ END;
673
+
674
+ DROP TRIGGER direct_chat_compactions_no_delete;
675
+ CREATE TRIGGER direct_chat_compactions_no_delete
676
+ BEFORE DELETE ON direct_chat_compactions
677
+ WHEN NOT EXISTS (
678
+ SELECT 1 FROM direct_chat_thread_deletions
679
+ WHERE account_id = OLD.account_id AND thread_id = OLD.thread_id
680
+ )
681
+ AND NOT EXISTS (
682
+ SELECT 1 FROM direct_chat_compactions AS newer
683
+ WHERE newer.account_id = OLD.account_id
684
+ AND newer.thread_id = OLD.thread_id
685
+ AND (
686
+ newer.source_end_revision > OLD.source_end_revision
687
+ OR (
688
+ newer.source_end_revision = OLD.source_end_revision
689
+ AND newer.created_at > OLD.created_at
690
+ )
691
+ OR (
692
+ newer.source_end_revision = OLD.source_end_revision
693
+ AND newer.created_at = OLD.created_at
694
+ AND newer.snapshot_id > OLD.snapshot_id
695
+ )
696
+ )
697
+ )
698
+ BEGIN
699
+ SELECT RAISE(ABORT, 'the current direct chat compaction snapshot cannot be pruned');
700
+ END;
701
+ `;
702
+
703
+ function checksum(sql) {
704
+ return createHash('sha256').update(sql, 'utf8').digest('hex');
705
+ }
706
+
707
+ export const CHAT_MIGRATIONS = Object.freeze([
708
+ Object.freeze({
709
+ version: 1,
710
+ name: 'standalone_direct_chat_ledger',
711
+ sql: INITIAL_CHAT_SCHEMA,
712
+ checksum: checksum(INITIAL_CHAT_SCHEMA)
713
+ }),
714
+ Object.freeze({
715
+ version: 2,
716
+ name: 'fenced_generation_dispatch_leases',
717
+ sql: GENERATION_DISPATCH_LEASE_SCHEMA,
718
+ checksum: checksum(GENERATION_DISPATCH_LEASE_SCHEMA)
719
+ }),
720
+ Object.freeze({
721
+ version: 3,
722
+ name: 'private_vision_attachments',
723
+ sql: VISION_ATTACHMENT_SCHEMA,
724
+ checksum: checksum(VISION_ATTACHMENT_SCHEMA)
725
+ }),
726
+ Object.freeze({
727
+ version: 4,
728
+ name: 'bounded_multi_vision_attachments',
729
+ sql: MULTI_VISION_ATTACHMENT_SCHEMA,
730
+ checksum: checksum(MULTI_VISION_ATTACHMENT_SCHEMA)
731
+ }),
732
+ Object.freeze({
733
+ version: 5,
734
+ name: 'safe_direct_chat_thread_deletion',
735
+ sql: SAFE_THREAD_DELETION_SCHEMA,
736
+ checksum: checksum(SAFE_THREAD_DELETION_SCHEMA)
737
+ })
738
+ ]);
739
+
740
+ export const LATEST_CHAT_SCHEMA_VERSION = CHAT_MIGRATIONS[CHAT_MIGRATIONS.length - 1].version;
741
+ // Thread deletion needs the v5 authority/receipt schema in every deployment.
742
+ // The attachment tables remain feature-gated at the application boundary when
743
+ // vision is disabled, but are materialized so one ordered migration lineage is
744
+ // retained for both deployment modes.
745
+ export const DEFAULT_CHAT_SCHEMA_VERSION = LATEST_CHAT_SCHEMA_VERSION;
746
+
747
+ const EXPECTED_SCHEMA_OBJECTS_V2 = Object.freeze([
748
+ 'index:direct_chat_compactions_owner_range:direct_chat_compactions',
749
+ 'index:direct_chat_deltas_replay:direct_chat_deltas',
750
+ 'index:direct_chat_generations_owner_started:direct_chat_generations',
751
+ 'index:direct_chat_idempotency_expiry:direct_chat_idempotency',
752
+ 'index:direct_chat_idempotency_owner_created:direct_chat_idempotency',
753
+ 'index:direct_chat_messages_owner_revision:direct_chat_messages',
754
+ 'index:direct_chat_one_active_dispatch:direct_chat_generation_leases',
755
+ 'index:direct_chat_one_active_generation:direct_chat_generations',
756
+ 'index:direct_chat_threads_owner_updated:direct_chat_threads',
757
+ 'table:chat_schema_migrations:chat_schema_migrations',
758
+ 'table:direct_chat_compactions:direct_chat_compactions',
759
+ 'table:direct_chat_deltas:direct_chat_deltas',
760
+ 'table:direct_chat_generation_leases:direct_chat_generation_leases',
761
+ 'table:direct_chat_generations:direct_chat_generations',
762
+ 'table:direct_chat_idempotency:direct_chat_idempotency',
763
+ 'table:direct_chat_messages:direct_chat_messages',
764
+ 'table:direct_chat_threads:direct_chat_threads',
765
+ 'trigger:direct_chat_compactions_no_delete:direct_chat_compactions',
766
+ 'trigger:direct_chat_compactions_no_update:direct_chat_compactions',
767
+ 'trigger:direct_chat_deltas_no_delete:direct_chat_deltas',
768
+ 'trigger:direct_chat_deltas_no_update:direct_chat_deltas',
769
+ 'trigger:direct_chat_generation_leases_fence_monotonic:direct_chat_generation_leases',
770
+ 'trigger:direct_chat_generation_leases_no_delete:direct_chat_generation_leases',
771
+ 'trigger:direct_chat_generation_leases_phase_monotonic:direct_chat_generation_leases',
772
+ 'trigger:direct_chat_generations_no_delete:direct_chat_generations',
773
+ 'trigger:direct_chat_generations_prune_irreversible:direct_chat_generations',
774
+ 'trigger:direct_chat_generations_prune_only_superseded:direct_chat_generations',
775
+ 'trigger:direct_chat_messages_no_delete:direct_chat_messages',
776
+ 'trigger:direct_chat_messages_no_update:direct_chat_messages'
777
+ ]);
778
+
779
+ const EXPECTED_SCHEMA_OBJECTS_V3 = Object.freeze([
780
+ 'index:direct_chat_attachments_owner_latest:direct_chat_attachments',
781
+ ...EXPECTED_SCHEMA_OBJECTS_V2.slice(0, 9),
782
+ 'table:chat_schema_migrations:chat_schema_migrations',
783
+ 'table:direct_chat_attachments:direct_chat_attachments',
784
+ ...EXPECTED_SCHEMA_OBJECTS_V2.slice(10, 17),
785
+ 'trigger:direct_chat_attachments_no_delete:direct_chat_attachments',
786
+ 'trigger:direct_chat_attachments_no_update:direct_chat_attachments',
787
+ ...EXPECTED_SCHEMA_OBJECTS_V2.slice(17, 24),
788
+ 'trigger:direct_chat_generations_model_immutable:direct_chat_generations',
789
+ ...EXPECTED_SCHEMA_OBJECTS_V2.slice(24)
790
+ ]);
791
+
792
+ const EXPECTED_SCHEMA_OBJECTS_V4 = EXPECTED_SCHEMA_OBJECTS_V3;
793
+
794
+ const EXPECTED_SCHEMA_OBJECTS_V5 = Object.freeze([
795
+ ...EXPECTED_SCHEMA_OBJECTS_V4,
796
+ 'table:direct_chat_thread_deletions:direct_chat_thread_deletions',
797
+ 'trigger:direct_chat_thread_deletions_no_delete:direct_chat_thread_deletions',
798
+ 'trigger:direct_chat_thread_deletions_no_update:direct_chat_thread_deletions',
799
+ 'trigger:direct_chat_threads_authorized_delete:direct_chat_threads'
800
+ ].sort());
801
+
802
+ const EXPECTED_SCHEMA_FINGERPRINTS = Object.freeze({
803
+ 2: 'ad84514c28ac6762061439579862853249f6c87b613f81b79a20947d5c52d4d5',
804
+ 3: '1707c8971cd41ed0fe3743af381528ff176dc91c2ed398b2e74ffb0d17b5d0bb',
805
+ 4: '4c8475d7a9aa7052733d27f514adfb539266a58c629f7eccf6522bbd70d9a256',
806
+ 5: 'd2a81bde2e0a58b31b50222652a924cb82f583d93592f49b1c0c33e1ac10d424'
807
+ });
808
+
809
+ function pragmaInteger(database, pragma) {
810
+ const row = database.prepare(`PRAGMA ${pragma}`).get();
811
+ const value = row?.[pragma];
812
+ if (!Number.isSafeInteger(value)) {
813
+ throw new StorageCorruptionError(`SQLite returned an invalid ${pragma} value.`);
814
+ }
815
+ return value;
816
+ }
817
+
818
+ function assertIntegrity(database) {
819
+ const integrity = database.prepare('PRAGMA integrity_check').get();
820
+ if (integrity?.integrity_check !== 'ok') {
821
+ throw new StorageCorruptionError('The direct-chat database failed SQLite integrity validation.');
822
+ }
823
+ const foreignKeyFailures = database.prepare('PRAGMA foreign_key_check').all();
824
+ if (foreignKeyFailures.length !== 0) {
825
+ throw new StorageCorruptionError('The direct-chat database contains invalid ownership references.');
826
+ }
827
+ }
828
+
829
+ function existingUserTables(database) {
830
+ return database.prepare(`
831
+ SELECT name
832
+ FROM sqlite_schema
833
+ WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
834
+ ORDER BY name
835
+ `).all();
836
+ }
837
+
838
+ function verifyMigrationLedger(database, currentVersion) {
839
+ let rows;
840
+ try {
841
+ rows = database.prepare(`
842
+ SELECT version, name, checksum
843
+ FROM chat_schema_migrations
844
+ ORDER BY version
845
+ `).all();
846
+ } catch (error) {
847
+ throw new StorageCorruptionError('The direct-chat migration ledger is missing or unreadable.', { cause: error });
848
+ }
849
+ if (rows.length !== currentVersion) {
850
+ throw new StorageCorruptionError('The direct-chat migration ledger does not match the schema version.');
851
+ }
852
+ for (let index = 0; index < currentVersion; index += 1) {
853
+ const expected = CHAT_MIGRATIONS[index];
854
+ const actual = rows[index];
855
+ if (
856
+ Number(actual?.version) !== expected.version ||
857
+ actual?.name !== expected.name ||
858
+ actual?.checksum !== expected.checksum
859
+ ) {
860
+ throw new StorageCorruptionError(`Direct-chat migration ${expected.version} failed checksum validation.`);
861
+ }
862
+ }
863
+ }
864
+
865
+ function verifySchemaObjects(database, currentVersion) {
866
+ const rows = database.prepare(`
867
+ SELECT type, name, tbl_name, sql
868
+ FROM sqlite_schema
869
+ WHERE name NOT LIKE 'sqlite_%'
870
+ ORDER BY type, name, tbl_name
871
+ `).all();
872
+ const actual = rows.map((row) => `${row.type}:${row.name}:${row.tbl_name}`);
873
+ const expectedObjects = currentVersion === 2
874
+ ? EXPECTED_SCHEMA_OBJECTS_V2
875
+ : (currentVersion === 3
876
+ ? EXPECTED_SCHEMA_OBJECTS_V3
877
+ : (currentVersion === 4
878
+ ? EXPECTED_SCHEMA_OBJECTS_V4
879
+ : (currentVersion === 5 ? EXPECTED_SCHEMA_OBJECTS_V5 : null)));
880
+ if (expectedObjects === null || JSON.stringify(actual) !== JSON.stringify(expectedObjects)) {
881
+ throw new StorageCorruptionError('The direct-chat schema object set is missing, altered, or extended.');
882
+ }
883
+ if (checksum(JSON.stringify(rows)) !== EXPECTED_SCHEMA_FINGERPRINTS[currentVersion]) {
884
+ throw new StorageCorruptionError('The direct-chat schema definition fingerprint does not match.');
885
+ }
886
+ }
887
+
888
+ export function applyChatMigrations(database, appliedAt, { enableVisionAttachments = false } = {}) {
889
+ assertIntegrity(database);
890
+
891
+ const currentVersion = pragmaInteger(database, 'user_version');
892
+ const applicationId = pragmaInteger(database, 'application_id');
893
+ if (currentVersion > LATEST_CHAT_SCHEMA_VERSION) throw new UnsupportedSchemaError();
894
+ if (currentVersion === 0 && applicationId === 0 && existingUserTables(database).length !== 0) {
895
+ throw new StorageCorruptionError('Refusing to claim a non-empty, unidentified direct-chat database.');
896
+ }
897
+ if (currentVersion > 0 && applicationId !== CHAT_SQLITE_APPLICATION_ID) {
898
+ throw new StorageCorruptionError('The database belongs to a different application.');
899
+ }
900
+ if (applicationId !== 0 && applicationId !== CHAT_SQLITE_APPLICATION_ID) {
901
+ throw new StorageCorruptionError('The direct-chat database application identifier is not recognized.');
902
+ }
903
+
904
+ if (typeof enableVisionAttachments !== 'boolean') {
905
+ throw new TypeError('enableVisionAttachments must be boolean');
906
+ }
907
+ if (currentVersion > 0) {
908
+ verifyMigrationLedger(database, currentVersion);
909
+ if (currentVersion >= 2) verifySchemaObjects(database, currentVersion);
910
+ }
911
+
912
+ // V5 is common to every deployment because safe thread deletion relies on
913
+ // its durable authority receipts. Vision use remains application-gated even
914
+ // though the empty attachment tables now exist in a vision-disabled store.
915
+ const targetVersion = enableVisionAttachments || currentVersion >= 3
916
+ ? LATEST_CHAT_SCHEMA_VERSION
917
+ : DEFAULT_CHAT_SCHEMA_VERSION;
918
+
919
+ for (const migration of CHAT_MIGRATIONS.slice(currentVersion, targetVersion)) {
920
+ database.exec('BEGIN IMMEDIATE');
921
+ try {
922
+ database.exec(migration.sql);
923
+ database.prepare(`
924
+ INSERT INTO chat_schema_migrations(version, name, checksum, applied_at)
925
+ VALUES (?, ?, ?, ?)
926
+ `).run(migration.version, migration.name, migration.checksum, appliedAt);
927
+ database.exec(`PRAGMA application_id = ${CHAT_SQLITE_APPLICATION_ID}`);
928
+ database.exec(`PRAGMA user_version = ${migration.version}`);
929
+ database.exec('COMMIT');
930
+ } catch (error) {
931
+ try {
932
+ database.exec('ROLLBACK');
933
+ } catch {
934
+ // Preserve the migration failure.
935
+ }
936
+ throw new StorageCorruptionError(
937
+ `Direct-chat migration ${migration.version} could not be applied.`,
938
+ { cause: error }
939
+ );
940
+ }
941
+ }
942
+
943
+ verifyMigrationLedger(database, targetVersion);
944
+ verifySchemaObjects(database, targetVersion);
945
+ assertIntegrity(database);
946
+ return targetVersion;
947
+ }