or3-provider-sqlite 0.0.8 → 0.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -5
- package/dist/module.json +1 -1
- package/dist/runtime/server/background-jobs/sqlite-provider.d.ts +27 -0
- package/dist/runtime/server/background-jobs/sqlite-provider.js +536 -0
- package/dist/runtime/server/db/migrate.js +13 -2
- package/dist/runtime/server/db/migrations/017_background_jobs.d.ts +4 -0
- package/dist/runtime/server/db/migrations/017_background_jobs.js +42 -0
- package/dist/runtime/server/db/migrations/018_background_admission_cancels.d.ts +7 -0
- package/dist/runtime/server/db/migrations/018_background_admission_cancels.js +18 -0
- package/dist/runtime/server/db/migrations/019_background_generation_history.d.ts +7 -0
- package/dist/runtime/server/db/migrations/019_background_generation_history.js +25 -0
- package/dist/runtime/server/db/migrations/020_background_generation_receipts.d.ts +4 -0
- package/dist/runtime/server/db/migrations/020_background_generation_receipts.js +19 -0
- package/dist/runtime/server/db/schema.d.ts +38 -0
- package/dist/runtime/server/plugins/register.js +8 -1
- package/dist/runtime/server/sync/history-gc-policy.d.ts +8 -0
- package/dist/runtime/server/sync/history-gc-policy.js +7 -0
- package/dist/runtime/server/sync/sqlite-sync-gateway-adapter.d.ts +13 -1
- package/dist/runtime/server/sync/sqlite-sync-gateway-adapter.js +669 -96
- package/package.json +1 -1
|
@@ -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,305 @@ 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
|
+
});
|
|
299
371
|
}
|
|
300
372
|
export class SqliteSyncGatewayAdapter {
|
|
301
373
|
id = "sqlite";
|
|
302
|
-
capabilities
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
374
|
+
get capabilities() {
|
|
375
|
+
return isD1Driver() ? {
|
|
376
|
+
snapshotBootstrap: "snapshot-v1",
|
|
377
|
+
historyRetention: "snapshot-v1"
|
|
378
|
+
} : {
|
|
379
|
+
snapshotBootstrap: "snapshot-v1",
|
|
380
|
+
historyRetention: "snapshot-v1",
|
|
381
|
+
backgroundGenerationHistory: "v1"
|
|
382
|
+
};
|
|
383
|
+
}
|
|
306
384
|
get db() {
|
|
307
385
|
return getSqliteDb();
|
|
308
386
|
}
|
|
387
|
+
assertCanonicalActor(actor) {
|
|
388
|
+
if (isD1Driver()) {
|
|
389
|
+
throw new Error("SQLite D1 does not support background generation history v1");
|
|
390
|
+
}
|
|
391
|
+
const membership = getRawDb().prepare(
|
|
392
|
+
`SELECT role FROM workspace_members
|
|
393
|
+
WHERE workspace_id = ? AND user_id = ?`
|
|
394
|
+
).get(actor.workspaceId, actor.userId);
|
|
395
|
+
if (!membership || membership.role !== "owner" && membership.role !== "editor") {
|
|
396
|
+
throw new Error("Forbidden background history actor");
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
async admitChatGeneration(actor, input) {
|
|
400
|
+
const admission = parseChatGenerationAdmissionEnvelope(input);
|
|
401
|
+
if (actor.workspaceId !== admission.workspaceId) {
|
|
402
|
+
throw new Error("Invalid background history workspace");
|
|
403
|
+
}
|
|
404
|
+
const raw = getRawDb();
|
|
405
|
+
const fingerprint = createHash("sha256").update(stableJson(admission)).digest("hex");
|
|
406
|
+
return raw.transaction(() => {
|
|
407
|
+
this.assertCanonicalActor(actor);
|
|
408
|
+
const receipt = raw.prepare(
|
|
409
|
+
`SELECT fingerprint, outcome, server_version
|
|
410
|
+
FROM background_generation_receipts
|
|
411
|
+
WHERE workspace_id = ? AND generation_id = ? AND stage = 'admission'`
|
|
412
|
+
).get(admission.workspaceId, admission.generationId);
|
|
413
|
+
if (receipt) {
|
|
414
|
+
if (receipt.fingerprint !== fingerprint) {
|
|
415
|
+
throw new Error("Conflicting background admission replay");
|
|
416
|
+
}
|
|
417
|
+
return {
|
|
418
|
+
status: "admitted",
|
|
419
|
+
replayed: true,
|
|
420
|
+
serverVersion: receipt.server_version ?? 0
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
if (admission.kind === "continuation") {
|
|
424
|
+
const row = raw.prepare(
|
|
425
|
+
`SELECT data_json, clock, deleted FROM s_messages
|
|
426
|
+
WHERE workspace_id = ? AND id = ?`
|
|
427
|
+
).get(admission.workspaceId, admission.messageId);
|
|
428
|
+
const generationId = row ? JSON.parse(row.data_json).data?.generation_id : void 0;
|
|
429
|
+
const expectedClock = admission.expectedAssistant?.clock ?? -1;
|
|
430
|
+
const admissionClock = admission.assistantMessage.clock;
|
|
431
|
+
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) {
|
|
432
|
+
throw new Error("Conflicting continuation admission");
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
const records = [
|
|
436
|
+
admission.thread ? { table: "threads", materialized: "s_threads", record: admission.thread } : null,
|
|
437
|
+
admission.userMessage ? { table: "messages", materialized: "s_messages", record: admission.userMessage } : null,
|
|
438
|
+
{ table: "messages", materialized: "s_messages", record: admission.assistantMessage }
|
|
439
|
+
].filter(Boolean);
|
|
440
|
+
const counter = raw.prepare("SELECT value FROM server_version_counter WHERE workspace_id = ?").get(admission.workspaceId);
|
|
441
|
+
const baseVersion = counter?.value ?? 0;
|
|
442
|
+
const finalVersion = baseVersion + records.length;
|
|
443
|
+
raw.prepare(
|
|
444
|
+
`INSERT INTO server_version_counter (workspace_id, value) VALUES (?, ?)
|
|
445
|
+
ON CONFLICT(workspace_id) DO UPDATE SET value = excluded.value`
|
|
446
|
+
).run(admission.workspaceId, finalVersion);
|
|
447
|
+
const now = nowEpoch();
|
|
448
|
+
records.forEach((entry, index) => {
|
|
449
|
+
const opId = backgroundHistoryOpId(
|
|
450
|
+
admission,
|
|
451
|
+
entry.table === "threads" ? "thread" : entry.record.id === admission.messageId ? "assistant" : "user"
|
|
452
|
+
);
|
|
453
|
+
const hlc = entry.record.hlc ?? `${Date.now().toString(36).padStart(9, "0")}:000:background`;
|
|
454
|
+
const tombstone = raw.prepare(
|
|
455
|
+
`SELECT clock, hlc, op_id FROM tombstones
|
|
456
|
+
WHERE workspace_id = ? AND table_name = ? AND pk = ?`
|
|
457
|
+
).get(
|
|
458
|
+
admission.workspaceId,
|
|
459
|
+
entry.table,
|
|
460
|
+
entry.record.id
|
|
461
|
+
);
|
|
462
|
+
if (tombstone && !incomingWinsRevision(
|
|
463
|
+
{ clock: entry.record.clock, hlc, opId },
|
|
464
|
+
tombstone
|
|
465
|
+
)) {
|
|
466
|
+
throw new Error("Background history record was superseded");
|
|
467
|
+
}
|
|
468
|
+
const sanitized = sanitizePayloadForSync(
|
|
469
|
+
entry.table,
|
|
470
|
+
entry.record,
|
|
471
|
+
"put"
|
|
472
|
+
);
|
|
473
|
+
if (!sanitized) {
|
|
474
|
+
throw new Error("Invalid background history payload");
|
|
475
|
+
}
|
|
476
|
+
const payload = { ...sanitized, hlc, op_id: opId };
|
|
477
|
+
const payloadJson = JSON.stringify(payload);
|
|
478
|
+
if (new TextEncoder().encode(payloadJson).byteLength > MAX_SYNC_PAYLOAD_BYTES) {
|
|
479
|
+
throw new Error("Invalid background history payload size");
|
|
480
|
+
}
|
|
481
|
+
const version = baseVersion + index + 1;
|
|
482
|
+
raw.prepare(
|
|
483
|
+
`INSERT INTO change_log
|
|
484
|
+
(id, workspace_id, server_version, table_name, pk, op, payload_json,
|
|
485
|
+
clock, hlc, device_id, op_id, created_at)
|
|
486
|
+
VALUES (?, ?, ?, ?, ?, 'put', ?, ?, ?, ?, ?, ?)`
|
|
487
|
+
).run(
|
|
488
|
+
uid(),
|
|
489
|
+
admission.workspaceId,
|
|
490
|
+
version,
|
|
491
|
+
entry.table,
|
|
492
|
+
entry.record.id,
|
|
493
|
+
payloadJson,
|
|
494
|
+
entry.record.clock,
|
|
495
|
+
hlc,
|
|
496
|
+
backgroundHistoryDeviceId(admission.generationId),
|
|
497
|
+
opId,
|
|
498
|
+
now
|
|
499
|
+
);
|
|
500
|
+
raw.prepare(
|
|
501
|
+
`INSERT INTO ${entry.materialized}
|
|
502
|
+
(id, workspace_id, data_json, clock, hlc, device_id, op_id,
|
|
503
|
+
deleted, created_at, updated_at)
|
|
504
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
|
|
505
|
+
ON CONFLICT(workspace_id, id) DO UPDATE SET
|
|
506
|
+
data_json = excluded.data_json, clock = excluded.clock,
|
|
507
|
+
hlc = excluded.hlc, device_id = excluded.device_id,
|
|
508
|
+
op_id = excluded.op_id, deleted = 0, updated_at = excluded.updated_at
|
|
509
|
+
WHERE ${lwwExcludedWins(entry.materialized)}`
|
|
510
|
+
).run(
|
|
511
|
+
entry.record.id,
|
|
512
|
+
admission.workspaceId,
|
|
513
|
+
payloadJson,
|
|
514
|
+
entry.record.clock,
|
|
515
|
+
hlc,
|
|
516
|
+
backgroundHistoryDeviceId(admission.generationId),
|
|
517
|
+
opId,
|
|
518
|
+
now,
|
|
519
|
+
now
|
|
520
|
+
);
|
|
521
|
+
});
|
|
522
|
+
const admittedAssistant = raw.prepare(
|
|
523
|
+
`SELECT data_json, clock, deleted FROM s_messages
|
|
524
|
+
WHERE workspace_id = ? AND id = ?`
|
|
525
|
+
).get(admission.workspaceId, admission.messageId);
|
|
526
|
+
const admittedPayload = admittedAssistant ? JSON.parse(admittedAssistant.data_json) : null;
|
|
527
|
+
const admittedData = admittedPayload?.data && typeof admittedPayload.data === "object" ? admittedPayload.data : {};
|
|
528
|
+
if (!admittedAssistant || admittedAssistant.deleted === 1 || admittedAssistant.clock !== admission.assistantMessage.clock || admittedData.generation_id !== admission.generationId) {
|
|
529
|
+
throw new Error("Background assistant admission was superseded");
|
|
530
|
+
}
|
|
531
|
+
raw.prepare(
|
|
532
|
+
`INSERT INTO background_generation_receipts
|
|
533
|
+
(id, workspace_id, generation_id, stage, fingerprint, outcome,
|
|
534
|
+
server_version, created_at)
|
|
535
|
+
VALUES (?, ?, ?, 'admission', ?, 'admitted', ?, ?)`
|
|
536
|
+
).run(uid(), admission.workspaceId, admission.generationId, fingerprint, finalVersion, now);
|
|
537
|
+
return { status: "admitted", replayed: false, serverVersion: finalVersion };
|
|
538
|
+
}).immediate();
|
|
539
|
+
}
|
|
540
|
+
async finalizeChatGeneration(actor, input) {
|
|
541
|
+
const admission = parseChatGenerationAdmissionEnvelope(input.admission);
|
|
542
|
+
if (actor.workspaceId !== admission.workspaceId) {
|
|
543
|
+
throw new Error("Invalid background history workspace");
|
|
544
|
+
}
|
|
545
|
+
const raw = getRawDb();
|
|
546
|
+
const fingerprint = createHash("sha256").update(stableJson(input)).digest("hex");
|
|
547
|
+
return raw.transaction(() => {
|
|
548
|
+
this.assertCanonicalActor(actor);
|
|
549
|
+
const receipt = raw.prepare(
|
|
550
|
+
`SELECT fingerprint, outcome, server_version
|
|
551
|
+
FROM background_generation_receipts
|
|
552
|
+
WHERE workspace_id = ? AND generation_id = ? AND stage = 'finalization'`
|
|
553
|
+
).get(admission.workspaceId, admission.generationId);
|
|
554
|
+
if (receipt) {
|
|
555
|
+
if (receipt.fingerprint !== fingerprint) {
|
|
556
|
+
throw new Error("Conflicting background finalization replay");
|
|
557
|
+
}
|
|
558
|
+
return receipt.outcome === "committed" ? {
|
|
559
|
+
status: "committed",
|
|
560
|
+
replayed: true,
|
|
561
|
+
serverVersion: receipt.server_version ?? 0
|
|
562
|
+
} : {
|
|
563
|
+
status: "superseded",
|
|
564
|
+
reason: receipt.outcome
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
const row = raw.prepare(
|
|
568
|
+
`SELECT data_json, clock, deleted FROM s_messages
|
|
569
|
+
WHERE workspace_id = ? AND id = ?`
|
|
570
|
+
).get(admission.workspaceId, admission.messageId);
|
|
571
|
+
const current = row ? JSON.parse(row.data_json) : null;
|
|
572
|
+
const data = current?.data && typeof current.data === "object" ? current.data : {};
|
|
573
|
+
let superseded = null;
|
|
574
|
+
if (!row || !current) superseded = "missing";
|
|
575
|
+
else if (row.deleted === 1 || current.deleted === true) superseded = "deleted";
|
|
576
|
+
else if (data.generation_id !== admission.generationId) superseded = "newer_generation";
|
|
577
|
+
else if (row.clock !== admission.assistantMessage.clock) superseded = "edited";
|
|
578
|
+
const now = nowEpoch();
|
|
579
|
+
if (superseded) {
|
|
580
|
+
raw.prepare(
|
|
581
|
+
`INSERT INTO background_generation_receipts
|
|
582
|
+
(id, workspace_id, generation_id, stage, fingerprint, outcome,
|
|
583
|
+
server_version, created_at)
|
|
584
|
+
VALUES (?, ?, ?, 'finalization', ?, ?, NULL, ?)`
|
|
585
|
+
).run(uid(), admission.workspaceId, admission.generationId, fingerprint, superseded, now);
|
|
586
|
+
return { status: "superseded", reason: superseded };
|
|
587
|
+
}
|
|
588
|
+
const nextClock = row.clock + 1;
|
|
589
|
+
const hlc = `${Date.now().toString(36).padStart(9, "0")}:000:background`;
|
|
590
|
+
const opId = backgroundHistoryOpId(admission, "finalize");
|
|
591
|
+
const terminalState = input.snapshot.status === "complete" ? "complete" : input.snapshot.status === "aborted" ? "aborted" : "failed";
|
|
592
|
+
const payload = {
|
|
593
|
+
...current,
|
|
594
|
+
pending: false,
|
|
595
|
+
error: input.snapshot.error ?? null,
|
|
596
|
+
updated_at: Math.floor(input.snapshot.completedAt / 1e3),
|
|
597
|
+
clock: nextClock,
|
|
598
|
+
hlc,
|
|
599
|
+
op_id: opId,
|
|
600
|
+
data: {
|
|
601
|
+
...data,
|
|
602
|
+
content: input.snapshot.content,
|
|
603
|
+
reasoning_text: input.snapshot.reasoning || null,
|
|
604
|
+
tool_calls: input.snapshot.toolCalls ?? null,
|
|
605
|
+
generation_state: terminalState,
|
|
606
|
+
background_job_status: input.snapshot.status,
|
|
607
|
+
background_job_error: input.snapshot.error ?? null,
|
|
608
|
+
error: input.snapshot.error ?? null
|
|
609
|
+
}
|
|
610
|
+
};
|
|
611
|
+
const payloadJson = JSON.stringify(payload);
|
|
612
|
+
if (new TextEncoder().encode(payloadJson).byteLength > MAX_SYNC_PAYLOAD_BYTES) {
|
|
613
|
+
throw new Error("Invalid background history payload size");
|
|
614
|
+
}
|
|
615
|
+
const counter = raw.prepare("SELECT value FROM server_version_counter WHERE workspace_id = ?").get(admission.workspaceId);
|
|
616
|
+
const serverVersion = (counter?.value ?? 0) + 1;
|
|
617
|
+
raw.prepare(
|
|
618
|
+
`INSERT INTO server_version_counter (workspace_id, value) VALUES (?, ?)
|
|
619
|
+
ON CONFLICT(workspace_id) DO UPDATE SET value = excluded.value`
|
|
620
|
+
).run(admission.workspaceId, serverVersion);
|
|
621
|
+
raw.prepare(
|
|
622
|
+
`UPDATE s_messages SET data_json = ?, clock = ?, hlc = ?, device_id = ?,
|
|
623
|
+
op_id = ?, deleted = 0, updated_at = ?
|
|
624
|
+
WHERE workspace_id = ? AND id = ?`
|
|
625
|
+
).run(
|
|
626
|
+
payloadJson,
|
|
627
|
+
nextClock,
|
|
628
|
+
hlc,
|
|
629
|
+
backgroundHistoryDeviceId(admission.generationId),
|
|
630
|
+
opId,
|
|
631
|
+
now,
|
|
632
|
+
admission.workspaceId,
|
|
633
|
+
admission.messageId
|
|
634
|
+
);
|
|
635
|
+
raw.prepare(
|
|
636
|
+
`INSERT INTO change_log
|
|
637
|
+
(id, workspace_id, server_version, table_name, pk, op, payload_json,
|
|
638
|
+
clock, hlc, device_id, op_id, created_at)
|
|
639
|
+
VALUES (?, ?, ?, 'messages', ?, 'put', ?, ?, ?, ?, ?, ?)`
|
|
640
|
+
).run(
|
|
641
|
+
uid(),
|
|
642
|
+
admission.workspaceId,
|
|
643
|
+
serverVersion,
|
|
644
|
+
admission.messageId,
|
|
645
|
+
payloadJson,
|
|
646
|
+
nextClock,
|
|
647
|
+
hlc,
|
|
648
|
+
backgroundHistoryDeviceId(admission.generationId),
|
|
649
|
+
opId,
|
|
650
|
+
now
|
|
651
|
+
);
|
|
652
|
+
raw.prepare(
|
|
653
|
+
`INSERT INTO background_generation_receipts
|
|
654
|
+
(id, workspace_id, generation_id, stage, fingerprint, outcome,
|
|
655
|
+
server_version, created_at)
|
|
656
|
+
VALUES (?, ?, ?, 'finalization', ?, 'committed', ?, ?)`
|
|
657
|
+
).run(uid(), admission.workspaceId, admission.generationId, fingerprint, serverVersion, now);
|
|
658
|
+
return { status: "committed", replayed: false, serverVersion };
|
|
659
|
+
}).immediate();
|
|
660
|
+
}
|
|
309
661
|
async reserveUploadIntent(event, input) {
|
|
310
662
|
await assertWorkspaceScopeAuthorized(event, input.workspaceId);
|
|
311
|
-
if (!input.intentId || !Number.isSafeInteger(input.sizeBytes) || input.sizeBytes <
|
|
663
|
+
if (!input.intentId || !Number.isSafeInteger(input.sizeBytes) || input.sizeBytes < 0 || !Number.isSafeInteger(input.expiresAt) || input.expiresAt <= nowEpoch()) {
|
|
312
664
|
throw createError({ statusCode: 400, statusMessage: "Invalid upload intent" });
|
|
313
665
|
}
|
|
314
666
|
if (input.workspaceQuotaBytes !== void 0 && (!Number.isSafeInteger(input.workspaceQuotaBytes) || input.workspaceQuotaBytes < 1)) {
|
|
@@ -497,11 +849,15 @@ export class SqliteSyncGatewayAdapter {
|
|
|
497
849
|
throw createError({ statusCode: 500, statusMessage: "Invalid canonical file size" });
|
|
498
850
|
}
|
|
499
851
|
const storageId = payload.storage_id ?? payload.storageId;
|
|
852
|
+
const fileKind = payload.kind === "image" ? "image" : payload.kind === "pdf" ? "pdf" : payload.kind === "file" ? "file" : void 0;
|
|
500
853
|
return {
|
|
501
854
|
kind: "metadata",
|
|
502
855
|
hash: normalizeStorageHash(row.id),
|
|
503
856
|
sizeBytes: size,
|
|
504
857
|
...typeof storageId === "string" && storageId ? { storageId } : {},
|
|
858
|
+
...typeof (payload.mime_type ?? payload.mimeType) === "string" ? { mimeType: String(payload.mime_type ?? payload.mimeType) } : {},
|
|
859
|
+
...typeof payload.name === "string" ? { name: payload.name } : {},
|
|
860
|
+
...fileKind ? { fileKind } : {},
|
|
505
861
|
updatedAt: row.updated_at
|
|
506
862
|
};
|
|
507
863
|
});
|
|
@@ -609,6 +965,7 @@ export class SqliteSyncGatewayAdapter {
|
|
|
609
965
|
if (!ops.length) {
|
|
610
966
|
return { results: [], serverVersion: 0 };
|
|
611
967
|
}
|
|
968
|
+
const userId = resolveSessionUserId(event);
|
|
612
969
|
const resultSlots = new Array(ops.length);
|
|
613
970
|
const groups = /* @__PURE__ */ new Map();
|
|
614
971
|
ops.forEach((op, index) => {
|
|
@@ -629,19 +986,20 @@ export class SqliteSyncGatewayAdapter {
|
|
|
629
986
|
const uniqueOps = [];
|
|
630
987
|
const indicesByOpId = /* @__PURE__ */ new Map();
|
|
631
988
|
for (const group of groups.values()) {
|
|
632
|
-
const
|
|
989
|
+
const scoped = group.conflicting ? group.op : scopeNotificationWrite(group.op, userId);
|
|
990
|
+
const error = group.conflicting ? `Conflicting operations reuse op_id ${group.op.stamp.opId}` : typeof scoped === "string" ? scoped : validatePushOperation(workspaceId, scoped);
|
|
633
991
|
if (error) {
|
|
634
992
|
for (const index of group.indices) {
|
|
635
993
|
resultSlots[index] = {
|
|
636
994
|
opId: group.op.stamp.opId,
|
|
637
995
|
success: false,
|
|
638
996
|
error,
|
|
639
|
-
errorCode: group.conflicting ? "CONFLICT" : "VALIDATION_ERROR"
|
|
997
|
+
errorCode: group.conflicting ? "CONFLICT" : error.startsWith("Unauthorized") || error.startsWith("Forbidden") ? "UNAUTHORIZED" : "VALIDATION_ERROR"
|
|
640
998
|
};
|
|
641
999
|
}
|
|
642
1000
|
continue;
|
|
643
1001
|
}
|
|
644
|
-
uniqueOps.push(
|
|
1002
|
+
uniqueOps.push(scoped);
|
|
645
1003
|
indicesByOpId.set(group.op.stamp.opId, group.indices);
|
|
646
1004
|
}
|
|
647
1005
|
if (isD1Driver()) {
|
|
@@ -650,12 +1008,12 @@ export class SqliteSyncGatewayAdapter {
|
|
|
650
1008
|
workspaceId,
|
|
651
1009
|
uniqueOps,
|
|
652
1010
|
resultSlots,
|
|
653
|
-
indicesByOpId
|
|
1011
|
+
indicesByOpId,
|
|
1012
|
+
userId
|
|
654
1013
|
});
|
|
655
1014
|
}
|
|
656
1015
|
const raw = getRawDb();
|
|
657
1016
|
const now = nowEpoch();
|
|
658
|
-
const userId = resolveSessionUserId(event);
|
|
659
1017
|
const uniqueResults = [];
|
|
660
1018
|
let finalServerVersion = 0;
|
|
661
1019
|
const hookEmissions = [];
|
|
@@ -667,15 +1025,42 @@ export class SqliteSyncGatewayAdapter {
|
|
|
667
1025
|
const chunk = opIds.slice(i, i + chunkSize);
|
|
668
1026
|
const placeholders = chunk.map(() => "?").join(",");
|
|
669
1027
|
const rows = raw.prepare(
|
|
670
|
-
`SELECT op_id, server_version
|
|
1028
|
+
`SELECT op_id, server_version, workspace_id, table_name, pk, op,
|
|
1029
|
+
payload_json, clock, hlc, device_id
|
|
1030
|
+
FROM change_log WHERE op_id IN (${placeholders})`
|
|
671
1031
|
).all(...chunk);
|
|
672
1032
|
for (const row of rows) {
|
|
673
|
-
existingOps.set(row.op_id,
|
|
1033
|
+
existingOps.set(row.op_id, {
|
|
1034
|
+
server_version: row.server_version,
|
|
1035
|
+
workspace_id: row.workspace_id,
|
|
1036
|
+
fingerprint: changeLogFingerprint(row),
|
|
1037
|
+
op_id: row.op_id
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
const failedBeforeAlloc = /* @__PURE__ */ new Set();
|
|
1042
|
+
for (const op of uniqueOps) {
|
|
1043
|
+
if (op.tableName !== "notifications" || op.operation !== "delete") continue;
|
|
1044
|
+
const materializedTable = SYNCED_TABLE_MAP[op.tableName];
|
|
1045
|
+
if (!materializedTable) continue;
|
|
1046
|
+
const owned = raw.prepare(
|
|
1047
|
+
`SELECT data_json FROM ${materializedTable} WHERE id = ? AND workspace_id = ?`
|
|
1048
|
+
).get(op.pk, workspaceId);
|
|
1049
|
+
const owner = owned ? notificationPayloadUserId(JSON.parse(owned.data_json)) : void 0;
|
|
1050
|
+
if (!owned || owner !== userId) {
|
|
1051
|
+
failedBeforeAlloc.add(op.stamp.opId);
|
|
1052
|
+
uniqueResults.push({
|
|
1053
|
+
opId: op.stamp.opId,
|
|
1054
|
+
success: false,
|
|
1055
|
+
error: "Forbidden: notification owner mismatch",
|
|
1056
|
+
errorCode: "UNAUTHORIZED"
|
|
1057
|
+
});
|
|
674
1058
|
}
|
|
675
1059
|
}
|
|
676
1060
|
const uniqueNewOpIds = /* @__PURE__ */ new Set();
|
|
677
1061
|
for (const op of uniqueOps) {
|
|
678
1062
|
if (existingOps.has(op.stamp.opId)) continue;
|
|
1063
|
+
if (failedBeforeAlloc.has(op.stamp.opId)) continue;
|
|
679
1064
|
uniqueNewOpIds.add(op.stamp.opId);
|
|
680
1065
|
}
|
|
681
1066
|
let baseVersion;
|
|
@@ -700,6 +1085,7 @@ export class SqliteSyncGatewayAdapter {
|
|
|
700
1085
|
let versionOffset = 0;
|
|
701
1086
|
for (const op of uniqueOps) {
|
|
702
1087
|
if (existingOps.has(op.stamp.opId)) continue;
|
|
1088
|
+
if (failedBeforeAlloc.has(op.stamp.opId)) continue;
|
|
703
1089
|
if (assignedVersions.has(op.stamp.opId)) continue;
|
|
704
1090
|
versionOffset++;
|
|
705
1091
|
assignedVersions.set(op.stamp.opId, baseVersion + versionOffset);
|
|
@@ -707,12 +1093,26 @@ export class SqliteSyncGatewayAdapter {
|
|
|
707
1093
|
const processedNew = /* @__PURE__ */ new Set();
|
|
708
1094
|
for (const op of uniqueOps) {
|
|
709
1095
|
const opId = op.stamp.opId;
|
|
710
|
-
|
|
711
|
-
|
|
1096
|
+
if (failedBeforeAlloc.has(opId)) continue;
|
|
1097
|
+
const existingLog = existingOps.get(opId);
|
|
1098
|
+
if (existingLog !== void 0) {
|
|
1099
|
+
if (existingLog.workspace_id !== workspaceId || existingLog.fingerprint !== operationFingerprint(op)) {
|
|
1100
|
+
uniqueResults.push({
|
|
1101
|
+
opId,
|
|
1102
|
+
success: false,
|
|
1103
|
+
error: `Conflicting operation reuses processed op_id ${opId}`,
|
|
1104
|
+
errorCode: "CONFLICT"
|
|
1105
|
+
});
|
|
1106
|
+
continue;
|
|
1107
|
+
}
|
|
1108
|
+
const materialized = SYNCED_TABLE_MAP[op.tableName] ? raw.prepare(
|
|
1109
|
+
`SELECT op_id FROM ${SYNCED_TABLE_MAP[op.tableName]} WHERE id = ? AND workspace_id = ?`
|
|
1110
|
+
).get(op.pk, workspaceId) : void 0;
|
|
712
1111
|
uniqueResults.push({
|
|
713
1112
|
opId,
|
|
714
1113
|
success: true,
|
|
715
|
-
serverVersion:
|
|
1114
|
+
serverVersion: existingLog.server_version,
|
|
1115
|
+
applied: materialized?.op_id === opId
|
|
716
1116
|
});
|
|
717
1117
|
continue;
|
|
718
1118
|
}
|
|
@@ -740,6 +1140,21 @@ export class SqliteSyncGatewayAdapter {
|
|
|
740
1140
|
continue;
|
|
741
1141
|
}
|
|
742
1142
|
const pkValue = op.pk;
|
|
1143
|
+
if (op.tableName === "notifications" && op.operation === "delete") {
|
|
1144
|
+
const owned = raw.prepare(
|
|
1145
|
+
`SELECT data_json FROM ${materializedTable} WHERE id = ? AND workspace_id = ?`
|
|
1146
|
+
).get(pkValue, workspaceId);
|
|
1147
|
+
const owner = owned ? notificationPayloadUserId(JSON.parse(owned.data_json)) : void 0;
|
|
1148
|
+
if (!owned || owner !== userId) {
|
|
1149
|
+
uniqueResults.push({
|
|
1150
|
+
opId,
|
|
1151
|
+
success: false,
|
|
1152
|
+
error: "Forbidden: notification owner mismatch",
|
|
1153
|
+
errorCode: "UNAUTHORIZED"
|
|
1154
|
+
});
|
|
1155
|
+
continue;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
743
1158
|
const payloadJson = op.payload != null ? JSON.stringify(op.payload) : null;
|
|
744
1159
|
insertChangeLog.run(
|
|
745
1160
|
uid(),
|
|
@@ -755,33 +1170,42 @@ export class SqliteSyncGatewayAdapter {
|
|
|
755
1170
|
opId,
|
|
756
1171
|
now
|
|
757
1172
|
);
|
|
1173
|
+
const incomingRev = {
|
|
1174
|
+
clock: op.stamp.clock,
|
|
1175
|
+
hlc: op.stamp.hlc,
|
|
1176
|
+
opId
|
|
1177
|
+
};
|
|
758
1178
|
if (op.operation === "put") {
|
|
759
1179
|
const existing = raw.prepare(
|
|
760
|
-
`SELECT clock, hlc FROM ${materializedTable} WHERE id = ? AND workspace_id = ?`
|
|
1180
|
+
`SELECT clock, hlc, op_id, data_json FROM ${materializedTable} WHERE id = ? AND workspace_id = ?`
|
|
761
1181
|
).get(pkValue, workspaceId);
|
|
1182
|
+
const tombstone = !existing ? raw.prepare(
|
|
1183
|
+
`SELECT clock, hlc, op_id FROM tombstones
|
|
1184
|
+
WHERE workspace_id = ? AND table_name = ? AND pk = ?`
|
|
1185
|
+
).get(workspaceId, op.tableName, pkValue) : void 0;
|
|
762
1186
|
let applied = false;
|
|
1187
|
+
let winnerPayload = op.payload;
|
|
763
1188
|
if (!existing) {
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
)) {
|
|
1189
|
+
if (tombstone && !incomingWinsRevision(incomingRev, tombstone)) {
|
|
1190
|
+
winnerPayload = void 0;
|
|
1191
|
+
} else {
|
|
1192
|
+
raw.prepare(
|
|
1193
|
+
`INSERT INTO ${materializedTable} (id, workspace_id, data_json, clock, hlc, device_id, op_id, deleted, created_at, updated_at)
|
|
1194
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`
|
|
1195
|
+
).run(
|
|
1196
|
+
pkValue,
|
|
1197
|
+
workspaceId,
|
|
1198
|
+
payloadJson ?? "{}",
|
|
1199
|
+
op.stamp.clock,
|
|
1200
|
+
op.stamp.hlc,
|
|
1201
|
+
op.stamp.deviceId,
|
|
1202
|
+
opId,
|
|
1203
|
+
now,
|
|
1204
|
+
now
|
|
1205
|
+
);
|
|
1206
|
+
applied = true;
|
|
1207
|
+
}
|
|
1208
|
+
} else if (incomingWinsRevision(incomingRev, existing)) {
|
|
785
1209
|
raw.prepare(
|
|
786
1210
|
`UPDATE ${materializedTable}
|
|
787
1211
|
SET data_json = ?, clock = ?, hlc = ?, device_id = ?, op_id = ?, deleted = 0, updated_at = ?
|
|
@@ -797,6 +1221,8 @@ export class SqliteSyncGatewayAdapter {
|
|
|
797
1221
|
workspaceId
|
|
798
1222
|
);
|
|
799
1223
|
applied = true;
|
|
1224
|
+
} else {
|
|
1225
|
+
winnerPayload = JSON.parse(existing.data_json);
|
|
800
1226
|
}
|
|
801
1227
|
const emission = resolveHookEmission({
|
|
802
1228
|
op,
|
|
@@ -809,9 +1235,16 @@ export class SqliteSyncGatewayAdapter {
|
|
|
809
1235
|
if (emission) {
|
|
810
1236
|
hookEmissions.push(emission);
|
|
811
1237
|
}
|
|
1238
|
+
uniqueResults.push({
|
|
1239
|
+
opId,
|
|
1240
|
+
success: true,
|
|
1241
|
+
serverVersion,
|
|
1242
|
+
applied,
|
|
1243
|
+
...applied ? {} : { payload: winnerPayload }
|
|
1244
|
+
});
|
|
812
1245
|
} else if (op.operation === "delete") {
|
|
813
1246
|
const existing = raw.prepare(
|
|
814
|
-
`SELECT clock, hlc FROM ${materializedTable} WHERE id = ? AND workspace_id = ?`
|
|
1247
|
+
`SELECT clock, hlc, op_id, data_json FROM ${materializedTable} WHERE id = ? AND workspace_id = ?`
|
|
815
1248
|
).get(pkValue, workspaceId);
|
|
816
1249
|
let applied = false;
|
|
817
1250
|
if (!existing) {
|
|
@@ -829,12 +1262,7 @@ export class SqliteSyncGatewayAdapter {
|
|
|
829
1262
|
now
|
|
830
1263
|
);
|
|
831
1264
|
applied = true;
|
|
832
|
-
} else if (
|
|
833
|
-
op.stamp.clock,
|
|
834
|
-
op.stamp.hlc,
|
|
835
|
-
existing.clock,
|
|
836
|
-
existing.hlc
|
|
837
|
-
)) {
|
|
1265
|
+
} else if (incomingWinsRevision(incomingRev, existing)) {
|
|
838
1266
|
raw.prepare(
|
|
839
1267
|
`UPDATE ${materializedTable}
|
|
840
1268
|
SET deleted = 1, clock = ?, hlc = ?, device_id = ?, op_id = ?, updated_at = ?
|
|
@@ -859,11 +1287,7 @@ export class SqliteSyncGatewayAdapter {
|
|
|
859
1287
|
hlc = excluded.hlc,
|
|
860
1288
|
op_id = excluded.op_id,
|
|
861
1289
|
server_version = excluded.server_version
|
|
862
|
-
WHERE
|
|
863
|
-
OR (
|
|
864
|
-
excluded.clock = tombstones.clock
|
|
865
|
-
AND excluded.server_version > tombstones.server_version
|
|
866
|
-
)`
|
|
1290
|
+
WHERE ${lwwExcludedWins("tombstones")}`
|
|
867
1291
|
).run(
|
|
868
1292
|
uid(),
|
|
869
1293
|
workspaceId,
|
|
@@ -887,12 +1311,14 @@ export class SqliteSyncGatewayAdapter {
|
|
|
887
1311
|
if (emission) {
|
|
888
1312
|
hookEmissions.push(emission);
|
|
889
1313
|
}
|
|
1314
|
+
uniqueResults.push({
|
|
1315
|
+
opId,
|
|
1316
|
+
success: true,
|
|
1317
|
+
serverVersion,
|
|
1318
|
+
applied,
|
|
1319
|
+
...applied || !existing ? {} : { payload: JSON.parse(existing.data_json) }
|
|
1320
|
+
});
|
|
890
1321
|
}
|
|
891
|
-
uniqueResults.push({
|
|
892
|
-
opId,
|
|
893
|
-
success: true,
|
|
894
|
-
serverVersion
|
|
895
|
-
});
|
|
896
1322
|
}
|
|
897
1323
|
});
|
|
898
1324
|
runTx.immediate();
|
|
@@ -935,17 +1361,32 @@ export class SqliteSyncGatewayAdapter {
|
|
|
935
1361
|
deviceId: row.device_id,
|
|
936
1362
|
opId: row.op_id
|
|
937
1363
|
}
|
|
938
|
-
}))
|
|
1364
|
+
})).filter(
|
|
1365
|
+
(change) => isNotificationVisibleToUser(change, resolveSessionUserId(event))
|
|
1366
|
+
);
|
|
939
1367
|
const lastRow = resultRows[resultRows.length - 1];
|
|
940
1368
|
const nextCursor = lastRow ? lastRow.server_version : cursor;
|
|
941
|
-
|
|
1369
|
+
const minLog = await db.selectFrom("change_log").select("server_version").where("workspace_id", "=", scope.workspaceId).orderBy("server_version", "asc").limit(1).executeTakeFirst();
|
|
1370
|
+
const counter = await db.selectFrom("server_version_counter").select("value").where("workspace_id", "=", scope.workspaceId).executeTakeFirst();
|
|
1371
|
+
const retention = computePullRetention({
|
|
1372
|
+
cursor,
|
|
1373
|
+
oldestLogVersion: minLog?.server_version ?? null,
|
|
1374
|
+
highWatermark: counter?.value ?? 0
|
|
1375
|
+
});
|
|
1376
|
+
return {
|
|
1377
|
+
changes,
|
|
1378
|
+
nextCursor,
|
|
1379
|
+
hasMore,
|
|
1380
|
+
...retention
|
|
1381
|
+
};
|
|
942
1382
|
}
|
|
943
1383
|
async snapshot(event, input) {
|
|
944
1384
|
const workspaceId = input.scope.workspaceId;
|
|
945
1385
|
await assertWorkspaceScopeAuthorized(event, workspaceId);
|
|
946
1386
|
const pageSize = resolveSnapshotPageSize(input.pageSize);
|
|
1387
|
+
const snapshotUserId = resolveSessionUserId(event);
|
|
947
1388
|
if (isD1Driver()) {
|
|
948
|
-
return this.snapshotInD1(workspaceId, input, pageSize);
|
|
1389
|
+
return this.snapshotInD1(workspaceId, input, pageSize, snapshotUserId);
|
|
949
1390
|
}
|
|
950
1391
|
const raw = getRawDb();
|
|
951
1392
|
const now = nowEpoch();
|
|
@@ -1015,6 +1456,11 @@ export class SqliteSyncGatewayAdapter {
|
|
|
1015
1456
|
if (!materializedTable) {
|
|
1016
1457
|
throw new Error(`Missing materialized table for ${tableName}`);
|
|
1017
1458
|
}
|
|
1459
|
+
const liveOwner = snapshotNotificationOwnerClause(
|
|
1460
|
+
tableName,
|
|
1461
|
+
"data_json",
|
|
1462
|
+
snapshotUserId
|
|
1463
|
+
);
|
|
1018
1464
|
raw.prepare(`
|
|
1019
1465
|
INSERT INTO sync_snapshot_items (
|
|
1020
1466
|
snapshot_id, table_name, pk, kind, payload_json,
|
|
@@ -1030,8 +1476,13 @@ export class SqliteSyncGatewayAdapter {
|
|
|
1030
1476
|
END,
|
|
1031
1477
|
NULL
|
|
1032
1478
|
FROM "${materializedTable}"
|
|
1033
|
-
WHERE workspace_id = ? AND deleted = 0
|
|
1034
|
-
`).run(snapshotId, tableName, workspaceId);
|
|
1479
|
+
WHERE workspace_id = ? AND deleted = 0${liveOwner.sql}
|
|
1480
|
+
`).run(snapshotId, tableName, workspaceId, ...liveOwner.params);
|
|
1481
|
+
const deletedOwner = snapshotNotificationOwnerClause(
|
|
1482
|
+
tableName,
|
|
1483
|
+
"materialized.data_json",
|
|
1484
|
+
snapshotUserId
|
|
1485
|
+
);
|
|
1035
1486
|
raw.prepare(`
|
|
1036
1487
|
INSERT INTO sync_snapshot_items (
|
|
1037
1488
|
snapshot_id, table_name, pk, kind, payload_json,
|
|
@@ -1062,8 +1513,9 @@ export class SqliteSyncGatewayAdapter {
|
|
|
1062
1513
|
0
|
|
1063
1514
|
))
|
|
1064
1515
|
FROM "${materializedTable}" AS materialized
|
|
1065
|
-
WHERE materialized.workspace_id = ? AND materialized.deleted <> 0
|
|
1066
|
-
`).run(snapshotId, tableName, tableName, workspaceId);
|
|
1516
|
+
WHERE materialized.workspace_id = ? AND materialized.deleted <> 0${deletedOwner.sql}
|
|
1517
|
+
`).run(snapshotId, tableName, tableName, workspaceId, ...deletedOwner.params);
|
|
1518
|
+
const orphanFilter = tableName === "notifications" ? " AND 1 = 0" : "";
|
|
1067
1519
|
raw.prepare(`
|
|
1068
1520
|
INSERT INTO sync_snapshot_items (
|
|
1069
1521
|
snapshot_id, table_name, pk, kind, payload_json,
|
|
@@ -1086,7 +1538,7 @@ export class SqliteSyncGatewayAdapter {
|
|
|
1086
1538
|
FROM "${materializedTable}" AS materialized
|
|
1087
1539
|
WHERE materialized.workspace_id = tombstone.workspace_id
|
|
1088
1540
|
AND materialized.id = tombstone.pk
|
|
1089
|
-
)
|
|
1541
|
+
)${orphanFilter}
|
|
1090
1542
|
`).run(snapshotId, tableName, workspaceId, tableName);
|
|
1091
1543
|
}
|
|
1092
1544
|
return {
|
|
@@ -1400,7 +1852,7 @@ export class SqliteSyncGatewayAdapter {
|
|
|
1400
1852
|
}
|
|
1401
1853
|
]);
|
|
1402
1854
|
}
|
|
1403
|
-
async snapshotInD1(workspaceId, input, pageSize) {
|
|
1855
|
+
async snapshotInD1(workspaceId, input, pageSize, snapshotUserId) {
|
|
1404
1856
|
const now = nowEpoch();
|
|
1405
1857
|
let header;
|
|
1406
1858
|
let after;
|
|
@@ -1466,6 +1918,17 @@ export class SqliteSyncGatewayAdapter {
|
|
|
1466
1918
|
if (!materializedTable) {
|
|
1467
1919
|
throw new Error(`Missing materialized table for ${tableName}`);
|
|
1468
1920
|
}
|
|
1921
|
+
const liveOwner = snapshotNotificationOwnerClause(
|
|
1922
|
+
tableName,
|
|
1923
|
+
"data_json",
|
|
1924
|
+
snapshotUserId
|
|
1925
|
+
);
|
|
1926
|
+
const deletedOwner = snapshotNotificationOwnerClause(
|
|
1927
|
+
tableName,
|
|
1928
|
+
"materialized.data_json",
|
|
1929
|
+
snapshotUserId
|
|
1930
|
+
);
|
|
1931
|
+
const orphanFilter = tableName === "notifications" ? " AND 1 = 0" : "";
|
|
1469
1932
|
statements.push(
|
|
1470
1933
|
{
|
|
1471
1934
|
sql: `INSERT INTO sync_snapshot_items (
|
|
@@ -1482,8 +1945,8 @@ export class SqliteSyncGatewayAdapter {
|
|
|
1482
1945
|
END,
|
|
1483
1946
|
NULL
|
|
1484
1947
|
FROM "${materializedTable}"
|
|
1485
|
-
WHERE workspace_id = ? AND deleted = 0`,
|
|
1486
|
-
parameters: [snapshotId, tableName, workspaceId]
|
|
1948
|
+
WHERE workspace_id = ? AND deleted = 0${liveOwner.sql}`,
|
|
1949
|
+
parameters: [snapshotId, tableName, workspaceId, ...liveOwner.params]
|
|
1487
1950
|
},
|
|
1488
1951
|
{
|
|
1489
1952
|
sql: `INSERT INTO sync_snapshot_items (
|
|
@@ -1515,8 +1978,14 @@ export class SqliteSyncGatewayAdapter {
|
|
|
1515
1978
|
0
|
|
1516
1979
|
))
|
|
1517
1980
|
FROM "${materializedTable}" AS materialized
|
|
1518
|
-
WHERE materialized.workspace_id = ? AND materialized.deleted <> 0`,
|
|
1519
|
-
parameters: [
|
|
1981
|
+
WHERE materialized.workspace_id = ? AND materialized.deleted <> 0${deletedOwner.sql}`,
|
|
1982
|
+
parameters: [
|
|
1983
|
+
snapshotId,
|
|
1984
|
+
tableName,
|
|
1985
|
+
tableName,
|
|
1986
|
+
workspaceId,
|
|
1987
|
+
...deletedOwner.params
|
|
1988
|
+
]
|
|
1520
1989
|
},
|
|
1521
1990
|
{
|
|
1522
1991
|
sql: `INSERT INTO sync_snapshot_items (
|
|
@@ -1540,7 +2009,7 @@ export class SqliteSyncGatewayAdapter {
|
|
|
1540
2009
|
FROM "${materializedTable}" AS materialized
|
|
1541
2010
|
WHERE materialized.workspace_id = tombstone.workspace_id
|
|
1542
2011
|
AND materialized.id = tombstone.pk
|
|
1543
|
-
)`,
|
|
2012
|
+
)${orphanFilter}`,
|
|
1544
2013
|
parameters: [snapshotId, tableName, workspaceId, tableName]
|
|
1545
2014
|
}
|
|
1546
2015
|
);
|
|
@@ -1663,7 +2132,7 @@ export class SqliteSyncGatewayAdapter {
|
|
|
1663
2132
|
}
|
|
1664
2133
|
}
|
|
1665
2134
|
async pushInD1(input) {
|
|
1666
|
-
const {
|
|
2135
|
+
const { workspaceId, uniqueOps, resultSlots, indicesByOpId, userId } = input;
|
|
1667
2136
|
const existingOps = /* @__PURE__ */ new Map();
|
|
1668
2137
|
const opIds = uniqueOps.map((op) => op.stamp.opId);
|
|
1669
2138
|
for (let offset = 0; offset < opIds.length; offset += 500) {
|
|
@@ -1671,12 +2140,61 @@ export class SqliteSyncGatewayAdapter {
|
|
|
1671
2140
|
if (chunk.length === 0) continue;
|
|
1672
2141
|
const placeholders = chunk.map(() => "?").join(",");
|
|
1673
2142
|
const rows = await d1All(
|
|
1674
|
-
`SELECT op_id, server_version
|
|
2143
|
+
`SELECT op_id, server_version, workspace_id, table_name, pk, op,
|
|
2144
|
+
payload_json, clock, hlc, device_id
|
|
2145
|
+
FROM change_log WHERE op_id IN (${placeholders})`,
|
|
1675
2146
|
...chunk
|
|
1676
2147
|
);
|
|
1677
|
-
for (const row of rows)
|
|
2148
|
+
for (const row of rows) {
|
|
2149
|
+
existingOps.set(row.op_id, {
|
|
2150
|
+
server_version: row.server_version,
|
|
2151
|
+
workspace_id: row.workspace_id,
|
|
2152
|
+
fingerprint: changeLogFingerprint(row)
|
|
2153
|
+
});
|
|
2154
|
+
}
|
|
1678
2155
|
}
|
|
1679
|
-
const
|
|
2156
|
+
const rejected = /* @__PURE__ */ new Set();
|
|
2157
|
+
for (const op of uniqueOps) {
|
|
2158
|
+
const existing = existingOps.get(op.stamp.opId);
|
|
2159
|
+
if (!existing) continue;
|
|
2160
|
+
if (existing.workspace_id !== workspaceId || existing.fingerprint !== operationFingerprint(op)) {
|
|
2161
|
+
rejected.add(op.stamp.opId);
|
|
2162
|
+
for (const index of indicesByOpId.get(op.stamp.opId) ?? []) {
|
|
2163
|
+
resultSlots[index] = {
|
|
2164
|
+
opId: op.stamp.opId,
|
|
2165
|
+
success: false,
|
|
2166
|
+
error: `Conflicting operation reuses processed op_id ${op.stamp.opId}`,
|
|
2167
|
+
errorCode: "CONFLICT"
|
|
2168
|
+
};
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
for (const op of uniqueOps) {
|
|
2173
|
+
if (op.tableName !== "notifications" || op.operation !== "delete") continue;
|
|
2174
|
+
if (existingOps.has(op.stamp.opId) || rejected.has(op.stamp.opId)) continue;
|
|
2175
|
+
const materializedTable = SYNCED_TABLE_MAP[op.tableName];
|
|
2176
|
+
if (!materializedTable) continue;
|
|
2177
|
+
const [owned] = await d1All(
|
|
2178
|
+
`SELECT data_json FROM "${materializedTable}" WHERE id = ? AND workspace_id = ?`,
|
|
2179
|
+
op.pk,
|
|
2180
|
+
workspaceId
|
|
2181
|
+
);
|
|
2182
|
+
const owner = owned ? notificationPayloadUserId(JSON.parse(owned.data_json)) : void 0;
|
|
2183
|
+
if (!owned || owner !== userId) {
|
|
2184
|
+
rejected.add(op.stamp.opId);
|
|
2185
|
+
for (const index of indicesByOpId.get(op.stamp.opId) ?? []) {
|
|
2186
|
+
resultSlots[index] = {
|
|
2187
|
+
opId: op.stamp.opId,
|
|
2188
|
+
success: false,
|
|
2189
|
+
error: "Forbidden: notification owner mismatch",
|
|
2190
|
+
errorCode: "UNAUTHORIZED"
|
|
2191
|
+
};
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
const newOps = uniqueOps.filter(
|
|
2196
|
+
(op) => !existingOps.has(op.stamp.opId) && !rejected.has(op.stamp.opId)
|
|
2197
|
+
);
|
|
1680
2198
|
const versionOffsets = /* @__PURE__ */ new Map();
|
|
1681
2199
|
newOps.forEach((op, index) => {
|
|
1682
2200
|
versionOffsets.set(op.stamp.opId, index + 1);
|
|
@@ -1707,8 +2225,32 @@ export class SqliteSyncGatewayAdapter {
|
|
|
1707
2225
|
}
|
|
1708
2226
|
}
|
|
1709
2227
|
}
|
|
2228
|
+
const skipMaterialize = /* @__PURE__ */ new Set();
|
|
2229
|
+
for (const op of newOps) {
|
|
2230
|
+
if (op.operation !== "put") continue;
|
|
2231
|
+
const materializedTable = SYNCED_TABLE_MAP[op.tableName];
|
|
2232
|
+
if (!materializedTable) continue;
|
|
2233
|
+
const materializedKey = `${materializedTable}\0${op.pk}`;
|
|
2234
|
+
if (existingMaterializedKeys.has(materializedKey)) continue;
|
|
2235
|
+
const [tombstone] = await d1All(
|
|
2236
|
+
`SELECT clock, hlc, op_id FROM tombstones
|
|
2237
|
+
WHERE workspace_id = ? AND table_name = ? AND pk = ?`,
|
|
2238
|
+
workspaceId,
|
|
2239
|
+
op.tableName,
|
|
2240
|
+
op.pk
|
|
2241
|
+
);
|
|
2242
|
+
if (tombstone && !incomingWinsRevision(
|
|
2243
|
+
{
|
|
2244
|
+
clock: op.stamp.clock,
|
|
2245
|
+
hlc: op.stamp.hlc,
|
|
2246
|
+
opId: op.stamp.opId
|
|
2247
|
+
},
|
|
2248
|
+
tombstone
|
|
2249
|
+
)) {
|
|
2250
|
+
skipMaterialize.add(op.stamp.opId);
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
1710
2253
|
const now = nowEpoch();
|
|
1711
|
-
const userId = resolveSessionUserId(event);
|
|
1712
2254
|
const statements = [];
|
|
1713
2255
|
if (newOps.length > 0) {
|
|
1714
2256
|
statements.push(
|
|
@@ -1739,7 +2281,7 @@ export class SqliteSyncGatewayAdapter {
|
|
|
1739
2281
|
);
|
|
1740
2282
|
seenMaterializedKeys.add(materializedKey);
|
|
1741
2283
|
statements.push({
|
|
1742
|
-
sql: `INSERT
|
|
2284
|
+
sql: `INSERT INTO change_log (
|
|
1743
2285
|
id, workspace_id, server_version, table_name, pk, op,
|
|
1744
2286
|
payload_json, clock, hlc, device_id, op_id, created_at
|
|
1745
2287
|
) VALUES (
|
|
@@ -1764,6 +2306,7 @@ export class SqliteSyncGatewayAdapter {
|
|
|
1764
2306
|
now
|
|
1765
2307
|
]
|
|
1766
2308
|
});
|
|
2309
|
+
if (skipMaterialize.has(op.stamp.opId)) continue;
|
|
1767
2310
|
materializedStatementIndex.set(op.stamp.opId, statements.length);
|
|
1768
2311
|
if (op.operation === "put") {
|
|
1769
2312
|
statements.push({
|
|
@@ -1779,11 +2322,7 @@ export class SqliteSyncGatewayAdapter {
|
|
|
1779
2322
|
op_id = excluded.op_id,
|
|
1780
2323
|
deleted = 0,
|
|
1781
2324
|
updated_at = excluded.updated_at
|
|
1782
|
-
WHERE
|
|
1783
|
-
OR (
|
|
1784
|
-
excluded.clock = "${materializedTable}".clock
|
|
1785
|
-
AND excluded.hlc > "${materializedTable}".hlc
|
|
1786
|
-
)`,
|
|
2325
|
+
WHERE ${lwwExcludedWins(`"${materializedTable}"`)}`,
|
|
1787
2326
|
parameters: [
|
|
1788
2327
|
op.pk,
|
|
1789
2328
|
workspaceId,
|
|
@@ -1809,11 +2348,7 @@ export class SqliteSyncGatewayAdapter {
|
|
|
1809
2348
|
op_id = excluded.op_id,
|
|
1810
2349
|
deleted = 1,
|
|
1811
2350
|
updated_at = excluded.updated_at
|
|
1812
|
-
WHERE
|
|
1813
|
-
OR (
|
|
1814
|
-
excluded.clock = "${materializedTable}".clock
|
|
1815
|
-
AND excluded.hlc > "${materializedTable}".hlc
|
|
1816
|
-
)`,
|
|
2351
|
+
WHERE ${lwwExcludedWins(`"${materializedTable}"`)}`,
|
|
1817
2352
|
parameters: [
|
|
1818
2353
|
op.pk,
|
|
1819
2354
|
workspaceId,
|
|
@@ -1840,11 +2375,7 @@ export class SqliteSyncGatewayAdapter {
|
|
|
1840
2375
|
hlc = excluded.hlc,
|
|
1841
2376
|
op_id = excluded.op_id,
|
|
1842
2377
|
server_version = excluded.server_version
|
|
1843
|
-
WHERE
|
|
1844
|
-
OR (
|
|
1845
|
-
excluded.clock = tombstones.clock
|
|
1846
|
-
AND excluded.server_version > tombstones.server_version
|
|
1847
|
-
)`,
|
|
2378
|
+
WHERE ${lwwExcludedWins("tombstones")}`,
|
|
1848
2379
|
parameters: [
|
|
1849
2380
|
uid(),
|
|
1850
2381
|
workspaceId,
|
|
@@ -1897,20 +2428,62 @@ export class SqliteSyncGatewayAdapter {
|
|
|
1897
2428
|
}
|
|
1898
2429
|
const emissions = [];
|
|
1899
2430
|
for (const op of uniqueOps) {
|
|
2431
|
+
if (rejected.has(op.stamp.opId)) continue;
|
|
2432
|
+
const existing = existingOps.get(op.stamp.opId);
|
|
2433
|
+
if (existing) {
|
|
2434
|
+
const materializedTable = SYNCED_TABLE_MAP[op.tableName];
|
|
2435
|
+
let applied2 = true;
|
|
2436
|
+
if (materializedTable) {
|
|
2437
|
+
const [row] = await d1All(
|
|
2438
|
+
`SELECT op_id FROM "${materializedTable}" WHERE id = ? AND workspace_id = ?`,
|
|
2439
|
+
op.pk,
|
|
2440
|
+
workspaceId
|
|
2441
|
+
);
|
|
2442
|
+
applied2 = row?.op_id === op.stamp.opId;
|
|
2443
|
+
}
|
|
2444
|
+
const result2 = {
|
|
2445
|
+
opId: op.stamp.opId,
|
|
2446
|
+
success: true,
|
|
2447
|
+
serverVersion: existing.server_version,
|
|
2448
|
+
applied: applied2
|
|
2449
|
+
};
|
|
2450
|
+
for (const index of indicesByOpId.get(op.stamp.opId) ?? []) {
|
|
2451
|
+
resultSlots[index] = result2;
|
|
2452
|
+
}
|
|
2453
|
+
continue;
|
|
2454
|
+
}
|
|
1900
2455
|
const serverVersion = resolvedOps.get(op.stamp.opId);
|
|
1901
2456
|
if (serverVersion === void 0) {
|
|
1902
2457
|
throw new Error(`D1 failed to resolve sync operation ${op.stamp.opId}.`);
|
|
1903
2458
|
}
|
|
2459
|
+
const skipApply = skipMaterialize.has(op.stamp.opId);
|
|
2460
|
+
const materializedResult = batchResults[materializedStatementIndex.get(op.stamp.opId) ?? -1];
|
|
2461
|
+
const applied = !skipApply && (materializedResult?.meta?.changes ?? 0) > 0;
|
|
2462
|
+
let winnerPayload;
|
|
2463
|
+
if (!applied && !skipApply) {
|
|
2464
|
+
const materializedTable = SYNCED_TABLE_MAP[op.tableName];
|
|
2465
|
+
if (materializedTable) {
|
|
2466
|
+
const [winner] = await d1All(
|
|
2467
|
+
`SELECT data_json FROM "${materializedTable}" WHERE id = ? AND workspace_id = ?`,
|
|
2468
|
+
op.pk,
|
|
2469
|
+
workspaceId
|
|
2470
|
+
);
|
|
2471
|
+
if (winner?.data_json) {
|
|
2472
|
+
winnerPayload = JSON.parse(winner.data_json);
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
}
|
|
1904
2476
|
const result = {
|
|
1905
2477
|
opId: op.stamp.opId,
|
|
1906
2478
|
success: true,
|
|
1907
|
-
serverVersion
|
|
2479
|
+
serverVersion,
|
|
2480
|
+
applied,
|
|
2481
|
+
...applied ? {} : { payload: winnerPayload }
|
|
1908
2482
|
};
|
|
1909
2483
|
for (const index of indicesByOpId.get(op.stamp.opId) ?? []) {
|
|
1910
2484
|
resultSlots[index] = result;
|
|
1911
2485
|
}
|
|
1912
|
-
|
|
1913
|
-
if ((materializedResult?.meta?.changes ?? 0) > 0) {
|
|
2486
|
+
if (applied) {
|
|
1914
2487
|
const emission = resolveHookEmission({
|
|
1915
2488
|
op,
|
|
1916
2489
|
workspaceId,
|