@korajs/server 1.0.0-beta.4 → 1.0.0-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -64,7 +64,7 @@ function isRetriableRejection(code) {
64
64
  import { generateUUIDv7 } from "@korajs/core";
65
65
 
66
66
  // src/store/materialization.ts
67
- import { StorageError, quoteIdent } from "@korajs/core";
67
+ import { StorageError, quoteIdent, replayOperationsForRecord } from "@korajs/core";
68
68
  function fieldTypeToSql(descriptor, dialect) {
69
69
  switch (descriptor.kind) {
70
70
  case "string":
@@ -146,30 +146,6 @@ function generateAllCollectionDDL(schema, dialect) {
146
146
  }
147
147
  return statements;
148
148
  }
149
- function replayOperationsForRecord(ops) {
150
- let record = null;
151
- let deleted = false;
152
- for (const op of ops) {
153
- switch (op.type) {
154
- case "insert":
155
- if (op.data) {
156
- record = { ...op.data };
157
- deleted = false;
158
- }
159
- break;
160
- case "update":
161
- if (op.data) {
162
- record = { ...record ?? {}, ...op.data };
163
- deleted = false;
164
- }
165
- break;
166
- case "delete":
167
- deleted = true;
168
- break;
169
- }
170
- }
171
- return deleted ? null : record;
172
- }
173
149
  var BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
174
150
  var BASE64_REVERSE = new Map(
175
151
  Array.from(BASE64_ALPHABET, (char, index3) => [char, index3])
@@ -220,6 +196,9 @@ function serializeFieldValue(value, descriptor) {
220
196
  if (value === null || value === void 0) return null;
221
197
  switch (descriptor.kind) {
222
198
  case "array":
199
+ case "object":
200
+ case "json":
201
+ case "blob":
223
202
  return typeof value === "string" ? value : JSON.stringify(value);
224
203
  case "boolean":
225
204
  return value ? 1 : 0;
@@ -233,6 +212,9 @@ function deserializeFieldValue(value, descriptor) {
233
212
  if (value === null || value === void 0) return null;
234
213
  switch (descriptor.kind) {
235
214
  case "array":
215
+ case "object":
216
+ case "json":
217
+ case "blob":
236
218
  return typeof value === "string" ? JSON.parse(value) : value;
237
219
  case "boolean":
238
220
  return value === 1 || value === true;
@@ -265,6 +247,14 @@ var MemoryServerStore = class {
265
247
  operations = [];
266
248
  operationIndex = /* @__PURE__ */ new Map();
267
249
  versionVector = /* @__PURE__ */ new Map();
250
+ /**
251
+ * Server-assigned delivery sequence per operation id. Single-process, so a plain
252
+ * counter incremented at insert time gives commit-order delivery sequence with no
253
+ * gaps. `operations` is kept in insertion (== delivery) order, so a delivery scan
254
+ * is a forward walk.
255
+ */
256
+ deliverySeqByOpId = /* @__PURE__ */ new Map();
257
+ deliverySeqCounter = 0;
268
258
  schema = null;
269
259
  /** Materialized records: collection -> recordId -> record data */
270
260
  materializedRecords = /* @__PURE__ */ new Map();
@@ -298,6 +288,8 @@ var MemoryServerStore = class {
298
288
  }
299
289
  this.operations.push(op);
300
290
  this.operationIndex.set(op.id, op);
291
+ this.deliverySeqCounter += 1;
292
+ this.deliverySeqByOpId.set(op.id, this.deliverySeqCounter);
301
293
  const currentSeq = this.versionVector.get(op.nodeId) ?? 0;
302
294
  if (op.sequenceNumber > currentSeq) {
303
295
  this.versionVector.set(op.nodeId, op.sequenceNumber);
@@ -317,6 +309,23 @@ var MemoryServerStore = class {
317
309
  this.assertOpen();
318
310
  return this.operations.length;
319
311
  }
312
+ async getMaxDeliverySequence() {
313
+ this.assertOpen();
314
+ return this.deliverySeqCounter;
315
+ }
316
+ async getOperationsAfterDelivery(afterDeliverySequence, limit) {
317
+ this.assertOpen();
318
+ const result = [];
319
+ if (limit <= 0) return result;
320
+ for (const op of this.operations) {
321
+ const deliverySequence = this.deliverySeqByOpId.get(op.id) ?? 0;
322
+ if (deliverySequence > afterDeliverySequence) {
323
+ result.push({ operation: op, deliverySequence });
324
+ if (result.length >= limit) break;
325
+ }
326
+ }
327
+ return result;
328
+ }
320
329
  async materializeCollection(collection) {
321
330
  this.assertOpen();
322
331
  if (this.schema?.collections[collection]) {
@@ -461,12 +470,16 @@ var MemoryServerStore = class {
461
470
  this.operations.length = 0;
462
471
  this.operationIndex.clear();
463
472
  this.versionVector.clear();
473
+ this.deliverySeqByOpId.clear();
474
+ this.deliverySeqCounter = 0;
464
475
  for (const [nid, seq] of versionVector) {
465
476
  this.versionVector.set(nid, seq);
466
477
  }
467
478
  for (const op of operations2) {
468
479
  this.operations.push(op);
469
480
  this.operationIndex.set(op.id, op);
481
+ this.deliverySeqCounter += 1;
482
+ this.deliverySeqByOpId.set(op.id, this.deliverySeqCounter);
470
483
  if (this.schema?.collections[op.collection]) {
471
484
  this.rebuildMaterializedRecord(op.collection, op.recordId);
472
485
  }
@@ -496,11 +509,12 @@ var MemoryServerStore = class {
496
509
  return a.timestamp.wallTime - b.timestamp.wallTime;
497
510
  if (a.timestamp.logical !== b.timestamp.logical)
498
511
  return a.timestamp.logical - b.timestamp.logical;
499
- return a.sequenceNumber - b.sequenceNumber;
512
+ return a.timestamp.nodeId < b.timestamp.nodeId ? -1 : a.timestamp.nodeId > b.timestamp.nodeId ? 1 : 0;
500
513
  });
501
514
  const parsedOps = recordOps.map((op) => ({
502
515
  type: op.type,
503
- data: op.data
516
+ data: op.data,
517
+ atomicOps: op.atomicOps ?? null
504
518
  }));
505
519
  const recordData = replayOperationsForRecord(parsedOps);
506
520
  if (recordData) {
@@ -606,8 +620,8 @@ var MemoryServerStore = class {
606
620
  };
607
621
 
608
622
  // src/store/postgres-server-store.ts
609
- import { generateUUIDv7 as generateUUIDv72, quoteIdent as quoteIdent2 } from "@korajs/core";
610
- import { and, asc, between, count, eq, sql } from "drizzle-orm";
623
+ import { HybridLogicalClock, generateUUIDv7 as generateUUIDv72, quoteIdent as quoteIdent2 } from "@korajs/core";
624
+ import { and, asc, between, count, desc, eq, gt, sql } from "drizzle-orm";
611
625
 
612
626
  // src/store/drizzle-pg-schema.ts
613
627
  import { bigint, index, integer, pgTable, text } from "drizzle-orm/pg-core";
@@ -623,6 +637,8 @@ var pgOperations = pgTable(
623
637
  // JSON-serialized, null for deletes
624
638
  previousData: text("previous_data"),
625
639
  // JSON-serialized, null for insert/delete
640
+ atomicOps: text("atomic_ops"),
641
+ // JSON-serialized Record<field, AtomicOp>, null when none
626
642
  wallTime: bigint("wall_time", { mode: "number" }).notNull(),
627
643
  logical: integer("logical").notNull(),
628
644
  timestampNodeId: text("timestamp_node_id").notNull(),
@@ -630,12 +646,18 @@ var pgOperations = pgTable(
630
646
  causalDeps: text("causal_deps").notNull().default("[]"),
631
647
  // JSON array of op IDs
632
648
  schemaVersion: integer("schema_version").notNull(),
633
- receivedAt: bigint("received_at", { mode: "number" }).notNull()
649
+ receivedAt: bigint("received_at", { mode: "number" }).notNull(),
650
+ // Server-assigned monotonic delivery sequence, ordered by commit (assigned from
651
+ // a counter row locked inside the append transaction, so delivery order equals
652
+ // visibility order even across instances). Drives the gap-free server->client
653
+ // delivery watermark. Nullable only for pre-column rows, backfilled on startup.
654
+ deliverySeq: bigint("delivery_seq", { mode: "number" })
634
655
  },
635
656
  (table) => ({
636
657
  nodeSeqIdx: index("idx_pg_node_seq").on(table.nodeId, table.sequenceNumber),
637
658
  collectionIdx: index("idx_pg_collection").on(table.collection),
638
- receivedIdx: index("idx_pg_received").on(table.receivedAt)
659
+ receivedIdx: index("idx_pg_received").on(table.receivedAt),
660
+ deliveryIdx: index("idx_pg_delivery_seq").on(table.deliverySeq)
639
661
  })
640
662
  );
641
663
  var pgSyncState = pgTable("sync_state", {
@@ -652,15 +674,43 @@ var PostgresServerStore = class {
652
674
  ready;
653
675
  schema = null;
654
676
  closed = false;
655
- constructor(db, nodeId) {
677
+ /**
678
+ * Monotonic counter for this node's server-originated sequence numbers. Lazily
679
+ * seeded from the persisted version vector on first use, then advanced in memory
680
+ * (a synchronous `++`, atomic on the single JS thread) so concurrent conditional
681
+ * applies never receive the same number. Null means "not yet seeded"; reset to
682
+ * null after a backup import so it re-seeds from the restored version vector.
683
+ */
684
+ sequenceCounter = null;
685
+ /**
686
+ * Time source for HLC timestamps on server-originated operations. Injectable so a
687
+ * test can freeze wall-clock time and prove the conditional-apply ordering holds
688
+ * under same-millisecond commits (the case the advisory lock and clock advance
689
+ * exist to make correct). Defaults to the system clock in production.
690
+ */
691
+ timeSource;
692
+ constructor(db, nodeId, timeSource) {
656
693
  this.db = db;
657
694
  this.nodeId = nodeId ?? generateUUIDv72();
695
+ this.timeSource = timeSource ?? { now: () => Date.now() };
658
696
  this.ready = this.initialize();
659
697
  }
660
698
  getVersionVector() {
661
699
  this.assertOpen();
662
700
  return new Map(this.versionVector);
663
701
  }
702
+ /**
703
+ * Atomically reserve this node's next server-operation sequence number. The
704
+ * synchronous increment cannot interleave on the single JS thread, so two
705
+ * concurrent conditional applies are always handed distinct numbers.
706
+ */
707
+ reserveSequenceNumber() {
708
+ if (this.sequenceCounter === null) {
709
+ this.sequenceCounter = this.versionVector.get(this.nodeId) ?? 0;
710
+ }
711
+ this.sequenceCounter += 1;
712
+ return this.sequenceCounter;
713
+ }
664
714
  getNodeId() {
665
715
  return this.nodeId;
666
716
  }
@@ -698,8 +748,9 @@ var PostgresServerStore = class {
698
748
  return "duplicate";
699
749
  }
700
750
  const now = Date.now();
701
- const row = this.serializeOperation(op, now);
702
751
  await this.db.transaction(async (tx) => {
752
+ const deliverySeq = await this.nextDeliverySeq(tx);
753
+ const row = this.serializeOperation(op, now, deliverySeq);
703
754
  await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
704
755
  await tx.insert(pgSyncState).values({
705
756
  nodeId: op.nodeId,
@@ -722,6 +773,73 @@ var PostgresServerStore = class {
722
773
  }
723
774
  return "applied";
724
775
  }
776
+ /**
777
+ * Conditionally apply operations atomically, serialized across server instances
778
+ * on the target record via a transaction-scoped advisory lock. The lock makes
779
+ * the read-decide-write cycle atomic against concurrent instances, so a cap
780
+ * check like `responseCount < max` cannot be passed by two admissions at once.
781
+ */
782
+ async applyConditional(input) {
783
+ this.assertOpen();
784
+ await this.ready;
785
+ this.assertSchema();
786
+ this.assertCollection(input.target.collection);
787
+ const schema = this.schema;
788
+ const collectionDef = schema.collections[input.target.collection];
789
+ const lockKey = `kora:${input.target.collection}:${input.target.id}`;
790
+ const result = await this.db.transaction(async (tx) => {
791
+ await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`);
792
+ if (input.idempotencyKey) {
793
+ const existing = await tx.execute(
794
+ sql`SELECT 1 FROM ${sql.raw(quoteIdent2(input.idempotencyKey.collection))} WHERE id = ${input.idempotencyKey.id} AND _deleted = 0 LIMIT 1`
795
+ );
796
+ if (existing.length > 0) {
797
+ return { admitted: true, idempotent: true, applied: [] };
798
+ }
799
+ }
800
+ const rows = await tx.execute(
801
+ sql`SELECT * FROM ${sql.raw(quoteIdent2(input.target.collection))} WHERE id = ${input.target.id} AND _deleted = 0`
802
+ );
803
+ const current = rows.length > 0 ? this.deserializeRow(rows[0], collectionDef) : null;
804
+ if (!input.admit(current)) {
805
+ return { admitted: false, idempotent: false, applied: [] };
806
+ }
807
+ const clock = new HybridLogicalClock(this.nodeId, this.timeSource);
808
+ const latest = await this.latestOperationTimestamp(
809
+ tx,
810
+ input.target.collection,
811
+ input.target.id
812
+ );
813
+ if (latest) {
814
+ clock.advanceTo(latest);
815
+ }
816
+ const ops = await input.buildOperations(current, { clock });
817
+ const now = Date.now();
818
+ for (const op of ops) {
819
+ const deliverySeq = await this.nextDeliverySeq(tx);
820
+ const row = this.serializeOperation(op, now, deliverySeq);
821
+ await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
822
+ await tx.insert(pgSyncState).values({ nodeId: op.nodeId, maxSequenceNumber: op.sequenceNumber, lastSeenAt: now }).onConflictDoUpdate({
823
+ target: pgSyncState.nodeId,
824
+ set: {
825
+ maxSequenceNumber: sql`GREATEST(${pgSyncState.maxSequenceNumber}, ${op.sequenceNumber})`,
826
+ lastSeenAt: sql`${now}`
827
+ }
828
+ });
829
+ if (this.schema?.collections[op.collection]) {
830
+ await this.rebuildMaterializedRecord(tx, op.collection, op.recordId);
831
+ }
832
+ }
833
+ return { admitted: true, idempotent: false, applied: ops };
834
+ });
835
+ for (const op of result.applied) {
836
+ const currentMax = this.versionVector.get(op.nodeId) ?? 0;
837
+ if (op.sequenceNumber > currentMax) {
838
+ this.versionVector.set(op.nodeId, op.sequenceNumber);
839
+ }
840
+ }
841
+ return result;
842
+ }
725
843
  async getOperationRange(nodeId, fromSeq, toSeq) {
726
844
  this.assertOpen();
727
845
  await this.ready;
@@ -736,6 +854,23 @@ var PostgresServerStore = class {
736
854
  const result = await this.db.select({ value: count() }).from(pgOperations);
737
855
  return result[0]?.value ?? 0;
738
856
  }
857
+ async getMaxDeliverySequence() {
858
+ this.assertOpen();
859
+ await this.ready;
860
+ const rows = await this.db.execute(
861
+ sql`SELECT COALESCE(MAX(delivery_seq), 0) AS m FROM operations`
862
+ );
863
+ return Number(rows[0]?.m ?? 0);
864
+ }
865
+ async getOperationsAfterDelivery(afterDeliverySequence, limit) {
866
+ this.assertOpen();
867
+ await this.ready;
868
+ const rows = await this.db.select().from(pgOperations).where(gt(pgOperations.deliverySeq, afterDeliverySequence)).orderBy(asc(pgOperations.deliverySeq)).limit(limit);
869
+ return rows.map((row) => ({
870
+ operation: this.deserializeOperation(row),
871
+ deliverySequence: row.deliverySeq ?? 0
872
+ }));
873
+ }
739
874
  async materializeCollection(collection) {
740
875
  this.assertOpen();
741
876
  await this.ready;
@@ -799,7 +934,7 @@ var PostgresServerStore = class {
799
934
  this.assertOpen();
800
935
  await this.ready;
801
936
  const { buildServerBackup } = await import("./server-backup-MOICK6MI.js");
802
- const rows = await this.db.select().from(pgOperations).orderBy(asc(pgOperations.sequenceNumber));
937
+ const rows = await this.db.select().from(pgOperations).orderBy(asc(pgOperations.deliverySeq));
803
938
  const operations2 = rows.map((row) => this.deserializeOperation(row));
804
939
  return buildServerBackup(this.nodeId, operations2, this.versionVector);
805
940
  }
@@ -814,6 +949,7 @@ var PostgresServerStore = class {
814
949
  const result = await this.applyRemoteOperation(op);
815
950
  if (result === "applied") restored++;
816
951
  }
952
+ this.sequenceCounter = null;
817
953
  return { operationsRestored: restored, success: true };
818
954
  }
819
955
  const now = Date.now();
@@ -823,20 +959,49 @@ var PostgresServerStore = class {
823
959
  for (const [nid, seq] of versionVector) {
824
960
  await tx.insert(pgSyncState).values({ nodeId: nid, maxSequenceNumber: seq, lastSeenAt: now }).onConflictDoNothing({ target: pgSyncState.nodeId });
825
961
  }
962
+ let deliverySeq = 0;
826
963
  for (const op of operations2) {
827
- const row = this.serializeOperation(op, now);
964
+ deliverySeq += 1;
965
+ const row = this.serializeOperation(op, now, deliverySeq);
828
966
  await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
829
967
  }
968
+ await tx.execute(
969
+ sql`INSERT INTO delivery_counter (id, value) VALUES (1, ${deliverySeq})
970
+ ON CONFLICT (id) DO UPDATE SET value = ${deliverySeq}`
971
+ );
830
972
  });
831
973
  this.versionVector.clear();
832
974
  for (const [nid, seq] of versionVector) {
833
975
  this.versionVector.set(nid, seq);
834
976
  }
977
+ this.sequenceCounter = null;
835
978
  return { operationsRestored: operations2.length, success: true };
836
979
  }
837
980
  // ---------------------------------------------------------------------------
838
981
  // Materialization internals
839
982
  // ---------------------------------------------------------------------------
983
+ /**
984
+ * The HLC timestamp of the most recent operation on a record, by total order
985
+ * (wallTime, logical, nodeId), or null when the record has no operations. Used
986
+ * by {@link applyConditional} to advance its clock past the record's latest
987
+ * write so newly built operations sort strictly after it.
988
+ */
989
+ async latestOperationTimestamp(txOrDb, collection, recordId) {
990
+ const rows = await txOrDb.select({
991
+ wallTime: pgOperations.wallTime,
992
+ logical: pgOperations.logical,
993
+ timestampNodeId: pgOperations.timestampNodeId
994
+ }).from(pgOperations).where(and(eq(pgOperations.collection, collection), eq(pgOperations.recordId, recordId))).orderBy(
995
+ desc(pgOperations.wallTime),
996
+ desc(pgOperations.logical),
997
+ desc(pgOperations.timestampNodeId)
998
+ ).limit(1);
999
+ const row = rows[0];
1000
+ if (!row) {
1001
+ return null;
1002
+ }
1003
+ return { wallTime: row.wallTime, logical: row.logical, nodeId: row.timestampNodeId };
1004
+ }
840
1005
  /**
841
1006
  * Rebuild a single record in the materialized collection table by replaying
842
1007
  * all operations for that record.
@@ -847,15 +1012,17 @@ var PostgresServerStore = class {
847
1012
  const ops = await txOrDb.select({
848
1013
  type: pgOperations.type,
849
1014
  data: pgOperations.data,
1015
+ atomicOps: pgOperations.atomicOps,
850
1016
  wallTime: pgOperations.wallTime
851
1017
  }).from(pgOperations).where(and(eq(pgOperations.collection, collection), eq(pgOperations.recordId, recordId))).orderBy(
852
1018
  asc(pgOperations.wallTime),
853
1019
  asc(pgOperations.logical),
854
- asc(pgOperations.sequenceNumber)
1020
+ asc(pgOperations.timestampNodeId)
855
1021
  );
856
1022
  const parsedOps = ops.map((op) => ({
857
1023
  type: op.type,
858
- data: op.data !== null ? JSON.parse(op.data) : null
1024
+ data: op.data !== null ? JSON.parse(op.data) : null,
1025
+ atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
859
1026
  }));
860
1027
  const recordData = replayOperationsForRecord(parsedOps);
861
1028
  const fieldNames = Object.keys(collectionDef.fields);
@@ -924,11 +1091,12 @@ var PostgresServerStore = class {
924
1091
  recordId: pgOperations.recordId,
925
1092
  type: pgOperations.type,
926
1093
  data: pgOperations.data,
1094
+ atomicOps: pgOperations.atomicOps,
927
1095
  wallTime: pgOperations.wallTime
928
1096
  }).from(pgOperations).where(eq(pgOperations.collection, collectionName)).orderBy(
929
1097
  asc(pgOperations.wallTime),
930
1098
  asc(pgOperations.logical),
931
- asc(pgOperations.sequenceNumber)
1099
+ asc(pgOperations.timestampNodeId)
932
1100
  );
933
1101
  if (allOps.length === 0) return;
934
1102
  const grouped = /* @__PURE__ */ new Map();
@@ -944,7 +1112,8 @@ var PostgresServerStore = class {
944
1112
  for (const [recordId, recordOps] of grouped) {
945
1113
  const parsedOps = recordOps.map((op) => ({
946
1114
  type: op.type,
947
- data: op.data !== null ? JSON.parse(op.data) : null
1115
+ data: op.data !== null ? JSON.parse(op.data) : null,
1116
+ atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
948
1117
  }));
949
1118
  const recordData = replayOperationsForRecord(parsedOps);
950
1119
  if (recordData) {
@@ -1069,44 +1238,92 @@ var PostgresServerStore = class {
1069
1238
  }
1070
1239
  }
1071
1240
  async ensureTables() {
1072
- await this.db.execute(sql`
1073
- CREATE TABLE IF NOT EXISTS operations (
1074
- id TEXT PRIMARY KEY,
1075
- node_id TEXT NOT NULL,
1076
- type TEXT NOT NULL,
1077
- collection TEXT NOT NULL,
1078
- record_id TEXT NOT NULL,
1079
- data TEXT,
1080
- previous_data TEXT,
1081
- wall_time BIGINT NOT NULL,
1082
- logical INTEGER NOT NULL,
1083
- timestamp_node_id TEXT NOT NULL,
1084
- sequence_number INTEGER NOT NULL,
1085
- causal_deps TEXT NOT NULL DEFAULT '[]',
1086
- schema_version INTEGER NOT NULL,
1087
- received_at BIGINT NOT NULL
1088
- )
1089
- `);
1090
- await this.db.execute(
1091
- sql`CREATE INDEX IF NOT EXISTS idx_node_seq ON operations (node_id, sequence_number)`
1092
- );
1093
- await this.db.execute(sql`CREATE INDEX IF NOT EXISTS idx_collection ON operations (collection)`);
1094
- await this.db.execute(sql`CREATE INDEX IF NOT EXISTS idx_received ON operations (received_at)`);
1095
- await this.db.execute(
1096
- sql`CREATE INDEX IF NOT EXISTS idx_collection_record ON operations (collection, record_id)`
1241
+ await this.db.transaction(async (tx) => {
1242
+ await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended('kora:ensure-tables', 0))`);
1243
+ await tx.execute(sql`
1244
+ CREATE TABLE IF NOT EXISTS operations (
1245
+ id TEXT PRIMARY KEY,
1246
+ node_id TEXT NOT NULL,
1247
+ type TEXT NOT NULL,
1248
+ collection TEXT NOT NULL,
1249
+ record_id TEXT NOT NULL,
1250
+ data TEXT,
1251
+ previous_data TEXT,
1252
+ atomic_ops TEXT,
1253
+ wall_time BIGINT NOT NULL,
1254
+ logical INTEGER NOT NULL,
1255
+ timestamp_node_id TEXT NOT NULL,
1256
+ sequence_number INTEGER NOT NULL,
1257
+ causal_deps TEXT NOT NULL DEFAULT '[]',
1258
+ schema_version INTEGER NOT NULL,
1259
+ received_at BIGINT NOT NULL
1260
+ )
1261
+ `);
1262
+ await tx.execute(sql`ALTER TABLE operations ADD COLUMN IF NOT EXISTS atomic_ops TEXT`);
1263
+ await tx.execute(sql`ALTER TABLE operations ADD COLUMN IF NOT EXISTS delivery_seq BIGINT`);
1264
+ await tx.execute(
1265
+ sql`CREATE INDEX IF NOT EXISTS idx_node_seq ON operations (node_id, sequence_number)`
1266
+ );
1267
+ await tx.execute(sql`CREATE INDEX IF NOT EXISTS idx_collection ON operations (collection)`);
1268
+ await tx.execute(sql`CREATE INDEX IF NOT EXISTS idx_received ON operations (received_at)`);
1269
+ await tx.execute(
1270
+ sql`CREATE INDEX IF NOT EXISTS idx_delivery_seq ON operations (delivery_seq)`
1271
+ );
1272
+ await tx.execute(
1273
+ sql`CREATE INDEX IF NOT EXISTS idx_collection_record ON operations (collection, record_id)`
1274
+ );
1275
+ await tx.execute(sql`
1276
+ CREATE TABLE IF NOT EXISTS sync_state (
1277
+ node_id TEXT PRIMARY KEY,
1278
+ max_sequence_number INTEGER NOT NULL,
1279
+ last_seen_at BIGINT NOT NULL
1280
+ )
1281
+ `);
1282
+ await tx.execute(sql`
1283
+ CREATE TABLE IF NOT EXISTS delivery_counter (
1284
+ id INTEGER PRIMARY KEY,
1285
+ value BIGINT NOT NULL
1286
+ )
1287
+ `);
1288
+ await tx.execute(sql`
1289
+ WITH ordered AS (
1290
+ SELECT id, ROW_NUMBER() OVER (
1291
+ ORDER BY received_at ASC, sequence_number ASC, id ASC
1292
+ ) + COALESCE((SELECT MAX(delivery_seq) FROM operations), 0) AS rn
1293
+ FROM operations WHERE delivery_seq IS NULL
1294
+ )
1295
+ UPDATE operations o SET delivery_seq = ordered.rn
1296
+ FROM ordered WHERE o.id = ordered.id
1297
+ `);
1298
+ await tx.execute(sql`
1299
+ INSERT INTO delivery_counter (id, value)
1300
+ VALUES (1, COALESCE((SELECT MAX(delivery_seq) FROM operations), 0))
1301
+ ON CONFLICT (id) DO UPDATE
1302
+ SET value = GREATEST(delivery_counter.value, EXCLUDED.value)
1303
+ `);
1304
+ });
1305
+ }
1306
+ /**
1307
+ * Reserve the next delivery sequence inside an open append transaction. The
1308
+ * `UPDATE ... RETURNING` locks the counter row until this transaction commits,
1309
+ * serializing assignment into commit order (see the counter table comment).
1310
+ */
1311
+ async nextDeliverySeq(tx) {
1312
+ const rows = await tx.execute(
1313
+ sql`UPDATE delivery_counter SET value = value + 1 WHERE id = 1 RETURNING value`
1097
1314
  );
1098
- await this.db.execute(sql`
1099
- CREATE TABLE IF NOT EXISTS sync_state (
1100
- node_id TEXT PRIMARY KEY,
1101
- max_sequence_number INTEGER NOT NULL,
1102
- last_seen_at BIGINT NOT NULL
1103
- )
1104
- `);
1315
+ const value = rows[0]?.value;
1316
+ if (value === void 0 || value === null) {
1317
+ throw new Error(
1318
+ "delivery_counter row (id=1) is missing; the operations log cannot assign delivery sequences"
1319
+ );
1320
+ }
1321
+ return Number(value);
1105
1322
  }
1106
1323
  // ---------------------------------------------------------------------------
1107
1324
  // Operation serialization
1108
1325
  // ---------------------------------------------------------------------------
1109
- serializeOperation(op, receivedAt) {
1326
+ serializeOperation(op, receivedAt, deliverySeq) {
1110
1327
  return {
1111
1328
  id: op.id,
1112
1329
  nodeId: op.nodeId,
@@ -1115,16 +1332,19 @@ var PostgresServerStore = class {
1115
1332
  recordId: op.recordId,
1116
1333
  data: op.data !== null ? JSON.stringify(op.data) : null,
1117
1334
  previousData: op.previousData !== null ? JSON.stringify(op.previousData) : null,
1335
+ atomicOps: op.atomicOps && Object.keys(op.atomicOps).length > 0 ? JSON.stringify(op.atomicOps) : null,
1118
1336
  wallTime: op.timestamp.wallTime,
1119
1337
  logical: op.timestamp.logical,
1120
1338
  timestampNodeId: op.timestamp.nodeId,
1121
1339
  sequenceNumber: op.sequenceNumber,
1122
1340
  causalDeps: JSON.stringify(op.causalDeps),
1123
1341
  schemaVersion: op.schemaVersion,
1124
- receivedAt
1342
+ receivedAt,
1343
+ deliverySeq
1125
1344
  };
1126
1345
  }
1127
1346
  deserializeOperation(row) {
1347
+ const atomicOps = row.atomicOps != null ? JSON.parse(row.atomicOps) : void 0;
1128
1348
  return {
1129
1349
  id: row.id,
1130
1350
  nodeId: row.nodeId,
@@ -1140,7 +1360,8 @@ var PostgresServerStore = class {
1140
1360
  },
1141
1361
  sequenceNumber: row.sequenceNumber,
1142
1362
  causalDeps: JSON.parse(row.causalDeps),
1143
- schemaVersion: row.schemaVersion
1363
+ schemaVersion: row.schemaVersion,
1364
+ ...atomicOps ? { atomicOps } : {}
1144
1365
  };
1145
1366
  }
1146
1367
  // ---------------------------------------------------------------------------
@@ -1192,7 +1413,7 @@ async function loadPostgresDeps() {
1192
1413
  // src/store/sqlite-server-store.ts
1193
1414
  import { createRequire } from "module";
1194
1415
  import { generateUUIDv7 as generateUUIDv73, quoteIdent as quoteIdent3 } from "@korajs/core";
1195
- import { and as and2, asc as asc2, between as between2, count as count2, eq as eq2, sql as sql2 } from "drizzle-orm";
1416
+ import { and as and2, asc as asc2, between as between2, count as count2, eq as eq2, gt as gt2, sql as sql2 } from "drizzle-orm";
1196
1417
 
1197
1418
  // src/store/drizzle-schema.ts
1198
1419
  import { index as index2, integer as integer2, sqliteTable, text as text2 } from "drizzle-orm/sqlite-core";
@@ -1208,6 +1429,8 @@ var operations = sqliteTable(
1208
1429
  // JSON-serialized, null for deletes
1209
1430
  previousData: text2("previous_data"),
1210
1431
  // JSON-serialized, null for insert/delete
1432
+ atomicOps: text2("atomic_ops"),
1433
+ // JSON-serialized Record<field, AtomicOp>, null when none
1211
1434
  wallTime: integer2("wall_time").notNull(),
1212
1435
  logical: integer2("logical").notNull(),
1213
1436
  timestampNodeId: text2("timestamp_node_id").notNull(),
@@ -1215,12 +1438,17 @@ var operations = sqliteTable(
1215
1438
  causalDeps: text2("causal_deps").notNull().default("[]"),
1216
1439
  // JSON array of op IDs
1217
1440
  schemaVersion: integer2("schema_version").notNull(),
1218
- receivedAt: integer2("received_at").notNull()
1441
+ receivedAt: integer2("received_at").notNull(),
1442
+ // Server-assigned monotonic delivery sequence, ordered by commit. Drives the
1443
+ // gap-free server->client delivery watermark. Nullable only for rows written
1444
+ // before the column existed; those are backfilled on startup.
1445
+ deliverySeq: integer2("delivery_seq")
1219
1446
  },
1220
1447
  (table) => ({
1221
1448
  nodeSeqIdx: index2("idx_node_seq").on(table.nodeId, table.sequenceNumber),
1222
1449
  collectionIdx: index2("idx_collection").on(table.collection),
1223
- receivedIdx: index2("idx_received").on(table.receivedAt)
1450
+ receivedIdx: index2("idx_received").on(table.receivedAt),
1451
+ deliveryIdx: index2("idx_delivery_seq").on(table.deliverySeq)
1224
1452
  })
1225
1453
  );
1226
1454
  var syncState = sqliteTable("sync_state", {
@@ -1236,6 +1464,12 @@ var SqliteServerStore = class {
1236
1464
  db;
1237
1465
  schema = null;
1238
1466
  closed = false;
1467
+ /**
1468
+ * Next delivery sequence to assign. Single-writer (better-sqlite3 is synchronous),
1469
+ * so a plain in-memory counter, seeded from MAX(delivery_seq) at startup, gives
1470
+ * commit-order delivery sequence. Assigned inside the append transaction.
1471
+ */
1472
+ deliverySeqCounter = 0;
1239
1473
  constructor(db, nodeId) {
1240
1474
  this.db = db;
1241
1475
  this.nodeId = nodeId ?? generateUUIDv73();
@@ -1281,12 +1515,16 @@ var SqliteServerStore = class {
1281
1515
  async applyRemoteOperation(op) {
1282
1516
  this.assertOpen();
1283
1517
  const now = Date.now();
1284
- const row = this.serializeOperation(op, now);
1285
1518
  const result = this.db.transaction((tx) => {
1286
- const insertResult = tx.insert(operations).values(row).onConflictDoNothing({ target: operations.id }).run();
1287
- if (insertResult.changes === 0) {
1519
+ const existing = tx.all(
1520
+ sql2`SELECT id FROM operations WHERE id = ${op.id} LIMIT 1`
1521
+ );
1522
+ if (existing.length > 0) {
1288
1523
  return "duplicate";
1289
1524
  }
1525
+ this.deliverySeqCounter += 1;
1526
+ const row = this.serializeOperation(op, now, this.deliverySeqCounter);
1527
+ tx.insert(operations).values(row).run();
1290
1528
  tx.insert(syncState).values({
1291
1529
  nodeId: op.nodeId,
1292
1530
  maxSequenceNumber: op.sequenceNumber,
@@ -1315,6 +1553,21 @@ var SqliteServerStore = class {
1315
1553
  const result = this.db.select({ value: count2() }).from(operations).all();
1316
1554
  return result[0]?.value ?? 0;
1317
1555
  }
1556
+ async getMaxDeliverySequence() {
1557
+ this.assertOpen();
1558
+ const rows = this.db.all(
1559
+ sql2`SELECT MAX(delivery_seq) AS m FROM operations`
1560
+ );
1561
+ return rows[0]?.m ?? 0;
1562
+ }
1563
+ async getOperationsAfterDelivery(afterDeliverySequence, limit) {
1564
+ this.assertOpen();
1565
+ const rows = this.db.select().from(operations).where(gt2(operations.deliverySeq, afterDeliverySequence)).orderBy(asc2(operations.deliverySeq)).limit(limit).all();
1566
+ return rows.map((row) => ({
1567
+ operation: this.deserializeOperation(row),
1568
+ deliverySequence: row.deliverySeq ?? 0
1569
+ }));
1570
+ }
1318
1571
  async materializeCollection(collection) {
1319
1572
  this.assertOpen();
1320
1573
  if (this.schema?.collections[collection]) {
@@ -1372,7 +1625,7 @@ var SqliteServerStore = class {
1372
1625
  async exportBackup() {
1373
1626
  this.assertOpen();
1374
1627
  const { buildServerBackup } = await import("./server-backup-MOICK6MI.js");
1375
- const rows = this.db.select().from(operations).all();
1628
+ const rows = this.db.select().from(operations).orderBy(asc2(operations.deliverySeq)).all();
1376
1629
  const deserialized = rows.map((row) => this.deserializeOperation(row));
1377
1630
  const vv = this.getVersionVector();
1378
1631
  return buildServerBackup(this.nodeId, deserialized, vv);
@@ -1395,10 +1648,13 @@ var SqliteServerStore = class {
1395
1648
  for (const [nid, seq] of versionVector) {
1396
1649
  tx.insert(syncState).values({ nodeId: nid, maxSequenceNumber: seq, lastSeenAt: Date.now() }).run();
1397
1650
  }
1651
+ let deliverySeq = 0;
1398
1652
  for (const op of ops) {
1399
- const row = this.serializeOperation(op, Date.now());
1653
+ deliverySeq += 1;
1654
+ const row = this.serializeOperation(op, Date.now(), deliverySeq);
1400
1655
  tx.insert(operations).values(row).run();
1401
1656
  }
1657
+ this.deliverySeqCounter = deliverySeq;
1402
1658
  });
1403
1659
  return { operationsRestored: ops.length, success: true };
1404
1660
  }
@@ -1416,11 +1672,13 @@ var SqliteServerStore = class {
1416
1672
  const ops = txOrDb.select({
1417
1673
  type: operations.type,
1418
1674
  data: operations.data,
1675
+ atomicOps: operations.atomicOps,
1419
1676
  wallTime: operations.wallTime
1420
- }).from(operations).where(and2(eq2(operations.collection, collection), eq2(operations.recordId, recordId))).orderBy(asc2(operations.wallTime), asc2(operations.logical), asc2(operations.sequenceNumber)).all();
1677
+ }).from(operations).where(and2(eq2(operations.collection, collection), eq2(operations.recordId, recordId))).orderBy(asc2(operations.wallTime), asc2(operations.logical), asc2(operations.timestampNodeId)).all();
1421
1678
  const parsedOps = ops.map((op) => ({
1422
1679
  type: op.type,
1423
- data: op.data !== null ? JSON.parse(op.data) : null
1680
+ data: op.data !== null ? JSON.parse(op.data) : null,
1681
+ atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
1424
1682
  }));
1425
1683
  const recordData = replayOperationsForRecord(parsedOps);
1426
1684
  const fieldNames = Object.keys(collectionDef.fields);
@@ -1492,8 +1750,9 @@ var SqliteServerStore = class {
1492
1750
  recordId: operations.recordId,
1493
1751
  type: operations.type,
1494
1752
  data: operations.data,
1753
+ atomicOps: operations.atomicOps,
1495
1754
  wallTime: operations.wallTime
1496
- }).from(operations).where(eq2(operations.collection, collectionName)).orderBy(asc2(operations.wallTime), asc2(operations.logical), asc2(operations.sequenceNumber)).all();
1755
+ }).from(operations).where(eq2(operations.collection, collectionName)).orderBy(asc2(operations.wallTime), asc2(operations.logical), asc2(operations.timestampNodeId)).all();
1497
1756
  if (allOps.length === 0) return;
1498
1757
  const grouped = /* @__PURE__ */ new Map();
1499
1758
  for (const op of allOps) {
@@ -1509,7 +1768,8 @@ var SqliteServerStore = class {
1509
1768
  for (const [recordId, recordOps] of grouped) {
1510
1769
  const parsedOps = recordOps.map((op) => ({
1511
1770
  type: op.type,
1512
- data: op.data !== null ? JSON.parse(op.data) : null
1771
+ data: op.data !== null ? JSON.parse(op.data) : null,
1772
+ atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
1513
1773
  }));
1514
1774
  const recordData = replayOperationsForRecord(parsedOps);
1515
1775
  if (recordData) {
@@ -1633,6 +1893,7 @@ var SqliteServerStore = class {
1633
1893
  record_id TEXT NOT NULL,
1634
1894
  data TEXT,
1635
1895
  previous_data TEXT,
1896
+ atomic_ops TEXT,
1636
1897
  wall_time INTEGER NOT NULL,
1637
1898
  logical INTEGER NOT NULL,
1638
1899
  timestamp_node_id TEXT NOT NULL,
@@ -1642,9 +1903,30 @@ var SqliteServerStore = class {
1642
1903
  received_at INTEGER NOT NULL
1643
1904
  )
1644
1905
  `);
1906
+ try {
1907
+ this.db.run(sql2`ALTER TABLE operations ADD COLUMN atomic_ops TEXT`);
1908
+ } catch (e) {
1909
+ const msg = e instanceof Error ? e.message : "";
1910
+ const causeMsg = e instanceof Error && e.cause instanceof Error ? e.cause.message : "";
1911
+ if (!msg.includes("duplicate column") && !causeMsg.includes("duplicate column")) {
1912
+ throw e;
1913
+ }
1914
+ }
1915
+ try {
1916
+ this.db.run(sql2`ALTER TABLE operations ADD COLUMN delivery_seq INTEGER`);
1917
+ } catch (e) {
1918
+ const msg = e instanceof Error ? e.message : "";
1919
+ const causeMsg = e instanceof Error && e.cause instanceof Error ? e.cause.message : "";
1920
+ if (!msg.includes("duplicate column") && !causeMsg.includes("duplicate column")) {
1921
+ throw e;
1922
+ }
1923
+ }
1645
1924
  this.db.run(sql2`
1646
1925
  CREATE INDEX IF NOT EXISTS idx_node_seq ON operations (node_id, sequence_number)
1647
1926
  `);
1927
+ this.db.run(sql2`
1928
+ CREATE INDEX IF NOT EXISTS idx_delivery_seq ON operations (delivery_seq)
1929
+ `);
1648
1930
  this.db.run(sql2`
1649
1931
  CREATE INDEX IF NOT EXISTS idx_collection ON operations (collection)
1650
1932
  `);
@@ -1661,11 +1943,34 @@ var SqliteServerStore = class {
1661
1943
  last_seen_at INTEGER NOT NULL
1662
1944
  )
1663
1945
  `);
1946
+ this.backfillDeliverySequence();
1947
+ }
1948
+ /**
1949
+ * Assign delivery sequences to any operations written before the column existed,
1950
+ * then seed the in-memory counter from the current max. Ordered by
1951
+ * (received_at, sequence_number, id) so the backfill is deterministic and stable
1952
+ * across restarts. A one-time migration: after it runs, all rows have a value.
1953
+ */
1954
+ backfillDeliverySequence() {
1955
+ this.db.transaction((tx) => {
1956
+ const maxRow = tx.all(
1957
+ sql2`SELECT MAX(delivery_seq) AS m FROM operations`
1958
+ );
1959
+ let next = maxRow[0]?.m ?? 0;
1960
+ const nullRows = tx.all(
1961
+ sql2`SELECT id FROM operations WHERE delivery_seq IS NULL ORDER BY received_at ASC, sequence_number ASC, id ASC`
1962
+ );
1963
+ for (const row of nullRows) {
1964
+ next += 1;
1965
+ tx.run(sql2`UPDATE operations SET delivery_seq = ${next} WHERE id = ${row.id}`);
1966
+ }
1967
+ this.deliverySeqCounter = next;
1968
+ });
1664
1969
  }
1665
1970
  // ---------------------------------------------------------------------------
1666
1971
  // Operation serialization
1667
1972
  // ---------------------------------------------------------------------------
1668
- serializeOperation(op, receivedAt) {
1973
+ serializeOperation(op, receivedAt, deliverySeq) {
1669
1974
  return {
1670
1975
  id: op.id,
1671
1976
  nodeId: op.nodeId,
@@ -1674,16 +1979,19 @@ var SqliteServerStore = class {
1674
1979
  recordId: op.recordId,
1675
1980
  data: op.data !== null ? JSON.stringify(op.data) : null,
1676
1981
  previousData: op.previousData !== null ? JSON.stringify(op.previousData) : null,
1982
+ atomicOps: op.atomicOps && Object.keys(op.atomicOps).length > 0 ? JSON.stringify(op.atomicOps) : null,
1677
1983
  wallTime: op.timestamp.wallTime,
1678
1984
  logical: op.timestamp.logical,
1679
1985
  timestampNodeId: op.timestamp.nodeId,
1680
1986
  sequenceNumber: op.sequenceNumber,
1681
1987
  causalDeps: JSON.stringify(op.causalDeps),
1682
1988
  schemaVersion: op.schemaVersion,
1683
- receivedAt
1989
+ receivedAt,
1990
+ deliverySeq
1684
1991
  };
1685
1992
  }
1686
1993
  deserializeOperation(row) {
1994
+ const atomicOps = row.atomicOps != null ? JSON.parse(row.atomicOps) : void 0;
1687
1995
  return {
1688
1996
  id: row.id,
1689
1997
  nodeId: row.nodeId,
@@ -1699,7 +2007,8 @@ var SqliteServerStore = class {
1699
2007
  },
1700
2008
  sequenceNumber: row.sequenceNumber,
1701
2009
  causalDeps: JSON.parse(row.causalDeps),
1702
- schemaVersion: row.schemaVersion
2010
+ schemaVersion: row.schemaVersion,
2011
+ ...atomicOps ? { atomicOps } : {}
1703
2012
  };
1704
2013
  }
1705
2014
  // ---------------------------------------------------------------------------
@@ -1981,10 +2290,10 @@ function createServerReferentialContext(store) {
1981
2290
  }
1982
2291
 
1983
2292
  // src/apply/server-side-effect-operation.ts
1984
- import { HybridLogicalClock, createOperation } from "@korajs/core";
2293
+ import { HybridLogicalClock as HybridLogicalClock2, createOperation } from "@korajs/core";
1985
2294
  async function createServerSideEffectOperation(store, parentOp, effect, schemaVersion, sequenceNumber) {
1986
2295
  const nodeId = store.getNodeId();
1987
- const clock = new HybridLogicalClock(nodeId);
2296
+ const clock = new HybridLogicalClock2(nodeId);
1988
2297
  clock.receive(parentOp.timestamp);
1989
2298
  return createOperation(
1990
2299
  {
@@ -2002,6 +2311,9 @@ async function createServerSideEffectOperation(store, parentOp, effect, schemaVe
2002
2311
  );
2003
2312
  }
2004
2313
  function nextServerSequenceNumber(store) {
2314
+ if (store.reserveSequenceNumber) {
2315
+ return store.reserveSequenceNumber();
2316
+ }
2005
2317
  const nodeId = store.getNodeId();
2006
2318
  const current = store.getVersionVector().get(nodeId) ?? 0;
2007
2319
  return current + 1;
@@ -2043,16 +2355,14 @@ async function applyServerOperation(store, op, relationLookup) {
2043
2355
  return { result: primaryResult, appliedOperations: [] };
2044
2356
  }
2045
2357
  const appliedOperations = [op];
2046
- let serverSeq = nextServerSequenceNumber(store);
2047
2358
  for (const effect of referential.sideEffectOps) {
2048
2359
  const sideOp = await createServerSideEffectOperation(
2049
2360
  store,
2050
2361
  op,
2051
2362
  effect,
2052
2363
  op.schemaVersion,
2053
- serverSeq
2364
+ nextServerSequenceNumber(store)
2054
2365
  );
2055
- serverSeq += 1;
2056
2366
  const sideResult = await store.applyRemoteOperation(sideOp);
2057
2367
  if (sideResult === "applied") {
2058
2368
  appliedOperations.push(sideOp);
@@ -2110,11 +2420,11 @@ function mergeScopeMaps(base, overlay, source) {
2110
2420
  }
2111
2421
 
2112
2422
  // src/scopes/server-scope-filter.ts
2113
- function operationMatchesScopes(op, scopes) {
2423
+ function operationMatchesScopes(op, scopes, fullRecord) {
2114
2424
  if (!scopes) return true;
2115
2425
  const collectionScope = scopes[op.collection];
2116
2426
  if (!collectionScope) return false;
2117
- const snapshot = buildSnapshot(op);
2427
+ const snapshot = buildSnapshot(op, fullRecord);
2118
2428
  if (!snapshot) return false;
2119
2429
  for (const [field, expected] of Object.entries(collectionScope)) {
2120
2430
  if (snapshot[field] !== expected) {
@@ -2123,11 +2433,19 @@ function operationMatchesScopes(op, scopes) {
2123
2433
  }
2124
2434
  return true;
2125
2435
  }
2126
- function buildSnapshot(op) {
2436
+ function missingScopeFields(op, scopes) {
2437
+ if (!scopes) return [];
2438
+ const collectionScope = scopes[op.collection];
2439
+ if (!collectionScope) return [];
2440
+ const snapshot = buildSnapshot(op);
2441
+ return Object.keys(collectionScope).filter((field) => !snapshot || !(field in snapshot));
2442
+ }
2443
+ function buildSnapshot(op, fullRecord) {
2127
2444
  const previous = asRecord(op.previousData);
2128
2445
  const next = asRecord(op.data);
2129
- if (!previous && !next) return null;
2446
+ if (!previous && !next && !fullRecord) return null;
2130
2447
  return {
2448
+ ...fullRecord ?? {},
2131
2449
  ...previous ?? {},
2132
2450
  ...next ?? {}
2133
2451
  };
@@ -2218,18 +2536,48 @@ function warnIfMultiTenantWithoutScopes(auth, resolvedScopes, schema) {
2218
2536
  "[kora] An authenticated session resolved to no sync scopes, so every user will sync every other user's data. Return per-user sync scopes from your auth provider (for example KoraAuthProvider's resolveScopes) to isolate tenants. Note: declaring sync rules in your schema is not enough on its own \u2014 the per-user values come from the auth provider. (This warning is expected for single-tenant apps where all authenticated users are meant to share the same data.)"
2219
2537
  );
2220
2538
  }
2221
- var ClientSession = class {
2539
+ var ClientSession = class _ClientSession {
2222
2540
  state = "connected";
2223
2541
  clientNodeId = null;
2224
2542
  authContext = null;
2225
2543
  syncQuerySubsets = [];
2226
2544
  resumeDeltaCursor = null;
2545
+ /**
2546
+ * The delivery watermark the client reported at handshake, or null when the client
2547
+ * does not use the delivery watermark (old client, or no watermark yet). When set,
2548
+ * the server drives the gap-free server->client stream from delivery sequences
2549
+ * instead of the version-vector delta.
2550
+ */
2551
+ clientDeliveryWatermark = null;
2552
+ /**
2553
+ * The highest delivery sequence this client has ACKNOWLEDGED (its confirmed
2554
+ * watermark, as reported in acks). Incremental streaming pushes resume from here, not
2555
+ * from the highest sequence sent, so a dropped or unapplied batch is always
2556
+ * re-included by the next push (which re-scans from the acknowledged position). This
2557
+ * is what makes streaming recovery independent of any bounded retransmit buffer: there
2558
+ * is no sent-but-unacked window that a buffer eviction could strand. Seeded from the
2559
+ * client's reported watermark at handshake.
2560
+ */
2561
+ lastAckedDeliverySeq = 0;
2562
+ /** Serializes incremental delivery pushes so their batches never interleave. */
2563
+ deliveryPushChain = Promise.resolve();
2564
+ /**
2565
+ * Relay batches sent to this client but not yet acknowledged, keyed by messageId.
2566
+ * The client acks every operation-batch it applies; on ack the entry is cleared.
2567
+ * {@link retransmitPendingRelays} re-sends anything still unacked, so a relay
2568
+ * dropped by a lossy transport is redelivered instead of leaving the client with a
2569
+ * permanent version-vector gap (a lost operation) that delta sync cannot recover.
2570
+ * Bounded so a silent client cannot grow it without limit.
2571
+ */
2572
+ pendingRelays = /* @__PURE__ */ new Map();
2573
+ static MAX_PENDING_RELAYS = 1e3;
2227
2574
  sessionId;
2228
2575
  transport;
2229
2576
  store;
2230
2577
  auth;
2231
2578
  serializer;
2232
2579
  emitter;
2580
+ logger;
2233
2581
  batchSize;
2234
2582
  schemaVersion;
2235
2583
  supportedSchemaVersions;
@@ -2240,6 +2588,8 @@ var ClientSession = class {
2240
2588
  onBlobChunkResponse;
2241
2589
  persistBlobChunk;
2242
2590
  onClose;
2591
+ onOrphanedRelays;
2592
+ takeOrphanedRelays;
2243
2593
  maxOperationBytes;
2244
2594
  maxOpsPerMinute;
2245
2595
  rateLimiter;
@@ -2252,6 +2602,7 @@ var ClientSession = class {
2252
2602
  this.auth = options.auth ?? null;
2253
2603
  this.serializer = options.serializer ?? new NegotiatedMessageSerializer("json");
2254
2604
  this.emitter = options.emitter ?? null;
2605
+ this.logger = options.logger ?? null;
2255
2606
  this.batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
2256
2607
  this.schemaVersion = options.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
2257
2608
  const supported = options.supportedSchemaVersions;
@@ -2266,6 +2617,8 @@ var ClientSession = class {
2266
2617
  this.onBlobChunkResponse = options.onBlobChunkResponse ?? null;
2267
2618
  this.persistBlobChunk = options.persistBlobChunk ?? null;
2268
2619
  this.onClose = options.onClose ?? null;
2620
+ this.onOrphanedRelays = options.onOrphanedRelays ?? null;
2621
+ this.takeOrphanedRelays = options.takeOrphanedRelays ?? null;
2269
2622
  this.maxOperationBytes = options.maxOperationBytes ?? DEFAULT_MAX_OPERATION_BYTES;
2270
2623
  this.maxOpsPerMinute = options.maxOpsPerMinute ?? DEFAULT_MAX_OPS_PER_MINUTE;
2271
2624
  this.rateLimiter = new SessionRateLimiter(this.maxOpsPerMinute);
@@ -2290,9 +2643,40 @@ var ClientSession = class {
2290
2643
  */
2291
2644
  relayOperations(operations2) {
2292
2645
  if (this.state !== "streaming" || !this.transport.isConnected()) return;
2646
+ if (this.clientDeliveryWatermark !== null) {
2647
+ this.pushDeliveryStream();
2648
+ return;
2649
+ }
2293
2650
  if (operations2.length === 0) return;
2294
- const visibleOperations = operations2.filter((op) => this.operationVisibleToClient(op));
2651
+ void this.relayVisibleOperations(operations2);
2652
+ }
2653
+ /**
2654
+ * Push newly-available in-scope operations to a delivery-watermark client, resuming
2655
+ * from the highest delivery sequence already sent. Pushes are serialized so their
2656
+ * batches never interleave (which would break the base/max chain).
2657
+ */
2658
+ pushDeliveryStream() {
2659
+ this.deliveryPushChain = this.deliveryPushChain.then(async () => {
2660
+ if (this.state !== "streaming" || !this.transport.isConnected()) return;
2661
+ try {
2662
+ await this.sendDeliveryStream(
2663
+ this.lastAckedDeliverySeq,
2664
+ false,
2665
+ this.clientNodeId ?? void 0
2666
+ );
2667
+ } catch {
2668
+ }
2669
+ });
2670
+ }
2671
+ async relayVisibleOperations(operations2) {
2672
+ const visibleOperations = [];
2673
+ for (const op of operations2) {
2674
+ if (await this.operationVisibleToClient(op)) {
2675
+ visibleOperations.push(op);
2676
+ }
2677
+ }
2295
2678
  if (visibleOperations.length === 0) return;
2679
+ if (this.state !== "streaming" || !this.transport.isConnected()) return;
2296
2680
  const serializedOps = visibleOperations.map((op) => this.serializer.encodeOperation(op));
2297
2681
  const msg = {
2298
2682
  type: "operation-batch",
@@ -2301,14 +2685,62 @@ var ClientSession = class {
2301
2685
  isFinal: true,
2302
2686
  batchIndex: 0
2303
2687
  };
2688
+ this.trackPendingRelay(msg, visibleOperations);
2304
2689
  this.sendToClient(msg);
2305
2690
  }
2691
+ /** Record a relay batch as awaiting acknowledgment, evicting the oldest if full. */
2692
+ trackPendingRelay(msg, ops) {
2693
+ if (this.pendingRelays.size >= _ClientSession.MAX_PENDING_RELAYS) {
2694
+ const oldest = this.pendingRelays.keys().next().value;
2695
+ if (oldest !== void 0) {
2696
+ this.pendingRelays.delete(oldest);
2697
+ }
2698
+ }
2699
+ this.pendingRelays.set(msg.messageId, { message: msg, ops, sentAtMs: Date.now() });
2700
+ }
2701
+ /**
2702
+ * Retransmit relay batches this client has not acknowledged within `staleMs`.
2703
+ * Called on a periodic tick by the server (and directly by tests). Redelivering an
2704
+ * already-applied op is harmless: the client dedups by content-addressed id.
2705
+ */
2706
+ retransmitPendingRelays(staleMs = 0) {
2707
+ if (this.state !== "streaming" || !this.transport.isConnected()) return;
2708
+ if (this.clientDeliveryWatermark !== null) {
2709
+ this.pushDeliveryStream();
2710
+ return;
2711
+ }
2712
+ if (this.pendingRelays.size === 0) return;
2713
+ const cutoff = Date.now() - staleMs;
2714
+ for (const { message, sentAtMs } of this.pendingRelays.values()) {
2715
+ if (sentAtMs <= cutoff) {
2716
+ this.sendToClient(message);
2717
+ }
2718
+ }
2719
+ }
2306
2720
  /**
2307
2721
  * Close this session.
2308
2722
  */
2723
+ /**
2724
+ * Hand any unacknowledged relays to the server so they survive this session and are
2725
+ * redelivered when this client reconnects (per-node buffer), then clear them. Called
2726
+ * from every session-teardown path (explicit close and transport close).
2727
+ */
2728
+ flushOrphanedRelays() {
2729
+ if (this.onOrphanedRelays && this.clientNodeId && this.pendingRelays.size > 0) {
2730
+ const ops = [];
2731
+ for (const entry of this.pendingRelays.values()) {
2732
+ ops.push(...entry.ops);
2733
+ }
2734
+ if (ops.length > 0) {
2735
+ this.onOrphanedRelays(this.clientNodeId, ops);
2736
+ }
2737
+ }
2738
+ this.pendingRelays.clear();
2739
+ }
2309
2740
  close(reason) {
2310
2741
  if (this.state === "closed") return;
2311
2742
  this.state = "closed";
2743
+ this.flushOrphanedRelays();
2312
2744
  if (this.transport.isConnected()) {
2313
2745
  this.transport.close(1e3, reason ?? "session closed");
2314
2746
  }
@@ -2363,6 +2795,10 @@ var ClientSession = class {
2363
2795
  await this.handleOperationBatch(message);
2364
2796
  break;
2365
2797
  case "acknowledgment":
2798
+ this.pendingRelays.delete(message.acknowledgedMessageId);
2799
+ if (message.deliverySequence !== void 0) {
2800
+ this.lastAckedDeliverySeq = Math.max(this.lastAckedDeliverySeq, message.deliverySequence);
2801
+ }
2366
2802
  break;
2367
2803
  case "error":
2368
2804
  break;
@@ -2401,6 +2837,18 @@ var ClientSession = class {
2401
2837
  }
2402
2838
  handleMessageFailure(error) {
2403
2839
  const reason = error instanceof Error ? error.message : "Message handling failed";
2840
+ this.logger?.log({
2841
+ timestamp: Date.now(),
2842
+ level: "error",
2843
+ event: "session.message_failed",
2844
+ sessionId: this.sessionId,
2845
+ nodeId: this.clientNodeId ?? void 0,
2846
+ error: reason,
2847
+ details: {
2848
+ state: this.state,
2849
+ errorName: error instanceof Error ? error.name : typeof error
2850
+ }
2851
+ });
2404
2852
  this.sendError("SYNC_ERROR", reason, true);
2405
2853
  this.close(reason);
2406
2854
  }
@@ -2439,6 +2887,15 @@ var ClientSession = class {
2439
2887
  this.syncQuerySubsets = [];
2440
2888
  }
2441
2889
  this.resumeDeltaCursor = msg.deltaCursor ? decodeDeltaCursor(msg.deltaCursor) : null;
2890
+ this.clientDeliveryWatermark = msg.lastDeliverySequence ?? null;
2891
+ const clientUsesWatermark = this.clientDeliveryWatermark !== null;
2892
+ let serverMaxDelivery = 0;
2893
+ if (clientUsesWatermark) {
2894
+ serverMaxDelivery = await this.store.getMaxDeliverySequence();
2895
+ if (this.clientDeliveryWatermark !== null && this.clientDeliveryWatermark > serverMaxDelivery) {
2896
+ this.clientDeliveryWatermark = 0;
2897
+ }
2898
+ }
2442
2899
  const serverVector = this.store.getVersionVector();
2443
2900
  const selectedWireFormat = selectWireFormat(msg.supportedWireFormats);
2444
2901
  this.setSerializerWireFormat(selectedWireFormat);
@@ -2469,6 +2926,10 @@ var ClientSession = class {
2469
2926
  accepted: true,
2470
2927
  selectedWireFormat,
2471
2928
  serverTime: Date.now(),
2929
+ // The server's highest delivery sequence, so a client whose persisted watermark
2930
+ // is ahead of it (server rolled back) can reset to a full resync. Sent only to
2931
+ // watermark-using clients.
2932
+ ...clientUsesWatermark ? { serverMaxDeliverySequence: serverMaxDelivery } : {},
2472
2933
  // Advertise central blob storage so the client uploads the bytes behind
2473
2934
  // its blob fields, keeping them available after the author goes offline.
2474
2935
  ...this.persistBlobChunk ? { blobStorageEnabled: true } : {},
@@ -2479,16 +2940,28 @@ var ClientSession = class {
2479
2940
  this.sendToClient(response);
2480
2941
  this.emitter?.emit({ type: "sync:connected", nodeId: msg.nodeId });
2481
2942
  this.state = "syncing";
2482
- const clientVector = wireToVersionVector(msg.versionVector);
2483
- await this.sendDelta(clientVector);
2943
+ if (this.clientDeliveryWatermark !== null) {
2944
+ this.lastAckedDeliverySeq = this.clientDeliveryWatermark;
2945
+ const excludeOwn = this.clientDeliveryWatermark > 0 ? this.clientNodeId ?? void 0 : void 0;
2946
+ await this.sendDeliveryStream(this.clientDeliveryWatermark, true, excludeOwn);
2947
+ } else {
2948
+ const clientVector = wireToVersionVector(msg.versionVector);
2949
+ await this.sendDelta(clientVector);
2950
+ }
2484
2951
  this.state = "streaming";
2952
+ if (this.takeOrphanedRelays && this.clientNodeId) {
2953
+ const buffered = this.takeOrphanedRelays(this.clientNodeId);
2954
+ if (buffered.length > 0) {
2955
+ this.relayOperations(buffered);
2956
+ }
2957
+ }
2485
2958
  }
2486
2959
  async handleOperationBatch(msg) {
2487
2960
  const operations2 = msg.operations.map((s) => this.serializer.decodeOperation(s));
2488
2961
  const applied = [];
2489
2962
  const rejected = [];
2490
2963
  for (const op of operations2) {
2491
- if (!this.operationVisibleToClient(op)) {
2964
+ if (!await this.operationVisibleToClient(op)) {
2492
2965
  rejected.push(op);
2493
2966
  continue;
2494
2967
  }
@@ -2542,20 +3015,17 @@ var ClientSession = class {
2542
3015
  continue;
2543
3016
  }
2544
3017
  }
2545
- try {
2546
- const applyResult = await applyServerOperation(this.store, op);
2547
- if (applyResult.rejection) {
2548
- this.sendError(
2549
- applyResult.rejection.code,
2550
- applyResult.rejection.message,
2551
- applyResult.rejection.retriable
2552
- );
2553
- continue;
2554
- }
2555
- if (applyResult.result === "applied") {
2556
- applied.push(...applyResult.appliedOperations);
2557
- }
2558
- } catch {
3018
+ const applyResult = await applyServerOperation(this.store, op);
3019
+ if (applyResult.rejection) {
3020
+ this.sendError(
3021
+ applyResult.rejection.code,
3022
+ applyResult.rejection.message,
3023
+ applyResult.rejection.retriable
3024
+ );
3025
+ continue;
3026
+ }
3027
+ if (applyResult.result === "applied") {
3028
+ applied.push(...applyResult.appliedOperations);
2559
3029
  }
2560
3030
  }
2561
3031
  if (rejected.length > 0) {
@@ -2593,8 +3063,11 @@ var ClientSession = class {
2593
3063
  const clientSeq = clientVector.get(nodeId) ?? 0;
2594
3064
  if (serverSeq > clientSeq) {
2595
3065
  const ops = await this.store.getOperationRange(nodeId, clientSeq + 1, serverSeq);
2596
- const visible = ops.filter((op) => this.operationVisibleToClient(op));
2597
- missing.push(...visible);
3066
+ for (const op of ops) {
3067
+ if (await this.operationVisibleToClient(op)) {
3068
+ missing.push(op);
3069
+ }
3070
+ }
2598
3071
  }
2599
3072
  }
2600
3073
  if (missing.length === 0) {
@@ -2646,11 +3119,111 @@ var ClientSession = class {
2646
3119
  });
2647
3120
  }
2648
3121
  }
2649
- operationVisibleToClient(op) {
2650
- if (!operationMatchesScopes(op, this.authContext?.scopes)) {
3122
+ /**
3123
+ * Send the gap-free server->client delivery stream, resuming just after
3124
+ * `fromDeliverySeq`. Operations are scanned in server delivery-sequence order
3125
+ * (commit order), scope-filtered for this session, and sent in batches that chain:
3126
+ * each batch's `baseDeliverySequence` equals the previous batch's
3127
+ * `maxDeliverySequence` (the first batch bases on `fromDeliverySeq`). The client
3128
+ * applies a batch only when its watermark equals the base and advances the
3129
+ * watermark to the max, so a dropped batch stalls the watermark and is recovered by
3130
+ * the next handshake resend. Because delivery-sequence order respects causal order
3131
+ * (a dependency is always committed, and thus sequenced, before its dependent), no
3132
+ * topological sort is needed and the client never defers on a missing dependency
3133
+ * that is itself in this stream.
3134
+ *
3135
+ * The final batch advances the watermark to the highest delivery sequence scanned,
3136
+ * not merely the last in-scope one, so an out-of-scope tail is not re-scanned on the
3137
+ * next reconnect. Returns the number of operations actually sent.
3138
+ */
3139
+ async sendDeliveryStream(fromDeliverySeq, finalizeWhenEmpty, excludeNodeId) {
3140
+ const scanChunk = Math.max(this.batchSize, 1) * 5;
3141
+ let scanCursor = fromDeliverySeq;
3142
+ let maxScanned = fromDeliverySeq;
3143
+ const visible = [];
3144
+ while (true) {
3145
+ const chunk = await this.store.getOperationsAfterDelivery(scanCursor, scanChunk);
3146
+ const last = chunk[chunk.length - 1];
3147
+ if (last === void 0) break;
3148
+ scanCursor = last.deliverySequence;
3149
+ maxScanned = scanCursor;
3150
+ for (const delivered of chunk) {
3151
+ if (excludeNodeId !== void 0 && delivered.operation.nodeId === excludeNodeId) {
3152
+ continue;
3153
+ }
3154
+ if (await this.operationVisibleToClient(delivered.operation)) {
3155
+ visible.push(delivered);
3156
+ }
3157
+ }
3158
+ if (chunk.length < scanChunk) break;
3159
+ }
3160
+ if (visible.length === 0) {
3161
+ if (finalizeWhenEmpty) {
3162
+ this.sendDeliveryBatch([], fromDeliverySeq, maxScanned, 0, true);
3163
+ }
3164
+ return 0;
3165
+ }
3166
+ const totalBatches = Math.ceil(visible.length / this.batchSize);
3167
+ let base = fromDeliverySeq;
3168
+ for (let i = 0; i < totalBatches; i++) {
3169
+ const slice = visible.slice(i * this.batchSize, (i + 1) * this.batchSize);
3170
+ const lastInSlice = slice[slice.length - 1];
3171
+ if (lastInSlice === void 0) continue;
3172
+ const isFinal = i === totalBatches - 1;
3173
+ const lastSeq = lastInSlice.deliverySequence;
3174
+ const max = isFinal ? Math.max(maxScanned, lastSeq) : lastSeq;
3175
+ this.sendDeliveryBatch(slice, base, max, i, isFinal);
3176
+ base = max;
3177
+ }
3178
+ return visible.length;
3179
+ }
3180
+ /** Build, track, and send one chained delivery-stream batch. */
3181
+ sendDeliveryBatch(slice, base, max, batchIndex, isFinal) {
3182
+ const batchMsg = {
3183
+ type: "operation-batch",
3184
+ messageId: generateUUIDv74(),
3185
+ operations: slice.map((delivered) => this.serializer.encodeOperation(delivered.operation)),
3186
+ isFinal,
3187
+ batchIndex,
3188
+ baseDeliverySequence: base,
3189
+ maxDeliverySequence: max
3190
+ };
3191
+ this.sendToClient(batchMsg);
3192
+ if (slice.length > 0) {
3193
+ this.emitter?.emit({
3194
+ type: "sync:sent",
3195
+ operations: slice.map((delivered) => delivered.operation),
3196
+ batchSize: slice.length
3197
+ });
3198
+ }
3199
+ }
3200
+ async operationVisibleToClient(op) {
3201
+ const scopes = this.authContext?.scopes;
3202
+ const subsets = this.syncQuerySubsets;
3203
+ const needsBackfill = missingScopeFields(op, scopes).length > 0 || subsets !== void 0 && subsets.length > 0;
3204
+ const fullRecord = needsBackfill ? await this.lookupRecordFields(op.collection, op.recordId) : void 0;
3205
+ if (!operationMatchesScopes(op, scopes, fullRecord)) {
2651
3206
  return false;
2652
3207
  }
2653
- return operationMatchesQuerySubsets(op, this.syncQuerySubsets);
3208
+ return operationMatchesQuerySubsets(op, subsets, fullRecord);
3209
+ }
3210
+ /**
3211
+ * Read a record's current field values from the materialized store for scope /
3212
+ * query-subset backfill. Includes soft-deleted rows so a relayed delete (whose op
3213
+ * carries no fields) is still judged against the record's actual scope. Returns
3214
+ * undefined when the record cannot be read (never materialized, or no schema).
3215
+ */
3216
+ async lookupRecordFields(collection, recordId) {
3217
+ try {
3218
+ const rows = await this.store.queryCollection(collection, {
3219
+ where: { id: recordId },
3220
+ includeDeleted: true,
3221
+ limit: 1
3222
+ });
3223
+ return rows[0];
3224
+ } catch {
3225
+ return void 0;
3226
+ }
2654
3227
  }
2655
3228
  handleAwarenessUpdate(msg) {
2656
3229
  this.onAwarenessUpdate?.(this.sessionId, msg);
@@ -2695,6 +3268,7 @@ var ClientSession = class {
2695
3268
  handleTransportClose() {
2696
3269
  if (this.state === "closed") return;
2697
3270
  this.state = "closed";
3271
+ this.flushOrphanedRelays();
2698
3272
  this.emitter?.emit({ type: "sync:disconnected", reason: "transport closed" });
2699
3273
  this.onClose?.(this.sessionId);
2700
3274
  }
@@ -3069,7 +3643,7 @@ var YjsDocRelay = class {
3069
3643
 
3070
3644
  // src/server/route-context.ts
3071
3645
  import {
3072
- HybridLogicalClock as HybridLogicalClock2,
3646
+ HybridLogicalClock as HybridLogicalClock3,
3073
3647
  createOperation as createOperation2,
3074
3648
  generateUUIDv7 as generateUUIDv76,
3075
3649
  isAtomicOp,
@@ -3147,9 +3721,9 @@ function pickFields(record, keys) {
3147
3721
  }
3148
3722
  return picked;
3149
3723
  }
3150
- async function buildRouteOperation(store, mutation) {
3724
+ async function buildRouteOperation(store, mutation, currentOverride, clockOverride) {
3151
3725
  const nodeId = store.getNodeId();
3152
- const clock = new HybridLogicalClock2(nodeId);
3726
+ const clock = clockOverride ?? new HybridLogicalClock3(nodeId);
3153
3727
  const schemaVersion = store.getSchema()?.version ?? 1;
3154
3728
  const sequenceNumber = nextServerSequenceNumber(store);
3155
3729
  if (mutation.type === "insert") {
@@ -3174,7 +3748,7 @@ async function buildRouteOperation(store, mutation) {
3174
3748
  `A ${mutation.type} mutation on "${mutation.collection}" requires a recordId.`
3175
3749
  );
3176
3750
  }
3177
- const current = await store.findRecord(mutation.collection, mutation.recordId);
3751
+ const current = currentOverride ? currentOverride.record : await store.findRecord(mutation.collection, mutation.recordId);
3178
3752
  if (mutation.type === "update") {
3179
3753
  const rawData = mutation.data ?? {};
3180
3754
  const data = {};
@@ -3291,11 +3865,88 @@ function createRouteContext(server, store) {
3291
3865
  }
3292
3866
  return commitOperation(prepared.op, mutation.collection);
3293
3867
  }
3868
+ async function runConditionalViaStore(storeApplyConditional, spec, options) {
3869
+ const scope = options?.scope;
3870
+ const mutations = [];
3871
+ const hasTargetUpdate = spec.update !== void 0 && spec.update !== null;
3872
+ if (hasTargetUpdate) {
3873
+ mutations.push({
3874
+ collection: spec.collection,
3875
+ type: "update",
3876
+ recordId: spec.id,
3877
+ data: spec.update
3878
+ });
3879
+ }
3880
+ if (spec.also) {
3881
+ mutations.push(...spec.also);
3882
+ }
3883
+ let result;
3884
+ try {
3885
+ result = await storeApplyConditional({
3886
+ target: { collection: spec.collection, id: spec.id },
3887
+ ...spec.idempotencyKey ? { idempotencyKey: spec.idempotencyKey } : {},
3888
+ admit: (current) => spec.if && Object.keys(spec.if).length > 0 ? evaluateRoutePredicate(current, spec.if) : true,
3889
+ buildOperations: async (current, context) => {
3890
+ const ops = [];
3891
+ for (let i = 0; i < mutations.length; i++) {
3892
+ const mutation = mutations[i];
3893
+ const useLockedCurrent = i === 0 && hasTargetUpdate;
3894
+ const op = await buildRouteOperation(
3895
+ store,
3896
+ mutation,
3897
+ useLockedCurrent ? { record: current } : void 0,
3898
+ context.clock
3899
+ );
3900
+ if (scope && !operationMatchesScopes(op, scope)) {
3901
+ throw new RouteMutationError(
3902
+ "SCOPE_VIOLATION",
3903
+ `Mutation on "${mutation.collection}" is outside the provided scope.`
3904
+ );
3905
+ }
3906
+ ops.push(op);
3907
+ }
3908
+ return ops;
3909
+ }
3910
+ });
3911
+ } catch (error) {
3912
+ if (error instanceof RouteMutationError) {
3913
+ return {
3914
+ ok: false,
3915
+ code: error.code,
3916
+ message: error.message,
3917
+ retriable: isRetriableRejection(error.code)
3918
+ };
3919
+ }
3920
+ throw error;
3921
+ }
3922
+ if (!result.admitted) {
3923
+ return {
3924
+ ok: false,
3925
+ code: spec.reject?.code ?? "CONDITION_NOT_MET",
3926
+ message: spec.reject?.message ?? `Admission condition not met on "${spec.collection}" record "${spec.id}".`,
3927
+ retriable: false
3928
+ };
3929
+ }
3930
+ if (result.idempotent) {
3931
+ const records2 = spec.idempotencyKey ? [await store.findRecord(spec.idempotencyKey.collection, spec.idempotencyKey.id)] : [];
3932
+ return { ok: true, idempotent: true, operations: [], records: records2 };
3933
+ }
3934
+ server.relayServerOperations(result.applied);
3935
+ const records = [];
3936
+ for (const op of result.applied) {
3937
+ records.push(await store.findRecord(op.collection, op.recordId));
3938
+ }
3939
+ return { ok: true, idempotent: false, operations: result.applied, records };
3940
+ }
3294
3941
  return {
3295
3942
  apply(mutation, options) {
3296
3943
  return serialize(() => rawApply(mutation, options?.scope));
3297
3944
  },
3298
3945
  applyConditional(spec, options) {
3946
+ const storeApplyConditional = store.applyConditional;
3947
+ if (storeApplyConditional) {
3948
+ return runConditionalViaStore(storeApplyConditional.bind(store), spec, options);
3949
+ }
3299
3950
  return serialize(async () => {
3300
3951
  if (spec.idempotencyKey) {
3301
3952
  const existing = await store.findRecord(
@@ -3383,7 +4034,8 @@ var DEFAULT_BATCH_SIZE2 = 100;
3383
4034
  var DEFAULT_SCHEMA_VERSION2 = 1;
3384
4035
  var DEFAULT_HOST = "0.0.0.0";
3385
4036
  var DEFAULT_PATH = "/";
3386
- var KoraSyncServer = class {
4037
+ var RELAY_RETRANSMIT_INTERVAL_MS = 2e3;
4038
+ var KoraSyncServer = class _KoraSyncServer {
3387
4039
  store;
3388
4040
  auth;
3389
4041
  serializer;
@@ -3413,6 +4065,20 @@ var KoraSyncServer = class {
3413
4065
  serverVersion = "1.0.0-beta.0";
3414
4066
  wsServer = null;
3415
4067
  running = false;
4068
+ /**
4069
+ * Periodic tick that retransmits relay batches connected clients have not
4070
+ * acknowledged, so a relay dropped by a lossy transport is redelivered instead of
4071
+ * leaving that client with a permanent version-vector gap (a lost operation).
4072
+ */
4073
+ relayRetransmitTimer = null;
4074
+ /**
4075
+ * Relay operations a client never acknowledged before it disconnected, buffered by
4076
+ * client node id so they can be redelivered on its next connection. Bounded per
4077
+ * node and expired by age so a client that never returns cannot grow this forever.
4078
+ */
4079
+ orphanedRelaysByNode = /* @__PURE__ */ new Map();
4080
+ static MAX_ORPHANED_RELAY_OPS_PER_NODE = 5e3;
4081
+ static ORPHANED_RELAY_TTL_MS = 5 * 6e4;
3416
4082
  constructor(config) {
3417
4083
  this.store = config.store;
3418
4084
  this.auth = config.auth ?? null;
@@ -3440,6 +4106,57 @@ var KoraSyncServer = class {
3440
4106
  if (!this.emitter) {
3441
4107
  this.emitter = new SimpleEventEmitter();
3442
4108
  }
4109
+ this.relayRetransmitTimer = setInterval(() => {
4110
+ this.retransmitPendingRelays(RELAY_RETRANSMIT_INTERVAL_MS);
4111
+ }, RELAY_RETRANSMIT_INTERVAL_MS);
4112
+ this.relayRetransmitTimer.unref?.();
4113
+ }
4114
+ /**
4115
+ * Retransmit relay batches that connected clients have not acknowledged within
4116
+ * `staleMs`. Called on a periodic tick and directly by tests. Redelivering an
4117
+ * already-applied operation is harmless (clients dedup by content-addressed id).
4118
+ */
4119
+ retransmitPendingRelays(staleMs = 0) {
4120
+ for (const session of this.sessions.values()) {
4121
+ session.retransmitPendingRelays(staleMs);
4122
+ }
4123
+ this.expireOrphanedRelays();
4124
+ }
4125
+ /**
4126
+ * Buffer relay operations a disconnected client never acknowledged, keyed by its
4127
+ * node id, deduped by operation id and bounded by count. Redelivered when the
4128
+ * client reconnects (see {@link takeOrphanedRelays}).
4129
+ */
4130
+ bufferOrphanedRelays(nodeId, ops) {
4131
+ if (ops.length === 0) return;
4132
+ const existing = this.orphanedRelaysByNode.get(nodeId);
4133
+ const merged = existing ? existing.ops : [];
4134
+ const seen = new Set(merged.map((op) => op.id));
4135
+ for (const op of ops) {
4136
+ if (!seen.has(op.id)) {
4137
+ merged.push(op);
4138
+ seen.add(op.id);
4139
+ }
4140
+ }
4141
+ const bounded = merged.length > _KoraSyncServer.MAX_ORPHANED_RELAY_OPS_PER_NODE ? merged.slice(merged.length - _KoraSyncServer.MAX_ORPHANED_RELAY_OPS_PER_NODE) : merged;
4142
+ this.orphanedRelaysByNode.set(nodeId, { ops: bounded, bufferedAtMs: Date.now() });
4143
+ }
4144
+ /** Remove and return a node's buffered orphaned relay operations, if any. */
4145
+ takeOrphanedRelays(nodeId) {
4146
+ const entry = this.orphanedRelaysByNode.get(nodeId);
4147
+ if (!entry) return [];
4148
+ this.orphanedRelaysByNode.delete(nodeId);
4149
+ return entry.ops;
4150
+ }
4151
+ /** Drop buffered orphaned relays older than the TTL (client never returned). */
4152
+ expireOrphanedRelays() {
4153
+ if (this.orphanedRelaysByNode.size === 0) return;
4154
+ const cutoff = Date.now() - _KoraSyncServer.ORPHANED_RELAY_TTL_MS;
4155
+ for (const [nodeId, entry] of this.orphanedRelaysByNode) {
4156
+ if (entry.bufferedAtMs <= cutoff) {
4157
+ this.orphanedRelaysByNode.delete(nodeId);
4158
+ }
4159
+ }
3443
4160
  }
3444
4161
  /**
3445
4162
  * The trusted, scoped data-plane context (`apply` / `query` / `findById`).
@@ -3566,6 +4283,11 @@ var KoraSyncServer = class {
3566
4283
  event: "server.stopping",
3567
4284
  details: { connectedClients: this.sessions.size }
3568
4285
  });
4286
+ if (this.relayRetransmitTimer) {
4287
+ clearInterval(this.relayRetransmitTimer);
4288
+ this.relayRetransmitTimer = null;
4289
+ }
4290
+ this.orphanedRelaysByNode.clear();
3569
4291
  this.awarenessRelay.clear();
3570
4292
  this.yjsDocRelay.clear();
3571
4293
  this.blobChunkRelay.clear();
@@ -3701,6 +4423,7 @@ var KoraSyncServer = class {
3701
4423
  auth: this.auth ?? void 0,
3702
4424
  serializer: this.serializer,
3703
4425
  emitter: sessionEmitter,
4426
+ logger: this.logger,
3704
4427
  batchSize: this.batchSize,
3705
4428
  schemaVersion: this.schemaVersion,
3706
4429
  supportedSchemaVersions: this.supportedSchemaVersions,
@@ -3727,7 +4450,11 @@ var KoraSyncServer = class {
3727
4450
  ...this.validateOperation ? { validateOperation: this.validateOperation, koraContext: this.koraContext } : {},
3728
4451
  onClose: (sid) => {
3729
4452
  this.handleSessionClose(sid);
3730
- }
4453
+ },
4454
+ onOrphanedRelays: (nodeId, ops) => {
4455
+ this.bufferOrphanedRelays(nodeId, ops);
4456
+ },
4457
+ takeOrphanedRelays: (nodeId) => this.takeOrphanedRelays(nodeId)
3731
4458
  });
3732
4459
  this.sessions.set(sessionId, session);
3733
4460
  this.yjsDocRelay.addClient(sessionId, transport);
@@ -3787,6 +4514,22 @@ var KoraSyncServer = class {
3787
4514
  }
3788
4515
  return result;
3789
4516
  }
4517
+ /**
4518
+ * Relay already-applied, server-originated operations to every connected client.
4519
+ * Used by the conditional route apply, which commits its operations atomically
4520
+ * through the store (bypassing {@link applyLocalOperation}) and then fans them
4521
+ * out. Each session still enforces its own per-scope visibility filter.
4522
+ *
4523
+ * @param operations - Operations that have already been committed to the store
4524
+ */
4525
+ relayServerOperations(operations2) {
4526
+ if (operations2.length === 0) {
4527
+ return;
4528
+ }
4529
+ for (const session of this.sessions.values()) {
4530
+ session.relayOperations(operations2);
4531
+ }
4532
+ }
3790
4533
  /**
3791
4534
  * Collect every blob reference still reachable from live records on the server.
3792
4535
  *
@@ -4004,6 +4747,7 @@ function createProductionServer(config) {
4004
4747
  const port = config.port ?? (Number(process.env.PORT) || 3001);
4005
4748
  const staticDir = config.staticDir ?? "./dist";
4006
4749
  const syncPath = config.syncPath ?? "/kora-sync";
4750
+ const crossOriginEmbedderPolicy = config.crossOriginEmbedderPolicy ?? "credentialless";
4007
4751
  const syncServer = new KoraSyncServer({
4008
4752
  store: config.store,
4009
4753
  enableDashboard: true,
@@ -4174,7 +4918,7 @@ function createProductionServer(config) {
4174
4918
  });
4175
4919
  async function handleRequest(req, res) {
4176
4920
  res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
4177
- res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
4921
+ res.setHeader("Cross-Origin-Embedder-Policy", crossOriginEmbedderPolicy);
4178
4922
  const url = new URL(req.url || "/", `http://${req.headers.host}`);
4179
4923
  if (url.pathname === "/health") {
4180
4924
  const status = await syncServer.getStatus();