@korajs/server 1.0.0-beta.4 → 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.cjs CHANGED
@@ -12,11 +12,11 @@ var __export = (target, all) => {
12
12
  for (var name in all)
13
13
  __defProp(target, name, { get: all[name], enumerable: true });
14
14
  };
15
- var __copyProps = (to, from, except, desc) => {
15
+ var __copyProps = (to, from, except, desc2) => {
16
16
  if (from && typeof from === "object" || typeof from === "function") {
17
17
  for (let key of __getOwnPropNames(from))
18
18
  if (!__hasOwnProp.call(to, key) && key !== except)
19
- __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
19
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc2 = __getOwnPropDesc(from, key)) || desc2.enumerable });
20
20
  }
21
21
  return to;
22
22
  };
@@ -345,30 +345,6 @@ function generateAllCollectionDDL(schema, dialect) {
345
345
  }
346
346
  return statements;
347
347
  }
348
- function replayOperationsForRecord(ops) {
349
- let record = null;
350
- let deleted = false;
351
- for (const op of ops) {
352
- switch (op.type) {
353
- case "insert":
354
- if (op.data) {
355
- record = { ...op.data };
356
- deleted = false;
357
- }
358
- break;
359
- case "update":
360
- if (op.data) {
361
- record = { ...record ?? {}, ...op.data };
362
- deleted = false;
363
- }
364
- break;
365
- case "delete":
366
- deleted = true;
367
- break;
368
- }
369
- }
370
- return deleted ? null : record;
371
- }
372
348
  var BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
