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

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.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { Operation, HybridLogicalClock, SchemaDefinition, BlobRef, KoraEventEmitter, VersionVector, TimeSource } from '@korajs/core';
1
+ import { Operation, HybridLogicalClock, SchemaDefinition, BlobRef, KoraEventEmitter, OperationTransform, VersionVector, TimeSource } from '@korajs/core';
2
2
  import * as _korajs_sync from '@korajs/sync';
3
3
  import { SyncStore, ApplyResult, MessageSerializer, SyncMessage, AwarenessUpdateMessage, YjsDocUpdateMessage, BlobChunkRequestMessage, BlobChunkResponseMessage } from '@korajs/sync';
4
4
  import { S as ServerTransport, a as ServerMessageHandler, b as ServerCloseHandler, c as ServerErrorHandler } from './server-transport-CU1BLWgr.cjs';
@@ -449,6 +449,7 @@ declare class KoraSyncServer {
449
449
  private readonly batchSize;
450
450
  private readonly schemaVersion;
451
451
  private readonly supportedSchemaVersions;
452
+ private readonly operationTransforms;
452
453
  private readonly port;
453
454
  private readonly host;
454
455
  private readonly path;
@@ -808,7 +809,10 @@ interface KoraSyncServerConfig {
808
809
  maxConnections?: number;
809
810
  /** Maximum operations per sync batch. Defaults to 100. */
810
811
  batchSize?: number;
811
- /** Schema version the server expects. Defaults to 1. */
812
+ /**
813
+ * Schema version the server expects. Defaults to `store.getSchema()?.version`
814
+ * when the store has been configured with a schema, otherwise `1`.
815
+ */
812
816
  schemaVersion?: number;
813
817
  /**
814
818
  * Inclusive range of client schema versions accepted at handshake.
@@ -818,6 +822,13 @@ interface KoraSyncServerConfig {
818
822
  min: number;
819
823
  max: number;
820
824
  };
825
+ /**
826
+ * Transform accepted legacy client operations into the server schema version
827
+ * before validation and materialization. Required when
828
+ * `supportedSchemaVersions.min` is lower than `schemaVersion` and operation
829
+ * shapes changed across versions.
830
+ */
831
+ operationTransforms?: OperationTransform[];
821
832
  /** WebSocket path (standalone mode). Defaults to '/'. */
822
833
  path?: string;
823
834
  /** Structured logger. Defaults to pretty-print in dev, JSON lines in production. */
@@ -1062,6 +1073,8 @@ interface ClientSessionOptions {
1062
1073
  min: number;
1063
1074
  max: number;
1064
1075
  };
1076
+ /** Transform accepted legacy operations into the server schema before validation. */
1077
+ operationTransforms?: OperationTransform[];
1065
1078
  /** Called when this session has operations to relay to other sessions */
1066
1079
  onRelay?: RelayCallback;
1067
1080
  /** Called when this session receives an awareness update to broadcast */
@@ -1139,6 +1152,13 @@ declare class ClientSession {
1139
1152
  * client's reported watermark at handshake.
1140
1153
  */
1141
1154
  private lastAckedDeliverySeq;
1155
+ /**
1156
+ * Timestamp of the last delivery-stream send attempt. Periodic retransmission uses
1157
+ * this as its stale window so a slow client does not receive duplicate full-stream
1158
+ * batches every server tick while the first batch is still being applied.
1159
+ */
1160
+ private lastDeliveryPushAttemptAtMs;
1161
+ private deliveryStallRepeatCount;
1142
1162
  /** Serializes incremental delivery pushes so their batches never interleave. */
1143
1163
  private deliveryPushChain;
1144
1164
  /**
@@ -1161,6 +1181,7 @@ declare class ClientSession {
1161
1181
  private readonly batchSize;
1162
1182
  private readonly schemaVersion;
1163
1183
  private readonly supportedSchemaVersions;
1184
+ private readonly operationTransforms;
1164
1185
  private readonly onRelay;
1165
1186
  private readonly onAwarenessUpdate;
1166
1187
  private readonly onYjsDocUpdate;
@@ -1234,6 +1255,7 @@ declare class ClientSession {
1234
1255
  private handleMessageFailure;
1235
1256
  private handleHandshake;
1236
1257
  private handleOperationBatch;
1258
+ private transformForServerSchema;
1237
1259
  private sendDelta;
1238
1260
  /**
1239
1261
  * Send the gap-free server->client delivery stream, resuming just after
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Operation, HybridLogicalClock, SchemaDefinition, BlobRef, KoraEventEmitter, VersionVector, TimeSource } from '@korajs/core';
1
+ import { Operation, HybridLogicalClock, SchemaDefinition, BlobRef, KoraEventEmitter, OperationTransform, VersionVector, TimeSource } from '@korajs/core';
2
2
  import * as _korajs_sync from '@korajs/sync';
3
3
  import { SyncStore, ApplyResult, MessageSerializer, SyncMessage, AwarenessUpdateMessage, YjsDocUpdateMessage, BlobChunkRequestMessage, BlobChunkResponseMessage } from '@korajs/sync';
4
4
  import { S as ServerTransport, a as ServerMessageHandler, b as ServerCloseHandler, c as ServerErrorHandler } from './server-transport-CU1BLWgr.js';
@@ -449,6 +449,7 @@ declare class KoraSyncServer {
449
449
  private readonly batchSize;
450
450
  private readonly schemaVersion;
451
451
  private readonly supportedSchemaVersions;
452
+ private readonly operationTransforms;
452
453
  private readonly port;
453
454
  private readonly host;
454
455
  private readonly path;
@@ -808,7 +809,10 @@ interface KoraSyncServerConfig {
808
809
  maxConnections?: number;
809
810
  /** Maximum operations per sync batch. Defaults to 100. */
810
811
  batchSize?: number;
811
- /** Schema version the server expects. Defaults to 1. */
812
+ /**
813
+ * Schema version the server expects. Defaults to `store.getSchema()?.version`
814
+ * when the store has been configured with a schema, otherwise `1`.
815
+ */
812
816
  schemaVersion?: number;
813
817
  /**
814
818
  * Inclusive range of client schema versions accepted at handshake.
@@ -818,6 +822,13 @@ interface KoraSyncServerConfig {
818
822
  min: number;
819
823
  max: number;
820
824
  };
825
+ /**
826
+ * Transform accepted legacy client operations into the server schema version
827
+ * before validation and materialization. Required when
828
+ * `supportedSchemaVersions.min` is lower than `schemaVersion` and operation
829
+ * shapes changed across versions.
830
+ */
831
+ operationTransforms?: OperationTransform[];
821
832
  /** WebSocket path (standalone mode). Defaults to '/'. */
822
833
  path?: string;
823
834
  /** Structured logger. Defaults to pretty-print in dev, JSON lines in production. */
@@ -1062,6 +1073,8 @@ interface ClientSessionOptions {
1062
1073
  min: number;
1063
1074
  max: number;
1064
1075
  };
1076
+ /** Transform accepted legacy operations into the server schema before validation. */
1077
+ operationTransforms?: OperationTransform[];
1065
1078
  /** Called when this session has operations to relay to other sessions */
1066
1079
  onRelay?: RelayCallback;
1067
1080
  /** Called when this session receives an awareness update to broadcast */
@@ -1139,6 +1152,13 @@ declare class ClientSession {
1139
1152
  * client's reported watermark at handshake.
1140
1153
  */
1141
1154
  private lastAckedDeliverySeq;
1155
+ /**
1156
+ * Timestamp of the last delivery-stream send attempt. Periodic retransmission uses
1157
+ * this as its stale window so a slow client does not receive duplicate full-stream
1158
+ * batches every server tick while the first batch is still being applied.
1159
+ */
1160
+ private lastDeliveryPushAttemptAtMs;
1161
+ private deliveryStallRepeatCount;
1142
1162
  /** Serializes incremental delivery pushes so their batches never interleave. */
1143
1163
  private deliveryPushChain;
1144
1164
  /**
@@ -1161,6 +1181,7 @@ declare class ClientSession {
1161
1181
  private readonly batchSize;
1162
1182
  private readonly schemaVersion;
1163
1183
  private readonly supportedSchemaVersions;
1184
+ private readonly operationTransforms;
1164
1185
  private readonly onRelay;
1165
1186
  private readonly onAwarenessUpdate;
1166
1187
  private readonly onYjsDocUpdate;
@@ -1234,6 +1255,7 @@ declare class ClientSession {
1234
1255
  private handleMessageFailure;
1235
1256
  private handleHandshake;
1236
1257
  private handleOperationBatch;
1258
+ private transformForServerSchema;
1237
1259
  private sendDelta;
1238
1260
  /**
1239
1261
  * Send the gap-free server->client delivery stream, resuming just after
package/dist/index.js CHANGED
@@ -2190,6 +2190,7 @@ var WsServerTransport = class {
2190
2190
  };
2191
2191
 
2192
2192
  // src/session/client-session.ts
2193
+ import { applyOperationTransforms } from "@korajs/core";
2193
2194
  import { generateUUIDv7 as generateUUIDv74, hashBlob } from "@korajs/core";
2194
2195
  import { topologicalSort } from "@korajs/core/internal";
2195
2196
  import { decodeBlobChunkBytes } from "@korajs/sync";
@@ -2323,6 +2324,18 @@ function nextServerSequenceNumber(store) {
2323
2324
  async function applyServerOperation(store, op, relationLookup) {
2324
2325
  const schema = store.getSchema();
2325
2326
  const lookup = relationLookup ?? (schema ? buildMergeRelationLookup(schema) : /* @__PURE__ */ new Map());
2327
+ const shapeCheck = validateOperationShape(op, schema);
2328
+ if (!shapeCheck.valid) {
2329
+ return {
2330
+ result: "skipped",
2331
+ appliedOperations: [],
2332
+ rejection: {
2333
+ code: shapeCheck.code ?? "SCHEMA_VALIDATION_ERROR",
2334
+ message: shapeCheck.message ?? `Operation "${op.id}" does not match the server schema`,
2335
+ retriable: false
2336
+ }
2337
+ };
2338
+ }
2326
2339
  const constraintCheck = await validateIncomingOperationConstraints(store, op, schema);
2327
2340
  if (!constraintCheck.valid) {
2328
2341
  const code = constraintCheck.code ?? "CONSTRAINT_VIOLATION";
@@ -2376,6 +2389,67 @@ async function applyServerOperation(store, op, relationLookup) {
2376
2389
  appliedOperations: result === "applied" ? [op] : []
2377
2390
  };
2378
2391
  }
2392
+ function validateOperationShape(op, schema) {
2393
+ if (!schema) {
2394
+ return { valid: true };
2395
+ }
2396
+ const collection = schema.collections[op.collection];
2397
+ if (!collection) {
2398
+ return {
2399
+ valid: false,
2400
+ code: "UNKNOWN_COLLECTION",
2401
+ message: `Operation "${op.id}" targets unknown collection "${op.collection}".`
2402
+ };
2403
+ }
2404
+ const declaredFields = new Set(Object.keys(collection.fields));
2405
+ const systemPreviousFields = /* @__PURE__ */ new Set(["id", "_created_at", "_updated_at", "_deleted"]);
2406
+ const invalidDataField = firstInvalidField(op.data, declaredFields);
2407
+ if (invalidDataField) {
2408
+ return {
2409
+ valid: false,
2410
+ code: "SCHEMA_VALIDATION_ERROR",
2411
+ message: `Operation "${op.id}" contains undeclared field "${invalidDataField}" in data for collection "${op.collection}".`
2412
+ };
2413
+ }
2414
+ const invalidPreviousField = firstInvalidField(
2415
+ op.previousData,
2416
+ declaredFields,
2417
+ systemPreviousFields
2418
+ );
2419
+ if (invalidPreviousField) {
2420
+ return {
2421
+ valid: false,
2422
+ code: "SCHEMA_VALIDATION_ERROR",
2423
+ message: `Operation "${op.id}" contains undeclared field "${invalidPreviousField}" in previousData for collection "${op.collection}".`
2424
+ };
2425
+ }
2426
+ if (op.atomicOps) {
2427
+ for (const field of Object.keys(op.atomicOps)) {
2428
+ if (!declaredFields.has(field)) {
2429
+ return {
2430
+ valid: false,
2431
+ code: "SCHEMA_VALIDATION_ERROR",
2432
+ message: `Operation "${op.id}" contains undeclared atomic field "${field}" for collection "${op.collection}".`
2433
+ };
2434
+ }
2435
+ }
2436
+ }
2437
+ return { valid: true };
2438
+ }
2439
+ function firstInvalidField(value, declaredFields, extraAllowedFields) {
2440
+ if (value === null || value === void 0) {
2441
+ return null;
2442
+ }
2443
+ if (typeof value !== "object" || Array.isArray(value)) {
2444
+ return null;
2445
+ }
2446
+ for (const field of Object.keys(value)) {
2447
+ if (!declaredFields.has(field) && !extraAllowedFields?.has(field)) {
2448
+ return field;
2449
+ }
2450
+ }
2451
+ return null;
2452
+ }
2379
2453
 
2380
2454
  // src/auth/no-auth.ts
2381
2455
  var NoAuthProvider = class {
@@ -2424,6 +2498,7 @@ function operationMatchesScopes(op, scopes, fullRecord) {
2424
2498
  if (!scopes) return true;
2425
2499
  const collectionScope = scopes[op.collection];
2426
2500
  if (!collectionScope) return false;
2501
+ if (Object.keys(collectionScope).length === 0) return true;
2427
2502
  const snapshot = buildSnapshot(op, fullRecord);
2428
2503
  if (!snapshot) return false;
2429
2504
  for (const [field, expected] of Object.entries(collectionScope)) {
@@ -2437,6 +2512,7 @@ function missingScopeFields(op, scopes) {
2437
2512
  if (!scopes) return [];
2438
2513
  const collectionScope = scopes[op.collection];
2439
2514
  if (!collectionScope) return [];
2515
+ if (Object.keys(collectionScope).length === 0) return [];
2440
2516
  const snapshot = buildSnapshot(op);
2441
2517
  return Object.keys(collectionScope).filter((field) => !snapshot || !(field in snapshot));
2442
2518
  }
@@ -2536,6 +2612,20 @@ function warnIfMultiTenantWithoutScopes(auth, resolvedScopes, schema) {
2536
2612
  "[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.)"
2537
2613
  );
2538
2614
  }
2615
+ function stableStringify(value) {
2616
+ if (Array.isArray(value)) {
2617
+ return `[${value.map((item) => stableStringify(item)).join(",")}]`;
2618
+ }
2619
+ if (value && typeof value === "object") {
2620
+ const record = value;
2621
+ const entries = Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`);
2622
+ return `{${entries.join(",")}}`;
2623
+ }
2624
+ return JSON.stringify(value);
2625
+ }
2626
+ function sameScopeMap(a, b) {
2627
+ return stableStringify(a ?? null) === stableStringify(b ?? null);
2628
+ }
2539
2629
  var ClientSession = class _ClientSession {
2540
2630
  state = "connected";
2541
2631
  clientNodeId = null;
@@ -2559,6 +2649,13 @@ var ClientSession = class _ClientSession {
2559
2649
  * client's reported watermark at handshake.
2560
2650
  */
2561
2651
  lastAckedDeliverySeq = 0;
2652
+ /**
2653
+ * Timestamp of the last delivery-stream send attempt. Periodic retransmission uses
2654
+ * this as its stale window so a slow client does not receive duplicate full-stream
2655
+ * batches every server tick while the first batch is still being applied.
2656
+ */
2657
+ lastDeliveryPushAttemptAtMs = 0;
2658
+ deliveryStallRepeatCount = 0;
2562
2659
  /** Serializes incremental delivery pushes so their batches never interleave. */
2563
2660
  deliveryPushChain = Promise.resolve();
2564
2661
  /**
@@ -2581,6 +2678,7 @@ var ClientSession = class _ClientSession {
2581
2678
  batchSize;
2582
2679
  schemaVersion;
2583
2680
  supportedSchemaVersions;
2681
+ operationTransforms;
2584
2682
  onRelay;
2585
2683
  onAwarenessUpdate;
2586
2684
  onYjsDocUpdate;
@@ -2610,6 +2708,7 @@ var ClientSession = class _ClientSession {
2610
2708
  min: this.schemaVersion,
2611
2709
  max: this.schemaVersion
2612
2710
  };
2711
+ this.operationTransforms = options.operationTransforms ?? [];
2613
2712
  this.onRelay = options.onRelay ?? null;
2614
2713
  this.onAwarenessUpdate = options.onAwarenessUpdate ?? null;
2615
2714
  this.onYjsDocUpdate = options.onYjsDocUpdate ?? null;
@@ -2659,6 +2758,7 @@ var ClientSession = class _ClientSession {
2659
2758
  this.deliveryPushChain = this.deliveryPushChain.then(async () => {
2660
2759
  if (this.state !== "streaming" || !this.transport.isConnected()) return;
2661
2760
  try {
2761
+ this.lastDeliveryPushAttemptAtMs = Date.now();
2662
2762
  await this.sendDeliveryStream(
2663
2763
  this.lastAckedDeliverySeq,
2664
2764
  false,
@@ -2706,6 +2806,18 @@ var ClientSession = class _ClientSession {
2706
2806
  retransmitPendingRelays(staleMs = 0) {
2707
2807
  if (this.state !== "streaming" || !this.transport.isConnected()) return;
2708
2808
  if (this.clientDeliveryWatermark !== null) {
2809
+ if (staleMs > 0 && Date.now() - this.lastDeliveryPushAttemptAtMs < staleMs) {
2810
+ return;
2811
+ }
2812
+ this.deliveryStallRepeatCount += 1;
2813
+ if (this.deliveryStallRepeatCount >= 3) {
2814
+ this.emitter?.emit({
2815
+ type: "sync:delivery-stalled",
2816
+ sessionId: this.sessionId,
2817
+ watermark: this.lastAckedDeliverySeq,
2818
+ repeatCount: this.deliveryStallRepeatCount
2819
+ });
2820
+ }
2709
2821
  this.pushDeliveryStream();
2710
2822
  return;
2711
2823
  }
@@ -2798,6 +2910,7 @@ var ClientSession = class _ClientSession {
2798
2910
  this.pendingRelays.delete(message.acknowledgedMessageId);
2799
2911
  if (message.deliverySequence !== void 0) {
2800
2912
  this.lastAckedDeliverySeq = Math.max(this.lastAckedDeliverySeq, message.deliverySequence);
2913
+ this.deliveryStallRepeatCount = 0;
2801
2914
  }
2802
2915
  break;
2803
2916
  case "error":
@@ -2888,6 +3001,9 @@ var ClientSession = class _ClientSession {
2888
3001
  }
2889
3002
  this.resumeDeltaCursor = msg.deltaCursor ? decodeDeltaCursor(msg.deltaCursor) : null;
2890
3003
  this.clientDeliveryWatermark = msg.lastDeliverySequence ?? null;
3004
+ if (this.clientDeliveryWatermark !== null && !sameScopeMap(msg.syncScope, this.authContext?.scopes)) {
3005
+ this.clientDeliveryWatermark = 0;
3006
+ }
2891
3007
  const clientUsesWatermark = this.clientDeliveryWatermark !== null;
2892
3008
  let serverMaxDelivery = 0;
2893
3009
  if (clientUsesWatermark) {
@@ -2901,6 +3017,14 @@ var ClientSession = class _ClientSession {
2901
3017
  this.setSerializerWireFormat(selectedWireFormat);
2902
3018
  if (!isClientSchemaVersionSupported(msg.schemaVersion, this.supportedSchemaVersions)) {
2903
3019
  const { min, max } = this.supportedSchemaVersions;
3020
+ this.emitter?.emit({
3021
+ type: "sync:schema-mismatch",
3022
+ clientSchemaVersion: msg.schemaVersion,
3023
+ serverSchemaVersion: this.schemaVersion,
3024
+ supportedMin: min,
3025
+ supportedMax: max,
3026
+ reason: `${SCHEMA_MISMATCH_PREFIX}: client schema version ${msg.schemaVersion} not in supported range [${min}, ${max}]`
3027
+ });
2904
3028
  const response2 = {
2905
3029
  type: "handshake-response",
2906
3030
  messageId: generateUUIDv74(),
@@ -2943,6 +3067,7 @@ var ClientSession = class _ClientSession {
2943
3067
  if (this.clientDeliveryWatermark !== null) {
2944
3068
  this.lastAckedDeliverySeq = this.clientDeliveryWatermark;
2945
3069
  const excludeOwn = this.clientDeliveryWatermark > 0 ? this.clientNodeId ?? void 0 : void 0;
3070
+ this.lastDeliveryPushAttemptAtMs = Date.now();
2946
3071
  await this.sendDeliveryStream(this.clientDeliveryWatermark, true, excludeOwn);
2947
3072
  } else {
2948
3073
  const clientVector = wireToVersionVector(msg.versionVector);
@@ -2959,10 +3084,20 @@ var ClientSession = class _ClientSession {
2959
3084
  async handleOperationBatch(msg) {
2960
3085
  const operations2 = msg.operations.map((s) => this.serializer.decodeOperation(s));
2961
3086
  const applied = [];
2962
- const rejected = [];
3087
+ let acknowledgedThrough = 0;
3088
+ let canAdvanceAck = true;
2963
3089
  for (const op of operations2) {
3090
+ if (!canAdvanceAck) {
3091
+ continue;
3092
+ }
2964
3093
  if (!await this.operationVisibleToClient(op)) {
2965
- rejected.push(op);
3094
+ this.sendOperationRejected(
3095
+ op,
3096
+ "SCOPE_VIOLATION",
3097
+ `Operation "${op.id}" in collection "${op.collection}" is outside the client's sync scope. Refresh sync scopes and retry.`,
3098
+ true
3099
+ );
3100
+ canAdvanceAck = false;
2966
3101
  continue;
2967
3102
  }
2968
3103
  if (!isOperationTimestampValid(op)) {
@@ -2971,6 +3106,7 @@ var ClientSession = class _ClientSession {
2971
3106
  `Operation "${op.id}" timestamp is too far in the future`,
2972
3107
  false
2973
3108
  );
3109
+ canAdvanceAck = false;
2974
3110
  continue;
2975
3111
  }
2976
3112
  if (!this.rateLimiter.allow(1)) {
@@ -2979,6 +3115,7 @@ var ClientSession = class _ClientSession {
2979
3115
  `Session exceeded operation rate limit (${String(this.maxOpsPerMinute)} ops/min)`,
2980
3116
  true
2981
3117
  );
3118
+ canAdvanceAck = false;
2982
3119
  continue;
2983
3120
  }
2984
3121
  const sizeCheck = validateOperationSize(op, this.maxOperationBytes);
@@ -2988,53 +3125,73 @@ var ClientSession = class _ClientSession {
2988
3125
  sizeCheck.message ?? `Operation "${op.id}" is too large`,
2989
3126
  false
2990
3127
  );
3128
+ canAdvanceAck = false;
3129
+ continue;
3130
+ }
3131
+ const serverOp = this.transformForServerSchema(op);
3132
+ if (serverOp === null) {
3133
+ this.sendOperationRejected(
3134
+ op,
3135
+ "SCHEMA_TRANSFORM_UNAVAILABLE",
3136
+ `Operation "${op.id}" cannot be transformed from schema v${op.schemaVersion} to server schema v${this.schemaVersion}.`,
3137
+ false
3138
+ );
3139
+ acknowledgedThrough = op.sequenceNumber;
2991
3140
  continue;
2992
3141
  }
2993
3142
  if (this.validateOperation && this.koraContext) {
2994
3143
  let decision;
2995
3144
  try {
2996
- decision = await this.validateOperation(op, {
3145
+ decision = await this.validateOperation(serverOp, {
2997
3146
  auth: this.authContext,
2998
3147
  kora: this.koraContext
2999
3148
  });
3000
3149
  } catch (error) {
3001
3150
  const message = error instanceof Error ? error.message : "validator error";
3002
- this.sendOperationRejected(op, "VALIDATION_ERROR", `Validator threw: ${message}`, true);
3151
+ this.sendOperationRejected(
3152
+ serverOp,
3153
+ "VALIDATION_ERROR",
3154
+ `Validator threw: ${message}`,
3155
+ true
3156
+ );
3157
+ canAdvanceAck = false;
3003
3158
  continue;
3004
3159
  }
3005
3160
  if (decision.action === "reject") {
3006
- this.sendOperationRejected(
3007
- op,
3008
- decision.code,
3009
- decision.message,
3010
- decision.retriable ?? isRetriableRejection(decision.code)
3011
- );
3161
+ const retriable = decision.retriable ?? isRetriableRejection(decision.code);
3162
+ this.sendOperationRejected(serverOp, decision.code, decision.message, retriable);
3163
+ if (retriable) {
3164
+ canAdvanceAck = false;
3165
+ } else {
3166
+ acknowledgedThrough = op.sequenceNumber;
3167
+ }
3012
3168
  continue;
3013
3169
  }
3014
3170
  if (decision.action === "ignore") {
3171
+ acknowledgedThrough = op.sequenceNumber;
3015
3172
  continue;
3016
3173
  }
3017
3174
  }
3018
- const applyResult = await applyServerOperation(this.store, op);
3175
+ const applyResult = await applyServerOperation(this.store, serverOp);
3019
3176
  if (applyResult.rejection) {
3020
- this.sendError(
3177
+ this.sendOperationRejected(
3178
+ serverOp,
3021
3179
  applyResult.rejection.code,
3022
3180
  applyResult.rejection.message,
3023
3181
  applyResult.rejection.retriable
3024
3182
  );
3183
+ if (applyResult.rejection.retriable) {
3184
+ canAdvanceAck = false;
3185
+ } else {
3186
+ acknowledgedThrough = op.sequenceNumber;
3187
+ }
3025
3188
  continue;
3026
3189
  }
3027
3190
  if (applyResult.result === "applied") {
3028
3191
  applied.push(...applyResult.appliedOperations);
3029
- }
3030
- }
3031
- if (rejected.length > 0) {
3032
- for (const op of rejected) {
3033
- this.sendError(
3034
- "SCOPE_VIOLATION",
3035
- `Operation "${op.id}" in collection "${op.collection}" is outside the client's sync scope`,
3036
- false
3037
- );
3192
+ acknowledgedThrough = op.sequenceNumber;
3193
+ } else {
3194
+ acknowledgedThrough = op.sequenceNumber;
3038
3195
  }
3039
3196
  }
3040
3197
  if (operations2.length > 0) {
@@ -3049,13 +3206,19 @@ var ClientSession = class _ClientSession {
3049
3206
  type: "acknowledgment",
3050
3207
  messageId: generateUUIDv74(),
3051
3208
  acknowledgedMessageId: msg.messageId,
3052
- lastSequenceNumber: lastOp ? lastOp.sequenceNumber : 0
3209
+ lastSequenceNumber: lastOp ? Math.min(acknowledgedThrough, lastOp.sequenceNumber) : 0
3053
3210
  };
3054
3211
  this.sendToClient(ack);
3055
3212
  if (applied.length > 0) {
3056
3213
  this.onRelay?.(this.sessionId, applied);
3057
3214
  }
3058
3215
  }
3216
+ transformForServerSchema(op) {
3217
+ if (op.schemaVersion === this.schemaVersion) {
3218
+ return op;
3219
+ }
3220
+ return applyOperationTransforms(op, this.schemaVersion, this.operationTransforms);
3221
+ }
3059
3222
  async sendDelta(clientVector) {
3060
3223
  const serverVector = this.store.getVersionVector();
3061
3224
  const missing = [];
@@ -4044,6 +4207,7 @@ var KoraSyncServer = class _KoraSyncServer {
4044
4207
  batchSize;
4045
4208
  schemaVersion;
4046
4209
  supportedSchemaVersions;
4210
+ operationTransforms;
4047
4211
  port;
4048
4212
  host;
4049
4213
  path;
@@ -4081,16 +4245,24 @@ var KoraSyncServer = class _KoraSyncServer {
4081
4245
  static ORPHANED_RELAY_TTL_MS = 5 * 6e4;
4082
4246
  constructor(config) {
4083
4247
  this.store = config.store;
4248
+ const storeSchemaVersion = this.store.getSchema()?.version;
4249
+ if (config.schemaVersion !== void 0 && storeSchemaVersion !== void 0 && config.schemaVersion !== storeSchemaVersion) {
4250
+ throw new SyncError4("Sync schema version does not match the configured store schema.", {
4251
+ syncSchemaVersion: config.schemaVersion,
4252
+ storeSchemaVersion
4253
+ });
4254
+ }
4084
4255
  this.auth = config.auth ?? null;
4085
4256
  this.serializer = config.serializer ?? new JsonMessageSerializer2();
4086
4257
  this.emitter = config.emitter ?? null;
4087
4258
  this.maxConnections = config.maxConnections ?? DEFAULT_MAX_CONNECTIONS;
4088
4259
  this.batchSize = config.batchSize ?? DEFAULT_BATCH_SIZE2;
4089
- this.schemaVersion = config.schemaVersion ?? DEFAULT_SCHEMA_VERSION2;
4260
+ this.schemaVersion = config.schemaVersion ?? storeSchemaVersion ?? DEFAULT_SCHEMA_VERSION2;
4090
4261
  this.supportedSchemaVersions = config.supportedSchemaVersions ?? {
4091
4262
  min: this.schemaVersion,
4092
4263
  max: this.schemaVersion
4093
4264
  };
4265
+ this.operationTransforms = config.operationTransforms ?? [];
4094
4266
  this.port = config.port;
4095
4267
  this.host = config.host ?? DEFAULT_HOST;
4096
4268
  this.path = config.path ?? DEFAULT_PATH;
@@ -4214,6 +4386,21 @@ var KoraSyncServer = class _KoraSyncServer {
4214
4386
  details: { reason: event.reason }
4215
4387
  });
4216
4388
  });
4389
+ sessionEmitter.on("sync:schema-mismatch", (event) => {
4390
+ this.logger.log({
4391
+ timestamp: Date.now(),
4392
+ level: "warn",
4393
+ event: "session.schema_mismatch",
4394
+ sessionId,
4395
+ details: {
4396
+ clientSchemaVersion: event.clientSchemaVersion,
4397
+ serverSchemaVersion: event.serverSchemaVersion,
4398
+ supportedMin: event.supportedMin,
4399
+ supportedMax: event.supportedMax,
4400
+ reason: event.reason
4401
+ }
4402
+ });
4403
+ });
4217
4404
  }
4218
4405
  /**
4219
4406
  * Get the metrics collector for external access (e.g., HTTP endpoints).
@@ -4416,6 +4603,33 @@ var KoraSyncServer = class _KoraSyncServer {
4416
4603
  sessionId
4417
4604
  });
4418
4605
  });
4606
+ sessionEmitter.on("sync:schema-mismatch", (event) => {
4607
+ this.logger.log({
4608
+ timestamp: Date.now(),
4609
+ level: "warn",
4610
+ event: "session.schema_mismatch",
4611
+ sessionId,
4612
+ details: {
4613
+ clientSchemaVersion: event.clientSchemaVersion,
4614
+ serverSchemaVersion: event.serverSchemaVersion,
4615
+ supportedMin: event.supportedMin,
4616
+ supportedMax: event.supportedMax,
4617
+ reason: event.reason
4618
+ }
4619
+ });
4620
+ });
4621
+ sessionEmitter.on("sync:delivery-stalled", (event) => {
4622
+ this.logger.log({
4623
+ timestamp: Date.now(),
4624
+ level: "warn",
4625
+ event: "session.delivery_stalled",
4626
+ sessionId,
4627
+ details: {
4628
+ watermark: event.watermark,
4629
+ repeatCount: event.repeatCount
4630
+ }
4631
+ });
4632
+ });
4419
4633
  const session = new ClientSession({
4420
4634
  sessionId,
4421
4635
  transport,
@@ -4427,6 +4641,7 @@ var KoraSyncServer = class _KoraSyncServer {
4427
4641
  batchSize: this.batchSize,
4428
4642
  schemaVersion: this.schemaVersion,
4429
4643
  supportedSchemaVersions: this.supportedSchemaVersions,
4644
+ operationTransforms: this.operationTransforms,
4430
4645
  onRelay: (sourceSessionId, operations2) => {
4431
4646
  this.handleRelay(sourceSessionId, operations2);
4432
4647
  },