or3-provider-sqlite 0.0.8 → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,7 @@ SQLite sync and workspace store provider for OR3 Chat. Provides a lightweight, s
5
5
  ## What it provides
6
6
 
7
7
  - **AuthWorkspaceStore** (`sqlite`) — user identity mapping, workspace CRUD, role resolution
8
- - **SyncGatewayAdapter** (`sqlite`) — push/pull sync, consistent materialized snapshot pages, LWW conflict resolution, and cursor tracking
8
+ - **SyncGatewayAdapter** (`sqlite`) — push/pull sync, consistent materialized snapshot pages, LWW conflict resolution, cursor tracking, and canonical background-generation history on synchronous SQLite runtimes
9
9
  - **ConnectStore** (`sqlite`) — durable, atomic device enrollment and connected-computer records for OR3 Connect
10
10
  - **WebhookStore** (`sqlite`) — durable webhook registrations and delivery logs (local, Bun, and Turso runtimes)
11
11
  - **Admin stores** (`sqlite`) — workspace access/lifecycle, workspace settings, user search, and deployment-admin grants (local, Bun, and Turso runtimes)
@@ -125,9 +125,11 @@ On server startup, the Nitro plugin:
125
125
  6. Registers `WebhookStore` and the admin stores (workspace access, workspace
126
126
  settings, user search) — all runtimes except D1
127
127
  7. Registers `RateLimitProvider` with ID `sqlite` and the sync admin adapter
128
+ 8. Registers a durable `BackgroundJobProvider` with ID `sqlite`, so active chat
129
+ and workflow jobs remain addressable across Nitro HMR/module reloads
128
130
 
129
131
  Registration is skipped when `auth.enabled` is `false`, or when neither SQLite
130
- sync nor SQLite Connect is selected (local-only mode).
132
+ sync, SQLite Connect, nor SQLite background jobs are selected (local-only mode).
131
133
 
132
134
  ### Schema
133
135
 
@@ -138,6 +140,15 @@ Ordered migrations create and evolve all tables:
138
140
  - **003–005**: workspace-scoped sync keys, invitations, and admin stores
139
141
  - **006_sync_snapshots**: winning operation IDs plus immutable snapshot headers/items
140
142
  - **009_or3_connect**: single-use device authorizations and connected computers
143
+ - **017_background_jobs**: durable job status, workflow snapshots, cancellation,
144
+ inactivity timestamps, and worker leases
145
+ - **018–020**: durable pre-admission cancellation, reasoning/generation history
146
+ phases, and idempotent canonical generation-write receipts
147
+
148
+ Background-job claims reset text and iteration progress back to the durable
149
+ checkpoint when reclaiming an expired lease, matching the memory/Convex recovery
150
+ contract. The provider implements `findJobByIdempotencyKey`, which lets the
151
+ server cancel an admission before the client has received its job ID.
141
152
 
142
153
  Additional migrations (007–008, 010–016) evolve device-cursor ownership,
143
154
  upload intents, and Connect credential/lifecycle hardening and rate limits.
@@ -147,16 +158,24 @@ All tables use snake_case aligned with the sync wire format.
147
158
  ### Sync semantics
148
159
 
149
160
  - **Push**: validates ops (including a 256 KB serialized payload ceiling per operation) → checks `op_id` idempotency → allocates contiguous `server_version` block → writes change_log → applies LWW to materialized tables → upserts tombstones for deletes
150
- - **Pull**: returns ordered changes for `server_version > cursor` with limit/pagination and optional table filtering
151
- - **Snapshot**: captures canonical live rows and current tombstones at one `highWatermark`, then serves immutable, keyset-paginated pages ordered by `(tableName, pk, kind)`
161
+ - **Pull**: returns ordered changes for `server_version > cursor` with limit/pagination, optional table filtering, `oldestRetainedVersion`, and `requiresSnapshot` when the cursor is behind retained history
162
+ - **Snapshot**: captures canonical live rows and current tombstones at one `highWatermark`, then serves immutable, keyset-paginated pages ordered by `(tableName, pk, kind)`. Notification live rows are filtered to the session user.
152
163
  - **Cursor**: forward-only per-device cursor tracking
