or3-provider-sqlite 0.0.7 → 0.0.9
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 +6 -2
- package/dist/module.json +1 -1
- package/dist/runtime/server/background-jobs/sqlite-provider.d.ts +20 -0
- package/dist/runtime/server/background-jobs/sqlite-provider.js +299 -0
- package/dist/runtime/server/db/migrate.js +5 -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/schema.d.ts +23 -0
- package/dist/runtime/server/plugins/register.js +8 -1
- package/dist/runtime/server/sync/sqlite-sync-gateway-adapter.js +6 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -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,8 @@ 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
|
|
141
145
|
|
|
142
146
|
Additional migrations (007–008, 010–016) evolve device-cursor ownership,
|
|
143
147
|
upload intents, and Connect credential/lifecycle hardening and rate limits.
|
|
@@ -146,7 +150,7 @@ All tables use snake_case aligned with the sync wire format.
|
|
|
146
150
|
|
|
147
151
|
### Sync semantics
|
|
148
152
|
|
|
149
|
-
- **Push**: validates ops → checks `op_id` idempotency → allocates contiguous `server_version` block → writes change_log → applies LWW to materialized tables → upserts tombstones for deletes
|
|
153
|
+
- **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
154
|
- **Pull**: returns ordered changes for `server_version > cursor` with limit/pagination and optional table filtering
|
|
151
155
|
- **Snapshot**: captures canonical live rows and current tombstones at one `highWatermark`, then serves immutable, keyset-paginated pages ordered by `(tableName, pk, kind)`
|
|
152
156
|
- **Cursor**: forward-only per-device cursor tracking
|
package/dist/module.json
CHANGED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { BackgroundJob, BackgroundJobExecution, BackgroundJobProvider, CreateJobParams, JobUpdate } 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
|
+
updateJob(jobId: string, update: JobUpdate): Promise<void>;
|
|
7
|
+
completeJob(jobId: string, finalContent: string, leaseOwner?: string): Promise<void>;
|
|
8
|
+
failJob(jobId: string, error: string, leaseOwner?: string): Promise<void>;
|
|
9
|
+
private finish;
|
|
10
|
+
abortJob(jobId: string, userId: string): Promise<boolean>;
|
|
11
|
+
getAbortController(): undefined;
|
|
12
|
+
checkJobAborted(jobId: string): Promise<boolean>;
|
|
13
|
+
claimJob(jobId: string, leaseOwner: string, now: number, leaseExpiresAt: number): Promise<BackgroundJob | null>;
|
|
14
|
+
claimNextJob(leaseOwner: string, now: number, leaseExpiresAt: number): Promise<BackgroundJob | null>;
|
|
15
|
+
renewJobLease(jobId: string, leaseOwner: string, _now: number, leaseExpiresAt: number): Promise<boolean>;
|
|
16
|
+
updateJobExecution(jobId: string, execution: BackgroundJobExecution, leaseOwner: string): Promise<boolean>;
|
|
17
|
+
cleanupExpired(): Promise<number>;
|
|
18
|
+
getActiveJobCount(): Promise<number>;
|
|
19
|
+
}
|
|
20
|
+
export declare const sqliteBackgroundJobProvider: SqliteBackgroundJobProvider;
|
|
@@ -0,0 +1,299 @@
|
|
|
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
|
+
chunksReceived: row.chunks_received,
|
|
23
|
+
startedAt: row.started_at,
|
|
24
|
+
lastActivityAt: row.last_activity_at,
|
|
25
|
+
completedAt: row.completed_at ?? void 0,
|
|
26
|
+
error: row.error ?? void 0,
|
|
27
|
+
tool_calls: parseJson(row.tool_calls_json),
|
|
28
|
+
workflow_state: parseJson(
|
|
29
|
+
row.workflow_state_json
|
|
30
|
+
),
|
|
31
|
+
execution: parseJson(row.execution_json),
|
|
32
|
+
leaseOwner: row.lease_owner ?? void 0,
|
|
33
|
+
leaseExpiresAt: row.lease_expires_at ?? void 0,
|
|
34
|
+
attempts: row.attempts
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
async function all(statement, ...parameters) {
|
|
38
|
+
if (isD1Driver()) return d1All(statement, ...parameters);
|
|
39
|
+
return getRawDb().prepare(statement).all(...parameters);
|
|
40
|
+
}
|
|
41
|
+
async function one(statement, ...parameters) {
|
|
42
|
+
if (isD1Driver()) return (await d1All(statement, ...parameters))[0];
|
|
43
|
+
return getRawDb().prepare(statement).get(...parameters);
|
|
44
|
+
}
|
|
45
|
+
async function run(statement, ...parameters) {
|
|
46
|
+
if (isD1Driver()) {
|
|
47
|
+
const result = await d1Run(statement, ...parameters);
|
|
48
|
+
return result.meta?.changes ?? 0;
|
|
49
|
+
}
|
|
50
|
+
return getRawDb().prepare(statement).run(...parameters).changes;
|
|
51
|
+
}
|
|
52
|
+
function leaseLost() {
|
|
53
|
+
const error = new Error("Background job lease was superseded");
|
|
54
|
+
error.name = "BackgroundJobLeaseLostError";
|
|
55
|
+
return error;
|
|
56
|
+
}
|
|
57
|
+
function insertParameters(id, params, now) {
|
|
58
|
+
return [
|
|
59
|
+
id,
|
|
60
|
+
params.userId,
|
|
61
|
+
params.threadId,
|
|
62
|
+
params.messageId,
|
|
63
|
+
params.model,
|
|
64
|
+
params.kind ?? null,
|
|
65
|
+
now,
|
|
66
|
+
now,
|
|
67
|
+
json(params.tool_calls),
|
|
68
|
+
json(params.workflow_state),
|
|
69
|
+
json(params.execution),
|
|
70
|
+
params.idempotencyKey ?? null
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
const INSERT_JOB = `INSERT INTO background_jobs (
|
|
74
|
+
id, user_id, thread_id, message_id, model, kind, status, content,
|
|
75
|
+
chunks_received, started_at, last_activity_at, tool_calls_json,
|
|
76
|
+
workflow_state_json, execution_json, idempotency_key, attempts
|
|
77
|
+
) VALUES (?, ?, ?, ?, ?, ?, 'streaming', '', 0, ?, ?, ?, ?, ?, ?, 0)`;
|
|
78
|
+
const INSERT_JOB_IF_CAPACITY = `INSERT INTO background_jobs (
|
|
79
|
+
id, user_id, thread_id, message_id, model, kind, status, content,
|
|
80
|
+
chunks_received, started_at, last_activity_at, tool_calls_json,
|
|
81
|
+
workflow_state_json, execution_json, idempotency_key, attempts
|
|
82
|
+
) SELECT ?, ?, ?, ?, ?, ?, 'streaming', '', 0, ?, ?, ?, ?, ?, ?, 0
|
|
83
|
+
WHERE (SELECT COUNT(*) FROM background_jobs WHERE status = 'streaming') < ?
|
|
84
|
+
AND (SELECT COUNT(*) FROM background_jobs WHERE status = 'streaming' AND user_id = ?) < ?
|
|
85
|
+
ON CONFLICT(idempotency_key) DO NOTHING
|
|
86
|
+
RETURNING id`;
|
|
87
|
+
export class SqliteBackgroundJobProvider {
|
|
88
|
+
name = "sqlite";
|
|
89
|
+
async createJob(params) {
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
const id = randomUUID();
|
|
92
|
+
const config = getJobConfig();
|
|
93
|
+
if (isD1Driver()) {
|
|
94
|
+
if (params.idempotencyKey) {
|
|
95
|
+
const existing = await one(
|
|
96
|
+
"SELECT id FROM background_jobs WHERE idempotency_key = ?",
|
|
97
|
+
params.idempotencyKey
|
|
98
|
+
);
|
|
99
|
+
if (existing) return existing.id;
|
|
100
|
+
}
|
|
101
|
+
const inserted = await all(
|
|
102
|
+
INSERT_JOB_IF_CAPACITY,
|
|
103
|
+
...insertParameters(id, params, now),
|
|
104
|
+
config.maxConcurrentJobs,
|
|
105
|
+
params.userId,
|
|
106
|
+
config.maxConcurrentJobsPerUser
|
|
107
|
+
);
|
|
108
|
+
if (inserted[0]) return inserted[0].id;
|
|
109
|
+
if (params.idempotencyKey) {
|
|
110
|
+
const existing = await one(
|
|
111
|
+
"SELECT id FROM background_jobs WHERE idempotency_key = ?",
|
|
112
|
+
params.idempotencyKey
|
|
113
|
+
);
|
|
114
|
+
if (existing) return existing.id;
|
|
115
|
+
}
|
|
116
|
+
throw new Error("Maximum concurrent background jobs reached");
|
|
117
|
+
}
|
|
118
|
+
const raw = getRawDb();
|
|
119
|
+
return raw.transaction(() => {
|
|
120
|
+
if (params.idempotencyKey) {
|
|
121
|
+
const existing = raw.prepare(
|
|
122
|
+
"SELECT id FROM background_jobs WHERE idempotency_key = ?"
|
|
123
|
+
).get(params.idempotencyKey);
|
|
124
|
+
if (existing) return existing.id;
|
|
125
|
+
}
|
|
126
|
+
const active = raw.prepare(
|
|
127
|
+
`SELECT COUNT(*) AS total,
|
|
128
|
+
SUM(CASE WHEN user_id = ? THEN 1 ELSE 0 END) AS user_total
|
|
129
|
+
FROM background_jobs WHERE status = 'streaming'`
|
|
130
|
+
).get(params.userId);
|
|
131
|
+
if (active.total >= config.maxConcurrentJobs || (active.user_total ?? 0) >= config.maxConcurrentJobsPerUser) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
"Maximum concurrent background jobs reached"
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
raw.prepare(INSERT_JOB).run(
|
|
137
|
+
...insertParameters(id, params, now)
|
|
138
|
+
);
|
|
139
|
+
return id;
|
|
140
|
+
}).immediate();
|
|
141
|
+
}
|
|
142
|
+
async getJob(jobId, userId) {
|
|
143
|
+
const row = await one(
|
|
144
|
+
`SELECT * FROM background_jobs WHERE id = ?${userId === "*" ? "" : " AND user_id = ?"}`,
|
|
145
|
+
...[jobId, ...userId === "*" ? [] : [userId]]
|
|
146
|
+
);
|
|
147
|
+
return row ? toJob(row) : null;
|
|
148
|
+
}
|
|
149
|
+
async updateJob(jobId, update) {
|
|
150
|
+
const sets = ["last_activity_at = ?"];
|
|
151
|
+
const values = [Date.now()];
|
|
152
|
+
if (update.contentChunk !== void 0) {
|
|
153
|
+
sets.push("content = content || ?");
|
|
154
|
+
values.push(update.contentChunk);
|
|
155
|
+
}
|
|
156
|
+
if (update.chunksReceived !== void 0) {
|
|
157
|
+
sets.push("chunks_received = ?");
|
|
158
|
+
values.push(update.chunksReceived);
|
|
159
|
+
}
|
|
160
|
+
if (update.tool_calls !== void 0) {
|
|
161
|
+
sets.push("tool_calls_json = ?");
|
|
162
|
+
values.push(json(update.tool_calls));
|
|
163
|
+
}
|
|
164
|
+
if (update.workflow_state !== void 0) {
|
|
165
|
+
sets.push("workflow_state_json = ?");
|
|
166
|
+
values.push(json(update.workflow_state));
|
|
167
|
+
}
|
|
168
|
+
values.push(jobId);
|
|
169
|
+
let where = "id = ? AND status = 'streaming'";
|
|
170
|
+
if (update.leaseOwner) {
|
|
171
|
+
where += " AND lease_owner = ?";
|
|
172
|
+
values.push(update.leaseOwner);
|
|
173
|
+
}
|
|
174
|
+
const changes = await run(
|
|
175
|
+
`UPDATE background_jobs SET ${sets.join(", ")} WHERE ${where}`,
|
|
176
|
+
...values
|
|
177
|
+
);
|
|
178
|
+
if (update.leaseOwner && changes === 0) throw leaseLost();
|
|
179
|
+
}
|
|
180
|
+
async completeJob(jobId, finalContent, leaseOwner) {
|
|
181
|
+
await this.finish(jobId, "complete", finalContent, null, leaseOwner);
|
|
182
|
+
}
|
|
183
|
+
async failJob(jobId, error, leaseOwner) {
|
|
184
|
+
await this.finish(jobId, "error", null, error, leaseOwner);
|
|
185
|
+
}
|
|
186
|
+
async finish(jobId, status, content, error, leaseOwner) {
|
|
187
|
+
const now = Date.now();
|
|
188
|
+
const values = [status, now, now, error];
|
|
189
|
+
const contentSet = content === null ? "" : ", content = ?";
|
|
190
|
+
if (content !== null) values.push(content);
|
|
191
|
+
values.push(jobId);
|
|
192
|
+
let where = "id = ? AND status = 'streaming'";
|
|
193
|
+
if (leaseOwner) {
|
|
194
|
+
where += " AND lease_owner = ?";
|
|
195
|
+
values.push(leaseOwner);
|
|
196
|
+
}
|
|
197
|
+
const changes = await run(
|
|
198
|
+
`UPDATE background_jobs SET status = ?, completed_at = ?, last_activity_at = ?,
|
|
199
|
+
error = ?, lease_owner = NULL, lease_expires_at = NULL${contentSet}
|
|
200
|
+
WHERE ${where}`,
|
|
201
|
+
...values
|
|
202
|
+
);
|
|
203
|
+
if (leaseOwner && changes === 0) throw leaseLost();
|
|
204
|
+
}
|
|
205
|
+
async abortJob(jobId, userId) {
|
|
206
|
+
const now = Date.now();
|
|
207
|
+
return await run(
|
|
208
|
+
`UPDATE background_jobs SET status = 'aborted', completed_at = ?,
|
|
209
|
+
last_activity_at = ?, lease_owner = NULL, lease_expires_at = NULL
|
|
210
|
+
WHERE id = ? AND user_id = ? AND status = 'streaming'`,
|
|
211
|
+
now,
|
|
212
|
+
now,
|
|
213
|
+
jobId,
|
|
214
|
+
userId
|
|
215
|
+
) > 0;
|
|
216
|
+
}
|
|
217
|
+
getAbortController() {
|
|
218
|
+
return void 0;
|
|
219
|
+
}
|
|
220
|
+
async checkJobAborted(jobId) {
|
|
221
|
+
const row = await one(
|
|
222
|
+
"SELECT status FROM background_jobs WHERE id = ?",
|
|
223
|
+
jobId
|
|
224
|
+
);
|
|
225
|
+
return row?.status === "aborted";
|
|
226
|
+
}
|
|
227
|
+
async claimJob(jobId, leaseOwner, now, leaseExpiresAt) {
|
|
228
|
+
const row = await one(
|
|
229
|
+
`UPDATE background_jobs SET lease_owner = ?, lease_expires_at = ?, attempts = attempts + 1
|
|
230
|
+
WHERE id = ? AND status = 'streaming' AND execution_json IS NOT NULL
|
|
231
|
+
AND (lease_owner IS NULL OR lease_expires_at <= ?)
|
|
232
|
+
RETURNING *`,
|
|
233
|
+
leaseOwner,
|
|
234
|
+
leaseExpiresAt,
|
|
235
|
+
jobId,
|
|
236
|
+
now
|
|
237
|
+
);
|
|
238
|
+
return row ? toJob(row) : null;
|
|
239
|
+
}
|
|
240
|
+
async claimNextJob(leaseOwner, now, leaseExpiresAt) {
|
|
241
|
+
const row = await one(
|
|
242
|
+
`UPDATE background_jobs SET lease_owner = ?, lease_expires_at = ?, attempts = attempts + 1
|
|
243
|
+
WHERE id = (
|
|
244
|
+
SELECT id FROM background_jobs
|
|
245
|
+
WHERE status = 'streaming' AND execution_json IS NOT NULL
|
|
246
|
+
AND (lease_owner IS NULL OR lease_expires_at <= ?)
|
|
247
|
+
ORDER BY started_at ASC LIMIT 1
|
|
248
|
+
) RETURNING *`,
|
|
249
|
+
leaseOwner,
|
|
250
|
+
leaseExpiresAt,
|
|
251
|
+
now
|
|
252
|
+
);
|
|
253
|
+
return row ? toJob(row) : null;
|
|
254
|
+
}
|
|
255
|
+
async renewJobLease(jobId, leaseOwner, _now, leaseExpiresAt) {
|
|
256
|
+
return await run(
|
|
257
|
+
`UPDATE background_jobs SET lease_expires_at = ?, last_activity_at = ?
|
|
258
|
+
WHERE id = ? AND status = 'streaming' AND lease_owner = ?`,
|
|
259
|
+
leaseExpiresAt,
|
|
260
|
+
Date.now(),
|
|
261
|
+
jobId,
|
|
262
|
+
leaseOwner
|
|
263
|
+
) > 0;
|
|
264
|
+
}
|
|
265
|
+
async updateJobExecution(jobId, execution, leaseOwner) {
|
|
266
|
+
return await run(
|
|
267
|
+
`UPDATE background_jobs SET execution_json = ?, last_activity_at = ?
|
|
268
|
+
WHERE id = ? AND status = 'streaming' AND lease_owner = ?`,
|
|
269
|
+
json(execution),
|
|
270
|
+
Date.now(),
|
|
271
|
+
jobId,
|
|
272
|
+
leaseOwner
|
|
273
|
+
) > 0;
|
|
274
|
+
}
|
|
275
|
+
async cleanupExpired() {
|
|
276
|
+
const config = getJobConfig();
|
|
277
|
+
const now = Date.now();
|
|
278
|
+
const timedOut = await run(
|
|
279
|
+
`UPDATE background_jobs SET status = 'error', error = 'Job timed out',
|
|
280
|
+
completed_at = ?, lease_owner = NULL, lease_expires_at = NULL
|
|
281
|
+
WHERE status = 'streaming' AND last_activity_at <= ?`,
|
|
282
|
+
now,
|
|
283
|
+
now - config.jobTimeoutMs
|
|
284
|
+
);
|
|
285
|
+
const removed = await run(
|
|
286
|
+
`DELETE FROM background_jobs
|
|
287
|
+
WHERE status != 'streaming' AND completed_at IS NOT NULL AND completed_at <= ?`,
|
|
288
|
+
now - config.completedJobRetentionMs
|
|
289
|
+
);
|
|
290
|
+
return timedOut + removed;
|
|
291
|
+
}
|
|
292
|
+
async getActiveJobCount() {
|
|
293
|
+
const row = await one(
|
|
294
|
+
"SELECT COUNT(*) AS total FROM background_jobs WHERE status = 'streaming'"
|
|
295
|
+
);
|
|
296
|
+
return row?.total ?? 0;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
export const sqliteBackgroundJobProvider = new SqliteBackgroundJobProvider();
|
|
@@ -15,6 +15,7 @@ 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";
|
|
18
19
|
const REQUIRED_SCHEMA_TABLES = [
|
|
19
20
|
"users",
|
|
20
21
|
"auth_accounts",
|
|
@@ -39,7 +40,8 @@ const REQUIRED_SCHEMA_TABLES = [
|
|
|
39
40
|
"upload_intents",
|
|
40
41
|
"connect_device_authorizations",
|
|
41
42
|
"connect_environments",
|
|
42
|
-
"rate_limits"
|
|
43
|
+
"rate_limits",
|
|
44
|
+
"background_jobs"
|
|
43
45
|
];
|
|
44
46
|
const migrations = {
|
|
45
47
|
"001_init": m001,
|
|
@@ -57,7 +59,8 @@ const migrations = {
|
|
|
57
59
|
"013_connect_environment_lifecycle": m013,
|
|
58
60
|
"014_connect_retention_activation": m014,
|
|
59
61
|
"015_rate_limits": m015,
|
|
60
|
-
"016_connect_runtime_binding": m016
|
|
62
|
+
"016_connect_runtime_binding": m016,
|
|
63
|
+
"017_background_jobs": m017
|
|
61
64
|
};
|
|
62
65
|
class StaticMigrationProvider {
|
|
63
66
|
async getMigrations() {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { sql } from "kysely";
|
|
2
|
+
export async function up(db) {
|
|
3
|
+
await sql`
|
|
4
|
+
CREATE TABLE IF NOT EXISTS background_jobs (
|
|
5
|
+
id TEXT PRIMARY KEY,
|
|
6
|
+
user_id TEXT NOT NULL,
|
|
7
|
+
thread_id TEXT NOT NULL,
|
|
8
|
+
message_id TEXT NOT NULL,
|
|
9
|
+
model TEXT NOT NULL,
|
|
10
|
+
kind TEXT,
|
|
11
|
+
status TEXT NOT NULL,
|
|
12
|
+
content TEXT NOT NULL DEFAULT '',
|
|
13
|
+
chunks_received INTEGER NOT NULL DEFAULT 0,
|
|
14
|
+
started_at INTEGER NOT NULL,
|
|
15
|
+
last_activity_at INTEGER NOT NULL,
|
|
16
|
+
completed_at INTEGER,
|
|
17
|
+
error TEXT,
|
|
18
|
+
tool_calls_json TEXT,
|
|
19
|
+
workflow_state_json TEXT,
|
|
20
|
+
execution_json TEXT,
|
|
21
|
+
idempotency_key TEXT UNIQUE,
|
|
22
|
+
lease_owner TEXT,
|
|
23
|
+
lease_expires_at INTEGER,
|
|
24
|
+
attempts INTEGER NOT NULL DEFAULT 0
|
|
25
|
+
)
|
|
26
|
+
`.execute(db);
|
|
27
|
+
await sql`
|
|
28
|
+
CREATE INDEX IF NOT EXISTS background_jobs_active
|
|
29
|
+
ON background_jobs(status, last_activity_at)
|
|
30
|
+
`.execute(db);
|
|
31
|
+
await sql`
|
|
32
|
+
CREATE INDEX IF NOT EXISTS background_jobs_user_active
|
|
33
|
+
ON background_jobs(user_id, status)
|
|
34
|
+
`.execute(db);
|
|
35
|
+
await sql`
|
|
36
|
+
CREATE INDEX IF NOT EXISTS background_jobs_claim
|
|
37
|
+
ON background_jobs(status, lease_expires_at, started_at)
|
|
38
|
+
`.execute(db);
|
|
39
|
+
}
|
|
40
|
+
export async function down(db) {
|
|
41
|
+
await sql`DROP TABLE IF EXISTS background_jobs`.execute(db);
|
|
42
|
+
}
|
|
@@ -188,6 +188,28 @@ export interface RateLimitsTable {
|
|
|
188
188
|
window_started_at: number;
|
|
189
189
|
expires_at: number;
|
|
190
190
|
}
|
|
191
|
+
export interface BackgroundJobsTable {
|
|
192
|
+
id: string;
|
|
193
|
+
user_id: string;
|
|
194
|
+
thread_id: string;
|
|
195
|
+
message_id: string;
|
|
196
|
+
model: string;
|
|
197
|
+
kind: string | null;
|
|
198
|
+
status: 'streaming' | 'complete' | 'error' | 'aborted';
|
|
199
|
+
content: string;
|
|
200
|
+
chunks_received: number;
|
|
201
|
+
started_at: number;
|
|
202
|
+
last_activity_at: number;
|
|
203
|
+
completed_at: number | null;
|
|
204
|
+
error: string | null;
|
|
205
|
+
tool_calls_json: string | null;
|
|
206
|
+
workflow_state_json: string | null;
|
|
207
|
+
execution_json: string | null;
|
|
208
|
+
idempotency_key: string | null;
|
|
209
|
+
lease_owner: string | null;
|
|
210
|
+
lease_expires_at: number | null;
|
|
211
|
+
attempts: number;
|
|
212
|
+
}
|
|
191
213
|
export interface AdminUsersTable {
|
|
192
214
|
user_id: string;
|
|
193
215
|
created_at: number;
|
|
@@ -216,6 +238,7 @@ export interface Or3SqliteDb {
|
|
|
216
238
|
connect_device_authorizations: ConnectDeviceAuthorizationsTable;
|
|
217
239
|
connect_environments: ConnectEnvironmentsTable;
|
|
218
240
|
rate_limits: RateLimitsTable;
|
|
241
|
+
background_jobs: BackgroundJobsTable;
|
|
219
242
|
s_threads: SyncedEntityTable;
|
|
220
243
|
s_messages: SyncedEntityTable;
|
|
221
244
|
s_projects: SyncedEntityTable;
|
|
@@ -6,6 +6,7 @@ import { registerSyncGatewayAdapter } from "~~/server/sync/gateway/registry";
|
|
|
6
6
|
import { registerWebhookStore } from "~~/server/utils/webhooks/store/registry";
|
|
7
7
|
import { registerConnectStore } from "~~/server/connect/store/registry";
|
|
8
8
|
import { registerRateLimitProvider } from "~~/server/utils/rate-limit/registry";
|
|
9
|
+
import { registerBackgroundJobProvider } from "~~/server/utils/background-jobs/registry";
|
|
9
10
|
import { createSqliteAuthWorkspaceStore } from "../auth/sqlite-auth-workspace-store.js";
|
|
10
11
|
import { createSqliteSyncGatewayAdapter } from "../sync/sqlite-sync-gateway-adapter.js";
|
|
11
12
|
import { createSqliteWebhookStore } from "../webhooks/sqlite-webhook-store.js";
|
|
@@ -19,6 +20,7 @@ import { createSqliteConnectStore } from "../connect/sqlite-connect-store.js";
|
|
|
19
20
|
import { getSqliteDriver, initializeSqliteDb } from "../db/kysely.js";
|
|
20
21
|
import { runMigrations } from "../db/migrate.js";
|
|
21
22
|
import { sqliteRateLimitProvider } from "../rate-limit/sqlite-provider.js";
|
|
23
|
+
import { sqliteBackgroundJobProvider } from "../background-jobs/sqlite-provider.js";
|
|
22
24
|
import { useRuntimeConfig } from "#imports";
|
|
23
25
|
const SQLITE_PROVIDER_ID = "sqlite";
|
|
24
26
|
let d1RuntimeInitialization;
|
|
@@ -27,7 +29,8 @@ export default defineNitroPlugin(async (nitroApp) => {
|
|
|
27
29
|
if (!config.auth?.enabled) return;
|
|
28
30
|
const syncSelected = config.sync?.enabled === true && config.sync?.provider === SQLITE_PROVIDER_ID;
|
|
29
31
|
const connectSelected = config.connect?.enabled === true && config.connect?.provider === SQLITE_PROVIDER_ID;
|
|
30
|
-
|
|
32
|
+
const backgroundJobsSelected = config.backgroundJobs?.enabled === true && config.backgroundJobs?.storageProvider === SQLITE_PROVIDER_ID;
|
|
33
|
+
if (!syncSelected && !connectSelected && !backgroundJobsSelected) return;
|
|
31
34
|
const driver = getSqliteDriver();
|
|
32
35
|
if (driver === "d1" && connectSelected) {
|
|
33
36
|
throw new Error(
|
|
@@ -40,6 +43,10 @@ export default defineNitroPlugin(async (nitroApp) => {
|
|
|
40
43
|
create: createSqliteAuthWorkspaceStore
|
|
41
44
|
});
|
|
42
45
|
registerRateLimitProvider(SQLITE_PROVIDER_ID, sqliteRateLimitProvider);
|
|
46
|
+
registerBackgroundJobProvider(
|
|
47
|
+
SQLITE_PROVIDER_ID,
|
|
48
|
+
sqliteBackgroundJobProvider
|
|
49
|
+
);
|
|
43
50
|
if (syncSelected) {
|
|
44
51
|
registerSyncGatewayAdapter({
|
|
45
52
|
id: SQLITE_PROVIDER_ID,
|
|
@@ -21,6 +21,7 @@ const SNAPSHOT_TTL_SECONDS = 60 * 60;
|
|
|
21
21
|
const SESSION_CONTEXT_KEY_PREFIX = "__or3_session_context_";
|
|
22
22
|
const MIN_SYNC_RETENTION_SECONDS = 60 * 60;
|
|
23
23
|
const MAX_SYNC_RETENTION_SECONDS = 365 * 24 * 60 * 60;
|
|
24
|
+
const MAX_SYNC_PAYLOAD_BYTES = 256 * 1024;
|
|
24
25
|
function uid() {
|
|
25
26
|
return globalThis.crypto.randomUUID();
|
|
26
27
|
}
|
|
@@ -56,11 +57,15 @@ function validatePushOperation(workspaceId, op) {
|
|
|
56
57
|
}
|
|
57
58
|
const payload = op.payload;
|
|
58
59
|
if (!payload) return void 0;
|
|
60
|
+
let serializedPayload;
|
|
59
61
|
try {
|
|
60
|
-
JSON.stringify(payload);
|
|
62
|
+
serializedPayload = JSON.stringify(payload);
|
|
61
63
|
} catch {
|
|
62
64
|
return `Invalid payload for ${op.tableName}: payload is not serializable`;
|
|
63
65
|
}
|
|
66
|
+
if (new TextEncoder().encode(serializedPayload).byteLength > MAX_SYNC_PAYLOAD_BYTES) {
|
|
67
|
+
return `Payload too large for ${op.tableName}: exceeds ${MAX_SYNC_PAYLOAD_BYTES} bytes`;
|
|
68
|
+
}
|
|
64
69
|
if ("_id" in payload) return `Invalid payload for ${op.tableName}: '_id' is immutable`;
|
|
65
70
|
if ("workspace_id" in payload && payload.workspace_id !== workspaceId) {
|
|
66
71
|
return `Invalid payload for ${op.tableName}: 'workspace_id' is immutable`;
|