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 +24 -5
- package/dist/module.json +1 -1
- package/dist/runtime/server/background-jobs/sqlite-provider.d.ts +27 -0
- package/dist/runtime/server/background-jobs/sqlite-provider.js +536 -0
- package/dist/runtime/server/db/migrate.js +13 -2
- package/dist/runtime/server/db/migrations/017_background_jobs.d.ts +4 -0
- package/dist/runtime/server/db/migrations/017_background_jobs.js +42 -0
- package/dist/runtime/server/db/migrations/018_background_admission_cancels.d.ts +7 -0
- package/dist/runtime/server/db/migrations/018_background_admission_cancels.js +18 -0
- package/dist/runtime/server/db/migrations/019_background_generation_history.d.ts +7 -0
- package/dist/runtime/server/db/migrations/019_background_generation_history.js +25 -0
- package/dist/runtime/server/db/migrations/020_background_generation_receipts.d.ts +4 -0
- package/dist/runtime/server/db/migrations/020_background_generation_receipts.js +19 -0
- package/dist/runtime/server/db/schema.d.ts +38 -0
- package/dist/runtime/server/plugins/register.js +8 -1
- package/dist/runtime/server/sync/history-gc-policy.d.ts +8 -0
- package/dist/runtime/server/sync/history-gc-policy.js +7 -0
- package/dist/runtime/server/sync/sqlite-sync-gateway-adapter.d.ts +13 -1
- package/dist/runtime/server/sync/sqlite-sync-gateway-adapter.js +669 -96
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
@@ -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() {
|