or3-provider-sqlite 0.0.9 → 0.0.11

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