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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -12,11 +12,11 @@ var __export = (target, all) => {
12
12
  for (var name in all)
13
13
  __defProp(target, name, { get: all[name], enumerable: true });
14
14
  };
15
- var __copyProps = (to, from, except, desc) => {
15
+ var __copyProps = (to, from, except, desc2) => {
16
16
  if (from && typeof from === "object" || typeof from === "function") {
17
17
  for (let key of __getOwnPropNames(from))
18
18
  if (!__hasOwnProp.call(to, key) && key !== except)
19
- __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
19
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc2 = __getOwnPropDesc(from, key)) || desc2.enumerable });
20
20
  }
21
21
  return to;
22
22
  };
@@ -176,6 +176,7 @@ __export(index_exports, {
176
176
  MixedAuthProvider: () => MixedAuthProvider,
177
177
  NoAuthProvider: () => NoAuthProvider,
178
178
  PostgresServerStore: () => PostgresServerStore,
179
+ RETRIABLE_REJECTION_CODES: () => RETRIABLE_REJECTION_CODES,
179
180
  SqliteServerStore: () => SqliteServerStore,
180
181
  TokenAuthProvider: () => TokenAuthProvider,
181
182
  WsServerTransport: () => WsServerTransport,
@@ -186,7 +187,8 @@ __export(index_exports, {
186
187
  createPrettyLogger: () => createPrettyLogger,
187
188
  createProductionServer: () => createProductionServer,
188
189
  createSilentLogger: () => createSilentLogger,
189
- createSqliteServerStore: () => createSqliteServerStore
190
+ createSqliteServerStore: () => createSqliteServerStore,
191
+ isRetriableRejection: () => isRetriableRejection
190
192
  });
191
193
  module.exports = __toCommonJS(index_exports);
192
194
  init_cjs_shims();
@@ -248,6 +250,13 @@ function createDefaultLogger() {
248
250
  }
249
251
  }
250
252
 
253
+ // src/apply/rejection-taxonomy.ts
254
+ init_cjs_shims();
255
+ var RETRIABLE_REJECTION_CODES = /* @__PURE__ */ new Set(["RATE_LIMIT"]);
256
+ function isRetriableRejection(code) {
257
+ return RETRIABLE_REJECTION_CODES.has(code);
258
+ }
259
+
251
260
  // src/store/memory-server-store.ts
252
261
  init_cjs_shims();
253
262
  var import_core2 = require("@korajs/core");
@@ -269,6 +278,14 @@ function fieldTypeToSql(descriptor, dialect) {
269
278
  return dialect === "postgres" ? "BIGINT" : "INTEGER";
270
279
  case "array":
271
280
  return dialect === "postgres" ? "JSONB" : "TEXT";
281
+ case "object":
282
+ return dialect === "postgres" ? "JSONB" : "TEXT";
283
+ case "json":
284
+ return dialect === "postgres" ? "JSONB" : "TEXT";
285
+ case "blob":
286
+ return "TEXT";
287
+ case "secret":
288
+ return "TEXT";
272
289
  case "richtext":
273
290
  return dialect === "postgres" ? "BYTEA" : "BLOB";
274
291
  }
@@ -285,13 +302,13 @@ function generateCollectionDDL(name, collection, dialect) {
285
302
  const columns = ["id TEXT PRIMARY KEY NOT NULL"];
286
303
  for (const [fieldName, descriptor] of Object.entries(collection.fields)) {
287
304
  const sqlType = fieldTypeToSql(descriptor, dialect);
288
- let colDef = `${fieldName} ${sqlType}`;
305
+ let colDef = `${(0, import_core.quoteIdent)(fieldName)} ${sqlType}`;
289
306
  if (descriptor.defaultValue !== void 0) {
290
307
  colDef += ` DEFAULT ${sqlDefaultLiteral(descriptor.defaultValue)}`;
291
308
  }
292
309
  if (descriptor.kind === "enum" && descriptor.enumValues) {
293
310
  const values = descriptor.enumValues.map((v) => `'${v}'`).join(", ");
294
- colDef += ` CHECK (${fieldName} IN (${values}))`;
311
+ colDef += ` CHECK (${(0, import_core.quoteIdent)(fieldName)} IN (${values}))`;
295
312
  }
296
313
  columns.push(colDef);
297
314
  }
@@ -299,24 +316,26 @@ function generateCollectionDDL(name, collection, dialect) {
299
316
  columns.push(`_created_at ${tsType} NOT NULL DEFAULT 0`);
300
317
  columns.push(`_updated_at ${tsType} NOT NULL DEFAULT 0`);
301
318
  columns.push("_deleted INTEGER NOT NULL DEFAULT 0");
302
- statements.push(`CREATE TABLE IF NOT EXISTS ${name} (
319
+ statements.push(`CREATE TABLE IF NOT EXISTS ${(0, import_core.quoteIdent)(name)} (
303
320
  ${columns.join(",\n ")}
304
321
  )`);
305
322
  for (const [fieldName, descriptor] of Object.entries(collection.fields)) {
306
323
  const sqlType = fieldTypeToSql(descriptor, dialect);
307
- let colDef = `${fieldName} ${sqlType}`;
324
+ let colDef = `${(0, import_core.quoteIdent)(fieldName)} ${sqlType}`;
308
325
  if (descriptor.defaultValue !== void 0) {
309
326
  colDef += ` DEFAULT ${sqlDefaultLiteral(descriptor.defaultValue)}`;
310
327
  }
311
328
  statements.push(`--kora:safe-alter
312
- ALTER TABLE ${name} ADD COLUMN ${colDef}`);
329
+ ALTER TABLE ${(0, import_core.quoteIdent)(name)} ADD COLUMN ${colDef}`);
313
330
  }
314
331
  for (const indexField of collection.indexes) {
315
332
  statements.push(
316
- `CREATE INDEX IF NOT EXISTS idx_${name}_${indexField} ON ${name} (${indexField})`
333
+ `CREATE INDEX IF NOT EXISTS idx_${name}_${indexField} ON ${(0, import_core.quoteIdent)(name)} (${(0, import_core.quoteIdent)(indexField)})`
317
334
  );
318
335
  }
319
- statements.push(`CREATE INDEX IF NOT EXISTS idx_${name}__deleted ON ${name} (_deleted)`);
336
+ statements.push(
337
+ `CREATE INDEX IF NOT EXISTS idx_${name}__deleted ON ${(0, import_core.quoteIdent)(name)} (_deleted)`
338
+ );
320
339
  return statements;
321
340
  }
322
341
  function generateAllCollectionDDL(schema, dialect) {
@@ -326,30 +345,6 @@ function generateAllCollectionDDL(schema, dialect) {
326
345
  }
327
346
  return statements;
328
347
  }
329
- function replayOperationsForRecord(ops) {
330
- let record = null;
331
- let deleted = false;
332
- for (const op of ops) {
333
- switch (op.type) {
334
- case "insert":
335
- if (op.data) {
336
- record = { ...op.data };
337
- deleted = false;
338
- }
339
- break;
340
- case "update":
341
- if (op.data) {
342
- record = { ...record ?? {}, ...op.data };
343
- deleted = false;
344
- }
345
- break;
346
- case "delete":
347
- deleted = true;
348
- break;
349
- }
350
- }
351
- return deleted ? null : record;
352
- }
353
348
  var BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
354
349
  var BASE64_REVERSE = new Map(
355
350
  Array.from(BASE64_ALPHABET, (char, index3) => [char, index3])
@@ -400,6 +395,9 @@ function serializeFieldValue(value, descriptor) {
400
395
  if (value === null || value === void 0) return null;
401
396
  switch (descriptor.kind) {
402
397
  case "array":
398
+ case "object":
399
+ case "json":
400
+ case "blob":
403
401
  return typeof value === "string" ? value : JSON.stringify(value);
404
402
  case "boolean":
405
403
  return value ? 1 : 0;
@@ -413,6 +411,9 @@ function deserializeFieldValue(value, descriptor) {
413
411
  if (value === null || value === void 0) return null;
414
412
  switch (descriptor.kind) {
415
413
  case "array":
414
+ case "object":
415
+ case "json":
416
+ case "blob":
416
417
  return typeof value === "string" ? JSON.parse(value) : value;
417
418
  case "boolean":
418
419
  return value === 1 || value === true;
@@ -445,6 +446,14 @@ var MemoryServerStore = class {
445
446
  operations = [];
446
447
  operationIndex = /* @__PURE__ */ new Map();
447
448
  versionVector = /* @__PURE__ */ new Map();
449
+ /**
450
+ * Server-assigned delivery sequence per operation id. Single-process, so a plain
451
+ * counter incremented at insert time gives commit-order delivery sequence with no
452
+ * gaps. `operations` is kept in insertion (== delivery) order, so a delivery scan
453
+ * is a forward walk.
454
+ */
455
+ deliverySeqByOpId = /* @__PURE__ */ new Map();
456
+ deliverySeqCounter = 0;
448
457
  schema = null;
449
458
  /** Materialized records: collection -> recordId -> record data */
450
459
  materializedRecords = /* @__PURE__ */ new Map();
@@ -478,6 +487,8 @@ var MemoryServerStore = class {
478
487
  }
479
488
  this.operations.push(op);
480
489
  this.operationIndex.set(op.id, op);
490
+ this.deliverySeqCounter += 1;
491
+ this.deliverySeqByOpId.set(op.id, this.deliverySeqCounter);
481
492
  const currentSeq = this.versionVector.get(op.nodeId) ?? 0;
482
493
  if (op.sequenceNumber > currentSeq) {
483
494
  this.versionVector.set(op.nodeId, op.sequenceNumber);
@@ -497,6 +508,23 @@ var MemoryServerStore = class {
497
508
  this.assertOpen();
498
509
  return this.operations.length;
499
510
  }
511
+ async getMaxDeliverySequence() {
512
+ this.assertOpen();
513
+ return this.deliverySeqCounter;
514
+ }
515
+ async getOperationsAfterDelivery(afterDeliverySequence, limit) {
516
+ this.assertOpen();
517
+ const result = [];
518
+ if (limit <= 0) return result;
519
+ for (const op of this.operations) {
520
+ const deliverySequence = this.deliverySeqByOpId.get(op.id) ?? 0;
521
+ if (deliverySequence > afterDeliverySequence) {
522
+ result.push({ operation: op, deliverySequence });
523
+ if (result.length >= limit) break;
524
+ }
525
+ }
526
+ return result;
527
+ }
500
528
  async materializeCollection(collection) {
501
529
  this.assertOpen();
502
530
  if (this.schema?.collections[collection]) {
@@ -641,12 +669,16 @@ var MemoryServerStore = class {
641
669
  this.operations.length = 0;
642
670
  this.operationIndex.clear();
643
671
  this.versionVector.clear();
672
+ this.deliverySeqByOpId.clear();
673
+ this.deliverySeqCounter = 0;
644
674
  for (const [nid, seq] of versionVector) {
645
675
  this.versionVector.set(nid, seq);
646
676
  }
647
677
  for (const op of operations2) {
648
678
  this.operations.push(op);
649
679
  this.operationIndex.set(op.id, op);
680
+ this.deliverySeqCounter += 1;
681
+ this.deliverySeqByOpId.set(op.id, this.deliverySeqCounter);
650
682
  if (this.schema?.collections[op.collection]) {
651
683
  this.rebuildMaterializedRecord(op.collection, op.recordId);
652
684
  }
@@ -676,13 +708,14 @@ var MemoryServerStore = class {
676
708
  return a.timestamp.wallTime - b.timestamp.wallTime;
677
709
  if (a.timestamp.logical !== b.timestamp.logical)
678
710
  return a.timestamp.logical - b.timestamp.logical;
679
- return a.sequenceNumber - b.sequenceNumber;
711
+ return a.timestamp.nodeId < b.timestamp.nodeId ? -1 : a.timestamp.nodeId > b.timestamp.nodeId ? 1 : 0;
680
712
  });
681
713
  const parsedOps = recordOps.map((op) => ({
682
714
  type: op.type,
683
- data: op.data
715
+ data: op.data,
716
+ atomicOps: op.atomicOps ?? null
684
717
  }));
685
- const recordData = replayOperationsForRecord(parsedOps);
718
+ const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
686
719
  if (recordData) {
687
720
  const createdAt = recordOps.length > 0 ? recordOps[0].timestamp.wallTime : Date.now();
688
721
  const updatedAt = recordOps.length > 0 ? recordOps[recordOps.length - 1].timestamp.wallTime : Date.now();
@@ -805,6 +838,8 @@ var pgOperations = (0, import_pg_core.pgTable)(
805
838
  // JSON-serialized, null for deletes
806
839
  previousData: (0, import_pg_core.text)("previous_data"),
807
840
  // JSON-serialized, null for insert/delete
841
+ atomicOps: (0, import_pg_core.text)("atomic_ops"),
842
+ // JSON-serialized Record<field, AtomicOp>, null when none
808
843
  wallTime: (0, import_pg_core.bigint)("wall_time", { mode: "number" }).notNull(),
809
844
  logical: (0, import_pg_core.integer)("logical").notNull(),
810
845
  timestampNodeId: (0, import_pg_core.text)("timestamp_node_id").notNull(),
@@ -812,12 +847,18 @@ var pgOperations = (0, import_pg_core.pgTable)(
812
847
  causalDeps: (0, import_pg_core.text)("causal_deps").notNull().default("[]"),
813
848
  // JSON array of op IDs
814
849
  schemaVersion: (0, import_pg_core.integer)("schema_version").notNull(),
815
- receivedAt: (0, import_pg_core.bigint)("received_at", { mode: "number" }).notNull()
850
+ receivedAt: (0, import_pg_core.bigint)("received_at", { mode: "number" }).notNull(),
851
+ // Server-assigned monotonic delivery sequence, ordered by commit (assigned from
852
+ // a counter row locked inside the append transaction, so delivery order equals
853
+ // visibility order even across instances). Drives the gap-free server->client
854
+ // delivery watermark. Nullable only for pre-column rows, backfilled on startup.
855
+ deliverySeq: (0, import_pg_core.bigint)("delivery_seq", { mode: "number" })
816
856
  },
817
857
  (table) => ({
818
858
  nodeSeqIdx: (0, import_pg_core.index)("idx_pg_node_seq").on(table.nodeId, table.sequenceNumber),
819
859
  collectionIdx: (0, import_pg_core.index)("idx_pg_collection").on(table.collection),
820
- receivedIdx: (0, import_pg_core.index)("idx_pg_received").on(table.receivedAt)
860
+ receivedIdx: (0, import_pg_core.index)("idx_pg_received").on(table.receivedAt),
861
+ deliveryIdx: (0, import_pg_core.index)("idx_pg_delivery_seq").on(table.deliverySeq)
821
862
  })
822
863
  );
823
864
  var pgSyncState = (0, import_pg_core.pgTable)("sync_state", {
@@ -834,15 +875,43 @@ var PostgresServerStore = class {
834
875
  ready;
835
876
  schema = null;
836
877
  closed = false;
837
- constructor(db, nodeId) {
878
+ /**
879
+ * Monotonic counter for this node's server-originated sequence numbers. Lazily
880
+ * seeded from the persisted version vector on first use, then advanced in memory
881
+ * (a synchronous `++`, atomic on the single JS thread) so concurrent conditional
882
+ * applies never receive the same number. Null means "not yet seeded"; reset to
883
+ * null after a backup import so it re-seeds from the restored version vector.
884
+ */
885
+ sequenceCounter = null;
886
+ /**
887
+ * Time source for HLC timestamps on server-originated operations. Injectable so a
888
+ * test can freeze wall-clock time and prove the conditional-apply ordering holds
889
+ * under same-millisecond commits (the case the advisory lock and clock advance
890
+ * exist to make correct). Defaults to the system clock in production.
891
+ */
892
+ timeSource;
893
+ constructor(db, nodeId, timeSource) {
838
894
  this.db = db;
839
895
  this.nodeId = nodeId ?? (0, import_core3.generateUUIDv7)();
896
+ this.timeSource = timeSource ?? { now: () => Date.now() };
840
897
  this.ready = this.initialize();
841
898
  }
842
899
  getVersionVector() {
843
900
  this.assertOpen();
844
901
  return new Map(this.versionVector);
845
902
  }
903
+ /**
904
+ * Atomically reserve this node's next server-operation sequence number. The
905
+ * synchronous increment cannot interleave on the single JS thread, so two
906
+ * concurrent conditional applies are always handed distinct numbers.
907
+ */
908
+ reserveSequenceNumber() {
909
+ if (this.sequenceCounter === null) {
910
+ this.sequenceCounter = this.versionVector.get(this.nodeId) ?? 0;
911
+ }
912
+ this.sequenceCounter += 1;
913
+ return this.sequenceCounter;
914
+ }
846
915
  getNodeId() {
847
916
  return this.nodeId;
848
917
  }
@@ -880,8 +949,9 @@ var PostgresServerStore = class {
880
949
  return "duplicate";
881
950
  }
882
951
  const now = Date.now();
883
- const row = this.serializeOperation(op, now);
884
952
  await this.db.transaction(async (tx) => {
953
+ const deliverySeq = await this.nextDeliverySeq(tx);
954
+ const row = this.serializeOperation(op, now, deliverySeq);
885
955
  await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
886
956
  await tx.insert(pgSyncState).values({
887
957
  nodeId: op.nodeId,
@@ -904,6 +974,73 @@ var PostgresServerStore = class {
904
974
  }
905
975
  return "applied";
906
976
  }
977
+ /**
978
+ * Conditionally apply operations atomically, serialized across server instances
979
+ * on the target record via a transaction-scoped advisory lock. The lock makes
980
+ * the read-decide-write cycle atomic against concurrent instances, so a cap
981
+ * check like `responseCount < max` cannot be passed by two admissions at once.
982
+ */
983
+ async applyConditional(input) {
984
+ this.assertOpen();
985
+ await this.ready;
986
+ this.assertSchema();
987
+ this.assertCollection(input.target.collection);
988
+ const schema = this.schema;
989
+ const collectionDef = schema.collections[input.target.collection];
990
+ const lockKey = `kora:${input.target.collection}:${input.target.id}`;
991
+ const result = await this.db.transaction(async (tx) => {
992
+ await tx.execute(import_drizzle_orm.sql`SELECT pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`);
993
+ if (input.idempotencyKey) {
994
+ const existing = await tx.execute(
995
+ import_drizzle_orm.sql`SELECT 1 FROM ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(input.idempotencyKey.collection))} WHERE id = ${input.idempotencyKey.id} AND _deleted = 0 LIMIT 1`
996
+ );
997
+ if (existing.length > 0) {
998
+ return { admitted: true, idempotent: true, applied: [] };
999
+ }
1000
+ }
1001
+ const rows = await tx.execute(
1002
+ import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(input.target.collection))} WHERE id = ${input.target.id} AND _deleted = 0`
1003
+ );
1004
+ const current = rows.length > 0 ? this.deserializeRow(rows[0], collectionDef) : null;
1005
+ if (!input.admit(current)) {
1006
+ return { admitted: false, idempotent: false, applied: [] };
1007
+ }
1008
+ const clock = new import_core3.HybridLogicalClock(this.nodeId, this.timeSource);
1009
+ const latest = await this.latestOperationTimestamp(
1010
+ tx,
1011
+ input.target.collection,
1012
+ input.target.id
1013
+ );
1014
+ if (latest) {
1015
+ clock.advanceTo(latest);
1016
+ }
1017
+ const ops = await input.buildOperations(current, { clock });
1018
+ const now = Date.now();
1019
+ for (const op of ops) {
1020
+ const deliverySeq = await this.nextDeliverySeq(tx);
1021
+ const row = this.serializeOperation(op, now, deliverySeq);
1022
+ await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
1023
+ await tx.insert(pgSyncState).values({ nodeId: op.nodeId, maxSequenceNumber: op.sequenceNumber, lastSeenAt: now }).onConflictDoUpdate({
1024
+ target: pgSyncState.nodeId,
1025
+ set: {
1026
+ maxSequenceNumber: import_drizzle_orm.sql`GREATEST(${pgSyncState.maxSequenceNumber}, ${op.sequenceNumber})`,
1027
+ lastSeenAt: import_drizzle_orm.sql`${now}`
1028
+ }
1029
+ });
1030
+ if (this.schema?.collections[op.collection]) {
1031
+ await this.rebuildMaterializedRecord(tx, op.collection, op.recordId);
1032
+ }
1033
+ }
1034
+ return { admitted: true, idempotent: false, applied: ops };
1035
+ });
1036
+ for (const op of result.applied) {
1037
+ const currentMax = this.versionVector.get(op.nodeId) ?? 0;
1038
+ if (op.sequenceNumber > currentMax) {
1039
+ this.versionVector.set(op.nodeId, op.sequenceNumber);
1040
+ }
1041
+ }
1042
+ return result;
1043
+ }
907
1044
  async getOperationRange(nodeId, fromSeq, toSeq) {
908
1045
  this.assertOpen();
909
1046
  await this.ready;
@@ -918,6 +1055,23 @@ var PostgresServerStore = class {
918
1055
  const result = await this.db.select({ value: (0, import_drizzle_orm.count)() }).from(pgOperations);
919
1056
  return result[0]?.value ?? 0;
920
1057
  }
1058
+ async getMaxDeliverySequence() {
1059
+ this.assertOpen();
1060
+ await this.ready;
1061
+ const rows = await this.db.execute(
1062
+ import_drizzle_orm.sql`SELECT COALESCE(MAX(delivery_seq), 0) AS m FROM operations`
1063
+ );
1064
+ return Number(rows[0]?.m ?? 0);
1065
+ }
1066
+ async getOperationsAfterDelivery(afterDeliverySequence, limit) {
1067
+ this.assertOpen();
1068
+ await this.ready;
1069
+ const rows = await this.db.select().from(pgOperations).where((0, import_drizzle_orm.gt)(pgOperations.deliverySeq, afterDeliverySequence)).orderBy((0, import_drizzle_orm.asc)(pgOperations.deliverySeq)).limit(limit);
1070
+ return rows.map((row) => ({
1071
+ operation: this.deserializeOperation(row),
1072
+ deliverySequence: row.deliverySeq ?? 0
1073
+ }));
1074
+ }
921
1075
  async materializeCollection(collection) {
922
1076
  this.assertOpen();
923
1077
  await this.ready;
@@ -952,7 +1106,7 @@ var PostgresServerStore = class {
952
1106
  this.assertCollection(collection);
953
1107
  const schema = this.schema;
954
1108
  const collectionDef = schema.collections[collection];
955
- const query = import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw(collection)} WHERE id = ${id} AND _deleted = 0`;
1109
+ const query = import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(collection))} WHERE id = ${id} AND _deleted = 0`;
956
1110
  const rows = await this.db.execute(query);
957
1111
  if (rows.length === 0) return null;
958
1112
  return this.deserializeRow(rows[0], collectionDef);
@@ -969,7 +1123,7 @@ var PostgresServerStore = class {
969
1123
  }
970
1124
  }
971
1125
  const whereClause = this.buildWhereClause(where ?? {}, false);
972
- const query = import_drizzle_orm.sql`SELECT COUNT(*) as cnt FROM ${import_drizzle_orm.sql.raw(collection)} WHERE ${whereClause}`;
1126
+ const query = import_drizzle_orm.sql`SELECT COUNT(*) as cnt FROM ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(collection))} WHERE ${whereClause}`;
973
1127
  const rows = await this.db.execute(query);
974
1128
  const cnt = rows[0]?.cnt;
975
1129
  return typeof cnt === "string" ? Number.parseInt(cnt, 10) : cnt ?? 0;
@@ -981,7 +1135,7 @@ var PostgresServerStore = class {
981
1135
  this.assertOpen();
982
1136
  await this.ready;
983
1137
  const { buildServerBackup: buildServerBackup2 } = await Promise.resolve().then(() => (init_server_backup(), server_backup_exports));
984
- const rows = await this.db.select().from(pgOperations).orderBy((0, import_drizzle_orm.asc)(pgOperations.sequenceNumber));
1138
+ const rows = await this.db.select().from(pgOperations).orderBy((0, import_drizzle_orm.asc)(pgOperations.deliverySeq));
985
1139
  const operations2 = rows.map((row) => this.deserializeOperation(row));
986
1140
  return buildServerBackup2(this.nodeId, operations2, this.versionVector);
987
1141
  }
@@ -996,6 +1150,7 @@ var PostgresServerStore = class {
996
1150
  const result = await this.applyRemoteOperation(op);
997
1151
  if (result === "applied") restored++;
998
1152
  }
1153
+ this.sequenceCounter = null;
999
1154
  return { operationsRestored: restored, success: true };
1000
1155
  }
1001
1156
  const now = Date.now();
@@ -1005,20 +1160,49 @@ var PostgresServerStore = class {
1005
1160
  for (const [nid, seq] of versionVector) {
1006
1161
  await tx.insert(pgSyncState).values({ nodeId: nid, maxSequenceNumber: seq, lastSeenAt: now }).onConflictDoNothing({ target: pgSyncState.nodeId });
1007
1162
  }
1163
+ let deliverySeq = 0;
1008
1164
  for (const op of operations2) {
1009
- const row = this.serializeOperation(op, now);
1165
+ deliverySeq += 1;
1166
+ const row = this.serializeOperation(op, now, deliverySeq);
1010
1167
  await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
1011
1168
  }
1169
+ await tx.execute(
1170
+ import_drizzle_orm.sql`INSERT INTO delivery_counter (id, value) VALUES (1, ${deliverySeq})
1171
+ ON CONFLICT (id) DO UPDATE SET value = ${deliverySeq}`
1172
+ );
1012
1173
  });
1013
1174
  this.versionVector.clear();
1014
1175
  for (const [nid, seq] of versionVector) {
1015
1176
  this.versionVector.set(nid, seq);
1016
1177
  }
1178
+ this.sequenceCounter = null;
1017
1179
  return { operationsRestored: operations2.length, success: true };
1018
1180
  }
1019
1181
  // ---------------------------------------------------------------------------
1020
1182
  // Materialization internals
1021
1183
  // ---------------------------------------------------------------------------
1184
+ /**
1185
+ * The HLC timestamp of the most recent operation on a record, by total order
1186
+ * (wallTime, logical, nodeId), or null when the record has no operations. Used
1187
+ * by {@link applyConditional} to advance its clock past the record's latest
1188
+ * write so newly built operations sort strictly after it.
1189
+ */
1190
+ async latestOperationTimestamp(txOrDb, collection, recordId) {
1191
+ const rows = await txOrDb.select({
1192
+ wallTime: pgOperations.wallTime,
1193
+ logical: pgOperations.logical,
1194
+ timestampNodeId: pgOperations.timestampNodeId
1195
+ }).from(pgOperations).where((0, import_drizzle_orm.and)((0, import_drizzle_orm.eq)(pgOperations.collection, collection), (0, import_drizzle_orm.eq)(pgOperations.recordId, recordId))).orderBy(
1196
+ (0, import_drizzle_orm.desc)(pgOperations.wallTime),
1197
+ (0, import_drizzle_orm.desc)(pgOperations.logical),
1198
+ (0, import_drizzle_orm.desc)(pgOperations.timestampNodeId)
1199
+ ).limit(1);
1200
+ const row = rows[0];
1201
+ if (!row) {
1202
+ return null;
1203
+ }
1204
+ return { wallTime: row.wallTime, logical: row.logical, nodeId: row.timestampNodeId };
1205
+ }
1022
1206
  /**
1023
1207
  * Rebuild a single record in the materialized collection table by replaying
1024
1208
  * all operations for that record.
@@ -1029,17 +1213,19 @@ var PostgresServerStore = class {
1029
1213
  const ops = await txOrDb.select({
1030
1214
  type: pgOperations.type,
1031
1215
  data: pgOperations.data,
1216
+ atomicOps: pgOperations.atomicOps,
1032
1217
  wallTime: pgOperations.wallTime
1033
1218
  }).from(pgOperations).where((0, import_drizzle_orm.and)((0, import_drizzle_orm.eq)(pgOperations.collection, collection), (0, import_drizzle_orm.eq)(pgOperations.recordId, recordId))).orderBy(
1034
1219
  (0, import_drizzle_orm.asc)(pgOperations.wallTime),
1035
1220
  (0, import_drizzle_orm.asc)(pgOperations.logical),
1036
- (0, import_drizzle_orm.asc)(pgOperations.sequenceNumber)
1221
+ (0, import_drizzle_orm.asc)(pgOperations.timestampNodeId)
1037
1222
  );
1038
1223
  const parsedOps = ops.map((op) => ({
1039
1224
  type: op.type,
1040
- data: op.data !== null ? JSON.parse(op.data) : null
1225
+ data: op.data !== null ? JSON.parse(op.data) : null,
1226
+ atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
1041
1227
  }));
1042
- const recordData = replayOperationsForRecord(parsedOps);
1228
+ const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
1043
1229
  const fieldNames = Object.keys(collectionDef.fields);
1044
1230
  if (recordData) {
1045
1231
  const createdAt = ops.length > 0 ? ops[0].wallTime : Date.now();
@@ -1056,7 +1242,7 @@ var PostgresServerStore = class {
1056
1242
  );
1057
1243
  } else {
1058
1244
  await txOrDb.execute(
1059
- import_drizzle_orm.sql`UPDATE ${import_drizzle_orm.sql.raw(collection)} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
1245
+ import_drizzle_orm.sql`UPDATE ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(collection))} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
1060
1246
  );
1061
1247
  }
1062
1248
  }
@@ -1075,16 +1261,16 @@ var PostgresServerStore = class {
1075
1261
  updatedAt,
1076
1262
  0
1077
1263
  ];
1078
- const columnsSql = import_drizzle_orm.sql.raw(allColumns.join(", "));
1264
+ const columnsSql = import_drizzle_orm.sql.raw(allColumns.map((c) => (0, import_core3.quoteIdent)(c)).join(", "));
1079
1265
  const valuesSql = import_drizzle_orm.sql.join(
1080
1266
  values.map((v) => import_drizzle_orm.sql`${v}`),
1081
1267
  import_drizzle_orm.sql.raw(", ")
1082
1268
  );
1083
1269
  const updateSet = import_drizzle_orm.sql.raw(
1084
- allColumns.slice(1).map((c) => `${c} = excluded.${c}`).join(", ")
1270
+ allColumns.slice(1).map((c) => `${(0, import_core3.quoteIdent)(c)} = excluded.${(0, import_core3.quoteIdent)(c)}`).join(", ")
1085
1271
  );
1086
1272
  await txOrDb.execute(
1087
- import_drizzle_orm.sql`INSERT INTO ${import_drizzle_orm.sql.raw(tableName)} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
1273
+ import_drizzle_orm.sql`INSERT INTO ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(tableName))} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
1088
1274
  );
1089
1275
  }
1090
1276
  /**
@@ -1106,11 +1292,12 @@ var PostgresServerStore = class {
1106
1292
  recordId: pgOperations.recordId,
1107
1293
  type: pgOperations.type,
1108
1294
  data: pgOperations.data,
1295
+ atomicOps: pgOperations.atomicOps,
1109
1296
  wallTime: pgOperations.wallTime
1110
1297
  }).from(pgOperations).where((0, import_drizzle_orm.eq)(pgOperations.collection, collectionName)).orderBy(
1111
1298
  (0, import_drizzle_orm.asc)(pgOperations.wallTime),
1112
1299
  (0, import_drizzle_orm.asc)(pgOperations.logical),
1113
- (0, import_drizzle_orm.asc)(pgOperations.sequenceNumber)
1300
+ (0, import_drizzle_orm.asc)(pgOperations.timestampNodeId)
1114
1301
  );
1115
1302
  if (allOps.length === 0) return;
1116
1303
  const grouped = /* @__PURE__ */ new Map();
@@ -1126,9 +1313,10 @@ var PostgresServerStore = class {
1126
1313
  for (const [recordId, recordOps] of grouped) {
1127
1314
  const parsedOps = recordOps.map((op) => ({
1128
1315
  type: op.type,
1129
- data: op.data !== null ? JSON.parse(op.data) : null
1316
+ data: op.data !== null ? JSON.parse(op.data) : null,
1317
+ atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
1130
1318
  }));
1131
- const recordData = replayOperationsForRecord(parsedOps);
1319
+ const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
1132
1320
  if (recordData) {
1133
1321
  const createdAt = recordOps[0].wallTime;
1134
1322
  const updatedAt = recordOps[recordOps.length - 1].wallTime;
@@ -1144,7 +1332,7 @@ var PostgresServerStore = class {
1144
1332
  );
1145
1333
  } else {
1146
1334
  await this.db.execute(
1147
- import_drizzle_orm.sql`INSERT INTO ${import_drizzle_orm.sql.raw(collectionName)} (id, _deleted, _created_at, _updated_at) VALUES (${recordId}, 1, ${Date.now()}, ${Date.now()}) ON CONFLICT (id) DO UPDATE SET _deleted = 1, _updated_at = ${Date.now()}`
1335
+ import_drizzle_orm.sql`INSERT INTO ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(collectionName))} (id, _deleted, _created_at, _updated_at) VALUES (${recordId}, 1, ${Date.now()}, ${Date.now()}) ON CONFLICT (id) DO UPDATE SET _deleted = 1, _updated_at = ${Date.now()}`
1148
1336
  );
1149
1337
  }
1150
1338
  }
@@ -1157,10 +1345,12 @@ var PostgresServerStore = class {
1157
1345
  options?.where ?? {},
1158
1346
  options?.includeDeleted ?? false
1159
1347
  );
1160
- const parts = [import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw(collection)} WHERE ${whereClause}`];
1348
+ const parts = [
1349
+ import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(collection))} WHERE ${whereClause}`
1350
+ ];
1161
1351
  if (options?.orderBy) {
1162
1352
  const dir = options.orderDirection === "desc" ? "DESC" : "ASC";
1163
- parts.push(import_drizzle_orm.sql.raw(` ORDER BY ${options.orderBy} ${dir}`));
1353
+ parts.push(import_drizzle_orm.sql.raw(` ORDER BY ${(0, import_core3.quoteIdent)(options.orderBy)} ${dir}`));
1164
1354
  }
1165
1355
  if (options?.limit !== void 0) {
1166
1356
  parts.push(import_drizzle_orm.sql` LIMIT ${options.limit}`);
@@ -1176,7 +1366,7 @@ var PostgresServerStore = class {
1176
1366
  conditions.push(import_drizzle_orm.sql.raw("_deleted = 0"));
1177
1367
  }
1178
1368
  for (const [key, value] of Object.entries(where)) {
1179
- conditions.push(import_drizzle_orm.sql`${import_drizzle_orm.sql.raw(key)} = ${value}`);
1369
+ conditions.push(import_drizzle_orm.sql`${import_drizzle_orm.sql.raw((0, import_core3.quoteIdent)(key))} = ${value}`);
1180
1370
  }
1181
1371
  if (conditions.length === 0) {
1182
1372
  return import_drizzle_orm.sql.raw("1 = 1");
@@ -1249,44 +1439,92 @@ var PostgresServerStore = class {
1249
1439
  }
1250
1440
  }
1251
1441
  async ensureTables() {
1252
- await this.db.execute(import_drizzle_orm.sql`
1253
- CREATE TABLE IF NOT EXISTS operations (
1254
- id TEXT PRIMARY KEY,
1255
- node_id TEXT NOT NULL,
1256
- type TEXT NOT NULL,
1257
- collection TEXT NOT NULL,
1258
- record_id TEXT NOT NULL,
1259
- data TEXT,
1260
- previous_data TEXT,
1261
- wall_time BIGINT NOT NULL,
1262
- logical INTEGER NOT NULL,
1263
- timestamp_node_id TEXT NOT NULL,
1264
- sequence_number INTEGER NOT NULL,
1265
- causal_deps TEXT NOT NULL DEFAULT '[]',
1266
- schema_version INTEGER NOT NULL,
1267
- received_at BIGINT NOT NULL
1268
- )
1269
- `);
1270
- await this.db.execute(
1271
- import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_node_seq ON operations (node_id, sequence_number)`
1272
- );
1273
- await this.db.execute(import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_collection ON operations (collection)`);
1274
- await this.db.execute(import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_received ON operations (received_at)`);
1275
- await this.db.execute(
1276
- import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_collection_record ON operations (collection, record_id)`
1442
+ await this.db.transaction(async (tx) => {
1443
+ await tx.execute(import_drizzle_orm.sql`SELECT pg_advisory_xact_lock(hashtextextended('kora:ensure-tables', 0))`);
1444
+ await tx.execute(import_drizzle_orm.sql`
1445
+ CREATE TABLE IF NOT EXISTS operations (
1446
+ id TEXT PRIMARY KEY,
1447
+ node_id TEXT NOT NULL,
1448
+ type TEXT NOT NULL,
1449
+ collection TEXT NOT NULL,
1450
+ record_id TEXT NOT NULL,
1451
+ data TEXT,
1452
+ previous_data TEXT,
1453
+ atomic_ops TEXT,
1454
+ wall_time BIGINT NOT NULL,
1455
+ logical INTEGER NOT NULL,
1456
+ timestamp_node_id TEXT NOT NULL,
1457
+ sequence_number INTEGER NOT NULL,
1458
+ causal_deps TEXT NOT NULL DEFAULT '[]',
1459
+ schema_version INTEGER NOT NULL,
1460
+ received_at BIGINT NOT NULL
1461
+ )
1462
+ `);
1463
+ await tx.execute(import_drizzle_orm.sql`ALTER TABLE operations ADD COLUMN IF NOT EXISTS atomic_ops TEXT`);
1464
+ await tx.execute(import_drizzle_orm.sql`ALTER TABLE operations ADD COLUMN IF NOT EXISTS delivery_seq BIGINT`);
1465
+ await tx.execute(
1466
+ import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_node_seq ON operations (node_id, sequence_number)`
1467
+ );
1468
+ await tx.execute(import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_collection ON operations (collection)`);
1469
+ await tx.execute(import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_received ON operations (received_at)`);
1470
+ await tx.execute(
1471
+ import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_delivery_seq ON operations (delivery_seq)`
1472
+ );
1473
+ await tx.execute(
1474
+ import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_collection_record ON operations (collection, record_id)`
1475
+ );
1476
+ await tx.execute(import_drizzle_orm.sql`
1477
+ CREATE TABLE IF NOT EXISTS sync_state (
1478
+ node_id TEXT PRIMARY KEY,
1479
+ max_sequence_number INTEGER NOT NULL,
1480
+ last_seen_at BIGINT NOT NULL
1481
+ )
1482
+ `);
1483
+ await tx.execute(import_drizzle_orm.sql`
1484
+ CREATE TABLE IF NOT EXISTS delivery_counter (
1485
+ id INTEGER PRIMARY KEY,
1486
+ value BIGINT NOT NULL
1487
+ )
1488
+ `);
1489
+ await tx.execute(import_drizzle_orm.sql`
1490
+ WITH ordered AS (
1491
+ SELECT id, ROW_NUMBER() OVER (
1492
+ ORDER BY received_at ASC, sequence_number ASC, id ASC
1493
+ ) + COALESCE((SELECT MAX(delivery_seq) FROM operations), 0) AS rn
1494
+ FROM operations WHERE delivery_seq IS NULL
1495
+ )
1496
+ UPDATE operations o SET delivery_seq = ordered.rn
1497
+ FROM ordered WHERE o.id = ordered.id
1498
+ `);
1499
+ await tx.execute(import_drizzle_orm.sql`
1500
+ INSERT INTO delivery_counter (id, value)
1501
+ VALUES (1, COALESCE((SELECT MAX(delivery_seq) FROM operations), 0))
1502
+ ON CONFLICT (id) DO UPDATE
1503
+ SET value = GREATEST(delivery_counter.value, EXCLUDED.value)
1504
+ `);
1505
+ });
1506
+ }
1507
+ /**
1508
+ * Reserve the next delivery sequence inside an open append transaction. The
1509
+ * `UPDATE ... RETURNING` locks the counter row until this transaction commits,
1510
+ * serializing assignment into commit order (see the counter table comment).
1511
+ */
1512
+ async nextDeliverySeq(tx) {
1513
+ const rows = await tx.execute(
1514
+ import_drizzle_orm.sql`UPDATE delivery_counter SET value = value + 1 WHERE id = 1 RETURNING value`
1277
1515
  );
1278
- await this.db.execute(import_drizzle_orm.sql`
1279
- CREATE TABLE IF NOT EXISTS sync_state (
1280
- node_id TEXT PRIMARY KEY,
1281
- max_sequence_number INTEGER NOT NULL,
1282
- last_seen_at BIGINT NOT NULL
1283
- )
1284
- `);
1516
+ const value = rows[0]?.value;
1517
+ if (value === void 0 || value === null) {
1518
+ throw new Error(
1519
+ "delivery_counter row (id=1) is missing; the operations log cannot assign delivery sequences"
1520
+ );
1521
+ }
1522
+ return Number(value);
1285
1523
  }
1286
1524
  // ---------------------------------------------------------------------------
1287
1525
  // Operation serialization
1288
1526
  // ---------------------------------------------------------------------------
1289
- serializeOperation(op, receivedAt) {
1527
+ serializeOperation(op, receivedAt, deliverySeq) {
1290
1528
  return {
1291
1529
  id: op.id,
1292
1530
  nodeId: op.nodeId,
@@ -1295,16 +1533,19 @@ var PostgresServerStore = class {
1295
1533
  recordId: op.recordId,
1296
1534
  data: op.data !== null ? JSON.stringify(op.data) : null,
1297
1535
  previousData: op.previousData !== null ? JSON.stringify(op.previousData) : null,
1536
+ atomicOps: op.atomicOps && Object.keys(op.atomicOps).length > 0 ? JSON.stringify(op.atomicOps) : null,
1298
1537
  wallTime: op.timestamp.wallTime,
1299
1538
  logical: op.timestamp.logical,
1300
1539
  timestampNodeId: op.timestamp.nodeId,
1301
1540
  sequenceNumber: op.sequenceNumber,
1302
1541
  causalDeps: JSON.stringify(op.causalDeps),
1303
1542
  schemaVersion: op.schemaVersion,
1304
- receivedAt
1543
+ receivedAt,
1544
+ deliverySeq
1305
1545
  };
1306
1546
  }
1307
1547
  deserializeOperation(row) {
1548
+ const atomicOps = row.atomicOps != null ? JSON.parse(row.atomicOps) : void 0;
1308
1549
  return {
1309
1550
  id: row.id,
1310
1551
  nodeId: row.nodeId,
@@ -1320,7 +1561,8 @@ var PostgresServerStore = class {
1320
1561
  },
1321
1562
  sequenceNumber: row.sequenceNumber,
1322
1563
  causalDeps: JSON.parse(row.causalDeps),
1323
- schemaVersion: row.schemaVersion
1564
+ schemaVersion: row.schemaVersion,
1565
+ ...atomicOps ? { atomicOps } : {}
1324
1566
  };
1325
1567
  }
1326
1568
  // ---------------------------------------------------------------------------
@@ -1390,6 +1632,8 @@ var operations = (0, import_sqlite_core.sqliteTable)(
1390
1632
  // JSON-serialized, null for deletes
1391
1633
  previousData: (0, import_sqlite_core.text)("previous_data"),
1392
1634
  // JSON-serialized, null for insert/delete
1635
+ atomicOps: (0, import_sqlite_core.text)("atomic_ops"),
1636
+ // JSON-serialized Record<field, AtomicOp>, null when none
1393
1637
  wallTime: (0, import_sqlite_core.integer)("wall_time").notNull(),
1394
1638
  logical: (0, import_sqlite_core.integer)("logical").notNull(),
1395
1639
  timestampNodeId: (0, import_sqlite_core.text)("timestamp_node_id").notNull(),
@@ -1397,12 +1641,17 @@ var operations = (0, import_sqlite_core.sqliteTable)(
1397
1641
  causalDeps: (0, import_sqlite_core.text)("causal_deps").notNull().default("[]"),
1398
1642
  // JSON array of op IDs
1399
1643
  schemaVersion: (0, import_sqlite_core.integer)("schema_version").notNull(),
1400
- receivedAt: (0, import_sqlite_core.integer)("received_at").notNull()
1644
+ receivedAt: (0, import_sqlite_core.integer)("received_at").notNull(),
1645
+ // Server-assigned monotonic delivery sequence, ordered by commit. Drives the
1646
+ // gap-free server->client delivery watermark. Nullable only for rows written
1647
+ // before the column existed; those are backfilled on startup.
1648
+ deliverySeq: (0, import_sqlite_core.integer)("delivery_seq")
1401
1649
  },
1402
1650
  (table) => ({
1403
1651
  nodeSeqIdx: (0, import_sqlite_core.index)("idx_node_seq").on(table.nodeId, table.sequenceNumber),
1404
1652
  collectionIdx: (0, import_sqlite_core.index)("idx_collection").on(table.collection),
1405
- receivedIdx: (0, import_sqlite_core.index)("idx_received").on(table.receivedAt)
1653
+ receivedIdx: (0, import_sqlite_core.index)("idx_received").on(table.receivedAt),
1654
+ deliveryIdx: (0, import_sqlite_core.index)("idx_delivery_seq").on(table.deliverySeq)
1406
1655
  })
1407
1656
  );
1408
1657
  var syncState = (0, import_sqlite_core.sqliteTable)("sync_state", {
@@ -1410,6 +1659,10 @@ var syncState = (0, import_sqlite_core.sqliteTable)("sync_state", {
1410
1659
  maxSequenceNumber: (0, import_sqlite_core.integer)("max_sequence_number").notNull(),
1411
1660
  lastSeenAt: (0, import_sqlite_core.integer)("last_seen_at").notNull()
1412
1661
  });
1662
+ var deliveryCounter = (0, import_sqlite_core.sqliteTable)("delivery_counter", {
1663
+ id: (0, import_sqlite_core.integer)("id").primaryKey(),
1664
+ value: (0, import_sqlite_core.integer)("value").notNull()
1665
+ });
1413
1666
 
1414
1667
  // src/store/sqlite-server-store.ts
1415
1668
  var esmRequire = (0, import_node_module.createRequire)(importMetaUrl);
@@ -1463,12 +1716,15 @@ var SqliteServerStore = class {
1463
1716
  async applyRemoteOperation(op) {
1464
1717
  this.assertOpen();
1465
1718
  const now = Date.now();
1466
- const row = this.serializeOperation(op, now);
1467
1719
  const result = this.db.transaction((tx) => {
1468
- const insertResult = tx.insert(operations).values(row).onConflictDoNothing({ target: operations.id }).run();
1469
- if (insertResult.changes === 0) {
1720
+ const existing = tx.all(
1721
+ import_drizzle_orm2.sql`SELECT id FROM operations WHERE id = ${op.id} LIMIT 1`
1722
+ );
1723
+ if (existing.length > 0) {
1470
1724
  return "duplicate";
1471
1725
  }
1726
+ const row = this.serializeOperation(op, now, this.nextDeliverySeq(tx));
1727
+ tx.insert(operations).values(row).run();
1472
1728
  tx.insert(syncState).values({
1473
1729
  nodeId: op.nodeId,
1474
1730
  maxSequenceNumber: op.sequenceNumber,
@@ -1497,6 +1753,21 @@ var SqliteServerStore = class {
1497
1753
  const result = this.db.select({ value: (0, import_drizzle_orm2.count)() }).from(operations).all();
1498
1754
  return result[0]?.value ?? 0;
1499
1755
  }
1756
+ async getMaxDeliverySequence() {
1757
+ this.assertOpen();
1758
+ const rows = this.db.all(
1759
+ import_drizzle_orm2.sql`SELECT MAX(delivery_seq) AS m FROM operations`
1760
+ );
1761
+ return rows[0]?.m ?? 0;
1762
+ }
1763
+ async getOperationsAfterDelivery(afterDeliverySequence, limit) {
1764
+ this.assertOpen();
1765
+ const rows = this.db.select().from(operations).where((0, import_drizzle_orm2.gt)(operations.deliverySeq, afterDeliverySequence)).orderBy((0, import_drizzle_orm2.asc)(operations.deliverySeq)).limit(limit).all();
1766
+ return rows.map((row) => ({
1767
+ operation: this.deserializeOperation(row),
1768
+ deliverySequence: row.deliverySeq ?? 0
1769
+ }));
1770
+ }
1500
1771
  async materializeCollection(collection) {
1501
1772
  this.assertOpen();
1502
1773
  if (this.schema?.collections[collection]) {
@@ -1528,7 +1799,7 @@ var SqliteServerStore = class {
1528
1799
  this.assertCollection(collection);
1529
1800
  const schema = this.schema;
1530
1801
  const collectionDef = schema.collections[collection];
1531
- const query = import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw(collection)} WHERE id = ${id} AND _deleted = 0`;
1802
+ const query = import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(collection))} WHERE id = ${id} AND _deleted = 0`;
1532
1803
  const rows = this.db.all(query);
1533
1804
  if (rows.length === 0) return null;
1534
1805
  return this.deserializeRow(rows[0], collectionDef);
@@ -1544,7 +1815,7 @@ var SqliteServerStore = class {
1544
1815
  }
1545
1816
  }
1546
1817
  const whereClause = this.buildWhereClause(where ?? {}, false);
1547
- const query = import_drizzle_orm2.sql`SELECT COUNT(*) as cnt FROM ${import_drizzle_orm2.sql.raw(collection)} WHERE ${whereClause}`;
1818
+ const query = import_drizzle_orm2.sql`SELECT COUNT(*) as cnt FROM ${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(collection))} WHERE ${whereClause}`;
1548
1819
  const rows = this.db.all(query);
1549
1820
  return rows[0]?.cnt ?? 0;
1550
1821
  }
@@ -1554,7 +1825,7 @@ var SqliteServerStore = class {
1554
1825
  async exportBackup() {
1555
1826
  this.assertOpen();
1556
1827
  const { buildServerBackup: buildServerBackup2 } = await Promise.resolve().then(() => (init_server_backup(), server_backup_exports));
1557
- const rows = this.db.select().from(operations).all();
1828
+ const rows = this.db.select().from(operations).orderBy((0, import_drizzle_orm2.asc)(operations.deliverySeq)).all();
1558
1829
  const deserialized = rows.map((row) => this.deserializeOperation(row));
1559
1830
  const vv = this.getVersionVector();
1560
1831
  return buildServerBackup2(this.nodeId, deserialized, vv);
@@ -1574,13 +1845,17 @@ var SqliteServerStore = class {
1574
1845
  this.db.transaction((tx) => {
1575
1846
  tx.run(import_drizzle_orm2.sql.raw("DELETE FROM operations"));
1576
1847
  tx.run(import_drizzle_orm2.sql.raw("DELETE FROM sync_state"));
1848
+ tx.update(deliveryCounter).set({ value: 0 }).where((0, import_drizzle_orm2.eq)(deliveryCounter.id, 1)).run();
1577
1849
  for (const [nid, seq] of versionVector) {
1578
1850
  tx.insert(syncState).values({ nodeId: nid, maxSequenceNumber: seq, lastSeenAt: Date.now() }).run();
1579
1851
  }
1852
+ let deliverySeq = 0;
1580
1853
  for (const op of ops) {
1581
- const row = this.serializeOperation(op, Date.now());
1854
+ deliverySeq += 1;
1855
+ const row = this.serializeOperation(op, Date.now(), deliverySeq);
1582
1856
  tx.insert(operations).values(row).run();
1583
1857
  }
1858
+ tx.update(deliveryCounter).set({ value: deliverySeq }).where((0, import_drizzle_orm2.eq)(deliveryCounter.id, 1)).run();
1584
1859
  });
1585
1860
  return { operationsRestored: ops.length, success: true };
1586
1861
  }
@@ -1598,13 +1873,15 @@ var SqliteServerStore = class {
1598
1873
  const ops = txOrDb.select({
1599
1874
  type: operations.type,
1600
1875
  data: operations.data,
1876
+ atomicOps: operations.atomicOps,
1601
1877
  wallTime: operations.wallTime
1602
- }).from(operations).where((0, import_drizzle_orm2.and)((0, import_drizzle_orm2.eq)(operations.collection, collection), (0, import_drizzle_orm2.eq)(operations.recordId, recordId))).orderBy((0, import_drizzle_orm2.asc)(operations.wallTime), (0, import_drizzle_orm2.asc)(operations.logical), (0, import_drizzle_orm2.asc)(operations.sequenceNumber)).all();
1878
+ }).from(operations).where((0, import_drizzle_orm2.and)((0, import_drizzle_orm2.eq)(operations.collection, collection), (0, import_drizzle_orm2.eq)(operations.recordId, recordId))).orderBy((0, import_drizzle_orm2.asc)(operations.wallTime), (0, import_drizzle_orm2.asc)(operations.logical), (0, import_drizzle_orm2.asc)(operations.timestampNodeId)).all();
1603
1879
  const parsedOps = ops.map((op) => ({
1604
1880
  type: op.type,
1605
- data: op.data !== null ? JSON.parse(op.data) : null
1881
+ data: op.data !== null ? JSON.parse(op.data) : null,
1882
+ atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
1606
1883
  }));
1607
- const recordData = replayOperationsForRecord(parsedOps);
1884
+ const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
1608
1885
  const fieldNames = Object.keys(collectionDef.fields);
1609
1886
  if (recordData) {
1610
1887
  const createdAt = ops.length > 0 ? ops[0].wallTime : Date.now();
@@ -1621,7 +1898,7 @@ var SqliteServerStore = class {
1621
1898
  );
1622
1899
  } else {
1623
1900
  txOrDb.run(
1624
- import_drizzle_orm2.sql`UPDATE ${import_drizzle_orm2.sql.raw(collection)} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
1901
+ import_drizzle_orm2.sql`UPDATE ${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(collection))} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
1625
1902
  );
1626
1903
  }
1627
1904
  }
@@ -1642,16 +1919,16 @@ var SqliteServerStore = class {
1642
1919
  0
1643
1920
  // _deleted = false
1644
1921
  ];
1645
- const columnsSql = import_drizzle_orm2.sql.raw(allColumns.join(", "));
1922
+ const columnsSql = import_drizzle_orm2.sql.raw(allColumns.map((c) => (0, import_core4.quoteIdent)(c)).join(", "));
1646
1923
  const valuesSql = import_drizzle_orm2.sql.join(
1647
1924
  values.map((v) => import_drizzle_orm2.sql`${v}`),
1648
1925
  import_drizzle_orm2.sql.raw(", ")
1649
1926
  );
1650
1927
  const updateSet = import_drizzle_orm2.sql.raw(
1651
- allColumns.slice(1).map((c) => `${c} = excluded.${c}`).join(", ")
1928
+ allColumns.slice(1).map((c) => `${(0, import_core4.quoteIdent)(c)} = excluded.${(0, import_core4.quoteIdent)(c)}`).join(", ")
1652
1929
  );
1653
1930
  txOrDb.run(
1654
- import_drizzle_orm2.sql`INSERT INTO ${import_drizzle_orm2.sql.raw(tableName)} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
1931
+ import_drizzle_orm2.sql`INSERT INTO ${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(tableName))} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
1655
1932
  );
1656
1933
  }
1657
1934
  /**
@@ -1674,8 +1951,9 @@ var SqliteServerStore = class {
1674
1951
  recordId: operations.recordId,
1675
1952
  type: operations.type,
1676
1953
  data: operations.data,
1954
+ atomicOps: operations.atomicOps,
1677
1955
  wallTime: operations.wallTime
1678
- }).from(operations).where((0, import_drizzle_orm2.eq)(operations.collection, collectionName)).orderBy((0, import_drizzle_orm2.asc)(operations.wallTime), (0, import_drizzle_orm2.asc)(operations.logical), (0, import_drizzle_orm2.asc)(operations.sequenceNumber)).all();
1956
+ }).from(operations).where((0, import_drizzle_orm2.eq)(operations.collection, collectionName)).orderBy((0, import_drizzle_orm2.asc)(operations.wallTime), (0, import_drizzle_orm2.asc)(operations.logical), (0, import_drizzle_orm2.asc)(operations.timestampNodeId)).all();
1679
1957
  if (allOps.length === 0) return;
1680
1958
  const grouped = /* @__PURE__ */ new Map();
1681
1959
  for (const op of allOps) {
@@ -1691,9 +1969,10 @@ var SqliteServerStore = class {
1691
1969
  for (const [recordId, recordOps] of grouped) {
1692
1970
  const parsedOps = recordOps.map((op) => ({
1693
1971
  type: op.type,
1694
- data: op.data !== null ? JSON.parse(op.data) : null
1972
+ data: op.data !== null ? JSON.parse(op.data) : null,
1973
+ atomicOps: op.atomicOps != null ? JSON.parse(op.atomicOps) : null
1695
1974
  }));
1696
- const recordData = replayOperationsForRecord(parsedOps);
1975
+ const recordData = (0, import_core.replayOperationsForRecord)(parsedOps);
1697
1976
  if (recordData) {
1698
1977
  const createdAt = recordOps[0].wallTime;
1699
1978
  const updatedAt = recordOps[recordOps.length - 1].wallTime;
@@ -1709,7 +1988,7 @@ var SqliteServerStore = class {
1709
1988
  );
1710
1989
  } else {
1711
1990
  tx.run(
1712
- import_drizzle_orm2.sql`INSERT INTO ${import_drizzle_orm2.sql.raw(collectionName)} (id, _deleted, _created_at, _updated_at) VALUES (${recordId}, 1, ${Date.now()}, ${Date.now()}) ON CONFLICT (id) DO UPDATE SET _deleted = 1, _updated_at = ${Date.now()}`
1991
+ import_drizzle_orm2.sql`INSERT INTO ${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(collectionName))} (id, _deleted, _created_at, _updated_at) VALUES (${recordId}, 1, ${Date.now()}, ${Date.now()}) ON CONFLICT (id) DO UPDATE SET _deleted = 1, _updated_at = ${Date.now()}`
1713
1992
  );
1714
1993
  }
1715
1994
  }
@@ -1723,10 +2002,12 @@ var SqliteServerStore = class {
1723
2002
  options?.where ?? {},
1724
2003
  options?.includeDeleted ?? false
1725
2004
  );
1726
- const parts = [import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw(collection)} WHERE ${whereClause}`];
2005
+ const parts = [
2006
+ import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(collection))} WHERE ${whereClause}`
2007
+ ];
1727
2008
  if (options?.orderBy) {
1728
2009
  const dir = options.orderDirection === "desc" ? "DESC" : "ASC";
1729
- parts.push(import_drizzle_orm2.sql.raw(` ORDER BY ${options.orderBy} ${dir}`));
2010
+ parts.push(import_drizzle_orm2.sql.raw(` ORDER BY ${(0, import_core4.quoteIdent)(options.orderBy)} ${dir}`));
1730
2011
  }
1731
2012
  if (options?.limit !== void 0) {
1732
2013
  parts.push(import_drizzle_orm2.sql` LIMIT ${options.limit}`);
@@ -1742,7 +2023,7 @@ var SqliteServerStore = class {
1742
2023
  conditions.push(import_drizzle_orm2.sql.raw("_deleted = 0"));
1743
2024
  }
1744
2025
  for (const [key, value] of Object.entries(where)) {
1745
- conditions.push(import_drizzle_orm2.sql`${import_drizzle_orm2.sql.raw(key)} = ${value}`);
2026
+ conditions.push(import_drizzle_orm2.sql`${import_drizzle_orm2.sql.raw((0, import_core4.quoteIdent)(key))} = ${value}`);
1746
2027
  }
1747
2028
  if (conditions.length === 0) {
1748
2029
  return import_drizzle_orm2.sql.raw("1 = 1");
@@ -1813,6 +2094,7 @@ var SqliteServerStore = class {
1813
2094
  record_id TEXT NOT NULL,
1814
2095
  data TEXT,
1815
2096
  previous_data TEXT,
2097
+ atomic_ops TEXT,
1816
2098
  wall_time INTEGER NOT NULL,
1817
2099
  logical INTEGER NOT NULL,
1818
2100
  timestamp_node_id TEXT NOT NULL,
@@ -1822,9 +2104,35 @@ var SqliteServerStore = class {
1822
2104
  received_at INTEGER NOT NULL
1823
2105
  )
1824
2106
  `);
2107
+ try {
2108
+ this.db.run(import_drizzle_orm2.sql`ALTER TABLE operations ADD COLUMN atomic_ops TEXT`);
2109
+ } catch (e) {
2110
+ const msg = e instanceof Error ? e.message : "";
2111
+ const causeMsg = e instanceof Error && e.cause instanceof Error ? e.cause.message : "";
2112
+ if (!msg.includes("duplicate column") && !causeMsg.includes("duplicate column")) {
2113
+ throw e;
2114
+ }
2115
+ }
2116
+ try {
2117
+ this.db.run(import_drizzle_orm2.sql`ALTER TABLE operations ADD COLUMN delivery_seq INTEGER`);
2118
+ } catch (e) {
2119
+ const msg = e instanceof Error ? e.message : "";
2120
+ const causeMsg = e instanceof Error && e.cause instanceof Error ? e.cause.message : "";
2121
+ if (!msg.includes("duplicate column") && !causeMsg.includes("duplicate column")) {
2122
+ throw e;
2123
+ }
2124
+ }
1825
2125
  this.db.run(import_drizzle_orm2.sql`
1826
2126
  CREATE INDEX IF NOT EXISTS idx_node_seq ON operations (node_id, sequence_number)
1827
2127
  `);
2128
+ this.db.run(import_drizzle_orm2.sql`
2129
+ CREATE INDEX IF NOT EXISTS idx_delivery_seq ON operations (delivery_seq)
2130
+ `);
2131
+ this.db.run(import_drizzle_orm2.sql`
2132
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_delivery_seq_unique
2133
+ ON operations (delivery_seq)
2134
+ WHERE delivery_seq IS NOT NULL
2135
+ `);
1828
2136
  this.db.run(import_drizzle_orm2.sql`
1829
2137
  CREATE INDEX IF NOT EXISTS idx_collection ON operations (collection)
1830
2138
  `);
@@ -1841,11 +2149,60 @@ var SqliteServerStore = class {
1841
2149
  last_seen_at INTEGER NOT NULL
1842
2150
  )
1843
2151
  `);
2152
+ this.db.run(import_drizzle_orm2.sql`
2153
+ CREATE TABLE IF NOT EXISTS delivery_counter (
2154
+ id INTEGER PRIMARY KEY,
2155
+ value INTEGER NOT NULL
2156
+ )
2157
+ `);
2158
+ this.backfillDeliverySequence();
2159
+ }
2160
+ /**
2161
+ * Assign delivery sequences to any operations written before the column existed,
2162
+ * then seed the durable counter from the current max. Ordered by
2163
+ * (received_at, sequence_number, id) so the backfill is deterministic and stable
2164
+ * across restarts. A one-time migration: after it runs, all rows have a value.
2165
+ */
2166
+ backfillDeliverySequence() {
2167
+ this.db.transaction((tx) => {
2168
+ const maxRow = tx.all(
2169
+ import_drizzle_orm2.sql`SELECT MAX(delivery_seq) AS m FROM operations`
2170
+ );
2171
+ let next = maxRow[0]?.m ?? 0;
2172
+ const nullRows = tx.all(
2173
+ import_drizzle_orm2.sql`SELECT id FROM operations WHERE delivery_seq IS NULL ORDER BY received_at ASC, sequence_number ASC, id ASC`
2174
+ );
2175
+ for (const row of nullRows) {
2176
+ next += 1;
2177
+ tx.run(import_drizzle_orm2.sql`UPDATE operations SET delivery_seq = ${next} WHERE id = ${row.id}`);
2178
+ }
2179
+ tx.insert(deliveryCounter).values({ id: 1, value: next }).onConflictDoUpdate({
2180
+ target: deliveryCounter.id,
2181
+ set: {
2182
+ value: import_drizzle_orm2.sql`MAX(${deliveryCounter.value}, ${next})`
2183
+ }
2184
+ }).run();
2185
+ });
2186
+ }
2187
+ /**
2188
+ * Reserve the next delivery sequence inside the append transaction. SQLite locks
2189
+ * the database for the write transaction, and this counter row keeps sequence
2190
+ * allocation shared by every SqliteServerStore instance using the same file.
2191
+ */
2192
+ nextDeliverySeq(tx) {
2193
+ const rows = tx.update(deliveryCounter).set({ value: import_drizzle_orm2.sql`${deliveryCounter.value} + 1` }).where((0, import_drizzle_orm2.eq)(deliveryCounter.id, 1)).returning({ value: deliveryCounter.value }).all();
2194
+ const value = rows[0]?.value;
2195
+ if (value === void 0 || value === null) {
2196
+ throw new Error(
2197
+ "delivery_counter row (id=1) is missing; the operations log cannot assign delivery sequences"
2198
+ );
2199
+ }
2200
+ return value;
1844
2201
  }
1845
2202
  // ---------------------------------------------------------------------------
1846
2203
  // Operation serialization
1847
2204
  // ---------------------------------------------------------------------------
1848
- serializeOperation(op, receivedAt) {
2205
+ serializeOperation(op, receivedAt, deliverySeq) {
1849
2206
  return {
1850
2207
  id: op.id,
1851
2208
  nodeId: op.nodeId,
@@ -1854,16 +2211,19 @@ var SqliteServerStore = class {
1854
2211
  recordId: op.recordId,
1855
2212
  data: op.data !== null ? JSON.stringify(op.data) : null,
1856
2213
  previousData: op.previousData !== null ? JSON.stringify(op.previousData) : null,
2214
+ atomicOps: op.atomicOps && Object.keys(op.atomicOps).length > 0 ? JSON.stringify(op.atomicOps) : null,
1857
2215
  wallTime: op.timestamp.wallTime,
1858
2216
  logical: op.timestamp.logical,
1859
2217
  timestampNodeId: op.timestamp.nodeId,
1860
2218
  sequenceNumber: op.sequenceNumber,
1861
2219
  causalDeps: JSON.stringify(op.causalDeps),
1862
2220
  schemaVersion: op.schemaVersion,
1863
- receivedAt
2221
+ receivedAt,
2222
+ deliverySeq
1864
2223
  };
1865
2224
  }
1866
2225
  deserializeOperation(row) {
2226
+ const atomicOps = row.atomicOps != null ? JSON.parse(row.atomicOps) : void 0;
1867
2227
  return {
1868
2228
  id: row.id,
1869
2229
  nodeId: row.nodeId,
@@ -1879,7 +2239,8 @@ var SqliteServerStore = class {
1879
2239
  },
1880
2240
  sequenceNumber: row.sequenceNumber,
1881
2241
  causalDeps: JSON.parse(row.causalDeps),
1882
- schemaVersion: row.schemaVersion
2242
+ schemaVersion: row.schemaVersion,
2243
+ ...atomicOps ? { atomicOps } : {}
1883
2244
  };
1884
2245
  }
1885
2246
  // ---------------------------------------------------------------------------
@@ -2065,8 +2426,10 @@ var WsServerTransport = class {
2065
2426
  // src/session/client-session.ts
2066
2427
  init_cjs_shims();
2067
2428
  var import_core10 = require("@korajs/core");
2429
+ var import_core11 = require("@korajs/core");
2068
2430
  var import_internal = require("@korajs/core/internal");
2069
2431
  var import_sync2 = require("@korajs/sync");
2432
+ var import_sync3 = require("@korajs/sync");
2070
2433
 
2071
2434
  // src/apply/apply-server-operation.ts
2072
2435
  init_cjs_shims();
@@ -2177,6 +2540,9 @@ async function createServerSideEffectOperation(store, parentOp, effect, schemaVe
2177
2540
  );
2178
2541
  }
2179
2542
  function nextServerSequenceNumber(store) {
2543
+ if (store.reserveSequenceNumber) {
2544
+ return store.reserveSequenceNumber();
2545
+ }
2180
2546
  const nodeId = store.getNodeId();
2181
2547
  const current = store.getVersionVector().get(nodeId) ?? 0;
2182
2548
  return current + 1;
@@ -2186,14 +2552,28 @@ function nextServerSequenceNumber(store) {
2186
2552
  async function applyServerOperation(store, op, relationLookup) {
2187
2553
  const schema = store.getSchema();
2188
2554
  const lookup = relationLookup ?? (schema ? (0, import_merge2.buildMergeRelationLookup)(schema) : /* @__PURE__ */ new Map());
2555
+ const shapeCheck = validateOperationShape(op, schema);
2556
+ if (!shapeCheck.valid) {
2557
+ return {
2558
+ result: "skipped",
2559
+ appliedOperations: [],
2560
+ rejection: {
2561
+ code: shapeCheck.code ?? "SCHEMA_VALIDATION_ERROR",
2562
+ message: shapeCheck.message ?? `Operation "${op.id}" does not match the server schema`,
2563
+ retriable: false
2564
+ }
2565
+ };
2566
+ }
2189
2567
  const constraintCheck = await validateIncomingOperationConstraints(store, op, schema);
2190
2568
  if (!constraintCheck.valid) {
2569
+ const code = constraintCheck.code ?? "CONSTRAINT_VIOLATION";
2191
2570
  return {
2192
2571
  result: "skipped",
2193
2572
  appliedOperations: [],
2194
2573
  rejection: {
2195
- code: constraintCheck.code ?? "CONSTRAINT_VIOLATION",
2196
- message: constraintCheck.message ?? `Operation "${op.id}" violates a schema constraint`
2574
+ code,
2575
+ message: constraintCheck.message ?? `Operation "${op.id}" violates a schema constraint`,
2576
+ retriable: isRetriableRejection(code)
2197
2577
  }
2198
2578
  };
2199
2579
  }
@@ -2206,7 +2586,8 @@ async function applyServerOperation(store, op, relationLookup) {
2206
2586
  appliedOperations: [],
2207
2587
  rejection: {
2208
2588
  code: "REFERENTIAL_INTEGRITY",
2209
- message: `Operation "${op.id}" violates referential integrity on "${op.collection}"`
2589
+ message: `Operation "${op.id}" violates referential integrity on "${op.collection}"`,
2590
+ retriable: isRetriableRejection("REFERENTIAL_INTEGRITY")
2210
2591
  }
2211
2592
  };
2212
2593
  }
@@ -2215,16 +2596,14 @@ async function applyServerOperation(store, op, relationLookup) {
2215
2596
  return { result: primaryResult, appliedOperations: [] };
2216
2597
  }
2217
2598
  const appliedOperations = [op];
2218
- let serverSeq = nextServerSequenceNumber(store);
2219
2599
  for (const effect of referential.sideEffectOps) {
2220
2600
  const sideOp = await createServerSideEffectOperation(
2221
2601
  store,
2222
2602
  op,
2223
2603
  effect,
2224
2604
  op.schemaVersion,
2225
- serverSeq
2605
+ nextServerSequenceNumber(store)
2226
2606
  );
2227
- serverSeq += 1;
2228
2607
  const sideResult = await store.applyRemoteOperation(sideOp);
2229
2608
  if (sideResult === "applied") {
2230
2609
  appliedOperations.push(sideOp);
@@ -2238,6 +2617,75 @@ async function applyServerOperation(store, op, relationLookup) {
2238
2617
  appliedOperations: result === "applied" ? [op] : []
2239
2618
  };
2240
2619
  }
2620
+ function validateOperationShape(op, schema) {
2621
+ if (!schema) {
2622
+ return { valid: true };
2623
+ }
2624
+ const collection = schema.collections[op.collection];
2625
+ if (!collection) {
2626
+ return {
2627
+ valid: false,
2628
+ code: "UNKNOWN_COLLECTION",
2629
+ message: `Operation "${op.id}" targets unknown collection "${op.collection}".`
2630
+ };
2631
+ }
2632
+ const declaredFields = new Set(Object.keys(collection.fields));
2633
+ const systemPreviousFields = /* @__PURE__ */ new Set(["id", "_created_at", "_updated_at", "_deleted"]);
2634
+ const invalidDataField = firstInvalidField(op.data, declaredFields);
2635
+ if (invalidDataField) {
2636
+ return {
2637
+ valid: false,
2638
+ code: "SCHEMA_VALIDATION_ERROR",
2639
+ message: `Operation "${op.id}" contains undeclared field "${invalidDataField}" in data for collection "${op.collection}".`
2640
+ };
2641
+ }
2642
+ const invalidPreviousField = firstInvalidField(
2643
+ op.previousData,
2644
+ declaredFields,
2645
+ systemPreviousFields
2646
+ );
2647
+ if (invalidPreviousField) {
2648
+ return {
2649
+ valid: false,
2650
+ code: "SCHEMA_VALIDATION_ERROR",
2651
+ message: `Operation "${op.id}" contains undeclared field "${invalidPreviousField}" in previousData for collection "${op.collection}".`
2652
+ };
2653
+ }
2654
+ if (op.atomicOps) {
2655
+ for (const field of Object.keys(op.atomicOps)) {
2656
+ if (!declaredFields.has(field)) {
2657
+ return {
2658
+ valid: false,
2659
+ code: "SCHEMA_VALIDATION_ERROR",
2660
+ message: `Operation "${op.id}" contains undeclared atomic field "${field}" for collection "${op.collection}".`
2661
+ };
2662
+ }
2663
+ }
2664
+ }
2665
+ return { valid: true };
2666
+ }
2667
+ function firstInvalidField(value, declaredFields, extraAllowedFields) {
2668
+ if (value === null || value === void 0) {
2669
+ return null;
2670
+ }
2671
+ if (typeof value !== "object" || Array.isArray(value)) {
2672
+ return null;
2673
+ }
2674
+ for (const field of Object.keys(value)) {
2675
+ if (!declaredFields.has(field) && !extraAllowedFields?.has(field)) {
2676
+ return field;
2677
+ }
2678
+ }
2679
+ return null;
2680
+ }
2681
+
2682
+ // src/auth/no-auth.ts
2683
+ init_cjs_shims();
2684
+ var NoAuthProvider = class {
2685
+ async authenticate(_token) {
2686
+ return { userId: "anonymous" };
2687
+ }
2688
+ };
2241
2689
 
2242
2690
  // src/scopes/resolve-session-scopes.ts
2243
2691
  init_cjs_shims();
@@ -2277,11 +2725,12 @@ function mergeScopeMaps(base, overlay, source) {
2277
2725
 
2278
2726
  // src/scopes/server-scope-filter.ts
2279
2727
  init_cjs_shims();
2280
- function operationMatchesScopes(op, scopes) {
2728
+ function operationMatchesScopes(op, scopes, fullRecord) {
2281
2729
  if (!scopes) return true;
2282
2730
  const collectionScope = scopes[op.collection];
2283
2731
  if (!collectionScope) return false;
2284
- const snapshot = buildSnapshot(op);
2732
+ if (Object.keys(collectionScope).length === 0) return true;
2733
+ const snapshot = buildSnapshot(op, fullRecord);
2285
2734
  if (!snapshot) return false;
2286
2735
  for (const [field, expected] of Object.entries(collectionScope)) {
2287
2736
  if (snapshot[field] !== expected) {
@@ -2290,11 +2739,20 @@ function operationMatchesScopes(op, scopes) {
2290
2739
  }
2291
2740
  return true;
2292
2741
  }
2293
- function buildSnapshot(op) {
2742
+ function missingScopeFields(op, scopes) {
2743
+ if (!scopes) return [];
2744
+ const collectionScope = scopes[op.collection];
2745
+ if (!collectionScope) return [];
2746
+ if (Object.keys(collectionScope).length === 0) return [];
2747
+ const snapshot = buildSnapshot(op);
2748
+ return Object.keys(collectionScope).filter((field) => !snapshot || !(field in snapshot));
2749
+ }
2750
+ function buildSnapshot(op, fullRecord) {
2294
2751
  const previous = asRecord(op.previousData);
2295
2752
  const next = asRecord(op.data);
2296
- if (!previous && !next) return null;
2753
+ if (!previous && !next && !fullRecord) return null;
2297
2754
  return {
2755
+ ...fullRecord ?? {},
2298
2756
  ...previous ?? {},
2299
2757
  ...next ?? {}
2300
2758
  };
@@ -2368,35 +2826,114 @@ var SessionRateLimiter = class {
2368
2826
  // src/session/client-session.ts
2369
2827
  var DEFAULT_BATCH_SIZE = 100;
2370
2828
  var DEFAULT_SCHEMA_VERSION = 1;
2371
- var ClientSession = class {
2829
+ var warnedUnscopedProviders = /* @__PURE__ */ new WeakSet();
2830
+ function warnIfMultiTenantWithoutScopes(auth, resolvedScopes, schema) {
2831
+ if (!auth || auth instanceof NoAuthProvider) {
2832
+ return;
2833
+ }
2834
+ if (resolvedScopes) {
2835
+ return;
2836
+ }
2837
+ if (!schema || Object.keys(schema.collections).length === 0) {
2838
+ return;
2839
+ }
2840
+ if (warnedUnscopedProviders.has(auth)) {
2841
+ return;
2842
+ }
2843
+ warnedUnscopedProviders.add(auth);
2844
+ console.warn(
2845
+ "[kora] An authenticated session resolved to no sync scopes, so every user will sync every other user's data. Return per-user sync scopes from your auth provider (for example KoraAuthProvider's resolveScopes) to isolate tenants. Note: declaring sync rules in your schema is not enough on its own \u2014 the per-user values come from the auth provider. (This warning is expected for single-tenant apps where all authenticated users are meant to share the same data.)"
2846
+ );
2847
+ }
2848
+ function stableStringify(value) {
2849
+ if (Array.isArray(value)) {
2850
+ return `[${value.map((item) => stableStringify(item)).join(",")}]`;
2851
+ }
2852
+ if (value && typeof value === "object") {
2853
+ const record = value;
2854
+ const entries = Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`);
2855
+ return `{${entries.join(",")}}`;
2856
+ }
2857
+ return JSON.stringify(value);
2858
+ }
2859
+ function sameScopeMap(a, b) {
2860
+ return stableStringify(a ?? null) === stableStringify(b ?? null);
2861
+ }
2862
+ var ClientSession = class _ClientSession {
2372
2863
  state = "connected";
2373
2864
  clientNodeId = null;
2374
2865
  authContext = null;
2375
2866
  syncQuerySubsets = [];
2376
2867
  resumeDeltaCursor = null;
2868
+ /**
2869
+ * The delivery watermark the client reported at handshake, or null when the client
2870
+ * does not use the delivery watermark (old client, or no watermark yet). When set,
2871
+ * the server drives the gap-free server->client stream from delivery sequences
2872
+ * instead of the version-vector delta.
2873
+ */
2874
+ clientDeliveryWatermark = null;
2875
+ /**
2876
+ * The highest delivery sequence this client has ACKNOWLEDGED (its confirmed
2877
+ * watermark, as reported in acks). Incremental streaming pushes resume from here, not
2878
+ * from the highest sequence sent, so a dropped or unapplied batch is always
2879
+ * re-included by the next push (which re-scans from the acknowledged position). This
2880
+ * is what makes streaming recovery independent of any bounded retransmit buffer: there
2881
+ * is no sent-but-unacked window that a buffer eviction could strand. Seeded from the
2882
+ * client's reported watermark at handshake.
2883
+ */
2884
+ lastAckedDeliverySeq = 0;
2885
+ /**
2886
+ * Timestamp of the last delivery-stream send attempt. Periodic retransmission uses
2887
+ * this as its stale window so a slow client does not receive duplicate full-stream
2888
+ * batches every server tick while the first batch is still being applied.
2889
+ */
2890
+ lastDeliveryPushAttemptAtMs = 0;
2891
+ deliveryStallRepeatCount = 0;
2892
+ /** Serializes incremental delivery pushes so their batches never interleave. */
2893
+ deliveryPushChain = Promise.resolve();
2894
+ /**
2895
+ * Relay batches sent to this client but not yet acknowledged, keyed by messageId.
2896
+ * The client acks every operation-batch it applies; on ack the entry is cleared.
2897
+ * {@link retransmitPendingRelays} re-sends anything still unacked, so a relay
2898
+ * dropped by a lossy transport is redelivered instead of leaving the client with a
2899
+ * permanent version-vector gap (a lost operation) that delta sync cannot recover.
2900
+ * Bounded so a silent client cannot grow it without limit.
2901
+ */
2902
+ pendingRelays = /* @__PURE__ */ new Map();
2903
+ static MAX_PENDING_RELAYS = 1e3;
2377
2904
  sessionId;
2378
2905
  transport;
2379
2906
  store;
2380
2907
  auth;
2381
2908
  serializer;
2382
2909
  emitter;
2910
+ logger;
2383
2911
  batchSize;
2384
2912
  schemaVersion;
2385
2913
  supportedSchemaVersions;
2914
+ operationTransforms;
2386
2915
  onRelay;
2387
2916
  onAwarenessUpdate;
2388
2917
  onYjsDocUpdate;
2918
+ onBlobChunkRequest;
2919
+ onBlobChunkResponse;
2920
+ persistBlobChunk;
2389
2921
  onClose;
2922
+ onOrphanedRelays;
2923
+ takeOrphanedRelays;
2390
2924
  maxOperationBytes;
2391
2925
  maxOpsPerMinute;
2392
2926
  rateLimiter;
2927
+ validateOperation;
2928
+ koraContext;
2393
2929
  constructor(options) {
2394
2930
  this.sessionId = options.sessionId;
2395
2931
  this.transport = options.transport;
2396
2932
  this.store = options.store;
2397
2933
  this.auth = options.auth ?? null;
2398
- this.serializer = options.serializer ?? new import_sync2.NegotiatedMessageSerializer("json");
2934
+ this.serializer = options.serializer ?? new import_sync3.NegotiatedMessageSerializer("json");
2399
2935
  this.emitter = options.emitter ?? null;
2936
+ this.logger = options.logger ?? null;
2400
2937
  this.batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
2401
2938
  this.schemaVersion = options.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
2402
2939
  const supported = options.supportedSchemaVersions;
@@ -2404,13 +2941,21 @@ var ClientSession = class {
2404
2941
  min: this.schemaVersion,
2405
2942
  max: this.schemaVersion
2406
2943
  };
2944
+ this.operationTransforms = options.operationTransforms ?? [];
2407
2945
  this.onRelay = options.onRelay ?? null;
2408
2946
  this.onAwarenessUpdate = options.onAwarenessUpdate ?? null;
2409
2947
  this.onYjsDocUpdate = options.onYjsDocUpdate ?? null;
2948
+ this.onBlobChunkRequest = options.onBlobChunkRequest ?? null;
2949
+ this.onBlobChunkResponse = options.onBlobChunkResponse ?? null;
2950
+ this.persistBlobChunk = options.persistBlobChunk ?? null;
2410
2951
  this.onClose = options.onClose ?? null;
2952
+ this.onOrphanedRelays = options.onOrphanedRelays ?? null;
2953
+ this.takeOrphanedRelays = options.takeOrphanedRelays ?? null;
2411
2954
  this.maxOperationBytes = options.maxOperationBytes ?? DEFAULT_MAX_OPERATION_BYTES;
2412
2955
  this.maxOpsPerMinute = options.maxOpsPerMinute ?? DEFAULT_MAX_OPS_PER_MINUTE;
2413
2956
  this.rateLimiter = new SessionRateLimiter(this.maxOpsPerMinute);
2957
+ this.validateOperation = options.validateOperation ?? null;
2958
+ this.koraContext = options.koraContext ?? null;
2414
2959
  }
2415
2960
  /**
2416
2961
  * Start handling messages from the client transport.
@@ -2430,39 +2975,143 @@ var ClientSession = class {
2430
2975
  */
2431
2976
  relayOperations(operations2) {
2432
2977
  if (this.state !== "streaming" || !this.transport.isConnected()) return;
2978
+ if (this.clientDeliveryWatermark !== null) {
2979
+ this.pushDeliveryStream();
2980
+ return;
2981
+ }
2433
2982
  if (operations2.length === 0) return;
2434
- const visibleOperations = operations2.filter((op) => this.operationVisibleToClient(op));
2983
+ void this.relayVisibleOperations(operations2);
2984
+ }
2985
+ /**
2986
+ * Push newly-available in-scope operations to a delivery-watermark client, resuming
2987
+ * from the highest delivery sequence already sent. Pushes are serialized so their
2988
+ * batches never interleave (which would break the base/max chain).
2989
+ */
2990
+ pushDeliveryStream() {
2991
+ this.deliveryPushChain = this.deliveryPushChain.then(async () => {
2992
+ if (this.state !== "streaming" || !this.transport.isConnected()) return;
2993
+ try {
2994
+ this.lastDeliveryPushAttemptAtMs = Date.now();
2995
+ await this.sendDeliveryStream(
2996
+ this.lastAckedDeliverySeq,
2997
+ false,
2998
+ this.clientNodeId ?? void 0
2999
+ );
3000
+ } catch {
3001
+ }
3002
+ });
3003
+ }
3004
+ async relayVisibleOperations(operations2) {
3005
+ const visibleOperations = [];
3006
+ for (const op of operations2) {
3007
+ if (await this.operationVisibleToClient(op)) {
3008
+ visibleOperations.push(op);
3009
+ }
3010
+ }
2435
3011
  if (visibleOperations.length === 0) return;
3012
+ if (this.state !== "streaming" || !this.transport.isConnected()) return;
2436
3013
  const serializedOps = visibleOperations.map((op) => this.serializer.encodeOperation(op));
2437
3014
  const msg = {
2438
3015
  type: "operation-batch",
2439
- messageId: (0, import_core10.generateUUIDv7)(),
3016
+ messageId: (0, import_core11.generateUUIDv7)(),
2440
3017
  operations: serializedOps,
2441
3018
  isFinal: true,
2442
3019
  batchIndex: 0
2443
3020
  };
3021
+ this.trackPendingRelay(msg, visibleOperations);
2444
3022
  this.sendToClient(msg);
2445
3023
  }
3024
+ /** Record a relay batch as awaiting acknowledgment, evicting the oldest if full. */
3025
+ trackPendingRelay(msg, ops) {
3026
+ if (this.pendingRelays.size >= _ClientSession.MAX_PENDING_RELAYS) {
3027
+ const oldest = this.pendingRelays.keys().next().value;
3028
+ if (oldest !== void 0) {
3029
+ this.pendingRelays.delete(oldest);
3030
+ }
3031
+ }
3032
+ this.pendingRelays.set(msg.messageId, { message: msg, ops, sentAtMs: Date.now() });
3033
+ }
2446
3034
  /**
2447
- * Close this session.
3035
+ * Retransmit relay batches this client has not acknowledged within `staleMs`.
3036
+ * Called on a periodic tick by the server (and directly by tests). Redelivering an
3037
+ * already-applied op is harmless: the client dedups by content-addressed id.
2448
3038
  */
2449
- close(reason) {
2450
- if (this.state === "closed") return;
2451
- this.state = "closed";
2452
- if (this.transport.isConnected()) {
2453
- this.transport.close(1e3, reason ?? "session closed");
3039
+ retransmitPendingRelays(staleMs = 0) {
3040
+ if (this.state !== "streaming" || !this.transport.isConnected()) return;
3041
+ if (this.clientDeliveryWatermark !== null) {
3042
+ return;
3043
+ }
3044
+ if (this.pendingRelays.size === 0) return;
3045
+ const cutoff = Date.now() - staleMs;
3046
+ for (const { message, sentAtMs } of this.pendingRelays.values()) {
3047
+ if (sentAtMs <= cutoff) {
3048
+ this.sendToClient(message);
3049
+ }
2454
3050
  }
2455
- this.onClose?.(this.sessionId);
2456
- }
2457
- // --- Getters ---
2458
- getState() {
2459
- return this.state;
2460
- }
2461
- getSessionId() {
2462
- return this.sessionId;
2463
3051
  }
2464
- getClientNodeId() {
2465
- return this.clientNodeId;
3052
+ /**
3053
+ * Wake the durable delivery stream for clients that negotiated delivery
3054
+ * watermarks. This is used both for dropped watermark batches and for operations
3055
+ * appended by another server/store instance: the session always scans from its
3056
+ * own acknowledged cursor and applies its visibility filter before sending.
3057
+ */
3058
+ pushDeliveryStreamIfSupported(staleMs = 0, options = {}) {
3059
+ if (this.state !== "streaming" || !this.transport.isConnected()) return;
3060
+ if (this.clientDeliveryWatermark === null) return;
3061
+ if (staleMs > 0 && Date.now() - this.lastDeliveryPushAttemptAtMs < staleMs) {
3062
+ return;
3063
+ }
3064
+ if (options.trackStall) {
3065
+ this.deliveryStallRepeatCount += 1;
3066
+ if (this.deliveryStallRepeatCount >= 3) {
3067
+ this.emitter?.emit({
3068
+ type: "sync:delivery-stalled",
3069
+ sessionId: this.sessionId,
3070
+ watermark: this.lastAckedDeliverySeq,
3071
+ repeatCount: this.deliveryStallRepeatCount
3072
+ });
3073
+ }
3074
+ }
3075
+ this.pushDeliveryStream();
3076
+ }
3077
+ /**
3078
+ * Close this session.
3079
+ */
3080
+ /**
3081
+ * Hand any unacknowledged relays to the server so they survive this session and are
3082
+ * redelivered when this client reconnects (per-node buffer), then clear them. Called
3083
+ * from every session-teardown path (explicit close and transport close).
3084
+ */
3085
+ flushOrphanedRelays() {
3086
+ if (this.onOrphanedRelays && this.clientNodeId && this.pendingRelays.size > 0) {
3087
+ const ops = [];
3088
+ for (const entry of this.pendingRelays.values()) {
3089
+ ops.push(...entry.ops);
3090
+ }
3091
+ if (ops.length > 0) {
3092
+ this.onOrphanedRelays(this.clientNodeId, ops);
3093
+ }
3094
+ }
3095
+ this.pendingRelays.clear();
3096
+ }
3097
+ close(reason) {
3098
+ if (this.state === "closed") return;
3099
+ this.state = "closed";
3100
+ this.flushOrphanedRelays();
3101
+ if (this.transport.isConnected()) {
3102
+ this.transport.close(1e3, reason ?? "session closed");
3103
+ }
3104
+ this.onClose?.(this.sessionId);
3105
+ }
3106
+ // --- Getters ---
3107
+ getState() {
3108
+ return this.state;
3109
+ }
3110
+ getSessionId() {
3111
+ return this.sessionId;
3112
+ }
3113
+ getClientNodeId() {
3114
+ return this.clientNodeId;
2466
3115
  }
2467
3116
  getAuthContext() {
2468
3117
  return this.authContext;
@@ -2503,6 +3152,11 @@ var ClientSession = class {
2503
3152
  await this.handleOperationBatch(message);
2504
3153
  break;
2505
3154
  case "acknowledgment":
3155
+ this.pendingRelays.delete(message.acknowledgedMessageId);
3156
+ if (message.deliverySequence !== void 0) {
3157
+ this.lastAckedDeliverySeq = Math.max(this.lastAckedDeliverySeq, message.deliverySequence);
3158
+ this.deliveryStallRepeatCount = 0;
3159
+ }
2506
3160
  break;
2507
3161
  case "error":
2508
3162
  break;
@@ -2512,10 +3166,47 @@ var ClientSession = class {
2512
3166
  case "yjs-doc-update":
2513
3167
  this.handleYjsDocUpdate(message);
2514
3168
  break;
3169
+ case "blob-chunk-request":
3170
+ this.onBlobChunkRequest?.(this.sessionId, message);
3171
+ break;
3172
+ case "blob-chunk-response":
3173
+ this.onBlobChunkResponse?.(this.sessionId, message);
3174
+ break;
3175
+ case "blob-chunk-push":
3176
+ await this.handleBlobChunkPush(message);
3177
+ break;
2515
3178
  }
2516
3179
  }
3180
+ /**
3181
+ * Persist a client-uploaded blob chunk (or manifest) centrally. The bytes are
3182
+ * verified to hash to the declared hash before storing, so a corrupt or
3183
+ * mislabeled upload is rejected rather than served later as trusted content.
3184
+ */
3185
+ async handleBlobChunkPush(message) {
3186
+ if (!this.persistBlobChunk) {
3187
+ return;
3188
+ }
3189
+ const bytes = (0, import_sync2.decodeBlobChunkBytes)(message.bytes);
3190
+ const actual = await (0, import_core11.hashBlob)(bytes);
3191
+ if (actual !== message.hash) {
3192
+ return;
3193
+ }
3194
+ await this.persistBlobChunk(message.hash, bytes);
3195
+ }
2517
3196
  handleMessageFailure(error) {
2518
3197
  const reason = error instanceof Error ? error.message : "Message handling failed";
3198
+ this.logger?.log({
3199
+ timestamp: Date.now(),
3200
+ level: "error",
3201
+ event: "session.message_failed",
3202
+ sessionId: this.sessionId,
3203
+ nodeId: this.clientNodeId ?? void 0,
3204
+ error: reason,
3205
+ details: {
3206
+ state: this.state,
3207
+ errorName: error instanceof Error ? error.name : typeof error
3208
+ }
3209
+ });
2519
3210
  this.sendError("SYNC_ERROR", reason, true);
2520
3211
  this.close(reason);
2521
3212
  }
@@ -2547,25 +3238,46 @@ var ClientSession = class {
2547
3238
  this.authContext = { userId: msg.nodeId, scopes: resolvedScopes };
2548
3239
  }
2549
3240
  }
3241
+ warnIfMultiTenantWithoutScopes(this.auth, resolvedScopes, this.store.getSchema());
2550
3242
  if (msg.syncQueries && msg.syncQueries.length > 0) {
2551
- this.syncQuerySubsets = (0, import_sync2.dedupeQuerySubsets)(msg.syncQueries);
3243
+ this.syncQuerySubsets = (0, import_sync3.dedupeQuerySubsets)(msg.syncQueries);
2552
3244
  } else {
2553
3245
  this.syncQuerySubsets = [];
2554
3246
  }
2555
- this.resumeDeltaCursor = msg.deltaCursor ? (0, import_sync2.decodeDeltaCursor)(msg.deltaCursor) : null;
3247
+ this.resumeDeltaCursor = msg.deltaCursor ? (0, import_sync3.decodeDeltaCursor)(msg.deltaCursor) : null;
3248
+ this.clientDeliveryWatermark = msg.lastDeliverySequence ?? null;
3249
+ if (this.clientDeliveryWatermark !== null && !sameScopeMap(msg.syncScope, this.authContext?.scopes)) {
3250
+ this.clientDeliveryWatermark = 0;
3251
+ }
3252
+ const clientUsesWatermark = this.clientDeliveryWatermark !== null;
3253
+ let serverMaxDelivery = 0;
3254
+ if (clientUsesWatermark) {
3255
+ serverMaxDelivery = await this.store.getMaxDeliverySequence();
3256
+ if (this.clientDeliveryWatermark !== null && this.clientDeliveryWatermark > serverMaxDelivery) {
3257
+ this.clientDeliveryWatermark = 0;
3258
+ }
3259
+ }
2556
3260
  const serverVector = this.store.getVersionVector();
2557
3261
  const selectedWireFormat = selectWireFormat(msg.supportedWireFormats);
2558
3262
  this.setSerializerWireFormat(selectedWireFormat);
2559
- if (!(0, import_sync2.isClientSchemaVersionSupported)(msg.schemaVersion, this.supportedSchemaVersions)) {
3263
+ if (!(0, import_sync3.isClientSchemaVersionSupported)(msg.schemaVersion, this.supportedSchemaVersions)) {
2560
3264
  const { min, max } = this.supportedSchemaVersions;
3265
+ this.emitter?.emit({
3266
+ type: "sync:schema-mismatch",
3267
+ clientSchemaVersion: msg.schemaVersion,
3268
+ serverSchemaVersion: this.schemaVersion,
3269
+ supportedMin: min,
3270
+ supportedMax: max,
3271
+ reason: `${import_sync3.SCHEMA_MISMATCH_PREFIX}: client schema version ${msg.schemaVersion} not in supported range [${min}, ${max}]`
3272
+ });
2561
3273
  const response2 = {
2562
3274
  type: "handshake-response",
2563
- messageId: (0, import_core10.generateUUIDv7)(),
3275
+ messageId: (0, import_core11.generateUUIDv7)(),
2564
3276
  nodeId: this.store.getNodeId(),
2565
- versionVector: (0, import_sync2.versionVectorToWire)(serverVector),
3277
+ versionVector: (0, import_sync3.versionVectorToWire)(serverVector),
2566
3278
  schemaVersion: this.schemaVersion,
2567
3279
  accepted: false,
2568
- rejectReason: `${import_sync2.SCHEMA_MISMATCH_PREFIX}: client schema version ${msg.schemaVersion} not in supported range [${min}, ${max}]`,
3280
+ rejectReason: `${import_sync3.SCHEMA_MISMATCH_PREFIX}: client schema version ${msg.schemaVersion} not in supported range [${min}, ${max}]`,
2569
3281
  supportedSchemaMin: min,
2570
3282
  supportedSchemaMax: max,
2571
3283
  serverTime: Date.now()
@@ -2576,13 +3288,20 @@ var ClientSession = class {
2576
3288
  }
2577
3289
  const response = {
2578
3290
  type: "handshake-response",
2579
- messageId: (0, import_core10.generateUUIDv7)(),
3291
+ messageId: (0, import_core11.generateUUIDv7)(),
2580
3292
  nodeId: this.store.getNodeId(),
2581
- versionVector: (0, import_sync2.versionVectorToWire)(serverVector),
3293
+ versionVector: (0, import_sync3.versionVectorToWire)(serverVector),
2582
3294
  schemaVersion: this.schemaVersion,
2583
3295
  accepted: true,
2584
3296
  selectedWireFormat,
2585
3297
  serverTime: Date.now(),
3298
+ // The server's highest delivery sequence, so a client whose persisted watermark
3299
+ // is ahead of it (server rolled back) can reset to a full resync. Sent only to
3300
+ // watermark-using clients.
3301
+ ...clientUsesWatermark ? { serverMaxDeliverySequence: serverMaxDelivery } : {},
3302
+ // Advertise central blob storage so the client uploads the bytes behind
3303
+ // its blob fields, keeping them available after the author goes offline.
3304
+ ...this.persistBlobChunk ? { blobStorageEnabled: true } : {},
2586
3305
  // Confirm the accepted scope so the client knows what data will be synced.
2587
3306
  // This may differ from what the client requested if auth scopes are narrower.
2588
3307
  ...this.authContext?.scopes ? { acceptedScope: this.authContext.scopes } : {}
@@ -2590,17 +3309,40 @@ var ClientSession = class {
2590
3309
  this.sendToClient(response);
2591
3310
  this.emitter?.emit({ type: "sync:connected", nodeId: msg.nodeId });
2592
3311
  this.state = "syncing";
2593
- const clientVector = (0, import_sync2.wireToVersionVector)(msg.versionVector);
2594
- await this.sendDelta(clientVector);
3312
+ if (this.clientDeliveryWatermark !== null) {
3313
+ this.lastAckedDeliverySeq = this.clientDeliveryWatermark;
3314
+ const excludeOwn = this.clientDeliveryWatermark > 0 ? this.clientNodeId ?? void 0 : void 0;
3315
+ this.lastDeliveryPushAttemptAtMs = Date.now();
3316
+ await this.sendDeliveryStream(this.clientDeliveryWatermark, true, excludeOwn);
3317
+ } else {
3318
+ const clientVector = (0, import_sync3.wireToVersionVector)(msg.versionVector);
3319
+ await this.sendDelta(clientVector);
3320
+ }
2595
3321
  this.state = "streaming";
3322
+ if (this.takeOrphanedRelays && this.clientNodeId) {
3323
+ const buffered = this.takeOrphanedRelays(this.clientNodeId);
3324
+ if (buffered.length > 0) {
3325
+ this.relayOperations(buffered);
3326
+ }
3327
+ }
2596
3328
  }
2597
3329
  async handleOperationBatch(msg) {
2598
3330
  const operations2 = msg.operations.map((s) => this.serializer.decodeOperation(s));
2599
3331
  const applied = [];
2600
- const rejected = [];
3332
+ let acknowledgedThrough = 0;
3333
+ let canAdvanceAck = true;
2601
3334
  for (const op of operations2) {
2602
- if (!this.operationVisibleToClient(op)) {
2603
- rejected.push(op);
3335
+ if (!canAdvanceAck) {
3336
+ continue;
3337
+ }
3338
+ if (!await this.operationVisibleToClient(op)) {
3339
+ this.sendOperationRejected(
3340
+ op,
3341
+ "SCOPE_VIOLATION",
3342
+ `Operation "${op.id}" in collection "${op.collection}" is outside the client's sync scope. Refresh sync scopes and retry.`,
3343
+ true
3344
+ );
3345
+ canAdvanceAck = false;
2604
3346
  continue;
2605
3347
  }
2606
3348
  if (!isOperationTimestampValid(op)) {
@@ -2609,6 +3351,7 @@ var ClientSession = class {
2609
3351
  `Operation "${op.id}" timestamp is too far in the future`,
2610
3352
  false
2611
3353
  );
3354
+ canAdvanceAck = false;
2612
3355
  continue;
2613
3356
  }
2614
3357
  if (!this.rateLimiter.allow(1)) {
@@ -2617,6 +3360,7 @@ var ClientSession = class {
2617
3360
  `Session exceeded operation rate limit (${String(this.maxOpsPerMinute)} ops/min)`,
2618
3361
  true
2619
3362
  );
3363
+ canAdvanceAck = false;
2620
3364
  continue;
2621
3365
  }
2622
3366
  const sizeCheck = validateOperationSize(op, this.maxOperationBytes);
@@ -2626,27 +3370,73 @@ var ClientSession = class {
2626
3370
  sizeCheck.message ?? `Operation "${op.id}" is too large`,
2627
3371
  false
2628
3372
  );
3373
+ canAdvanceAck = false;
2629
3374
  continue;
2630
3375
  }
2631
- try {
2632
- const applyResult = await applyServerOperation(this.store, op);
2633
- if (applyResult.rejection) {
2634
- this.sendError(applyResult.rejection.code, applyResult.rejection.message, false);
3376
+ const serverOp = this.transformForServerSchema(op);
3377
+ if (serverOp === null) {
3378
+ this.sendOperationRejected(
3379
+ op,
3380
+ "SCHEMA_TRANSFORM_UNAVAILABLE",
3381
+ `Operation "${op.id}" cannot be transformed from schema v${op.schemaVersion} to server schema v${this.schemaVersion}.`,
3382
+ false
3383
+ );
3384
+ acknowledgedThrough = op.sequenceNumber;
3385
+ continue;
3386
+ }
3387
+ if (this.validateOperation && this.koraContext) {
3388
+ let decision;
3389
+ try {
3390
+ decision = await this.validateOperation(serverOp, {
3391
+ auth: this.authContext,
3392
+ kora: this.koraContext
3393
+ });
3394
+ } catch (error) {
3395
+ const message = error instanceof Error ? error.message : "validator error";
3396
+ this.sendOperationRejected(
3397
+ serverOp,
3398
+ "VALIDATION_ERROR",
3399
+ `Validator threw: ${message}`,
3400
+ true
3401
+ );
3402
+ canAdvanceAck = false;
2635
3403
  continue;
2636
3404
  }
2637
- if (applyResult.result === "applied") {
2638
- applied.push(...applyResult.appliedOperations);
3405
+ if (decision.action === "reject") {
3406
+ const retriable = decision.retriable ?? isRetriableRejection(decision.code);
3407
+ this.sendOperationRejected(serverOp, decision.code, decision.message, retriable);
3408
+ if (retriable) {
3409
+ canAdvanceAck = false;
3410
+ } else {
3411
+ acknowledgedThrough = op.sequenceNumber;
3412
+ }
3413
+ continue;
3414
+ }
3415
+ if (decision.action === "ignore") {
3416
+ acknowledgedThrough = op.sequenceNumber;
3417
+ continue;
2639
3418
  }
2640
- } catch {
2641
3419
  }
2642
- }
2643
- if (rejected.length > 0) {
2644
- for (const op of rejected) {
2645
- this.sendError(
2646
- "SCOPE_VIOLATION",
2647
- `Operation "${op.id}" in collection "${op.collection}" is outside the client's sync scope`,
2648
- false
3420
+ const applyResult = await applyServerOperation(this.store, serverOp);
3421
+ if (applyResult.rejection) {
3422
+ this.sendOperationRejected(
3423
+ serverOp,
3424
+ applyResult.rejection.code,
3425
+ applyResult.rejection.message,
3426
+ applyResult.rejection.retriable
2649
3427
  );
3428
+ if (applyResult.rejection.retriable) {
3429
+ canAdvanceAck = false;
3430
+ } else {
3431
+ acknowledgedThrough = op.sequenceNumber;
3432
+ }
3433
+ continue;
3434
+ }
3435
+ if (applyResult.result === "applied") {
3436
+ applied.push(...applyResult.appliedOperations);
3437
+ acknowledgedThrough = op.sequenceNumber;
3438
+ } else {
3439
+ acknowledgedThrough = op.sequenceNumber;
2650
3440
  }
2651
3441
  }
2652
3442
  if (operations2.length > 0) {
@@ -2659,15 +3449,21 @@ var ClientSession = class {
2659
3449
  const lastOp = operations2[operations2.length - 1];
2660
3450
  const ack = {
2661
3451
  type: "acknowledgment",
2662
- messageId: (0, import_core10.generateUUIDv7)(),
3452
+ messageId: (0, import_core11.generateUUIDv7)(),
2663
3453
  acknowledgedMessageId: msg.messageId,
2664
- lastSequenceNumber: lastOp ? lastOp.sequenceNumber : 0
3454
+ lastSequenceNumber: lastOp ? Math.min(acknowledgedThrough, lastOp.sequenceNumber) : 0
2665
3455
  };
2666
3456
  this.sendToClient(ack);
2667
3457
  if (applied.length > 0) {
2668
3458
  this.onRelay?.(this.sessionId, applied);
2669
3459
  }
2670
3460
  }
3461
+ transformForServerSchema(op) {
3462
+ if (op.schemaVersion === this.schemaVersion) {
3463
+ return op;
3464
+ }
3465
+ return (0, import_core10.applyOperationTransforms)(op, this.schemaVersion, this.operationTransforms);
3466
+ }
2671
3467
  async sendDelta(clientVector) {
2672
3468
  const serverVector = this.store.getVersionVector();
2673
3469
  const missing = [];
@@ -2675,14 +3471,17 @@ var ClientSession = class {
2675
3471
  const clientSeq = clientVector.get(nodeId) ?? 0;
2676
3472
  if (serverSeq > clientSeq) {
2677
3473
  const ops = await this.store.getOperationRange(nodeId, clientSeq + 1, serverSeq);
2678
- const visible = ops.filter((op) => this.operationVisibleToClient(op));
2679
- missing.push(...visible);
3474
+ for (const op of ops) {
3475
+ if (await this.operationVisibleToClient(op)) {
3476
+ missing.push(op);
3477
+ }
3478
+ }
2680
3479
  }
2681
3480
  }
2682
3481
  if (missing.length === 0) {
2683
3482
  const emptyBatch = {
2684
3483
  type: "operation-batch",
2685
- messageId: (0, import_core10.generateUUIDv7)(),
3484
+ messageId: (0, import_core11.generateUUIDv7)(),
2686
3485
  operations: [],
2687
3486
  isFinal: true,
2688
3487
  batchIndex: 0,
@@ -2692,12 +3491,12 @@ var ClientSession = class {
2692
3491
  return;
2693
3492
  }
2694
3493
  const sorted = (0, import_internal.topologicalSort)(missing);
2695
- const afterCursor = (0, import_sync2.sliceOperationsAfterCursor)(sorted, this.resumeDeltaCursor);
3494
+ const afterCursor = (0, import_sync3.sliceOperationsAfterCursor)(sorted, this.resumeDeltaCursor);
2696
3495
  const totalBatches = Math.ceil(afterCursor.length / this.batchSize);
2697
3496
  if (afterCursor.length === 0) {
2698
3497
  const emptyBatch = {
2699
3498
  type: "operation-batch",
2700
- messageId: (0, import_core10.generateUUIDv7)(),
3499
+ messageId: (0, import_core11.generateUUIDv7)(),
2701
3500
  operations: [],
2702
3501
  isFinal: true,
2703
3502
  batchIndex: this.resumeDeltaCursor?.batchIndex ?? 0,
@@ -2710,15 +3509,15 @@ var ClientSession = class {
2710
3509
  const start = i * this.batchSize;
2711
3510
  const batchOps = afterCursor.slice(start, start + this.batchSize);
2712
3511
  const serializedOps = batchOps.map((op) => this.serializer.encodeOperation(op));
2713
- const batchCursor = (0, import_sync2.createDeltaCursorFromBatch)(batchOps, i);
3512
+ const batchCursor = (0, import_sync3.createDeltaCursorFromBatch)(batchOps, i);
2714
3513
  const batchMsg = {
2715
3514
  type: "operation-batch",
2716
- messageId: (0, import_core10.generateUUIDv7)(),
3515
+ messageId: (0, import_core11.generateUUIDv7)(),
2717
3516
  operations: serializedOps,
2718
3517
  isFinal: i === totalBatches - 1,
2719
3518
  batchIndex: i,
2720
3519
  totalBatches,
2721
- ...batchCursor ? { cursor: (0, import_sync2.encodeDeltaCursor)(batchCursor) } : {}
3520
+ ...batchCursor ? { cursor: (0, import_sync3.encodeDeltaCursor)(batchCursor) } : {}
2722
3521
  };
2723
3522
  this.sendToClient(batchMsg);
2724
3523
  this.emitter?.emit({
@@ -2728,11 +3527,111 @@ var ClientSession = class {
2728
3527
  });
2729
3528
  }
2730
3529
  }
2731
- operationVisibleToClient(op) {
2732
- if (!operationMatchesScopes(op, this.authContext?.scopes)) {
3530
+ /**
3531
+ * Send the gap-free server->client delivery stream, resuming just after
3532
+ * `fromDeliverySeq`. Operations are scanned in server delivery-sequence order
3533
+ * (commit order), scope-filtered for this session, and sent in batches that chain:
3534
+ * each batch's `baseDeliverySequence` equals the previous batch's
3535
+ * `maxDeliverySequence` (the first batch bases on `fromDeliverySeq`). The client
3536
+ * applies a batch only when its watermark equals the base and advances the
3537
+ * watermark to the max, so a dropped batch stalls the watermark and is recovered by
3538
+ * the next handshake resend. Because delivery-sequence order respects causal order
3539
+ * (a dependency is always committed, and thus sequenced, before its dependent), no
3540
+ * topological sort is needed and the client never defers on a missing dependency
3541
+ * that is itself in this stream.
3542
+ *
3543
+ * The final batch advances the watermark to the highest delivery sequence scanned,
3544
+ * not merely the last in-scope one, so an out-of-scope tail is not re-scanned on the
3545
+ * next reconnect. Returns the number of operations actually sent.
3546
+ */
3547
+ async sendDeliveryStream(fromDeliverySeq, finalizeWhenEmpty, excludeNodeId) {
3548
+ const scanChunk = Math.max(this.batchSize, 1) * 5;
3549
+ let scanCursor = fromDeliverySeq;
3550
+ let maxScanned = fromDeliverySeq;
3551
+ const visible = [];
3552
+ while (true) {
3553
+ const chunk = await this.store.getOperationsAfterDelivery(scanCursor, scanChunk);
3554
+ const last = chunk[chunk.length - 1];
3555
+ if (last === void 0) break;
3556
+ scanCursor = last.deliverySequence;
3557
+ maxScanned = scanCursor;
3558
+ for (const delivered of chunk) {
3559
+ if (excludeNodeId !== void 0 && delivered.operation.nodeId === excludeNodeId) {
3560
+ continue;
3561
+ }
3562
+ if (await this.operationVisibleToClient(delivered.operation)) {
3563
+ visible.push(delivered);
3564
+ }
3565
+ }
3566
+ if (chunk.length < scanChunk) break;
3567
+ }
3568
+ if (visible.length === 0) {
3569
+ if (finalizeWhenEmpty) {
3570
+ this.sendDeliveryBatch([], fromDeliverySeq, maxScanned, 0, true);
3571
+ }
3572
+ return 0;
3573
+ }
3574
+ const totalBatches = Math.ceil(visible.length / this.batchSize);
3575
+ let base = fromDeliverySeq;
3576
+ for (let i = 0; i < totalBatches; i++) {
3577
+ const slice = visible.slice(i * this.batchSize, (i + 1) * this.batchSize);
3578
+ const lastInSlice = slice[slice.length - 1];
3579
+ if (lastInSlice === void 0) continue;
3580
+ const isFinal = i === totalBatches - 1;
3581
+ const lastSeq = lastInSlice.deliverySequence;
3582
+ const max = isFinal ? Math.max(maxScanned, lastSeq) : lastSeq;
3583
+ this.sendDeliveryBatch(slice, base, max, i, isFinal);
3584
+ base = max;
3585
+ }
3586
+ return visible.length;
3587
+ }
3588
+ /** Build, track, and send one chained delivery-stream batch. */
3589
+ sendDeliveryBatch(slice, base, max, batchIndex, isFinal) {
3590
+ const batchMsg = {
3591
+ type: "operation-batch",
3592
+ messageId: (0, import_core11.generateUUIDv7)(),
3593
+ operations: slice.map((delivered) => this.serializer.encodeOperation(delivered.operation)),
3594
+ isFinal,
3595
+ batchIndex,
3596
+ baseDeliverySequence: base,
3597
+ maxDeliverySequence: max
3598
+ };
3599
+ this.sendToClient(batchMsg);
3600
+ if (slice.length > 0) {
3601
+ this.emitter?.emit({
3602
+ type: "sync:sent",
3603
+ operations: slice.map((delivered) => delivered.operation),
3604
+ batchSize: slice.length
3605
+ });
3606
+ }
3607
+ }
3608
+ async operationVisibleToClient(op) {
3609
+ const scopes = this.authContext?.scopes;
3610
+ const subsets = this.syncQuerySubsets;
3611
+ const needsBackfill = missingScopeFields(op, scopes).length > 0 || subsets !== void 0 && subsets.length > 0;
3612
+ const fullRecord = needsBackfill ? await this.lookupRecordFields(op.collection, op.recordId) : void 0;
3613
+ if (!operationMatchesScopes(op, scopes, fullRecord)) {
2733
3614
  return false;
2734
3615
  }
2735
- return (0, import_sync2.operationMatchesQuerySubsets)(op, this.syncQuerySubsets);
3616
+ return (0, import_sync3.operationMatchesQuerySubsets)(op, subsets, fullRecord);
3617
+ }
3618
+ /**
3619
+ * Read a record's current field values from the materialized store for scope /
3620
+ * query-subset backfill. Includes soft-deleted rows so a relayed delete (whose op
3621
+ * carries no fields) is still judged against the record's actual scope. Returns
3622
+ * undefined when the record cannot be read (never materialized, or no schema).
3623
+ */
3624
+ async lookupRecordFields(collection, recordId) {
3625
+ try {
3626
+ const rows = await this.store.queryCollection(collection, {
3627
+ where: { id: recordId },
3628
+ includeDeleted: true,
3629
+ limit: 1
3630
+ });
3631
+ return rows[0];
3632
+ } catch {
3633
+ return void 0;
3634
+ }
2736
3635
  }
2737
3636
  handleAwarenessUpdate(msg) {
2738
3637
  this.onAwarenessUpdate?.(this.sessionId, msg);
@@ -2743,13 +3642,32 @@ var ClientSession = class {
2743
3642
  sendError(code, message, retriable) {
2744
3643
  const errorMsg = {
2745
3644
  type: "error",
2746
- messageId: (0, import_core10.generateUUIDv7)(),
3645
+ messageId: (0, import_core11.generateUUIDv7)(),
2747
3646
  code,
2748
3647
  message,
2749
3648
  retriable
2750
3649
  };
2751
3650
  this.sendToClient(errorMsg);
2752
3651
  }
3652
+ /**
3653
+ * Reject one specific client operation, tied to its id, so the submitter can
3654
+ * divert it out of its pending queue into a durable rejected store rather than
3655
+ * losing it or retrying forever. The op is NOT materialized, so no other
3656
+ * replica ever sees it.
3657
+ */
3658
+ sendOperationRejected(op, code, message, retriable) {
3659
+ const rejectedMsg = {
3660
+ type: "operation-rejected",
3661
+ messageId: (0, import_core11.generateUUIDv7)(),
3662
+ operationId: op.id,
3663
+ collection: op.collection,
3664
+ recordId: op.recordId,
3665
+ code,
3666
+ message,
3667
+ retriable
3668
+ };
3669
+ this.sendToClient(rejectedMsg);
3670
+ }
2753
3671
  setSerializerWireFormat(format) {
2754
3672
  if (typeof this.serializer.setWireFormat === "function") {
2755
3673
  this.serializer.setWireFormat(format);
@@ -2758,6 +3676,7 @@ var ClientSession = class {
2758
3676
  handleTransportClose() {
2759
3677
  if (this.state === "closed") return;
2760
3678
  this.state = "closed";
3679
+ this.flushOrphanedRelays();
2761
3680
  this.emitter?.emit({ type: "sync:disconnected", reason: "transport closed" });
2762
3681
  this.onClose?.(this.sessionId);
2763
3682
  }
@@ -2771,13 +3690,13 @@ function selectWireFormat(supportedWireFormats) {
2771
3690
 
2772
3691
  // src/server/kora-sync-server.ts
2773
3692
  init_cjs_shims();
2774
- var import_core12 = require("@korajs/core");
3693
+ var import_core14 = require("@korajs/core");
2775
3694
  var import_internal2 = require("@korajs/core/internal");
2776
- var import_sync3 = require("@korajs/sync");
3695
+ var import_sync5 = require("@korajs/sync");
2777
3696
 
2778
3697
  // src/awareness/awareness-relay.ts
2779
3698
  init_cjs_shims();
2780
- var import_core11 = require("@korajs/core");
3699
+ var import_core12 = require("@korajs/core");
2781
3700
  var AwarenessRelay = class {
2782
3701
  clients = /* @__PURE__ */ new Map();
2783
3702
  /**
@@ -2806,7 +3725,7 @@ var AwarenessRelay = class {
2806
3725
  if (hasStates) {
2807
3726
  const catchUpMsg = {
2808
3727
  type: "awareness-update",
2809
- messageId: (0, import_core11.generateUUIDv7)(),
3728
+ messageId: (0, import_core12.generateUUIDv7)(),
2810
3729
  clientId: 0,
2811
3730
  // Server-sourced
2812
3731
  states: existingStates
@@ -2829,7 +3748,7 @@ var AwarenessRelay = class {
2829
3748
  };
2830
3749
  const msg = {
2831
3750
  type: "awareness-update",
2832
- messageId: (0, import_core11.generateUUIDv7)(),
3751
+ messageId: (0, import_core12.generateUUIDv7)(),
2833
3752
  clientId: client.clientId,
2834
3753
  states: removalStates
2835
3754
  };
@@ -2990,6 +3909,114 @@ var ServerMetricsCollector = class {
2990
3909
  }
2991
3910
  };
2992
3911
 
3912
+ // src/richtext/blob-chunk-relay.ts
3913
+ init_cjs_shims();
3914
+ var import_sync4 = require("@korajs/sync");
3915
+ var BlobChunkRelay = class {
3916
+ clients = /* @__PURE__ */ new Map();
3917
+ /** requestId -> the session that originated the request (to route the answer back). */
3918
+ pending = /* @__PURE__ */ new Map();
3919
+ resolveBlobChunk;
3920
+ constructor(resolveBlobChunk) {
3921
+ this.resolveBlobChunk = resolveBlobChunk ?? null;
3922
+ }
3923
+ addClient(sessionId, transport) {
3924
+ this.clients.set(sessionId, { sessionId, transport });
3925
+ }
3926
+ removeClient(sessionId) {
3927
+ this.clients.delete(sessionId);
3928
+ for (const [requestId, originSessionId] of this.pending) {
3929
+ if (originSessionId === sessionId) {
3930
+ this.pending.delete(requestId);
3931
+ }
3932
+ }
3933
+ }
3934
+ getClientCount() {
3935
+ return this.clients.size;
3936
+ }
3937
+ getPendingCount() {
3938
+ return this.pending.size;
3939
+ }
3940
+ clear() {
3941
+ this.clients.clear();
3942
+ this.pending.clear();
3943
+ }
3944
+ /**
3945
+ * Handle an inbound chunk request from a session. Tries the server's own
3946
+ * store first (if configured), otherwise forwards the request to peer
3947
+ * sessions and remembers who to route the answer back to.
3948
+ */
3949
+ handleRequest(sourceSessionId, message) {
3950
+ if (!this.clients.has(sourceSessionId)) {
3951
+ return;
3952
+ }
3953
+ if (this.resolveBlobChunk) {
3954
+ void this.resolveBlobChunk(message.hash).then(
3955
+ (bytes) => {
3956
+ if (bytes !== null) {
3957
+ this.sendResponseTo(sourceSessionId, message.requestId, (0, import_sync4.encodeBlobChunkBytes)(bytes));
3958
+ return;
3959
+ }
3960
+ this.forwardRequestToPeers(sourceSessionId, message);
3961
+ },
3962
+ () => {
3963
+ this.forwardRequestToPeers(sourceSessionId, message);
3964
+ }
3965
+ );
3966
+ return;
3967
+ }
3968
+ this.forwardRequestToPeers(sourceSessionId, message);
3969
+ }
3970
+ /**
3971
+ * Handle an inbound chunk response from a peer. Routes it back to the session
3972
+ * that originally requested it. A "not held" answer (bytes === null) is
3973
+ * ignored so a peer without the chunk does not preempt one that has it; the
3974
+ * requester's own per-request timeout bounds the wait.
3975
+ */
3976
+ handleResponse(sourceSessionId, message) {
3977
+ if (!this.clients.has(sourceSessionId)) {
3978
+ return;
3979
+ }
3980
+ if (message.bytes === null) {
3981
+ return;
3982
+ }
3983
+ const originSessionId = this.pending.get(message.requestId);
3984
+ if (!originSessionId) {
3985
+ return;
3986
+ }
3987
+ this.pending.delete(message.requestId);
3988
+ this.sendResponseTo(originSessionId, message.requestId, message.bytes);
3989
+ }
3990
+ forwardRequestToPeers(sourceSessionId, message) {
3991
+ this.pending.set(message.requestId, sourceSessionId);
3992
+ for (const [, client] of this.clients) {
3993
+ if (client.sessionId === sourceSessionId) {
3994
+ continue;
3995
+ }
3996
+ if (!client.transport.isConnected()) {
3997
+ continue;
3998
+ }
3999
+ client.transport.send(message);
4000
+ }
4001
+ }
4002
+ sendResponseTo(sessionId, requestId, bytes) {
4003
+ const client = this.clients.get(sessionId);
4004
+ if (!client || !client.transport.isConnected()) {
4005
+ return;
4006
+ }
4007
+ const response = {
4008
+ type: "blob-chunk-response",
4009
+ messageId: generateResponseId(requestId),
4010
+ requestId,
4011
+ bytes
4012
+ };
4013
+ client.transport.send(response);
4014
+ }
4015
+ };
4016
+ function generateResponseId(requestId) {
4017
+ return `blob-resp-${requestId}`;
4018
+ }
4019
+
2993
4020
  // src/richtext/yjs-doc-relay.ts
2994
4021
  init_cjs_shims();
2995
4022
  var YjsDocRelay = class {
@@ -3025,21 +4052,416 @@ var YjsDocRelay = class {
3025
4052
  }
3026
4053
  };
3027
4054
 
4055
+ // src/server/route-context.ts
4056
+ init_cjs_shims();
4057
+ var import_core13 = require("@korajs/core");
4058
+
4059
+ // src/apply/route-predicate.ts
4060
+ init_cjs_shims();
4061
+ function evaluateField(actual, operators) {
4062
+ if ("$eq" in operators && !Object.is(actual, operators.$eq)) {
4063
+ return false;
4064
+ }
4065
+ if ("$ne" in operators && Object.is(actual, operators.$ne)) {
4066
+ return false;
4067
+ }
4068
+ if (operators.$in !== void 0 && !operators.$in.some((candidate) => Object.is(actual, candidate))) {
4069
+ return false;
4070
+ }
4071
+ const numericChecks = [
4072
+ ["$lt", (a, b) => a < b],
4073
+ ["$lte", (a, b) => a <= b],
4074
+ ["$gt", (a, b) => a > b],
4075
+ ["$gte", (a, b) => a >= b]
4076
+ ];
4077
+ for (const [operator, compare] of numericChecks) {
4078
+ const bound = operators[operator];
4079
+ if (bound === void 0) {
4080
+ continue;
4081
+ }
4082
+ if (typeof actual !== "number" || typeof bound !== "number") {
4083
+ return false;
4084
+ }
4085
+ if (!compare(actual, bound)) {
4086
+ return false;
4087
+ }
4088
+ }
4089
+ return true;
4090
+ }
4091
+ function evaluateRoutePredicate(record, predicate) {
4092
+ const fields = Object.keys(predicate);
4093
+ if (fields.length === 0) {
4094
+ return true;
4095
+ }
4096
+ if (record === null) {
4097
+ return false;
4098
+ }
4099
+ for (const field of fields) {
4100
+ const operators = predicate[field];
4101
+ if (operators && !evaluateField(record[field], operators)) {
4102
+ return false;
4103
+ }
4104
+ }
4105
+ return true;
4106
+ }
4107
+
4108
+ // src/server/route-context.ts
4109
+ function recordMatchesScope(collection, record, scope) {
4110
+ const collectionScope = scope[collection];
4111
+ if (!collectionScope) {
4112
+ return false;
4113
+ }
4114
+ for (const [field, expected] of Object.entries(collectionScope)) {
4115
+ if (record[field] !== expected) {
4116
+ return false;
4117
+ }
4118
+ }
4119
+ return true;
4120
+ }
4121
+ function pickFields(record, keys) {
4122
+ const picked = {};
4123
+ for (const key of keys) {
4124
+ if (key in record) {
4125
+ picked[key] = record[key];
4126
+ }
4127
+ }
4128
+ return picked;
4129
+ }
4130
+ async function buildRouteOperation(store, mutation, currentOverride, clockOverride) {
4131
+ const nodeId = store.getNodeId();
4132
+ const clock = clockOverride ?? new import_core13.HybridLogicalClock(nodeId);
4133
+ const schemaVersion = store.getSchema()?.version ?? 1;
4134
+ const sequenceNumber = nextServerSequenceNumber(store);
4135
+ if (mutation.type === "insert") {
4136
+ return (0, import_core13.createOperation)(
4137
+ {
4138
+ nodeId,
4139
+ type: "insert",
4140
+ collection: mutation.collection,
4141
+ recordId: mutation.recordId ?? (0, import_core13.generateUUIDv7)(),
4142
+ data: mutation.data ?? {},
4143
+ previousData: null,
4144
+ sequenceNumber,
4145
+ causalDeps: [],
4146
+ schemaVersion
4147
+ },
4148
+ clock
4149
+ );
4150
+ }
4151
+ if (!mutation.recordId) {
4152
+ throw new RouteMutationError(
4153
+ "MISSING_RECORD_ID",
4154
+ `A ${mutation.type} mutation on "${mutation.collection}" requires a recordId.`
4155
+ );
4156
+ }
4157
+ const current = currentOverride ? currentOverride.record : await store.findRecord(mutation.collection, mutation.recordId);
4158
+ if (mutation.type === "update") {
4159
+ const rawData = mutation.data ?? {};
4160
+ const data = {};
4161
+ const atomicOps = {};
4162
+ for (const key of Object.keys(rawData)) {
4163
+ const value = rawData[key];
4164
+ if ((0, import_core13.isAtomicOp)(value)) {
4165
+ data[key] = (0, import_core13.resolveAtomicOp)(current ? current[key] : void 0, value);
4166
+ atomicOps[key] = (0, import_core13.toAtomicOp)(value);
4167
+ } else {
4168
+ data[key] = value;
4169
+ }
4170
+ }
4171
+ return (0, import_core13.createOperation)(
4172
+ {
4173
+ nodeId,
4174
+ type: "update",
4175
+ collection: mutation.collection,
4176
+ recordId: mutation.recordId,
4177
+ data,
4178
+ previousData: current ? pickFields(current, Object.keys(rawData)) : {},
4179
+ sequenceNumber,
4180
+ causalDeps: [],
4181
+ schemaVersion,
4182
+ ...Object.keys(atomicOps).length > 0 ? { atomicOps } : {}
4183
+ },
4184
+ clock
4185
+ );
4186
+ }
4187
+ return (0, import_core13.createOperation)(
4188
+ {
4189
+ nodeId,
4190
+ type: "delete",
4191
+ collection: mutation.collection,
4192
+ recordId: mutation.recordId,
4193
+ data: null,
4194
+ previousData: current ? { ...current } : null,
4195
+ sequenceNumber,
4196
+ causalDeps: [],
4197
+ schemaVersion
4198
+ },
4199
+ clock
4200
+ );
4201
+ }
4202
+ var RouteMutationError = class extends Error {
4203
+ constructor(code, message) {
4204
+ super(message);
4205
+ this.code = code;
4206
+ this.name = "RouteMutationError";
4207
+ }
4208
+ code;
4209
+ };
4210
+ function createRouteContext(server, store) {
4211
+ let mutationTail = Promise.resolve();
4212
+ function serialize(work) {
4213
+ const run = mutationTail.then(work, work);
4214
+ mutationTail = run.then(
4215
+ () => void 0,
4216
+ () => void 0
4217
+ );
4218
+ return run;
4219
+ }
4220
+ async function prepareMutation(mutation, scope) {
4221
+ let op;
4222
+ try {
4223
+ op = await buildRouteOperation(store, mutation);
4224
+ } catch (error) {
4225
+ if (error instanceof RouteMutationError) {
4226
+ return {
4227
+ ok: false,
4228
+ rejection: {
4229
+ ok: false,
4230
+ code: error.code,
4231
+ message: error.message,
4232
+ retriable: isRetriableRejection(error.code)
4233
+ }
4234
+ };
4235
+ }
4236
+ throw error;
4237
+ }
4238
+ if (scope && !operationMatchesScopes(op, scope)) {
4239
+ return {
4240
+ ok: false,
4241
+ rejection: {
4242
+ ok: false,
4243
+ code: "SCOPE_VIOLATION",
4244
+ message: `Mutation on "${mutation.collection}" is outside the provided scope.`,
4245
+ retriable: isRetriableRejection("SCOPE_VIOLATION")
4246
+ }
4247
+ };
4248
+ }
4249
+ return { ok: true, op };
4250
+ }
4251
+ async function commitOperation(op, collection) {
4252
+ const result = await server.applyLocalOperation(op);
4253
+ if (result.result !== "applied") {
4254
+ const code = result.rejection?.code ?? "NOT_APPLIED";
4255
+ return {
4256
+ ok: false,
4257
+ code,
4258
+ message: result.rejection?.message ?? `Operation on "${collection}" was not applied (${result.result}).`,
4259
+ // Prefer the pipeline's own classification when present; fall back
4260
+ // to classifying the code so this stays correct if new codes appear.
4261
+ retriable: result.rejection?.retriable ?? isRetriableRejection(code)
4262
+ };
4263
+ }
4264
+ const record = await store.findRecord(collection, op.recordId);
4265
+ return { ok: true, operation: op, record };
4266
+ }
4267
+ async function rawApply(mutation, scope) {
4268
+ const prepared = await prepareMutation(mutation, scope);
4269
+ if (!prepared.ok) {
4270
+ return prepared.rejection;
4271
+ }
4272
+ return commitOperation(prepared.op, mutation.collection);
4273
+ }
4274
+ async function runConditionalViaStore(storeApplyConditional, spec, options) {
4275
+ const scope = options?.scope;
4276
+ const mutations = [];
4277
+ const hasTargetUpdate = spec.update !== void 0 && spec.update !== null;
4278
+ if (hasTargetUpdate) {
4279
+ mutations.push({
4280
+ collection: spec.collection,
4281
+ type: "update",
4282
+ recordId: spec.id,
4283
+ data: spec.update
4284
+ });
4285
+ }
4286
+ if (spec.also) {
4287
+ mutations.push(...spec.also);
4288
+ }
4289
+ let result;
4290
+ try {
4291
+ result = await storeApplyConditional({
4292
+ target: { collection: spec.collection, id: spec.id },
4293
+ ...spec.idempotencyKey ? { idempotencyKey: spec.idempotencyKey } : {},
4294
+ admit: (current) => spec.if && Object.keys(spec.if).length > 0 ? evaluateRoutePredicate(current, spec.if) : true,
4295
+ buildOperations: async (current, context) => {
4296
+ const ops = [];
4297
+ for (let i = 0; i < mutations.length; i++) {
4298
+ const mutation = mutations[i];
4299
+ const useLockedCurrent = i === 0 && hasTargetUpdate;
4300
+ const op = await buildRouteOperation(
4301
+ store,
4302
+ mutation,
4303
+ useLockedCurrent ? { record: current } : void 0,
4304
+ context.clock
4305
+ );
4306
+ if (scope && !operationMatchesScopes(op, scope)) {
4307
+ throw new RouteMutationError(
4308
+ "SCOPE_VIOLATION",
4309
+ `Mutation on "${mutation.collection}" is outside the provided scope.`
4310
+ );
4311
+ }
4312
+ ops.push(op);
4313
+ }
4314
+ return ops;
4315
+ }
4316
+ });
4317
+ } catch (error) {
4318
+ if (error instanceof RouteMutationError) {
4319
+ return {
4320
+ ok: false,
4321
+ code: error.code,
4322
+ message: error.message,
4323
+ retriable: isRetriableRejection(error.code)
4324
+ };
4325
+ }
4326
+ throw error;
4327
+ }
4328
+ if (!result.admitted) {
4329
+ return {
4330
+ ok: false,
4331
+ code: spec.reject?.code ?? "CONDITION_NOT_MET",
4332
+ message: spec.reject?.message ?? `Admission condition not met on "${spec.collection}" record "${spec.id}".`,
4333
+ retriable: false
4334
+ };
4335
+ }
4336
+ if (result.idempotent) {
4337
+ const records2 = spec.idempotencyKey ? [await store.findRecord(spec.idempotencyKey.collection, spec.idempotencyKey.id)] : [];
4338
+ return { ok: true, idempotent: true, operations: [], records: records2 };
4339
+ }
4340
+ server.relayServerOperations(result.applied);
4341
+ const records = [];
4342
+ for (const op of result.applied) {
4343
+ records.push(await store.findRecord(op.collection, op.recordId));
4344
+ }
4345
+ return { ok: true, idempotent: false, operations: result.applied, records };
4346
+ }
4347
+ return {
4348
+ apply(mutation, options) {
4349
+ return serialize(() => rawApply(mutation, options?.scope));
4350
+ },
4351
+ applyConditional(spec, options) {
4352
+ const storeApplyConditional = store.applyConditional;
4353
+ if (storeApplyConditional) {
4354
+ return runConditionalViaStore(storeApplyConditional.bind(store), spec, options);
4355
+ }
4356
+ return serialize(async () => {
4357
+ if (spec.idempotencyKey) {
4358
+ const existing = await store.findRecord(
4359
+ spec.idempotencyKey.collection,
4360
+ spec.idempotencyKey.id
4361
+ );
4362
+ if (existing) {
4363
+ return { ok: true, idempotent: true, operations: [], records: [existing] };
4364
+ }
4365
+ }
4366
+ if (spec.if && Object.keys(spec.if).length > 0) {
4367
+ const target = await store.findRecord(spec.collection, spec.id);
4368
+ if (!evaluateRoutePredicate(target, spec.if)) {
4369
+ const code = spec.reject?.code ?? "CONDITION_NOT_MET";
4370
+ return {
4371
+ ok: false,
4372
+ code,
4373
+ message: spec.reject?.message ?? `Admission condition not met on "${spec.collection}" record "${spec.id}".`,
4374
+ retriable: false
4375
+ };
4376
+ }
4377
+ }
4378
+ const mutations = [];
4379
+ if (spec.update !== void 0 && spec.update !== null) {
4380
+ mutations.push({
4381
+ collection: spec.collection,
4382
+ type: "update",
4383
+ recordId: spec.id,
4384
+ data: spec.update
4385
+ });
4386
+ }
4387
+ if (spec.also) {
4388
+ mutations.push(...spec.also);
4389
+ }
4390
+ const prepared = [];
4391
+ for (const mutation of mutations) {
4392
+ const p = await prepareMutation(mutation, options?.scope);
4393
+ if (!p.ok) {
4394
+ return p.rejection;
4395
+ }
4396
+ prepared.push({ op: p.op, collection: mutation.collection });
4397
+ }
4398
+ const operations2 = [];
4399
+ const records = [];
4400
+ for (const { op, collection } of prepared) {
4401
+ const result = await commitOperation(op, collection);
4402
+ if (!result.ok) {
4403
+ return {
4404
+ ok: false,
4405
+ code: result.code,
4406
+ message: result.message,
4407
+ retriable: result.retriable
4408
+ };
4409
+ }
4410
+ operations2.push(result.operation);
4411
+ records.push(result.record);
4412
+ }
4413
+ return { ok: true, idempotent: false, operations: operations2, records };
4414
+ });
4415
+ },
4416
+ async query(collection, options) {
4417
+ const { scope, ...queryOptions } = options ?? {};
4418
+ const records = await store.queryCollection(collection, queryOptions);
4419
+ if (!scope) {
4420
+ return records;
4421
+ }
4422
+ return records.filter((record) => recordMatchesScope(collection, record, scope));
4423
+ },
4424
+ async findById(collection, id, options) {
4425
+ const record = await store.findRecord(collection, id);
4426
+ if (!record) {
4427
+ return null;
4428
+ }
4429
+ if (options?.scope && !recordMatchesScope(collection, record, options.scope)) {
4430
+ return null;
4431
+ }
4432
+ return record;
4433
+ }
4434
+ };
4435
+ }
4436
+
3028
4437
  // src/server/kora-sync-server.ts
3029
4438
  var DEFAULT_MAX_CONNECTIONS = 0;
3030
4439
  var DEFAULT_BATCH_SIZE2 = 100;
3031
4440
  var DEFAULT_SCHEMA_VERSION2 = 1;
3032
4441
  var DEFAULT_HOST = "0.0.0.0";
3033
4442
  var DEFAULT_PATH = "/";
3034
- var KoraSyncServer = class {
4443
+ var DEFAULT_RELAY_RETRANSMIT_INTERVAL_MS = 2e3;
4444
+ var DEFAULT_DELIVERY_POLL_INTERVAL_MS = 2e3;
4445
+ function validateIntervalOption(name, value) {
4446
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
4447
+ throw new import_core14.SyncError(`${name} must be a non-negative integer number of milliseconds`, {
4448
+ [name]: value
4449
+ });
4450
+ }
4451
+ return value;
4452
+ }
4453
+ var KoraSyncServer = class _KoraSyncServer {
3035
4454
  store;
3036
4455
  auth;
3037
4456
  serializer;
3038
4457
  emitter;
3039
4458
  maxConnections;
3040
4459
  batchSize;
4460
+ relayRetransmitIntervalMs;
4461
+ deliveryPollIntervalMs;
3041
4462
  schemaVersion;
3042
4463
  supportedSchemaVersions;
4464
+ operationTransforms;
3043
4465
  port;
3044
4466
  host;
3045
4467
  path;
@@ -3047,34 +4469,183 @@ var KoraSyncServer = class {
3047
4469
  metrics;
3048
4470
  awarenessRelay = new AwarenessRelay();
3049
4471
  yjsDocRelay = new YjsDocRelay();
4472
+ blobChunkRelay;
4473
+ persistBlobChunk;
4474
+ maxOperationBytes;
4475
+ maxOpsPerMinute;
4476
+ validateOperation;
4477
+ koraContext;
3050
4478
  sessions = /* @__PURE__ */ new Map();
3051
4479
  httpClients = /* @__PURE__ */ new Map();
3052
4480
  httpSessionToClient = /* @__PURE__ */ new Map();
3053
- serverVersion = "0.4.0";
4481
+ // Informational value reported by getStatus(). Keep in sync with the
4482
+ // @korajs/server package version on release; it is not used for protocol negotiation.
4483
+ serverVersion = "1.0.0-beta.0";
3054
4484
  wsServer = null;
3055
4485
  running = false;
4486
+ /**
4487
+ * Periodic tick that retransmits relay batches connected clients have not
4488
+ * acknowledged, so a relay dropped by a lossy transport is redelivered instead of
4489
+ * leaving that client with a permanent version-vector gap (a lost operation).
4490
+ */
4491
+ relayRetransmitTimer = null;
4492
+ deliveryPollTimer = null;
4493
+ lastObservedDeliverySequence = 0;
4494
+ deliveryPollInFlight = false;
4495
+ /**
4496
+ * Relay operations a client never acknowledged before it disconnected, buffered by
4497
+ * client node id so they can be redelivered on its next connection. Bounded per
4498
+ * node and expired by age so a client that never returns cannot grow this forever.
4499
+ */
4500
+ orphanedRelaysByNode = /* @__PURE__ */ new Map();
4501
+ static MAX_ORPHANED_RELAY_OPS_PER_NODE = 5e3;
4502
+ static ORPHANED_RELAY_TTL_MS = 5 * 6e4;
3056
4503
  constructor(config) {
3057
4504
  this.store = config.store;
4505
+ const storeSchemaVersion = this.store.getSchema()?.version;
4506
+ if (config.schemaVersion !== void 0 && storeSchemaVersion !== void 0 && config.schemaVersion !== storeSchemaVersion) {
4507
+ throw new import_core14.SyncError("Sync schema version does not match the configured store schema.", {
4508
+ syncSchemaVersion: config.schemaVersion,
4509
+ storeSchemaVersion
4510
+ });
4511
+ }
3058
4512
  this.auth = config.auth ?? null;
3059
- this.serializer = config.serializer ?? new import_sync3.JsonMessageSerializer();
4513
+ this.serializer = config.serializer ?? new import_sync5.JsonMessageSerializer();
3060
4514
  this.emitter = config.emitter ?? null;
3061
4515
  this.maxConnections = config.maxConnections ?? DEFAULT_MAX_CONNECTIONS;
3062
4516
  this.batchSize = config.batchSize ?? DEFAULT_BATCH_SIZE2;
3063
- this.schemaVersion = config.schemaVersion ?? DEFAULT_SCHEMA_VERSION2;
4517
+ this.relayRetransmitIntervalMs = validateIntervalOption(
4518
+ "relayRetransmitIntervalMs",
4519
+ config.relayRetransmitIntervalMs ?? DEFAULT_RELAY_RETRANSMIT_INTERVAL_MS
4520
+ );
4521
+ this.deliveryPollIntervalMs = validateIntervalOption(
4522
+ "deliveryPollIntervalMs",
4523
+ config.deliveryPollIntervalMs ?? DEFAULT_DELIVERY_POLL_INTERVAL_MS
4524
+ );
4525
+ this.schemaVersion = config.schemaVersion ?? storeSchemaVersion ?? DEFAULT_SCHEMA_VERSION2;
3064
4526
  this.supportedSchemaVersions = config.supportedSchemaVersions ?? {
3065
4527
  min: this.schemaVersion,
3066
4528
  max: this.schemaVersion
3067
4529
  };
4530
+ this.operationTransforms = config.operationTransforms ?? [];
3068
4531
  this.port = config.port;
3069
4532
  this.host = config.host ?? DEFAULT_HOST;
3070
4533
  this.path = config.path ?? DEFAULT_PATH;
3071
4534
  this.logger = config.logger ?? createDefaultLogger();
3072
4535
  this.metrics = config.metricsCollector ?? new ServerMetricsCollector();
3073
4536
  this.metrics.setSchemaVersion(this.schemaVersion);
4537
+ this.blobChunkRelay = new BlobChunkRelay(config.resolveBlobChunk);
4538
+ this.persistBlobChunk = config.persistBlobChunk ?? null;
4539
+ this.maxOperationBytes = config.maxOperationBytes;
4540
+ this.maxOpsPerMinute = config.maxOpsPerMinute;
4541
+ this.validateOperation = config.validateOperation;
4542
+ this.koraContext = createRouteContext(this, this.store);
3074
4543
  if (!this.emitter) {
3075
4544
  this.emitter = new import_internal2.SimpleEventEmitter();
3076
4545
  }
3077
4546
  }
4547
+ ensureBackgroundTimersStarted() {
4548
+ if (this.relayRetransmitIntervalMs > 0 && !this.relayRetransmitTimer) {
4549
+ this.relayRetransmitTimer = setInterval(() => {
4550
+ this.retransmitPendingRelays(this.relayRetransmitIntervalMs);
4551
+ }, this.relayRetransmitIntervalMs);
4552
+ this.relayRetransmitTimer.unref?.();
4553
+ }
4554
+ if (this.deliveryPollIntervalMs > 0 && !this.deliveryPollTimer) {
4555
+ this.deliveryPollTimer = setInterval(() => {
4556
+ void this.pollDeliveryLog();
4557
+ }, this.deliveryPollIntervalMs);
4558
+ this.deliveryPollTimer.unref?.();
4559
+ }
4560
+ }
4561
+ /**
4562
+ * Retransmit relay batches that connected clients have not acknowledged within
4563
+ * `staleMs`. Called on a periodic tick and directly by tests. Redelivering an
4564
+ * already-applied operation is harmless (clients dedup by content-addressed id).
4565
+ */
4566
+ retransmitPendingRelays(staleMs = 0) {
4567
+ for (const session of this.sessions.values()) {
4568
+ session.retransmitPendingRelays(staleMs);
4569
+ }
4570
+ this.expireOrphanedRelays();
4571
+ }
4572
+ /**
4573
+ * Check whether the authoritative delivery log advanced without passing through
4574
+ * this server instance, then wake delivery-watermark sessions to scan from their
4575
+ * own acknowledged cursors. Session-level filtering remains the only fan-out path.
4576
+ */
4577
+ async pollDeliveryLog() {
4578
+ if (this.deliveryPollInFlight) return;
4579
+ this.deliveryPollInFlight = true;
4580
+ try {
4581
+ const maxDeliverySequence = await this.store.getMaxDeliverySequence();
4582
+ if (maxDeliverySequence <= this.lastObservedDeliverySequence) {
4583
+ for (const session of this.sessions.values()) {
4584
+ session.pushDeliveryStreamIfSupported(this.deliveryPollIntervalMs, {
4585
+ trackStall: true
4586
+ });
4587
+ }
4588
+ return;
4589
+ }
4590
+ this.lastObservedDeliverySequence = maxDeliverySequence;
4591
+ for (const session of this.sessions.values()) {
4592
+ session.pushDeliveryStreamIfSupported(0);
4593
+ }
4594
+ } catch (error) {
4595
+ this.logger.log({
4596
+ timestamp: Date.now(),
4597
+ level: "warn",
4598
+ event: "delivery_poll.failed",
4599
+ details: { error: error instanceof Error ? error.message : String(error) }
4600
+ });
4601
+ } finally {
4602
+ this.deliveryPollInFlight = false;
4603
+ }
4604
+ }
4605
+ /**
4606
+ * Buffer relay operations a disconnected client never acknowledged, keyed by its
4607
+ * node id, deduped by operation id and bounded by count. Redelivered when the
4608
+ * client reconnects (see {@link takeOrphanedRelays}).
4609
+ */
4610
+ bufferOrphanedRelays(nodeId, ops) {
4611
+ if (ops.length === 0) return;
4612
+ const existing = this.orphanedRelaysByNode.get(nodeId);
4613
+ const merged = existing ? existing.ops : [];
4614
+ const seen = new Set(merged.map((op) => op.id));
4615
+ for (const op of ops) {
4616
+ if (!seen.has(op.id)) {
4617
+ merged.push(op);
4618
+ seen.add(op.id);
4619
+ }
4620
+ }
4621
+ const bounded = merged.length > _KoraSyncServer.MAX_ORPHANED_RELAY_OPS_PER_NODE ? merged.slice(merged.length - _KoraSyncServer.MAX_ORPHANED_RELAY_OPS_PER_NODE) : merged;
4622
+ this.orphanedRelaysByNode.set(nodeId, { ops: bounded, bufferedAtMs: Date.now() });
4623
+ }
4624
+ /** Remove and return a node's buffered orphaned relay operations, if any. */
4625
+ takeOrphanedRelays(nodeId) {
4626
+ const entry = this.orphanedRelaysByNode.get(nodeId);
4627
+ if (!entry) return [];
4628
+ this.orphanedRelaysByNode.delete(nodeId);
4629
+ return entry.ops;
4630
+ }
4631
+ /** Drop buffered orphaned relays older than the TTL (client never returned). */
4632
+ expireOrphanedRelays() {
4633
+ if (this.orphanedRelaysByNode.size === 0) return;
4634
+ const cutoff = Date.now() - _KoraSyncServer.ORPHANED_RELAY_TTL_MS;
4635
+ for (const [nodeId, entry] of this.orphanedRelaysByNode) {
4636
+ if (entry.bufferedAtMs <= cutoff) {
4637
+ this.orphanedRelaysByNode.delete(nodeId);
4638
+ }
4639
+ }
4640
+ }
4641
+ /**
4642
+ * The trusted, scoped data-plane context (`apply` / `query` / `findById`).
4643
+ * Shared with the production server so HTTP routes, the operation validator,
4644
+ * and `server.kora` are all the same object over the same pipeline.
4645
+ */
4646
+ getKoraContext() {
4647
+ return this.koraContext;
4648
+ }
3078
4649
  /**
3079
4650
  * Subscribe to session-level events for metrics collection and logging.
3080
4651
  * Called when a new session is created.
@@ -3123,6 +4694,21 @@ var KoraSyncServer = class {
3123
4694
  details: { reason: event.reason }
3124
4695
  });
3125
4696
  });
4697
+ sessionEmitter.on("sync:schema-mismatch", (event) => {
4698
+ this.logger.log({
4699
+ timestamp: Date.now(),
4700
+ level: "warn",
4701
+ event: "session.schema_mismatch",
4702
+ sessionId,
4703
+ details: {
4704
+ clientSchemaVersion: event.clientSchemaVersion,
4705
+ serverSchemaVersion: event.serverSchemaVersion,
4706
+ supportedMin: event.supportedMin,
4707
+ supportedMax: event.supportedMax,
4708
+ reason: event.reason
4709
+ }
4710
+ });
4711
+ });
3126
4712
  }
3127
4713
  /**
3128
4714
  * Get the metrics collector for external access (e.g., HTTP endpoints).
@@ -3143,10 +4729,10 @@ var KoraSyncServer = class {
3143
4729
  */
3144
4730
  async start(wsServerImpl) {
3145
4731
  if (this.running) {
3146
- throw new import_core12.SyncError("Server is already running", { port: this.port });
4732
+ throw new import_core14.SyncError("Server is already running", { port: this.port });
3147
4733
  }
3148
4734
  if (!wsServerImpl && this.port === void 0) {
3149
- throw new import_core12.SyncError(
4735
+ throw new import_core14.SyncError(
3150
4736
  "Port is required for standalone mode. Provide port in config or use handleConnection() for attach mode.",
3151
4737
  {}
3152
4738
  );
@@ -3192,8 +4778,18 @@ var KoraSyncServer = class {
3192
4778
  event: "server.stopping",
3193
4779
  details: { connectedClients: this.sessions.size }
3194
4780
  });
4781
+ if (this.relayRetransmitTimer) {
4782
+ clearInterval(this.relayRetransmitTimer);
4783
+ this.relayRetransmitTimer = null;
4784
+ }
4785
+ if (this.deliveryPollTimer) {
4786
+ clearInterval(this.deliveryPollTimer);
4787
+ this.deliveryPollTimer = null;
4788
+ }
4789
+ this.orphanedRelaysByNode.clear();
3195
4790
  this.awarenessRelay.clear();
3196
4791
  this.yjsDocRelay.clear();
4792
+ this.blobChunkRelay.clear();
3197
4793
  for (const session of this.sessions.values()) {
3198
4794
  session.close("server shutting down");
3199
4795
  }
@@ -3255,7 +4851,7 @@ var KoraSyncServer = class {
3255
4851
  if (this.maxConnections > 0 && this.sessions.size >= this.maxConnections) {
3256
4852
  transport.send({
3257
4853
  type: "error",
3258
- messageId: (0, import_core12.generateUUIDv7)(),
4854
+ messageId: (0, import_core14.generateUUIDv7)(),
3259
4855
  code: "MAX_CONNECTIONS",
3260
4856
  message: `Server has reached maximum connections (${this.maxConnections})`,
3261
4857
  retriable: true
@@ -3268,12 +4864,13 @@ var KoraSyncServer = class {
3268
4864
  event: "connection.rejected",
3269
4865
  details: { reason: "max_connections", max: this.maxConnections }
3270
4866
  });
3271
- throw new import_core12.SyncError("Maximum connections reached", {
4867
+ throw new import_core14.SyncError("Maximum connections reached", {
3272
4868
  current: this.sessions.size,
3273
4869
  max: this.maxConnections
3274
4870
  });
3275
4871
  }
3276
- const sessionId = (0, import_core12.generateUUIDv7)();
4872
+ this.ensureBackgroundTimersStarted();
4873
+ const sessionId = (0, import_core14.generateUUIDv7)();
3277
4874
  this.metrics.recordConnection(sessionId);
3278
4875
  const sessionEmitter = new import_internal2.SimpleEventEmitter();
3279
4876
  sessionEmitter.on("sync:connected", (event) => {
@@ -3319,6 +4916,33 @@ var KoraSyncServer = class {
3319
4916
  sessionId
3320
4917
  });
3321
4918
  });
4919
+ sessionEmitter.on("sync:schema-mismatch", (event) => {
4920
+ this.logger.log({
4921
+ timestamp: Date.now(),
4922
+ level: "warn",
4923
+ event: "session.schema_mismatch",
4924
+ sessionId,
4925
+ details: {
4926
+ clientSchemaVersion: event.clientSchemaVersion,
4927
+ serverSchemaVersion: event.serverSchemaVersion,
4928
+ supportedMin: event.supportedMin,
4929
+ supportedMax: event.supportedMax,
4930
+ reason: event.reason
4931
+ }
4932
+ });
4933
+ });
4934
+ sessionEmitter.on("sync:delivery-stalled", (event) => {
4935
+ this.logger.log({
4936
+ timestamp: Date.now(),
4937
+ level: "warn",
4938
+ event: "session.delivery_stalled",
4939
+ sessionId,
4940
+ details: {
4941
+ watermark: event.watermark,
4942
+ repeatCount: event.repeatCount
4943
+ }
4944
+ });
4945
+ });
3322
4946
  const session = new ClientSession({
3323
4947
  sessionId,
3324
4948
  transport,
@@ -3326,9 +4950,11 @@ var KoraSyncServer = class {
3326
4950
  auth: this.auth ?? void 0,
3327
4951
  serializer: this.serializer,
3328
4952
  emitter: sessionEmitter,
4953
+ logger: this.logger,
3329
4954
  batchSize: this.batchSize,
3330
4955
  schemaVersion: this.schemaVersion,
3331
4956
  supportedSchemaVersions: this.supportedSchemaVersions,
4957
+ operationTransforms: this.operationTransforms,
3332
4958
  onRelay: (sourceSessionId, operations2) => {
3333
4959
  this.handleRelay(sourceSessionId, operations2);
3334
4960
  },
@@ -3338,12 +4964,29 @@ var KoraSyncServer = class {
3338
4964
  onYjsDocUpdate: (sourceSessionId, message) => {
3339
4965
  this.handleYjsDocRelay(sourceSessionId, message);
3340
4966
  },
4967
+ onBlobChunkRequest: (sourceSessionId, message) => {
4968
+ this.blobChunkRelay.handleRequest(sourceSessionId, message);
4969
+ },
4970
+ onBlobChunkResponse: (sourceSessionId, message) => {
4971
+ this.blobChunkRelay.handleResponse(sourceSessionId, message);
4972
+ },
4973
+ ...this.persistBlobChunk ? { persistBlobChunk: this.persistBlobChunk } : {},
4974
+ // Only forward when configured, so an unset server value leaves the
4975
+ // session on its own documented default rather than `undefined`.
4976
+ ...this.maxOperationBytes !== void 0 ? { maxOperationBytes: this.maxOperationBytes } : {},
4977
+ ...this.maxOpsPerMinute !== void 0 ? { maxOpsPerMinute: this.maxOpsPerMinute } : {},
4978
+ ...this.validateOperation ? { validateOperation: this.validateOperation, koraContext: this.koraContext } : {},
3341
4979
  onClose: (sid) => {
3342
4980
  this.handleSessionClose(sid);
3343
- }
4981
+ },
4982
+ onOrphanedRelays: (nodeId, ops) => {
4983
+ this.bufferOrphanedRelays(nodeId, ops);
4984
+ },
4985
+ takeOrphanedRelays: (nodeId) => this.takeOrphanedRelays(nodeId)
3344
4986
  });
3345
4987
  this.sessions.set(sessionId, session);
3346
4988
  this.yjsDocRelay.addClient(sessionId, transport);
4989
+ this.blobChunkRelay.addClient(sessionId, transport);
3347
4990
  session.start();
3348
4991
  this.logger.log({
3349
4992
  timestamp: Date.now(),
@@ -3376,6 +5019,77 @@ var KoraSyncServer = class {
3376
5019
  errorCount: snapshot.errorCount
3377
5020
  };
3378
5021
  }
5022
+ /**
5023
+ * Apply a server-originated operation (for example one created by a custom
5024
+ * HTTP route) through the same validated pipeline that incoming client
5025
+ * operations use — Tier 2 constraints, referential integrity, and cascade
5026
+ * side effects — then relay every applied operation to connected clients.
5027
+ *
5028
+ * Because the operation did not come from a client session, it is relayed to
5029
+ * ALL sessions (there is no source session to exclude). Each session still
5030
+ * applies its own per-scope visibility filter in `relayOperations`, so a
5031
+ * client only receives the operation if it falls within that client's scope.
5032
+ *
5033
+ * @param op - A fully-formed, server-originated operation to apply
5034
+ * @returns The apply result, including any server-generated side-effect ops
5035
+ */
5036
+ async applyLocalOperation(op) {
5037
+ const result = await applyServerOperation(this.store, op);
5038
+ if (result.result === "applied" && result.appliedOperations.length > 0) {
5039
+ for (const session of this.sessions.values()) {
5040
+ session.relayOperations(result.appliedOperations);
5041
+ }
5042
+ }
5043
+ return result;
5044
+ }
5045
+ /**
5046
+ * Relay already-applied, server-originated operations to every connected client.
5047
+ * Used by the conditional route apply, which commits its operations atomically
5048
+ * through the store (bypassing {@link applyLocalOperation}) and then fans them
5049
+ * out. Each session still enforces its own per-scope visibility filter.
5050
+ *
5051
+ * @param operations - Operations that have already been committed to the store
5052
+ */
5053
+ relayServerOperations(operations2) {
5054
+ if (operations2.length === 0) {
5055
+ return;
5056
+ }
5057
+ for (const session of this.sessions.values()) {
5058
+ session.relayOperations(operations2);
5059
+ }
5060
+ }
5061
+ /**
5062
+ * Collect every blob reference still reachable from live records on the server.
5063
+ *
5064
+ * This is the live set for garbage-collecting the server's central blob store:
5065
+ * pass the result to `collectBlobGarbage(blobStore, liveRefs)` from
5066
+ * `@korajs/store` to reclaim bytes no record references any more. Only
5067
+ * collections that declare a `blob` field are scanned.
5068
+ *
5069
+ * @returns Every live blob reference across all collections
5070
+ */
5071
+ async getLiveBlobRefs() {
5072
+ const schema = this.store.getSchema();
5073
+ if (!schema) {
5074
+ return [];
5075
+ }
5076
+ const refs = [];
5077
+ for (const [name, collection] of Object.entries(schema.collections)) {
5078
+ const hasBlobField = Object.values(collection.fields).some((field) => field.kind === "blob");
5079
+ if (!hasBlobField) {
5080
+ continue;
5081
+ }
5082
+ const records = await this.store.materializeCollection(name);
5083
+ for (const record of records) {
5084
+ for (const value of Object.values(record)) {
5085
+ if ((0, import_core14.isBlobRef)(value)) {
5086
+ refs.push(value);
5087
+ }
5088
+ }
5089
+ }
5090
+ }
5091
+ return refs;
5092
+ }
3379
5093
  /**
3380
5094
  * Get the number of currently connected clients.
3381
5095
  */
@@ -3409,6 +5123,7 @@ var KoraSyncServer = class {
3409
5123
  this.metrics.recordDisconnection(sessionId);
3410
5124
  this.awarenessRelay.removeClient(sessionId);
3411
5125
  this.yjsDocRelay.removeClient(sessionId);
5126
+ this.blobChunkRelay.removeClient(sessionId);
3412
5127
  this.sessions.delete(sessionId);
3413
5128
  const clientId = this.httpSessionToClient.get(sessionId);
3414
5129
  if (clientId) {
@@ -3461,14 +5176,6 @@ function normalizeHttpBody(body, contentType) {
3461
5176
  return body;
3462
5177
  }
3463
5178
 
3464
- // src/auth/no-auth.ts
3465
- init_cjs_shims();
3466
- var NoAuthProvider = class {
3467
- async authenticate(_token) {
3468
- return { userId: "anonymous" };
3469
- }
3470
- };
3471
-
3472
5179
  // src/auth/token-auth.ts
3473
5180
  init_cjs_shims();
3474
5181
  var TokenAuthProvider = class {
@@ -3573,11 +5280,13 @@ function createProductionServer(config) {
3573
5280
  const port = config.port ?? (Number(process.env.PORT) || 3001);
3574
5281
  const staticDir = config.staticDir ?? "./dist";
3575
5282
  const syncPath = config.syncPath ?? "/kora-sync";
5283
+ const crossOriginEmbedderPolicy = config.crossOriginEmbedderPolicy ?? "credentialless";
3576
5284
  const syncServer = new KoraSyncServer({
3577
5285
  store: config.store,
3578
5286
  enableDashboard: true,
3579
5287
  ...config.syncOptions
3580
5288
  });
5289
+ const routeContext = syncServer.getKoraContext();
3581
5290
  let httpServer = null;
3582
5291
  function getOperationalToken(kind) {
3583
5292
  const auth = config.operationalAuth;
@@ -3664,6 +5373,10 @@ function createProductionServer(config) {
3664
5373
  const chunks = [];
3665
5374
  req.on("data", (chunk) => chunks.push(chunk));
3666
5375
  req.on("end", () => resolve(Buffer.concat(chunks)));
5376
+ if (!req.readableFlowing) {
5377
+ req.resume();
5378
+ }
5379
+ req.on("error", () => resolve(Buffer.concat(chunks)));
3667
5380
  });
3668
5381
  }
3669
5382
  async function readJsonBody(req) {
@@ -3713,6 +5426,10 @@ function createProductionServer(config) {
3713
5426
  res.end(JSON.stringify(result.body ?? null));
3714
5427
  }
3715
5428
  return {
5429
+ kora: routeContext,
5430
+ getLiveBlobRefs() {
5431
+ return syncServer.getLiveBlobRefs();
5432
+ },
3716
5433
  async start() {
3717
5434
  const { createServer } = await import("http");
3718
5435
  const { createReadStream, existsSync, statSync } = await import("fs");
@@ -3720,8 +5437,21 @@ function createProductionServer(config) {
3720
5437
  const { WebSocketServer } = await import("ws");
3721
5438
  const distDir = resolve(staticDir);
3722
5439
  httpServer = createServer(async (req, res) => {
5440
+ try {
5441
+ await handleRequest(req, res);
5442
+ } catch (error) {
5443
+ console.error("[kora] Unhandled error in HTTP request handler:", error);
5444
+ if (!res.headersSent) {
5445
+ res.writeHead(500, { "Content-Type": "application/json" });
5446
+ res.end(JSON.stringify({ error: "Internal server error" }));
5447
+ } else if (!res.writableEnded) {
5448
+ res.end();
5449
+ }
5450
+ }
5451
+ });
5452
+ async function handleRequest(req, res) {
3723
5453
  res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
3724
- res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
5454
+ res.setHeader("Cross-Origin-Embedder-Policy", crossOriginEmbedderPolicy);
3725
5455
  const url = new URL(req.url || "/", `http://${req.headers.host}`);
3726
5456
  if (url.pathname === "/health") {
3727
5457
  const status = await syncServer.getStatus();
@@ -3847,7 +5577,8 @@ data: ${JSON.stringify(s)}
3847
5577
  body: await readJsonBody(req),
3848
5578
  headers: req.headers,
3849
5579
  query: getQuery(url),
3850
- ip: getClientIp(req)
5580
+ ip: getClientIp(req),
5581
+ kora: routeContext
3851
5582
  });
3852
5583
  writeJsonResponse(res, result);
3853
5584
  return;
@@ -3881,7 +5612,7 @@ data: ${JSON.stringify(s)}
3881
5612
  const contentType = MIME_TYPES[ext] || "application/octet-stream";
3882
5613
  res.writeHead(200, { "Content-Type": contentType });
3883
5614
  createReadStream(filePath).pipe(res);
3884
- });
5615
+ }
3885
5616
  const wss = new WebSocketServer({ noServer: true });
3886
5617
  httpServer.on("upgrade", (req, socket, head) => {
3887
5618
  const url = new URL(req.url || "/", `http://${req.headers.host}`);
@@ -3894,8 +5625,13 @@ data: ${JSON.stringify(s)}
3894
5625
  socket.destroy();
3895
5626
  }
3896
5627
  });
3897
- return new Promise((resolve2) => {
5628
+ return new Promise((resolve2, reject) => {
5629
+ const onError = (error) => {
5630
+ reject(error);
5631
+ };
5632
+ httpServer?.once("error", onError);
3898
5633
  httpServer?.listen(port, "0.0.0.0", () => {
5634
+ httpServer?.off("error", onError);
3899
5635
  resolve2(`http://localhost:${port}`);
3900
5636
  });
3901
5637
  });
@@ -3998,6 +5734,7 @@ function formatUptime(ms) {
3998
5734
  MixedAuthProvider,
3999
5735
  NoAuthProvider,
4000
5736
  PostgresServerStore,
5737
+ RETRIABLE_REJECTION_CODES,
4001
5738
  SqliteServerStore,
4002
5739
  TokenAuthProvider,
4003
5740
  WsServerTransport,
@@ -4008,6 +5745,7 @@ function formatUptime(ms) {
4008
5745
  createPrettyLogger,
4009
5746
  createProductionServer,
4010
5747
  createSilentLogger,
4011
- createSqliteServerStore
5748
+ createSqliteServerStore,
5749
+ isRetriableRejection
4012
5750
  });
4013
5751
  //# sourceMappingURL=index.cjs.map