@korajs/server 1.0.0-beta.1 → 1.0.0-beta.10

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
  };
@@ -176,6 +176,7 @@ __export(index_exports, {
176
176
  MixedAuthProvider: () => MixedAuthProvider,
177
177
  NoAuthProvider: () => NoAuthProvider,
178
178
  PostgresServerStore: () => PostgresServerStore,
179
+ RETRIABLE_REJECTION_CODES: () => RETRIABLE_REJECTION_CODES,
179
180
  SqliteServerStore: () => SqliteServerStore,
180
181
  TokenAuthProvider: () => TokenAuthProvider,
181
182
  WsServerTransport: () => WsServerTransport,
@@ -186,7 +187,8 @@ __export(index_exports, {
186
187
  createPrettyLogger: () => createPrettyLogger,
187
188
  createProductionServer: () => createProductionServer,
188
189
  createSilentLogger: () => createSilentLogger,
189
- createSqliteServerStore: () => createSqliteServerStore
190
+ createSqliteServerStore: () => createSqliteServerStore,
191
+ isRetriableRejection: () => isRetriableRejection
190
192
  });
191
193
  module.exports = __toCommonJS(index_exports);
192
194
  init_cjs_shims();
@@ -248,6 +250,13 @@ function createDefaultLogger() {
248
250
  }
249
251
  }
250
252
 
253
+ // src/apply/rejection-taxonomy.ts
254
+ init_cjs_shims();
255
+ var RETRIABLE_REJECTION_CODES = /* @__PURE__ */ new Set(["RATE_LIMIT"]);
256
+ function isRetriableRejection(code) {
257
+ return RETRIABLE_REJECTION_CODES.has(code);
258
+ }
259
+
251
260
  // src/store/memory-server-store.ts
252
261
  init_cjs_shims();
253
262
  var import_core2 = require("@korajs/core");
@@ -269,6 +278,14 @@ function fieldTypeToSql(descriptor, dialect) {
269
278
  return dialect === "postgres" ? "BIGINT" : "INTEGER";
270
279
  case "array":
271
280
  return dialect === "postgres" ? "JSONB" : "TEXT";
281
+ case "object":
282
+ return dialect === "postgres" ? "JSONB" : "TEXT";
283
+ case "json":
284
+ return dialect === "postgres" ? "JSONB" : "TEXT";
285
+ case "blob":
286
+ return "TEXT";
287
+ case "secret":
288
+ return "TEXT";
272
289
  case "richtext":
273
290
  return dialect === "postgres" ? "BYTEA" : "BLOB";
274
291
  }
@@ -285,13 +302,13 @@ function generateCollectionDDL(name, collection, dialect) {
285
302
  const columns = ["id TEXT PRIMARY KEY NOT NULL"];
286
303
  for (const [fieldName, descriptor] of Object.entries(collection.fields)) {
287
304
  const sqlType = fieldTypeToSql(descriptor, dialect);
288
- let colDef = `${fieldName} ${sqlType}`;
305
+ let colDef = `${(0, import_core.quoteIdent)(fieldName)} ${sqlType}`;
289
306
  if (descriptor.defaultValue !== void 0) {
290
307
  colDef += ` DEFAULT ${sqlDefaultLiteral(descriptor.defaultValue)}`;
291
308
  }
292
309
  if (descriptor.kind === "enum" && descriptor.enumValues) {
293
310
  const values = descriptor.enumValues.map((v) => `'${v}'`).join(", ");
294
- colDef += ` CHECK (${fieldName} IN (${values}))`;
311
+ colDef += ` CHECK (${(0, import_core.quoteIdent)(fieldName)} IN (${values}))`;
295
312
  }
296
313
  columns.push(colDef);
297
314
  }
@@ -299,24 +316,26 @@ function generateCollectionDDL(name, collection, dialect) {
299
316
  columns.push(`_created_at ${tsType} NOT NULL DEFAULT 0`);
300
317
  columns.push(`_updated_at ${tsType} NOT NULL DEFAULT 0`);
301
318
  columns.push("_deleted INTEGER NOT NULL DEFAULT 0");
302
- statements.push(`CREATE TABLE IF NOT EXISTS ${name} (
319
+ statements.push(`CREATE TABLE IF NOT EXISTS ${(0, import_core.quoteIdent)(name)} (
303
320
  ${columns.join(",\n ")}
304
321
  )`);
305
322
  for (const [fieldName, descriptor] of Object.entries(collection.fields)) {
306
323
  const sqlType = fieldTypeToSql(descriptor, dialect);
307
- let colDef = `${fieldName} ${sqlType}`;
324
+ let colDef = `${(0, import_core.quoteIdent)(fieldName)} ${sqlType}`;
308
325
  if (descriptor.defaultValue !== void 0) {
309
326
  colDef += ` DEFAULT ${sqlDefaultLiteral(descriptor.defaultValue)}`;
310
327
  }
311
328
  statements.push(`--kora:safe-alter
312
- ALTER TABLE ${name} ADD COLUMN ${colDef}`);
329
+ ALTER TABLE ${(0, import_core.quoteIdent)(name)} ADD COLUMN ${colDef}`);
313
330
  }
314
331
  for (const indexField of collection.indexes) {
315
332
  statements.push(
316
- `CREATE INDEX IF NOT EXISTS idx_${name}_${indexField} ON ${name} (${indexField})`
333
+ `CREATE INDEX IF NOT EXISTS idx_${name}_${indexField} ON ${(0, import_core.quoteIdent)(name)} (${(0, import_core.quoteIdent)(indexField)})`
317
334
  );
318
335
  }
319
- statements.push(`CREATE INDEX IF NOT EXISTS idx_${name}__deleted ON ${name} (_deleted)`);
336
+ statements.push(
337
+ `CREATE INDEX IF NOT EXISTS idx_${name}__deleted ON ${(0, import_core.quoteIdent)(name)} (_deleted)`
338
+ );
320
339
  return statements;
321
340
  }
322
341
  function generateAllCollectionDDL(schema, dialect) {
@@ -326,30 +345,6 @@ function generateAllCollectionDDL(schema, dialect) {
326
345
  }
327
346
  return statements;
328
347
  }
329
- function replayOperationsForRecord(ops) {
330
- let record = null;
331
- let deleted = false;
332
- for (const op of ops) {
333
- switch (op.type) {
334
- case "insert":
335
- if (op.data) {
336
- record = { ...op.data };
337
- deleted = false;
338
- }
339
- break;
340
- case "update":
341
- if (op.data) {
342
- record = { ...record ?? {}, ...op.data };
343
- deleted = false;
344
- }
345
- break;
346
- case "delete":
347
- deleted = true;
348
- break;
349
- }
350
- }
351
- return deleted ? null : record;
352
- }
353
348
  var BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
354
349
  var BASE64_REVERSE = new Map(
355
350
  Array.from(BASE64_ALPHABET, (char, index3) => [char, index3])
@@ -400,6 +395,9 @@ function serializeFieldValue(value, descriptor) {
400
395
  if (value === null || value === void 0) return null;
401
396
  switch (descriptor.kind) {
402
397
  case "array":
398
+ case "object":
399
+ case "json":
400
+ case "blob":
403
401
  return typeof value === "string" ? value : JSON.stringify(value);
404
402
  case "boolean":
405
403
  return value ? 1 : 0;
@@ -413,6 +411,9 @@ function deserializeFieldValue(value, descriptor) {
413
411
  if (value === null || value === void 0) return null;
414
412
  switch (descriptor.kind) {
415
413
  case "array":
414
+ case "object":
415
+ case "json":
416
+ case "blob":
416
417
  return typeof value === "string" ? JSON.parse(value) : value;
417
418
  case "boolean":
418
419
  return value === 1 || value === true;
@@ -445,6 +446,14 @@ var MemoryServerStore = class {
445
446
  operations = [];
446
447
  operationIndex = /* @__PURE__ */ new Map();
447
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;
448
457
  schema = null;
449
458
  /** Materialized records: collection -> recordId -> record data */
450
459
  materializedRecords = /* @__PURE__ */ new Map();
@@ -478,6 +487,8 @@ var MemoryServerStore = class {
478
487
  }
479
488
  this.operations.push(op);
480
489
  this.operationIndex.set(op.id, op);
490
+ this.deliverySeqCounter += 1;
491
+ this.deliverySeqByOpId.set(op.id, this.deliverySeqCounter);
481
492
  const currentSeq = this.versionVector.get(op.nodeId) ?? 0;
482
493
  if (op.sequenceNumber > currentSeq) {
483
494
  this.versionVector.set(op.nodeId, op.sequenceNumber);
@@ -497,6 +508,23 @@ var MemoryServerStore = class {
497
508
  this.assertOpen();
498
509
  return this.operations.length;
499
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
+ }
500
528
  async materializeCollection(collection) {
501
529
  this.assertOpen();
502
530
  if (this.schema?.collections[collection]) {
@@ -641,12 +669,16 @@ var MemoryServerStore = class {
641
669
  this.operations.length = 0;
642
670
  this.operationIndex.clear();
643
671
  this.versionVector.clear();
672
+ this.deliverySeqByOpId.clear();
673
+ this.deliverySeqCounter = 0;
644
674
  for (const [nid, seq] of versionVector) {
645
675
  this.versionVector.set(nid, seq);
646
676
  }
647
677
  for (const op of operations2) {
648
678
  this.operations.push(op);
649
679
  this.operationIndex.set(op.id, op);
680
+ this.deliverySeqCounter += 1;
681
+ this.deliverySeqByOpId.set(op.id, this.deliverySeqCounter);
650
682
  if (this.schema?.collections[op.collection]) {
651
683
  this.rebuildMaterializedRecord(op.collection, op.recordId);
652
684
  }
@@ -676,13 +708,14 @@ var MemoryServerStore = class {
676
708
  return a.timestamp.wallTime - b.timestamp.wallTime;
677
709
  if (a.timestamp.logical !== b.timestamp.logical)
678
710
  return a.timestamp.logical - b.timestamp.logical;
679
- return a.sequenceNumber - b.sequenceNumber;
711
+ return a.timestamp.nodeId < b.timestamp.nodeId ? -1 : a.timestamp.nodeId > b.timestamp.nodeId ? 1 : 0;
680
712
  });
681
713
  const parsedOps = recordOps.map((op) => ({
682
714
  type: op.type,
683
- data: op.data
715
+ data: op.data,
716
+ atomicOps: op.atomicOps ?? null
684
717
  }));
685
- const recordData = replayOperationsForRecord(parsedOps);
718
+ const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
686
719
  if (recordData) {
687
720
  const createdAt = recordOps.length > 0 ? recordOps[0].timestamp.wallTime : Date.now();
688
721
  const updatedAt = recordOps.length > 0 ? recordOps[recordOps.length - 1].timestamp.wallTime : Date.now();
@@ -805,6 +838,8 @@ var pgOperations = (0, import_pg_core.pgTable)(
805
838
  // JSON-serialized, null for deletes
806
839
  previousData: (0, import_pg_core.text)("previous_data"),
807
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
808
843
  wallTime: (0, import_pg_core.bigint)("wall_time", { mode: "number" }).notNull(),
809
844
  logical: (0, import_pg_core.integer)("logical").notNull(),
810
845
  timestampNodeId: (0, import_pg_core.text)("timestamp_node_id").notNull(),
@@ -812,12 +847,18 @@ var pgOperations = (0, import_pg_core.pgTable)(
812
847
  causalDeps: (0, import_pg_core.text)("causal_deps").notNull().default("[]"),
813
848
  // JSON array of op IDs
814
849
  schemaVersion: (0, import_pg_core.integer)("schema_version").notNull(),
815
- 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" })
816
856
  },
817
857
  (table) => ({
818
858
  nodeSeqIdx: (0, import_pg_core.index)("idx_pg_node_seq").on(table.nodeId, table.sequenceNumber),
819
859
  collectionIdx: (0, import_pg_core.index)("idx_pg_collection").on(table.collection),
820
- 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)
821
862
  })
822
863
  );
823
864
  var pgSyncState = (0, import_pg_core.pgTable)("sync_state", {
@@ -834,15 +875,43 @@ var PostgresServerStore = class {
834
875
  ready;
835
876
  schema = null;
836
877
  closed = false;
837
- 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) {
838
894
  this.db = db;
839
895
  this.nodeId = nodeId ?? (0, import_core3.generateUUIDv7)();
896
+ this.timeSource = timeSource ?? { now: () => Date.now() };
840
897
  this.ready = this.initialize();
841
898
  }
842
899
  getVersionVector() {
843
900
  this.assertOpen();
844
901
  return new Map(this.versionVector);
845
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
+ }
846
915
  getNodeId() {
847
916
  return this.nodeId;
848
917
  }
@@ -880,8 +949,9 @@ var PostgresServerStore = class {
880
949
  return "duplicate";
881
950
  }
882
951
  const now = Date.now();
883
- const row = this.serializeOperation(op, now);
884
952
  await this.db.transaction(async (tx) => {
953
+ const deliverySeq = await this.nextDeliverySeq(tx);
954
+ const row = this.serializeOperation(op, now, deliverySeq);
885
955
  await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
886
956
  await tx.insert(pgSyncState).values({
887
957
  nodeId: op.nodeId,
@@ -904,6 +974,73 @@ var PostgresServerStore = class {
904
974
  }
905
975
  return "applied";
906
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
+ }
907
1044
  async getOperationRange(nodeId, fromSeq, toSeq) {
908
1045
  this.assertOpen();
909
1046
  await this.ready;
@@ -918,6 +1055,23 @@ var PostgresServerStore = class {
918
1055
  const result = await this.db.select({ value: (0, import_drizzle_orm.count)() }).from(pgOperations);
919
1056
  return result[0]?.value ?? 0;
920
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
+ }
921
1075
  async materializeCollection(collection) {
922
1076
  this.assertOpen();
923
1077
  await this.ready;
@@ -952,7 +1106,7 @@ var PostgresServerStore = class {
952
1106
  this.assertCollection(collection);
953
1107
  const schema = this.schema;
954
1108
  const collectionDef = schema.collections[collection];
955
- const query = import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw(collection)} WHERE id = ${id} AND _deleted = 0`;
1109
+ const query = import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(collection))} WHERE id = ${id} AND _deleted = 0`;
956
1110
  const rows = await this.db.execute(query);
957
1111
  if (rows.length === 0) return null;
958
1112
  return this.deserializeRow(rows[0], collectionDef);
@@ -969,7 +1123,7 @@ var PostgresServerStore = class {
969
1123
  }
970
1124
  }
971
1125
  const whereClause = this.buildWhereClause(where ?? {}, false);
972
- const query = import_drizzle_orm.sql`SELECT COUNT(*) as cnt FROM ${import_drizzle_orm.sql.raw(collection)} WHERE ${whereClause}`;
1126
+ const query = import_drizzle_orm.sql`SELECT COUNT(*) as cnt FROM ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(collection))} WHERE ${whereClause}`;
973
1127
  const rows = await this.db.execute(query);
974
1128
  const cnt = rows[0]?.cnt;
975
1129
  return typeof cnt === "string" ? Number.parseInt(cnt, 10) : cnt ?? 0;
@@ -981,7 +1135,7 @@ var PostgresServerStore = class {
981
1135
  this.assertOpen();
982
1136
  await this.ready;
983
1137
  const { buildServerBackup: buildServerBackup2 } = await Promise.resolve().then(() => (init_server_backup(), server_backup_exports));
984
- 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));
985
1139
  const operations2 = rows.map((row) => this.deserializeOperation(row));
986
1140
  return buildServerBackup2(this.nodeId, operations2, this.versionVector);
987
1141
  }
@@ -996,6 +1150,7 @@ var PostgresServerStore = class {
996
1150
  const result = await this.applyRemoteOperation(op);
997
1151
  if (result === "applied") restored++;
998
1152
  }
1153
+ this.sequenceCounter = null;
999
1154
  return { operationsRestored: restored, success: true };
1000
1155
  }
1001
1156
  const now = Date.now();
@@ -1005,20 +1160,49 @@ var PostgresServerStore = class {
1005
1160
  for (const [nid, seq] of versionVector) {
1006
1161
  await tx.insert(pgSyncState).values({ nodeId: nid, maxSequenceNumber: seq, lastSeenAt: now }).onConflictDoNothing({ target: pgSyncState.nodeId });
1007
1162
  }
1163
+ let deliverySeq = 0;
1008
1164
  for (const op of operations2) {
1009
- const row = this.serializeOperation(op, now);
1165
+ deliverySeq += 1;
1166
+ const row = this.serializeOperation(op, now, deliverySeq);
1010
1167
  await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
1011
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
+ );
1012
1173
  });
1013
1174
  this.versionVector.clear();
1014
1175
  for (const [nid, seq] of versionVector) {
1015
1176
  this.versionVector.set(nid, seq);
1016
1177
  }
1178
+ this.sequenceCounter = null;
1017
1179
  return { operationsRestored: operations2.length, success: true };
1018
1180
  }
1019
1181
  // ---------------------------------------------------------------------------
1020
1182
  // Materialization internals
1021
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
+ }
1022
1206
  /**
1023
1207
  * Rebuild a single record in the materialized collection table by replaying
1024
1208
  * all operations for that record.
@@ -1029,17 +1213,19 @@ var PostgresServerStore = class {
1029
1213
  const ops = await txOrDb.select({
1030
1214
  type: pgOperations.type,
1031
1215
  data: pgOperations.data,
1216
+ atomicOps: pgOperations.atomicOps,
1032
1217
  wallTime: pgOperations.wallTime
1033
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(
1034
1219
  (0, import_drizzle_orm.asc)(pgOperations.wallTime),
1035
1220
  (0, import_drizzle_orm.asc)(pgOperations.logical),
1036
- (0, import_drizzle_orm.asc)(pgOperations.sequenceNumber)
1221
+ (0, import_drizzle_orm.asc)(pgOperations.timestampNodeId)
1037
1222
  );
1038
1223
  const parsedOps = ops.map((op) => ({
1039
1224
  type: op.type,
1040
- 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
1041
1227
  }));
1042
- const recordData = replayOperationsForRecord(parsedOps);
1228
+ const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
1043
1229
  const fieldNames = Object.keys(collectionDef.fields);
1044
1230
  if (recordData) {
1045
1231
  const createdAt = ops.length > 0 ? ops[0].wallTime : Date.now();
@@ -1056,7 +1242,7 @@ var PostgresServerStore = class {
1056
1242
  );
1057
1243
  } else {
1058
1244
  await txOrDb.execute(
1059
- import_drizzle_orm.sql`UPDATE ${import_drizzle_orm.sql.raw(collection)} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
1245
+ import_drizzle_orm.sql`UPDATE ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(collection))} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
1060
1246
  );
1061
1247
  }
1062
1248
  }
@@ -1075,16 +1261,16 @@ var PostgresServerStore = class {
1075
1261
  updatedAt,
1076
1262
  0
1077
1263
  ];
1078
- const columnsSql = import_drizzle_orm.sql.raw(allColumns.join(", "));
1264
+ const columnsSql = import_drizzle_orm.sql.raw(allColumns.map((c) => (0, import_core3.quoteIdent)(c)).join(", "));
1079
1265
  const valuesSql = import_drizzle_orm.sql.join(
1080
1266
  values.map((v) => import_drizzle_orm.sql`${v}`),
1081
1267
  import_drizzle_orm.sql.raw(", ")
1082
1268
  );
1083
1269
  const updateSet = import_drizzle_orm.sql.raw(
1084
- allColumns.slice(1).map((c) => `${c} = excluded.${c}`).join(", ")
1270
+ allColumns.slice(1).map((c) => `${(0, import_core3.quoteIdent)(c)} = excluded.${(0, import_core3.quoteIdent)(c)}`).join(", ")
1085
1271
  );
1086
1272
  await txOrDb.execute(
1087
- import_drizzle_orm.sql`INSERT INTO ${import_drizzle_orm.sql.raw(tableName)} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
1273
+ import_drizzle_orm.sql`INSERT INTO ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(tableName))} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
1088
1274
  );
1089
1275
  }
1090
1276
  /**
@@ -1106,11 +1292,12 @@ var PostgresServerStore = class {
1106
1292
  recordId: pgOperations.recordId,
1107
1293
  type: pgOperations.type,
1108
1294
  data: pgOperations.data,
1295
+ atomicOps: pgOperations.atomicOps,
1109
1296
  wallTime: pgOperations.wallTime
1110
1297
  }).from(pgOperations).where((0, import_drizzle_orm.eq)(pgOperations.collection, collectionName)).orderBy(
1111
1298
  (0, import_drizzle_orm.asc)(pgOperations.wallTime),
1112
1299
  (0, import_drizzle_orm.asc)(pgOperations.logical),
1113
- (0, import_drizzle_orm.asc)(pgOperations.sequenceNumber)
1300
+ (0, import_drizzle_orm.asc)(pgOperations.timestampNodeId)
1114
1301
  );
1115
1302
  if (allOps.length === 0) return;
1116
1303
  const grouped = /* @__PURE__ */ new Map();
@@ -1126,9 +1313,10 @@ var PostgresServerStore = class {
1126
1313
  for (const [recordId, recordOps] of grouped) {
1127
1314
  const parsedOps = recordOps.map((op) => ({
1128
1315
  type: op.type,
1129
- 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
1130
1318
  }));
1131
- const recordData = replayOperationsForRecord(parsedOps);
1319
+ const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
1132
1320
  if (recordData) {
1133
1321
  const createdAt = recordOps[0].wallTime;
1134
1322
  const updatedAt = recordOps[recordOps.length - 1].wallTime;
@@ -1144,7 +1332,7 @@ var PostgresServerStore = class {
1144
1332
  );
1145
1333
  } else {
1146
1334
  await this.db.execute(
1147
- import_drizzle_orm.sql`INSERT INTO ${import_drizzle_orm.sql.raw(collectionName)} (id, _deleted, _created_at, _updated_at) VALUES (${recordId}, 1, ${Date.now()}, ${Date.now()}) ON CONFLICT (id) DO UPDATE SET _deleted = 1, _updated_at = ${Date.now()}`
1335
+ import_drizzle_orm.sql`INSERT INTO ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(collectionName))} (id, _deleted, _created_at, _updated_at) VALUES (${recordId}, 1, ${Date.now()}, ${Date.now()}) ON CONFLICT (id) DO UPDATE SET _deleted = 1, _updated_at = ${Date.now()}`
1148
1336
  );
1149
1337
  }
1150
1338
  }
@@ -1157,10 +1345,12 @@ var PostgresServerStore = class {
1157
1345
  options?.where ?? {},
1158
1346
  options?.includeDeleted ?? false
1159
1347
  );
1160
- const parts = [import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw(collection)} WHERE ${whereClause}`];
1348
+ const parts = [
1349
+ import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(collection))} WHERE ${whereClause}`
1350
+ ];
1161
1351
  if (options?.orderBy) {
1162
1352
  const dir = options.orderDirection === "desc" ? "DESC" : "ASC";
1163
- parts.push(import_drizzle_orm.sql.raw(` ORDER BY ${options.orderBy} ${dir}`));
1353
+ parts.push(import_drizzle_orm.sql.raw(` ORDER BY ${(0, import_core3.quoteIdent)(options.orderBy)} ${dir}`));
1164
1354
  }
1165
1355
  if (options?.limit !== void 0) {
1166
1356
  parts.push(import_drizzle_orm.sql` LIMIT ${options.limit}`);
@@ -1176,7 +1366,7 @@ var PostgresServerStore = class {
1176
1366
  conditions.push(import_drizzle_orm.sql.raw("_deleted = 0"));
1177
1367
  }
1178
1368
  for (const [key, value] of Object.entries(where)) {
1179
- conditions.push(import_drizzle_orm.sql`${import_drizzle_orm.sql.raw(key)} = ${value}`);
1369
+ conditions.push(import_drizzle_orm.sql`${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(key))} = ${value}`);
1180
1370
  }
1181
1371
  if (conditions.length === 0) {
1182
1372
  return import_drizzle_orm.sql.raw("1 = 1");
@@ -1249,44 +1439,92 @@ var PostgresServerStore = class {
1249
1439
  }
1250
1440
  }
1251
1441
  async ensureTables() {
1252
- await this.db.execute(import_drizzle_orm.sql`
1253
- CREATE TABLE IF NOT EXISTS operations (
1254
- id TEXT PRIMARY KEY,
1255
- node_id TEXT NOT NULL,
1256
- type TEXT NOT NULL,
1257
- collection TEXT NOT NULL,
1258
- record_id TEXT NOT NULL,
1259
- data TEXT,
1260
- previous_data TEXT,
1261
- wall_time BIGINT NOT NULL,
1262
- logical INTEGER NOT NULL,
1263
- timestamp_node_id TEXT NOT NULL,
1264
- sequence_number INTEGER NOT NULL,
1265
- causal_deps TEXT NOT NULL DEFAULT '[]',
1266
- schema_version INTEGER NOT NULL,
1267
- received_at BIGINT NOT NULL
1268
- )
1269
- `);
1270
- await this.db.execute(
1271
- import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_node_seq ON operations (node_id, sequence_number)`
1272
- );
1273
- await this.db.execute(import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_collection ON operations (collection)`);
1274
- await this.db.execute(import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_received ON operations (received_at)`);
1275
- await this.db.execute(
1276
- 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`
1277
1515
  );
1278
- await this.db.execute(import_drizzle_orm.sql`
1279
- CREATE TABLE IF NOT EXISTS sync_state (
1280
- node_id TEXT PRIMARY KEY,
1281
- max_sequence_number INTEGER NOT NULL,
1282
- last_seen_at BIGINT NOT NULL
1283
- )
1284
- `);
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);
1285
1523
  }
1286
1524
  // ---------------------------------------------------------------------------
1287
1525
  // Operation serialization
1288
1526
  // ---------------------------------------------------------------------------
1289
- serializeOperation(op, receivedAt) {
1527
+ serializeOperation(op, receivedAt, deliverySeq) {
1290
1528
  return {
1291
1529
  id: op.id,
1292
1530
  nodeId: op.nodeId,
@@ -1295,16 +1533,19 @@ var PostgresServerStore = class {
1295
1533
  recordId: op.recordId,
1296
1534
  data: op.data !== null ? JSON.stringify(op.data) : null,
1297
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,
1298
1537
  wallTime: op.timestamp.wallTime,
1299
1538
  logical: op.timestamp.logical,
1300
1539
  timestampNodeId: op.timestamp.nodeId,
1301
1540
  sequenceNumber: op.sequenceNumber,
1302
1541
  causalDeps: JSON.stringify(op.causalDeps),
1303
1542
  schemaVersion: op.schemaVersion,
1304
- receivedAt
1543
+ receivedAt,
1544
+ deliverySeq
1305
1545
  };
1306
1546
  }
1307
1547
  deserializeOperation(row) {
1548
+ const atomicOps = row.atomicOps != null ? JSON.parse(row.atomicOps) : void 0;
1308
1549
  return {
1309
1550
  id: row.id,
1310
1551
  nodeId: row.nodeId,
@@ -1320,7 +1561,8 @@ var PostgresServerStore = class {
1320
1561
  },
1321
1562
  sequenceNumber: row.sequenceNumber,
1322
1563
  causalDeps: JSON.parse(row.causalDeps),
1323
- schemaVersion: row.schemaVersion
1564
+ schemaVersion: row.schemaVersion,
1565
+ ...atomicOps ? { atomicOps } : {}
1324
1566
  };
1325
1567
  }
1326
1568
  // ---------------------------------------------------------------------------
@@ -1390,6 +1632,8 @@ var operations = (0, import_sqlite_core.sqliteTable)(
1390
1632
  // JSON-serialized, null for deletes
1391
1633
  previousData: (0, import_sqlite_core.text)("previous_data"),
1392
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
1393
1637
  wallTime: (0, import_sqlite_core.integer)("wall_time").notNull(),
1394
1638
  logical: (0, import_sqlite_core.integer)("logical").notNull(),
1395
1639
  timestampNodeId: (0, import_sqlite_core.text)("timestamp_node_id").notNull(),
@@ -1397,12 +1641,17 @@ var operations = (0, import_sqlite_core.sqliteTable)(
1397
1641
  causalDeps: (0, import_sqlite_core.text)("causal_deps").notNull().default("[]"),
1398
1642
  // JSON array of op IDs
1399
1643
  schemaVersion: (0, import_sqlite_core.integer)("schema_version").notNull(),
1400
- 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")
1401
1649
  },
1402
1650
  (table) => ({
1403
1651
  nodeSeqIdx: (0, import_sqlite_core.index)("idx_node_seq").on(table.nodeId, table.sequenceNumber),
1404
1652
  collectionIdx: (0, import_sqlite_core.index)("idx_collection").on(table.collection),
1405
- 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)
1406
1655
  })
1407
1656
  );
1408
1657
  var syncState = (0, import_sqlite_core.sqliteTable)("sync_state", {
@@ -1410,6 +1659,10 @@ var syncState = (0, import_sqlite_core.sqliteTable)("sync_state", {
1410
1659
  maxSequenceNumber: (0, import_sqlite_core.integer)("max_sequence_number").notNull(),
1411
1660
  lastSeenAt: (0, import_sqlite_core.integer)("last_seen_at").notNull()
1412
1661
  });
1662
+ var deliveryCounter = (0, import_sqlite_core.sqliteTable)("delivery_counter", {
1663
+ id: (0, import_sqlite_core.integer)("id").primaryKey(),
1664
+ value: (0, import_sqlite_core.integer)("value").notNull()
1665
+ });
1413
1666
 
1414
1667
  // src/store/sqlite-server-store.ts
1415
1668
  var esmRequire = (0, import_node_module.createRequire)(importMetaUrl);
@@ -1463,12 +1716,15 @@ var SqliteServerStore = class {
1463
1716
  async applyRemoteOperation(op) {
1464
1717
  this.assertOpen();
1465
1718
  const now = Date.now();
1466
- const row = this.serializeOperation(op, now);
1467
1719
  const result = this.db.transaction((tx) => {
1468
- const insertResult = tx.insert(operations).values(row).onConflictDoNothing({ target: operations.id }).run();
1469
- if (insertResult.changes === 0) {
1720
+ const existing = tx.all(
1721
+ import_drizzle_orm2.sql`SELECT id FROM operations WHERE id = ${op.id} LIMIT 1`
1722
+ );
1723
+ if (existing.length > 0) {
1470
1724
  return "duplicate";
1471
1725
  }
1726
+ const row = this.serializeOperation(op, now, this.nextDeliverySeq(tx));
1727
+ tx.insert(operations).values(row).run();
1472
1728
  tx.insert(syncState).values({
1473
1729
  nodeId: op.nodeId,
1474
1730
  maxSequenceNumber: op.sequenceNumber,
@@ -1497,6 +1753,21 @@ var SqliteServerStore = class {
1497
1753
  const result = this.db.select({ value: (0, import_drizzle_orm2.count)() }).from(operations).all();
1498
1754
  return result[0]?.value ?? 0;
1499
1755
  }
1756
+ async getMaxDeliverySequence() {
1757
+ this.assertOpen();
1758
+ const rows = this.db.all(
1759
+ import_drizzle_orm2.sql`SELECT MAX(delivery_seq) AS m FROM operations`
1760
+ );
1761
+ return rows[0]?.m ?? 0;
1762
+ }
1763
+ async getOperationsAfterDelivery(afterDeliverySequence, limit) {
1764
+ this.assertOpen();
1765
+ 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();
1766
+ return rows.map((row) => ({
1767
+ operation: this.deserializeOperation(row),
1768
+ deliverySequence: row.deliverySeq ?? 0
1769
+ }));
1770
+ }
1500
1771
  async materializeCollection(collection) {
1501
1772
  this.assertOpen();
1502
1773
  if (this.schema?.collections[collection]) {
@@ -1528,7 +1799,7 @@ var SqliteServerStore = class {
1528
1799
  this.assertCollection(collection);
1529
1800
  const schema = this.schema;
1530
1801
  const collectionDef = schema.collections[collection];
1531
- const query = import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw(collection)} WHERE id = ${id} AND _deleted = 0`;
1802
+ const query = import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(collection))} WHERE id = ${id} AND _deleted = 0`;
1532
1803
  const rows = this.db.all(query);
1533
1804
  if (rows.length === 0) return null;
1534
1805
  return this.deserializeRow(rows[0], collectionDef);
@@ -1544,7 +1815,7 @@ var SqliteServerStore = class {
1544
1815
  }
1545
1816
  }
1546
1817
  const whereClause = this.buildWhereClause(where ?? {}, false);
1547
- const query = import_drizzle_orm2.sql`SELECT COUNT(*) as cnt FROM ${import_drizzle_orm2.sql.raw(collection)} WHERE ${whereClause}`;
1818
+ const query = import_drizzle_orm2.sql`SELECT COUNT(*) as cnt FROM ${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(collection))} WHERE ${whereClause}`;
1548
1819
  const rows = this.db.all(query);
1549
1820
  return rows[0]?.cnt ?? 0;
1550
1821
  }
@@ -1554,7 +1825,7 @@ var SqliteServerStore = class {
1554
1825
  async exportBackup() {
1555
1826
  this.assertOpen();
1556
1827
  const { buildServerBackup: buildServerBackup2 } = await Promise.resolve().then(() => (init_server_backup(), server_backup_exports));
1557
- const rows = this.db.select().from(operations).all();
1828
+ const rows = this.db.select().from(operations).orderBy((0, import_drizzle_orm2.asc)(operations.deliverySeq)).all();
1558
1829
  const deserialized = rows.map((row) => this.deserializeOperation(row));
1559
1830
  const vv = this.getVersionVector();
1560
1831
  return buildServerBackup2(this.nodeId, deserialized, vv);
@@ -1574,13 +1845,17 @@ var SqliteServerStore = class {
1574
1845
  this.db.transaction((tx) => {
1575
1846
  tx.run(import_drizzle_orm2.sql.raw("DELETE FROM operations"));
1576
1847
  tx.run(import_drizzle_orm2.sql.raw("DELETE FROM sync_state"));
1848
+ tx.update(deliveryCounter).set({ value: 0 }).where((0, import_drizzle_orm2.eq)(deliveryCounter.id, 1)).run();
1577
1849
  for (const [nid, seq] of versionVector) {
1578
1850
  tx.insert(syncState).values({ nodeId: nid, maxSequenceNumber: seq, lastSeenAt: Date.now() }).run();
1579
1851
  }
1852
+ let deliverySeq = 0;
1580
1853
  for (const op of ops) {
1581
- const row = this.serializeOperation(op, Date.now());
1854
+ deliverySeq += 1;
1855
+ const row = this.serializeOperation(op, Date.now(), deliverySeq);
1582
1856
  tx.insert(operations).values(row).run();
1583
1857
  }
1858
+ tx.update(deliveryCounter).set({ value: deliverySeq }).where((0, import_drizzle_orm2.eq)(deliveryCounter.id, 1)).run();
1584
1859
  });
1585
1860
  return { operationsRestored: ops.length, success: true };
1586
1861
  }
@@ -1598,13 +1873,15 @@ var SqliteServerStore = class {
1598
1873
  const ops = txOrDb.select({
1599
1874
  type: operations.type,
1600
1875
  data: operations.data,
1876
+ atomicOps: operations.atomicOps,
1601
1877
  wallTime: operations.wallTime
1602
- }).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();
1878
+ }).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();
1603
1879
  const parsedOps = ops.map((op) => ({
1604
1880
  type: op.type,
1605
- data: op.data !== null ? JSON.parse(op.data) : null
1881
+ data: op.data !== null ? JSON.parse(op.data) : null,
1882
+ atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
1606
1883
  }));
1607
- const recordData = replayOperationsForRecord(parsedOps);
1884
+ const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
1608
1885
  const fieldNames = Object.keys(collectionDef.fields);
1609
1886
  if (recordData) {
1610
1887
  const createdAt = ops.length > 0 ? ops[0].wallTime : Date.now();
@@ -1621,7 +1898,7 @@ var SqliteServerStore = class {
1621
1898
  );
1622
1899
  } else {
1623
1900
  txOrDb.run(
1624
- import_drizzle_orm2.sql`UPDATE ${import_drizzle_orm2.sql.raw(collection)} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
1901
+ import_drizzle_orm2.sql`UPDATE ${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(collection))} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
1625
1902
  );
1626
1903
  }
1627
1904
  }
@@ -1642,16 +1919,16 @@ var SqliteServerStore = class {
1642
1919
  0
1643
1920
  // _deleted = false
1644
1921
  ];
1645
- const columnsSql = import_drizzle_orm2.sql.raw(allColumns.join(", "));
1922
+ const columnsSql = import_drizzle_orm2.sql.raw(allColumns.map((c) => (0, import_core4.quoteIdent)(c)).join(", "));
1646
1923
  const valuesSql = import_drizzle_orm2.sql.join(
1647
1924
  values.map((v) => import_drizzle_orm2.sql`${v}`),
1648
1925
  import_drizzle_orm2.sql.raw(", ")
1649
1926
  );
1650
1927
  const updateSet = import_drizzle_orm2.sql.raw(
1651
- allColumns.slice(1).map((c) => `${c} = excluded.${c}`).join(", ")
1928
+ allColumns.slice(1).map((c) => `${(0, import_core4.quoteIdent)(c)} = excluded.${(0, import_core4.quoteIdent)(c)}`).join(", ")
1652
1929
  );
1653
1930
  txOrDb.run(
1654
- import_drizzle_orm2.sql`INSERT INTO ${import_drizzle_orm2.sql.raw(tableName)} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
1931
+ import_drizzle_orm2.sql`INSERT INTO ${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(tableName))} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
1655
1932
  );
1656
1933
  }
1657
1934
  /**
@@ -1674,8 +1951,9 @@ var SqliteServerStore = class {
1674
1951
  recordId: operations.recordId,
1675
1952
  type: operations.type,
1676
1953
  data: operations.data,
1954
+ atomicOps: operations.atomicOps,
1677
1955
  wallTime: operations.wallTime
1678
- }).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();
1956
+ }).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();
1679
1957
  if (allOps.length === 0) return;
1680
1958
  const grouped = /* @__PURE__ */ new Map();
1681
1959
  for (const op of allOps) {
@@ -1691,9 +1969,10 @@ var SqliteServerStore = class {
1691
1969
  for (const [recordId, recordOps] of grouped) {
1692
1970
  const parsedOps = recordOps.map((op) => ({
1693
1971
  type: op.type,
1694
- data: op.data !== null ? JSON.parse(op.data) : null
1972
+ data: op.data !== null ? JSON.parse(op.data) : null,
1973
+ atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
1695
1974
  }));
1696
- const recordData = replayOperationsForRecord(parsedOps);
1975
+ const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
1697
1976
  if (recordData) {
1698
1977
  const createdAt = recordOps[0].wallTime;
1699
1978
  const updatedAt = recordOps[recordOps.length - 1].wallTime;
@@ -1709,7 +1988,7 @@ var SqliteServerStore = class {
1709
1988
  );
1710
1989
  } else {
1711
1990
  tx.run(
1712
- import_drizzle_orm2.sql`INSERT INTO ${import_drizzle_orm2.sql.raw(collectionName)} (id, _deleted, _created_at, _updated_at) VALUES (${recordId}, 1, ${Date.now()}, ${Date.now()}) ON CONFLICT (id) DO UPDATE SET _deleted = 1, _updated_at = ${Date.now()}`
1991
+ import_drizzle_orm2.sql`INSERT INTO ${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(collectionName))} (id, _deleted, _created_at, _updated_at) VALUES (${recordId}, 1, ${Date.now()}, ${Date.now()}) ON CONFLICT (id) DO UPDATE SET _deleted = 1, _updated_at = ${Date.now()}`
1713
1992
  );
1714
1993
  }
1715
1994
  }
@@ -1723,10 +2002,12 @@ var SqliteServerStore = class {
1723
2002
  options?.where ?? {},
1724
2003
  options?.includeDeleted ?? false
1725
2004
  );
1726
- const parts = [import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw(collection)} WHERE ${whereClause}`];
2005
+ const parts = [
2006
+ import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(collection))} WHERE ${whereClause}`
2007
+ ];
1727
2008
  if (options?.orderBy) {
1728
2009
  const dir = options.orderDirection === "desc" ? "DESC" : "ASC";
1729
- parts.push(import_drizzle_orm2.sql.raw(` ORDER BY ${options.orderBy} ${dir}`));
2010
+ parts.push(import_drizzle_orm2.sql.raw(` ORDER BY ${(0, import_core4.quoteIdent)(options.orderBy)} ${dir}`));
1730
2011
  }
1731
2012
  if (options?.limit !== void 0) {
1732
2013
  parts.push(import_drizzle_orm2.sql` LIMIT ${options.limit}`);
@@ -1742,7 +2023,7 @@ var SqliteServerStore = class {
1742
2023
  conditions.push(import_drizzle_orm2.sql.raw("_deleted = 0"));
1743
2024
  }
1744
2025
  for (const [key, value] of Object.entries(where)) {
1745
- conditions.push(import_drizzle_orm2.sql`${import_drizzle_orm2.sql.raw(key)} = ${value}`);
2026
+ conditions.push(import_drizzle_orm2.sql`${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(key))} = ${value}`);
1746
2027
  }
1747
2028
  if (conditions.length === 0) {
1748
2029
  return import_drizzle_orm2.sql.raw("1 = 1");
@@ -1813,6 +2094,7 @@ var SqliteServerStore = class {
1813
2094
  record_id TEXT NOT NULL,
1814
2095
  data TEXT,
1815
2096
  previous_data TEXT,
2097
+ atomic_ops TEXT,
1816
2098
  wall_time INTEGER NOT NULL,
1817
2099
  logical INTEGER NOT NULL,
1818
2100
  timestamp_node_id TEXT NOT NULL,
@@ -1822,9 +2104,35 @@ var SqliteServerStore = class {
1822
2104
  received_at INTEGER NOT NULL
1823
2105
  )
1824
2106
  `);
2107
+ try {
2108
+ this.db.run(import_drizzle_orm2.sql`ALTER TABLE operations ADD COLUMN atomic_ops TEXT`);
2109
+ } catch (e) {
2110
+ const msg = e instanceof Error ? e.message : "";
2111
+ const causeMsg = e instanceof Error && e.cause instanceof Error ? e.cause.message : "";
2112
+ if (!msg.includes("duplicate column") && !causeMsg.includes("duplicate column")) {
2113
+ throw e;
2114
+ }
2115
+ }
2116
+ try {
2117
+ this.db.run(import_drizzle_orm2.sql`ALTER TABLE operations ADD COLUMN delivery_seq INTEGER`);
2118
+ } catch (e) {
2119
+ const msg = e instanceof Error ? e.message : "";
2120
+ const causeMsg = e instanceof Error && e.cause instanceof Error ? e.cause.message : "";
2121
+ if (!msg.includes("duplicate column") && !causeMsg.includes("duplicate column")) {
2122
+ throw e;
2123
+ }
2124
+ }
1825
2125
  this.db.run(import_drizzle_orm2.sql`
1826
2126
  CREATE INDEX IF NOT EXISTS idx_node_seq ON operations (node_id, sequence_number)
1827
2127
  `);
2128
+ this.db.run(import_drizzle_orm2.sql`
2129
+ CREATE INDEX IF NOT EXISTS idx_delivery_seq ON operations (delivery_seq)
2130
+ `);
2131
+ this.db.run(import_drizzle_orm2.sql`
2132
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_delivery_seq_unique
2133
+ ON operations (delivery_seq)
2134
+ WHERE delivery_seq IS NOT NULL
2135
+ `);
1828
2136
  this.db.run(import_drizzle_orm2.sql`
1829
2137
  CREATE INDEX IF NOT EXISTS idx_collection ON operations (collection)
1830
2138
  `);
@@ -1841,11 +2149,60 @@ var SqliteServerStore = class {
1841
2149
  last_seen_at INTEGER NOT NULL
1842
2150
  )
1843
2151
  `);
2152
+ this.db.run(import_drizzle_orm2.sql`
2153
+ CREATE TABLE IF NOT EXISTS delivery_counter (
2154
+ id INTEGER PRIMARY KEY,
2155
+ value INTEGER NOT NULL
2156
+ )
2157
+ `);
2158
+ this.backfillDeliverySequence();
2159
+ }
2160
+ /**
2161
+ * Assign delivery sequences to any operations written before the column existed,
2162
+ * then seed the durable counter from the current max. Ordered by
2163
+ * (received_at, sequence_number, id) so the backfill is deterministic and stable
2164
+ * across restarts. A one-time migration: after it runs, all rows have a value.
2165
+ */
2166
+ backfillDeliverySequence() {
2167
+ this.db.transaction((tx) => {
2168
+ const maxRow = tx.all(
2169
+ import_drizzle_orm2.sql`SELECT MAX(delivery_seq) AS m FROM operations`
2170
+ );
2171
+ let next = maxRow[0]?.m ?? 0;
2172
+ const nullRows = tx.all(
2173
+ import_drizzle_orm2.sql`SELECT id FROM operations WHERE delivery_seq IS NULL ORDER BY received_at ASC, sequence_number ASC, id ASC`
2174
+ );
2175
+ for (const row of nullRows) {
2176
+ next += 1;
2177
+ tx.run(import_drizzle_orm2.sql`UPDATE operations SET delivery_seq = ${next} WHERE id = ${row.id}`);
2178
+ }
2179
+ tx.insert(deliveryCounter).values({ id: 1, value: next }).onConflictDoUpdate({
2180
+ target: deliveryCounter.id,
2181
+ set: {
2182
+ value: import_drizzle_orm2.sql`MAX(${deliveryCounter.value}, ${next})`
2183
+ }
2184
+ }).run();
2185
+ });
2186
+ }
2187
+ /**
2188
+ * Reserve the next delivery sequence inside the append transaction. SQLite locks
2189
+ * the database for the write transaction, and this counter row keeps sequence
2190
+ * allocation shared by every SqliteServerStore instance using the same file.
2191
+ */
2192
+ nextDeliverySeq(tx) {
2193
+ const rows = tx.update(deliveryCounter).set({ value: import_drizzle_orm2.sql`${deliveryCounter.value} + 1` }).where((0, import_drizzle_orm2.eq)(deliveryCounter.id, 1)).returning({ value: deliveryCounter.value }).all();
2194
+ const value = rows[0]?.value;
2195
+ if (value === void 0 || value === null) {
2196
+ throw new Error(
2197
+ "delivery_counter row (id=1) is missing; the operations log cannot assign delivery sequences"
2198
+ );
2199
+ }
2200
+ return value;
1844
2201
  }
1845
2202
  // ---------------------------------------------------------------------------
1846
2203
  // Operation serialization
1847
2204
  // ---------------------------------------------------------------------------
1848
- serializeOperation(op, receivedAt) {
2205
+ serializeOperation(op, receivedAt, deliverySeq) {
1849
2206
  return {
1850
2207
  id: op.id,
1851
2208
  nodeId: op.nodeId,
@@ -1854,16 +2211,19 @@ var SqliteServerStore = class {
1854
2211
  recordId: op.recordId,
1855
2212
  data: op.data !== null ? JSON.stringify(op.data) : null,
1856
2213
  previousData: op.previousData !== null ? JSON.stringify(op.previousData) : null,
2214
+ atomicOps: op.atomicOps && Object.keys(op.atomicOps).length > 0 ? JSON.stringify(op.atomicOps) : null,
1857
2215
  wallTime: op.timestamp.wallTime,
1858
2216
  logical: op.timestamp.logical,
1859
2217
  timestampNodeId: op.timestamp.nodeId,
1860
2218
  sequenceNumber: op.sequenceNumber,
1861
2219
  causalDeps: JSON.stringify(op.causalDeps),
1862
2220
  schemaVersion: op.schemaVersion,
1863
- receivedAt
2221
+ receivedAt,
2222
+ deliverySeq
1864
2223
  };
1865
2224
  }
1866
2225
  deserializeOperation(row) {
2226
+ const atomicOps = row.atomicOps != null ? JSON.parse(row.atomicOps) : void 0;
1867
2227
  return {
1868
2228
  id: row.id,
1869
2229
  nodeId: row.nodeId,
@@ -1879,7 +2239,8 @@ var SqliteServerStore = class {
1879
2239
  },
1880
2240
  sequenceNumber: row.sequenceNumber,
1881
2241
  causalDeps: JSON.parse(row.causalDeps),
1882
- schemaVersion: row.schemaVersion
2242
+ schemaVersion: row.schemaVersion,
2243
+ ...atomicOps ? { atomicOps } : {}
1883
2244
  };
1884
2245
  }
1885
2246
  // ---------------------------------------------------------------------------
@@ -2065,6 +2426,7 @@ var WsServerTransport = class {
2065
2426
  // src/session/client-session.ts
2066
2427
  init_cjs_shims();
2067
2428
  var import_core10 = require("@korajs/core");
2429
+ var import_core11 = require("@korajs/core");
2068
2430
  var import_internal = require("@korajs/core/internal");
2069
2431
  var import_sync2 = require("@korajs/sync");
2070
2432
  var import_sync3 = require("@korajs/sync");
@@ -2178,6 +2540,9 @@ async function createServerSideEffectOperation(store, parentOp, effect, schemaVe
2178
2540
  );
2179
2541
  }
2180
2542
  function nextServerSequenceNumber(store) {
2543
+ if (store.reserveSequenceNumber) {
2544
+ return store.reserveSequenceNumber();
2545
+ }
2181
2546
  const nodeId = store.getNodeId();
2182
2547
  const current = store.getVersionVector().get(nodeId) ?? 0;
2183
2548
  return current + 1;
@@ -2187,14 +2552,28 @@ function nextServerSequenceNumber(store) {
2187
2552
  async function applyServerOperation(store, op, relationLookup) {
2188
2553
  const schema = store.getSchema();
2189
2554
  const lookup = relationLookup ?? (schema ? (0, import_merge2.buildMergeRelationLookup)(schema) : /* @__PURE__ */ new Map());
2555
+ const shapeCheck = validateOperationShape(op, schema);
2556
+ if (!shapeCheck.valid) {
2557
+ return {
2558
+ result: "skipped",
2559
+ appliedOperations: [],
2560
+ rejection: {
2561
+ code: shapeCheck.code ?? "SCHEMA_VALIDATION_ERROR",
2562
+ message: shapeCheck.message ?? `Operation "${op.id}" does not match the server schema`,
2563
+ retriable: false
2564
+ }
2565
+ };
2566
+ }
2190
2567
  const constraintCheck = await validateIncomingOperationConstraints(store, op, schema);
2191
2568
  if (!constraintCheck.valid) {
2569
+ const code = constraintCheck.code ?? "CONSTRAINT_VIOLATION";
2192
2570
  return {
2193
2571
  result: "skipped",
2194
2572
  appliedOperations: [],
2195
2573
  rejection: {
2196
- code: constraintCheck.code ?? "CONSTRAINT_VIOLATION",
2197
- message: constraintCheck.message ?? `Operation "${op.id}" violates a schema constraint`
2574
+ code,
2575
+ message: constraintCheck.message ?? `Operation "${op.id}" violates a schema constraint`,
2576
+ retriable: isRetriableRejection(code)
2198
2577
  }
2199
2578
  };
2200
2579
  }
@@ -2207,7 +2586,8 @@ async function applyServerOperation(store, op, relationLookup) {
2207
2586
  appliedOperations: [],
2208
2587
  rejection: {
2209
2588
  code: "REFERENTIAL_INTEGRITY",
2210
- message: `Operation "${op.id}" violates referential integrity on "${op.collection}"`
2589
+ message: `Operation "${op.id}" violates referential integrity on "${op.collection}"`,
2590
+ retriable: isRetriableRejection("REFERENTIAL_INTEGRITY")
2211
2591
  }
2212
2592
  };
2213
2593
  }
@@ -2216,16 +2596,14 @@ async function applyServerOperation(store, op, relationLookup) {
2216
2596
  return { result: primaryResult, appliedOperations: [] };
2217
2597
  }
2218
2598
  const appliedOperations = [op];
2219
- let serverSeq = nextServerSequenceNumber(store);
2220
2599
  for (const effect of referential.sideEffectOps) {
2221
2600
  const sideOp = await createServerSideEffectOperation(
2222
2601
  store,
2223
2602
  op,
2224
2603
  effect,
2225
2604
  op.schemaVersion,
2226
- serverSeq
2605
+ nextServerSequenceNumber(store)
2227
2606
  );
2228
- serverSeq += 1;
2229
2607
  const sideResult = await store.applyRemoteOperation(sideOp);
2230
2608
  if (sideResult === "applied") {
2231
2609
  appliedOperations.push(sideOp);
@@ -2239,6 +2617,67 @@ async function applyServerOperation(store, op, relationLookup) {
2239
2617
  appliedOperations: result === "applied" ? [op] : []
2240
2618
  };
2241
2619
  }
2620
+ function validateOperationShape(op, schema) {
2621
+ if (!schema) {
2622
+ return { valid: true };
2623
+ }
2624
+ const collection = schema.collections[op.collection];
2625
+ if (!collection) {
2626
+ return {
2627
+ valid: false,
2628
+ code: "UNKNOWN_COLLECTION",
2629
+ message: `Operation "${op.id}" targets unknown collection "${op.collection}".`
2630
+ };
2631
+ }
2632
+ const declaredFields = new Set(Object.keys(collection.fields));
2633
+ const systemPreviousFields = /* @__PURE__ */ new Set(["id", "_created_at", "_updated_at", "_deleted"]);
2634
+ const invalidDataField = firstInvalidField(op.data, declaredFields);
2635
+ if (invalidDataField) {
2636
+ return {
2637
+ valid: false,
2638
+ code: "SCHEMA_VALIDATION_ERROR",
2639
+ message: `Operation "${op.id}" contains undeclared field "${invalidDataField}" in data for collection "${op.collection}".`
2640
+ };
2641
+ }
2642
+ const invalidPreviousField = firstInvalidField(
2643
+ op.previousData,
2644
+ declaredFields,
2645
+ systemPreviousFields
2646
+ );
2647
+ if (invalidPreviousField) {
2648
+ return {
2649
+ valid: false,
2650
+ code: "SCHEMA_VALIDATION_ERROR",
2651
+ message: `Operation "${op.id}" contains undeclared field "${invalidPreviousField}" in previousData for collection "${op.collection}".`
2652
+ };
2653
+ }
2654
+ if (op.atomicOps) {
2655
+ for (const field of Object.keys(op.atomicOps)) {
2656
+ if (!declaredFields.has(field)) {
2657
+ return {
2658
+ valid: false,
2659
+ code: "SCHEMA_VALIDATION_ERROR",
2660
+ message: `Operation "${op.id}" contains undeclared atomic field "${field}" for collection "${op.collection}".`
2661
+ };
2662
+ }
2663
+ }
2664
+ }
2665
+ return { valid: true };
2666
+ }
2667
+ function firstInvalidField(value, declaredFields, extraAllowedFields) {
2668
+ if (value === null || value === void 0) {
2669
+ return null;
2670
+ }
2671
+ if (typeof value !== "object" || Array.isArray(value)) {
2672
+ return null;
2673
+ }
2674
+ for (const field of Object.keys(value)) {
2675
+ if (!declaredFields.has(field) && !extraAllowedFields?.has(field)) {
2676
+ return field;
2677
+ }
2678
+ }
2679
+ return null;
2680
+ }
2242
2681
 
2243
2682
  // src/auth/no-auth.ts
2244
2683
  init_cjs_shims();
@@ -2286,11 +2725,12 @@ function mergeScopeMaps(base, overlay, source) {
2286
2725
 
2287
2726
  // src/scopes/server-scope-filter.ts
2288
2727
  init_cjs_shims();
2289
- function operationMatchesScopes(op, scopes) {
2728
+ function operationMatchesScopes(op, scopes, fullRecord) {
2290
2729
  if (!scopes) return true;
2291
2730
  const collectionScope = scopes[op.collection];
2292
2731
  if (!collectionScope) return false;
2293
- const snapshot = buildSnapshot(op);
2732
+ if (Object.keys(collectionScope).length === 0) return true;
2733
+ const snapshot = buildSnapshot(op, fullRecord);
2294
2734
  if (!snapshot) return false;
2295
2735
  for (const [field, expected] of Object.entries(collectionScope)) {
2296
2736
  if (snapshot[field] !== expected) {
@@ -2299,11 +2739,20 @@ function operationMatchesScopes(op, scopes) {
2299
2739
  }
2300
2740
  return true;
2301
2741
  }
2302
- function buildSnapshot(op) {
2742
+ function missingScopeFields(op, scopes) {
2743
+ if (!scopes) return [];
2744
+ const collectionScope = scopes[op.collection];
2745
+ if (!collectionScope) return [];
2746
+ if (Object.keys(collectionScope).length === 0) return [];
2747
+ const snapshot = buildSnapshot(op);
2748
+ return Object.keys(collectionScope).filter((field) => !snapshot || !(field in snapshot));
2749
+ }
2750
+ function buildSnapshot(op, fullRecord) {
2303
2751
  const previous = asRecord(op.previousData);
2304
2752
  const next = asRecord(op.data);
2305
- if (!previous && !next) return null;
2753
+ if (!previous && !next && !fullRecord) return null;
2306
2754
  return {
2755
+ ...fullRecord ?? {},
2307
2756
  ...previous ?? {},
2308
2757
  ...next ?? {}
2309
2758
  };
@@ -2396,21 +2845,73 @@ function warnIfMultiTenantWithoutScopes(auth, resolvedScopes, schema) {
2396
2845
  "[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.)"
2397
2846
  );
2398
2847
  }
2399
- var ClientSession = class {
2848
+ function stableStringify(value) {
2849
+ if (Array.isArray(value)) {
2850
+ return `[${value.map((item) => stableStringify(item)).join(",")}]`;
2851
+ }
2852
+ if (value && typeof value === "object") {
2853
+ const record = value;
2854
+ const entries = Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`);
2855
+ return `{${entries.join(",")}}`;
2856
+ }
2857
+ return JSON.stringify(value);
2858
+ }
2859
+ function sameScopeMap(a, b) {
2860
+ return stableStringify(a ?? null) === stableStringify(b ?? null);
2861
+ }
2862
+ var ClientSession = class _ClientSession {
2400
2863
  state = "connected";
2401
2864
  clientNodeId = null;
2402
2865
  authContext = null;
2403
2866
  syncQuerySubsets = [];
2404
2867
  resumeDeltaCursor = null;
2868
+ /**
2869
+ * The delivery watermark the client reported at handshake, or null when the client
2870
+ * does not use the delivery watermark (old client, or no watermark yet). When set,
2871
+ * the server drives the gap-free server->client stream from delivery sequences
2872
+ * instead of the version-vector delta.
2873
+ */
2874
+ clientDeliveryWatermark = null;
2875
+ /**
2876
+ * The highest delivery sequence this client has ACKNOWLEDGED (its confirmed
2877
+ * watermark, as reported in acks). Incremental streaming pushes resume from here, not
2878
+ * from the highest sequence sent, so a dropped or unapplied batch is always
2879
+ * re-included by the next push (which re-scans from the acknowledged position). This
2880
+ * is what makes streaming recovery independent of any bounded retransmit buffer: there
2881
+ * is no sent-but-unacked window that a buffer eviction could strand. Seeded from the
2882
+ * client's reported watermark at handshake.
2883
+ */
2884
+ lastAckedDeliverySeq = 0;
2885
+ /**
2886
+ * Timestamp of the last delivery-stream send attempt. Periodic retransmission uses
2887
+ * this as its stale window so a slow client does not receive duplicate full-stream
2888
+ * batches every server tick while the first batch is still being applied.
2889
+ */
2890
+ lastDeliveryPushAttemptAtMs = 0;
2891
+ deliveryStallRepeatCount = 0;
2892
+ /** Serializes incremental delivery pushes so their batches never interleave. */
2893
+ deliveryPushChain = Promise.resolve();
2894
+ /**
2895
+ * Relay batches sent to this client but not yet acknowledged, keyed by messageId.
2896
+ * The client acks every operation-batch it applies; on ack the entry is cleared.
2897
+ * {@link retransmitPendingRelays} re-sends anything still unacked, so a relay
2898
+ * dropped by a lossy transport is redelivered instead of leaving the client with a
2899
+ * permanent version-vector gap (a lost operation) that delta sync cannot recover.
2900
+ * Bounded so a silent client cannot grow it without limit.
2901
+ */
2902
+ pendingRelays = /* @__PURE__ */ new Map();
2903
+ static MAX_PENDING_RELAYS = 1e3;
2405
2904
  sessionId;
2406
2905
  transport;
2407
2906
  store;
2408
2907
  auth;
2409
2908
  serializer;
2410
2909
  emitter;
2910
+ logger;
2411
2911
  batchSize;
2412
2912
  schemaVersion;
2413
2913
  supportedSchemaVersions;
2914
+ operationTransforms;
2414
2915
  onRelay;
2415
2916
  onAwarenessUpdate;
2416
2917
  onYjsDocUpdate;
@@ -2418,9 +2919,13 @@ var ClientSession = class {
2418
2919
  onBlobChunkResponse;
2419
2920
  persistBlobChunk;
2420
2921
  onClose;
2922
+ onOrphanedRelays;
2923
+ takeOrphanedRelays;
2421
2924
  maxOperationBytes;
2422
2925
  maxOpsPerMinute;
2423
2926
  rateLimiter;
2927
+ validateOperation;
2928
+ koraContext;
2424
2929
  constructor(options) {
2425
2930
  this.sessionId = options.sessionId;
2426
2931
  this.transport = options.transport;
@@ -2428,6 +2933,7 @@ var ClientSession = class {
2428
2933
  this.auth = options.auth ?? null;
2429
2934
  this.serializer = options.serializer ?? new import_sync3.NegotiatedMessageSerializer("json");
2430
2935
  this.emitter = options.emitter ?? null;
2936
+ this.logger = options.logger ?? null;
2431
2937
  this.batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
2432
2938
  this.schemaVersion = options.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
2433
2939
  const supported = options.supportedSchemaVersions;
@@ -2435,6 +2941,7 @@ var ClientSession = class {
2435
2941
  min: this.schemaVersion,
2436
2942
  max: this.schemaVersion
2437
2943
  };
2944
+ this.operationTransforms = options.operationTransforms ?? [];
2438
2945
  this.onRelay = options.onRelay ?? null;
2439
2946
  this.onAwarenessUpdate = options.onAwarenessUpdate ?? null;
2440
2947
  this.onYjsDocUpdate = options.onYjsDocUpdate ?? null;
@@ -2442,9 +2949,13 @@ var ClientSession = class {
2442
2949
  this.onBlobChunkResponse = options.onBlobChunkResponse ?? null;
2443
2950
  this.persistBlobChunk = options.persistBlobChunk ?? null;
2444
2951
  this.onClose = options.onClose ?? null;
2952
+ this.onOrphanedRelays = options.onOrphanedRelays ?? null;
2953
+ this.takeOrphanedRelays = options.takeOrphanedRelays ?? null;
2445
2954
  this.maxOperationBytes = options.maxOperationBytes ?? DEFAULT_MAX_OPERATION_BYTES;
2446
2955
  this.maxOpsPerMinute = options.maxOpsPerMinute ?? DEFAULT_MAX_OPS_PER_MINUTE;
2447
2956
  this.rateLimiter = new SessionRateLimiter(this.maxOpsPerMinute);
2957
+ this.validateOperation = options.validateOperation ?? null;
2958
+ this.koraContext = options.koraContext ?? null;
2448
2959
  }
2449
2960
  /**
2450
2961
  * Start handling messages from the client transport.
@@ -2464,25 +2975,129 @@ var ClientSession = class {
2464
2975
  */
2465
2976
  relayOperations(operations2) {
2466
2977
  if (this.state !== "streaming" || !this.transport.isConnected()) return;
2978
+ if (this.clientDeliveryWatermark !== null) {
2979
+ this.pushDeliveryStream();
2980
+ return;
2981
+ }
2467
2982
  if (operations2.length === 0) return;
2468
- const visibleOperations = operations2.filter((op) => this.operationVisibleToClient(op));
2983
+ void this.relayVisibleOperations(operations2);
2984
+ }
2985
+ /**
2986
+ * Push newly-available in-scope operations to a delivery-watermark client, resuming
2987
+ * from the highest delivery sequence already sent. Pushes are serialized so their
2988
+ * batches never interleave (which would break the base/max chain).
2989
+ */
2990
+ pushDeliveryStream() {
2991
+ this.deliveryPushChain = this.deliveryPushChain.then(async () => {
2992
+ if (this.state !== "streaming" || !this.transport.isConnected()) return;
2993
+ try {
2994
+ this.lastDeliveryPushAttemptAtMs = Date.now();
2995
+ await this.sendDeliveryStream(
2996
+ this.lastAckedDeliverySeq,
2997
+ false,
2998
+ this.clientNodeId ?? void 0
2999
+ );
3000
+ } catch {
3001
+ }
3002
+ });
3003
+ }
3004
+ async relayVisibleOperations(operations2) {
3005
+ const visibleOperations = [];
3006
+ for (const op of operations2) {
3007
+ if (await this.operationVisibleToClient(op)) {
3008
+ visibleOperations.push(op);
3009
+ }
3010
+ }
2469
3011
  if (visibleOperations.length === 0) return;
3012
+ if (this.state !== "streaming" || !this.transport.isConnected()) return;
2470
3013
  const serializedOps = visibleOperations.map((op) => this.serializer.encodeOperation(op));
2471
3014
  const msg = {
2472
3015
  type: "operation-batch",
2473
- messageId: (0, import_core10.generateUUIDv7)(),
3016
+ messageId: (0, import_core11.generateUUIDv7)(),
2474
3017
  operations: serializedOps,
2475
3018
  isFinal: true,
2476
3019
  batchIndex: 0
2477
3020
  };
3021
+ this.trackPendingRelay(msg, visibleOperations);
2478
3022
  this.sendToClient(msg);
2479
3023
  }
3024
+ /** Record a relay batch as awaiting acknowledgment, evicting the oldest if full. */
3025
+ trackPendingRelay(msg, ops) {
3026
+ if (this.pendingRelays.size >= _ClientSession.MAX_PENDING_RELAYS) {
3027
+ const oldest = this.pendingRelays.keys().next().value;
3028
+ if (oldest !== void 0) {
3029
+ this.pendingRelays.delete(oldest);
3030
+ }
3031
+ }
3032
+ this.pendingRelays.set(msg.messageId, { message: msg, ops, sentAtMs: Date.now() });
3033
+ }
3034
+ /**
3035
+ * Retransmit relay batches this client has not acknowledged within `staleMs`.
3036
+ * Called on a periodic tick by the server (and directly by tests). Redelivering an
3037
+ * already-applied op is harmless: the client dedups by content-addressed id.
3038
+ */
3039
+ retransmitPendingRelays(staleMs = 0) {
3040
+ if (this.state !== "streaming" || !this.transport.isConnected()) return;
3041
+ if (this.clientDeliveryWatermark !== null) {
3042
+ return;
3043
+ }
3044
+ if (this.pendingRelays.size === 0) return;
3045
+ const cutoff = Date.now() - staleMs;
3046
+ for (const { message, sentAtMs } of this.pendingRelays.values()) {
3047
+ if (sentAtMs <= cutoff) {
3048
+ this.sendToClient(message);
3049
+ }
3050
+ }
3051
+ }
3052
+ /**
3053
+ * Wake the durable delivery stream for clients that negotiated delivery
3054
+ * watermarks. This is used both for dropped watermark batches and for operations
3055
+ * appended by another server/store instance: the session always scans from its
3056
+ * own acknowledged cursor and applies its visibility filter before sending.
3057
+ */
3058
+ pushDeliveryStreamIfSupported(staleMs = 0, options = {}) {
3059
+ if (this.state !== "streaming" || !this.transport.isConnected()) return;
3060
+ if (this.clientDeliveryWatermark === null) return;
3061
+ if (staleMs > 0 && Date.now() - this.lastDeliveryPushAttemptAtMs < staleMs) {
3062
+ return;
3063
+ }
3064
+ if (options.trackStall) {
3065
+ this.deliveryStallRepeatCount += 1;
3066
+ if (this.deliveryStallRepeatCount >= 3) {
3067
+ this.emitter?.emit({
3068
+ type: "sync:delivery-stalled",
3069
+ sessionId: this.sessionId,
3070
+ watermark: this.lastAckedDeliverySeq,
3071
+ repeatCount: this.deliveryStallRepeatCount
3072
+ });
3073
+ }
3074
+ }
3075
+ this.pushDeliveryStream();
3076
+ }
2480
3077
  /**
2481
3078
  * Close this session.
2482
3079
  */
3080
+ /**
3081
+ * Hand any unacknowledged relays to the server so they survive this session and are
3082
+ * redelivered when this client reconnects (per-node buffer), then clear them. Called
3083
+ * from every session-teardown path (explicit close and transport close).
3084
+ */
3085
+ flushOrphanedRelays() {
3086
+ if (this.onOrphanedRelays && this.clientNodeId && this.pendingRelays.size > 0) {
3087
+ const ops = [];
3088
+ for (const entry of this.pendingRelays.values()) {
3089
+ ops.push(...entry.ops);
3090
+ }
3091
+ if (ops.length > 0) {
3092
+ this.onOrphanedRelays(this.clientNodeId, ops);
3093
+ }
3094
+ }
3095
+ this.pendingRelays.clear();
3096
+ }
2483
3097
  close(reason) {
2484
3098
  if (this.state === "closed") return;
2485
3099
  this.state = "closed";
3100
+ this.flushOrphanedRelays();
2486
3101
  if (this.transport.isConnected()) {
2487
3102
  this.transport.close(1e3, reason ?? "session closed");
2488
3103
  }
@@ -2537,6 +3152,11 @@ var ClientSession = class {
2537
3152
  await this.handleOperationBatch(message);
2538
3153
  break;
2539
3154
  case "acknowledgment":
3155
+ this.pendingRelays.delete(message.acknowledgedMessageId);
3156
+ if (message.deliverySequence !== void 0) {
3157
+ this.lastAckedDeliverySeq = Math.max(this.lastAckedDeliverySeq, message.deliverySequence);
3158
+ this.deliveryStallRepeatCount = 0;
3159
+ }
2540
3160
  break;
2541
3161
  case "error":
2542
3162
  break;
@@ -2567,7 +3187,7 @@ var ClientSession = class {
2567
3187
  return;
2568
3188
  }
2569
3189
  const bytes = (0, import_sync2.decodeBlobChunkBytes)(message.bytes);
2570
- const actual = await (0, import_core10.hashBlob)(bytes);
3190
+ const actual = await (0, import_core11.hashBlob)(bytes);
2571
3191
  if (actual !== message.hash) {
2572
3192
  return;
2573
3193
  }
@@ -2575,6 +3195,18 @@ var ClientSession = class {
2575
3195
  }
2576
3196
  handleMessageFailure(error) {
2577
3197
  const reason = error instanceof Error ? error.message : "Message handling failed";
3198
+ this.logger?.log({
3199
+ timestamp: Date.now(),
3200
+ level: "error",
3201
+ event: "session.message_failed",
3202
+ sessionId: this.sessionId,
3203
+ nodeId: this.clientNodeId ?? void 0,
3204
+ error: reason,
3205
+ details: {
3206
+ state: this.state,
3207
+ errorName: error instanceof Error ? error.name : typeof error
3208
+ }
3209
+ });
2578
3210
  this.sendError("SYNC_ERROR", reason, true);
2579
3211
  this.close(reason);
2580
3212
  }
@@ -2613,14 +3245,34 @@ var ClientSession = class {
2613
3245
  this.syncQuerySubsets = [];
2614
3246
  }
2615
3247
  this.resumeDeltaCursor = msg.deltaCursor ? (0, import_sync3.decodeDeltaCursor)(msg.deltaCursor) : null;
3248
+ this.clientDeliveryWatermark = msg.lastDeliverySequence ?? null;
3249
+ if (this.clientDeliveryWatermark !== null && !sameScopeMap(msg.syncScope, this.authContext?.scopes)) {
3250
+ this.clientDeliveryWatermark = 0;
3251
+ }
3252
+ const clientUsesWatermark = this.clientDeliveryWatermark !== null;
3253
+ let serverMaxDelivery = 0;
3254
+ if (clientUsesWatermark) {
3255
+ serverMaxDelivery = await this.store.getMaxDeliverySequence();
3256
+ if (this.clientDeliveryWatermark !== null && this.clientDeliveryWatermark > serverMaxDelivery) {
3257
+ this.clientDeliveryWatermark = 0;
3258
+ }
3259
+ }
2616
3260
  const serverVector = this.store.getVersionVector();
2617
3261
  const selectedWireFormat = selectWireFormat(msg.supportedWireFormats);
2618
3262
  this.setSerializerWireFormat(selectedWireFormat);
2619
3263
  if (!(0, import_sync3.isClientSchemaVersionSupported)(msg.schemaVersion, this.supportedSchemaVersions)) {
2620
3264
  const { min, max } = this.supportedSchemaVersions;
3265
+ this.emitter?.emit({
3266
+ type: "sync:schema-mismatch",
3267
+ clientSchemaVersion: msg.schemaVersion,
3268
+ serverSchemaVersion: this.schemaVersion,
3269
+ supportedMin: min,
3270
+ supportedMax: max,
3271
+ reason: `${import_sync3.SCHEMA_MISMATCH_PREFIX}: client schema version ${msg.schemaVersion} not in supported range [${min}, ${max}]`
3272
+ });
2621
3273
  const response2 = {
2622
3274
  type: "handshake-response",
2623
- messageId: (0, import_core10.generateUUIDv7)(),
3275
+ messageId: (0, import_core11.generateUUIDv7)(),
2624
3276
  nodeId: this.store.getNodeId(),
2625
3277
  versionVector: (0, import_sync3.versionVectorToWire)(serverVector),
2626
3278
  schemaVersion: this.schemaVersion,
@@ -2636,13 +3288,17 @@ var ClientSession = class {
2636
3288
  }
2637
3289
  const response = {
2638
3290
  type: "handshake-response",
2639
- messageId: (0, import_core10.generateUUIDv7)(),
3291
+ messageId: (0, import_core11.generateUUIDv7)(),
2640
3292
  nodeId: this.store.getNodeId(),
2641
3293
  versionVector: (0, import_sync3.versionVectorToWire)(serverVector),
2642
3294
  schemaVersion: this.schemaVersion,
2643
3295
  accepted: true,
2644
3296
  selectedWireFormat,
2645
3297
  serverTime: Date.now(),
3298
+ // The server's highest delivery sequence, so a client whose persisted watermark
3299
+ // is ahead of it (server rolled back) can reset to a full resync. Sent only to
3300
+ // watermark-using clients.
3301
+ ...clientUsesWatermark ? { serverMaxDeliverySequence: serverMaxDelivery } : {},
2646
3302
  // Advertise central blob storage so the client uploads the bytes behind
2647
3303
  // its blob fields, keeping them available after the author goes offline.
2648
3304
  ...this.persistBlobChunk ? { blobStorageEnabled: true } : {},
@@ -2653,17 +3309,40 @@ var ClientSession = class {
2653
3309
  this.sendToClient(response);
2654
3310
  this.emitter?.emit({ type: "sync:connected", nodeId: msg.nodeId });
2655
3311
  this.state = "syncing";
2656
- const clientVector = (0, import_sync3.wireToVersionVector)(msg.versionVector);
2657
- await this.sendDelta(clientVector);
3312
+ if (this.clientDeliveryWatermark !== null) {
3313
+ this.lastAckedDeliverySeq = this.clientDeliveryWatermark;
3314
+ const excludeOwn = this.clientDeliveryWatermark > 0 ? this.clientNodeId ?? void 0 : void 0;
3315
+ this.lastDeliveryPushAttemptAtMs = Date.now();
3316
+ await this.sendDeliveryStream(this.clientDeliveryWatermark, true, excludeOwn);
3317
+ } else {
3318
+ const clientVector = (0, import_sync3.wireToVersionVector)(msg.versionVector);
3319
+ await this.sendDelta(clientVector);
3320
+ }
2658
3321
  this.state = "streaming";
3322
+ if (this.takeOrphanedRelays && this.clientNodeId) {
3323
+ const buffered = this.takeOrphanedRelays(this.clientNodeId);
3324
+ if (buffered.length > 0) {
3325
+ this.relayOperations(buffered);
3326
+ }
3327
+ }
2659
3328
  }
2660
3329
  async handleOperationBatch(msg) {
2661
3330
  const operations2 = msg.operations.map((s) => this.serializer.decodeOperation(s));
2662
3331
  const applied = [];
2663
- const rejected = [];
3332
+ let acknowledgedThrough = 0;
3333
+ let canAdvanceAck = true;
2664
3334
  for (const op of operations2) {
2665
- if (!this.operationVisibleToClient(op)) {
2666
- rejected.push(op);
3335
+ if (!canAdvanceAck) {
3336
+ continue;
3337
+ }
3338
+ if (!await this.operationVisibleToClient(op)) {
3339
+ this.sendOperationRejected(
3340
+ op,
3341
+ "SCOPE_VIOLATION",
3342
+ `Operation "${op.id}" in collection "${op.collection}" is outside the client's sync scope. Refresh sync scopes and retry.`,
3343
+ true
3344
+ );
3345
+ canAdvanceAck = false;
2667
3346
  continue;
2668
3347
  }
2669
3348
  if (!isOperationTimestampValid(op)) {
@@ -2672,6 +3351,7 @@ var ClientSession = class {
2672
3351
  `Operation "${op.id}" timestamp is too far in the future`,
2673
3352
  false
2674
3353
  );
3354
+ canAdvanceAck = false;
2675
3355
  continue;
2676
3356
  }
2677
3357
  if (!this.rateLimiter.allow(1)) {
@@ -2680,6 +3360,7 @@ var ClientSession = class {
2680
3360
  `Session exceeded operation rate limit (${String(this.maxOpsPerMinute)} ops/min)`,
2681
3361
  true
2682
3362
  );
3363
+ canAdvanceAck = false;
2683
3364
  continue;
2684
3365
  }
2685
3366
  const sizeCheck = validateOperationSize(op, this.maxOperationBytes);
@@ -2689,27 +3370,73 @@ var ClientSession = class {
2689
3370
  sizeCheck.message ?? `Operation "${op.id}" is too large`,
2690
3371
  false
2691
3372
  );
3373
+ canAdvanceAck = false;
2692
3374
  continue;
2693
3375
  }
2694
- try {
2695
- const applyResult = await applyServerOperation(this.store, op);
2696
- if (applyResult.rejection) {
2697
- this.sendError(applyResult.rejection.code, applyResult.rejection.message, false);
3376
+ const serverOp = this.transformForServerSchema(op);
3377
+ if (serverOp === null) {
3378
+ this.sendOperationRejected(
3379
+ op,
3380
+ "SCHEMA_TRANSFORM_UNAVAILABLE",
3381
+ `Operation "${op.id}" cannot be transformed from schema v${op.schemaVersion} to server schema v${this.schemaVersion}.`,
3382
+ false
3383
+ );
3384
+ acknowledgedThrough = op.sequenceNumber;
3385
+ continue;
3386
+ }
3387
+ if (this.validateOperation && this.koraContext) {
3388
+ let decision;
3389
+ try {
3390
+ decision = await this.validateOperation(serverOp, {
3391
+ auth: this.authContext,
3392
+ kora: this.koraContext
3393
+ });
3394
+ } catch (error) {
3395
+ const message = error instanceof Error ? error.message : "validator error";
3396
+ this.sendOperationRejected(
3397
+ serverOp,
3398
+ "VALIDATION_ERROR",
3399
+ `Validator threw: ${message}`,
3400
+ true
3401
+ );
3402
+ canAdvanceAck = false;
2698
3403
  continue;
2699
3404
  }
2700
- if (applyResult.result === "applied") {
2701
- applied.push(...applyResult.appliedOperations);
3405
+ if (decision.action === "reject") {
3406
+ const retriable = decision.retriable ?? isRetriableRejection(decision.code);
3407
+ this.sendOperationRejected(serverOp, decision.code, decision.message, retriable);
3408
+ if (retriable) {
3409
+ canAdvanceAck = false;
3410
+ } else {
3411
+ acknowledgedThrough = op.sequenceNumber;
3412
+ }
3413
+ continue;
3414
+ }
3415
+ if (decision.action === "ignore") {
3416
+ acknowledgedThrough = op.sequenceNumber;
3417
+ continue;
2702
3418
  }
2703
- } catch {
2704
3419
  }
2705
- }
2706
- if (rejected.length > 0) {
2707
- for (const op of rejected) {
2708
- this.sendError(
2709
- "SCOPE_VIOLATION",
2710
- `Operation "${op.id}" in collection "${op.collection}" is outside the client's sync scope`,
2711
- false
3420
+ const applyResult = await applyServerOperation(this.store, serverOp);
3421
+ if (applyResult.rejection) {
3422
+ this.sendOperationRejected(
3423
+ serverOp,
3424
+ applyResult.rejection.code,
3425
+ applyResult.rejection.message,
3426
+ applyResult.rejection.retriable
2712
3427
  );
3428
+ if (applyResult.rejection.retriable) {
3429
+ canAdvanceAck = false;
3430
+ } else {
3431
+ acknowledgedThrough = op.sequenceNumber;
3432
+ }
3433
+ continue;
3434
+ }
3435
+ if (applyResult.result === "applied") {
3436
+ applied.push(...applyResult.appliedOperations);
3437
+ acknowledgedThrough = op.sequenceNumber;
3438
+ } else {
3439
+ acknowledgedThrough = op.sequenceNumber;
2713
3440
  }
2714
3441
  }
2715
3442
  if (operations2.length > 0) {
@@ -2722,15 +3449,21 @@ var ClientSession = class {
2722
3449
  const lastOp = operations2[operations2.length - 1];
2723
3450
  const ack = {
2724
3451
  type: "acknowledgment",
2725
- messageId: (0, import_core10.generateUUIDv7)(),
3452
+ messageId: (0, import_core11.generateUUIDv7)(),
2726
3453
  acknowledgedMessageId: msg.messageId,
2727
- lastSequenceNumber: lastOp ? lastOp.sequenceNumber : 0
3454
+ lastSequenceNumber: lastOp ? Math.min(acknowledgedThrough, lastOp.sequenceNumber) : 0
2728
3455
  };
2729
3456
  this.sendToClient(ack);
2730
3457
  if (applied.length > 0) {
2731
3458
  this.onRelay?.(this.sessionId, applied);
2732
3459
  }
2733
3460
  }
3461
+ transformForServerSchema(op) {
3462
+ if (op.schemaVersion === this.schemaVersion) {
3463
+ return op;
3464
+ }
3465
+ return (0, import_core10.applyOperationTransforms)(op, this.schemaVersion, this.operationTransforms);
3466
+ }
2734
3467
  async sendDelta(clientVector) {
2735
3468
  const serverVector = this.store.getVersionVector();
2736
3469
  const missing = [];
@@ -2738,14 +3471,17 @@ var ClientSession = class {
2738
3471
  const clientSeq = clientVector.get(nodeId) ?? 0;
2739
3472
  if (serverSeq > clientSeq) {
2740
3473
  const ops = await this.store.getOperationRange(nodeId, clientSeq + 1, serverSeq);
2741
- const visible = ops.filter((op) => this.operationVisibleToClient(op));
2742
- missing.push(...visible);
3474
+ for (const op of ops) {
3475
+ if (await this.operationVisibleToClient(op)) {
3476
+ missing.push(op);
3477
+ }
3478
+ }
2743
3479
  }
2744
3480
  }
2745
3481
  if (missing.length === 0) {
2746
3482
  const emptyBatch = {
2747
3483
  type: "operation-batch",
2748
- messageId: (0, import_core10.generateUUIDv7)(),
3484
+ messageId: (0, import_core11.generateUUIDv7)(),
2749
3485
  operations: [],
2750
3486
  isFinal: true,
2751
3487
  batchIndex: 0,
@@ -2760,7 +3496,7 @@ var ClientSession = class {
2760
3496
  if (afterCursor.length === 0) {
2761
3497
  const emptyBatch = {
2762
3498
  type: "operation-batch",
2763
- messageId: (0, import_core10.generateUUIDv7)(),
3499
+ messageId: (0, import_core11.generateUUIDv7)(),
2764
3500
  operations: [],
2765
3501
  isFinal: true,
2766
3502
  batchIndex: this.resumeDeltaCursor?.batchIndex ?? 0,
@@ -2776,7 +3512,7 @@ var ClientSession = class {
2776
3512
  const batchCursor = (0, import_sync3.createDeltaCursorFromBatch)(batchOps, i);
2777
3513
  const batchMsg = {
2778
3514
  type: "operation-batch",
2779
- messageId: (0, import_core10.generateUUIDv7)(),
3515
+ messageId: (0, import_core11.generateUUIDv7)(),
2780
3516
  operations: serializedOps,
2781
3517
  isFinal: i === totalBatches - 1,
2782
3518
  batchIndex: i,
@@ -2791,11 +3527,111 @@ var ClientSession = class {
2791
3527
  });
2792
3528
  }
2793
3529
  }
2794
- operationVisibleToClient(op) {
2795
- if (!operationMatchesScopes(op, this.authContext?.scopes)) {
3530
+ /**
3531
+ * Send the gap-free server->client delivery stream, resuming just after
3532
+ * `fromDeliverySeq`. Operations are scanned in server delivery-sequence order
3533
+ * (commit order), scope-filtered for this session, and sent in batches that chain:
3534
+ * each batch's `baseDeliverySequence` equals the previous batch's
3535
+ * `maxDeliverySequence` (the first batch bases on `fromDeliverySeq`). The client
3536
+ * applies a batch only when its watermark equals the base and advances the
3537
+ * watermark to the max, so a dropped batch stalls the watermark and is recovered by
3538
+ * the next handshake resend. Because delivery-sequence order respects causal order
3539
+ * (a dependency is always committed, and thus sequenced, before its dependent), no
3540
+ * topological sort is needed and the client never defers on a missing dependency
3541
+ * that is itself in this stream.
3542
+ *
3543
+ * The final batch advances the watermark to the highest delivery sequence scanned,
3544
+ * not merely the last in-scope one, so an out-of-scope tail is not re-scanned on the
3545
+ * next reconnect. Returns the number of operations actually sent.
3546
+ */
3547
+ async sendDeliveryStream(fromDeliverySeq, finalizeWhenEmpty, excludeNodeId) {
3548
+ const scanChunk = Math.max(this.batchSize, 1) * 5;
3549
+ let scanCursor = fromDeliverySeq;
3550
+ let maxScanned = fromDeliverySeq;
3551
+ const visible = [];
3552
+ while (true) {
3553
+ const chunk = await this.store.getOperationsAfterDelivery(scanCursor, scanChunk);
3554
+ const last = chunk[chunk.length - 1];
3555
+ if (last === void 0) break;
3556
+ scanCursor = last.deliverySequence;
3557
+ maxScanned = scanCursor;
3558
+ for (const delivered of chunk) {
3559
+ if (excludeNodeId !== void 0 && delivered.operation.nodeId === excludeNodeId) {
3560
+ continue;
3561
+ }
3562
+ if (await this.operationVisibleToClient(delivered.operation)) {
3563
+ visible.push(delivered);
3564
+ }
3565
+ }
3566
+ if (chunk.length < scanChunk) break;
3567
+ }
3568
+ if (visible.length === 0) {
3569
+ if (finalizeWhenEmpty) {
3570
+ this.sendDeliveryBatch([], fromDeliverySeq, maxScanned, 0, true);
3571
+ }
3572
+ return 0;
3573
+ }
3574
+ const totalBatches = Math.ceil(visible.length / this.batchSize);
3575
+ let base = fromDeliverySeq;
3576
+ for (let i = 0; i < totalBatches; i++) {
3577
+ const slice = visible.slice(i * this.batchSize, (i + 1) * this.batchSize);
3578
+ const lastInSlice = slice[slice.length - 1];
3579
+ if (lastInSlice === void 0) continue;
3580
+ const isFinal = i === totalBatches - 1;
3581
+ const lastSeq = lastInSlice.deliverySequence;
3582
+ const max = isFinal ? Math.max(maxScanned, lastSeq) : lastSeq;
3583
+ this.sendDeliveryBatch(slice, base, max, i, isFinal);
3584
+ base = max;
3585
+ }
3586
+ return visible.length;
3587
+ }
3588
+ /** Build, track, and send one chained delivery-stream batch. */
3589
+ sendDeliveryBatch(slice, base, max, batchIndex, isFinal) {
3590
+ const batchMsg = {
3591
+ type: "operation-batch",
3592
+ messageId: (0, import_core11.generateUUIDv7)(),
3593
+ operations: slice.map((delivered) => this.serializer.encodeOperation(delivered.operation)),
3594
+ isFinal,
3595
+ batchIndex,
3596
+ baseDeliverySequence: base,
3597
+ maxDeliverySequence: max
3598
+ };
3599
+ this.sendToClient(batchMsg);
3600
+ if (slice.length > 0) {
3601
+ this.emitter?.emit({
3602
+ type: "sync:sent",
3603
+ operations: slice.map((delivered) => delivered.operation),
3604
+ batchSize: slice.length
3605
+ });
3606
+ }
3607
+ }
3608
+ async operationVisibleToClient(op) {
3609
+ const scopes = this.authContext?.scopes;
3610
+ const subsets = this.syncQuerySubsets;
3611
+ const needsBackfill = missingScopeFields(op, scopes).length > 0 || subsets !== void 0 && subsets.length > 0;
3612
+ const fullRecord = needsBackfill ? await this.lookupRecordFields(op.collection, op.recordId) : void 0;
3613
+ if (!operationMatchesScopes(op, scopes, fullRecord)) {
2796
3614
  return false;
2797
3615
  }
2798
- return (0, import_sync3.operationMatchesQuerySubsets)(op, this.syncQuerySubsets);
3616
+ return (0, import_sync3.operationMatchesQuerySubsets)(op, subsets, fullRecord);
3617
+ }
3618
+ /**
3619
+ * Read a record's current field values from the materialized store for scope /
3620
+ * query-subset backfill. Includes soft-deleted rows so a relayed delete (whose op
3621
+ * carries no fields) is still judged against the record's actual scope. Returns
3622
+ * undefined when the record cannot be read (never materialized, or no schema).
3623
+ */
3624
+ async lookupRecordFields(collection, recordId) {
3625
+ try {
3626
+ const rows = await this.store.queryCollection(collection, {
3627
+ where: { id: recordId },
3628
+ includeDeleted: true,
3629
+ limit: 1
3630
+ });
3631
+ return rows[0];
3632
+ } catch {
3633
+ return void 0;
3634
+ }
2799
3635
  }
2800
3636
  handleAwarenessUpdate(msg) {
2801
3637
  this.onAwarenessUpdate?.(this.sessionId, msg);
@@ -2806,13 +3642,32 @@ var ClientSession = class {
2806
3642
  sendError(code, message, retriable) {
2807
3643
  const errorMsg = {
2808
3644
  type: "error",
2809
- messageId: (0, import_core10.generateUUIDv7)(),
3645
+ messageId: (0, import_core11.generateUUIDv7)(),
2810
3646
  code,
2811
3647
  message,
2812
3648
  retriable
2813
3649
  };
2814
3650
  this.sendToClient(errorMsg);
2815
3651
  }
3652
+ /**
3653
+ * Reject one specific client operation, tied to its id, so the submitter can
3654
+ * divert it out of its pending queue into a durable rejected store rather than
3655
+ * losing it or retrying forever. The op is NOT materialized, so no other
3656
+ * replica ever sees it.
3657
+ */
3658
+ sendOperationRejected(op, code, message, retriable) {
3659
+ const rejectedMsg = {
3660
+ type: "operation-rejected",
3661
+ messageId: (0, import_core11.generateUUIDv7)(),
3662
+ operationId: op.id,
3663
+ collection: op.collection,
3664
+ recordId: op.recordId,
3665
+ code,
3666
+ message,
3667
+ retriable
3668
+ };
3669
+ this.sendToClient(rejectedMsg);
3670
+ }
2816
3671
  setSerializerWireFormat(format) {
2817
3672
  if (typeof this.serializer.setWireFormat === "function") {
2818
3673
  this.serializer.setWireFormat(format);
@@ -2821,6 +3676,7 @@ var ClientSession = class {
2821
3676
  handleTransportClose() {
2822
3677
  if (this.state === "closed") return;
2823
3678
  this.state = "closed";
3679
+ this.flushOrphanedRelays();
2824
3680
  this.emitter?.emit({ type: "sync:disconnected", reason: "transport closed" });
2825
3681
  this.onClose?.(this.sessionId);
2826
3682
  }
@@ -2834,13 +3690,13 @@ function selectWireFormat(supportedWireFormats) {
2834
3690
 
2835
3691
  // src/server/kora-sync-server.ts
2836
3692
  init_cjs_shims();
2837
- var import_core12 = require("@korajs/core");
3693
+ var import_core14 = require("@korajs/core");
2838
3694
  var import_internal2 = require("@korajs/core/internal");
2839
3695
  var import_sync5 = require("@korajs/sync");
2840
3696
 
2841
3697
  // src/awareness/awareness-relay.ts
2842
3698
  init_cjs_shims();
2843
- var import_core11 = require("@korajs/core");
3699
+ var import_core12 = require("@korajs/core");
2844
3700
  var AwarenessRelay = class {
2845
3701
  clients = /* @__PURE__ */ new Map();
2846
3702
  /**
@@ -2869,7 +3725,7 @@ var AwarenessRelay = class {
2869
3725
  if (hasStates) {
2870
3726
  const catchUpMsg = {
2871
3727
  type: "awareness-update",
2872
- messageId: (0, import_core11.generateUUIDv7)(),
3728
+ messageId: (0, import_core12.generateUUIDv7)(),
2873
3729
  clientId: 0,
2874
3730
  // Server-sourced
2875
3731
  states: existingStates
@@ -2892,7 +3748,7 @@ var AwarenessRelay = class {
2892
3748
  };
2893
3749
  const msg = {
2894
3750
  type: "awareness-update",
2895
- messageId: (0, import_core11.generateUUIDv7)(),
3751
+ messageId: (0, import_core12.generateUUIDv7)(),
2896
3752
  clientId: client.clientId,
2897
3753
  states: removalStates
2898
3754
  };
@@ -3196,21 +4052,416 @@ var YjsDocRelay = class {
3196
4052
  }
3197
4053
  };
3198
4054
 
4055
+ // src/server/route-context.ts
4056
+ init_cjs_shims();
4057
+ var import_core13 = require("@korajs/core");
4058
+
4059
+ // src/apply/route-predicate.ts
4060
+ init_cjs_shims();
4061
+ function evaluateField(actual, operators) {
4062
+ if ("$eq" in operators && !Object.is(actual, operators.$eq)) {
4063
+ return false;
4064
+ }
4065
+ if ("$ne" in operators && Object.is(actual, operators.$ne)) {
4066
+ return false;
4067
+ }
4068
+ if (operators.$in !== void 0 && !operators.$in.some((candidate) => Object.is(actual, candidate))) {
4069
+ return false;
4070
+ }
4071
+ const numericChecks = [
4072
+ ["$lt", (a, b) => a < b],
4073
+ ["$lte", (a, b) => a <= b],
4074
+ ["$gt", (a, b) => a > b],
4075
+ ["$gte", (a, b) => a >= b]
4076
+ ];
4077
+ for (const [operator, compare] of numericChecks) {
4078
+ const bound = operators[operator];
4079
+ if (bound === void 0) {
4080
+ continue;
4081
+ }
4082
+ if (typeof actual !== "number" || typeof bound !== "number") {
4083
+ return false;
4084
+ }
4085
+ if (!compare(actual, bound)) {
4086
+ return false;
4087
+ }
4088
+ }
4089
+ return true;
4090
+ }
4091
+ function evaluateRoutePredicate(record, predicate) {
4092
+ const fields = Object.keys(predicate);
4093
+ if (fields.length === 0) {
4094
+ return true;
4095
+ }
4096
+ if (record === null) {
4097
+ return false;
4098
+ }
4099
+ for (const field of fields) {
4100
+ const operators = predicate[field];
4101
+ if (operators && !evaluateField(record[field], operators)) {
4102
+ return false;
4103
+ }
4104
+ }
4105
+ return true;
4106
+ }
4107
+
4108
+ // src/server/route-context.ts
4109
+ function recordMatchesScope(collection, record, scope) {
4110
+ const collectionScope = scope[collection];
4111
+ if (!collectionScope) {
4112
+ return false;
4113
+ }
4114
+ for (const [field, expected] of Object.entries(collectionScope)) {
4115
+ if (record[field] !== expected) {
4116
+ return false;
4117
+ }
4118
+ }
4119
+ return true;
4120
+ }
4121
+ function pickFields(record, keys) {
4122
+ const picked = {};
4123
+ for (const key of keys) {
4124
+ if (key in record) {
4125
+ picked[key] = record[key];
4126
+ }
4127
+ }
4128
+ return picked;
4129
+ }
4130
+ async function buildRouteOperation(store, mutation, currentOverride, clockOverride) {
4131
+ const nodeId = store.getNodeId();
4132
+ const clock = clockOverride ?? new import_core13.HybridLogicalClock(nodeId);
4133
+ const schemaVersion = store.getSchema()?.version ?? 1;
4134
+ const sequenceNumber = nextServerSequenceNumber(store);
4135
+ if (mutation.type === "insert") {
4136
+ return (0, import_core13.createOperation)(
4137
+ {
4138
+ nodeId,
4139
+ type: "insert",
4140
+ collection: mutation.collection,
4141
+ recordId: mutation.recordId ?? (0, import_core13.generateUUIDv7)(),
4142
+ data: mutation.data ?? {},
4143
+ previousData: null,
4144
+ sequenceNumber,
4145
+ causalDeps: [],
4146
+ schemaVersion
4147
+ },
4148
+ clock
4149
+ );
4150
+ }
4151
+ if (!mutation.recordId) {
4152
+ throw new RouteMutationError(
4153
+ "MISSING_RECORD_ID",
4154
+ `A ${mutation.type} mutation on "${mutation.collection}" requires a recordId.`
4155
+ );
4156
+ }
4157
+ const current = currentOverride ? currentOverride.record : await store.findRecord(mutation.collection, mutation.recordId);
4158
+ if (mutation.type === "update") {
4159
+ const rawData = mutation.data ?? {};
4160
+ const data = {};
4161
+ const atomicOps = {};
4162
+ for (const key of Object.keys(rawData)) {
4163
+ const value = rawData[key];
4164
+ if ((0, import_core13.isAtomicOp)(value)) {
4165
+ data[key] = (0, import_core13.resolveAtomicOp)(current ? current[key] : void 0, value);
4166
+ atomicOps[key] = (0, import_core13.toAtomicOp)(value);
4167
+ } else {
4168
+ data[key] = value;
4169
+ }
4170
+ }
4171
+ return (0, import_core13.createOperation)(
4172
+ {
4173
+ nodeId,
4174
+ type: "update",
4175
+ collection: mutation.collection,
4176
+ recordId: mutation.recordId,
4177
+ data,
4178
+ previousData: current ? pickFields(current, Object.keys(rawData)) : {},
4179
+ sequenceNumber,
4180
+ causalDeps: [],
4181
+ schemaVersion,
4182
+ ...Object.keys(atomicOps).length > 0 ? { atomicOps } : {}
4183
+ },
4184
+ clock
4185
+ );
4186
+ }
4187
+ return (0, import_core13.createOperation)(
4188
+ {
4189
+ nodeId,
4190
+ type: "delete",
4191
+ collection: mutation.collection,
4192
+ recordId: mutation.recordId,
4193
+ data: null,
4194
+ previousData: current ? { ...current } : null,
4195
+ sequenceNumber,
4196
+ causalDeps: [],
4197
+ schemaVersion
4198
+ },
4199
+ clock
4200
+ );
4201
+ }
4202
+ var RouteMutationError = class extends Error {
4203
+ constructor(code, message) {
4204
+ super(message);
4205
+ this.code = code;
4206
+ this.name = "RouteMutationError";
4207
+ }
4208
+ code;
4209
+ };
4210
+ function createRouteContext(server, store) {
4211
+ let mutationTail = Promise.resolve();
4212
+ function serialize(work) {
4213
+ const run = mutationTail.then(work, work);
4214
+ mutationTail = run.then(
4215
+ () => void 0,
4216
+ () => void 0
4217
+ );
4218
+ return run;
4219
+ }
4220
+ async function prepareMutation(mutation, scope) {
4221
+ let op;
4222
+ try {
4223
+ op = await buildRouteOperation(store, mutation);
4224
+ } catch (error) {
4225
+ if (error instanceof RouteMutationError) {
4226
+ return {
4227
+ ok: false,
4228
+ rejection: {
4229
+ ok: false,
4230
+ code: error.code,
4231
+ message: error.message,
4232
+ retriable: isRetriableRejection(error.code)
4233
+ }
4234
+ };
4235
+ }
4236
+ throw error;
4237
+ }
4238
+ if (scope && !operationMatchesScopes(op, scope)) {
4239
+ return {
4240
+ ok: false,
4241
+ rejection: {
4242
+ ok: false,
4243
+ code: "SCOPE_VIOLATION",
4244
+ message: `Mutation on "${mutation.collection}" is outside the provided scope.`,
4245
+ retriable: isRetriableRejection("SCOPE_VIOLATION")
4246
+ }
4247
+ };
4248
+ }
4249
+ return { ok: true, op };
4250
+ }
4251
+ async function commitOperation(op, collection) {
4252
+ const result = await server.applyLocalOperation(op);
4253
+ if (result.result !== "applied") {
4254
+ const code = result.rejection?.code ?? "NOT_APPLIED";
4255
+ return {
4256
+ ok: false,
4257
+ code,
4258
+ message: result.rejection?.message ?? `Operation on "${collection}" was not applied (${result.result}).`,
4259
+ // Prefer the pipeline's own classification when present; fall back
4260
+ // to classifying the code so this stays correct if new codes appear.
4261
+ retriable: result.rejection?.retriable ?? isRetriableRejection(code)
4262
+ };
4263
+ }
4264
+ const record = await store.findRecord(collection, op.recordId);
4265
+ return { ok: true, operation: op, record };
4266
+ }
4267
+ async function rawApply(mutation, scope) {
4268
+ const prepared = await prepareMutation(mutation, scope);
4269
+ if (!prepared.ok) {
4270
+ return prepared.rejection;
4271
+ }
4272
+ return commitOperation(prepared.op, mutation.collection);
4273
+ }
4274
+ async function runConditionalViaStore(storeApplyConditional, spec, options) {
4275
+ const scope = options?.scope;
4276
+ const mutations = [];
4277
+ const hasTargetUpdate = spec.update !== void 0 && spec.update !== null;
4278
+ if (hasTargetUpdate) {
4279
+ mutations.push({
4280
+ collection: spec.collection,
4281
+ type: "update",
4282
+ recordId: spec.id,
4283
+ data: spec.update
4284
+ });
4285
+ }
4286
+ if (spec.also) {
4287
+ mutations.push(...spec.also);
4288
+ }
4289
+ let result;
4290
+ try {
4291
+ result = await storeApplyConditional({
4292
+ target: { collection: spec.collection, id: spec.id },
4293
+ ...spec.idempotencyKey ? { idempotencyKey: spec.idempotencyKey } : {},
4294
+ admit: (current) => spec.if && Object.keys(spec.if).length > 0 ? evaluateRoutePredicate(current, spec.if) : true,
4295
+ buildOperations: async (current, context) => {
4296
+ const ops = [];
4297
+ for (let i = 0; i < mutations.length; i++) {
4298
+ const mutation = mutations[i];
4299
+ const useLockedCurrent = i === 0 && hasTargetUpdate;
4300
+ const op = await buildRouteOperation(
4301
+ store,
4302
+ mutation,
4303
+ useLockedCurrent ? { record: current } : void 0,
4304
+ context.clock
4305
+ );
4306
+ if (scope && !operationMatchesScopes(op, scope)) {
4307
+ throw new RouteMutationError(
4308
+ "SCOPE_VIOLATION",
4309
+ `Mutation on "${mutation.collection}" is outside the provided scope.`
4310
+ );
4311
+ }
4312
+ ops.push(op);
4313
+ }
4314
+ return ops;
4315
+ }
4316
+ });
4317
+ } catch (error) {
4318
+ if (error instanceof RouteMutationError) {
4319
+ return {
4320
+ ok: false,
4321
+ code: error.code,
4322
+ message: error.message,
4323
+ retriable: isRetriableRejection(error.code)
4324
+ };
4325
+ }
4326
+ throw error;
4327
+ }
4328
+ if (!result.admitted) {
4329
+ return {
4330
+ ok: false,
4331
+ code: spec.reject?.code ?? "CONDITION_NOT_MET",
4332
+ message: spec.reject?.message ?? `Admission condition not met on "${spec.collection}" record "${spec.id}".`,
4333
+ retriable: false
4334
+ };
4335
+ }
4336
+ if (result.idempotent) {
4337
+ const records2 = spec.idempotencyKey ? [await store.findRecord(spec.idempotencyKey.collection, spec.idempotencyKey.id)] : [];
4338
+ return { ok: true, idempotent: true, operations: [], records: records2 };
4339
+ }
4340
+ server.relayServerOperations(result.applied);
4341
+ const records = [];
4342
+ for (const op of result.applied) {
4343
+ records.push(await store.findRecord(op.collection, op.recordId));
4344
+ }
4345
+ return { ok: true, idempotent: false, operations: result.applied, records };
4346
+ }
4347
+ return {
4348
+ apply(mutation, options) {
4349
+ return serialize(() => rawApply(mutation, options?.scope));
4350
+ },
4351
+ applyConditional(spec, options) {
4352
+ const storeApplyConditional = store.applyConditional;
4353
+ if (storeApplyConditional) {
4354
+ return runConditionalViaStore(storeApplyConditional.bind(store), spec, options);
4355
+ }
4356
+ return serialize(async () => {
4357
+ if (spec.idempotencyKey) {
4358
+ const existing = await store.findRecord(
4359
+ spec.idempotencyKey.collection,
4360
+ spec.idempotencyKey.id
4361
+ );
4362
+ if (existing) {
4363
+ return { ok: true, idempotent: true, operations: [], records: [existing] };
4364
+ }
4365
+ }
4366
+ if (spec.if && Object.keys(spec.if).length > 0) {
4367
+ const target = await store.findRecord(spec.collection, spec.id);
4368
+ if (!evaluateRoutePredicate(target, spec.if)) {
4369
+ const code = spec.reject?.code ?? "CONDITION_NOT_MET";
4370
+ return {
4371
+ ok: false,
4372
+ code,
4373
+ message: spec.reject?.message ?? `Admission condition not met on "${spec.collection}" record "${spec.id}".`,
4374
+ retriable: false
4375
+ };
4376
+ }
4377
+ }
4378
+ const mutations = [];
4379
+ if (spec.update !== void 0 && spec.update !== null) {
4380
+ mutations.push({
4381
+ collection: spec.collection,
4382
+ type: "update",
4383
+ recordId: spec.id,
4384
+ data: spec.update
4385
+ });
4386
+ }
4387
+ if (spec.also) {
4388
+ mutations.push(...spec.also);
4389
+ }
4390
+ const prepared = [];
4391
+ for (const mutation of mutations) {
4392
+ const p = await prepareMutation(mutation, options?.scope);
4393
+ if (!p.ok) {
4394
+ return p.rejection;
4395
+ }
4396
+ prepared.push({ op: p.op, collection: mutation.collection });
4397
+ }
4398
+ const operations2 = [];
4399
+ const records = [];
4400
+ for (const { op, collection } of prepared) {
4401
+ const result = await commitOperation(op, collection);
4402
+ if (!result.ok) {
4403
+ return {
4404
+ ok: false,
4405
+ code: result.code,
4406
+ message: result.message,
4407
+ retriable: result.retriable
4408
+ };
4409
+ }
4410
+ operations2.push(result.operation);
4411
+ records.push(result.record);
4412
+ }
4413
+ return { ok: true, idempotent: false, operations: operations2, records };
4414
+ });
4415
+ },
4416
+ async query(collection, options) {
4417
+ const { scope, ...queryOptions } = options ?? {};
4418
+ const records = await store.queryCollection(collection, queryOptions);
4419
+ if (!scope) {
4420
+ return records;
4421
+ }
4422
+ return records.filter((record) => recordMatchesScope(collection, record, scope));
4423
+ },
4424
+ async findById(collection, id, options) {
4425
+ const record = await store.findRecord(collection, id);
4426
+ if (!record) {
4427
+ return null;
4428
+ }
4429
+ if (options?.scope && !recordMatchesScope(collection, record, options.scope)) {
4430
+ return null;
4431
+ }
4432
+ return record;
4433
+ }
4434
+ };
4435
+ }
4436
+
3199
4437
  // src/server/kora-sync-server.ts
3200
4438
  var DEFAULT_MAX_CONNECTIONS = 0;
3201
4439
  var DEFAULT_BATCH_SIZE2 = 100;
3202
4440
  var DEFAULT_SCHEMA_VERSION2 = 1;
3203
4441
  var DEFAULT_HOST = "0.0.0.0";
3204
4442
  var DEFAULT_PATH = "/";
3205
- var KoraSyncServer = class {
4443
+ var DEFAULT_RELAY_RETRANSMIT_INTERVAL_MS = 2e3;
4444
+ var DEFAULT_DELIVERY_POLL_INTERVAL_MS = 2e3;
4445
+ function validateIntervalOption(name, value) {
4446
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
4447
+ throw new import_core14.SyncError(`${name} must be a non-negative integer number of milliseconds`, {
4448
+ [name]: value
4449
+ });
4450
+ }
4451
+ return value;
4452
+ }
4453
+ var KoraSyncServer = class _KoraSyncServer {
3206
4454
  store;
3207
4455
  auth;
3208
4456
  serializer;
3209
4457
  emitter;
3210
4458
  maxConnections;
3211
4459
  batchSize;
4460
+ relayRetransmitIntervalMs;
4461
+ deliveryPollIntervalMs;
3212
4462
  schemaVersion;
3213
4463
  supportedSchemaVersions;
4464
+ operationTransforms;
3214
4465
  port;
3215
4466
  host;
3216
4467
  path;
@@ -3220,24 +4471,63 @@ var KoraSyncServer = class {
3220
4471
  yjsDocRelay = new YjsDocRelay();
3221
4472
  blobChunkRelay;
3222
4473
  persistBlobChunk;
4474
+ maxOperationBytes;
4475
+ maxOpsPerMinute;
4476
+ validateOperation;
4477
+ koraContext;
3223
4478
  sessions = /* @__PURE__ */ new Map();
3224
4479
  httpClients = /* @__PURE__ */ new Map();
3225
4480
  httpSessionToClient = /* @__PURE__ */ new Map();
3226
- serverVersion = "0.4.0";
4481
+ // Informational value reported by getStatus(). Keep in sync with the
4482
+ // @korajs/server package version on release; it is not used for protocol negotiation.
4483
+ serverVersion = "1.0.0-beta.0";
3227
4484
  wsServer = null;
3228
4485
  running = false;
4486
+ /**
4487
+ * Periodic tick that retransmits relay batches connected clients have not
4488
+ * acknowledged, so a relay dropped by a lossy transport is redelivered instead of
4489
+ * leaving that client with a permanent version-vector gap (a lost operation).
4490
+ */
4491
+ relayRetransmitTimer = null;
4492
+ deliveryPollTimer = null;
4493
+ lastObservedDeliverySequence = 0;
4494
+ deliveryPollInFlight = false;
4495
+ /**
4496
+ * Relay operations a client never acknowledged before it disconnected, buffered by
4497
+ * client node id so they can be redelivered on its next connection. Bounded per
4498
+ * node and expired by age so a client that never returns cannot grow this forever.
4499
+ */
4500
+ orphanedRelaysByNode = /* @__PURE__ */ new Map();
4501
+ static MAX_ORPHANED_RELAY_OPS_PER_NODE = 5e3;
4502
+ static ORPHANED_RELAY_TTL_MS = 5 * 6e4;
3229
4503
  constructor(config) {
3230
4504
  this.store = config.store;
4505
+ const storeSchemaVersion = this.store.getSchema()?.version;
4506
+ if (config.schemaVersion !== void 0 && storeSchemaVersion !== void 0 && config.schemaVersion !== storeSchemaVersion) {
4507
+ throw new import_core14.SyncError("Sync schema version does not match the configured store schema.", {
4508
+ syncSchemaVersion: config.schemaVersion,
4509
+ storeSchemaVersion
4510
+ });
4511
+ }
3231
4512
  this.auth = config.auth ?? null;
3232
4513
  this.serializer = config.serializer ?? new import_sync5.JsonMessageSerializer();
3233
4514
  this.emitter = config.emitter ?? null;
3234
4515
  this.maxConnections = config.maxConnections ?? DEFAULT_MAX_CONNECTIONS;
3235
4516
  this.batchSize = config.batchSize ?? DEFAULT_BATCH_SIZE2;
3236
- this.schemaVersion = config.schemaVersion ?? DEFAULT_SCHEMA_VERSION2;
4517
+ this.relayRetransmitIntervalMs = validateIntervalOption(
4518
+ "relayRetransmitIntervalMs",
4519
+ config.relayRetransmitIntervalMs ?? DEFAULT_RELAY_RETRANSMIT_INTERVAL_MS
4520
+ );
4521
+ this.deliveryPollIntervalMs = validateIntervalOption(
4522
+ "deliveryPollIntervalMs",
4523
+ config.deliveryPollIntervalMs ?? DEFAULT_DELIVERY_POLL_INTERVAL_MS
4524
+ );
4525
+ this.schemaVersion = config.schemaVersion ?? storeSchemaVersion ?? DEFAULT_SCHEMA_VERSION2;
3237
4526
  this.supportedSchemaVersions = config.supportedSchemaVersions ?? {
3238
4527
  min: this.schemaVersion,
3239
4528
  max: this.schemaVersion
3240
4529
  };
4530
+ this.operationTransforms = config.operationTransforms ?? [];
3241
4531
  this.port = config.port;
3242
4532
  this.host = config.host ?? DEFAULT_HOST;
3243
4533
  this.path = config.path ?? DEFAULT_PATH;
@@ -3246,10 +4536,116 @@ var KoraSyncServer = class {
3246
4536
  this.metrics.setSchemaVersion(this.schemaVersion);
3247
4537
  this.blobChunkRelay = new BlobChunkRelay(config.resolveBlobChunk);
3248
4538
  this.persistBlobChunk = config.persistBlobChunk ?? null;
4539
+ this.maxOperationBytes = config.maxOperationBytes;
4540
+ this.maxOpsPerMinute = config.maxOpsPerMinute;
4541
+ this.validateOperation = config.validateOperation;
4542
+ this.koraContext = createRouteContext(this, this.store);
3249
4543
  if (!this.emitter) {
3250
4544
  this.emitter = new import_internal2.SimpleEventEmitter();
3251
4545
  }
3252
4546
  }
4547
+ ensureBackgroundTimersStarted() {
4548
+ if (this.relayRetransmitIntervalMs > 0 && !this.relayRetransmitTimer) {
4549
+ this.relayRetransmitTimer = setInterval(() => {
4550
+ this.retransmitPendingRelays(this.relayRetransmitIntervalMs);
4551
+ }, this.relayRetransmitIntervalMs);
4552
+ this.relayRetransmitTimer.unref?.();
4553
+ }
4554
+ if (this.deliveryPollIntervalMs > 0 && !this.deliveryPollTimer) {
4555
+ this.deliveryPollTimer = setInterval(() => {
4556
+ void this.pollDeliveryLog();
4557
+ }, this.deliveryPollIntervalMs);
4558
+ this.deliveryPollTimer.unref?.();
4559
+ }
4560
+ }
4561
+ /**
4562
+ * Retransmit relay batches that connected clients have not acknowledged within
4563
+ * `staleMs`. Called on a periodic tick and directly by tests. Redelivering an
4564
+ * already-applied operation is harmless (clients dedup by content-addressed id).
4565
+ */
4566
+ retransmitPendingRelays(staleMs = 0) {
4567
+ for (const session of this.sessions.values()) {
4568
+ session.retransmitPendingRelays(staleMs);
4569
+ }
4570
+ this.expireOrphanedRelays();
4571
+ }
4572
+ /**
4573
+ * Check whether the authoritative delivery log advanced without passing through
4574
+ * this server instance, then wake delivery-watermark sessions to scan from their
4575
+ * own acknowledged cursors. Session-level filtering remains the only fan-out path.
4576
+ */
4577
+ async pollDeliveryLog() {
4578
+ if (this.deliveryPollInFlight) return;
4579
+ this.deliveryPollInFlight = true;
4580
+ try {
4581
+ const maxDeliverySequence = await this.store.getMaxDeliverySequence();
4582
+ if (maxDeliverySequence <= this.lastObservedDeliverySequence) {
4583
+ for (const session of this.sessions.values()) {
4584
+ session.pushDeliveryStreamIfSupported(this.deliveryPollIntervalMs, {
4585
+ trackStall: true
4586
+ });
4587
+ }
4588
+ return;
4589
+ }
4590
+ this.lastObservedDeliverySequence = maxDeliverySequence;
4591
+ for (const session of this.sessions.values()) {
4592
+ session.pushDeliveryStreamIfSupported(0);
4593
+ }
4594
+ } catch (error) {
4595
+ this.logger.log({
4596
+ timestamp: Date.now(),
4597
+ level: "warn",
4598
+ event: "delivery_poll.failed",
4599
+ details: { error: error instanceof Error ? error.message : String(error) }
4600
+ });
4601
+ } finally {
4602
+ this.deliveryPollInFlight = false;
4603
+ }
4604
+ }
4605
+ /**
4606
+ * Buffer relay operations a disconnected client never acknowledged, keyed by its
4607
+ * node id, deduped by operation id and bounded by count. Redelivered when the
4608
+ * client reconnects (see {@link takeOrphanedRelays}).
4609
+ */
4610
+ bufferOrphanedRelays(nodeId, ops) {
4611
+ if (ops.length === 0) return;
4612
+ const existing = this.orphanedRelaysByNode.get(nodeId);
4613
+ const merged = existing ? existing.ops : [];
4614
+ const seen = new Set(merged.map((op) => op.id));
4615
+ for (const op of ops) {
4616
+ if (!seen.has(op.id)) {
4617
+ merged.push(op);
4618
+ seen.add(op.id);
4619
+ }
4620
+ }
4621
+ const bounded = merged.length > _KoraSyncServer.MAX_ORPHANED_RELAY_OPS_PER_NODE ? merged.slice(merged.length - _KoraSyncServer.MAX_ORPHANED_RELAY_OPS_PER_NODE) : merged;
4622
+ this.orphanedRelaysByNode.set(nodeId, { ops: bounded, bufferedAtMs: Date.now() });
4623
+ }
4624
+ /** Remove and return a node's buffered orphaned relay operations, if any. */
4625
+ takeOrphanedRelays(nodeId) {
4626
+ const entry = this.orphanedRelaysByNode.get(nodeId);
4627
+ if (!entry) return [];
4628
+ this.orphanedRelaysByNode.delete(nodeId);
4629
+ return entry.ops;
4630
+ }
4631
+ /** Drop buffered orphaned relays older than the TTL (client never returned). */
4632
+ expireOrphanedRelays() {
4633
+ if (this.orphanedRelaysByNode.size === 0) return;
4634
+ const cutoff = Date.now() - _KoraSyncServer.ORPHANED_RELAY_TTL_MS;
4635
+ for (const [nodeId, entry] of this.orphanedRelaysByNode) {
4636
+ if (entry.bufferedAtMs <= cutoff) {
4637
+ this.orphanedRelaysByNode.delete(nodeId);
4638
+ }
4639
+ }
4640
+ }
4641
+ /**
4642
+ * The trusted, scoped data-plane context (`apply` / `query` / `findById`).
4643
+ * Shared with the production server so HTTP routes, the operation validator,
4644
+ * and `server.kora` are all the same object over the same pipeline.
4645
+ */
4646
+ getKoraContext() {
4647
+ return this.koraContext;
4648
+ }
3253
4649
  /**
3254
4650
  * Subscribe to session-level events for metrics collection and logging.
3255
4651
  * Called when a new session is created.
@@ -3298,6 +4694,21 @@ var KoraSyncServer = class {
3298
4694
  details: { reason: event.reason }
3299
4695
  });
3300
4696
  });
4697
+ sessionEmitter.on("sync:schema-mismatch", (event) => {
4698
+ this.logger.log({
4699
+ timestamp: Date.now(),
4700
+ level: "warn",
4701
+ event: "session.schema_mismatch",
4702
+ sessionId,
4703
+ details: {
4704
+ clientSchemaVersion: event.clientSchemaVersion,
4705
+ serverSchemaVersion: event.serverSchemaVersion,
4706
+ supportedMin: event.supportedMin,
4707
+ supportedMax: event.supportedMax,
4708
+ reason: event.reason
4709
+ }
4710
+ });
4711
+ });
3301
4712
  }
3302
4713
  /**
3303
4714
  * Get the metrics collector for external access (e.g., HTTP endpoints).
@@ -3318,10 +4729,10 @@ var KoraSyncServer = class {
3318
4729
  */
3319
4730
  async start(wsServerImpl) {
3320
4731
  if (this.running) {
3321
- throw new import_core12.SyncError("Server is already running", { port: this.port });
4732
+ throw new import_core14.SyncError("Server is already running", { port: this.port });
3322
4733
  }
3323
4734
  if (!wsServerImpl && this.port === void 0) {
3324
- throw new import_core12.SyncError(
4735
+ throw new import_core14.SyncError(
3325
4736
  "Port is required for standalone mode. Provide port in config or use handleConnection() for attach mode.",
3326
4737
  {}
3327
4738
  );
@@ -3367,6 +4778,15 @@ var KoraSyncServer = class {
3367
4778
  event: "server.stopping",
3368
4779
  details: { connectedClients: this.sessions.size }
3369
4780
  });
4781
+ if (this.relayRetransmitTimer) {
4782
+ clearInterval(this.relayRetransmitTimer);
4783
+ this.relayRetransmitTimer = null;
4784
+ }
4785
+ if (this.deliveryPollTimer) {
4786
+ clearInterval(this.deliveryPollTimer);
4787
+ this.deliveryPollTimer = null;
4788
+ }
4789
+ this.orphanedRelaysByNode.clear();
3370
4790
  this.awarenessRelay.clear();
3371
4791
  this.yjsDocRelay.clear();
3372
4792
  this.blobChunkRelay.clear();
@@ -3431,7 +4851,7 @@ var KoraSyncServer = class {
3431
4851
  if (this.maxConnections > 0 && this.sessions.size >= this.maxConnections) {
3432
4852
  transport.send({
3433
4853
  type: "error",
3434
- messageId: (0, import_core12.generateUUIDv7)(),
4854
+ messageId: (0, import_core14.generateUUIDv7)(),
3435
4855
  code: "MAX_CONNECTIONS",
3436
4856
  message: `Server has reached maximum connections (${this.maxConnections})`,
3437
4857
  retriable: true
@@ -3444,12 +4864,13 @@ var KoraSyncServer = class {
3444
4864
  event: "connection.rejected",
3445
4865
  details: { reason: "max_connections", max: this.maxConnections }
3446
4866
  });
3447
- throw new import_core12.SyncError("Maximum connections reached", {
4867
+ throw new import_core14.SyncError("Maximum connections reached", {
3448
4868
  current: this.sessions.size,
3449
4869
  max: this.maxConnections
3450
4870
  });
3451
4871
  }
3452
- const sessionId = (0, import_core12.generateUUIDv7)();
4872
+ this.ensureBackgroundTimersStarted();
4873
+ const sessionId = (0, import_core14.generateUUIDv7)();
3453
4874
  this.metrics.recordConnection(sessionId);
3454
4875
  const sessionEmitter = new import_internal2.SimpleEventEmitter();
3455
4876
  sessionEmitter.on("sync:connected", (event) => {
@@ -3495,6 +4916,33 @@ var KoraSyncServer = class {
3495
4916
  sessionId
3496
4917
  });
3497
4918
  });
4919
+ sessionEmitter.on("sync:schema-mismatch", (event) => {
4920
+ this.logger.log({
4921
+ timestamp: Date.now(),
4922
+ level: "warn",
4923
+ event: "session.schema_mismatch",
4924
+ sessionId,
4925
+ details: {
4926
+ clientSchemaVersion: event.clientSchemaVersion,
4927
+ serverSchemaVersion: event.serverSchemaVersion,
4928
+ supportedMin: event.supportedMin,
4929
+ supportedMax: event.supportedMax,
4930
+ reason: event.reason
4931
+ }
4932
+ });
4933
+ });
4934
+ sessionEmitter.on("sync:delivery-stalled", (event) => {
4935
+ this.logger.log({
4936
+ timestamp: Date.now(),
4937
+ level: "warn",
4938
+ event: "session.delivery_stalled",
4939
+ sessionId,
4940
+ details: {
4941
+ watermark: event.watermark,
4942
+ repeatCount: event.repeatCount
4943
+ }
4944
+ });
4945
+ });
3498
4946
  const session = new ClientSession({
3499
4947
  sessionId,
3500
4948
  transport,
@@ -3502,9 +4950,11 @@ var KoraSyncServer = class {
3502
4950
  auth: this.auth ?? void 0,
3503
4951
  serializer: this.serializer,
3504
4952
  emitter: sessionEmitter,
4953
+ logger: this.logger,
3505
4954
  batchSize: this.batchSize,
3506
4955
  schemaVersion: this.schemaVersion,
3507
4956
  supportedSchemaVersions: this.supportedSchemaVersions,
4957
+ operationTransforms: this.operationTransforms,
3508
4958
  onRelay: (sourceSessionId, operations2) => {
3509
4959
  this.handleRelay(sourceSessionId, operations2);
3510
4960
  },
@@ -3521,9 +4971,18 @@ var KoraSyncServer = class {
3521
4971
  this.blobChunkRelay.handleResponse(sourceSessionId, message);
3522
4972
  },
3523
4973
  ...this.persistBlobChunk ? { persistBlobChunk: this.persistBlobChunk } : {},
4974
+ // Only forward when configured, so an unset server value leaves the
4975
+ // session on its own documented default rather than `undefined`.
4976
+ ...this.maxOperationBytes !== void 0 ? { maxOperationBytes: this.maxOperationBytes } : {},
4977
+ ...this.maxOpsPerMinute !== void 0 ? { maxOpsPerMinute: this.maxOpsPerMinute } : {},
4978
+ ...this.validateOperation ? { validateOperation: this.validateOperation, koraContext: this.koraContext } : {},
3524
4979
  onClose: (sid) => {
3525
4980
  this.handleSessionClose(sid);
3526
- }
4981
+ },
4982
+ onOrphanedRelays: (nodeId, ops) => {
4983
+ this.bufferOrphanedRelays(nodeId, ops);
4984
+ },
4985
+ takeOrphanedRelays: (nodeId) => this.takeOrphanedRelays(nodeId)
3527
4986
  });
3528
4987
  this.sessions.set(sessionId, session);
3529
4988
  this.yjsDocRelay.addClient(sessionId, transport);
@@ -3583,6 +5042,22 @@ var KoraSyncServer = class {
3583
5042
  }
3584
5043
  return result;
3585
5044
  }
5045
+ /**
5046
+ * Relay already-applied, server-originated operations to every connected client.
5047
+ * Used by the conditional route apply, which commits its operations atomically
5048
+ * through the store (bypassing {@link applyLocalOperation}) and then fans them
5049
+ * out. Each session still enforces its own per-scope visibility filter.
5050
+ *
5051
+ * @param operations - Operations that have already been committed to the store
5052
+ */
5053
+ relayServerOperations(operations2) {
5054
+ if (operations2.length === 0) {
5055
+ return;
5056
+ }
5057
+ for (const session of this.sessions.values()) {
5058
+ session.relayOperations(operations2);
5059
+ }
5060
+ }
3586
5061
  /**
3587
5062
  * Collect every blob reference still reachable from live records on the server.
3588
5063
  *
@@ -3607,7 +5082,7 @@ var KoraSyncServer = class {
3607
5082
  const records = await this.store.materializeCollection(name);
3608
5083
  for (const record of records) {
3609
5084
  for (const value of Object.values(record)) {
3610
- if ((0, import_core12.isBlobRef)(value)) {
5085
+ if ((0, import_core14.isBlobRef)(value)) {
3611
5086
  refs.push(value);
3612
5087
  }
3613
5088
  }
@@ -3787,162 +5262,6 @@ function createKoraServer(config) {
3787
5262
 
3788
5263
  // src/server/production-server.ts
3789
5264
  init_cjs_shims();
3790
-
3791
- // src/server/route-context.ts
3792
- init_cjs_shims();
3793
- var import_core13 = require("@korajs/core");
3794
- function recordMatchesScope(collection, record, scope) {
3795
- const collectionScope = scope[collection];
3796
- if (!collectionScope) {
3797
- return false;
3798
- }
3799
- for (const [field, expected] of Object.entries(collectionScope)) {
3800
- if (record[field] !== expected) {
3801
- return false;
3802
- }
3803
- }
3804
- return true;
3805
- }
3806
- function pickFields(record, keys) {
3807
- const picked = {};
3808
- for (const key of keys) {
3809
- if (key in record) {
3810
- picked[key] = record[key];
3811
- }
3812
- }
3813
- return picked;
3814
- }
3815
- async function buildRouteOperation(store, mutation) {
3816
- const nodeId = store.getNodeId();
3817
- const clock = new import_core13.HybridLogicalClock(nodeId);
3818
- const schemaVersion = store.getSchema()?.version ?? 1;
3819
- const sequenceNumber = nextServerSequenceNumber(store);
3820
- if (mutation.type === "insert") {
3821
- return (0, import_core13.createOperation)(
3822
- {
3823
- nodeId,
3824
- type: "insert",
3825
- collection: mutation.collection,
3826
- recordId: mutation.recordId ?? (0, import_core13.generateUUIDv7)(),
3827
- data: mutation.data ?? {},
3828
- previousData: null,
3829
- sequenceNumber,
3830
- causalDeps: [],
3831
- schemaVersion
3832
- },
3833
- clock
3834
- );
3835
- }
3836
- if (!mutation.recordId) {
3837
- throw new RouteMutationError(
3838
- "MISSING_RECORD_ID",
3839
- `A ${mutation.type} mutation on "${mutation.collection}" requires a recordId.`
3840
- );
3841
- }
3842
- const current = await store.findRecord(mutation.collection, mutation.recordId);
3843
- if (mutation.type === "update") {
3844
- const data = mutation.data ?? {};
3845
- return (0, import_core13.createOperation)(
3846
- {
3847
- nodeId,
3848
- type: "update",
3849
- collection: mutation.collection,
3850
- recordId: mutation.recordId,
3851
- data,
3852
- previousData: current ? pickFields(current, Object.keys(data)) : {},
3853
- sequenceNumber,
3854
- causalDeps: [],
3855
- schemaVersion
3856
- },
3857
- clock
3858
- );
3859
- }
3860
- return (0, import_core13.createOperation)(
3861
- {
3862
- nodeId,
3863
- type: "delete",
3864
- collection: mutation.collection,
3865
- recordId: mutation.recordId,
3866
- data: null,
3867
- previousData: current ? { ...current } : null,
3868
- sequenceNumber,
3869
- causalDeps: [],
3870
- schemaVersion
3871
- },
3872
- clock
3873
- );
3874
- }
3875
- var RouteMutationError = class extends Error {
3876
- constructor(code, message) {
3877
- super(message);
3878
- this.code = code;
3879
- this.name = "RouteMutationError";
3880
- }
3881
- code;
3882
- };
3883
- function createRouteContext(server, store) {
3884
- let mutationTail = Promise.resolve();
3885
- function serialize(work) {
3886
- const run = mutationTail.then(work, work);
3887
- mutationTail = run.then(
3888
- () => void 0,
3889
- () => void 0
3890
- );
3891
- return run;
3892
- }
3893
- return {
3894
- apply(mutation, options) {
3895
- return serialize(async () => {
3896
- let op;
3897
- try {
3898
- op = await buildRouteOperation(store, mutation);
3899
- } catch (error) {
3900
- if (error instanceof RouteMutationError) {
3901
- return { ok: false, code: error.code, message: error.message };
3902
- }
3903
- throw error;
3904
- }
3905
- if (options?.scope && !operationMatchesScopes(op, options.scope)) {
3906
- return {
3907
- ok: false,
3908
- code: "SCOPE_VIOLATION",
3909
- message: `Mutation on "${mutation.collection}" is outside the provided scope.`
3910
- };
3911
- }
3912
- const result = await server.applyLocalOperation(op);
3913
- if (result.result !== "applied") {
3914
- return {
3915
- ok: false,
3916
- code: result.rejection?.code ?? "NOT_APPLIED",
3917
- message: result.rejection?.message ?? `Operation on "${mutation.collection}" was not applied (${result.result}).`
3918
- };
3919
- }
3920
- const record = await store.findRecord(mutation.collection, op.recordId);
3921
- return { ok: true, operation: op, record };
3922
- });
3923
- },
3924
- async query(collection, options) {
3925
- const { scope, ...queryOptions } = options ?? {};
3926
- const records = await store.queryCollection(collection, queryOptions);
3927
- if (!scope) {
3928
- return records;
3929
- }
3930
- return records.filter((record) => recordMatchesScope(collection, record, scope));
3931
- },
3932
- async findById(collection, id, options) {
3933
- const record = await store.findRecord(collection, id);
3934
- if (!record) {
3935
- return null;
3936
- }
3937
- if (options?.scope && !recordMatchesScope(collection, record, options.scope)) {
3938
- return null;
3939
- }
3940
- return record;
3941
- }
3942
- };
3943
- }
3944
-
3945
- // src/server/production-server.ts
3946
5265
  var MIME_TYPES = {
3947
5266
  ".html": "text/html",
3948
5267
  ".js": "text/javascript",
@@ -3961,12 +5280,13 @@ function createProductionServer(config) {
3961
5280
  const port = config.port ?? (Number(process.env.PORT) || 3001);
3962
5281
  const staticDir = config.staticDir ?? "./dist";
3963
5282
  const syncPath = config.syncPath ?? "/kora-sync";
5283
+ const crossOriginEmbedderPolicy = config.crossOriginEmbedderPolicy ?? "credentialless";
3964
5284
  const syncServer = new KoraSyncServer({
3965
5285
  store: config.store,
3966
5286
  enableDashboard: true,
3967
5287
  ...config.syncOptions
3968
5288
  });
3969
- const routeContext = createRouteContext(syncServer, config.store);
5289
+ const routeContext = syncServer.getKoraContext();
3970
5290
  let httpServer = null;
3971
5291
  function getOperationalToken(kind) {
3972
5292
  const auth = config.operationalAuth;
@@ -4106,6 +5426,10 @@ function createProductionServer(config) {
4106
5426
  res.end(JSON.stringify(result.body ?? null));
4107
5427
  }
4108
5428
  return {
5429
+ kora: routeContext,
5430
+ getLiveBlobRefs() {
5431
+ return syncServer.getLiveBlobRefs();
5432
+ },
4109
5433
  async start() {
4110
5434
  const { createServer } = await import("http");
4111
5435
  const { createReadStream, existsSync, statSync } = await import("fs");
@@ -4127,7 +5451,7 @@ function createProductionServer(config) {
4127
5451
  });
4128
5452
  async function handleRequest(req, res) {
4129
5453
  res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
4130
- res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
5454
+ res.setHeader("Cross-Origin-Embedder-Policy", crossOriginEmbedderPolicy);
4131
5455
  const url = new URL(req.url || "/", `http://${req.headers.host}`);
4132
5456
  if (url.pathname === "/health") {
4133
5457
  const status = await syncServer.getStatus();
@@ -4301,8 +5625,13 @@ data: ${JSON.stringify(s)}
4301
5625
  socket.destroy();
4302
5626
  }
4303
5627
  });
4304
- return new Promise((resolve2) => {
5628
+ return new Promise((resolve2, reject) => {
5629
+ const onError = (error) => {
5630
+ reject(error);
5631
+ };
5632
+ httpServer?.once("error", onError);
4305
5633
  httpServer?.listen(port, "0.0.0.0", () => {
5634
+ httpServer?.off("error", onError);
4306
5635
  resolve2(`http://localhost:${port}`);
4307
5636
  });
4308
5637
  });
@@ -4405,6 +5734,7 @@ function formatUptime(ms) {
4405
5734
  MixedAuthProvider,
4406
5735
  NoAuthProvider,
4407
5736
  PostgresServerStore,
5737
+ RETRIABLE_REJECTION_CODES,
4408
5738
  SqliteServerStore,
4409
5739
  TokenAuthProvider,
4410
5740
  WsServerTransport,
@@ -4415,6 +5745,7 @@ function formatUptime(ms) {
4415
5745
  createPrettyLogger,
4416
5746
  createProductionServer,
4417
5747
  createSilentLogger,
4418
- createSqliteServerStore
5748
+ createSqliteServerStore,
5749
+ isRetriableRejection
4419
5750
  });
4420
5751
  //# sourceMappingURL=index.cjs.map