@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.
- package/LICENSE +22 -0
- package/README.md +438 -0
- package/docs/architecture.md +503 -0
- package/package.json +43 -0
- package/src/aginti-adapter.js +602 -0
- package/src/chat-context.js +1020 -0
- package/src/chat-migrations.js +947 -0
- package/src/chat-store.js +3308 -0
- package/src/cli.js +134 -0
- package/src/cloud-server.js +2043 -0
- package/src/contracts.js +103 -0
- package/src/deterministic-context-summarizer.js +254 -0
- package/src/direct-chat-capability-limits.js +66 -0
- package/src/direct-chat-contract.js +3 -0
- package/src/errors.js +50 -0
- package/src/http-contract.js +592 -0
- package/src/index.js +88 -0
- package/src/localllm-connector.js +667 -0
- package/src/migrations.js +231 -0
- package/src/operator-health.js +184 -0
- package/src/password-verifier.js +131 -0
- package/src/service-config.js +547 -0
- package/src/service.js +408 -0
- package/src/sqlite-health.js +83 -0
- package/src/storage-path.js +130 -0
- package/src/store.js +914 -0
- package/src/validation.js +181 -0
- package/src/vision-attachment.js +404 -0
- package/src/web/aginti-client.js +552 -0
- package/src/web/aginti-protocol.js +1146 -0
- package/src/web/asset-map.js +462 -0
- package/src/web/browser-app.js +6491 -0
- package/src/web/cloud-session-client.js +427 -0
- package/src/web/direct-chat-client.js +1482 -0
- package/src/web/index.js +10 -0
- package/src/web/presentation-state.js +107 -0
- package/src/web/pwa-assets.js +854 -0
- package/src/web/pwa-update-handoff-store.js +179 -0
- package/src/web/safe-rendering.js +836 -0
- package/src/web/vision-image-client.js +546 -0
- package/src/web/vision-image-sanitizer.js +168 -0
- package/src/web/web-release.js +28 -0
package/src/store.js
ADDED
|
@@ -0,0 +1,914 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ConflictError,
|
|
6
|
+
IdempotencyConflictError,
|
|
7
|
+
NotFoundError,
|
|
8
|
+
StorageCorruptionError,
|
|
9
|
+
ValidationError
|
|
10
|
+
} from './errors.js';
|
|
11
|
+
import {
|
|
12
|
+
LATEST_SCHEMA_VERSION,
|
|
13
|
+
SQLITE_APPLICATION_ID,
|
|
14
|
+
applyMigrations
|
|
15
|
+
} from './migrations.js';
|
|
16
|
+
import { checkOpenSqliteHealth } from './sqlite-health.js';
|
|
17
|
+
import { assertSecureDatabaseFile, prepareSecureDatabasePath } from './storage-path.js';
|
|
18
|
+
import {
|
|
19
|
+
assertBoolean,
|
|
20
|
+
assertBoundedString,
|
|
21
|
+
assertCanonicalIsoTimestamp,
|
|
22
|
+
assertEventHash,
|
|
23
|
+
assertExactKeys,
|
|
24
|
+
assertIdentifier,
|
|
25
|
+
assertIdempotencyKey,
|
|
26
|
+
assertInteger,
|
|
27
|
+
canonicalJson,
|
|
28
|
+
digestSecret,
|
|
29
|
+
nowIso,
|
|
30
|
+
sha256
|
|
31
|
+
} from './validation.js';
|
|
32
|
+
|
|
33
|
+
const DEFAULT_CLOCK = () => new Date();
|
|
34
|
+
|
|
35
|
+
export const IDEMPOTENCY_RECEIPT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
36
|
+
export const MAX_IDEMPOTENCY_RECEIPTS_PER_ACCOUNT = 256;
|
|
37
|
+
export const MAX_BROWSER_SESSIONS_PER_ACCOUNT = 32;
|
|
38
|
+
|
|
39
|
+
function addMilliseconds(timestamp, milliseconds) {
|
|
40
|
+
return new Date(new Date(timestamp).getTime() + milliseconds).toISOString();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function pruneExpiredRows(database, timestamp) {
|
|
44
|
+
const sessions = database.prepare(`
|
|
45
|
+
DELETE FROM browser_sessions
|
|
46
|
+
WHERE expires_at <= ? OR revoked_at IS NOT NULL
|
|
47
|
+
`).run(timestamp);
|
|
48
|
+
const receipts = database.prepare(`
|
|
49
|
+
DELETE FROM idempotency_records WHERE expires_at <= ?
|
|
50
|
+
`).run(timestamp);
|
|
51
|
+
return {
|
|
52
|
+
browserSessionsRemoved: Number(sessions.changes),
|
|
53
|
+
idempotencyReceiptsRemoved: Number(receipts.changes)
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function accountView(row) {
|
|
58
|
+
if (!row) return null;
|
|
59
|
+
return {
|
|
60
|
+
id: row.id,
|
|
61
|
+
issuer: row.issuer,
|
|
62
|
+
subject: row.subject,
|
|
63
|
+
displayName: row.display_name,
|
|
64
|
+
createdAt: row.created_at,
|
|
65
|
+
updatedAt: row.updated_at
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sessionView(row) {
|
|
70
|
+
if (!row) return null;
|
|
71
|
+
return {
|
|
72
|
+
accountId: row.account_id,
|
|
73
|
+
createdAt: row.created_at,
|
|
74
|
+
expiresAt: row.expires_at,
|
|
75
|
+
lastSeenAt: row.last_seen_at,
|
|
76
|
+
revokedAt: row.revoked_at
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function threadView(row) {
|
|
81
|
+
if (!row) return null;
|
|
82
|
+
return {
|
|
83
|
+
accountId: row.account_id,
|
|
84
|
+
threadId: row.thread_id,
|
|
85
|
+
authority: row.authority,
|
|
86
|
+
title: row.title,
|
|
87
|
+
pinned: row.pinned === 1,
|
|
88
|
+
routingNodeId: row.routing_node_id,
|
|
89
|
+
authorityRevision: row.authority_revision,
|
|
90
|
+
lastRunId: row.last_run_id,
|
|
91
|
+
createdAt: row.created_at,
|
|
92
|
+
updatedAt: row.updated_at,
|
|
93
|
+
lastSeenAt: row.last_seen_at
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function cursorView(row) {
|
|
98
|
+
if (!row) return null;
|
|
99
|
+
return {
|
|
100
|
+
accountId: row.account_id,
|
|
101
|
+
threadId: row.thread_id,
|
|
102
|
+
runId: row.run_id,
|
|
103
|
+
lastSeq: Number(row.last_seq),
|
|
104
|
+
lastEventHash: row.last_event_hash,
|
|
105
|
+
createdAt: row.created_at,
|
|
106
|
+
updatedAt: row.updated_at
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isConstraintError(error) {
|
|
111
|
+
return typeof error?.code === 'string' && error.code.startsWith('ERR_SQLITE_CONSTRAINT');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function secretDigestsEqual(first, second) {
|
|
115
|
+
if (
|
|
116
|
+
typeof first !== 'string' ||
|
|
117
|
+
typeof second !== 'string' ||
|
|
118
|
+
!/^[a-f0-9]{64}$/u.test(first) ||
|
|
119
|
+
!/^[a-f0-9]{64}$/u.test(second)
|
|
120
|
+
) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
return timingSafeEqual(Buffer.from(first, 'hex'), Buffer.from(second, 'hex'));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function validateDisplayName(value) {
|
|
127
|
+
if (value === undefined || value === null) return null;
|
|
128
|
+
return assertBoundedString(value, 'displayName', { min: 1, max: 256 });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function validateThreadPatch(patch) {
|
|
132
|
+
assertExactKeys(
|
|
133
|
+
patch,
|
|
134
|
+
{ optional: ['title', 'pinned'] },
|
|
135
|
+
'patch'
|
|
136
|
+
);
|
|
137
|
+
if (Object.keys(patch).length === 0) {
|
|
138
|
+
throw new ValidationError('patch must change at least one presentation field.');
|
|
139
|
+
}
|
|
140
|
+
const result = {};
|
|
141
|
+
if (Object.hasOwn(patch, 'title')) {
|
|
142
|
+
result.title = assertBoundedString(patch.title, 'patch.title', { min: 0, max: 120 });
|
|
143
|
+
}
|
|
144
|
+
if (Object.hasOwn(patch, 'pinned')) {
|
|
145
|
+
result.pinned = assertBoolean(patch.pinned, 'patch.pinned');
|
|
146
|
+
}
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function validateAuthorityMetadata(input) {
|
|
151
|
+
const routingNodeId = input.routingNodeId === null
|
|
152
|
+
? null
|
|
153
|
+
: assertIdentifier(input.routingNodeId, 'routingNodeId');
|
|
154
|
+
const authorityRevision = input.authorityRevision === null
|
|
155
|
+
? null
|
|
156
|
+
: assertInteger(input.authorityRevision, 'authorityRevision', { min: 0 });
|
|
157
|
+
return { routingNodeId, authorityRevision };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export class CloudIndexStore {
|
|
161
|
+
#clock;
|
|
162
|
+
#closed = false;
|
|
163
|
+
#database;
|
|
164
|
+
#databasePath;
|
|
165
|
+
|
|
166
|
+
constructor({ databasePath, clock = DEFAULT_CLOCK } = {}) {
|
|
167
|
+
if (typeof clock !== 'function') throw new ValidationError('clock must be a function.');
|
|
168
|
+
this.#clock = clock;
|
|
169
|
+
this.#databasePath = prepareSecureDatabasePath(databasePath);
|
|
170
|
+
|
|
171
|
+
let database;
|
|
172
|
+
try {
|
|
173
|
+
database = new DatabaseSync(this.#databasePath);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
throw new StorageCorruptionError('SQLite could not open the control-plane database.', { cause: error });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
database.enableLoadExtension(false);
|
|
180
|
+
database.exec(`
|
|
181
|
+
PRAGMA busy_timeout = 5000;
|
|
182
|
+
PRAGMA foreign_keys = ON;
|
|
183
|
+
PRAGMA journal_mode = DELETE;
|
|
184
|
+
PRAGMA synchronous = FULL;
|
|
185
|
+
PRAGMA temp_store = MEMORY;
|
|
186
|
+
PRAGMA trusted_schema = OFF;
|
|
187
|
+
PRAGMA secure_delete = ON;
|
|
188
|
+
`);
|
|
189
|
+
applyMigrations(database, nowIso(this.#clock));
|
|
190
|
+
database.exec('BEGIN IMMEDIATE');
|
|
191
|
+
try {
|
|
192
|
+
pruneExpiredRows(database, nowIso(this.#clock));
|
|
193
|
+
database.exec('COMMIT');
|
|
194
|
+
} catch (error) {
|
|
195
|
+
try {
|
|
196
|
+
database.exec('ROLLBACK');
|
|
197
|
+
} catch {
|
|
198
|
+
// Preserve the retention failure.
|
|
199
|
+
}
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
assertSecureDatabaseFile(this.#databasePath);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
try {
|
|
205
|
+
database.close();
|
|
206
|
+
} catch {
|
|
207
|
+
// Preserve the validation failure.
|
|
208
|
+
}
|
|
209
|
+
if (
|
|
210
|
+
error instanceof StorageCorruptionError ||
|
|
211
|
+
error?.code === 'unsupported_schema' ||
|
|
212
|
+
error?.code === 'storage_security_error' ||
|
|
213
|
+
error?.code === 'invalid_input'
|
|
214
|
+
) {
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
throw new StorageCorruptionError('The control-plane database could not be initialized safely.', { cause: error });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
this.#database = database;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
#assertOpen() {
|
|
224
|
+
if (this.#closed) throw new StorageCorruptionError('The control-plane database is closed.');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
#transaction(callback) {
|
|
228
|
+
this.#assertOpen();
|
|
229
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
230
|
+
try {
|
|
231
|
+
const result = callback();
|
|
232
|
+
this.#database.exec('COMMIT');
|
|
233
|
+
return result;
|
|
234
|
+
} catch (error) {
|
|
235
|
+
try {
|
|
236
|
+
this.#database.exec('ROLLBACK');
|
|
237
|
+
} catch {
|
|
238
|
+
// Preserve the mutation error.
|
|
239
|
+
}
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
#idempotentMutation(
|
|
245
|
+
{ accountId, operation, idempotencyKey, request, resourceKind, resourceId },
|
|
246
|
+
mutation,
|
|
247
|
+
replay
|
|
248
|
+
) {
|
|
249
|
+
assertIdentifier(accountId, 'accountId');
|
|
250
|
+
assertBoundedString(operation, 'operation', { min: 1, max: 128 });
|
|
251
|
+
assertIdempotencyKey(idempotencyKey);
|
|
252
|
+
if (!['account', 'browser_session', 'thread_index'].includes(resourceKind)) {
|
|
253
|
+
throw new ValidationError('resourceKind is not a supported idempotency receipt type.');
|
|
254
|
+
}
|
|
255
|
+
assertIdentifier(resourceId, 'resourceId');
|
|
256
|
+
if (typeof replay !== 'function') throw new ValidationError('An idempotency replay function is required.');
|
|
257
|
+
const keyHash = sha256(idempotencyKey);
|
|
258
|
+
const requestHash = sha256(canonicalJson(request));
|
|
259
|
+
const receiptTimestamp = nowIso(this.#clock);
|
|
260
|
+
const receiptExpiresAt = addMilliseconds(receiptTimestamp, IDEMPOTENCY_RECEIPT_TTL_MS);
|
|
261
|
+
|
|
262
|
+
return this.#transaction(() => {
|
|
263
|
+
pruneExpiredRows(this.#database, receiptTimestamp);
|
|
264
|
+
const existing = this.#database.prepare(`
|
|
265
|
+
SELECT request_hash, outcome_code, resource_kind, resource_id, result_digest
|
|
266
|
+
FROM idempotency_records
|
|
267
|
+
WHERE account_id = ? AND operation = ? AND key_hash = ?
|
|
268
|
+
`).get(accountId, operation, keyHash);
|
|
269
|
+
if (existing) {
|
|
270
|
+
if (existing.request_hash !== requestHash) throw new IdempotencyConflictError();
|
|
271
|
+
if (
|
|
272
|
+
existing.outcome_code !== 'succeeded' ||
|
|
273
|
+
existing.resource_kind !== resourceKind ||
|
|
274
|
+
existing.resource_id !== resourceId ||
|
|
275
|
+
typeof existing.result_digest !== 'string' ||
|
|
276
|
+
!/^[a-f0-9]{64}$/u.test(existing.result_digest)
|
|
277
|
+
) {
|
|
278
|
+
throw new StorageCorruptionError('An idempotency receipt failed closed-schema validation.');
|
|
279
|
+
}
|
|
280
|
+
const replayResult = replay(existing);
|
|
281
|
+
const replayDigest = sha256(canonicalJson(replayResult));
|
|
282
|
+
if (replayDigest !== existing.result_digest) {
|
|
283
|
+
throw new ConflictError('The original idempotent response can no longer be replayed exactly.');
|
|
284
|
+
}
|
|
285
|
+
return replayResult;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const result = mutation();
|
|
289
|
+
const resultDigest = sha256(canonicalJson(result));
|
|
290
|
+
this.#database.prepare(`
|
|
291
|
+
INSERT INTO idempotency_records(
|
|
292
|
+
account_id, operation, key_hash, request_hash, outcome_code,
|
|
293
|
+
resource_kind, resource_id, result_digest, created_at, expires_at
|
|
294
|
+
) VALUES (?, ?, ?, ?, 'succeeded', ?, ?, ?, ?, ?)
|
|
295
|
+
`).run(
|
|
296
|
+
accountId,
|
|
297
|
+
operation,
|
|
298
|
+
keyHash,
|
|
299
|
+
requestHash,
|
|
300
|
+
resourceKind,
|
|
301
|
+
resourceId,
|
|
302
|
+
resultDigest,
|
|
303
|
+
receiptTimestamp,
|
|
304
|
+
receiptExpiresAt
|
|
305
|
+
);
|
|
306
|
+
this.#database.prepare(`
|
|
307
|
+
DELETE FROM idempotency_records
|
|
308
|
+
WHERE rowid IN (
|
|
309
|
+
SELECT rowid
|
|
310
|
+
FROM idempotency_records
|
|
311
|
+
WHERE account_id = ?
|
|
312
|
+
ORDER BY rowid DESC
|
|
313
|
+
LIMIT -1 OFFSET ?
|
|
314
|
+
)
|
|
315
|
+
`).run(accountId, MAX_IDEMPOTENCY_RECEIPTS_PER_ACCOUNT);
|
|
316
|
+
return result;
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
#assertAccountExists(accountId) {
|
|
321
|
+
const row = this.#database.prepare('SELECT 1 AS present FROM accounts WHERE id = ?').get(accountId);
|
|
322
|
+
if (!row) throw new NotFoundError();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
provisionAccount(input) {
|
|
326
|
+
assertExactKeys(
|
|
327
|
+
input,
|
|
328
|
+
{ required: ['accountId', 'issuer', 'subject', 'idempotencyKey'], optional: ['displayName'] },
|
|
329
|
+
'account'
|
|
330
|
+
);
|
|
331
|
+
const accountId = assertIdentifier(input.accountId, 'accountId');
|
|
332
|
+
const issuer = assertBoundedString(input.issuer, 'issuer', { min: 1, max: 256 });
|
|
333
|
+
const subject = assertBoundedString(input.subject, 'subject', { min: 1, max: 512 });
|
|
334
|
+
const displayName = validateDisplayName(input.displayName);
|
|
335
|
+
const request = { accountId, issuer, subject, displayName };
|
|
336
|
+
|
|
337
|
+
return this.#idempotentMutation(
|
|
338
|
+
{
|
|
339
|
+
accountId,
|
|
340
|
+
operation: 'account.provision',
|
|
341
|
+
idempotencyKey: input.idempotencyKey,
|
|
342
|
+
request,
|
|
343
|
+
resourceKind: 'account',
|
|
344
|
+
resourceId: accountId
|
|
345
|
+
},
|
|
346
|
+
() => {
|
|
347
|
+
const byId = this.#database.prepare('SELECT * FROM accounts WHERE id = ?').get(accountId);
|
|
348
|
+
if (byId) {
|
|
349
|
+
if (byId.issuer !== issuer || byId.subject !== subject) throw new ConflictError();
|
|
350
|
+
return accountView(byId);
|
|
351
|
+
}
|
|
352
|
+
const byIdentity = this.#database.prepare(`
|
|
353
|
+
SELECT id FROM accounts WHERE issuer = ? AND subject = ?
|
|
354
|
+
`).get(issuer, subject);
|
|
355
|
+
if (byIdentity) throw new ConflictError();
|
|
356
|
+
const timestamp = nowIso(this.#clock);
|
|
357
|
+
try {
|
|
358
|
+
this.#database.prepare(`
|
|
359
|
+
INSERT INTO accounts(id, issuer, subject, display_name, created_at, updated_at)
|
|
360
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
361
|
+
`).run(accountId, issuer, subject, displayName, timestamp, timestamp);
|
|
362
|
+
} catch (error) {
|
|
363
|
+
if (isConstraintError(error)) throw new ConflictError();
|
|
364
|
+
throw error;
|
|
365
|
+
}
|
|
366
|
+
return accountView(this.#database.prepare('SELECT * FROM accounts WHERE id = ?').get(accountId));
|
|
367
|
+
},
|
|
368
|
+
() => {
|
|
369
|
+
const row = accountView(this.#database.prepare('SELECT * FROM accounts WHERE id = ?').get(accountId));
|
|
370
|
+
if (!row) throw new ConflictError('The idempotent account resource is no longer present.');
|
|
371
|
+
return row;
|
|
372
|
+
}
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
getAccount(accountId) {
|
|
377
|
+
this.#assertOpen();
|
|
378
|
+
assertIdentifier(accountId, 'accountId');
|
|
379
|
+
return accountView(this.#database.prepare('SELECT * FROM accounts WHERE id = ?').get(accountId));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
resolveAccountIdentity(input) {
|
|
383
|
+
this.#assertOpen();
|
|
384
|
+
assertExactKeys(input, { required: ['issuer', 'subject'] }, 'identity');
|
|
385
|
+
const issuer = assertBoundedString(input.issuer, 'issuer', { min: 1, max: 256 });
|
|
386
|
+
const subject = assertBoundedString(input.subject, 'subject', { min: 1, max: 512 });
|
|
387
|
+
return accountView(this.#database.prepare(`
|
|
388
|
+
SELECT * FROM accounts WHERE issuer = ? AND subject = ?
|
|
389
|
+
`).get(issuer, subject));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
createBrowserSession(input) {
|
|
393
|
+
assertExactKeys(
|
|
394
|
+
input,
|
|
395
|
+
{
|
|
396
|
+
required: ['accountId', 'sessionToken', 'csrfToken', 'expiresAt', 'idempotencyKey']
|
|
397
|
+
},
|
|
398
|
+
'browser session'
|
|
399
|
+
);
|
|
400
|
+
const accountId = assertIdentifier(input.accountId, 'accountId');
|
|
401
|
+
const sessionDigest = digestSecret(input.sessionToken, 'sessionToken');
|
|
402
|
+
const csrfDigest = digestSecret(input.csrfToken, 'csrfToken');
|
|
403
|
+
const expiresAt = assertCanonicalIsoTimestamp(input.expiresAt, 'expiresAt');
|
|
404
|
+
const request = { accountId, sessionDigest, csrfDigest, expiresAt };
|
|
405
|
+
|
|
406
|
+
return this.#idempotentMutation(
|
|
407
|
+
{
|
|
408
|
+
accountId,
|
|
409
|
+
operation: 'browser_session.create',
|
|
410
|
+
idempotencyKey: input.idempotencyKey,
|
|
411
|
+
request,
|
|
412
|
+
resourceKind: 'browser_session',
|
|
413
|
+
resourceId: sessionDigest
|
|
414
|
+
},
|
|
415
|
+
() => {
|
|
416
|
+
this.#assertAccountExists(accountId);
|
|
417
|
+
const timestamp = nowIso(this.#clock);
|
|
418
|
+
if (expiresAt <= timestamp) throw new ValidationError('expiresAt must be in the future.');
|
|
419
|
+
const existingSession = this.#database.prepare(`
|
|
420
|
+
SELECT 1 AS present FROM browser_sessions WHERE session_digest = ?
|
|
421
|
+
`).get(sessionDigest);
|
|
422
|
+
if (existingSession) {
|
|
423
|
+
// Check before capacity eviction so a caller cannot delete and
|
|
424
|
+
// silently rebind an existing oldest token with new CSRF authority.
|
|
425
|
+
throw new ConflictError('The browser-session token already exists.');
|
|
426
|
+
}
|
|
427
|
+
const sessionCount = Number(this.#database.prepare(`
|
|
428
|
+
SELECT count(*) AS count
|
|
429
|
+
FROM browser_sessions
|
|
430
|
+
WHERE account_id = ?
|
|
431
|
+
`).get(accountId)?.count);
|
|
432
|
+
if (sessionCount >= MAX_BROWSER_SESSIONS_PER_ACCOUNT) {
|
|
433
|
+
const sessionsToEvict = sessionCount - MAX_BROWSER_SESSIONS_PER_ACCOUNT + 1;
|
|
434
|
+
const eviction = this.#database.prepare(`
|
|
435
|
+
DELETE FROM browser_sessions
|
|
436
|
+
WHERE account_id = ? AND session_digest IN (
|
|
437
|
+
SELECT session_digest
|
|
438
|
+
FROM browser_sessions
|
|
439
|
+
WHERE account_id = ?
|
|
440
|
+
-- last_seen_at is issuance-only until authentication records durable access.
|
|
441
|
+
ORDER BY created_at ASC, session_digest ASC
|
|
442
|
+
LIMIT ?
|
|
443
|
+
)
|
|
444
|
+
`).run(accountId, accountId, sessionsToEvict);
|
|
445
|
+
if (Number(eviction.changes) !== sessionsToEvict) {
|
|
446
|
+
throw new StorageCorruptionError('Browser-session admission could not make exactly one slot.');
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
try {
|
|
450
|
+
this.#database.prepare(`
|
|
451
|
+
INSERT INTO browser_sessions(
|
|
452
|
+
session_digest, account_id, csrf_digest, created_at, expires_at, last_seen_at, revoked_at
|
|
453
|
+
) VALUES (?, ?, ?, ?, ?, ?, NULL)
|
|
454
|
+
`).run(sessionDigest, accountId, csrfDigest, timestamp, expiresAt, timestamp);
|
|
455
|
+
} catch (error) {
|
|
456
|
+
if (isConstraintError(error)) throw new ConflictError();
|
|
457
|
+
throw error;
|
|
458
|
+
}
|
|
459
|
+
return sessionView(this.#database.prepare(`
|
|
460
|
+
SELECT * FROM browser_sessions
|
|
461
|
+
WHERE session_digest = ? AND account_id = ?
|
|
462
|
+
`).get(sessionDigest, accountId));
|
|
463
|
+
},
|
|
464
|
+
() => {
|
|
465
|
+
const row = sessionView(this.#database.prepare(`
|
|
466
|
+
SELECT * FROM browser_sessions WHERE account_id = ? AND session_digest = ?
|
|
467
|
+
`).get(accountId, sessionDigest));
|
|
468
|
+
if (!row) throw new ConflictError('The idempotent browser session is no longer present.');
|
|
469
|
+
return row;
|
|
470
|
+
}
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
#authenticateBrowserSession(input, requireCsrf) {
|
|
475
|
+
this.#assertOpen();
|
|
476
|
+
assertExactKeys(
|
|
477
|
+
input,
|
|
478
|
+
requireCsrf ? { required: ['sessionToken', 'csrfToken'] } : { required: ['sessionToken'] },
|
|
479
|
+
'browser authentication'
|
|
480
|
+
);
|
|
481
|
+
const sessionDigest = digestSecret(input.sessionToken, 'sessionToken');
|
|
482
|
+
const row = this.#database.prepare(`
|
|
483
|
+
SELECT * FROM browser_sessions
|
|
484
|
+
WHERE session_digest = ? AND revoked_at IS NULL AND expires_at > ?
|
|
485
|
+
`).get(sessionDigest, nowIso(this.#clock));
|
|
486
|
+
if (!row) return null;
|
|
487
|
+
if (requireCsrf) {
|
|
488
|
+
const csrfDigest = digestSecret(input.csrfToken, 'csrfToken');
|
|
489
|
+
if (!secretDigestsEqual(row.csrf_digest, csrfDigest)) return null;
|
|
490
|
+
}
|
|
491
|
+
return sessionView(row);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
authenticateBrowserSession(input) {
|
|
495
|
+
return this.#authenticateBrowserSession(input, false);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
authenticateBrowserMutation(input) {
|
|
499
|
+
return this.#authenticateBrowserSession(input, true);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
revokeBrowserSession(input) {
|
|
503
|
+
assertExactKeys(
|
|
504
|
+
input,
|
|
505
|
+
{ required: ['accountId', 'sessionToken', 'idempotencyKey'] },
|
|
506
|
+
'browser session revocation'
|
|
507
|
+
);
|
|
508
|
+
const accountId = assertIdentifier(input.accountId, 'accountId');
|
|
509
|
+
const sessionDigest = digestSecret(input.sessionToken, 'sessionToken');
|
|
510
|
+
const request = { accountId, sessionDigest };
|
|
511
|
+
return this.#idempotentMutation(
|
|
512
|
+
{
|
|
513
|
+
accountId,
|
|
514
|
+
operation: 'browser_session.revoke',
|
|
515
|
+
idempotencyKey: input.idempotencyKey,
|
|
516
|
+
request,
|
|
517
|
+
resourceKind: 'browser_session',
|
|
518
|
+
resourceId: sessionDigest
|
|
519
|
+
},
|
|
520
|
+
() => {
|
|
521
|
+
const row = this.#database.prepare(`
|
|
522
|
+
SELECT * FROM browser_sessions WHERE account_id = ? AND session_digest = ?
|
|
523
|
+
`).get(accountId, sessionDigest);
|
|
524
|
+
if (!row) throw new NotFoundError();
|
|
525
|
+
this.#database.prepare(`
|
|
526
|
+
DELETE FROM browser_sessions
|
|
527
|
+
WHERE account_id = ? AND session_digest = ?
|
|
528
|
+
`).run(accountId, sessionDigest);
|
|
529
|
+
return { accountId, revoked: true };
|
|
530
|
+
},
|
|
531
|
+
() => ({ accountId, revoked: true })
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
registerThread(input) {
|
|
536
|
+
assertExactKeys(
|
|
537
|
+
input,
|
|
538
|
+
{
|
|
539
|
+
required: ['accountId', 'threadId', 'idempotencyKey'],
|
|
540
|
+
optional: ['title', 'pinned', 'routingNodeId', 'authorityRevision']
|
|
541
|
+
},
|
|
542
|
+
'thread index'
|
|
543
|
+
);
|
|
544
|
+
const accountId = assertIdentifier(input.accountId, 'accountId');
|
|
545
|
+
const threadId = assertIdentifier(input.threadId, 'threadId');
|
|
546
|
+
const title = input.title === undefined
|
|
547
|
+
? ''
|
|
548
|
+
: assertBoundedString(input.title, 'title', { min: 0, max: 120 });
|
|
549
|
+
const pinned = input.pinned === undefined ? false : assertBoolean(input.pinned, 'pinned');
|
|
550
|
+
const routingNodeId = input.routingNodeId === undefined || input.routingNodeId === null
|
|
551
|
+
? null
|
|
552
|
+
: assertIdentifier(input.routingNodeId, 'routingNodeId');
|
|
553
|
+
const authorityRevision = input.authorityRevision === undefined || input.authorityRevision === null
|
|
554
|
+
? null
|
|
555
|
+
: assertInteger(input.authorityRevision, 'authorityRevision', { min: 0 });
|
|
556
|
+
const request = {
|
|
557
|
+
accountId,
|
|
558
|
+
threadId,
|
|
559
|
+
title,
|
|
560
|
+
pinned,
|
|
561
|
+
routingNodeId,
|
|
562
|
+
authorityRevision
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
return this.#idempotentMutation(
|
|
566
|
+
{
|
|
567
|
+
accountId,
|
|
568
|
+
operation: 'thread.register',
|
|
569
|
+
idempotencyKey: input.idempotencyKey,
|
|
570
|
+
request,
|
|
571
|
+
resourceKind: 'thread_index',
|
|
572
|
+
resourceId: threadId
|
|
573
|
+
},
|
|
574
|
+
() => {
|
|
575
|
+
this.#assertAccountExists(accountId);
|
|
576
|
+
const existing = this.#database.prepare('SELECT 1 AS present FROM thread_index WHERE thread_id = ?').get(threadId);
|
|
577
|
+
if (existing) throw new ConflictError();
|
|
578
|
+
const timestamp = nowIso(this.#clock);
|
|
579
|
+
try {
|
|
580
|
+
this.#database.prepare(`
|
|
581
|
+
INSERT INTO thread_index(
|
|
582
|
+
thread_id, account_id, authority, title, pinned, routing_node_id,
|
|
583
|
+
authority_revision, last_run_id, created_at, updated_at, last_seen_at
|
|
584
|
+
) VALUES (?, ?, 'aginti', ?, ?, ?, ?, NULL, ?, ?, ?)
|
|
585
|
+
`).run(
|
|
586
|
+
threadId,
|
|
587
|
+
accountId,
|
|
588
|
+
title,
|
|
589
|
+
pinned ? 1 : 0,
|
|
590
|
+
routingNodeId,
|
|
591
|
+
authorityRevision,
|
|
592
|
+
timestamp,
|
|
593
|
+
timestamp,
|
|
594
|
+
timestamp
|
|
595
|
+
);
|
|
596
|
+
} catch (error) {
|
|
597
|
+
if (isConstraintError(error)) throw new ConflictError();
|
|
598
|
+
throw error;
|
|
599
|
+
}
|
|
600
|
+
return threadView(this.#database.prepare(`
|
|
601
|
+
SELECT * FROM thread_index WHERE account_id = ? AND thread_id = ?
|
|
602
|
+
`).get(accountId, threadId));
|
|
603
|
+
},
|
|
604
|
+
() => {
|
|
605
|
+
const row = threadView(this.#database.prepare(`
|
|
606
|
+
SELECT * FROM thread_index WHERE account_id = ? AND thread_id = ?
|
|
607
|
+
`).get(accountId, threadId));
|
|
608
|
+
if (!row) throw new ConflictError('The idempotent thread index is no longer present.');
|
|
609
|
+
return row;
|
|
610
|
+
}
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
getThread(accountId, threadId) {
|
|
615
|
+
this.#assertOpen();
|
|
616
|
+
assertIdentifier(accountId, 'accountId');
|
|
617
|
+
assertIdentifier(threadId, 'threadId');
|
|
618
|
+
return threadView(this.#database.prepare(`
|
|
619
|
+
SELECT * FROM thread_index WHERE account_id = ? AND thread_id = ?
|
|
620
|
+
`).get(accountId, threadId));
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
listThreads(input) {
|
|
624
|
+
this.#assertOpen();
|
|
625
|
+
assertExactKeys(input, { required: ['accountId'], optional: ['limit', 'before'] }, 'thread list');
|
|
626
|
+
const accountId = assertIdentifier(input.accountId, 'accountId');
|
|
627
|
+
const limit = input.limit === undefined ? 50 : assertInteger(input.limit, 'limit', { min: 1, max: 100 });
|
|
628
|
+
if (input.before === undefined || input.before === null) {
|
|
629
|
+
return this.#database.prepare(`
|
|
630
|
+
SELECT * FROM thread_index
|
|
631
|
+
WHERE account_id = ?
|
|
632
|
+
ORDER BY updated_at DESC, thread_id DESC
|
|
633
|
+
LIMIT ?
|
|
634
|
+
`).all(accountId, limit).map(threadView);
|
|
635
|
+
}
|
|
636
|
+
assertExactKeys(input.before, { required: ['updatedAt', 'threadId'] }, 'before');
|
|
637
|
+
const updatedAt = assertCanonicalIsoTimestamp(input.before.updatedAt, 'before.updatedAt');
|
|
638
|
+
const threadId = assertIdentifier(input.before.threadId, 'before.threadId');
|
|
639
|
+
return this.#database.prepare(`
|
|
640
|
+
SELECT * FROM thread_index
|
|
641
|
+
WHERE account_id = ?
|
|
642
|
+
AND (updated_at < ? OR (updated_at = ? AND thread_id < ?))
|
|
643
|
+
ORDER BY updated_at DESC, thread_id DESC
|
|
644
|
+
LIMIT ?
|
|
645
|
+
`).all(accountId, updatedAt, updatedAt, threadId, limit).map(threadView);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
updateThreadPresentation(input) {
|
|
649
|
+
assertExactKeys(
|
|
650
|
+
input,
|
|
651
|
+
{ required: ['accountId', 'threadId', 'patch', 'idempotencyKey'] },
|
|
652
|
+
'thread update'
|
|
653
|
+
);
|
|
654
|
+
const accountId = assertIdentifier(input.accountId, 'accountId');
|
|
655
|
+
const threadId = assertIdentifier(input.threadId, 'threadId');
|
|
656
|
+
const patch = validateThreadPatch(input.patch);
|
|
657
|
+
const request = { accountId, threadId, patch };
|
|
658
|
+
|
|
659
|
+
return this.#idempotentMutation(
|
|
660
|
+
{
|
|
661
|
+
accountId,
|
|
662
|
+
operation: 'thread.update',
|
|
663
|
+
idempotencyKey: input.idempotencyKey,
|
|
664
|
+
request,
|
|
665
|
+
resourceKind: 'thread_index',
|
|
666
|
+
resourceId: threadId
|
|
667
|
+
},
|
|
668
|
+
() => {
|
|
669
|
+
const existing = this.#database.prepare(`
|
|
670
|
+
SELECT 1 AS present FROM thread_index WHERE account_id = ? AND thread_id = ?
|
|
671
|
+
`).get(accountId, threadId);
|
|
672
|
+
if (!existing) throw new NotFoundError();
|
|
673
|
+
|
|
674
|
+
const assignments = [];
|
|
675
|
+
const values = [];
|
|
676
|
+
if (Object.hasOwn(patch, 'title')) {
|
|
677
|
+
assignments.push('title = ?');
|
|
678
|
+
values.push(patch.title);
|
|
679
|
+
}
|
|
680
|
+
if (Object.hasOwn(patch, 'pinned')) {
|
|
681
|
+
assignments.push('pinned = ?');
|
|
682
|
+
values.push(patch.pinned ? 1 : 0);
|
|
683
|
+
}
|
|
684
|
+
const timestamp = nowIso(this.#clock);
|
|
685
|
+
assignments.push('updated_at = ?', 'last_seen_at = ?');
|
|
686
|
+
values.push(timestamp, timestamp, accountId, threadId);
|
|
687
|
+
this.#database.prepare(`
|
|
688
|
+
UPDATE thread_index SET ${assignments.join(', ')}
|
|
689
|
+
WHERE account_id = ? AND thread_id = ?
|
|
690
|
+
`).run(...values);
|
|
691
|
+
return threadView(this.#database.prepare(`
|
|
692
|
+
SELECT * FROM thread_index WHERE account_id = ? AND thread_id = ?
|
|
693
|
+
`).get(accountId, threadId));
|
|
694
|
+
},
|
|
695
|
+
() => {
|
|
696
|
+
const row = threadView(this.#database.prepare(`
|
|
697
|
+
SELECT * FROM thread_index WHERE account_id = ? AND thread_id = ?
|
|
698
|
+
`).get(accountId, threadId));
|
|
699
|
+
if (!row) throw new ConflictError('The idempotent thread index is no longer present.');
|
|
700
|
+
return row;
|
|
701
|
+
}
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
syncThreadAuthorityMetadataFromAginti(input) {
|
|
706
|
+
assertExactKeys(
|
|
707
|
+
input,
|
|
708
|
+
{
|
|
709
|
+
required: [
|
|
710
|
+
'accountId',
|
|
711
|
+
'threadId',
|
|
712
|
+
'routingNodeId',
|
|
713
|
+
'authorityRevision',
|
|
714
|
+
'idempotencyKey'
|
|
715
|
+
]
|
|
716
|
+
},
|
|
717
|
+
'AgInTi thread metadata sync'
|
|
718
|
+
);
|
|
719
|
+
const accountId = assertIdentifier(input.accountId, 'accountId');
|
|
720
|
+
const threadId = assertIdentifier(input.threadId, 'threadId');
|
|
721
|
+
const { routingNodeId, authorityRevision } = validateAuthorityMetadata(input);
|
|
722
|
+
const request = { accountId, threadId, routingNodeId, authorityRevision };
|
|
723
|
+
|
|
724
|
+
return this.#idempotentMutation(
|
|
725
|
+
{
|
|
726
|
+
accountId,
|
|
727
|
+
operation: 'thread.sync_aginti_metadata',
|
|
728
|
+
idempotencyKey: input.idempotencyKey,
|
|
729
|
+
request,
|
|
730
|
+
resourceKind: 'thread_index',
|
|
731
|
+
resourceId: threadId
|
|
732
|
+
},
|
|
733
|
+
() => {
|
|
734
|
+
const timestamp = nowIso(this.#clock);
|
|
735
|
+
const result = this.#database.prepare(`
|
|
736
|
+
UPDATE thread_index
|
|
737
|
+
SET routing_node_id = ?, authority_revision = ?,
|
|
738
|
+
updated_at = ?, last_seen_at = ?
|
|
739
|
+
WHERE account_id = ? AND thread_id = ?
|
|
740
|
+
`).run(
|
|
741
|
+
routingNodeId,
|
|
742
|
+
authorityRevision,
|
|
743
|
+
timestamp,
|
|
744
|
+
timestamp,
|
|
745
|
+
accountId,
|
|
746
|
+
threadId
|
|
747
|
+
);
|
|
748
|
+
if (Number(result.changes) !== 1) throw new NotFoundError();
|
|
749
|
+
return threadView(this.#database.prepare(`
|
|
750
|
+
SELECT * FROM thread_index WHERE account_id = ? AND thread_id = ?
|
|
751
|
+
`).get(accountId, threadId));
|
|
752
|
+
},
|
|
753
|
+
() => {
|
|
754
|
+
const row = threadView(this.#database.prepare(`
|
|
755
|
+
SELECT * FROM thread_index WHERE account_id = ? AND thread_id = ?
|
|
756
|
+
`).get(accountId, threadId));
|
|
757
|
+
if (!row) throw new ConflictError('The idempotent thread index is no longer present.');
|
|
758
|
+
return row;
|
|
759
|
+
}
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
removeThreadIndex(input) {
|
|
764
|
+
assertExactKeys(
|
|
765
|
+
input,
|
|
766
|
+
{ required: ['accountId', 'threadId', 'idempotencyKey'] },
|
|
767
|
+
'thread index removal'
|
|
768
|
+
);
|
|
769
|
+
const accountId = assertIdentifier(input.accountId, 'accountId');
|
|
770
|
+
const threadId = assertIdentifier(input.threadId, 'threadId');
|
|
771
|
+
const request = { accountId, threadId };
|
|
772
|
+
return this.#idempotentMutation(
|
|
773
|
+
{
|
|
774
|
+
accountId,
|
|
775
|
+
operation: 'thread.remove_index',
|
|
776
|
+
idempotencyKey: input.idempotencyKey,
|
|
777
|
+
request,
|
|
778
|
+
resourceKind: 'thread_index',
|
|
779
|
+
resourceId: threadId
|
|
780
|
+
},
|
|
781
|
+
() => {
|
|
782
|
+
const result = this.#database.prepare(`
|
|
783
|
+
DELETE FROM thread_index WHERE account_id = ? AND thread_id = ?
|
|
784
|
+
`).run(accountId, threadId);
|
|
785
|
+
if (Number(result.changes) !== 1) throw new NotFoundError();
|
|
786
|
+
return { threadId, removedFromCloudIndex: true };
|
|
787
|
+
},
|
|
788
|
+
() => ({ threadId, removedFromCloudIndex: true })
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
recordRunCursor(input) {
|
|
793
|
+
assertExactKeys(
|
|
794
|
+
input,
|
|
795
|
+
{
|
|
796
|
+
required: ['accountId', 'threadId', 'runId', 'lastSeq', 'lastEventHash']
|
|
797
|
+
},
|
|
798
|
+
'run cursor'
|
|
799
|
+
);
|
|
800
|
+
const accountId = assertIdentifier(input.accountId, 'accountId');
|
|
801
|
+
const threadId = assertIdentifier(input.threadId, 'threadId');
|
|
802
|
+
const runId = assertIdentifier(input.runId, 'runId');
|
|
803
|
+
const lastSeq = assertInteger(input.lastSeq, 'lastSeq', { min: 0 });
|
|
804
|
+
const lastEventHash = assertEventHash(input.lastEventHash, lastSeq);
|
|
805
|
+
const retentionTimestamp = nowIso(this.#clock);
|
|
806
|
+
|
|
807
|
+
return this.#transaction(() => {
|
|
808
|
+
pruneExpiredRows(this.#database, retentionTimestamp);
|
|
809
|
+
const thread = this.#database.prepare(`
|
|
810
|
+
SELECT 1 AS present FROM thread_index WHERE account_id = ? AND thread_id = ?
|
|
811
|
+
`).get(accountId, threadId);
|
|
812
|
+
if (!thread) throw new NotFoundError();
|
|
813
|
+
|
|
814
|
+
const existing = this.#database.prepare(`
|
|
815
|
+
SELECT * FROM run_cursors WHERE account_id = ? AND run_id = ?
|
|
816
|
+
`).get(accountId, runId);
|
|
817
|
+
if (existing) {
|
|
818
|
+
if (existing.thread_id !== threadId) throw new ConflictError();
|
|
819
|
+
if (lastSeq < Number(existing.last_seq)) {
|
|
820
|
+
throw new ConflictError('A delivery cursor must not move backwards.');
|
|
821
|
+
}
|
|
822
|
+
if (lastSeq === Number(existing.last_seq)) {
|
|
823
|
+
if (existing.last_event_hash !== lastEventHash) {
|
|
824
|
+
throw new ConflictError('A delivery sequence cannot be rebound to a different event hash.');
|
|
825
|
+
}
|
|
826
|
+
return cursorView(existing);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const timestamp = nowIso(this.#clock);
|
|
831
|
+
if (!existing) {
|
|
832
|
+
const globalCollision = this.#database.prepare(`
|
|
833
|
+
SELECT 1 AS present FROM run_cursors WHERE run_id = ?
|
|
834
|
+
`).get(runId);
|
|
835
|
+
if (globalCollision) throw new ConflictError();
|
|
836
|
+
try {
|
|
837
|
+
this.#database.prepare(`
|
|
838
|
+
INSERT INTO run_cursors(
|
|
839
|
+
run_id, account_id, thread_id, last_seq, last_event_hash,
|
|
840
|
+
created_at, updated_at
|
|
841
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
842
|
+
`).run(
|
|
843
|
+
runId,
|
|
844
|
+
accountId,
|
|
845
|
+
threadId,
|
|
846
|
+
lastSeq,
|
|
847
|
+
lastEventHash,
|
|
848
|
+
timestamp,
|
|
849
|
+
timestamp
|
|
850
|
+
);
|
|
851
|
+
} catch (error) {
|
|
852
|
+
if (isConstraintError(error)) throw new ConflictError();
|
|
853
|
+
throw error;
|
|
854
|
+
}
|
|
855
|
+
} else {
|
|
856
|
+
this.#database.prepare(`
|
|
857
|
+
UPDATE run_cursors
|
|
858
|
+
SET last_seq = ?, last_event_hash = ?, updated_at = ?
|
|
859
|
+
WHERE account_id = ? AND run_id = ?
|
|
860
|
+
`).run(lastSeq, lastEventHash, timestamp, accountId, runId);
|
|
861
|
+
}
|
|
862
|
+
this.#database.prepare(`
|
|
863
|
+
UPDATE thread_index
|
|
864
|
+
SET last_run_id = ?, updated_at = ?, last_seen_at = ?
|
|
865
|
+
WHERE account_id = ? AND thread_id = ?
|
|
866
|
+
`).run(runId, timestamp, timestamp, accountId, threadId);
|
|
867
|
+
return cursorView(this.#database.prepare(`
|
|
868
|
+
SELECT * FROM run_cursors WHERE account_id = ? AND run_id = ?
|
|
869
|
+
`).get(accountId, runId));
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
getRunCursor(accountId, runId) {
|
|
874
|
+
this.#assertOpen();
|
|
875
|
+
assertIdentifier(accountId, 'accountId');
|
|
876
|
+
assertIdentifier(runId, 'runId');
|
|
877
|
+
return cursorView(this.#database.prepare(`
|
|
878
|
+
SELECT * FROM run_cursors WHERE account_id = ? AND run_id = ?
|
|
879
|
+
`).get(accountId, runId));
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
listRunCursors(input) {
|
|
883
|
+
this.#assertOpen();
|
|
884
|
+
assertExactKeys(input, { required: ['accountId', 'threadId'], optional: ['limit'] }, 'run cursor list');
|
|
885
|
+
const accountId = assertIdentifier(input.accountId, 'accountId');
|
|
886
|
+
const threadId = assertIdentifier(input.threadId, 'threadId');
|
|
887
|
+
const limit = input.limit === undefined ? 50 : assertInteger(input.limit, 'limit', { min: 1, max: 100 });
|
|
888
|
+
return this.#database.prepare(`
|
|
889
|
+
SELECT * FROM run_cursors
|
|
890
|
+
WHERE account_id = ? AND thread_id = ?
|
|
891
|
+
ORDER BY updated_at DESC, run_id DESC
|
|
892
|
+
LIMIT ?
|
|
893
|
+
`).all(accountId, threadId, limit).map(cursorView);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
pruneExpiredState() {
|
|
897
|
+
const timestamp = nowIso(this.#clock);
|
|
898
|
+
return this.#transaction(() => pruneExpiredRows(this.#database, timestamp));
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
healthCheck() {
|
|
902
|
+
this.#assertOpen();
|
|
903
|
+
return checkOpenSqliteHealth(this.#database, {
|
|
904
|
+
expectedApplicationId: SQLITE_APPLICATION_ID,
|
|
905
|
+
allowedSchemaVersions: [LATEST_SCHEMA_VERSION]
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
close() {
|
|
910
|
+
if (this.#closed) return;
|
|
911
|
+
this.#database.close();
|
|
912
|
+
this.#closed = true;
|
|
913
|
+
}
|
|
914
|
+
}
|