@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,3308 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+
3
+ import {
4
+ ConflictError,
5
+ IdempotencyConflictError,
6
+ NotFoundError,
7
+ StorageCorruptionError,
8
+ ValidationError
9
+ } from './errors.js';
10
+ import {
11
+ CHAT_SQLITE_APPLICATION_ID,
12
+ applyChatMigrations
13
+ } from './chat-migrations.js';
14
+ import { checkOpenSqliteHealth } from './sqlite-health.js';
15
+ import { assertSecureDatabaseFile, prepareSecureDatabasePath } from './storage-path.js';
16
+ import {
17
+ assertCanonicalIsoTimestamp,
18
+ assertEventHash,
19
+ assertExactKeys,
20
+ assertIdentifier,
21
+ assertIdempotencyKey,
22
+ assertInteger,
23
+ canonicalJson,
24
+ nowIso,
25
+ sha256
26
+ } from './validation.js';
27
+ import {
28
+ VISION_ATTACHMENT_LIMITS,
29
+ VISION_MODEL_ALIAS,
30
+ validateStoredVisionAttachment,
31
+ visionAttachmentDescriptor
32
+ } from './vision-attachment.js';
33
+
34
+ const DEFAULT_CLOCK = () => new Date();
35
+ const HASH_PATTERN = /^[a-f0-9]{64}$/u;
36
+ const MODEL_ALIAS_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
37
+ const DISPATCH_LEASE_OWNER_PATTERN = /^[A-Za-z0-9._~:-]{32,256}$/u;
38
+ const MAX_DISPATCH_LEASE_FENCE = Number.MAX_SAFE_INTEGER;
39
+ const FAILURE_CODES = new Set([
40
+ 'provider_unavailable',
41
+ 'timeout',
42
+ 'internal_error',
43
+ 'response_limit',
44
+ 'content_rejected'
45
+ ]);
46
+ const PRE_DISPATCH_FAILURE_CODES = new Set(['provider_unavailable', 'timeout']);
47
+ const DATABASE_METADATA = new WeakMap();
48
+ const MAX_AUDITED_THREAD_CACHE_ENTRIES = 256;
49
+
50
+ export const DIRECT_CHAT_LIMITS = Object.freeze({
51
+ threadsPerAccount: 100,
52
+ messagesPerThread: 2_000,
53
+ ledgerBytesPerThread: 8 * 1024 * 1024,
54
+ messageBytes: 64 * 1024,
55
+ generationsPerThread: 1_024,
56
+ deltasPerGeneration: 8_192,
57
+ deltasPerThread: 32_768,
58
+ deltaBytes: 16 * 1024,
59
+ generationBytes: 64 * 1024,
60
+ journalBytesPerThread: 16 * 1024 * 1024,
61
+ compactionsPerThread: 32,
62
+ summaryBytes: 256 * 1024,
63
+ listPage: 200,
64
+ idempotencyReceiptsPerAccount: 1_024,
65
+ threadDeletionReceiptsPerAccount: 100_000,
66
+ cleanupRows: 256
67
+ });
68
+
69
+ export const DIRECT_CHAT_IDEMPOTENCY_TTL_MS = 7 * 24 * 60 * 60 * 1000;
70
+ export const DIRECT_CHAT_TERMINAL_DELTA_RETENTION_MS = 24 * 60 * 60 * 1000;
71
+ export const DIRECT_CHAT_DISPATCH_LEASE_LIMITS = Object.freeze({
72
+ minimumTtlMs: 1_000,
73
+ maximumTtlMs: 5 * 60 * 1_000,
74
+ maximumFence: MAX_DISPATCH_LEASE_FENCE
75
+ });
76
+
77
+ function utf8Bytes(value) {
78
+ return Buffer.byteLength(value, 'utf8');
79
+ }
80
+
81
+ function assertUnicodeScalarString(value, name, { minBytes = 1, maxBytes }) {
82
+ if (typeof value !== 'string') throw new ValidationError(`${name} must be a string.`);
83
+ for (let index = 0; index < value.length; index += 1) {
84
+ const code = value.charCodeAt(index);
85
+ if (code >= 0xd800 && code <= 0xdbff) {
86
+ const next = value.charCodeAt(index + 1);
87
+ if (!(next >= 0xdc00 && next <= 0xdfff)) {
88
+ throw new ValidationError(`${name} must not contain unpaired UTF-16 surrogates.`);
89
+ }
90
+ index += 1;
91
+ } else if (code >= 0xdc00 && code <= 0xdfff) {
92
+ throw new ValidationError(`${name} must not contain unpaired UTF-16 surrogates.`);
93
+ }
94
+ }
95
+ if (/\u0000/u.test(value)) throw new ValidationError(`${name} must not contain NUL bytes.`);
96
+ const bytes = utf8Bytes(value);
97
+ if (bytes < minBytes || bytes > maxBytes) {
98
+ throw new ValidationError(`${name} must contain between ${minBytes} and ${maxBytes} UTF-8 bytes.`);
99
+ }
100
+ return { value, bytes };
101
+ }
102
+
103
+ function assertModelAlias(value) {
104
+ if (typeof value !== 'string' || !MODEL_ALIAS_PATTERN.test(value)) {
105
+ throw new ValidationError('modelAlias must be a server-selected portable alias, not a provider URL or path.');
106
+ }
107
+ return value;
108
+ }
109
+
110
+ function assertFailureCode(value) {
111
+ if (typeof value !== 'string' || !FAILURE_CODES.has(value)) {
112
+ throw new ValidationError('failureCode is not an approved server-side failure category.');
113
+ }
114
+ return value;
115
+ }
116
+
117
+ function assertDispatchLeaseOwnerToken(value) {
118
+ if (typeof value !== 'string' || !DISPATCH_LEASE_OWNER_PATTERN.test(value)) {
119
+ throw new ValidationError(
120
+ 'ownerToken must be a 32-256 character opaque restricted-ASCII dispatch identity.'
121
+ );
122
+ }
123
+ return value;
124
+ }
125
+
126
+ function assertDispatchLeaseTtl(value) {
127
+ return assertInteger(value, 'ttlMs', {
128
+ min: DIRECT_CHAT_DISPATCH_LEASE_LIMITS.minimumTtlMs,
129
+ max: DIRECT_CHAT_DISPATCH_LEASE_LIMITS.maximumTtlMs
130
+ });
131
+ }
132
+
133
+ function assertDispatchLeaseProof(value) {
134
+ if (value === undefined) return null;
135
+ assertExactKeys(
136
+ value,
137
+ { required: ['ownerToken', 'fence'] },
138
+ 'direct chat generation dispatch lease proof'
139
+ );
140
+ const ownerToken = assertDispatchLeaseOwnerToken(value.ownerToken);
141
+ return Object.freeze({
142
+ ownerHash: sha256(ownerToken),
143
+ fence: assertInteger(value.fence, 'dispatchLease.fence', {
144
+ min: 1,
145
+ max: MAX_DISPATCH_LEASE_FENCE
146
+ })
147
+ });
148
+ }
149
+
150
+ function addMilliseconds(timestamp, milliseconds) {
151
+ return new Date(new Date(timestamp).getTime() + milliseconds).toISOString();
152
+ }
153
+
154
+ function isConstraintError(error) {
155
+ return typeof error?.code === 'string' && error.code.startsWith('ERR_SQLITE_CONSTRAINT');
156
+ }
157
+
158
+ function threadView(row) {
159
+ if (!row) return null;
160
+ return {
161
+ accountId: row.account_id,
162
+ threadId: row.thread_id,
163
+ title: row.title,
164
+ modelAlias: row.model_alias,
165
+ revision: Number(row.ledger_revision),
166
+ ledgerHash: row.ledger_hash,
167
+ messageCount: Number(row.message_count),
168
+ ledgerBytes: Number(row.ledger_bytes),
169
+ currentGenerationId: row.current_generation_id,
170
+ createdAt: row.created_at,
171
+ updatedAt: row.updated_at
172
+ };
173
+ }
174
+
175
+ function attachmentViewFields(attachments) {
176
+ if (!Array.isArray(attachments) || attachments.length === 0) return {};
177
+ const descriptors = Object.freeze(attachments.map(visionAttachmentDescriptor));
178
+ // Preserve the v3 one-image ledger/API representation so existing rows,
179
+ // idempotency receipts, and the immediately previous PWA remain valid.
180
+ return descriptors.length === 1
181
+ ? { attachment: descriptors[0] }
182
+ : { attachments: descriptors };
183
+ }
184
+
185
+ function messageView(row, attachments = []) {
186
+ if (!row) return null;
187
+ return {
188
+ accountId: row.account_id,
189
+ threadId: row.thread_id,
190
+ messageId: row.message_id,
191
+ revision: Number(row.revision),
192
+ role: row.role,
193
+ content: row.content,
194
+ contentBytes: Number(row.content_bytes),
195
+ previousHash: row.previous_hash,
196
+ messageHash: row.message_hash,
197
+ generationId: row.generation_id,
198
+ createdAt: row.created_at,
199
+ ...attachmentViewFields(attachments)
200
+ };
201
+ }
202
+
203
+ function attachmentFromRow(row) {
204
+ if (!row) return null;
205
+ let checked;
206
+ try {
207
+ checked = validateStoredVisionAttachment({
208
+ accountId: row.account_id,
209
+ threadId: row.thread_id,
210
+ attachmentId: row.attachment_id,
211
+ messageId: row.message_id,
212
+ position: Number(row.position),
213
+ mediaType: row.media_type,
214
+ byteLength: Number(row.byte_length),
215
+ width: Number(row.width),
216
+ height: Number(row.height),
217
+ contentSha256: row.content_sha256,
218
+ content: row.content,
219
+ createdAt: row.created_at
220
+ });
221
+ } catch (error) {
222
+ throw new StorageCorruptionError('A stored direct-chat vision attachment is invalid.', { cause: error });
223
+ }
224
+ assertStoredIdentifier(checked.accountId, 'attachment.accountId');
225
+ assertStoredIdentifier(checked.threadId, 'attachment.threadId');
226
+ assertStoredIdentifier(checked.messageId, 'attachment.messageId');
227
+ assertStoredTimestamp(checked.createdAt, 'attachment.createdAt');
228
+ if (!Number.isSafeInteger(checked.position) || checked.position < 0
229
+ || checked.position >= VISION_ATTACHMENT_LIMITS.attachmentsPerMessage) {
230
+ throw new StorageCorruptionError('A stored direct-chat vision attachment position is invalid.');
231
+ }
232
+ return checked;
233
+ }
234
+
235
+ function attachmentDescriptorFromRow(row) {
236
+ if (!row) return null;
237
+ try {
238
+ assertStoredIdentifier(row.account_id, 'attachment.accountId');
239
+ assertStoredIdentifier(row.thread_id, 'attachment.threadId');
240
+ assertStoredIdentifier(row.attachment_id, 'attachment.attachmentId');
241
+ assertStoredIdentifier(row.message_id, 'attachment.messageId');
242
+ assertStoredTimestamp(row.created_at, 'attachment.createdAt');
243
+ } catch (error) {
244
+ if (error instanceof StorageCorruptionError) throw error;
245
+ throw new StorageCorruptionError('A stored direct-chat attachment descriptor is invalid.', { cause: error });
246
+ }
247
+ const byteLength = Number(row.byte_length);
248
+ const width = Number(row.width);
249
+ const height = Number(row.height);
250
+ const position = Number(row.position);
251
+ if (!['image/jpeg', 'image/png'].includes(row.media_type)
252
+ || !Number.isSafeInteger(byteLength) || byteLength < 1 || byteLength > VISION_ATTACHMENT_LIMITS.bytes
253
+ || !Number.isSafeInteger(width) || width < 1 || width > VISION_ATTACHMENT_LIMITS.maximumEdge
254
+ || !Number.isSafeInteger(height) || height < 1 || height > VISION_ATTACHMENT_LIMITS.maximumEdge
255
+ || width * height > VISION_ATTACHMENT_LIMITS.pixels
256
+ || !Number.isSafeInteger(position) || position < 0
257
+ || position >= VISION_ATTACHMENT_LIMITS.attachmentsPerMessage
258
+ || !HASH_PATTERN.test(row.content_sha256)
259
+ || (row.content_length !== undefined && Number(row.content_length) !== byteLength)) {
260
+ throw new StorageCorruptionError('A stored direct-chat attachment descriptor is inconsistent.');
261
+ }
262
+ return Object.freeze({
263
+ accountId: row.account_id,
264
+ threadId: row.thread_id,
265
+ attachmentId: row.attachment_id,
266
+ messageId: row.message_id,
267
+ position,
268
+ mediaType: row.media_type,
269
+ byteLength,
270
+ width,
271
+ height,
272
+ contentSha256: row.content_sha256,
273
+ createdAt: row.created_at,
274
+ descriptor: visionAttachmentDescriptor({
275
+ attachmentId: row.attachment_id,
276
+ mediaType: row.media_type,
277
+ byteLength,
278
+ width,
279
+ height,
280
+ contentSha256: row.content_sha256
281
+ })
282
+ });
283
+ }
284
+
285
+ function attachmentsForMessage(database, accountId, threadId, messageId) {
286
+ if ((DATABASE_METADATA.get(database)?.schemaVersion ?? 0) < 3) return Object.freeze([]);
287
+ return Object.freeze(database.prepare(`
288
+ SELECT * FROM direct_chat_attachments
289
+ WHERE account_id = ? AND thread_id = ? AND message_id = ?
290
+ ORDER BY position
291
+ `).all(accountId, threadId, messageId).map(attachmentFromRow));
292
+ }
293
+
294
+ function generationView(row) {
295
+ if (!row) return null;
296
+ return {
297
+ accountId: row.account_id,
298
+ threadId: row.thread_id,
299
+ generationId: row.generation_id,
300
+ assistantMessageId: row.assistant_message_id,
301
+ status: row.status,
302
+ terminal: row.status !== 'in_progress',
303
+ modelAlias: row.model_alias,
304
+ sourceRevision: Number(row.source_revision),
305
+ sourceHash: row.source_hash,
306
+ deltaCount: Number(row.delta_count),
307
+ deltaBytes: Number(row.delta_bytes),
308
+ lastDeltaHash: row.last_delta_hash,
309
+ finalRevision: row.final_revision === null ? null : Number(row.final_revision),
310
+ finalHash: row.final_hash,
311
+ failureCode: row.failure_code,
312
+ deltasPruned: row.deltas_pruned === 1,
313
+ startedAt: row.started_at,
314
+ updatedAt: row.updated_at,
315
+ terminalAt: row.terminal_at,
316
+ prunedAt: row.pruned_at
317
+ };
318
+ }
319
+
320
+ function deltaView(row) {
321
+ if (!row) return null;
322
+ return {
323
+ accountId: row.account_id,
324
+ threadId: row.thread_id,
325
+ generationId: row.generation_id,
326
+ sequence: Number(row.sequence),
327
+ content: row.content,
328
+ contentBytes: Number(row.content_bytes),
329
+ previousHash: row.previous_hash,
330
+ deltaHash: row.delta_hash,
331
+ createdAt: row.created_at
332
+ };
333
+ }
334
+
335
+ function compactionView(row) {
336
+ if (!row) return null;
337
+ return {
338
+ accountId: row.account_id,
339
+ threadId: row.thread_id,
340
+ snapshotId: row.snapshot_id,
341
+ sourceStartRevision: Number(row.source_start_revision),
342
+ sourceStartHash: row.source_start_hash,
343
+ sourceEndRevision: Number(row.source_end_revision),
344
+ sourceEndHash: row.source_end_hash,
345
+ summaryText: row.summary_text,
346
+ summaryBytes: Number(row.summary_bytes),
347
+ summaryHash: row.summary_hash,
348
+ untrustedDirectChatData: true,
349
+ createdAt: row.created_at
350
+ };
351
+ }
352
+
353
+ function threadDeletionView(row) {
354
+ if (!row) return null;
355
+ return {
356
+ accountId: row.account_id,
357
+ threadId: row.thread_id,
358
+ deleted: true,
359
+ revision: Number(row.deleted_revision),
360
+ ledgerHash: row.deleted_hash,
361
+ deletedAt: row.deleted_at
362
+ };
363
+ }
364
+
365
+ function threadDeletionRequest(accountId, threadId, cursor) {
366
+ return {
367
+ accountId,
368
+ threadId,
369
+ expectedRevision: cursor.revision,
370
+ expectedHash: cursor.hash
371
+ };
372
+ }
373
+
374
+ function threadDeletionDigest(result) {
375
+ return {
376
+ accountId: result.accountId,
377
+ threadId: result.threadId,
378
+ deleted: result.deleted,
379
+ revision: result.revision,
380
+ ledgerHash: result.ledgerHash,
381
+ deletedAt: result.deletedAt
382
+ };
383
+ }
384
+
385
+ function dispatchLeaseView(row, timestamp) {
386
+ if (!row) return null;
387
+ const active = row.released_at === null && row.expires_at > timestamp;
388
+ return {
389
+ accountId: row.account_id,
390
+ threadId: row.thread_id,
391
+ generationId: row.generation_id,
392
+ fence: Number(row.fence),
393
+ phase: row.phase,
394
+ expiresAt: row.expires_at,
395
+ claimedAt: row.claimed_at,
396
+ dispatchStartedAt: row.dispatch_started_at,
397
+ updatedAt: row.updated_at,
398
+ releasedAt: row.released_at,
399
+ active,
400
+ dispatchMayHaveStarted: row.dispatch_started_at !== null,
401
+ dispatchAmbiguous: row.phase === 'interrupted' || (row.phase === 'dispatch_started' && !active)
402
+ };
403
+ }
404
+
405
+ function calculateMessageHash(row, attachments = []) {
406
+ return sha256(canonicalJson({
407
+ accountId: row.account_id,
408
+ threadId: row.thread_id,
409
+ messageId: row.message_id,
410
+ revision: Number(row.revision),
411
+ role: row.role,
412
+ content: row.content,
413
+ contentBytes: Number(row.content_bytes),
414
+ previousHash: row.previous_hash,
415
+ generationId: row.generation_id,
416
+ createdAt: row.created_at,
417
+ ...attachmentViewFields(attachments)
418
+ }));
419
+ }
420
+
421
+ function calculateDeltaHash(row) {
422
+ return sha256(canonicalJson({
423
+ accountId: row.account_id,
424
+ threadId: row.thread_id,
425
+ generationId: row.generation_id,
426
+ sequence: Number(row.sequence),
427
+ content: row.content,
428
+ contentBytes: Number(row.content_bytes),
429
+ previousHash: row.previous_hash,
430
+ createdAt: row.created_at
431
+ }));
432
+ }
433
+
434
+ function assertStoredTimestamp(value, label) {
435
+ try {
436
+ assertCanonicalIsoTimestamp(value, label);
437
+ } catch (error) {
438
+ throw new StorageCorruptionError(`Stored ${label} is invalid.`, { cause: error });
439
+ }
440
+ }
441
+
442
+ function assertStoredIdentifier(value, label) {
443
+ try {
444
+ assertIdentifier(value, label);
445
+ } catch (error) {
446
+ throw new StorageCorruptionError(`Stored ${label} is invalid.`, { cause: error });
447
+ }
448
+ }
449
+
450
+ function assertStoredText(value, label, limits) {
451
+ try {
452
+ return assertUnicodeScalarString(value, label, limits);
453
+ } catch (error) {
454
+ throw new StorageCorruptionError(`Stored ${label} is invalid.`, { cause: error });
455
+ }
456
+ }
457
+
458
+ function auditDispatchLease(row) {
459
+ assertStoredIdentifier(row.account_id, 'dispatch_lease.account_id');
460
+ assertStoredIdentifier(row.thread_id, 'dispatch_lease.thread_id');
461
+ assertStoredIdentifier(row.generation_id, 'dispatch_lease.generation_id');
462
+ if (!HASH_PATTERN.test(row.owner_hash)) {
463
+ throw new StorageCorruptionError('A stored generation dispatch lease owner digest is invalid.');
464
+ }
465
+ if (!Number.isSafeInteger(Number(row.fence)) || Number(row.fence) < 1) {
466
+ throw new StorageCorruptionError('A stored generation dispatch lease fence is invalid.');
467
+ }
468
+ if (!['claimed', 'dispatch_started', 'released', 'interrupted'].includes(row.phase)) {
469
+ throw new StorageCorruptionError('A stored generation dispatch lease phase is invalid.');
470
+ }
471
+ assertStoredTimestamp(row.expires_at, 'dispatch_lease.expires_at');
472
+ assertStoredTimestamp(row.claimed_at, 'dispatch_lease.claimed_at');
473
+ if (row.dispatch_started_at !== null) {
474
+ assertStoredTimestamp(row.dispatch_started_at, 'dispatch_lease.dispatch_started_at');
475
+ }
476
+ assertStoredTimestamp(row.updated_at, 'dispatch_lease.updated_at');
477
+ if (row.released_at !== null) {
478
+ assertStoredTimestamp(row.released_at, 'dispatch_lease.released_at');
479
+ }
480
+ if (
481
+ row.updated_at < row.claimed_at ||
482
+ (row.dispatch_started_at !== null && (
483
+ row.dispatch_started_at < row.claimed_at || row.dispatch_started_at > row.updated_at
484
+ )) ||
485
+ (['claimed', 'dispatch_started'].includes(row.phase) && (
486
+ row.released_at !== null || row.expires_at <= row.updated_at
487
+ )) ||
488
+ (['released', 'interrupted'].includes(row.phase) && (
489
+ row.released_at === null ||
490
+ row.expires_at !== row.released_at ||
491
+ row.updated_at !== row.released_at ||
492
+ row.released_at < row.claimed_at
493
+ )) ||
494
+ (row.phase === 'claimed' && row.dispatch_started_at !== null) ||
495
+ (['dispatch_started', 'interrupted'].includes(row.phase) && row.dispatch_started_at === null)
496
+ ) {
497
+ throw new StorageCorruptionError('A stored generation dispatch lease lifetime is inconsistent.');
498
+ }
499
+ }
500
+
501
+ function requireThread(database, accountId, threadId) {
502
+ const row = database.prepare(`
503
+ SELECT * FROM direct_chat_threads WHERE account_id = ? AND thread_id = ?
504
+ `).get(accountId, threadId);
505
+ if (!row) throw new NotFoundError();
506
+ return row;
507
+ }
508
+
509
+ function requireGeneration(database, accountId, threadId, generationId) {
510
+ const row = database.prepare(`
511
+ SELECT * FROM direct_chat_generations
512
+ WHERE account_id = ? AND thread_id = ? AND generation_id = ?
513
+ `).get(accountId, threadId, generationId);
514
+ if (!row) throw new NotFoundError();
515
+ return row;
516
+ }
517
+
518
+ function auditGeneration(database, generation) {
519
+ assertStoredIdentifier(generation.account_id, 'generation.account_id');
520
+ assertStoredIdentifier(generation.thread_id, 'generation.thread_id');
521
+ assertStoredIdentifier(generation.generation_id, 'generation.generation_id');
522
+ assertStoredIdentifier(generation.assistant_message_id, 'generation.assistant_message_id');
523
+ assertStoredTimestamp(generation.started_at, 'generation.started_at');
524
+ assertStoredTimestamp(generation.updated_at, 'generation.updated_at');
525
+ if (generation.terminal_at !== null) assertStoredTimestamp(generation.terminal_at, 'generation.terminal_at');
526
+ if (generation.pruned_at !== null) assertStoredTimestamp(generation.pruned_at, 'generation.pruned_at');
527
+
528
+ const deltas = database.prepare(`
529
+ SELECT * FROM direct_chat_deltas
530
+ WHERE account_id = ? AND thread_id = ? AND generation_id = ?
531
+ ORDER BY sequence
532
+ `).all(generation.account_id, generation.thread_id, generation.generation_id);
533
+ let previousHash = null;
534
+ let bytes = 0;
535
+ for (let index = 0; index < deltas.length; index += 1) {
536
+ const delta = deltas[index];
537
+ assertStoredIdentifier(delta.account_id, 'delta.account_id');
538
+ assertStoredIdentifier(delta.thread_id, 'delta.thread_id');
539
+ assertStoredIdentifier(delta.generation_id, 'delta.generation_id');
540
+ assertStoredText(delta.content, 'delta.content', { maxBytes: DIRECT_CHAT_LIMITS.deltaBytes });
541
+ const expectedSequence = index + 1;
542
+ if (
543
+ Number(delta.sequence) !== expectedSequence ||
544
+ delta.previous_hash !== previousHash ||
545
+ Number(delta.content_bytes) !== utf8Bytes(delta.content) ||
546
+ calculateDeltaHash(delta) !== delta.delta_hash
547
+ ) {
548
+ throw new StorageCorruptionError('A direct-chat generation delta hash chain is inconsistent.');
549
+ }
550
+ assertStoredTimestamp(delta.created_at, 'delta.created_at');
551
+ bytes += Number(delta.content_bytes);
552
+ previousHash = delta.delta_hash;
553
+ }
554
+ if (generation.deltas_pruned === 1) {
555
+ if (
556
+ generation.status !== 'completed' || generation.pruned_at === null || deltas.length !== 0 ||
557
+ Number(generation.delta_count) < 1 || Number(generation.delta_count) > DIRECT_CHAT_LIMITS.deltasPerGeneration ||
558
+ Number(generation.delta_bytes) < 1 || Number(generation.delta_bytes) > DIRECT_CHAT_LIMITS.generationBytes ||
559
+ !HASH_PATTERN.test(generation.last_delta_hash)
560
+ ) {
561
+ throw new StorageCorruptionError('A pruned direct-chat generation has invalid retained metadata.');
562
+ }
563
+ return deltas;
564
+ }
565
+ if (
566
+ deltas.length !== Number(generation.delta_count) ||
567
+ bytes !== Number(generation.delta_bytes) ||
568
+ previousHash !== generation.last_delta_hash ||
569
+ deltas.length > DIRECT_CHAT_LIMITS.deltasPerGeneration ||
570
+ bytes > DIRECT_CHAT_LIMITS.generationBytes
571
+ ) {
572
+ throw new StorageCorruptionError('A direct-chat generation journal is inconsistent.');
573
+ }
574
+ return deltas;
575
+ }
576
+
577
+ function auditThread(database, thread) {
578
+ assertStoredIdentifier(thread.account_id, 'thread.account_id');
579
+ assertStoredIdentifier(thread.thread_id, 'thread.thread_id');
580
+ assertStoredText(thread.title, 'thread.title', { minBytes: 0, maxBytes: 512 });
581
+ assertStoredTimestamp(thread.created_at, 'thread.created_at');
582
+ assertStoredTimestamp(thread.updated_at, 'thread.updated_at');
583
+ if (!MODEL_ALIAS_PATTERN.test(thread.model_alias)) {
584
+ throw new StorageCorruptionError('A stored direct-chat model alias is invalid.');
585
+ }
586
+ const messages = database.prepare(`
587
+ SELECT * FROM direct_chat_messages
588
+ WHERE account_id = ? AND thread_id = ?
589
+ ORDER BY revision
590
+ `).all(thread.account_id, thread.thread_id);
591
+ const metadata = DATABASE_METADATA.get(database);
592
+ const attachmentRows = metadata?.schemaVersion >= 3
593
+ ? database.prepare(`
594
+ SELECT account_id, thread_id, attachment_id, message_id, position, media_type,
595
+ byte_length, width, height, content_sha256, created_at,
596
+ length(content) AS content_length
597
+ FROM direct_chat_attachments
598
+ WHERE account_id = ? AND thread_id = ?
599
+ ORDER BY created_at, message_id, position
600
+ `).all(thread.account_id, thread.thread_id)
601
+ : [];
602
+ if (attachmentRows.length > VISION_ATTACHMENT_LIMITS.attachmentsPerThread) {
603
+ throw new StorageCorruptionError('A direct-chat thread exceeds its vision attachment count bound.');
604
+ }
605
+ const attachments = attachmentRows.map(attachmentDescriptorFromRow);
606
+ const attachmentByMessage = new Map();
607
+ for (const attachment of attachments) {
608
+ const owned = attachmentByMessage.get(attachment.messageId) ?? [];
609
+ if (attachment.position !== owned.length
610
+ || owned.length >= VISION_ATTACHMENT_LIMITS.attachmentsPerMessage) {
611
+ throw new StorageCorruptionError('A direct-chat message has inconsistent vision attachment positions.');
612
+ }
613
+ owned.push(attachment);
614
+ attachmentByMessage.set(attachment.messageId, owned);
615
+ }
616
+ const attachmentBytes = attachments.reduce((total, attachment) => total + attachment.byteLength, 0);
617
+ if (attachmentBytes > VISION_ATTACHMENT_LIMITS.bytesPerThread) {
618
+ throw new StorageCorruptionError('A direct-chat thread exceeds its vision attachment storage bound.');
619
+ }
620
+ for (const owned of attachmentByMessage.values()) {
621
+ if (owned.reduce((total, attachment) => total + attachment.byteLength, 0)
622
+ > VISION_ATTACHMENT_LIMITS.bytesPerMessage) {
623
+ throw new StorageCorruptionError('A direct-chat message exceeds its vision attachment storage bound.');
624
+ }
625
+ }
626
+ const messageRevisionById = new Map(messages.map((message) => [message.message_id, Number(message.revision)]));
627
+ const firstVisionRevision = attachments.reduce((minimum, attachment) => {
628
+ const revision = messageRevisionById.get(attachment.messageId);
629
+ if (revision === undefined) {
630
+ throw new StorageCorruptionError('A direct-chat vision attachment has no ledger message.');
631
+ }
632
+ return Math.min(minimum, revision);
633
+ }, Number.POSITIVE_INFINITY);
634
+ let previousHash = null;
635
+ let bytes = 0;
636
+ for (let index = 0; index < messages.length; index += 1) {
637
+ const message = messages[index];
638
+ assertStoredIdentifier(message.account_id, 'message.account_id');
639
+ assertStoredIdentifier(message.thread_id, 'message.thread_id');
640
+ assertStoredIdentifier(message.message_id, 'message.message_id');
641
+ if (message.generation_id !== null) {
642
+ assertStoredIdentifier(message.generation_id, 'message.generation_id');
643
+ }
644
+ const messageAttachments = attachmentByMessage.get(message.message_id) ?? [];
645
+ if (messageAttachments.length > 0 && message.role !== 'user') {
646
+ throw new StorageCorruptionError('A direct-chat vision attachment is not bound to a user message.');
647
+ }
648
+ assertStoredText(message.content, 'message.content', { maxBytes: DIRECT_CHAT_LIMITS.messageBytes });
649
+ const expectedRevision = index + 1;
650
+ if (
651
+ Number(message.revision) !== expectedRevision ||
652
+ message.previous_hash !== previousHash ||
653
+ Number(message.content_bytes) !== utf8Bytes(message.content) ||
654
+ calculateMessageHash(message, messageAttachments) !== message.message_hash
655
+ ) {
656
+ throw new StorageCorruptionError('A direct-chat message ledger hash chain is inconsistent.');
657
+ }
658
+ assertStoredTimestamp(message.created_at, 'message.created_at');
659
+ bytes += Number(message.content_bytes);
660
+ previousHash = message.message_hash;
661
+ }
662
+ if (
663
+ messages.length !== Number(thread.message_count) ||
664
+ messages.length !== Number(thread.ledger_revision) ||
665
+ bytes !== Number(thread.ledger_bytes) ||
666
+ previousHash !== thread.ledger_hash ||
667
+ messages.length > DIRECT_CHAT_LIMITS.messagesPerThread ||
668
+ bytes > DIRECT_CHAT_LIMITS.ledgerBytesPerThread
669
+ ) {
670
+ throw new StorageCorruptionError('A direct-chat thread ledger is inconsistent.');
671
+ }
672
+
673
+ const generations = database.prepare(`
674
+ SELECT * FROM direct_chat_generations
675
+ WHERE account_id = ? AND thread_id = ?
676
+ ORDER BY started_at, generation_id
677
+ `).all(thread.account_id, thread.thread_id);
678
+ let activeId = null;
679
+ let totalDeltas = 0;
680
+ let totalJournalBytes = 0;
681
+ const completedGenerationIds = new Set();
682
+ for (const generation of generations) {
683
+ const source = messages[Number(generation.source_revision) - 1];
684
+ const hasVision = firstVisionRevision <= Number(generation.source_revision);
685
+ const expectedModelAlias = hasVision ? metadata.visionModelAlias : thread.model_alias;
686
+ if (
687
+ !source || source.message_hash !== generation.source_hash || source.role !== 'user' ||
688
+ generation.model_alias !== expectedModelAlias
689
+ ) {
690
+ throw new StorageCorruptionError('A direct-chat generation does not reference an exact user-ledger state.');
691
+ }
692
+ const deltas = auditGeneration(database, generation);
693
+ if (generation.deltas_pruned === 0) {
694
+ totalDeltas += deltas.length;
695
+ totalJournalBytes += Number(generation.delta_bytes);
696
+ }
697
+ if (generation.status === 'in_progress') {
698
+ if (activeId !== null) throw new StorageCorruptionError('A thread has multiple active generations.');
699
+ activeId = generation.generation_id;
700
+ }
701
+ if (generation.status === 'completed') {
702
+ const finalMessage = messages[Number(generation.final_revision) - 1];
703
+ if (
704
+ Number(generation.final_revision) !== Number(generation.source_revision) + 1 ||
705
+ !finalMessage ||
706
+ finalMessage.role !== 'assistant' ||
707
+ finalMessage.generation_id !== generation.generation_id ||
708
+ finalMessage.message_id !== generation.assistant_message_id ||
709
+ finalMessage.message_hash !== generation.final_hash ||
710
+ (generation.deltas_pruned === 0 && finalMessage.content !== deltas.map((delta) => delta.content).join(''))
711
+ ) {
712
+ throw new StorageCorruptionError('A completed direct-chat generation has no exact assistant-ledger turn.');
713
+ }
714
+ completedGenerationIds.add(generation.generation_id);
715
+ }
716
+ }
717
+ for (const message of messages) {
718
+ if (message.role === 'assistant' && !completedGenerationIds.has(message.generation_id)) {
719
+ throw new StorageCorruptionError('An assistant-ledger turn has no completed direct-chat generation.');
720
+ }
721
+ }
722
+ if (
723
+ generations.length !== Number(thread.generation_count) ||
724
+ generations.length > DIRECT_CHAT_LIMITS.generationsPerThread ||
725
+ totalDeltas !== Number(thread.journal_delta_count) ||
726
+ totalDeltas > DIRECT_CHAT_LIMITS.deltasPerThread ||
727
+ totalJournalBytes !== Number(thread.journal_bytes) ||
728
+ totalJournalBytes > DIRECT_CHAT_LIMITS.journalBytesPerThread ||
729
+ activeId !== thread.current_generation_id
730
+ ) {
731
+ throw new StorageCorruptionError('A direct-chat thread generation index is inconsistent.');
732
+ }
733
+
734
+ const generationById = new Map(
735
+ generations.map((generation) => [generation.generation_id, generation])
736
+ );
737
+ const dispatchLeases = database.prepare(`
738
+ SELECT * FROM direct_chat_generation_leases
739
+ WHERE account_id = ? AND thread_id = ?
740
+ ORDER BY generation_id
741
+ `).all(thread.account_id, thread.thread_id);
742
+ if (dispatchLeases.length > generations.length) {
743
+ throw new StorageCorruptionError('A thread has more generation dispatch leases than generations.');
744
+ }
745
+ for (const lease of dispatchLeases) {
746
+ auditDispatchLease(lease);
747
+ const generation = generationById.get(lease.generation_id);
748
+ if (!generation) {
749
+ throw new StorageCorruptionError('A generation dispatch lease has no owned generation.');
750
+ }
751
+ if (
752
+ lease.released_at === null &&
753
+ (generation.status !== 'in_progress' || thread.current_generation_id !== lease.generation_id)
754
+ ) {
755
+ throw new StorageCorruptionError('An unreleased dispatch lease is not bound to the active generation.');
756
+ }
757
+ }
758
+
759
+ const compactions = database.prepare(`
760
+ SELECT * FROM direct_chat_compactions
761
+ WHERE account_id = ? AND thread_id = ?
762
+ ORDER BY source_end_revision, created_at, snapshot_id
763
+ `).all(thread.account_id, thread.thread_id);
764
+ if (compactions.length > DIRECT_CHAT_LIMITS.compactionsPerThread) {
765
+ throw new StorageCorruptionError('A direct-chat thread exceeds the compaction snapshot bound.');
766
+ }
767
+ for (const snapshot of compactions) {
768
+ assertStoredIdentifier(snapshot.account_id, 'compaction.account_id');
769
+ assertStoredIdentifier(snapshot.thread_id, 'compaction.thread_id');
770
+ assertStoredIdentifier(snapshot.snapshot_id, 'compaction.snapshot_id');
771
+ assertStoredText(snapshot.summary_text, 'compaction.summary_text', {
772
+ maxBytes: DIRECT_CHAT_LIMITS.summaryBytes
773
+ });
774
+ const first = messages[Number(snapshot.source_start_revision) - 1];
775
+ const last = messages[Number(snapshot.source_end_revision) - 1];
776
+ if (
777
+ !first || !last ||
778
+ first.message_hash !== snapshot.source_start_hash ||
779
+ last.message_hash !== snapshot.source_end_hash ||
780
+ Number(snapshot.summary_bytes) !== utf8Bytes(snapshot.summary_text) ||
781
+ snapshot.summary_hash !== sha256(snapshot.summary_text)
782
+ ) {
783
+ throw new StorageCorruptionError('A direct-chat compaction snapshot references an invalid ledger range.');
784
+ }
785
+ assertStoredTimestamp(snapshot.created_at, 'compaction.created_at');
786
+ }
787
+ return { messages, generations, attachments, attachmentBytes };
788
+ }
789
+
790
+ function auditDatabase(database) {
791
+ const threads = database.prepare(`
792
+ SELECT * FROM direct_chat_threads ORDER BY account_id, thread_id
793
+ `).all();
794
+ const counts = new Map();
795
+ const attachmentBytesByAccount = new Map();
796
+ for (const thread of threads) {
797
+ const count = (counts.get(thread.account_id) ?? 0) + 1;
798
+ if (count > DIRECT_CHAT_LIMITS.threadsPerAccount) {
799
+ throw new StorageCorruptionError('An account exceeds the direct-chat thread bound.');
800
+ }
801
+ counts.set(thread.account_id, count);
802
+ const audit = auditThread(database, thread);
803
+ attachmentBytesByAccount.set(
804
+ thread.account_id,
805
+ (attachmentBytesByAccount.get(thread.account_id) ?? 0) + audit.attachmentBytes
806
+ );
807
+ if (attachmentBytesByAccount.get(thread.account_id) > VISION_ATTACHMENT_LIMITS.bytesPerAccount) {
808
+ throw new StorageCorruptionError('An account exceeds its vision attachment storage bound.');
809
+ }
810
+ if ((DATABASE_METADATA.get(database)?.schemaVersion ?? 0) >= 3) {
811
+ for (const descriptor of audit.attachments) {
812
+ attachmentFromRow(database.prepare(`
813
+ SELECT * FROM direct_chat_attachments
814
+ WHERE account_id = ? AND thread_id = ? AND attachment_id = ?
815
+ `).get(thread.account_id, thread.thread_id, descriptor.attachmentId));
816
+ }
817
+ }
818
+ }
819
+ const receiptOverflows = database.prepare(`
820
+ SELECT account_id, count(*) AS count
821
+ FROM direct_chat_idempotency
822
+ GROUP BY account_id
823
+ HAVING count(*) > ?
824
+ `).all(DIRECT_CHAT_LIMITS.idempotencyReceiptsPerAccount);
825
+ if (receiptOverflows.length !== 0) {
826
+ throw new StorageCorruptionError('An account exceeds the direct-chat idempotency receipt bound.');
827
+ }
828
+ const receipts = database.prepare(`
829
+ SELECT account_id, thread_id, resource_id, created_at, expires_at
830
+ FROM direct_chat_idempotency
831
+ `).all();
832
+ for (const receipt of receipts) {
833
+ assertStoredIdentifier(receipt.account_id, 'idempotency.account_id');
834
+ assertStoredIdentifier(receipt.thread_id, 'idempotency.thread_id');
835
+ assertStoredIdentifier(receipt.resource_id, 'idempotency.resource_id');
836
+ assertStoredTimestamp(receipt.created_at, 'idempotency.created_at');
837
+ assertStoredTimestamp(receipt.expires_at, 'idempotency.expires_at');
838
+ if (receipt.expires_at <= receipt.created_at) {
839
+ throw new StorageCorruptionError('A direct-chat idempotency receipt has an invalid lifetime.');
840
+ }
841
+ }
842
+ const deletionReceiptOverflows = database.prepare(`
843
+ SELECT account_id, count(*) AS count
844
+ FROM direct_chat_thread_deletions
845
+ GROUP BY account_id
846
+ HAVING count(*) > ?
847
+ `).all(DIRECT_CHAT_LIMITS.threadDeletionReceiptsPerAccount);
848
+ if (deletionReceiptOverflows.length !== 0) {
849
+ throw new StorageCorruptionError('An account exceeds the direct-chat thread deletion receipt bound.');
850
+ }
851
+ const deletionReceipts = database.prepare(`
852
+ SELECT * FROM direct_chat_thread_deletions
853
+ ORDER BY account_id, thread_id
854
+ `).all();
855
+ const liveDeletedThread = database.prepare(`
856
+ SELECT deletion.account_id, deletion.thread_id
857
+ FROM direct_chat_thread_deletions AS deletion
858
+ INNER JOIN direct_chat_threads AS thread
859
+ ON thread.account_id = deletion.account_id AND thread.thread_id = deletion.thread_id
860
+ LIMIT 1
861
+ `).get();
862
+ if (liveDeletedThread) {
863
+ throw new StorageCorruptionError('A deleted direct-chat thread still has live storage.');
864
+ }
865
+ for (const receipt of deletionReceipts) {
866
+ assertStoredIdentifier(receipt.account_id, 'thread_deletion.account_id');
867
+ assertStoredIdentifier(receipt.thread_id, 'thread_deletion.thread_id');
868
+ assertStoredTimestamp(receipt.deleted_at, 'thread_deletion.deleted_at');
869
+ const revision = Number(receipt.deleted_revision);
870
+ if (!Number.isSafeInteger(revision) || revision < 0 || revision > DIRECT_CHAT_LIMITS.messagesPerThread
871
+ || (revision === 0) !== (receipt.deleted_hash === null)
872
+ || (revision > 0 && !HASH_PATTERN.test(receipt.deleted_hash))
873
+ || !HASH_PATTERN.test(receipt.key_hash)
874
+ || !HASH_PATTERN.test(receipt.request_hash)
875
+ || !HASH_PATTERN.test(receipt.result_digest)) {
876
+ throw new StorageCorruptionError('A direct-chat thread deletion receipt is invalid.');
877
+ }
878
+ const cursor = { revision, hash: receipt.deleted_hash };
879
+ const request = threadDeletionRequest(receipt.account_id, receipt.thread_id, cursor);
880
+ const result = threadDeletionView(receipt);
881
+ if (sha256(canonicalJson(request)) !== receipt.request_hash
882
+ || sha256(canonicalJson(threadDeletionDigest(result))) !== receipt.result_digest) {
883
+ throw new StorageCorruptionError('A direct-chat thread deletion receipt digest is inconsistent.');
884
+ }
885
+ }
886
+ }
887
+
888
+ function assertCursor(revision, hash, prefix = 'expected') {
889
+ const normalizedRevision = assertInteger(revision, `${prefix}Revision`, { min: 0 });
890
+ const normalizedHash = assertEventHash(hash, normalizedRevision, `${prefix}Hash`);
891
+ return { revision: normalizedRevision, hash: normalizedHash };
892
+ }
893
+
894
+ function assertCursorMatches(thread, cursor) {
895
+ if (Number(thread.ledger_revision) !== cursor.revision || thread.ledger_hash !== cursor.hash) {
896
+ throw new ConflictError('The direct-chat ledger cursor is stale.');
897
+ }
898
+ }
899
+
900
+ function assertTurnAttachment(value) {
901
+ assertExactKeys(
902
+ value,
903
+ {
904
+ required: [
905
+ 'attachmentId', 'mediaType', 'byteLength', 'width', 'height',
906
+ 'contentSha256', 'content'
907
+ ]
908
+ },
909
+ 'direct chat turn attachment'
910
+ );
911
+ return validateStoredVisionAttachment(value);
912
+ }
913
+
914
+ function assertTurnAttachments(value) {
915
+ if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype
916
+ || value.length < 1 || value.length > VISION_ATTACHMENT_LIMITS.attachmentsPerMessage) {
917
+ throw new ValidationError('Direct Chat attachments must be a bounded non-empty array.');
918
+ }
919
+ const attachments = [];
920
+ const identifiers = new Set();
921
+ let bytes = 0;
922
+ for (let index = 0; index < value.length; index += 1) {
923
+ if (!Object.hasOwn(value, index)) throw new ValidationError('Direct Chat attachments must be dense.');
924
+ const attachment = assertTurnAttachment(value[index]);
925
+ if (identifiers.has(attachment.attachmentId)) {
926
+ throw new ValidationError('Direct Chat attachment identifiers must be unique.');
927
+ }
928
+ identifiers.add(attachment.attachmentId);
929
+ bytes += attachment.byteLength;
930
+ if (bytes > VISION_ATTACHMENT_LIMITS.bytesPerMessage) {
931
+ throw new ValidationError('Direct Chat attachments exceed the per-message byte limit.');
932
+ }
933
+ attachments.push(attachment);
934
+ }
935
+ const keys = Reflect.ownKeys(value);
936
+ if (keys.some((key) => key !== 'length'
937
+ && (typeof key !== 'string' || !/^(0|[1-9]\d*)$/u.test(key)
938
+ || Number(key) >= value.length))) {
939
+ throw new ValidationError('Direct Chat attachments contain an unsupported property.');
940
+ }
941
+ return Object.freeze(attachments);
942
+ }
943
+
944
+ function statusVersion(generation) {
945
+ const deltaCount = Number(generation.delta_count ?? generation.deltaCount);
946
+ const finalRevision = Number(generation.final_revision ?? generation.finalRevision);
947
+ if (generation.status === 'completed') return finalRevision;
948
+ return deltaCount;
949
+ }
950
+
951
+ function terminalGenerationDigestInput(result) {
952
+ return {
953
+ accountId: result.accountId,
954
+ threadId: result.threadId,
955
+ generationId: result.generationId,
956
+ assistantMessageId: result.assistantMessageId,
957
+ status: result.status,
958
+ modelAlias: result.modelAlias,
959
+ sourceRevision: result.sourceRevision,
960
+ sourceHash: result.sourceHash,
961
+ deltaCount: result.deltaCount,
962
+ deltaBytes: result.deltaBytes,
963
+ lastDeltaHash: result.lastDeltaHash,
964
+ finalRevision: result.finalRevision,
965
+ finalHash: result.finalHash,
966
+ failureCode: result.failureCode,
967
+ terminalAt: result.terminalAt
968
+ };
969
+ }
970
+
971
+ export class DirectChatStore {
972
+ #auditedThreads = new Map();
973
+ #auditDataVersion = null;
974
+ #clock;
975
+ #closed = false;
976
+ #database;
977
+ #databasePath;
978
+ #enableVisionAttachments;
979
+ #modelAlias;
980
+ #readTransactionActive = false;
981
+ #readTransactionDataVersion = null;
982
+ #schemaVersion;
983
+ #visionModelAlias;
984
+ #writeTransactionActive = false;
985
+
986
+ constructor({
987
+ databasePath,
988
+ modelAlias = 'local-default',
989
+ visionModelAlias = VISION_MODEL_ALIAS,
990
+ enableVisionAttachments = false,
991
+ clock = DEFAULT_CLOCK
992
+ } = {}) {
993
+ if (typeof clock !== 'function') throw new ValidationError('clock must be a function.');
994
+ if (typeof enableVisionAttachments !== 'boolean') {
995
+ throw new ValidationError('enableVisionAttachments must be boolean.');
996
+ }
997
+ this.#clock = clock;
998
+ this.#modelAlias = assertModelAlias(modelAlias);
999
+ this.#visionModelAlias = assertModelAlias(visionModelAlias);
1000
+ this.#enableVisionAttachments = enableVisionAttachments;
1001
+ this.#databasePath = prepareSecureDatabasePath(databasePath);
1002
+
1003
+ let database;
1004
+ try {
1005
+ database = new DatabaseSync(this.#databasePath);
1006
+ } catch (error) {
1007
+ throw new StorageCorruptionError('SQLite could not open the direct-chat database.', { cause: error });
1008
+ }
1009
+ try {
1010
+ database.enableLoadExtension(false);
1011
+ database.exec(`
1012
+ PRAGMA busy_timeout = 5000;
1013
+ PRAGMA foreign_keys = ON;
1014
+ PRAGMA journal_mode = DELETE;
1015
+ PRAGMA synchronous = FULL;
1016
+ PRAGMA temp_store = MEMORY;
1017
+ PRAGMA trusted_schema = OFF;
1018
+ PRAGMA secure_delete = ON;
1019
+ `);
1020
+ this.#schemaVersion = applyChatMigrations(database, nowIso(this.#clock), {
1021
+ enableVisionAttachments
1022
+ });
1023
+ DATABASE_METADATA.set(database, Object.freeze({
1024
+ schemaVersion: this.#schemaVersion,
1025
+ visionModelAlias: this.#visionModelAlias
1026
+ }));
1027
+ database.exec('BEGIN');
1028
+ try {
1029
+ auditDatabase(database);
1030
+ database.exec('COMMIT');
1031
+ } catch (error) {
1032
+ try {
1033
+ database.exec('ROLLBACK');
1034
+ } catch {
1035
+ // Preserve the audit failure.
1036
+ }
1037
+ throw error;
1038
+ }
1039
+ assertSecureDatabaseFile(this.#databasePath);
1040
+ } catch (error) {
1041
+ try {
1042
+ database.close();
1043
+ } catch {
1044
+ // Preserve the initialization error.
1045
+ }
1046
+ if (
1047
+ error instanceof StorageCorruptionError ||
1048
+ error?.code === 'unsupported_schema' ||
1049
+ error?.code === 'storage_security_error' ||
1050
+ error?.code === 'invalid_input'
1051
+ ) {
1052
+ throw error;
1053
+ }
1054
+ throw new StorageCorruptionError('The direct-chat database could not be initialized safely.', { cause: error });
1055
+ }
1056
+ this.#database = database;
1057
+ }
1058
+
1059
+ #assertOpen() {
1060
+ if (this.#closed) throw new StorageCorruptionError('The direct-chat database is closed.');
1061
+ }
1062
+
1063
+ #clearAuditedThreads() {
1064
+ this.#auditedThreads.clear();
1065
+ this.#auditDataVersion = null;
1066
+ }
1067
+
1068
+ #dataVersion() {
1069
+ const value = Number(this.#database.prepare('PRAGMA data_version').get()?.data_version);
1070
+ if (!Number.isSafeInteger(value) || value < 1) {
1071
+ this.#clearAuditedThreads();
1072
+ throw new StorageCorruptionError('SQLite returned an invalid direct-chat data version.');
1073
+ }
1074
+ return value;
1075
+ }
1076
+
1077
+ #auditThread(thread) {
1078
+ // Write transactions deliberately bypass the cache. A transaction can
1079
+ // touch several integrity-linked rows without advancing data_version on
1080
+ // its own connection, so both its precondition and postcondition audits
1081
+ // must inspect the actual transactional snapshot.
1082
+ if (this.#writeTransactionActive) return auditThread(this.#database, thread);
1083
+
1084
+ const dataVersion = this.#readTransactionDataVersion ?? this.#dataVersion();
1085
+ if (this.#readTransactionActive && this.#readTransactionDataVersion === null) {
1086
+ // The owning lookup already pinned this read transaction's snapshot.
1087
+ // Reuse one data_version guard for every thread in the same list read.
1088
+ this.#readTransactionDataVersion = dataVersion;
1089
+ }
1090
+ if (this.#auditDataVersion !== dataVersion) {
1091
+ this.#auditedThreads.clear();
1092
+ this.#auditDataVersion = dataVersion;
1093
+ }
1094
+ assertStoredIdentifier(thread.account_id, 'thread.account_id');
1095
+ assertStoredIdentifier(thread.thread_id, 'thread.thread_id');
1096
+ const identity = canonicalJson([thread.account_id, thread.thread_id]);
1097
+ if (this.#auditedThreads.has(identity)) {
1098
+ // Refresh insertion order so the bounded map behaves as a small LRU.
1099
+ this.#auditedThreads.delete(identity);
1100
+ this.#auditedThreads.set(identity, dataVersion);
1101
+ return null;
1102
+ }
1103
+
1104
+ let result;
1105
+ try {
1106
+ result = auditThread(this.#database, thread);
1107
+ } catch (error) {
1108
+ this.#auditedThreads.delete(identity);
1109
+ throw error;
1110
+ }
1111
+ // A read transaction validates its version again after COMMIT. Keep the
1112
+ // local second check for any future caller that audits outside one.
1113
+ if (!this.#readTransactionActive && this.#dataVersion() !== dataVersion) {
1114
+ this.#clearAuditedThreads();
1115
+ return result;
1116
+ }
1117
+ this.#auditedThreads.set(identity, dataVersion);
1118
+ if (this.#auditedThreads.size > MAX_AUDITED_THREAD_CACHE_ENTRIES) {
1119
+ this.#auditedThreads.delete(this.#auditedThreads.keys().next().value);
1120
+ }
1121
+ return result;
1122
+ }
1123
+
1124
+ #transaction(callback) {
1125
+ this.#assertOpen();
1126
+ // SQLite data_version changes only for commits made by other connections.
1127
+ // Clear around every local write transaction and never cache its audits.
1128
+ this.#clearAuditedThreads();
1129
+ this.#writeTransactionActive = true;
1130
+ try {
1131
+ try {
1132
+ this.#database.exec('BEGIN IMMEDIATE');
1133
+ const result = callback();
1134
+ this.#database.exec('COMMIT');
1135
+ return result;
1136
+ } catch (error) {
1137
+ try {
1138
+ this.#database.exec('ROLLBACK');
1139
+ } catch {
1140
+ // Preserve the mutation error.
1141
+ }
1142
+ throw error;
1143
+ }
1144
+ } finally {
1145
+ this.#writeTransactionActive = false;
1146
+ this.#clearAuditedThreads();
1147
+ }
1148
+ }
1149
+
1150
+ #readTransaction(callback) {
1151
+ this.#assertOpen();
1152
+ this.#readTransactionActive = true;
1153
+ this.#readTransactionDataVersion = null;
1154
+ try {
1155
+ try {
1156
+ this.#database.exec('BEGIN');
1157
+ const result = callback();
1158
+ this.#database.exec('COMMIT');
1159
+ if (this.#readTransactionDataVersion !== null
1160
+ && this.#dataVersion() !== this.#readTransactionDataVersion) {
1161
+ // An external WAL-capable connection may commit while a read
1162
+ // snapshot is pinned. The result was coherent, but it must not seed
1163
+ // cache state for the newer database version.
1164
+ this.#clearAuditedThreads();
1165
+ }
1166
+ return result;
1167
+ } catch (error) {
1168
+ try {
1169
+ this.#database.exec('ROLLBACK');
1170
+ } catch {
1171
+ // Preserve the read or validation failure.
1172
+ }
1173
+ this.#clearAuditedThreads();
1174
+ throw error;
1175
+ }
1176
+ } finally {
1177
+ this.#readTransactionDataVersion = null;
1178
+ this.#readTransactionActive = false;
1179
+ }
1180
+ }
1181
+
1182
+ #deleteExpiredReceipts(timestamp, limit = DIRECT_CHAT_LIMITS.cleanupRows, accountId = null) {
1183
+ const result = this.#database.prepare(`
1184
+ DELETE FROM direct_chat_idempotency
1185
+ WHERE rowid IN (
1186
+ SELECT rowid FROM direct_chat_idempotency
1187
+ WHERE expires_at <= ? AND (? IS NULL OR account_id = ?)
1188
+ ORDER BY expires_at, rowid
1189
+ LIMIT ?
1190
+ )
1191
+ `).run(timestamp, accountId, accountId, limit);
1192
+ return Number(result.changes);
1193
+ }
1194
+
1195
+ #generationLease(accountId, threadId, generationId) {
1196
+ return this.#database.prepare(`
1197
+ SELECT * FROM direct_chat_generation_leases
1198
+ WHERE account_id = ? AND thread_id = ? AND generation_id = ?
1199
+ `).get(accountId, threadId, generationId) ?? null;
1200
+ }
1201
+
1202
+ #reconcileExpiredGenerationDispatches(timestamp) {
1203
+ // A one-way dispatch marker is never replayable. Releasing an expired
1204
+ // global admission therefore also records the owning generation as
1205
+ // interrupted and advances its fence before another inference may start.
1206
+ this.#database.prepare(`
1207
+ UPDATE direct_chat_generation_leases
1208
+ SET fence = fence + 1, phase = 'interrupted', expires_at = ?,
1209
+ updated_at = ?, released_at = ?
1210
+ WHERE phase = 'dispatch_started' AND released_at IS NULL
1211
+ AND expires_at <= ? AND fence < ?
1212
+ `).run(timestamp, timestamp, timestamp, timestamp, MAX_DISPATCH_LEASE_FENCE);
1213
+ const exhausted = this.#database.prepare(`
1214
+ SELECT 1 AS present FROM direct_chat_generation_leases
1215
+ WHERE phase = 'dispatch_started' AND released_at IS NULL
1216
+ AND expires_at <= ? AND fence >= ?
1217
+ LIMIT 1
1218
+ `).get(timestamp, MAX_DISPATCH_LEASE_FENCE);
1219
+ if (exhausted) throw new ConflictError('An expired generation dispatch fence is exhausted.');
1220
+ }
1221
+
1222
+ #requireGenerationLease(
1223
+ accountId,
1224
+ threadId,
1225
+ generationId,
1226
+ proof,
1227
+ timestamp,
1228
+ allowedPhases = ['dispatch_started']
1229
+ ) {
1230
+ const lease = this.#generationLease(accountId, threadId, generationId);
1231
+ if (!lease) {
1232
+ if (proof !== null) {
1233
+ throw new ConflictError('No dispatch lease is registered for this generation.');
1234
+ }
1235
+ return null;
1236
+ }
1237
+ auditDispatchLease(lease);
1238
+ if (
1239
+ proof === null ||
1240
+ lease.owner_hash !== proof.ownerHash ||
1241
+ Number(lease.fence) !== proof.fence ||
1242
+ lease.released_at !== null ||
1243
+ lease.expires_at <= timestamp ||
1244
+ !allowedPhases.includes(lease.phase)
1245
+ ) {
1246
+ throw new ConflictError(
1247
+ 'The generation dispatch lease is missing, unstarted, expired, released, interrupted, or fenced out.'
1248
+ );
1249
+ }
1250
+ return lease;
1251
+ }
1252
+
1253
+ #invalidateGenerationLease(
1254
+ accountId,
1255
+ threadId,
1256
+ generationId,
1257
+ timestamp,
1258
+ targetPhase = 'released'
1259
+ ) {
1260
+ if (!['released', 'interrupted'].includes(targetPhase)) {
1261
+ throw new StorageCorruptionError('The generation dispatch lease terminal phase is invalid.');
1262
+ }
1263
+ const lease = this.#generationLease(accountId, threadId, generationId);
1264
+ if (!lease) return null;
1265
+ auditDispatchLease(lease);
1266
+ if (lease.released_at !== null) return lease;
1267
+ const releasedAt = timestamp < lease.updated_at ? lease.updated_at : timestamp;
1268
+ const updated = this.#database.prepare(`
1269
+ UPDATE direct_chat_generation_leases
1270
+ SET phase = ?, expires_at = ?, updated_at = ?, released_at = ?
1271
+ WHERE account_id = ? AND thread_id = ? AND generation_id = ?
1272
+ AND fence = ? AND owner_hash = ? AND released_at IS NULL
1273
+ `).run(
1274
+ targetPhase,
1275
+ releasedAt,
1276
+ releasedAt,
1277
+ releasedAt,
1278
+ accountId,
1279
+ threadId,
1280
+ generationId,
1281
+ lease.fence,
1282
+ lease.owner_hash
1283
+ );
1284
+ if (Number(updated.changes) !== 1) {
1285
+ throw new ConflictError('The generation dispatch lease changed concurrently.');
1286
+ }
1287
+ return this.#generationLease(accountId, threadId, generationId);
1288
+ }
1289
+
1290
+ #idempotentMutation(meta, mutation, replay) {
1291
+ const accountId = assertIdentifier(meta.accountId, 'accountId');
1292
+ const threadId = assertIdentifier(meta.threadId, 'threadId');
1293
+ assertIdempotencyKey(meta.idempotencyKey);
1294
+ const keyHash = sha256(meta.idempotencyKey);
1295
+ const requestHash = sha256(canonicalJson(meta.request));
1296
+ const timestamp = nowIso(this.#clock);
1297
+ const expiresAt = addMilliseconds(timestamp, DIRECT_CHAT_IDEMPOTENCY_TTL_MS);
1298
+
1299
+ return this.#transaction(() => {
1300
+ this.#deleteExpiredReceipts(timestamp);
1301
+ const existing = this.#database.prepare(`
1302
+ SELECT * FROM direct_chat_idempotency
1303
+ WHERE account_id = ? AND operation = ? AND key_hash = ?
1304
+ `).get(accountId, meta.operation, keyHash);
1305
+ if (existing) {
1306
+ if (existing.request_hash !== requestHash) throw new IdempotencyConflictError();
1307
+ if (
1308
+ existing.resource_kind !== meta.resourceKind ||
1309
+ existing.thread_id !== threadId ||
1310
+ existing.resource_id !== meta.resourceId ||
1311
+ !HASH_PATTERN.test(existing.result_digest)
1312
+ ) {
1313
+ throw new StorageCorruptionError('A direct-chat idempotency receipt failed closed-schema validation.');
1314
+ }
1315
+ const result = replay(existing);
1316
+ const digestInput = meta.digestOf === undefined ? result : meta.digestOf(result);
1317
+ if (sha256(canonicalJson(digestInput)) !== existing.result_digest) {
1318
+ throw new ConflictError('The original direct-chat mutation result can no longer be replayed exactly.');
1319
+ }
1320
+ return result;
1321
+ }
1322
+
1323
+ const result = mutation();
1324
+ const version = meta.versionOf(result);
1325
+ assertInteger(version, 'resourceVersion', { min: 0 });
1326
+ this.#database.prepare(`
1327
+ INSERT INTO direct_chat_idempotency(
1328
+ account_id, operation, key_hash, request_hash, resource_kind,
1329
+ thread_id, resource_id, resource_version, result_digest, created_at, expires_at
1330
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1331
+ `).run(
1332
+ accountId,
1333
+ meta.operation,
1334
+ keyHash,
1335
+ requestHash,
1336
+ meta.resourceKind,
1337
+ threadId,
1338
+ meta.resourceId,
1339
+ version,
1340
+ sha256(canonicalJson(meta.digestOf === undefined ? result : meta.digestOf(result))),
1341
+ timestamp,
1342
+ expiresAt
1343
+ );
1344
+ this.#database.prepare(`
1345
+ DELETE FROM direct_chat_idempotency
1346
+ WHERE rowid IN (
1347
+ SELECT rowid FROM direct_chat_idempotency
1348
+ WHERE account_id = ?
1349
+ ORDER BY created_at DESC, rowid DESC
1350
+ LIMIT -1 OFFSET ?
1351
+ )
1352
+ `).run(accountId, DIRECT_CHAT_LIMITS.idempotencyReceiptsPerAccount);
1353
+ return result;
1354
+ });
1355
+ }
1356
+
1357
+ createThread(input) {
1358
+ assertExactKeys(
1359
+ input,
1360
+ { required: ['accountId', 'threadId', 'idempotencyKey'], optional: ['title'] },
1361
+ 'direct chat thread'
1362
+ );
1363
+ const accountId = assertIdentifier(input.accountId, 'accountId');
1364
+ const threadId = assertIdentifier(input.threadId, 'threadId');
1365
+ const title = assertUnicodeScalarString(input.title ?? '', 'title', { minBytes: 0, maxBytes: 512 });
1366
+ const request = { accountId, threadId, title: title.value };
1367
+
1368
+ return this.#idempotentMutation(
1369
+ {
1370
+ accountId,
1371
+ threadId,
1372
+ operation: 'thread.create',
1373
+ idempotencyKey: input.idempotencyKey,
1374
+ request,
1375
+ resourceKind: 'thread',
1376
+ resourceId: threadId,
1377
+ versionOf: (result) => result.revision,
1378
+ digestOf: (result) => ({
1379
+ accountId: result.accountId,
1380
+ threadId: result.threadId,
1381
+ title: result.title,
1382
+ modelAlias: result.modelAlias
1383
+ })
1384
+ },
1385
+ () => {
1386
+ const retired = this.#database.prepare(`
1387
+ SELECT 1 AS present FROM direct_chat_thread_deletions
1388
+ WHERE account_id = ? AND thread_id = ?
1389
+ `).get(accountId, threadId);
1390
+ if (retired) {
1391
+ throw new ConflictError('The direct-chat thread identifier was permanently retired.');
1392
+ }
1393
+ const existing = this.#database.prepare(`
1394
+ SELECT * FROM direct_chat_threads WHERE account_id = ? AND thread_id = ?
1395
+ `).get(accountId, threadId);
1396
+ if (existing) {
1397
+ if (existing.title !== title.value || existing.model_alias !== this.#modelAlias) {
1398
+ throw new ConflictError('The direct-chat thread identifier already has different immutable settings.');
1399
+ }
1400
+ this.#auditThread(existing);
1401
+ return threadView(existing);
1402
+ }
1403
+ const count = Number(this.#database.prepare(`
1404
+ SELECT count(*) AS count FROM direct_chat_threads WHERE account_id = ?
1405
+ `).get(accountId).count);
1406
+ if (count >= DIRECT_CHAT_LIMITS.threadsPerAccount) {
1407
+ throw new ConflictError('The account reached its direct-chat thread limit.');
1408
+ }
1409
+ const timestamp = nowIso(this.#clock);
1410
+ try {
1411
+ this.#database.prepare(`
1412
+ INSERT INTO direct_chat_threads(
1413
+ account_id, thread_id, title, title_bytes, model_alias, created_at, updated_at
1414
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
1415
+ `).run(accountId, threadId, title.value, title.bytes, this.#modelAlias, timestamp, timestamp);
1416
+ } catch (error) {
1417
+ if (isConstraintError(error)) throw new ConflictError();
1418
+ throw error;
1419
+ }
1420
+ return threadView(requireThread(this.#database, accountId, threadId));
1421
+ },
1422
+ () => {
1423
+ const thread = requireThread(this.#database, accountId, threadId);
1424
+ this.#auditThread(thread);
1425
+ return threadView(thread);
1426
+ }
1427
+ );
1428
+ }
1429
+
1430
+ getThread(accountId, threadId) {
1431
+ this.#assertOpen();
1432
+ assertIdentifier(accountId, 'accountId');
1433
+ assertIdentifier(threadId, 'threadId');
1434
+ return this.#readTransaction(() => {
1435
+ const row = this.#database.prepare(`
1436
+ SELECT * FROM direct_chat_threads WHERE account_id = ? AND thread_id = ?
1437
+ `).get(accountId, threadId);
1438
+ if (!row) return null;
1439
+ this.#auditThread(row);
1440
+ return threadView(row);
1441
+ });
1442
+ }
1443
+
1444
+ listThreads(input) {
1445
+ this.#assertOpen();
1446
+ assertExactKeys(input, { required: ['accountId'], optional: ['limit'] }, 'direct chat thread query');
1447
+ const accountId = assertIdentifier(input.accountId, 'accountId');
1448
+ const limit = assertInteger(input.limit ?? 50, 'limit', { min: 1, max: DIRECT_CHAT_LIMITS.listPage });
1449
+ return this.#readTransaction(() => {
1450
+ const rows = this.#database.prepare(`
1451
+ SELECT * FROM direct_chat_threads
1452
+ WHERE account_id = ?
1453
+ ORDER BY updated_at DESC, thread_id DESC
1454
+ LIMIT ?
1455
+ `).all(accountId, limit);
1456
+ for (const row of rows) this.#auditThread(row);
1457
+ return rows.map(threadView);
1458
+ });
1459
+ }
1460
+
1461
+ deleteThread(input) {
1462
+ assertExactKeys(
1463
+ input,
1464
+ {
1465
+ required: [
1466
+ 'accountId', 'threadId', 'expectedRevision', 'expectedHash', 'idempotencyKey'
1467
+ ]
1468
+ },
1469
+ 'direct chat thread deletion'
1470
+ );
1471
+ const accountId = assertIdentifier(input.accountId, 'accountId');
1472
+ const threadId = assertIdentifier(input.threadId, 'threadId');
1473
+ const cursor = assertCursor(input.expectedRevision, input.expectedHash);
1474
+ assertIdempotencyKey(input.idempotencyKey);
1475
+ const keyHash = sha256(input.idempotencyKey);
1476
+ const request = threadDeletionRequest(accountId, threadId, cursor);
1477
+ const requestHash = sha256(canonicalJson(request));
1478
+
1479
+ return this.#transaction(() => {
1480
+ const existing = this.#database.prepare(`
1481
+ SELECT * FROM direct_chat_thread_deletions
1482
+ WHERE account_id = ? AND key_hash = ?
1483
+ `).get(accountId, keyHash);
1484
+ if (existing) {
1485
+ if (existing.request_hash !== requestHash) throw new IdempotencyConflictError();
1486
+ if (existing.thread_id !== threadId) {
1487
+ throw new StorageCorruptionError('A direct-chat deletion receipt has inconsistent ownership.');
1488
+ }
1489
+ const replayed = threadDeletionView(existing);
1490
+ if (sha256(canonicalJson(threadDeletionDigest(replayed))) !== existing.result_digest) {
1491
+ throw new ConflictError('The original direct-chat deletion result cannot be replayed exactly.');
1492
+ }
1493
+ return replayed;
1494
+ }
1495
+
1496
+ if (this.#database.prepare(`
1497
+ SELECT 1 AS present FROM direct_chat_thread_deletions
1498
+ WHERE account_id = ? AND thread_id = ?
1499
+ `).get(accountId, threadId)) {
1500
+ throw new NotFoundError();
1501
+ }
1502
+ const thread = requireThread(this.#database, accountId, threadId);
1503
+ this.#auditThread(thread);
1504
+ assertCursorMatches(thread, cursor);
1505
+ if (thread.current_generation_id !== null) {
1506
+ throw new ConflictError('An active or unresolved Direct Chat generation cannot be deleted.');
1507
+ }
1508
+ const unresolvedUser = this.#database.prepare(`
1509
+ SELECT 1 AS present
1510
+ FROM direct_chat_messages AS message
1511
+ WHERE message.account_id = ? AND message.thread_id = ?
1512
+ AND message.revision = ? AND message.role = 'user'
1513
+ AND NOT EXISTS (
1514
+ SELECT 1 FROM direct_chat_generations AS generation
1515
+ WHERE generation.account_id = message.account_id
1516
+ AND generation.thread_id = message.thread_id
1517
+ AND generation.source_revision = message.revision
1518
+ AND generation.source_hash = message.message_hash
1519
+ )
1520
+ `).get(accountId, threadId, Number(thread.ledger_revision));
1521
+ if (unresolvedUser) {
1522
+ throw new ConflictError('A Direct Chat send with unresolved acceptance cannot be deleted.');
1523
+ }
1524
+ const receiptCount = Number(this.#database.prepare(`
1525
+ SELECT count(*) AS count FROM direct_chat_thread_deletions WHERE account_id = ?
1526
+ `).get(accountId).count);
1527
+ if (receiptCount >= DIRECT_CHAT_LIMITS.threadDeletionReceiptsPerAccount) {
1528
+ throw new ConflictError('The account reached its durable thread deletion receipt limit.');
1529
+ }
1530
+
1531
+ const deletedAt = nowIso(this.#clock);
1532
+ const result = {
1533
+ accountId,
1534
+ threadId,
1535
+ deleted: true,
1536
+ revision: Number(thread.ledger_revision),
1537
+ ledgerHash: thread.ledger_hash,
1538
+ deletedAt
1539
+ };
1540
+ this.#database.prepare(`
1541
+ INSERT INTO direct_chat_thread_deletions(
1542
+ account_id, thread_id, key_hash, request_hash, deleted_revision,
1543
+ deleted_hash, result_digest, deleted_at
1544
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1545
+ `).run(
1546
+ accountId,
1547
+ threadId,
1548
+ keyHash,
1549
+ requestHash,
1550
+ result.revision,
1551
+ result.ledgerHash,
1552
+ sha256(canonicalJson(threadDeletionDigest(result))),
1553
+ deletedAt
1554
+ );
1555
+
1556
+ // The durable receipt above is the only authority accepted by the
1557
+ // deletion triggers. All private descendants and mutation receipts are
1558
+ // removed in one transaction before the owning thread row disappears.
1559
+ this.#database.prepare(`
1560
+ DELETE FROM direct_chat_attachments WHERE account_id = ? AND thread_id = ?
1561
+ `).run(accountId, threadId);
1562
+ this.#database.prepare(`
1563
+ DELETE FROM direct_chat_deltas WHERE account_id = ? AND thread_id = ?
1564
+ `).run(accountId, threadId);
1565
+ this.#database.prepare(`
1566
+ DELETE FROM direct_chat_generation_leases WHERE account_id = ? AND thread_id = ?
1567
+ `).run(accountId, threadId);
1568
+ this.#database.prepare(`
1569
+ DELETE FROM direct_chat_compactions WHERE account_id = ? AND thread_id = ?
1570
+ `).run(accountId, threadId);
1571
+ this.#database.prepare(`
1572
+ DELETE FROM direct_chat_generations WHERE account_id = ? AND thread_id = ?
1573
+ `).run(accountId, threadId);
1574
+ this.#database.prepare(`
1575
+ DELETE FROM direct_chat_messages WHERE account_id = ? AND thread_id = ?
1576
+ `).run(accountId, threadId);
1577
+ this.#database.prepare(`
1578
+ DELETE FROM direct_chat_idempotency WHERE account_id = ? AND thread_id = ?
1579
+ `).run(accountId, threadId);
1580
+ const removed = this.#database.prepare(`
1581
+ DELETE FROM direct_chat_threads WHERE account_id = ? AND thread_id = ?
1582
+ `).run(accountId, threadId);
1583
+ if (Number(removed.changes) !== 1) {
1584
+ throw new StorageCorruptionError('The authorized direct-chat thread deletion was incomplete.');
1585
+ }
1586
+ return result;
1587
+ });
1588
+ }
1589
+
1590
+ startTurn(input) {
1591
+ assertExactKeys(
1592
+ input,
1593
+ {
1594
+ required: [
1595
+ 'accountId', 'threadId', 'messageId', 'content',
1596
+ 'generationId', 'assistantMessageId',
1597
+ 'expectedRevision', 'expectedHash', 'idempotencyKey'
1598
+ ],
1599
+ optional: ['attachment', 'attachments']
1600
+ },
1601
+ 'atomic direct chat turn'
1602
+ );
1603
+ const accountId = assertIdentifier(input.accountId, 'accountId');
1604
+ const threadId = assertIdentifier(input.threadId, 'threadId');
1605
+ const messageId = assertIdentifier(input.messageId, 'messageId');
1606
+ const generationId = assertIdentifier(input.generationId, 'generationId');
1607
+ const assistantMessageId = assertIdentifier(input.assistantMessageId, 'assistantMessageId');
1608
+ const content = assertUnicodeScalarString(input.content, 'content', {
1609
+ maxBytes: DIRECT_CHAT_LIMITS.messageBytes
1610
+ });
1611
+ const cursor = assertCursor(input.expectedRevision, input.expectedHash);
1612
+ if (input.attachment !== undefined && input.attachments !== undefined) {
1613
+ throw new ValidationError('Use either attachment or attachments, not both.');
1614
+ }
1615
+ const attachments = input.attachments !== undefined
1616
+ ? assertTurnAttachments(input.attachments)
1617
+ : (input.attachment === undefined
1618
+ ? Object.freeze([])
1619
+ : Object.freeze([assertTurnAttachment(input.attachment)]));
1620
+ if (messageId === assistantMessageId) {
1621
+ throw new ConflictError('The user and assistant message identifiers must be different.');
1622
+ }
1623
+ const request = {
1624
+ accountId,
1625
+ threadId,
1626
+ messageId,
1627
+ content: content.value,
1628
+ generationId,
1629
+ assistantMessageId,
1630
+ expectedRevision: cursor.revision,
1631
+ expectedHash: cursor.hash,
1632
+ ...attachmentViewFields(attachments)
1633
+ };
1634
+
1635
+ const immutableDigest = (result) => ({
1636
+ message: {
1637
+ accountId: result.message.accountId,
1638
+ threadId: result.message.threadId,
1639
+ messageId: result.message.messageId,
1640
+ revision: result.message.revision,
1641
+ role: result.message.role,
1642
+ content: result.message.content,
1643
+ contentBytes: result.message.contentBytes,
1644
+ previousHash: result.message.previousHash,
1645
+ messageHash: result.message.messageHash,
1646
+ createdAt: result.message.createdAt,
1647
+ ...(result.message.attachment === undefined ? {} : { attachment: result.message.attachment }),
1648
+ ...(result.message.attachments === undefined ? {} : { attachments: result.message.attachments })
1649
+ },
1650
+ generation: {
1651
+ accountId: result.generation.accountId,
1652
+ threadId: result.generation.threadId,
1653
+ generationId: result.generation.generationId,
1654
+ assistantMessageId: result.generation.assistantMessageId,
1655
+ modelAlias: result.generation.modelAlias,
1656
+ sourceRevision: result.generation.sourceRevision,
1657
+ sourceHash: result.generation.sourceHash,
1658
+ startedAt: result.generation.startedAt
1659
+ }
1660
+ });
1661
+ const replayTurn = () => {
1662
+ const message = this.#database.prepare(`
1663
+ SELECT * FROM direct_chat_messages
1664
+ WHERE account_id = ? AND thread_id = ? AND message_id = ?
1665
+ `).get(accountId, threadId, messageId);
1666
+ const generation = this.#database.prepare(`
1667
+ SELECT * FROM direct_chat_generations
1668
+ WHERE account_id = ? AND thread_id = ? AND generation_id = ?
1669
+ `).get(accountId, threadId, generationId);
1670
+ if (!message || !generation) {
1671
+ throw new ConflictError('The atomic direct-chat turn is only partially present.');
1672
+ }
1673
+ const storedAttachments = attachmentsForMessage(this.#database, accountId, threadId, messageId);
1674
+ const expectedAttachmentFields = attachmentViewFields(attachments);
1675
+ const actualAttachmentFields = attachmentViewFields(storedAttachments);
1676
+ const latestAttachment = this.#schemaVersion >= 3
1677
+ ? this.#database.prepare(`
1678
+ SELECT 1 AS present
1679
+ FROM direct_chat_attachments AS attachment
1680
+ JOIN direct_chat_messages AS owned
1681
+ ON owned.account_id = attachment.account_id
1682
+ AND owned.thread_id = attachment.thread_id
1683
+ AND owned.message_id = attachment.message_id
1684
+ WHERE attachment.account_id = ? AND attachment.thread_id = ?
1685
+ AND owned.revision <= ?
1686
+ LIMIT 1
1687
+ `).get(accountId, threadId, Number(message.revision))
1688
+ : null;
1689
+ const ownerThread = requireThread(this.#database, accountId, threadId);
1690
+ const expectedModelAlias = latestAttachment ? this.#visionModelAlias : ownerThread.model_alias;
1691
+ if (
1692
+ message.role !== 'user' ||
1693
+ message.content !== content.value ||
1694
+ Number(message.revision) !== cursor.revision + 1 ||
1695
+ message.previous_hash !== cursor.hash ||
1696
+ generation.assistant_message_id !== assistantMessageId ||
1697
+ Number(generation.source_revision) !== Number(message.revision) ||
1698
+ generation.source_hash !== message.message_hash ||
1699
+ generation.model_alias !== expectedModelAlias ||
1700
+ canonicalJson(actualAttachmentFields) !== canonicalJson(expectedAttachmentFields)
1701
+ ) {
1702
+ throw new ConflictError('The atomic direct-chat turn identifiers are bound to different data.');
1703
+ }
1704
+ return {
1705
+ message: messageView(message, storedAttachments),
1706
+ generation: generationView(generation)
1707
+ };
1708
+ };
1709
+
1710
+ return this.#idempotentMutation(
1711
+ {
1712
+ accountId,
1713
+ threadId,
1714
+ operation: 'generation.start',
1715
+ idempotencyKey: input.idempotencyKey,
1716
+ request,
1717
+ resourceKind: 'generation',
1718
+ resourceId: generationId,
1719
+ versionOf: (result) => result.generation.sourceRevision,
1720
+ digestOf: immutableDigest
1721
+ },
1722
+ () => {
1723
+ const thread = requireThread(this.#database, accountId, threadId);
1724
+ this.#auditThread(thread);
1725
+ const existingMessage = this.#database.prepare(`
1726
+ SELECT * FROM direct_chat_messages
1727
+ WHERE account_id = ? AND thread_id = ? AND message_id = ?
1728
+ `).get(accountId, threadId, messageId);
1729
+ const existingGeneration = this.#database.prepare(`
1730
+ SELECT * FROM direct_chat_generations
1731
+ WHERE account_id = ? AND thread_id = ? AND generation_id = ?
1732
+ `).get(accountId, threadId, generationId);
1733
+ if (existingMessage || existingGeneration) return replayTurn();
1734
+
1735
+ const existingVision = this.#schemaVersion >= 3
1736
+ ? this.#database.prepare(`
1737
+ SELECT 1 AS present FROM direct_chat_attachments
1738
+ WHERE account_id = ? AND thread_id = ?
1739
+ LIMIT 1
1740
+ `).get(accountId, threadId)
1741
+ : null;
1742
+ if ((attachments.length > 0 || existingVision)
1743
+ && (!this.#enableVisionAttachments || this.#schemaVersion < 3)) {
1744
+ throw new ValidationError('Vision attachments are not enabled for new Direct Chat turns.');
1745
+ }
1746
+ if (thread.current_generation_id !== null) {
1747
+ throw new ConflictError('A user turn cannot start while generation is in progress.');
1748
+ }
1749
+ assertCursorMatches(thread, cursor);
1750
+ if (Number(thread.message_count) >= DIRECT_CHAT_LIMITS.messagesPerThread) {
1751
+ throw new ConflictError('The direct-chat thread reached its message limit.');
1752
+ }
1753
+ if (Number(thread.ledger_bytes) + content.bytes > DIRECT_CHAT_LIMITS.ledgerBytesPerThread) {
1754
+ throw new ConflictError('The direct-chat thread reached its ledger byte limit.');
1755
+ }
1756
+ if (Number(thread.generation_count) >= DIRECT_CHAT_LIMITS.generationsPerThread) {
1757
+ throw new ConflictError('The direct-chat thread reached its generation limit.');
1758
+ }
1759
+ const reservedUserId = this.#database.prepare(`
1760
+ SELECT 1 AS present FROM direct_chat_generations
1761
+ WHERE account_id = ? AND thread_id = ? AND assistant_message_id = ?
1762
+ `).get(accountId, threadId, messageId);
1763
+ if (reservedUserId) {
1764
+ throw new ConflictError('The user message identifier is reserved by an assistant generation.');
1765
+ }
1766
+ const assistantMessageCollision = this.#database.prepare(`
1767
+ SELECT 1 AS present FROM direct_chat_messages
1768
+ WHERE account_id = ? AND thread_id = ? AND message_id = ?
1769
+ `).get(accountId, threadId, assistantMessageId);
1770
+ if (assistantMessageCollision) {
1771
+ throw new ConflictError('The assistant message identifier is already used.');
1772
+ }
1773
+ const assistantReservationCollision = this.#database.prepare(`
1774
+ SELECT 1 AS present FROM direct_chat_generations
1775
+ WHERE account_id = ? AND thread_id = ? AND assistant_message_id = ?
1776
+ `).get(accountId, threadId, assistantMessageId);
1777
+ if (assistantReservationCollision) {
1778
+ throw new ConflictError('The assistant message identifier is already reserved.');
1779
+ }
1780
+
1781
+ let inferenceModelAlias = thread.model_alias;
1782
+ if (existingVision || attachments.length > 0) inferenceModelAlias = this.#visionModelAlias;
1783
+ if (attachments.length > 0) {
1784
+ const threadAttachmentTotals = this.#database.prepare(`
1785
+ SELECT count(*) AS count, coalesce(sum(byte_length), 0) AS bytes
1786
+ FROM direct_chat_attachments
1787
+ WHERE account_id = ? AND thread_id = ?
1788
+ `).get(accountId, threadId);
1789
+ const accountAttachmentBytes = Number(this.#database.prepare(`
1790
+ SELECT coalesce(sum(byte_length), 0) AS bytes
1791
+ FROM direct_chat_attachments WHERE account_id = ?
1792
+ `).get(accountId).bytes);
1793
+ const incomingBytes = attachments.reduce((total, attachment) => total + attachment.byteLength, 0);
1794
+ if (Number(threadAttachmentTotals.count) + attachments.length > VISION_ATTACHMENT_LIMITS.attachmentsPerThread
1795
+ || Number(threadAttachmentTotals.bytes) + incomingBytes > VISION_ATTACHMENT_LIMITS.bytesPerThread
1796
+ || accountAttachmentBytes + incomingBytes > VISION_ATTACHMENT_LIMITS.bytesPerAccount) {
1797
+ throw new ConflictError('The Direct Chat vision attachment storage quota is reached.');
1798
+ }
1799
+ }
1800
+
1801
+ const timestamp = nowIso(this.#clock);
1802
+ const message = {
1803
+ account_id: accountId,
1804
+ thread_id: threadId,
1805
+ message_id: messageId,
1806
+ revision: cursor.revision + 1,
1807
+ role: 'user',
1808
+ content: content.value,
1809
+ content_bytes: content.bytes,
1810
+ previous_hash: cursor.hash,
1811
+ generation_id: null,
1812
+ created_at: timestamp
1813
+ };
1814
+ message.message_hash = calculateMessageHash(message, attachments);
1815
+ try {
1816
+ this.#database.prepare(`
1817
+ INSERT INTO direct_chat_messages(
1818
+ account_id, thread_id, message_id, revision, role, content, content_bytes,
1819
+ previous_hash, message_hash, generation_id, created_at
1820
+ ) VALUES (?, ?, ?, ?, 'user', ?, ?, ?, ?, NULL, ?)
1821
+ `).run(
1822
+ accountId,
1823
+ threadId,
1824
+ messageId,
1825
+ message.revision,
1826
+ message.content,
1827
+ message.content_bytes,
1828
+ message.previous_hash,
1829
+ message.message_hash,
1830
+ timestamp
1831
+ );
1832
+ for (let position = 0; position < attachments.length; position += 1) {
1833
+ const attachment = attachments[position];
1834
+ this.#database.prepare(`
1835
+ INSERT INTO direct_chat_attachments(
1836
+ account_id, thread_id, attachment_id, message_id, position, media_type,
1837
+ byte_length, width, height, content_sha256, content, created_at
1838
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1839
+ `).run(
1840
+ accountId,
1841
+ threadId,
1842
+ attachment.attachmentId,
1843
+ messageId,
1844
+ position,
1845
+ attachment.mediaType,
1846
+ attachment.byteLength,
1847
+ attachment.width,
1848
+ attachment.height,
1849
+ attachment.contentSha256,
1850
+ attachment.content,
1851
+ timestamp
1852
+ );
1853
+ }
1854
+ this.#database.prepare(`
1855
+ INSERT INTO direct_chat_generations(
1856
+ account_id, thread_id, generation_id, assistant_message_id, status,
1857
+ model_alias, source_revision, source_hash, started_at, updated_at
1858
+ ) VALUES (?, ?, ?, ?, 'in_progress', ?, ?, ?, ?, ?)
1859
+ `).run(
1860
+ accountId,
1861
+ threadId,
1862
+ generationId,
1863
+ assistantMessageId,
1864
+ inferenceModelAlias,
1865
+ message.revision,
1866
+ message.message_hash,
1867
+ timestamp,
1868
+ timestamp
1869
+ );
1870
+ } catch (error) {
1871
+ if (isConstraintError(error)) throw new ConflictError('The atomic direct-chat turn identifiers conflict.');
1872
+ throw error;
1873
+ }
1874
+ const updated = this.#database.prepare(`
1875
+ UPDATE direct_chat_threads
1876
+ SET ledger_revision = ?, ledger_hash = ?, message_count = message_count + 1,
1877
+ ledger_bytes = ledger_bytes + ?, current_generation_id = ?,
1878
+ generation_count = generation_count + 1, updated_at = ?
1879
+ WHERE account_id = ? AND thread_id = ? AND ledger_revision = ?
1880
+ AND ledger_hash IS ? AND current_generation_id IS NULL
1881
+ `).run(
1882
+ message.revision,
1883
+ message.message_hash,
1884
+ message.content_bytes,
1885
+ generationId,
1886
+ timestamp,
1887
+ accountId,
1888
+ threadId,
1889
+ cursor.revision,
1890
+ cursor.hash
1891
+ );
1892
+ if (Number(updated.changes) !== 1) {
1893
+ throw new ConflictError('The direct-chat thread changed concurrently.');
1894
+ }
1895
+ return {
1896
+ message: messageView(message, attachments),
1897
+ generation: generationView(
1898
+ requireGeneration(this.#database, accountId, threadId, generationId)
1899
+ )
1900
+ };
1901
+ },
1902
+ replayTurn
1903
+ );
1904
+ }
1905
+
1906
+ sendUserMessage(input) {
1907
+ assertExactKeys(
1908
+ input,
1909
+ {
1910
+ required: [
1911
+ 'accountId', 'threadId', 'messageId', 'content',
1912
+ 'expectedRevision', 'expectedHash', 'idempotencyKey'
1913
+ ]
1914
+ },
1915
+ 'direct chat user message'
1916
+ );
1917
+ const accountId = assertIdentifier(input.accountId, 'accountId');
1918
+ const threadId = assertIdentifier(input.threadId, 'threadId');
1919
+ const messageId = assertIdentifier(input.messageId, 'messageId');
1920
+ const content = assertUnicodeScalarString(input.content, 'content', {
1921
+ maxBytes: DIRECT_CHAT_LIMITS.messageBytes
1922
+ });
1923
+ const cursor = assertCursor(input.expectedRevision, input.expectedHash);
1924
+ const request = {
1925
+ accountId, threadId, messageId, content: content.value,
1926
+ expectedRevision: cursor.revision, expectedHash: cursor.hash
1927
+ };
1928
+
1929
+ const replayMessage = () => {
1930
+ const row = this.#database.prepare(`
1931
+ SELECT * FROM direct_chat_messages
1932
+ WHERE account_id = ? AND thread_id = ? AND message_id = ?
1933
+ `).get(accountId, threadId, messageId);
1934
+ if (!row) throw new ConflictError('The idempotent user message is no longer present.');
1935
+ return messageView(row);
1936
+ };
1937
+
1938
+ return this.#idempotentMutation(
1939
+ {
1940
+ accountId,
1941
+ threadId,
1942
+ operation: 'message.user.append',
1943
+ idempotencyKey: input.idempotencyKey,
1944
+ request,
1945
+ resourceKind: 'message',
1946
+ resourceId: messageId,
1947
+ versionOf: (result) => result.revision
1948
+ },
1949
+ () => {
1950
+ const thread = requireThread(this.#database, accountId, threadId);
1951
+ this.#auditThread(thread);
1952
+ const existing = this.#database.prepare(`
1953
+ SELECT * FROM direct_chat_messages
1954
+ WHERE account_id = ? AND thread_id = ? AND message_id = ?
1955
+ `).get(accountId, threadId, messageId);
1956
+ if (existing) {
1957
+ if (
1958
+ existing.role !== 'user' || existing.content !== content.value ||
1959
+ Number(existing.revision) !== cursor.revision + 1 || existing.previous_hash !== cursor.hash
1960
+ ) {
1961
+ throw new ConflictError('The direct-chat message identifier is already bound to different content.');
1962
+ }
1963
+ return messageView(existing);
1964
+ }
1965
+ const reservedAssistantId = this.#database.prepare(`
1966
+ SELECT 1 AS present FROM direct_chat_generations
1967
+ WHERE account_id = ? AND thread_id = ? AND assistant_message_id = ?
1968
+ `).get(accountId, threadId, messageId);
1969
+ if (reservedAssistantId) {
1970
+ throw new ConflictError('The message identifier is reserved by an assistant generation.');
1971
+ }
1972
+ if (thread.current_generation_id !== null) {
1973
+ throw new ConflictError('A user message cannot be appended while generation is in progress.');
1974
+ }
1975
+ assertCursorMatches(thread, cursor);
1976
+ if (Number(thread.message_count) >= DIRECT_CHAT_LIMITS.messagesPerThread) {
1977
+ throw new ConflictError('The direct-chat thread reached its message limit.');
1978
+ }
1979
+ if (Number(thread.ledger_bytes) + content.bytes > DIRECT_CHAT_LIMITS.ledgerBytesPerThread) {
1980
+ throw new ConflictError('The direct-chat thread reached its ledger byte limit.');
1981
+ }
1982
+ const timestamp = nowIso(this.#clock);
1983
+ const row = {
1984
+ account_id: accountId,
1985
+ thread_id: threadId,
1986
+ message_id: messageId,
1987
+ revision: cursor.revision + 1,
1988
+ role: 'user',
1989
+ content: content.value,
1990
+ content_bytes: content.bytes,
1991
+ previous_hash: cursor.hash,
1992
+ generation_id: null,
1993
+ created_at: timestamp
1994
+ };
1995
+ row.message_hash = calculateMessageHash(row);
1996
+ this.#database.prepare(`
1997
+ INSERT INTO direct_chat_messages(
1998
+ account_id, thread_id, message_id, revision, role, content, content_bytes,
1999
+ previous_hash, message_hash, generation_id, created_at
2000
+ ) VALUES (?, ?, ?, ?, 'user', ?, ?, ?, ?, NULL, ?)
2001
+ `).run(
2002
+ accountId, threadId, messageId, row.revision, row.content, row.content_bytes,
2003
+ row.previous_hash, row.message_hash, timestamp
2004
+ );
2005
+ const updated = this.#database.prepare(`
2006
+ UPDATE direct_chat_threads
2007
+ SET ledger_revision = ?, ledger_hash = ?, message_count = message_count + 1,
2008
+ ledger_bytes = ledger_bytes + ?, updated_at = ?
2009
+ WHERE account_id = ? AND thread_id = ? AND ledger_revision = ?
2010
+ AND ledger_hash IS ? AND current_generation_id IS NULL
2011
+ `).run(
2012
+ row.revision, row.message_hash, row.content_bytes, timestamp,
2013
+ accountId, threadId, cursor.revision, cursor.hash
2014
+ );
2015
+ if (Number(updated.changes) !== 1) throw new ConflictError('The direct-chat ledger changed concurrently.');
2016
+ return messageView(row);
2017
+ },
2018
+ replayMessage
2019
+ );
2020
+ }
2021
+
2022
+ startGeneration(input) {
2023
+ assertExactKeys(
2024
+ input,
2025
+ {
2026
+ required: [
2027
+ 'accountId', 'threadId', 'generationId', 'assistantMessageId',
2028
+ 'expectedRevision', 'expectedHash', 'idempotencyKey'
2029
+ ]
2030
+ },
2031
+ 'direct chat generation'
2032
+ );
2033
+ const accountId = assertIdentifier(input.accountId, 'accountId');
2034
+ const threadId = assertIdentifier(input.threadId, 'threadId');
2035
+ const generationId = assertIdentifier(input.generationId, 'generationId');
2036
+ const assistantMessageId = assertIdentifier(input.assistantMessageId, 'assistantMessageId');
2037
+ const cursor = assertCursor(input.expectedRevision, input.expectedHash);
2038
+ if (cursor.revision === 0) throw new ValidationError('A generation must follow a user message.');
2039
+ const request = {
2040
+ accountId, threadId, generationId, assistantMessageId,
2041
+ expectedRevision: cursor.revision, expectedHash: cursor.hash
2042
+ };
2043
+ const replayGeneration = () => generationView(
2044
+ requireGeneration(this.#database, accountId, threadId, generationId)
2045
+ );
2046
+
2047
+ return this.#idempotentMutation(
2048
+ {
2049
+ accountId,
2050
+ threadId,
2051
+ operation: 'generation.start',
2052
+ idempotencyKey: input.idempotencyKey,
2053
+ request,
2054
+ resourceKind: 'generation',
2055
+ resourceId: generationId,
2056
+ versionOf: statusVersion,
2057
+ digestOf: (result) => ({
2058
+ accountId: result.accountId,
2059
+ threadId: result.threadId,
2060
+ generationId: result.generationId,
2061
+ assistantMessageId: result.assistantMessageId,
2062
+ modelAlias: result.modelAlias,
2063
+ sourceRevision: result.sourceRevision,
2064
+ sourceHash: result.sourceHash
2065
+ })
2066
+ },
2067
+ () => {
2068
+ const thread = requireThread(this.#database, accountId, threadId);
2069
+ this.#auditThread(thread);
2070
+ const inferenceModelAlias = this.#schemaVersion >= 3 && this.#database.prepare(`
2071
+ SELECT 1 AS present
2072
+ FROM direct_chat_attachments AS attachment
2073
+ JOIN direct_chat_messages AS message
2074
+ ON message.account_id = attachment.account_id
2075
+ AND message.thread_id = attachment.thread_id
2076
+ AND message.message_id = attachment.message_id
2077
+ WHERE attachment.account_id = ? AND attachment.thread_id = ?
2078
+ AND message.revision <= ?
2079
+ LIMIT 1
2080
+ `).get(accountId, threadId, cursor.revision)
2081
+ ? this.#visionModelAlias
2082
+ : thread.model_alias;
2083
+ const existing = this.#database.prepare(`
2084
+ SELECT * FROM direct_chat_generations
2085
+ WHERE account_id = ? AND thread_id = ? AND generation_id = ?
2086
+ `).get(accountId, threadId, generationId);
2087
+ if (existing) {
2088
+ if (
2089
+ existing.assistant_message_id !== assistantMessageId ||
2090
+ Number(existing.source_revision) !== cursor.revision ||
2091
+ existing.source_hash !== cursor.hash || existing.model_alias !== inferenceModelAlias
2092
+ ) {
2093
+ throw new ConflictError('The generation identifier is already bound to a different request.');
2094
+ }
2095
+ return generationView(existing);
2096
+ }
2097
+ assertCursorMatches(thread, cursor);
2098
+ if (thread.current_generation_id !== null) {
2099
+ throw new ConflictError('The thread already has an active generation.');
2100
+ }
2101
+ if (Number(thread.generation_count) >= DIRECT_CHAT_LIMITS.generationsPerThread) {
2102
+ throw new ConflictError('The direct-chat thread reached its generation limit.');
2103
+ }
2104
+ const last = this.#database.prepare(`
2105
+ SELECT role, message_hash FROM direct_chat_messages
2106
+ WHERE account_id = ? AND thread_id = ? AND revision = ?
2107
+ `).get(accountId, threadId, cursor.revision);
2108
+ if (!last || last.role !== 'user' || last.message_hash !== cursor.hash) {
2109
+ throw new ConflictError('A generation must start from the exact latest user message.');
2110
+ }
2111
+ const messageCollision = this.#database.prepare(`
2112
+ SELECT 1 AS present FROM direct_chat_messages
2113
+ WHERE account_id = ? AND thread_id = ? AND message_id = ?
2114
+ `).get(accountId, threadId, assistantMessageId);
2115
+ if (messageCollision) throw new ConflictError('The assistant message identifier is already used.');
2116
+ const generationMessageCollision = this.#database.prepare(`
2117
+ SELECT 1 AS present FROM direct_chat_generations
2118
+ WHERE account_id = ? AND thread_id = ? AND assistant_message_id = ?
2119
+ `).get(accountId, threadId, assistantMessageId);
2120
+ if (generationMessageCollision) {
2121
+ throw new ConflictError('The assistant message identifier is already reserved.');
2122
+ }
2123
+ const timestamp = nowIso(this.#clock);
2124
+ this.#database.prepare(`
2125
+ INSERT INTO direct_chat_generations(
2126
+ account_id, thread_id, generation_id, assistant_message_id, status,
2127
+ model_alias, source_revision, source_hash, started_at, updated_at
2128
+ ) VALUES (?, ?, ?, ?, 'in_progress', ?, ?, ?, ?, ?)
2129
+ `).run(
2130
+ accountId, threadId, generationId, assistantMessageId,
2131
+ inferenceModelAlias, cursor.revision, cursor.hash, timestamp, timestamp
2132
+ );
2133
+ const updated = this.#database.prepare(`
2134
+ UPDATE direct_chat_threads
2135
+ SET current_generation_id = ?, generation_count = generation_count + 1, updated_at = ?
2136
+ WHERE account_id = ? AND thread_id = ? AND current_generation_id IS NULL
2137
+ AND ledger_revision = ? AND ledger_hash = ?
2138
+ `).run(generationId, timestamp, accountId, threadId, cursor.revision, cursor.hash);
2139
+ if (Number(updated.changes) !== 1) throw new ConflictError('The direct-chat thread changed concurrently.');
2140
+ return generationView(requireGeneration(this.#database, accountId, threadId, generationId));
2141
+ },
2142
+ replayGeneration
2143
+ );
2144
+ }
2145
+
2146
+ claimGenerationLease(input) {
2147
+ assertExactKeys(
2148
+ input,
2149
+ { required: ['accountId', 'threadId', 'generationId', 'ownerToken', 'ttlMs'] },
2150
+ 'direct chat generation dispatch lease claim'
2151
+ );
2152
+ const accountId = assertIdentifier(input.accountId, 'accountId');
2153
+ const threadId = assertIdentifier(input.threadId, 'threadId');
2154
+ const generationId = assertIdentifier(input.generationId, 'generationId');
2155
+ const ownerHash = sha256(assertDispatchLeaseOwnerToken(input.ownerToken));
2156
+ const ttlMs = assertDispatchLeaseTtl(input.ttlMs);
2157
+
2158
+ let blockedMessage = null;
2159
+ const claimed = this.#transaction(() => {
2160
+ const thread = requireThread(this.#database, accountId, threadId);
2161
+ this.#auditThread(thread);
2162
+ const generation = requireGeneration(this.#database, accountId, threadId, generationId);
2163
+ if (generation.status !== 'in_progress' || thread.current_generation_id !== generationId) {
2164
+ throw new ConflictError('Only the active in-progress generation can acquire a dispatch lease.');
2165
+ }
2166
+ const timestamp = nowIso(this.#clock);
2167
+ this.#reconcileExpiredGenerationDispatches(timestamp);
2168
+ const existing = this.#generationLease(accountId, threadId, generationId);
2169
+ if (existing) auditDispatchLease(existing);
2170
+ if (existing?.phase === 'interrupted') {
2171
+ blockedMessage = 'This generation has an ambiguous interrupted dispatch and cannot run inference again.';
2172
+ return dispatchLeaseView(existing, timestamp);
2173
+ }
2174
+ if (
2175
+ existing &&
2176
+ existing.released_at === null &&
2177
+ existing.expires_at > timestamp
2178
+ ) {
2179
+ if (existing.owner_hash !== ownerHash) {
2180
+ throw new ConflictError('The generation already has an active dispatch lease.');
2181
+ }
2182
+ if (existing.phase !== 'claimed') {
2183
+ blockedMessage = 'Inference dispatch was already marked started for this generation.';
2184
+ }
2185
+ return dispatchLeaseView(existing, timestamp);
2186
+ }
2187
+
2188
+ if (existing && existing.dispatch_started_at !== null) {
2189
+ const fence = Number(existing.fence) + 1;
2190
+ if (!Number.isSafeInteger(fence) || fence > MAX_DISPATCH_LEASE_FENCE) {
2191
+ throw new ConflictError('The generation dispatch lease fence is exhausted.');
2192
+ }
2193
+ const interruptedAt = timestamp < existing.updated_at ? existing.updated_at : timestamp;
2194
+ const updated = this.#database.prepare(`
2195
+ UPDATE direct_chat_generation_leases
2196
+ SET fence = ?, phase = 'interrupted', expires_at = ?,
2197
+ updated_at = ?, released_at = ?
2198
+ WHERE account_id = ? AND thread_id = ? AND generation_id = ?
2199
+ AND fence = ? AND owner_hash = ? AND phase = 'dispatch_started'
2200
+ AND dispatch_started_at IS NOT NULL AND released_at IS NULL
2201
+ `).run(
2202
+ fence,
2203
+ interruptedAt,
2204
+ interruptedAt,
2205
+ interruptedAt,
2206
+ accountId,
2207
+ threadId,
2208
+ generationId,
2209
+ existing.fence,
2210
+ existing.owner_hash
2211
+ );
2212
+ if (Number(updated.changes) !== 1) {
2213
+ throw new ConflictError('The generation dispatch lease changed concurrently.');
2214
+ }
2215
+ blockedMessage = 'The expired generation dispatch may already have run; start a new user generation.';
2216
+ return dispatchLeaseView(
2217
+ this.#generationLease(accountId, threadId, generationId),
2218
+ timestamp
2219
+ );
2220
+ }
2221
+
2222
+ const fence = existing === null ? 1 : Number(existing.fence) + 1;
2223
+ if (!Number.isSafeInteger(fence) || fence > MAX_DISPATCH_LEASE_FENCE) {
2224
+ throw new ConflictError('The generation dispatch lease fence is exhausted.');
2225
+ }
2226
+ const claimedAt = existing && timestamp < existing.updated_at
2227
+ ? existing.updated_at
2228
+ : timestamp;
2229
+ const expiresAt = addMilliseconds(claimedAt, ttlMs);
2230
+ if (!existing) {
2231
+ this.#database.prepare(`
2232
+ INSERT INTO direct_chat_generation_leases(
2233
+ account_id, thread_id, generation_id, owner_hash, fence, phase,
2234
+ expires_at, claimed_at, dispatch_started_at, updated_at, released_at
2235
+ ) VALUES (?, ?, ?, ?, ?, 'claimed', ?, ?, NULL, ?, NULL)
2236
+ `).run(
2237
+ accountId,
2238
+ threadId,
2239
+ generationId,
2240
+ ownerHash,
2241
+ fence,
2242
+ expiresAt,
2243
+ claimedAt,
2244
+ claimedAt
2245
+ );
2246
+ } else {
2247
+ const updated = this.#database.prepare(`
2248
+ UPDATE direct_chat_generation_leases
2249
+ SET owner_hash = ?, fence = ?, phase = 'claimed', expires_at = ?, claimed_at = ?,
2250
+ dispatch_started_at = NULL, updated_at = ?, released_at = NULL
2251
+ WHERE account_id = ? AND thread_id = ? AND generation_id = ?
2252
+ AND fence = ? AND owner_hash = ? AND expires_at = ?
2253
+ AND phase IN ('claimed', 'released') AND dispatch_started_at IS NULL
2254
+ AND released_at IS ?
2255
+ `).run(
2256
+ ownerHash,
2257
+ fence,
2258
+ expiresAt,
2259
+ claimedAt,
2260
+ claimedAt,
2261
+ accountId,
2262
+ threadId,
2263
+ generationId,
2264
+ existing.fence,
2265
+ existing.owner_hash,
2266
+ existing.expires_at,
2267
+ existing.released_at
2268
+ );
2269
+ if (Number(updated.changes) !== 1) {
2270
+ throw new ConflictError('The generation dispatch lease changed concurrently.');
2271
+ }
2272
+ }
2273
+ return dispatchLeaseView(
2274
+ this.#generationLease(accountId, threadId, generationId),
2275
+ timestamp
2276
+ );
2277
+ });
2278
+ if (blockedMessage !== null) throw new ConflictError(blockedMessage);
2279
+ return claimed;
2280
+ }
2281
+
2282
+ markGenerationDispatchStarted(input) {
2283
+ assertExactKeys(
2284
+ input,
2285
+ { required: ['accountId', 'threadId', 'generationId', 'ownerToken', 'fence'] },
2286
+ 'direct chat generation dispatch start marker'
2287
+ );
2288
+ const accountId = assertIdentifier(input.accountId, 'accountId');
2289
+ const threadId = assertIdentifier(input.threadId, 'threadId');
2290
+ const generationId = assertIdentifier(input.generationId, 'generationId');
2291
+ const proof = assertDispatchLeaseProof({ ownerToken: input.ownerToken, fence: input.fence });
2292
+
2293
+ return this.#transaction(() => {
2294
+ const thread = requireThread(this.#database, accountId, threadId);
2295
+ this.#auditThread(thread);
2296
+ const generation = requireGeneration(this.#database, accountId, threadId, generationId);
2297
+ if (generation.status !== 'in_progress' || thread.current_generation_id !== generationId) {
2298
+ throw new ConflictError('Only the active in-progress generation can start dispatch.');
2299
+ }
2300
+ const timestamp = nowIso(this.#clock);
2301
+ const lease = this.#requireGenerationLease(
2302
+ accountId,
2303
+ threadId,
2304
+ generationId,
2305
+ proof,
2306
+ timestamp,
2307
+ ['claimed', 'dispatch_started']
2308
+ );
2309
+ if (lease.phase === 'dispatch_started') {
2310
+ return Object.freeze({
2311
+ ...dispatchLeaseView(lease, timestamp),
2312
+ dispatchAuthorized: false,
2313
+ dispatchState: 'already_started'
2314
+ });
2315
+ }
2316
+ this.#reconcileExpiredGenerationDispatches(timestamp);
2317
+ const activeInference = this.#database.prepare(`
2318
+ SELECT 1 AS present FROM direct_chat_generation_leases
2319
+ WHERE phase = 'dispatch_started' AND released_at IS NULL
2320
+ AND NOT (account_id = ? AND thread_id = ? AND generation_id = ?)
2321
+ LIMIT 1
2322
+ `).get(accountId, threadId, generationId);
2323
+ if (activeInference) {
2324
+ return Object.freeze({
2325
+ ...dispatchLeaseView(lease, timestamp),
2326
+ dispatchAuthorized: false,
2327
+ dispatchState: 'global_busy'
2328
+ });
2329
+ }
2330
+ const startedAt = timestamp < lease.updated_at ? lease.updated_at : timestamp;
2331
+ const updated = this.#database.prepare(`
2332
+ UPDATE direct_chat_generation_leases
2333
+ SET phase = 'dispatch_started', dispatch_started_at = ?, updated_at = ?
2334
+ WHERE account_id = ? AND thread_id = ? AND generation_id = ?
2335
+ AND owner_hash = ? AND fence = ? AND phase = 'claimed'
2336
+ AND dispatch_started_at IS NULL AND released_at IS NULL AND expires_at > ?
2337
+ `).run(
2338
+ startedAt,
2339
+ startedAt,
2340
+ accountId,
2341
+ threadId,
2342
+ generationId,
2343
+ proof.ownerHash,
2344
+ proof.fence,
2345
+ timestamp
2346
+ );
2347
+ if (Number(updated.changes) !== 1) {
2348
+ throw new ConflictError('The generation dispatch start marker changed concurrently.');
2349
+ }
2350
+ return Object.freeze({
2351
+ ...dispatchLeaseView(
2352
+ this.#generationLease(accountId, threadId, generationId),
2353
+ timestamp
2354
+ ),
2355
+ dispatchAuthorized: true,
2356
+ dispatchState: 'started'
2357
+ });
2358
+ });
2359
+ }
2360
+
2361
+ getGenerationLease(input) {
2362
+ this.#assertOpen();
2363
+ assertExactKeys(
2364
+ input,
2365
+ { required: ['accountId', 'threadId', 'generationId'] },
2366
+ 'direct chat generation dispatch lease lookup'
2367
+ );
2368
+ const accountId = assertIdentifier(input.accountId, 'accountId');
2369
+ const threadId = assertIdentifier(input.threadId, 'threadId');
2370
+ const generationId = assertIdentifier(input.generationId, 'generationId');
2371
+ return this.#readTransaction(() => {
2372
+ const thread = this.#database.prepare(`
2373
+ SELECT * FROM direct_chat_threads WHERE account_id = ? AND thread_id = ?
2374
+ `).get(accountId, threadId);
2375
+ if (!thread) return null;
2376
+ this.#auditThread(thread);
2377
+ const lease = this.#generationLease(accountId, threadId, generationId);
2378
+ if (!lease) return null;
2379
+ auditDispatchLease(lease);
2380
+ return dispatchLeaseView(lease, nowIso(this.#clock));
2381
+ });
2382
+ }
2383
+
2384
+ renewGenerationLease(input) {
2385
+ assertExactKeys(
2386
+ input,
2387
+ { required: ['accountId', 'threadId', 'generationId', 'ownerToken', 'fence', 'ttlMs'] },
2388
+ 'direct chat generation dispatch lease renewal'
2389
+ );
2390
+ const accountId = assertIdentifier(input.accountId, 'accountId');
2391
+ const threadId = assertIdentifier(input.threadId, 'threadId');
2392
+ const generationId = assertIdentifier(input.generationId, 'generationId');
2393
+ const proof = assertDispatchLeaseProof({ ownerToken: input.ownerToken, fence: input.fence });
2394
+ const ttlMs = assertDispatchLeaseTtl(input.ttlMs);
2395
+
2396
+ return this.#transaction(() => {
2397
+ const thread = requireThread(this.#database, accountId, threadId);
2398
+ this.#auditThread(thread);
2399
+ const generation = requireGeneration(this.#database, accountId, threadId, generationId);
2400
+ if (generation.status !== 'in_progress' || thread.current_generation_id !== generationId) {
2401
+ throw new ConflictError('Only the active in-progress generation can renew a dispatch lease.');
2402
+ }
2403
+ const timestamp = nowIso(this.#clock);
2404
+ const lease = this.#requireGenerationLease(
2405
+ accountId,
2406
+ threadId,
2407
+ generationId,
2408
+ proof,
2409
+ timestamp,
2410
+ ['claimed', 'dispatch_started']
2411
+ );
2412
+ const renewedAt = timestamp < lease.updated_at ? lease.updated_at : timestamp;
2413
+ const expiresAt = addMilliseconds(renewedAt, ttlMs);
2414
+ if (expiresAt <= lease.expires_at) return dispatchLeaseView(lease, timestamp);
2415
+ const updated = this.#database.prepare(`
2416
+ UPDATE direct_chat_generation_leases
2417
+ SET expires_at = ?, updated_at = ?
2418
+ WHERE account_id = ? AND thread_id = ? AND generation_id = ?
2419
+ AND owner_hash = ? AND fence = ? AND expires_at = ? AND released_at IS NULL
2420
+ `).run(
2421
+ expiresAt,
2422
+ renewedAt,
2423
+ accountId,
2424
+ threadId,
2425
+ generationId,
2426
+ proof.ownerHash,
2427
+ proof.fence,
2428
+ lease.expires_at
2429
+ );
2430
+ if (Number(updated.changes) !== 1) {
2431
+ throw new ConflictError('The generation dispatch lease changed concurrently.');
2432
+ }
2433
+ return dispatchLeaseView(
2434
+ this.#generationLease(accountId, threadId, generationId),
2435
+ timestamp
2436
+ );
2437
+ });
2438
+ }
2439
+
2440
+ releaseGenerationLease(input) {
2441
+ assertExactKeys(
2442
+ input,
2443
+ { required: ['accountId', 'threadId', 'generationId', 'ownerToken', 'fence'] },
2444
+ 'direct chat generation dispatch lease release'
2445
+ );
2446
+ const accountId = assertIdentifier(input.accountId, 'accountId');
2447
+ const threadId = assertIdentifier(input.threadId, 'threadId');
2448
+ const generationId = assertIdentifier(input.generationId, 'generationId');
2449
+ const proof = assertDispatchLeaseProof({ ownerToken: input.ownerToken, fence: input.fence });
2450
+
2451
+ return this.#transaction(() => {
2452
+ const thread = requireThread(this.#database, accountId, threadId);
2453
+ this.#auditThread(thread);
2454
+ const generation = requireGeneration(this.#database, accountId, threadId, generationId);
2455
+ const timestamp = nowIso(this.#clock);
2456
+ const existing = this.#generationLease(accountId, threadId, generationId);
2457
+ if (
2458
+ existing &&
2459
+ existing.owner_hash === proof.ownerHash &&
2460
+ Number(existing.fence) === proof.fence &&
2461
+ existing.released_at !== null
2462
+ ) {
2463
+ auditDispatchLease(existing);
2464
+ return dispatchLeaseView(existing, timestamp);
2465
+ }
2466
+ if (generation.status !== 'in_progress' || thread.current_generation_id !== generationId) {
2467
+ throw new ConflictError('Only the active in-progress generation can release a dispatch lease.');
2468
+ }
2469
+ const lease = this.#requireGenerationLease(
2470
+ accountId,
2471
+ threadId,
2472
+ generationId,
2473
+ proof,
2474
+ timestamp,
2475
+ ['claimed', 'dispatch_started']
2476
+ );
2477
+ const targetPhase = lease.phase === 'dispatch_started' ? 'interrupted' : 'released';
2478
+ const released = this.#invalidateGenerationLease(
2479
+ accountId,
2480
+ threadId,
2481
+ generationId,
2482
+ timestamp,
2483
+ targetPhase
2484
+ );
2485
+ return dispatchLeaseView(released, timestamp);
2486
+ });
2487
+ }
2488
+
2489
+ appendGenerationDelta(input) {
2490
+ assertExactKeys(
2491
+ input,
2492
+ {
2493
+ required: [
2494
+ 'accountId', 'threadId', 'generationId', 'expectedSequence',
2495
+ 'expectedHash', 'content'
2496
+ ],
2497
+ optional: ['dispatchLease']
2498
+ },
2499
+ 'direct chat generation delta'
2500
+ );
2501
+ const accountId = assertIdentifier(input.accountId, 'accountId');
2502
+ const threadId = assertIdentifier(input.threadId, 'threadId');
2503
+ const generationId = assertIdentifier(input.generationId, 'generationId');
2504
+ const expectedSequence = assertInteger(input.expectedSequence, 'expectedSequence', { min: 0 });
2505
+ const expectedHash = assertEventHash(input.expectedHash, expectedSequence, 'expectedHash');
2506
+ const content = assertUnicodeScalarString(input.content, 'content', {
2507
+ maxBytes: DIRECT_CHAT_LIMITS.deltaBytes
2508
+ });
2509
+ const dispatchLease = assertDispatchLeaseProof(input.dispatchLease);
2510
+ const sequence = expectedSequence + 1;
2511
+ return this.#transaction(() => {
2512
+ const thread = requireThread(this.#database, accountId, threadId);
2513
+ const generation = requireGeneration(this.#database, accountId, threadId, generationId);
2514
+ this.#auditThread(thread);
2515
+ const timestamp = nowIso(this.#clock);
2516
+ this.#requireGenerationLease(
2517
+ accountId,
2518
+ threadId,
2519
+ generationId,
2520
+ dispatchLease,
2521
+ timestamp
2522
+ );
2523
+ const existing = this.#database.prepare(`
2524
+ SELECT * FROM direct_chat_deltas
2525
+ WHERE account_id = ? AND thread_id = ? AND generation_id = ? AND sequence = ?
2526
+ `).get(accountId, threadId, generationId, sequence);
2527
+ if (existing) {
2528
+ if (existing.content !== content.value || existing.previous_hash !== expectedHash) {
2529
+ throw new ConflictError('The generation sequence is already bound to a different hash or content.');
2530
+ }
2531
+ if (calculateDeltaHash(existing) !== existing.delta_hash) {
2532
+ throw new StorageCorruptionError('The naturally idempotent generation delta failed hash validation.');
2533
+ }
2534
+ return deltaView(existing);
2535
+ }
2536
+ if (generation.status !== 'in_progress' || thread.current_generation_id !== generationId) {
2537
+ throw new ConflictError('The generation is no longer in progress.');
2538
+ }
2539
+ if (
2540
+ Number(generation.delta_count) !== expectedSequence ||
2541
+ generation.last_delta_hash !== expectedHash
2542
+ ) {
2543
+ throw new ConflictError('The generation delta cursor is stale.');
2544
+ }
2545
+ if (sequence > DIRECT_CHAT_LIMITS.deltasPerGeneration) {
2546
+ throw new ConflictError('The generation reached its delta count limit.');
2547
+ }
2548
+ if (Number(generation.delta_bytes) + content.bytes > DIRECT_CHAT_LIMITS.generationBytes) {
2549
+ throw new ConflictError('The generation reached its output byte limit.');
2550
+ }
2551
+ if (Number(thread.journal_delta_count) >= DIRECT_CHAT_LIMITS.deltasPerThread) {
2552
+ throw new ConflictError('The thread reached its durable delta count limit; run bounded maintenance.');
2553
+ }
2554
+ if (Number(thread.journal_bytes) + content.bytes > DIRECT_CHAT_LIMITS.journalBytesPerThread) {
2555
+ throw new ConflictError('The thread reached its durable delta byte limit; run bounded maintenance.');
2556
+ }
2557
+ const row = {
2558
+ account_id: accountId,
2559
+ thread_id: threadId,
2560
+ generation_id: generationId,
2561
+ sequence,
2562
+ content: content.value,
2563
+ content_bytes: content.bytes,
2564
+ previous_hash: expectedHash,
2565
+ created_at: timestamp
2566
+ };
2567
+ row.delta_hash = calculateDeltaHash(row);
2568
+ this.#database.prepare(`
2569
+ INSERT INTO direct_chat_deltas(
2570
+ account_id, thread_id, generation_id, sequence, content, content_bytes,
2571
+ previous_hash, delta_hash, created_at
2572
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
2573
+ `).run(
2574
+ accountId, threadId, generationId, sequence, row.content, row.content_bytes,
2575
+ row.previous_hash, row.delta_hash, timestamp
2576
+ );
2577
+ const generationUpdated = this.#database.prepare(`
2578
+ UPDATE direct_chat_generations
2579
+ SET delta_count = delta_count + 1, delta_bytes = delta_bytes + ?,
2580
+ last_delta_hash = ?, updated_at = ?
2581
+ WHERE account_id = ? AND thread_id = ? AND generation_id = ?
2582
+ AND status = 'in_progress' AND delta_count = ? AND last_delta_hash IS ?
2583
+ `).run(
2584
+ content.bytes, row.delta_hash, timestamp,
2585
+ accountId, threadId, generationId, expectedSequence, expectedHash
2586
+ );
2587
+ const threadUpdated = this.#database.prepare(`
2588
+ UPDATE direct_chat_threads
2589
+ SET journal_delta_count = journal_delta_count + 1,
2590
+ journal_bytes = journal_bytes + ?, updated_at = ?
2591
+ WHERE account_id = ? AND thread_id = ? AND current_generation_id = ?
2592
+ `).run(content.bytes, timestamp, accountId, threadId, generationId);
2593
+ if (Number(generationUpdated.changes) !== 1 || Number(threadUpdated.changes) !== 1) {
2594
+ throw new ConflictError('The generation changed concurrently.');
2595
+ }
2596
+ return deltaView(row);
2597
+ });
2598
+ }
2599
+
2600
+ finalizeGeneration(input) {
2601
+ assertExactKeys(
2602
+ input,
2603
+ {
2604
+ required: ['accountId', 'threadId', 'generationId', 'idempotencyKey'],
2605
+ optional: ['dispatchLease']
2606
+ },
2607
+ 'direct chat generation finalization'
2608
+ );
2609
+ const accountId = assertIdentifier(input.accountId, 'accountId');
2610
+ const threadId = assertIdentifier(input.threadId, 'threadId');
2611
+ const generationId = assertIdentifier(input.generationId, 'generationId');
2612
+ const dispatchLease = assertDispatchLeaseProof(input.dispatchLease);
2613
+ const request = { accountId, threadId, generationId };
2614
+ if (dispatchLease !== null) request.dispatchLease = dispatchLease;
2615
+ const replayGeneration = () => generationView(
2616
+ requireGeneration(this.#database, accountId, threadId, generationId)
2617
+ );
2618
+
2619
+ return this.#idempotentMutation(
2620
+ {
2621
+ accountId,
2622
+ threadId,
2623
+ operation: 'generation.finalize',
2624
+ idempotencyKey: input.idempotencyKey,
2625
+ request,
2626
+ resourceKind: 'generation',
2627
+ resourceId: generationId,
2628
+ versionOf: statusVersion,
2629
+ digestOf: terminalGenerationDigestInput
2630
+ },
2631
+ () => {
2632
+ const thread = requireThread(this.#database, accountId, threadId);
2633
+ this.#auditThread(thread);
2634
+ const generation = requireGeneration(this.#database, accountId, threadId, generationId);
2635
+ const timestamp = nowIso(this.#clock);
2636
+ this.#requireGenerationLease(
2637
+ accountId,
2638
+ threadId,
2639
+ generationId,
2640
+ dispatchLease,
2641
+ timestamp
2642
+ );
2643
+ if (generation.status === 'completed') return generationView(generation);
2644
+ if (generation.status !== 'in_progress' || thread.current_generation_id !== generationId) {
2645
+ throw new ConflictError('Only an in-progress generation can be finalized.');
2646
+ }
2647
+ if (Number(generation.delta_count) === 0) {
2648
+ throw new ConflictError('An empty generation cannot become an assistant ledger turn.');
2649
+ }
2650
+ if (
2651
+ Number(thread.ledger_revision) !== Number(generation.source_revision) ||
2652
+ thread.ledger_hash !== generation.source_hash
2653
+ ) {
2654
+ throw new ConflictError('The chat ledger changed after generation began.');
2655
+ }
2656
+ if (Number(thread.message_count) >= DIRECT_CHAT_LIMITS.messagesPerThread) {
2657
+ throw new ConflictError('The direct-chat thread reached its message limit.');
2658
+ }
2659
+ const deltas = auditGeneration(this.#database, generation);
2660
+ const content = deltas.map((delta) => delta.content).join('');
2661
+ const contentBytes = utf8Bytes(content);
2662
+ if (
2663
+ contentBytes !== Number(generation.delta_bytes) ||
2664
+ contentBytes < 1 ||
2665
+ contentBytes > DIRECT_CHAT_LIMITS.messageBytes ||
2666
+ Number(thread.ledger_bytes) + contentBytes > DIRECT_CHAT_LIMITS.ledgerBytesPerThread
2667
+ ) {
2668
+ throw new ConflictError('The completed generation cannot fit in the append-only chat ledger.');
2669
+ }
2670
+ const message = {
2671
+ account_id: accountId,
2672
+ thread_id: threadId,
2673
+ message_id: generation.assistant_message_id,
2674
+ revision: Number(generation.source_revision) + 1,
2675
+ role: 'assistant',
2676
+ content,
2677
+ content_bytes: contentBytes,
2678
+ previous_hash: generation.source_hash,
2679
+ generation_id: generationId,
2680
+ created_at: timestamp
2681
+ };
2682
+ message.message_hash = calculateMessageHash(message);
2683
+ this.#database.prepare(`
2684
+ INSERT INTO direct_chat_messages(
2685
+ account_id, thread_id, message_id, revision, role, content, content_bytes,
2686
+ previous_hash, message_hash, generation_id, created_at
2687
+ ) VALUES (?, ?, ?, ?, 'assistant', ?, ?, ?, ?, ?, ?)
2688
+ `).run(
2689
+ accountId, threadId, message.message_id, message.revision, content, contentBytes,
2690
+ message.previous_hash, message.message_hash, generationId, timestamp
2691
+ );
2692
+ const generationUpdated = this.#database.prepare(`
2693
+ UPDATE direct_chat_generations
2694
+ SET status = 'completed', final_revision = ?, final_hash = ?,
2695
+ updated_at = ?, terminal_at = ?
2696
+ WHERE account_id = ? AND thread_id = ? AND generation_id = ?
2697
+ AND status = 'in_progress'
2698
+ `).run(
2699
+ message.revision, message.message_hash, timestamp, timestamp,
2700
+ accountId, threadId, generationId
2701
+ );
2702
+ const threadUpdated = this.#database.prepare(`
2703
+ UPDATE direct_chat_threads
2704
+ SET ledger_revision = ?, ledger_hash = ?, message_count = message_count + 1,
2705
+ ledger_bytes = ledger_bytes + ?, current_generation_id = NULL, updated_at = ?
2706
+ WHERE account_id = ? AND thread_id = ? AND current_generation_id = ?
2707
+ AND ledger_revision = ? AND ledger_hash = ?
2708
+ `).run(
2709
+ message.revision, message.message_hash, contentBytes, timestamp,
2710
+ accountId, threadId, generationId,
2711
+ generation.source_revision, generation.source_hash
2712
+ );
2713
+ if (Number(generationUpdated.changes) !== 1 || Number(threadUpdated.changes) !== 1) {
2714
+ throw new ConflictError('The generation changed concurrently.');
2715
+ }
2716
+ this.#invalidateGenerationLease(accountId, threadId, generationId, timestamp);
2717
+ return generationView(requireGeneration(this.#database, accountId, threadId, generationId));
2718
+ },
2719
+ replayGeneration
2720
+ );
2721
+ }
2722
+
2723
+ cancelGeneration(input) {
2724
+ return this.#terminateGeneration(input, 'cancelled');
2725
+ }
2726
+
2727
+ failGeneration(input) {
2728
+ return this.#terminateGeneration(input, 'failed');
2729
+ }
2730
+
2731
+ #terminateGeneration(input, targetStatus) {
2732
+ const required = targetStatus === 'failed'
2733
+ ? ['accountId', 'threadId', 'generationId', 'failureCode', 'idempotencyKey']
2734
+ : ['accountId', 'threadId', 'generationId', 'idempotencyKey'];
2735
+ const optional = targetStatus === 'failed' ? ['dispatchLease'] : [];
2736
+ assertExactKeys(input, { required, optional }, `direct chat generation ${targetStatus}`);
2737
+ const accountId = assertIdentifier(input.accountId, 'accountId');
2738
+ const threadId = assertIdentifier(input.threadId, 'threadId');
2739
+ const generationId = assertIdentifier(input.generationId, 'generationId');
2740
+ const failureCode = targetStatus === 'failed' ? assertFailureCode(input.failureCode) : null;
2741
+ const dispatchLease = targetStatus === 'failed'
2742
+ ? assertDispatchLeaseProof(input.dispatchLease)
2743
+ : null;
2744
+ const request = { accountId, threadId, generationId, failureCode };
2745
+ if (dispatchLease !== null) request.dispatchLease = dispatchLease;
2746
+ const replayGeneration = () => generationView(
2747
+ requireGeneration(this.#database, accountId, threadId, generationId)
2748
+ );
2749
+
2750
+ return this.#idempotentMutation(
2751
+ {
2752
+ accountId,
2753
+ threadId,
2754
+ operation: `generation.${targetStatus === 'cancelled' ? 'cancel' : 'fail'}`,
2755
+ idempotencyKey: input.idempotencyKey,
2756
+ request,
2757
+ resourceKind: 'generation',
2758
+ resourceId: generationId,
2759
+ versionOf: statusVersion,
2760
+ digestOf: terminalGenerationDigestInput
2761
+ },
2762
+ () => {
2763
+ const thread = requireThread(this.#database, accountId, threadId);
2764
+ this.#auditThread(thread);
2765
+ const generation = requireGeneration(this.#database, accountId, threadId, generationId);
2766
+ const timestamp = nowIso(this.#clock);
2767
+ if (targetStatus === 'failed') {
2768
+ const lease = this.#generationLease(accountId, threadId, generationId);
2769
+ if (
2770
+ dispatchLease === null && lease !== null &&
2771
+ lease.phase === 'interrupted' && lease.released_at !== null &&
2772
+ failureCode === 'provider_unavailable'
2773
+ ) {
2774
+ auditDispatchLease(lease);
2775
+ } else {
2776
+ this.#requireGenerationLease(
2777
+ accountId,
2778
+ threadId,
2779
+ generationId,
2780
+ dispatchLease,
2781
+ timestamp,
2782
+ dispatchLease !== null && PRE_DISPATCH_FAILURE_CODES.has(failureCode)
2783
+ ? ['claimed', 'dispatch_started']
2784
+ : ['dispatch_started']
2785
+ );
2786
+ }
2787
+ }
2788
+ if (generation.status === targetStatus) {
2789
+ if (generation.failure_code !== failureCode) {
2790
+ throw new ConflictError('The terminal generation has a different failure category.');
2791
+ }
2792
+ return generationView(generation);
2793
+ }
2794
+ if (generation.status !== 'in_progress' || thread.current_generation_id !== generationId) {
2795
+ throw new ConflictError('Only an in-progress generation can be terminated.');
2796
+ }
2797
+ const generationUpdated = this.#database.prepare(`
2798
+ UPDATE direct_chat_generations
2799
+ SET status = ?, failure_code = ?, updated_at = ?, terminal_at = ?
2800
+ WHERE account_id = ? AND thread_id = ? AND generation_id = ?
2801
+ AND status = 'in_progress'
2802
+ `).run(
2803
+ targetStatus, failureCode, timestamp, timestamp,
2804
+ accountId, threadId, generationId
2805
+ );
2806
+ const threadUpdated = this.#database.prepare(`
2807
+ UPDATE direct_chat_threads SET current_generation_id = NULL, updated_at = ?
2808
+ WHERE account_id = ? AND thread_id = ? AND current_generation_id = ?
2809
+ `).run(timestamp, accountId, threadId, generationId);
2810
+ if (Number(generationUpdated.changes) !== 1 || Number(threadUpdated.changes) !== 1) {
2811
+ throw new ConflictError('The generation changed concurrently.');
2812
+ }
2813
+ this.#invalidateGenerationLease(accountId, threadId, generationId, timestamp);
2814
+ return generationView(requireGeneration(this.#database, accountId, threadId, generationId));
2815
+ },
2816
+ replayGeneration
2817
+ );
2818
+ }
2819
+
2820
+ getGeneration(input) {
2821
+ this.#assertOpen();
2822
+ assertExactKeys(
2823
+ input,
2824
+ { required: ['accountId', 'threadId', 'generationId'] },
2825
+ 'direct chat generation lookup'
2826
+ );
2827
+ const accountId = assertIdentifier(input.accountId, 'accountId');
2828
+ const threadId = assertIdentifier(input.threadId, 'threadId');
2829
+ const generationId = assertIdentifier(input.generationId, 'generationId');
2830
+ return this.#readTransaction(() => {
2831
+ const thread = this.#database.prepare(`
2832
+ SELECT * FROM direct_chat_threads WHERE account_id = ? AND thread_id = ?
2833
+ `).get(accountId, threadId);
2834
+ if (!thread) return null;
2835
+ this.#auditThread(thread);
2836
+ const row = this.#database.prepare(`
2837
+ SELECT * FROM direct_chat_generations
2838
+ WHERE account_id = ? AND thread_id = ? AND generation_id = ?
2839
+ `).get(accountId, threadId, generationId);
2840
+ if (!row) return null;
2841
+ return generationView(row);
2842
+ });
2843
+ }
2844
+
2845
+ replayGeneration(input) {
2846
+ this.#assertOpen();
2847
+ assertExactKeys(
2848
+ input,
2849
+ {
2850
+ required: ['accountId', 'threadId', 'generationId'],
2851
+ optional: ['afterSequence', 'limit']
2852
+ },
2853
+ 'direct chat generation replay'
2854
+ );
2855
+ const accountId = assertIdentifier(input.accountId, 'accountId');
2856
+ const threadId = assertIdentifier(input.threadId, 'threadId');
2857
+ const generationId = assertIdentifier(input.generationId, 'generationId');
2858
+ const afterSequence = assertInteger(input.afterSequence ?? 0, 'afterSequence', { min: 0 });
2859
+ const limit = assertInteger(input.limit ?? 200, 'limit', { min: 1, max: DIRECT_CHAT_LIMITS.listPage });
2860
+ return this.#readTransaction(() => {
2861
+ const thread = requireThread(this.#database, accountId, threadId);
2862
+ this.#auditThread(thread);
2863
+ const generation = requireGeneration(this.#database, accountId, threadId, generationId);
2864
+ const rows = this.#database.prepare(`
2865
+ SELECT * FROM direct_chat_deltas
2866
+ WHERE account_id = ? AND thread_id = ? AND generation_id = ? AND sequence > ?
2867
+ ORDER BY sequence
2868
+ LIMIT ?
2869
+ `).all(accountId, threadId, generationId, afterSequence, limit);
2870
+ return {
2871
+ generation: generationView(generation),
2872
+ deltas: rows.map(deltaView),
2873
+ hasMore: rows.length === limit && Number(rows[rows.length - 1].sequence) < Number(generation.delta_count)
2874
+ };
2875
+ });
2876
+ }
2877
+
2878
+ listMessages(input) {
2879
+ this.#assertOpen();
2880
+ assertExactKeys(
2881
+ input,
2882
+ { required: ['accountId', 'threadId'], optional: ['afterRevision', 'limit'] },
2883
+ 'direct chat message query'
2884
+ );
2885
+ const accountId = assertIdentifier(input.accountId, 'accountId');
2886
+ const threadId = assertIdentifier(input.threadId, 'threadId');
2887
+ const afterRevision = assertInteger(input.afterRevision ?? 0, 'afterRevision', { min: 0 });
2888
+ const limit = assertInteger(input.limit ?? 100, 'limit', { min: 1, max: DIRECT_CHAT_LIMITS.listPage });
2889
+ return this.#readTransaction(() => {
2890
+ const thread = requireThread(this.#database, accountId, threadId);
2891
+ this.#auditThread(thread);
2892
+ const rows = this.#database.prepare(`
2893
+ SELECT * FROM direct_chat_messages
2894
+ WHERE account_id = ? AND thread_id = ? AND revision > ?
2895
+ ORDER BY revision
2896
+ LIMIT ?
2897
+ `).all(accountId, threadId, afterRevision, limit);
2898
+ if (this.#schemaVersion < 3 || rows.length === 0) return rows.map((row) => messageView(row));
2899
+ const attachmentRows = this.#database.prepare(`
2900
+ SELECT attachment.account_id, attachment.thread_id, attachment.attachment_id,
2901
+ attachment.message_id, attachment.position, attachment.media_type, attachment.byte_length,
2902
+ attachment.width, attachment.height, attachment.content_sha256,
2903
+ attachment.created_at, length(attachment.content) AS content_length
2904
+ FROM direct_chat_attachments AS attachment
2905
+ JOIN direct_chat_messages AS message
2906
+ ON message.account_id = attachment.account_id
2907
+ AND message.thread_id = attachment.thread_id
2908
+ AND message.message_id = attachment.message_id
2909
+ WHERE attachment.account_id = ? AND attachment.thread_id = ?
2910
+ AND message.revision > ? AND message.revision <= ?
2911
+ ORDER BY message.revision, attachment.position
2912
+ `).all(accountId, threadId, afterRevision, Number(rows.at(-1).revision));
2913
+ const byMessage = new Map();
2914
+ for (const row of attachmentRows) {
2915
+ const attachment = attachmentDescriptorFromRow(row);
2916
+ const owned = byMessage.get(attachment.messageId) ?? [];
2917
+ owned.push(attachment);
2918
+ byMessage.set(attachment.messageId, owned);
2919
+ }
2920
+ return rows.map((row) => messageView(row, byMessage.get(row.message_id) ?? []));
2921
+ });
2922
+ }
2923
+
2924
+ getVisionAttachment(input) {
2925
+ this.#assertOpen();
2926
+ assertExactKeys(
2927
+ input,
2928
+ { required: ['accountId', 'threadId', 'attachmentId'] },
2929
+ 'direct chat vision attachment lookup'
2930
+ );
2931
+ const accountId = assertIdentifier(input.accountId, 'accountId');
2932
+ const threadId = assertIdentifier(input.threadId, 'threadId');
2933
+ const attachmentId = assertIdentifier(input.attachmentId, 'attachmentId');
2934
+ if (this.#schemaVersion < 3) return null;
2935
+ return this.#readTransaction(() => {
2936
+ const thread = this.#database.prepare(`
2937
+ SELECT * FROM direct_chat_threads WHERE account_id = ? AND thread_id = ?
2938
+ `).get(accountId, threadId);
2939
+ if (!thread) return null;
2940
+ this.#auditThread(thread);
2941
+ return attachmentFromRow(this.#database.prepare(`
2942
+ SELECT * FROM direct_chat_attachments
2943
+ WHERE account_id = ? AND thread_id = ? AND attachment_id = ?
2944
+ `).get(accountId, threadId, attachmentId));
2945
+ });
2946
+ }
2947
+
2948
+ getLatestVisionAttachments(input) {
2949
+ this.#assertOpen();
2950
+ assertExactKeys(
2951
+ input,
2952
+ { required: ['accountId', 'threadId', 'sourceRevision'] },
2953
+ 'latest direct chat vision attachments lookup'
2954
+ );
2955
+ const accountId = assertIdentifier(input.accountId, 'accountId');
2956
+ const threadId = assertIdentifier(input.threadId, 'threadId');
2957
+ const sourceRevision = assertInteger(input.sourceRevision, 'sourceRevision', { min: 1, max: 2_000 });
2958
+ if (this.#schemaVersion < 3) return Object.freeze([]);
2959
+ return this.#readTransaction(() => {
2960
+ const thread = this.#database.prepare(`
2961
+ SELECT * FROM direct_chat_threads WHERE account_id = ? AND thread_id = ?
2962
+ `).get(accountId, threadId);
2963
+ if (!thread) return Object.freeze([]);
2964
+ this.#auditThread(thread);
2965
+ const owner = this.#database.prepare(`
2966
+ SELECT attachment.message_id
2967
+ FROM direct_chat_attachments AS attachment
2968
+ JOIN direct_chat_messages AS message
2969
+ ON message.account_id = attachment.account_id
2970
+ AND message.thread_id = attachment.thread_id
2971
+ AND message.message_id = attachment.message_id
2972
+ WHERE attachment.account_id = ? AND attachment.thread_id = ?
2973
+ AND message.revision <= ?
2974
+ ORDER BY message.revision DESC
2975
+ LIMIT 1
2976
+ `).get(accountId, threadId, sourceRevision);
2977
+ if (!owner) return Object.freeze([]);
2978
+ return Object.freeze(this.#database.prepare(`
2979
+ SELECT * FROM direct_chat_attachments
2980
+ WHERE account_id = ? AND thread_id = ? AND message_id = ?
2981
+ ORDER BY position
2982
+ `).all(accountId, threadId, owner.message_id).map(attachmentFromRow));
2983
+ });
2984
+ }
2985
+
2986
+ getLatestVisionAttachment(input) {
2987
+ const attachments = this.getLatestVisionAttachments(input);
2988
+ return attachments[0] ?? null;
2989
+ }
2990
+
2991
+ createCompactionSnapshot(input) {
2992
+ assertExactKeys(
2993
+ input,
2994
+ {
2995
+ required: [
2996
+ 'accountId', 'threadId', 'snapshotId', 'sourceStartRevision',
2997
+ 'sourceStartHash', 'sourceEndRevision', 'sourceEndHash',
2998
+ 'summaryText', 'idempotencyKey'
2999
+ ]
3000
+ },
3001
+ 'direct chat compaction snapshot'
3002
+ );
3003
+ const accountId = assertIdentifier(input.accountId, 'accountId');
3004
+ const threadId = assertIdentifier(input.threadId, 'threadId');
3005
+ const snapshotId = assertIdentifier(input.snapshotId, 'snapshotId');
3006
+ const sourceStartRevision = assertInteger(input.sourceStartRevision, 'sourceStartRevision', { min: 1 });
3007
+ const sourceEndRevision = assertInteger(input.sourceEndRevision, 'sourceEndRevision', {
3008
+ min: sourceStartRevision
3009
+ });
3010
+ const sourceStartHash = assertEventHash(input.sourceStartHash, sourceStartRevision, 'sourceStartHash');
3011
+ const sourceEndHash = assertEventHash(input.sourceEndHash, sourceEndRevision, 'sourceEndHash');
3012
+ const summary = assertUnicodeScalarString(input.summaryText, 'summaryText', {
3013
+ maxBytes: DIRECT_CHAT_LIMITS.summaryBytes
3014
+ });
3015
+ const summaryHash = sha256(summary.value);
3016
+ const request = {
3017
+ accountId, threadId, snapshotId, sourceStartRevision, sourceStartHash,
3018
+ sourceEndRevision, sourceEndHash, summaryText: summary.value
3019
+ };
3020
+ const replaySnapshot = () => {
3021
+ const row = this.#database.prepare(`
3022
+ SELECT * FROM direct_chat_compactions
3023
+ WHERE account_id = ? AND thread_id = ? AND snapshot_id = ?
3024
+ `).get(accountId, threadId, snapshotId);
3025
+ if (!row) throw new ConflictError('The idempotent compaction snapshot is no longer present.');
3026
+ return compactionView(row);
3027
+ };
3028
+
3029
+ return this.#idempotentMutation(
3030
+ {
3031
+ accountId,
3032
+ threadId,
3033
+ operation: 'compaction.create',
3034
+ idempotencyKey: input.idempotencyKey,
3035
+ request,
3036
+ resourceKind: 'compaction',
3037
+ resourceId: snapshotId,
3038
+ versionOf: (result) => result.sourceEndRevision
3039
+ },
3040
+ () => {
3041
+ const thread = requireThread(this.#database, accountId, threadId);
3042
+ this.#auditThread(thread);
3043
+ const existing = this.#database.prepare(`
3044
+ SELECT * FROM direct_chat_compactions
3045
+ WHERE account_id = ? AND thread_id = ? AND snapshot_id = ?
3046
+ `).get(accountId, threadId, snapshotId);
3047
+ if (existing) {
3048
+ if (
3049
+ Number(existing.source_start_revision) !== sourceStartRevision ||
3050
+ existing.source_start_hash !== sourceStartHash ||
3051
+ Number(existing.source_end_revision) !== sourceEndRevision ||
3052
+ existing.source_end_hash !== sourceEndHash || existing.summary_text !== summary.value ||
3053
+ existing.summary_hash !== summaryHash
3054
+ ) {
3055
+ throw new ConflictError('The compaction snapshot identifier is already bound to different data.');
3056
+ }
3057
+ return compactionView(existing);
3058
+ }
3059
+ if (thread.current_generation_id !== null) {
3060
+ throw new ConflictError('A compaction snapshot cannot be recorded during an active generation.');
3061
+ }
3062
+ if (sourceEndRevision > Number(thread.ledger_revision)) {
3063
+ throw new ConflictError('The compaction range extends beyond the raw chat ledger.');
3064
+ }
3065
+ const count = Number(this.#database.prepare(`
3066
+ SELECT count(*) AS count FROM direct_chat_compactions
3067
+ WHERE account_id = ? AND thread_id = ?
3068
+ `).get(accountId, threadId).count);
3069
+ if (count >= DIRECT_CHAT_LIMITS.compactionsPerThread) {
3070
+ throw new ConflictError('The thread reached its compaction snapshot limit.');
3071
+ }
3072
+ const endpoints = this.#database.prepare(`
3073
+ SELECT revision, message_hash FROM direct_chat_messages
3074
+ WHERE account_id = ? AND thread_id = ? AND revision IN (?, ?)
3075
+ ORDER BY revision
3076
+ `).all(accountId, threadId, sourceStartRevision, sourceEndRevision);
3077
+ const first = endpoints.find((row) => Number(row.revision) === sourceStartRevision);
3078
+ const last = endpoints.find((row) => Number(row.revision) === sourceEndRevision);
3079
+ if (!first || first.message_hash !== sourceStartHash || !last || last.message_hash !== sourceEndHash) {
3080
+ throw new ConflictError('The compaction snapshot hashes do not match the exact raw ledger range.');
3081
+ }
3082
+ const timestamp = nowIso(this.#clock);
3083
+ this.#database.prepare(`
3084
+ INSERT INTO direct_chat_compactions(
3085
+ account_id, thread_id, snapshot_id, source_start_revision, source_start_hash,
3086
+ source_end_revision, source_end_hash, summary_text, summary_bytes, summary_hash, created_at
3087
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3088
+ `).run(
3089
+ accountId, threadId, snapshotId, sourceStartRevision, sourceStartHash,
3090
+ sourceEndRevision, sourceEndHash, summary.value, summary.bytes, summaryHash, timestamp
3091
+ );
3092
+ return replaySnapshot();
3093
+ },
3094
+ replaySnapshot
3095
+ );
3096
+ }
3097
+
3098
+ getLatestCompactionSnapshot(accountId, threadId) {
3099
+ this.#assertOpen();
3100
+ assertIdentifier(accountId, 'accountId');
3101
+ assertIdentifier(threadId, 'threadId');
3102
+ return this.#readTransaction(() => {
3103
+ const thread = this.#database.prepare(`
3104
+ SELECT * FROM direct_chat_threads WHERE account_id = ? AND thread_id = ?
3105
+ `).get(accountId, threadId);
3106
+ if (!thread) return null;
3107
+ this.#auditThread(thread);
3108
+ return compactionView(this.#database.prepare(`
3109
+ SELECT * FROM direct_chat_compactions
3110
+ WHERE account_id = ? AND thread_id = ?
3111
+ ORDER BY source_end_revision DESC, created_at DESC, snapshot_id DESC
3112
+ LIMIT 1
3113
+ `).get(accountId, threadId));
3114
+ });
3115
+ }
3116
+
3117
+ runMaintenance(input) {
3118
+ assertExactKeys(
3119
+ input,
3120
+ { required: ['accountId'], optional: ['terminalBefore', 'snapshotBefore', 'limit'] },
3121
+ 'direct chat maintenance'
3122
+ );
3123
+ const accountId = assertIdentifier(input.accountId, 'accountId');
3124
+ const timestamp = nowIso(this.#clock);
3125
+ const retentionCutoff = addMilliseconds(timestamp, -DIRECT_CHAT_TERMINAL_DELTA_RETENTION_MS);
3126
+ const requestedTerminalBefore = input.terminalBefore === undefined
3127
+ ? retentionCutoff
3128
+ : assertCanonicalIsoTimestamp(input.terminalBefore, 'terminalBefore');
3129
+ const terminalBefore = requestedTerminalBefore < retentionCutoff
3130
+ ? requestedTerminalBefore
3131
+ : retentionCutoff;
3132
+ const requestedSnapshotBefore = input.snapshotBefore === undefined
3133
+ ? timestamp
3134
+ : assertCanonicalIsoTimestamp(input.snapshotBefore, 'snapshotBefore');
3135
+ const snapshotBefore = requestedSnapshotBefore < timestamp ? requestedSnapshotBefore : timestamp;
3136
+ const limit = assertInteger(input.limit ?? DIRECT_CHAT_LIMITS.cleanupRows, 'limit', {
3137
+ min: 1,
3138
+ max: DIRECT_CHAT_LIMITS.cleanupRows
3139
+ });
3140
+
3141
+ return this.#transaction(() => {
3142
+ let remaining = limit;
3143
+ let terminalGenerationsPruned = 0;
3144
+ let deltaRowsRemoved = 0;
3145
+ let deltaBytesReleased = 0;
3146
+ let compactionSnapshotsRemoved = 0;
3147
+ const touchedThreads = new Set();
3148
+
3149
+ const candidates = this.#database.prepare(`
3150
+ SELECT g.*
3151
+ FROM direct_chat_generations AS g
3152
+ WHERE g.account_id = ?
3153
+ AND g.status = 'completed'
3154
+ AND g.deltas_pruned = 0
3155
+ AND g.terminal_at <= ?
3156
+ AND EXISTS (
3157
+ SELECT 1 FROM direct_chat_generations AS newer
3158
+ WHERE newer.account_id = g.account_id
3159
+ AND newer.thread_id = g.thread_id
3160
+ AND newer.status = 'completed'
3161
+ AND (
3162
+ newer.terminal_at > g.terminal_at
3163
+ OR (newer.terminal_at = g.terminal_at AND newer.generation_id > g.generation_id)
3164
+ )
3165
+ )
3166
+ ORDER BY g.terminal_at, g.thread_id, g.generation_id
3167
+ LIMIT ?
3168
+ `).all(accountId, terminalBefore, remaining);
3169
+ for (const generation of candidates) {
3170
+ const thread = requireThread(this.#database, accountId, generation.thread_id);
3171
+ this.#auditThread(thread);
3172
+ const marked = this.#database.prepare(`
3173
+ UPDATE direct_chat_generations
3174
+ SET deltas_pruned = 1, pruned_at = ?
3175
+ WHERE account_id = ? AND thread_id = ? AND generation_id = ?
3176
+ AND status = 'completed' AND deltas_pruned = 0
3177
+ `).run(timestamp, accountId, generation.thread_id, generation.generation_id);
3178
+ if (Number(marked.changes) !== 1) {
3179
+ throw new ConflictError('A terminal generation changed during maintenance.');
3180
+ }
3181
+ const deleted = this.#database.prepare(`
3182
+ DELETE FROM direct_chat_deltas
3183
+ WHERE account_id = ? AND thread_id = ? AND generation_id = ?
3184
+ `).run(accountId, generation.thread_id, generation.generation_id);
3185
+ if (Number(deleted.changes) !== Number(generation.delta_count)) {
3186
+ throw new StorageCorruptionError('Terminal delta pruning removed an unexpected row count.');
3187
+ }
3188
+ const counters = this.#database.prepare(`
3189
+ UPDATE direct_chat_threads
3190
+ SET journal_delta_count = journal_delta_count - ?, journal_bytes = journal_bytes - ?
3191
+ WHERE account_id = ? AND thread_id = ?
3192
+ AND current_generation_id IS NOT ?
3193
+ AND journal_delta_count >= ? AND journal_bytes >= ?
3194
+ `).run(
3195
+ generation.delta_count,
3196
+ generation.delta_bytes,
3197
+ accountId,
3198
+ generation.thread_id,
3199
+ generation.generation_id,
3200
+ generation.delta_count,
3201
+ generation.delta_bytes
3202
+ );
3203
+ if (Number(counters.changes) !== 1) {
3204
+ throw new StorageCorruptionError('Terminal delta pruning could not safely release thread counters.');
3205
+ }
3206
+ terminalGenerationsPruned += 1;
3207
+ deltaRowsRemoved += Number(deleted.changes);
3208
+ deltaBytesReleased += Number(generation.delta_bytes);
3209
+ remaining -= 1;
3210
+ touchedThreads.add(generation.thread_id);
3211
+ }
3212
+
3213
+ if (remaining > 0) {
3214
+ const snapshots = this.#database.prepare(`
3215
+ SELECT old.account_id, old.thread_id, old.snapshot_id
3216
+ FROM direct_chat_compactions AS old
3217
+ WHERE old.account_id = ? AND old.created_at <= ?
3218
+ AND EXISTS (
3219
+ SELECT 1 FROM direct_chat_compactions AS newer
3220
+ WHERE newer.account_id = old.account_id
3221
+ AND newer.thread_id = old.thread_id
3222
+ AND (
3223
+ newer.source_end_revision > old.source_end_revision
3224
+ OR (
3225
+ newer.source_end_revision = old.source_end_revision
3226
+ AND newer.created_at > old.created_at
3227
+ )
3228
+ OR (
3229
+ newer.source_end_revision = old.source_end_revision
3230
+ AND newer.created_at = old.created_at
3231
+ AND newer.snapshot_id > old.snapshot_id
3232
+ )
3233
+ )
3234
+ )
3235
+ ORDER BY old.created_at, old.thread_id, old.snapshot_id
3236
+ LIMIT ?
3237
+ `).all(accountId, snapshotBefore, remaining);
3238
+ for (const snapshot of snapshots) {
3239
+ const deleted = this.#database.prepare(`
3240
+ DELETE FROM direct_chat_compactions
3241
+ WHERE account_id = ? AND thread_id = ? AND snapshot_id = ?
3242
+ `).run(accountId, snapshot.thread_id, snapshot.snapshot_id);
3243
+ if (Number(deleted.changes) !== 1) {
3244
+ throw new ConflictError('A compaction snapshot changed during maintenance.');
3245
+ }
3246
+ compactionSnapshotsRemoved += 1;
3247
+ remaining -= 1;
3248
+ touchedThreads.add(snapshot.thread_id);
3249
+ }
3250
+ }
3251
+
3252
+ const idempotencyReceiptsRemoved = remaining > 0
3253
+ ? this.#deleteExpiredReceipts(timestamp, remaining, accountId)
3254
+ : 0;
3255
+ for (const threadId of touchedThreads) {
3256
+ this.#auditThread(requireThread(this.#database, accountId, threadId));
3257
+ }
3258
+ return {
3259
+ terminalGenerationsPruned,
3260
+ deltaRowsRemoved,
3261
+ deltaBytesReleased,
3262
+ compactionSnapshotsRemoved,
3263
+ idempotencyReceiptsRemoved,
3264
+ chatMessagesRemoved: 0,
3265
+ activeGenerationsRemoved: 0
3266
+ };
3267
+ });
3268
+ }
3269
+
3270
+ cleanupExpiredIdempotency(input = {}) {
3271
+ assertExactKeys(input, { optional: ['before', 'limit'] }, 'direct chat cleanup');
3272
+ const now = nowIso(this.#clock);
3273
+ const requestedBefore = input.before === undefined
3274
+ ? now
3275
+ : assertCanonicalIsoTimestamp(input.before, 'before');
3276
+ const before = requestedBefore < now ? requestedBefore : now;
3277
+ const limit = assertInteger(input.limit ?? DIRECT_CHAT_LIMITS.cleanupRows, 'limit', {
3278
+ min: 1,
3279
+ max: DIRECT_CHAT_LIMITS.cleanupRows
3280
+ });
3281
+ return this.#transaction(() => ({
3282
+ idempotencyReceiptsRemoved: this.#deleteExpiredReceipts(before, limit),
3283
+ chatRowsRemoved: 0
3284
+ }));
3285
+ }
3286
+
3287
+ healthCheck() {
3288
+ this.#assertOpen();
3289
+ return checkOpenSqliteHealth(this.#database, {
3290
+ expectedApplicationId: CHAT_SQLITE_APPLICATION_ID,
3291
+ allowedSchemaVersions: [this.#schemaVersion]
3292
+ });
3293
+ }
3294
+
3295
+ close() {
3296
+ if (this.#closed) return;
3297
+ try {
3298
+ assertSecureDatabaseFile(this.#databasePath);
3299
+ } finally {
3300
+ try {
3301
+ this.#database.close();
3302
+ } finally {
3303
+ this.#clearAuditedThreads();
3304
+ this.#closed = true;
3305
+ }
3306
+ }
3307
+ }
3308
+ }