or3-provider-sqlite 0.0.1 → 0.0.3

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.
Files changed (40) hide show
  1. package/README.md +20 -4
  2. package/dist/module.json +1 -1
  3. package/dist/runtime/server/admin/adapters/sync-sqlite.js +12 -34
  4. package/dist/runtime/server/admin/stores/sqlite-store.js +64 -9
  5. package/dist/runtime/server/auth/sqlite-auth-workspace-store.d.ts +16 -1
  6. package/dist/runtime/server/auth/sqlite-auth-workspace-store.js +114 -3
  7. package/dist/runtime/server/connect/sqlite-connect-store.d.ts +2 -0
  8. package/dist/runtime/server/connect/sqlite-connect-store.js +885 -0
  9. package/dist/runtime/server/db/migrate.js +62 -1
  10. package/dist/runtime/server/db/migrations/006_sync_snapshots.d.ts +10 -0
  11. package/dist/runtime/server/db/migrations/006_sync_snapshots.js +106 -0
  12. package/dist/runtime/server/db/migrations/007_device_cursor_ownership.d.ts +7 -0
  13. package/dist/runtime/server/db/migrations/007_device_cursor_ownership.js +5 -0
  14. package/dist/runtime/server/db/migrations/008_upload_intents.d.ts +4 -0
  15. package/dist/runtime/server/db/migrations/008_upload_intents.js +26 -0
  16. package/dist/runtime/server/db/migrations/009_or3_connect.d.ts +4 -0
  17. package/dist/runtime/server/db/migrations/009_or3_connect.js +91 -0
  18. package/dist/runtime/server/db/migrations/010_connect_user_code_hmac.d.ts +10 -0
  19. package/dist/runtime/server/db/migrations/010_connect_user_code_hmac.js +97 -0
  20. package/dist/runtime/server/db/migrations/011_connect_environment_scope_indexes.d.ts +4 -0
  21. package/dist/runtime/server/db/migrations/011_connect_environment_scope_indexes.js +26 -0
  22. package/dist/runtime/server/db/migrations/012_connect_credential_redelivery.d.ts +4 -0
  23. package/dist/runtime/server/db/migrations/012_connect_credential_redelivery.js +124 -0
  24. package/dist/runtime/server/db/migrations/013_connect_environment_lifecycle.d.ts +10 -0
  25. package/dist/runtime/server/db/migrations/013_connect_environment_lifecycle.js +261 -0
  26. package/dist/runtime/server/db/migrations/014_connect_retention_activation.d.ts +4 -0
  27. package/dist/runtime/server/db/migrations/014_connect_retention_activation.js +44 -0
  28. package/dist/runtime/server/db/migrations/015_rate_limits.d.ts +4 -0
  29. package/dist/runtime/server/db/migrations/015_rate_limits.js +18 -0
  30. package/dist/runtime/server/db/schema.d.ts +97 -0
  31. package/dist/runtime/server/plugins/register.js +27 -5
  32. package/dist/runtime/server/rate-limit/sqlite-provider.d.ts +7 -0
  33. package/dist/runtime/server/rate-limit/sqlite-provider.js +70 -0
  34. package/dist/runtime/server/sync/history-gc-policy.d.ts +13 -0
  35. package/dist/runtime/server/sync/history-gc-policy.js +8 -0
  36. package/dist/runtime/server/sync/sqlite-sync-gateway-adapter.d.ts +14 -2
  37. package/dist/runtime/server/sync/sqlite-sync-gateway-adapter.js +901 -67
  38. package/dist/runtime/server/webhooks/sqlite-webhook-store.d.ts +9 -0
  39. package/dist/runtime/server/webhooks/sqlite-webhook-store.js +521 -0
  40. package/package.json +9 -1
@@ -1,20 +1,138 @@
1
+ import { canRunSyncHistoryGc } from "./history-gc-policy.js";
1
2
  import { createError } from "h3";
2
3
  import { randomUUID } from "node:crypto";
3
4
  import { getSqliteDb, getRawDb } from "../db/kysely.js";
4
5
  import { SYNCED_TABLE_MAP, ALLOWED_SYNC_TABLES } from "../db/schema.js";
6
+ import { emitWebhookSystemHook } from "~~/server/utils/webhooks/runtime";
5
7
  const DEFAULT_PULL_LIMIT = 100;
6
8
  const MAX_PULL_LIMIT = 1e3;
9
+ const MAX_SNAPSHOT_PAGE_SIZE = 1e3;
10
+ const DEFAULT_CANONICAL_STORAGE_PAGE_SIZE = 100;
11
+ const MAX_CANONICAL_STORAGE_PAGE_SIZE = 500;
12
+ const SNAPSHOT_TTL_SECONDS = 60 * 60;
7
13
  const SESSION_CONTEXT_KEY_PREFIX = "__or3_session_context_";
14
+ const MIN_SYNC_RETENTION_SECONDS = 60 * 60;
15
+ const MAX_SYNC_RETENTION_SECONDS = 365 * 24 * 60 * 60;
8
16
  function uid() {
9
17
  return randomUUID();
10
18
  }
11
19
  function nowEpoch() {
12
20
  return Math.floor(Date.now() / 1e3);
13
21
  }
22
+ function stableJson(value) {
23
+ if (value === void 0) return "undefined";
24
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
25
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
26
+ const record = value;
27
+ return `{${Object.keys(record).sort().map(
28
+ (key) => `${JSON.stringify(key)}:${stableJson(record[key])}`
29
+ ).join(",")}}`;
30
+ }
31
+ function operationFingerprint(op) {
32
+ return stableJson({
33
+ tableName: op.tableName,
34
+ operation: op.operation,
35
+ pk: op.pk,
36
+ payload: op.payload,
37
+ clock: op.stamp.clock,
38
+ hlc: op.stamp.hlc,
39
+ deviceId: op.stamp.deviceId
40
+ });
41
+ }
42
+ function validatePushOperation(workspaceId, op) {
43
+ if (!ALLOWED_SYNC_TABLES.includes(op.tableName)) {
44
+ return `Invalid table: ${op.tableName}`;
45
+ }
46
+ if (op.payload !== void 0 && (typeof op.payload !== "object" || op.payload === null || Array.isArray(op.payload))) {
47
+ return `Invalid payload for ${op.tableName}: payload must be an object`;
48
+ }
49
+ const payload = op.payload;
50
+ if (!payload) return void 0;
51
+ try {
52
+ JSON.stringify(payload);
53
+ } catch {
54
+ return `Invalid payload for ${op.tableName}: payload is not serializable`;
55
+ }
56
+ if ("_id" in payload) return `Invalid payload for ${op.tableName}: '_id' is immutable`;
57
+ if ("workspace_id" in payload && payload.workspace_id !== workspaceId) {
58
+ return `Invalid payload for ${op.tableName}: 'workspace_id' is immutable`;
59
+ }
60
+ const pkField = op.tableName === "file_meta" ? "hash" : "id";
61
+ if (pkField in payload && payload[pkField] !== op.pk) {
62
+ return `Invalid payload for ${op.tableName}: '${pkField}' must match operation pk`;
63
+ }
64
+ return void 0;
65
+ }
66
+ function normalizeStorageHash(value) {
67
+ return value.replace(/^sha256:/i, "").replace(/^md5:/i, "").trim().toLowerCase();
68
+ }
69
+ function resolveCanonicalStorageLimit(limit) {
70
+ if (typeof limit !== "number" || !Number.isFinite(limit)) {
71
+ return DEFAULT_CANONICAL_STORAGE_PAGE_SIZE;
72
+ }
73
+ return Math.max(1, Math.min(Math.floor(limit), MAX_CANONICAL_STORAGE_PAGE_SIZE));
74
+ }
75
+ function encodeCanonicalStorageCursor(cursor) {
76
+ return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url");
77
+ }
78
+ function decodeCanonicalStorageCursor(input, kind, hash) {
79
+ if (!input) return [];
80
+ try {
81
+ if (input.length > 2048) throw new Error("oversized");
82
+ const parsed = JSON.parse(Buffer.from(input, "base64url").toString("utf8"));
83
+ if (parsed.version !== 1 || parsed.kind !== kind || parsed.hash !== hash || !Array.isArray(parsed.key) || !parsed.key.every((part) => typeof part === "string")) {
84
+ throw new Error("mismatch");
85
+ }
86
+ return parsed.key;
87
+ } catch {
88
+ throw createError({ statusCode: 400, statusMessage: "Invalid canonical storage cursor" });
89
+ }
90
+ }
14
91
  function resolvePullLimit(limit) {
15
92
  if (typeof limit !== "number" || !Number.isFinite(limit)) return DEFAULT_PULL_LIMIT;
16
93
  return Math.max(1, Math.min(Math.floor(limit), MAX_PULL_LIMIT));
17
94
  }
