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

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.js CHANGED
@@ -2570,6 +2570,20 @@ function operationMatchesScopes(op, scopes, fullRecord) {
2570
2570
  }
2571
2571
  return true;
2572
2572
  }
2573
+ function operationExitsScopes(op, scopes, resultingRecord) {
2574
+ if (!scopes || op.type !== "update" || !op.previousData) return false;
2575
+ if (operationMatchesScopes(op, scopes, resultingRecord)) return false;
2576
+ const previousSnapshot = {
2577
+ ...resultingRecord ?? {},
2578
+ ...op.data ?? {},
2579
+ ...op.previousData
2580
+ };
2581
+ return operationMatchesScopes(
2582
+ { ...op, type: "insert", data: previousSnapshot, previousData: null },
2583
+ scopes,
2584
+ previousSnapshot
2585
+ );
2586
+ }
2573
2587
  function matchesPredicate(actual, expected) {
2574
2588
  if (expected && typeof expected === "object" && !Array.isArray(expected) && "$in" in expected) {
2575
2589
  const values = expected.$in;
@@ -2701,6 +2715,7 @@ var ClientSession = class _ClientSession {
2701
2715
  clientNodeId = null;
2702
2716
  authContext = null;
2703
2717
  syncQuerySubsets = [];
2718
+ scopeExitPolicy = "retain";
2704
2719
  resumeDeltaCursor = null;
2705
2720
  /**
2706
2721
  * The delivery watermark the client reported at handshake, or null when the client
@@ -2725,7 +2740,7 @@ var ClientSession = class _ClientSession {
2725
2740
  * batches every server tick while the first batch is still being applied.
2726
2741
  */
2727
2742
  lastDeliveryPushAttemptAtMs = 0;
2728
- deliveryStallRepeatCount = 0;
2743
+ outstandingDelivery = null;
2729
2744
  /** Serializes incremental delivery pushes so their batches never interleave. */
2730
2745
  deliveryPushChain = Promise.resolve();
2731
2746
  /**
@@ -2824,34 +2839,66 @@ var ClientSession = class _ClientSession {
2824
2839
  * from the highest delivery sequence already sent. Pushes are serialized so their
2825
2840
  * batches never interleave (which would break the base/max chain).
2826
2841
  */
2827
- pushDeliveryStream() {
2842
+ pushDeliveryStream(options = {}) {
2828
2843
  this.deliveryPushChain = this.deliveryPushChain.then(async () => {
2829
2844
  if (this.state !== "streaming" || !this.transport.isConnected()) return;
2830
2845
  try {
2846
+ const previous = this.outstandingDelivery;
2847
+ if (options.trackStall && previous && previous.max > this.lastAckedDeliverySeq) {
2848
+ previous.repeatCount += 1;
2849
+ if (previous.repeatCount >= 3) {
2850
+ this.emitter?.emit({
2851
+ type: "sync:delivery-stalled",
2852
+ sessionId: this.sessionId,
2853
+ watermark: this.lastAckedDeliverySeq,
2854
+ outstandingMaxDeliverySequence: previous.max,
2855
+ repeatCount: previous.repeatCount,
2856
+ reason: "unacknowledged-delivery"
2857
+ });
2858
+ }
2859
+ }
2831
2860
  this.lastDeliveryPushAttemptAtMs = Date.now();
2832
- await this.sendDeliveryStream(
2861
+ const result = await this.sendDeliveryStream(
2833
2862
  this.lastAckedDeliverySeq,
2834
2863
  false,
2835
2864
  this.clientNodeId ?? void 0
2836
2865
  );
2866
+ if (result.sent && result.maxScanned > this.lastAckedDeliverySeq) {
2867
+ if (!this.outstandingDelivery || this.outstandingDelivery.base !== this.lastAckedDeliverySeq || this.outstandingDelivery.max !== result.maxScanned) {
2868
+ this.outstandingDelivery = {
2869
+ base: this.lastAckedDeliverySeq,
2870
+ max: result.maxScanned,
2871
+ sentAtMs: Date.now(),
2872
+ repeatCount: 0
2873
+ };
2874
+ } else {
2875
+ this.outstandingDelivery.sentAtMs = Date.now();
2876
+ }
2877
+ } else if (result.maxScanned <= this.lastAckedDeliverySeq) {
2878
+ this.outstandingDelivery = null;
2879
+ }
2837
2880
  } catch {
2838
2881
  }
2839
2882
  });
2840
2883
  }
2841
2884
  async relayVisibleOperations(operations2) {
2842
2885
  const visibleOperations = [];
2886
+ const retractions = [];
2843
2887
  for (const op of operations2) {
2844
2888
  if (await this.operationVisibleToClient(op)) {
2845
2889
  visibleOperations.push(op);
2890
+ } else if (await this.scopeRetractionFor(op)) {
2891
+ retractions.push({ collection: op.collection, recordId: op.recordId });
2846
2892
  }
2847
2893
  }
2848
- if (visibleOperations.length === 0) return;
2894
+ if (visibleOperations.length === 0 && retractions.length === 0) return;
2849
2895
  if (this.state !== "streaming" || !this.transport.isConnected()) return;
2850
2896
  const serializedOps = visibleOperations.map((op) => this.serializer.encodeOperation(op));
2851
2897
  const msg = {
2852
2898
  type: "operation-batch",
2853
2899
  messageId: generateUUIDv74(),
2854
2900
  operations: serializedOps,
2901
+ ...retractions.length > 0 ? { retractions } : {},
2855
2902
  isFinal: true,
2856
2903
  batchIndex: 0
2857
2904
  };
@@ -2895,21 +2942,14 @@ var ClientSession = class _ClientSession {
2895
2942
  pushDeliveryStreamIfSupported(staleMs = 0, options = {}) {
2896
2943
  if (this.state !== "streaming" || !this.transport.isConnected()) return;
2897
2944
  if (this.clientDeliveryWatermark === null) return;
2898
- if (staleMs > 0 && Date.now() - this.lastDeliveryPushAttemptAtMs < staleMs) {
2945
+ if (options.serverFrontier !== void 0 && options.serverFrontier <= this.lastAckedDeliverySeq) {
2946
+ this.outstandingDelivery = null;
2899
2947
  return;
2900
2948
  }
2901
- if (options.trackStall) {
2902
- this.deliveryStallRepeatCount += 1;
2903
- if (this.deliveryStallRepeatCount >= 3) {
2904
- this.emitter?.emit({
2905
- type: "sync:delivery-stalled",
2906
- sessionId: this.sessionId,
2907
- watermark: this.lastAckedDeliverySeq,
2908
- repeatCount: this.deliveryStallRepeatCount
2909
- });
2910
- }
2949
+ if (staleMs > 0 && Date.now() - this.lastDeliveryPushAttemptAtMs < staleMs) {
2950
+ return;
2911
2951
  }
2912
- this.pushDeliveryStream();
2952
+ this.pushDeliveryStream({ trackStall: options.trackStall });
2913
2953
  }
2914
2954
  /**
2915
2955
  * Close this session.
@@ -2992,7 +3032,11 @@ var ClientSession = class _ClientSession {
2992
3032
  this.pendingRelays.delete(message.acknowledgedMessageId);
2993
3033
  if (message.deliverySequence !== void 0) {
2994
3034
  this.lastAckedDeliverySeq = Math.max(this.lastAckedDeliverySeq, message.deliverySequence);
2995
- this.deliveryStallRepeatCount = 0;
3035
+ if (this.outstandingDelivery && this.lastAckedDeliverySeq >= this.outstandingDelivery.max) {
3036
+ this.outstandingDelivery = null;
3037
+ } else if (this.outstandingDelivery) {
3038
+ this.outstandingDelivery.repeatCount = 0;
3039
+ }
2996
3040
  }
2997
3041
  break;
2998
3042
  case "error":
@@ -3053,6 +3097,7 @@ var ClientSession = class _ClientSession {
3053
3097
  return;
3054
3098
  }
3055
3099
  this.clientNodeId = msg.nodeId;
3100
+ this.scopeExitPolicy = msg.scopeExitPolicy ?? "retain";
3056
3101
  if (this.auth) {
3057
3102
  const token = msg.authToken ?? "";
3058
3103
  const context = await this.auth.authenticate(token);
@@ -3064,13 +3109,19 @@ var ClientSession = class _ClientSession {
3064
3109
  this.authContext = context;
3065
3110
  this.state = "authenticated";
3066
3111
  }
3067
- const rawResolvedScopes = resolveSessionScopes(this.store.getSchema(), {
3112
+ const directionalScopesConfigured = this.authContext?.downlinkScopes !== void 0 || this.authContext?.uplinkScopes !== void 0;
3113
+ const downlinkAuthScopes = directionalScopesConfigured ? this.authContext?.downlinkScopes ?? this.authContext?.scopes ?? {} : this.authContext?.scopes;
3114
+ const uplinkAuthScopes = directionalScopesConfigured ? this.authContext?.uplinkScopes ?? this.authContext?.scopes ?? {} : this.authContext?.scopes;
3115
+ const rawResolvedDownlinkScopes = resolveSessionScopes(this.store.getSchema(), {
3068
3116
  handshakeScope: msg.syncScope,
3069
- authScopes: this.authContext?.scopes
3117
+ authScopes: downlinkAuthScopes
3070
3118
  });
3071
- let resolvedScopes;
3119
+ const rawResolvedUplinkScopes = directionalScopesConfigured ? uplinkAuthScopes : rawResolvedDownlinkScopes;
3120
+ let resolvedDownlinkScopes;
3121
+ let resolvedUplinkScopes;
3072
3122
  try {
3073
- resolvedScopes = rawResolvedScopes ? normalizeScopeMap(rawResolvedScopes) : void 0;
3123
+ resolvedDownlinkScopes = rawResolvedDownlinkScopes ? normalizeScopeMap(rawResolvedDownlinkScopes) : directionalScopesConfigured ? {} : void 0;
3124
+ resolvedUplinkScopes = rawResolvedUplinkScopes ? normalizeScopeMap(rawResolvedUplinkScopes) : directionalScopesConfigured ? {} : void 0;
3074
3125
  } catch (error) {
3075
3126
  this.sendToClient({
3076
3127
  type: "error",
@@ -3082,14 +3133,24 @@ var ClientSession = class _ClientSession {
3082
3133
  this.close("invalid scope predicate");
3083
3134
  return;
3084
3135
  }
3085
- if (resolvedScopes) {
3136
+ if (resolvedDownlinkScopes || resolvedUplinkScopes) {
3086
3137
  if (this.authContext) {
3087
- this.authContext = { ...this.authContext, scopes: resolvedScopes };
3138
+ this.authContext = {
3139
+ ...this.authContext,
3140
+ scopes: resolvedDownlinkScopes,
3141
+ downlinkScopes: resolvedDownlinkScopes,
3142
+ uplinkScopes: resolvedUplinkScopes
3143
+ };
3088
3144
  } else {
3089
- this.authContext = { userId: msg.nodeId, scopes: resolvedScopes };
3145
+ this.authContext = {
3146
+ userId: msg.nodeId,
3147
+ scopes: resolvedDownlinkScopes,
3148
+ downlinkScopes: resolvedDownlinkScopes,
3149
+ uplinkScopes: resolvedUplinkScopes
3150
+ };
3090
3151
  }
3091
3152
  }
3092
- warnIfMultiTenantWithoutScopes(this.auth, resolvedScopes, this.store.getSchema());
3153
+ warnIfMultiTenantWithoutScopes(this.auth, resolvedDownlinkScopes, this.store.getSchema());
3093
3154
  if (msg.syncQueries && msg.syncQueries.length > 0) {
3094
3155
  this.syncQuerySubsets = dedupeQuerySubsets(msg.syncQueries);
3095
3156
  } else {
@@ -3097,7 +3158,7 @@ var ClientSession = class _ClientSession {
3097
3158
  }
3098
3159
  this.resumeDeltaCursor = msg.deltaCursor ? decodeDeltaCursor(msg.deltaCursor) : null;
3099
3160
  this.clientDeliveryWatermark = msg.lastDeliverySequence ?? null;
3100
- if (this.clientDeliveryWatermark !== null && !sameScopeMap(msg.syncScope, this.authContext?.scopes)) {
3161
+ if (this.clientDeliveryWatermark !== null && !sameScopeMap(msg.syncScope, this.authContext?.downlinkScopes)) {
3101
3162
  this.clientDeliveryWatermark = 0;
3102
3163
  }
3103
3164
  const clientUsesWatermark = this.clientDeliveryWatermark !== null;
@@ -3155,7 +3216,11 @@ var ClientSession = class _ClientSession {
3155
3216
  ...this.persistBlobChunk ? { blobStorageEnabled: true } : {},
3156
3217
  // Confirm the accepted scope so the client knows what data will be synced.
3157
3218
  // This may differ from what the client requested if auth scopes are narrower.
3158
- ...this.authContext?.scopes ? { acceptedScope: this.authContext.scopes } : {}
3219
+ ...this.authContext?.downlinkScopes ? {
3220
+ acceptedScope: this.authContext.downlinkScopes,
3221
+ acceptedDownlinkScopes: this.authContext.downlinkScopes
3222
+ } : {},
3223
+ ...this.authContext?.uplinkScopes ? { acceptedUplinkScopes: this.authContext.uplinkScopes } : {}
3159
3224
  };
3160
3225
  this.sendToClient(response);
3161
3226
  this.emitter?.emit({ type: "sync:connected", nodeId: msg.nodeId });
@@ -3182,18 +3247,22 @@ var ClientSession = class _ClientSession {
3182
3247
  const applied = [];
3183
3248
  let acknowledgedThrough = 0;
3184
3249
  let canAdvanceAck = true;
3250
+ let uniqueOperations = 0;
3251
+ let duplicateOperations = 0;
3252
+ let rejectedOperations = 0;
3185
3253
  for (const op of operations2) {
3186
3254
  if (!canAdvanceAck) {
3187
3255
  continue;
3188
3256
  }
3189
- if (!await this.operationVisibleToClient(op)) {
3257
+ if (!await this.operationAllowedFromClient(op)) {
3190
3258
  this.sendOperationRejected(
3191
3259
  op,
3192
3260
  "SCOPE_VIOLATION",
3193
- `Operation "${op.id}" in collection "${op.collection}" is outside the client's sync scope. Refresh sync scopes and retry.`,
3194
- true
3261
+ `Operation "${op.id}" in collection "${op.collection}" is outside the accepted uplink scope. Refresh scopes before creating or explicitly resubmitting an authorized operation.`,
3262
+ false
3195
3263
  );
3196
- canAdvanceAck = false;
3264
+ rejectedOperations += 1;
3265
+ acknowledgedThrough = op.sequenceNumber;
3197
3266
  continue;
3198
3267
  }
3199
3268
  if (!isOperationTimestampValid(op)) {
@@ -3232,6 +3301,7 @@ var ClientSession = class _ClientSession {
3232
3301
  `Operation "${op.id}" cannot be transformed from schema v${op.schemaVersion} to server schema v${this.schemaVersion}.`,
3233
3302
  false
3234
3303
  );
3304
+ rejectedOperations += 1;
3235
3305
  acknowledgedThrough = op.sequenceNumber;
3236
3306
  continue;
3237
3307
  }
@@ -3256,6 +3326,7 @@ var ClientSession = class _ClientSession {
3256
3326
  if (decision.action === "reject") {
3257
3327
  const retriable = decision.retriable ?? isRetriableRejection(decision.code);
3258
3328
  this.sendOperationRejected(serverOp, decision.code, decision.message, retriable);
3329
+ rejectedOperations += 1;
3259
3330
  if (retriable) {
3260
3331
  canAdvanceAck = false;
3261
3332
  } else {
@@ -3276,6 +3347,7 @@ var ClientSession = class _ClientSession {
3276
3347
  applyResult.rejection.message,
3277
3348
  applyResult.rejection.retriable
3278
3349
  );
3350
+ rejectedOperations += 1;
3279
3351
  if (applyResult.rejection.retriable) {
3280
3352
  canAdvanceAck = false;
3281
3353
  } else {
@@ -3285,8 +3357,10 @@ var ClientSession = class _ClientSession {
3285
3357
  }
3286
3358
  if (applyResult.result === "applied") {
3287
3359
  applied.push(...applyResult.appliedOperations);
3360
+ uniqueOperations += 1;
3288
3361
  acknowledgedThrough = op.sequenceNumber;
3289
3362
  } else {
3363
+ duplicateOperations += 1;
3290
3364
  acknowledgedThrough = op.sequenceNumber;
3291
3365
  }
3292
3366
  }
@@ -3294,7 +3368,10 @@ var ClientSession = class _ClientSession {
3294
3368
  this.emitter?.emit({
3295
3369
  type: "sync:received",
3296
3370
  operations: operations2,
3297
- batchSize: operations2.length
3371
+ batchSize: operations2.length,
3372
+ uniqueOperations,
3373
+ duplicateOperations,
3374
+ rejectedOperations
3298
3375
  });
3299
3376
  }
3300
3377
  const lastOp = operations2[operations2.length - 1];
@@ -3399,7 +3476,7 @@ var ClientSession = class _ClientSession {
3399
3476
  const scanChunk = Math.max(this.batchSize, 1) * 5;
3400
3477
  let scanCursor = fromDeliverySeq;
3401
3478
  let maxScanned = fromDeliverySeq;
3402
- const visible = [];
3479
+ const deliverable = [];
3403
3480
  while (true) {
3404
3481
  const chunk = await this.store.getOperationsAfterDelivery(scanCursor, scanChunk);
3405
3482
  const last = chunk[chunk.length - 1];
@@ -3411,21 +3488,24 @@ var ClientSession = class _ClientSession {
3411
3488
  continue;
3412
3489
  }
3413
3490
  if (await this.operationVisibleToClient(delivered.operation)) {
3414
- visible.push(delivered);
3491
+ deliverable.push(delivered);
3492
+ } else if (await this.scopeRetractionFor(delivered.operation)) {
3493
+ deliverable.push({ ...delivered, retraction: true });
3415
3494
  }
3416
3495
  }
3417
3496
  if (chunk.length < scanChunk) break;
3418
3497
  }
3419
- if (visible.length === 0) {
3420
- if (finalizeWhenEmpty) {
3421
- this.sendDeliveryBatch([], fromDeliverySeq, maxScanned, 0, true);
3498
+ if (deliverable.length === 0) {
3499
+ let sent = false;
3500
+ if (finalizeWhenEmpty || maxScanned > fromDeliverySeq) {
3501
+ sent = this.sendDeliveryBatch([], fromDeliverySeq, maxScanned, 0, true);
3422
3502
  }
3423
- return 0;
3503
+ return { sentOperations: 0, maxScanned, sent };
3424
3504
  }
3425
- const totalBatches = Math.ceil(visible.length / this.batchSize);
3505
+ const totalBatches = Math.ceil(deliverable.length / this.batchSize);
3426
3506
  let base = fromDeliverySeq;
3427
3507
  for (let i = 0; i < totalBatches; i++) {
3428
- const slice = visible.slice(i * this.batchSize, (i + 1) * this.batchSize);
3508
+ const slice = deliverable.slice(i * this.batchSize, (i + 1) * this.batchSize);
3429
3509
  const lastInSlice = slice[slice.length - 1];
3430
3510
  if (lastInSlice === void 0) continue;
3431
3511
  const isFinal = i === totalBatches - 1;
@@ -3434,30 +3514,42 @@ var ClientSession = class _ClientSession {
3434
3514
  this.sendDeliveryBatch(slice, base, max, i, isFinal);
3435
3515
  base = max;
3436
3516
  }
3437
- return visible.length;
3517
+ return {
3518
+ sentOperations: deliverable.filter((item) => !item.retraction).length,
3519
+ maxScanned,
3520
+ sent: true
3521
+ };
3438
3522
  }
3439
3523
  /** Build, track, and send one chained delivery-stream batch. */
3440
3524
  sendDeliveryBatch(slice, base, max, batchIndex, isFinal) {
3441
3525
  const batchMsg = {
3442
3526
  type: "operation-batch",
3443
3527
  messageId: generateUUIDv74(),
3444
- operations: slice.map((delivered) => this.serializer.encodeOperation(delivered.operation)),
3528
+ operations: slice.filter((delivered) => !delivered.retraction).map((delivered) => this.serializer.encodeOperation(delivered.operation)),
3529
+ ...slice.some((delivered) => delivered.retraction) ? {
3530
+ retractions: slice.filter((delivered) => delivered.retraction).map((delivered) => ({
3531
+ collection: delivered.operation.collection,
3532
+ recordId: delivered.operation.recordId
3533
+ }))
3534
+ } : {},
3445
3535
  isFinal,
3446
3536
  batchIndex,
3447
3537
  baseDeliverySequence: base,
3448
3538
  maxDeliverySequence: max
3449
3539
  };
3450
- this.sendToClient(batchMsg);
3451
- if (slice.length > 0) {
3540
+ const sent = this.sendToClient(batchMsg);
3541
+ const sentOperations = slice.filter((delivered) => !delivered.retraction);
3542
+ if (sentOperations.length > 0) {
3452
3543
  this.emitter?.emit({
3453
3544
  type: "sync:sent",
3454
- operations: slice.map((delivered) => delivered.operation),
3455
- batchSize: slice.length
3545
+ operations: sentOperations.map((delivered) => delivered.operation),
3546
+ batchSize: sentOperations.length
3456
3547
  });
3457
3548
  }
3549
+ return sent;
3458
3550
  }
3459
3551
  async operationVisibleToClient(op) {
3460
- const scopes = this.authContext?.scopes;
3552
+ const scopes = this.authContext?.downlinkScopes ?? this.authContext?.scopes;
3461
3553
  const subsets = this.syncQuerySubsets;
3462
3554
  const needsBackfill = missingScopeFields(op, scopes).length > 0 || subsets !== void 0 && subsets.length > 0;
3463
3555
  const fullRecord = needsBackfill ? await this.lookupRecordFields(op.collection, op.recordId) : void 0;
@@ -3466,6 +3558,20 @@ var ClientSession = class _ClientSession {
3466
3558
  }
3467
3559
  return operationMatchesQuerySubsets(op, subsets, fullRecord);
3468
3560
  }
3561
+ /** Upload authorization is independent from the client's downloaded/query view. */
3562
+ async operationAllowedFromClient(op) {
3563
+ const scopes = this.authContext?.uplinkScopes ?? this.authContext?.scopes;
3564
+ const needsBackfill = missingScopeFields(op, scopes).length > 0;
3565
+ const fullRecord = needsBackfill ? await this.lookupRecordFields(op.collection, op.recordId) : void 0;
3566
+ return operationMatchesScopes(op, scopes, fullRecord);
3567
+ }
3568
+ async scopeRetractionFor(op) {
3569
+ if (this.scopeExitPolicy !== "retract") return false;
3570
+ const scopes = this.authContext?.downlinkScopes ?? this.authContext?.scopes;
3571
+ if (!scopes) return false;
3572
+ const resultingRecord = await this.lookupRecordFields(op.collection, op.recordId);
3573
+ return operationExitsScopes(op, scopes, resultingRecord);
3574
+ }
3469
3575
  /**
3470
3576
  * Read a record's current field values from the materialized store for scope /
3471
3577
  * query-subset backfill. Includes soft-deleted rows so a relayed delete (whose op
@@ -3654,6 +3760,10 @@ var ServerMetricsCollector = class {
3654
3760
  peakConnections = 0;
3655
3761
  connectionsTotal = 0;
3656
3762
  operationsReceived = 0;
3763
+ batchesReceived = 0;
3764
+ uniqueOperationsReceived = 0;
3765
+ duplicateOperationsReceived = 0;
3766
+ rejectedOperations = 0;
3657
3767
  operationsSent = 0;
3658
3768
  bytesReceived = 0;
3659
3769
  bytesSent = 0;
@@ -3669,6 +3779,10 @@ var ServerMetricsCollector = class {
3669
3779
  state: "connected",
3670
3780
  connectedAt: Date.now(),
3671
3781
  operationsReceived: 0,
3782
+ batchesReceived: 0,
3783
+ uniqueOperationsReceived: 0,
3784
+ duplicateOperationsReceived: 0,
3785
+ rejectedOperations: 0,
3672
3786
  operationsSent: 0,
3673
3787
  authContext: null
3674
3788
  });
@@ -3700,12 +3814,24 @@ var ServerMetricsCollector = class {
3700
3814
  }
3701
3815
  }
3702
3816
  /** Record operations received from a client. */
3703
- recordReceived(sessionId, count3, byteSize) {
3817
+ recordReceived(sessionId, count3, byteSize, outcomes = {
3818
+ unique: count3,
3819
+ duplicates: 0,
3820
+ rejected: 0
3821
+ }) {
3704
3822
  this.operationsReceived += count3;
3823
+ this.batchesReceived += 1;
3824
+ this.uniqueOperationsReceived += outcomes.unique;
3825
+ this.duplicateOperationsReceived += outcomes.duplicates;
3826
+ this.rejectedOperations += outcomes.rejected;
3705
3827
  this.bytesReceived += byteSize;
3706
3828
  const client = this.clientMetrics.get(sessionId);
3707
3829
  if (client) {
3708
3830
  client.operationsReceived += count3;
3831
+ client.batchesReceived += 1;
3832
+ client.uniqueOperationsReceived += outcomes.unique;
3833
+ client.duplicateOperationsReceived += outcomes.duplicates;
3834
+ client.rejectedOperations += outcomes.rejected;
3709
3835
  }
3710
3836
  }
3711
3837
  /** Record operations sent to a client. */
@@ -3733,6 +3859,10 @@ var ServerMetricsCollector = class {
3733
3859
  peakConnections: this.peakConnections,
3734
3860
  connectionsTotal: this.connectionsTotal,
3735
3861
  operationsReceived: this.operationsReceived,
3862
+ batchesReceived: this.batchesReceived,
3863
+ uniqueOperationsReceived: this.uniqueOperationsReceived,
3864
+ duplicateOperationsReceived: this.duplicateOperationsReceived,
3865
+ rejectedOperations: this.rejectedOperations,
3736
3866
  operationsSent: this.operationsSent,
3737
3867
  bytesReceived: this.bytesReceived,
3738
3868
  bytesSent: this.bytesSent,
@@ -3749,6 +3879,10 @@ var ServerMetricsCollector = class {
3749
3879
  this.peakConnections = 0;
3750
3880
  this.connectionsTotal = 0;
3751
3881
  this.operationsReceived = 0;
3882
+ this.batchesReceived = 0;
3883
+ this.uniqueOperationsReceived = 0;
3884
+ this.duplicateOperationsReceived = 0;
3885
+ this.rejectedOperations = 0;
3752
3886
  this.operationsSent = 0;
3753
3887
  this.bytesReceived = 0;
3754
3888
  this.bytesSent = 0;
@@ -4435,14 +4569,17 @@ var KoraSyncServer = class _KoraSyncServer {
4435
4569
  if (maxDeliverySequence <= this.lastObservedDeliverySequence) {
4436
4570
  for (const session of this.sessions.values()) {
4437
4571
  session.pushDeliveryStreamIfSupported(this.deliveryPollIntervalMs, {
4438
- trackStall: true
4572
+ trackStall: true,
4573
+ serverFrontier: maxDeliverySequence
4439
4574
  });
4440
4575
  }
4441
4576
  return;
4442
4577
  }
4443
4578
  this.lastObservedDeliverySequence = maxDeliverySequence;
4444
4579
  for (const session of this.sessions.values()) {
4445
- session.pushDeliveryStreamIfSupported(0);
4580
+ session.pushDeliveryStreamIfSupported(0, {
4581
+ serverFrontier: maxDeliverySequence
4582
+ });
4446
4583
  }
4447
4584
  } catch (error) {
4448
4585
  this.logger.log({
@@ -4516,7 +4653,11 @@ var KoraSyncServer = class _KoraSyncServer {
4516
4653
  });
4517
4654
  sessionEmitter.on("sync:received", (event) => {
4518
4655
  const byteSize = estimateByteSize(event.operations);
4519
- this.metrics.recordReceived(sessionId, event.batchSize, byteSize);
4656
+ this.metrics.recordReceived(sessionId, event.batchSize, byteSize, {
4657
+ unique: event.uniqueOperations ?? event.batchSize,
4658
+ duplicates: event.duplicateOperations ?? 0,
4659
+ rejected: event.rejectedOperations ?? 0
4660
+ });
4520
4661
  this.logger.log({
4521
4662
  timestamp: Date.now(),
4522
4663
  level: "info",
@@ -4739,7 +4880,11 @@ var KoraSyncServer = class _KoraSyncServer {
4739
4880
  });
4740
4881
  sessionEmitter.on("sync:received", (event) => {
4741
4882
  const byteSize = estimateOperationByteSize(event.operations);
4742
- this.metrics.recordReceived(sessionId, event.batchSize, byteSize);
4883
+ this.metrics.recordReceived(sessionId, event.batchSize, byteSize, {
4884
+ unique: event.uniqueOperations ?? event.batchSize,
4885
+ duplicates: event.duplicateOperations ?? 0,
4886
+ rejected: event.rejectedOperations ?? 0
4887
+ });
4743
4888
  this.logger.log({
4744
4889
  timestamp: Date.now(),
4745
4890
  level: "info",
@@ -4792,7 +4937,9 @@ var KoraSyncServer = class _KoraSyncServer {
4792
4937
  sessionId,
4793
4938
  details: {
4794
4939
  watermark: event.watermark,
4795
- repeatCount: event.repeatCount
4940
+ outstandingMaxDeliverySequence: event.outstandingMaxDeliverySequence,
4941
+ repeatCount: event.repeatCount,
4942
+ reason: event.reason
4796
4943
  }
4797
4944
  });
4798
4945
  });
@@ -5189,6 +5336,22 @@ function createProductionServer(config) {
5189
5336
  "# TYPE kora_operations_received_total counter",
5190
5337
  `kora_operations_received_total ${status.operationsReceived}`,
5191
5338
  "",
5339
+ "# HELP kora_operation_batches_received_total Total operation batches received from clients",
5340
+ "# TYPE kora_operation_batches_received_total counter",
5341
+ `kora_operation_batches_received_total ${status.batchesReceived}`,
5342
+ "",
5343
+ "# HELP kora_unique_operations_received_total Newly materialized operations received from clients",
5344
+ "# TYPE kora_unique_operations_received_total counter",
5345
+ `kora_unique_operations_received_total ${status.uniqueOperationsReceived}`,
5346
+ "",
5347
+ "# HELP kora_duplicate_operations_received_total Duplicate operations received from clients",
5348
+ "# TYPE kora_duplicate_operations_received_total counter",
5349
+ `kora_duplicate_operations_received_total ${status.duplicateOperationsReceived}`,
5350
+ "",
5351
+ "# HELP kora_rejected_operations_total Operations rejected before authoritative materialization",
5352
+ "# TYPE kora_rejected_operations_total counter",
5353
+ `kora_rejected_operations_total ${status.rejectedOperations}`,
5354
+ "",
5192
5355
  "# HELP kora_operations_sent_total Total operations sent to clients",
5193
5356
  "# TYPE kora_operations_sent_total counter",
5194
5357
  `kora_operations_sent_total ${status.operationsSent}`,