373
349
  var BASE64_REVERSE = new Map(
374
350
  Array.from(BASE64_ALPHABET, (char, index3) => [char, index3])
@@ -464,6 +440,14 @@ var MemoryServerStore = class {
464
440
  operations = [];
465
441
  operationIndex = /* @__PURE__ */ new Map();
466
442
  versionVector = /* @__PURE__ */ new Map();
443
+ /**
444
+ * Server-assigned delivery sequence per operation id. Single-process, so a plain
445
+ * counter incremented at insert time gives commit-order delivery sequence with no
446
+ * gaps. `operations` is kept in insertion (== delivery) order, so a delivery scan
447
+ * is a forward walk.
448
+ */
449
+ deliverySeqByOpId = /* @__PURE__ */ new Map();
450
+ deliverySeqCounter = 0;
467
451
  schema = null;
468
452
  /** Materialized records: collection -> recordId -> record data */
469
453
  materializedRecords = /* @__PURE__ */ new Map();
@@ -497,6 +481,8 @@ var MemoryServerStore = class {
497
481
  }
498
482
  this.operations.push(op);
499
483
  this.operationIndex.set(op.id, op);
484
+ this.deliverySeqCounter += 1;
485
+ this.deliverySeqByOpId.set(op.id, this.deliverySeqCounter);
500
486
  const currentSeq = this.versionVector.get(op.nodeId) ?? 0;
501
487
  if (op.sequenceNumber > currentSeq) {
502
488
  this.versionVector.set(op.nodeId, op.sequenceNumber);
@@ -516,6 +502,23 @@ var MemoryServerStore = class {
516
502
  this.assertOpen();
517
503
  return this.operations.length;
518
504
  }
505
+ async getMaxDeliverySequence() {
506
+ this.assertOpen();
507
+ return this.deliverySeqCounter;
508
+ }
509
+ async getOperationsAfterDelivery(afterDeliverySequence, limit) {
510
+ this.assertOpen();
511
+ const result = [];
512
+ if (limit <= 0) return result;
513
+ for (const op of this.operations) {
514
+ const deliverySequence = this.deliverySeqByOpId.get(op.id) ?? 0;
515
+ if (deliverySequence > afterDeliverySequence) {
516
+ result.push({ operation: op, deliverySequence });
517
+ if (result.length >= limit) break;
518
+ }
519
+ }
520
+ return result;
521
+ }
519
522
  async materializeCollection(collection) {
520
523
  this.assertOpen();
521
524
  if (this.schema?.collections[collection]) {
@@ -660,12 +663,16 @@ var MemoryServerStore = class {
660
663
  this.operations.length = 0;
661
664
  this.operationIndex.clear();
662
665
  this.versionVector.clear();
666
+ this.deliverySeqByOpId.clear();
667
+ this.deliverySeqCounter = 0;
663
668
  for (const [nid, seq] of versionVector) {
664
669
  this.versionVector.set(nid, seq);
665
670
  }
666
671
  for (const op of operations2) {
667
672
  this.operations.push(op);
668
673
  this.operationIndex.set(op.id, op);
674
+ this.deliverySeqCounter += 1;
675
+ this.deliverySeqByOpId.set(op.id, this.deliverySeqCounter);
669
676
  if (this.schema?.collections[op.collection]) {
670
677
  this.rebuildMaterializedRecord(op.collection, op.recordId);
671
678
  }
@@ -695,13 +702,14 @@ var MemoryServerStore = class {
695
702
  return a.timestamp.wallTime - b.timestamp.wallTime;
696
703
  if (a.timestamp.logical !== b.timestamp.logical)
697
704
  return a.timestamp.logical - b.timestamp.logical;
698
- return a.sequenceNumber - b.sequenceNumber;
705
+ return a.timestamp.nodeId < b.timestamp.nodeId ? -1 : a.timestamp.nodeId > b.timestamp.nodeId ? 1 : 0;
699
706
  });
700
707
  const parsedOps = recordOps.map((op) => ({
701
708
  type: op.type,
702
- data: op.data
709
+ data: op.data,
710
+ atomicOps: op.atomicOps ?? null
703
711
  }));
704
- const recordData = replayOperationsForRecord(parsedOps);
712
+ const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
705
713
  if (recordData) {
706
714
  const createdAt = recordOps.length > 0 ? recordOps[0].timestamp.wallTime : Date.now();
707
715
  const updatedAt = recordOps.length > 0 ? recordOps[recordOps.length - 1].timestamp.wallTime : Date.now();
@@ -824,6 +832,8 @@ var pgOperations = (0, import_pg_core.pgTable)(
824
832
  // JSON-serialized, null for deletes
825
833
  previousData: (0, import_pg_core.text)("previous_data"),
826
834
  // JSON-serialized, null for insert/delete
835
+ atomicOps: (0, import_pg_core.text)("atomic_ops"),
836
+ // JSON-serialized Record<field, AtomicOp>, null when none
827
837
  wallTime: (0, import_pg_core.bigint)("wall_time", { mode: "number" }).notNull(),
828
838
  logical: (0, import_pg_core.integer)("logical").notNull(),
829
839
  timestampNodeId: (0, import_pg_core.text)("timestamp_node_id").notNull(),
@@ -831,12 +841,18 @@ var pgOperations = (0, import_pg_core.pgTable)(
831
841
  causalDeps: (0, import_pg_core.text)("causal_deps").notNull().default("[]"),
832
842
  // JSON array of op IDs
833
843
  schemaVersion: (0, import_pg_core.integer)("schema_version").notNull(),
834
- receivedAt: (0, import_pg_core.bigint)("received_at", { mode: "number" }).notNull()
844
+ receivedAt: (0, import_pg_core.bigint)("received_at", { mode: "number" }).notNull(),
845
+ // Server-assigned monotonic delivery sequence, ordered by commit (assigned from
846
+ // a counter row locked inside the append transaction, so delivery order equals
847
+ // visibility order even across instances). Drives the gap-free server->client
848
+ // delivery watermark. Nullable only for pre-column rows, backfilled on startup.
849
+ deliverySeq: (0, import_pg_core.bigint)("delivery_seq", { mode: "number" })
835
850
  },
836
851
  (table) => ({
837
852
  nodeSeqIdx: (0, import_pg_core.index)("idx_pg_node_seq").on(table.nodeId, table.sequenceNumber),
838
853
  collectionIdx: (0, import_pg_core.index)("idx_pg_collection").on(table.collection),
839
- receivedIdx: (0, import_pg_core.index)("idx_pg_received").on(table.receivedAt)
854
+ receivedIdx: (0, import_pg_core.index)("idx_pg_received").on(table.receivedAt),
855
+ deliveryIdx: (0, import_pg_core.index)("idx_pg_delivery_seq").on(table.deliverySeq)
840
856
  })
841
857
  );
842
858
  var pgSyncState = (0, import_pg_core.pgTable)("sync_state", {
@@ -853,15 +869,43 @@ var PostgresServerStore = class {
853
869
  ready;
854
870
  schema = null;
855
871
  closed = false;
856
- constructor(db, nodeId) {
872
+ /**
873
+ * Monotonic counter for this node's server-originated sequence numbers. Lazily
874
+ * seeded from the persisted version vector on first use, then advanced in memory
875
+ * (a synchronous `++`, atomic on the single JS thread) so concurrent conditional
876
+ * applies never receive the same number. Null means "not yet seeded"; reset to
877
+ * null after a backup import so it re-seeds from the restored version vector.
878
+ */
879
+ sequenceCounter = null;
880
+ /**
881
+ * Time source for HLC timestamps on server-originated operations. Injectable so a
882
+ * test can freeze wall-clock time and prove the conditional-apply ordering holds
883
+ * under same-millisecond commits (the case the advisory lock and clock advance
884
+ * exist to make correct). Defaults to the system clock in production.
885
+ */
886
+ timeSource;
887
+ constructor(db, nodeId, timeSource) {
857
888
  this.db = db;
858
889
  this.nodeId = nodeId ?? (0, import_core3.generateUUIDv7)();
890
+ this.timeSource = timeSource ?? { now: () => Date.now() };
859
891
  this.ready = this.initialize();
860
892
  }
861
893
  getVersionVector() {
862
894
  this.assertOpen();
863
895
  return new Map(this.versionVector);
864
896
  }
897
+ /**
898
+ * Atomically reserve this node's next server-operation sequence number. The
899
+ * synchronous increment cannot interleave on the single JS thread, so two
900
+ * concurrent conditional applies are always handed distinct numbers.
901
+ */
902
+ reserveSequenceNumber() {
903
+ if (this.sequenceCounter === null) {
904
+ this.sequenceCounter = this.versionVector.get(this.nodeId) ?? 0;
905
+ }
906
+ this.sequenceCounter += 1;
907
+ return this.sequenceCounter;
908
+ }
865
909
  getNodeId() {
866
910
  return this.nodeId;
867
911
  }
@@ -899,8 +943,9 @@ var PostgresServerStore = class {
899
943
  return "duplicate";
900
944
  }
901
945
  const now = Date.now();
902
- const row = this.serializeOperation(op, now);
903
946
  await this.db.transaction(async (tx) => {
947
+ const deliverySeq = await this.nextDeliverySeq(tx);
948
+ const row = this.serializeOperation(op, now, deliverySeq);
904
949
  await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
905
950
  await tx.insert(pgSyncState).values({
906
951
  nodeId: op.nodeId,
@@ -923,6 +968,73 @@ var PostgresServerStore = class {
923
968
  }
924
969
  return "applied";
925
970
  }
971
+ /**
972
+ * Conditionally apply operations atomically, serialized across server instances
973
+ * on the target record via a transaction-scoped advisory lock. The lock makes
974
+ * the read-decide-write cycle atomic against concurrent instances, so a cap
975
+ * check like `responseCount < max` cannot be passed by two admissions at once.
976
+ */
977
+ async applyConditional(input) {
978
+ this.assertOpen();
979
+ await this.ready;
980
+ this.assertSchema();
981
+ this.assertCollection(input.target.collection);
982
+ const schema = this.schema;
983
+ const collectionDef = schema.collections[input.target.collection];
984
+ const lockKey = `kora:${input.target.collection}:${input.target.id}`;
985
+ const result = await this.db.transaction(async (tx) => {
986
+ await tx.execute(import_drizzle_orm.sql`SELECT pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`);
987
+ if (input.idempotencyKey) {
988
+ const existing = await tx.execute(
989
+ import_drizzle_orm.sql`SELECT 1 FROM ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(input.idempotencyKey.collection))} WHERE id = ${input.idempotencyKey.id} AND _deleted = 0 LIMIT 1`
990
+ );
991
+ if (existing.length > 0) {
992
+ return { admitted: true, idempotent: true, applied: [] };
993
+ }
994
+ }
995
+ const rows = await tx.execute(
996
+ import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(input.target.collection))} WHERE id = ${input.target.id} AND _deleted = 0`
997
+ );
998
+ const current = rows.length > 0 ? this.deserializeRow(rows[0], collectionDef) : null;
999
+ if (!input.admit(current)) {
1000
+ return { admitted: false, idempotent: false, applied: [] };
1001
+ }
1002
+ const clock = new import_core3.HybridLogicalClock(this.nodeId, this.timeSource);
1003
+ const latest = await this.latestOperationTimestamp(
1004
+ tx,
1005
+ input.target.collection,
1006
+ input.target.id
1007
+ );
1008
+ if (latest) {
1009
+ clock.advanceTo(latest);
1010
+ }
1011
+ const ops = await input.buildOperations(current, { clock });
1012
+ const now = Date.now();
1013
+ for (const op of ops) {
1014
+ const deliverySeq = await this.nextDeliverySeq(tx);
1015
+ const row = this.serializeOperation(op, now, deliverySeq);
1016
+ await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
1017
+ await tx.insert(pgSyncState).values({ nodeId: op.nodeId, maxSequenceNumber: op.sequenceNumber, lastSeenAt: now }).onConflictDoUpdate({
1018
+ target: pgSyncState.nodeId,
1019
+ set: {
1020
+ maxSequenceNumber: import_drizzle_orm.sql`GREATEST(${pgSyncState.maxSequenceNumber}, ${op.sequenceNumber})`,
1021
+ lastSeenAt: import_drizzle_orm.sql`${now}`
1022
+ }
1023
+ });
1024
+ if (this.schema?.collections[op.collection]) {
1025
+ await this.rebuildMaterializedRecord(tx, op.collection, op.recordId);
1026
+ }
1027
+ }
1028
+ return { admitted: true, idempotent: false, applied: ops };
1029
+ });
1030
+ for (const op of result.applied) {
1031
+ const currentMax = this.versionVector.get(op.nodeId) ?? 0;
1032
+ if (op.sequenceNumber > currentMax) {
1033
+ this.versionVector.set(op.nodeId, op.sequenceNumber);
1034
+ }
1035
+ }
1036
+ return result;
1037
+ }
926
1038
  async getOperationRange(nodeId, fromSeq, toSeq) {
927
1039
  this.assertOpen();
928
1040
  await this.ready;
@@ -937,6 +1049,23 @@ var PostgresServerStore = class {
937
1049
  const result = await this.db.select({ value: (0, import_drizzle_orm.count)() }).from(pgOperations);
938
1050
  return result[0]?.value ?? 0;
939
1051
  }
1052
+ async getMaxDeliverySequence() {
1053
+ this.assertOpen();
1054
+ await this.ready;
1055
+ const rows = await this.db.execute(
1056
+ import_drizzle_orm.sql`SELECT COALESCE(MAX(delivery_seq), 0) AS m FROM operations`
1057
+ );
1058
+ return Number(rows[0]?.m ?? 0);
1059
+ }
1060
+ async getOperationsAfterDelivery(afterDeliverySequence, limit) {
1061
+ this.assertOpen();
1062
+ await this.ready;
1063
+ const rows = await this.db.select().from(pgOperations).where((0, import_drizzle_orm.gt)(pgOperations.deliverySeq, afterDeliverySequence)).orderBy((0, import_drizzle_orm.asc)(pgOperations.deliverySeq)).limit(limit);
1064
+ return rows.map((row) => ({
1065
+ operation: this.deserializeOperation(row),
1066
+ deliverySequence: row.deliverySeq ?? 0
1067
+ }));
1068
+ }
940
1069
  async materializeCollection(collection) {
941
1070
  this.assertOpen();
942
1071
  await this.ready;
@@ -1000,7 +1129,7 @@ var PostgresServerStore = class {
1000
1129
  this.assertOpen();
1001
1130
  await this.ready;
1002
1131
  const { buildServerBackup: buildServerBackup2 } = await Promise.resolve().then(() => (init_server_backup(), server_backup_exports));
1003
- const rows = await this.db.select().from(pgOperations).orderBy((0, import_drizzle_orm.asc)(pgOperations.sequenceNumber));
1132
+ const rows = await this.db.select().from(pgOperations).orderBy((0, import_drizzle_orm.asc)(pgOperations.deliverySeq));
1004
1133
  const operations2 = rows.map((row) => this.deserializeOperation(row));
1005
1134
  return buildServerBackup2(this.nodeId, operations2, this.versionVector);
1006
1135
  }
@@ -1015,6 +1144,7 @@ var PostgresServerStore = class {
1015
1144
  const result = await this.applyRemoteOperation(op);
1016
1145
  if (result === "applied") restored++;
1017
1146
  }
1147
+ this.sequenceCounter = null;
1018
1148
  return { operationsRestored: restored, success: true };
1019
1149
  }
1020
1150
  const now = Date.now();
@@ -1024,20 +1154,49 @@ var PostgresServerStore = class {
1024
1154
  for (const [nid, seq] of versionVector) {
1025
1155
  await tx.insert(pgSyncState).values({ nodeId: nid, maxSequenceNumber: seq, lastSeenAt: now }).onConflictDoNothing({ target: pgSyncState.nodeId });
1026
1156
  }
1157
+ let deliverySeq = 0;
1027
1158
  for (const op of operations2) {
1028
- const row = this.serializeOperation(op, now);
1159
+ deliverySeq += 1;
1160
+ const row = this.serializeOperation(op, now, deliverySeq);
1029
1161
  await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
1030
1162
  }
1163
+ await tx.execute(
1164
+ import_drizzle_orm.sql`INSERT INTO delivery_counter (id, value) VALUES (1, ${deliverySeq})
1165
+ ON CONFLICT (id) DO UPDATE SET value = ${deliverySeq}`
1166
+ );
1031
1167
  });
1032
1168
  this.versionVector.clear();
1033
1169
  for (const [nid, seq] of versionVector) {
1034
1170
  this.versionVector.set(nid, seq);
1035
1171
  }
1172
+ this.sequenceCounter = null;
1036
1173
  return { operationsRestored: operations2.length, success: true };
1037
1174
  }
1038
1175
  // ---------------------------------------------------------------------------
1039
1176
  // Materialization internals
1040
1177
  // ---------------------------------------------------------------------------
1178
+ /**
1179
+ * The HLC timestamp of the most recent operation on a record, by total order
1180
+ * (wallTime, logical, nodeId), or null when the record has no operations. Used
1181
+ * by {@link applyConditional} to advance its clock past the record's latest
1182
+ * write so newly built operations sort strictly after it.
1183
+ */
1184
+ async latestOperationTimestamp(txOrDb, collection, recordId) {
1185
+ const rows = await txOrDb.select({
1186
+ wallTime: pgOperations.wallTime,
1187
+ logical: pgOperations.logical,
1188
+ timestampNodeId: pgOperations.timestampNodeId
1189
+ }).from(pgOperations).where((0, import_drizzle_orm.and)((0, import_drizzle_orm.eq)(pgOperations.collection, collection), (0, import_drizzle_orm.eq)(pgOperations.recordId, recordId))).orderBy(
1190
+ (0, import_drizzle_orm.desc)(pgOperations.wallTime),
1191
+ (0, import_drizzle_orm.desc)(pgOperations.logical),
1192
+ (0, import_drizzle_orm.desc)(pgOperations.timestampNodeId)
1193
+ ).limit(1);
1194
+ const row = rows[0];
1195
+ if (!row) {
1196
+ return null;
1197
+ }
1198
+ return { wallTime: row.wallTime, logical: row.logical, nodeId: row.timestampNodeId };
1199
+ }
1041
1200
  /**
1042
1201
  * Rebuild a single record in the materialized collection table by replaying
1043
1202
  * all operations for that record.
@@ -1048,17 +1207,19 @@ var PostgresServerStore = class {
1048
1207
  const ops = await txOrDb.select({
1049
1208
  type: pgOperations.type,
1050
1209
  data: pgOperations.data,
1210
+ atomicOps: pgOperations.atomicOps,
1051
1211
  wallTime: pgOperations.wallTime
1052
1212
  }).from(pgOperations).where((0, import_drizzle_orm.and)((0, import_drizzle_orm.eq)(pgOperations.collection, collection), (0, import_drizzle_orm.eq)(pgOperations.recordId, recordId))).orderBy(
1053
1213
  (0, import_drizzle_orm.asc)(pgOperations.wallTime),
1054
1214
  (0, import_drizzle_orm.asc)(pgOperations.logical),
1055
- (0, import_drizzle_orm.asc)(pgOperations.sequenceNumber)
1215
+ (0, import_drizzle_orm.asc)(pgOperations.timestampNodeId)
1056
1216
  );
1057
1217
  const parsedOps = ops.map((op) => ({
1058
1218
  type: op.type,
1059
- data: op.data !== null ? JSON.parse(op.data) : null
1219
+ data: op.data !== null ? JSON.parse(op.data) : null,
1220
+ atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
1060
1221
  }));
1061
- const recordData = replayOperationsForRecord(parsedOps);
1222
+ const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
1062
1223
  const fieldNames = Object.keys(collectionDef.fields);
1063
1224
  if (recordData) {
1064
1225
  const createdAt = ops.length > 0 ? ops[0].wallTime : Date.now();
@@ -1125,11 +1286,12 @@ var PostgresServerStore = class {
1125
1286
  recordId: pgOperations.recordId,
1126
1287
  type: pgOperations.type,
1127
1288
  data: pgOperations.data,
1289
+ atomicOps: pgOperations.atomicOps,
1128
1290
  wallTime: pgOperations.wallTime
1129
1291
  }).from(pgOperations).where((0, import_drizzle_orm.eq)(pgOperations.collection, collectionName)).orderBy(
1130
1292
  (0, import_drizzle_orm.asc)(pgOperations.wallTime),
1131
1293
  (0, import_drizzle_orm.asc)(pgOperations.logical),
1132
- (0, import_drizzle_orm.asc)(pgOperations.sequenceNumber)
1294
+ (0, import_drizzle_orm.asc)(pgOperations.timestampNodeId)
1133
1295
  );
1134
1296
  if (allOps.length === 0) return;
1135
1297
  const grouped = /* @__PURE__ */ new Map();
@@ -1145,9 +1307,10 @@ var PostgresServerStore = class {
1145
1307
  for (const [recordId, recordOps] of grouped) {
1146
1308
  const parsedOps = recordOps.map((op) => ({
1147
1309
  type: op.type,
1148
- data: op.data !== null ? JSON.parse(op.data) : null
1310
+ data: op.data !== null ? JSON.parse(op.data) : null,
1311
+ atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
1149
1312
  }));
1150
- const recordData = replayOperationsForRecord(parsedOps);
1313
+ const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
1151
1314
  if (recordData) {
1152
1315
  const createdAt = recordOps[0].wallTime;
1153
1316
  const updatedAt = recordOps[recordOps.length - 1].wallTime;
@@ -1270,44 +1433,92 @@ var PostgresServerStore = class {
1270
1433
  }
1271
1434
  }
1272
1435
  async ensureTables() {
1273
- await this.db.execute(import_drizzle_orm.sql`
1274
- CREATE TABLE IF NOT EXISTS operations (
1275
- id TEXT PRIMARY KEY,
1276
- node_id TEXT NOT NULL,
1277
- type TEXT NOT NULL,
1278
- collection TEXT NOT NULL,
1279
- record_id TEXT NOT NULL,
1280
- data TEXT,
1281
- previous_data TEXT,
1282
- wall_time BIGINT NOT NULL,
1283
- logical INTEGER NOT NULL,
1284
- timestamp_node_id TEXT NOT NULL,
1285
- sequence_number INTEGER NOT NULL,
1286
- causal_deps TEXT NOT NULL DEFAULT '[]',
1287
- schema_version INTEGER NOT NULL,
1288
- received_at BIGINT NOT NULL
1289
- )
1290
- `);
1291
- await this.db.execute(
1292
- import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_node_seq ON operations (node_id, sequence_number)`
1293
- );
1294
- await this.db.execute(import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_collection ON operations (collection)`);
1295
- await this.db.execute(import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_received ON operations (received_at)`);
1296
- await this.db.execute(
1297
- import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_collection_record ON operations (collection, record_id)`
1436
+ await this.db.transaction(async (tx) => {
1437
+ await tx.execute(import_drizzle_orm.sql`SELECT pg_advisory_xact_lock(hashtextextended('kora:ensure-tables', 0))`);
1438
+ await tx.execute(import_drizzle_orm.sql`
1439
+ CREATE TABLE IF NOT EXISTS operations (
1440
+ id TEXT PRIMARY KEY,
1441
+ node_id TEXT NOT NULL,
1442
+ type TEXT NOT NULL,
1443
+ collection TEXT NOT NULL,
1444
+ record_id TEXT NOT NULL,
1445
+ data TEXT,
1446
+ previous_data TEXT,
1447
+ atomic_ops TEXT,
1448
+ wall_time BIGINT NOT NULL,
1449
+ logical INTEGER NOT NULL,
1450
+ timestamp_node_id TEXT NOT NULL,
1451
+ sequence_number INTEGER NOT NULL,
1452
+ causal_deps TEXT NOT NULL DEFAULT '[]',
1453
+ schema_version INTEGER NOT NULL,
1454
+ received_at BIGINT NOT NULL
1455
+ )
1456
+ `);
1457
+ await tx.execute(import_drizzle_orm.sql`ALTER TABLE operations ADD COLUMN IF NOT EXISTS atomic_ops TEXT`);
1458
+ await tx.execute(import_drizzle_orm.sql`ALTER TABLE operations ADD COLUMN IF NOT EXISTS delivery_seq BIGINT`);
1459
+ await tx.execute(
1460
+ import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_node_seq ON operations (node_id, sequence_number)`
1461
+ );
1462
+ await tx.execute(import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_collection ON operations (collection)`);
1463
+ await tx.execute(import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_received ON operations (received_at)`);
1464
+ await tx.execute(
1465
+ import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_delivery_seq ON operations (delivery_seq)`
1466
+ );
1467
+ await tx.execute(
1468
+ import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_collection_record ON operations (collection, record_id)`
1469
+ );
1470
+ await tx.execute(import_drizzle_orm.sql`
1471
+ CREATE TABLE IF NOT EXISTS sync_state (
1472
+ node_id TEXT PRIMARY KEY,
1473
+ max_sequence_number INTEGER NOT NULL,
1474
+ last_seen_at BIGINT NOT NULL
1475
+ )
1476
+ `);
1477
+ await tx.execute(import_drizzle_orm.sql`
1478
+ CREATE TABLE IF NOT EXISTS delivery_counter (
1479
+ id INTEGER PRIMARY KEY,
1480
+ value BIGINT NOT NULL
1481
+ )
1482
+ `);
1483
+ await tx.execute(import_drizzle_orm.sql`
1484
+ WITH ordered AS (
1485
+ SELECT id, ROW_NUMBER() OVER (
1486
+ ORDER BY received_at ASC, sequence_number ASC, id ASC
1487
+ ) + COALESCE((SELECT MAX(delivery_seq) FROM operations), 0) AS rn
1488
+ FROM operations WHERE delivery_seq IS NULL
1489
+ )
1490
+ UPDATE operations o SET delivery_seq = ordered.rn
1491
+ FROM ordered WHERE o.id = ordered.id
1492
+ `);
1493
+ await tx.execute(import_drizzle_orm.sql`
1494
+ INSERT INTO delivery_counter (id, value)
1495
+ VALUES (1, COALESCE((SELECT MAX(delivery_seq) FROM operations), 0))
1496
+ ON CONFLICT (id) DO UPDATE
1497
+ SET value = GREATEST(delivery_counter.value, EXCLUDED.value)
1498
+ `);
1499
+ });
1500
+ }
1501
+ /**
1502
+ * Reserve the next delivery sequence inside an open append transaction. The
1503
+ * `UPDATE ... RETURNING` locks the counter row until this transaction commits,
1504
+ * serializing assignment into commit order (see the counter table comment).
1505
+ */
1506
+ async nextDeliverySeq(tx) {
1507
+ const rows = await tx.execute(
1508
+ import_drizzle_orm.sql`UPDATE delivery_counter SET value = value + 1 WHERE id = 1 RETURNING value`
1298
1509
  );
1299
- await this.db.execute(import_drizzle_orm.sql`
1300
- CREATE TABLE IF NOT EXISTS sync_state (
1301
- node_id TEXT PRIMARY KEY,
1302
- max_sequence_number INTEGER NOT NULL,
1303
- last_seen_at BIGINT NOT NULL
1304
- )
1305
- `);
1510
+ const value = rows[0]?.value;
1511
+ if (value === void 0 || value === null) {
1512
+ throw new Error(
1513
+ "delivery_counter row (id=1) is missing; the operations log cannot assign delivery sequences"
1514
+ );
1515
+ }
1516
+ return Number(value);
1306
1517
  }
1307
1518
  // ---------------------------------------------------------------------------
1308
1519
  // Operation serialization
1309
1520
  // ---------------------------------------------------------------------------
1310
- serializeOperation(op, receivedAt) {
1521
+ serializeOperation(op, receivedAt, deliverySeq) {
1311
1522
  return {
1312
1523
  id: op.id,
1313
1524
  nodeId: op.nodeId,
@@ -1316,16 +1527,19 @@ var PostgresServerStore = class {
1316
1527
  recordId: op.recordId,
1317
1528
  data: op.data !== null ? JSON.stringify(op.data) : null,
1318
1529
  previousData: op.previousData !== null ? JSON.stringify(op.previousData) : null,
1530
+ atomicOps: op.atomicOps && Object.keys(op.atomicOps).length > 0 ? JSON.stringify(op.atomicOps) : null,
1319
1531
  wallTime: op.timestamp.wallTime,
1320
1532
  logical: op.timestamp.logical,
1321
1533
  timestampNodeId: op.timestamp.nodeId,
1322
1534
  sequenceNumber: op.sequenceNumber,
1323
1535
  causalDeps: JSON.stringify(op.causalDeps),
1324
1536
  schemaVersion: op.schemaVersion,
1325
- receivedAt
1537
+ receivedAt,
1538
+ deliverySeq
1326
1539
  };
1327
1540
  }
1328
1541
  deserializeOperation(row) {
1542
+ const atomicOps = row.atomicOps != null ? JSON.parse(row.atomicOps) : void 0;
1329
1543
  return {
1330
1544
  id: row.id,
1331
1545
  nodeId: row.nodeId,
@@ -1341,7 +1555,8 @@ var PostgresServerStore = class {
1341
1555
  },
1342
1556
  sequenceNumber: row.sequenceNumber,
1343
1557
  causalDeps: JSON.parse(row.causalDeps),
1344
- schemaVersion: row.schemaVersion
1558
+ schemaVersion: row.schemaVersion,
1559
+ ...atomicOps ? { atomicOps } : {}
1345
1560
  };
1346
1561
  }
1347
1562
  // ---------------------------------------------------------------------------
@@ -1411,6 +1626,8 @@ var operations = (0, import_sqlite_core.sqliteTable)(
1411
1626
  // JSON-serialized, null for deletes
1412
1627
  previousData: (0, import_sqlite_core.text)("previous_data"),
1413
1628
  // JSON-serialized, null for insert/delete
1629
+ atomicOps: (0, import_sqlite_core.text)("atomic_ops"),
1630
+ // JSON-serialized Record<field, AtomicOp>, null when none
1414
1631
  wallTime: (0, import_sqlite_core.integer)("wall_time").notNull(),
1415
1632
  logical: (0, import_sqlite_core.integer)("logical").notNull(),
1416
1633
  timestampNodeId: (0, import_sqlite_core.text)("timestamp_node_id").notNull(),
@@ -1418,12 +1635,17 @@ var operations = (0, import_sqlite_core.sqliteTable)(
1418
1635
  causalDeps: (0, import_sqlite_core.text)("causal_deps").notNull().default("[]"),
1419
1636
  // JSON array of op IDs
1420
1637
  schemaVersion: (0, import_sqlite_core.integer)("schema_version").notNull(),
1421
- receivedAt: (0, import_sqlite_core.integer)("received_at").notNull()
1638
+ receivedAt: (0, import_sqlite_core.integer)("received_at").notNull(),
1639
+ // Server-assigned monotonic delivery sequence, ordered by commit. Drives the
1640
+ // gap-free server->client delivery watermark. Nullable only for rows written
1641
+ // before the column existed; those are backfilled on startup.
1642
+ deliverySeq: (0, import_sqlite_core.integer)("delivery_seq")
1422
1643
  },
1423
1644
  (table) => ({
1424
1645
  nodeSeqIdx: (0, import_sqlite_core.index)("idx_node_seq").on(table.nodeId, table.sequenceNumber),
1425
1646
  collectionIdx: (0, import_sqlite_core.index)("idx_collection").on(table.collection),
1426
- receivedIdx: (0, import_sqlite_core.index)("idx_received").on(table.receivedAt)
1647
+ receivedIdx: (0, import_sqlite_core.index)("idx_received").on(table.receivedAt),
1648
+ deliveryIdx: (0, import_sqlite_core.index)("idx_delivery_seq").on(table.deliverySeq)
1427
1649
  })
1428
1650
  );
1429
1651
  var syncState = (0, import_sqlite_core.sqliteTable)("sync_state", {
@@ -1439,6 +1661,12 @@ var SqliteServerStore = class {
1439
1661
  db;
1440
1662
  schema = null;
1441
1663
  closed = false;
1664
+ /**
1665
+ * Next delivery sequence to assign. Single-writer (better-sqlite3 is synchronous),
1666
+ * so a plain in-memory counter, seeded from MAX(delivery_seq) at startup, gives
1667
+ * commit-order delivery sequence. Assigned inside the append transaction.
1668
+ */
1669
+ deliverySeqCounter = 0;
1442
1670
  constructor(db, nodeId) {
1443
1671
  this.db = db;
1444
1672
  this.nodeId = nodeId ?? (0, import_core4.generateUUIDv7)();
@@ -1484,12 +1712,16 @@ var SqliteServerStore = class {
1484
1712
  async applyRemoteOperation(op) {
1485
1713
  this.assertOpen();
1486
1714
  const now = Date.now();
1487
- const row = this.serializeOperation(op, now);
1488
1715
  const result = this.db.transaction((tx) => {
1489
- const insertResult = tx.insert(operations).values(row).onConflictDoNothing({ target: operations.id }).run();
1490
- if (insertResult.changes === 0) {
1716
+ const existing = tx.all(
1717
+ import_drizzle_orm2.sql`SELECT id FROM operations WHERE id = ${op.id} LIMIT 1`
1718
+ );
1719
+ if (existing.length > 0) {
1491
1720
  return "duplicate";
1492
1721
  }
1722
+ this.deliverySeqCounter += 1;
1723
+ const row = this.serializeOperation(op, now, this.deliverySeqCounter);
1724
+ tx.insert(operations).values(row).run();
1493
1725
  tx.insert(syncState).values({
1494
1726
  nodeId: op.nodeId,
1495
1727
  maxSequenceNumber: op.sequenceNumber,
@@ -1518,6 +1750,21 @@ var SqliteServerStore = class {
1518
1750
  const result = this.db.select({ value: (0, import_drizzle_orm2.count)() }).from(operations).all();
1519
1751
  return result[0]?.value ?? 0;
1520
1752
  }
1753
+ async getMaxDeliverySequence() {
1754
+ this.assertOpen();
1755
+ const rows = this.db.all(
1756
+ import_drizzle_orm2.sql`SELECT MAX(delivery_seq) AS m FROM operations`
1757
+ );
1758
+ return rows[0]?.m ?? 0;
1759
+ }
1760
+ async getOperationsAfterDelivery(afterDeliverySequence, limit) {
1761
+ this.assertOpen();
1762
+ const rows = this.db.select().from(operations).where((0, import_drizzle_orm2.gt)(operations.deliverySeq, afterDeliverySequence)).orderBy((0, import_drizzle_orm2.asc)(operations.deliverySeq)).limit(limit).all();
1763
+ return rows.map((row) => ({
1764
+ operation: this.deserializeOperation(row),
1765
+ deliverySequence: row.deliverySeq ?? 0
1766
+ }));
1767
+ }
1521
1768
  async materializeCollection(collection) {
1522
1769
  this.assertOpen();
1523
1770
  if (this.schema?.collections[collection]) {
@@ -1575,7 +1822,7 @@ var SqliteServerStore = class {
1575
1822
  async exportBackup() {
1576
1823
  this.assertOpen();
1577
1824
  const { buildServerBackup: buildServerBackup2 } = await Promise.resolve().then(() => (init_server_backup(), server_backup_exports));
1578
- const rows = this.db.select().from(operations).all();
1825
+ const rows = this.db.select().from(operations).orderBy((0, import_drizzle_orm2.asc)(operations.deliverySeq)).all();
1579
1826
  const deserialized = rows.map((row) => this.deserializeOperation(row));
1580
1827
  const vv = this.getVersionVector();
1581
1828
  return buildServerBackup2(this.nodeId, deserialized, vv);
@@ -1598,10 +1845,13 @@ var SqliteServerStore = class {
1598
1845
  for (const [nid, seq] of versionVector) {
1599
1846
  tx.insert(syncState).values({ nodeId: nid, maxSequenceNumber: seq, lastSeenAt: Date.now() }).run();
1600
1847
  }
1848
+ let deliverySeq = 0;
1601
1849
  for (const op of ops) {
1602
- const row = this.serializeOperation(op, Date.now());
1850
+ deliverySeq += 1;
1851
+ const row = this.serializeOperation(op, Date.now(), deliverySeq);
1603
1852
  tx.insert(operations).values(row).run();
1604
1853
  }
1854
+ this.deliverySeqCounter = deliverySeq;
1605
1855
  });
1606
1856
  return { operationsRestored: ops.length, success: true };
1607
1857
  }
@@ -1619,13 +1869,15 @@ var SqliteServerStore = class {
1619
1869
  const ops = txOrDb.select({
1620
1870
  type: operations.type,
1621
1871
  data: operations.data,
1872
+ atomicOps: operations.atomicOps,
1622
1873
  wallTime: operations.wallTime
1623
- }).from(operations).where((0, import_drizzle_orm2.and)((0, import_drizzle_orm2.eq)(operations.collection, collection), (0, import_drizzle_orm2.eq)(operations.recordId, recordId))).orderBy((0, import_drizzle_orm2.asc)(operations.wallTime), (0, import_drizzle_orm2.asc)(operations.logical), (0, import_drizzle_orm2.asc)(operations.sequenceNumber)).all();
1874
+ }).from(operations).where((0, import_drizzle_orm2.and)((0, import_drizzle_orm2.eq)(operations.collection, collection), (0, import_drizzle_orm2.eq)(operations.recordId, recordId))).orderBy((0, import_drizzle_orm2.asc)(operations.wallTime), (0, import_drizzle_orm2.asc)(operations.logical), (0, import_drizzle_orm2.asc)(operations.timestampNodeId)).all();
1624
1875
  const parsedOps = ops.map((op) => ({
1625
1876
  type: op.type,
1626
- data: op.data !== null ? JSON.parse(op.data) : null
1877
+ data: op.data !== null ? JSON.parse(op.data) : null,
1878
+ atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
1627
1879
  }));
1628
- const recordData = replayOperationsForRecord(parsedOps);
1880
+ const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
1629
1881
  const fieldNames = Object.keys(collectionDef.fields);
1630
1882
  if (recordData) {
1631
1883
  const createdAt = ops.length > 0 ? ops[0].wallTime : Date.now();
@@ -1695,8 +1947,9 @@ var SqliteServerStore = class {
1695
1947
  recordId: operations.recordId,
1696
1948
  type: operations.type,
1697
1949
  data: operations.data,
1950
+ atomicOps: operations.atomicOps,
1698
1951
  wallTime: operations.wallTime
1699
- }).from(operations).where((0, import_drizzle_orm2.eq)(operations.collection, collectionName)).orderBy((0, import_drizzle_orm2.asc)(operations.wallTime), (0, import_drizzle_orm2.asc)(operations.logical), (0, import_drizzle_orm2.asc)(operations.sequenceNumber)).all();
1952
+ }).from(operations).where((0, import_drizzle_orm2.eq)(operations.collection, collectionName)).orderBy((0, import_drizzle_orm2.asc)(operations.wallTime), (0, import_drizzle_orm2.asc)(operations.logical), (0, import_drizzle_orm2.asc)(operations.timestampNodeId)).all();
1700
1953
  if (allOps.length === 0) return;
1701
1954
  const grouped = /* @__PURE__ */ new Map();
1702
1955
  for (const op of allOps) {
@@ -1712,9 +1965,10 @@ var SqliteServerStore = class {
1712
1965
  for (const [recordId, recordOps] of grouped) {
1713
1966
  const parsedOps = recordOps.map((op) => ({
1714
1967
  type: op.type,
1715
- data: op.data !== null ? JSON.parse(op.data) : null
1968
+ data: op.data !== null ? JSON.parse(op.data) : null,
1969
+ atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
1716
1970
  }));
1717
- const recordData = replayOperationsForRecord(parsedOps);
1971
+ const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
1718
1972
  if (recordData) {
1719
1973
  const createdAt = recordOps[0].wallTime;
1720
1974
  const updatedAt = recordOps[recordOps.length - 1].wallTime;
@@ -1836,6 +2090,7 @@ var SqliteServerStore = class {
1836
2090
  record_id TEXT NOT NULL,
1837
2091
  data TEXT,
1838
2092
  previous_data TEXT,
2093
+ atomic_ops TEXT,
1839
2094
  wall_time INTEGER NOT NULL,
1840
2095
  logical INTEGER NOT NULL,
1841
2096
  timestamp_node_id TEXT NOT NULL,
@@ -1845,9 +2100,30 @@ var SqliteServerStore = class {
1845
2100
  received_at INTEGER NOT NULL
1846
2101
  )
1847
2102
  `);
2103
+ try {
2104
+ this.db.run(import_drizzle_orm2.sql`ALTER TABLE operations ADD COLUMN atomic_ops TEXT`);
2105
+ } catch (e) {
2106
+ const msg = e instanceof Error ? e.message : "";
2107
+ const causeMsg = e instanceof Error && e.cause instanceof Error ? e.cause.message : "";
2108
+ if (!msg.includes("duplicate column") && !causeMsg.includes("duplicate column")) {
2109
+ throw e;
2110
+ }
2111
+ }
2112
+ try {
2113
+ this.db.run(import_drizzle_orm2.sql`ALTER TABLE operations ADD COLUMN delivery_seq INTEGER`);
2114
+ } catch (e) {
2115
+ const msg = e instanceof Error ? e.message : "";
2116
+ const causeMsg = e instanceof Error && e.cause instanceof Error ? e.cause.message : "";
2117
+ if (!msg.includes("duplicate column") && !causeMsg.includes("duplicate column")) {
2118
+ throw e;
2119
+ }
2120
+ }
1848
2121
  this.db.run(import_drizzle_orm2.sql`
1849
2122
  CREATE INDEX IF NOT EXISTS idx_node_seq ON operations (node_id, sequence_number)
1850
2123
  `);
2124
+ this.db.run(import_drizzle_orm2.sql`
2125
+ CREATE INDEX IF NOT EXISTS idx_delivery_seq ON operations (delivery_seq)
2126
+ `);
1851
2127
  this.db.run(import_drizzle_orm2.sql`
1852
2128
  CREATE INDEX IF NOT EXISTS idx_collection ON operations (collection)
1853
2129
  `);
@@ -1864,11 +2140,34 @@ var SqliteServerStore = class {
1864
2140
  last_seen_at INTEGER NOT NULL
1865
2141
  )
1866
2142
  `);
2143
+ this.backfillDeliverySequence();
2144
+ }
2145
+ /**
2146
+ * Assign delivery sequences to any operations written before the column existed,
2147
+ * then seed the in-memory counter from the current max. Ordered by
2148
+ * (received_at, sequence_number, id) so the backfill is deterministic and stable
2149
+ * across restarts. A one-time migration: after it runs, all rows have a value.
2150
+ */
2151
+ backfillDeliverySequence() {
2152
+ this.db.transaction((tx) => {
2153
+ const maxRow = tx.all(
2154
+ import_drizzle_orm2.sql`SELECT MAX(delivery_seq) AS m FROM operations`
2155
+ );
2156
+ let next = maxRow[0]?.m ?? 0;
2157
+ const nullRows = tx.all(
2158
+ import_drizzle_orm2.sql`SELECT id FROM operations WHERE delivery_seq IS NULL ORDER BY received_at ASC, sequence_number ASC, id ASC`
2159
+ );
2160
+ for (const row of nullRows) {
2161
+ next += 1;
2162
+ tx.run(import_drizzle_orm2.sql`UPDATE operations SET delivery_seq = ${next} WHERE id = ${row.id}`);
2163
+ }
2164
+ this.deliverySeqCounter = next;
2165
+ });
1867
2166
  }
1868
2167
  // ---------------------------------------------------------------------------
1869
2168
  // Operation serialization
1870
2169
  // ---------------------------------------------------------------------------
1871
- serializeOperation(op, receivedAt) {
2170
+ serializeOperation(op, receivedAt, deliverySeq) {
1872
2171
  return {
1873
2172
  id: op.id,
1874
2173
  nodeId: op.nodeId,
@@ -1877,16 +2176,19 @@ var SqliteServerStore = class {
1877
2176
  recordId: op.recordId,
1878
2177
  data: op.data !== null ? JSON.stringify(op.data) : null,
1879
2178
  previousData: op.previousData !== null ? JSON.stringify(op.previousData) : null,
2179
+ atomicOps: op.atomicOps && Object.keys(op.atomicOps).length > 0 ? JSON.stringify(op.atomicOps) : null,
1880
2180
  wallTime: op.timestamp.wallTime,
1881
2181
  logical: op.timestamp.logical,
1882
2182
  timestampNodeId: op.timestamp.nodeId,
1883
2183
  sequenceNumber: op.sequenceNumber,
1884
2184
  causalDeps: JSON.stringify(op.causalDeps),
1885
2185
  schemaVersion: op.schemaVersion,
1886
- receivedAt
2186
+ receivedAt,
2187
+ deliverySeq
1887
2188
  };
1888
2189
  }
1889
2190
  deserializeOperation(row) {
2191
+ const atomicOps = row.atomicOps != null ? JSON.parse(row.atomicOps) : void 0;
1890
2192
  return {
1891
2193
  id: row.id,
1892
2194
  nodeId: row.nodeId,
@@ -1902,7 +2204,8 @@ var SqliteServerStore = class {
1902
2204
  },
1903
2205
  sequenceNumber: row.sequenceNumber,
1904
2206
  causalDeps: JSON.parse(row.causalDeps),
1905
- schemaVersion: row.schemaVersion
2207
+ schemaVersion: row.schemaVersion,
2208
+ ...atomicOps ? { atomicOps } : {}
1906
2209
  };
1907
2210
  }
1908
2211
  // ---------------------------------------------------------------------------
@@ -2201,6 +2504,9 @@ async function createServerSideEffectOperation(store, parentOp, effect, schemaVe
2201
2504
  );
2202
2505
  }
2203
2506
  function nextServerSequenceNumber(store) {
2507
+ if (store.reserveSequenceNumber) {
2508
+ return store.reserveSequenceNumber();
2509
+ }
2204
2510
  const nodeId = store.getNodeId();
2205
2511
  const current = store.getVersionVector().get(nodeId) ?? 0;
2206
2512
  return current + 1;
@@ -2242,16 +2548,14 @@ async function applyServerOperation(store, op, relationLookup) {
2242
2548
  return { result: primaryResult, appliedOperations: [] };
2243
2549
  }
2244
2550
  const appliedOperations = [op];
2245
- let serverSeq = nextServerSequenceNumber(store);
2246
2551
  for (const effect of referential.sideEffectOps) {
2247
2552
  const sideOp = await createServerSideEffectOperation(
2248
2553
  store,
2249
2554
  op,
2250
2555
  effect,
2251
2556
  op.schemaVersion,
2252
- serverSeq
2557
+ nextServerSequenceNumber(store)
2253
2558
  );
2254
- serverSeq += 1;
2255
2559
  const sideResult = await store.applyRemoteOperation(sideOp);
2256
2560
  if (sideResult === "applied") {
2257
2561
  appliedOperations.push(sideOp);
@@ -2312,11 +2616,11 @@ function mergeScopeMaps(base, overlay, source) {
2312
2616
 
2313
2617
  // src/scopes/server-scope-filter.ts
2314
2618
  init_cjs_shims();
2315
- function operationMatchesScopes(op, scopes) {
2619
+ function operationMatchesScopes(op, scopes, fullRecord) {
2316
2620
  if (!scopes) return true;
2317
2621
  const collectionScope = scopes[op.collection];
2318
2622
  if (!collectionScope) return false;
2319
- const snapshot = buildSnapshot(op);
2623
+ const snapshot = buildSnapshot(op, fullRecord);
2320
2624
  if (!snapshot) return false;
2321
2625
  for (const [field, expected] of Object.entries(collectionScope)) {
2322
2626
  if (snapshot[field] !== expected) {
@@ -2325,11 +2629,19 @@ function operationMatchesScopes(op, scopes) {
2325
2629
  }
2326
2630
  return true;
2327
2631
  }
2328
- function buildSnapshot(op) {
2632
+ function missingScopeFields(op, scopes) {
2633
+ if (!scopes) return [];
2634
+ const collectionScope = scopes[op.collection];
2635
+ if (!collectionScope) return [];
2636
+ const snapshot = buildSnapshot(op);
2637
+ return Object.keys(collectionScope).filter((field) => !snapshot || !(field in snapshot));
2638
+ }
2639
+ function buildSnapshot(op, fullRecord) {
2329
2640
  const previous = asRecord(op.previousData);
2330
2641
  const next = asRecord(op.data);
2331
- if (!previous && !next) return null;
2642
+ if (!previous && !next && !fullRecord) return null;
2332
2643
  return {
2644
+ ...fullRecord ?? {},
2333
2645
  ...previous ?? {},
2334
2646
  ...next ?? {}
2335
2647
  };
@@ -2422,12 +2734,41 @@ function warnIfMultiTenantWithoutScopes(auth, resolvedScopes, schema) {
2422
2734
  "[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.)"
2423
2735
  );
2424
2736
  }
2425
- var ClientSession = class {
2737
+ var ClientSession = class _ClientSession {
2426
2738
  state = "connected";
2427
2739
  clientNodeId = null;
2428
2740
  authContext = null;
2429
2741
  syncQuerySubsets = [];
2430
2742
  resumeDeltaCursor = null;
2743
+ /**
2744
+ * The delivery watermark the client reported at handshake, or null when the client
2745
+ * does not use the delivery watermark (old client, or no watermark yet). When set,
2746
+ * the server drives the gap-free server->client stream from delivery sequences
2747
+ * instead of the version-vector delta.
2748
+ */
2749
+ clientDeliveryWatermark = null;
2750
+ /**
2751
+ * The highest delivery sequence this client has ACKNOWLEDGED (its confirmed
2752
+ * watermark, as reported in acks). Incremental streaming pushes resume from here, not
2753
+ * from the highest sequence sent, so a dropped or unapplied batch is always
2754
+ * re-included by the next push (which re-scans from the acknowledged position). This
2755
+ * is what makes streaming recovery independent of any bounded retransmit buffer: there
2756
+ * is no sent-but-unacked window that a buffer eviction could strand. Seeded from the
2757
+ * client's reported watermark at handshake.
2758
+ */
2759
+ lastAckedDeliverySeq = 0;
2760
+ /** Serializes incremental delivery pushes so their batches never interleave. */
2761
+ deliveryPushChain = Promise.resolve();
2762
+ /**
2763
+ * Relay batches sent to this client but not yet acknowledged, keyed by messageId.
2764
+ * The client acks every operation-batch it applies; on ack the entry is cleared.
2765
+ * {@link retransmitPendingRelays} re-sends anything still unacked, so a relay
2766
+ * dropped by a lossy transport is redelivered instead of leaving the client with a
2767
+ * permanent version-vector gap (a lost operation) that delta sync cannot recover.
2768
+ * Bounded so a silent client cannot grow it without limit.
2769
+ */
2770
+ pendingRelays = /* @__PURE__ */ new Map();
2771
+ static MAX_PENDING_RELAYS = 1e3;
2431
2772
  sessionId;
2432
2773
  transport;
2433
2774
  store;
@@ -2444,6 +2785,8 @@ var ClientSession = class {
2444
2785
  onBlobChunkResponse;
2445
2786
  persistBlobChunk;
2446
2787
  onClose;
2788
+ onOrphanedRelays;
2789
+ takeOrphanedRelays;
2447
2790
  maxOperationBytes;
2448
2791
  maxOpsPerMinute;
2449
2792
  rateLimiter;
@@ -2470,6 +2813,8 @@ var ClientSession = class {
2470
2813
  this.onBlobChunkResponse = options.onBlobChunkResponse ?? null;
2471
2814
  this.persistBlobChunk = options.persistBlobChunk ?? null;
2472
2815
  this.onClose = options.onClose ?? null;
2816
+ this.onOrphanedRelays = options.onOrphanedRelays ?? null;
2817
+ this.takeOrphanedRelays = options.takeOrphanedRelays ?? null;
2473
2818
  this.maxOperationBytes = options.maxOperationBytes ?? DEFAULT_MAX_OPERATION_BYTES;
2474
2819
  this.maxOpsPerMinute = options.maxOpsPerMinute ?? DEFAULT_MAX_OPS_PER_MINUTE;
2475
2820
  this.rateLimiter = new SessionRateLimiter(this.maxOpsPerMinute);
@@ -2494,9 +2839,40 @@ var ClientSession = class {
2494
2839
  */
2495
2840
  relayOperations(operations2) {
2496
2841
  if (this.state !== "streaming" || !this.transport.isConnected()) return;
2842
+ if (this.clientDeliveryWatermark !== null) {
2843
+ this.pushDeliveryStream();
2844
+ return;
2845
+ }
2497
2846
  if (operations2.length === 0) return;
2498
- const visibleOperations = operations2.filter((op) => this.operationVisibleToClient(op));
2847
+ void this.relayVisibleOperations(operations2);
2848
+ }
2849
+ /**
2850
+ * Push newly-available in-scope operations to a delivery-watermark client, resuming
2851
+ * from the highest delivery sequence already sent. Pushes are serialized so their
2852
+ * batches never interleave (which would break the base/max chain).
2853
+ */
2854
+ pushDeliveryStream() {
2855
+ this.deliveryPushChain = this.deliveryPushChain.then(async () => {
2856
+ if (this.state !== "streaming" || !this.transport.isConnected()) return;
2857
+ try {
2858
+ await this.sendDeliveryStream(
2859
+ this.lastAckedDeliverySeq,
2860
+ false,
2861
+ this.clientNodeId ?? void 0
2862
+ );
2863
+ } catch {
2864
+ }
2865
+ });
2866
+ }
2867
+ async relayVisibleOperations(operations2) {
2868
+ const visibleOperations = [];
2869
+ for (const op of operations2) {
2870
+ if (await this.operationVisibleToClient(op)) {
2871
+ visibleOperations.push(op);
2872
+ }
2873
+ }
2499
2874
  if (visibleOperations.length === 0) return;
2875
+ if (this.state !== "streaming" || !this.transport.isConnected()) return;
2500
2876
  const serializedOps = visibleOperations.map((op) => this.serializer.encodeOperation(op));
2501
2877
  const msg = {
2502
2878
  type: "operation-batch",
@@ -2505,14 +2881,62 @@ var ClientSession = class {
2505
2881
  isFinal: true,
2506
2882
  batchIndex: 0
2507
2883
  };
2884
+ this.trackPendingRelay(msg, visibleOperations);
2508
2885
  this.sendToClient(msg);
2509
2886
  }
2887
+ /** Record a relay batch as awaiting acknowledgment, evicting the oldest if full. */
2888
+ trackPendingRelay(msg, ops) {
2889
+ if (this.pendingRelays.size >= _ClientSession.MAX_PENDING_RELAYS) {
2890
+ const oldest = this.pendingRelays.keys().next().value;
2891
+ if (oldest !== void 0) {
2892
+ this.pendingRelays.delete(oldest);
2893
+ }
2894
+ }
2895
+ this.pendingRelays.set(msg.messageId, { message: msg, ops, sentAtMs: Date.now() });
2896
+ }
2897
+ /**
2898
+ * Retransmit relay batches this client has not acknowledged within `staleMs`.
2899
+ * Called on a periodic tick by the server (and directly by tests). Redelivering an
2900
+ * already-applied op is harmless: the client dedups by content-addressed id.
2901
+ */
2902
+ retransmitPendingRelays(staleMs = 0) {
2903
+ if (this.state !== "streaming" || !this.transport.isConnected()) return;
2904
+ if (this.clientDeliveryWatermark !== null) {
2905
+ this.pushDeliveryStream();
2906
+ return;
2907
+ }
2908
+ if (this.pendingRelays.size === 0) return;
2909
+ const cutoff = Date.now() - staleMs;
2910
+ for (const { message, sentAtMs } of this.pendingRelays.values()) {
2911
+ if (sentAtMs <= cutoff) {
2912
+ this.sendToClient(message);
2913
+ }
2914
+ }
2915
+ }
2510
2916
  /**
2511
2917
  * Close this session.
2512
2918
  */
2919
+ /**
2920
+ * Hand any unacknowledged relays to the server so they survive this session and are
2921
+ * redelivered when this client reconnects (per-node buffer), then clear them. Called
2922
+ * from every session-teardown path (explicit close and transport close).
2923
+ */
2924
+ flushOrphanedRelays() {
2925
+ if (this.onOrphanedRelays && this.clientNodeId && this.pendingRelays.size > 0) {
2926
+ const ops = [];
2927
+ for (const entry of this.pendingRelays.values()) {
2928
+ ops.push(...entry.ops);
2929
+ }
2930
+ if (ops.length > 0) {
2931
+ this.onOrphanedRelays(this.clientNodeId, ops);
2932
+ }
2933
+ }
2934
+ this.pendingRelays.clear();
2935
+ }
2513
2936
  close(reason) {
2514
2937
  if (this.state === "closed") return;
2515
2938
  this.state = "closed";
2939
+ this.flushOrphanedRelays();
2516
2940
  if (this.transport.isConnected()) {
2517
2941
  this.transport.close(1e3, reason ?? "session closed");
2518
2942
  }
@@ -2567,6 +2991,10 @@ var ClientSession = class {
2567
2991
  await this.handleOperationBatch(message);
2568
2992
  break;
2569
2993
  case "acknowledgment":
2994
+ this.pendingRelays.delete(message.acknowledgedMessageId);
2995
+ if (message.deliverySequence !== void 0) {
2996
+ this.lastAckedDeliverySeq = Math.max(this.lastAckedDeliverySeq, message.deliverySequence);
2997
+ }
2570
2998
  break;
2571
2999
  case "error":
2572
3000
  break;
@@ -2643,6 +3071,15 @@ var ClientSession = class {
2643
3071
  this.syncQuerySubsets = [];
2644
3072
  }
2645
3073
  this.resumeDeltaCursor = msg.deltaCursor ? (0, import_sync3.decodeDeltaCursor)(msg.deltaCursor) : null;
3074
+ this.clientDeliveryWatermark = msg.lastDeliverySequence ?? null;
3075
+ const clientUsesWatermark = this.clientDeliveryWatermark !== null;
3076
+ let serverMaxDelivery = 0;
3077
+ if (clientUsesWatermark) {
3078
+ serverMaxDelivery = await this.store.getMaxDeliverySequence();
3079
+ if (this.clientDeliveryWatermark !== null && this.clientDeliveryWatermark > serverMaxDelivery) {
3080
+ this.clientDeliveryWatermark = 0;
3081
+ }
3082
+ }
2646
3083
  const serverVector = this.store.getVersionVector();
2647
3084
  const selectedWireFormat = selectWireFormat(msg.supportedWireFormats);
2648
3085
  this.setSerializerWireFormat(selectedWireFormat);
@@ -2673,6 +3110,10 @@ var ClientSession = class {
2673
3110
  accepted: true,
2674
3111
  selectedWireFormat,
2675
3112
  serverTime: Date.now(),
3113
+ // The server's highest delivery sequence, so a client whose persisted watermark
3114
+ // is ahead of it (server rolled back) can reset to a full resync. Sent only to
3115
+ // watermark-using clients.
3116
+ ...clientUsesWatermark ? { serverMaxDeliverySequence: serverMaxDelivery } : {},
2676
3117
  // Advertise central blob storage so the client uploads the bytes behind
2677
3118
  // its blob fields, keeping them available after the author goes offline.
2678
3119
  ...this.persistBlobChunk ? { blobStorageEnabled: true } : {},
@@ -2683,16 +3124,28 @@ var ClientSession = class {
2683
3124
  this.sendToClient(response);
2684
3125
  this.emitter?.emit({ type: "sync:connected", nodeId: msg.nodeId });
2685
3126
  this.state = "syncing";
2686
- const clientVector = (0, import_sync3.wireToVersionVector)(msg.versionVector);
2687
- await this.sendDelta(clientVector);
3127
+ if (this.clientDeliveryWatermark !== null) {
3128
+ this.lastAckedDeliverySeq = this.clientDeliveryWatermark;
3129
+ const excludeOwn = this.clientDeliveryWatermark > 0 ? this.clientNodeId ?? void 0 : void 0;
3130
+ await this.sendDeliveryStream(this.clientDeliveryWatermark, true, excludeOwn);
3131
+ } else {
3132
+ const clientVector = (0, import_sync3.wireToVersionVector)(msg.versionVector);
3133
+ await this.sendDelta(clientVector);
3134
+ }
2688
3135
  this.state = "streaming";
3136
+ if (this.takeOrphanedRelays && this.clientNodeId) {
3137
+ const buffered = this.takeOrphanedRelays(this.clientNodeId);
3138
+ if (buffered.length > 0) {
3139
+ this.relayOperations(buffered);
3140
+ }
3141
+ }
2689
3142
  }
2690
3143
  async handleOperationBatch(msg) {
2691
3144
  const operations2 = msg.operations.map((s) => this.serializer.decodeOperation(s));
2692
3145
  const applied = [];
2693
3146
  const rejected = [];
2694
3147
  for (const op of operations2) {
2695
- if (!this.operationVisibleToClient(op)) {
3148
+ if (!await this.operationVisibleToClient(op)) {
2696
3149
  rejected.push(op);
2697
3150
  continue;
2698
3151
  }
@@ -2797,8 +3250,11 @@ var ClientSession = class {
2797
3250
  const clientSeq = clientVector.get(nodeId) ?? 0;
2798
3251
  if (serverSeq > clientSeq) {
2799
3252
  const ops = await this.store.getOperationRange(nodeId, clientSeq + 1, serverSeq);
2800
- const visible = ops.filter((op) => this.operationVisibleToClient(op));
2801
- missing.push(...visible);
3253
+ for (const op of ops) {
3254
+ if (await this.operationVisibleToClient(op)) {
3255
+ missing.push(op);
3256
+ }
3257
+ }
2802
3258
  }
2803
3259
  }
2804
3260
  if (missing.length === 0) {
@@ -2850,11 +3306,111 @@ var ClientSession = class {
2850
3306
  });
2851
3307
  }
2852
3308
  }
2853
- operationVisibleToClient(op) {
2854
- if (!operationMatchesScopes(op, this.authContext?.scopes)) {
3309
+ /**
3310
+ * Send the gap-free server->client delivery stream, resuming just after
3311
+ * `fromDeliverySeq`. Operations are scanned in server delivery-sequence order
3312
+ * (commit order), scope-filtered for this session, and sent in batches that chain:
3313
+ * each batch's `baseDeliverySequence` equals the previous batch's
3314
+ * `maxDeliverySequence` (the first batch bases on `fromDeliverySeq`). The client
3315
+ * applies a batch only when its watermark equals the base and advances the
3316
+ * watermark to the max, so a dropped batch stalls the watermark and is recovered by
3317
+ * the next handshake resend. Because delivery-sequence order respects causal order
3318
+ * (a dependency is always committed, and thus sequenced, before its dependent), no
3319
+ * topological sort is needed and the client never defers on a missing dependency
3320
+ * that is itself in this stream.
3321
+ *
3322
+ * The final batch advances the watermark to the highest delivery sequence scanned,
3323
+ * not merely the last in-scope one, so an out-of-scope tail is not re-scanned on the
3324
+ * next reconnect. Returns the number of operations actually sent.
3325
+ */
3326
+ async sendDeliveryStream(fromDeliverySeq, finalizeWhenEmpty, excludeNodeId) {
3327
+ const scanChunk = Math.max(this.batchSize, 1) * 5;
3328
+ let scanCursor = fromDeliverySeq;
3329
+ let maxScanned = fromDeliverySeq;
3330
+ const visible = [];
3331
+ while (true) {
3332
+ const chunk = await this.store.getOperationsAfterDelivery(scanCursor, scanChunk);
3333
+ const last = chunk[chunk.length - 1];
3334
+ if (last === void 0) break;
3335
+ scanCursor = last.deliverySequence;
3336
+ maxScanned = scanCursor;
3337
+ for (const delivered of chunk) {
3338
+ if (excludeNodeId !== void 0 && delivered.operation.nodeId === excludeNodeId) {
3339
+ continue;
3340
+ }
3341
+ if (await this.operationVisibleToClient(delivered.operation)) {
3342
+ visible.push(delivered);
3343
+ }
3344
+ }
3345
+ if (chunk.length < scanChunk) break;
3346
+ }
3347
+ if (visible.length === 0) {
3348
+ if (finalizeWhenEmpty) {
3349
+ this.sendDeliveryBatch([], fromDeliverySeq, maxScanned, 0, true);
3350
+ }
3351
+ return 0;
3352
+ }
3353
+ const totalBatches = Math.ceil(visible.length / this.batchSize);
3354
+ let base = fromDeliverySeq;
3355
+ for (let i = 0; i < totalBatches; i++) {
3356
+ const slice = visible.slice(i * this.batchSize, (i + 1) * this.batchSize);
3357
+ const lastInSlice = slice[slice.length - 1];
3358
+ if (lastInSlice === void 0) continue;
3359
+ const isFinal = i === totalBatches - 1;
3360
+ const lastSeq = lastInSlice.deliverySequence;
3361
+ const max = isFinal ? Math.max(maxScanned, lastSeq) : lastSeq;
3362
+ this.sendDeliveryBatch(slice, base, max, i, isFinal);
3363
+ base = max;
3364
+ }
3365
+ return visible.length;
3366
+ }
3367
+ /** Build, track, and send one chained delivery-stream batch. */
3368
+ sendDeliveryBatch(slice, base, max, batchIndex, isFinal) {
3369
+ const batchMsg = {
3370
+ type: "operation-batch",
3371
+ messageId: (0, import_core10.generateUUIDv7)(),
3372
+ operations: slice.map((delivered) => this.serializer.encodeOperation(delivered.operation)),
3373
+ isFinal,
3374
+ batchIndex,
3375
+ baseDeliverySequence: base,
3376
+ maxDeliverySequence: max
3377
+ };
3378
+ this.sendToClient(batchMsg);
3379
+ if (slice.length > 0) {
3380
+ this.emitter?.emit({
3381
+ type: "sync:sent",
3382
+ operations: slice.map((delivered) => delivered.operation),
3383
+ batchSize: slice.length
3384
+ });
3385
+ }
3386
+ }
3387
+ async operationVisibleToClient(op) {
3388
+ const scopes = this.authContext?.scopes;
3389
+ const subsets = this.syncQuerySubsets;
3390
+ const needsBackfill = missingScopeFields(op, scopes).length > 0 || subsets !== void 0 && subsets.length > 0;
3391
+ const fullRecord = needsBackfill ? await this.lookupRecordFields(op.collection, op.recordId) : void 0;
3392
+ if (!operationMatchesScopes(op, scopes, fullRecord)) {
2855
3393
  return false;
2856
3394
  }
2857
- return (0, import_sync3.operationMatchesQuerySubsets)(op, this.syncQuerySubsets);
3395
+ return (0, import_sync3.operationMatchesQuerySubsets)(op, subsets, fullRecord);
3396
+ }
3397
+ /**
3398
+ * Read a record's current field values from the materialized store for scope /
3399
+ * query-subset backfill. Includes soft-deleted rows so a relayed delete (whose op
3400
+ * carries no fields) is still judged against the record's actual scope. Returns
3401
+ * undefined when the record cannot be read (never materialized, or no schema).
3402
+ */
3403
+ async lookupRecordFields(collection, recordId) {
3404
+ try {
3405
+ const rows = await this.store.queryCollection(collection, {
3406
+ where: { id: recordId },
3407
+ includeDeleted: true,
3408
+ limit: 1
3409
+ });
3410
+ return rows[0];
3411
+ } catch {
3412
+ return void 0;
3413
+ }
2858
3414
  }
2859
3415
  handleAwarenessUpdate(msg) {
2860
3416
  this.onAwarenessUpdate?.(this.sessionId, msg);
@@ -2899,6 +3455,7 @@ var ClientSession = class {
2899
3455
  handleTransportClose() {
2900
3456
  if (this.state === "closed") return;
2901
3457
  this.state = "closed";
3458
+ this.flushOrphanedRelays();
2902
3459
  this.emitter?.emit({ type: "sync:disconnected", reason: "transport closed" });
2903
3460
  this.onClose?.(this.sessionId);
2904
3461
  }
@@ -3349,9 +3906,9 @@ function pickFields(record, keys) {
3349
3906
  }
3350
3907
  return picked;
3351
3908
  }
3352
- async function buildRouteOperation(store, mutation) {
3909
+ async function buildRouteOperation(store, mutation, currentOverride, clockOverride) {
3353
3910
  const nodeId = store.getNodeId();
3354
- const clock = new import_core12.HybridLogicalClock(nodeId);
3911
+ const clock = clockOverride ?? new import_core12.HybridLogicalClock(nodeId);
3355
3912
  const schemaVersion = store.getSchema()?.version ?? 1;
3356
3913
  const sequenceNumber = nextServerSequenceNumber(store);
3357
3914
  if (mutation.type === "insert") {
@@ -3376,7 +3933,7 @@ async function buildRouteOperation(store, mutation) {
3376
3933
  `A ${mutation.type} mutation on "${mutation.collection}" requires a recordId.`
3377
3934
  );
3378
3935
  }
3379
- const current = await store.findRecord(mutation.collection, mutation.recordId);
3936
+ const current = currentOverride ? currentOverride.record : await store.findRecord(mutation.collection, mutation.recordId);
3380
3937
  if (mutation.type === "update") {
3381
3938
  const rawData = mutation.data ?? {};
3382
3939
  const data = {};
@@ -3493,11 +4050,88 @@ function createRouteContext(server, store) {
3493
4050
  }
3494
4051
  return commitOperation(prepared.op, mutation.collection);
3495
4052
  }
4053
+ async function runConditionalViaStore(storeApplyConditional, spec, options) {
4054
+ const scope = options?.scope;
4055
+ const mutations = [];
4056
+ const hasTargetUpdate = spec.update !== void 0 && spec.update !== null;
4057
+ if (hasTargetUpdate) {
4058
+ mutations.push({
4059
+ collection: spec.collection,
4060
+ type: "update",
4061
+ recordId: spec.id,
4062
+ data: spec.update
4063
+ });
4064
+ }
4065
+ if (spec.also) {
4066
+ mutations.push(...spec.also);
4067
+ }
4068
+ let result;
4069
+ try {
4070
+ result = await storeApplyConditional({
4071
+ target: { collection: spec.collection, id: spec.id },
4072
+ ...spec.idempotencyKey ? { idempotencyKey: spec.idempotencyKey } : {},
4073
+ admit: (current) => spec.if && Object.keys(spec.if).length > 0 ? evaluateRoutePredicate(current, spec.if) : true,
4074
+ buildOperations: async (current, context) => {
4075
+ const ops = [];
4076
+ for (let i = 0; i < mutations.length; i++) {
4077
+ const mutation = mutations[i];
4078
+ const useLockedCurrent = i === 0 && hasTargetUpdate;
4079
+ const op = await buildRouteOperation(
4080
+ store,
4081
+ mutation,
4082
+ useLockedCurrent ? { record: current } : void 0,
4083
+ context.clock
4084
+ );
4085
+ if (scope && !operationMatchesScopes(op, scope)) {
4086
+ throw new RouteMutationError(
4087
+ "SCOPE_VIOLATION",
4088
+ `Mutation on "${mutation.collection}" is outside the provided scope.`
4089
+ );
4090
+ }
4091
+ ops.push(op);
4092
+ }
4093
+ return ops;
4094
+ }
4095
+ });
4096
+ } catch (error) {
4097
+ if (error instanceof RouteMutationError) {
4098
+ return {
4099
+ ok: false,
4100
+ code: error.code,
4101
+ message: error.message,
4102
+ retriable: isRetriableRejection(error.code)
4103
+ };
4104
+ }
4105
+ throw error;
4106
+ }
4107
+ if (!result.admitted) {
4108
+ return {
4109
+ ok: false,
4110
+ code: spec.reject?.code ?? "CONDITION_NOT_MET",
4111
+ message: spec.reject?.message ?? `Admission condition not met on "${spec.collection}" record "${spec.id}".`,
4112
+ retriable: false
4113
+ };
4114
+ }
4115
+ if (result.idempotent) {
4116
+ const records2 = spec.idempotencyKey ? [await store.findRecord(spec.idempotencyKey.collection, spec.idempotencyKey.id)] : [];
4117
+ return { ok: true, idempotent: true, operations: [], records: records2 };
4118
+ }
4119
+ server.relayServerOperations(result.applied);
4120
+ const records = [];
4121
+ for (const op of result.applied) {
4122
+ records.push(await store.findRecord(op.collection, op.recordId));
4123
+ }
4124
+ return { ok: true, idempotent: false, operations: result.applied, records };
4125
+ }
3496
4126
  return {
3497
4127
  apply(mutation, options) {
3498
4128
  return serialize(() => rawApply(mutation, options?.scope));
3499
4129
  },
3500
4130
  applyConditional(spec, options) {
4131
+ const storeApplyConditional = store.applyConditional;
4132
+ if (storeApplyConditional) {
4133
+ return runConditionalViaStore(storeApplyConditional.bind(store), spec, options);
4134
+ }
3501
4135
  return serialize(async () => {
3502
4136
  if (spec.idempotencyKey) {
3503
4137
  const existing = await store.findRecord(
@@ -3585,7 +4219,8 @@ var DEFAULT_BATCH_SIZE2 = 100;
3585
4219
  var DEFAULT_SCHEMA_VERSION2 = 1;
3586
4220
  var DEFAULT_HOST = "0.0.0.0";
3587
4221
  var DEFAULT_PATH = "/";
3588
- var KoraSyncServer = class {
4222
+ var RELAY_RETRANSMIT_INTERVAL_MS = 2e3;
4223
+ var KoraSyncServer = class _KoraSyncServer {
3589
4224
  store;
3590
4225
  auth;
3591
4226
  serializer;
@@ -3615,6 +4250,20 @@ var KoraSyncServer = class {
3615
4250
  serverVersion = "1.0.0-beta.0";
3616
4251
  wsServer = null;
3617
4252
  running = false;
4253
+ /**
4254
+ * Periodic tick that retransmits relay batches connected clients have not
4255
+ * acknowledged, so a relay dropped by a lossy transport is redelivered instead of
4256
+ * leaving that client with a permanent version-vector gap (a lost operation).
4257
+ */
4258
+ relayRetransmitTimer = null;
4259
+ /**
4260
+ * Relay operations a client never acknowledged before it disconnected, buffered by
4261
+ * client node id so they can be redelivered on its next connection. Bounded per
4262
+ * node and expired by age so a client that never returns cannot grow this forever.
4263
+ */
4264
+ orphanedRelaysByNode = /* @__PURE__ */ new Map();
4265
+ static MAX_ORPHANED_RELAY_OPS_PER_NODE = 5e3;
4266
+ static ORPHANED_RELAY_TTL_MS = 5 * 6e4;
3618
4267
  constructor(config) {
3619
4268
  this.store = config.store;
3620
4269
  this.auth = config.auth ?? null;
@@ -3642,6 +4291,57 @@ var KoraSyncServer = class {
3642
4291
  if (!this.emitter) {
3643
4292
  this.emitter = new import_internal2.SimpleEventEmitter();
3644
4293
  }
4294
+ this.relayRetransmitTimer = setInterval(() => {
4295
+ this.retransmitPendingRelays(RELAY_RETRANSMIT_INTERVAL_MS);
4296
+ }, RELAY_RETRANSMIT_INTERVAL_MS);
4297
+ this.relayRetransmitTimer.unref?.();
4298
+ }
4299
+ /**
4300
+ * Retransmit relay batches that connected clients have not acknowledged within
4301
+ * `staleMs`. Called on a periodic tick and directly by tests. Redelivering an
4302
+ * already-applied operation is harmless (clients dedup by content-addressed id).
4303
+ */
4304
+ retransmitPendingRelays(staleMs = 0) {
4305
+ for (const session of this.sessions.values()) {
4306
+ session.retransmitPendingRelays(staleMs);
4307
+ }
4308
+ this.expireOrphanedRelays();
4309
+ }
4310
+ /**
4311
+ * Buffer relay operations a disconnected client never acknowledged, keyed by its
4312
+ * node id, deduped by operation id and bounded by count. Redelivered when the
4313
+ * client reconnects (see {@link takeOrphanedRelays}).
4314
+ */
4315
+ bufferOrphanedRelays(nodeId, ops) {
4316
+ if (ops.length === 0) return;
4317
+ const existing = this.orphanedRelaysByNode.get(nodeId);
4318
+ const merged = existing ? existing.ops : [];
4319
+ const seen = new Set(merged.map((op) => op.id));
4320
+ for (const op of ops) {
4321
+ if (!seen.has(op.id)) {
4322
+ merged.push(op);
4323
+ seen.add(op.id);
4324
+ }
4325
+ }
4326
+ const bounded = merged.length > _KoraSyncServer.MAX_ORPHANED_RELAY_OPS_PER_NODE ? merged.slice(merged.length - _KoraSyncServer.MAX_ORPHANED_RELAY_OPS_PER_NODE) : merged;
4327
+ this.orphanedRelaysByNode.set(nodeId, { ops: bounded, bufferedAtMs: Date.now() });
4328
+ }
4329
+ /** Remove and return a node's buffered orphaned relay operations, if any. */
4330
+ takeOrphanedRelays(nodeId) {
4331
+ const entry = this.orphanedRelaysByNode.get(nodeId);
4332
+ if (!entry) return [];
4333
+ this.orphanedRelaysByNode.delete(nodeId);
4334
+ return entry.ops;
4335
+ }
4336
+ /** Drop buffered orphaned relays older than the TTL (client never returned). */
4337
+ expireOrphanedRelays() {
4338
+ if (this.orphanedRelaysByNode.size === 0) return;
4339
+ const cutoff = Date.now() - _KoraSyncServer.ORPHANED_RELAY_TTL_MS;
4340
+ for (const [nodeId, entry] of this.orphanedRelaysByNode) {
4341
+ if (entry.bufferedAtMs <= cutoff) {
4342
+ this.orphanedRelaysByNode.delete(nodeId);
4343
+ }
4344
+ }
3645
4345
  }
3646
4346
  /**
3647
4347
  * The trusted, scoped data-plane context (`apply` / `query` / `findById`).
@@ -3768,6 +4468,11 @@ var KoraSyncServer = class {
3768
4468
  event: "server.stopping",
3769
4469
  details: { connectedClients: this.sessions.size }
3770
4470
  });
4471
+ if (this.relayRetransmitTimer) {
4472
+ clearInterval(this.relayRetransmitTimer);
4473
+ this.relayRetransmitTimer = null;
4474
+ }
4475
+ this.orphanedRelaysByNode.clear();
3771
4476
  this.awarenessRelay.clear();
3772
4477
  this.yjsDocRelay.clear();
3773
4478
  this.blobChunkRelay.clear();
@@ -3929,7 +4634,11 @@ var KoraSyncServer = class {
3929
4634
  ...this.validateOperation ? { validateOperation: this.validateOperation, koraContext: this.koraContext } : {},
3930
4635
  onClose: (sid) => {
3931
4636
  this.handleSessionClose(sid);
3932
- }
4637
+ },
4638
+ onOrphanedRelays: (nodeId, ops) => {
4639
+ this.bufferOrphanedRelays(nodeId, ops);
4640
+ },
4641
+ takeOrphanedRelays: (nodeId) => this.takeOrphanedRelays(nodeId)
3933
4642
  });
3934
4643
  this.sessions.set(sessionId, session);
3935
4644
  this.yjsDocRelay.addClient(sessionId, transport);
@@ -3989,6 +4698,22 @@ var KoraSyncServer = class {
3989
4698
  }
3990
4699
  return result;
3991
4700
  }
4701
+ /**
4702
+ * Relay already-applied, server-originated operations to every connected client.
4703
+ * Used by the conditional route apply, which commits its operations atomically
4704
+ * through the store (bypassing {@link applyLocalOperation}) and then fans them
4705
+ * out. Each session still enforces its own per-scope visibility filter.
4706
+ *
4707
+ * @param operations - Operations that have already been committed to the store
4708
+ */
4709
+ relayServerOperations(operations2) {
4710
+ if (operations2.length === 0) {
4711
+ return;
4712
+ }
4713
+ for (const session of this.sessions.values()) {
4714
+ session.relayOperations(operations2);
4715
+ }
4716
+ }
3992
4717
  /**
3993
4718
  * Collect every blob reference still reachable from live records on the server.
3994
4719
  *