153
164
  - **Retention safety**: tombstone and `change_log` GC is enabled only under the explicit `snapshot-v1` capability and deletes old revisions acknowledged by every registered device
165
+ - **Notifications**: push forces `payload.user_id` to the session user; foreign notification writes are rejected; pull/snapshot omit other users' rows
154
166
 
155
- LWW conflict resolution: incoming wins when `clock` is higher, or when clocks are equal and `hlc` is lexicographically greater.
167
+ LWW conflict resolution: incoming wins when `clock` is higher, then when clocks are equal and `hlc` is lexicographically greater, then when `op_id` is greater. Tombstones use the same `(clock, hlc, op_id)` tuple. LWW losers return `applied: false` with the winning payload.
156
168
 
157
169
  Local, Bun, and Turso runtimes use `BEGIN IMMEDIATE` transactions. D1 uses its
158
170
  native atomic batch API for grouped writes.
159
171
 
172
+ Canonical background generation history is advertised by the local, Bun, and
173
+ Turso adapters. It writes admission rows, contiguous change-log versions,
174
+ materialized state, and the receipt in one transaction, then commits a terminal
175
+ snapshot only if its generation still owns the assistant row. D1 does not
176
+ advertise this versioned capability, so background chat admission fails before
177
+ model execution there; ordinary sync and other D1 features are unchanged.
178
+
160
179
  ### Workspace store
161
180
 
162
181
  - `getOrCreateUser` — maps `(provider, provider_user_id)` to internal user (idempotent)
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "or3-provider-sqlite",
3
3
  "configKey": "or3-provider-sqlite",
