or3-provider-sqlite 0.0.9 → 0.0.11
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 +50 -5
- package/dist/module.json +1 -1
- package/dist/runtime/server/admin/stores/sqlite-store.js +16 -0
- package/dist/runtime/server/background-jobs/sqlite-provider.d.ts +10 -1
- package/dist/runtime/server/background-jobs/sqlite-provider.js +316 -18
- package/dist/runtime/server/db/migrate.js +10 -2
- 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 +15 -0
- package/dist/runtime/server/plugin-connections/sqlite-connection-store.d.ts +7 -0
- package/dist/runtime/server/plugin-connections/sqlite-connection-store.js +209 -0
- package/dist/runtime/server/plugins/register.js +13 -0
- 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 +722 -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)
|
|
@@ -32,7 +32,7 @@ runtime.
|
|
|
32
32
|
| Runtime | `OR3_SQLITE_DRIVER` | Required configuration | Extra install |
|
|
33
33
|
|---|---|---|---|
|
|
34
34
|
| Local Node (default) | `better-sqlite3` | `OR3_SQLITE_DB_PATH` | `better-sqlite3` |
|
|
35
|
-
| Bun | `bun` | `OR3_SQLITE_DB_PATH` | None; uses built-in `bun:sqlite` |
|
|
35
|
+
| Bun | `bun` | `OR3_SQLITE_DB_PATH` | None for sync; uses built-in `bun:sqlite` |
|
|
36
36
|
| Turso/libSQL | `turso` | `OR3_SQLITE_TURSO_URL`, `OR3_SQLITE_TURSO_AUTH_TOKEN` | `libsql` |
|
|
37
37
|
| Cloudflare D1 | `d1` | `OR3_SQLITE_D1_BINDING` (defaults to `DB`) | None; uses the Worker binding |
|
|
38
38
|
|
|
@@ -51,6 +51,16 @@ OR3_SQLITE_DRIVER=bun
|
|
|
51
51
|
OR3_SQLITE_DB_PATH=/data/or3-sync.db
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
+
OR3 Chat's source launcher (`bun run dev:ssr`) selects Bun when this provider
|
|
55
|
+
uses the Bun driver, including when only the auth workspace store is active.
|
|
56
|
+
Use the Bun version pinned in the host's `package.json`. The launcher checks
|
|
57
|
+
the built-in driver and required native bindings before Nuxt starts. Run a
|
|
58
|
+
built Bun-configured server with `bun .output/server/index.mjs`.
|
|
59
|
+
|
|
60
|
+
Basic Auth still uses a separate `better-sqlite3` database; its native binding
|
|
61
|
+
must also be compatible with Bun. The managed Cloud image uses Node and the
|
|
62
|
+
default `better-sqlite3` sync driver.
|
|
63
|
+
|
|
54
64
|
### Turso/libSQL
|
|
55
65
|
|
|
56
66
|
```bash
|
|
@@ -131,6 +141,12 @@ On server startup, the Nitro plugin:
|
|
|
131
141
|
Registration is skipped when `auth.enabled` is `false`, or when neither SQLite
|
|
132
142
|
sync, SQLite Connect, nor SQLite background jobs are selected (local-only mode).
|
|
133
143
|
|
|
144
|
+
SQLite background jobs also persist mixed-runtime tool handoffs. Browser-only
|
|
145
|
+
calls are atomically claimed and settled in `execution_json`; parked calls are
|
|
146
|
+
excluded from worker claims until their browser result makes the job runnable.
|
|
147
|
+
Parked handoffs have a separate global and per-user admission cap, so they do
|
|
148
|
+
not consume active worker slots or grow without bound.
|
|
149
|
+
|
|
134
150
|
### Schema
|
|
135
151
|
|
|
136
152
|
Ordered migrations create and evolve all tables:
|
|
@@ -142,6 +158,13 @@ Ordered migrations create and evolve all tables:
|
|
|
142
158
|
- **009_or3_connect**: single-use device authorizations and connected computers
|
|
143
159
|
- **017_background_jobs**: durable job status, workflow snapshots, cancellation,
|
|
144
160
|
inactivity timestamps, and worker leases
|
|
161
|
+
- **018–020**: durable pre-admission cancellation, reasoning/generation history
|
|
162
|
+
phases, and idempotent canonical generation-write receipts
|
|
163
|
+
|
|
164
|
+
Background-job claims reset text and iteration progress back to the durable
|
|
165
|
+
checkpoint when reclaiming an expired lease, matching the memory/Convex recovery
|
|
166
|
+
contract. The provider implements `findJobByIdempotencyKey`, which lets the
|
|
167
|
+
server cancel an admission before the client has received its job ID.
|
|
145
168
|
|
|
146
169
|
Additional migrations (007–008, 010–016) evolve device-cursor ownership,
|
|
147
170
|
upload intents, and Connect credential/lifecycle hardening and rate limits.
|
|
@@ -151,16 +174,24 @@ All tables use snake_case aligned with the sync wire format.
|
|
|
151
174
|
### Sync semantics
|
|
152
175
|
|
|
153
176
|
- **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
|
|
154
|
-
- **Pull**: returns ordered changes for `server_version > cursor` with limit/pagination
|
|
155
|
-
- **Snapshot**: captures canonical live rows and current tombstones at one `highWatermark`, then serves immutable, keyset-paginated pages ordered by `(tableName, pk, kind)
|
|
177
|
+
- **Pull**: returns ordered changes for `server_version > cursor` with limit/pagination, optional table filtering, `oldestRetainedVersion`, and `requiresSnapshot` when the cursor is behind retained history
|
|
178
|
+
- **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.
|
|
156
179
|
- **Cursor**: forward-only per-device cursor tracking
|
|
157
180
|
- **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
|
|
181
|
+
- **Notifications**: push forces `payload.user_id` to the session user; foreign notification writes are rejected; pull/snapshot omit other users' rows
|
|
158
182
|
|
|
159
|
-
LWW conflict resolution: incoming wins when `clock` is higher,
|
|
183
|
+
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 current winner as either `{ kind: 'put', payload, revision }` or `{ kind: 'delete', revision, serverDeletedAt? }`. Idempotent `op_id` retries return `replayed: true` and, if superseded, the current winner. A replay does not allocate another server version.
|
|
160
184
|
|
|
161
185
|
Local, Bun, and Turso runtimes use `BEGIN IMMEDIATE` transactions. D1 uses its
|
|
162
186
|
native atomic batch API for grouped writes.
|
|
163
187
|
|
|
188
|
+
Canonical background generation history is advertised by the local, Bun, and
|
|
189
|
+
Turso adapters. It writes admission rows, contiguous change-log versions,
|
|
190
|
+
materialized state, and the receipt in one transaction, then commits a terminal
|
|
191
|
+
snapshot only if its generation still owns the assistant row. D1 does not
|
|
192
|
+
advertise this versioned capability, so background chat admission fails before
|
|
193
|
+
model execution there; ordinary sync and other D1 features are unchanged.
|
|
194
|
+
|
|
164
195
|
### Workspace store
|
|
165
196
|
|
|
166
197
|
- `getOrCreateUser` — maps `(provider, provider_user_id)` to internal user (idempotent)
|
|
@@ -220,3 +251,17 @@ bun run build # build for distribution
|
|
|
220
251
|
`OR3_CONNECT_ENABLED=false` or switch to better-sqlite3, Bun, or Turso.
|
|
221
252
|
- **`OR3_SQLITE_DRIVER=bun requires Bun`** — the Bun driver only runs under
|
|
222
253
|
the Bun runtime with its built-in `bun:sqlite`.
|
|
254
|
+
|
|
255
|
+
### Atomic workspace settings
|
|
256
|
+
|
|
257
|
+
The workspace settings store implements `compareAndSet(workspaceId, key, expectedValue, nextValue)` atomically. A `null` expected value means the key must be absent; a conflict returns `false` without overwriting it. The host uses this for concurrent plugin setup saves and persistent AI spend reservations.
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
### Testing local changes in OR3 Chat
|
|
261
|
+
|
|
262
|
+
With this repository beside `or3-chat`, run `bun install` here once, then
|
|
263
|
+
`bun run dev:ssr` from Chat. Chat's dev wrapper rebuilds the local provider and
|
|
264
|
+
prints its selected path; restart it after provider edits. Missing repositories
|
|
265
|
+
or failed builds fall back to installed packages with a warning.
|
|
266
|
+
`OR3_LOCAL_PROVIDERS=false` disables local selection. Production builds use the
|
|
267
|
+
installed package, so local development does not publish these changes.
|
package/dist/module.json
CHANGED
|
@@ -323,6 +323,22 @@ class SqliteWorkspaceSettingsStore {
|
|
|
323
323
|
})
|
|
324
324
|
).execute();
|
|
325
325
|
}
|
|
326
|
+
/** Atomically write only when the stored value still equals the expected value. */
|
|
327
|
+
async compareAndSet(workspaceId, key, expectedValue, nextValue) {
|
|
328
|
+
const now = nowEpoch();
|
|
329
|
+
if (expectedValue === null) {
|
|
330
|
+
const result2 = await this.db.insertInto("admin_workspace_settings").values({
|
|
331
|
+
id: globalThis.crypto.randomUUID(),
|
|
332
|
+
workspace_id: workspaceId,
|
|
333
|
+
key,
|
|
334
|
+
value: nextValue,
|
|
335
|
+
updated_at: now
|
|
336
|
+
}).onConflict((oc) => oc.columns(["workspace_id", "key"]).doNothing()).executeTakeFirst();
|
|
337
|
+
return Number(result2.numInsertedOrUpdatedRows ?? 0) === 1;
|
|
338
|
+
}
|
|
339
|
+
const result = await this.db.updateTable("admin_workspace_settings").set({ value: nextValue, updated_at: now }).where("workspace_id", "=", workspaceId).where("key", "=", key).where("value", "=", expectedValue).executeTakeFirst();
|
|
340
|
+
return Number(result.numUpdatedRows ?? 0) === 1;
|
|
341
|
+
}
|
|
326
342
|
}
|
|
327
343
|
class SqliteAdminUserStore {
|
|
328
344
|
get db() {
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import type { BackgroundJob, BackgroundJobExecution, BackgroundJobProvider, CreateJobParams, JobUpdate } from '~~/server/utils/background-jobs/types';
|
|
1
|
+
import type { BackgroundJob, BackgroundJobExecution, BackgroundJobProvider, CreateJobParams, JobUpdate, AdmissionCancellationResult, GenerationHistoryPhase, TerminalGenerationSnapshot } from '~~/server/utils/background-jobs/types';
|
|
2
2
|
export declare class SqliteBackgroundJobProvider implements BackgroundJobProvider {
|
|
3
3
|
readonly name = "sqlite";
|
|
4
4
|
createJob(params: CreateJobParams): Promise<string>;
|
|
5
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>;
|
|
6
8
|
updateJob(jobId: string, update: JobUpdate): Promise<void>;
|
|
7
9
|
completeJob(jobId: string, finalContent: string, leaseOwner?: string): Promise<void>;
|
|
8
10
|
failJob(jobId: string, error: string, leaseOwner?: string): Promise<void>;
|
|
@@ -14,6 +16,13 @@ export declare class SqliteBackgroundJobProvider implements BackgroundJobProvide
|
|
|
14
16
|
claimNextJob(leaseOwner: string, now: number, leaseExpiresAt: number): Promise<BackgroundJob | null>;
|
|
15
17
|
renewJobLease(jobId: string, leaseOwner: string, _now: number, leaseExpiresAt: number): Promise<boolean>;
|
|
16
18
|
updateJobExecution(jobId: string, execution: BackgroundJobExecution, leaseOwner: string): Promise<boolean>;
|
|
19
|
+
claimClientToolCall(jobId: string, userId: string, callId: string, claimToken: string, claimExpiresAt: number): Promise<BackgroundJob | null>;
|
|
20
|
+
settleClientToolCall(jobId: string, userId: string, callId: string, claimToken: string, execution: BackgroundJobExecution, toolCalls: BackgroundJob['tool_calls']): Promise<boolean>;
|
|
21
|
+
saveTerminalSnapshot(jobId: string, snapshot: TerminalGenerationSnapshot, leaseOwner?: string): Promise<boolean>;
|
|
22
|
+
setHistoryPhase(jobId: string, phase: GenerationHistoryPhase, options?: {
|
|
23
|
+
from?: GenerationHistoryPhase[];
|
|
24
|
+
}): Promise<boolean>;
|
|
25
|
+
getPendingHistoryJobs(limit: number): Promise<BackgroundJob[]>;
|
|
17
26
|
cleanupExpired(): Promise<number>;
|
|
18
27
|
getActiveJobCount(): Promise<number>;
|
|
19
28
|
}
|
|
@@ -19,6 +19,10 @@ function toJob(row) {
|
|
|
19
19
|
kind: row.kind ?? void 0,
|
|
20
20
|
status: row.status,
|
|
21
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,
|
|
22
26
|
chunksReceived: row.chunks_received,
|
|
23
27
|
startedAt: row.started_at,
|
|
24
28
|
lastActivityAt: row.last_activity_at,
|
|
@@ -54,6 +58,24 @@ function leaseLost() {
|
|
|
54
58
|
error.name = "BackgroundJobLeaseLostError";
|
|
55
59
|
return error;
|
|
56
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
|
+
}
|
|
57
79
|
function insertParameters(id, params, now) {
|
|
58
80
|
return [
|
|
59
81
|
id,
|
|
@@ -62,6 +84,11 @@ function insertParameters(id, params, now) {
|
|
|
62
84
|
params.messageId,
|
|
63
85
|
params.model,
|
|
64
86
|
params.kind ?? null,
|
|
87
|
+
params.initialContent ?? "",
|
|
88
|
+
params.initialReasoning ?? "",
|
|
89
|
+
params.generationId ?? null,
|
|
90
|
+
params.historyPhase ?? "ready",
|
|
91
|
+
params.syncProviderId ?? null,
|
|
65
92
|
now,
|
|
66
93
|
now,
|
|
67
94
|
json(params.tool_calls),
|
|
@@ -72,16 +99,28 @@ function insertParameters(id, params, now) {
|
|
|
72
99
|
}
|
|
73
100
|
const INSERT_JOB = `INSERT INTO background_jobs (
|
|
74
101
|
id, user_id, thread_id, message_id, model, kind, status, content,
|
|
102
|
+
reasoning, generation_id, history_phase, sync_provider_id,
|
|
75
103
|
chunks_received, started_at, last_activity_at, tool_calls_json,
|
|
76
104
|
workflow_state_json, execution_json, idempotency_key, attempts
|
|
77
|
-
) VALUES (?, ?, ?, ?, ?, ?, 'streaming',
|
|
105
|
+
) VALUES (?, ?, ?, ?, ?, ?, 'streaming', ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, 0)`;
|
|
78
106
|
const INSERT_JOB_IF_CAPACITY = `INSERT INTO background_jobs (
|
|
79
107
|
id, user_id, thread_id, message_id, model, kind, status, content,
|
|
108
|
+
reasoning, generation_id, history_phase, sync_provider_id,
|
|
80
109
|
chunks_received, started_at, last_activity_at, tool_calls_json,
|
|
81
110
|
workflow_state_json, execution_json, idempotency_key, attempts
|
|
82
|
-
) SELECT ?, ?, ?, ?, ?, ?, 'streaming',
|
|
83
|
-
WHERE (SELECT COUNT(*) FROM background_jobs WHERE status = 'streaming'
|
|
84
|
-
|
|
111
|
+
) SELECT ?, ?, ?, ?, ?, ?, 'streaming', ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, 0
|
|
112
|
+
WHERE (SELECT COUNT(*) FROM background_jobs WHERE status = 'streaming'
|
|
113
|
+
AND json_extract(execution_json, '$.clientToolCall') IS NULL) < ?
|
|
114
|
+
AND (SELECT COUNT(*) FROM background_jobs WHERE status = 'streaming' AND user_id = ?
|
|
115
|
+
AND json_extract(execution_json, '$.clientToolCall') IS NULL) < ?
|
|
116
|
+
AND (SELECT COUNT(*) FROM background_jobs WHERE status = 'streaming'
|
|
117
|
+
AND json_extract(execution_json, '$.clientToolCall') IS NOT NULL) < ?
|
|
118
|
+
AND (SELECT COUNT(*) FROM background_jobs WHERE status = 'streaming' AND user_id = ?
|
|
119
|
+
AND json_extract(execution_json, '$.clientToolCall') IS NOT NULL) < ?
|
|
120
|
+
AND NOT EXISTS (
|
|
121
|
+
SELECT 1 FROM background_admission_cancels
|
|
122
|
+
WHERE user_id = ? AND admission_id = ? AND expires_at > ?
|
|
123
|
+
)
|
|
85
124
|
ON CONFLICT(idempotency_key) DO NOTHING
|
|
86
125
|
RETURNING id`;
|
|
87
126
|
export class SqliteBackgroundJobProvider {
|
|
@@ -97,13 +136,26 @@ export class SqliteBackgroundJobProvider {
|
|
|
97
136
|
params.idempotencyKey
|
|
98
137
|
);
|
|
99
138
|
if (existing) return existing.id;
|
|
139
|
+
if (await isAdmissionCancelled(
|
|
140
|
+
params.userId,
|
|
141
|
+
params.idempotencyKey,
|
|
142
|
+
now
|
|
143
|
+
)) {
|
|
144
|
+
throw admissionCancelled(params.idempotencyKey);
|
|
145
|
+
}
|
|
100
146
|
}
|
|
101
147
|
const inserted = await all(
|
|
102
148
|
INSERT_JOB_IF_CAPACITY,
|
|
103
149
|
...insertParameters(id, params, now),
|
|
104
150
|
config.maxConcurrentJobs,
|
|
105
151
|
params.userId,
|
|
106
|
-
config.maxConcurrentJobsPerUser
|
|
152
|
+
config.maxConcurrentJobsPerUser,
|
|
153
|
+
config.maxConcurrentJobs,
|
|
154
|
+
params.userId,
|
|
155
|
+
config.maxConcurrentJobsPerUser,
|
|
156
|
+
params.userId,
|
|
157
|
+
params.idempotencyKey ?? "",
|
|
158
|
+
now
|
|
107
159
|
);
|
|
108
160
|
if (inserted[0]) return inserted[0].id;
|
|
109
161
|
if (params.idempotencyKey) {
|
|
@@ -112,6 +164,13 @@ export class SqliteBackgroundJobProvider {
|
|
|
112
164
|
params.idempotencyKey
|
|
113
165
|
);
|
|
114
166
|
if (existing) return existing.id;
|
|
167
|
+
if (await isAdmissionCancelled(
|
|
168
|
+
params.userId,
|
|
169
|
+
params.idempotencyKey,
|
|
170
|
+
now
|
|
171
|
+
)) {
|
|
172
|
+
throw admissionCancelled(params.idempotencyKey);
|
|
173
|
+
}
|
|
115
174
|
}
|
|
116
175
|
throw new Error("Maximum concurrent background jobs reached");
|
|
117
176
|
}
|
|
@@ -122,13 +181,28 @@ export class SqliteBackgroundJobProvider {
|
|
|
122
181
|
"SELECT id FROM background_jobs WHERE idempotency_key = ?"
|
|
123
182
|
).get(params.idempotencyKey);
|
|
124
183
|
if (existing) return existing.id;
|
|
184
|
+
const cancelled = raw.prepare(
|
|
185
|
+
`SELECT 1 AS hit FROM background_admission_cancels
|
|
186
|
+
WHERE user_id = ? AND admission_id = ?
|
|
187
|
+
AND expires_at > ?`
|
|
188
|
+
).get(
|
|
189
|
+
params.userId,
|
|
190
|
+
params.idempotencyKey,
|
|
191
|
+
now
|
|
192
|
+
);
|
|
193
|
+
if (cancelled) {
|
|
194
|
+
throw admissionCancelled(params.idempotencyKey);
|
|
195
|
+
}
|
|
125
196
|
}
|
|
126
197
|
const active = raw.prepare(
|
|
127
|
-
`SELECT
|
|
128
|
-
SUM(CASE WHEN
|
|
198
|
+
`SELECT
|
|
199
|
+
SUM(CASE WHEN json_extract(execution_json, '$.clientToolCall') IS NULL THEN 1 ELSE 0 END) AS total,
|
|
200
|
+
SUM(CASE WHEN user_id = ? AND json_extract(execution_json, '$.clientToolCall') IS NULL THEN 1 ELSE 0 END) AS user_total,
|
|
201
|
+
SUM(CASE WHEN json_extract(execution_json, '$.clientToolCall') IS NOT NULL THEN 1 ELSE 0 END) AS waiting_total,
|
|
202
|
+
SUM(CASE WHEN user_id = ? AND json_extract(execution_json, '$.clientToolCall') IS NOT NULL THEN 1 ELSE 0 END) AS waiting_user_total
|
|
129
203
|
FROM background_jobs WHERE status = 'streaming'`
|
|
130
|
-
).get(params.userId);
|
|
131
|
-
if (active.total >= config.maxConcurrentJobs || (active.user_total ?? 0) >= config.maxConcurrentJobsPerUser) {
|
|
204
|
+
).get(params.userId, params.userId);
|
|
205
|
+
if ((active.total ?? 0) >= config.maxConcurrentJobs || (active.user_total ?? 0) >= config.maxConcurrentJobsPerUser || (active.waiting_total ?? 0) >= config.maxConcurrentJobs || (active.waiting_user_total ?? 0) >= config.maxConcurrentJobsPerUser) {
|
|
132
206
|
throw new Error(
|
|
133
207
|
"Maximum concurrent background jobs reached"
|
|
134
208
|
);
|
|
@@ -146,6 +220,89 @@ export class SqliteBackgroundJobProvider {
|
|
|
146
220
|
);
|
|
147
221
|
return row ? toJob(row) : null;
|
|
148
222
|
}
|
|
223
|
+
async findJobByIdempotencyKey(idempotencyKey, userId) {
|
|
224
|
+
const row = await one(
|
|
225
|
+
"SELECT * FROM background_jobs WHERE idempotency_key = ? AND user_id = ?",
|
|
226
|
+
idempotencyKey,
|
|
227
|
+
userId
|
|
228
|
+
);
|
|
229
|
+
return row ? toJob(row) : null;
|
|
230
|
+
}
|
|
231
|
+
async cancelAdmission(userId, admissionId) {
|
|
232
|
+
const now = Date.now();
|
|
233
|
+
const expiresAt = now + ADMISSION_CANCEL_TTL_MS;
|
|
234
|
+
const upsertMarker = `INSERT INTO background_admission_cancels
|
|
235
|
+
(user_id, admission_id, expires_at)
|
|
236
|
+
VALUES (?, ?, ?)
|
|
237
|
+
ON CONFLICT(user_id, admission_id)
|
|
238
|
+
DO UPDATE SET expires_at = excluded.expires_at`;
|
|
239
|
+
const abortJob = `UPDATE background_jobs SET status = 'aborted',
|
|
240
|
+
completed_at = ?, last_activity_at = ?, error = ?,
|
|
241
|
+
lease_owner = NULL, lease_expires_at = NULL,
|
|
242
|
+
history_phase = CASE
|
|
243
|
+
WHEN generation_id IS NOT NULL
|
|
244
|
+
AND COALESCE(history_phase, 'ready') = 'ready'
|
|
245
|
+
THEN 'finalization_pending'
|
|
246
|
+
ELSE history_phase END
|
|
247
|
+
WHERE id = ? AND status = 'streaming'`;
|
|
248
|
+
if (!isD1Driver()) {
|
|
249
|
+
const raw = getRawDb();
|
|
250
|
+
return raw.transaction(() => {
|
|
251
|
+
raw.prepare(
|
|
252
|
+
"DELETE FROM background_admission_cancels WHERE expires_at <= ?"
|
|
253
|
+
).run(now);
|
|
254
|
+
raw.prepare(upsertMarker).run(userId, admissionId, expiresAt);
|
|
255
|
+
const job2 = raw.prepare(
|
|
256
|
+
"SELECT id, status FROM background_jobs WHERE user_id = ? AND idempotency_key = ?"
|
|
257
|
+
).get(userId, admissionId);
|
|
258
|
+
if (!job2) {
|
|
259
|
+
return { aborted: false, pending: true };
|
|
260
|
+
}
|
|
261
|
+
if (job2.status === "streaming") {
|
|
262
|
+
const changes = raw.prepare(abortJob).run(
|
|
263
|
+
now,
|
|
264
|
+
now,
|
|
265
|
+
"Cancelled by user",
|
|
266
|
+
job2.id
|
|
267
|
+
).changes;
|
|
268
|
+
return {
|
|
269
|
+
aborted: changes > 0,
|
|
270
|
+
jobId: job2.id,
|
|
271
|
+
pending: false
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
return { aborted: false, jobId: job2.id, pending: false };
|
|
275
|
+
}).immediate();
|
|
276
|
+
}
|
|
277
|
+
await run(
|
|
278
|
+
"DELETE FROM background_admission_cancels WHERE expires_at <= ?",
|
|
279
|
+
now
|
|
280
|
+
);
|
|
281
|
+
await run(upsertMarker, userId, admissionId, expiresAt);
|
|
282
|
+
const job = await one(
|
|
283
|
+
"SELECT id, status FROM background_jobs WHERE user_id = ? AND idempotency_key = ?",
|
|
284
|
+
userId,
|
|
285
|
+
admissionId
|
|
286
|
+
);
|
|
287
|
+
if (!job) {
|
|
288
|
+
return { aborted: false, pending: true };
|
|
289
|
+
}
|
|
290
|
+
if (job.status === "streaming") {
|
|
291
|
+
const changes = await run(
|
|
292
|
+
abortJob,
|
|
293
|
+
now,
|
|
294
|
+
now,
|
|
295
|
+
"Cancelled by user",
|
|
296
|
+
job.id
|
|
297
|
+
);
|
|
298
|
+
return {
|
|
299
|
+
aborted: changes > 0,
|
|
300
|
+
jobId: job.id,
|
|
301
|
+
pending: false
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
return { aborted: false, jobId: job.id, pending: false };
|
|
305
|
+
}
|
|
149
306
|
async updateJob(jobId, update) {
|
|
150
307
|
const sets = ["last_activity_at = ?"];
|
|
151
308
|
const values = [Date.now()];
|
|
@@ -153,6 +310,10 @@ export class SqliteBackgroundJobProvider {
|
|
|
153
310
|
sets.push("content = content || ?");
|
|
154
311
|
values.push(update.contentChunk);
|
|
155
312
|
}
|
|
313
|
+
if (update.reasoningChunk !== void 0) {
|
|
314
|
+
sets.push("reasoning = reasoning || ?");
|
|
315
|
+
values.push(update.reasoningChunk);
|
|
316
|
+
}
|
|
156
317
|
if (update.chunksReceived !== void 0) {
|
|
157
318
|
sets.push("chunks_received = ?");
|
|
158
319
|
values.push(update.chunksReceived);
|
|
@@ -168,8 +329,9 @@ export class SqliteBackgroundJobProvider {
|
|
|
168
329
|
values.push(jobId);
|
|
169
330
|
let where = "id = ? AND status = 'streaming'";
|
|
170
331
|
if (update.leaseOwner) {
|
|
171
|
-
where += " AND lease_owner = ?";
|
|
332
|
+
where += " AND lease_owner = ? AND lease_expires_at > ?";
|
|
172
333
|
values.push(update.leaseOwner);
|
|
334
|
+
values.push(Date.now());
|
|
173
335
|
}
|
|
174
336
|
const changes = await run(
|
|
175
337
|
`UPDATE background_jobs SET ${sets.join(", ")} WHERE ${where}`,
|
|
@@ -206,7 +368,12 @@ export class SqliteBackgroundJobProvider {
|
|
|
206
368
|
const now = Date.now();
|
|
207
369
|
return await run(
|
|
208
370
|
`UPDATE background_jobs SET status = 'aborted', completed_at = ?,
|
|
209
|
-
last_activity_at = ?, lease_owner = NULL, lease_expires_at = NULL
|
|
371
|
+
last_activity_at = ?, lease_owner = NULL, lease_expires_at = NULL,
|
|
372
|
+
history_phase = CASE
|
|
373
|
+
WHEN generation_id IS NOT NULL
|
|
374
|
+
AND COALESCE(history_phase, 'ready') = 'ready'
|
|
375
|
+
THEN 'finalization_pending'
|
|
376
|
+
ELSE history_phase END
|
|
210
377
|
WHERE id = ? AND user_id = ? AND status = 'streaming'`,
|
|
211
378
|
now,
|
|
212
379
|
now,
|
|
@@ -226,12 +393,26 @@ export class SqliteBackgroundJobProvider {
|
|
|
226
393
|
}
|
|
227
394
|
async claimJob(jobId, leaseOwner, now, leaseExpiresAt) {
|
|
228
395
|
const row = await one(
|
|
229
|
-
`UPDATE background_jobs SET
|
|
396
|
+
`UPDATE background_jobs SET
|
|
397
|
+
lease_owner = ?,
|
|
398
|
+
lease_expires_at = ?,
|
|
399
|
+
last_activity_at = ?,
|
|
400
|
+
content = CASE WHEN attempts > 0
|
|
401
|
+
THEN COALESCE(json_extract(execution_json, '$.contentBase'), '')
|
|
402
|
+
ELSE content END,
|
|
403
|
+
reasoning = CASE WHEN attempts > 0
|
|
404
|
+
THEN COALESCE(json_extract(execution_json, '$.reasoningBase'), '')
|
|
405
|
+
ELSE reasoning END,
|
|
406
|
+
chunks_received = CASE WHEN attempts > 0 THEN 0 ELSE chunks_received END,
|
|
407
|
+
attempts = attempts + 1
|
|
230
408
|
WHERE id = ? AND status = 'streaming' AND execution_json IS NOT NULL
|
|
409
|
+
AND json_extract(execution_json, '$.clientToolCall') IS NULL
|
|
410
|
+
AND COALESCE(history_phase, 'ready') = 'ready'
|
|
231
411
|
AND (lease_owner IS NULL OR lease_expires_at <= ?)
|
|
232
412
|
RETURNING *`,
|
|
233
413
|
leaseOwner,
|
|
234
414
|
leaseExpiresAt,
|
|
415
|
+
now,
|
|
235
416
|
jobId,
|
|
236
417
|
now
|
|
237
418
|
);
|
|
@@ -239,15 +420,29 @@ export class SqliteBackgroundJobProvider {
|
|
|
239
420
|
}
|
|
240
421
|
async claimNextJob(leaseOwner, now, leaseExpiresAt) {
|
|
241
422
|
const row = await one(
|
|
242
|
-
`UPDATE background_jobs SET
|
|
423
|
+
`UPDATE background_jobs SET
|
|
424
|
+
lease_owner = ?,
|
|
425
|
+
lease_expires_at = ?,
|
|
426
|
+
last_activity_at = ?,
|
|
427
|
+
content = CASE WHEN attempts > 0
|
|
428
|
+
THEN COALESCE(json_extract(execution_json, '$.contentBase'), '')
|
|
429
|
+
ELSE content END,
|
|
430
|
+
reasoning = CASE WHEN attempts > 0
|
|
431
|
+
THEN COALESCE(json_extract(execution_json, '$.reasoningBase'), '')
|
|
432
|
+
ELSE reasoning END,
|
|
433
|
+
chunks_received = CASE WHEN attempts > 0 THEN 0 ELSE chunks_received END,
|
|
434
|
+
attempts = attempts + 1
|
|
243
435
|
WHERE id = (
|
|
244
436
|
SELECT id FROM background_jobs
|
|
245
437
|
WHERE status = 'streaming' AND execution_json IS NOT NULL
|
|
438
|
+
AND json_extract(execution_json, '$.clientToolCall') IS NULL
|
|
439
|
+
AND COALESCE(history_phase, 'ready') = 'ready'
|
|
246
440
|
AND (lease_owner IS NULL OR lease_expires_at <= ?)
|
|
247
441
|
ORDER BY started_at ASC LIMIT 1
|
|
248
442
|
) RETURNING *`,
|
|
249
443
|
leaseOwner,
|
|
250
444
|
leaseExpiresAt,
|
|
445
|
+
now,
|
|
251
446
|
now
|
|
252
447
|
);
|
|
253
448
|
return row ? toJob(row) : null;
|
|
@@ -255,11 +450,13 @@ export class SqliteBackgroundJobProvider {
|
|
|
255
450
|
async renewJobLease(jobId, leaseOwner, _now, leaseExpiresAt) {
|
|
256
451
|
return await run(
|
|
257
452
|
`UPDATE background_jobs SET lease_expires_at = ?, last_activity_at = ?
|
|
258
|
-
WHERE id = ? AND status = 'streaming' AND lease_owner =
|
|
453
|
+
WHERE id = ? AND status = 'streaming' AND lease_owner = ?
|
|
454
|
+
AND lease_expires_at > ?`,
|
|
259
455
|
leaseExpiresAt,
|
|
260
456
|
Date.now(),
|
|
261
457
|
jobId,
|
|
262
|
-
leaseOwner
|
|
458
|
+
leaseOwner,
|
|
459
|
+
Date.now()
|
|
263
460
|
) > 0;
|
|
264
461
|
}
|
|
265
462
|
async updateJobExecution(jobId, execution, leaseOwner) {
|
|
@@ -272,26 +469,127 @@ export class SqliteBackgroundJobProvider {
|
|
|
272
469
|
leaseOwner
|
|
273
470
|
) > 0;
|
|
274
471
|
}
|
|
472
|
+
async claimClientToolCall(jobId, userId, callId, claimToken, claimExpiresAt) {
|
|
473
|
+
const now = Date.now();
|
|
474
|
+
const row = await one(
|
|
475
|
+
`UPDATE background_jobs SET
|
|
476
|
+
execution_json = json_set(
|
|
477
|
+
execution_json,
|
|
478
|
+
'$.clientToolCall.claimToken', ?,
|
|
479
|
+
'$.clientToolCall.claimExpiresAt', ?
|
|
480
|
+
),
|
|
481
|
+
last_activity_at = ?
|
|
482
|
+
WHERE id = ? AND user_id = ? AND status = 'streaming'
|
|
483
|
+
AND json_extract(execution_json, '$.clientToolCall.callId') = ?
|
|
484
|
+
AND (
|
|
485
|
+
json_extract(execution_json, '$.clientToolCall.claimToken') IS NULL
|
|
486
|
+
OR COALESCE(json_extract(execution_json, '$.clientToolCall.claimExpiresAt'), 0) <= ?
|
|
487
|
+
)
|
|
488
|
+
RETURNING *`,
|
|
489
|
+
claimToken,
|
|
490
|
+
claimExpiresAt,
|
|
491
|
+
now,
|
|
492
|
+
jobId,
|
|
493
|
+
userId,
|
|
494
|
+
callId,
|
|
495
|
+
now
|
|
496
|
+
);
|
|
497
|
+
return row ? toJob(row) : null;
|
|
498
|
+
}
|
|
499
|
+
async settleClientToolCall(jobId, userId, callId, claimToken, execution, toolCalls) {
|
|
500
|
+
const now = Date.now();
|
|
501
|
+
return await run(
|
|
502
|
+
`UPDATE background_jobs SET execution_json = ?, tool_calls_json = ?,
|
|
503
|
+
lease_owner = NULL, lease_expires_at = NULL, last_activity_at = ?
|
|
504
|
+
WHERE id = ? AND user_id = ? AND status = 'streaming'
|
|
505
|
+
AND json_extract(execution_json, '$.clientToolCall.callId') = ?
|
|
506
|
+
AND json_extract(execution_json, '$.clientToolCall.claimToken') = ?
|
|
507
|
+
AND COALESCE(json_extract(execution_json, '$.clientToolCall.claimExpiresAt'), 0) > ?`,
|
|
508
|
+
json(execution),
|
|
509
|
+
json(toolCalls),
|
|
510
|
+
now,
|
|
511
|
+
jobId,
|
|
512
|
+
userId,
|
|
513
|
+
callId,
|
|
514
|
+
claimToken,
|
|
515
|
+
now
|
|
516
|
+
) > 0;
|
|
517
|
+
}
|
|
518
|
+
async saveTerminalSnapshot(jobId, snapshot, leaseOwner) {
|
|
519
|
+
const values = [
|
|
520
|
+
snapshot.status,
|
|
521
|
+
snapshot.content,
|
|
522
|
+
snapshot.reasoning,
|
|
523
|
+
json(snapshot.toolCalls),
|
|
524
|
+
snapshot.error ?? null,
|
|
525
|
+
snapshot.completedAt,
|
|
526
|
+
snapshot.completedAt
|
|
527
|
+
];
|
|
528
|
+
let where = "id = ? AND status = 'streaming'";
|
|
529
|
+
values.push(jobId);
|
|
530
|
+
if (leaseOwner) {
|
|
531
|
+
where += " AND lease_owner = ? AND lease_expires_at > ?";
|
|
532
|
+
values.push(leaseOwner, Date.now());
|
|
533
|
+
}
|
|
534
|
+
return await run(
|
|
535
|
+
`UPDATE background_jobs SET status = ?, content = ?, reasoning = ?,
|
|
536
|
+
tool_calls_json = ?, error = ?, completed_at = ?,
|
|
537
|
+
history_phase = 'finalization_pending',
|
|
538
|
+
last_activity_at = ?, lease_owner = NULL, lease_expires_at = NULL
|
|
539
|
+
WHERE ${where}`,
|
|
540
|
+
...values
|
|
541
|
+
) > 0;
|
|
542
|
+
}
|
|
543
|
+
async setHistoryPhase(jobId, phase, options) {
|
|
544
|
+
const values = [phase, jobId];
|
|
545
|
+
let where = "id = ?";
|
|
546
|
+
if (options?.from && options.from.length > 0) {
|
|
547
|
+
const placeholders = options.from.map(() => "?").join(", ");
|
|
548
|
+
where += ` AND COALESCE(history_phase, 'ready') IN (${placeholders})`;
|
|
549
|
+
values.push(...options.from);
|
|
550
|
+
}
|
|
551
|
+
return await run(`UPDATE background_jobs SET history_phase = ? WHERE ${where}`, ...values) > 0;
|
|
552
|
+
}
|
|
553
|
+
async getPendingHistoryJobs(limit) {
|
|
554
|
+
const rows = await all(
|
|
555
|
+
`SELECT * FROM background_jobs
|
|
556
|
+
WHERE history_phase IN ('admission_pending', 'finalization_pending')
|
|
557
|
+
ORDER BY started_at ASC
|
|
558
|
+
LIMIT ?`,
|
|
559
|
+
Math.max(1, Math.min(100, Math.floor(limit)))
|
|
560
|
+
);
|
|
561
|
+
return rows.map(toJob);
|
|
562
|
+
}
|
|
275
563
|
async cleanupExpired() {
|
|
276
564
|
const config = getJobConfig();
|
|
277
565
|
const now = Date.now();
|
|
278
566
|
const timedOut = await run(
|
|
279
567
|
`UPDATE background_jobs SET status = 'error', error = 'Job timed out',
|
|
280
|
-
completed_at = ?, lease_owner = NULL, lease_expires_at = NULL
|
|
568
|
+
completed_at = ?, lease_owner = NULL, lease_expires_at = NULL,
|
|
569
|
+
history_phase = CASE
|
|
570
|
+
WHEN generation_id IS NOT NULL
|
|
571
|
+
AND COALESCE(history_phase, 'ready') = 'ready'
|
|
572
|
+
THEN 'finalization_pending'
|
|
573
|
+
ELSE history_phase END
|
|
281
574
|
WHERE status = 'streaming' AND last_activity_at <= ?`,
|
|
282
575
|
now,
|
|
283
576
|
now - config.jobTimeoutMs
|
|
284
577
|
);
|
|
285
578
|
const removed = await run(
|
|
286
579
|
`DELETE FROM background_jobs
|
|
287
|
-
WHERE status != 'streaming' AND completed_at IS NOT NULL AND completed_at <=
|
|
580
|
+
WHERE status != 'streaming' AND completed_at IS NOT NULL AND completed_at <= ?
|
|
581
|
+
AND (
|
|
582
|
+
history_phase IS NULL
|
|
583
|
+
OR history_phase IN ('ready', 'committed', 'superseded')
|
|
584
|
+
)`,
|
|
288
585
|
now - config.completedJobRetentionMs
|
|
289
586
|
);
|
|
290
587
|
return timedOut + removed;
|
|
291
588
|
}
|
|
292
589
|
async getActiveJobCount() {
|
|
293
590
|
const row = await one(
|
|
294
|
-
|
|
591
|
+
`SELECT COUNT(*) AS total FROM background_jobs WHERE status = 'streaming'
|
|
592
|
+
AND json_extract(execution_json, '$.clientToolCall') IS NULL`
|
|
295
593
|
);
|
|
296
594
|
return row?.total ?? 0;
|
|
297
595
|
}
|