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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.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])
@@ -419,6 +395,9 @@ function serializeFieldValue(value, descriptor) {
419
395
  if (value === null || value === void 0) return null;
420
396
  switch (descriptor.kind) {
421
397
  case "array":
398
+ case "object":
399
+ case "json":
400
+ case "blob":
422
401
  return typeof value === "string" ? value : JSON.stringify(value);
423
402
  case "boolean":
424
403
  return value ? 1 : 0;
@@ -432,6 +411,9 @@ function deserializeFieldValue(value, descriptor) {
432
411
  if (value === null || value === void 0) return null;
433
412
  switch (descriptor.kind) {
434
413
  case "array":
414
+ case "object":
415
+ case "json":
416
+ case "blob":
435
417
  return typeof value === "string" ? JSON.parse(value) : value;
436
418
  case "boolean":
437
419
  return value === 1 || value === true;
@@ -464,6 +446,14 @@ var MemoryServerStore = class {
464
446
  operations = [];
465
447
  operationIndex = /* @__PURE__ */ new Map();
466
448
  versionVector = /* @__PURE__ */ new Map();
449
+ /**
450
+ * Server-assigned delivery sequence per operation id. Single-process, so a plain
451
+ * counter incremented at insert time gives commit-order delivery sequence with no
452
+ * gaps. `operations` is kept in insertion (== delivery) order, so a delivery scan
453
+ * is a forward walk.
454
+ */
455
+ deliverySeqByOpId = /* @__PURE__ */ new Map();
456
+ deliverySeqCounter = 0;
467
457
  schema = null;
468
458
  /** Materialized records: collection -> recordId -> record data */
469
459
  materializedRecords = /* @__PURE__ */ new Map();
@@ -497,6 +487,8 @@ var MemoryServerStore = class {
497
487
  }
498
488
  this.operations.push(op);
499
489
  this.operationIndex.set(op.id, op);
490
+ this.deliverySeqCounter += 1;
491
+ this.deliverySeqByOpId.set(op.id, this.deliverySeqCounter);
500
492
  const currentSeq = this.versionVector.get(op.nodeId) ?? 0;
501
493
  if (op.sequenceNumber > currentSeq) {
502
494
  this.versionVector.set(op.nodeId, op.sequenceNumber);
@@ -516,6 +508,23 @@ var MemoryServerStore = class {
516
508
  this.assertOpen();
517
509
  return this.operations.length;
518
510
  }
511
+ async getMaxDeliverySequence() {
512
+ this.assertOpen();
513
+ return this.deliverySeqCounter;
514
+ }
515
+ async getOperationsAfterDelivery(afterDeliverySequence, limit) {
516
+ this.assertOpen();
517
+ const result = [];
518
+ if (limit <= 0) return result;
519
+ for (const op of this.operations) {
520
+ const deliverySequence = this.deliverySeqByOpId.get(op.id) ?? 0;
521
+ if (deliverySequence > afterDeliverySequence) {
522
+ result.push({ operation: op, deliverySequence });
523
+ if (result.length >= limit) break;
524
+ }
525
+ }
526
+ return result;
527
+ }
519
528
  async materializeCollection(collection) {
520
529
  this.assertOpen();
521
530
  if (this.schema?.collections[collection]) {
@@ -660,12 +669,16 @@ var MemoryServerStore = class {
660
669
  this.operations.length = 0;
661
670
  this.operationIndex.clear();
662
671
  this.versionVector.clear();
672
+ this.deliverySeqByOpId.clear();
673
+ this.deliverySeqCounter = 0;
663
674
  for (const [nid, seq] of versionVector) {
664
675
  this.versionVector.set(nid, seq);
665
676
  }
666
677
  for (const op of operations2) {
667
678
  this.operations.push(op);
668
679
  this.operationIndex.set(op.id, op);
680
+ this.deliverySeqCounter += 1;
681
+ this.deliverySeqByOpId.set(op.id, this.deliverySeqCounter);
669
682
  if (this.schema?.collections[op.collection]) {
670
683
  this.rebuildMaterializedRecord(op.collection, op.recordId);
671
684
  }
@@ -695,13 +708,14 @@ var MemoryServerStore = class {
695
708
  return a.timestamp.wallTime - b.timestamp.wallTime;
696
709
  if (a.timestamp.logical !== b.timestamp.logical)
697
710
  return a.timestamp.logical - b.timestamp.logical;
698
- return a.sequenceNumber - b.sequenceNumber;
711
+ return a.timestamp.nodeId < b.timestamp.nodeId ? -1 : a.timestamp.nodeId > b.timestamp.nodeId ? 1 : 0;
699
712
  });
700
713
  const parsedOps = recordOps.map((op) => ({
701
714
  type: op.type,
702
- data: op.data
715
+ data: op.data,
716
+ atomicOps: op.atomicOps ?? null
703
717
  }));
704
- const recordData = replayOperationsForRecord(parsedOps);
718
+ const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
705
719
  if (recordData) {
706
720
  const createdAt = recordOps.length > 0 ? recordOps[0].timestamp.wallTime : Date.now();
707
721
  const updatedAt = recordOps.length > 0 ? recordOps[recordOps.length - 1].timestamp.wallTime : Date.now();
@@ -824,6 +838,8 @@ var pgOperations = (0, import_pg_core.pgTable)(
824
838
  // JSON-serialized, null for deletes
825
839
  previousData: (0, import_pg_core.text)("previous_data"),
826
840
  // JSON-serialized, null for insert/delete
841
+ atomicOps: (0, import_pg_core.text)("atomic_ops"),
842
+ // JSON-serialized Record<field, AtomicOp>, null when none
827
843
  wallTime: (0, import_pg_core.bigint)("wall_time", { mode: "number" }).notNull(),
828
844
  logical: (0, import_pg_core.integer)("logical").notNull(),
829
845
  timestampNodeId: (0, import_pg_core.text)("timestamp_node_id").notNull(),
@@ -831,12 +847,18 @@ var pgOperations = (0, import_pg_core.pgTable)(
831
847
  causalDeps: (0, import_pg_core.text)("causal_deps").notNull().default("[]"),
832
848
  // JSON array of op IDs
833
849
  schemaVersion: (0, import_pg_core.integer)("schema_version").notNull(),
834
- receivedAt: (0, import_pg_core.bigint)("received_at", { mode: "number" }).notNull()
850
+ receivedAt: (0, import_pg_core.bigint)("received_at", { mode: "number" }).notNull(),
851
+ // Server-assigned monotonic delivery sequence, ordered by commit (assigned from
852
+ // a counter row locked inside the append transaction, so delivery order equals
853
+ // visibility order even across instances). Drives the gap-free server->client
854
+ // delivery watermark. Nullable only for pre-column rows, backfilled on startup.
855
+ deliverySeq: (0, import_pg_core.bigint)("delivery_seq", { mode: "number" })
835
856
  },
836
857
  (table) => ({
837
858
  nodeSeqIdx: (0, import_pg_core.index)("idx_pg_node_seq").on(table.nodeId, table.sequenceNumber),
838
859
  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)
860
+ receivedIdx: (0, import_pg_core.index)("idx_pg_received").on(table.receivedAt),
861
+ deliveryIdx: (0, import_pg_core.index)("idx_pg_delivery_seq").on(table.deliverySeq)
840
862
  })
841
863
  );
842
864
  var pgSyncState = (0, import_pg_core.pgTable)("sync_state", {
@@ -853,15 +875,43 @@ var PostgresServerStore = class {
853
875
  ready;
854
876
  schema = null;
855
877
  closed = false;
856
- constructor(db, nodeId) {
878
+ /**
879
+ * Monotonic counter for this node's server-originated sequence numbers. Lazily
880
+ * seeded from the persisted version vector on first use, then advanced in memory
881
+ * (a synchronous `++`, atomic on the single JS thread) so concurrent conditional
882
+ * applies never receive the same number. Null means "not yet seeded"; reset to
883
+ * null after a backup import so it re-seeds from the restored version vector.
884
+ */
885
+ sequenceCounter = null;
886
+ /**
887
+ * Time source for HLC timestamps on server-originated operations. Injectable so a
888
+ * test can freeze wall-clock time and prove the conditional-apply ordering holds
889
+ * under same-millisecond commits (the case the advisory lock and clock advance
890
+ * exist to make correct). Defaults to the system clock in production.
891
+ */
892
+ timeSource;
893
+ constructor(db, nodeId, timeSource) {
857
894
  this.db = db;
858
895
  this.nodeId = nodeId ?? (0, import_core3.generateUUIDv7)();
896
+ this.timeSource = timeSource ?? { now: () => Date.now() };
859
897
  this.ready = this.initialize();
860
898
  }
861
899
  getVersionVector() {
862
900
  this.assertOpen();
863
901
  return new Map(this.versionVector);
864
902
  }
903
+ /**
904
+ * Atomically reserve this node's next server-operation sequence number. The
905
+ * synchronous increment cannot interleave on the single JS thread, so two
906
+ * concurrent conditional applies are always handed distinct numbers.
907
+ */
908
+ reserveSequenceNumber() {
909
+ if (this.sequenceCounter === null) {
910
+ this.sequenceCounter = this.versionVector.get(this.nodeId) ?? 0;
911
+ }
912
+ this.sequenceCounter += 1;
913
+ return this.sequenceCounter;
914
+ }
865
915
  getNodeId() {
866
916
  return this.nodeId;
867
917
  }
@@ -899,8 +949,9 @@ var PostgresServerStore = class {
899
949
  return "duplicate";
900
950
  }
901
951
  const now = Date.now();
902
- const row = this.serializeOperation(op, now);
903
952
  await this.db.transaction(async (tx) => {
953
+ const deliverySeq = await this.nextDeliverySeq(tx);
954
+ const row = this.serializeOperation(op, now, deliverySeq);
904
955
  await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
905
956
  await tx.insert(pgSyncState).values({
906
957
  nodeId: op.nodeId,
@@ -923,6 +974,73 @@ var PostgresServerStore = class {
923
974
  }
924
975
  return "applied";
925
976
  }
977
+ /**
978
+ * Conditionally apply operations atomically, serialized across server instances
979
+ * on the target record via a transaction-scoped advisory lock. The lock makes
980
+ * the read-decide-write cycle atomic against concurrent instances, so a cap
981
+ * check like `responseCount < max` cannot be passed by two admissions at once.
982
+ */
983
+ async applyConditional(input) {
984
+ this.assertOpen();
985
+ await this.ready;
986
+ this.assertSchema();
987
+ this.assertCollection(input.target.collection);
988
+ const schema = this.schema;
989
+ const collectionDef = schema.collections[input.target.collection];
990
+ const lockKey = `kora:${input.target.collection}:${input.target.id}`;
991
+ const result = await this.db.transaction(async (tx) => {
992
+ await tx.execute(import_drizzle_orm.sql`SELECT pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`);
993
+ if (input.idempotencyKey) {
994
+ const existing = await tx.execute(
995
+ 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`
996
+ );
997
+ if (existing.length > 0) {
998
+ return { admitted: true, idempotent: true, applied: [] };
999
+ }
1000
+ }
1001
+ const rows = await tx.execute(
1002
+ 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`
1003
+ );
1004
+ const current = rows.length > 0 ? this.deserializeRow(rows[0], collectionDef) : null;
1005
+ if (!input.admit(current)) {
1006
+ return { admitted: false, idempotent: false, applied: [] };
1007
+ }
1008
+ const clock = new import_core3.HybridLogicalClock(this.nodeId, this.timeSource);
1009
+ const latest = await this.latestOperationTimestamp(
1010
+ tx,
1011
+ input.target.collection,
1012
+ input.target.id
1013
+ );
1014
+ if (latest) {
1015
+ clock.advanceTo(latest);
1016
+ }
1017
+ const ops = await input.buildOperations(current, { clock });
1018
+ const now = Date.now();
1019
+ for (const op of ops) {
1020
+ const deliverySeq = await this.nextDeliverySeq(tx);
1021
+ const row = this.serializeOperation(op, now, deliverySeq);
1022
+ await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
1023
+ await tx.insert(pgSyncState).values({ nodeId: op.nodeId, maxSequenceNumber: op.sequenceNumber, lastSeenAt: now }).onConflictDoUpdate({
1024
+ target: pgSyncState.nodeId,
1025
+ set: {
1026
+ maxSequenceNumber: import_drizzle_orm.sql`GREATEST(${pgSyncState.maxSequenceNumber}, ${op.sequenceNumber})`,
1027
+ lastSeenAt: import_drizzle_orm.sql`${now}`
1028
+ }
1029
+ });
1030
+ if (this.schema?.collections[op.collection]) {
1031
+ await this.rebuildMaterializedRecord(tx, op.collection, op.recordId);
1032
+ }
1033
+ }
1034
+ return { admitted: true, idempotent: false, applied: ops };
1035
+ });
1036
+ for (const op of result.applied) {
1037
+ const currentMax = this.versionVector.get(op.nodeId) ?? 0;
1038
+ if (op.sequenceNumber > currentMax) {
1039
+ this.versionVector.set(op.nodeId, op.sequenceNumber);
1040
+ }
1041
+ }
1042
+ return result;
1043
+ }
926
1044
  async getOperationRange(nodeId, fromSeq, toSeq) {
927
1045
  this.assertOpen();
928
1046
  await this.ready;
@@ -937,6 +1055,23 @@ var PostgresServerStore = class {
937
1055
  const result = await this.db.select({ value: (0, import_drizzle_orm.count)() }).from(pgOperations);
938
1056
  return result[0]?.value ?? 0;
939
1057
  }
1058
+ async getMaxDeliverySequence() {
1059
+ this.assertOpen();
1060
+ await this.ready;
1061
+ const rows = await this.db.execute(
1062
+ import_drizzle_orm.sql`SELECT COALESCE(MAX(delivery_seq), 0) AS m FROM operations`
1063
+ );
1064
+ return Number(rows[0]?.m ?? 0);
1065
+ }
1066
+ async getOperationsAfterDelivery(afterDeliverySequence, limit) {
1067
+ this.assertOpen();
1068
+ await this.ready;
1069
+ 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);
1070
+ return rows.map((row) => ({
1071
+ operation: this.deserializeOperation(row),
1072
+ deliverySequence: row.deliverySeq ?? 0
1073
+ }));
1074
+ }
940
1075
  async materializeCollection(collection) {
941
1076
  this.assertOpen();
942
1077
  await this.ready;
@@ -1000,7 +1135,7 @@ var PostgresServerStore = class {
1000
1135
  this.assertOpen();
1001
1136
  await this.ready;
1002
1137
  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));
1138
+ const rows = await this.db.select().from(pgOperations).orderBy((0, import_drizzle_orm.asc)(pgOperations.deliverySeq));
1004
1139
  const operations2 = rows.map((row) => this.deserializeOperation(row));
1005
1140
  return buildServerBackup2(this.nodeId, operations2, this.versionVector);
1006
1141
  }
@@ -1015,6 +1150,7 @@ var PostgresServerStore = class {
1015
1150
  const result = await this.applyRemoteOperation(op);
1016
1151
  if (result === "applied") restored++;
1017
1152
  }
1153
+ this.sequenceCounter = null;
1018
1154
  return { operationsRestored: restored, success: true };
1019
1155
  }
1020
1156
  const now = Date.now();
@@ -1024,20 +1160,49 @@ var PostgresServerStore = class {
1024
1160
  for (const [nid, seq] of versionVector) {
1025
1161
  await tx.insert(pgSyncState).values({ nodeId: nid, maxSequenceNumber: seq, lastSeenAt: now }).onConflictDoNothing({ target: pgSyncState.nodeId });
1026
1162
  }
1163
+ let deliverySeq = 0;
1027
1164
  for (const op of operations2) {
1028
- const row = this.serializeOperation(op, now);
1165
+ deliverySeq += 1;
1166
+ const row = this.serializeOperation(op, now, deliverySeq);
1029
1167
  await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
1030
1168
  }
1169
+ await tx.execute(
1170
+ import_drizzle_orm.sql`INSERT INTO delivery_counter (id, value) VALUES (1, ${deliverySeq})
1171
+ ON CONFLICT (id) DO UPDATE SET value = ${deliverySeq}`
1172
+ );
1031
1173
  });
1032
1174
  this.versionVector.clear();
1033
1175
  for (const [nid, seq] of versionVector) {
1034
1176
  this.versionVector.set(nid, seq);
1035
1177
  }
1178
+ this.sequenceCounter = null;
1036
1179
  return { operationsRestored: operations2.length, success: true };
1037
1180
  }
1038
1181
  // ---------------------------------------------------------------------------
1039
1182
  // Materialization internals
1040
1183
  // ---------------------------------------------------------------------------
1184
+ /**
1185
+ * The HLC timestamp of the most recent operation on a record, by total order
1186
+ * (wallTime, logical, nodeId), or null when the record has no operations. Used
1187
+ * by {@link applyConditional} to advance its clock past the record's latest
1188
+ * write so newly built operations sort strictly after it.
1189
+ */
1190
+ async latestOperationTimestamp(txOrDb, collection, recordId) {
1191
+ const rows = await txOrDb.select({
1192
+ wallTime: pgOperations.wallTime,
1193
+ logical: pgOperations.logical,
1194
+ timestampNodeId: pgOperations.timestampNodeId
1195
+ }).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(
1196
+ (0, import_drizzle_orm.desc)(pgOperations.wallTime),
1197
+ (0, import_drizzle_orm.desc)(pgOperations.logical),
1198
+ (0, import_drizzle_orm.desc)(pgOperations.timestampNodeId)
1199
+ ).limit(1);
1200
+ const row = rows[0];
1201
+ if (!row) {
1202
+ return null;
1203
+ }
1204
+ return { wallTime: row.wallTime, logical: row.logical, nodeId: row.timestampNodeId };
1205
+ }
1041
1206
  /**
1042
1207
  * Rebuild a single record in the materialized collection table by replaying
1043
1208
  * all operations for that record.
@@ -1048,17 +1213,19 @@ var PostgresServerStore = class {
1048
1213
  const ops = await txOrDb.select({
1049
1214
  type: pgOperations.type,
1050
1215
  data: pgOperations.data,
1216
+ atomicOps: pgOperations.atomicOps,
1051
1217
  wallTime: pgOperations.wallTime
1052
1218
  }).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
1219
  (0, import_drizzle_orm.asc)(pgOperations.wallTime),
1054
1220
  (0, import_drizzle_orm.asc)(pgOperations.logical),
1055
- (0, import_drizzle_orm.asc)(pgOperations.sequenceNumber)
1221
+ (0, import_drizzle_orm.asc)(pgOperations.timestampNodeId)
1056
1222
  );
1057
1223
  const parsedOps = ops.map((op) => ({
1058
1224
  type: op.type,
1059
- data: op.data !== null ? JSON.parse(op.data) : null
1225
+ data: op.data !== null ? JSON.parse(op.data) : null,
1226
+ atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
1060
1227
  }));
1061
- const recordData = replayOperationsForRecord(parsedOps);
1228
+ const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
1062
1229
  const fieldNames = Object.keys(collectionDef.fields);
1063
1230
  if (recordData) {
1064
1231
  const createdAt = ops.length > 0 ? ops[0].wallTime : Date.now();
@@ -1125,11 +1292,12 @@ var PostgresServerStore = class {
1125
1292
  recordId: pgOperations.recordId,
1126
1293
  type: pgOperations.type,
1127
1294
  data: pgOperations.data,
1295
+ atomicOps: pgOperations.atomicOps,
1128
1296
  wallTime: pgOperations.wallTime
1129
1297
  }).from(pgOperations).where((0, import_drizzle_orm.eq)(pgOperations.collection, collectionName)).orderBy(
1130
1298
  (0, import_drizzle_orm.asc)(pgOperations.wallTime),
1131
1299
  (0, import_drizzle_orm.asc)(pgOperations.logical),
1132
- (0, import_drizzle_orm.asc)(pgOperations.sequenceNumber)
1300
+ (0, import_drizzle_orm.asc)(pgOperations.timestampNodeId)
1133
1301
  );
1134
1302
  if (allOps.length === 0) return;
1135
1303
  const grouped = /* @__PURE__ */ new Map();
@@ -1145,9 +1313,10 @@ var PostgresServerStore = class {
1145
1313
  for (const [recordId, recordOps] of grouped) {
1146
1314
  const parsedOps = recordOps.map((op) => ({
1147
1315
  type: op.type,
1148
- data: op.data !== null ? JSON.parse(op.data) : null
1316
+ data: op.data !== null ? JSON.parse(op.data) : null,
1317
+ atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
1149
1318
  }));
1150
- const recordData = replayOperationsForRecord(parsedOps);
1319
+ const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
1151
1320
  if (recordData) {
1152
1321
  const createdAt = recordOps[0].wallTime;
1153
1322
  const updatedAt = recordOps[recordOps.length - 1].wallTime;
@@ -1270,44 +1439,92 @@ var PostgresServerStore = class {
1270
1439
  }
1271
1440
  }
1272
1441
  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)`
1442
+ await this.db.transaction(async (tx) => {
1443
+ await tx.execute(import_drizzle_orm.sql`SELECT pg_advisory_xact_lock(hashtextextended('kora:ensure-tables', 0))`);
1444
+ await tx.execute(import_drizzle_orm.sql`
1445
+ CREATE TABLE IF NOT EXISTS operations (
1446
+ id TEXT PRIMARY KEY,
1447
+ node_id TEXT NOT NULL,
1448
+ type TEXT NOT NULL,
1449
+ collection TEXT NOT NULL,
1450
+ record_id TEXT NOT NULL,
1451
+ data TEXT,
1452
+ previous_data TEXT,
1453
+ atomic_ops TEXT,
1454
+ wall_time BIGINT NOT NULL,
1455
+ logical INTEGER NOT NULL,
1456
+ timestamp_node_id TEXT NOT NULL,
1457
+ sequence_number INTEGER NOT NULL,
1458
+ causal_deps TEXT NOT NULL DEFAULT '[]',
1459
+ schema_version INTEGER NOT NULL,
1460
+ received_at BIGINT NOT NULL
1461
+ )
1462
+ `);
1463
+ await tx.execute(import_drizzle_orm.sql`ALTER TABLE operations ADD COLUMN IF NOT EXISTS atomic_ops TEXT`);
1464
+ await tx.execute(import_drizzle_orm.sql`ALTER TABLE operations ADD COLUMN IF NOT EXISTS delivery_seq BIGINT`);
1465
+ await tx.execute(
1466
+ import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_node_seq ON operations (node_id, sequence_number)`
1467
+ );
1468
+ await tx.execute(import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_collection ON operations (collection)`);
1469
+ await tx.execute(import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_received ON operations (received_at)`);
1470
+ await tx.execute(
1471
+ import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_delivery_seq ON operations (delivery_seq)`
1472
+ );
1473
+ await tx.execute(
1474
+ import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_collection_record ON operations (collection, record_id)`
1475
+ );
1476
+ await tx.execute(import_drizzle_orm.sql`
1477
+ CREATE TABLE IF NOT EXISTS sync_state (
1478
+ node_id TEXT PRIMARY KEY,
1479
+ max_sequence_number INTEGER NOT NULL,
1480
+ last_seen_at BIGINT NOT NULL
1481
+ )
1482
+ `);
1483
+ await tx.execute(import_drizzle_orm.sql`
1484
+ CREATE TABLE IF NOT EXISTS delivery_counter (
1485
+ id INTEGER PRIMARY KEY,
1486
+ value BIGINT NOT NULL
1487
+ )
1488
+ `);
1489
+ await tx.execute(import_drizzle_orm.sql`
1490
+ WITH ordered AS (
1491
+ SELECT id, ROW_NUMBER() OVER (
1492
+ ORDER BY received_at ASC, sequence_number ASC, id ASC
1493
+ ) + COALESCE((SELECT MAX(delivery_seq) FROM operations), 0) AS rn
1494
+ FROM operations WHERE delivery_seq IS NULL
1495
+ )
1496
+ UPDATE operations o SET delivery_seq = ordered.rn
1497
+ FROM ordered WHERE o.id = ordered.id
1498
+ `);
1499
+ await tx.execute(import_drizzle_orm.sql`
1500
+ INSERT INTO delivery_counter (id, value)
1501
+ VALUES (1, COALESCE((SELECT MAX(delivery_seq) FROM operations), 0))
1502
+ ON CONFLICT (id) DO UPDATE
1503
+ SET value = GREATEST(delivery_counter.value, EXCLUDED.value)
1504
+ `);
1505
+ });
1506
+ }
1507
+ /**
1508
+ * Reserve the next delivery sequence inside an open append transaction. The
1509
+ * `UPDATE ... RETURNING` locks the counter row until this transaction commits,
1510
+ * serializing assignment into commit order (see the counter table comment).
1511
+ */
1512
+ async nextDeliverySeq(tx) {
1513
+ const rows = await tx.execute(
1514
+ import_drizzle_orm.sql`UPDATE delivery_counter SET value = value + 1 WHERE id = 1 RETURNING value`
1298
1515
  );
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
- `);
1516
+ const value = rows[0]?.value;
1517
+ if (value === void 0 || value === null) {
1518
+ throw new Error(
1519
+ "delivery_counter row (id=1) is missing; the operations log cannot assign delivery sequences"
1520
+ );
1521
+ }
1522
+ return Number(value);
1306
1523
  }
1307
1524
  // ---------------------------------------------------------------------------
1308
1525
  // Operation serialization
1309
1526
  // ---------------------------------------------------------------------------
1310
- serializeOperation(op, receivedAt) {
1527
+ serializeOperation(op, receivedAt, deliverySeq) {
1311
1528
  return {
1312
1529
  id: op.id,
1313
1530
  nodeId: op.nodeId,
@@ -1316,16 +1533,19 @@ var PostgresServerStore = class {
1316
1533
  recordId: op.recordId,
1317
1534
  data: op.data !== null ? JSON.stringify(op.data) : null,
1318
1535
  previousData: op.previousData !== null ? JSON.stringify(op.previousData) : null,
1536
+ atomicOps: op.atomicOps && Object.keys(op.atomicOps).length > 0 ? JSON.stringify(op.atomicOps) : null,
1319
1537
  wallTime: op.timestamp.wallTime,
1320
1538
  logical: op.timestamp.logical,
1321
1539
  timestampNodeId: op.timestamp.nodeId,
1322
1540
  sequenceNumber: op.sequenceNumber,
1323
1541
  causalDeps: JSON.stringify(op.causalDeps),
1324
1542
  schemaVersion: op.schemaVersion,
1325
- receivedAt
1543
+ receivedAt,
1544
+ deliverySeq
1326
1545
  };
1327
1546
  }
1328
1547
  deserializeOperation(row) {
1548
+ const atomicOps = row.atomicOps != null ? JSON.parse(row.atomicOps) : void 0;
1329
1549
  return {
1330
1550
  id: row.id,
1331
1551
  nodeId: row.nodeId,
@@ -1341,7 +1561,8 @@ var PostgresServerStore = class {
1341
1561
  },
1342
1562
  sequenceNumber: row.sequenceNumber,
1343
1563
  causalDeps: JSON.parse(row.causalDeps),
1344
- schemaVersion: row.schemaVersion
1564
+ schemaVersion: row.schemaVersion,
1565
+ ...atomicOps ? { atomicOps } : {}
1345
1566
  };
1346
1567
  }
1347
1568
  // ---------------------------------------------------------------------------
@@ -1411,6 +1632,8 @@ var operations = (0, import_sqlite_core.sqliteTable)(
1411
1632
  // JSON-serialized, null for deletes
1412
1633
  previousData: (0, import_sqlite_core.text)("previous_data"),
1413
1634
  // JSON-serialized, null for insert/delete
1635
+ atomicOps: (0, import_sqlite_core.text)("atomic_ops"),
1636
+ // JSON-serialized Record<field, AtomicOp>, null when none
1414
1637
  wallTime: (0, import_sqlite_core.integer)("wall_time").notNull(),
1415
1638
  logical: (0, import_sqlite_core.integer)("logical").notNull(),
1416
1639
  timestampNodeId: (0, import_sqlite_core.text)("timestamp_node_id").notNull(),
@@ -1418,12 +1641,17 @@ var operations = (0, import_sqlite_core.sqliteTable)(
1418
1641
  causalDeps: (0, import_sqlite_core.text)("causal_deps").notNull().default("[]"),
1419
1642
  // JSON array of op IDs
1420
1643
  schemaVersion: (0, import_sqlite_core.integer)("schema_version").notNull(),
1421
- receivedAt: (0, import_sqlite_core.integer)("received_at").notNull()
1644
+ receivedAt: (0, import_sqlite_core.integer)("received_at").notNull(),
1645
+ // Server-assigned monotonic delivery sequence, ordered by commit. Drives the
1646
+ // gap-free server->client delivery watermark. Nullable only for rows written
1647
+ // before the column existed; those are backfilled on startup.
1648
+ deliverySeq: (0, import_sqlite_core.integer)("delivery_seq")
1422
1649
  },
1423
1650
  (table) => ({
1424
1651
  nodeSeqIdx: (0, import_sqlite_core.index)("idx_node_seq").on(table.nodeId, table.sequenceNumber),
1425
1652
  collectionIdx: (0, import_sqlite_core.index)("idx_collection").on(table.collection),
1426
- receivedIdx: (0, import_sqlite_core.index)("idx_received").on(table.receivedAt)
1653
+ receivedIdx: (0, import_sqlite_core.index)("idx_received").on(table.receivedAt),
1654
+ deliveryIdx: (0, import_sqlite_core.index)("idx_delivery_seq").on(table.deliverySeq)
1427
1655
  })
1428
1656
  );
1429
1657
  var syncState = (0, import_sqlite_core.sqliteTable)("sync_state", {
@@ -1439,6 +1667,12 @@ var SqliteServerStore = class {
1439
1667
  db;
1440
1668
  schema = null;
1441
1669
  closed = false;
1670
+ /**
1671
+ * Next delivery sequence to assign. Single-writer (better-sqlite3 is synchronous),
1672
+ * so a plain in-memory counter, seeded from MAX(delivery_seq) at startup, gives
1673
+ * commit-order delivery sequence. Assigned inside the append transaction.
1674
+ */
1675
+ deliverySeqCounter = 0;
1442
1676
  constructor(db, nodeId) {
1443
1677
  this.db = db;
1444
1678
  this.nodeId = nodeId ?? (0, import_core4.generateUUIDv7)();
@@ -1484,12 +1718,16 @@ var SqliteServerStore = class {
1484
1718
  async applyRemoteOperation(op) {
1485
1719
  this.assertOpen();
1486
1720
  const now = Date.now();
1487
- const row = this.serializeOperation(op, now);
1488
1721
  const result = this.db.transaction((tx) => {
1489
- const insertResult = tx.insert(operations).values(row).onConflictDoNothing({ target: operations.id }).run();
1490
- if (insertResult.changes === 0) {
1722
+ const existing = tx.all(
1723
+ import_drizzle_orm2.sql`SELECT id FROM operations WHERE id = ${op.id} LIMIT 1`
1724
+ );
1725
+ if (existing.length > 0) {
1491
1726
  return "duplicate";
1492
1727
  }
1728
+ this.deliverySeqCounter += 1;
1729
+ const row = this.serializeOperation(op, now, this.deliverySeqCounter);
1730
+ tx.insert(operations).values(row).run();
1493
1731
  tx.insert(syncState).values({
1494
1732
  nodeId: op.nodeId,
1495
1733
  maxSequenceNumber: op.sequenceNumber,
@@ -1518,6 +1756,21 @@ var SqliteServerStore = class {
1518
1756
  const result = this.db.select({ value: (0, import_drizzle_orm2.count)() }).from(operations).all();
1519
1757
  return result[0]?.value ?? 0;
1520
1758
  }
1759
+ async getMaxDeliverySequence() {
1760
+ this.assertOpen();
1761
+ const rows = this.db.all(
1762
+ import_drizzle_orm2.sql`SELECT MAX(delivery_seq) AS m FROM operations`
1763
+ );
1764
+ return rows[0]?.m ?? 0;
1765
+ }
1766
+ async getOperationsAfterDelivery(afterDeliverySequence, limit) {
1767
+ this.assertOpen();
1768
+ 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();
1769
+ return rows.map((row) => ({
1770
+ operation: this.deserializeOperation(row),
1771
+ deliverySequence: row.deliverySeq ?? 0
1772
+ }));
1773
+ }
1521
1774
  async materializeCollection(collection) {
1522
1775
  this.assertOpen();
1523
1776
  if (this.schema?.collections[collection]) {
@@ -1575,7 +1828,7 @@ var SqliteServerStore = class {
1575
1828
  async exportBackup() {
1576
1829
  this.assertOpen();
1577
1830
  const { buildServerBackup: buildServerBackup2 } = await Promise.resolve().then(() => (init_server_backup(), server_backup_exports));
1578
- const rows = this.db.select().from(operations).all();
1831
+ const rows = this.db.select().from(operations).orderBy((0, import_drizzle_orm2.asc)(operations.deliverySeq)).all();
1579
1832
  const deserialized = rows.map((row) => this.deserializeOperation(row));
1580
1833
  const vv = this.getVersionVector();
1581
1834
  return buildServerBackup2(this.nodeId, deserialized, vv);
@@ -1598,10 +1851,13 @@ var SqliteServerStore = class {
1598
1851
  for (const [nid, seq] of versionVector) {
1599
1852
  tx.insert(syncState).values({ nodeId: nid, maxSequenceNumber: seq, lastSeenAt: Date.now() }).run();
1600
1853
  }
1854
+ let deliverySeq = 0;
1601
1855
  for (const op of ops) {
1602
- const row = this.serializeOperation(op, Date.now());
1856
+ deliverySeq += 1;
1857
+ const row = this.serializeOperation(op, Date.now(), deliverySeq);
1603
1858
  tx.insert(operations).values(row).run();
1604
1859
  }
1860
+ this.deliverySeqCounter = deliverySeq;
1605
1861
  });
1606
1862
  return { operationsRestored: ops.length, success: true };
1607
1863
  }
@@ -1619,13 +1875,15 @@ var SqliteServerStore = class {
1619
1875
  const ops = txOrDb.select({
1620
1876
  type: operations.type,
1621
1877
  data: operations.data,
1878
+ atomicOps: operations.atomicOps,
1622
1879
  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();
1880
+ }).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
1881
  const parsedOps = ops.map((op) => ({
1625
1882
  type: op.type,
1626
- data: op.data !== null ? JSON.parse(op.data) : null
1883
+ data: op.data !== null ? JSON.parse(op.data) : null,
1884
+ atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
1627
1885
  }));
1628
- const recordData = replayOperationsForRecord(parsedOps);
1886
+ const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
1629
1887
  const fieldNames = Object.keys(collectionDef.fields);
1630
1888
  if (recordData) {
1631
1889
  const createdAt = ops.length > 0 ? ops[0].wallTime : Date.now();
@@ -1695,8 +1953,9 @@ var SqliteServerStore = class {
1695
1953
  recordId: operations.recordId,
1696
1954
  type: operations.type,
1697
1955
  data: operations.data,
1956
+ atomicOps: operations.atomicOps,
1698
1957
  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();
1958
+ }).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
1959
  if (allOps.length === 0) return;
1701
1960
  const grouped = /* @__PURE__ */ new Map();
1702
1961
  for (const op of allOps) {
@@ -1712,9 +1971,10 @@ var SqliteServerStore = class {
1712
1971
  for (const [recordId, recordOps] of grouped) {
1713
1972
  const parsedOps = recordOps.map((op) => ({
1714
1973
  type: op.type,
1715
- data: op.data !== null ? JSON.parse(op.data) : null
1974
+ data: op.data !== null ? JSON.parse(op.data) : null,
1975
+ atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
1716
1976
  }));
1717
- const recordData = replayOperationsForRecord(parsedOps);
1977
+ const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
1718
1978
  if (recordData) {
1719
1979
  const createdAt = recordOps[0].wallTime;
1720
1980
  const updatedAt = recordOps[recordOps.length - 1].wallTime;
@@ -1836,6 +2096,7 @@ var SqliteServerStore = class {
1836
2096
  record_id TEXT NOT NULL,
1837
2097
  data TEXT,
1838
2098
  previous_data TEXT,
2099
+ atomic_ops TEXT,
1839
2100
  wall_time INTEGER NOT NULL,
1840
2101
  logical INTEGER NOT NULL,
1841
2102
  timestamp_node_id TEXT NOT NULL,
@@ -1845,9 +2106,30 @@ var SqliteServerStore = class {
1845
2106
  received_at INTEGER NOT NULL
1846
2107
  )
1847
2108
  `);
2109
+ try {
2110
+ this.db.run(import_drizzle_orm2.sql`ALTER TABLE operations ADD COLUMN atomic_ops TEXT`);
2111
+ } catch (e) {
2112
+ const msg = e instanceof Error ? e.message : "";
2113
+ const causeMsg = e instanceof Error && e.cause instanceof Error ? e.cause.message : "";
2114
+ if (!msg.includes("duplicate column") && !causeMsg.includes("duplicate column")) {
2115
+ throw e;
2116
+ }
2117
+ }
2118
+ try {
2119
+ this.db.run(import_drizzle_orm2.sql`ALTER TABLE operations ADD COLUMN delivery_seq INTEGER`);
2120
+ } catch (e) {
2121
+ const msg = e instanceof Error ? e.message : "";
2122
+ const causeMsg = e instanceof Error && e.cause instanceof Error ? e.cause.message : "";
2123
+ if (!msg.includes("duplicate column") && !causeMsg.includes("duplicate column")) {
2124
+ throw e;
2125
+ }
2126
+ }
1848
2127
  this.db.run(import_drizzle_orm2.sql`
1849
2128
  CREATE INDEX IF NOT EXISTS idx_node_seq ON operations (node_id, sequence_number)
1850
2129
  `);
2130
+ this.db.run(import_drizzle_orm2.sql`
2131
+ CREATE INDEX IF NOT EXISTS idx_delivery_seq ON operations (delivery_seq)
2132
+ `);
1851
2133
  this.db.run(import_drizzle_orm2.sql`
1852
2134
  CREATE INDEX IF NOT EXISTS idx_collection ON operations (collection)
1853
2135
  `);
@@ -1864,11 +2146,34 @@ var SqliteServerStore = class {
1864
2146
  last_seen_at INTEGER NOT NULL
1865
2147
  )
1866
2148
  `);
2149
+ this.backfillDeliverySequence();
2150
+ }
2151
+ /**
2152
+ * Assign delivery sequences to any operations written before the column existed,
2153
+ * then seed the in-memory counter from the current max. Ordered by
2154
+ * (received_at, sequence_number, id) so the backfill is deterministic and stable
2155
+ * across restarts. A one-time migration: after it runs, all rows have a value.
2156
+ */
2157
+ backfillDeliverySequence() {
2158
+ this.db.transaction((tx) => {
2159
+ const maxRow = tx.all(
2160
+ import_drizzle_orm2.sql`SELECT MAX(delivery_seq) AS m FROM operations`
2161
+ );
2162
+ let next = maxRow[0]?.m ?? 0;
2163
+ const nullRows = tx.all(
2164
+ import_drizzle_orm2.sql`SELECT id FROM operations WHERE delivery_seq IS NULL ORDER BY received_at ASC, sequence_number ASC, id ASC`
2165
+ );
2166
+ for (const row of nullRows) {
2167
+ next += 1;
2168
+ tx.run(import_drizzle_orm2.sql`UPDATE operations SET delivery_seq = ${next} WHERE id = ${row.id}`);
2169
+ }
2170
+ this.deliverySeqCounter = next;
2171
+ });
1867
2172
  }
1868
2173
  // ---------------------------------------------------------------------------
1869
2174
  // Operation serialization
1870
2175
  // ---------------------------------------------------------------------------
1871
- serializeOperation(op, receivedAt) {
2176
+ serializeOperation(op, receivedAt, deliverySeq) {
1872
2177
  return {
1873
2178
  id: op.id,
1874
2179
  nodeId: op.nodeId,
@@ -1877,16 +2182,19 @@ var SqliteServerStore = class {
1877
2182
  recordId: op.recordId,
1878
2183
  data: op.data !== null ? JSON.stringify(op.data) : null,
1879
2184
  previousData: op.previousData !== null ? JSON.stringify(op.previousData) : null,
2185
+ atomicOps: op.atomicOps && Object.keys(op.atomicOps).length > 0 ? JSON.stringify(op.atomicOps) : null,
1880
2186
  wallTime: op.timestamp.wallTime,
1881
2187
  logical: op.timestamp.logical,
1882
2188
  timestampNodeId: op.timestamp.nodeId,
1883
2189
  sequenceNumber: op.sequenceNumber,
1884
2190
  causalDeps: JSON.stringify(op.causalDeps),
1885
2191
  schemaVersion: op.schemaVersion,
1886
- receivedAt
2192
+ receivedAt,
2193
+ deliverySeq
1887
2194
  };
1888
2195
  }
1889
2196
  deserializeOperation(row) {
2197
+ const atomicOps = row.atomicOps != null ? JSON.parse(row.atomicOps) : void 0;
1890
2198
  return {
1891
2199
  id: row.id,
1892
2200
  nodeId: row.nodeId,
@@ -1902,7 +2210,8 @@ var SqliteServerStore = class {
1902
2210
  },
1903
2211
  sequenceNumber: row.sequenceNumber,
1904
2212
  causalDeps: JSON.parse(row.causalDeps),
1905
- schemaVersion: row.schemaVersion
2213
+ schemaVersion: row.schemaVersion,
2214
+ ...atomicOps ? { atomicOps } : {}
1906
2215
  };
1907
2216
  }
1908
2217
  // ---------------------------------------------------------------------------
@@ -2201,6 +2510,9 @@ async function createServerSideEffectOperation(store, parentOp, effect, schemaVe
2201
2510
  );
2202
2511
  }
2203
2512
  function nextServerSequenceNumber(store) {
2513
+ if (store.reserveSequenceNumber) {
2514
+ return store.reserveSequenceNumber();
2515
+ }
2204
2516
  const nodeId = store.getNodeId();
2205
2517
  const current = store.getVersionVector().get(nodeId) ?? 0;
2206
2518
  return current + 1;
@@ -2242,16 +2554,14 @@ async function applyServerOperation(store, op, relationLookup) {
2242
2554
  return { result: primaryResult, appliedOperations: [] };
2243
2555
  }
2244
2556
  const appliedOperations = [op];
2245
- let serverSeq = nextServerSequenceNumber(store);
2246
2557
  for (const effect of referential.sideEffectOps) {
2247
2558
  const sideOp = await createServerSideEffectOperation(
2248
2559
  store,
2249
2560
  op,
2250
2561
  effect,
2251
2562
  op.schemaVersion,
2252
- serverSeq
2563
+ nextServerSequenceNumber(store)
2253
2564
  );
2254
- serverSeq += 1;
2255
2565
  const sideResult = await store.applyRemoteOperation(sideOp);
2256
2566
  if (sideResult === "applied") {
2257
2567
  appliedOperations.push(sideOp);
@@ -2312,11 +2622,11 @@ function mergeScopeMaps(base, overlay, source) {
2312
2622
 
2313
2623
  // src/scopes/server-scope-filter.ts
2314
2624
  init_cjs_shims();
2315
- function operationMatchesScopes(op, scopes) {
2625
+ function operationMatchesScopes(op, scopes, fullRecord) {
2316
2626
  if (!scopes) return true;
2317
2627
  const collectionScope = scopes[op.collection];
2318
2628
  if (!collectionScope) return false;
2319
- const snapshot = buildSnapshot(op);
2629
+ const snapshot = buildSnapshot(op, fullRecord);
2320
2630
  if (!snapshot) return false;
2321
2631
  for (const [field, expected] of Object.entries(collectionScope)) {
2322
2632
  if (snapshot[field] !== expected) {
@@ -2325,11 +2635,19 @@ function operationMatchesScopes(op, scopes) {
2325
2635
  }
2326
2636
  return true;
2327
2637
  }
2328
- function buildSnapshot(op) {
2638
+ function missingScopeFields(op, scopes) {
2639
+ if (!scopes) return [];
2640
+ const collectionScope = scopes[op.collection];
2641
+ if (!collectionScope) return [];
2642
+ const snapshot = buildSnapshot(op);
2643
+ return Object.keys(collectionScope).filter((field) => !snapshot || !(field in snapshot));
2644
+ }
2645
+ function buildSnapshot(op, fullRecord) {
2329
2646
  const previous = asRecord(op.previousData);
2330
2647
  const next = asRecord(op.data);
2331
- if (!previous && !next) return null;
2648
+ if (!previous && !next && !fullRecord) return null;
2332
2649
  return {
2650
+ ...fullRecord ?? {},
2333
2651
  ...previous ?? {},
2334
2652
  ...next ?? {}
2335
2653
  };
@@ -2422,18 +2740,48 @@ function warnIfMultiTenantWithoutScopes(auth, resolvedScopes, schema) {
2422
2740
  "[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
2741
  );
2424
2742
  }
2425
- var ClientSession = class {
2743
+ var ClientSession = class _ClientSession {
2426
2744
  state = "connected";
2427
2745
  clientNodeId = null;
2428
2746
  authContext = null;
2429
2747
  syncQuerySubsets = [];
2430
2748
  resumeDeltaCursor = null;
2749
+ /**
2750
+ * The delivery watermark the client reported at handshake, or null when the client
2751
+ * does not use the delivery watermark (old client, or no watermark yet). When set,
2752
+ * the server drives the gap-free server->client stream from delivery sequences
2753
+ * instead of the version-vector delta.
2754
+ */
2755
+ clientDeliveryWatermark = null;
2756
+ /**
2757
+ * The highest delivery sequence this client has ACKNOWLEDGED (its confirmed
2758
+ * watermark, as reported in acks). Incremental streaming pushes resume from here, not
2759
+ * from the highest sequence sent, so a dropped or unapplied batch is always
2760
+ * re-included by the next push (which re-scans from the acknowledged position). This
2761
+ * is what makes streaming recovery independent of any bounded retransmit buffer: there
2762
+ * is no sent-but-unacked window that a buffer eviction could strand. Seeded from the
2763
+ * client's reported watermark at handshake.
2764
+ */
2765
+ lastAckedDeliverySeq = 0;
2766
+ /** Serializes incremental delivery pushes so their batches never interleave. */
2767
+ deliveryPushChain = Promise.resolve();
2768
+ /**
2769
+ * Relay batches sent to this client but not yet acknowledged, keyed by messageId.
2770
+ * The client acks every operation-batch it applies; on ack the entry is cleared.
2771
+ * {@link retransmitPendingRelays} re-sends anything still unacked, so a relay
2772
+ * dropped by a lossy transport is redelivered instead of leaving the client with a
2773
+ * permanent version-vector gap (a lost operation) that delta sync cannot recover.
2774
+ * Bounded so a silent client cannot grow it without limit.
2775
+ */
2776
+ pendingRelays = /* @__PURE__ */ new Map();
2777
+ static MAX_PENDING_RELAYS = 1e3;
2431
2778
  sessionId;
2432
2779
  transport;
2433
2780
  store;
2434
2781
  auth;
2435
2782
  serializer;
2436
2783
  emitter;
2784
+ logger;
2437
2785
  batchSize;
2438
2786
  schemaVersion;
2439
2787
  supportedSchemaVersions;
@@ -2444,6 +2792,8 @@ var ClientSession = class {
2444
2792
  onBlobChunkResponse;
2445
2793
  persistBlobChunk;
2446
2794
  onClose;
2795
+ onOrphanedRelays;
2796
+ takeOrphanedRelays;
2447
2797
  maxOperationBytes;
2448
2798
  maxOpsPerMinute;
2449
2799
  rateLimiter;
@@ -2456,6 +2806,7 @@ var ClientSession = class {
2456
2806
  this.auth = options.auth ?? null;
2457
2807
  this.serializer = options.serializer ?? new import_sync3.NegotiatedMessageSerializer("json");
2458
2808
  this.emitter = options.emitter ?? null;
2809
+ this.logger = options.logger ?? null;
2459
2810
  this.batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
2460
2811
  this.schemaVersion = options.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
2461
2812
  const supported = options.supportedSchemaVersions;
@@ -2470,6 +2821,8 @@ var ClientSession = class {
2470
2821
  this.onBlobChunkResponse = options.onBlobChunkResponse ?? null;
2471
2822
  this.persistBlobChunk = options.persistBlobChunk ?? null;
2472
2823
  this.onClose = options.onClose ?? null;
2824
+ this.onOrphanedRelays = options.onOrphanedRelays ?? null;
2825
+ this.takeOrphanedRelays = options.takeOrphanedRelays ?? null;
2473
2826
  this.maxOperationBytes = options.maxOperationBytes ?? DEFAULT_MAX_OPERATION_BYTES;
2474
2827
  this.maxOpsPerMinute = options.maxOpsPerMinute ?? DEFAULT_MAX_OPS_PER_MINUTE;
2475
2828
  this.rateLimiter = new SessionRateLimiter(this.maxOpsPerMinute);
@@ -2494,9 +2847,40 @@ var ClientSession = class {
2494
2847
  */
2495
2848
  relayOperations(operations2) {
2496
2849
  if (this.state !== "streaming" || !this.transport.isConnected()) return;
2850
+ if (this.clientDeliveryWatermark !== null) {
2851
+ this.pushDeliveryStream();
2852
+ return;
2853
+ }
2497
2854
  if (operations2.length === 0) return;
2498
- const visibleOperations = operations2.filter((op) => this.operationVisibleToClient(op));
2855
+ void this.relayVisibleOperations(operations2);
2856
+ }
2857
+ /**
2858
+ * Push newly-available in-scope operations to a delivery-watermark client, resuming
2859
+ * from the highest delivery sequence already sent. Pushes are serialized so their
2860
+ * batches never interleave (which would break the base/max chain).
2861
+ */
2862
+ pushDeliveryStream() {
2863
+ this.deliveryPushChain = this.deliveryPushChain.then(async () => {
2864
+ if (this.state !== "streaming" || !this.transport.isConnected()) return;
2865
+ try {
2866
+ await this.sendDeliveryStream(
2867
+ this.lastAckedDeliverySeq,
2868
+ false,
2869
+ this.clientNodeId ?? void 0
2870
+ );
2871
+ } catch {
2872
+ }
2873
+ });
2874
+ }
2875
+ async relayVisibleOperations(operations2) {
2876
+ const visibleOperations = [];
2877
+ for (const op of operations2) {
2878
+ if (await this.operationVisibleToClient(op)) {
2879
+ visibleOperations.push(op);
2880
+ }
2881
+ }
2499
2882
  if (visibleOperations.length === 0) return;
2883
+ if (this.state !== "streaming" || !this.transport.isConnected()) return;
2500
2884
  const serializedOps = visibleOperations.map((op) => this.serializer.encodeOperation(op));
2501
2885
  const msg = {
2502
2886
  type: "operation-batch",
@@ -2505,14 +2889,62 @@ var ClientSession = class {
2505
2889
  isFinal: true,
2506
2890
  batchIndex: 0
2507
2891
  };
2892
+ this.trackPendingRelay(msg, visibleOperations);
2508
2893
  this.sendToClient(msg);
2509
2894
  }
2895
+ /** Record a relay batch as awaiting acknowledgment, evicting the oldest if full. */
2896
+ trackPendingRelay(msg, ops) {
2897
+ if (this.pendingRelays.size >= _ClientSession.MAX_PENDING_RELAYS) {
2898
+ const oldest = this.pendingRelays.keys().next().value;
2899
+ if (oldest !== void 0) {
2900
+ this.pendingRelays.delete(oldest);
2901
+ }
2902
+ }
2903
+ this.pendingRelays.set(msg.messageId, { message: msg, ops, sentAtMs: Date.now() });
2904
+ }
2905
+ /**
2906
+ * Retransmit relay batches this client has not acknowledged within `staleMs`.
2907
+ * Called on a periodic tick by the server (and directly by tests). Redelivering an
2908
+ * already-applied op is harmless: the client dedups by content-addressed id.
2909
+ */
2910
+ retransmitPendingRelays(staleMs = 0) {
2911
+ if (this.state !== "streaming" || !this.transport.isConnected()) return;
2912
+ if (this.clientDeliveryWatermark !== null) {
2913
+ this.pushDeliveryStream();
2914
+ return;
2915
+ }
2916
+ if (this.pendingRelays.size === 0) return;
2917
+ const cutoff = Date.now() - staleMs;
2918
+ for (const { message, sentAtMs } of this.pendingRelays.values()) {
2919
+ if (sentAtMs <= cutoff) {
2920
+ this.sendToClient(message);
2921
+ }
2922
+ }
2923
+ }
2510
2924
  /**
2511
2925
  * Close this session.
2512
2926
  */
2927
+ /**
2928
+ * Hand any unacknowledged relays to the server so they survive this session and are
2929
+ * redelivered when this client reconnects (per-node buffer), then clear them. Called
2930
+ * from every session-teardown path (explicit close and transport close).
2931
+ */
2932
+ flushOrphanedRelays() {
2933
+ if (this.onOrphanedRelays && this.clientNodeId && this.pendingRelays.size > 0) {
2934
+ const ops = [];
2935
+ for (const entry of this.pendingRelays.values()) {
2936
+ ops.push(...entry.ops);
2937
+ }
2938
+ if (ops.length > 0) {
2939
+ this.onOrphanedRelays(this.clientNodeId, ops);
2940
+ }
2941
+ }
2942
+ this.pendingRelays.clear();
2943
+ }
2513
2944
  close(reason) {
2514
2945
  if (this.state === "closed") return;
2515
2946
  this.state = "closed";
2947
+ this.flushOrphanedRelays();
2516
2948
  if (this.transport.isConnected()) {
2517
2949
  this.transport.close(1e3, reason ?? "session closed");
2518
2950
  }
@@ -2567,6 +2999,10 @@ var ClientSession = class {
2567
2999
  await this.handleOperationBatch(message);
2568
3000
  break;
2569
3001
  case "acknowledgment":
3002
+ this.pendingRelays.delete(message.acknowledgedMessageId);
3003
+ if (message.deliverySequence !== void 0) {
3004
+ this.lastAckedDeliverySeq = Math.max(this.lastAckedDeliverySeq, message.deliverySequence);
3005
+ }
2570
3006
  break;
2571
3007
  case "error":
2572
3008
  break;
@@ -2605,6 +3041,18 @@ var ClientSession = class {
2605
3041
  }
2606
3042
  handleMessageFailure(error) {
2607
3043
  const reason = error instanceof Error ? error.message : "Message handling failed";
3044
+ this.logger?.log({
3045
+ timestamp: Date.now(),
3046
+ level: "error",
3047
+ event: "session.message_failed",
3048
+ sessionId: this.sessionId,
3049
+ nodeId: this.clientNodeId ?? void 0,
3050
+ error: reason,
3051
+ details: {
3052
+ state: this.state,
3053
+ errorName: error instanceof Error ? error.name : typeof error
3054
+ }
3055
+ });
2608
3056
  this.sendError("SYNC_ERROR", reason, true);
2609
3057
  this.close(reason);
2610
3058
  }
@@ -2643,6 +3091,15 @@ var ClientSession = class {
2643
3091
  this.syncQuerySubsets = [];
2644
3092
  }
2645
3093
  this.resumeDeltaCursor = msg.deltaCursor ? (0, import_sync3.decodeDeltaCursor)(msg.deltaCursor) : null;
3094
+ this.clientDeliveryWatermark = msg.lastDeliverySequence ?? null;
3095
+ const clientUsesWatermark = this.clientDeliveryWatermark !== null;
3096
+ let serverMaxDelivery = 0;
3097
+ if (clientUsesWatermark) {
3098
+ serverMaxDelivery = await this.store.getMaxDeliverySequence();
3099
+ if (this.clientDeliveryWatermark !== null && this.clientDeliveryWatermark > serverMaxDelivery) {
3100
+ this.clientDeliveryWatermark = 0;
3101
+ }
3102
+ }
2646
3103
  const serverVector = this.store.getVersionVector();
2647
3104
  const selectedWireFormat = selectWireFormat(msg.supportedWireFormats);
2648
3105
  this.setSerializerWireFormat(selectedWireFormat);
@@ -2673,6 +3130,10 @@ var ClientSession = class {
2673
3130
  accepted: true,
2674
3131
  selectedWireFormat,
2675
3132
  serverTime: Date.now(),
3133
+ // The server's highest delivery sequence, so a client whose persisted watermark
3134
+ // is ahead of it (server rolled back) can reset to a full resync. Sent only to
3135
+ // watermark-using clients.
3136
+ ...clientUsesWatermark ? { serverMaxDeliverySequence: serverMaxDelivery } : {},
2676
3137
  // Advertise central blob storage so the client uploads the bytes behind
2677
3138
  // its blob fields, keeping them available after the author goes offline.
2678
3139
  ...this.persistBlobChunk ? { blobStorageEnabled: true } : {},
@@ -2683,16 +3144,28 @@ var ClientSession = class {
2683
3144
  this.sendToClient(response);
2684
3145
  this.emitter?.emit({ type: "sync:connected", nodeId: msg.nodeId });
2685
3146
  this.state = "syncing";
2686
- const clientVector = (0, import_sync3.wireToVersionVector)(msg.versionVector);
2687
- await this.sendDelta(clientVector);
3147
+ if (this.clientDeliveryWatermark !== null) {
3148
+ this.lastAckedDeliverySeq = this.clientDeliveryWatermark;
3149
+ const excludeOwn = this.clientDeliveryWatermark > 0 ? this.clientNodeId ?? void 0 : void 0;
3150
+ await this.sendDeliveryStream(this.clientDeliveryWatermark, true, excludeOwn);
3151
+ } else {
3152
+ const clientVector = (0, import_sync3.wireToVersionVector)(msg.versionVector);
3153
+ await this.sendDelta(clientVector);
3154
+ }
2688
3155
  this.state = "streaming";
3156
+ if (this.takeOrphanedRelays && this.clientNodeId) {
3157
+ const buffered = this.takeOrphanedRelays(this.clientNodeId);
3158
+ if (buffered.length > 0) {
3159
+ this.relayOperations(buffered);
3160
+ }
3161
+ }
2689
3162
  }
2690
3163
  async handleOperationBatch(msg) {
2691
3164
  const operations2 = msg.operations.map((s) => this.serializer.decodeOperation(s));
2692
3165
  const applied = [];
2693
3166
  const rejected = [];
2694
3167
  for (const op of operations2) {
2695
- if (!this.operationVisibleToClient(op)) {
3168
+ if (!await this.operationVisibleToClient(op)) {
2696
3169
  rejected.push(op);
2697
3170
  continue;
2698
3171
  }
@@ -2746,20 +3219,17 @@ var ClientSession = class {
2746
3219
  continue;
2747
3220
  }
2748
3221
  }
2749
- try {
2750
- const applyResult = await applyServerOperation(this.store, op);
2751
- if (applyResult.rejection) {
2752
- this.sendError(
2753
- applyResult.rejection.code,
2754
- applyResult.rejection.message,
2755
- applyResult.rejection.retriable
2756
- );
2757
- continue;
2758
- }
2759
- if (applyResult.result === "applied") {
2760
- applied.push(...applyResult.appliedOperations);
2761
- }
2762
- } catch {
3222
+ const applyResult = await applyServerOperation(this.store, op);
3223
+ if (applyResult.rejection) {
3224
+ this.sendError(
3225
+ applyResult.rejection.code,
3226
+ applyResult.rejection.message,
3227
+ applyResult.rejection.retriable
3228
+ );
3229
+ continue;
3230
+ }
3231
+ if (applyResult.result === "applied") {
3232
+ applied.push(...applyResult.appliedOperations);
2763
3233
  }
2764
3234
  }
2765
3235
  if (rejected.length > 0) {
@@ -2797,8 +3267,11 @@ var ClientSession = class {
2797
3267
  const clientSeq = clientVector.get(nodeId) ?? 0;
2798
3268
  if (serverSeq > clientSeq) {
2799
3269
  const ops = await this.store.getOperationRange(nodeId, clientSeq + 1, serverSeq);
2800
- const visible = ops.filter((op) => this.operationVisibleToClient(op));
2801
- missing.push(...visible);
3270
+ for (const op of ops) {
3271
+ if (await this.operationVisibleToClient(op)) {
3272
+ missing.push(op);
3273
+ }
3274
+ }
2802
3275
  }
2803
3276
  }
2804
3277
  if (missing.length === 0) {
@@ -2850,11 +3323,111 @@ var ClientSession = class {
2850
3323
  });
2851
3324
  }
2852
3325
  }
2853
- operationVisibleToClient(op) {
2854
- if (!operationMatchesScopes(op, this.authContext?.scopes)) {
3326
+ /**
3327
+ * Send the gap-free server->client delivery stream, resuming just after
3328
+ * `fromDeliverySeq`. Operations are scanned in server delivery-sequence order
3329
+ * (commit order), scope-filtered for this session, and sent in batches that chain:
3330
+ * each batch's `baseDeliverySequence` equals the previous batch's
3331
+ * `maxDeliverySequence` (the first batch bases on `fromDeliverySeq`). The client
3332
+ * applies a batch only when its watermark equals the base and advances the
3333
+ * watermark to the max, so a dropped batch stalls the watermark and is recovered by
3334
+ * the next handshake resend. Because delivery-sequence order respects causal order
3335
+ * (a dependency is always committed, and thus sequenced, before its dependent), no
3336
+ * topological sort is needed and the client never defers on a missing dependency
3337
+ * that is itself in this stream.
3338
+ *
3339
+ * The final batch advances the watermark to the highest delivery sequence scanned,
3340
+ * not merely the last in-scope one, so an out-of-scope tail is not re-scanned on the
3341
+ * next reconnect. Returns the number of operations actually sent.
3342
+ */
3343
+ async sendDeliveryStream(fromDeliverySeq, finalizeWhenEmpty, excludeNodeId) {
3344
+ const scanChunk = Math.max(this.batchSize, 1) * 5;
3345
+ let scanCursor = fromDeliverySeq;
3346
+ let maxScanned = fromDeliverySeq;
3347
+ const visible = [];
3348
+ while (true) {
3349
+ const chunk = await this.store.getOperationsAfterDelivery(scanCursor, scanChunk);
3350
+ const last = chunk[chunk.length - 1];
3351
+ if (last === void 0) break;
3352
+ scanCursor = last.deliverySequence;
3353
+ maxScanned = scanCursor;
3354
+ for (const delivered of chunk) {
3355
+ if (excludeNodeId !== void 0 && delivered.operation.nodeId === excludeNodeId) {
3356
+ continue;
3357
+ }
3358
+ if (await this.operationVisibleToClient(delivered.operation)) {
3359
+ visible.push(delivered);
3360
+ }
3361
+ }
3362
+ if (chunk.length < scanChunk) break;
3363
+ }
3364
+ if (visible.length === 0) {
3365
+ if (finalizeWhenEmpty) {
3366
+ this.sendDeliveryBatch([], fromDeliverySeq, maxScanned, 0, true);
3367
+ }
3368
+ return 0;
3369
+ }
3370
+ const totalBatches = Math.ceil(visible.length / this.batchSize);
3371
+ let base = fromDeliverySeq;
3372
+ for (let i = 0; i < totalBatches; i++) {
3373
+ const slice = visible.slice(i * this.batchSize, (i + 1) * this.batchSize);
3374
+ const lastInSlice = slice[slice.length - 1];
3375
+ if (lastInSlice === void 0) continue;
3376
+ const isFinal = i === totalBatches - 1;
3377
+ const lastSeq = lastInSlice.deliverySequence;
3378
+ const max = isFinal ? Math.max(maxScanned, lastSeq) : lastSeq;
3379
+ this.sendDeliveryBatch(slice, base, max, i, isFinal);
3380
+ base = max;
3381
+ }
3382
+ return visible.length;
3383
+ }
3384
+ /** Build, track, and send one chained delivery-stream batch. */
3385
+ sendDeliveryBatch(slice, base, max, batchIndex, isFinal) {
3386
+ const batchMsg = {
3387
+ type: "operation-batch",
3388
+ messageId: (0, import_core10.generateUUIDv7)(),
3389
+ operations: slice.map((delivered) => this.serializer.encodeOperation(delivered.operation)),
3390
+ isFinal,
3391
+ batchIndex,
3392
+ baseDeliverySequence: base,
3393
+ maxDeliverySequence: max
3394
+ };
3395
+ this.sendToClient(batchMsg);
3396
+ if (slice.length > 0) {
3397
+ this.emitter?.emit({
3398
+ type: "sync:sent",
3399
+ operations: slice.map((delivered) => delivered.operation),
3400
+ batchSize: slice.length
3401
+ });
3402
+ }
3403
+ }
3404
+ async operationVisibleToClient(op) {
3405
+ const scopes = this.authContext?.scopes;
3406
+ const subsets = this.syncQuerySubsets;
3407
+ const needsBackfill = missingScopeFields(op, scopes).length > 0 || subsets !== void 0 && subsets.length > 0;
3408
+ const fullRecord = needsBackfill ? await this.lookupRecordFields(op.collection, op.recordId) : void 0;
3409
+ if (!operationMatchesScopes(op, scopes, fullRecord)) {
2855
3410
  return false;
2856
3411
  }
2857
- return (0, import_sync3.operationMatchesQuerySubsets)(op, this.syncQuerySubsets);
3412
+ return (0, import_sync3.operationMatchesQuerySubsets)(op, subsets, fullRecord);
3413
+ }
3414
+ /**
3415
+ * Read a record's current field values from the materialized store for scope /
3416
+ * query-subset backfill. Includes soft-deleted rows so a relayed delete (whose op
3417
+ * carries no fields) is still judged against the record's actual scope. Returns
3418
+ * undefined when the record cannot be read (never materialized, or no schema).
3419
+ */
3420
+ async lookupRecordFields(collection, recordId) {
3421
+ try {
3422
+ const rows = await this.store.queryCollection(collection, {
3423
+ where: { id: recordId },
3424
+ includeDeleted: true,
3425
+ limit: 1
3426
+ });
3427
+ return rows[0];
3428
+ } catch {
3429
+ return void 0;
3430
+ }
2858
3431
  }
2859
3432
  handleAwarenessUpdate(msg) {
2860
3433
  this.onAwarenessUpdate?.(this.sessionId, msg);
@@ -2899,6 +3472,7 @@ var ClientSession = class {
2899
3472
  handleTransportClose() {
2900
3473
  if (this.state === "closed") return;
2901
3474
  this.state = "closed";
3475
+ this.flushOrphanedRelays();
2902
3476
  this.emitter?.emit({ type: "sync:disconnected", reason: "transport closed" });
2903
3477
  this.onClose?.(this.sessionId);
2904
3478
  }
@@ -3349,9 +3923,9 @@ function pickFields(record, keys) {
3349
3923
  }
3350
3924
  return picked;
3351
3925
  }
3352
- async function buildRouteOperation(store, mutation) {
3926
+ async function buildRouteOperation(store, mutation, currentOverride, clockOverride) {
3353
3927
  const nodeId = store.getNodeId();
3354
- const clock = new import_core12.HybridLogicalClock(nodeId);
3928
+ const clock = clockOverride ?? new import_core12.HybridLogicalClock(nodeId);
3355
3929
  const schemaVersion = store.getSchema()?.version ?? 1;
3356
3930
  const sequenceNumber = nextServerSequenceNumber(store);
3357
3931
  if (mutation.type === "insert") {
@@ -3376,7 +3950,7 @@ async function buildRouteOperation(store, mutation) {
3376
3950
  `A ${mutation.type} mutation on "${mutation.collection}" requires a recordId.`
3377
3951
  );
3378
3952
  }
3379
- const current = await store.findRecord(mutation.collection, mutation.recordId);
3953
+ const current = currentOverride ? currentOverride.record : await store.findRecord(mutation.collection, mutation.recordId);
3380
3954
  if (mutation.type === "update") {
3381
3955
  const rawData = mutation.data ?? {};
3382
3956
  const data = {};
@@ -3493,11 +4067,88 @@ function createRouteContext(server, store) {
3493
4067
  }
3494
4068
  return commitOperation(prepared.op, mutation.collection);
3495
4069
  }
4070
+ async function runConditionalViaStore(storeApplyConditional, spec, options) {
4071
+ const scope = options?.scope;
4072
+ const mutations = [];
4073
+ const hasTargetUpdate = spec.update !== void 0 && spec.update !== null;
4074
+ if (hasTargetUpdate) {
4075
+ mutations.push({
4076
+ collection: spec.collection,
4077
+ type: "update",
4078
+ recordId: spec.id,
4079
+ data: spec.update
4080
+ });
4081
+ }
4082
+ if (spec.also) {
4083
+ mutations.push(...spec.also);
4084
+ }
4085
+ let result;
4086
+ try {
4087
+ result = await storeApplyConditional({
4088
+ target: { collection: spec.collection, id: spec.id },
4089
+ ...spec.idempotencyKey ? { idempotencyKey: spec.idempotencyKey } : {},
4090
+ admit: (current) => spec.if && Object.keys(spec.if).length > 0 ? evaluateRoutePredicate(current, spec.if) : true,
4091
+ buildOperations: async (current, context) => {
4092
+ const ops = [];
4093
+ for (let i = 0; i < mutations.length; i++) {
4094
+ const mutation = mutations[i];
4095
+ const useLockedCurrent = i === 0 && hasTargetUpdate;
4096
+ const op = await buildRouteOperation(
4097
+ store,
4098
+ mutation,
4099
+ useLockedCurrent ? { record: current } : void 0,
4100
+ context.clock
4101
+ );
4102
+ if (scope && !operationMatchesScopes(op, scope)) {
4103
+ throw new RouteMutationError(
4104
+ "SCOPE_VIOLATION",
4105
+ `Mutation on "${mutation.collection}" is outside the provided scope.`
4106
+ );
4107
+ }
4108
+ ops.push(op);
4109
+ }
4110
+ return ops;
4111
+ }
4112
+ });
4113
+ } catch (error) {
4114
+ if (error instanceof RouteMutationError) {
4115
+ return {
4116
+ ok: false,
4117
+ code: error.code,
4118
+ message: error.message,
4119
+ retriable: isRetriableRejection(error.code)
4120
+ };
4121
+ }
4122
+ throw error;
4123
+ }
4124
+ if (!result.admitted) {
4125
+ return {
4126
+ ok: false,
4127
+ code: spec.reject?.code ?? "CONDITION_NOT_MET",
4128
+ message: spec.reject?.message ?? `Admission condition not met on "${spec.collection}" record "${spec.id}".`,
4129
+ retriable: false
4130
+ };
4131
+ }
4132
+ if (result.idempotent) {
4133
+ const records2 = spec.idempotencyKey ? [await store.findRecord(spec.idempotencyKey.collection, spec.idempotencyKey.id)] : [];
4134
+ return { ok: true, idempotent: true, operations: [], records: records2 };
4135
+ }
4136
+ server.relayServerOperations(result.applied);
4137
+ const records = [];
4138
+ for (const op of result.applied) {
4139
+ records.push(await store.findRecord(op.collection, op.recordId));
4140
+ }
4141
+ return { ok: true, idempotent: false, operations: result.applied, records };
4142
+ }
3496
4143
  return {
3497
4144
  apply(mutation, options) {
3498
4145
  return serialize(() => rawApply(mutation, options?.scope));
3499
4146
  },
3500
4147
  applyConditional(spec, options) {
4148
+ const storeApplyConditional = store.applyConditional;
4149
+ if (storeApplyConditional) {
4150
+ return runConditionalViaStore(storeApplyConditional.bind(store), spec, options);
4151
+ }
3501
4152
  return serialize(async () => {
3502
4153
  if (spec.idempotencyKey) {
3503
4154
  const existing = await store.findRecord(
@@ -3585,7 +4236,8 @@ var DEFAULT_BATCH_SIZE2 = 100;
3585
4236
  var DEFAULT_SCHEMA_VERSION2 = 1;
3586
4237
  var DEFAULT_HOST = "0.0.0.0";
3587
4238
  var DEFAULT_PATH = "/";
3588
- var KoraSyncServer = class {
4239
+ var RELAY_RETRANSMIT_INTERVAL_MS = 2e3;
4240
+ var KoraSyncServer = class _KoraSyncServer {
3589
4241
  store;
3590
4242
  auth;
3591
4243
  serializer;
@@ -3615,6 +4267,20 @@ var KoraSyncServer = class {
3615
4267
  serverVersion = "1.0.0-beta.0";
3616
4268
  wsServer = null;
3617
4269
  running = false;
4270
+ /**
4271
+ * Periodic tick that retransmits relay batches connected clients have not
4272
+ * acknowledged, so a relay dropped by a lossy transport is redelivered instead of
4273
+ * leaving that client with a permanent version-vector gap (a lost operation).
4274
+ */
4275
+ relayRetransmitTimer = null;
4276
+ /**
4277
+ * Relay operations a client never acknowledged before it disconnected, buffered by
4278
+ * client node id so they can be redelivered on its next connection. Bounded per
4279
+ * node and expired by age so a client that never returns cannot grow this forever.
4280
+ */
4281
+ orphanedRelaysByNode = /* @__PURE__ */ new Map();
4282
+ static MAX_ORPHANED_RELAY_OPS_PER_NODE = 5e3;
4283
+ static ORPHANED_RELAY_TTL_MS = 5 * 6e4;
3618
4284
  constructor(config) {
3619
4285
  this.store = config.store;
3620
4286
  this.auth = config.auth ?? null;
@@ -3642,6 +4308,57 @@ var KoraSyncServer = class {
3642
4308
  if (!this.emitter) {
3643
4309
  this.emitter = new import_internal2.SimpleEventEmitter();
3644
4310
  }
4311
+ this.relayRetransmitTimer = setInterval(() => {
4312
+ this.retransmitPendingRelays(RELAY_RETRANSMIT_INTERVAL_MS);
4313
+ }, RELAY_RETRANSMIT_INTERVAL_MS);
4314
+ this.relayRetransmitTimer.unref?.();
4315
+ }
4316
+ /**
4317
+ * Retransmit relay batches that connected clients have not acknowledged within
4318
+ * `staleMs`. Called on a periodic tick and directly by tests. Redelivering an
4319
+ * already-applied operation is harmless (clients dedup by content-addressed id).
4320
+ */
4321
+ retransmitPendingRelays(staleMs = 0) {
4322
+ for (const session of this.sessions.values()) {
4323
+ session.retransmitPendingRelays(staleMs);
4324
+ }
4325
+ this.expireOrphanedRelays();
4326
+ }
4327
+ /**
4328
+ * Buffer relay operations a disconnected client never acknowledged, keyed by its
4329
+ * node id, deduped by operation id and bounded by count. Redelivered when the
4330
+ * client reconnects (see {@link takeOrphanedRelays}).
4331
+ */
4332
+ bufferOrphanedRelays(nodeId, ops) {
4333
+ if (ops.length === 0) return;
4334
+ const existing = this.orphanedRelaysByNode.get(nodeId);
4335
+ const merged = existing ? existing.ops : [];
4336
+ const seen = new Set(merged.map((op) => op.id));
4337
+ for (const op of ops) {
4338
+ if (!seen.has(op.id)) {
4339
+ merged.push(op);
4340
+ seen.add(op.id);
4341
+ }
4342
+ }
4343
+ const bounded = merged.length > _KoraSyncServer.MAX_ORPHANED_RELAY_OPS_PER_NODE ? merged.slice(merged.length - _KoraSyncServer.MAX_ORPHANED_RELAY_OPS_PER_NODE) : merged;
4344
+ this.orphanedRelaysByNode.set(nodeId, { ops: bounded, bufferedAtMs: Date.now() });
4345
+ }
4346
+ /** Remove and return a node's buffered orphaned relay operations, if any. */
4347
+ takeOrphanedRelays(nodeId) {
4348
+ const entry = this.orphanedRelaysByNode.get(nodeId);
4349
+ if (!entry) return [];
4350
+ this.orphanedRelaysByNode.delete(nodeId);
4351
+ return entry.ops;
4352
+ }
4353
+ /** Drop buffered orphaned relays older than the TTL (client never returned). */
4354
+ expireOrphanedRelays() {
4355
+ if (this.orphanedRelaysByNode.size === 0) return;
4356
+ const cutoff = Date.now() - _KoraSyncServer.ORPHANED_RELAY_TTL_MS;
4357
+ for (const [nodeId, entry] of this.orphanedRelaysByNode) {
4358
+ if (entry.bufferedAtMs <= cutoff) {
4359
+ this.orphanedRelaysByNode.delete(nodeId);
4360
+ }
4361
+ }
3645
4362
  }
3646
4363
  /**
3647
4364
  * The trusted, scoped data-plane context (`apply` / `query` / `findById`).
@@ -3768,6 +4485,11 @@ var KoraSyncServer = class {
3768
4485
  event: "server.stopping",
3769
4486
  details: { connectedClients: this.sessions.size }
3770
4487
  });
4488
+ if (this.relayRetransmitTimer) {
4489
+ clearInterval(this.relayRetransmitTimer);
4490
+ this.relayRetransmitTimer = null;
4491
+ }
4492
+ this.orphanedRelaysByNode.clear();
3771
4493
  this.awarenessRelay.clear();
3772
4494
  this.yjsDocRelay.clear();
3773
4495
  this.blobChunkRelay.clear();
@@ -3903,6 +4625,7 @@ var KoraSyncServer = class {
3903
4625
  auth: this.auth ?? void 0,
3904
4626
  serializer: this.serializer,
3905
4627
  emitter: sessionEmitter,
4628
+ logger: this.logger,
3906
4629
  batchSize: this.batchSize,
3907
4630
  schemaVersion: this.schemaVersion,
3908
4631
  supportedSchemaVersions: this.supportedSchemaVersions,
@@ -3929,7 +4652,11 @@ var KoraSyncServer = class {
3929
4652
  ...this.validateOperation ? { validateOperation: this.validateOperation, koraContext: this.koraContext } : {},
3930
4653
  onClose: (sid) => {
3931
4654
  this.handleSessionClose(sid);
3932
- }
4655
+ },
4656
+ onOrphanedRelays: (nodeId, ops) => {
4657
+ this.bufferOrphanedRelays(nodeId, ops);
4658
+ },
4659
+ takeOrphanedRelays: (nodeId) => this.takeOrphanedRelays(nodeId)
3933
4660
  });
3934
4661
  this.sessions.set(sessionId, session);
3935
4662
  this.yjsDocRelay.addClient(sessionId, transport);
@@ -3989,6 +4716,22 @@ var KoraSyncServer = class {
3989
4716
  }
3990
4717
  return result;
3991
4718
  }
4719
+ /**
4720
+ * Relay already-applied, server-originated operations to every connected client.
4721
+ * Used by the conditional route apply, which commits its operations atomically
4722
+ * through the store (bypassing {@link applyLocalOperation}) and then fans them
4723
+ * out. Each session still enforces its own per-scope visibility filter.
4724
+ *
4725
+ * @param operations - Operations that have already been committed to the store
4726
+ */
4727
+ relayServerOperations(operations2) {
4728
+ if (operations2.length === 0) {
4729
+ return;
4730
+ }
4731
+ for (const session of this.sessions.values()) {
4732
+ session.relayOperations(operations2);
4733
+ }
4734
+ }
3992
4735
  /**
3993
4736
  * Collect every blob reference still reachable from live records on the server.
3994
4737
  *
@@ -4211,6 +4954,7 @@ function createProductionServer(config) {
4211
4954
  const port = config.port ?? (Number(process.env.PORT) || 3001);
4212
4955
  const staticDir = config.staticDir ?? "./dist";
4213
4956
  const syncPath = config.syncPath ?? "/kora-sync";
4957
+ const crossOriginEmbedderPolicy = config.crossOriginEmbedderPolicy ?? "credentialless";
4214
4958
  const syncServer = new KoraSyncServer({
4215
4959
  store: config.store,
4216
4960
  enableDashboard: true,
@@ -4381,7 +5125,7 @@ function createProductionServer(config) {
4381
5125
  });
4382
5126
  async function handleRequest(req, res) {
4383
5127
  res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
4384
- res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
5128
+ res.setHeader("Cross-Origin-Embedder-Policy", crossOriginEmbedderPolicy);
4385
5129
  const url = new URL(req.url || "/", `http://${req.headers.host}`);
4386
5130
  if (url.pathname === "/health") {
4387
5131
  const status = await syncServer.getStatus();