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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { canRunSyncHistoryGc } from "./history-gc-policy.js";
|
|
2
3
|
import { createError } from "h3";
|
|
3
4
|
import {
|
|
@@ -8,10 +9,17 @@ import {
|
|
|
8
9
|
getSyncMaintenanceState,
|
|
9
10
|
SYNC_MAINTENANCE_RETENTION_SECONDS
|
|
10
11
|
} from "./maintenance-state.js";
|
|
12
|
+
import {
|
|
13
|
+
backgroundHistoryDeviceId,
|
|
14
|
+
parseChatGenerationAdmissionEnvelope
|
|
15
|
+
} from "~~/shared/chat/background-history";
|
|
11
16
|
import { getSqliteDb, getRawDb, isD1Driver } from "../db/kysely.js";
|
|
12
17
|
import { d1All, d1Batch, d1Run } from "../db/d1.js";
|
|
13
18
|
import { SYNCED_TABLE_MAP, ALLOWED_SYNC_TABLES } from "../db/schema.js";
|
|
14
19
|
import { emitWebhookSystemHook } from "~~/server/utils/webhooks/runtime";
|
|
20
|
+
import { incomingRevisionWins } from "~~/shared/sync/revision";
|
|
21
|
+
import { sanitizePayloadForSync } from "~~/shared/sync/sanitize";
|
|
22
|
+
import { computePullRetention } from "./history-gc-policy.js";
|
|
15
23
|
const DEFAULT_PULL_LIMIT = 100;
|
|
16
24
|
const MAX_PULL_LIMIT = 1e3;
|
|
17
25
|
const MAX_SNAPSHOT_PAGE_SIZE = 1e3;
|
|
@@ -37,6 +45,13 @@ function stableJson(value) {
|
|
|
37
45
|
(key) => `${JSON.stringify(key)}:${stableJson(record[key])}`
|
|
38
46
|
).join(",")}}`;
|
|
39
47
|
}
|
|
48
|
+
function backgroundHistoryOpId(admission, stage) {
|
|
49
|
+
const hex = createHash("sha256").update(`${admission.workspaceId}\0${admission.generationId}\0${stage}`).digest("hex").slice(0, 32).split("");
|
|
50
|
+
hex[12] = "4";
|
|
51
|
+
hex[16] = (Number.parseInt(hex[16], 16) & 3 | 8).toString(16);
|
|
52
|
+
const value = hex.join("");
|
|
53
|
+
return `${value.slice(0, 8)}-${value.slice(8, 12)}-${value.slice(12, 16)}-${value.slice(16, 20)}-${value.slice(20)}`;
|
|
54
|
+
}
|
|
40
55
|
function operationFingerprint(op) {
|
|
41
56
|
return stableJson({
|
|
42
57
|
tableName: op.tableName,
|
|
@@ -48,6 +63,61 @@ function operationFingerprint(op) {
|
|
|
48
63
|
deviceId: op.stamp.deviceId
|
|
49
64
|
});
|
|
50
65
|
}
|
|
66
|
+
function changeLogFingerprint(row) {
|
|
67
|
+
return stableJson({
|
|
68
|
+
tableName: row.table_name,
|
|
69
|
+
operation: row.op,
|
|
70
|
+
pk: row.pk,
|
|
71
|
+
payload: row.payload_json ? JSON.parse(row.payload_json) : void 0,
|
|
72
|
+
clock: row.clock,
|
|
73
|
+
hlc: row.hlc,
|
|
74
|
+
deviceId: row.device_id
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
function notificationPayloadUserId(payload) {
|
|
78
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
79
|
+
return void 0;
|
|
80
|
+
}
|
|
81
|
+
const userId = payload.user_id;
|
|
82
|
+
return typeof userId === "string" && userId.trim().length > 0 ? userId : void 0;
|
|
83
|
+
}
|
|
84
|
+
function isNotificationVisibleToUser(change, userId) {
|
|
85
|
+
if (change.tableName !== "notifications") return true;
|
|
86
|
+
if (!userId) return false;
|
|
87
|
+
return notificationPayloadUserId(change.payload) === userId;
|
|
88
|
+
}
|
|
89
|
+
function scopeNotificationWrite(op, userId) {
|
|
90
|
+
if (op.tableName !== "notifications") return op;
|
|
91
|
+
if (!userId) {
|
|
92
|
+
return "Unauthorized: notification writes require a session user";
|
|
93
|
+
}
|
|
94
|
+
if (op.operation !== "put") return op;
|
|
95
|
+
const payload = op.payload && typeof op.payload === "object" && !Array.isArray(op.payload) ? { ...op.payload } : {};
|
|
96
|
+
if (payload.user_id !== void 0 && payload.user_id !== userId) {
|
|
97
|
+
return "Forbidden: notification owner mismatch";
|
|
98
|
+
}
|
|
99
|
+
return { ...op, payload: { ...payload, user_id: userId } };
|
|
100
|
+
}
|
|
101
|
+
const LWW_EXCLUDED_WINS = `(
|
|
102
|
+
excluded.clock > TARGET.clock
|
|
103
|
+
OR (excluded.clock = TARGET.clock AND excluded.hlc > TARGET.hlc)
|
|
104
|
+
OR (
|
|
105
|
+
excluded.clock = TARGET.clock
|
|
106
|
+
AND excluded.hlc = TARGET.hlc
|
|
107
|
+
AND excluded.op_id > TARGET.op_id
|
|
108
|
+
)
|
|
109
|
+
)`;
|
|
110
|
+
function lwwExcludedWins(existingTable) {
|
|
111
|
+
return LWW_EXCLUDED_WINS.replaceAll("TARGET", existingTable);
|
|
112
|
+
}
|
|
113
|
+
function snapshotNotificationOwnerClause(tableName, jsonExpr, userId) {
|
|
114
|
+
if (tableName !== "notifications") return { sql: "", params: [] };
|
|
115
|
+
if (!userId) return { sql: " AND 1 = 0", params: [] };
|
|
116
|
+
return {
|
|
117
|
+
sql: ` AND json_extract(${jsonExpr}, '$.user_id') = ?`,
|
|
118
|
+
params: [userId]
|
|
119
|
+
};
|
|
120
|
+
}
|
|
51
121
|
function validatePushOperation(workspaceId, op) {
|
|
52
122
|
if (!ALLOWED_SYNC_TABLES.includes(op.tableName)) {
|
|
53
123
|
return `Invalid table: ${op.tableName}`;
|
|
@@ -292,23 +362,355 @@ function resolveHookEmission(input) {
|
|
|
292
362
|
}
|
|
293
363
|
return null;
|
|
294
364
|
}
|
|
295
|
-
function
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
365
|
+
function incomingWinsRevision(incoming, existing) {
|
|
366
|
+
return incomingRevisionWins(incoming, {
|
|
367
|
+
clock: existing.clock,
|
|
368
|
+
hlc: existing.hlc,
|
|
369
|
+
opId: existing.opId || existing.op_id || ""
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
function materializedWinner(row, tombstone) {
|
|
373
|
+
if (tombstone && (!row || incomingWinsRevision(
|
|
374
|
+
{ clock: tombstone.clock, hlc: tombstone.hlc, opId: tombstone.op_id },
|
|
375
|
+
row
|
|
376
|
+
))) {
|
|
377
|
+
return {
|
|
378
|
+
kind: "delete",
|
|
379
|
+
revision: { clock: tombstone.clock, hlc: tombstone.hlc, opId: tombstone.op_id },
|
|
380
|
+
serverDeletedAt: tombstone.deleted_at
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
if (!row) return void 0;
|
|
384
|
+
const revision = { clock: row.clock, hlc: row.hlc, opId: row.op_id };
|
|
385
|
+
return row.deleted ? {
|
|
386
|
+
kind: "delete",
|
|
387
|
+
revision,
|
|
388
|
+
serverDeletedAt: tombstone?.op_id === row.op_id ? tombstone.deleted_at : row.updated_at
|
|
389
|
+
} : { kind: "put", payload: JSON.parse(row.data_json), revision };
|
|
390
|
+
}
|
|
391
|
+
function currentWinnerNative(raw, workspaceId, tableName, pk) {
|
|
392
|
+
const materializedTable = SYNCED_TABLE_MAP[tableName];
|
|
393
|
+
if (!materializedTable) return void 0;
|
|
394
|
+
const row = raw.prepare(
|
|
395
|
+
`SELECT data_json, deleted, clock, hlc, op_id, updated_at
|
|
396
|
+
FROM ${materializedTable} WHERE id = ? AND workspace_id = ?`
|
|
397
|
+
).get(pk, workspaceId);
|
|
398
|
+
const tombstone = raw.prepare(
|
|
399
|
+
`SELECT clock, hlc, op_id, deleted_at FROM tombstones
|
|
400
|
+
WHERE workspace_id = ? AND table_name = ? AND pk = ?`
|
|
401
|
+
).get(workspaceId, tableName, pk);
|
|
402
|
+
return materializedWinner(row, tombstone);
|
|
403
|
+
}
|
|
404
|
+
async function currentWinnerD1(workspaceId, tableName, pk) {
|
|
405
|
+
const materializedTable = SYNCED_TABLE_MAP[tableName];
|
|
406
|
+
if (!materializedTable) return void 0;
|
|
407
|
+
const [row] = await d1All(
|
|
408
|
+
`SELECT data_json, deleted, clock, hlc, op_id, updated_at
|
|
409
|
+
FROM "${materializedTable}" WHERE id = ? AND workspace_id = ?`,
|
|
410
|
+
pk,
|
|
411
|
+
workspaceId
|
|
412
|
+
);
|
|
413
|
+
const [tombstone] = await d1All(
|
|
414
|
+
`SELECT clock, hlc, op_id, deleted_at FROM tombstones
|
|
415
|
+
WHERE workspace_id = ? AND table_name = ? AND pk = ?`,
|
|
416
|
+
workspaceId,
|
|
417
|
+
tableName,
|
|
418
|
+
pk
|
|
419
|
+
);
|
|
420
|
+
return materializedWinner(row, tombstone);
|
|
299
421
|
}
|
|
300
422
|
export class SqliteSyncGatewayAdapter {
|
|
301
423
|
id = "sqlite";
|
|
302
|
-
capabilities
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
424
|
+
get capabilities() {
|
|
425
|
+
return isD1Driver() ? {
|
|
426
|
+
snapshotBootstrap: "snapshot-v1",
|
|
427
|
+
historyRetention: "snapshot-v1"
|
|
428
|
+
} : {
|
|
429
|
+
snapshotBootstrap: "snapshot-v1",
|
|
430
|
+
historyRetention: "snapshot-v1",
|
|
431
|
+
backgroundGenerationHistory: "v1"
|
|
432
|
+
};
|
|
433
|
+
}
|
|
306
434
|
get db() {
|
|
307
435
|
return getSqliteDb();
|
|
308
436
|
}
|
|
437
|
+
assertCanonicalActor(actor) {
|
|
438
|
+
if (isD1Driver()) {
|
|
439
|
+
throw new Error("SQLite D1 does not support background generation history v1");
|
|
440
|
+
}
|
|
441
|
+
const membership = getRawDb().prepare(
|
|
442
|
+
`SELECT role FROM workspace_members
|
|
443
|
+
WHERE workspace_id = ? AND user_id = ?`
|
|
444
|
+
).get(actor.workspaceId, actor.userId);
|
|
445
|
+
if (!membership || membership.role !== "owner" && membership.role !== "editor") {
|
|
446
|
+
throw new Error("Forbidden background history actor");
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
async admitChatGeneration(actor, input) {
|
|
450
|
+
const admission = parseChatGenerationAdmissionEnvelope(input);
|
|
451
|
+
if (actor.workspaceId !== admission.workspaceId) {
|
|
452
|
+
throw new Error("Invalid background history workspace");
|
|
453
|
+
}
|
|
454
|
+
const raw = getRawDb();
|
|
455
|
+
const fingerprint = createHash("sha256").update(stableJson(admission)).digest("hex");
|
|
456
|
+
return raw.transaction(() => {
|
|
457
|
+
this.assertCanonicalActor(actor);
|
|
458
|
+
const receipt = raw.prepare(
|
|
459
|
+
`SELECT fingerprint, outcome, server_version
|
|
460
|
+
FROM background_generation_receipts
|
|
461
|
+
WHERE workspace_id = ? AND generation_id = ? AND stage = 'admission'`
|
|
462
|
+
).get(admission.workspaceId, admission.generationId);
|
|
463
|
+
if (receipt) {
|
|
464
|
+
if (receipt.fingerprint !== fingerprint) {
|
|
465
|
+
throw new Error("Conflicting background admission replay");
|
|
466
|
+
}
|
|
467
|
+
return {
|
|
468
|
+
status: "admitted",
|
|
469
|
+
replayed: true,
|
|
470
|
+
serverVersion: receipt.server_version ?? 0
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
if (admission.kind === "continuation") {
|
|
474
|
+
const row = raw.prepare(
|
|
475
|
+
`SELECT data_json, clock, deleted FROM s_messages
|
|
476
|
+
WHERE workspace_id = ? AND id = ?`
|
|
477
|
+
).get(admission.workspaceId, admission.messageId);
|
|
478
|
+
const generationId = row ? JSON.parse(row.data_json).data?.generation_id : void 0;
|
|
479
|
+
const expectedClock = admission.expectedAssistant?.clock ?? -1;
|
|
480
|
+
const admissionClock = admission.assistantMessage.clock;
|
|
481
|
+
if (row && row.deleted === 1 || row && row.clock > admissionClock || row && row.clock === admissionClock && generationId !== admission.generationId || row && row.clock > expectedClock && row.clock < admissionClock || row && row.clock === expectedClock && admission.expectedAssistant?.generationId !== void 0 && generationId !== admission.expectedAssistant.generationId) {
|
|
482
|
+
throw new Error("Conflicting continuation admission");
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
const records = [
|
|
486
|
+
admission.thread ? { table: "threads", materialized: "s_threads", record: admission.thread } : null,
|
|
487
|
+
admission.userMessage ? { table: "messages", materialized: "s_messages", record: admission.userMessage } : null,
|
|
488
|
+
{ table: "messages", materialized: "s_messages", record: admission.assistantMessage }
|
|
489
|
+
].filter(Boolean);
|
|
490
|
+
const counter = raw.prepare("SELECT value FROM server_version_counter WHERE workspace_id = ?").get(admission.workspaceId);
|
|
491
|
+
const baseVersion = counter?.value ?? 0;
|
|
492
|
+
const finalVersion = baseVersion + records.length;
|
|
493
|
+
raw.prepare(
|
|
494
|
+
`INSERT INTO server_version_counter (workspace_id, value) VALUES (?, ?)
|
|
495
|
+
ON CONFLICT(workspace_id) DO UPDATE SET value = excluded.value`
|
|
496
|
+
).run(admission.workspaceId, finalVersion);
|
|
497
|
+
const now = nowEpoch();
|
|
498
|
+
records.forEach((entry, index) => {
|
|
499
|
+
const opId = backgroundHistoryOpId(
|
|
500
|
+
admission,
|
|
501
|
+
entry.table === "threads" ? "thread" : entry.record.id === admission.messageId ? "assistant" : "user"
|
|
502
|
+
);
|
|
503
|
+
const hlc = entry.record.hlc ?? `${Date.now().toString(36).padStart(9, "0")}:000:background`;
|
|
504
|
+
const tombstone = raw.prepare(
|
|
505
|
+
`SELECT clock, hlc, op_id FROM tombstones
|
|
506
|
+
WHERE workspace_id = ? AND table_name = ? AND pk = ?`
|
|
507
|
+
).get(
|
|
508
|
+
admission.workspaceId,
|
|
509
|
+
entry.table,
|
|
510
|
+
entry.record.id
|
|
511
|
+
);
|
|
512
|
+
if (tombstone && !incomingWinsRevision(
|
|
513
|
+
{ clock: entry.record.clock, hlc, opId },
|
|
514
|
+
tombstone
|
|
515
|
+
)) {
|
|
516
|
+
throw new Error("Background history record was superseded");
|
|
517
|
+
}
|
|
518
|
+
const sanitized = sanitizePayloadForSync(
|
|
519
|
+
entry.table,
|
|
520
|
+
entry.record,
|
|
521
|
+
"put"
|
|
522
|
+
);
|
|
523
|
+
if (!sanitized) {
|
|
524
|
+
throw new Error("Invalid background history payload");
|
|
525
|
+
}
|
|
526
|
+
const payload = { ...sanitized, hlc, op_id: opId };
|
|
527
|
+
const payloadJson = JSON.stringify(payload);
|
|
528
|
+
if (new TextEncoder().encode(payloadJson).byteLength > MAX_SYNC_PAYLOAD_BYTES) {
|
|
529
|
+
throw new Error("Invalid background history payload size");
|
|
530
|
+
}
|
|
531
|
+
const version = baseVersion + index + 1;
|
|
532
|
+
raw.prepare(
|
|
533
|
+
`INSERT INTO change_log
|
|
534
|
+
(id, workspace_id, server_version, table_name, pk, op, payload_json,
|
|
535
|
+
clock, hlc, device_id, op_id, created_at)
|
|
536
|
+
VALUES (?, ?, ?, ?, ?, 'put', ?, ?, ?, ?, ?, ?)`
|
|
537
|
+
).run(
|
|
538
|
+
uid(),
|
|
539
|
+
admission.workspaceId,
|
|
540
|
+
version,
|
|
541
|
+
entry.table,
|
|
542
|
+
entry.record.id,
|
|
543
|
+
payloadJson,
|
|
544
|
+
entry.record.clock,
|
|
545
|
+
hlc,
|
|
546
|
+
backgroundHistoryDeviceId(admission.generationId),
|
|
547
|
+
opId,
|
|
548
|
+
now
|
|
549
|
+
);
|
|
550
|
+
raw.prepare(
|
|
551
|
+
`INSERT INTO ${entry.materialized}
|
|
552
|
+
(id, workspace_id, data_json, clock, hlc, device_id, op_id,
|
|
553
|
+
deleted, created_at, updated_at)
|
|
554
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
|
|
555
|
+
ON CONFLICT(workspace_id, id) DO UPDATE SET
|
|
556
|
+
data_json = excluded.data_json, clock = excluded.clock,
|
|
557
|
+
hlc = excluded.hlc, device_id = excluded.device_id,
|
|
558
|
+
op_id = excluded.op_id, deleted = 0, updated_at = excluded.updated_at
|
|
559
|
+
WHERE ${lwwExcludedWins(entry.materialized)}`
|
|
560
|
+
).run(
|
|
561
|
+
entry.record.id,
|
|
562
|
+
admission.workspaceId,
|
|
563
|
+
payloadJson,
|
|
564
|
+
entry.record.clock,
|
|
565
|
+
hlc,
|
|
566
|
+
backgroundHistoryDeviceId(admission.generationId),
|
|
567
|
+
opId,
|
|
568
|
+
now,
|
|
569
|
+
now
|
|
570
|
+
);
|
|
571
|
+
});
|
|
572
|
+
const admittedAssistant = raw.prepare(
|
|
573
|
+
`SELECT data_json, clock, deleted FROM s_messages
|
|
574
|
+
WHERE workspace_id = ? AND id = ?`
|
|
575
|
+
).get(admission.workspaceId, admission.messageId);
|
|
576
|
+
const admittedPayload = admittedAssistant ? JSON.parse(admittedAssistant.data_json) : null;
|
|
577
|
+
const admittedData = admittedPayload?.data && typeof admittedPayload.data === "object" ? admittedPayload.data : {};
|
|
578
|
+
if (!admittedAssistant || admittedAssistant.deleted === 1 || admittedAssistant.clock !== admission.assistantMessage.clock || admittedData.generation_id !== admission.generationId) {
|
|
579
|
+
throw new Error("Background assistant admission was superseded");
|
|
580
|
+
}
|
|
581
|
+
raw.prepare(
|
|
582
|
+
`INSERT INTO background_generation_receipts
|
|
583
|
+
(id, workspace_id, generation_id, stage, fingerprint, outcome,
|
|
584
|
+
server_version, created_at)
|
|
585
|
+
VALUES (?, ?, ?, 'admission', ?, 'admitted', ?, ?)`
|
|
586
|
+
).run(uid(), admission.workspaceId, admission.generationId, fingerprint, finalVersion, now);
|
|
587
|
+
return { status: "admitted", replayed: false, serverVersion: finalVersion };
|
|
588
|
+
}).immediate();
|
|
589
|
+
}
|
|
590
|
+
async finalizeChatGeneration(actor, input) {
|
|
591
|
+
const admission = parseChatGenerationAdmissionEnvelope(input.admission);
|
|
592
|
+
if (actor.workspaceId !== admission.workspaceId) {
|
|
593
|
+
throw new Error("Invalid background history workspace");
|
|
594
|
+
}
|
|
595
|
+
const raw = getRawDb();
|
|
596
|
+
const fingerprint = createHash("sha256").update(stableJson(input)).digest("hex");
|
|
597
|
+
return raw.transaction(() => {
|
|
598
|
+
this.assertCanonicalActor(actor);
|
|
599
|
+
const receipt = raw.prepare(
|
|
600
|
+
`SELECT fingerprint, outcome, server_version
|
|
601
|
+
FROM background_generation_receipts
|
|
602
|
+
WHERE workspace_id = ? AND generation_id = ? AND stage = 'finalization'`
|
|
603
|
+
).get(admission.workspaceId, admission.generationId);
|
|
604
|
+
if (receipt) {
|
|
605
|
+
if (receipt.fingerprint !== fingerprint) {
|
|
606
|
+
throw new Error("Conflicting background finalization replay");
|
|
607
|
+
}
|
|
608
|
+
return receipt.outcome === "committed" ? {
|
|
609
|
+
status: "committed",
|
|
610
|
+
replayed: true,
|
|
611
|
+
serverVersion: receipt.server_version ?? 0
|
|
612
|
+
} : {
|
|
613
|
+
status: "superseded",
|
|
614
|
+
reason: receipt.outcome
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
const row = raw.prepare(
|
|
618
|
+
`SELECT data_json, clock, deleted FROM s_messages
|
|
619
|
+
WHERE workspace_id = ? AND id = ?`
|
|
620
|
+
).get(admission.workspaceId, admission.messageId);
|
|
621
|
+
const current = row ? JSON.parse(row.data_json) : null;
|
|
622
|
+
const data = current?.data && typeof current.data === "object" ? current.data : {};
|
|
623
|
+
let superseded = null;
|
|
624
|
+
if (!row || !current) superseded = "missing";
|
|
625
|
+
else if (row.deleted === 1 || current.deleted === true) superseded = "deleted";
|
|
626
|
+
else if (data.generation_id !== admission.generationId) superseded = "newer_generation";
|
|
627
|
+
else if (row.clock !== admission.assistantMessage.clock) superseded = "edited";
|
|
628
|
+
const now = nowEpoch();
|
|
629
|
+
if (superseded) {
|
|
630
|
+
raw.prepare(
|
|
631
|
+
`INSERT INTO background_generation_receipts
|
|
632
|
+
(id, workspace_id, generation_id, stage, fingerprint, outcome,
|
|
633
|
+
server_version, created_at)
|
|
634
|
+
VALUES (?, ?, ?, 'finalization', ?, ?, NULL, ?)`
|
|
635
|
+
).run(uid(), admission.workspaceId, admission.generationId, fingerprint, superseded, now);
|
|
636
|
+
return { status: "superseded", reason: superseded };
|
|
637
|
+
}
|
|
638
|
+
const nextClock = row.clock + 1;
|
|
639
|
+
const hlc = `${Date.now().toString(36).padStart(9, "0")}:000:background`;
|
|
640
|
+
const opId = backgroundHistoryOpId(admission, "finalize");
|
|
641
|
+
const terminalState = input.snapshot.status === "complete" ? "complete" : input.snapshot.status === "aborted" ? "aborted" : "failed";
|
|
642
|
+
const payload = {
|
|
643
|
+
...current,
|
|
644
|
+
pending: false,
|
|
645
|
+
error: input.snapshot.error ?? null,
|
|
646
|
+
updated_at: Math.floor(input.snapshot.completedAt / 1e3),
|
|
647
|
+
clock: nextClock,
|
|
648
|
+
hlc,
|
|
649
|
+
op_id: opId,
|
|
650
|
+
data: {
|
|
651
|
+
...data,
|
|
652
|
+
content: input.snapshot.content,
|
|
653
|
+
reasoning_text: input.snapshot.reasoning || null,
|
|
654
|
+
tool_calls: input.snapshot.toolCalls ?? null,
|
|
655
|
+
generation_state: terminalState,
|
|
656
|
+
background_job_status: input.snapshot.status,
|
|
657
|
+
background_job_error: input.snapshot.error ?? null,
|
|
658
|
+
error: input.snapshot.error ?? null
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
const payloadJson = JSON.stringify(payload);
|
|
662
|
+
if (new TextEncoder().encode(payloadJson).byteLength > MAX_SYNC_PAYLOAD_BYTES) {
|
|
663
|
+
throw new Error("Invalid background history payload size");
|
|
664
|
+
}
|
|
665
|
+
const counter = raw.prepare("SELECT value FROM server_version_counter WHERE workspace_id = ?").get(admission.workspaceId);
|
|
666
|
+
const serverVersion = (counter?.value ?? 0) + 1;
|
|
667
|
+
raw.prepare(
|
|
668
|
+
`INSERT INTO server_version_counter (workspace_id, value) VALUES (?, ?)
|
|
669
|
+
ON CONFLICT(workspace_id) DO UPDATE SET value = excluded.value`
|
|
670
|
+
).run(admission.workspaceId, serverVersion);
|
|
671
|
+
raw.prepare(
|
|
672
|
+
`UPDATE s_messages SET data_json = ?, clock = ?, hlc = ?, device_id = ?,
|
|
673
|
+
op_id = ?, deleted = 0, updated_at = ?
|
|
674
|
+
WHERE workspace_id = ? AND id = ?`
|
|
675
|
+
).run(
|
|
676
|
+
payloadJson,
|
|
677
|
+
nextClock,
|
|
678
|
+
hlc,
|
|
679
|
+
backgroundHistoryDeviceId(admission.generationId),
|
|
680
|
+
opId,
|
|
681
|
+
now,
|
|
682
|
+
admission.workspaceId,
|
|
683
|
+
admission.messageId
|
|
684
|
+
);
|
|
685
|
+
raw.prepare(
|
|
686
|
+
`INSERT INTO change_log
|
|
687
|
+
(id, workspace_id, server_version, table_name, pk, op, payload_json,
|
|
688
|
+
clock, hlc, device_id, op_id, created_at)
|
|
689
|
+
VALUES (?, ?, ?, 'messages', ?, 'put', ?, ?, ?, ?, ?, ?)`
|
|
690
|
+
).run(
|
|
691
|
+
uid(),
|
|
692
|
+
admission.workspaceId,
|
|
693
|
+
serverVersion,
|
|
694
|
+
admission.messageId,
|
|
695
|
+
payloadJson,
|
|
696
|
+
nextClock,
|
|
697
|
+
hlc,
|
|
698
|
+
backgroundHistoryDeviceId(admission.generationId),
|
|
699
|
+
opId,
|
|
700
|
+
now
|
|
701
|
+
);
|
|
702
|
+
raw.prepare(
|
|
703
|
+
`INSERT INTO background_generation_receipts
|
|
704
|
+
(id, workspace_id, generation_id, stage, fingerprint, outcome,
|
|
705
|
+
server_version, created_at)
|
|
706
|
+
VALUES (?, ?, ?, 'finalization', ?, 'committed', ?, ?)`
|
|
707
|
+
).run(uid(), admission.workspaceId, admission.generationId, fingerprint, serverVersion, now);
|
|
708
|
+
return { status: "committed", replayed: false, serverVersion };
|
|
709
|
+
}).immediate();
|
|
710
|
+
}
|
|
309
711
|
async reserveUploadIntent(event, input) {
|
|
310
712
|
await assertWorkspaceScopeAuthorized(event, input.workspaceId);
|
|
311
|
-
if (!input.intentId || !Number.isSafeInteger(input.sizeBytes) || input.sizeBytes <
|
|
713
|
+
if (!input.intentId || !Number.isSafeInteger(input.sizeBytes) || input.sizeBytes < 0 || !Number.isSafeInteger(input.expiresAt) || input.expiresAt <= nowEpoch()) {
|
|
312
714
|
throw createError({ statusCode: 400, statusMessage: "Invalid upload intent" });
|
|
313
715
|
}
|
|
314
716
|
if (input.workspaceQuotaBytes !== void 0 && (!Number.isSafeInteger(input.workspaceQuotaBytes) || input.workspaceQuotaBytes < 1)) {
|
|
@@ -497,11 +899,15 @@ export class SqliteSyncGatewayAdapter {
|
|
|
497
899
|
throw createError({ statusCode: 500, statusMessage: "Invalid canonical file size" });
|
|
498
900
|
}
|
|
499
901
|
const storageId = payload.storage_id ?? payload.storageId;
|
|
902
|
+
const fileKind = payload.kind === "image" ? "image" : payload.kind === "pdf" ? "pdf" : payload.kind === "file" ? "file" : void 0;
|
|
500
903
|
return {
|
|
501
904
|
kind: "metadata",
|
|
502
905
|
hash: normalizeStorageHash(row.id),
|
|
503
906
|
sizeBytes: size,
|
|
504
907
|
...typeof storageId === "string" && storageId ? { storageId } : {},
|
|
908
|
+
...typeof (payload.mime_type ?? payload.mimeType) === "string" ? { mimeType: String(payload.mime_type ?? payload.mimeType) } : {},
|
|
909
|
+
...typeof payload.name === "string" ? { name: payload.name } : {},
|
|
910
|
+
...fileKind ? { fileKind } : {},
|
|
505
911
|
updatedAt: row.updated_at
|
|
506
912
|
};
|
|
507
913
|
});
|
|
@@ -609,6 +1015,7 @@ export class SqliteSyncGatewayAdapter {
|
|
|
609
1015
|
if (!ops.length) {
|
|
610
1016
|
return { results: [], serverVersion: 0 };
|
|
611
1017
|
}
|
|
1018
|
+
const userId = resolveSessionUserId(event);
|
|
612
1019
|
const resultSlots = new Array(ops.length);
|
|
613
1020
|
const groups = /* @__PURE__ */ new Map();
|
|
614
1021
|
ops.forEach((op, index) => {
|
|
@@ -629,19 +1036,20 @@ export class SqliteSyncGatewayAdapter {
|
|
|
629
1036
|
const uniqueOps = [];
|
|
630
1037
|
const indicesByOpId = /* @__PURE__ */ new Map();
|
|
631
1038
|
for (const group of groups.values()) {
|
|
632
|
-
const
|
|
1039
|
+
const scoped = group.conflicting ? group.op : scopeNotificationWrite(group.op, userId);
|
|
1040
|
+
const error = group.conflicting ? `Conflicting operations reuse op_id ${group.op.stamp.opId}` : typeof scoped === "string" ? scoped : validatePushOperation(workspaceId, scoped);
|
|
633
1041
|
if (error) {
|
|
634
1042
|
for (const index of group.indices) {
|
|
635
1043
|
resultSlots[index] = {
|
|
636
1044
|
opId: group.op.stamp.opId,
|
|
637
1045
|
success: false,
|
|
638
1046
|
error,
|
|
639
|
-
errorCode: group.conflicting ? "CONFLICT" : "VALIDATION_ERROR"
|
|
1047
|
+
errorCode: group.conflicting ? "CONFLICT" : error.startsWith("Unauthorized") || error.startsWith("Forbidden") ? "UNAUTHORIZED" : "VALIDATION_ERROR"
|
|
640
1048
|
};
|
|
641
1049
|
}
|
|
642
1050
|
continue;
|
|
643
1051
|
}
|
|
644
|
-
uniqueOps.push(
|
|
1052
|
+
uniqueOps.push(scoped);
|
|
645
1053
|
indicesByOpId.set(group.op.stamp.opId, group.indices);
|
|
646
1054
|
}
|
|
647
1055
|
if (isD1Driver()) {
|
|
@@ -650,12 +1058,12 @@ export class SqliteSyncGatewayAdapter {
|
|
|
650
1058
|
workspaceId,
|
|
651
1059
|
uniqueOps,
|
|
652
1060
|
resultSlots,
|
|
653
|
-
indicesByOpId
|
|
1061
|
+
indicesByOpId,
|
|
1062
|
+
userId
|
|
654
1063
|
});
|
|
655
1064
|
}
|
|
656
1065
|
const raw = getRawDb();
|
|
657
1066
|
const now = nowEpoch();
|
|
658
|
-
const userId = resolveSessionUserId(event);
|
|
659
1067
|
const uniqueResults = [];
|
|
660
1068
|
let finalServerVersion = 0;
|
|
661
1069
|
const hookEmissions = [];
|
|
@@ -667,15 +1075,42 @@ export class SqliteSyncGatewayAdapter {
|
|
|
667
1075
|
const chunk = opIds.slice(i, i + chunkSize);
|
|
668
1076
|
const placeholders = chunk.map(() => "?").join(",");
|
|
669
1077
|
const rows = raw.prepare(
|
|
670
|
-
`SELECT op_id, server_version
|
|
1078
|
+
`SELECT op_id, server_version, workspace_id, table_name, pk, op,
|
|
1079
|
+
payload_json, clock, hlc, device_id
|
|
1080
|
+
FROM change_log WHERE op_id IN (${placeholders})`
|
|
671
1081
|
).all(...chunk);
|
|
672
1082
|
for (const row of rows) {
|
|
673
|
-
existingOps.set(row.op_id,
|
|
1083
|
+
existingOps.set(row.op_id, {
|
|
1084
|
+
server_version: row.server_version,
|
|
1085
|
+
workspace_id: row.workspace_id,
|
|
1086
|
+
fingerprint: changeLogFingerprint(row),
|
|
1087
|
+
op_id: row.op_id
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
const failedBeforeAlloc = /* @__PURE__ */ new Set();
|
|
1092
|
+
for (const op of uniqueOps) {
|
|
1093
|
+
if (op.tableName !== "notifications" || op.operation !== "delete") continue;
|
|
1094
|
+
const materializedTable = SYNCED_TABLE_MAP[op.tableName];
|
|
1095
|
+
if (!materializedTable) continue;
|
|
1096
|
+
const owned = raw.prepare(
|
|
1097
|
+
`SELECT data_json FROM ${materializedTable} WHERE id = ? AND workspace_id = ?`
|
|
1098
|
+
).get(op.pk, workspaceId);
|
|
1099
|
+
const owner = owned ? notificationPayloadUserId(JSON.parse(owned.data_json)) : void 0;
|
|
1100
|
+
if (!owned || owner !== userId) {
|
|
1101
|
+
failedBeforeAlloc.add(op.stamp.opId);
|
|
1102
|
+
uniqueResults.push({
|
|
1103
|
+
opId: op.stamp.opId,
|
|
1104
|
+
success: false,
|
|
1105
|
+
error: "Forbidden: notification owner mismatch",
|
|
1106
|
+
errorCode: "UNAUTHORIZED"
|
|
1107
|
+
});
|
|
674
1108
|
}
|
|
675
1109
|
}
|
|
676
1110
|
const uniqueNewOpIds = /* @__PURE__ */ new Set();
|
|
677
1111
|
for (const op of uniqueOps) {
|
|
678
1112
|
if (existingOps.has(op.stamp.opId)) continue;
|
|
1113
|
+
if (failedBeforeAlloc.has(op.stamp.opId)) continue;
|
|
679
1114
|
uniqueNewOpIds.add(op.stamp.opId);
|
|
680
1115
|
}
|
|
681
1116
|
let baseVersion;
|
|
@@ -700,6 +1135,7 @@ export class SqliteSyncGatewayAdapter {
|
|
|
700
1135
|
let versionOffset = 0;
|
|
701
1136
|
for (const op of uniqueOps) {
|
|
702
1137
|
if (existingOps.has(op.stamp.opId)) continue;
|
|
1138
|
+
if (failedBeforeAlloc.has(op.stamp.opId)) continue;
|
|
703
1139
|
if (assignedVersions.has(op.stamp.opId)) continue;
|
|
704
1140
|
versionOffset++;
|
|
705
1141
|
assignedVersions.set(op.stamp.opId, baseVersion + versionOffset);
|
|
@@ -707,12 +1143,26 @@ export class SqliteSyncGatewayAdapter {
|
|
|
707
1143
|
const processedNew = /* @__PURE__ */ new Set();
|
|
708
1144
|
for (const op of uniqueOps) {
|
|
709
1145
|
const opId = op.stamp.opId;
|
|
710
|
-
|
|
711
|
-
|
|
1146
|
+
if (failedBeforeAlloc.has(opId)) continue;
|
|
1147
|
+
const existingLog = existingOps.get(opId);
|
|
1148
|
+
if (existingLog !== void 0) {
|
|
1149
|
+
if (existingLog.workspace_id !== workspaceId || existingLog.fingerprint !== operationFingerprint(op)) {
|
|
1150
|
+
uniqueResults.push({
|
|
1151
|
+
opId,
|
|
1152
|
+
success: false,
|
|
1153
|
+
error: `Conflicting operation reuses processed op_id ${opId}`,
|
|
1154
|
+
errorCode: "CONFLICT"
|
|
1155
|
+
});
|
|
1156
|
+
continue;
|
|
1157
|
+
}
|
|
1158
|
+
const winner = currentWinnerNative(raw, workspaceId, op.tableName, op.pk);
|
|
712
1159
|
uniqueResults.push({
|
|
713
1160
|
opId,
|
|
714
1161
|
success: true,
|
|
715
|
-
serverVersion:
|
|
1162
|
+
serverVersion: existingLog.server_version,
|
|
1163
|
+
replayed: true,
|
|
1164
|
+
applied: winner?.revision.opId === opId,
|
|
1165
|
+
...winner?.revision.opId === opId ? {} : { winner }
|
|
716
1166
|
});
|
|
717
1167
|
continue;
|
|
718
1168
|
}
|
|
@@ -740,6 +1190,21 @@ export class SqliteSyncGatewayAdapter {
|
|
|
740
1190
|
continue;
|
|
741
1191
|
}
|
|
742
1192
|
const pkValue = op.pk;
|
|
1193
|
+
if (op.tableName === "notifications" && op.operation === "delete") {
|
|
1194
|
+
const owned = raw.prepare(
|
|
1195
|
+
`SELECT data_json FROM ${materializedTable} WHERE id = ? AND workspace_id = ?`
|
|
1196
|
+
).get(pkValue, workspaceId);
|
|
1197
|
+
const owner = owned ? notificationPayloadUserId(JSON.parse(owned.data_json)) : void 0;
|
|
1198
|
+
if (!owned || owner !== userId) {
|
|
1199
|
+
uniqueResults.push({
|
|
1200
|
+
opId,
|
|
1201
|
+
success: false,
|
|
1202
|
+
error: "Forbidden: notification owner mismatch",
|
|
1203
|
+
errorCode: "UNAUTHORIZED"
|
|
1204
|
+
});
|
|
1205
|
+
continue;
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
743
1208
|
const payloadJson = op.payload != null ? JSON.stringify(op.payload) : null;
|
|
744
1209
|
insertChangeLog.run(
|
|
745
1210
|
uid(),
|
|
@@ -755,33 +1220,42 @@ export class SqliteSyncGatewayAdapter {
|
|
|
755
1220
|
opId,
|
|
756
1221
|
now
|
|
757
1222
|
);
|
|
1223
|
+
const incomingRev = {
|
|
1224
|
+
clock: op.stamp.clock,
|
|
1225
|
+
hlc: op.stamp.hlc,
|
|
1226
|
+
opId
|
|
1227
|
+
};
|
|
758
1228
|
if (op.operation === "put") {
|
|
759
1229
|
const existing = raw.prepare(
|
|
760
|
-
`SELECT clock, hlc FROM ${materializedTable} WHERE id = ? AND workspace_id = ?`
|
|
1230
|
+
`SELECT clock, hlc, op_id, data_json FROM ${materializedTable} WHERE id = ? AND workspace_id = ?`
|
|
761
1231
|
).get(pkValue, workspaceId);
|
|
1232
|
+
const tombstone = !existing ? raw.prepare(
|
|
1233
|
+
`SELECT clock, hlc, op_id FROM tombstones
|
|
1234
|
+
WHERE workspace_id = ? AND table_name = ? AND pk = ?`
|
|
1235
|
+
).get(workspaceId, op.tableName, pkValue) : void 0;
|
|
762
1236
|
let applied = false;
|
|
1237
|
+
let winnerPayload = op.payload;
|
|
763
1238
|
if (!existing) {
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
)) {
|
|
1239
|
+
if (tombstone && !incomingWinsRevision(incomingRev, tombstone)) {
|
|
1240
|
+
winnerPayload = void 0;
|
|
1241
|
+
} else {
|
|
1242
|
+
raw.prepare(
|
|
1243
|
+
`INSERT INTO ${materializedTable} (id, workspace_id, data_json, clock, hlc, device_id, op_id, deleted, created_at, updated_at)
|
|
1244
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`
|
|
1245
|
+
).run(
|
|
1246
|
+
pkValue,
|
|
1247
|
+
workspaceId,
|
|
1248
|
+
payloadJson ?? "{}",
|
|
1249
|
+
op.stamp.clock,
|
|
1250
|
+
op.stamp.hlc,
|
|
1251
|
+
op.stamp.deviceId,
|
|
1252
|
+
opId,
|
|
1253
|
+
now,
|
|
1254
|
+
now
|
|
1255
|
+
);
|
|
1256
|
+
applied = true;
|
|
1257
|
+
}
|
|
1258
|
+
} else if (incomingWinsRevision(incomingRev, existing)) {
|
|
785
1259
|
raw.prepare(
|
|
786
1260
|
`UPDATE ${materializedTable}
|
|
787
1261
|
SET data_json = ?, clock = ?, hlc = ?, device_id = ?, op_id = ?, deleted = 0, updated_at = ?
|
|
@@ -797,6 +1271,8 @@ export class SqliteSyncGatewayAdapter {
|
|
|
797
1271
|
workspaceId
|
|
798
1272
|
);
|
|
799
1273
|
applied = true;
|
|
1274
|
+
} else {
|
|
1275
|
+
winnerPayload = JSON.parse(existing.data_json);
|
|
800
1276
|
}
|
|
801
1277
|
const emission = resolveHookEmission({
|
|
802
1278
|
op,
|
|
@@ -809,9 +1285,19 @@ export class SqliteSyncGatewayAdapter {
|
|
|
809
1285
|
if (emission) {
|
|
810
1286
|
hookEmissions.push(emission);
|
|
811
1287
|
}
|
|
1288
|
+
uniqueResults.push({
|
|
1289
|
+
opId,
|
|
1290
|
+
success: true,
|
|
1291
|
+
serverVersion,
|
|
1292
|
+
applied,
|
|
1293
|
+
...applied ? {} : {
|
|
1294
|
+
payload: winnerPayload,
|
|
1295
|
+
winner: currentWinnerNative(raw, workspaceId, op.tableName, pkValue)
|
|
1296
|
+
}
|
|
1297
|
+
});
|
|
812
1298
|
} else if (op.operation === "delete") {
|
|
813
1299
|
const existing = raw.prepare(
|
|
814
|
-
`SELECT clock, hlc FROM ${materializedTable} WHERE id = ? AND workspace_id = ?`
|
|
1300
|
+
`SELECT clock, hlc, op_id, data_json FROM ${materializedTable} WHERE id = ? AND workspace_id = ?`
|
|
815
1301
|
).get(pkValue, workspaceId);
|
|
816
1302
|
let applied = false;
|
|
817
1303
|
if (!existing) {
|
|
@@ -829,12 +1315,7 @@ export class SqliteSyncGatewayAdapter {
|
|
|
829
1315
|
now
|
|
830
1316
|
);
|
|
831
1317
|
applied = true;
|
|
832
|
-
} else if (
|
|
833
|
-
op.stamp.clock,
|
|
834
|
-
op.stamp.hlc,
|
|
835
|
-
existing.clock,
|
|
836
|
-
existing.hlc
|
|
837
|
-
)) {
|
|
1318
|
+
} else if (incomingWinsRevision(incomingRev, existing)) {
|
|
838
1319
|
raw.prepare(
|
|
839
1320
|
`UPDATE ${materializedTable}
|
|
840
1321
|
SET deleted = 1, clock = ?, hlc = ?, device_id = ?, op_id = ?, updated_at = ?
|
|
@@ -859,11 +1340,7 @@ export class SqliteSyncGatewayAdapter {
|
|
|
859
1340
|
hlc = excluded.hlc,
|
|
860
1341
|
op_id = excluded.op_id,
|
|
861
1342
|
server_version = excluded.server_version
|
|
862
|
-
WHERE
|
|
863
|
-
OR (
|
|
864
|
-
excluded.clock = tombstones.clock
|
|
865
|
-
AND excluded.server_version > tombstones.server_version
|
|
866
|
-
)`
|
|
1343
|
+
WHERE ${lwwExcludedWins("tombstones")}`
|
|
867
1344
|
).run(
|
|
868
1345
|
uid(),
|
|
869
1346
|
workspaceId,
|
|
@@ -887,12 +1364,17 @@ export class SqliteSyncGatewayAdapter {
|
|
|
887
1364
|
if (emission) {
|
|
888
1365
|
hookEmissions.push(emission);
|
|
889
1366
|
}
|
|
1367
|
+
uniqueResults.push({
|
|
1368
|
+
opId,
|
|
1369
|
+
success: true,
|
|
1370
|
+
serverVersion,
|
|
1371
|
+
applied,
|
|
1372
|
+
...applied || !existing ? {} : { payload: JSON.parse(existing.data_json) },
|
|
1373
|
+
...applied ? {} : {
|
|
1374
|
+
winner: currentWinnerNative(raw, workspaceId, op.tableName, pkValue)
|
|
1375
|
+
}
|
|
1376
|
+
});
|
|
890
1377
|
}
|
|
891
|
-
uniqueResults.push({
|
|
892
|
-
opId,
|
|
893
|
-
success: true,
|
|
894
|
-
serverVersion
|
|
895
|
-
});
|
|
896
1378
|
}
|
|
897
1379
|
});
|
|
898
1380
|
runTx.immediate();
|
|
@@ -935,17 +1417,32 @@ export class SqliteSyncGatewayAdapter {
|
|
|
935
1417
|
deviceId: row.device_id,
|
|
936
1418
|
opId: row.op_id
|
|
937
1419
|
}
|
|
938
|
-
}))
|
|
1420
|
+
})).filter(
|
|
1421
|
+
(change) => isNotificationVisibleToUser(change, resolveSessionUserId(event))
|
|
1422
|
+
);
|
|
939
1423
|
const lastRow = resultRows[resultRows.length - 1];
|
|
940
1424
|
const nextCursor = lastRow ? lastRow.server_version : cursor;
|
|
941
|
-
|
|
1425
|
+
const minLog = await db.selectFrom("change_log").select("server_version").where("workspace_id", "=", scope.workspaceId).orderBy("server_version", "asc").limit(1).executeTakeFirst();
|
|
1426
|
+
const counter = await db.selectFrom("server_version_counter").select("value").where("workspace_id", "=", scope.workspaceId).executeTakeFirst();
|
|
1427
|
+
const retention = computePullRetention({
|
|
1428
|
+
cursor,
|
|
1429
|
+
oldestLogVersion: minLog?.server_version ?? null,
|
|
1430
|
+
highWatermark: counter?.value ?? 0
|
|
1431
|
+
});
|
|
1432
|
+
return {
|
|
1433
|
+
changes,
|
|
1434
|
+
nextCursor,
|
|
1435
|
+
hasMore,
|
|
1436
|
+
...retention
|
|
1437
|
+
};
|
|
942
1438
|
}
|
|
943
1439
|
async snapshot(event, input) {
|
|
944
1440
|
const workspaceId = input.scope.workspaceId;
|
|
945
1441
|
await assertWorkspaceScopeAuthorized(event, workspaceId);
|
|
946
1442
|
const pageSize = resolveSnapshotPageSize(input.pageSize);
|
|
1443
|
+
const snapshotUserId = resolveSessionUserId(event);
|
|
947
1444
|
if (isD1Driver()) {
|
|
948
|
-
return this.snapshotInD1(workspaceId, input, pageSize);
|
|
1445
|
+
return this.snapshotInD1(workspaceId, input, pageSize, snapshotUserId);
|
|
949
1446
|
}
|
|
950
1447
|
const raw = getRawDb();
|
|
951
1448
|
const now = nowEpoch();
|
|
@@ -1015,6 +1512,11 @@ export class SqliteSyncGatewayAdapter {
|
|
|
1015
1512
|
if (!materializedTable) {
|
|
1016
1513
|
throw new Error(`Missing materialized table for ${tableName}`);
|
|
1017
1514
|
}
|
|
1515
|
+
const liveOwner = snapshotNotificationOwnerClause(
|
|
1516
|
+
tableName,
|
|
1517
|
+
"data_json",
|
|
1518
|
+
snapshotUserId
|
|
1519
|
+
);
|
|
1018
1520
|
raw.prepare(`
|
|
1019
1521
|
INSERT INTO sync_snapshot_items (
|
|
1020
1522
|
snapshot_id, table_name, pk, kind, payload_json,
|
|
@@ -1030,8 +1532,13 @@ export class SqliteSyncGatewayAdapter {
|
|
|
1030
1532
|
END,
|
|
1031
1533
|
NULL
|
|
1032
1534
|
FROM "${materializedTable}"
|
|
1033
|
-
WHERE workspace_id = ? AND deleted = 0
|
|
1034
|
-
`).run(snapshotId, tableName, workspaceId);
|
|
1535
|
+
WHERE workspace_id = ? AND deleted = 0${liveOwner.sql}
|
|
1536
|
+
`).run(snapshotId, tableName, workspaceId, ...liveOwner.params);
|
|
1537
|
+
const deletedOwner = snapshotNotificationOwnerClause(
|
|
1538
|
+
tableName,
|
|
1539
|
+
"materialized.data_json",
|
|
1540
|
+
snapshotUserId
|
|
1541
|
+
);
|
|
1035
1542
|
raw.prepare(`
|
|
1036
1543
|
INSERT INTO sync_snapshot_items (
|
|
1037
1544
|
snapshot_id, table_name, pk, kind, payload_json,
|
|
@@ -1062,8 +1569,9 @@ export class SqliteSyncGatewayAdapter {
|
|
|
1062
1569
|
0
|
|
1063
1570
|
))
|
|
1064
1571
|
FROM "${materializedTable}" AS materialized
|
|
1065
|
-
WHERE materialized.workspace_id = ? AND materialized.deleted <> 0
|
|
1066
|
-
`).run(snapshotId, tableName, tableName, workspaceId);
|
|
1572
|
+
WHERE materialized.workspace_id = ? AND materialized.deleted <> 0${deletedOwner.sql}
|
|
1573
|
+
`).run(snapshotId, tableName, tableName, workspaceId, ...deletedOwner.params);
|
|
1574
|
+
const orphanFilter = tableName === "notifications" ? " AND 1 = 0" : "";
|
|
1067
1575
|
raw.prepare(`
|
|
1068
1576
|
INSERT INTO sync_snapshot_items (
|
|
1069
1577
|
snapshot_id, table_name, pk, kind, payload_json,
|
|
@@ -1086,7 +1594,7 @@ export class SqliteSyncGatewayAdapter {
|
|
|
1086
1594
|
FROM "${materializedTable}" AS materialized
|
|
1087
1595
|
WHERE materialized.workspace_id = tombstone.workspace_id
|
|
1088
1596
|
AND materialized.id = tombstone.pk
|
|
1089
|
-
)
|
|
1597
|
+
)${orphanFilter}
|
|
1090
1598
|
`).run(snapshotId, tableName, workspaceId, tableName);
|
|
1091
1599
|
}
|
|
1092
1600
|
return {
|
|
@@ -1400,7 +1908,7 @@ export class SqliteSyncGatewayAdapter {
|
|
|
1400
1908
|
}
|
|
1401
1909
|
]);
|
|
1402
1910
|
}
|
|
1403
|
-
async snapshotInD1(workspaceId, input, pageSize) {
|
|
1911
|
+
async snapshotInD1(workspaceId, input, pageSize, snapshotUserId) {
|
|
1404
1912
|
const now = nowEpoch();
|
|
1405
1913
|
let header;
|
|
1406
1914
|
let after;
|
|
@@ -1466,6 +1974,17 @@ export class SqliteSyncGatewayAdapter {
|
|
|
1466
1974
|
if (!materializedTable) {
|
|
1467
1975
|
throw new Error(`Missing materialized table for ${tableName}`);
|
|
1468
1976
|
}
|
|
1977
|
+
const liveOwner = snapshotNotificationOwnerClause(
|
|
1978
|
+
tableName,
|
|
1979
|
+
"data_json",
|
|
1980
|
+
snapshotUserId
|
|
1981
|
+
);
|
|
1982
|
+
const deletedOwner = snapshotNotificationOwnerClause(
|
|
1983
|
+
tableName,
|
|
1984
|
+
"materialized.data_json",
|
|
1985
|
+
snapshotUserId
|
|
1986
|
+
);
|
|
1987
|
+
const orphanFilter = tableName === "notifications" ? " AND 1 = 0" : "";
|
|
1469
1988
|
statements.push(
|
|
1470
1989
|
{
|
|
1471
1990
|
sql: `INSERT INTO sync_snapshot_items (
|
|
@@ -1482,8 +2001,8 @@ export class SqliteSyncGatewayAdapter {
|
|
|
1482
2001
|
END,
|
|
1483
2002
|
NULL
|
|
1484
2003
|
FROM "${materializedTable}"
|
|
1485
|
-
WHERE workspace_id = ? AND deleted = 0`,
|
|
1486
|
-
parameters: [snapshotId, tableName, workspaceId]
|
|
2004
|
+
WHERE workspace_id = ? AND deleted = 0${liveOwner.sql}`,
|
|
2005
|
+
parameters: [snapshotId, tableName, workspaceId, ...liveOwner.params]
|
|
1487
2006
|
},
|
|
1488
2007
|
{
|
|
1489
2008
|
sql: `INSERT INTO sync_snapshot_items (
|
|
@@ -1515,8 +2034,14 @@ export class SqliteSyncGatewayAdapter {
|
|
|
1515
2034
|
0
|
|
1516
2035
|
))
|
|
1517
2036
|
FROM "${materializedTable}" AS materialized
|
|
1518
|
-
WHERE materialized.workspace_id = ? AND materialized.deleted <> 0`,
|
|
1519
|
-
parameters: [
|
|
2037
|
+
WHERE materialized.workspace_id = ? AND materialized.deleted <> 0${deletedOwner.sql}`,
|
|
2038
|
+
parameters: [
|
|
2039
|
+
snapshotId,
|
|
2040
|
+
tableName,
|
|
2041
|
+
tableName,
|
|
2042
|
+
workspaceId,
|
|
2043
|
+
...deletedOwner.params
|
|
2044
|
+
]
|
|
1520
2045
|
},
|
|
1521
2046
|
{
|
|
1522
2047
|
sql: `INSERT INTO sync_snapshot_items (
|
|
@@ -1540,7 +2065,7 @@ export class SqliteSyncGatewayAdapter {
|
|
|
1540
2065
|
FROM "${materializedTable}" AS materialized
|
|
1541
2066
|
WHERE materialized.workspace_id = tombstone.workspace_id
|
|
1542
2067
|
AND materialized.id = tombstone.pk
|
|
1543
|
-
)`,
|
|
2068
|
+
)${orphanFilter}`,
|
|
1544
2069
|
parameters: [snapshotId, tableName, workspaceId, tableName]
|
|
1545
2070
|
}
|
|
1546
2071
|
);
|
|
@@ -1663,7 +2188,7 @@ export class SqliteSyncGatewayAdapter {
|
|
|
1663
2188
|
}
|
|
1664
2189
|
}
|
|
1665
2190
|
async pushInD1(input) {
|
|
1666
|
-
const {
|
|
2191
|
+
const { workspaceId, uniqueOps, resultSlots, indicesByOpId, userId } = input;
|
|
1667
2192
|
const existingOps = /* @__PURE__ */ new Map();
|
|
1668
2193
|
const opIds = uniqueOps.map((op) => op.stamp.opId);
|
|
1669
2194
|
for (let offset = 0; offset < opIds.length; offset += 500) {
|
|
@@ -1671,12 +2196,61 @@ export class SqliteSyncGatewayAdapter {
|
|
|
1671
2196
|
if (chunk.length === 0) continue;
|
|
1672
2197
|
const placeholders = chunk.map(() => "?").join(",");
|
|
1673
2198
|
const rows = await d1All(
|
|
1674
|
-
`SELECT op_id, server_version
|
|
2199
|
+
`SELECT op_id, server_version, workspace_id, table_name, pk, op,
|
|
2200
|
+
payload_json, clock, hlc, device_id
|
|
2201
|
+
FROM change_log WHERE op_id IN (${placeholders})`,
|
|
1675
2202
|
...chunk
|
|
1676
2203
|
);
|
|
1677
|
-
for (const row of rows)
|
|
2204
|
+
for (const row of rows) {
|
|
2205
|
+
existingOps.set(row.op_id, {
|
|
2206
|
+
server_version: row.server_version,
|
|
2207
|
+
workspace_id: row.workspace_id,
|
|
2208
|
+
fingerprint: changeLogFingerprint(row)
|
|
2209
|
+
});
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
const rejected = /* @__PURE__ */ new Set();
|
|
2213
|
+
for (const op of uniqueOps) {
|
|
2214
|
+
const existing = existingOps.get(op.stamp.opId);
|
|
2215
|
+
if (!existing) continue;
|
|
2216
|
+
if (existing.workspace_id !== workspaceId || existing.fingerprint !== operationFingerprint(op)) {
|
|
2217
|
+
rejected.add(op.stamp.opId);
|
|
2218
|
+
for (const index of indicesByOpId.get(op.stamp.opId) ?? []) {
|
|
2219
|
+
resultSlots[index] = {
|
|
2220
|
+
opId: op.stamp.opId,
|
|
2221
|
+
success: false,
|
|
2222
|
+
error: `Conflicting operation reuses processed op_id ${op.stamp.opId}`,
|
|
2223
|
+
errorCode: "CONFLICT"
|
|
2224
|
+
};
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
for (const op of uniqueOps) {
|
|
2229
|
+
if (op.tableName !== "notifications" || op.operation !== "delete") continue;
|
|
2230
|
+
if (existingOps.has(op.stamp.opId) || rejected.has(op.stamp.opId)) continue;
|
|
2231
|
+
const materializedTable = SYNCED_TABLE_MAP[op.tableName];
|
|
2232
|
+
if (!materializedTable) continue;
|
|
2233
|
+
const [owned] = await d1All(
|
|
2234
|
+
`SELECT data_json FROM "${materializedTable}" WHERE id = ? AND workspace_id = ?`,
|
|
2235
|
+
op.pk,
|
|
2236
|
+
workspaceId
|
|
2237
|
+
);
|
|
2238
|
+
const owner = owned ? notificationPayloadUserId(JSON.parse(owned.data_json)) : void 0;
|
|
2239
|
+
if (!owned || owner !== userId) {
|
|
2240
|
+
rejected.add(op.stamp.opId);
|
|
2241
|
+
for (const index of indicesByOpId.get(op.stamp.opId) ?? []) {
|
|
2242
|
+
resultSlots[index] = {
|
|
2243
|
+
opId: op.stamp.opId,
|
|
2244
|
+
success: false,
|
|
2245
|
+
error: "Forbidden: notification owner mismatch",
|
|
2246
|
+
errorCode: "UNAUTHORIZED"
|
|
2247
|
+
};
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
1678
2250
|
}
|
|
1679
|
-
const newOps = uniqueOps.filter(
|
|
2251
|
+
const newOps = uniqueOps.filter(
|
|
2252
|
+
(op) => !existingOps.has(op.stamp.opId) && !rejected.has(op.stamp.opId)
|
|
2253
|
+
);
|
|
1680
2254
|
const versionOffsets = /* @__PURE__ */ new Map();
|
|
1681
2255
|
newOps.forEach((op, index) => {
|
|
1682
2256
|
versionOffsets.set(op.stamp.opId, index + 1);
|
|
@@ -1707,8 +2281,32 @@ export class SqliteSyncGatewayAdapter {
|
|
|
1707
2281
|
}
|
|
1708
2282
|
}
|
|
1709
2283
|
}
|
|
2284
|
+
const skipMaterialize = /* @__PURE__ */ new Set();
|
|
2285
|
+
for (const op of newOps) {
|
|
2286
|
+
if (op.operation !== "put") continue;
|
|
2287
|
+
const materializedTable = SYNCED_TABLE_MAP[op.tableName];
|
|
2288
|
+
if (!materializedTable) continue;
|
|
2289
|
+
const materializedKey = `${materializedTable}\0${op.pk}`;
|
|
2290
|
+
if (existingMaterializedKeys.has(materializedKey)) continue;
|
|
2291
|
+
const [tombstone] = await d1All(
|
|
2292
|
+
`SELECT clock, hlc, op_id FROM tombstones
|
|
2293
|
+
WHERE workspace_id = ? AND table_name = ? AND pk = ?`,
|
|
2294
|
+
workspaceId,
|
|
2295
|
+
op.tableName,
|
|
2296
|
+
op.pk
|
|
2297
|
+
);
|
|
2298
|
+
if (tombstone && !incomingWinsRevision(
|
|
2299
|
+
{
|
|
2300
|
+
clock: op.stamp.clock,
|
|
2301
|
+
hlc: op.stamp.hlc,
|
|
2302
|
+
opId: op.stamp.opId
|
|
2303
|
+
},
|
|
2304
|
+
tombstone
|
|
2305
|
+
)) {
|
|
2306
|
+
skipMaterialize.add(op.stamp.opId);
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
1710
2309
|
const now = nowEpoch();
|
|
1711
|
-
const userId = resolveSessionUserId(event);
|
|
1712
2310
|
const statements = [];
|
|
1713
2311
|
if (newOps.length > 0) {
|
|
1714
2312
|
statements.push(
|
|
@@ -1739,7 +2337,7 @@ export class SqliteSyncGatewayAdapter {
|
|
|
1739
2337
|
);
|
|
1740
2338
|
seenMaterializedKeys.add(materializedKey);
|
|
1741
2339
|
statements.push({
|
|
1742
|
-
sql: `INSERT
|
|
2340
|
+
sql: `INSERT INTO change_log (
|
|
1743
2341
|
id, workspace_id, server_version, table_name, pk, op,
|
|
1744
2342
|
payload_json, clock, hlc, device_id, op_id, created_at
|
|
1745
2343
|
) VALUES (
|
|
@@ -1764,6 +2362,7 @@ export class SqliteSyncGatewayAdapter {
|
|
|
1764
2362
|
now
|
|
1765
2363
|
]
|
|
1766
2364
|
});
|
|
2365
|
+
if (skipMaterialize.has(op.stamp.opId)) continue;
|
|
1767
2366
|
materializedStatementIndex.set(op.stamp.opId, statements.length);
|
|
1768
2367
|
if (op.operation === "put") {
|
|
1769
2368
|
statements.push({
|
|
@@ -1779,11 +2378,7 @@ export class SqliteSyncGatewayAdapter {
|
|
|
1779
2378
|
op_id = excluded.op_id,
|
|
1780
2379
|
deleted = 0,
|
|
1781
2380
|
updated_at = excluded.updated_at
|
|
1782
|
-
WHERE
|
|
1783
|
-
OR (
|
|
1784
|
-
excluded.clock = "${materializedTable}".clock
|
|
1785
|
-
AND excluded.hlc > "${materializedTable}".hlc
|
|
1786
|
-
)`,
|
|
2381
|
+
WHERE ${lwwExcludedWins(`"${materializedTable}"`)}`,
|
|
1787
2382
|
parameters: [
|
|
1788
2383
|
op.pk,
|
|
1789
2384
|
workspaceId,
|
|
@@ -1809,11 +2404,7 @@ export class SqliteSyncGatewayAdapter {
|
|
|
1809
2404
|
op_id = excluded.op_id,
|
|
1810
2405
|
deleted = 1,
|
|
1811
2406
|
updated_at = excluded.updated_at
|
|
1812
|
-
WHERE
|
|
1813
|
-
OR (
|
|
1814
|
-
excluded.clock = "${materializedTable}".clock
|
|
1815
|
-
AND excluded.hlc > "${materializedTable}".hlc
|
|
1816
|
-
)`,
|
|
2407
|
+
WHERE ${lwwExcludedWins(`"${materializedTable}"`)}`,
|
|
1817
2408
|
parameters: [
|
|
1818
2409
|
op.pk,
|
|
1819
2410
|
workspaceId,
|
|
@@ -1840,11 +2431,7 @@ export class SqliteSyncGatewayAdapter {
|
|
|
1840
2431
|
hlc = excluded.hlc,
|
|
1841
2432
|
op_id = excluded.op_id,
|
|
1842
2433
|
server_version = excluded.server_version
|
|
1843
|
-
WHERE
|
|
1844
|
-
OR (
|
|
1845
|
-
excluded.clock = tombstones.clock
|
|
1846
|
-
AND excluded.server_version > tombstones.server_version
|
|
1847
|
-
)`,
|
|
2434
|
+
WHERE ${lwwExcludedWins("tombstones")}`,
|
|
1848
2435
|
parameters: [
|
|
1849
2436
|
uid(),
|
|
1850
2437
|
workspaceId,
|
|
@@ -1897,20 +2484,59 @@ export class SqliteSyncGatewayAdapter {
|
|
|
1897
2484
|
}
|
|
1898
2485
|
const emissions = [];
|
|
1899
2486
|
for (const op of uniqueOps) {
|
|
2487
|
+
if (rejected.has(op.stamp.opId)) continue;
|
|
2488
|
+
const existing = existingOps.get(op.stamp.opId);
|
|
2489
|
+
if (existing) {
|
|
2490
|
+
const winner = await currentWinnerD1(workspaceId, op.tableName, op.pk);
|
|
2491
|
+
const applied2 = winner?.revision.opId === op.stamp.opId;
|
|
2492
|
+
const result2 = {
|
|
2493
|
+
opId: op.stamp.opId,
|
|
2494
|
+
success: true,
|
|
2495
|
+
serverVersion: existing.server_version,
|
|
2496
|
+
replayed: true,
|
|
2497
|
+
applied: applied2,
|
|
2498
|
+
...applied2 ? {} : { winner }
|
|
2499
|
+
};
|
|
2500
|
+
for (const index of indicesByOpId.get(op.stamp.opId) ?? []) {
|
|
2501
|
+
resultSlots[index] = result2;
|
|
2502
|
+
}
|
|
2503
|
+
continue;
|
|
2504
|
+
}
|
|
1900
2505
|
const serverVersion = resolvedOps.get(op.stamp.opId);
|
|
1901
2506
|
if (serverVersion === void 0) {
|
|
1902
2507
|
throw new Error(`D1 failed to resolve sync operation ${op.stamp.opId}.`);
|
|
1903
2508
|
}
|
|
2509
|
+
const skipApply = skipMaterialize.has(op.stamp.opId);
|
|
2510
|
+
const materializedResult = batchResults[materializedStatementIndex.get(op.stamp.opId) ?? -1];
|
|
2511
|
+
const applied = !skipApply && (materializedResult?.meta?.changes ?? 0) > 0;
|
|
2512
|
+
let winnerPayload;
|
|
2513
|
+
if (!applied && !skipApply) {
|
|
2514
|
+
const materializedTable = SYNCED_TABLE_MAP[op.tableName];
|
|
2515
|
+
if (materializedTable) {
|
|
2516
|
+
const [winner] = await d1All(
|
|
2517
|
+
`SELECT data_json FROM "${materializedTable}" WHERE id = ? AND workspace_id = ?`,
|
|
2518
|
+
op.pk,
|
|
2519
|
+
workspaceId
|
|
2520
|
+
);
|
|
2521
|
+
if (winner?.data_json) {
|
|
2522
|
+
winnerPayload = JSON.parse(winner.data_json);
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
1904
2526
|
const result = {
|
|
1905
2527
|
opId: op.stamp.opId,
|
|
1906
2528
|
success: true,
|
|
1907
|
-
serverVersion
|
|
2529
|
+
serverVersion,
|
|
2530
|
+
applied,
|
|
2531
|
+
...applied ? {} : { payload: winnerPayload },
|
|
2532
|
+
...applied ? {} : {
|
|
2533
|
+
winner: await currentWinnerD1(workspaceId, op.tableName, op.pk)
|
|
2534
|
+
}
|
|
1908
2535
|
};
|
|
1909
2536
|
for (const index of indicesByOpId.get(op.stamp.opId) ?? []) {
|
|
1910
2537
|
resultSlots[index] = result;
|
|
1911
2538
|
}
|
|
1912
|
-
|
|
1913
|
-
if ((materializedResult?.meta?.changes ?? 0) > 0) {
|
|
2539
|
+
if (applied) {
|
|
1914
2540
|
const emission = resolveHookEmission({
|
|
1915
2541
|
op,
|
|
1916
2542
|
workspaceId,
|