@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.cjs CHANGED
@@ -2397,6 +2397,7 @@ var WsServerTransport = class {
2397
2397
  // src/session/client-session.ts
2398
2398
  init_cjs_shims();
2399
2399
  var import_core10 = require("@korajs/core");
2400
+ var import_core11 = require("@korajs/core");
2400
2401
  var import_internal = require("@korajs/core/internal");
2401
2402
  var import_sync2 = require("@korajs/sync");
2402
2403
  var import_sync3 = require("@korajs/sync");
@@ -2522,6 +2523,18 @@ function nextServerSequenceNumber(store) {
2522
2523
  async function applyServerOperation(store, op, relationLookup) {
2523
2524
  const schema = store.getSchema();
2524
2525
  const lookup = relationLookup ?? (schema ? (0, import_merge2.buildMergeRelationLookup)(schema) : /* @__PURE__ */ new Map());
2526
+ const shapeCheck = validateOperationShape(op, schema);
2527
+ if (!shapeCheck.valid) {
2528
+ return {
2529
+ result: "skipped",
2530
+ appliedOperations: [],
2531
+ rejection: {
2532
+ code: shapeCheck.code ?? "SCHEMA_VALIDATION_ERROR",
2533
+ message: shapeCheck.message ?? `Operation "${op.id}" does not match the server schema`,
2534
+ retriable: false
2535
+ }
2536
+ };
2537
+ }
2525
2538
  const constraintCheck = await validateIncomingOperationConstraints(store, op, schema);
2526
2539
  if (!constraintCheck.valid) {
2527
2540
  const code = constraintCheck.code ?? "CONSTRAINT_VIOLATION";
@@ -2575,6 +2588,67 @@ async function applyServerOperation(store, op, relationLookup) {
2575
2588
  appliedOperations: result === "applied" ? [op] : []
2576
2589
  };
2577
2590
  }
2591
+ function validateOperationShape(op, schema) {
2592
+ if (!schema) {
2593
+ return { valid: true };
2594
+ }
2595
+ const collection = schema.collections[op.collection];
2596
+ if (!collection) {
2597
+ return {
2598
+ valid: false,
2599
+ code: "UNKNOWN_COLLECTION",
2600
+ message: `Operation "${op.id}" targets unknown collection "${op.collection}".`
2601
+ };
2602
+ }
2603
+ const declaredFields = new Set(Object.keys(collection.fields));
2604
+ const systemPreviousFields = /* @__PURE__ */ new Set(["id", "_created_at", "_updated_at", "_deleted"]);
2605
+ const invalidDataField = firstInvalidField(op.data, declaredFields);
2606
+ if (invalidDataField) {
2607
+ return {
2608
+ valid: false,
2609
+ code: "SCHEMA_VALIDATION_ERROR",
2610
+ message: `Operation "${op.id}" contains undeclared field "${invalidDataField}" in data for collection "${op.collection}".`
2611
+ };
2612
+ }
2613
+ const invalidPreviousField = firstInvalidField(
2614
+ op.previousData,
2615
+ declaredFields,
2616
+ systemPreviousFields
2617
+ );
2618
+ if (invalidPreviousField) {
2619
+ return {
2620
+ valid: false,
2621
+ code: "SCHEMA_VALIDATION_ERROR",
2622
+ message: `Operation "${op.id}" contains undeclared field "${invalidPreviousField}" in previousData for collection "${op.collection}".`
2623
+ };
2624
+ }
2625
+ if (op.atomicOps) {
2626
+ for (const field of Object.keys(op.atomicOps)) {
2627
+ if (!declaredFields.has(field)) {
2628
+ return {
2629
+ valid: false,
2630
+ code: "SCHEMA_VALIDATION_ERROR",
2631
+ message: `Operation "${op.id}" contains undeclared atomic field "${field}" for collection "${op.collection}".`
2632
+ };
2633
+ }
2634
+ }
2635
+ }
2636
+ return { valid: true };
2637
+ }
2638
+ function firstInvalidField(value, declaredFields, extraAllowedFields) {
2639
+ if (value === null || value === void 0) {
2640
+ return null;
2641
+ }
2642
+ if (typeof value !== "object" || Array.isArray(value)) {
2643
+ return null;
2644
+ }
2645
+ for (const field of Object.keys(value)) {
2646
+ if (!declaredFields.has(field) && !extraAllowedFields?.has(field)) {
2647
+ return field;
2648
+ }
2649
+ }
2650
+ return null;
2651
+ }
2578
2652
 
2579
2653
  // src/auth/no-auth.ts
2580
2654
  init_cjs_shims();
@@ -2626,6 +2700,7 @@ function operationMatchesScopes(op, scopes, fullRecord) {
2626
2700
  if (!scopes) return true;
2627
2701
  const collectionScope = scopes[op.collection];
2628
2702
  if (!collectionScope) return false;
2703
+ if (Object.keys(collectionScope).length === 0) return true;
2629
2704
  const snapshot = buildSnapshot(op, fullRecord);
2630
2705
  if (!snapshot) return false;
2631
2706
  for (const [field, expected] of Object.entries(collectionScope)) {
@@ -2639,6 +2714,7 @@ function missingScopeFields(op, scopes) {
2639
2714
  if (!scopes) return [];
2640
2715
  const collectionScope = scopes[op.collection];
2641
2716
  if (!collectionScope) return [];
2717
+ if (Object.keys(collectionScope).length === 0) return [];
2642
2718
  const snapshot = buildSnapshot(op);
2643
2719
  return Object.keys(collectionScope).filter((field) => !snapshot || !(field in snapshot));
2644
2720
  }
@@ -2777,6 +2853,13 @@ var ClientSession = class _ClientSession {
2777
2853
  * client's reported watermark at handshake.
2778
2854
  */
2779
2855
  lastAckedDeliverySeq = 0;
2856
+ /**
2857
+ * Timestamp of the last delivery-stream send attempt. Periodic retransmission uses
2858
+ * this as its stale window so a slow client does not receive duplicate full-stream
2859
+ * batches every server tick while the first batch is still being applied.
2860
+ */
2861
+ lastDeliveryPushAttemptAtMs = 0;
2862
+ deliveryStallRepeatCount = 0;
2780
2863
  /** Serializes incremental delivery pushes so their batches never interleave. */
2781
2864
  deliveryPushChain = Promise.resolve();
2782
2865
  /**
@@ -2799,6 +2882,7 @@ var ClientSession = class _ClientSession {
2799
2882
  batchSize;
2800
2883
  schemaVersion;
2801
2884
  supportedSchemaVersions;
2885
+ operationTransforms;
2802
2886
  onRelay;
2803
2887
  onAwarenessUpdate;
2804
2888
  onYjsDocUpdate;
@@ -2828,6 +2912,7 @@ var ClientSession = class _ClientSession {
2828
2912
  min: this.schemaVersion,
2829
2913
  max: this.schemaVersion
2830
2914
  };
2915
+ this.operationTransforms = options.operationTransforms ?? [];
2831
2916
  this.onRelay = options.onRelay ?? null;
2832
2917
  this.onAwarenessUpdate = options.onAwarenessUpdate ?? null;
2833
2918
  this.onYjsDocUpdate = options.onYjsDocUpdate ?? null;
@@ -2877,6 +2962,7 @@ var ClientSession = class _ClientSession {
2877
2962
  this.deliveryPushChain = this.deliveryPushChain.then(async () => {
2878
2963
  if (this.state !== "streaming" || !this.transport.isConnected()) return;
2879
2964
  try {
2965
+ this.lastDeliveryPushAttemptAtMs = Date.now();
2880
2966
  await this.sendDeliveryStream(
2881
2967
  this.lastAckedDeliverySeq,
2882
2968
  false,
@@ -2898,7 +2984,7 @@ var ClientSession = class _ClientSession {
2898
2984
  const serializedOps = visibleOperations.map((op) => this.serializer.encodeOperation(op));
2899
2985
  const msg = {
2900
2986
  type: "operation-batch",
2901
- messageId: (0, import_core10.generateUUIDv7)(),
2987
+ messageId: (0, import_core11.generateUUIDv7)(),
2902
2988
  operations: serializedOps,
2903
2989
  isFinal: true,
2904
2990
  batchIndex: 0
@@ -2924,6 +3010,18 @@ var ClientSession = class _ClientSession {
2924
3010
  retransmitPendingRelays(staleMs = 0) {
2925
3011
  if (this.state !== "streaming" || !this.transport.isConnected()) return;
2926
3012
  if (this.clientDeliveryWatermark !== null) {
3013
+ if (staleMs > 0 && Date.now() - this.lastDeliveryPushAttemptAtMs < staleMs) {
3014
+ return;
3015
+ }
3016
+ this.deliveryStallRepeatCount += 1;
3017
+ if (this.deliveryStallRepeatCount >= 3) {
3018
+ this.emitter?.emit({
3019
+ type: "sync:delivery-stalled",
3020
+ sessionId: this.sessionId,
3021
+ watermark: this.lastAckedDeliverySeq,
3022
+ repeatCount: this.deliveryStallRepeatCount
3023
+ });
3024
+ }
2927
3025
  this.pushDeliveryStream();
2928
3026
  return;
2929
3027
  }
@@ -3016,6 +3114,7 @@ var ClientSession = class _ClientSession {
3016
3114
  this.pendingRelays.delete(message.acknowledgedMessageId);
3017
3115
  if (message.deliverySequence !== void 0) {
3018
3116
  this.lastAckedDeliverySeq = Math.max(this.lastAckedDeliverySeq, message.deliverySequence);
3117
+ this.deliveryStallRepeatCount = 0;
3019
3118
  }
3020
3119
  break;
3021
3120
  case "error":
@@ -3047,7 +3146,7 @@ var ClientSession = class _ClientSession {
3047
3146
  return;
3048
3147
  }
3049
3148
  const bytes = (0, import_sync2.decodeBlobChunkBytes)(message.bytes);
3050
- const actual = await (0, import_core10.hashBlob)(bytes);
3149
+ const actual = await (0, import_core11.hashBlob)(bytes);
3051
3150
  if (actual !== message.hash) {
3052
3151
  return;
3053
3152
  }
@@ -3122,9 +3221,17 @@ var ClientSession = class _ClientSession {
3122
3221
  this.setSerializerWireFormat(selectedWireFormat);
3123
3222
  if (!(0, import_sync3.isClientSchemaVersionSupported)(msg.schemaVersion, this.supportedSchemaVersions)) {
3124
3223
  const { min, max } = this.supportedSchemaVersions;
3224
+ this.emitter?.emit({
3225
+ type: "sync:schema-mismatch",
3226
+ clientSchemaVersion: msg.schemaVersion,
3227
+ serverSchemaVersion: this.schemaVersion,
3228
+ supportedMin: min,
3229
+ supportedMax: max,
3230
+ reason: `${import_sync3.SCHEMA_MISMATCH_PREFIX}: client schema version ${msg.schemaVersion} not in supported range [${min}, ${max}]`
3231
+ });
3125
3232
  const response2 = {
3126
3233
  type: "handshake-response",
3127
- messageId: (0, import_core10.generateUUIDv7)(),
3234
+ messageId: (0, import_core11.generateUUIDv7)(),
3128
3235
  nodeId: this.store.getNodeId(),
3129
3236
  versionVector: (0, import_sync3.versionVectorToWire)(serverVector),
3130
3237
  schemaVersion: this.schemaVersion,
@@ -3140,7 +3247,7 @@ var ClientSession = class _ClientSession {
3140
3247
  }
3141
3248
  const response = {
3142
3249
  type: "handshake-response",
3143
- messageId: (0, import_core10.generateUUIDv7)(),
3250
+ messageId: (0, import_core11.generateUUIDv7)(),
3144
3251
  nodeId: this.store.getNodeId(),
3145
3252
  versionVector: (0, import_sync3.versionVectorToWire)(serverVector),
3146
3253
  schemaVersion: this.schemaVersion,
@@ -3164,6 +3271,7 @@ var ClientSession = class _ClientSession {
3164
3271
  if (this.clientDeliveryWatermark !== null) {
3165
3272
  this.lastAckedDeliverySeq = this.clientDeliveryWatermark;
3166
3273
  const excludeOwn = this.clientDeliveryWatermark > 0 ? this.clientNodeId ?? void 0 : void 0;
3274
+ this.lastDeliveryPushAttemptAtMs = Date.now();
3167
3275
  await this.sendDeliveryStream(this.clientDeliveryWatermark, true, excludeOwn);
3168
3276
  } else {
3169
3277
  const clientVector = (0, import_sync3.wireToVersionVector)(msg.versionVector);
@@ -3180,10 +3288,20 @@ var ClientSession = class _ClientSession {
3180
3288
  async handleOperationBatch(msg) {
3181
3289
  const operations2 = msg.operations.map((s) => this.serializer.decodeOperation(s));
3182
3290
  const applied = [];
3183
- const rejected = [];
3291
+ let acknowledgedThrough = 0;
3292
+ let canAdvanceAck = true;
3184
3293
  for (const op of operations2) {
3294
+ if (!canAdvanceAck) {
3295
+ continue;
3296
+ }
3185
3297
  if (!await this.operationVisibleToClient(op)) {
3186
- rejected.push(op);
3298
+ this.sendOperationRejected(
3299
+ op,
3300
+ "SCOPE_VIOLATION",
3301
+ `Operation "${op.id}" in collection "${op.collection}" is outside the client's sync scope. Refresh sync scopes and retry.`,
3302
+ true
3303
+ );
3304
+ canAdvanceAck = false;
3187
3305
  continue;
3188
3306
  }
3189
3307
  if (!isOperationTimestampValid(op)) {
@@ -3192,6 +3310,7 @@ var ClientSession = class _ClientSession {
3192
3310
  `Operation "${op.id}" timestamp is too far in the future`,
3193
3311
  false
3194
3312
  );
3313
+ canAdvanceAck = false;
3195
3314
  continue;
3196
3315
  }
3197
3316
  if (!this.rateLimiter.allow(1)) {
@@ -3200,6 +3319,7 @@ var ClientSession = class _ClientSession {
3200
3319
  `Session exceeded operation rate limit (${String(this.maxOpsPerMinute)} ops/min)`,
3201
3320
  true
3202
3321
  );
3322
+ canAdvanceAck = false;
3203
3323
  continue;
3204
3324
  }
3205
3325
  const sizeCheck = validateOperationSize(op, this.maxOperationBytes);
@@ -3209,53 +3329,73 @@ var ClientSession = class _ClientSession {
3209
3329
  sizeCheck.message ?? `Operation "${op.id}" is too large`,
3210
3330
  false
3211
3331
  );
3332
+ canAdvanceAck = false;
3333
+ continue;
3334
+ }
3335
+ const serverOp = this.transformForServerSchema(op);
3336
+ if (serverOp === null) {
3337
+ this.sendOperationRejected(
3338
+ op,
3339
+ "SCHEMA_TRANSFORM_UNAVAILABLE",
3340
+ `Operation "${op.id}" cannot be transformed from schema v${op.schemaVersion} to server schema v${this.schemaVersion}.`,
3341
+ false
3342
+ );
3343
+ acknowledgedThrough = op.sequenceNumber;
3212
3344
  continue;
3213
3345
  }
3214
3346
  if (this.validateOperation && this.koraContext) {
3215
3347
  let decision;
3216
3348
  try {
3217
- decision = await this.validateOperation(op, {
3349
+ decision = await this.validateOperation(serverOp, {
3218
3350
  auth: this.authContext,
3219
3351
  kora: this.koraContext
3220
3352
  });
3221
3353
  } catch (error) {
3222
3354
  const message = error instanceof Error ? error.message : "validator error";
3223
- this.sendOperationRejected(op, "VALIDATION_ERROR", `Validator threw: ${message}`, true);
3355
+ this.sendOperationRejected(
3356
+ serverOp,
3357
+ "VALIDATION_ERROR",
3358
+ `Validator threw: ${message}`,
3359
+ true
3360
+ );
3361
+ canAdvanceAck = false;
3224
3362
  continue;
3225
3363
  }
3226
3364
  if (decision.action === "reject") {
3227
- this.sendOperationRejected(
3228
- op,
3229
- decision.code,
3230
- decision.message,
3231
- decision.retriable ?? isRetriableRejection(decision.code)
3232
- );
3365
+ const retriable = decision.retriable ?? isRetriableRejection(decision.code);
3366
+ this.sendOperationRejected(serverOp, decision.code, decision.message, retriable);
3367
+ if (retriable) {
3368
+ canAdvanceAck = false;
3369
+ } else {
3370
+ acknowledgedThrough = op.sequenceNumber;
3371
+ }
3233
3372
  continue;
3234
3373
  }
3235
3374
  if (decision.action === "ignore") {
3375
+ acknowledgedThrough = op.sequenceNumber;
3236
3376
  continue;
3237
3377
  }
3238
3378
  }
3239
- const applyResult = await applyServerOperation(this.store, op);
3379
+ const applyResult = await applyServerOperation(this.store, serverOp);
3240
3380
  if (applyResult.rejection) {
3241
- this.sendError(
3381
+ this.sendOperationRejected(
3382
+ serverOp,
3242
3383
  applyResult.rejection.code,
3243
3384
  applyResult.rejection.message,
3244
3385
  applyResult.rejection.retriable
3245
3386
  );
3387
+ if (applyResult.rejection.retriable) {
3388
+ canAdvanceAck = false;
3389
+ } else {
3390
+ acknowledgedThrough = op.sequenceNumber;
3391
+ }
3246
3392
  continue;
3247
3393
  }
3248
3394
  if (applyResult.result === "applied") {
3249
3395
  applied.push(...applyResult.appliedOperations);
3250
- }
3251
- }
3252
- if (rejected.length > 0) {
3253
- for (const op of rejected) {
3254
- this.sendError(
3255
- "SCOPE_VIOLATION",
3256
- `Operation "${op.id}" in collection "${op.collection}" is outside the client's sync scope`,
3257
- false
3258
- );
3396
+ acknowledgedThrough = op.sequenceNumber;
3397
+ } else {
3398
+ acknowledgedThrough = op.sequenceNumber;
3259
3399
  }
3260
3400
  }
3261
3401
  if (operations2.length > 0) {
@@ -3268,15 +3408,21 @@ var ClientSession = class _ClientSession {
3268
3408
  const lastOp = operations2[operations2.length - 1];
3269
3409
  const ack = {
3270
3410
  type: "acknowledgment",
3271
- messageId: (0, import_core10.generateUUIDv7)(),
3411
+ messageId: (0, import_core11.generateUUIDv7)(),
3272
3412
  acknowledgedMessageId: msg.messageId,
3273
- lastSequenceNumber: lastOp ? lastOp.sequenceNumber : 0
3413
+ lastSequenceNumber: lastOp ? Math.min(acknowledgedThrough, lastOp.sequenceNumber) : 0
3274
3414
  };
3275
3415
  this.sendToClient(ack);
3276
3416
  if (applied.length > 0) {
3277
3417
  this.onRelay?.(this.sessionId, applied);
3278
3418
  }
3279
3419
  }
3420
+ transformForServerSchema(op) {
3421
+ if (op.schemaVersion === this.schemaVersion) {
3422
+ return op;
3423
+ }
3424
+ return (0, import_core10.applyOperationTransforms)(op, this.schemaVersion, this.operationTransforms);
3425
+ }
3280
3426
  async sendDelta(clientVector) {
3281
3427
  const serverVector = this.store.getVersionVector();
3282
3428
  const missing = [];
@@ -3294,7 +3440,7 @@ var ClientSession = class _ClientSession {
3294
3440
  if (missing.length === 0) {
3295
3441
  const emptyBatch = {
3296
3442
  type: "operation-batch",
3297
- messageId: (0, import_core10.generateUUIDv7)(),
3443
+ messageId: (0, import_core11.generateUUIDv7)(),
3298
3444
  operations: [],
3299
3445
  isFinal: true,
3300
3446
  batchIndex: 0,
@@ -3309,7 +3455,7 @@ var ClientSession = class _ClientSession {
3309
3455
  if (afterCursor.length === 0) {
3310
3456
  const emptyBatch = {
3311
3457
  type: "operation-batch",
3312
- messageId: (0, import_core10.generateUUIDv7)(),
3458
+ messageId: (0, import_core11.generateUUIDv7)(),
3313
3459
  operations: [],
3314
3460
  isFinal: true,
3315
3461
  batchIndex: this.resumeDeltaCursor?.batchIndex ?? 0,
@@ -3325,7 +3471,7 @@ var ClientSession = class _ClientSession {
3325
3471
  const batchCursor = (0, import_sync3.createDeltaCursorFromBatch)(batchOps, i);
3326
3472
  const batchMsg = {
3327
3473
  type: "operation-batch",
3328
- messageId: (0, import_core10.generateUUIDv7)(),
3474
+ messageId: (0, import_core11.generateUUIDv7)(),
3329
3475
  operations: serializedOps,
3330
3476
  isFinal: i === totalBatches - 1,
3331
3477
  batchIndex: i,
@@ -3402,7 +3548,7 @@ var ClientSession = class _ClientSession {
3402
3548
  sendDeliveryBatch(slice, base, max, batchIndex, isFinal) {
3403
3549
  const batchMsg = {
3404
3550
  type: "operation-batch",
3405
- messageId: (0, import_core10.generateUUIDv7)(),
3551
+ messageId: (0, import_core11.generateUUIDv7)(),
3406
3552
  operations: slice.map((delivered) => this.serializer.encodeOperation(delivered.operation)),
3407
3553
  isFinal,
3408
3554
  batchIndex,
@@ -3455,7 +3601,7 @@ var ClientSession = class _ClientSession {
3455
3601
  sendError(code, message, retriable) {
3456
3602
  const errorMsg = {
3457
3603
  type: "error",
3458
- messageId: (0, import_core10.generateUUIDv7)(),
3604
+ messageId: (0, import_core11.generateUUIDv7)(),
3459
3605
  code,
3460
3606
  message,
3461
3607
  retriable
@@ -3471,7 +3617,7 @@ var ClientSession = class _ClientSession {
3471
3617
  sendOperationRejected(op, code, message, retriable) {
3472
3618
  const rejectedMsg = {
3473
3619
  type: "operation-rejected",
3474
- messageId: (0, import_core10.generateUUIDv7)(),
3620
+ messageId: (0, import_core11.generateUUIDv7)(),
3475
3621
  operationId: op.id,
3476
3622
  collection: op.collection,
3477
3623
  recordId: op.recordId,
@@ -3503,13 +3649,13 @@ function selectWireFormat(supportedWireFormats) {
3503
3649
 
3504
3650
  // src/server/kora-sync-server.ts
3505
3651
  init_cjs_shims();
3506
- var import_core13 = require("@korajs/core");
3652
+ var import_core14 = require("@korajs/core");
3507
3653
  var import_internal2 = require("@korajs/core/internal");
3508
3654
  var import_sync5 = require("@korajs/sync");
3509
3655
 
3510
3656
  // src/awareness/awareness-relay.ts
3511
3657
  init_cjs_shims();
3512
- var import_core11 = require("@korajs/core");
3658
+ var import_core12 = require("@korajs/core");
3513
3659
  var AwarenessRelay = class {
3514
3660
  clients = /* @__PURE__ */ new Map();
3515
3661
  /**
@@ -3538,7 +3684,7 @@ var AwarenessRelay = class {
3538
3684
  if (hasStates) {
3539
3685
  const catchUpMsg = {
3540
3686
  type: "awareness-update",
3541
- messageId: (0, import_core11.generateUUIDv7)(),
3687
+ messageId: (0, import_core12.generateUUIDv7)(),
3542
3688
  clientId: 0,
3543
3689
  // Server-sourced
3544
3690
  states: existingStates
@@ -3561,7 +3707,7 @@ var AwarenessRelay = class {
3561
3707
  };
3562
3708
  const msg = {
3563
3709
  type: "awareness-update",
3564
- messageId: (0, import_core11.generateUUIDv7)(),
3710
+ messageId: (0, import_core12.generateUUIDv7)(),
3565
3711
  clientId: client.clientId,
3566
3712
  states: removalStates
3567
3713
  };
@@ -3867,7 +4013,7 @@ var YjsDocRelay = class {
3867
4013
 
3868
4014
  // src/server/route-context.ts
3869
4015
  init_cjs_shims();
3870
- var import_core12 = require("@korajs/core");
4016
+ var import_core13 = require("@korajs/core");
3871
4017
 
3872
4018
  // src/apply/route-predicate.ts
3873
4019
  init_cjs_shims();
@@ -3942,16 +4088,16 @@ function pickFields(record, keys) {
3942
4088
  }
3943
4089
  async function buildRouteOperation(store, mutation, currentOverride, clockOverride) {
3944
4090
  const nodeId = store.getNodeId();
3945
- const clock = clockOverride ?? new import_core12.HybridLogicalClock(nodeId);
4091
+ const clock = clockOverride ?? new import_core13.HybridLogicalClock(nodeId);
3946
4092
  const schemaVersion = store.getSchema()?.version ?? 1;
3947
4093
  const sequenceNumber = nextServerSequenceNumber(store);
3948
4094
  if (mutation.type === "insert") {
3949
- return (0, import_core12.createOperation)(
4095
+ return (0, import_core13.createOperation)(
3950
4096
  {
3951
4097
  nodeId,
3952
4098
  type: "insert",
3953
4099
  collection: mutation.collection,
3954
- recordId: mutation.recordId ?? (0, import_core12.generateUUIDv7)(),
4100
+ recordId: mutation.recordId ?? (0, import_core13.generateUUIDv7)(),
3955
4101
  data: mutation.data ?? {},
3956
4102
  previousData: null,
3957
4103
  sequenceNumber,
@@ -3974,14 +4120,14 @@ async function buildRouteOperation(store, mutation, currentOverride, clockOverri
3974
4120
  const atomicOps = {};
3975
4121
  for (const key of Object.keys(rawData)) {
3976
4122
  const value = rawData[key];
3977
- if ((0, import_core12.isAtomicOp)(value)) {
3978
- data[key] = (0, import_core12.resolveAtomicOp)(current ? current[key] : void 0, value);
3979
- atomicOps[key] = (0, import_core12.toAtomicOp)(value);
4123
+ if ((0, import_core13.isAtomicOp)(value)) {
4124
+ data[key] = (0, import_core13.resolveAtomicOp)(current ? current[key] : void 0, value);
4125
+ atomicOps[key] = (0, import_core13.toAtomicOp)(value);
3980
4126
  } else {
3981
4127
  data[key] = value;
3982
4128
  }
3983
4129
  }
3984
- return (0, import_core12.createOperation)(
4130
+ return (0, import_core13.createOperation)(
3985
4131
  {
3986
4132
  nodeId,
3987
4133
  type: "update",
@@ -3997,7 +4143,7 @@ async function buildRouteOperation(store, mutation, currentOverride, clockOverri
3997
4143
  clock
3998
4144
  );
3999
4145
  }
4000
- return (0, import_core12.createOperation)(
4146
+ return (0, import_core13.createOperation)(
4001
4147
  {
4002
4148
  nodeId,
4003
4149
  type: "delete",
@@ -4263,6 +4409,7 @@ var KoraSyncServer = class _KoraSyncServer {
4263
4409
  batchSize;
4264
4410
  schemaVersion;
4265
4411
  supportedSchemaVersions;
4412
+ operationTransforms;
4266
4413
  port;
4267
4414
  host;
4268
4415
  path;
@@ -4300,16 +4447,24 @@ var KoraSyncServer = class _KoraSyncServer {
4300
4447
  static ORPHANED_RELAY_TTL_MS = 5 * 6e4;
4301
4448
  constructor(config) {
4302
4449
  this.store = config.store;
4450
+ const storeSchemaVersion = this.store.getSchema()?.version;
4451
+ if (config.schemaVersion !== void 0 && storeSchemaVersion !== void 0 && config.schemaVersion !== storeSchemaVersion) {
4452
+ throw new import_core14.SyncError("Sync schema version does not match the configured store schema.", {
4453
+ syncSchemaVersion: config.schemaVersion,
4454
+ storeSchemaVersion
4455
+ });
4456
+ }
4303
4457
  this.auth = config.auth ?? null;
4304
4458
  this.serializer = config.serializer ?? new import_sync5.JsonMessageSerializer();
4305
4459
  this.emitter = config.emitter ?? null;
4306
4460
  this.maxConnections = config.maxConnections ?? DEFAULT_MAX_CONNECTIONS;
4307
4461
  this.batchSize = config.batchSize ?? DEFAULT_BATCH_SIZE2;
4308
- this.schemaVersion = config.schemaVersion ?? DEFAULT_SCHEMA_VERSION2;
4462
+ this.schemaVersion = config.schemaVersion ?? storeSchemaVersion ?? DEFAULT_SCHEMA_VERSION2;
4309
4463
  this.supportedSchemaVersions = config.supportedSchemaVersions ?? {
4310
4464
  min: this.schemaVersion,
4311
4465
  max: this.schemaVersion
4312
4466
  };
4467
+ this.operationTransforms = config.operationTransforms ?? [];
4313
4468
  this.port = config.port;
4314
4469
  this.host = config.host ?? DEFAULT_HOST;
4315
4470
  this.path = config.path ?? DEFAULT_PATH;
@@ -4433,6 +4588,21 @@ var KoraSyncServer = class _KoraSyncServer {
4433
4588
  details: { reason: event.reason }
4434
4589
  });
4435
4590
  });
4591
+ sessionEmitter.on("sync:schema-mismatch", (event) => {
4592
+ this.logger.log({
4593
+ timestamp: Date.now(),
4594
+ level: "warn",
4595
+ event: "session.schema_mismatch",
4596
+ sessionId,
4597
+ details: {
4598
+ clientSchemaVersion: event.clientSchemaVersion,
4599
+ serverSchemaVersion: event.serverSchemaVersion,
4600
+ supportedMin: event.supportedMin,
4601
+ supportedMax: event.supportedMax,
4602
+ reason: event.reason
4603
+ }
4604
+ });
4605
+ });
4436
4606
  }
4437
4607
  /**
4438
4608
  * Get the metrics collector for external access (e.g., HTTP endpoints).
@@ -4453,10 +4623,10 @@ var KoraSyncServer = class _KoraSyncServer {
4453
4623
  */
4454
4624
  async start(wsServerImpl) {
4455
4625
  if (this.running) {
4456
- throw new import_core13.SyncError("Server is already running", { port: this.port });
4626
+ throw new import_core14.SyncError("Server is already running", { port: this.port });
4457
4627
  }
4458
4628
  if (!wsServerImpl && this.port === void 0) {
4459
- throw new import_core13.SyncError(
4629
+ throw new import_core14.SyncError(
4460
4630
  "Port is required for standalone mode. Provide port in config or use handleConnection() for attach mode.",
4461
4631
  {}
4462
4632
  );
@@ -4571,7 +4741,7 @@ var KoraSyncServer = class _KoraSyncServer {
4571
4741
  if (this.maxConnections > 0 && this.sessions.size >= this.maxConnections) {
4572
4742
  transport.send({
4573
4743
  type: "error",
4574
- messageId: (0, import_core13.generateUUIDv7)(),
4744
+ messageId: (0, import_core14.generateUUIDv7)(),
4575
4745
  code: "MAX_CONNECTIONS",
4576
4746
  message: `Server has reached maximum connections (${this.maxConnections})`,
4577
4747
  retriable: true
@@ -4584,12 +4754,12 @@ var KoraSyncServer = class _KoraSyncServer {
4584
4754
  event: "connection.rejected",
4585
4755
  details: { reason: "max_connections", max: this.maxConnections }
4586
4756
  });
4587
- throw new import_core13.SyncError("Maximum connections reached", {
4757
+ throw new import_core14.SyncError("Maximum connections reached", {
4588
4758
  current: this.sessions.size,
4589
4759
  max: this.maxConnections
4590
4760
  });
4591
4761
  }
4592
- const sessionId = (0, import_core13.generateUUIDv7)();
4762
+ const sessionId = (0, import_core14.generateUUIDv7)();
4593
4763
  this.metrics.recordConnection(sessionId);
4594
4764
  const sessionEmitter = new import_internal2.SimpleEventEmitter();
4595
4765
  sessionEmitter.on("sync:connected", (event) => {
@@ -4635,6 +4805,33 @@ var KoraSyncServer = class _KoraSyncServer {
4635
4805
  sessionId
4636
4806
  });
4637
4807
  });
4808
+ sessionEmitter.on("sync:schema-mismatch", (event) => {
4809
+ this.logger.log({
4810
+ timestamp: Date.now(),
4811
+ level: "warn",
4812
+ event: "session.schema_mismatch",
4813
+ sessionId,
4814
+ details: {
4815
+ clientSchemaVersion: event.clientSchemaVersion,
4816
+ serverSchemaVersion: event.serverSchemaVersion,
4817
+ supportedMin: event.supportedMin,
4818
+ supportedMax: event.supportedMax,
4819
+ reason: event.reason
4820
+ }
4821
+ });
4822
+ });
4823
+ sessionEmitter.on("sync:delivery-stalled", (event) => {
4824
+ this.logger.log({
4825
+ timestamp: Date.now(),
4826
+ level: "warn",
4827
+ event: "session.delivery_stalled",
4828
+ sessionId,
4829
+ details: {
4830
+ watermark: event.watermark,
4831
+ repeatCount: event.repeatCount
4832
+ }
4833
+ });
4834
+ });
4638
4835
  const session = new ClientSession({
4639
4836
  sessionId,
4640
4837
  transport,
@@ -4646,6 +4843,7 @@ var KoraSyncServer = class _KoraSyncServer {
4646
4843
  batchSize: this.batchSize,
4647
4844
  schemaVersion: this.schemaVersion,
4648
4845
  supportedSchemaVersions: this.supportedSchemaVersions,
4846
+ operationTransforms: this.operationTransforms,
4649
4847
  onRelay: (sourceSessionId, operations2) => {
4650
4848
  this.handleRelay(sourceSessionId, operations2);
4651
4849
  },
@@ -4773,7 +4971,7 @@ var KoraSyncServer = class _KoraSyncServer {
4773
4971
  const records = await this.store.materializeCollection(name);
4774
4972
  for (const record of records) {
4775
4973
  for (const value of Object.values(record)) {
4776
- if ((0, import_core13.isBlobRef)(value)) {
4974
+ if ((0, import_core14.isBlobRef)(value)) {
4777
4975
  refs.push(value);
4778
4976
  }
4779
4977
  }