4
- "version": "0.0.8",
4
+ "version": "0.0.10",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
@@ -0,0 +1,27 @@
1
+ import type { BackgroundJob, BackgroundJobExecution, BackgroundJobProvider, CreateJobParams, JobUpdate, AdmissionCancellationResult, GenerationHistoryPhase, TerminalGenerationSnapshot } from '~~/server/utils/background-jobs/types';
2
+ export declare class SqliteBackgroundJobProvider implements BackgroundJobProvider {
3
+ readonly name = "sqlite";
4
+ createJob(params: CreateJobParams): Promise<string>;
5
+ getJob(jobId: string, userId: string): Promise<BackgroundJob | null>;
6
+ findJobByIdempotencyKey(idempotencyKey: string, userId: string): Promise<BackgroundJob | null>;
7
+ cancelAdmission(userId: string, admissionId: string): Promise<AdmissionCancellationResult>;
8
+ updateJob(jobId: string, update: JobUpdate): Promise<void>;
9
+ completeJob(jobId: string, finalContent: string, leaseOwner?: string): Promise<void>;
10
+ failJob(jobId: string, error: string, leaseOwner?: string): Promise<void>;
11
+ private finish;
12
+ abortJob(jobId: string, userId: string): Promise<boolean>;
13
+ getAbortController(): undefined;
14
+ checkJobAborted(jobId: string): Promise<boolean>;
15
+ claimJob(jobId: string, leaseOwner: string, now: number, leaseExpiresAt: number): Promise<BackgroundJob | null>;
16
+ claimNextJob(leaseOwner: string, now: number, leaseExpiresAt: number): Promise<BackgroundJob | null>;
17
+ renewJobLease(jobId: string, leaseOwner: string, _now: number, leaseExpiresAt: number): Promise<boolean>;
18
+ updateJobExecution(jobId: string, execution: BackgroundJobExecution, leaseOwner: string): Promise<boolean>;
19
+ saveTerminalSnapshot(jobId: string, snapshot: TerminalGenerationSnapshot, leaseOwner?: string): Promise<boolean>;
20
+ setHistoryPhase(jobId: string, phase: GenerationHistoryPhase, options?: {
21
+ from?: GenerationHistoryPhase[];
22
+ }): Promise<boolean>;
23
+ getPendingHistoryJobs(limit: number): Promise<BackgroundJob[]>;
24
+ cleanupExpired(): Promise<number>;
25
+ getActiveJobCount(): Promise<number>;
26
+ }
27
+ export declare const sqliteBackgroundJobProvider: SqliteBackgroundJobProvider;
@@ -0,0 +1,536 @@
1
+ import { getJobConfig } from "~~/server/utils/background-jobs/store";
2
+ import { randomUUID } from "node:crypto";
3
+ import { d1All, d1Run } from "../db/d1.js";
4
+ import { getRawDb, isD1Driver } from "../db/kysely.js";
5
+ function parseJson(value) {
6
+ if (!value) return void 0;
7
+ return JSON.parse(value);
8
+ }
9
+ function json(value) {
10
+ return value === void 0 ? null : JSON.stringify(value);
11
+ }
12
+ function toJob(row) {
13
+ return {
14
+ id: row.id,
15
+ userId: row.user_id,
16
+ threadId: row.thread_id,
17
+ messageId: row.message_id,
18
+ model: row.model,
19
+ kind: row.kind ?? void 0,
20
+ status: row.status,
21
+ content: row.content,
22
+ reasoning: row.reasoning ?? "",
23
+ generationId: row.generation_id ?? void 0,
24
+ historyPhase: row.history_phase ?? void 0,
25
+ syncProviderId: row.sync_provider_id ?? void 0,
26
+ chunksReceived: row.chunks_received,
27
+ startedAt: row.started_at,
28
+ lastActivityAt: row.last_activity_at,
29
+ completedAt: row.completed_at ?? void 0,
30
+ error: row.error ?? void 0,
31
+ tool_calls: parseJson(row.tool_calls_json),
32
+ workflow_state: parseJson(
33
+ row.workflow_state_json
34
+ ),
35
+ execution: parseJson(row.execution_json),
36
+ leaseOwner: row.lease_owner ?? void 0,
37
+ leaseExpiresAt: row.lease_expires_at ?? void 0,
38
+ attempts: row.attempts
39
+ };
40
+ }
41
+ async function all(statement, ...parameters) {
42
+ if (isD1Driver()) return d1All(statement, ...parameters);
43
+ return getRawDb().prepare(statement).all(...parameters);
44
+ }
45
+ async function one(statement, ...parameters) {
46
+ if (isD1Driver()) return (await d1All(statement, ...parameters))[0];
47
+ return getRawDb().prepare(statement).get(...parameters);
48
+ }
49
+ async function run(statement, ...parameters) {
50
+ if (isD1Driver()) {
51
+ const result = await d1Run(statement, ...parameters);
52
+ return result.meta?.changes ?? 0;
53
+ }
54
+ return getRawDb().prepare(statement).run(...parameters).changes;
55
+ }
56
+ function leaseLost() {
57
+ const error = new Error("Background job lease was superseded");
58
+ error.name = "BackgroundJobLeaseLostError";
59
+ return error;
60
+ }
61
+ const ADMISSION_CANCEL_TTL_MS = 10 * 60 * 1e3;
62
+ function admissionCancelled(admissionId) {
63
+ const error = new Error(
64
+ `Background admission ${admissionId} was cancelled`
65
+ );
66
+ error.name = "AdmissionCancelledError";
67
+ return error;
68
+ }
69
+ async function isAdmissionCancelled(userId, admissionId, now) {
70
+ const row = await one(
71
+ `SELECT 1 AS hit FROM background_admission_cancels
72
+ WHERE user_id = ? AND admission_id = ? AND expires_at > ?`,
73
+ userId,
74
+ admissionId,
75
+ now
76
+ );
77
+ return row !== void 0;
78
+ }
79
+ function insertParameters(id, params, now) {
80
+ return [
81
+ id,
82
+ params.userId,
83
+ params.threadId,
84
+ params.messageId,
85
+ params.model,
86
+ params.kind ?? null,
87
+ params.initialContent ?? "",
88
+ params.initialReasoning ?? "",
89
+ params.generationId ?? null,
90
+ params.historyPhase ?? "ready",
91
+ params.syncProviderId ?? null,
92
+ now,
93
+ now,
94
+ json(params.tool_calls),
95
+ json(params.workflow_state),
96
+ json(params.execution),
97
+ params.idempotencyKey ?? null
98
+ ];
99
+ }
100
+ const INSERT_JOB = `INSERT INTO background_jobs (
101
+ id, user_id, thread_id, message_id, model, kind, status, content,
102
+ reasoning, generation_id, history_phase, sync_provider_id,
103
+ chunks_received, started_at, last_activity_at, tool_calls_json,
104
+ workflow_state_json, execution_json, idempotency_key, attempts
105
+ ) VALUES (?, ?, ?, ?, ?, ?, 'streaming', ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, 0)`;
106
+ const INSERT_JOB_IF_CAPACITY = `INSERT INTO background_jobs (
107
+ id, user_id, thread_id, message_id, model, kind, status, content,
108
+ reasoning, generation_id, history_phase, sync_provider_id,
109
+ chunks_received, started_at, last_activity_at, tool_calls_json,
110
+ workflow_state_json, execution_json, idempotency_key, attempts
111
+ ) SELECT ?, ?, ?, ?, ?, ?, 'streaming', ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, 0
112
+ WHERE (SELECT COUNT(*) FROM background_jobs WHERE status = 'streaming') < ?
113
+ AND (SELECT COUNT(*) FROM background_jobs WHERE status = 'streaming' AND user_id = ?) < ?
114
+ AND NOT EXISTS (
115
+ SELECT 1 FROM background_admission_cancels
116
+ WHERE user_id = ? AND admission_id = ? AND expires_at > ?
117
+ )
118
+ ON CONFLICT(idempotency_key) DO NOTHING
119
+ RETURNING id`;
120
+ export class SqliteBackgroundJobProvider {
121
+ name = "sqlite";
122
+ async createJob(params) {
123
+ const now = Date.now();
124
+ const id = randomUUID();
125
+ const config = getJobConfig();
126
+ if (isD1Driver()) {
127
+ if (params.idempotencyKey) {
128
+ const existing = await one(
129
+ "SELECT id FROM background_jobs WHERE idempotency_key = ?",
130
+ params.idempotencyKey
131
+ );
132
+ if (existing) return existing.id;
133
+ if (await isAdmissionCancelled(
134
+ params.userId,
135
+ params.idempotencyKey,
136
+ now
137
+ )) {
138
+ throw admissionCancelled(params.idempotencyKey);
139
+ }
140
+ }
141
+ const inserted = await all(
142
+ INSERT_JOB_IF_CAPACITY,
143
+ ...insertParameters(id, params, now),
144
+ config.maxConcurrentJobs,
145
+ params.userId,
146
+ config.maxConcurrentJobsPerUser,
147
+ params.userId,
148
+ params.idempotencyKey ?? "",
149
+ now
150
+ );
151
+ if (inserted[0]) return inserted[0].id;
152
+ if (params.idempotencyKey) {
153
+ const existing = await one(
154
+ "SELECT id FROM background_jobs WHERE idempotency_key = ?",
155
+ params.idempotencyKey
156
+ );
157
+ if (existing) return existing.id;
158
+ if (await isAdmissionCancelled(
159
+ params.userId,
160
+ params.idempotencyKey,
161
+ now
162
+ )) {
163
+ throw admissionCancelled(params.idempotencyKey);
164
+ }
165
+ }
166
+ throw new Error("Maximum concurrent background jobs reached");
167
+ }
168
+ const raw = getRawDb();
169
+ return raw.transaction(() => {
170
+ if (params.idempotencyKey) {
171
+ const existing = raw.prepare(
172
+ "SELECT id FROM background_jobs WHERE idempotency_key = ?"
173
+ ).get(params.idempotencyKey);
174
+ if (existing) return existing.id;
175
+ const cancelled = raw.prepare(
176
+ `SELECT 1 AS hit FROM background_admission_cancels
177
+ WHERE user_id = ? AND admission_id = ?
178
+ AND expires_at > ?`
179
+ ).get(
180
+ params.userId,
181
+ params.idempotencyKey,
182
+ now
183
+ );
184
+ if (cancelled) {
185
+ throw admissionCancelled(params.idempotencyKey);
186
+ }
187
+ }
188
+ const active = raw.prepare(
189
+ `SELECT COUNT(*) AS total,
190
+ SUM(CASE WHEN user_id = ? THEN 1 ELSE 0 END) AS user_total
191
+ FROM background_jobs WHERE status = 'streaming'`
192
+ ).get(params.userId);
193
+ if (active.total >= config.maxConcurrentJobs || (active.user_total ?? 0) >= config.maxConcurrentJobsPerUser) {
194
+ throw new Error(
195
+ "Maximum concurrent background jobs reached"
196
+ );
197
+ }
198
+ raw.prepare(INSERT_JOB).run(
199
+ ...insertParameters(id, params, now)
200
+ );
201
+ return id;
202
+ }).immediate();
203
+ }
204
+ async getJob(jobId, userId) {
205
+ const row = await one(
206
+ `SELECT * FROM background_jobs WHERE id = ?${userId === "*" ? "" : " AND user_id = ?"}`,
207
+ ...[jobId, ...userId === "*" ? [] : [userId]]
208
+ );
209
+ return row ? toJob(row) : null;
210
+ }
211
+ async findJobByIdempotencyKey(idempotencyKey, userId) {
212
+ const row = await one(
213
+ "SELECT * FROM background_jobs WHERE idempotency_key = ? AND user_id = ?",
214
+ idempotencyKey,
215
+ userId
216
+ );
217
+ return row ? toJob(row) : null;
218
+ }
219
+ async cancelAdmission(userId, admissionId) {
220
+ const now = Date.now();
221
+ const expiresAt = now + ADMISSION_CANCEL_TTL_MS;
222
+ const upsertMarker = `INSERT INTO background_admission_cancels
223
+ (user_id, admission_id, expires_at)
224
+ VALUES (?, ?, ?)
225
+ ON CONFLICT(user_id, admission_id)
226
+ DO UPDATE SET expires_at = excluded.expires_at`;
227
+ const abortJob = `UPDATE background_jobs SET status = 'aborted',
228
+ completed_at = ?, last_activity_at = ?, error = ?,
229
+ lease_owner = NULL, lease_expires_at = NULL,
230
+ history_phase = CASE
231
+ WHEN generation_id IS NOT NULL
232
+ AND COALESCE(history_phase, 'ready') = 'ready'
233
+ THEN 'finalization_pending'
234
+ ELSE history_phase END
235
+ WHERE id = ? AND status = 'streaming'`;
236
+ if (!isD1Driver()) {
237
+ const raw = getRawDb();
238
+ return raw.transaction(() => {
239
+ raw.prepare(
240
+ "DELETE FROM background_admission_cancels WHERE expires_at <= ?"
241
+ ).run(now);
242
+ raw.prepare(upsertMarker).run(userId, admissionId, expiresAt);
243
+ const job2 = raw.prepare(
244
+ "SELECT id, status FROM background_jobs WHERE user_id = ? AND idempotency_key = ?"
245
+ ).get(userId, admissionId);
246
+ if (!job2) {
247
+ return { aborted: false, pending: true };
248
+ }
249
+ if (job2.status === "streaming") {
250
+ const changes = raw.prepare(abortJob).run(
251
+ now,
252
+ now,
253
+ "Cancelled by user",
254
+ job2.id
255
+ ).changes;
256
+ return {
257
+ aborted: changes > 0,
258
+ jobId: job2.id,
259
+ pending: false
260
+ };
261
+ }
262
+ return { aborted: false, jobId: job2.id, pending: false };
263
+ }).immediate();
264
+ }
265
+ await run(
266
+ "DELETE FROM background_admission_cancels WHERE expires_at <= ?",
267
+ now
268
+ );
269
+ await run(upsertMarker, userId, admissionId, expiresAt);
270
+ const job = await one(
271
+ "SELECT id, status FROM background_jobs WHERE user_id = ? AND idempotency_key = ?",
272
+ userId,
273
+ admissionId
274
+ );
275
+ if (!job) {
276
+ return { aborted: false, pending: true };
277
+ }
278
+ if (job.status === "streaming") {
279
+ const changes = await run(
280
+ abortJob,
281
+ now,
282
+ now,
283
+ "Cancelled by user",
284
+ job.id
285
+ );
286
+ return {
287
+ aborted: changes > 0,
288
+ jobId: job.id,
289
+ pending: false
290
+ };
291
+ }
292
+ return { aborted: false, jobId: job.id, pending: false };
293
+ }
294
+ async updateJob(jobId, update) {
295
+ const sets = ["last_activity_at = ?"];
296
+ const values = [Date.now()];
297
+ if (update.contentChunk !== void 0) {
298
+ sets.push("content = content || ?");
299
+ values.push(update.contentChunk);
300
+ }
301
+ if (update.reasoningChunk !== void 0) {
302
+ sets.push("reasoning = reasoning || ?");
303
+ values.push(update.reasoningChunk);
304
+ }
305
+ if (update.chunksReceived !== void 0) {
306
+ sets.push("chunks_received = ?");
307
+ values.push(update.chunksReceived);
308
+ }
309
+ if (update.tool_calls !== void 0) {
310
+ sets.push("tool_calls_json = ?");
311
+ values.push(json(update.tool_calls));
312
+ }
313
+ if (update.workflow_state !== void 0) {
314
+ sets.push("workflow_state_json = ?");
315
+ values.push(json(update.workflow_state));
316
+ }
317
+ values.push(jobId);
318
+ let where = "id = ? AND status = 'streaming'";
319
+ if (update.leaseOwner) {
320
+ where += " AND lease_owner = ? AND lease_expires_at > ?";
321
+ values.push(update.leaseOwner);
322
+ values.push(Date.now());
323
+ }
324
+ const changes = await run(
325
+ `UPDATE background_jobs SET ${sets.join(", ")} WHERE ${where}`,
326
+ ...values
327
+ );
328
+ if (update.leaseOwner && changes === 0) throw leaseLost();
329
+ }
330
+ async completeJob(jobId, finalContent, leaseOwner) {
331
+ await this.finish(jobId, "complete", finalContent, null, leaseOwner);
332
+ }
333
+ async failJob(jobId, error, leaseOwner) {
334
+ await this.finish(jobId, "error", null, error, leaseOwner);
335
+ }
336
+ async finish(jobId, status, content, error, leaseOwner) {
337
+ const now = Date.now();
338
+ const values = [status, now, now, error];
339
+ const contentSet = content === null ? "" : ", content = ?";
340
+ if (content !== null) values.push(content);
341
+ values.push(jobId);
342
+ let where = "id = ? AND status = 'streaming'";
343
+ if (leaseOwner) {
344
+ where += " AND lease_owner = ?";
345
+ values.push(leaseOwner);
346
+ }
347
+ const changes = await run(
348
+ `UPDATE background_jobs SET status = ?, completed_at = ?, last_activity_at = ?,
349
+ error = ?, lease_owner = NULL, lease_expires_at = NULL${contentSet}
350
+ WHERE ${where}`,
351
+ ...values
352
+ );
353
+ if (leaseOwner && changes === 0) throw leaseLost();
354
+ }
355
+ async abortJob(jobId, userId) {
356
+ const now = Date.now();
357
+ return await run(
358
+ `UPDATE background_jobs SET status = 'aborted', completed_at = ?,
359
+ last_activity_at = ?, lease_owner = NULL, lease_expires_at = NULL,
360
+ history_phase = CASE
361
+ WHEN generation_id IS NOT NULL
362
+ AND COALESCE(history_phase, 'ready') = 'ready'
363
+ THEN 'finalization_pending'
364
+ ELSE history_phase END
365
+ WHERE id = ? AND user_id = ? AND status = 'streaming'`,
366
+ now,
367
+ now,
368
+ jobId,
369
+ userId
370
+ ) > 0;
371
+ }
372
+ getAbortController() {
373
+ return void 0;
374
+ }
375
+ async checkJobAborted(jobId) {
376
+ const row = await one(
377
+ "SELECT status FROM background_jobs WHERE id = ?",
378
+ jobId
379
+ );
380
+ return row?.status === "aborted";
381
+ }
382
+ async claimJob(jobId, leaseOwner, now, leaseExpiresAt) {
383
+ const row = await one(
384
+ `UPDATE background_jobs SET
385
+ lease_owner = ?,
386
+ lease_expires_at = ?,
387
+ last_activity_at = ?,
388
+ content = CASE WHEN attempts > 0
389
+ THEN COALESCE(json_extract(execution_json, '$.contentBase'), '')
390
+ ELSE content END,
391
+ reasoning = CASE WHEN attempts > 0
392
+ THEN COALESCE(json_extract(execution_json, '$.reasoningBase'), '')
393
+ ELSE reasoning END,
394
+ chunks_received = CASE WHEN attempts > 0 THEN 0 ELSE chunks_received END,
395
+ attempts = attempts + 1
396
+ WHERE id = ? AND status = 'streaming' AND execution_json IS NOT NULL
397
+ AND COALESCE(history_phase, 'ready') = 'ready'
398
+ AND (lease_owner IS NULL OR lease_expires_at <= ?)
399
+ RETURNING *`,
400
+ leaseOwner,
401
+ leaseExpiresAt,
402
+ now,
403
+ jobId,
404
+ now
405
+ );
406
+ return row ? toJob(row) : null;
407
+ }
408
+ async claimNextJob(leaseOwner, now, leaseExpiresAt) {
409
+ const row = await one(
410
+ `UPDATE background_jobs SET
411
+ lease_owner = ?,
412
+ lease_expires_at = ?,
413
+ last_activity_at = ?,
414
+ content = CASE WHEN attempts > 0
415
+ THEN COALESCE(json_extract(execution_json, '$.contentBase'), '')
416
+ ELSE content END,
417
+ reasoning = CASE WHEN attempts > 0
418
+ THEN COALESCE(json_extract(execution_json, '$.reasoningBase'), '')
419
+ ELSE reasoning END,
420
+ chunks_received = CASE WHEN attempts > 0 THEN 0 ELSE chunks_received END,
421
+ attempts = attempts + 1
422
+ WHERE id = (
423
+ SELECT id FROM background_jobs
424
+ WHERE status = 'streaming' AND execution_json IS NOT NULL
425
+ AND COALESCE(history_phase, 'ready') = 'ready'
426
+ AND (lease_owner IS NULL OR lease_expires_at <= ?)
427
+ ORDER BY started_at ASC LIMIT 1
428
+ ) RETURNING *`,
429
+ leaseOwner,
430
+ leaseExpiresAt,
431
+ now,
432
+ now
433
+ );
434
+ return row ? toJob(row) : null;
435
+ }
436
+ async renewJobLease(jobId, leaseOwner, _now, leaseExpiresAt) {
437
+ return await run(
438
+ `UPDATE background_jobs SET lease_expires_at = ?, last_activity_at = ?
439
+ WHERE id = ? AND status = 'streaming' AND lease_owner = ?
440
+ AND lease_expires_at > ?`,
441
+ leaseExpiresAt,
442
+ Date.now(),
443
+ jobId,
444
+ leaseOwner,
445
+ Date.now()
446
+ ) > 0;
447
+ }
448
+ async updateJobExecution(jobId, execution, leaseOwner) {
449
+ return await run(
450
+ `UPDATE background_jobs SET execution_json = ?, last_activity_at = ?
451
+ WHERE id = ? AND status = 'streaming' AND lease_owner = ?`,
452
+ json(execution),
453
+ Date.now(),
454
+ jobId,
455
+ leaseOwner
456
+ ) > 0;
457
+ }
458
+ async saveTerminalSnapshot(jobId, snapshot, leaseOwner) {
459
+ const values = [
460
+ snapshot.status,
461
+ snapshot.content,
462
+ snapshot.reasoning,
463
+ json(snapshot.toolCalls),
464
+ snapshot.error ?? null,
465
+ snapshot.completedAt,
466
+ snapshot.completedAt
467
+ ];
468
+ let where = "id = ? AND status = 'streaming'";
469
+ values.push(jobId);
470
+ if (leaseOwner) {
471
+ where += " AND lease_owner = ? AND lease_expires_at > ?";
472
+ values.push(leaseOwner, Date.now());
473
+ }
474
+ return await run(
475
+ `UPDATE background_jobs SET status = ?, content = ?, reasoning = ?,
476
+ tool_calls_json = ?, error = ?, completed_at = ?,
477
+ history_phase = 'finalization_pending',
478
+ last_activity_at = ?, lease_owner = NULL, lease_expires_at = NULL
479
+ WHERE ${where}`,
480
+ ...values
481
+ ) > 0;
482
+ }
483
+ async setHistoryPhase(jobId, phase, options) {
484
+ const values = [phase, jobId];
485
+ let where = "id = ?";
486
+ if (options?.from && options.from.length > 0) {
487
+ const placeholders = options.from.map(() => "?").join(", ");
488
+ where += ` AND COALESCE(history_phase, 'ready') IN (${placeholders})`;
489
+ values.push(...options.from);
490
+ }
491
+ return await run(`UPDATE background_jobs SET history_phase = ? WHERE ${where}`, ...values) > 0;
492
+ }
493
+ async getPendingHistoryJobs(limit) {
494
+ const rows = await all(
495
+ `SELECT * FROM background_jobs
496
+ WHERE history_phase IN ('admission_pending', 'finalization_pending')
497
+ ORDER BY started_at ASC
498
+ LIMIT ?`,
499
+ Math.max(1, Math.min(100, Math.floor(limit)))
500
+ );
501
+ return rows.map(toJob);
502
+ }
503
+ async cleanupExpired() {
504
+ const config = getJobConfig();
505
+ const now = Date.now();
506
+ const timedOut = await run(
507
+ `UPDATE background_jobs SET status = 'error', error = 'Job timed out',
508
+ completed_at = ?, lease_owner = NULL, lease_expires_at = NULL,
509
+ history_phase = CASE
510
+ WHEN generation_id IS NOT NULL
511
+ AND COALESCE(history_phase, 'ready') = 'ready'
512
+ THEN 'finalization_pending'
513
+ ELSE history_phase END
514
+ WHERE status = 'streaming' AND last_activity_at <= ?`,
515
+ now,
516
+ now - config.jobTimeoutMs
517
+ );
518
+ const removed = await run(
519
+ `DELETE FROM background_jobs
520
+ WHERE status != 'streaming' AND completed_at IS NOT NULL AND completed_at <= ?
521
+ AND (
522
+ history_phase IS NULL
523
+ OR history_phase IN ('ready', 'committed', 'superseded')
524
+ )`,
525
+ now - config.completedJobRetentionMs
526
+ );
527
+ return timedOut + removed;
528
+ }
529
+ async getActiveJobCount() {
530
+ const row = await one(
531
+ "SELECT COUNT(*) AS total FROM background_jobs WHERE status = 'streaming'"
532
+ );
533
+ return row?.total ?? 0;
534
+ }
535
+ }
536
+ export const sqliteBackgroundJobProvider = new SqliteBackgroundJobProvider();
@@ -15,6 +15,10 @@ import * as m013 from "./migrations/013_connect_environment_lifecycle.js";
15
15
  import * as m014 from "./migrations/014_connect_retention_activation.js";
