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.
@@ -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 incomingWinsLww(inClock, inHlc, existingClock, existingHlc) {
296
- if (inClock > existingClock) return true;
297
- if (inClock === existingClock && inHlc > existingHlc) return true;
298
- return false;
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
- snapshotBootstrap: "snapshot-v1",
304
- historyRetention: "snapshot-v1"
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 < 1 || !Number.isSafeInteger(input.expiresAt) || input.expiresAt <= nowEpoch()) {
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 error = group.conflicting ? `Conflicting operations reuse op_id ${group.op.stamp.opId}` : validatePushOperation(workspaceId, group.op);
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(group.op);
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 FROM change_log WHERE op_id IN (${placeholders})`
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, row.server_version);
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
- const existingSv = existingOps.get(opId);
711
- if (existingSv !== void 0) {
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: existingSv
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
- raw.prepare(
765
- `INSERT INTO ${materializedTable} (id, workspace_id, data_json, clock, hlc, device_id, op_id, deleted, created_at, updated_at)
766
- VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`
767
- ).run(
768
- pkValue,
769
- workspaceId,
770
- payloadJson ?? "{}",
771
- op.stamp.clock,
772
- op.stamp.hlc,
773
- op.stamp.deviceId,
774
- opId,
775
- now,
776
- now
777
- );
778
- applied = true;
779
- } else if (incomingWinsLww(
780
- op.stamp.clock,
781
- op.stamp.hlc,
782
- existing.clock,
783
- existing.hlc
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 (incomingWinsLww(
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 excluded.clock > tombstones.clock
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
- return { changes, nextCursor, hasMore };
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: [snapshotId, tableName, tableName, workspaceId]
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 { event, workspaceId, uniqueOps, resultSlots, indicesByOpId } = input;
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 FROM change_log WHERE op_id IN (${placeholders})`,
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) existingOps.set(row.op_id, row.server_version);
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 newOps = uniqueOps.filter((op) => !existingOps.has(op.stamp.opId));
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 OR IGNORE INTO change_log (
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 excluded.clock > "${materializedTable}".clock
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 excluded.clock > "${materializedTable}".clock
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 excluded.clock > tombstones.clock
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
- const materializedResult = batchResults[materializedStatementIndex.get(op.stamp.opId) ?? -1];
1913
- if ((materializedResult?.meta?.changes ?? 0) > 0) {
2486
+ if (applied) {
1914
2487
  const emission = resolveHookEmission({
1915
2488
  op,
1916
2489
  workspaceId,