@korajs/server 1.0.0-beta.2 → 1.0.0-beta.5

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])
@@ -265,6 +241,14 @@ var MemoryServerStore = class {
265
241
  operations = [];
266
242
  operationIndex = /* @__PURE__ */ new Map();
267
243
  versionVector = /* @__PURE__ */ new Map();
244
+ /**
245
+ * Server-assigned delivery sequence per operation id. Single-process, so a plain
246
+ * counter incremented at insert time gives commit-order delivery sequence with no
247
+ * gaps. `operations` is kept in insertion (== delivery) order, so a delivery scan
248
+ * is a forward walk.
249
+ */
250
+ deliverySeqByOpId = /* @__PURE__ */ new Map();
251
+ deliverySeqCounter = 0;
268
252
  schema = null;
269
253
  /** Materialized records: collection -> recordId -> record data */
270
254
  materializedRecords = /* @__PURE__ */ new Map();
@@ -298,6 +282,8 @@ var MemoryServerStore = class {
298
282
  }
299
283
  this.operations.push(op);
300
284
  this.operationIndex.set(op.id, op);
285
+ this.deliverySeqCounter += 1;
286
+ this.deliverySeqByOpId.set(op.id, this.deliverySeqCounter);
301
287
  const currentSeq = this.versionVector.get(op.nodeId) ?? 0;
302
288
  if (op.sequenceNumber > currentSeq) {
303
289
  this.versionVector.set(op.nodeId, op.sequenceNumber);
@@ -317,6 +303,23 @@ var MemoryServerStore = class {
317
303
  this.assertOpen();
318
304
  return this.operations.length;
319
305
  }
306
+ async getMaxDeliverySequence() {
307
+ this.assertOpen();
308
+ return this.deliverySeqCounter;
309
+ }
310
+ async getOperationsAfterDelivery(afterDeliverySequence, limit) {
311
+ this.assertOpen();
312
+ const result = [];
313
+ if (limit <= 0) return result;
314
+ for (const op of this.operations) {
315
+ const deliverySequence = this.deliverySeqByOpId.get(op.id) ?? 0;
316
+ if (deliverySequence > afterDeliverySequence) {
317
+ result.push({ operation: op, deliverySequence });
318
+ if (result.length >= limit) break;
319
+ }
320
+ }
321
+ return result;
322
+ }
320
323
  async materializeCollection(collection) {
321
324
  this.assertOpen();
322
325
  if (this.schema?.collections[collection]) {
@@ -461,12 +464,16 @@ var MemoryServerStore = class {
461
464
  this.operations.length = 0;
462
465
  this.operationIndex.clear();
463
466
  this.versionVector.clear();
467
+ this.deliverySeqByOpId.clear();
468
+ this.deliverySeqCounter = 0;
464
469
  for (const [nid, seq] of versionVector) {
465
470
  this.versionVector.set(nid, seq);
466
471
  }
467
472
  for (const op of operations2) {
468
473
  this.operations.push(op);
469
474
  this.operationIndex.set(op.id, op);
475
+ this.deliverySeqCounter += 1;
476
+ this.deliverySeqByOpId.set(op.id, this.deliverySeqCounter);
470
477
  if (this.schema?.collections[op.collection]) {
471
478
  this.rebuildMaterializedRecord(op.collection, op.recordId);
472
479
  }
@@ -496,11 +503,12 @@ var MemoryServerStore = class {
496
503
  return a.timestamp.wallTime - b.timestamp.wallTime;
497
504
  if (a.timestamp.logical !== b.timestamp.logical)
498
505
  return a.timestamp.logical - b.timestamp.logical;
499
- return a.sequenceNumber - b.sequenceNumber;
506
+ return a.timestamp.nodeId < b.timestamp.nodeId ? -1 : a.timestamp.nodeId > b.timestamp.nodeId ? 1 : 0;
500
507
  });
501
508
  const parsedOps = recordOps.map((op) => ({
502
509
  type: op.type,
503
- data: op.data
510
+ data: op.data,
511
+ atomicOps: op.atomicOps ?? null
504
512
  }));
505
513
  const recordData = replayOperationsForRecord(parsedOps);
506
514
  if (recordData) {
@@ -606,8 +614,8 @@ var MemoryServerStore = class {
606
614
  };
607
615
 
608
616
  // 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";
617
+ import { HybridLogicalClock, generateUUIDv7 as generateUUIDv72, quoteIdent as quoteIdent2 } from "@korajs/core";
618
+ import { and, asc, between, count, desc, eq, gt, sql } from "drizzle-orm";
611
619
 
612
620
  // src/store/drizzle-pg-schema.ts
613
621
  import { bigint, index, integer, pgTable, text } from "drizzle-orm/pg-core";
@@ -623,6 +631,8 @@ var pgOperations = pgTable(
623
631
  // JSON-serialized, null for deletes
624
632
  previousData: text("previous_data"),
625
633
  // JSON-serialized, null for insert/delete
634
+ atomicOps: text("atomic_ops"),
635
+ // JSON-serialized Record<field, AtomicOp>, null when none
626
636
  wallTime: bigint("wall_time", { mode: "number" }).notNull(),
627
637
  logical: integer("logical").notNull(),
628
638
  timestampNodeId: text("timestamp_node_id").notNull(),
@@ -630,12 +640,18 @@ var pgOperations = pgTable(
630
640
  causalDeps: text("causal_deps").notNull().default("[]"),
631
641
  // JSON array of op IDs
632
642
  schemaVersion: integer("schema_version").notNull(),
633
- receivedAt: bigint("received_at", { mode: "number" }).notNull()
643
+ receivedAt: bigint("received_at", { mode: "number" }).notNull(),
644
+ // Server-assigned monotonic delivery sequence, ordered by commit (assigned from
645
+ // a counter row locked inside the append transaction, so delivery order equals
646
+ // visibility order even across instances). Drives the gap-free server->client
647
+ // delivery watermark. Nullable only for pre-column rows, backfilled on startup.
648
+ deliverySeq: bigint("delivery_seq", { mode: "number" })
634
649
  },
635
650
  (table) => ({
636
651
  nodeSeqIdx: index("idx_pg_node_seq").on(table.nodeId, table.sequenceNumber),
637
652
  collectionIdx: index("idx_pg_collection").on(table.collection),
638
- receivedIdx: index("idx_pg_received").on(table.receivedAt)
653
+ receivedIdx: index("idx_pg_received").on(table.receivedAt),
654
+ deliveryIdx: index("idx_pg_delivery_seq").on(table.deliverySeq)
639
655
  })
640
656
  );
641
657
  var pgSyncState = pgTable("sync_state", {
@@ -652,15 +668,43 @@ var PostgresServerStore = class {
652
668
  ready;
653
669
  schema = null;
654
670
  closed = false;
655
- constructor(db, nodeId) {
671
+ /**
672
+ * Monotonic counter for this node's server-originated sequence numbers. Lazily
673
+ * seeded from the persisted version vector on first use, then advanced in memory
674
+ * (a synchronous `++`, atomic on the single JS thread) so concurrent conditional
675
+ * applies never receive the same number. Null means "not yet seeded"; reset to
676
+ * null after a backup import so it re-seeds from the restored version vector.
677
+ */
678
+ sequenceCounter = null;
679
+ /**
680
+ * Time source for HLC timestamps on server-originated operations. Injectable so a
681
+ * test can freeze wall-clock time and prove the conditional-apply ordering holds
682
+ * under same-millisecond commits (the case the advisory lock and clock advance
683
+ * exist to make correct). Defaults to the system clock in production.
684
+ */
685
+ timeSource;
686
+ constructor(db, nodeId, timeSource) {
656
687
  this.db = db;
657
688
  this.nodeId = nodeId ?? generateUUIDv72();
689
+ this.timeSource = timeSource ?? { now: () => Date.now() };
658
690
  this.ready = this.initialize();
659
691
  }
660
692
  getVersionVector() {
661
693
  this.assertOpen();
662
694
  return new Map(this.versionVector);
663
695
  }
696
+ /**
697
+ * Atomically reserve this node's next server-operation sequence number. The
698
+ * synchronous increment cannot interleave on the single JS thread, so two
699
+ * concurrent conditional applies are always handed distinct numbers.
700
+ */
701
+ reserveSequenceNumber() {
702
+ if (this.sequenceCounter === null) {
703
+ this.sequenceCounter = this.versionVector.get(this.nodeId) ?? 0;
704
+ }
705
+ this.sequenceCounter += 1;
706
+ return this.sequenceCounter;
707
+ }
664
708
  getNodeId() {
665
709
  return this.nodeId;
666
710
  }
@@ -698,8 +742,9 @@ var PostgresServerStore = class {
698
742
  return "duplicate";
699
743
  }
700
744
  const now = Date.now();
701
- const row = this.serializeOperation(op, now);
702
745
  await this.db.transaction(async (tx) => {
746
+ const deliverySeq = await this.nextDeliverySeq(tx);
747
+ const row = this.serializeOperation(op, now, deliverySeq);
703
748
  await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
704
749
  await tx.insert(pgSyncState).values({
705
750
  nodeId: op.nodeId,
@@ -722,6 +767,73 @@ var PostgresServerStore = class {
722
767
  }
723
768
  return "applied";
724
769
  }
770
+ /**
771
+ * Conditionally apply operations atomically, serialized across server instances
772
+ * on the target record via a transaction-scoped advisory lock. The lock makes
773
+ * the read-decide-write cycle atomic against concurrent instances, so a cap
774
+ * check like `responseCount < max` cannot be passed by two admissions at once.
775
+ */
776
+ async applyConditional(input) {
777
+ this.assertOpen();
778
+ await this.ready;
779
+ this.assertSchema();
780
+ this.assertCollection(input.target.collection);
781
+ const schema = this.schema;
782
+ const collectionDef = schema.collections[input.target.collection];
783
+ const lockKey = `kora:${input.target.collection}:${input.target.id}`;
784
+ const result = await this.db.transaction(async (tx) => {
785
+ await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`);
786
+ if (input.idempotencyKey) {
787
+ const existing = await tx.execute(
788
+ sql`SELECT 1 FROM ${sql.raw(quoteIdent2(input.idempotencyKey.collection))} WHERE id = ${input.idempotencyKey.id} AND _deleted = 0 LIMIT 1`
789
+ );
790
+ if (existing.length > 0) {
791
+ return { admitted: true, idempotent: true, applied: [] };
792
+ }
793
+ }
794
+ const rows = await tx.execute(
795
+ sql`SELECT * FROM ${sql.raw(quoteIdent2(input.target.collection))} WHERE id = ${input.target.id} AND _deleted = 0`
796
+ );
797
+ const current = rows.length > 0 ? this.deserializeRow(rows[0], collectionDef) : null;
798
+ if (!input.admit(current)) {
799
+ return { admitted: false, idempotent: false, applied: [] };
800
+ }
801
+ const clock = new HybridLogicalClock(this.nodeId, this.timeSource);
802
+ const latest = await this.latestOperationTimestamp(
803
+ tx,
804
+ input.target.collection,
805
+ input.target.id
806
+ );
807
+ if (latest) {
808
+ clock.advanceTo(latest);
809
+ }
810
+ const ops = await input.buildOperations(current, { clock });
811
+ const now = Date.now();
812
+ for (const op of ops) {
813
+ const deliverySeq = await this.nextDeliverySeq(tx);
814
+ const row = this.serializeOperation(op, now, deliverySeq);
815
+ await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
816
+ await tx.insert(pgSyncState).values({ nodeId: op.nodeId, maxSequenceNumber: op.sequenceNumber, lastSeenAt: now }).onConflictDoUpdate({
817
+ target: pgSyncState.nodeId,
818
+ set: {
819
+ maxSequenceNumber: sql`GREATEST(${pgSyncState.maxSequenceNumber}, ${op.sequenceNumber})`,
820
+ lastSeenAt: sql`${now}`
821
+ }
822
+ });
823
+ if (this.schema?.collections[op.collection]) {
824
+ await this.rebuildMaterializedRecord(tx, op.collection, op.recordId);
825
+ }
826
+ }
827
+ return { admitted: true, idempotent: false, applied: ops };
828
+ });
829
+ for (const op of result.applied) {
830
+ const currentMax = this.versionVector.get(op.nodeId) ?? 0;
831
+ if (op.sequenceNumber > currentMax) {
832
+ this.versionVector.set(op.nodeId, op.sequenceNumber);
833
+ }
834
+ }
835
+ return result;
836
+ }
725
837
  async getOperationRange(nodeId, fromSeq, toSeq) {
726
838
  this.assertOpen();
727
839
  await this.ready;
@@ -736,6 +848,23 @@ var PostgresServerStore = class {
736
848
  const result = await this.db.select({ value: count() }).from(pgOperations);
737
849
  return result[0]?.value ?? 0;
738
850
  }
851
+ async getMaxDeliverySequence() {
852
+ this.assertOpen();
853
+ await this.ready;
854
+ const rows = await this.db.execute(
855
+ sql`SELECT COALESCE(MAX(delivery_seq), 0) AS m FROM operations`
856
+ );
857
+ return Number(rows[0]?.m ?? 0);
858
+ }
859
+ async getOperationsAfterDelivery(afterDeliverySequence, limit) {
860
+ this.assertOpen();
861
+ await this.ready;
862
+ const rows = await this.db.select().from(pgOperations).where(gt(pgOperations.deliverySeq, afterDeliverySequence)).orderBy(asc(pgOperations.deliverySeq)).limit(limit);
863
+ return rows.map((row) => ({
864
+ operation: this.deserializeOperation(row),
865
+ deliverySequence: row.deliverySeq ?? 0
866
+ }));
867
+ }
739
868
  async materializeCollection(collection) {
740
869
  this.assertOpen();
741
870
  await this.ready;
@@ -799,7 +928,7 @@ var PostgresServerStore = class {
799
928
  this.assertOpen();
800
929
  await this.ready;
801
930
  const { buildServerBackup } = await import("./server-backup-MOICK6MI.js");
802
- const rows = await this.db.select().from(pgOperations).orderBy(asc(pgOperations.sequenceNumber));
931
+ const rows = await this.db.select().from(pgOperations).orderBy(asc(pgOperations.deliverySeq));
803
932
  const operations2 = rows.map((row) => this.deserializeOperation(row));
804
933
  return buildServerBackup(this.nodeId, operations2, this.versionVector);
805
934
  }
@@ -814,6 +943,7 @@ var PostgresServerStore = class {
814
943
  const result = await this.applyRemoteOperation(op);
815
944
  if (result === "applied") restored++;
816
945
  }
946
+ this.sequenceCounter = null;
817
947
  return { operationsRestored: restored, success: true };
818
948
  }
819
949
  const now = Date.now();
@@ -823,20 +953,49 @@ var PostgresServerStore = class {
823
953
  for (const [nid, seq] of versionVector) {
824
954
  await tx.insert(pgSyncState).values({ nodeId: nid, maxSequenceNumber: seq, lastSeenAt: now }).onConflictDoNothing({ target: pgSyncState.nodeId });
825
955
  }
956
+ let deliverySeq = 0;
826
957
  for (const op of operations2) {
827
- const row = this.serializeOperation(op, now);
958
+ deliverySeq += 1;
959
+ const row = this.serializeOperation(op, now, deliverySeq);
828
960
  await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
829
961
  }
962
+ await tx.execute(
963
+ sql`INSERT INTO delivery_counter (id, value) VALUES (1, ${deliverySeq})
964
+ ON CONFLICT (id) DO UPDATE SET value = ${deliverySeq}`
965
+ );
830
966
  });
831
967
  this.versionVector.clear();
832
968
  for (const [nid, seq] of versionVector) {
833
969
  this.versionVector.set(nid, seq);
834
970
  }
971
+ this.sequenceCounter = null;
835
972
  return { operationsRestored: operations2.length, success: true };
836
973
  }
837
974
  // ---------------------------------------------------------------------------
838
975
  // Materialization internals
839
976
  // ---------------------------------------------------------------------------
977
+ /**
978
+ * The HLC timestamp of the most recent operation on a record, by total order
979
+ * (wallTime, logical, nodeId), or null when the record has no operations. Used
980
+ * by {@link applyConditional} to advance its clock past the record's latest
981
+ * write so newly built operations sort strictly after it.
982
+ */
983
+ async latestOperationTimestamp(txOrDb, collection, recordId) {
984
+ const rows = await txOrDb.select({
985
+ wallTime: pgOperations.wallTime,
986
+ logical: pgOperations.logical,
987
+ timestampNodeId: pgOperations.timestampNodeId
988
+ }).from(pgOperations).where(and(eq(pgOperations.collection, collection), eq(pgOperations.recordId, recordId))).orderBy(
989
+ desc(pgOperations.wallTime),
990
+ desc(pgOperations.logical),
991
+ desc(pgOperations.timestampNodeId)
992
+ ).limit(1);
993
+ const row = rows[0];
994
+ if (!row) {
995
+ return null;
996
+ }
997
+ return { wallTime: row.wallTime, logical: row.logical, nodeId: row.timestampNodeId };
998
+ }
840
999
  /**
841
1000
  * Rebuild a single record in the materialized collection table by replaying
842
1001
  * all operations for that record.
@@ -847,15 +1006,17 @@ var PostgresServerStore = class {
847
1006
  const ops = await txOrDb.select({
848
1007
  type: pgOperations.type,
849
1008
  data: pgOperations.data,
1009
+ atomicOps: pgOperations.atomicOps,
850
1010
  wallTime: pgOperations.wallTime
851
1011
  }).from(pgOperations).where(and(eq(pgOperations.collection, collection), eq(pgOperations.recordId, recordId))).orderBy(
852
1012
  asc(pgOperations.wallTime),
853
1013
  asc(pgOperations.logical),
854
- asc(pgOperations.sequenceNumber)
1014
+ asc(pgOperations.timestampNodeId)
855
1015
  );
856
1016
  const parsedOps = ops.map((op) => ({
857
1017
  type: op.type,
858
- data: op.data !== null ? JSON.parse(op.data) : null
1018
+ data: op.data !== null ? JSON.parse(op.data) : null,
1019
+ atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
859
1020
  }));
860
1021
  const recordData = replayOperationsForRecord(parsedOps);
861
1022
  const fieldNames = Object.keys(collectionDef.fields);
@@ -924,11 +1085,12 @@ var PostgresServerStore = class {
924
1085
  recordId: pgOperations.recordId,
925
1086
  type: pgOperations.type,
926
1087
  data: pgOperations.data,
1088
+ atomicOps: pgOperations.atomicOps,
927
1089
  wallTime: pgOperations.wallTime
928
1090
  }).from(pgOperations).where(eq(pgOperations.collection, collectionName)).orderBy(
929
1091
  asc(pgOperations.wallTime),
930
1092
  asc(pgOperations.logical),
931
- asc(pgOperations.sequenceNumber)
1093
+ asc(pgOperations.timestampNodeId)
932
1094
  );
933
1095
  if (allOps.length === 0) return;
934
1096
  const grouped = /* @__PURE__ */ new Map();
@@ -944,7 +1106,8 @@ var PostgresServerStore = class {
944
1106
  for (const [recordId, recordOps] of grouped) {
945
1107
  const parsedOps = recordOps.map((op) => ({
946
1108
  type: op.type,
947
- data: op.data !== null ? JSON.parse(op.data) : null
1109
+ data: op.data !== null ? JSON.parse(op.data) : null,
1110
+ atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
948
1111
  }));
949
1112
  const recordData = replayOperationsForRecord(parsedOps);
950
1113
  if (recordData) {
@@ -1069,44 +1232,92 @@ var PostgresServerStore = class {
1069
1232
  }
1070
1233
  }
1071
1234
  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)`
1235
+ await this.db.transaction(async (tx) => {
1236
+ await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended('kora:ensure-tables', 0))`);
1237
+ await tx.execute(sql`
1238
+ CREATE TABLE IF NOT EXISTS operations (
1239
+ id TEXT PRIMARY KEY,
1240
+ node_id TEXT NOT NULL,
1241
+ type TEXT NOT NULL,
1242
+ collection TEXT NOT NULL,
1243
+ record_id TEXT NOT NULL,
1244
+ data TEXT,
1245
+ previous_data TEXT,
1246
+ atomic_ops TEXT,
1247
+ wall_time BIGINT NOT NULL,
1248
+ logical INTEGER NOT NULL,
1249
+ timestamp_node_id TEXT NOT NULL,
1250
+ sequence_number INTEGER NOT NULL,
1251
+ causal_deps TEXT NOT NULL DEFAULT '[]',
1252
+ schema_version INTEGER NOT NULL,
1253
+ received_at BIGINT NOT NULL
1254
+ )
1255
+ `);
1256
+ await tx.execute(sql`ALTER TABLE operations ADD COLUMN IF NOT EXISTS atomic_ops TEXT`);
1257
+ await tx.execute(sql`ALTER TABLE operations ADD COLUMN IF NOT EXISTS delivery_seq BIGINT`);
1258
+ await tx.execute(
1259
+ sql`CREATE INDEX IF NOT EXISTS idx_node_seq ON operations (node_id, sequence_number)`
1260
+ );
1261
+ await tx.execute(sql`CREATE INDEX IF NOT EXISTS idx_collection ON operations (collection)`);
1262
+ await tx.execute(sql`CREATE INDEX IF NOT EXISTS idx_received ON operations (received_at)`);
1263
+ await tx.execute(
1264
+ sql`CREATE INDEX IF NOT EXISTS idx_delivery_seq ON operations (delivery_seq)`
1265
+ );
1266
+ await tx.execute(
1267
+ sql`CREATE INDEX IF NOT EXISTS idx_collection_record ON operations (collection, record_id)`
1268
+ );
1269
+ await tx.execute(sql`
1270
+ CREATE TABLE IF NOT EXISTS sync_state (
1271
+ node_id TEXT PRIMARY KEY,
1272
+ max_sequence_number INTEGER NOT NULL,
1273
+ last_seen_at BIGINT NOT NULL
1274
+ )
1275
+ `);
1276
+ await tx.execute(sql`
1277
+ CREATE TABLE IF NOT EXISTS delivery_counter (
1278
+ id INTEGER PRIMARY KEY,
1279
+ value BIGINT NOT NULL
1280
+ )
1281
+ `);
1282
+ await tx.execute(sql`
1283
+ WITH ordered AS (
1284
+ SELECT id, ROW_NUMBER() OVER (
1285
+ ORDER BY received_at ASC, sequence_number ASC, id ASC
1286
+ ) + COALESCE((SELECT MAX(delivery_seq) FROM operations), 0) AS rn
1287
+ FROM operations WHERE delivery_seq IS NULL
1288
+ )
1289
+ UPDATE operations o SET delivery_seq = ordered.rn
1290
+ FROM ordered WHERE o.id = ordered.id
1291
+ `);
1292
+ await tx.execute(sql`
1293
+ INSERT INTO delivery_counter (id, value)
1294
+ VALUES (1, COALESCE((SELECT MAX(delivery_seq) FROM operations), 0))
1295
+ ON CONFLICT (id) DO UPDATE
1296
+ SET value = GREATEST(delivery_counter.value, EXCLUDED.value)
1297
+ `);
1298
+ });
1299
+ }
1300
+ /**
1301
+ * Reserve the next delivery sequence inside an open append transaction. The
1302
+ * `UPDATE ... RETURNING` locks the counter row until this transaction commits,
1303
+ * serializing assignment into commit order (see the counter table comment).
1304
+ */
1305
+ async nextDeliverySeq(tx) {
1306
+ const rows = await tx.execute(
1307
+ sql`UPDATE delivery_counter SET value = value + 1 WHERE id = 1 RETURNING value`
1097
1308
  );
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
- `);
1309
+ const value = rows[0]?.value;
1310
+ if (value === void 0 || value === null) {
1311
+ throw new Error(
1312
+ "delivery_counter row (id=1) is missing; the operations log cannot assign delivery sequences"
1313
+ );
1314
+ }
1315
+ return Number(value);
1105
1316
  }
1106
1317
  // ---------------------------------------------------------------------------
1107
1318
  // Operation serialization
1108
1319
  // ---------------------------------------------------------------------------
1109
- serializeOperation(op, receivedAt) {
1320
+ serializeOperation(op, receivedAt, deliverySeq) {
1110
1321
  return {
1111
1322
  id: op.id,
1112
1323
  nodeId: op.nodeId,
@@ -1115,16 +1326,19 @@ var PostgresServerStore = class {
1115
1326
  recordId: op.recordId,
1116
1327
  data: op.data !== null ? JSON.stringify(op.data) : null,
1117
1328
  previousData: op.previousData !== null ? JSON.stringify(op.previousData) : null,
1329
+ atomicOps: op.atomicOps && Object.keys(op.atomicOps).length > 0 ? JSON.stringify(op.atomicOps) : null,
1118
1330
  wallTime: op.timestamp.wallTime,
1119
1331
  logical: op.timestamp.logical,
1120
1332
  timestampNodeId: op.timestamp.nodeId,
1121
1333
  sequenceNumber: op.sequenceNumber,
1122
1334
  causalDeps: JSON.stringify(op.causalDeps),
1123
1335
  schemaVersion: op.schemaVersion,
1124
- receivedAt
1336
+ receivedAt,
1337
+ deliverySeq
1125
1338
  };
1126
1339
  }
1127
1340
  deserializeOperation(row) {
1341
+ const atomicOps = row.atomicOps != null ? JSON.parse(row.atomicOps) : void 0;
1128
1342
  return {
1129
1343
  id: row.id,
1130
1344
  nodeId: row.nodeId,
@@ -1140,7 +1354,8 @@ var PostgresServerStore = class {
1140
1354
  },
1141
1355
  sequenceNumber: row.sequenceNumber,
1142
1356
  causalDeps: JSON.parse(row.causalDeps),
1143
- schemaVersion: row.schemaVersion
1357
+ schemaVersion: row.schemaVersion,
1358
+ ...atomicOps ? { atomicOps } : {}
1144
1359
  };
1145
1360
  }
1146
1361
  // ---------------------------------------------------------------------------
@@ -1192,7 +1407,7 @@ async function loadPostgresDeps() {
1192
1407
  // src/store/sqlite-server-store.ts
1193
1408
  import { createRequire } from "module";
1194
1409
  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";
1410
+ 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
1411
 
1197
1412
  // src/store/drizzle-schema.ts
1198
1413
  import { index as index2, integer as integer2, sqliteTable, text as text2 } from "drizzle-orm/sqlite-core";
@@ -1208,6 +1423,8 @@ var operations = sqliteTable(
1208
1423
  // JSON-serialized, null for deletes
1209
1424
  previousData: text2("previous_data"),
1210
1425
  // JSON-serialized, null for insert/delete
1426
+ atomicOps: text2("atomic_ops"),
1427
+ // JSON-serialized Record<field, AtomicOp>, null when none
1211
1428
  wallTime: integer2("wall_time").notNull(),
1212
1429
  logical: integer2("logical").notNull(),
1213
1430
  timestampNodeId: text2("timestamp_node_id").notNull(),
@@ -1215,12 +1432,17 @@ var operations = sqliteTable(
1215
1432
  causalDeps: text2("causal_deps").notNull().default("[]"),
1216
1433
  // JSON array of op IDs
1217
1434
  schemaVersion: integer2("schema_version").notNull(),
1218
- receivedAt: integer2("received_at").notNull()
1435
+ receivedAt: integer2("received_at").notNull(),
1436
+ // Server-assigned monotonic delivery sequence, ordered by commit. Drives the
1437
+ // gap-free server->client delivery watermark. Nullable only for rows written
1438
+ // before the column existed; those are backfilled on startup.
1439
+ deliverySeq: integer2("delivery_seq")
1219
1440
  },
1220
1441
  (table) => ({
1221
1442
  nodeSeqIdx: index2("idx_node_seq").on(table.nodeId, table.sequenceNumber),
1222
1443
  collectionIdx: index2("idx_collection").on(table.collection),
1223
- receivedIdx: index2("idx_received").on(table.receivedAt)
1444
+ receivedIdx: index2("idx_received").on(table.receivedAt),
1445
+ deliveryIdx: index2("idx_delivery_seq").on(table.deliverySeq)
1224
1446
  })
1225
1447
  );
1226
1448
  var syncState = sqliteTable("sync_state", {
@@ -1236,6 +1458,12 @@ var SqliteServerStore = class {
1236
1458
  db;
1237
1459
  schema = null;
1238
1460
  closed = false;
1461
+ /**
1462
+ * Next delivery sequence to assign. Single-writer (better-sqlite3 is synchronous),
1463
+ * so a plain in-memory counter, seeded from MAX(delivery_seq) at startup, gives
1464
+ * commit-order delivery sequence. Assigned inside the append transaction.
1465
+ */
1466
+ deliverySeqCounter = 0;
1239
1467
  constructor(db, nodeId) {
1240
1468
  this.db = db;
1241
1469
  this.nodeId = nodeId ?? generateUUIDv73();
@@ -1281,12 +1509,16 @@ var SqliteServerStore = class {
1281
1509
  async applyRemoteOperation(op) {
1282
1510
  this.assertOpen();
1283
1511
  const now = Date.now();
1284
- const row = this.serializeOperation(op, now);
1285
1512
  const result = this.db.transaction((tx) => {
1286
- const insertResult = tx.insert(operations).values(row).onConflictDoNothing({ target: operations.id }).run();
1287
- if (insertResult.changes === 0) {
1513
+ const existing = tx.all(
1514
+ sql2`SELECT id FROM operations WHERE id = ${op.id} LIMIT 1`
1515
+ );
1516
+ if (existing.length > 0) {
1288
1517
  return "duplicate";
1289
1518
  }
1519
+ this.deliverySeqCounter += 1;
1520
+ const row = this.serializeOperation(op, now, this.deliverySeqCounter);
1521
+ tx.insert(operations).values(row).run();
1290
1522
  tx.insert(syncState).values({
1291
1523
  nodeId: op.nodeId,
1292
1524
  maxSequenceNumber: op.sequenceNumber,
@@ -1315,6 +1547,21 @@ var SqliteServerStore = class {
1315
1547
  const result = this.db.select({ value: count2() }).from(operations).all();
1316
1548
  return result[0]?.value ?? 0;
1317
1549
  }
1550
+ async getMaxDeliverySequence() {
1551
+ this.assertOpen();
1552
+ const rows = this.db.all(
1553
+ sql2`SELECT MAX(delivery_seq) AS m FROM operations`
1554
+ );
1555
+ return rows[0]?.m ?? 0;
1556
+ }
1557
+ async getOperationsAfterDelivery(afterDeliverySequence, limit) {
1558
+ this.assertOpen();
1559
+ const rows = this.db.select().from(operations).where(gt2(operations.deliverySeq, afterDeliverySequence)).orderBy(asc2(operations.deliverySeq)).limit(limit).all();
1560
+ return rows.map((row) => ({
1561
+ operation: this.deserializeOperation(row),
1562
+ deliverySequence: row.deliverySeq ?? 0
1563
+ }));
1564
+ }
1318
1565
  async materializeCollection(collection) {
1319
1566
  this.assertOpen();
1320
1567
  if (this.schema?.collections[collection]) {
@@ -1372,7 +1619,7 @@ var SqliteServerStore = class {
1372
1619
  async exportBackup() {
1373
1620
  this.assertOpen();
1374
1621
  const { buildServerBackup } = await import("./server-backup-MOICK6MI.js");
1375
- const rows = this.db.select().from(operations).all();
1622
+ const rows = this.db.select().from(operations).orderBy(asc2(operations.deliverySeq)).all();
1376
1623
  const deserialized = rows.map((row) => this.deserializeOperation(row));
1377
1624
  const vv = this.getVersionVector();
1378
1625
  return buildServerBackup(this.nodeId, deserialized, vv);
@@ -1395,10 +1642,13 @@ var SqliteServerStore = class {
1395
1642
  for (const [nid, seq] of versionVector) {
1396
1643
  tx.insert(syncState).values({ nodeId: nid, maxSequenceNumber: seq, lastSeenAt: Date.now() }).run();
1397
1644
  }
1645
+ let deliverySeq = 0;
1398
1646
  for (const op of ops) {
1399
- const row = this.serializeOperation(op, Date.now());
1647
+ deliverySeq += 1;
1648
+ const row = this.serializeOperation(op, Date.now(), deliverySeq);
1400
1649
  tx.insert(operations).values(row).run();
1401
1650
  }
1651
+ this.deliverySeqCounter = deliverySeq;
1402
1652
  });
1403
1653
  return { operationsRestored: ops.length, success: true };
1404
1654
  }
@@ -1416,11 +1666,13 @@ var SqliteServerStore = class {
1416
1666
  const ops = txOrDb.select({
1417
1667
  type: operations.type,
1418
1668
  data: operations.data,
1669
+ atomicOps: operations.atomicOps,
1419
1670
  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();
1671
+ }).from(operations).where(and2(eq2(operations.collection, collection), eq2(operations.recordId, recordId))).orderBy(asc2(operations.wallTime), asc2(operations.logical), asc2(operations.timestampNodeId)).all();
1421
1672
  const parsedOps = ops.map((op) => ({
1422
1673
  type: op.type,
1423
- data: op.data !== null ? JSON.parse(op.data) : null
1674
+ data: op.data !== null ? JSON.parse(op.data) : null,
1675
+ atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
1424
1676
  }));
1425
1677
  const recordData = replayOperationsForRecord(parsedOps);
1426
1678
  const fieldNames = Object.keys(collectionDef.fields);
@@ -1492,8 +1744,9 @@ var SqliteServerStore = class {
1492
1744
  recordId: operations.recordId,
1493
1745
  type: operations.type,
1494
1746
  data: operations.data,
1747
+ atomicOps: operations.atomicOps,
1495
1748
  wallTime: operations.wallTime
1496
- }).from(operations).where(eq2(operations.collection, collectionName)).orderBy(asc2(operations.wallTime), asc2(operations.logical), asc2(operations.sequenceNumber)).all();
1749
+ }).from(operations).where(eq2(operations.collection, collectionName)).orderBy(asc2(operations.wallTime), asc2(operations.logical), asc2(operations.timestampNodeId)).all();
1497
1750
  if (allOps.length === 0) return;
1498
1751
  const grouped = /* @__PURE__ */ new Map();
1499
1752
  for (const op of allOps) {
@@ -1509,7 +1762,8 @@ var SqliteServerStore = class {
1509
1762
  for (const [recordId, recordOps] of grouped) {
1510
1763
  const parsedOps = recordOps.map((op) => ({
1511
1764
  type: op.type,
1512
- data: op.data !== null ? JSON.parse(op.data) : null
1765
+ data: op.data !== null ? JSON.parse(op.data) : null,
1766
+ atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
1513
1767
  }));
1514
1768
  const recordData = replayOperationsForRecord(parsedOps);
1515
1769
  if (recordData) {
@@ -1633,6 +1887,7 @@ var SqliteServerStore = class {
1633
1887
  record_id TEXT NOT NULL,
1634
1888
  data TEXT,
1635
1889
  previous_data TEXT,
1890
+ atomic_ops TEXT,
1636
1891
  wall_time INTEGER NOT NULL,
1637
1892
  logical INTEGER NOT NULL,
1638
1893
  timestamp_node_id TEXT NOT NULL,
@@ -1642,9 +1897,30 @@ var SqliteServerStore = class {
1642
1897
  received_at INTEGER NOT NULL
1643
1898
  )
1644
1899
  `);
1900
+ try {
1901
+ this.db.run(sql2`ALTER TABLE operations ADD COLUMN atomic_ops TEXT`);
1902
+ } catch (e) {
1903
+ const msg = e instanceof Error ? e.message : "";
1904
+ const causeMsg = e instanceof Error && e.cause instanceof Error ? e.cause.message : "";
1905
+ if (!msg.includes("duplicate column") && !causeMsg.includes("duplicate column")) {
1906
+ throw e;
1907
+ }
1908
+ }
1909
+ try {
1910
+ this.db.run(sql2`ALTER TABLE operations ADD COLUMN delivery_seq INTEGER`);
1911
+ } catch (e) {
1912
+ const msg = e instanceof Error ? e.message : "";
1913
+ const causeMsg = e instanceof Error && e.cause instanceof Error ? e.cause.message : "";
1914
+ if (!msg.includes("duplicate column") && !causeMsg.includes("duplicate column")) {
1915
+ throw e;
1916
+ }
1917
+ }
1645
1918
  this.db.run(sql2`
1646
1919
  CREATE INDEX IF NOT EXISTS idx_node_seq ON operations (node_id, sequence_number)
1647
1920
  `);
1921
+ this.db.run(sql2`
1922
+ CREATE INDEX IF NOT EXISTS idx_delivery_seq ON operations (delivery_seq)
1923
+ `);
1648
1924
  this.db.run(sql2`
1649
1925
  CREATE INDEX IF NOT EXISTS idx_collection ON operations (collection)
1650
1926
  `);
@@ -1661,11 +1937,34 @@ var SqliteServerStore = class {
1661
1937
  last_seen_at INTEGER NOT NULL
1662
1938
  )
1663
1939
  `);
1940
+ this.backfillDeliverySequence();
1941
+ }
1942
+ /**
1943
+ * Assign delivery sequences to any operations written before the column existed,
1944
+ * then seed the in-memory counter from the current max. Ordered by
1945
+ * (received_at, sequence_number, id) so the backfill is deterministic and stable
1946
+ * across restarts. A one-time migration: after it runs, all rows have a value.
1947
+ */
1948
+ backfillDeliverySequence() {
1949
+ this.db.transaction((tx) => {
1950
+ const maxRow = tx.all(
1951
+ sql2`SELECT MAX(delivery_seq) AS m FROM operations`
1952
+ );
1953
+ let next = maxRow[0]?.m ?? 0;
1954
+ const nullRows = tx.all(
1955
+ sql2`SELECT id FROM operations WHERE delivery_seq IS NULL ORDER BY received_at ASC, sequence_number ASC, id ASC`
1956
+ );
1957
+ for (const row of nullRows) {
1958
+ next += 1;
1959
+ tx.run(sql2`UPDATE operations SET delivery_seq = ${next} WHERE id = ${row.id}`);
1960
+ }
1961
+ this.deliverySeqCounter = next;
1962
+ });
1664
1963
  }
1665
1964
  // ---------------------------------------------------------------------------
1666
1965
  // Operation serialization
1667
1966
  // ---------------------------------------------------------------------------
1668
- serializeOperation(op, receivedAt) {
1967
+ serializeOperation(op, receivedAt, deliverySeq) {
1669
1968
  return {
1670
1969
  id: op.id,
1671
1970
  nodeId: op.nodeId,
@@ -1674,16 +1973,19 @@ var SqliteServerStore = class {
1674
1973
  recordId: op.recordId,
1675
1974
  data: op.data !== null ? JSON.stringify(op.data) : null,
1676
1975
  previousData: op.previousData !== null ? JSON.stringify(op.previousData) : null,
1976
+ atomicOps: op.atomicOps && Object.keys(op.atomicOps).length > 0 ? JSON.stringify(op.atomicOps) : null,
1677
1977
  wallTime: op.timestamp.wallTime,
1678
1978
  logical: op.timestamp.logical,
1679
1979
  timestampNodeId: op.timestamp.nodeId,
1680
1980
  sequenceNumber: op.sequenceNumber,
1681
1981
  causalDeps: JSON.stringify(op.causalDeps),
1682
1982
  schemaVersion: op.schemaVersion,
1683
- receivedAt
1983
+ receivedAt,
1984
+ deliverySeq
1684
1985
  };
1685
1986
  }
1686
1987
  deserializeOperation(row) {
1988
+ const atomicOps = row.atomicOps != null ? JSON.parse(row.atomicOps) : void 0;
1687
1989
  return {
1688
1990
  id: row.id,
1689
1991
  nodeId: row.nodeId,
@@ -1699,7 +2001,8 @@ var SqliteServerStore = class {
1699
2001
  },
1700
2002
  sequenceNumber: row.sequenceNumber,
1701
2003
  causalDeps: JSON.parse(row.causalDeps),
1702
- schemaVersion: row.schemaVersion
2004
+ schemaVersion: row.schemaVersion,
2005
+ ...atomicOps ? { atomicOps } : {}
1703
2006
  };
1704
2007
  }
1705
2008
  // ---------------------------------------------------------------------------
@@ -1981,10 +2284,10 @@ function createServerReferentialContext(store) {
1981
2284
  }
1982
2285
 
1983
2286
  // src/apply/server-side-effect-operation.ts
1984
- import { HybridLogicalClock, createOperation } from "@korajs/core";
2287
+ import { HybridLogicalClock as HybridLogicalClock2, createOperation } from "@korajs/core";
1985
2288
  async function createServerSideEffectOperation(store, parentOp, effect, schemaVersion, sequenceNumber) {
1986
2289
  const nodeId = store.getNodeId();
1987
- const clock = new HybridLogicalClock(nodeId);
2290
+ const clock = new HybridLogicalClock2(nodeId);
1988
2291
  clock.receive(parentOp.timestamp);
1989
2292
  return createOperation(
1990
2293
  {
@@ -2002,6 +2305,9 @@ async function createServerSideEffectOperation(store, parentOp, effect, schemaVe
2002
2305
  );
2003
2306
  }
2004
2307
  function nextServerSequenceNumber(store) {
2308
+ if (store.reserveSequenceNumber) {
2309
+ return store.reserveSequenceNumber();
2310
+ }
2005
2311
  const nodeId = store.getNodeId();
2006
2312
  const current = store.getVersionVector().get(nodeId) ?? 0;
2007
2313
  return current + 1;
@@ -2043,16 +2349,14 @@ async function applyServerOperation(store, op, relationLookup) {
2043
2349
  return { result: primaryResult, appliedOperations: [] };
2044
2350
  }
2045
2351
  const appliedOperations = [op];
2046
- let serverSeq = nextServerSequenceNumber(store);
2047
2352
  for (const effect of referential.sideEffectOps) {
2048
2353
  const sideOp = await createServerSideEffectOperation(
2049
2354
  store,
2050
2355
  op,
2051
2356
  effect,
2052
2357
  op.schemaVersion,
2053
- serverSeq
2358
+ nextServerSequenceNumber(store)
2054
2359
  );
2055
- serverSeq += 1;
2056
2360
  const sideResult = await store.applyRemoteOperation(sideOp);
2057
2361
  if (sideResult === "applied") {
2058
2362
  appliedOperations.push(sideOp);
@@ -2110,11 +2414,11 @@ function mergeScopeMaps(base, overlay, source) {
2110
2414
  }
2111
2415
 
2112
2416
  // src/scopes/server-scope-filter.ts
2113
- function operationMatchesScopes(op, scopes) {
2417
+ function operationMatchesScopes(op, scopes, fullRecord) {
2114
2418
  if (!scopes) return true;
2115
2419
  const collectionScope = scopes[op.collection];
2116
2420
  if (!collectionScope) return false;
2117
- const snapshot = buildSnapshot(op);
2421
+ const snapshot = buildSnapshot(op, fullRecord);
2118
2422
  if (!snapshot) return false;
2119
2423
  for (const [field, expected] of Object.entries(collectionScope)) {
2120
2424
  if (snapshot[field] !== expected) {
@@ -2123,11 +2427,19 @@ function operationMatchesScopes(op, scopes) {
2123
2427
  }
2124
2428
  return true;
2125
2429
  }
2126
- function buildSnapshot(op) {
2430
+ function missingScopeFields(op, scopes) {
2431
+ if (!scopes) return [];
2432
+ const collectionScope = scopes[op.collection];
2433
+ if (!collectionScope) return [];
2434
+ const snapshot = buildSnapshot(op);
2435
+ return Object.keys(collectionScope).filter((field) => !snapshot || !(field in snapshot));
2436
+ }
2437
+ function buildSnapshot(op, fullRecord) {
2127
2438
  const previous = asRecord(op.previousData);
2128
2439
  const next = asRecord(op.data);
2129
- if (!previous && !next) return null;
2440
+ if (!previous && !next && !fullRecord) return null;
2130
2441
  return {
2442
+ ...fullRecord ?? {},
2131
2443
  ...previous ?? {},
2132
2444
  ...next ?? {}
2133
2445
  };
@@ -2218,12 +2530,41 @@ function warnIfMultiTenantWithoutScopes(auth, resolvedScopes, schema) {
2218
2530
  "[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
2531
  );
2220
2532
  }
2221
- var ClientSession = class {
2533
+ var ClientSession = class _ClientSession {
2222
2534
  state = "connected";
2223
2535
  clientNodeId = null;
2224
2536
  authContext = null;
2225
2537
  syncQuerySubsets = [];
2226
2538
  resumeDeltaCursor = null;
2539
+ /**
2540
+ * The delivery watermark the client reported at handshake, or null when the client
2541
+ * does not use the delivery watermark (old client, or no watermark yet). When set,
2542
+ * the server drives the gap-free server->client stream from delivery sequences
2543
+ * instead of the version-vector delta.
2544
+ */
2545
+ clientDeliveryWatermark = null;
2546
+ /**
2547
+ * The highest delivery sequence this client has ACKNOWLEDGED (its confirmed
2548
+ * watermark, as reported in acks). Incremental streaming pushes resume from here, not
2549
+ * from the highest sequence sent, so a dropped or unapplied batch is always
2550
+ * re-included by the next push (which re-scans from the acknowledged position). This
2551
+ * is what makes streaming recovery independent of any bounded retransmit buffer: there
2552
+ * is no sent-but-unacked window that a buffer eviction could strand. Seeded from the
2553
+ * client's reported watermark at handshake.
2554
+ */
2555
+ lastAckedDeliverySeq = 0;
2556
+ /** Serializes incremental delivery pushes so their batches never interleave. */
2557
+ deliveryPushChain = Promise.resolve();
2558
+ /**
2559
+ * Relay batches sent to this client but not yet acknowledged, keyed by messageId.
2560
+ * The client acks every operation-batch it applies; on ack the entry is cleared.
2561
+ * {@link retransmitPendingRelays} re-sends anything still unacked, so a relay
2562
+ * dropped by a lossy transport is redelivered instead of leaving the client with a
2563
+ * permanent version-vector gap (a lost operation) that delta sync cannot recover.
2564
+ * Bounded so a silent client cannot grow it without limit.
2565
+ */
2566
+ pendingRelays = /* @__PURE__ */ new Map();
2567
+ static MAX_PENDING_RELAYS = 1e3;
2227
2568
  sessionId;
2228
2569
  transport;
2229
2570
  store;
@@ -2240,6 +2581,8 @@ var ClientSession = class {
2240
2581
  onBlobChunkResponse;
2241
2582
  persistBlobChunk;
2242
2583
  onClose;
2584
+ onOrphanedRelays;
2585
+ takeOrphanedRelays;
2243
2586
  maxOperationBytes;
2244
2587
  maxOpsPerMinute;
2245
2588
  rateLimiter;
@@ -2266,6 +2609,8 @@ var ClientSession = class {
2266
2609
  this.onBlobChunkResponse = options.onBlobChunkResponse ?? null;
2267
2610
  this.persistBlobChunk = options.persistBlobChunk ?? null;
2268
2611
  this.onClose = options.onClose ?? null;
2612
+ this.onOrphanedRelays = options.onOrphanedRelays ?? null;
2613
+ this.takeOrphanedRelays = options.takeOrphanedRelays ?? null;
2269
2614
  this.maxOperationBytes = options.maxOperationBytes ?? DEFAULT_MAX_OPERATION_BYTES;
2270
2615
  this.maxOpsPerMinute = options.maxOpsPerMinute ?? DEFAULT_MAX_OPS_PER_MINUTE;
2271
2616
  this.rateLimiter = new SessionRateLimiter(this.maxOpsPerMinute);
@@ -2290,9 +2635,40 @@ var ClientSession = class {
2290
2635
  */
2291
2636
  relayOperations(operations2) {
2292
2637
  if (this.state !== "streaming" || !this.transport.isConnected()) return;
2638
+ if (this.clientDeliveryWatermark !== null) {
2639
+ this.pushDeliveryStream();
2640
+ return;
2641
+ }
2293
2642
  if (operations2.length === 0) return;
2294
- const visibleOperations = operations2.filter((op) => this.operationVisibleToClient(op));
2643
+ void this.relayVisibleOperations(operations2);
2644
+ }
2645
+ /**
2646
+ * Push newly-available in-scope operations to a delivery-watermark client, resuming
2647
+ * from the highest delivery sequence already sent. Pushes are serialized so their
2648
+ * batches never interleave (which would break the base/max chain).
2649
+ */
2650
+ pushDeliveryStream() {
2651
+ this.deliveryPushChain = this.deliveryPushChain.then(async () => {
2652
+ if (this.state !== "streaming" || !this.transport.isConnected()) return;
2653
+ try {
2654
+ await this.sendDeliveryStream(
2655
+ this.lastAckedDeliverySeq,
2656
+ false,
2657
+ this.clientNodeId ?? void 0
2658
+ );
2659
+ } catch {
2660
+ }
2661
+ });
2662
+ }
2663
+ async relayVisibleOperations(operations2) {
2664
+ const visibleOperations = [];
2665
+ for (const op of operations2) {
2666
+ if (await this.operationVisibleToClient(op)) {
2667
+ visibleOperations.push(op);
2668
+ }
2669
+ }
2295
2670
  if (visibleOperations.length === 0) return;
2671
+ if (this.state !== "streaming" || !this.transport.isConnected()) return;
2296
2672
  const serializedOps = visibleOperations.map((op) => this.serializer.encodeOperation(op));
2297
2673
  const msg = {
2298
2674
  type: "operation-batch",
@@ -2301,14 +2677,62 @@ var ClientSession = class {
2301
2677
  isFinal: true,
2302
2678
  batchIndex: 0
2303
2679
  };
2680
+ this.trackPendingRelay(msg, visibleOperations);
2304
2681
  this.sendToClient(msg);
2305
2682
  }
2683
+ /** Record a relay batch as awaiting acknowledgment, evicting the oldest if full. */
2684
+ trackPendingRelay(msg, ops) {
2685
+ if (this.pendingRelays.size >= _ClientSession.MAX_PENDING_RELAYS) {
2686
+ const oldest = this.pendingRelays.keys().next().value;
2687
+ if (oldest !== void 0) {
2688
+ this.pendingRelays.delete(oldest);
2689
+ }
2690
+ }
2691
+ this.pendingRelays.set(msg.messageId, { message: msg, ops, sentAtMs: Date.now() });
2692
+ }
2693
+ /**
2694
+ * Retransmit relay batches this client has not acknowledged within `staleMs`.
2695
+ * Called on a periodic tick by the server (and directly by tests). Redelivering an
2696
+ * already-applied op is harmless: the client dedups by content-addressed id.
2697
+ */
2698
+ retransmitPendingRelays(staleMs = 0) {
2699
+ if (this.state !== "streaming" || !this.transport.isConnected()) return;
2700
+ if (this.clientDeliveryWatermark !== null) {
2701
+ this.pushDeliveryStream();
2702
+ return;
2703
+ }
2704
+ if (this.pendingRelays.size === 0) return;
2705
+ const cutoff = Date.now() - staleMs;
2706
+ for (const { message, sentAtMs } of this.pendingRelays.values()) {
2707
+ if (sentAtMs <= cutoff) {
2708
+ this.sendToClient(message);
2709
+ }
2710
+ }
2711
+ }
2306
2712
  /**
2307
2713
  * Close this session.
2308
2714
  */
2715
+ /**
2716
+ * Hand any unacknowledged relays to the server so they survive this session and are
2717
+ * redelivered when this client reconnects (per-node buffer), then clear them. Called
2718
+ * from every session-teardown path (explicit close and transport close).
2719
+ */
2720
+ flushOrphanedRelays() {
2721
+ if (this.onOrphanedRelays && this.clientNodeId && this.pendingRelays.size > 0) {
2722
+ const ops = [];
2723
+ for (const entry of this.pendingRelays.values()) {
2724
+ ops.push(...entry.ops);
2725
+ }
2726
+ if (ops.length > 0) {
2727
+ this.onOrphanedRelays(this.clientNodeId, ops);
2728
+ }
2729
+ }
2730
+ this.pendingRelays.clear();
2731
+ }
2309
2732
  close(reason) {
2310
2733
  if (this.state === "closed") return;
2311
2734
  this.state = "closed";
2735
+ this.flushOrphanedRelays();
2312
2736
  if (this.transport.isConnected()) {
2313
2737
  this.transport.close(1e3, reason ?? "session closed");
2314
2738
  }
@@ -2363,6 +2787,10 @@ var ClientSession = class {
2363
2787
  await this.handleOperationBatch(message);
2364
2788
  break;
2365
2789
  case "acknowledgment":
2790
+ this.pendingRelays.delete(message.acknowledgedMessageId);
2791
+ if (message.deliverySequence !== void 0) {
2792
+ this.lastAckedDeliverySeq = Math.max(this.lastAckedDeliverySeq, message.deliverySequence);
2793
+ }
2366
2794
  break;
2367
2795
  case "error":
2368
2796
  break;
@@ -2439,6 +2867,15 @@ var ClientSession = class {
2439
2867
  this.syncQuerySubsets = [];
2440
2868
  }
2441
2869
  this.resumeDeltaCursor = msg.deltaCursor ? decodeDeltaCursor(msg.deltaCursor) : null;
2870
+ this.clientDeliveryWatermark = msg.lastDeliverySequence ?? null;
2871
+ const clientUsesWatermark = this.clientDeliveryWatermark !== null;
2872
+ let serverMaxDelivery = 0;
2873
+ if (clientUsesWatermark) {
2874
+ serverMaxDelivery = await this.store.getMaxDeliverySequence();
2875
+ if (this.clientDeliveryWatermark !== null && this.clientDeliveryWatermark > serverMaxDelivery) {
2876
+ this.clientDeliveryWatermark = 0;
2877
+ }
2878
+ }
2442
2879
  const serverVector = this.store.getVersionVector();
2443
2880
  const selectedWireFormat = selectWireFormat(msg.supportedWireFormats);
2444
2881
  this.setSerializerWireFormat(selectedWireFormat);
@@ -2469,6 +2906,10 @@ var ClientSession = class {
2469
2906
  accepted: true,
2470
2907
  selectedWireFormat,
2471
2908
  serverTime: Date.now(),
2909
+ // The server's highest delivery sequence, so a client whose persisted watermark
2910
+ // is ahead of it (server rolled back) can reset to a full resync. Sent only to
2911
+ // watermark-using clients.
2912
+ ...clientUsesWatermark ? { serverMaxDeliverySequence: serverMaxDelivery } : {},
2472
2913
  // Advertise central blob storage so the client uploads the bytes behind
2473
2914
  // its blob fields, keeping them available after the author goes offline.
2474
2915
  ...this.persistBlobChunk ? { blobStorageEnabled: true } : {},
@@ -2479,16 +2920,28 @@ var ClientSession = class {
2479
2920
  this.sendToClient(response);
2480
2921
  this.emitter?.emit({ type: "sync:connected", nodeId: msg.nodeId });
2481
2922
  this.state = "syncing";
2482
- const clientVector = wireToVersionVector(msg.versionVector);
2483
- await this.sendDelta(clientVector);
2923
+ if (this.clientDeliveryWatermark !== null) {
2924
+ this.lastAckedDeliverySeq = this.clientDeliveryWatermark;
2925
+ const excludeOwn = this.clientDeliveryWatermark > 0 ? this.clientNodeId ?? void 0 : void 0;
2926
+ await this.sendDeliveryStream(this.clientDeliveryWatermark, true, excludeOwn);
2927
+ } else {
2928
+ const clientVector = wireToVersionVector(msg.versionVector);
2929
+ await this.sendDelta(clientVector);
2930
+ }
2484
2931
  this.state = "streaming";
2932
+ if (this.takeOrphanedRelays && this.clientNodeId) {
2933
+ const buffered = this.takeOrphanedRelays(this.clientNodeId);
2934
+ if (buffered.length > 0) {
2935
+ this.relayOperations(buffered);
2936
+ }
2937
+ }
2485
2938
  }
2486
2939
  async handleOperationBatch(msg) {
2487
2940
  const operations2 = msg.operations.map((s) => this.serializer.decodeOperation(s));
2488
2941
  const applied = [];
2489
2942
  const rejected = [];
2490
2943
  for (const op of operations2) {
2491
- if (!this.operationVisibleToClient(op)) {
2944
+ if (!await this.operationVisibleToClient(op)) {
2492
2945
  rejected.push(op);
2493
2946
  continue;
2494
2947
  }
@@ -2593,8 +3046,11 @@ var ClientSession = class {
2593
3046
  const clientSeq = clientVector.get(nodeId) ?? 0;
2594
3047
  if (serverSeq > clientSeq) {
2595
3048
  const ops = await this.store.getOperationRange(nodeId, clientSeq + 1, serverSeq);
2596
- const visible = ops.filter((op) => this.operationVisibleToClient(op));
2597
- missing.push(...visible);
3049
+ for (const op of ops) {
3050
+ if (await this.operationVisibleToClient(op)) {
3051
+ missing.push(op);
3052
+ }
3053
+ }
2598
3054
  }
2599
3055
  }
2600
3056
  if (missing.length === 0) {
@@ -2646,11 +3102,111 @@ var ClientSession = class {
2646
3102
  });
2647
3103
  }
2648
3104
  }
2649
- operationVisibleToClient(op) {
2650
- if (!operationMatchesScopes(op, this.authContext?.scopes)) {
3105
+ /**
3106
+ * Send the gap-free server->client delivery stream, resuming just after
3107
+ * `fromDeliverySeq`. Operations are scanned in server delivery-sequence order
3108
+ * (commit order), scope-filtered for this session, and sent in batches that chain:
3109
+ * each batch's `baseDeliverySequence` equals the previous batch's
3110
+ * `maxDeliverySequence` (the first batch bases on `fromDeliverySeq`). The client
3111
+ * applies a batch only when its watermark equals the base and advances the
3112
+ * watermark to the max, so a dropped batch stalls the watermark and is recovered by
3113
+ * the next handshake resend. Because delivery-sequence order respects causal order
3114
+ * (a dependency is always committed, and thus sequenced, before its dependent), no
3115
+ * topological sort is needed and the client never defers on a missing dependency
3116
+ * that is itself in this stream.
3117
+ *
3118
+ * The final batch advances the watermark to the highest delivery sequence scanned,
3119
+ * not merely the last in-scope one, so an out-of-scope tail is not re-scanned on the
3120
+ * next reconnect. Returns the number of operations actually sent.
3121
+ */
3122
+ async sendDeliveryStream(fromDeliverySeq, finalizeWhenEmpty, excludeNodeId) {
3123
+ const scanChunk = Math.max(this.batchSize, 1) * 5;
3124
+ let scanCursor = fromDeliverySeq;
3125
+ let maxScanned = fromDeliverySeq;
3126
+ const visible = [];
3127
+ while (true) {
3128
+ const chunk = await this.store.getOperationsAfterDelivery(scanCursor, scanChunk);
3129
+ const last = chunk[chunk.length - 1];
3130
+ if (last === void 0) break;
3131
+ scanCursor = last.deliverySequence;
3132
+ maxScanned = scanCursor;
3133
+ for (const delivered of chunk) {
3134
+ if (excludeNodeId !== void 0 && delivered.operation.nodeId === excludeNodeId) {
3135
+ continue;
3136
+ }
3137
+ if (await this.operationVisibleToClient(delivered.operation)) {
3138
+ visible.push(delivered);
3139
+ }
3140
+ }
3141
+ if (chunk.length < scanChunk) break;
3142
+ }
3143
+ if (visible.length === 0) {
3144
+ if (finalizeWhenEmpty) {
3145
+ this.sendDeliveryBatch([], fromDeliverySeq, maxScanned, 0, true);
3146
+ }
3147
+ return 0;
3148
+ }
3149
+ const totalBatches = Math.ceil(visible.length / this.batchSize);
3150
+ let base = fromDeliverySeq;
3151
+ for (let i = 0; i < totalBatches; i++) {
3152
+ const slice = visible.slice(i * this.batchSize, (i + 1) * this.batchSize);
3153
+ const lastInSlice = slice[slice.length - 1];
3154
+ if (lastInSlice === void 0) continue;
3155
+ const isFinal = i === totalBatches - 1;
3156
+ const lastSeq = lastInSlice.deliverySequence;
3157
+ const max = isFinal ? Math.max(maxScanned, lastSeq) : lastSeq;
3158
+ this.sendDeliveryBatch(slice, base, max, i, isFinal);
3159
+ base = max;
3160
+ }
3161
+ return visible.length;
3162
+ }
3163
+ /** Build, track, and send one chained delivery-stream batch. */
3164
+ sendDeliveryBatch(slice, base, max, batchIndex, isFinal) {
3165
+ const batchMsg = {
3166
+ type: "operation-batch",
3167
+ messageId: generateUUIDv74(),
3168
+ operations: slice.map((delivered) => this.serializer.encodeOperation(delivered.operation)),
3169
+ isFinal,
3170
+ batchIndex,
3171
+ baseDeliverySequence: base,
3172
+ maxDeliverySequence: max
3173
+ };
3174
+ this.sendToClient(batchMsg);
3175
+ if (slice.length > 0) {
3176
+ this.emitter?.emit({
3177
+ type: "sync:sent",
3178
+ operations: slice.map((delivered) => delivered.operation),
3179
+ batchSize: slice.length
3180
+ });
3181
+ }
3182
+ }
3183
+ async operationVisibleToClient(op) {
3184
+ const scopes = this.authContext?.scopes;
3185
+ const subsets = this.syncQuerySubsets;
3186
+ const needsBackfill = missingScopeFields(op, scopes).length > 0 || subsets !== void 0 && subsets.length > 0;
3187
+ const fullRecord = needsBackfill ? await this.lookupRecordFields(op.collection, op.recordId) : void 0;
3188
+ if (!operationMatchesScopes(op, scopes, fullRecord)) {
2651
3189
  return false;
2652
3190
  }
2653
- return operationMatchesQuerySubsets(op, this.syncQuerySubsets);
3191
+ return operationMatchesQuerySubsets(op, subsets, fullRecord);
3192
+ }
3193
+ /**
3194
+ * Read a record's current field values from the materialized store for scope /
3195
+ * query-subset backfill. Includes soft-deleted rows so a relayed delete (whose op
3196
+ * carries no fields) is still judged against the record's actual scope. Returns
3197
+ * undefined when the record cannot be read (never materialized, or no schema).
3198
+ */
3199
+ async lookupRecordFields(collection, recordId) {
3200
+ try {
3201
+ const rows = await this.store.queryCollection(collection, {
3202
+ where: { id: recordId },
3203
+ includeDeleted: true,
3204
+ limit: 1
3205
+ });
3206
+ return rows[0];
3207
+ } catch {
3208
+ return void 0;
3209
+ }
2654
3210
  }
2655
3211
  handleAwarenessUpdate(msg) {
2656
3212
  this.onAwarenessUpdate?.(this.sessionId, msg);
@@ -2695,6 +3251,7 @@ var ClientSession = class {
2695
3251
  handleTransportClose() {
2696
3252
  if (this.state === "closed") return;
2697
3253
  this.state = "closed";
3254
+ this.flushOrphanedRelays();
2698
3255
  this.emitter?.emit({ type: "sync:disconnected", reason: "transport closed" });
2699
3256
  this.onClose?.(this.sessionId);
2700
3257
  }
@@ -3068,7 +3625,64 @@ var YjsDocRelay = class {
3068
3625
  };
3069
3626
 
3070
3627
  // src/server/route-context.ts
3071
- import { HybridLogicalClock as HybridLogicalClock2, createOperation as createOperation2, generateUUIDv7 as generateUUIDv76 } from "@korajs/core";
3628
+ import {
3629
+ HybridLogicalClock as HybridLogicalClock3,
3630
+ createOperation as createOperation2,
3631
+ generateUUIDv7 as generateUUIDv76,
3632
+ isAtomicOp,
3633
+ resolveAtomicOp,
3634
+ toAtomicOp
3635
+ } from "@korajs/core";
3636
+
3637
+ // src/apply/route-predicate.ts
3638
+ function evaluateField(actual, operators) {
3639
+ if ("$eq" in operators && !Object.is(actual, operators.$eq)) {
3640
+ return false;
3641
+ }
3642
+ if ("$ne" in operators && Object.is(actual, operators.$ne)) {
3643
+ return false;
3644
+ }
3645
+ if (operators.$in !== void 0 && !operators.$in.some((candidate) => Object.is(actual, candidate))) {
3646
+ return false;
3647
+ }
3648
+ const numericChecks = [
3649
+ ["$lt", (a, b) => a < b],
3650
+ ["$lte", (a, b) => a <= b],
3651
+ ["$gt", (a, b) => a > b],
3652
+ ["$gte", (a, b) => a >= b]
3653
+ ];
3654
+ for (const [operator, compare] of numericChecks) {
3655
+ const bound = operators[operator];
3656
+ if (bound === void 0) {
3657
+ continue;
3658
+ }
3659
+ if (typeof actual !== "number" || typeof bound !== "number") {
3660
+ return false;
3661
+ }
3662
+ if (!compare(actual, bound)) {
3663
+ return false;
3664
+ }
3665
+ }
3666
+ return true;
3667
+ }
3668
+ function evaluateRoutePredicate(record, predicate) {
3669
+ const fields = Object.keys(predicate);
3670
+ if (fields.length === 0) {
3671
+ return true;
3672
+ }
3673
+ if (record === null) {
3674
+ return false;
3675
+ }
3676
+ for (const field of fields) {
3677
+ const operators = predicate[field];
3678
+ if (operators && !evaluateField(record[field], operators)) {
3679
+ return false;
3680
+ }
3681
+ }
3682
+ return true;
3683
+ }
3684
+
3685
+ // src/server/route-context.ts
3072
3686
  function recordMatchesScope(collection, record, scope) {
3073
3687
  const collectionScope = scope[collection];
3074
3688
  if (!collectionScope) {
@@ -3090,9 +3704,9 @@ function pickFields(record, keys) {
3090
3704
  }
3091
3705
  return picked;
3092
3706
  }
3093
- async function buildRouteOperation(store, mutation) {
3707
+ async function buildRouteOperation(store, mutation, currentOverride, clockOverride) {
3094
3708
  const nodeId = store.getNodeId();
3095
- const clock = new HybridLogicalClock2(nodeId);
3709
+ const clock = clockOverride ?? new HybridLogicalClock3(nodeId);
3096
3710
  const schemaVersion = store.getSchema()?.version ?? 1;
3097
3711
  const sequenceNumber = nextServerSequenceNumber(store);
3098
3712
  if (mutation.type === "insert") {
@@ -3117,9 +3731,20 @@ async function buildRouteOperation(store, mutation) {
3117
3731
  `A ${mutation.type} mutation on "${mutation.collection}" requires a recordId.`
3118
3732
  );
3119
3733
  }
3120
- const current = await store.findRecord(mutation.collection, mutation.recordId);
3734
+ const current = currentOverride ? currentOverride.record : await store.findRecord(mutation.collection, mutation.recordId);
3121
3735
  if (mutation.type === "update") {
3122
- const data = mutation.data ?? {};
3736
+ const rawData = mutation.data ?? {};
3737
+ const data = {};
3738
+ const atomicOps = {};
3739
+ for (const key of Object.keys(rawData)) {
3740
+ const value = rawData[key];
3741
+ if (isAtomicOp(value)) {
3742
+ data[key] = resolveAtomicOp(current ? current[key] : void 0, value);
3743
+ atomicOps[key] = toAtomicOp(value);
3744
+ } else {
3745
+ data[key] = value;
3746
+ }
3747
+ }
3123
3748
  return createOperation2(
3124
3749
  {
3125
3750
  nodeId,
@@ -3127,10 +3752,11 @@ async function buildRouteOperation(store, mutation) {
3127
3752
  collection: mutation.collection,
3128
3753
  recordId: mutation.recordId,
3129
3754
  data,
3130
- previousData: current ? pickFields(current, Object.keys(data)) : {},
3755
+ previousData: current ? pickFields(current, Object.keys(rawData)) : {},
3131
3756
  sequenceNumber,
3132
3757
  causalDeps: [],
3133
- schemaVersion
3758
+ schemaVersion,
3759
+ ...Object.keys(atomicOps).length > 0 ? { atomicOps } : {}
3134
3760
  },
3135
3761
  clock
3136
3762
  );
@@ -3168,45 +3794,200 @@ function createRouteContext(server, store) {
3168
3794
  );
3169
3795
  return run;
3170
3796
  }
3797
+ async function prepareMutation(mutation, scope) {
3798
+ let op;
3799
+ try {
3800
+ op = await buildRouteOperation(store, mutation);
3801
+ } catch (error) {
3802
+ if (error instanceof RouteMutationError) {
3803
+ return {
3804
+ ok: false,
3805
+ rejection: {
3806
+ ok: false,
3807
+ code: error.code,
3808
+ message: error.message,
3809
+ retriable: isRetriableRejection(error.code)
3810
+ }
3811
+ };
3812
+ }
3813
+ throw error;
3814
+ }
3815
+ if (scope && !operationMatchesScopes(op, scope)) {
3816
+ return {
3817
+ ok: false,
3818
+ rejection: {
3819
+ ok: false,
3820
+ code: "SCOPE_VIOLATION",
3821
+ message: `Mutation on "${mutation.collection}" is outside the provided scope.`,
3822
+ retriable: isRetriableRejection("SCOPE_VIOLATION")
3823
+ }
3824
+ };
3825
+ }
3826
+ return { ok: true, op };
3827
+ }
3828
+ async function commitOperation(op, collection) {
3829
+ const result = await server.applyLocalOperation(op);
3830
+ if (result.result !== "applied") {
3831
+ const code = result.rejection?.code ?? "NOT_APPLIED";
3832
+ return {
3833
+ ok: false,
3834
+ code,
3835
+ message: result.rejection?.message ?? `Operation on "${collection}" was not applied (${result.result}).`,
3836
+ // Prefer the pipeline's own classification when present; fall back
3837
+ // to classifying the code so this stays correct if new codes appear.
3838
+ retriable: result.rejection?.retriable ?? isRetriableRejection(code)
3839
+ };
3840
+ }
3841
+ const record = await store.findRecord(collection, op.recordId);
3842
+ return { ok: true, operation: op, record };
3843
+ }
3844
+ async function rawApply(mutation, scope) {
3845
+ const prepared = await prepareMutation(mutation, scope);
3846
+ if (!prepared.ok) {
3847
+ return prepared.rejection;
3848
+ }
3849
+ return commitOperation(prepared.op, mutation.collection);
3850
+ }
3851
+ async function runConditionalViaStore(storeApplyConditional, spec, options) {
3852
+ const scope = options?.scope;
3853
+ const mutations = [];
3854
+ const hasTargetUpdate = spec.update !== void 0 && spec.update !== null;
3855
+ if (hasTargetUpdate) {
3856
+ mutations.push({
3857
+ collection: spec.collection,
3858
+ type: "update",
3859
+ recordId: spec.id,
3860
+ data: spec.update
3861
+ });
3862
+ }
3863
+ if (spec.also) {
3864
+ mutations.push(...spec.also);
3865
+ }
3866
+ let result;
3867
+ try {
3868
+ result = await storeApplyConditional({
3869
+ target: { collection: spec.collection, id: spec.id },
3870
+ ...spec.idempotencyKey ? { idempotencyKey: spec.idempotencyKey } : {},
3871
+ admit: (current) => spec.if && Object.keys(spec.if).length > 0 ? evaluateRoutePredicate(current, spec.if) : true,
3872
+ buildOperations: async (current, context) => {
3873
+ const ops = [];
3874
+ for (let i = 0; i < mutations.length; i++) {
3875
+ const mutation = mutations[i];
3876
+ const useLockedCurrent = i === 0 && hasTargetUpdate;
3877
+ const op = await buildRouteOperation(
3878
+ store,
3879
+ mutation,
3880
+ useLockedCurrent ? { record: current } : void 0,
3881
+ context.clock
3882
+ );
3883
+ if (scope && !operationMatchesScopes(op, scope)) {
3884
+ throw new RouteMutationError(
3885
+ "SCOPE_VIOLATION",
3886
+ `Mutation on "${mutation.collection}" is outside the provided scope.`
3887
+ );
3888
+ }
3889
+ ops.push(op);
3890
+ }
3891
+ return ops;
3892
+ }
3893
+ });
3894
+ } catch (error) {
3895
+ if (error instanceof RouteMutationError) {
3896
+ return {
3897
+ ok: false,
3898
+ code: error.code,
3899
+ message: error.message,
3900
+ retriable: isRetriableRejection(error.code)
3901
+ };
3902
+ }
3903
+ throw error;
3904
+ }
3905
+ if (!result.admitted) {
3906
+ return {
3907
+ ok: false,
3908
+ code: spec.reject?.code ?? "CONDITION_NOT_MET",
3909
+ message: spec.reject?.message ?? `Admission condition not met on "${spec.collection}" record "${spec.id}".`,
3910
+ retriable: false
3911
+ };
3912
+ }
3913
+ if (result.idempotent) {
3914
+ const records2 = spec.idempotencyKey ? [await store.findRecord(spec.idempotencyKey.collection, spec.idempotencyKey.id)] : [];
3915
+ return { ok: true, idempotent: true, operations: [], records: records2 };
3916
+ }
3917
+ server.relayServerOperations(result.applied);
3918
+ const records = [];
3919
+ for (const op of result.applied) {
3920
+ records.push(await store.findRecord(op.collection, op.recordId));
3921
+ }
3922
+ return { ok: true, idempotent: false, operations: result.applied, records };
3923
+ }
3171
3924
  return {
3172
3925
  apply(mutation, options) {
3926
+ return serialize(() => rawApply(mutation, options?.scope));
3927
+ },
3928
+ applyConditional(spec, options) {
3929
+ const storeApplyConditional = store.applyConditional;
3930
+ if (storeApplyConditional) {
3931
+ return runConditionalViaStore(storeApplyConditional.bind(store), spec, options);
3932
+ }
3173
3933
  return serialize(async () => {
3174
- let op;
3175
- try {
3176
- op = await buildRouteOperation(store, mutation);
3177
- } catch (error) {
3178
- if (error instanceof RouteMutationError) {
3934
+ if (spec.idempotencyKey) {
3935
+ const existing = await store.findRecord(
3936
+ spec.idempotencyKey.collection,
3937
+ spec.idempotencyKey.id
3938
+ );
3939
+ if (existing) {
3940
+ return { ok: true, idempotent: true, operations: [], records: [existing] };
3941
+ }
3942
+ }
3943
+ if (spec.if && Object.keys(spec.if).length > 0) {
3944
+ const target = await store.findRecord(spec.collection, spec.id);
3945
+ if (!evaluateRoutePredicate(target, spec.if)) {
3946
+ const code = spec.reject?.code ?? "CONDITION_NOT_MET";
3179
3947
  return {
3180
3948
  ok: false,
3181
- code: error.code,
3182
- message: error.message,
3183
- retriable: isRetriableRejection(error.code)
3949
+ code,
3950
+ message: spec.reject?.message ?? `Admission condition not met on "${spec.collection}" record "${spec.id}".`,
3951
+ retriable: false
3184
3952
  };
3185
3953
  }
3186
- throw error;
3187
3954
  }
3188
- if (options?.scope && !operationMatchesScopes(op, options.scope)) {
3189
- return {
3190
- ok: false,
3191
- code: "SCOPE_VIOLATION",
3192
- message: `Mutation on "${mutation.collection}" is outside the provided scope.`,
3193
- retriable: isRetriableRejection("SCOPE_VIOLATION")
3194
- };
3955
+ const mutations = [];
3956
+ if (spec.update !== void 0 && spec.update !== null) {
3957
+ mutations.push({
3958
+ collection: spec.collection,
3959
+ type: "update",
3960
+ recordId: spec.id,
3961
+ data: spec.update
3962
+ });
3195
3963
  }
3196
- const result = await server.applyLocalOperation(op);
3197
- if (result.result !== "applied") {
3198
- const code = result.rejection?.code ?? "NOT_APPLIED";
3199
- return {
3200
- ok: false,
3201
- code,
3202
- message: result.rejection?.message ?? `Operation on "${mutation.collection}" was not applied (${result.result}).`,
3203
- // Prefer the pipeline's own classification when present; fall back
3204
- // to classifying the code so this stays correct if new codes appear.
3205
- retriable: result.rejection?.retriable ?? isRetriableRejection(code)
3206
- };
3964
+ if (spec.also) {
3965
+ mutations.push(...spec.also);
3966
+ }
3967
+ const prepared = [];
3968
+ for (const mutation of mutations) {
3969
+ const p = await prepareMutation(mutation, options?.scope);
3970
+ if (!p.ok) {
3971
+ return p.rejection;
3972
+ }
3973
+ prepared.push({ op: p.op, collection: mutation.collection });
3974
+ }
3975
+ const operations2 = [];
3976
+ const records = [];
3977
+ for (const { op, collection } of prepared) {
3978
+ const result = await commitOperation(op, collection);
3979
+ if (!result.ok) {
3980
+ return {
3981
+ ok: false,
3982
+ code: result.code,
3983
+ message: result.message,
3984
+ retriable: result.retriable
3985
+ };
3986
+ }
3987
+ operations2.push(result.operation);
3988
+ records.push(result.record);
3207
3989
  }
3208
- const record = await store.findRecord(mutation.collection, op.recordId);
3209
- return { ok: true, operation: op, record };
3990
+ return { ok: true, idempotent: false, operations: operations2, records };
3210
3991
  });
3211
3992
  },
3212
3993
  async query(collection, options) {
@@ -3236,7 +4017,8 @@ var DEFAULT_BATCH_SIZE2 = 100;
3236
4017
  var DEFAULT_SCHEMA_VERSION2 = 1;
3237
4018
  var DEFAULT_HOST = "0.0.0.0";
3238
4019
  var DEFAULT_PATH = "/";
3239
- var KoraSyncServer = class {
4020
+ var RELAY_RETRANSMIT_INTERVAL_MS = 2e3;
4021
+ var KoraSyncServer = class _KoraSyncServer {
3240
4022
  store;
3241
4023
  auth;
3242
4024
  serializer;
@@ -3266,6 +4048,20 @@ var KoraSyncServer = class {
3266
4048
  serverVersion = "1.0.0-beta.0";
3267
4049
  wsServer = null;
3268
4050
  running = false;
4051
+ /**
4052
+ * Periodic tick that retransmits relay batches connected clients have not
4053
+ * acknowledged, so a relay dropped by a lossy transport is redelivered instead of
4054
+ * leaving that client with a permanent version-vector gap (a lost operation).
4055
+ */
4056
+ relayRetransmitTimer = null;
4057
+ /**
4058
+ * Relay operations a client never acknowledged before it disconnected, buffered by
4059
+ * client node id so they can be redelivered on its next connection. Bounded per
4060
+ * node and expired by age so a client that never returns cannot grow this forever.
4061
+ */
4062
+ orphanedRelaysByNode = /* @__PURE__ */ new Map();
4063
+ static MAX_ORPHANED_RELAY_OPS_PER_NODE = 5e3;
4064
+ static ORPHANED_RELAY_TTL_MS = 5 * 6e4;
3269
4065
  constructor(config) {
3270
4066
  this.store = config.store;
3271
4067
  this.auth = config.auth ?? null;
@@ -3293,6 +4089,57 @@ var KoraSyncServer = class {
3293
4089
  if (!this.emitter) {
3294
4090
  this.emitter = new SimpleEventEmitter();
3295
4091
  }
4092
+ this.relayRetransmitTimer = setInterval(() => {
4093
+ this.retransmitPendingRelays(RELAY_RETRANSMIT_INTERVAL_MS);
4094
+ }, RELAY_RETRANSMIT_INTERVAL_MS);
4095
+ this.relayRetransmitTimer.unref?.();
4096
+ }
4097
+ /**
4098
+ * Retransmit relay batches that connected clients have not acknowledged within
4099
+ * `staleMs`. Called on a periodic tick and directly by tests. Redelivering an
4100
+ * already-applied operation is harmless (clients dedup by content-addressed id).
4101
+ */
4102
+ retransmitPendingRelays(staleMs = 0) {
4103
+ for (const session of this.sessions.values()) {
4104
+ session.retransmitPendingRelays(staleMs);
4105
+ }
4106
+ this.expireOrphanedRelays();
4107
+ }
4108
+ /**
4109
+ * Buffer relay operations a disconnected client never acknowledged, keyed by its
4110
+ * node id, deduped by operation id and bounded by count. Redelivered when the
4111
+ * client reconnects (see {@link takeOrphanedRelays}).
4112
+ */
4113
+ bufferOrphanedRelays(nodeId, ops) {
4114
+ if (ops.length === 0) return;
4115
+ const existing = this.orphanedRelaysByNode.get(nodeId);
4116
+ const merged = existing ? existing.ops : [];
4117
+ const seen = new Set(merged.map((op) => op.id));
4118
+ for (const op of ops) {
4119
+ if (!seen.has(op.id)) {
4120
+ merged.push(op);
4121
+ seen.add(op.id);
4122
+ }
4123
+ }
4124
+ const bounded = merged.length > _KoraSyncServer.MAX_ORPHANED_RELAY_OPS_PER_NODE ? merged.slice(merged.length - _KoraSyncServer.MAX_ORPHANED_RELAY_OPS_PER_NODE) : merged;
4125
+ this.orphanedRelaysByNode.set(nodeId, { ops: bounded, bufferedAtMs: Date.now() });
4126
+ }
4127
+ /** Remove and return a node's buffered orphaned relay operations, if any. */
4128
+ takeOrphanedRelays(nodeId) {
4129
+ const entry = this.orphanedRelaysByNode.get(nodeId);
4130
+ if (!entry) return [];
4131
+ this.orphanedRelaysByNode.delete(nodeId);
4132
+ return entry.ops;
4133
+ }
4134
+ /** Drop buffered orphaned relays older than the TTL (client never returned). */
4135
+ expireOrphanedRelays() {
4136
+ if (this.orphanedRelaysByNode.size === 0) return;
4137
+ const cutoff = Date.now() - _KoraSyncServer.ORPHANED_RELAY_TTL_MS;
4138
+ for (const [nodeId, entry] of this.orphanedRelaysByNode) {
4139
+ if (entry.bufferedAtMs <= cutoff) {
4140
+ this.orphanedRelaysByNode.delete(nodeId);
4141
+ }
4142
+ }
3296
4143
  }
3297
4144
  /**
3298
4145
  * The trusted, scoped data-plane context (`apply` / `query` / `findById`).
@@ -3419,6 +4266,11 @@ var KoraSyncServer = class {
3419
4266
  event: "server.stopping",
3420
4267
  details: { connectedClients: this.sessions.size }
3421
4268
  });
4269
+ if (this.relayRetransmitTimer) {
4270
+ clearInterval(this.relayRetransmitTimer);
4271
+ this.relayRetransmitTimer = null;
4272
+ }
4273
+ this.orphanedRelaysByNode.clear();
3422
4274
  this.awarenessRelay.clear();
3423
4275
  this.yjsDocRelay.clear();
3424
4276
  this.blobChunkRelay.clear();
@@ -3580,7 +4432,11 @@ var KoraSyncServer = class {
3580
4432
  ...this.validateOperation ? { validateOperation: this.validateOperation, koraContext: this.koraContext } : {},
3581
4433
  onClose: (sid) => {
3582
4434
  this.handleSessionClose(sid);
3583
- }
4435
+ },
4436
+ onOrphanedRelays: (nodeId, ops) => {
4437
+ this.bufferOrphanedRelays(nodeId, ops);
4438
+ },
4439
+ takeOrphanedRelays: (nodeId) => this.takeOrphanedRelays(nodeId)
3584
4440
  });
3585
4441
  this.sessions.set(sessionId, session);
3586
4442
  this.yjsDocRelay.addClient(sessionId, transport);
@@ -3640,6 +4496,22 @@ var KoraSyncServer = class {
3640
4496
  }
3641
4497
  return result;
3642
4498
  }
4499
+ /**
4500
+ * Relay already-applied, server-originated operations to every connected client.
4501
+ * Used by the conditional route apply, which commits its operations atomically
4502
+ * through the store (bypassing {@link applyLocalOperation}) and then fans them
4503
+ * out. Each session still enforces its own per-scope visibility filter.
4504
+ *
4505
+ * @param operations - Operations that have already been committed to the store
4506
+ */
4507
+ relayServerOperations(operations2) {
4508
+ if (operations2.length === 0) {
4509
+ return;
4510
+ }
4511
+ for (const session of this.sessions.values()) {
4512
+ session.relayOperations(operations2);
4513
+ }
4514
+ }
3643
4515
  /**
3644
4516
  * Collect every blob reference still reachable from live records on the server.
3645
4517
  *