16
16
  import * as m015 from "./migrations/015_rate_limits.js";
17
17
  import * as m016 from "./migrations/016_connect_runtime_binding.js";
18
+ import * as m017 from "./migrations/017_background_jobs.js";
19
+ import * as m018 from "./migrations/018_background_admission_cancels.js";
20
+ import * as m019 from "./migrations/019_background_generation_history.js";
21
+ import * as m020 from "./migrations/020_background_generation_receipts.js";
18
22
  const REQUIRED_SCHEMA_TABLES = [
19
23
  "users",
20
24
  "auth_accounts",
@@ -39,7 +43,10 @@ const REQUIRED_SCHEMA_TABLES = [
39
43
  "upload_intents",
40
44
  "connect_device_authorizations",
41
45
  "connect_environments",
42
- "rate_limits"
46
+ "rate_limits",
47
+ "background_jobs",
48
+ "background_admission_cancels",
49
+ "background_generation_receipts"
43
50
  ];
44
51
  const migrations = {
45
52
  "001_init": m001,
@@ -57,7 +64,11 @@ const migrations = {
57
64
  "013_connect_environment_lifecycle": m013,
58
65
  "014_connect_retention_activation": m014,
59
66
  "015_rate_limits": m015,
60
- "016_connect_runtime_binding": m016
67
+ "016_connect_runtime_binding": m016,
68
+ "017_background_jobs": m017,
69
+ "018_background_admission_cancels": m018,
70
+ "019_background_generation_history": m019,
71
+ "020_background_generation_receipts": m020
61
72
  };
62
73
  class StaticMigrationProvider {
63
74
  async getMigrations() {
@@ -0,0 +1,4 @@
1
+ import { type Kysely } from 'kysely';
2
+ /** Durable background chat and workflow execution state. */
3
+ export declare function up(db: Kysely<unknown>): Promise<void>;
4
+ export declare function down(db: Kysely<unknown>): Promise<void>;