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

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
  };
@@ -169,6 +169,7 @@ var index_exports = {};
169
169
  __export(index_exports, {
170
170
  AwarenessRelay: () => AwarenessRelay,
171
171
  ClientSession: () => ClientSession,
172
+ DEFAULT_MAX_SCOPE_PREDICATE_VALUES: () => DEFAULT_MAX_SCOPE_PREDICATE_VALUES,
172
173
  HttpServerTransport: () => HttpServerTransport,
173
174
  KoraAuthProvider: () => KoraAuthProvider,
174
175
  KoraSyncServer: () => KoraSyncServer,
@@ -176,6 +177,7 @@ __export(index_exports, {
176
177
  MixedAuthProvider: () => MixedAuthProvider,
177
178
  NoAuthProvider: () => NoAuthProvider,
178
179
  PostgresServerStore: () => PostgresServerStore,
180
+ RETRIABLE_REJECTION_CODES: () => RETRIABLE_REJECTION_CODES,
179
181
  SqliteServerStore: () => SqliteServerStore,
180
182
  TokenAuthProvider: () => TokenAuthProvider,
181
183
  WsServerTransport: () => WsServerTransport,
@@ -186,7 +188,10 @@ __export(index_exports, {
186
188
  createPrettyLogger: () => createPrettyLogger,
187
189
  createProductionServer: () => createProductionServer,
188
190
  createSilentLogger: () => createSilentLogger,
189
- createSqliteServerStore: () => createSqliteServerStore
191
+ createSqliteServerStore: () => createSqliteServerStore,
192
+ isRetriableRejection: () => isRetriableRejection,
193
+ normalizeScopeMap: () => normalizeScopeMap,
194
+ operationMatchesScopes: () => operationMatchesScopes
190
195
  });
191
196
  module.exports = __toCommonJS(index_exports);
192
197
  init_cjs_shims();
@@ -248,6 +253,13 @@ function createDefaultLogger() {
248
253
  }
249
254
  }
250
255
 
256
+ // src/apply/rejection-taxonomy.ts
257
+ init_cjs_shims();
258
+ var RETRIABLE_REJECTION_CODES = /* @__PURE__ */ new Set(["RATE_LIMIT"]);
259
+ function isRetriableRejection(code) {
260
+ return RETRIABLE_REJECTION_CODES.has(code);
261
+ }
262
+
251
263
  // src/store/memory-server-store.ts
252
264
  init_cjs_shims();
253
265
  var import_core2 = require("@korajs/core");
@@ -269,6 +281,14 @@ function fieldTypeToSql(descriptor, dialect) {
269
281
  return dialect === "postgres" ? "BIGINT" : "INTEGER";
270
282
  case "array":
271
283
  return dialect === "postgres" ? "JSONB" : "TEXT";
284
+ case "object":
285
+ return dialect === "postgres" ? "JSONB" : "TEXT";
286
+ case "json":
287
+ return dialect === "postgres" ? "JSONB" : "TEXT";
288
+ case "blob":
289
+ return "TEXT";
290
+ case "secret":
291
+ return "TEXT";
272
292
  case "richtext":
273
293
  return dialect === "postgres" ? "BYTEA" : "BLOB";
274
294
  }
@@ -285,13 +305,13 @@ function generateCollectionDDL(name, collection, dialect) {
285
305
  const columns = ["id TEXT PRIMARY KEY NOT NULL"];
286
306
  for (const [fieldName, descriptor] of Object.entries(collection.fields)) {
287
307
  const sqlType = fieldTypeToSql(descriptor, dialect);
288
- let colDef = `${fieldName} ${sqlType}`;
308
+ let colDef = `${(0, import_core.quoteIdent)(fieldName)} ${sqlType}`;
289
309
  if (descriptor.defaultValue !== void 0) {
290
310
  colDef += ` DEFAULT ${sqlDefaultLiteral(descriptor.defaultValue)}`;
291
311
  }
292
312
  if (descriptor.kind === "enum" && descriptor.enumValues) {
293
313
  const values = descriptor.enumValues.map((v) => `'${v}'`).join(", ");
294
- colDef += ` CHECK (${fieldName} IN (${values}))`;
314
+ colDef += ` CHECK (${(0, import_core.quoteIdent)(fieldName)} IN (${values}))`;
295
315
  }
296
316
  columns.push(colDef);
297
317
  }
@@ -299,24 +319,26 @@ function generateCollectionDDL(name, collection, dialect) {
299
319
  columns.push(`_created_at ${tsType} NOT NULL DEFAULT 0`);
300
320
  columns.push(`_updated_at ${tsType} NOT NULL DEFAULT 0`);
301
321
  columns.push("_deleted INTEGER NOT NULL DEFAULT 0");
302
- statements.push(`CREATE TABLE IF NOT EXISTS ${name} (
322
+ statements.push(`CREATE TABLE IF NOT EXISTS ${(0, import_core.quoteIdent)(name)} (
303
323
  ${columns.join(",\n ")}
304
324
  )`);
305
325
  for (const [fieldName, descriptor] of Object.entries(collection.fields)) {
306
326
  const sqlType = fieldTypeToSql(descriptor, dialect);
307
- let colDef = `${fieldName} ${sqlType}`;
327
+ let colDef = `${(0, import_core.quoteIdent)(fieldName)} ${sqlType}`;
308
328
  if (descriptor.defaultValue !== void 0) {
309
329
  colDef += ` DEFAULT ${sqlDefaultLiteral(descriptor.defaultValue)}`;
310
330
  }
311
331
  statements.push(`--kora:safe-alter
312
- ALTER TABLE ${name} ADD COLUMN ${colDef}`);
332
+ ALTER TABLE ${(0, import_core.quoteIdent)(name)} ADD COLUMN ${colDef}`);
313
333
  }
314
334
  for (const indexField of collection.indexes) {
315
335
  statements.push(
316
- `CREATE INDEX IF NOT EXISTS idx_${name}_${indexField} ON ${name} (${indexField})`
336
+ `CREATE INDEX IF NOT EXISTS idx_${name}_${indexField} ON ${(0, import_core.quoteIdent)(name)} (${(0, import_core.quoteIdent)(indexField)})`
317
337
  );
318
338
  }
319
- statements.push(`CREATE INDEX IF NOT EXISTS idx_${name}__deleted ON ${name} (_deleted)`);
339
+ statements.push(
340
+ `CREATE INDEX IF NOT EXISTS idx_${name}__deleted ON ${(0, import_core.quoteIdent)(name)} (_deleted)`
341
+ );
320
342
  return statements;
321
343
  }
322
344
  function generateAllCollectionDDL(schema, dialect) {
@@ -326,30 +348,6 @@ function generateAllCollectionDDL(schema, dialect) {
326
348
  }
327
349
  return statements;
328
350
  }
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
351
  var BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
354
352
  var BASE64_REVERSE = new Map(
355
353
  Array.from(BASE64_ALPHABET, (char, index3) => [char, index3])
@@ -400,6 +398,9 @@ function serializeFieldValue(value, descriptor) {
400
398
  if (value === null || value === void 0) return null;
401
399
  switch (descriptor.kind) {
402
400
  case "array":
401
+ case "object":
402
+ case "json":
403
+ case "blob":
403
404
  return typeof value === "string" ? value : JSON.stringify(value);
404
405
  case "boolean":
405
406
  return value ? 1 : 0;
@@ -413,6 +414,9 @@ function deserializeFieldValue(value, descriptor) {
413
414
  if (value === null || value === void 0) return null;
414
415
  switch (descriptor.kind) {
415
416
  case "array":
417
+ case "object":
418
+ case "json":
419
+ case "blob":
416
420
  return typeof value === "string" ? JSON.parse(value) : value;
417
421
  case "boolean":
418
422
  return value === 1 || value === true;
@@ -445,6 +449,14 @@ var MemoryServerStore = class {
445
449
  operations = [];
446
450
  operationIndex = /* @__PURE__ */ new Map();
447
451
  versionVector = /* @__PURE__ */ new Map();
452
+ /**
453
+ * Server-assigned delivery sequence per operation id. Single-process, so a plain
454
+ * counter incremented at insert time gives commit-order delivery sequence with no
455
+ * gaps. `operations` is kept in insertion (== delivery) order, so a delivery scan
456
+ * is a forward walk.
457
+ */
458
+ deliverySeqByOpId = /* @__PURE__ */ new Map();
459
+ deliverySeqCounter = 0;
448
460
  schema = null;
449
461
  /** Materialized records: collection -> recordId -> record data */
450
462
  materializedRecords = /* @__PURE__ */ new Map();
@@ -478,6 +490,8 @@ var MemoryServerStore = class {
478
490
  }
479
491
  this.operations.push(op);
480
492
  this.operationIndex.set(op.id, op);
493
+ this.deliverySeqCounter += 1;
494
+ this.deliverySeqByOpId.set(op.id, this.deliverySeqCounter);
481
495
  const currentSeq = this.versionVector.get(op.nodeId) ?? 0;
482
496
  if (op.sequenceNumber > currentSeq) {
483
497
  this.versionVector.set(op.nodeId, op.sequenceNumber);
@@ -497,6 +511,23 @@ var MemoryServerStore = class {
497
511
  this.assertOpen();
498
512
  return this.operations.length;
499
513
  }
514
+ async getMaxDeliverySequence() {
515
+ this.assertOpen();
516
+ return this.deliverySeqCounter;
517
+ }
518
+ async getOperationsAfterDelivery(afterDeliverySequence, limit) {
519
+ this.assertOpen();
520
+ const result = [];
521
+ if (limit <= 0) return result;
522
+ for (const op of this.operations) {
523
+ const deliverySequence = this.deliverySeqByOpId.get(op.id) ?? 0;
524
+ if (deliverySequence > afterDeliverySequence) {
525
+ result.push({ operation: op, deliverySequence });
526
+ if (result.length >= limit) break;
527
+ }
528
+ }
529
+ return result;
530
+ }
500
531
  async materializeCollection(collection) {
501
532
  this.assertOpen();
502
533
  if (this.schema?.collections[collection]) {
@@ -641,12 +672,16 @@ var MemoryServerStore = class {
641
672
  this.operations.length = 0;
642
673
  this.operationIndex.clear();
643
674
  this.versionVector.clear();
675
+ this.deliverySeqByOpId.clear();
676
+ this.deliverySeqCounter = 0;
644
677
  for (const [nid, seq] of versionVector) {
645
678
  this.versionVector.set(nid, seq);
646
679
  }
647
680
  for (const op of operations2) {
648
681
  this.operations.push(op);
649
682
  this.operationIndex.set(op.id, op);
683
+ this.deliverySeqCounter += 1;
684
+ this.deliverySeqByOpId.set(op.id, this.deliverySeqCounter);
650
685
  if (this.schema?.collections[op.collection]) {
651
686
  this.rebuildMaterializedRecord(op.collection, op.recordId);
652
687
  }
@@ -676,13 +711,14 @@ var MemoryServerStore = class {
676
711
  return a.timestamp.wallTime - b.timestamp.wallTime;
677
712
  if (a.timestamp.logical !== b.timestamp.logical)
678
713
  return a.timestamp.logical - b.timestamp.logical;
679
- return a.sequenceNumber - b.sequenceNumber;
714
+ return a.timestamp.nodeId < b.timestamp.nodeId ? -1 : a.timestamp.nodeId > b.timestamp.nodeId ? 1 : 0;
680
715
  });
681
716
  const parsedOps = recordOps.map((op) => ({
682
717
  type: op.type,
683
- data: op.data
718
+ data: op.data,
719
+ atomicOps: op.atomicOps ?? null
684
720
  }));
685
- const recordData = replayOperationsForRecord(parsedOps);
721
+ const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
686
722
  if (recordData) {
687
723
  const createdAt = recordOps.length > 0 ? recordOps[0].timestamp.wallTime : Date.now();
688
724
  const updatedAt = recordOps.length > 0 ? recordOps[recordOps.length - 1].timestamp.wallTime : Date.now();
@@ -805,6 +841,8 @@ var pgOperations = (0, import_pg_core.pgTable)(
805
841
  // JSON-serialized, null for deletes
806
842
  previousData: (0, import_pg_core.text)("previous_data"),
807
843
  // JSON-serialized, null for insert/delete
844
+ atomicOps: (0, import_pg_core.text)("atomic_ops"),
845
+ // JSON-serialized Record<field, AtomicOp>, null when none
808
846
  wallTime: (0, import_pg_core.bigint)("wall_time", { mode: "number" }).notNull(),
809
847
  logical: (0, import_pg_core.integer)("logical").notNull(),
810
848
  timestampNodeId: (0, import_pg_core.text)("timestamp_node_id").notNull(),
@@ -812,12 +850,18 @@ var pgOperations = (0, import_pg_core.pgTable)(
812
850
  causalDeps: (0, import_pg_core.text)("causal_deps").notNull().default("[]"),
813
851
  // JSON array of op IDs
814
852
  schemaVersion: (0, import_pg_core.integer)("schema_version").notNull(),
815
- receivedAt: (0, import_pg_core.bigint)("received_at", { mode: "number" }).notNull()
853
+ receivedAt: (0, import_pg_core.bigint)("received_at", { mode: "number" }).notNull(),
854
+ // Server-assigned monotonic delivery sequence, ordered by commit (assigned from
855
+ // a counter row locked inside the append transaction, so delivery order equals
856
+ // visibility order even across instances). Drives the gap-free server->client
857
+ // delivery watermark. Nullable only for pre-column rows, backfilled on startup.
858
+ deliverySeq: (0, import_pg_core.bigint)("delivery_seq", { mode: "number" })
816
859
  },
817
860
  (table) => ({
818
861
  nodeSeqIdx: (0, import_pg_core.index)("idx_pg_node_seq").on(table.nodeId, table.sequenceNumber),
819
862
  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)
863
+ receivedIdx: (0, import_pg_core.index)("idx_pg_received").on(table.receivedAt),
864
+ deliveryIdx: (0, import_pg_core.index)("idx_pg_delivery_seq").on(table.deliverySeq)
821
865
  })
822
866
  );
823
867
  var pgSyncState = (0, import_pg_core.pgTable)("sync_state", {
@@ -834,15 +878,43 @@ var PostgresServerStore = class {
834
878
  ready;
835
879
  schema = null;
836
880
  closed = false;
837
- constructor(db, nodeId) {
881
+ /**
882
+ * Monotonic counter for this node's server-originated sequence numbers. Lazily
883
+ * seeded from the persisted version vector on first use, then advanced in memory
884
+ * (a synchronous `++`, atomic on the single JS thread) so concurrent conditional
885
+ * applies never receive the same number. Null means "not yet seeded"; reset to
886
+ * null after a backup import so it re-seeds from the restored version vector.
887
+ */
888
+ sequenceCounter = null;
889
+ /**
890
+ * Time source for HLC timestamps on server-originated operations. Injectable so a
891
+ * test can freeze wall-clock time and prove the conditional-apply ordering holds
892
+ * under same-millisecond commits (the case the advisory lock and clock advance
893
+ * exist to make correct). Defaults to the system clock in production.
894
+ */
895
+ timeSource;
896
+ constructor(db, nodeId, timeSource) {
838
897
  this.db = db;
839
898
  this.nodeId = nodeId ?? (0, import_core3.generateUUIDv7)();
899
+ this.timeSource = timeSource ?? { now: () => Date.now() };
840
900
  this.ready = this.initialize();
841
901
  }
842
902
  getVersionVector() {
843
903
  this.assertOpen();
844
904
  return new Map(this.versionVector);
845
905
  }
906
+ /**
907
+ * Atomically reserve this node's next server-operation sequence number. The
908
+ * synchronous increment cannot interleave on the single JS thread, so two
909
+ * concurrent conditional applies are always handed distinct numbers.
910
+ */
911
+ reserveSequenceNumber() {
912
+ if (this.sequenceCounter === null) {
913
+ this.sequenceCounter = this.versionVector.get(this.nodeId) ?? 0;
914
+ }
915
+ this.sequenceCounter += 1;
916
+ return this.sequenceCounter;
917
+ }
846
918
  getNodeId() {
847
919
  return this.nodeId;
848
920
  }
@@ -880,8 +952,9 @@ var PostgresServerStore = class {
880
952
  return "duplicate";
881
953
  }
882
954
  const now = Date.now();
883
- const row = this.serializeOperation(op, now);
884
955
  await this.db.transaction(async (tx) => {
956
+ const deliverySeq = await this.nextDeliverySeq(tx);
957
+ const row = this.serializeOperation(op, now, deliverySeq);
885
958
  await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
886
959
  await tx.insert(pgSyncState).values({
887
960
  nodeId: op.nodeId,
@@ -904,6 +977,73 @@ var PostgresServerStore = class {
904
977
  }
905
978
  return "applied";
906
979
  }
980
+ /**
981
+ * Conditionally apply operations atomically, serialized across server instances
982
+ * on the target record via a transaction-scoped advisory lock. The lock makes
983
+ * the read-decide-write cycle atomic against concurrent instances, so a cap
984
+ * check like `responseCount < max` cannot be passed by two admissions at once.
985
+ */
986
+ async applyConditional(input) {
987
+ this.assertOpen();
988
+ await this.ready;
989
+ this.assertSchema();
990
+ this.assertCollection(input.target.collection);
991
+ const schema = this.schema;
992
+ const collectionDef = schema.collections[input.target.collection];
993
+ const lockKey = `kora:${input.target.collection}:${input.target.id}`;
994
+ const result = await this.db.transaction(async (tx) => {
995
+ await tx.execute(import_drizzle_orm.sql`SELECT pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`);
996
+ if (input.idempotencyKey) {
997
+ const existing = await tx.execute(
998
+ 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`
999
+ );
1000
+ if (existing.length > 0) {
1001
+ return { admitted: true, idempotent: true, applied: [] };
1002
+ }
1003
+ }
1004
+ const rows = await tx.execute(
1005
+ 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`
1006
+ );
1007
+ const current = rows.length > 0 ? this.deserializeRow(rows[0], collectionDef) : null;
1008
+ if (!input.admit(current)) {
1009
+ return { admitted: false, idempotent: false, applied: [] };
1010
+ }
1011
+ const clock = new import_core3.HybridLogicalClock(this.nodeId, this.timeSource);
1012
+ const latest = await this.latestOperationTimestamp(
1013
+ tx,
1014
+ input.target.collection,
1015
+ input.target.id
1016
+ );
1017
+ if (latest) {
1018
+ clock.advanceTo(latest);
1019
+ }
1020
+ const ops = await input.buildOperations(current, { clock });
1021
+ const now = Date.now();
1022
+ for (const op of ops) {
1023
+ const deliverySeq = await this.nextDeliverySeq(tx);
1024
+ const row = this.serializeOperation(op, now, deliverySeq);
1025
+ await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
1026
+ await tx.insert(pgSyncState).values({ nodeId: op.nodeId, maxSequenceNumber: op.sequenceNumber, lastSeenAt: now }).onConflictDoUpdate({
1027
+ target: pgSyncState.nodeId,
1028
+ set: {
1029
+ maxSequenceNumber: import_drizzle_orm.sql`GREATEST(${pgSyncState.maxSequenceNumber}, ${op.sequenceNumber})`,
1030
+ lastSeenAt: import_drizzle_orm.sql`${now}`
1031
+ }
1032
+ });
1033
+ if (this.schema?.collections[op.collection]) {
1034
+ await this.rebuildMaterializedRecord(tx, op.collection, op.recordId);
1035
+ }
1036
+ }
1037
+ return { admitted: true, idempotent: false, applied: ops };
1038
+ });
1039
+ for (const op of result.applied) {
1040
+ const currentMax = this.versionVector.get(op.nodeId) ?? 0;
1041
+ if (op.sequenceNumber > currentMax) {
1042
+ this.versionVector.set(op.nodeId, op.sequenceNumber);
1043
+ }
1044
+ }
1045
+ return result;
1046
+ }
907
1047
  async getOperationRange(nodeId, fromSeq, toSeq) {
908
1048
  this.assertOpen();
909
1049
  await this.ready;
@@ -918,6 +1058,23 @@ var PostgresServerStore = class {
918
1058
  const result = await this.db.select({ value: (0, import_drizzle_orm.count)() }).from(pgOperations);
919
1059
  return result[0]?.value ?? 0;
920
1060
  }
1061
+ async getMaxDeliverySequence() {
1062
+ this.assertOpen();
1063
+ await this.ready;
1064
+ const rows = await this.db.execute(
1065
+ import_drizzle_orm.sql`SELECT COALESCE(MAX(delivery_seq), 0) AS m FROM operations`
1066
+ );
1067
+ return Number(rows[0]?.m ?? 0);
1068
+ }
1069
+ async getOperationsAfterDelivery(afterDeliverySequence, limit) {
1070
+ this.assertOpen();
1071
+ await this.ready;
1072
+ 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);
1073
+ return rows.map((row) => ({
1074
+ operation: this.deserializeOperation(row),
1075
+ deliverySequence: row.deliverySeq ?? 0
1076
+ }));
1077
+ }
921
1078
  async materializeCollection(collection) {
922
1079
  this.assertOpen();
923
1080
  await this.ready;
@@ -952,7 +1109,7 @@ var PostgresServerStore = class {
952
1109
  this.assertCollection(collection);
953
1110
  const schema = this.schema;
954
1111
  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`;
1112
+ 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
1113
  const rows = await this.db.execute(query);
957
1114
  if (rows.length === 0) return null;
958
1115
  return this.deserializeRow(rows[0], collectionDef);
@@ -969,7 +1126,7 @@ var PostgresServerStore = class {
969
1126
  }
970
1127
  }
971
1128
  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}`;
1129
+ const query = import_drizzle_orm.sql`SELECT COUNT(*) as cnt FROM ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(collection))} WHERE ${whereClause}`;
973
1130
  const rows = await this.db.execute(query);
974
1131
  const cnt = rows[0]?.cnt;
975
1132
  return typeof cnt === "string" ? Number.parseInt(cnt, 10) : cnt ?? 0;
@@ -981,7 +1138,7 @@ var PostgresServerStore = class {
981
1138
  this.assertOpen();
982
1139
  await this.ready;
983
1140
  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));
1141
+ const rows = await this.db.select().from(pgOperations).orderBy((0, import_drizzle_orm.asc)(pgOperations.deliverySeq));
985
1142
  const operations2 = rows.map((row) => this.deserializeOperation(row));
986
1143
  return buildServerBackup2(this.nodeId, operations2, this.versionVector);
987
1144
  }
@@ -996,6 +1153,7 @@ var PostgresServerStore = class {
996
1153
  const result = await this.applyRemoteOperation(op);
997
1154
  if (result === "applied") restored++;
998
1155
  }
1156
+ this.sequenceCounter = null;
999
1157
  return { operationsRestored: restored, success: true };
1000
1158
  }
1001
1159
  const now = Date.now();
@@ -1005,20 +1163,49 @@ var PostgresServerStore = class {
1005
1163
  for (const [nid, seq] of versionVector) {
1006
1164
  await tx.insert(pgSyncState).values({ nodeId: nid, maxSequenceNumber: seq, lastSeenAt: now }).onConflictDoNothing({ target: pgSyncState.nodeId });
1007
1165
  }
1166
+ let deliverySeq = 0;
1008
1167
  for (const op of operations2) {
1009
- const row = this.serializeOperation(op, now);
1168
+ deliverySeq += 1;
1169
+ const row = this.serializeOperation(op, now, deliverySeq);
1010
1170
  await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
1011
1171
  }
1172
+ await tx.execute(
1173
+ import_drizzle_orm.sql`INSERT INTO delivery_counter (id, value) VALUES (1, ${deliverySeq})
1174
+ ON CONFLICT (id) DO UPDATE SET value = ${deliverySeq}`
1175
+ );
1012
1176
  });
1013
1177
  this.versionVector.clear();
1014
1178
  for (const [nid, seq] of versionVector) {
1015
1179
  this.versionVector.set(nid, seq);
1016
1180
  }
1181
+ this.sequenceCounter = null;
1017
1182
  return { operationsRestored: operations2.length, success: true };
1018
1183
  }
1019
1184
  // ---------------------------------------------------------------------------
1020
1185
  // Materialization internals
1021
1186
  // ---------------------------------------------------------------------------
1187
+ /**
1188
+ * The HLC timestamp of the most recent operation on a record, by total order
1189
+ * (wallTime, logical, nodeId), or null when the record has no operations. Used
1190
+ * by {@link applyConditional} to advance its clock past the record's latest
1191
+ * write so newly built operations sort strictly after it.
1192
+ */
1193
+ async latestOperationTimestamp(txOrDb, collection, recordId) {
1194
+ const rows = await txOrDb.select({
1195
+ wallTime: pgOperations.wallTime,
1196
+ logical: pgOperations.logical,
1197
+ timestampNodeId: pgOperations.timestampNodeId
1198
+ }).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(
1199
+ (0, import_drizzle_orm.desc)(pgOperations.wallTime),
1200
+ (0, import_drizzle_orm.desc)(pgOperations.logical),
1201
+ (0, import_drizzle_orm.desc)(pgOperations.timestampNodeId)
1202
+ ).limit(1);
1203
+ const row = rows[0];
1204
+ if (!row) {
1205
+ return null;
1206
+ }
1207
+ return { wallTime: row.wallTime, logical: row.logical, nodeId: row.timestampNodeId };
1208
+ }
1022
1209
  /**
1023
1210
  * Rebuild a single record in the materialized collection table by replaying
1024
1211
  * all operations for that record.
@@ -1029,17 +1216,19 @@ var PostgresServerStore = class {
1029
1216
  const ops = await txOrDb.select({
1030
1217
  type: pgOperations.type,
1031
1218
  data: pgOperations.data,
1219
+ atomicOps: pgOperations.atomicOps,
1032
1220
  wallTime: pgOperations.wallTime
1033
1221
  }).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
1222
  (0, import_drizzle_orm.asc)(pgOperations.wallTime),
1035
1223
  (0, import_drizzle_orm.asc)(pgOperations.logical),
1036
- (0, import_drizzle_orm.asc)(pgOperations.sequenceNumber)
1224
+ (0, import_drizzle_orm.asc)(pgOperations.timestampNodeId)
1037
1225
  );
1038
1226
  const parsedOps = ops.map((op) => ({
1039
1227
  type: op.type,
1040
- data: op.data !== null ? JSON.parse(op.data) : null
1228
+ data: op.data !== null ? JSON.parse(op.data) : null,
1229
+ atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
1041
1230
  }));
1042
- const recordData = replayOperationsForRecord(parsedOps);
1231
+ const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
1043
1232
  const fieldNames = Object.keys(collectionDef.fields);
1044
1233
  if (recordData) {
1045
1234
  const createdAt = ops.length > 0 ? ops[0].wallTime : Date.now();
@@ -1056,7 +1245,7 @@ var PostgresServerStore = class {
1056
1245
  );
1057
1246
  } else {
1058
1247
  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}`
1248
+ 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
1249
  );
1061
1250
  }
1062
1251
  }
@@ -1075,16 +1264,16 @@ var PostgresServerStore = class {
1075
1264
  updatedAt,
1076
1265
  0
1077
1266
  ];
1078
- const columnsSql = import_drizzle_orm.sql.raw(allColumns.join(", "));
1267
+ const columnsSql = import_drizzle_orm.sql.raw(allColumns.map((c) => (0, import_core3.quoteIdent)(c)).join(", "));
1079
1268
  const valuesSql = import_drizzle_orm.sql.join(
1080
1269
  values.map((v) => import_drizzle_orm.sql`${v}`),
1081
1270
  import_drizzle_orm.sql.raw(", ")
1082
1271
  );
1083
1272
  const updateSet = import_drizzle_orm.sql.raw(
1084
- allColumns.slice(1).map((c) => `${c} = excluded.${c}`).join(", ")
1273
+ allColumns.slice(1).map((c) => `${(0, import_core3.quoteIdent)(c)} = excluded.${(0, import_core3.quoteIdent)(c)}`).join(", ")
1085
1274
  );
1086
1275
  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}`
1276
+ 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
1277
  );
1089
1278
  }
1090
1279
  /**
@@ -1106,11 +1295,12 @@ var PostgresServerStore = class {
1106
1295
  recordId: pgOperations.recordId,
1107
1296
  type: pgOperations.type,
1108
1297
  data: pgOperations.data,
1298
+ atomicOps: pgOperations.atomicOps,
1109
1299
  wallTime: pgOperations.wallTime
1110
1300
  }).from(pgOperations).where((0, import_drizzle_orm.eq)(pgOperations.collection, collectionName)).orderBy(
1111
1301
  (0, import_drizzle_orm.asc)(pgOperations.wallTime),
1112
1302
  (0, import_drizzle_orm.asc)(pgOperations.logical),
1113
- (0, import_drizzle_orm.asc)(pgOperations.sequenceNumber)
1303
+ (0, import_drizzle_orm.asc)(pgOperations.timestampNodeId)
1114
1304
  );
1115
1305
  if (allOps.length === 0) return;
1116
1306
  const grouped = /* @__PURE__ */ new Map();
@@ -1126,9 +1316,10 @@ var PostgresServerStore = class {
1126
1316
  for (const [recordId, recordOps] of grouped) {
1127
1317
  const parsedOps = recordOps.map((op) => ({
1128
1318
  type: op.type,
1129
- data: op.data !== null ? JSON.parse(op.data) : null
1319
+ data: op.data !== null ? JSON.parse(op.data) : null,
1320
+ atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
1130
1321
  }));
1131
- const recordData = replayOperationsForRecord(parsedOps);
1322
+ const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
1132
1323
  if (recordData) {
1133
1324
  const createdAt = recordOps[0].wallTime;
1134
1325
  const updatedAt = recordOps[recordOps.length - 1].wallTime;
@@ -1144,7 +1335,7 @@ var PostgresServerStore = class {
1144
1335
  );
1145
1336
  } else {
1146
1337
  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()}`
1338
+ 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
1339
  );
1149
1340
  }
1150
1341
  }
@@ -1157,10 +1348,12 @@ var PostgresServerStore = class {
1157
1348
  options?.where ?? {},
1158
1349
  options?.includeDeleted ?? false
1159
1350
  );
1160
- const parts = [import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw(collection)} WHERE ${whereClause}`];
1351
+ const parts = [
1352
+ import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(collection))} WHERE ${whereClause}`
1353
+ ];
1161
1354
  if (options?.orderBy) {
1162
1355
  const dir = options.orderDirection === "desc" ? "DESC" : "ASC";
1163
- parts.push(import_drizzle_orm.sql.raw(` ORDER BY ${options.orderBy} ${dir}`));
1356
+ parts.push(import_drizzle_orm.sql.raw(` ORDER BY ${(0, import_core3.quoteIdent)(options.orderBy)} ${dir}`));
1164
1357
  }
1165
1358
  if (options?.limit !== void 0) {
1166
1359
  parts.push(import_drizzle_orm.sql` LIMIT ${options.limit}`);
@@ -1176,7 +1369,7 @@ var PostgresServerStore = class {
1176
1369
  conditions.push(import_drizzle_orm.sql.raw("_deleted = 0"));
1177
1370
  }
1178
1371
  for (const [key, value] of Object.entries(where)) {
1179
- conditions.push(import_drizzle_orm.sql`${import_drizzle_orm.sql.raw(key)} = ${value}`);
1372
+ conditions.push(import_drizzle_orm.sql`${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(key))} = ${value}`);
1180
1373
  }
1181
1374
  if (conditions.length === 0) {
1182
1375
  return import_drizzle_orm.sql.raw("1 = 1");
@@ -1249,44 +1442,92 @@ var PostgresServerStore = class {
1249
1442
  }
1250
1443
  }
1251
1444
  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)`
1445
+ await this.db.transaction(async (tx) => {
1446
+ await tx.execute(import_drizzle_orm.sql`SELECT pg_advisory_xact_lock(hashtextextended('kora:ensure-tables', 0))`);
1447
+ await tx.execute(import_drizzle_orm.sql`
1448
+ CREATE TABLE IF NOT EXISTS operations (
1449
+ id TEXT PRIMARY KEY,
1450
+ node_id TEXT NOT NULL,
1451
+ type TEXT NOT NULL,
1452
+ collection TEXT NOT NULL,
1453
+ record_id TEXT NOT NULL,
1454
+ data TEXT,
1455
+ previous_data TEXT,
1456
+ atomic_ops TEXT,
1457
+ wall_time BIGINT NOT NULL,
1458
+ logical INTEGER NOT NULL,
1459
+ timestamp_node_id TEXT NOT NULL,
1460
+ sequence_number INTEGER NOT NULL,
1461
+ causal_deps TEXT NOT NULL DEFAULT '[]',
1462
+ schema_version INTEGER NOT NULL,
1463
+ received_at BIGINT NOT NULL
1464
+ )
1465
+ `);
1466
+ await tx.execute(import_drizzle_orm.sql`ALTER TABLE operations ADD COLUMN IF NOT EXISTS atomic_ops TEXT`);
1467
+ await tx.execute(import_drizzle_orm.sql`ALTER TABLE operations ADD COLUMN IF NOT EXISTS delivery_seq BIGINT`);
1468
+ await tx.execute(
1469
+ import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_node_seq ON operations (node_id, sequence_number)`
1470
+ );
1471
+ await tx.execute(import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_collection ON operations (collection)`);
1472
+ await tx.execute(import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_received ON operations (received_at)`);
1473
+ await tx.execute(
1474
+ import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_delivery_seq ON operations (delivery_seq)`
1475
+ );
1476
+ await tx.execute(
1477
+ import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_collection_record ON operations (collection, record_id)`
1478
+ );
1479
+ await tx.execute(import_drizzle_orm.sql`
1480
+ CREATE TABLE IF NOT EXISTS sync_state (
1481
+ node_id TEXT PRIMARY KEY,
1482
+ max_sequence_number INTEGER NOT NULL,
1483
+ last_seen_at BIGINT NOT NULL
1484
+ )
1485
+ `);
1486
+ await tx.execute(import_drizzle_orm.sql`
1487
+ CREATE TABLE IF NOT EXISTS delivery_counter (
1488
+ id INTEGER PRIMARY KEY,
1489
+ value BIGINT NOT NULL
1490
+ )
1491
+ `);
1492
+ await tx.execute(import_drizzle_orm.sql`
1493
+ WITH ordered AS (
1494
+ SELECT id, ROW_NUMBER() OVER (
1495
+ ORDER BY received_at ASC, sequence_number ASC, id ASC
1496
+ ) + COALESCE((SELECT MAX(delivery_seq) FROM operations), 0) AS rn
1497
+ FROM operations WHERE delivery_seq IS NULL
1498
+ )
1499
+ UPDATE operations o SET delivery_seq = ordered.rn
1500
+ FROM ordered WHERE o.id = ordered.id
1501
+ `);
1502
+ await tx.execute(import_drizzle_orm.sql`
1503
+ INSERT INTO delivery_counter (id, value)
1504
+ VALUES (1, COALESCE((SELECT MAX(delivery_seq) FROM operations), 0))
1505
+ ON CONFLICT (id) DO UPDATE
1506
+ SET value = GREATEST(delivery_counter.value, EXCLUDED.value)
1507
+ `);
1508
+ });
1509
+ }
1510
+ /**
1511
+ * Reserve the next delivery sequence inside an open append transaction. The
1512
+ * `UPDATE ... RETURNING` locks the counter row until this transaction commits,
1513
+ * serializing assignment into commit order (see the counter table comment).
1514
+ */
1515
+ async nextDeliverySeq(tx) {
1516
+ const rows = await tx.execute(
1517
+ import_drizzle_orm.sql`UPDATE delivery_counter SET value = value + 1 WHERE id = 1 RETURNING value`
1277
1518
  );
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
- `);
1519
+ const value = rows[0]?.value;
1520
+ if (value === void 0 || value === null) {
1521
+ throw new Error(
1522
+ "delivery_counter row (id=1) is missing; the operations log cannot assign delivery sequences"
1523
+ );
1524
+ }
1525
+ return Number(value);
1285
1526
  }
1286
1527
  // ---------------------------------------------------------------------------
1287
1528
  // Operation serialization
1288
1529
  // ---------------------------------------------------------------------------
1289
- serializeOperation(op, receivedAt) {
1530
+ serializeOperation(op, receivedAt, deliverySeq) {
1290
1531
  return {
1291
1532
  id: op.id,
1292
1533
  nodeId: op.nodeId,
@@ -1295,16 +1536,19 @@ var PostgresServerStore = class {
1295
1536
  recordId: op.recordId,
1296
1537
  data: op.data !== null ? JSON.stringify(op.data) : null,
1297
1538
  previousData: op.previousData !== null ? JSON.stringify(op.previousData) : null,
1539
+ atomicOps: op.atomicOps && Object.keys(op.atomicOps).length > 0 ? JSON.stringify(op.atomicOps) : null,
1298
1540
  wallTime: op.timestamp.wallTime,
1299
1541
  logical: op.timestamp.logical,
1300
1542
  timestampNodeId: op.timestamp.nodeId,
1301
1543
  sequenceNumber: op.sequenceNumber,
1302
1544
  causalDeps: JSON.stringify(op.causalDeps),
1303
1545
  schemaVersion: op.schemaVersion,
1304
- receivedAt
1546
+ receivedAt,
1547
+ deliverySeq
1305
1548
  };
1306
1549
  }
1307
1550
  deserializeOperation(row) {
1551
+ const atomicOps = row.atomicOps != null ? JSON.parse(row.atomicOps) : void 0;
1308
1552
  return {
1309
1553
  id: row.id,
1310
1554
  nodeId: row.nodeId,
@@ -1320,7 +1564,8 @@ var PostgresServerStore = class {
1320
1564
  },
1321
1565
  sequenceNumber: row.sequenceNumber,
1322
1566
  causalDeps: JSON.parse(row.causalDeps),
1323
- schemaVersion: row.schemaVersion
1567
+ schemaVersion: row.schemaVersion,
1568
+ ...atomicOps ? { atomicOps } : {}
1324
1569
  };
1325
1570
  }
1326
1571
  // ---------------------------------------------------------------------------
@@ -1390,6 +1635,8 @@ var operations = (0, import_sqlite_core.sqliteTable)(
1390
1635
  // JSON-serialized, null for deletes
1391
1636
  previousData: (0, import_sqlite_core.text)("previous_data"),
1392
1637
  // JSON-serialized, null for insert/delete
1638
+ atomicOps: (0, import_sqlite_core.text)("atomic_ops"),
1639
+ // JSON-serialized Record<field, AtomicOp>, null when none
1393
1640
  wallTime: (0, import_sqlite_core.integer)("wall_time").notNull(),
1394
1641
  logical: (0, import_sqlite_core.integer)("logical").notNull(),
1395
1642
  timestampNodeId: (0, import_sqlite_core.text)("timestamp_node_id").notNull(),
@@ -1397,12 +1644,17 @@ var operations = (0, import_sqlite_core.sqliteTable)(
1397
1644
  causalDeps: (0, import_sqlite_core.text)("causal_deps").notNull().default("[]"),
1398
1645
  // JSON array of op IDs
1399
1646
  schemaVersion: (0, import_sqlite_core.integer)("schema_version").notNull(),
1400
- receivedAt: (0, import_sqlite_core.integer)("received_at").notNull()
1647
+ receivedAt: (0, import_sqlite_core.integer)("received_at").notNull(),
1648
+ // Server-assigned monotonic delivery sequence, ordered by commit. Drives the
1649
+ // gap-free server->client delivery watermark. Nullable only for rows written
1650
+ // before the column existed; those are backfilled on startup.
1651
+ deliverySeq: (0, import_sqlite_core.integer)("delivery_seq")
1401
1652
  },
1402
1653
  (table) => ({
1403
1654
  nodeSeqIdx: (0, import_sqlite_core.index)("idx_node_seq").on(table.nodeId, table.sequenceNumber),
1404
1655
  collectionIdx: (0, import_sqlite_core.index)("idx_collection").on(table.collection),
1405
- receivedIdx: (0, import_sqlite_core.index)("idx_received").on(table.receivedAt)
1656
+ receivedIdx: (0, import_sqlite_core.index)("idx_received").on(table.receivedAt),
1657
+ deliveryIdx: (0, import_sqlite_core.index)("idx_delivery_seq").on(table.deliverySeq)
1406
1658
  })
1407
1659
  );
1408
1660
  var syncState = (0, import_sqlite_core.sqliteTable)("sync_state", {
@@ -1410,6 +1662,10 @@ var syncState = (0, import_sqlite_core.sqliteTable)("sync_state", {
1410
1662
  maxSequenceNumber: (0, import_sqlite_core.integer)("max_sequence_number").notNull(),
1411
1663
  lastSeenAt: (0, import_sqlite_core.integer)("last_seen_at").notNull()
1412
1664
  });
1665
+ var deliveryCounter = (0, import_sqlite_core.sqliteTable)("delivery_counter", {
1666
+ id: (0, import_sqlite_core.integer)("id").primaryKey(),
1667
+ value: (0, import_sqlite_core.integer)("value").notNull()
1668
+ });
1413
1669
 
1414
1670
  // src/store/sqlite-server-store.ts
1415
1671
  var esmRequire = (0, import_node_module.createRequire)(importMetaUrl);
@@ -1463,12 +1719,15 @@ var SqliteServerStore = class {
1463
1719
  async applyRemoteOperation(op) {
1464
1720
  this.assertOpen();
1465
1721
  const now = Date.now();
1466
- const row = this.serializeOperation(op, now);
1467
1722
  const result = this.db.transaction((tx) => {
1468
- const insertResult = tx.insert(operations).values(row).onConflictDoNothing({ target: operations.id }).run();
1469
- if (insertResult.changes === 0) {
1723
+ const existing = tx.all(
1724
+ import_drizzle_orm2.sql`SELECT id FROM operations WHERE id = ${op.id} LIMIT 1`
1725
+ );
1726
+ if (existing.length > 0) {
1470
1727
  return "duplicate";
1471
1728
  }
1729
+ const row = this.serializeOperation(op, now, this.nextDeliverySeq(tx));
1730
+ tx.insert(operations).values(row).run();
1472
1731
  tx.insert(syncState).values({
1473
1732
  nodeId: op.nodeId,
1474
1733
  maxSequenceNumber: op.sequenceNumber,
@@ -1497,6 +1756,21 @@ var SqliteServerStore = class {
1497
1756
  const result = this.db.select({ value: (0, import_drizzle_orm2.count)() }).from(operations).all();
1498
1757
  return result[0]?.value ?? 0;
1499
1758
  }
1759
+ async getMaxDeliverySequence() {
1760
+ this.assertOpen();
1761
+ const rows = this.db.all(
1762
+ import_drizzle_orm2.sql`SELECT MAX(delivery_seq) AS m FROM operations`
1763
+ );
1764
+ return rows[0]?.m ?? 0;
1765
+ }
1766
+ async getOperationsAfterDelivery(afterDeliverySequence, limit) {
1767
+ this.assertOpen();
1768
+ const rows = this.db.select().from(operations).where((0, import_drizzle_orm2.gt)(operations.deliverySeq, afterDeliverySequence)).orderBy((0, import_drizzle_orm2.asc)(operations.deliverySeq)).limit(limit).all();
1769
+ return rows.map((row) => ({
1770
+ operation: this.deserializeOperation(row),
1771
+ deliverySequence: row.deliverySeq ?? 0
1772
+ }));
1773
+ }
1500
1774
  async materializeCollection(collection) {
1501
1775
  this.assertOpen();
1502
1776
  if (this.schema?.collections[collection]) {
@@ -1528,7 +1802,7 @@ var SqliteServerStore = class {
1528
1802
  this.assertCollection(collection);
1529
1803
  const schema = this.schema;
1530
1804
  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`;
1805
+ 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
1806
  const rows = this.db.all(query);
1533
1807
  if (rows.length === 0) return null;
1534
1808
  return this.deserializeRow(rows[0], collectionDef);
@@ -1544,7 +1818,7 @@ var SqliteServerStore = class {
1544
1818
  }
1545
1819
  }
1546
1820
  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}`;
1821
+ const query = import_drizzle_orm2.sql`SELECT COUNT(*) as cnt FROM ${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(collection))} WHERE ${whereClause}`;
1548
1822
  const rows = this.db.all(query);
1549
1823
  return rows[0]?.cnt ?? 0;
1550
1824
  }
@@ -1554,7 +1828,7 @@ var SqliteServerStore = class {
1554
1828
  async exportBackup() {
1555
1829
  this.assertOpen();
1556
1830
  const { buildServerBackup: buildServerBackup2 } = await Promise.resolve().then(() => (init_server_backup(), server_backup_exports));
1557
- const rows = this.db.select().from(operations).all();
1831
+ const rows = this.db.select().from(operations).orderBy((0, import_drizzle_orm2.asc)(operations.deliverySeq)).all();
1558
1832
  const deserialized = rows.map((row) => this.deserializeOperation(row));
1559
1833
  const vv = this.getVersionVector();
1560
1834
  return buildServerBackup2(this.nodeId, deserialized, vv);
@@ -1574,13 +1848,17 @@ var SqliteServerStore = class {
1574
1848
  this.db.transaction((tx) => {
1575
1849
  tx.run(import_drizzle_orm2.sql.raw("DELETE FROM operations"));
1576
1850
  tx.run(import_drizzle_orm2.sql.raw("DELETE FROM sync_state"));
1851
+ tx.update(deliveryCounter).set({ value: 0 }).where((0, import_drizzle_orm2.eq)(deliveryCounter.id, 1)).run();
1577
1852
  for (const [nid, seq] of versionVector) {
1578
1853
  tx.insert(syncState).values({ nodeId: nid, maxSequenceNumber: seq, lastSeenAt: Date.now() }).run();
1579
1854
  }
1855
+ let deliverySeq = 0;
1580
1856
  for (const op of ops) {
1581
- const row = this.serializeOperation(op, Date.now());
1857
+ deliverySeq += 1;
1858
+ const row = this.serializeOperation(op, Date.now(), deliverySeq);
1582
1859
  tx.insert(operations).values(row).run();
1583
1860
  }
1861
+ tx.update(deliveryCounter).set({ value: deliverySeq }).where((0, import_drizzle_orm2.eq)(deliveryCounter.id, 1)).run();
1584
1862
  });
1585
1863
  return { operationsRestored: ops.length, success: true };
1586
1864
  }
@@ -1598,13 +1876,15 @@ var SqliteServerStore = class {
1598
1876
  const ops = txOrDb.select({
1599
1877
  type: operations.type,
1600
1878
  data: operations.data,
1879
+ atomicOps: operations.atomicOps,
1601
1880
  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();
1881
+ }).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
1882
  const parsedOps = ops.map((op) => ({
1604
1883
  type: op.type,
1605
- data: op.data !== null ? JSON.parse(op.data) : null
1884
+ data: op.data !== null ? JSON.parse(op.data) : null,
1885
+ atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
1606
1886
  }));
1607
- const recordData = replayOperationsForRecord(parsedOps);
1887
+ const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
1608
1888
  const fieldNames = Object.keys(collectionDef.fields);
1609
1889
  if (recordData) {
1610
1890
  const createdAt = ops.length > 0 ? ops[0].wallTime : Date.now();
@@ -1621,7 +1901,7 @@ var SqliteServerStore = class {
1621
1901
  );
1622
1902
  } else {
1623
1903
  txOrDb.run(
1624
- import_drizzle_orm2.sql`UPDATE ${import_drizzle_orm2.sql.raw(collection)} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
1904
+ 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
1905
  );
1626
1906
  }
1627
1907
  }
@@ -1642,16 +1922,16 @@ var SqliteServerStore = class {
1642
1922
  0
1643
1923
  // _deleted = false
1644
1924
  ];
1645
- const columnsSql = import_drizzle_orm2.sql.raw(allColumns.join(", "));
1925
+ const columnsSql = import_drizzle_orm2.sql.raw(allColumns.map((c) => (0, import_core4.quoteIdent)(c)).join(", "));
1646
1926
  const valuesSql = import_drizzle_orm2.sql.join(
1647
1927
  values.map((v) => import_drizzle_orm2.sql`${v}`),
1648
1928
  import_drizzle_orm2.sql.raw(", ")
1649
1929
  );
1650
1930
  const updateSet = import_drizzle_orm2.sql.raw(
1651
- allColumns.slice(1).map((c) => `${c} = excluded.${c}`).join(", ")
1931
+ allColumns.slice(1).map((c) => `${(0, import_core4.quoteIdent)(c)} = excluded.${(0, import_core4.quoteIdent)(c)}`).join(", ")
1652
1932
  );
1653
1933
  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}`
1934
+ 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
1935
  );
1656
1936
  }
1657
1937
  /**
@@ -1674,8 +1954,9 @@ var SqliteServerStore = class {
1674
1954
  recordId: operations.recordId,
1675
1955
  type: operations.type,
1676
1956
  data: operations.data,
1957
+ atomicOps: operations.atomicOps,
1677
1958
  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();
1959
+ }).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
1960
  if (allOps.length === 0) return;
1680
1961
  const grouped = /* @__PURE__ */ new Map();
1681
1962
  for (const op of allOps) {
@@ -1691,9 +1972,10 @@ var SqliteServerStore = class {
1691
1972
  for (const [recordId, recordOps] of grouped) {
1692
1973
  const parsedOps = recordOps.map((op) => ({
1693
1974
  type: op.type,
1694
- data: op.data !== null ? JSON.parse(op.data) : null
1975
+ data: op.data !== null ? JSON.parse(op.data) : null,
1976
+ atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
1695
1977
  }));
1696
- const recordData = replayOperationsForRecord(parsedOps);
1978
+ const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
1697
1979
  if (recordData) {
1698
1980
  const createdAt = recordOps[0].wallTime;
1699
1981
  const updatedAt = recordOps[recordOps.length - 1].wallTime;
@@ -1709,7 +1991,7 @@ var SqliteServerStore = class {
1709
1991
  );
1710
1992
  } else {
1711
1993
  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()}`
1994
+ 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
1995
  );
1714
1996
  }
1715
1997
  }
@@ -1723,10 +2005,12 @@ var SqliteServerStore = class {
1723
2005
  options?.where ?? {},
1724
2006
  options?.includeDeleted ?? false
1725
2007
  );
1726
- const parts = [import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw(collection)} WHERE ${whereClause}`];
2008
+ const parts = [
2009
+ import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(collection))} WHERE ${whereClause}`
2010
+ ];
1727
2011
  if (options?.orderBy) {
1728
2012
  const dir = options.orderDirection === "desc" ? "DESC" : "ASC";
1729
- parts.push(import_drizzle_orm2.sql.raw(` ORDER BY ${options.orderBy} ${dir}`));
2013
+ parts.push(import_drizzle_orm2.sql.raw(` ORDER BY ${(0, import_core4.quoteIdent)(options.orderBy)} ${dir}`));
1730
2014
  }
1731
2015
  if (options?.limit !== void 0) {
1732
2016
  parts.push(import_drizzle_orm2.sql` LIMIT ${options.limit}`);
@@ -1742,7 +2026,7 @@ var SqliteServerStore = class {
1742
2026
  conditions.push(import_drizzle_orm2.sql.raw("_deleted = 0"));
1743
2027
  }
1744
2028
  for (const [key, value] of Object.entries(where)) {
1745
- conditions.push(import_drizzle_orm2.sql`${import_drizzle_orm2.sql.raw(key)} = ${value}`);
2029
+ conditions.push(import_drizzle_orm2.sql`${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(key))} = ${value}`);
1746
2030
  }
1747
2031
  if (conditions.length === 0) {
1748
2032
  return import_drizzle_orm2.sql.raw("1 = 1");
@@ -1813,6 +2097,7 @@ var SqliteServerStore = class {
1813
2097
  record_id TEXT NOT NULL,
1814
2098
  data TEXT,
1815
2099
  previous_data TEXT,
2100
+ atomic_ops TEXT,
1816
2101
  wall_time INTEGER NOT NULL,
1817
2102
  logical INTEGER NOT NULL,
1818
2103
  timestamp_node_id TEXT NOT NULL,
@@ -1822,9 +2107,35 @@ var SqliteServerStore = class {
1822
2107
  received_at INTEGER NOT NULL
1823
2108
  )
1824
2109
  `);
2110
+ try {
2111
+ this.db.run(import_drizzle_orm2.sql`ALTER TABLE operations ADD COLUMN atomic_ops TEXT`);
2112
+ } catch (e) {
2113
+ const msg = e instanceof Error ? e.message : "";
2114
+ const causeMsg = e instanceof Error && e.cause instanceof Error ? e.cause.message : "";
2115
+ if (!msg.includes("duplicate column") && !causeMsg.includes("duplicate column")) {
2116
+ throw e;
2117
+ }
2118
+ }
2119
+ try {
2120
+ this.db.run(import_drizzle_orm2.sql`ALTER TABLE operations ADD COLUMN delivery_seq INTEGER`);
2121
+ } catch (e) {
2122
+ const msg = e instanceof Error ? e.message : "";
2123
+ const causeMsg = e instanceof Error && e.cause instanceof Error ? e.cause.message : "";
2124
+ if (!msg.includes("duplicate column") && !causeMsg.includes("duplicate column")) {
2125
+ throw e;
2126
+ }
2127
+ }
1825
2128
  this.db.run(import_drizzle_orm2.sql`
1826
2129
  CREATE INDEX IF NOT EXISTS idx_node_seq ON operations (node_id, sequence_number)
1827
2130
  `);
2131
+ this.db.run(import_drizzle_orm2.sql`
2132
+ CREATE INDEX IF NOT EXISTS idx_delivery_seq ON operations (delivery_seq)
2133
+ `);
2134
+ this.db.run(import_drizzle_orm2.sql`
2135
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_delivery_seq_unique
2136
+ ON operations (delivery_seq)
2137
+ WHERE delivery_seq IS NOT NULL
2138
+ `);
1828
2139
  this.db.run(import_drizzle_orm2.sql`
1829
2140
  CREATE INDEX IF NOT EXISTS idx_collection ON operations (collection)
1830
2141
  `);
@@ -1841,11 +2152,60 @@ var SqliteServerStore = class {
1841
2152
  last_seen_at INTEGER NOT NULL
1842
2153
  )
1843
2154
  `);
2155
+ this.db.run(import_drizzle_orm2.sql`
2156
+ CREATE TABLE IF NOT EXISTS delivery_counter (
2157
+ id INTEGER PRIMARY KEY,
2158
+ value INTEGER NOT NULL
2159
+ )
2160
+ `);
2161
+ this.backfillDeliverySequence();
2162
+ }
2163
+ /**
2164
+ * Assign delivery sequences to any operations written before the column existed,
2165
+ * then seed the durable counter from the current max. Ordered by
2166
+ * (received_at, sequence_number, id) so the backfill is deterministic and stable
2167
+ * across restarts. A one-time migration: after it runs, all rows have a value.
2168
+ */
2169
+ backfillDeliverySequence() {
2170
+ this.db.transaction((tx) => {
2171
+ const maxRow = tx.all(
2172
+ import_drizzle_orm2.sql`SELECT MAX(delivery_seq) AS m FROM operations`
2173
+ );
2174
+ let next = maxRow[0]?.m ?? 0;
2175
+ const nullRows = tx.all(
2176
+ import_drizzle_orm2.sql`SELECT id FROM operations WHERE delivery_seq IS NULL ORDER BY received_at ASC, sequence_number ASC, id ASC`
2177
+ );
2178
+ for (const row of nullRows) {
2179
+ next += 1;
2180
+ tx.run(import_drizzle_orm2.sql`UPDATE operations SET delivery_seq = ${next} WHERE id = ${row.id}`);
2181
+ }
2182
+ tx.insert(deliveryCounter).values({ id: 1, value: next }).onConflictDoUpdate({
2183
+ target: deliveryCounter.id,
2184
+ set: {
2185
+ value: import_drizzle_orm2.sql`MAX(${deliveryCounter.value}, ${next})`
2186
+ }
2187
+ }).run();
2188
+ });
2189
+ }
2190
+ /**
2191
+ * Reserve the next delivery sequence inside the append transaction. SQLite locks
2192
+ * the database for the write transaction, and this counter row keeps sequence
2193
+ * allocation shared by every SqliteServerStore instance using the same file.
2194
+ */
2195
+ nextDeliverySeq(tx) {
2196
+ 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();
2197
+ const value = rows[0]?.value;
2198
+ if (value === void 0 || value === null) {
2199
+ throw new Error(
2200
+ "delivery_counter row (id=1) is missing; the operations log cannot assign delivery sequences"
2201
+ );
2202
+ }
2203
+ return value;
1844
2204
  }
1845
2205
  // ---------------------------------------------------------------------------
1846
2206
  // Operation serialization
1847
2207
  // ---------------------------------------------------------------------------
1848
- serializeOperation(op, receivedAt) {
2208
+ serializeOperation(op, receivedAt, deliverySeq) {
1849
2209
  return {
1850
2210
  id: op.id,
1851
2211
  nodeId: op.nodeId,
@@ -1854,16 +2214,19 @@ var SqliteServerStore = class {
1854
2214
  recordId: op.recordId,
1855
2215
  data: op.data !== null ? JSON.stringify(op.data) : null,
1856
2216
  previousData: op.previousData !== null ? JSON.stringify(op.previousData) : null,
2217
+ atomicOps: op.atomicOps && Object.keys(op.atomicOps).length > 0 ? JSON.stringify(op.atomicOps) : null,
1857
2218
  wallTime: op.timestamp.wallTime,
1858
2219
  logical: op.timestamp.logical,
1859
2220
  timestampNodeId: op.timestamp.nodeId,
1860
2221
  sequenceNumber: op.sequenceNumber,
1861
2222
  causalDeps: JSON.stringify(op.causalDeps),
1862
2223
  schemaVersion: op.schemaVersion,
1863
- receivedAt
2224
+ receivedAt,
2225
+ deliverySeq
1864
2226
  };
1865
2227
  }
1866
2228
  deserializeOperation(row) {
2229
+ const atomicOps = row.atomicOps != null ? JSON.parse(row.atomicOps) : void 0;
1867
2230
  return {
1868
2231
  id: row.id,
1869
2232
  nodeId: row.nodeId,
@@ -1879,7 +2242,8 @@ var SqliteServerStore = class {
1879
2242
  },
1880
2243
  sequenceNumber: row.sequenceNumber,
1881
2244
  causalDeps: JSON.parse(row.causalDeps),
1882
- schemaVersion: row.schemaVersion
2245
+ schemaVersion: row.schemaVersion,
2246
+ ...atomicOps ? { atomicOps } : {}
1883
2247
  };
1884
2248
  }
1885
2249
  // ---------------------------------------------------------------------------
@@ -2065,6 +2429,7 @@ var WsServerTransport = class {
2065
2429
  // src/session/client-session.ts
2066
2430
  init_cjs_shims();
2067
2431
  var import_core10 = require("@korajs/core");
2432
+ var import_core11 = require("@korajs/core");
2068
2433
  var import_internal = require("@korajs/core/internal");
2069
2434
  var import_sync2 = require("@korajs/sync");
2070
2435
  var import_sync3 = require("@korajs/sync");
@@ -2178,6 +2543,9 @@ async function createServerSideEffectOperation(store, parentOp, effect, schemaVe
2178
2543
  );
2179
2544
  }
2180
2545
  function nextServerSequenceNumber(store) {
2546
+ if (store.reserveSequenceNumber) {
2547
+ return store.reserveSequenceNumber();
2548
+ }
2181
2549
  const nodeId = store.getNodeId();
2182
2550
  const current = store.getVersionVector().get(nodeId) ?? 0;
2183
2551
  return current + 1;
@@ -2187,14 +2555,28 @@ function nextServerSequenceNumber(store) {
2187
2555
  async function applyServerOperation(store, op, relationLookup) {
2188
2556
  const schema = store.getSchema();
2189
2557
  const lookup = relationLookup ?? (schema ? (0, import_merge2.buildMergeRelationLookup)(schema) : /* @__PURE__ */ new Map());
2558
+ const shapeCheck = validateOperationShape(op, schema);
2559
+ if (!shapeCheck.valid) {
2560
+ return {
2561
+ result: "skipped",
2562
+ appliedOperations: [],
2563
+ rejection: {
2564
+ code: shapeCheck.code ?? "SCHEMA_VALIDATION_ERROR",
2565
+ message: shapeCheck.message ?? `Operation "${op.id}" does not match the server schema`,
2566
+ retriable: false
2567
+ }
2568
+ };
2569
+ }
2190
2570
  const constraintCheck = await validateIncomingOperationConstraints(store, op, schema);
2191
2571
  if (!constraintCheck.valid) {
2572
+ const code = constraintCheck.code ?? "CONSTRAINT_VIOLATION";
2192
2573
  return {
2193
2574
  result: "skipped",
2194
2575
  appliedOperations: [],
2195
2576
  rejection: {
2196
- code: constraintCheck.code ?? "CONSTRAINT_VIOLATION",
2197
- message: constraintCheck.message ?? `Operation "${op.id}" violates a schema constraint`
2577
+ code,
2578
+ message: constraintCheck.message ?? `Operation "${op.id}" violates a schema constraint`,
2579
+ retriable: isRetriableRejection(code)
2198
2580
  }
2199
2581
  };
2200
2582
  }
@@ -2207,7 +2589,8 @@ async function applyServerOperation(store, op, relationLookup) {
2207
2589
  appliedOperations: [],
2208
2590
  rejection: {
2209
2591
  code: "REFERENTIAL_INTEGRITY",
2210
- message: `Operation "${op.id}" violates referential integrity on "${op.collection}"`
2592
+ message: `Operation "${op.id}" violates referential integrity on "${op.collection}"`,
2593
+ retriable: isRetriableRejection("REFERENTIAL_INTEGRITY")
2211
2594
  }
2212
2595
  };
2213
2596
  }
@@ -2216,16 +2599,14 @@ async function applyServerOperation(store, op, relationLookup) {
2216
2599
  return { result: primaryResult, appliedOperations: [] };
2217
2600
  }
2218
2601
  const appliedOperations = [op];
2219
- let serverSeq = nextServerSequenceNumber(store);
2220
2602
  for (const effect of referential.sideEffectOps) {
2221
2603
  const sideOp = await createServerSideEffectOperation(
2222
2604
  store,
2223
2605
  op,
2224
2606
  effect,
2225
2607
  op.schemaVersion,
2226
- serverSeq
2608
+ nextServerSequenceNumber(store)
2227
2609
  );
2228
- serverSeq += 1;
2229
2610
  const sideResult = await store.applyRemoteOperation(sideOp);
2230
2611
  if (sideResult === "applied") {
2231
2612
  appliedOperations.push(sideOp);
@@ -2239,6 +2620,67 @@ async function applyServerOperation(store, op, relationLookup) {
2239
2620
  appliedOperations: result === "applied" ? [op] : []
2240
2621
  };
2241
2622
  }
2623
+ function validateOperationShape(op, schema) {
2624
+ if (!schema) {
2625
+ return { valid: true };
2626
+ }
2627
+ const collection = schema.collections[op.collection];
2628
+ if (!collection) {
2629
+ return {
2630
+ valid: false,
2631
+ code: "UNKNOWN_COLLECTION",
2632
+ message: `Operation "${op.id}" targets unknown collection "${op.collection}".`
2633
+ };
2634
+ }
2635
+ const declaredFields = new Set(Object.keys(collection.fields));
2636
+ const systemPreviousFields = /* @__PURE__ */ new Set(["id", "_created_at", "_updated_at", "_deleted"]);
2637
+ const invalidDataField = firstInvalidField(op.data, declaredFields);
2638
+ if (invalidDataField) {
2639
+ return {
2640
+ valid: false,
2641
+ code: "SCHEMA_VALIDATION_ERROR",
2642
+ message: `Operation "${op.id}" contains undeclared field "${invalidDataField}" in data for collection "${op.collection}".`
2643
+ };
2644
+ }
2645
+ const invalidPreviousField = firstInvalidField(
2646
+ op.previousData,
2647
+ declaredFields,
2648
+ systemPreviousFields
2649
+ );
2650
+ if (invalidPreviousField) {
2651
+ return {
2652
+ valid: false,
2653
+ code: "SCHEMA_VALIDATION_ERROR",
2654
+ message: `Operation "${op.id}" contains undeclared field "${invalidPreviousField}" in previousData for collection "${op.collection}".`
2655
+ };
2656
+ }
2657
+ if (op.atomicOps) {
2658
+ for (const field of Object.keys(op.atomicOps)) {
2659
+ if (!declaredFields.has(field)) {
2660
+ return {
2661
+ valid: false,
2662
+ code: "SCHEMA_VALIDATION_ERROR",
2663
+ message: `Operation "${op.id}" contains undeclared atomic field "${field}" for collection "${op.collection}".`
2664
+ };
2665
+ }
2666
+ }
2667
+ }
2668
+ return { valid: true };
2669
+ }
2670
+ function firstInvalidField(value, declaredFields, extraAllowedFields) {
2671
+ if (value === null || value === void 0) {
2672
+ return null;
2673
+ }
2674
+ if (typeof value !== "object" || Array.isArray(value)) {
2675
+ return null;
2676
+ }
2677
+ for (const field of Object.keys(value)) {
2678
+ if (!declaredFields.has(field) && !extraAllowedFields?.has(field)) {
2679
+ return field;
2680
+ }
2681
+ }
2682
+ return null;
2683
+ }
2242
2684
 
2243
2685
  // src/auth/no-auth.ts
2244
2686
  init_cjs_shims();
@@ -2286,24 +2728,74 @@ function mergeScopeMaps(base, overlay, source) {
2286
2728
 
2287
2729
  // src/scopes/server-scope-filter.ts
2288
2730
  init_cjs_shims();
2289
- function operationMatchesScopes(op, scopes) {
2731
+ var DEFAULT_MAX_SCOPE_PREDICATE_VALUES = 100;
2732
+ function normalizeScopeMap(scopes, maxValues = DEFAULT_MAX_SCOPE_PREDICATE_VALUES) {
2733
+ const normalized = {};
2734
+ for (const collection of Object.keys(scopes).sort()) {
2735
+ const predicate = {};
2736
+ for (const field of Object.keys(scopes[collection] ?? {}).sort()) {
2737
+ const expected = scopes[collection]?.[field];
2738
+ if (expected && typeof expected === "object" && !Array.isArray(expected) && "$in" in expected) {
2739
+ const values = expected.$in;
2740
+ if (!Array.isArray(values))
2741
+ throw new Error(`Invalid $in predicate for ${collection}.${field}`);
2742
+ const unique = [...new Map(values.map((value) => [stableValueKey(value), value])).values()];
2743
+ if (unique.length > maxValues)
2744
+ throw new Error(
2745
+ `Scope predicate for ${collection}.${field} exceeds the ${maxValues}-value limit`
2746
+ );
2747
+ predicate[field] = {
2748
+ $in: unique.sort((a, b) => stableValueKey(a).localeCompare(stableValueKey(b)))
2749
+ };
2750
+ } else predicate[field] = expected;
2751
+ }
2752
+ normalized[collection] = predicate;
2753
+ }
2754
+ return normalized;
2755
+ }
2756
+ function stableValueKey(value) {
2757
+ if (Array.isArray(value)) return `[${value.map(stableValueKey).join(",")}]`;
2758
+ if (value && typeof value === "object")
2759
+ return `{${Object.keys(value).sort().map(
2760
+ (key) => `${JSON.stringify(key)}:${stableValueKey(value[key])}`
2761
+ ).join(",")}}`;
2762
+ return JSON.stringify(value);
2763
+ }
2764
+ function operationMatchesScopes(op, scopes, fullRecord) {
2290
2765
  if (!scopes) return true;
2291
2766
  const collectionScope = scopes[op.collection];
2292
2767
  if (!collectionScope) return false;
2293
- const snapshot = buildSnapshot(op);
2768
+ if (Object.keys(collectionScope).length === 0) return true;
2769
+ const snapshot = buildSnapshot(op, fullRecord);
2294
2770
  if (!snapshot) return false;
2295
2771
  for (const [field, expected] of Object.entries(collectionScope)) {
2296
- if (snapshot[field] !== expected) {
2772
+ if (!matchesPredicate(snapshot[field], expected)) {
2297
2773
  return false;
2298
2774
  }
2299
2775
  }
2300
2776
  return true;
2301
2777
  }
2302
- function buildSnapshot(op) {
2778
+ function matchesPredicate(actual, expected) {
2779
+ if (expected && typeof expected === "object" && !Array.isArray(expected) && "$in" in expected) {
2780
+ const values = expected.$in;
2781
+ return Array.isArray(values) && values.some((value) => Object.is(actual, value));
2782
+ }
2783
+ return Object.is(actual, expected);
2784
+ }
2785
+ function missingScopeFields(op, scopes) {
2786
+ if (!scopes) return [];
2787
+ const collectionScope = scopes[op.collection];
2788
+ if (!collectionScope) return [];
2789
+ if (Object.keys(collectionScope).length === 0) return [];
2790
+ const snapshot = buildSnapshot(op);
2791
+ return Object.keys(collectionScope).filter((field) => !snapshot || !(field in snapshot));
2792
+ }
2793
+ function buildSnapshot(op, fullRecord) {
2303
2794
  const previous = asRecord(op.previousData);
2304
2795
  const next = asRecord(op.data);
2305
- if (!previous && !next) return null;
2796
+ if (!previous && !next && !fullRecord) return null;
2306
2797
  return {
2798
+ ...fullRecord ?? {},
2307
2799
  ...previous ?? {},
2308
2800
  ...next ?? {}
2309
2801
  };
@@ -2396,21 +2888,74 @@ function warnIfMultiTenantWithoutScopes(auth, resolvedScopes, schema) {
2396
2888
  "[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
2889
  );
2398
2890
  }
2399
- var ClientSession = class {
2891
+ function stableStringify(value) {
2892
+ if (Array.isArray(value)) {
2893
+ return `[${value.map((item) => stableStringify(item)).join(",")}]`;
2894
+ }
2895
+ if (value && typeof value === "object") {
2896
+ const record = value;
2897
+ const entries = Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`);
2898
+ return `{${entries.join(",")}}`;
2899
+ }
2900
+ return JSON.stringify(value);
2901
+ }
2902
+ function sameScopeMap(a, b) {
2903
+ const normalize = (value) => value && typeof value === "object" ? normalizeScopeMap(value) : value;
2904
+ return stableStringify(normalize(a) ?? null) === stableStringify(normalize(b) ?? null);
2905
+ }
2906
+ var ClientSession = class _ClientSession {
2400
2907
  state = "connected";
2401
2908
  clientNodeId = null;
2402
2909
  authContext = null;
2403
2910
  syncQuerySubsets = [];
2404
2911
  resumeDeltaCursor = null;
2912
+ /**
2913
+ * The delivery watermark the client reported at handshake, or null when the client
2914
+ * does not use the delivery watermark (old client, or no watermark yet). When set,
2915
+ * the server drives the gap-free server->client stream from delivery sequences
2916
+ * instead of the version-vector delta.
2917
+ */
2918
+ clientDeliveryWatermark = null;
2919
+ /**
2920
+ * The highest delivery sequence this client has ACKNOWLEDGED (its confirmed
2921
+ * watermark, as reported in acks). Incremental streaming pushes resume from here, not
2922
+ * from the highest sequence sent, so a dropped or unapplied batch is always
2923
+ * re-included by the next push (which re-scans from the acknowledged position). This
2924
+ * is what makes streaming recovery independent of any bounded retransmit buffer: there
2925
+ * is no sent-but-unacked window that a buffer eviction could strand. Seeded from the
2926
+ * client's reported watermark at handshake.
2927
+ */
2928
+ lastAckedDeliverySeq = 0;
2929
+ /**
2930
+ * Timestamp of the last delivery-stream send attempt. Periodic retransmission uses
2931
+ * this as its stale window so a slow client does not receive duplicate full-stream
2932
+ * batches every server tick while the first batch is still being applied.
2933
+ */
2934
+ lastDeliveryPushAttemptAtMs = 0;
2935
+ deliveryStallRepeatCount = 0;
2936
+ /** Serializes incremental delivery pushes so their batches never interleave. */
2937
+ deliveryPushChain = Promise.resolve();
2938
+ /**
2939
+ * Relay batches sent to this client but not yet acknowledged, keyed by messageId.
2940
+ * The client acks every operation-batch it applies; on ack the entry is cleared.
2941
+ * {@link retransmitPendingRelays} re-sends anything still unacked, so a relay
2942
+ * dropped by a lossy transport is redelivered instead of leaving the client with a
2943
+ * permanent version-vector gap (a lost operation) that delta sync cannot recover.
2944
+ * Bounded so a silent client cannot grow it without limit.
2945
+ */
2946
+ pendingRelays = /* @__PURE__ */ new Map();
2947
+ static MAX_PENDING_RELAYS = 1e3;
2405
2948
  sessionId;
2406
2949
  transport;
2407
2950
  store;
2408
2951
  auth;
2409
2952
  serializer;
2410
2953
  emitter;
2954
+ logger;
2411
2955
  batchSize;
2412
2956
  schemaVersion;
2413
2957
  supportedSchemaVersions;
2958
+ operationTransforms;
2414
2959
  onRelay;
2415
2960
  onAwarenessUpdate;
2416
2961
  onYjsDocUpdate;
@@ -2418,9 +2963,13 @@ var ClientSession = class {
2418
2963
  onBlobChunkResponse;
2419
2964
  persistBlobChunk;
2420
2965
  onClose;
2966
+ onOrphanedRelays;
2967
+ takeOrphanedRelays;
2421
2968
  maxOperationBytes;
2422
2969
  maxOpsPerMinute;
2423
2970
  rateLimiter;
2971
+ validateOperation;
2972
+ koraContext;
2424
2973
  constructor(options) {
2425
2974
  this.sessionId = options.sessionId;
2426
2975
  this.transport = options.transport;
@@ -2428,6 +2977,7 @@ var ClientSession = class {
2428
2977
  this.auth = options.auth ?? null;
2429
2978
  this.serializer = options.serializer ?? new import_sync3.NegotiatedMessageSerializer("json");
2430
2979
  this.emitter = options.emitter ?? null;
2980
+ this.logger = options.logger ?? null;
2431
2981
  this.batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
2432
2982
  this.schemaVersion = options.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
2433
2983
  const supported = options.supportedSchemaVersions;
@@ -2435,6 +2985,7 @@ var ClientSession = class {
2435
2985
  min: this.schemaVersion,
2436
2986
  max: this.schemaVersion
2437
2987
  };
2988
+ this.operationTransforms = options.operationTransforms ?? [];
2438
2989
  this.onRelay = options.onRelay ?? null;
2439
2990
  this.onAwarenessUpdate = options.onAwarenessUpdate ?? null;
2440
2991
  this.onYjsDocUpdate = options.onYjsDocUpdate ?? null;
@@ -2442,9 +2993,13 @@ var ClientSession = class {
2442
2993
  this.onBlobChunkResponse = options.onBlobChunkResponse ?? null;
2443
2994
  this.persistBlobChunk = options.persistBlobChunk ?? null;
2444
2995
  this.onClose = options.onClose ?? null;
2996
+ this.onOrphanedRelays = options.onOrphanedRelays ?? null;
2997
+ this.takeOrphanedRelays = options.takeOrphanedRelays ?? null;
2445
2998
  this.maxOperationBytes = options.maxOperationBytes ?? DEFAULT_MAX_OPERATION_BYTES;
2446
2999
  this.maxOpsPerMinute = options.maxOpsPerMinute ?? DEFAULT_MAX_OPS_PER_MINUTE;
2447
3000
  this.rateLimiter = new SessionRateLimiter(this.maxOpsPerMinute);
3001
+ this.validateOperation = options.validateOperation ?? null;
3002
+ this.koraContext = options.koraContext ?? null;
2448
3003
  }
2449
3004
  /**
2450
3005
  * Start handling messages from the client transport.
@@ -2464,25 +3019,129 @@ var ClientSession = class {
2464
3019
  */
2465
3020
  relayOperations(operations2) {
2466
3021
  if (this.state !== "streaming" || !this.transport.isConnected()) return;
3022
+ if (this.clientDeliveryWatermark !== null) {
3023
+ this.pushDeliveryStream();
3024
+ return;
3025
+ }
2467
3026
  if (operations2.length === 0) return;
2468
- const visibleOperations = operations2.filter((op) => this.operationVisibleToClient(op));
3027
+ void this.relayVisibleOperations(operations2);
3028
+ }
3029
+ /**
3030
+ * Push newly-available in-scope operations to a delivery-watermark client, resuming
3031
+ * from the highest delivery sequence already sent. Pushes are serialized so their
3032
+ * batches never interleave (which would break the base/max chain).
3033
+ */
3034
+ pushDeliveryStream() {
3035
+ this.deliveryPushChain = this.deliveryPushChain.then(async () => {
3036
+ if (this.state !== "streaming" || !this.transport.isConnected()) return;
3037
+ try {
3038
+ this.lastDeliveryPushAttemptAtMs = Date.now();
3039
+ await this.sendDeliveryStream(
3040
+ this.lastAckedDeliverySeq,
3041
+ false,
3042
+ this.clientNodeId ?? void 0
3043
+ );
3044
+ } catch {
3045
+ }
3046
+ });
3047
+ }
3048
+ async relayVisibleOperations(operations2) {
3049
+ const visibleOperations = [];
3050
+ for (const op of operations2) {
3051
+ if (await this.operationVisibleToClient(op)) {
3052
+ visibleOperations.push(op);
3053
+ }
3054
+ }
2469
3055
  if (visibleOperations.length === 0) return;
3056
+ if (this.state !== "streaming" || !this.transport.isConnected()) return;
2470
3057
  const serializedOps = visibleOperations.map((op) => this.serializer.encodeOperation(op));
2471
3058
  const msg = {
2472
3059
  type: "operation-batch",
2473
- messageId: (0, import_core10.generateUUIDv7)(),
3060
+ messageId: (0, import_core11.generateUUIDv7)(),
2474
3061
  operations: serializedOps,
2475
3062
  isFinal: true,
2476
3063
  batchIndex: 0
2477
3064
  };
3065
+ this.trackPendingRelay(msg, visibleOperations);
2478
3066
  this.sendToClient(msg);
2479
3067
  }
3068
+ /** Record a relay batch as awaiting acknowledgment, evicting the oldest if full. */
3069
+ trackPendingRelay(msg, ops) {
3070
+ if (this.pendingRelays.size >= _ClientSession.MAX_PENDING_RELAYS) {
3071
+ const oldest = this.pendingRelays.keys().next().value;
3072
+ if (oldest !== void 0) {
3073
+ this.pendingRelays.delete(oldest);
3074
+ }
3075
+ }
3076
+ this.pendingRelays.set(msg.messageId, { message: msg, ops, sentAtMs: Date.now() });
3077
+ }
3078
+ /**
3079
+ * Retransmit relay batches this client has not acknowledged within `staleMs`.
3080
+ * Called on a periodic tick by the server (and directly by tests). Redelivering an
3081
+ * already-applied op is harmless: the client dedups by content-addressed id.
3082
+ */
3083
+ retransmitPendingRelays(staleMs = 0) {
3084
+ if (this.state !== "streaming" || !this.transport.isConnected()) return;
3085
+ if (this.clientDeliveryWatermark !== null) {
3086
+ return;
3087
+ }
3088
+ if (this.pendingRelays.size === 0) return;
3089
+ const cutoff = Date.now() - staleMs;
3090
+ for (const { message, sentAtMs } of this.pendingRelays.values()) {
3091
+ if (sentAtMs <= cutoff) {
3092
+ this.sendToClient(message);
3093
+ }
3094
+ }
3095
+ }
3096
+ /**
3097
+ * Wake the durable delivery stream for clients that negotiated delivery
3098
+ * watermarks. This is used both for dropped watermark batches and for operations
3099
+ * appended by another server/store instance: the session always scans from its
3100
+ * own acknowledged cursor and applies its visibility filter before sending.
3101
+ */
3102
+ pushDeliveryStreamIfSupported(staleMs = 0, options = {}) {
3103
+ if (this.state !== "streaming" || !this.transport.isConnected()) return;
3104
+ if (this.clientDeliveryWatermark === null) return;
3105
+ if (staleMs > 0 && Date.now() - this.lastDeliveryPushAttemptAtMs < staleMs) {
3106
+ return;
3107
+ }
3108
+ if (options.trackStall) {
3109
+ this.deliveryStallRepeatCount += 1;
3110
+ if (this.deliveryStallRepeatCount >= 3) {
3111
+ this.emitter?.emit({
3112
+ type: "sync:delivery-stalled",
3113
+ sessionId: this.sessionId,
3114
+ watermark: this.lastAckedDeliverySeq,
3115
+ repeatCount: this.deliveryStallRepeatCount
3116
+ });
3117
+ }
3118
+ }
3119
+ this.pushDeliveryStream();
3120
+ }
2480
3121
  /**
2481
3122
  * Close this session.
2482
3123
  */
3124
+ /**
3125
+ * Hand any unacknowledged relays to the server so they survive this session and are
3126
+ * redelivered when this client reconnects (per-node buffer), then clear them. Called
3127
+ * from every session-teardown path (explicit close and transport close).
3128
+ */
3129
+ flushOrphanedRelays() {
3130
+ if (this.onOrphanedRelays && this.clientNodeId && this.pendingRelays.size > 0) {
3131
+ const ops = [];
3132
+ for (const entry of this.pendingRelays.values()) {
3133
+ ops.push(...entry.ops);
3134
+ }
3135
+ if (ops.length > 0) {
3136
+ this.onOrphanedRelays(this.clientNodeId, ops);
3137
+ }
3138
+ }
3139
+ this.pendingRelays.clear();
3140
+ }
2483
3141
  close(reason) {
2484
3142
  if (this.state === "closed") return;
2485
3143
  this.state = "closed";
3144
+ this.flushOrphanedRelays();
2486
3145
  if (this.transport.isConnected()) {
2487
3146
  this.transport.close(1e3, reason ?? "session closed");
2488
3147
  }
@@ -2537,6 +3196,11 @@ var ClientSession = class {
2537
3196
  await this.handleOperationBatch(message);
2538
3197
  break;
2539
3198
  case "acknowledgment":
3199
+ this.pendingRelays.delete(message.acknowledgedMessageId);
3200
+ if (message.deliverySequence !== void 0) {
3201
+ this.lastAckedDeliverySeq = Math.max(this.lastAckedDeliverySeq, message.deliverySequence);
3202
+ this.deliveryStallRepeatCount = 0;
3203
+ }
2540
3204
  break;
2541
3205
  case "error":
2542
3206
  break;
@@ -2567,7 +3231,7 @@ var ClientSession = class {
2567
3231
  return;
2568
3232
  }
2569
3233
  const bytes = (0, import_sync2.decodeBlobChunkBytes)(message.bytes);
2570
- const actual = await (0, import_core10.hashBlob)(bytes);
3234
+ const actual = await (0, import_core11.hashBlob)(bytes);
2571
3235
  if (actual !== message.hash) {
2572
3236
  return;
2573
3237
  }
@@ -2575,6 +3239,18 @@ var ClientSession = class {
2575
3239
  }
2576
3240
  handleMessageFailure(error) {
2577
3241
  const reason = error instanceof Error ? error.message : "Message handling failed";
3242
+ this.logger?.log({
3243
+ timestamp: Date.now(),
3244
+ level: "error",
3245
+ event: "session.message_failed",
3246
+ sessionId: this.sessionId,
3247
+ nodeId: this.clientNodeId ?? void 0,
3248
+ error: reason,
3249
+ details: {
3250
+ state: this.state,
3251
+ errorName: error instanceof Error ? error.name : typeof error
3252
+ }
3253
+ });
2578
3254
  this.sendError("SYNC_ERROR", reason, true);
2579
3255
  this.close(reason);
2580
3256
  }
@@ -2595,11 +3271,25 @@ var ClientSession = class {
2595
3271
  this.authContext = context;
2596
3272
  this.state = "authenticated";
2597
3273
  }
2598
- const resolvedScopes = resolveSessionScopes(this.store.getSchema(), {
3274
+ const rawResolvedScopes = resolveSessionScopes(this.store.getSchema(), {
2599
3275
  handshakeScope: msg.syncScope,
2600
3276
  authScopes: this.authContext?.scopes
2601
3277
  });
2602
- if (resolvedScopes) {
3278
+ let resolvedScopes;
3279
+ try {
3280
+ resolvedScopes = rawResolvedScopes ? normalizeScopeMap(rawResolvedScopes) : void 0;
3281
+ } catch (error) {
3282
+ this.sendToClient({
3283
+ type: "error",
3284
+ messageId: (0, import_core11.generateUUIDv7)(),
3285
+ code: "SCOPE_PREDICATE_LIMIT",
3286
+ message: error instanceof Error ? error.message : "Invalid scope predicate",
3287
+ retriable: false
3288
+ });
3289
+ this.close("invalid scope predicate");
3290
+ return;
3291
+ }
3292
+ if (resolvedScopes) {
2603
3293
  if (this.authContext) {
2604
3294
  this.authContext = { ...this.authContext, scopes: resolvedScopes };
2605
3295
  } else {
@@ -2613,14 +3303,34 @@ var ClientSession = class {
2613
3303
  this.syncQuerySubsets = [];
2614
3304
  }
2615
3305
  this.resumeDeltaCursor = msg.deltaCursor ? (0, import_sync3.decodeDeltaCursor)(msg.deltaCursor) : null;
3306
+ this.clientDeliveryWatermark = msg.lastDeliverySequence ?? null;
3307
+ if (this.clientDeliveryWatermark !== null && !sameScopeMap(msg.syncScope, this.authContext?.scopes)) {
3308
+ this.clientDeliveryWatermark = 0;
3309
+ }
3310
+ const clientUsesWatermark = this.clientDeliveryWatermark !== null;
3311
+ let serverMaxDelivery = 0;
3312
+ if (clientUsesWatermark) {
3313
+ serverMaxDelivery = await this.store.getMaxDeliverySequence();
3314
+ if (this.clientDeliveryWatermark !== null && this.clientDeliveryWatermark > serverMaxDelivery) {
3315
+ this.clientDeliveryWatermark = 0;
3316
+ }
3317
+ }
2616
3318
  const serverVector = this.store.getVersionVector();
2617
3319
  const selectedWireFormat = selectWireFormat(msg.supportedWireFormats);
2618
3320
  this.setSerializerWireFormat(selectedWireFormat);
2619
3321
  if (!(0, import_sync3.isClientSchemaVersionSupported)(msg.schemaVersion, this.supportedSchemaVersions)) {
2620
3322
  const { min, max } = this.supportedSchemaVersions;
3323
+ this.emitter?.emit({
3324
+ type: "sync:schema-mismatch",
3325
+ clientSchemaVersion: msg.schemaVersion,
3326
+ serverSchemaVersion: this.schemaVersion,
3327
+ supportedMin: min,
3328
+ supportedMax: max,
3329
+ reason: `${import_sync3.SCHEMA_MISMATCH_PREFIX}: client schema version ${msg.schemaVersion} not in supported range [${min}, ${max}]`
3330
+ });
2621
3331
  const response2 = {
2622
3332
  type: "handshake-response",
2623
- messageId: (0, import_core10.generateUUIDv7)(),
3333
+ messageId: (0, import_core11.generateUUIDv7)(),
2624
3334
  nodeId: this.store.getNodeId(),
2625
3335
  versionVector: (0, import_sync3.versionVectorToWire)(serverVector),
2626
3336
  schemaVersion: this.schemaVersion,
@@ -2636,13 +3346,17 @@ var ClientSession = class {
2636
3346
  }
2637
3347
  const response = {
2638
3348
  type: "handshake-response",
2639
- messageId: (0, import_core10.generateUUIDv7)(),
3349
+ messageId: (0, import_core11.generateUUIDv7)(),
2640
3350
  nodeId: this.store.getNodeId(),
2641
3351
  versionVector: (0, import_sync3.versionVectorToWire)(serverVector),
2642
3352
  schemaVersion: this.schemaVersion,
2643
3353
  accepted: true,
2644
3354
  selectedWireFormat,
2645
3355
  serverTime: Date.now(),
3356
+ // The server's highest delivery sequence, so a client whose persisted watermark
3357
+ // is ahead of it (server rolled back) can reset to a full resync. Sent only to
3358
+ // watermark-using clients.
3359
+ ...clientUsesWatermark ? { serverMaxDeliverySequence: serverMaxDelivery } : {},
2646
3360
  // Advertise central blob storage so the client uploads the bytes behind
2647
3361
  // its blob fields, keeping them available after the author goes offline.
2648
3362
  ...this.persistBlobChunk ? { blobStorageEnabled: true } : {},
@@ -2653,17 +3367,40 @@ var ClientSession = class {
2653
3367
  this.sendToClient(response);
2654
3368
  this.emitter?.emit({ type: "sync:connected", nodeId: msg.nodeId });
2655
3369
  this.state = "syncing";
2656
- const clientVector = (0, import_sync3.wireToVersionVector)(msg.versionVector);
2657
- await this.sendDelta(clientVector);
3370
+ if (this.clientDeliveryWatermark !== null) {
3371
+ this.lastAckedDeliverySeq = this.clientDeliveryWatermark;
3372
+ const excludeOwn = this.clientDeliveryWatermark > 0 ? this.clientNodeId ?? void 0 : void 0;
3373
+ this.lastDeliveryPushAttemptAtMs = Date.now();
3374
+ await this.sendDeliveryStream(this.clientDeliveryWatermark, true, excludeOwn);
3375
+ } else {
3376
+ const clientVector = (0, import_sync3.wireToVersionVector)(msg.versionVector);
3377
+ await this.sendDelta(clientVector);
3378
+ }
2658
3379
  this.state = "streaming";
3380
+ if (this.takeOrphanedRelays && this.clientNodeId) {
3381
+ const buffered = this.takeOrphanedRelays(this.clientNodeId);
3382
+ if (buffered.length > 0) {
3383
+ this.relayOperations(buffered);
3384
+ }
3385
+ }
2659
3386
  }
2660
3387
  async handleOperationBatch(msg) {
2661
3388
  const operations2 = msg.operations.map((s) => this.serializer.decodeOperation(s));
2662
3389
  const applied = [];
2663
- const rejected = [];
3390
+ let acknowledgedThrough = 0;
3391
+ let canAdvanceAck = true;
2664
3392
  for (const op of operations2) {
2665
- if (!this.operationVisibleToClient(op)) {
2666
- rejected.push(op);
3393
+ if (!canAdvanceAck) {
3394
+ continue;
3395
+ }
3396
+ if (!await this.operationVisibleToClient(op)) {
3397
+ this.sendOperationRejected(
3398
+ op,
3399
+ "SCOPE_VIOLATION",
3400
+ `Operation "${op.id}" in collection "${op.collection}" is outside the client's sync scope. Refresh sync scopes and retry.`,
3401
+ true
3402
+ );
3403
+ canAdvanceAck = false;
2667
3404
  continue;
2668
3405
  }
2669
3406
  if (!isOperationTimestampValid(op)) {
@@ -2672,6 +3409,7 @@ var ClientSession = class {
2672
3409
  `Operation "${op.id}" timestamp is too far in the future`,
2673
3410
  false
2674
3411
  );
3412
+ canAdvanceAck = false;
2675
3413
  continue;
2676
3414
  }
2677
3415
  if (!this.rateLimiter.allow(1)) {
@@ -2680,6 +3418,7 @@ var ClientSession = class {
2680
3418
  `Session exceeded operation rate limit (${String(this.maxOpsPerMinute)} ops/min)`,
2681
3419
  true
2682
3420
  );
3421
+ canAdvanceAck = false;
2683
3422
  continue;
2684
3423
  }
2685
3424
  const sizeCheck = validateOperationSize(op, this.maxOperationBytes);
@@ -2689,27 +3428,73 @@ var ClientSession = class {
2689
3428
  sizeCheck.message ?? `Operation "${op.id}" is too large`,
2690
3429
  false
2691
3430
  );
3431
+ canAdvanceAck = false;
2692
3432
  continue;
2693
3433
  }
2694
- try {
2695
- const applyResult = await applyServerOperation(this.store, op);
2696
- if (applyResult.rejection) {
2697
- this.sendError(applyResult.rejection.code, applyResult.rejection.message, false);
3434
+ const serverOp = this.transformForServerSchema(op);
3435
+ if (serverOp === null) {
3436
+ this.sendOperationRejected(
3437
+ op,
3438
+ "SCHEMA_TRANSFORM_UNAVAILABLE",
3439
+ `Operation "${op.id}" cannot be transformed from schema v${op.schemaVersion} to server schema v${this.schemaVersion}.`,
3440
+ false
3441
+ );
3442
+ acknowledgedThrough = op.sequenceNumber;
3443
+ continue;
3444
+ }
3445
+ if (this.validateOperation && this.koraContext) {
3446
+ let decision;
3447
+ try {
3448
+ decision = await this.validateOperation(serverOp, {
3449
+ auth: this.authContext,
3450
+ kora: this.koraContext
3451
+ });
3452
+ } catch (error) {
3453
+ const message = error instanceof Error ? error.message : "validator error";
3454
+ this.sendOperationRejected(
3455
+ serverOp,
3456
+ "VALIDATION_ERROR",
3457
+ `Validator threw: ${message}`,
3458
+ true
3459
+ );
3460
+ canAdvanceAck = false;
3461
+ continue;
3462
+ }
3463
+ if (decision.action === "reject") {
3464
+ const retriable = decision.retriable ?? isRetriableRejection(decision.code);
3465
+ this.sendOperationRejected(serverOp, decision.code, decision.message, retriable);
3466
+ if (retriable) {
3467
+ canAdvanceAck = false;
3468
+ } else {
3469
+ acknowledgedThrough = op.sequenceNumber;
3470
+ }
2698
3471
  continue;
2699
3472
  }
2700
- if (applyResult.result === "applied") {
2701
- applied.push(...applyResult.appliedOperations);
3473
+ if (decision.action === "ignore") {
3474
+ acknowledgedThrough = op.sequenceNumber;
3475
+ continue;
2702
3476
  }
2703
- } catch {
2704
3477
  }
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
3478
+ const applyResult = await applyServerOperation(this.store, serverOp);
3479
+ if (applyResult.rejection) {
3480
+ this.sendOperationRejected(
3481
+ serverOp,
3482
+ applyResult.rejection.code,
3483
+ applyResult.rejection.message,
3484
+ applyResult.rejection.retriable
2712
3485
  );
3486
+ if (applyResult.rejection.retriable) {
3487
+ canAdvanceAck = false;
3488
+ } else {
3489
+ acknowledgedThrough = op.sequenceNumber;
3490
+ }
3491
+ continue;
3492
+ }
3493
+ if (applyResult.result === "applied") {
3494
+ applied.push(...applyResult.appliedOperations);
3495
+ acknowledgedThrough = op.sequenceNumber;
3496
+ } else {
3497
+ acknowledgedThrough = op.sequenceNumber;
2713
3498
  }
2714
3499
  }
2715
3500
  if (operations2.length > 0) {
@@ -2722,15 +3507,21 @@ var ClientSession = class {
2722
3507
  const lastOp = operations2[operations2.length - 1];
2723
3508
  const ack = {
2724
3509
  type: "acknowledgment",
2725
- messageId: (0, import_core10.generateUUIDv7)(),
3510
+ messageId: (0, import_core11.generateUUIDv7)(),
2726
3511
  acknowledgedMessageId: msg.messageId,
2727
- lastSequenceNumber: lastOp ? lastOp.sequenceNumber : 0
3512
+ lastSequenceNumber: lastOp ? Math.min(acknowledgedThrough, lastOp.sequenceNumber) : 0
2728
3513
  };
2729
3514
  this.sendToClient(ack);
2730
3515
  if (applied.length > 0) {
2731
3516
  this.onRelay?.(this.sessionId, applied);
2732
3517
  }
2733
3518
  }
3519
+ transformForServerSchema(op) {
3520
+ if (op.schemaVersion === this.schemaVersion) {
3521
+ return op;
3522
+ }
3523
+ return (0, import_core10.applyOperationTransforms)(op, this.schemaVersion, this.operationTransforms);
3524
+ }
2734
3525
  async sendDelta(clientVector) {
2735
3526
  const serverVector = this.store.getVersionVector();
2736
3527
  const missing = [];
@@ -2738,14 +3529,17 @@ var ClientSession = class {
2738
3529
  const clientSeq = clientVector.get(nodeId) ?? 0;
2739
3530
  if (serverSeq > clientSeq) {
2740
3531
  const ops = await this.store.getOperationRange(nodeId, clientSeq + 1, serverSeq);
2741
- const visible = ops.filter((op) => this.operationVisibleToClient(op));
2742
- missing.push(...visible);
3532
+ for (const op of ops) {
3533
+ if (await this.operationVisibleToClient(op)) {
3534
+ missing.push(op);
3535
+ }
3536
+ }
2743
3537
  }
2744
3538
  }
2745
3539
  if (missing.length === 0) {
2746
3540
  const emptyBatch = {
2747
3541
  type: "operation-batch",
2748
- messageId: (0, import_core10.generateUUIDv7)(),
3542
+ messageId: (0, import_core11.generateUUIDv7)(),
2749
3543
  operations: [],
2750
3544
  isFinal: true,
2751
3545
  batchIndex: 0,
@@ -2760,7 +3554,7 @@ var ClientSession = class {
2760
3554
  if (afterCursor.length === 0) {
2761
3555
  const emptyBatch = {
2762
3556
  type: "operation-batch",
2763
- messageId: (0, import_core10.generateUUIDv7)(),
3557
+ messageId: (0, import_core11.generateUUIDv7)(),
2764
3558
  operations: [],
2765
3559
  isFinal: true,
2766
3560
  batchIndex: this.resumeDeltaCursor?.batchIndex ?? 0,
@@ -2776,7 +3570,7 @@ var ClientSession = class {
2776
3570
  const batchCursor = (0, import_sync3.createDeltaCursorFromBatch)(batchOps, i);
2777
3571
  const batchMsg = {
2778
3572
  type: "operation-batch",
2779
- messageId: (0, import_core10.generateUUIDv7)(),
3573
+ messageId: (0, import_core11.generateUUIDv7)(),
2780
3574
  operations: serializedOps,
2781
3575
  isFinal: i === totalBatches - 1,
2782
3576
  batchIndex: i,
@@ -2791,11 +3585,111 @@ var ClientSession = class {
2791
3585
  });
2792
3586
  }
2793
3587
  }
2794
- operationVisibleToClient(op) {
2795
- if (!operationMatchesScopes(op, this.authContext?.scopes)) {
3588
+ /**
3589
+ * Send the gap-free server->client delivery stream, resuming just after
3590
+ * `fromDeliverySeq`. Operations are scanned in server delivery-sequence order
3591
+ * (commit order), scope-filtered for this session, and sent in batches that chain:
3592
+ * each batch's `baseDeliverySequence` equals the previous batch's
3593
+ * `maxDeliverySequence` (the first batch bases on `fromDeliverySeq`). The client
3594
+ * applies a batch only when its watermark equals the base and advances the
3595
+ * watermark to the max, so a dropped batch stalls the watermark and is recovered by
3596
+ * the next handshake resend. Because delivery-sequence order respects causal order
3597
+ * (a dependency is always committed, and thus sequenced, before its dependent), no
3598
+ * topological sort is needed and the client never defers on a missing dependency
3599
+ * that is itself in this stream.
3600
+ *
3601
+ * The final batch advances the watermark to the highest delivery sequence scanned,
3602
+ * not merely the last in-scope one, so an out-of-scope tail is not re-scanned on the
3603
+ * next reconnect. Returns the number of operations actually sent.
3604
+ */
3605
+ async sendDeliveryStream(fromDeliverySeq, finalizeWhenEmpty, excludeNodeId) {
3606
+ const scanChunk = Math.max(this.batchSize, 1) * 5;
3607
+ let scanCursor = fromDeliverySeq;
3608
+ let maxScanned = fromDeliverySeq;
3609
+ const visible = [];
3610
+ while (true) {
3611
+ const chunk = await this.store.getOperationsAfterDelivery(scanCursor, scanChunk);
3612
+ const last = chunk[chunk.length - 1];
3613
+ if (last === void 0) break;
3614
+ scanCursor = last.deliverySequence;
3615
+ maxScanned = scanCursor;
3616
+ for (const delivered of chunk) {
3617
+ if (excludeNodeId !== void 0 && delivered.operation.nodeId === excludeNodeId) {
3618
+ continue;
3619
+ }
3620
+ if (await this.operationVisibleToClient(delivered.operation)) {
3621
+ visible.push(delivered);
3622
+ }
3623
+ }
3624
+ if (chunk.length < scanChunk) break;
3625
+ }
3626
+ if (visible.length === 0) {
3627
+ if (finalizeWhenEmpty) {
3628
+ this.sendDeliveryBatch([], fromDeliverySeq, maxScanned, 0, true);
3629
+ }
3630
+ return 0;
3631
+ }
3632
+ const totalBatches = Math.ceil(visible.length / this.batchSize);
3633
+ let base = fromDeliverySeq;
3634
+ for (let i = 0; i < totalBatches; i++) {
3635
+ const slice = visible.slice(i * this.batchSize, (i + 1) * this.batchSize);
3636
+ const lastInSlice = slice[slice.length - 1];
3637
+ if (lastInSlice === void 0) continue;
3638
+ const isFinal = i === totalBatches - 1;
3639
+ const lastSeq = lastInSlice.deliverySequence;
3640
+ const max = isFinal ? Math.max(maxScanned, lastSeq) : lastSeq;
3641
+ this.sendDeliveryBatch(slice, base, max, i, isFinal);
3642
+ base = max;
3643
+ }
3644
+ return visible.length;
3645
+ }
3646
+ /** Build, track, and send one chained delivery-stream batch. */
3647
+ sendDeliveryBatch(slice, base, max, batchIndex, isFinal) {
3648
+ const batchMsg = {
3649
+ type: "operation-batch",
3650
+ messageId: (0, import_core11.generateUUIDv7)(),
3651
+ operations: slice.map((delivered) => this.serializer.encodeOperation(delivered.operation)),
3652
+ isFinal,
3653
+ batchIndex,
3654
+ baseDeliverySequence: base,
3655
+ maxDeliverySequence: max
3656
+ };
3657
+ this.sendToClient(batchMsg);
3658
+ if (slice.length > 0) {
3659
+ this.emitter?.emit({
3660
+ type: "sync:sent",
3661
+ operations: slice.map((delivered) => delivered.operation),
3662
+ batchSize: slice.length
3663
+ });
3664
+ }
3665
+ }
3666
+ async operationVisibleToClient(op) {
3667
+ const scopes = this.authContext?.scopes;
3668
+ const subsets = this.syncQuerySubsets;
3669
+ const needsBackfill = missingScopeFields(op, scopes).length > 0 || subsets !== void 0 && subsets.length > 0;
3670
+ const fullRecord = needsBackfill ? await this.lookupRecordFields(op.collection, op.recordId) : void 0;
3671
+ if (!operationMatchesScopes(op, scopes, fullRecord)) {
2796
3672
  return false;
2797
3673
  }
2798
- return (0, import_sync3.operationMatchesQuerySubsets)(op, this.syncQuerySubsets);
3674
+ return (0, import_sync3.operationMatchesQuerySubsets)(op, subsets, fullRecord);
3675
+ }
3676
+ /**
3677
+ * Read a record's current field values from the materialized store for scope /
3678
+ * query-subset backfill. Includes soft-deleted rows so a relayed delete (whose op
3679
+ * carries no fields) is still judged against the record's actual scope. Returns
3680
+ * undefined when the record cannot be read (never materialized, or no schema).
3681
+ */
3682
+ async lookupRecordFields(collection, recordId) {
3683
+ try {
3684
+ const rows = await this.store.queryCollection(collection, {
3685
+ where: { id: recordId },
3686
+ includeDeleted: true,
3687
+ limit: 1
3688
+ });
3689
+ return rows[0];
3690
+ } catch {
3691
+ return void 0;
3692
+ }
2799
3693
  }
2800
3694
  handleAwarenessUpdate(msg) {
2801
3695
  this.onAwarenessUpdate?.(this.sessionId, msg);
@@ -2806,13 +3700,32 @@ var ClientSession = class {
2806
3700
  sendError(code, message, retriable) {
2807
3701
  const errorMsg = {
2808
3702
  type: "error",
2809
- messageId: (0, import_core10.generateUUIDv7)(),
3703
+ messageId: (0, import_core11.generateUUIDv7)(),
2810
3704
  code,
2811
3705
  message,
2812
3706
  retriable
2813
3707
  };
2814
3708
  this.sendToClient(errorMsg);
2815
3709
  }
3710
+ /**
3711
+ * Reject one specific client operation, tied to its id, so the submitter can
3712
+ * divert it out of its pending queue into a durable rejected store rather than
3713
+ * losing it or retrying forever. The op is NOT materialized, so no other
3714
+ * replica ever sees it.
3715
+ */
3716
+ sendOperationRejected(op, code, message, retriable) {
3717
+ const rejectedMsg = {
3718
+ type: "operation-rejected",
3719
+ messageId: (0, import_core11.generateUUIDv7)(),
3720
+ operationId: op.id,
3721
+ collection: op.collection,
3722
+ recordId: op.recordId,
3723
+ code,
3724
+ message,
3725
+ retriable
3726
+ };
3727
+ this.sendToClient(rejectedMsg);
3728
+ }
2816
3729
  setSerializerWireFormat(format) {
2817
3730
  if (typeof this.serializer.setWireFormat === "function") {
2818
3731
  this.serializer.setWireFormat(format);
@@ -2821,6 +3734,7 @@ var ClientSession = class {
2821
3734
  handleTransportClose() {
2822
3735
  if (this.state === "closed") return;
2823
3736
  this.state = "closed";
3737
+ this.flushOrphanedRelays();
2824
3738
  this.emitter?.emit({ type: "sync:disconnected", reason: "transport closed" });
2825
3739
  this.onClose?.(this.sessionId);
2826
3740
  }
@@ -2834,13 +3748,13 @@ function selectWireFormat(supportedWireFormats) {
2834
3748
 
2835
3749
  // src/server/kora-sync-server.ts
2836
3750
  init_cjs_shims();
2837
- var import_core12 = require("@korajs/core");
3751
+ var import_core14 = require("@korajs/core");
2838
3752
  var import_internal2 = require("@korajs/core/internal");
2839
3753
  var import_sync5 = require("@korajs/sync");
2840
3754
 
2841
3755
  // src/awareness/awareness-relay.ts
2842
3756
  init_cjs_shims();
2843
- var import_core11 = require("@korajs/core");
3757
+ var import_core12 = require("@korajs/core");
2844
3758
  var AwarenessRelay = class {
2845
3759
  clients = /* @__PURE__ */ new Map();
2846
3760
  /**
@@ -2869,7 +3783,7 @@ var AwarenessRelay = class {
2869
3783
  if (hasStates) {
2870
3784
  const catchUpMsg = {
2871
3785
  type: "awareness-update",
2872
- messageId: (0, import_core11.generateUUIDv7)(),
3786
+ messageId: (0, import_core12.generateUUIDv7)(),
2873
3787
  clientId: 0,
2874
3788
  // Server-sourced
2875
3789
  states: existingStates
@@ -2892,7 +3806,7 @@ var AwarenessRelay = class {
2892
3806
  };
2893
3807
  const msg = {
2894
3808
  type: "awareness-update",
2895
- messageId: (0, import_core11.generateUUIDv7)(),
3809
+ messageId: (0, import_core12.generateUUIDv7)(),
2896
3810
  clientId: client.clientId,
2897
3811
  states: removalStates
2898
3812
  };
@@ -3188,13 +4102,395 @@ var YjsDocRelay = class {
3188
4102
  if (client.sessionId === excludeSessionId) {
3189
4103
  continue;
3190
4104
  }
3191
- if (!client.transport.isConnected()) {
3192
- continue;
4105
+ if (!client.transport.isConnected()) {
4106
+ continue;
4107
+ }
4108
+ client.transport.send(message);
4109
+ }
4110
+ }
4111
+ };
4112
+
4113
+ // src/server/route-context.ts
4114
+ init_cjs_shims();
4115
+ var import_core13 = require("@korajs/core");
4116
+
4117
+ // src/apply/route-predicate.ts
4118
+ init_cjs_shims();
4119
+ function evaluateField(actual, operators) {
4120
+ if ("$eq" in operators && !Object.is(actual, operators.$eq)) {
4121
+ return false;
4122
+ }
4123
+ if ("$ne" in operators && Object.is(actual, operators.$ne)) {
4124
+ return false;
4125
+ }
4126
+ if (operators.$in !== void 0 && !operators.$in.some((candidate) => Object.is(actual, candidate))) {
4127
+ return false;
4128
+ }
4129
+ const numericChecks = [
4130
+ ["$lt", (a, b) => a < b],
4131
+ ["$lte", (a, b) => a <= b],
4132
+ ["$gt", (a, b) => a > b],
4133
+ ["$gte", (a, b) => a >= b]
4134
+ ];
4135
+ for (const [operator, compare] of numericChecks) {
4136
+ const bound = operators[operator];
4137
+ if (bound === void 0) {
4138
+ continue;
4139
+ }
4140
+ if (typeof actual !== "number" || typeof bound !== "number") {
4141
+ return false;
4142
+ }
4143
+ if (!compare(actual, bound)) {
4144
+ return false;
4145
+ }
4146
+ }
4147
+ return true;
4148
+ }
4149
+ function evaluateRoutePredicate(record, predicate) {
4150
+ const fields = Object.keys(predicate);
4151
+ if (fields.length === 0) {
4152
+ return true;
4153
+ }
4154
+ if (record === null) {
4155
+ return false;
4156
+ }
4157
+ for (const field of fields) {
4158
+ const operators = predicate[field];
4159
+ if (operators && !evaluateField(record[field], operators)) {
4160
+ return false;
4161
+ }
4162
+ }
4163
+ return true;
4164
+ }
4165
+
4166
+ // src/server/route-context.ts
4167
+ function recordMatchesScope(collection, record, scope) {
4168
+ const collectionScope = scope[collection];
4169
+ if (!collectionScope) {
4170
+ return false;
4171
+ }
4172
+ for (const [field, expected] of Object.entries(collectionScope)) {
4173
+ if (record[field] !== expected) {
4174
+ return false;
4175
+ }
4176
+ }
4177
+ return true;
4178
+ }
4179
+ function pickFields(record, keys) {
4180
+ const picked = {};
4181
+ for (const key of keys) {
4182
+ if (key in record) {
4183
+ picked[key] = record[key];
4184
+ }
4185
+ }
4186
+ return picked;
4187
+ }
4188
+ async function buildRouteOperation(store, mutation, currentOverride, clockOverride) {
4189
+ const nodeId = store.getNodeId();
4190
+ const clock = clockOverride ?? new import_core13.HybridLogicalClock(nodeId);
4191
+ const schemaVersion = store.getSchema()?.version ?? 1;
4192
+ const sequenceNumber = nextServerSequenceNumber(store);
4193
+ if (mutation.type === "insert") {
4194
+ return (0, import_core13.createOperation)(
4195
+ {
4196
+ nodeId,
4197
+ type: "insert",
4198
+ collection: mutation.collection,
4199
+ recordId: mutation.recordId ?? (0, import_core13.generateUUIDv7)(),
4200
+ data: mutation.data ?? {},
4201
+ previousData: null,
4202
+ sequenceNumber,
4203
+ causalDeps: [],
4204
+ schemaVersion
4205
+ },
4206
+ clock
4207
+ );
4208
+ }
4209
+ if (!mutation.recordId) {
4210
+ throw new RouteMutationError(
4211
+ "MISSING_RECORD_ID",
4212
+ `A ${mutation.type} mutation on "${mutation.collection}" requires a recordId.`
4213
+ );
4214
+ }
4215
+ const current = currentOverride ? currentOverride.record : await store.findRecord(mutation.collection, mutation.recordId);
4216
+ if (mutation.type === "update") {
4217
+ const rawData = mutation.data ?? {};
4218
+ const data = {};
4219
+ const atomicOps = {};
4220
+ for (const key of Object.keys(rawData)) {
4221
+ const value = rawData[key];
4222
+ if ((0, import_core13.isAtomicOp)(value)) {
4223
+ data[key] = (0, import_core13.resolveAtomicOp)(current ? current[key] : void 0, value);
4224
+ atomicOps[key] = (0, import_core13.toAtomicOp)(value);
4225
+ } else {
4226
+ data[key] = value;
4227
+ }
4228
+ }
4229
+ return (0, import_core13.createOperation)(
4230
+ {
4231
+ nodeId,
4232
+ type: "update",
4233
+ collection: mutation.collection,
4234
+ recordId: mutation.recordId,
4235
+ data,
4236
+ previousData: current ? pickFields(current, Object.keys(rawData)) : {},
4237
+ sequenceNumber,
4238
+ causalDeps: [],
4239
+ schemaVersion,
4240
+ ...Object.keys(atomicOps).length > 0 ? { atomicOps } : {}
4241
+ },
4242
+ clock
4243
+ );
4244
+ }
4245
+ return (0, import_core13.createOperation)(
4246
+ {
4247
+ nodeId,
4248
+ type: "delete",
4249
+ collection: mutation.collection,
4250
+ recordId: mutation.recordId,
4251
+ data: null,
4252
+ previousData: current ? { ...current } : null,
4253
+ sequenceNumber,
4254
+ causalDeps: [],
4255
+ schemaVersion
4256
+ },
4257
+ clock
4258
+ );
4259
+ }
4260
+ var RouteMutationError = class extends Error {
4261
+ constructor(code, message) {
4262
+ super(message);
4263
+ this.code = code;
4264
+ this.name = "RouteMutationError";
4265
+ }
4266
+ code;
4267
+ };
4268
+ function createRouteContext(server, store) {
4269
+ let mutationTail = Promise.resolve();
4270
+ function serialize(work) {
4271
+ const run = mutationTail.then(work, work);
4272
+ mutationTail = run.then(
4273
+ () => void 0,
4274
+ () => void 0
4275
+ );
4276
+ return run;
4277
+ }
4278
+ async function prepareMutation(mutation, scope) {
4279
+ let op;
4280
+ try {
4281
+ op = await buildRouteOperation(store, mutation);
4282
+ } catch (error) {
4283
+ if (error instanceof RouteMutationError) {
4284
+ return {
4285
+ ok: false,
4286
+ rejection: {
4287
+ ok: false,
4288
+ code: error.code,
4289
+ message: error.message,
4290
+ retriable: isRetriableRejection(error.code)
4291
+ }
4292
+ };
4293
+ }
4294
+ throw error;
4295
+ }
4296
+ if (scope && !operationMatchesScopes(op, scope)) {
4297
+ return {
4298
+ ok: false,
4299
+ rejection: {
4300
+ ok: false,
4301
+ code: "SCOPE_VIOLATION",
4302
+ message: `Mutation on "${mutation.collection}" is outside the provided scope.`,
4303
+ retriable: isRetriableRejection("SCOPE_VIOLATION")
4304
+ }
4305
+ };
4306
+ }
4307
+ return { ok: true, op };
4308
+ }
4309
+ async function commitOperation(op, collection) {
4310
+ const result = await server.applyLocalOperation(op);
4311
+ if (result.result !== "applied") {
4312
+ const code = result.rejection?.code ?? "NOT_APPLIED";
4313
+ return {
4314
+ ok: false,
4315
+ code,
4316
+ message: result.rejection?.message ?? `Operation on "${collection}" was not applied (${result.result}).`,
4317
+ // Prefer the pipeline's own classification when present; fall back
4318
+ // to classifying the code so this stays correct if new codes appear.
4319
+ retriable: result.rejection?.retriable ?? isRetriableRejection(code)
4320
+ };
4321
+ }
4322
+ const record = await store.findRecord(collection, op.recordId);
4323
+ return { ok: true, operation: op, record };
4324
+ }
4325
+ async function rawApply(mutation, scope) {
4326
+ const prepared = await prepareMutation(mutation, scope);
4327
+ if (!prepared.ok) {
4328
+ return prepared.rejection;
4329
+ }
4330
+ return commitOperation(prepared.op, mutation.collection);
4331
+ }
4332
+ async function runConditionalViaStore(storeApplyConditional, spec, options) {
4333
+ const scope = options?.scope;
4334
+ const mutations = [];
4335
+ const hasTargetUpdate = spec.update !== void 0 && spec.update !== null;
4336
+ if (hasTargetUpdate) {
4337
+ mutations.push({
4338
+ collection: spec.collection,
4339
+ type: "update",
4340
+ recordId: spec.id,
4341
+ data: spec.update
4342
+ });
4343
+ }
4344
+ if (spec.also) {
4345
+ mutations.push(...spec.also);
4346
+ }
4347
+ let result;
4348
+ try {
4349
+ result = await storeApplyConditional({
4350
+ target: { collection: spec.collection, id: spec.id },
4351
+ ...spec.idempotencyKey ? { idempotencyKey: spec.idempotencyKey } : {},
4352
+ admit: (current) => spec.if && Object.keys(spec.if).length > 0 ? evaluateRoutePredicate(current, spec.if) : true,
4353
+ buildOperations: async (current, context) => {
4354
+ const ops = [];
4355
+ for (let i = 0; i < mutations.length; i++) {
4356
+ const mutation = mutations[i];
4357
+ const useLockedCurrent = i === 0 && hasTargetUpdate;
4358
+ const op = await buildRouteOperation(
4359
+ store,
4360
+ mutation,
4361
+ useLockedCurrent ? { record: current } : void 0,
4362
+ context.clock
4363
+ );
4364
+ if (scope && !operationMatchesScopes(op, scope)) {
4365
+ throw new RouteMutationError(
4366
+ "SCOPE_VIOLATION",
4367
+ `Mutation on "${mutation.collection}" is outside the provided scope.`
4368
+ );
4369
+ }
4370
+ ops.push(op);
4371
+ }
4372
+ return ops;
4373
+ }
4374
+ });
4375
+ } catch (error) {
4376
+ if (error instanceof RouteMutationError) {
4377
+ return {
4378
+ ok: false,
4379
+ code: error.code,
4380
+ message: error.message,
4381
+ retriable: isRetriableRejection(error.code)
4382
+ };
4383
+ }
4384
+ throw error;
4385
+ }
4386
+ if (!result.admitted) {
4387
+ return {
4388
+ ok: false,
4389
+ code: spec.reject?.code ?? "CONDITION_NOT_MET",
4390
+ message: spec.reject?.message ?? `Admission condition not met on "${spec.collection}" record "${spec.id}".`,
4391
+ retriable: false
4392
+ };
4393
+ }
4394
+ if (result.idempotent) {
4395
+ const records2 = spec.idempotencyKey ? [await store.findRecord(spec.idempotencyKey.collection, spec.idempotencyKey.id)] : [];
4396
+ return { ok: true, idempotent: true, operations: [], records: records2 };
4397
+ }
4398
+ server.relayServerOperations(result.applied);
4399
+ const records = [];
4400
+ for (const op of result.applied) {
4401
+ records.push(await store.findRecord(op.collection, op.recordId));
4402
+ }
4403
+ return { ok: true, idempotent: false, operations: result.applied, records };
4404
+ }
4405
+ return {
4406
+ apply(mutation, options) {
4407
+ return serialize(() => rawApply(mutation, options?.scope));
4408
+ },
4409
+ applyConditional(spec, options) {
4410
+ const storeApplyConditional = store.applyConditional;
4411
+ if (storeApplyConditional) {
4412
+ return runConditionalViaStore(storeApplyConditional.bind(store), spec, options);
4413
+ }
4414
+ return serialize(async () => {
4415
+ if (spec.idempotencyKey) {
4416
+ const existing = await store.findRecord(
4417
+ spec.idempotencyKey.collection,
4418
+ spec.idempotencyKey.id
4419
+ );
4420
+ if (existing) {
4421
+ return { ok: true, idempotent: true, operations: [], records: [existing] };
4422
+ }
4423
+ }
4424
+ if (spec.if && Object.keys(spec.if).length > 0) {
4425
+ const target = await store.findRecord(spec.collection, spec.id);
4426
+ if (!evaluateRoutePredicate(target, spec.if)) {
4427
+ const code = spec.reject?.code ?? "CONDITION_NOT_MET";
4428
+ return {
4429
+ ok: false,
4430
+ code,
4431
+ message: spec.reject?.message ?? `Admission condition not met on "${spec.collection}" record "${spec.id}".`,
4432
+ retriable: false
4433
+ };
4434
+ }
4435
+ }
4436
+ const mutations = [];
4437
+ if (spec.update !== void 0 && spec.update !== null) {
4438
+ mutations.push({
4439
+ collection: spec.collection,
4440
+ type: "update",
4441
+ recordId: spec.id,
4442
+ data: spec.update
4443
+ });
4444
+ }
4445
+ if (spec.also) {
4446
+ mutations.push(...spec.also);
4447
+ }
4448
+ const prepared = [];
4449
+ for (const mutation of mutations) {
4450
+ const p = await prepareMutation(mutation, options?.scope);
4451
+ if (!p.ok) {
4452
+ return p.rejection;
4453
+ }
4454
+ prepared.push({ op: p.op, collection: mutation.collection });
4455
+ }
4456
+ const operations2 = [];
4457
+ const records = [];
4458
+ for (const { op, collection } of prepared) {
4459
+ const result = await commitOperation(op, collection);
4460
+ if (!result.ok) {
4461
+ return {
4462
+ ok: false,
4463
+ code: result.code,
4464
+ message: result.message,
4465
+ retriable: result.retriable
4466
+ };
4467
+ }
4468
+ operations2.push(result.operation);
4469
+ records.push(result.record);
4470
+ }
4471
+ return { ok: true, idempotent: false, operations: operations2, records };
4472
+ });
4473
+ },
4474
+ async query(collection, options) {
4475
+ const { scope, ...queryOptions } = options ?? {};
4476
+ const records = await store.queryCollection(collection, queryOptions);
4477
+ if (!scope) {
4478
+ return records;
3193
4479
  }
3194
- client.transport.send(message);
4480
+ return records.filter((record) => recordMatchesScope(collection, record, scope));
4481
+ },
4482
+ async findById(collection, id, options) {
4483
+ const record = await store.findRecord(collection, id);
4484
+ if (!record) {
4485
+ return null;
4486
+ }
4487
+ if (options?.scope && !recordMatchesScope(collection, record, options.scope)) {
4488
+ return null;
4489
+ }
4490
+ return record;
3195
4491
  }
3196
- }
3197
- };
4492
+ };
4493
+ }
3198
4494
 
3199
4495
  // src/server/kora-sync-server.ts
3200
4496
  var DEFAULT_MAX_CONNECTIONS = 0;
@@ -3202,15 +4498,28 @@ var DEFAULT_BATCH_SIZE2 = 100;
3202
4498
  var DEFAULT_SCHEMA_VERSION2 = 1;
3203
4499
  var DEFAULT_HOST = "0.0.0.0";
3204
4500
  var DEFAULT_PATH = "/";
3205
- var KoraSyncServer = class {
4501
+ var DEFAULT_RELAY_RETRANSMIT_INTERVAL_MS = 2e3;
4502
+ var DEFAULT_DELIVERY_POLL_INTERVAL_MS = 2e3;
4503
+ function validateIntervalOption(name, value) {
4504
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
4505
+ throw new import_core14.SyncError(`${name} must be a non-negative integer number of milliseconds`, {
4506
+ [name]: value
4507
+ });
4508
+ }
4509
+ return value;
4510
+ }
4511
+ var KoraSyncServer = class _KoraSyncServer {
3206
4512
  store;
3207
4513
  auth;
3208
4514
  serializer;
3209
4515
  emitter;
3210
4516
  maxConnections;
3211
4517
  batchSize;
4518
+ relayRetransmitIntervalMs;
4519
+ deliveryPollIntervalMs;
3212
4520
  schemaVersion;
3213
4521
  supportedSchemaVersions;
4522
+ operationTransforms;
3214
4523
  port;
3215
4524
  host;
3216
4525
  path;
@@ -3220,24 +4529,63 @@ var KoraSyncServer = class {
3220
4529
  yjsDocRelay = new YjsDocRelay();
3221
4530
  blobChunkRelay;
3222
4531
  persistBlobChunk;
4532
+ maxOperationBytes;
4533
+ maxOpsPerMinute;
4534
+ validateOperation;
4535
+ koraContext;
3223
4536
  sessions = /* @__PURE__ */ new Map();
3224
4537
  httpClients = /* @__PURE__ */ new Map();
3225
4538
  httpSessionToClient = /* @__PURE__ */ new Map();
3226
- serverVersion = "0.4.0";
4539
+ // Informational value reported by getStatus(). Keep in sync with the
4540
+ // @korajs/server package version on release; it is not used for protocol negotiation.
4541
+ serverVersion = "1.0.0-beta.0";
3227
4542
  wsServer = null;
3228
4543
  running = false;
4544
+ /**
4545
+ * Periodic tick that retransmits relay batches connected clients have not
4546
+ * acknowledged, so a relay dropped by a lossy transport is redelivered instead of
4547
+ * leaving that client with a permanent version-vector gap (a lost operation).
4548
+ */
4549
+ relayRetransmitTimer = null;
4550
+ deliveryPollTimer = null;
4551
+ lastObservedDeliverySequence = 0;
4552
+ deliveryPollInFlight = false;
4553
+ /**
4554
+ * Relay operations a client never acknowledged before it disconnected, buffered by
4555
+ * client node id so they can be redelivered on its next connection. Bounded per
4556
+ * node and expired by age so a client that never returns cannot grow this forever.
4557
+ */
4558
+ orphanedRelaysByNode = /* @__PURE__ */ new Map();
4559
+ static MAX_ORPHANED_RELAY_OPS_PER_NODE = 5e3;
4560
+ static ORPHANED_RELAY_TTL_MS = 5 * 6e4;
3229
4561
  constructor(config) {
3230
4562
  this.store = config.store;
4563
+ const storeSchemaVersion = this.store.getSchema()?.version;
4564
+ if (config.schemaVersion !== void 0 && storeSchemaVersion !== void 0 && config.schemaVersion !== storeSchemaVersion) {
4565
+ throw new import_core14.SyncError("Sync schema version does not match the configured store schema.", {
4566
+ syncSchemaVersion: config.schemaVersion,
4567
+ storeSchemaVersion
4568
+ });
4569
+ }
3231
4570
  this.auth = config.auth ?? null;
3232
4571
  this.serializer = config.serializer ?? new import_sync5.JsonMessageSerializer();
3233
4572
  this.emitter = config.emitter ?? null;
3234
4573
  this.maxConnections = config.maxConnections ?? DEFAULT_MAX_CONNECTIONS;
3235
4574
  this.batchSize = config.batchSize ?? DEFAULT_BATCH_SIZE2;
3236
- this.schemaVersion = config.schemaVersion ?? DEFAULT_SCHEMA_VERSION2;
4575
+ this.relayRetransmitIntervalMs = validateIntervalOption(
4576
+ "relayRetransmitIntervalMs",
4577
+ config.relayRetransmitIntervalMs ?? DEFAULT_RELAY_RETRANSMIT_INTERVAL_MS
4578
+ );
4579
+ this.deliveryPollIntervalMs = validateIntervalOption(
4580
+ "deliveryPollIntervalMs",
4581
+ config.deliveryPollIntervalMs ?? DEFAULT_DELIVERY_POLL_INTERVAL_MS
4582
+ );
4583
+ this.schemaVersion = config.schemaVersion ?? storeSchemaVersion ?? DEFAULT_SCHEMA_VERSION2;
3237
4584
  this.supportedSchemaVersions = config.supportedSchemaVersions ?? {
3238
4585
  min: this.schemaVersion,
3239
4586
  max: this.schemaVersion
3240
4587
  };
4588
+ this.operationTransforms = config.operationTransforms ?? [];
3241
4589
  this.port = config.port;
3242
4590
  this.host = config.host ?? DEFAULT_HOST;
3243
4591
  this.path = config.path ?? DEFAULT_PATH;
@@ -3246,10 +4594,116 @@ var KoraSyncServer = class {
3246
4594
  this.metrics.setSchemaVersion(this.schemaVersion);
3247
4595
  this.blobChunkRelay = new BlobChunkRelay(config.resolveBlobChunk);
3248
4596
  this.persistBlobChunk = config.persistBlobChunk ?? null;
4597
+ this.maxOperationBytes = config.maxOperationBytes;
4598
+ this.maxOpsPerMinute = config.maxOpsPerMinute;
4599
+ this.validateOperation = config.validateOperation;
4600
+ this.koraContext = createRouteContext(this, this.store);
3249
4601
  if (!this.emitter) {
3250
4602
  this.emitter = new import_internal2.SimpleEventEmitter();
3251
4603
  }
3252
4604
  }
4605
+ ensureBackgroundTimersStarted() {
4606
+ if (this.relayRetransmitIntervalMs > 0 && !this.relayRetransmitTimer) {
4607
+ this.relayRetransmitTimer = setInterval(() => {
4608
+ this.retransmitPendingRelays(this.relayRetransmitIntervalMs);
4609
+ }, this.relayRetransmitIntervalMs);
4610
+ this.relayRetransmitTimer.unref?.();
4611
+ }
4612
+ if (this.deliveryPollIntervalMs > 0 && !this.deliveryPollTimer) {
4613
+ this.deliveryPollTimer = setInterval(() => {
4614
+ void this.pollDeliveryLog();
4615
+ }, this.deliveryPollIntervalMs);
4616
+ this.deliveryPollTimer.unref?.();
4617
+ }
4618
+ }
4619
+ /**
4620
+ * Retransmit relay batches that connected clients have not acknowledged within
4621
+ * `staleMs`. Called on a periodic tick and directly by tests. Redelivering an
4622
+ * already-applied operation is harmless (clients dedup by content-addressed id).
4623
+ */
4624
+ retransmitPendingRelays(staleMs = 0) {
4625
+ for (const session of this.sessions.values()) {
4626
+ session.retransmitPendingRelays(staleMs);
4627
+ }
4628
+ this.expireOrphanedRelays();
4629
+ }
4630
+ /**
4631
+ * Check whether the authoritative delivery log advanced without passing through
4632
+ * this server instance, then wake delivery-watermark sessions to scan from their
4633
+ * own acknowledged cursors. Session-level filtering remains the only fan-out path.
4634
+ */
4635
+ async pollDeliveryLog() {
4636
+ if (this.deliveryPollInFlight) return;
4637
+ this.deliveryPollInFlight = true;
4638
+ try {
4639
+ const maxDeliverySequence = await this.store.getMaxDeliverySequence();
4640
+ if (maxDeliverySequence <= this.lastObservedDeliverySequence) {
4641
+ for (const session of this.sessions.values()) {
4642
+ session.pushDeliveryStreamIfSupported(this.deliveryPollIntervalMs, {
4643
+ trackStall: true
4644
+ });
4645
+ }
4646
+ return;
4647
+ }
4648
+ this.lastObservedDeliverySequence = maxDeliverySequence;
4649
+ for (const session of this.sessions.values()) {
4650
+ session.pushDeliveryStreamIfSupported(0);
4651
+ }
4652
+ } catch (error) {
4653
+ this.logger.log({
4654
+ timestamp: Date.now(),
4655
+ level: "warn",
4656
+ event: "delivery_poll.failed",
4657
+ details: { error: error instanceof Error ? error.message : String(error) }
4658
+ });
4659
+ } finally {
4660
+ this.deliveryPollInFlight = false;
4661
+ }
4662
+ }
4663
+ /**
4664
+ * Buffer relay operations a disconnected client never acknowledged, keyed by its
4665
+ * node id, deduped by operation id and bounded by count. Redelivered when the
4666
+ * client reconnects (see {@link takeOrphanedRelays}).
4667
+ */
4668
+ bufferOrphanedRelays(nodeId, ops) {
4669
+ if (ops.length === 0) return;
4670
+ const existing = this.orphanedRelaysByNode.get(nodeId);
4671
+ const merged = existing ? existing.ops : [];
4672
+ const seen = new Set(merged.map((op) => op.id));
4673
+ for (const op of ops) {
4674
+ if (!seen.has(op.id)) {
4675
+ merged.push(op);
4676
+ seen.add(op.id);
4677
+ }
4678
+ }
4679
+ const bounded = merged.length > _KoraSyncServer.MAX_ORPHANED_RELAY_OPS_PER_NODE ? merged.slice(merged.length - _KoraSyncServer.MAX_ORPHANED_RELAY_OPS_PER_NODE) : merged;
4680
+ this.orphanedRelaysByNode.set(nodeId, { ops: bounded, bufferedAtMs: Date.now() });
4681
+ }
4682
+ /** Remove and return a node's buffered orphaned relay operations, if any. */
4683
+ takeOrphanedRelays(nodeId) {
4684
+ const entry = this.orphanedRelaysByNode.get(nodeId);
4685
+ if (!entry) return [];
4686
+ this.orphanedRelaysByNode.delete(nodeId);
4687
+ return entry.ops;
4688
+ }
4689
+ /** Drop buffered orphaned relays older than the TTL (client never returned). */
4690
+ expireOrphanedRelays() {
4691
+ if (this.orphanedRelaysByNode.size === 0) return;
4692
+ const cutoff = Date.now() - _KoraSyncServer.ORPHANED_RELAY_TTL_MS;
4693
+ for (const [nodeId, entry] of this.orphanedRelaysByNode) {
4694
+ if (entry.bufferedAtMs <= cutoff) {
4695
+ this.orphanedRelaysByNode.delete(nodeId);
4696
+ }
4697
+ }
4698
+ }
4699
+ /**
4700
+ * The trusted, scoped data-plane context (`apply` / `query` / `findById`).
4701
+ * Shared with the production server so HTTP routes, the operation validator,
4702
+ * and `server.kora` are all the same object over the same pipeline.
4703
+ */
4704
+ getKoraContext() {
4705
+ return this.koraContext;
4706
+ }
3253
4707
  /**
3254
4708
  * Subscribe to session-level events for metrics collection and logging.
3255
4709
  * Called when a new session is created.
@@ -3298,6 +4752,21 @@ var KoraSyncServer = class {
3298
4752
  details: { reason: event.reason }
3299
4753
  });
3300
4754
  });
4755
+ sessionEmitter.on("sync:schema-mismatch", (event) => {
4756
+ this.logger.log({
4757
+ timestamp: Date.now(),
4758
+ level: "warn",
4759
+ event: "session.schema_mismatch",
4760
+ sessionId,
4761
+ details: {
4762
+ clientSchemaVersion: event.clientSchemaVersion,
4763
+ serverSchemaVersion: event.serverSchemaVersion,
4764
+ supportedMin: event.supportedMin,
4765
+ supportedMax: event.supportedMax,
4766
+ reason: event.reason
4767
+ }
4768
+ });
4769
+ });
3301
4770
  }
3302
4771
  /**
3303
4772
  * Get the metrics collector for external access (e.g., HTTP endpoints).
@@ -3318,10 +4787,10 @@ var KoraSyncServer = class {
3318
4787
  */
3319
4788
  async start(wsServerImpl) {
3320
4789
  if (this.running) {
3321
- throw new import_core12.SyncError("Server is already running", { port: this.port });
4790
+ throw new import_core14.SyncError("Server is already running", { port: this.port });
3322
4791
  }
3323
4792
  if (!wsServerImpl && this.port === void 0) {
3324
- throw new import_core12.SyncError(
4793
+ throw new import_core14.SyncError(
3325
4794
  "Port is required for standalone mode. Provide port in config or use handleConnection() for attach mode.",
3326
4795
  {}
3327
4796
  );
@@ -3367,6 +4836,15 @@ var KoraSyncServer = class {
3367
4836
  event: "server.stopping",
3368
4837
  details: { connectedClients: this.sessions.size }
3369
4838
  });
4839
+ if (this.relayRetransmitTimer) {
4840
+ clearInterval(this.relayRetransmitTimer);
4841
+ this.relayRetransmitTimer = null;
4842
+ }
4843
+ if (this.deliveryPollTimer) {
4844
+ clearInterval(this.deliveryPollTimer);
4845
+ this.deliveryPollTimer = null;
4846
+ }
4847
+ this.orphanedRelaysByNode.clear();
3370
4848
  this.awarenessRelay.clear();
3371
4849
  this.yjsDocRelay.clear();
3372
4850
  this.blobChunkRelay.clear();
@@ -3431,7 +4909,7 @@ var KoraSyncServer = class {
3431
4909
  if (this.maxConnections > 0 && this.sessions.size >= this.maxConnections) {
3432
4910
  transport.send({
3433
4911
  type: "error",
3434
- messageId: (0, import_core12.generateUUIDv7)(),
4912
+ messageId: (0, import_core14.generateUUIDv7)(),
3435
4913
  code: "MAX_CONNECTIONS",
3436
4914
  message: `Server has reached maximum connections (${this.maxConnections})`,
3437
4915
  retriable: true
@@ -3444,12 +4922,13 @@ var KoraSyncServer = class {
3444
4922
  event: "connection.rejected",
3445
4923
  details: { reason: "max_connections", max: this.maxConnections }
3446
4924
  });
3447
- throw new import_core12.SyncError("Maximum connections reached", {
4925
+ throw new import_core14.SyncError("Maximum connections reached", {
3448
4926
  current: this.sessions.size,
3449
4927
  max: this.maxConnections
3450
4928
  });
3451
4929
  }
3452
- const sessionId = (0, import_core12.generateUUIDv7)();
4930
+ this.ensureBackgroundTimersStarted();
4931
+ const sessionId = (0, import_core14.generateUUIDv7)();
3453
4932
  this.metrics.recordConnection(sessionId);
3454
4933
  const sessionEmitter = new import_internal2.SimpleEventEmitter();
3455
4934
  sessionEmitter.on("sync:connected", (event) => {
@@ -3495,6 +4974,33 @@ var KoraSyncServer = class {
3495
4974
  sessionId
3496
4975
  });
3497
4976
  });
4977
+ sessionEmitter.on("sync:schema-mismatch", (event) => {
4978
+ this.logger.log({
4979
+ timestamp: Date.now(),
4980
+ level: "warn",
4981
+ event: "session.schema_mismatch",
4982
+ sessionId,
4983
+ details: {
4984
+ clientSchemaVersion: event.clientSchemaVersion,
4985
+ serverSchemaVersion: event.serverSchemaVersion,
4986
+ supportedMin: event.supportedMin,
4987
+ supportedMax: event.supportedMax,
4988
+ reason: event.reason
4989
+ }
4990
+ });
4991
+ });
4992
+ sessionEmitter.on("sync:delivery-stalled", (event) => {
4993
+ this.logger.log({
4994
+ timestamp: Date.now(),
4995
+ level: "warn",
4996
+ event: "session.delivery_stalled",
4997
+ sessionId,
4998
+ details: {
4999
+ watermark: event.watermark,
5000
+ repeatCount: event.repeatCount
5001
+ }
5002
+ });
5003
+ });
3498
5004
  const session = new ClientSession({
3499
5005
  sessionId,
3500
5006
  transport,
@@ -3502,9 +5008,11 @@ var KoraSyncServer = class {
3502
5008
  auth: this.auth ?? void 0,
3503
5009
  serializer: this.serializer,
3504
5010
  emitter: sessionEmitter,
5011
+ logger: this.logger,
3505
5012
  batchSize: this.batchSize,
3506
5013
  schemaVersion: this.schemaVersion,
3507
5014
  supportedSchemaVersions: this.supportedSchemaVersions,
5015
+ operationTransforms: this.operationTransforms,
3508
5016
  onRelay: (sourceSessionId, operations2) => {
3509
5017
  this.handleRelay(sourceSessionId, operations2);
3510
5018
  },
@@ -3521,9 +5029,18 @@ var KoraSyncServer = class {
3521
5029
  this.blobChunkRelay.handleResponse(sourceSessionId, message);
3522
5030
  },
3523
5031
  ...this.persistBlobChunk ? { persistBlobChunk: this.persistBlobChunk } : {},
5032
+ // Only forward when configured, so an unset server value leaves the
5033
+ // session on its own documented default rather than `undefined`.
5034
+ ...this.maxOperationBytes !== void 0 ? { maxOperationBytes: this.maxOperationBytes } : {},
5035
+ ...this.maxOpsPerMinute !== void 0 ? { maxOpsPerMinute: this.maxOpsPerMinute } : {},
5036
+ ...this.validateOperation ? { validateOperation: this.validateOperation, koraContext: this.koraContext } : {},
3524
5037
  onClose: (sid) => {
3525
5038
  this.handleSessionClose(sid);
3526
- }
5039
+ },
5040
+ onOrphanedRelays: (nodeId, ops) => {
5041
+ this.bufferOrphanedRelays(nodeId, ops);
5042
+ },
5043
+ takeOrphanedRelays: (nodeId) => this.takeOrphanedRelays(nodeId)
3527
5044
  });
3528
5045
  this.sessions.set(sessionId, session);
3529
5046
  this.yjsDocRelay.addClient(sessionId, transport);
@@ -3583,6 +5100,22 @@ var KoraSyncServer = class {
3583
5100
  }
3584
5101
  return result;
3585
5102
  }
5103
+ /**
5104
+ * Relay already-applied, server-originated operations to every connected client.
5105
+ * Used by the conditional route apply, which commits its operations atomically
5106
+ * through the store (bypassing {@link applyLocalOperation}) and then fans them
5107
+ * out. Each session still enforces its own per-scope visibility filter.
5108
+ *
5109
+ * @param operations - Operations that have already been committed to the store
5110
+ */
5111
+ relayServerOperations(operations2) {
5112
+ if (operations2.length === 0) {
5113
+ return;
5114
+ }
5115
+ for (const session of this.sessions.values()) {
5116
+ session.relayOperations(operations2);
5117
+ }
5118
+ }
3586
5119
  /**
3587
5120
  * Collect every blob reference still reachable from live records on the server.
3588
5121
  *
@@ -3607,7 +5140,7 @@ var KoraSyncServer = class {
3607
5140
  const records = await this.store.materializeCollection(name);
3608
5141
  for (const record of records) {
3609
5142
  for (const value of Object.values(record)) {
3610
- if ((0, import_core12.isBlobRef)(value)) {
5143
+ if ((0, import_core14.isBlobRef)(value)) {
3611
5144
  refs.push(value);
3612
5145
  }
3613
5146
  }
@@ -3787,162 +5320,6 @@ function createKoraServer(config) {
3787
5320
 
3788
5321
  // src/server/production-server.ts
3789
5322
  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
5323
  var MIME_TYPES = {
3947
5324
  ".html": "text/html",
3948
5325
  ".js": "text/javascript",
@@ -3961,12 +5338,13 @@ function createProductionServer(config) {
3961
5338
  const port = config.port ?? (Number(process.env.PORT) || 3001);
3962
5339
  const staticDir = config.staticDir ?? "./dist";
3963
5340
  const syncPath = config.syncPath ?? "/kora-sync";
5341
+ const crossOriginEmbedderPolicy = config.crossOriginEmbedderPolicy ?? "credentialless";
3964
5342
  const syncServer = new KoraSyncServer({
3965
5343
  store: config.store,
3966
5344
  enableDashboard: true,
3967
5345
  ...config.syncOptions
3968
5346
  });
3969
- const routeContext = createRouteContext(syncServer, config.store);
5347
+ const routeContext = syncServer.getKoraContext();
3970
5348
  let httpServer = null;
3971
5349
  function getOperationalToken(kind) {
3972
5350
  const auth = config.operationalAuth;
@@ -4106,6 +5484,10 @@ function createProductionServer(config) {
4106
5484
  res.end(JSON.stringify(result.body ?? null));
4107
5485
  }
4108
5486
  return {
5487
+ kora: routeContext,
5488
+ getLiveBlobRefs() {
5489
+ return syncServer.getLiveBlobRefs();
5490
+ },
4109
5491
  async start() {
4110
5492
  const { createServer } = await import("http");
4111
5493
  const { createReadStream, existsSync, statSync } = await import("fs");
@@ -4127,7 +5509,7 @@ function createProductionServer(config) {
4127
5509
  });
4128
5510
  async function handleRequest(req, res) {
4129
5511
  res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
4130
- res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
5512
+ res.setHeader("Cross-Origin-Embedder-Policy", crossOriginEmbedderPolicy);
4131
5513
  const url = new URL(req.url || "/", `http://${req.headers.host}`);
4132
5514
  if (url.pathname === "/health") {
4133
5515
  const status = await syncServer.getStatus();
@@ -4301,8 +5683,13 @@ data: ${JSON.stringify(s)}
4301
5683
  socket.destroy();
4302
5684
  }
4303
5685
  });
4304
- return new Promise((resolve2) => {
5686
+ return new Promise((resolve2, reject) => {
5687
+ const onError = (error) => {
5688
+ reject(error);
5689
+ };
5690
+ httpServer?.once("error", onError);
4305
5691
  httpServer?.listen(port, "0.0.0.0", () => {
5692
+ httpServer?.off("error", onError);
4306
5693
  resolve2(`http://localhost:${port}`);
4307
5694
  });
4308
5695
  });
@@ -4398,6 +5785,7 @@ function formatUptime(ms) {
4398
5785
  0 && (module.exports = {
4399
5786
  AwarenessRelay,
4400
5787
  ClientSession,
5788
+ DEFAULT_MAX_SCOPE_PREDICATE_VALUES,
4401
5789
  HttpServerTransport,
4402
5790
  KoraAuthProvider,
4403
5791
  KoraSyncServer,
@@ -4405,6 +5793,7 @@ function formatUptime(ms) {
4405
5793
  MixedAuthProvider,
4406
5794
  NoAuthProvider,
4407
5795
  PostgresServerStore,
5796
+ RETRIABLE_REJECTION_CODES,
4408
5797
  SqliteServerStore,
4409
5798
  TokenAuthProvider,
4410
5799
  WsServerTransport,
@@ -4415,6 +5804,9 @@ function formatUptime(ms) {
4415
5804
  createPrettyLogger,
4416
5805
  createProductionServer,
4417
5806
  createSilentLogger,
4418
- createSqliteServerStore
5807
+ createSqliteServerStore,
5808
+ isRetriableRejection,
5809
+ normalizeScopeMap,
5810
+ operationMatchesScopes
4419
5811
  });
4420
5812
  //# sourceMappingURL=index.cjs.map