95
+ function resolveSnapshotPageSize(pageSize) {
96
+ if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > MAX_SNAPSHOT_PAGE_SIZE) {
97
+ throw createError({
98
+ statusCode: 400,
99
+ statusMessage: `pageSize must be an integer between 1 and ${MAX_SNAPSHOT_PAGE_SIZE}`
100
+ });
101
+ }
102
+ return pageSize;
103
+ }
104
+ function normalizeSnapshotTables(tables) {
105
+ const requested = tables?.length ? [...new Set(tables)] : [...ALLOWED_SYNC_TABLES];
106
+ for (const tableName of requested) {
107
+ if (!ALLOWED_SYNC_TABLES.includes(tableName)) {
108
+ throw createError({
109
+ statusCode: 400,
110
+ statusMessage: `Invalid snapshot table: ${tableName}`
111
+ });
112
+ }
113
+ }
114
+ return requested.sort();
115
+ }
116
+ function encodeSnapshotPageToken(token) {
117
+ return Buffer.from(JSON.stringify(token), "utf8").toString("base64url");
118
+ }
119
+ function decodeSnapshotPageToken(value) {
120
+ try {
121
+ const parsed = JSON.parse(
122
+ Buffer.from(value, "base64url").toString("utf8")
123
+ );
124
+ const after = parsed.after;
125
+ if (parsed.version !== 1 || typeof parsed.snapshotId !== "string" || !parsed.snapshotId || !after || typeof after.tableName !== "string" || typeof after.pk !== "string" || after.kind !== "row" && after.kind !== "tombstone") {
126
+ throw new Error("Invalid token payload");
127
+ }
128
+ return parsed;
129
+ } catch {
130
+ throw createError({
131
+ statusCode: 400,
132
+ statusMessage: "Invalid snapshot page token"
133
+ });
134
+ }
135
+ }
18
136
  async function assertWorkspaceScopeAuthorized(event, workspaceId) {
19
137
  if (!workspaceId.trim()) {
20
138
  throw createError({
@@ -41,6 +159,126 @@ async function assertWorkspaceScopeAuthorized(event, workspaceId) {
41
159
  });
42
160
  }
43
161
  }
162
+ function resolveSessionUserId(event) {
163
+ const context = event.context;
164
+ if (!context || typeof context !== "object") {
165
+ return void 0;
166
+ }
167
+ for (const [key, value] of Object.entries(context)) {
168
+ if (!key.startsWith(SESSION_CONTEXT_KEY_PREFIX)) continue;
169
+ const userId = value.user?.id;
170
+ if (typeof userId === "string" && userId.trim().length > 0) {
171
+ return userId;
172
+ }
173
+ }
174
+ return void 0;
175
+ }
176
+ function toWebhookEntityPayload(input) {
177
+ const base = input.op.payload && typeof input.op.payload === "object" ? { ...input.op.payload } : {};
178
+ if (typeof base.id !== "string" || base.id.length === 0) {
179
+ base.id = input.op.pk;
180
+ }
181
+ if (typeof base.workspace_id !== "string" || base.workspace_id.length === 0) {
182
+ base.workspace_id = input.workspaceId;
183
+ }
184
+ if (input.userId && (typeof base.user_id !== "string" || base.user_id.length === 0)) {
185
+ base.user_id = input.userId;
186
+ }
187
+ if (input.deleted) {
188
+ base.deleted = true;
189
+ }
190
+ if (typeof base.updated_at !== "number") {
191
+ base.updated_at = input.now;
192
+ }
193
+ return base;
194
+ }
195
+ function resolveHookEmission(input) {
196
+ if (!input.applied) {
197
+ return null;
198
+ }
199
+ const { op } = input;
200
+ if (op.tableName === "threads") {
201
+ if (op.operation === "delete") {
202
+ return {
203
+ hookName: "db.threads.delete:action:soft:after",
204
+ payload: toWebhookEntityPayload({
205
+ op,
206
+ workspaceId: input.workspaceId,
207
+ now: input.now,
208
+ userId: input.userId,
209
+ deleted: true
210
+ })
211
+ };
212
+ }
213
+ return {
214
+ hookName: input.wasExisting ? "db.threads.update:action:after" : "db.threads.create:action:after",
215
+ payload: toWebhookEntityPayload({
216
+ op,
217
+ workspaceId: input.workspaceId,
218
+ now: input.now,
219
+ userId: input.userId
220
+ })
221
+ };
222
+ }
223
+ if (op.tableName === "messages") {
224
+ if (op.operation === "delete") {
225
+ return {
226
+ hookName: "db.messages.delete:action:soft:after",
227
+ payload: toWebhookEntityPayload({
228
+ op,
229
+ workspaceId: input.workspaceId,
230
+ now: input.now,
231
+ userId: input.userId,
232
+ deleted: true
233
+ })
234
+ };
235
+ }
236
+ return {
237
+ hookName: input.wasExisting ? "db.messages.update:action:after" : "db.messages.create:action:after",
238
+ payload: toWebhookEntityPayload({
239
+ op,
240
+ workspaceId: input.workspaceId,
241
+ now: input.now,
242
+ userId: input.userId
243
+ })
244
+ };
245
+ }
246
+ if (op.tableName === "documents" || op.tableName === "posts") {
247
+ if (op.operation === "delete") {
248
+ return {
249
+ hookName: "db.documents.delete:action:soft:after",
250
+ payload: toWebhookEntityPayload({
251
+ op,
252
+ workspaceId: input.workspaceId,
253
+ now: input.now,
254
+ userId: input.userId,
255
+ deleted: true
256
+ })
257
+ };
258
+ }
259
+ return {
260
+ hookName: input.wasExisting ? "db.documents.update:action:after" : "db.documents.create:action:after",
261
+ payload: toWebhookEntityPayload({
262
+ op,
263
+ workspaceId: input.workspaceId,
264
+ now: input.now,
265
+ userId: input.userId
266
+ })
267
+ };
268
+ }
269
+ if (op.tableName === "notifications" && op.operation === "put") {
270
+ return {
271
+ hookName: "notify:action:push",
272
+ payload: toWebhookEntityPayload({
273
+ op,
274
+ workspaceId: input.workspaceId,
275
+ now: input.now,
276
+ userId: input.userId
277
+ })
278
+ };
279
+ }
280
+ return null;
281
+ }
44
282
  function incomingWinsLww(inClock, inHlc, existingClock, existingHlc) {
45
283
  if (inClock > existingClock) return true;
46
284
  if (inClock === existingClock && inHlc > existingHlc) return true;
@@ -48,9 +286,270 @@ function incomingWinsLww(inClock, inHlc, existingClock, existingHlc) {
48
286
  }
49
287
  export class SqliteSyncGatewayAdapter {
50
288
  id = "sqlite";
289
+ capabilities = {
290
+ snapshotBootstrap: "snapshot-v1",
291
+ historyRetention: "snapshot-v1"
292
+ };
51
293
  get db() {
52
294
  return getSqliteDb();
53
295
  }
296
+ async reserveUploadIntent(event, input) {
297
+ await assertWorkspaceScopeAuthorized(event, input.workspaceId);
298
+ if (!input.intentId || !Number.isSafeInteger(input.sizeBytes) || input.sizeBytes < 1 || !Number.isSafeInteger(input.expiresAt) || input.expiresAt <= nowEpoch()) {
299
+ throw createError({ statusCode: 400, statusMessage: "Invalid upload intent" });
300
+ }
301
+ if (input.workspaceQuotaBytes !== void 0 && (!Number.isSafeInteger(input.workspaceQuotaBytes) || input.workspaceQuotaBytes < 1)) {
302
+ throw createError({ statusCode: 400, statusMessage: "Invalid workspace quota" });
303
+ }
304
+ const hash = normalizeStorageHash(input.hash);
305
+ const raw = getRawDb();
306
+ raw.transaction(() => {
307
+ const now = nowEpoch();
308
+ raw.prepare(`UPDATE upload_intents SET status = 'expired'
309
+ WHERE workspace_id = ? AND status = 'active' AND expires_at <= ?`).run(input.workspaceId, now);
310
+ const liveRows = raw.prepare(`SELECT id, data_json FROM s_file_meta
311
+ WHERE workspace_id = ? AND deleted = 0`).all(input.workspaceId);
312
+ let usedBytes = 0;
313
+ let alreadyStored = false;
314
+ for (const row of liveRows) {
315
+ const payload = JSON.parse(row.data_json);
316
+ const size = payload.size_bytes ?? payload.sizeBytes;
317
+ if (typeof size !== "number" || !Number.isSafeInteger(size) || size < 0) {
318
+ throw createError({ statusCode: 500, statusMessage: "Invalid canonical file size" });
319
+ }
320
+ usedBytes += size;
321
+ if (normalizeStorageHash(row.id) === hash) alreadyStored = true;
322
+ }
323
+ const totals = raw.prepare(`SELECT
324
+ COALESCE(SUM(reserved_bytes), 0) AS reserved_bytes,
325
+ MAX(CASE WHEN hash = ? AND reserved_bytes > 0 THEN 1 ELSE 0 END) AS same_hash
326
+ FROM upload_intents WHERE workspace_id = ? AND status = 'active' AND expires_at > ?`).get(hash, input.workspaceId, now);
327
+ const reservedBytes = alreadyStored || totals.same_hash === 1 ? 0 : input.sizeBytes;
328
+ if (input.workspaceQuotaBytes !== void 0 && usedBytes + totals.reserved_bytes + reservedBytes > input.workspaceQuotaBytes) {
329
+ throw createError({ statusCode: 413, statusMessage: "Workspace storage quota exceeded" });
330
+ }
331
+ raw.prepare(`INSERT INTO upload_intents (
332
+ id, workspace_id, hash, mime_type, size_bytes, reserved_bytes,
333
+ expires_at, status, created_at
334
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?)`).run(
335
+ input.intentId,
336
+ input.workspaceId,
337
+ hash,
338
+ input.mimeType,
339
+ input.sizeBytes,
340
+ reservedBytes,
341
+ input.expiresAt,
342
+ now
343
+ );
344
+ })();
345
+ }
346
+ async consumeUploadIntent(event, input) {
347
+ await assertWorkspaceScopeAuthorized(event, input.workspaceId);
348
+ const raw = getRawDb();
349
+ const result = raw.prepare(`UPDATE upload_intents
350
+ SET status = 'consumed', storage_id = ?, consumed_at = ?
351
+ WHERE id = ? AND workspace_id = ? AND status = 'active' AND expires_at > ?
352
+ AND hash = ? AND mime_type = ? AND size_bytes = ?`).run(
353
+ input.storageId,
354
+ nowEpoch(),
355
+ input.intentId,
356
+ input.workspaceId,
357
+ nowEpoch(),
358
+ normalizeStorageHash(input.hash),
359
+ input.mimeType,
360
+ input.sizeBytes
361
+ );
362
+ if (result.changes !== 1) {
363
+ throw createError({ statusCode: 409, statusMessage: "Upload intent expired, mismatched, or already consumed" });
364
+ }
365
+ }
366
+ async cancelUploadIntent(event, input) {
367
+ await assertWorkspaceScopeAuthorized(event, input.workspaceId);
368
+ const result = getRawDb().prepare(`UPDATE upload_intents
369
+ SET status = 'cancelled', cancelled_at = ?
370
+ WHERE id = ? AND workspace_id = ? AND status = 'active'`).run(nowEpoch(), input.intentId, input.workspaceId);
371
+ if (result.changes !== 1) {
372
+ throw createError({ statusCode: 409, statusMessage: "Upload intent is not active" });
373
+ }
374
+ }
375
+ async queryCanonicalStorage(event, input) {
376
+ const workspaceId = input.scope.workspaceId;
377
+ await assertWorkspaceScopeAuthorized(event, workspaceId);
378
+ const limit = resolveCanonicalStorageLimit(input.limit);
379
+ const hash = input.hash === void 0 ? void 0 : normalizeStorageHash(input.hash);
380
+ if (input.hash !== void 0 && !hash) {
381
+ throw createError({ statusCode: 400, statusMessage: "Invalid storage hash" });
382
+ }
383
+ const after = decodeCanonicalStorageCursor(input.cursor, input.kind, hash);
384
+ const raw = getRawDb();
385
+ if (input.kind === "active_reservations") {
386
+ if (after.length > 1) {
387
+ throw createError({ statusCode: 400, statusMessage: "Invalid canonical storage cursor" });
388
+ }
389
+ const now = input.now ?? nowEpoch();
390
+ const rows2 = raw.prepare(`SELECT id, hash, reserved_bytes, expires_at
391
+ FROM upload_intents
392
+ WHERE workspace_id = ? AND status = 'active' AND expires_at > ?
393
+ AND id > ? AND reserved_bytes > 0 AND (? IS NULL OR hash = ?)
394
+ ORDER BY id ASC LIMIT ?`).all(workspaceId, now, after[0] ?? "", hash ?? null, hash ?? null, limit + 1);
395
+ const hasMore2 = rows2.length > limit;
396
+ const page2 = hasMore2 ? rows2.slice(0, limit) : rows2;
397
+ const last2 = page2[page2.length - 1];
398
+ return {
399
+ items: page2.map((row) => ({
400
+ kind: "reservation",
401
+ reservationId: row.id,
402
+ hash: row.hash,
403
+ sizeBytes: row.reserved_bytes,
404
+ expiresAt: row.expires_at
405
+ })),
406
+ hasMore: hasMore2,
407
+ ...hasMore2 && last2 ? { nextCursor: encodeCanonicalStorageCursor({
408
+ version: 1,
409
+ kind: input.kind,
410
+ hash,
411
+ key: [last2.id]
412
+ }) } : {}
413
+ };
414
+ }
415
+ if (input.kind === "live_metadata") {
416
+ if (after.length > 1) {
417
+ throw createError({ statusCode: 400, statusMessage: "Invalid canonical storage cursor" });
418
+ }
419
+ const rows2 = raw.prepare(`
420
+ SELECT id, data_json, updated_at
421
+ FROM s_file_meta
422
+ WHERE workspace_id = ?
423
+ AND deleted = 0
424
+ AND id > ?
425
+ AND (? IS NULL OR lower(
426
+ CASE
427
+ WHEN instr(trim(id), ':') > 0
428
+ THEN substr(trim(id), instr(trim(id), ':') + 1)
429
+ ELSE trim(id)
430
+ END
431
+ ) = ?)
432
+ ORDER BY id ASC
433
+ LIMIT ?
434
+ `).all(workspaceId, after[0] ?? "", hash ?? null, hash ?? null, limit + 1);
435
+ const hasMore2 = rows2.length > limit;
436
+ const page2 = hasMore2 ? rows2.slice(0, limit) : rows2;
437
+ const items = page2.map((row) => {
438
+ let payload;
439
+ try {
440
+ payload = JSON.parse(row.data_json);
441
+ } catch {
442
+ throw createError({ statusCode: 500, statusMessage: "Invalid canonical file metadata" });
443
+ }
444
+ const size = payload.size_bytes ?? payload.sizeBytes;
445
+ if (typeof size !== "number" || !Number.isSafeInteger(size) || size < 0) {
446
+ throw createError({ statusCode: 500, statusMessage: "Invalid canonical file size" });
447
+ }
448
+ const storageId = payload.storage_id ?? payload.storageId;
449
+ return {
450
+ kind: "metadata",
451
+ hash: normalizeStorageHash(row.id),
452
+ sizeBytes: size,
453
+ ...typeof storageId === "string" && storageId ? { storageId } : {},
454
+ updatedAt: row.updated_at
455
+ };
456
+ });
457
+ const last2 = page2[page2.length - 1];
458
+ return {
459
+ items,
460
+ hasMore: hasMore2,
461
+ ...hasMore2 && last2 ? {
462
+ nextCursor: encodeCanonicalStorageCursor({
463
+ version: 1,
464
+ kind: input.kind,
465
+ hash,
466
+ key: [last2.id]
467
+ })
468
+ } : {}
469
+ };
470
+ }
471
+ if (after.length !== 0 && after.length !== 3) {
472
+ throw createError({ statusCode: 400, statusMessage: "Invalid canonical storage cursor" });
473
+ }
474
+ const [afterTable = "", afterId = "", afterHash = ""] = after;
475
+ const rows = raw.prepare(`
476
+ WITH reference_edges(source_table, source_id, raw_hash) AS (
477
+ SELECT 'messages', row.id, CAST(edge.value AS TEXT)
478
+ FROM s_messages AS row
479
+ JOIN json_each(
480
+ CASE
481
+ WHEN json_valid(json_extract(row.data_json, '$.file_hashes'))
482
+ THEN json_extract(row.data_json, '$.file_hashes')
483
+ ELSE '[]'
484
+ END
485
+ ) AS edge
486
+ WHERE row.workspace_id = ? AND row.deleted = 0 AND edge.type = 'text'
487
+ UNION ALL
488
+ SELECT 'posts', row.id, CAST(edge.value AS TEXT)
489
+ FROM s_posts AS row
490
+ JOIN json_each(
491
+ CASE
492
+ WHEN json_valid(json_extract(row.data_json, '$.file_hashes'))
493
+ THEN json_extract(row.data_json, '$.file_hashes')
494
+ ELSE '[]'
495
+ END
496
+ ) AS edge
497
+ WHERE row.workspace_id = ? AND row.deleted = 0 AND edge.type = 'text'
498
+ ), normalized_edges AS (
499
+ SELECT source_table, source_id, raw_hash, lower(
500
+ CASE
501
+ WHEN instr(trim(raw_hash), ':') > 0
502
+ THEN substr(trim(raw_hash), instr(trim(raw_hash), ':') + 1)
503
+ ELSE trim(raw_hash)
504
+ END
505
+ ) AS normalized_hash
506
+ FROM reference_edges
507
+ )
508
+ SELECT source_table, source_id, raw_hash, normalized_hash
509
+ FROM normalized_edges
510
+ WHERE normalized_hash <> ''
511
+ AND (? IS NULL OR normalized_hash = ?)
512
+ AND (
513
+ source_table > ? OR
514
+ (source_table = ? AND source_id > ?) OR
515
+ (source_table = ? AND source_id = ? AND raw_hash > ?)
516
+ )
517
+ ORDER BY source_table ASC, source_id ASC, raw_hash ASC
518
+ LIMIT ?
519
+ `).all(
520
+ workspaceId,
521
+ workspaceId,
522
+ hash ?? null,
523
+ hash ?? null,
524
+ afterTable,
525
+ afterTable,
526
+ afterId,
527
+ afterTable,
528
+ afterId,
529
+ afterHash,
530
+ limit + 1
531
+ );
532
+ const hasMore = rows.length > limit;
533
+ const page = hasMore ? rows.slice(0, limit) : rows;
534
+ const last = page[page.length - 1];
535
+ return {
536
+ items: page.map((row) => ({
537
+ kind: "reference",
538
+ hash: row.normalized_hash,
539
+ sourceTable: row.source_table,
540
+ sourceId: row.source_id
541
+ })),
542
+ hasMore,
543
+ ...hasMore && last ? {
544
+ nextCursor: encodeCanonicalStorageCursor({
545
+ version: 1,
546
+ kind: input.kind,
547
+ hash,
548
+ key: [last.source_table, last.source_id, last.raw_hash]
549
+ })
550
+ } : {}
551
+ };
552
+ }
54
553
  async push(event, input) {
55
554
  const { scope, ops } = input;
56
555
  const workspaceId = scope.workspaceId;
@@ -58,25 +557,49 @@ export class SqliteSyncGatewayAdapter {
58
557
  if (!ops.length) {
59
558
  return { results: [], serverVersion: 0 };
60
559
  }
61
- for (const op of ops) {
62
- if (!ALLOWED_SYNC_TABLES.includes(op.tableName)) {
63
- return {
64
- results: ops.map((o) => ({
65
- opId: o.stamp.opId,
560
+ const resultSlots = new Array(ops.length);
561
+ const groups = /* @__PURE__ */ new Map();
562
+ ops.forEach((op, index) => {
563
+ const fingerprint = operationFingerprint(op);
564
+ const group = groups.get(op.stamp.opId);
565
+ if (!group) {
566
+ groups.set(op.stamp.opId, {
567
+ op,
568
+ fingerprint,
569
+ indices: [index],
570
+ conflicting: false
571
+ });
572
+ return;
573
+ }
574
+ group.indices.push(index);
575
+ if (group.fingerprint !== fingerprint) group.conflicting = true;
576
+ });
577
+ const uniqueOps = [];
578
+ const indicesByOpId = /* @__PURE__ */ new Map();
579
+ for (const group of groups.values()) {
580
+ const error = group.conflicting ? `Conflicting operations reuse op_id ${group.op.stamp.opId}` : validatePushOperation(workspaceId, group.op);
581
+ if (error) {
582
+ for (const index of group.indices) {
583
+ resultSlots[index] = {
584
+ opId: group.op.stamp.opId,
66
585
  success: false,
67
- error: `Invalid table: ${o.tableName}`,
68
- errorCode: "VALIDATION_ERROR"
69
- })),
70
- serverVersion: 0
71
- };
586
+ error,
587
+ errorCode: group.conflicting ? "CONFLICT" : "VALIDATION_ERROR"
588
+ };
589
+ }
590
+ continue;
72
591
  }
592
+ uniqueOps.push(group.op);
593
+ indicesByOpId.set(group.op.stamp.opId, group.indices);
73
594
  }
74
595
  const raw = getRawDb();
75
596
  const now = nowEpoch();
76
- const results = [];
597
+ const userId = resolveSessionUserId(event);
598
+ const uniqueResults = [];
77
599
  let finalServerVersion = 0;
600
+ const hookEmissions = [];
78
601
  const runTx = raw.transaction(() => {
79
- const opIds = ops.map((o) => o.stamp.opId);
602
+ const opIds = uniqueOps.map((o) => o.stamp.opId);
80
603
  const existingOps = /* @__PURE__ */ new Map();
81
604
  const chunkSize = 500;
82
605
  for (let i = 0; i < opIds.length; i += chunkSize) {
@@ -90,7 +613,7 @@ export class SqliteSyncGatewayAdapter {
90
613
  }
91
614
  }
92
615
  const uniqueNewOpIds = /* @__PURE__ */ new Set();
93
- for (const op of ops) {
616
+ for (const op of uniqueOps) {
94
617
  if (existingOps.has(op.stamp.opId)) continue;
95
618
  uniqueNewOpIds.add(op.stamp.opId);
96
619
  }
@@ -114,18 +637,18 @@ export class SqliteSyncGatewayAdapter {
114
637
  `);
115
638
  const assignedVersions = /* @__PURE__ */ new Map();
116
639
  let versionOffset = 0;
117
- for (const op of ops) {
640
+ for (const op of uniqueOps) {
118
641
  if (existingOps.has(op.stamp.opId)) continue;
119
642
  if (assignedVersions.has(op.stamp.opId)) continue;
120
643
  versionOffset++;
121
644
  assignedVersions.set(op.stamp.opId, baseVersion + versionOffset);
122
645
  }
123
646
  const processedNew = /* @__PURE__ */ new Set();
124
- for (const op of ops) {
647
+ for (const op of uniqueOps) {
125
648
  const opId = op.stamp.opId;
126
649
  const existingSv = existingOps.get(opId);
127
650
  if (existingSv !== void 0) {
128
- results.push({
651
+ uniqueResults.push({
129
652
  opId,
130
653
  success: true,
131
654
  serverVersion: existingSv
@@ -137,7 +660,7 @@ export class SqliteSyncGatewayAdapter {
137
660
  throw new Error(`Missing server version allocation for op_id ${opId}`);
138
661
  }
139
662
  if (processedNew.has(opId)) {
140
- results.push({
663
+ uniqueResults.push({
141
664
  opId,
142
665
  success: true,
143
666
  serverVersion
@@ -147,7 +670,7 @@ export class SqliteSyncGatewayAdapter {
147
670
  processedNew.add(opId);
148
671
  const materializedTable = SYNCED_TABLE_MAP[op.tableName];
149
672
  if (!materializedTable) {
150
- results.push({
673
+ uniqueResults.push({
151
674
  opId,
152
675
  success: false,
153
676
  error: `Unknown table: ${op.tableName}`,
@@ -175,10 +698,11 @@ export class SqliteSyncGatewayAdapter {
175
698
  const existing = raw.prepare(
176
699
  `SELECT clock, hlc FROM ${materializedTable} WHERE id = ? AND workspace_id = ?`
177
700
  ).get(pkValue, workspaceId);
701
+ let applied = false;
178
702
  if (!existing) {
179
703
  raw.prepare(
180
- `INSERT INTO ${materializedTable} (id, workspace_id, data_json, clock, hlc, device_id, deleted, created_at, updated_at)
181
- VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)`
704
+ `INSERT INTO ${materializedTable} (id, workspace_id, data_json, clock, hlc, device_id, op_id, deleted, created_at, updated_at)
705
+ VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`
182
706
  ).run(
183
707
  pkValue,
184
708
  workspaceId,
@@ -186,9 +710,11 @@ export class SqliteSyncGatewayAdapter {
186
710
  op.stamp.clock,
187
711
  op.stamp.hlc,
188
712
  op.stamp.deviceId,
713
+ opId,
189
714
  now,
190
715
  now
191
716
  );
717
+ applied = true;
192
718
  } else if (incomingWinsLww(
193
719
  op.stamp.clock,
194
720
  op.stamp.hlc,
@@ -197,35 +723,51 @@ export class SqliteSyncGatewayAdapter {
197
723
  )) {
198
724
  raw.prepare(
199
725
  `UPDATE ${materializedTable}
200
- SET data_json = ?, clock = ?, hlc = ?, device_id = ?, deleted = 0, updated_at = ?
726
+ SET data_json = ?, clock = ?, hlc = ?, device_id = ?, op_id = ?, deleted = 0, updated_at = ?
201
727
  WHERE id = ? AND workspace_id = ?`
202
728
  ).run(
203
729
  payloadJson ?? "{}",
204
730
  op.stamp.clock,
205
731
  op.stamp.hlc,
206
732
  op.stamp.deviceId,
733
+ opId,
207
734
  now,
208
735
  pkValue,
209
736
  workspaceId
210
737
  );
738
+ applied = true;
739
+ }
740
+ const emission = resolveHookEmission({
741
+ op,
742
+ workspaceId,
743
+ now,
744
+ userId,
745
+ wasExisting: Boolean(existing),
746
+ applied
747
+ });
748
+ if (emission) {
749
+ hookEmissions.push(emission);
211
750
  }
212
751
  } else if (op.operation === "delete") {
213
752
  const existing = raw.prepare(
214
753
  `SELECT clock, hlc FROM ${materializedTable} WHERE id = ? AND workspace_id = ?`
215
754
  ).get(pkValue, workspaceId);
755
+ let applied = false;
216
756
  if (!existing) {
217
757
  raw.prepare(
218
- `INSERT INTO ${materializedTable} (id, workspace_id, data_json, clock, hlc, device_id, deleted, created_at, updated_at)
219
- VALUES (?, ?, '{}', ?, ?, ?, 1, ?, ?)`
758
+ `INSERT INTO ${materializedTable} (id, workspace_id, data_json, clock, hlc, device_id, op_id, deleted, created_at, updated_at)
759
+ VALUES (?, ?, '{}', ?, ?, ?, ?, 1, ?, ?)`
220
760
  ).run(
221
761
  pkValue,
222
762
  workspaceId,
223
763
  op.stamp.clock,
224
764
  op.stamp.hlc,
225
765
  op.stamp.deviceId,
766
+ opId,
226
767
  now,
227
768
  now
228
769
  );
770
+ applied = true;
229
771
  } else if (incomingWinsLww(
230
772
  op.stamp.clock,
231
773
  op.stamp.hlc,
@@ -234,23 +776,27 @@ export class SqliteSyncGatewayAdapter {
234
776
  )) {
235
777
  raw.prepare(
236
778
  `UPDATE ${materializedTable}
237
- SET deleted = 1, clock = ?, hlc = ?, device_id = ?, updated_at = ?
779
+ SET deleted = 1, clock = ?, hlc = ?, device_id = ?, op_id = ?, updated_at = ?
238
780
  WHERE id = ? AND workspace_id = ?`
239
781
  ).run(
240
782
  op.stamp.clock,
241
783
  op.stamp.hlc,
242
784
  op.stamp.deviceId,
785
+ opId,
243
786
  now,
244
787
  pkValue,
245
788
  workspaceId
246
789
  );
790
+ applied = true;
247
791
  }
248
792
  raw.prepare(
249
- `INSERT INTO tombstones (id, workspace_id, table_name, pk, deleted_at, clock, server_version, created_at)
250
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
793
+ `INSERT INTO tombstones (id, workspace_id, table_name, pk, deleted_at, clock, hlc, op_id, server_version, created_at)
794
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
251
795
  ON CONFLICT(workspace_id, table_name, pk) DO UPDATE SET
252
796
  deleted_at = excluded.deleted_at,
253
797
  clock = excluded.clock,
798
+ hlc = excluded.hlc,
799
+ op_id = excluded.op_id,
254
800
  server_version = excluded.server_version
255
801
  WHERE excluded.clock > tombstones.clock
256
802
  OR (
@@ -264,11 +810,24 @@ export class SqliteSyncGatewayAdapter {
264
810
  pkValue,
265
811
  now,
266
812
  op.stamp.clock,
813
+ op.stamp.hlc,
814
+ opId,
267
815
  serverVersion,
268
816
  now
269
817
  );
818
+ const emission = resolveHookEmission({
819
+ op,
820
+ workspaceId,
821
+ now,
822
+ userId,
823
+ wasExisting: Boolean(existing),
824
+ applied
825
+ });
826
+ if (emission) {
827
+ hookEmissions.push(emission);
828
+ }
270
829
  }
271
- results.push({
830
+ uniqueResults.push({
272
831
  opId,
273
832
  success: true,
274
833
  serverVersion
@@ -276,7 +835,20 @@ export class SqliteSyncGatewayAdapter {
276
835
  }
277
836
  });
278
837
  runTx.immediate();
279
- return { results, serverVersion: finalServerVersion };
838
+ for (const result of uniqueResults) {
839
+ for (const index of indicesByOpId.get(result.opId) ?? []) {
840
+ resultSlots[index] = result;
841
+ }
842
+ }
843
+ for (const emission of hookEmissions) {
844
+ await emitWebhookSystemHook(emission.hookName, emission.payload);
845
+ }
846
+ return {
847
+ results: resultSlots.filter(
848
+ (result) => Boolean(result)
849
+ ),
850
+ serverVersion: finalServerVersion
851
+ };
280
852
  }
281
853
  async pull(event, input) {
282
854
  const db = this.db;
@@ -307,58 +879,320 @@ export class SqliteSyncGatewayAdapter {
307
879
  const nextCursor = lastRow ? lastRow.server_version : cursor;
308
880
  return { changes, nextCursor, hasMore };
309
881
  }
882
+ async snapshot(event, input) {
883
+ const workspaceId = input.scope.workspaceId;
884
+ await assertWorkspaceScopeAuthorized(event, workspaceId);
885
+ const pageSize = resolveSnapshotPageSize(input.pageSize);
886
+ const raw = getRawDb();
887
+ const now = nowEpoch();
888
+ let header;
889
+ let after;
890
+ if (input.pageToken) {
891
+ if (input.pageToken.length > 4096) {
892
+ throw createError({
893
+ statusCode: 400,
894
+ statusMessage: "Invalid snapshot page token"
895
+ });
896
+ }
897
+ const token = decodeSnapshotPageToken(input.pageToken);
898
+ const existing = raw.prepare(`
899
+ SELECT id, workspace_id, high_watermark, tables_json, expires_at
900
+ FROM sync_snapshots
901
+ WHERE id = ?
902
+ `).get(token.snapshotId);
903
+ if (!existing || existing.expires_at <= now) {
904
+ if (existing) {
905
+ raw.prepare("DELETE FROM sync_snapshots WHERE id = ?").run(existing.id);
906
+ }
907
+ throw createError({
908
+ statusCode: 410,
909
+ statusMessage: "Snapshot expired or unavailable"
910
+ });
911
+ }
912
+ if (existing.workspace_id !== workspaceId) {
913
+ throw createError({
914
+ statusCode: 403,
915
+ statusMessage: "Forbidden"
916
+ });
917
+ }
918
+ if (input.tables && existing.tables_json !== JSON.stringify(normalizeSnapshotTables(input.tables))) {
919
+ throw createError({
920
+ statusCode: 400,
921
+ statusMessage: "Snapshot table filter cannot change between pages"
922
+ });
923
+ }
924
+ header = existing;
925
+ after = token.after;
926
+ } else {
927
+ const tables = normalizeSnapshotTables(input.tables);
928
+ const snapshotId = uid();
929
+ const expiresAt = now + SNAPSHOT_TTL_SECONDS;
930
+ const capture = raw.transaction(() => {
931
+ raw.prepare("DELETE FROM sync_snapshots WHERE expires_at <= ?").run(now);
932
+ const counter = raw.prepare(
933
+ "SELECT value FROM server_version_counter WHERE workspace_id = ?"
934
+ ).get(workspaceId);
935
+ const highWatermark = counter?.value ?? 0;
936
+ const tablesJson = JSON.stringify(tables);
937
+ raw.prepare(`
938
+ INSERT INTO sync_snapshots (
939
+ id, workspace_id, high_watermark, tables_json, created_at, expires_at
940
+ ) VALUES (?, ?, ?, ?, ?, ?)
941
+ `).run(
942
+ snapshotId,
943
+ workspaceId,
944
+ highWatermark,
945
+ tablesJson,
946
+ now,
947
+ expiresAt
948
+ );
949
+ for (const tableName of tables) {
950
+ const materializedTable = SYNCED_TABLE_MAP[tableName];
951
+ if (!materializedTable) {
952
+ throw new Error(`Missing materialized table for ${tableName}`);
953
+ }
954
+ raw.prepare(`
955
+ INSERT INTO sync_snapshot_items (
956
+ snapshot_id, table_name, pk, kind, payload_json,
957
+ clock, hlc, op_id, server_deleted_at
958
+ )
959
+ SELECT
960
+ ?, ?, id, 'row', data_json,
961
+ clock,
962
+ CASE WHEN hlc <> '' THEN hlc ELSE 'legacy:0' END,
963
+ CASE
964
+ WHEN op_id <> '' THEN op_id
965
+ ELSE 'legacy:${tableName}:' || id || ':' || clock || ':' || hlc
966
+ END,
967
+ NULL
968
+ FROM "${materializedTable}"
969
+ WHERE workspace_id = ? AND deleted = 0
970
+ `).run(snapshotId, tableName, workspaceId);
971
+ raw.prepare(`
972
+ INSERT INTO sync_snapshot_items (
973
+ snapshot_id, table_name, pk, kind, payload_json,
974
+ clock, hlc, op_id, server_deleted_at
975
+ )
976
+ SELECT
977
+ ?, ?, materialized.id, 'tombstone', NULL,
978
+ materialized.clock,
979
+ CASE
980
+ WHEN materialized.hlc <> '' THEN materialized.hlc
981
+ ELSE 'legacy:0'
982
+ END,
983
+ CASE
984
+ WHEN materialized.op_id <> '' THEN materialized.op_id
985
+ ELSE 'legacy:tombstone:${tableName}:' || materialized.id || ':' || materialized.clock
986
+ END,
987
+ MAX(0, COALESCE(
988
+ (
989
+ SELECT tombstone.deleted_at
990
+ FROM tombstones AS tombstone
991
+ WHERE tombstone.workspace_id = materialized.workspace_id
992
+ AND tombstone.table_name = ?
993
+ AND tombstone.pk = materialized.id
994
+ LIMIT 1
995
+ ),
996
+ materialized.updated_at,
997
+ materialized.created_at,
998
+ 0
999
+ ))
1000
+ FROM "${materializedTable}" AS materialized
1001
+ WHERE materialized.workspace_id = ? AND materialized.deleted <> 0
1002
+ `).run(snapshotId, tableName, tableName, workspaceId);
1003
+ raw.prepare(`
1004
+ INSERT INTO sync_snapshot_items (
1005
+ snapshot_id, table_name, pk, kind, payload_json,
1006
+ clock, hlc, op_id, server_deleted_at
1007
+ )
1008
+ SELECT
1009
+ ?, ?, tombstone.pk, 'tombstone', NULL,
1010
+ tombstone.clock,
1011
+ CASE WHEN tombstone.hlc <> '' THEN tombstone.hlc ELSE 'legacy:0' END,
1012
+ CASE
1013
+ WHEN tombstone.op_id <> '' THEN tombstone.op_id
1014
+ ELSE 'legacy:tombstone:${tableName}:' || tombstone.pk || ':' || tombstone.clock
1015
+ END,
1016
+ MAX(0, tombstone.deleted_at)
1017
+ FROM tombstones AS tombstone
1018
+ WHERE tombstone.workspace_id = ?
1019
+ AND tombstone.table_name = ?
1020
+ AND NOT EXISTS (
1021
+ SELECT 1
1022
+ FROM "${materializedTable}" AS materialized
1023
+ WHERE materialized.workspace_id = tombstone.workspace_id
1024
+ AND materialized.id = tombstone.pk
1025
+ )
1026
+ `).run(snapshotId, tableName, workspaceId, tableName);
1027
+ }
1028
+ return {
1029
+ id: snapshotId,
1030
+ workspace_id: workspaceId,
1031
+ high_watermark: highWatermark,
1032
+ tables_json: tablesJson,
1033
+ expires_at: expiresAt
1034
+ };
1035
+ });
1036
+ header = capture.immediate();
1037
+ }
1038
+ const rows = after ? raw.prepare(`
1039
+ SELECT
1040
+ table_name, pk, kind, payload_json, clock, hlc, op_id,
1041
+ server_deleted_at
1042
+ FROM sync_snapshot_items
1043
+ WHERE snapshot_id = ?
1044
+ AND (
1045
+ table_name > ?
1046
+ OR (table_name = ? AND pk > ?)
1047
+ OR (table_name = ? AND pk = ? AND kind > ?)
1048
+ )
1049
+ ORDER BY table_name ASC, pk ASC, kind ASC
1050
+ LIMIT ?
1051
+ `).all(
1052
+ header.id,
1053
+ after.tableName,
1054
+ after.tableName,
1055
+ after.pk,
1056
+ after.tableName,
1057
+ after.pk,
1058
+ after.kind,
1059
+ pageSize + 1
1060
+ ) : raw.prepare(`
1061
+ SELECT
1062
+ table_name, pk, kind, payload_json, clock, hlc, op_id,
1063
+ server_deleted_at
1064
+ FROM sync_snapshot_items
1065
+ WHERE snapshot_id = ?
1066
+ ORDER BY table_name ASC, pk ASC, kind ASC
1067
+ LIMIT ?
1068
+ `).all(header.id, pageSize + 1);
1069
+ const hasMore = rows.length > pageSize;
1070
+ const pageRows = hasMore ? rows.slice(0, pageSize) : rows;
1071
+ const items = pageRows.map((row) => {
1072
+ const revision = {
1073
+ clock: row.clock,
1074
+ hlc: row.hlc,
1075
+ opId: row.op_id
1076
+ };
1077
+ if (row.kind === "row") {
1078
+ return {
1079
+ kind: "row",
1080
+ tableName: row.table_name,
1081
+ pk: row.pk,
1082
+ payload: JSON.parse(row.payload_json ?? "null"),
1083
+ revision
1084
+ };
1085
+ }
1086
+ return {
1087
+ kind: "tombstone",
1088
+ tableName: row.table_name,
1089
+ pk: row.pk,
1090
+ revision,
1091
+ serverDeletedAt: row.server_deleted_at ?? 0
1092
+ };
1093
+ });
1094
+ const lastRow = pageRows.at(-1);
1095
+ const nextPageToken = hasMore && lastRow ? encodeSnapshotPageToken({
1096
+ version: 1,
1097
+ snapshotId: header.id,
1098
+ after: {
1099
+ tableName: lastRow.table_name,
1100
+ pk: lastRow.pk,
1101
+ kind: lastRow.kind
1102
+ }
1103
+ }) : null;
1104
+ return {
1105
+ workspaceId: header.workspace_id,
1106
+ snapshotId: header.id,
1107
+ highWatermark: header.high_watermark,
1108
+ items,
1109
+ nextPageToken
1110
+ };
1111
+ }
310
1112
  async updateCursor(event, input) {
311
1113
  await assertWorkspaceScopeAuthorized(event, input.scope.workspaceId);
312
1114
  const raw = getRawDb();
313
1115
  const now = nowEpoch();
314
- raw.prepare(
315
- `INSERT INTO device_cursors (id, workspace_id, device_id, last_seen_version, updated_at)
316
- VALUES (?, ?, ?, ?, ?)
317
- ON CONFLICT(workspace_id, device_id) DO UPDATE SET
318
- last_seen_version = MAX(device_cursors.last_seen_version, excluded.last_seen_version),
319
- updated_at = excluded.updated_at`
320
- ).run(uid(), input.scope.workspaceId, input.deviceId, input.version, now);
1116
+ const deviceId = input.deviceId.trim();
1117
+ if (!deviceId || deviceId.length > 256) {
1118
+ throw createError({ statusCode: 400, statusMessage: "Invalid device cursor" });
1119
+ }
1120
+ if (!Number.isSafeInteger(input.version) || input.version < 0) {
1121
+ throw createError({ statusCode: 400, statusMessage: "Invalid device cursor" });
1122
+ }
1123
+ const ownerUserId = resolveSessionUserId(event);
1124
+ if (!ownerUserId) {
1125
+ throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
1126
+ }
1127
+ const update = raw.transaction(() => {
1128
+ const counter = raw.prepare(
1129
+ "SELECT value FROM server_version_counter WHERE workspace_id = ?"
1130
+ ).get(input.scope.workspaceId);
1131
+ const maximum = counter?.value ?? 0;
1132
+ if (input.version > maximum) {
1133
+ throw createError({ statusCode: 400, statusMessage: "Cursor exceeds workspace version" });
1134
+ }
1135
+ const existing = raw.prepare(
1136
+ `SELECT owner_user_id, last_seen_version
1137
+ FROM device_cursors WHERE workspace_id = ? AND device_id = ?`
1138
+ ).get(input.scope.workspaceId, deviceId);
1139
+ if (existing?.owner_user_id && existing.owner_user_id !== ownerUserId) {
1140
+ throw createError({ statusCode: 403, statusMessage: "Device cursor belongs to another user" });
1141
+ }
1142
+ if (existing && input.version < existing.last_seen_version) {
1143
+ throw createError({ statusCode: 409, statusMessage: "Device cursor cannot regress" });
1144
+ }
1145
+ raw.prepare(
1146
+ `INSERT INTO device_cursors (
1147
+ id, workspace_id, device_id, owner_user_id, last_seen_version, updated_at
1148
+ ) VALUES (?, ?, ?, ?, ?, ?)
1149
+ ON CONFLICT(workspace_id, device_id) DO UPDATE SET
1150
+ owner_user_id = COALESCE(device_cursors.owner_user_id, excluded.owner_user_id),
1151
+ last_seen_version = excluded.last_seen_version,
1152
+ updated_at = excluded.updated_at`
1153
+ ).run(uid(), input.scope.workspaceId, deviceId, ownerUserId, input.version, now);
1154
+ });
1155
+ update();
321
1156
  }
322
1157
  async gcTombstones(event, input) {
323
1158
  await assertWorkspaceScopeAuthorized(event, input.scope.workspaceId);
324
- const raw = getRawDb();
325
- const workspaceId = input.scope.workspaceId;
326
- const minCursorRow = raw.prepare(
327
- "SELECT MIN(last_seen_version) as min_version FROM device_cursors WHERE workspace_id = ?"
328
- ).get(workspaceId);
329
- const minCursor = minCursorRow?.min_version ?? 0;
1159
+ if (!Number.isSafeInteger(input.retentionSeconds) || input.retentionSeconds < MIN_SYNC_RETENTION_SECONDS || input.retentionSeconds > MAX_SYNC_RETENTION_SECONDS) {
1160
+ throw createError({ statusCode: 400, statusMessage: "Invalid sync retention window" });
1161
+ }
1162
+ if (!canRunSyncHistoryGc()) return;
330
1163
  const cutoff = nowEpoch() - input.retentionSeconds;
331
- raw.prepare(
332
- `DELETE FROM tombstones
333
- WHERE workspace_id = ?
334
- AND server_version < ?
335
- AND created_at < ?`
336
- ).run(workspaceId, minCursor, cutoff);
1164
+ const raw = getRawDb();
1165
+ raw.transaction(() => {
1166
+ const cursor = raw.prepare(
1167
+ `SELECT MIN(last_seen_version) AS min_version
1168
+ FROM device_cursors WHERE workspace_id = ?`
1169
+ ).get(input.scope.workspaceId);
1170
+ if (cursor.min_version === null) return;
1171
+ raw.prepare(
1172
+ `DELETE FROM tombstones
1173
+ WHERE workspace_id = ? AND created_at < ? AND server_version <= ?`
1174
+ ).run(input.scope.workspaceId, cutoff, cursor.min_version);
1175
+ })();
337
1176
  }
338
1177
  async gcChangeLog(event, input) {
339
1178
  await assertWorkspaceScopeAuthorized(event, input.scope.workspaceId);
340
- const raw = getRawDb();
341
- const workspaceId = input.scope.workspaceId;
342
- const minCursorRow = raw.prepare(
343
- "SELECT MIN(last_seen_version) as min_version FROM device_cursors WHERE workspace_id = ?"
344
- ).get(workspaceId);
345
- const minCursor = minCursorRow?.min_version ?? 0;
1179
+ if (!Number.isSafeInteger(input.retentionSeconds) || input.retentionSeconds < MIN_SYNC_RETENTION_SECONDS || input.retentionSeconds > MAX_SYNC_RETENTION_SECONDS) {
1180
+ throw createError({ statusCode: 400, statusMessage: "Invalid sync retention window" });
1181
+ }
1182
+ if (!canRunSyncHistoryGc()) return;
346
1183
  const cutoff = nowEpoch() - input.retentionSeconds;
347
- const BATCH_SIZE = 1e3;
348
- let deleted;
349
- do {
350
- const result = raw.prepare(
1184
+ const raw = getRawDb();
1185
+ raw.transaction(() => {
1186
+ const cursor = raw.prepare(
1187
+ `SELECT MIN(last_seen_version) AS min_version
1188
+ FROM device_cursors WHERE workspace_id = ?`
1189
+ ).get(input.scope.workspaceId);
1190
+ if (cursor.min_version === null) return;
1191
+ raw.prepare(
351
1192
  `DELETE FROM change_log
352
- WHERE rowid IN (
353
- SELECT rowid FROM change_log
354
- WHERE workspace_id = ?
355
- AND server_version < ?
356
- AND created_at < ?
357
- LIMIT ?
358
- )`
359
- ).run(workspaceId, minCursor, cutoff, BATCH_SIZE);
360
- deleted = result.changes;
361
- } while (deleted >= BATCH_SIZE);
1193
+ WHERE workspace_id = ? AND created_at < ? AND server_version <= ?`
1194
+ ).run(input.scope.workspaceId, cutoff, cursor.min_version);
1195
+ })();
362
1196
  }
363
1197
  }
364
1198
  export function createSqliteSyncGatewayAdapter() {