@korajs/server 1.0.0-beta.7 → 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
  }
@@ -2573,6 +2649,13 @@ var ClientSession = class _ClientSession {
2573
2649
  * client's reported watermark at handshake.
2574
2650
  */
2575
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;
2576
2659
  /** Serializes incremental delivery pushes so their batches never interleave. */
2577
2660
  deliveryPushChain = Promise.resolve();
2578
2661
  /**
@@ -2595,6 +2678,7 @@ var ClientSession = class _ClientSession {
2595
2678
  batchSize;
2596
2679
  schemaVersion;
2597
2680
  supportedSchemaVersions;
2681
+ operationTransforms;
2598
2682
  onRelay;
2599
2683
  onAwarenessUpdate;
2600
2684
  onYjsDocUpdate;
@@ -2624,6 +2708,7 @@ var ClientSession = class _ClientSession {
2624
2708
  min: this.schemaVersion,
2625
2709
  max: this.schemaVersion
2626
2710
  };
2711
+ this.operationTransforms = options.operationTransforms ?? [];
2627
2712
  this.onRelay = options.onRelay ?? null;
2628
2713
  this.onAwarenessUpdate = options.onAwarenessUpdate ?? null;
2629
2714
  this.onYjsDocUpdate = options.onYjsDocUpdate ?? null;
@@ -2673,6 +2758,7 @@ var ClientSession = class _ClientSession {
2673
2758
  this.deliveryPushChain = this.deliveryPushChain.then(async () => {
2674
2759
  if (this.state !== "streaming" || !this.transport.isConnected()) return;
2675
2760
  try {
2761
+ this.lastDeliveryPushAttemptAtMs = Date.now();
2676
2762
  await this.sendDeliveryStream(
2677
2763
  this.lastAckedDeliverySeq,
2678
2764
  false,
@@ -2720,6 +2806,18 @@ var ClientSession = class _ClientSession {
2720
2806
  retransmitPendingRelays(staleMs = 0) {
2721
2807
  if (this.state !== "streaming" || !this.transport.isConnected()) return;
2722
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
+ }
2723
2821
  this.pushDeliveryStream();
2724
2822
  return;
2725
2823
  }
@@ -2812,6 +2910,7 @@ var ClientSession = class _ClientSession {
2812
2910
  this.pendingRelays.delete(message.acknowledgedMessageId);
2813
2911
  if (message.deliverySequence !== void 0) {
2814
2912
  this.lastAckedDeliverySeq = Math.max(this.lastAckedDeliverySeq, message.deliverySequence);
2913
+ this.deliveryStallRepeatCount = 0;
2815
2914
  }
2816
2915
  break;
2817
2916
  case "error":
@@ -2918,6 +3017,14 @@ var ClientSession = class _ClientSession {
2918
3017
  this.setSerializerWireFormat(selectedWireFormat);
2919
3018
  if (!isClientSchemaVersionSupported(msg.schemaVersion, this.supportedSchemaVersions)) {
2920
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
+ });
2921
3028
  const response2 = {
2922
3029
  type: "handshake-response",
2923
3030
  messageId: generateUUIDv74(),
@@ -2960,6 +3067,7 @@ var ClientSession = class _ClientSession {
2960
3067
  if (this.clientDeliveryWatermark !== null) {
2961
3068
  this.lastAckedDeliverySeq = this.clientDeliveryWatermark;
2962
3069
  const excludeOwn = this.clientDeliveryWatermark > 0 ? this.clientNodeId ?? void 0 : void 0;
3070
+ this.lastDeliveryPushAttemptAtMs = Date.now();
2963
3071
  await this.sendDeliveryStream(this.clientDeliveryWatermark, true, excludeOwn);
2964
3072
  } else {
2965
3073
  const clientVector = wireToVersionVector(msg.versionVector);
@@ -2976,10 +3084,20 @@ var ClientSession = class _ClientSession {
2976
3084
  async handleOperationBatch(msg) {
2977
3085
  const operations2 = msg.operations.map((s) => this.serializer.decodeOperation(s));
2978
3086
  const applied = [];
2979
- const rejected = [];
3087
+ let acknowledgedThrough = 0;
3088
+ let canAdvanceAck = true;
2980
3089
  for (const op of operations2) {
3090
+ if (!canAdvanceAck) {
3091
+ continue;
3092
+ }
2981
3093
  if (!await this.operationVisibleToClient(op)) {
2982
- 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;
2983
3101
  continue;
2984
3102
  }
2985
3103
  if (!isOperationTimestampValid(op)) {
@@ -2988,6 +3106,7 @@ var ClientSession = class _ClientSession {
2988
3106
  `Operation "${op.id}" timestamp is too far in the future`,
2989
3107
  false
2990
3108
  );
3109
+ canAdvanceAck = false;
2991
3110
  continue;
2992
3111
  }
2993
3112
  if (!this.rateLimiter.allow(1)) {
@@ -2996,6 +3115,7 @@ var ClientSession = class _ClientSession {
2996
3115
  `Session exceeded operation rate limit (${String(this.maxOpsPerMinute)} ops/min)`,
2997
3116
  true
2998
3117
  );
3118
+ canAdvanceAck = false;
2999
3119
  continue;
3000
3120
  }
3001
3121
  const sizeCheck = validateOperationSize(op, this.maxOperationBytes);
@@ -3005,53 +3125,73 @@ var ClientSession = class _ClientSession {
3005
3125
  sizeCheck.message ?? `Operation "${op.id}" is too large`,
3006
3126
  false
3007
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;
3008
3140
  continue;
3009
3141
  }
3010
3142
  if (this.validateOperation && this.koraContext) {
3011
3143
  let decision;
3012
3144
  try {
3013
- decision = await this.validateOperation(op, {
3145
+ decision = await this.validateOperation(serverOp, {
3014
3146
  auth: this.authContext,
3015
3147
  kora: this.koraContext
3016
3148
  });
3017
3149
  } catch (error) {
3018
3150
  const message = error instanceof Error ? error.message : "validator error";
3019
- 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;
3020
3158
  continue;
3021
3159
  }
3022
3160
  if (decision.action === "reject") {
3023
- this.sendOperationRejected(
3024
- op,
3025
- decision.code,
3026
- decision.message,
3027
- decision.retriable ?? isRetriableRejection(decision.code)
3028
- );
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
+ }
3029
3168
  continue;
3030
3169
  }
3031
3170
  if (decision.action === "ignore") {
3171
+ acknowledgedThrough = op.sequenceNumber;
3032
3172
  continue;
3033
3173
  }
3034
3174
  }
3035
- const applyResult = await applyServerOperation(this.store, op);
3175
+ const applyResult = await applyServerOperation(this.store, serverOp);
3036
3176
  if (applyResult.rejection) {
3037
- this.sendError(
3177
+ this.sendOperationRejected(
3178
+ serverOp,
3038
3179
  applyResult.rejection.code,
3039
3180
  applyResult.rejection.message,
3040
3181
  applyResult.rejection.retriable
3041
3182
  );
3183
+ if (applyResult.rejection.retriable) {
3184
+ canAdvanceAck = false;
3185
+ } else {
3186
+ acknowledgedThrough = op.sequenceNumber;
3187
+ }
3042
3188
  continue;
3043
3189
  }
3044
3190
  if (applyResult.result === "applied") {
3045
3191
  applied.push(...applyResult.appliedOperations);
3046
- }
3047
- }
3048
- if (rejected.length > 0) {
3049
- for (const op of rejected) {
3050
- this.sendError(
3051
- "SCOPE_VIOLATION",
3052
- `Operation "${op.id}" in collection "${op.collection}" is outside the client's sync scope`,
3053
- false
3054
- );
3192
+ acknowledgedThrough = op.sequenceNumber;
3193
+ } else {
3194
+ acknowledgedThrough = op.sequenceNumber;
3055
3195
  }
3056
3196
  }
3057
3197
  if (operations2.length > 0) {
@@ -3066,13 +3206,19 @@ var ClientSession = class _ClientSession {
3066
3206
  type: "acknowledgment",
3067
3207
  messageId: generateUUIDv74(),
3068
3208
  acknowledgedMessageId: msg.messageId,
3069
- lastSequenceNumber: lastOp ? lastOp.sequenceNumber : 0
3209
+ lastSequenceNumber: lastOp ? Math.min(acknowledgedThrough, lastOp.sequenceNumber) : 0
3070
3210
  };
3071
3211
  this.sendToClient(ack);
3072
3212
  if (applied.length > 0) {
3073
3213
  this.onRelay?.(this.sessionId, applied);
3074
3214
  }
3075
3215
  }
3216
+ transformForServerSchema(op) {
3217
+ if (op.schemaVersion === this.schemaVersion) {
3218
+ return op;
3219
+ }
3220
+ return applyOperationTransforms(op, this.schemaVersion, this.operationTransforms);
3221
+ }
3076
3222
  async sendDelta(clientVector) {
3077
3223
  const serverVector = this.store.getVersionVector();
3078
3224
  const missing = [];
@@ -4061,6 +4207,7 @@ var KoraSyncServer = class _KoraSyncServer {
4061
4207
  batchSize;
4062
4208
  schemaVersion;
4063
4209
  supportedSchemaVersions;
4210
+ operationTransforms;
4064
4211
  port;
4065
4212
  host;
4066
4213
  path;
@@ -4098,16 +4245,24 @@ var KoraSyncServer = class _KoraSyncServer {
4098
4245
  static ORPHANED_RELAY_TTL_MS = 5 * 6e4;
4099
4246
  constructor(config) {
4100
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
+ }
4101
4255
  this.auth = config.auth ?? null;
4102
4256
  this.serializer = config.serializer ?? new JsonMessageSerializer2();
4103
4257
  this.emitter = config.emitter ?? null;
4104
4258
  this.maxConnections = config.maxConnections ?? DEFAULT_MAX_CONNECTIONS;
4105
4259
  this.batchSize = config.batchSize ?? DEFAULT_BATCH_SIZE2;
4106
- this.schemaVersion = config.schemaVersion ?? DEFAULT_SCHEMA_VERSION2;
4260
+ this.schemaVersion = config.schemaVersion ?? storeSchemaVersion ?? DEFAULT_SCHEMA_VERSION2;
4107
4261
  this.supportedSchemaVersions = config.supportedSchemaVersions ?? {
4108
4262
  min: this.schemaVersion,
4109
4263
  max: this.schemaVersion
4110
4264
  };
4265
+ this.operationTransforms = config.operationTransforms ?? [];
4111
4266
  this.port = config.port;
4112
4267
  this.host = config.host ?? DEFAULT_HOST;
4113
4268
  this.path = config.path ?? DEFAULT_PATH;
@@ -4231,6 +4386,21 @@ var KoraSyncServer = class _KoraSyncServer {
4231
4386
  details: { reason: event.reason }
4232
4387
  });
4233
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
+ });
4234
4404
  }
4235
4405
  /**
4236
4406
  * Get the metrics collector for external access (e.g., HTTP endpoints).
@@ -4433,6 +4603,33 @@ var KoraSyncServer = class _KoraSyncServer {
4433
4603
  sessionId
4434
4604
  });
4435
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
+ });
4436
4633
  const session = new ClientSession({
4437
4634
  sessionId,
4438
4635
  transport,
@@ -4444,6 +4641,7 @@ var KoraSyncServer = class _KoraSyncServer {
4444
4641
  batchSize: this.batchSize,
4445
4642
  schemaVersion: this.schemaVersion,
4446
4643
  supportedSchemaVersions: this.supportedSchemaVersions,
4644
+ operationTransforms: this.operationTransforms,
4447
4645
  onRelay: (sourceSessionId, operations2) => {
4448
4646
  this.handleRelay(sourceSessionId, operations2);
4449
4647
  },