@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.cjs CHANGED
@@ -2775,6 +2775,20 @@ function operationMatchesScopes(op, scopes, fullRecord) {
2775
2775
  }
2776
2776
  return true;
2777
2777
  }
2778
+ function operationExitsScopes(op, scopes, resultingRecord) {
2779
+ if (!scopes || op.type !== "update" || !op.previousData) return false;
2780
+ if (operationMatchesScopes(op, scopes, resultingRecord)) return false;
2781
+ const previousSnapshot = {
2782
+ ...resultingRecord ?? {},
2783
+ ...op.data ?? {},
2784
+ ...op.previousData
2785
+ };
2786
+ return operationMatchesScopes(
2787
+ { ...op, type: "insert", data: previousSnapshot, previousData: null },
2788
+ scopes,
2789
+ previousSnapshot
2790
+ );
2791
+ }
2778
2792
  function matchesPredicate(actual, expected) {
2779
2793
  if (expected && typeof expected === "object" && !Array.isArray(expected) && "$in" in expected) {
2780
2794
  const values = expected.$in;
@@ -2908,6 +2922,7 @@ var ClientSession = class _ClientSession {
2908
2922
  clientNodeId = null;
2909
2923
  authContext = null;
2910
2924
  syncQuerySubsets = [];
2925
+ scopeExitPolicy = "retain";
2911
2926
  resumeDeltaCursor = null;
2912
2927
  /**
2913
2928
  * The delivery watermark the client reported at handshake, or null when the client
@@ -2932,7 +2947,7 @@ var ClientSession = class _ClientSession {
2932
2947
  * batches every server tick while the first batch is still being applied.
2933
2948
  */
2934
2949
  lastDeliveryPushAttemptAtMs = 0;
2935
- deliveryStallRepeatCount = 0;
2950
+ outstandingDelivery = null;
2936
2951
  /** Serializes incremental delivery pushes so their batches never interleave. */
2937
2952
  deliveryPushChain = Promise.resolve();
2938
2953
  /**
@@ -3031,34 +3046,66 @@ var ClientSession = class _ClientSession {
3031
3046
  * from the highest delivery sequence already sent. Pushes are serialized so their
3032
3047
  * batches never interleave (which would break the base/max chain).
3033
3048
  */
3034
- pushDeliveryStream() {
3049
+ pushDeliveryStream(options = {}) {
3035
3050
  this.deliveryPushChain = this.deliveryPushChain.then(async () => {
3036
3051
  if (this.state !== "streaming" || !this.transport.isConnected()) return;
3037
3052
  try {
3053
+ const previous = this.outstandingDelivery;
3054
+ if (options.trackStall && previous && previous.max > this.lastAckedDeliverySeq) {
3055
+ previous.repeatCount += 1;
3056
+ if (previous.repeatCount >= 3) {
3057
+ this.emitter?.emit({
3058
+ type: "sync:delivery-stalled",
3059
+ sessionId: this.sessionId,
3060
+ watermark: this.lastAckedDeliverySeq,
3061
+ outstandingMaxDeliverySequence: previous.max,
3062
+ repeatCount: previous.repeatCount,
3063
+ reason: "unacknowledged-delivery"
3064
+ });
3065
+ }
3066
+ }
3038
3067
  this.lastDeliveryPushAttemptAtMs = Date.now();
3039
- await this.sendDeliveryStream(
3068
+ const result = await this.sendDeliveryStream(
3040
3069
  this.lastAckedDeliverySeq,
3041
3070
  false,
3042
3071
  this.clientNodeId ?? void 0
3043
3072
  );
3073
+ if (result.sent && result.maxScanned > this.lastAckedDeliverySeq) {
3074
+ if (!this.outstandingDelivery || this.outstandingDelivery.base !== this.lastAckedDeliverySeq || this.outstandingDelivery.max !== result.maxScanned) {
3075
+ this.outstandingDelivery = {
3076
+ base: this.lastAckedDeliverySeq,
3077
+ max: result.maxScanned,
3078
+ sentAtMs: Date.now(),
3079
+ repeatCount: 0
3080
+ };
3081
+ } else {
3082
+ this.outstandingDelivery.sentAtMs = Date.now();
3083
+ }
3084
+ } else if (result.maxScanned <= this.lastAckedDeliverySeq) {
3085
+ this.outstandingDelivery = null;
3086
+ }
3044
3087
  } catch {
3045
3088
  }
3046
3089
  });
3047
3090
  }
3048
3091
  async relayVisibleOperations(operations2) {
3049
3092
  const visibleOperations = [];
3093
+ const retractions = [];
3050
3094
  for (const op of operations2) {
3051
3095
  if (await this.operationVisibleToClient(op)) {
3052
3096
  visibleOperations.push(op);
3097
+ } else if (await this.scopeRetractionFor(op)) {
3098
+ retractions.push({ collection: op.collection, recordId: op.recordId });
3053
3099
  }
3054
3100
  }
3055
- if (visibleOperations.length === 0) return;
3101
+ if (visibleOperations.length === 0 && retractions.length === 0) return;
3056
3102
  if (this.state !== "streaming" || !this.transport.isConnected()) return;
3057
3103
  const serializedOps = visibleOperations.map((op) => this.serializer.encodeOperation(op));
3058
3104
  const msg = {
3059
3105
  type: "operation-batch",
3060
3106
  messageId: (0, import_core11.generateUUIDv7)(),
3061
3107
  operations: serializedOps,
3108
+ ...retractions.length > 0 ? { retractions } : {},
3062
3109
  isFinal: true,
3063
3110
  batchIndex: 0
3064
3111
  };
@@ -3102,21 +3149,14 @@ var ClientSession = class _ClientSession {
3102
3149
  pushDeliveryStreamIfSupported(staleMs = 0, options = {}) {
3103
3150
  if (this.state !== "streaming" || !this.transport.isConnected()) return;
3104
3151
  if (this.clientDeliveryWatermark === null) return;
3105
- if (staleMs > 0 && Date.now() - this.lastDeliveryPushAttemptAtMs < staleMs) {
3152
+ if (options.serverFrontier !== void 0 && options.serverFrontier <= this.lastAckedDeliverySeq) {
3153
+ this.outstandingDelivery = null;
3106
3154
  return;
3107
3155
  }
3108
- if (options.trackStall) {
3109
- this.deliveryStallRepeatCount += 1;
3110
- if (this.deliveryStallRepeatCount >= 3) {
3111
- this.emitter?.emit({
3112
- type: "sync:delivery-stalled",
3113
- sessionId: this.sessionId,
3114
- watermark: this.lastAckedDeliverySeq,
3115
- repeatCount: this.deliveryStallRepeatCount
3116
- });
3117
- }
3156
+ if (staleMs > 0 && Date.now() - this.lastDeliveryPushAttemptAtMs < staleMs) {
3157
+ return;
3118
3158
  }
3119
- this.pushDeliveryStream();
3159
+ this.pushDeliveryStream({ trackStall: options.trackStall });
3120
3160
  }
3121
3161
  /**
3122
3162
  * Close this session.
@@ -3199,7 +3239,11 @@ var ClientSession = class _ClientSession {
3199
3239
  this.pendingRelays.delete(message.acknowledgedMessageId);
3200
3240
  if (message.deliverySequence !== void 0) {
3201
3241
  this.lastAckedDeliverySeq = Math.max(this.lastAckedDeliverySeq, message.deliverySequence);
3202
- this.deliveryStallRepeatCount = 0;
3242
+ if (this.outstandingDelivery && this.lastAckedDeliverySeq >= this.outstandingDelivery.max) {
3243
+ this.outstandingDelivery = null;
3244
+ } else if (this.outstandingDelivery) {
3245
+ this.outstandingDelivery.repeatCount = 0;
3246
+ }
3203
3247
  }
3204
3248
  break;
3205
3249
  case "error":
@@ -3260,6 +3304,7 @@ var ClientSession = class _ClientSession {
3260
3304
  return;
3261
3305
  }
3262
3306
  this.clientNodeId = msg.nodeId;
3307
+ this.scopeExitPolicy = msg.scopeExitPolicy ?? "retain";
3263
3308
  if (this.auth) {
3264
3309
  const token = msg.authToken ?? "";
3265
3310
  const context = await this.auth.authenticate(token);
@@ -3271,13 +3316,19 @@ var ClientSession = class _ClientSession {
3271
3316
  this.authContext = context;
3272
3317
  this.state = "authenticated";
3273
3318
  }
3274
- const rawResolvedScopes = resolveSessionScopes(this.store.getSchema(), {
3319
+ const directionalScopesConfigured = this.authContext?.downlinkScopes !== void 0 || this.authContext?.uplinkScopes !== void 0;
3320
+ const downlinkAuthScopes = directionalScopesConfigured ? this.authContext?.downlinkScopes ?? this.authContext?.scopes ?? {} : this.authContext?.scopes;
3321
+ const uplinkAuthScopes = directionalScopesConfigured ? this.authContext?.uplinkScopes ?? this.authContext?.scopes ?? {} : this.authContext?.scopes;
3322
+ const rawResolvedDownlinkScopes = resolveSessionScopes(this.store.getSchema(), {
3275
3323
  handshakeScope: msg.syncScope,
3276
- authScopes: this.authContext?.scopes
3324
+ authScopes: downlinkAuthScopes
3277
3325
  });
3278
- let resolvedScopes;
3326
+ const rawResolvedUplinkScopes = directionalScopesConfigured ? uplinkAuthScopes : rawResolvedDownlinkScopes;
3327
+ let resolvedDownlinkScopes;
3328
+ let resolvedUplinkScopes;
3279
3329
  try {
3280
- resolvedScopes = rawResolvedScopes ? normalizeScopeMap(rawResolvedScopes) : void 0;
3330
+ resolvedDownlinkScopes = rawResolvedDownlinkScopes ? normalizeScopeMap(rawResolvedDownlinkScopes) : directionalScopesConfigured ? {} : void 0;
3331
+ resolvedUplinkScopes = rawResolvedUplinkScopes ? normalizeScopeMap(rawResolvedUplinkScopes) : directionalScopesConfigured ? {} : void 0;
3281
3332
  } catch (error) {
3282
3333
  this.sendToClient({
3283
3334
  type: "error",
@@ -3289,14 +3340,24 @@ var ClientSession = class _ClientSession {
3289
3340
  this.close("invalid scope predicate");
3290
3341
  return;
3291
3342
  }
3292
- if (resolvedScopes) {
3343
+ if (resolvedDownlinkScopes || resolvedUplinkScopes) {
3293
3344
  if (this.authContext) {
3294
- this.authContext = { ...this.authContext, scopes: resolvedScopes };
3345
+ this.authContext = {
3346
+ ...this.authContext,
3347
+ scopes: resolvedDownlinkScopes,
3348
+ downlinkScopes: resolvedDownlinkScopes,
3349
+ uplinkScopes: resolvedUplinkScopes
3350
+ };
3295
3351
  } else {
3296
- this.authContext = { userId: msg.nodeId, scopes: resolvedScopes };
3352
+ this.authContext = {
3353
+ userId: msg.nodeId,
3354
+ scopes: resolvedDownlinkScopes,
3355
+ downlinkScopes: resolvedDownlinkScopes,
3356
+ uplinkScopes: resolvedUplinkScopes
3357
+ };
3297
3358
  }
3298
3359
  }
3299
- warnIfMultiTenantWithoutScopes(this.auth, resolvedScopes, this.store.getSchema());
3360
+ warnIfMultiTenantWithoutScopes(this.auth, resolvedDownlinkScopes, this.store.getSchema());
3300
3361
  if (msg.syncQueries && msg.syncQueries.length > 0) {
3301
3362
  this.syncQuerySubsets = (0, import_sync3.dedupeQuerySubsets)(msg.syncQueries);
3302
3363
  } else {
@@ -3304,7 +3365,7 @@ var ClientSession = class _ClientSession {
3304
3365
  }
3305
3366
  this.resumeDeltaCursor = msg.deltaCursor ? (0, import_sync3.decodeDeltaCursor)(msg.deltaCursor) : null;
3306
3367
  this.clientDeliveryWatermark = msg.lastDeliverySequence ?? null;
3307
- if (this.clientDeliveryWatermark !== null && !sameScopeMap(msg.syncScope, this.authContext?.scopes)) {
3368
+ if (this.clientDeliveryWatermark !== null && !sameScopeMap(msg.syncScope, this.authContext?.downlinkScopes)) {
3308
3369
  this.clientDeliveryWatermark = 0;
3309
3370
  }
3310
3371
  const clientUsesWatermark = this.clientDeliveryWatermark !== null;
@@ -3362,7 +3423,11 @@ var ClientSession = class _ClientSession {
3362
3423
  ...this.persistBlobChunk ? { blobStorageEnabled: true } : {},
3363
3424
  // Confirm the accepted scope so the client knows what data will be synced.
3364
3425
  // This may differ from what the client requested if auth scopes are narrower.
3365
- ...this.authContext?.scopes ? { acceptedScope: this.authContext.scopes } : {}
3426
+ ...this.authContext?.downlinkScopes ? {
3427
+ acceptedScope: this.authContext.downlinkScopes,
3428
+ acceptedDownlinkScopes: this.authContext.downlinkScopes
3429
+ } : {},
3430
+ ...this.authContext?.uplinkScopes ? { acceptedUplinkScopes: this.authContext.uplinkScopes } : {}
3366
3431
  };
3367
3432
  this.sendToClient(response);
3368
3433
  this.emitter?.emit({ type: "sync:connected", nodeId: msg.nodeId });
@@ -3389,18 +3454,22 @@ var ClientSession = class _ClientSession {
3389
3454
  const applied = [];
3390
3455
  let acknowledgedThrough = 0;
3391
3456
  let canAdvanceAck = true;
3457
+ let uniqueOperations = 0;
3458
+ let duplicateOperations = 0;
3459
+ let rejectedOperations = 0;
3392
3460
  for (const op of operations2) {
3393
3461
  if (!canAdvanceAck) {
3394
3462
  continue;
3395
3463
  }
3396
- if (!await this.operationVisibleToClient(op)) {
3464
+ if (!await this.operationAllowedFromClient(op)) {
3397
3465
  this.sendOperationRejected(
3398
3466
  op,
3399
3467
  "SCOPE_VIOLATION",
3400
- `Operation "${op.id}" in collection "${op.collection}" is outside the client's sync scope. Refresh sync scopes and retry.`,
3401
- true
3468
+ `Operation "${op.id}" in collection "${op.collection}" is outside the accepted uplink scope. Refresh scopes before creating or explicitly resubmitting an authorized operation.`,
3469
+ false
3402
3470
  );
3403
- canAdvanceAck = false;
3471
+ rejectedOperations += 1;
3472
+ acknowledgedThrough = op.sequenceNumber;
3404
3473
  continue;
3405
3474
  }
3406
3475
  if (!isOperationTimestampValid(op)) {
@@ -3439,6 +3508,7 @@ var ClientSession = class _ClientSession {
3439
3508
  `Operation "${op.id}" cannot be transformed from schema v${op.schemaVersion} to server schema v${this.schemaVersion}.`,
3440
3509
  false
3441
3510
  );
3511
+ rejectedOperations += 1;
3442
3512
  acknowledgedThrough = op.sequenceNumber;
3443
3513
  continue;
3444
3514
  }
@@ -3463,6 +3533,7 @@ var ClientSession = class _ClientSession {
3463
3533
  if (decision.action === "reject") {
3464
3534
  const retriable = decision.retriable ?? isRetriableRejection(decision.code);
3465
3535
  this.sendOperationRejected(serverOp, decision.code, decision.message, retriable);
3536
+ rejectedOperations += 1;
3466
3537
  if (retriable) {
3467
3538
  canAdvanceAck = false;
3468
3539
  } else {
@@ -3483,6 +3554,7 @@ var ClientSession = class _ClientSession {
3483
3554
  applyResult.rejection.message,
3484
3555
  applyResult.rejection.retriable
3485
3556
  );
3557
+ rejectedOperations += 1;
3486
3558
  if (applyResult.rejection.retriable) {
3487
3559
  canAdvanceAck = false;
3488
3560
  } else {
@@ -3492,8 +3564,10 @@ var ClientSession = class _ClientSession {
3492
3564
  }
3493
3565
  if (applyResult.result === "applied") {
3494
3566
  applied.push(...applyResult.appliedOperations);
3567
+ uniqueOperations += 1;
3495
3568
  acknowledgedThrough = op.sequenceNumber;
3496
3569
  } else {
3570
+ duplicateOperations += 1;
3497
3571
  acknowledgedThrough = op.sequenceNumber;
3498
3572
  }
3499
3573
  }
@@ -3501,7 +3575,10 @@ var ClientSession = class _ClientSession {
3501
3575
  this.emitter?.emit({
3502
3576
  type: "sync:received",
3503
3577
  operations: operations2,
3504
- batchSize: operations2.length
3578
+ batchSize: operations2.length,
3579
+ uniqueOperations,
3580
+ duplicateOperations,
3581
+ rejectedOperations
3505
3582
  });
3506
3583
  }
3507
3584
  const lastOp = operations2[operations2.length - 1];
@@ -3606,7 +3683,7 @@ var ClientSession = class _ClientSession {
3606
3683
  const scanChunk = Math.max(this.batchSize, 1) * 5;
3607
3684
  let scanCursor = fromDeliverySeq;
3608
3685
  let maxScanned = fromDeliverySeq;
3609
- const visible = [];
3686
+ const deliverable = [];
3610
3687
  while (true) {
3611
3688
  const chunk = await this.store.getOperationsAfterDelivery(scanCursor, scanChunk);
3612
3689
  const last = chunk[chunk.length - 1];
@@ -3618,21 +3695,24 @@ var ClientSession = class _ClientSession {
3618
3695
  continue;
3619
3696
  }
3620
3697
  if (await this.operationVisibleToClient(delivered.operation)) {
3621
- visible.push(delivered);
3698
+ deliverable.push(delivered);
3699
+ } else if (await this.scopeRetractionFor(delivered.operation)) {
3700
+ deliverable.push({ ...delivered, retraction: true });
3622
3701
  }
3623
3702
  }
3624
3703
  if (chunk.length < scanChunk) break;
3625
3704
  }
3626
- if (visible.length === 0) {
3627
- if (finalizeWhenEmpty) {
3628
- this.sendDeliveryBatch([], fromDeliverySeq, maxScanned, 0, true);
3705
+ if (deliverable.length === 0) {
3706
+ let sent = false;
3707
+ if (finalizeWhenEmpty || maxScanned > fromDeliverySeq) {
3708
+ sent = this.sendDeliveryBatch([], fromDeliverySeq, maxScanned, 0, true);
3629
3709
  }
3630
- return 0;
3710
+ return { sentOperations: 0, maxScanned, sent };
3631
3711
  }
3632
- const totalBatches = Math.ceil(visible.length / this.batchSize);
3712
+ const totalBatches = Math.ceil(deliverable.length / this.batchSize);
3633
3713
  let base = fromDeliverySeq;
3634
3714
  for (let i = 0; i < totalBatches; i++) {
3635
- const slice = visible.slice(i * this.batchSize, (i + 1) * this.batchSize);
3715
+ const slice = deliverable.slice(i * this.batchSize, (i + 1) * this.batchSize);
3636
3716
  const lastInSlice = slice[slice.length - 1];
3637
3717
  if (lastInSlice === void 0) continue;
3638
3718
  const isFinal = i === totalBatches - 1;
@@ -3641,30 +3721,42 @@ var ClientSession = class _ClientSession {
3641
3721
  this.sendDeliveryBatch(slice, base, max, i, isFinal);
3642
3722
  base = max;
3643
3723
  }
3644
- return visible.length;
3724
+ return {
3725
+ sentOperations: deliverable.filter((item) => !item.retraction).length,
3726
+ maxScanned,
3727
+ sent: true
3728
+ };
3645
3729
  }
3646
3730
  /** Build, track, and send one chained delivery-stream batch. */
3647
3731
  sendDeliveryBatch(slice, base, max, batchIndex, isFinal) {
3648
3732
  const batchMsg = {
3649
3733
  type: "operation-batch",
3650
3734
  messageId: (0, import_core11.generateUUIDv7)(),
3651
- operations: slice.map((delivered) => this.serializer.encodeOperation(delivered.operation)),
3735
+ operations: slice.filter((delivered) => !delivered.retraction).map((delivered) => this.serializer.encodeOperation(delivered.operation)),
3736
+ ...slice.some((delivered) => delivered.retraction) ? {
3737
+ retractions: slice.filter((delivered) => delivered.retraction).map((delivered) => ({
3738
+ collection: delivered.operation.collection,
3739
+ recordId: delivered.operation.recordId
3740
+ }))
3741
+ } : {},
3652
3742
  isFinal,
3653
3743
  batchIndex,
3654
3744
  baseDeliverySequence: base,
3655
3745
  maxDeliverySequence: max
3656
3746
  };
3657
- this.sendToClient(batchMsg);
3658
- if (slice.length > 0) {
3747
+ const sent = this.sendToClient(batchMsg);
3748
+ const sentOperations = slice.filter((delivered) => !delivered.retraction);
3749
+ if (sentOperations.length > 0) {
3659
3750
  this.emitter?.emit({
3660
3751
  type: "sync:sent",
3661
- operations: slice.map((delivered) => delivered.operation),
3662
- batchSize: slice.length
3752
+ operations: sentOperations.map((delivered) => delivered.operation),
3753
+ batchSize: sentOperations.length
3663
3754
  });
3664
3755
  }
3756
+ return sent;
3665
3757
  }
3666
3758
  async operationVisibleToClient(op) {
3667
- const scopes = this.authContext?.scopes;
3759
+ const scopes = this.authContext?.downlinkScopes ?? this.authContext?.scopes;
3668
3760
  const subsets = this.syncQuerySubsets;
3669
3761
  const needsBackfill = missingScopeFields(op, scopes).length > 0 || subsets !== void 0 && subsets.length > 0;
3670
3762
  const fullRecord = needsBackfill ? await this.lookupRecordFields(op.collection, op.recordId) : void 0;
@@ -3673,6 +3765,20 @@ var ClientSession = class _ClientSession {
3673
3765
  }
3674
3766
  return (0, import_sync3.operationMatchesQuerySubsets)(op, subsets, fullRecord);
3675
3767
  }
3768
+ /** Upload authorization is independent from the client's downloaded/query view. */
3769
+ async operationAllowedFromClient(op) {
3770
+ const scopes = this.authContext?.uplinkScopes ?? this.authContext?.scopes;
3771
+ const needsBackfill = missingScopeFields(op, scopes).length > 0;
3772
+ const fullRecord = needsBackfill ? await this.lookupRecordFields(op.collection, op.recordId) : void 0;
3773
+ return operationMatchesScopes(op, scopes, fullRecord);
3774
+ }
3775
+ async scopeRetractionFor(op) {
3776
+ if (this.scopeExitPolicy !== "retract") return false;
3777
+ const scopes = this.authContext?.downlinkScopes ?? this.authContext?.scopes;
3778
+ if (!scopes) return false;
3779
+ const resultingRecord = await this.lookupRecordFields(op.collection, op.recordId);
3780
+ return operationExitsScopes(op, scopes, resultingRecord);
3781
+ }
3676
3782
  /**
3677
3783
  * Read a record's current field values from the materialized store for scope /
3678
3784
  * query-subset backfill. Includes soft-deleted rows so a relayed delete (whose op
@@ -3864,6 +3970,10 @@ var ServerMetricsCollector = class {
3864
3970
  peakConnections = 0;
3865
3971
  connectionsTotal = 0;
3866
3972
  operationsReceived = 0;
3973
+ batchesReceived = 0;
3974
+ uniqueOperationsReceived = 0;
3975
+ duplicateOperationsReceived = 0;
3976
+ rejectedOperations = 0;
3867
3977
  operationsSent = 0;
3868
3978
  bytesReceived = 0;
3869
3979
  bytesSent = 0;
@@ -3879,6 +3989,10 @@ var ServerMetricsCollector = class {
3879
3989
  state: "connected",
3880
3990
  connectedAt: Date.now(),
3881
3991
  operationsReceived: 0,
3992
+ batchesReceived: 0,
3993
+ uniqueOperationsReceived: 0,
3994
+ duplicateOperationsReceived: 0,
3995
+ rejectedOperations: 0,
3882
3996
  operationsSent: 0,
3883
3997
  authContext: null
3884
3998
  });
@@ -3910,12 +4024,24 @@ var ServerMetricsCollector = class {
3910
4024
  }
3911
4025
  }
3912
4026
  /** Record operations received from a client. */
3913
- recordReceived(sessionId, count3, byteSize) {
4027
+ recordReceived(sessionId, count3, byteSize, outcomes = {
4028
+ unique: count3,
4029
+ duplicates: 0,
4030
+ rejected: 0
4031
+ }) {
3914
4032
  this.operationsReceived += count3;
4033
+ this.batchesReceived += 1;
4034
+ this.uniqueOperationsReceived += outcomes.unique;
4035
+ this.duplicateOperationsReceived += outcomes.duplicates;
4036
+ this.rejectedOperations += outcomes.rejected;
3915
4037
  this.bytesReceived += byteSize;
3916
4038
  const client = this.clientMetrics.get(sessionId);
3917
4039
  if (client) {
3918
4040
  client.operationsReceived += count3;
4041
+ client.batchesReceived += 1;
4042
+ client.uniqueOperationsReceived += outcomes.unique;
4043
+ client.duplicateOperationsReceived += outcomes.duplicates;
4044
+ client.rejectedOperations += outcomes.rejected;
3919
4045
  }
3920
4046
  }
3921
4047
  /** Record operations sent to a client. */
@@ -3943,6 +4069,10 @@ var ServerMetricsCollector = class {
3943
4069
  peakConnections: this.peakConnections,
3944
4070
  connectionsTotal: this.connectionsTotal,
3945
4071
  operationsReceived: this.operationsReceived,
4072
+ batchesReceived: this.batchesReceived,
4073
+ uniqueOperationsReceived: this.uniqueOperationsReceived,
4074
+ duplicateOperationsReceived: this.duplicateOperationsReceived,
4075
+ rejectedOperations: this.rejectedOperations,
3946
4076
  operationsSent: this.operationsSent,
3947
4077
  bytesReceived: this.bytesReceived,
3948
4078
  bytesSent: this.bytesSent,
@@ -3959,6 +4089,10 @@ var ServerMetricsCollector = class {
3959
4089
  this.peakConnections = 0;
3960
4090
  this.connectionsTotal = 0;
3961
4091
  this.operationsReceived = 0;
4092
+ this.batchesReceived = 0;
4093
+ this.uniqueOperationsReceived = 0;
4094
+ this.duplicateOperationsReceived = 0;
4095
+ this.rejectedOperations = 0;
3962
4096
  this.operationsSent = 0;
3963
4097
  this.bytesReceived = 0;
3964
4098
  this.bytesSent = 0;
@@ -4640,14 +4774,17 @@ var KoraSyncServer = class _KoraSyncServer {
4640
4774
  if (maxDeliverySequence <= this.lastObservedDeliverySequence) {
4641
4775
  for (const session of this.sessions.values()) {
4642
4776
  session.pushDeliveryStreamIfSupported(this.deliveryPollIntervalMs, {
4643
- trackStall: true
4777
+ trackStall: true,
4778
+ serverFrontier: maxDeliverySequence
4644
4779
  });
4645
4780
  }
4646
4781
  return;
4647
4782
  }
4648
4783
  this.lastObservedDeliverySequence = maxDeliverySequence;
4649
4784
  for (const session of this.sessions.values()) {
4650
- session.pushDeliveryStreamIfSupported(0);
4785
+ session.pushDeliveryStreamIfSupported(0, {
4786
+ serverFrontier: maxDeliverySequence
4787
+ });
4651
4788
  }
4652
4789
  } catch (error) {
4653
4790
  this.logger.log({
@@ -4721,7 +4858,11 @@ var KoraSyncServer = class _KoraSyncServer {
4721
4858
  });
4722
4859
  sessionEmitter.on("sync:received", (event) => {
4723
4860
  const byteSize = estimateByteSize(event.operations);
4724
- this.metrics.recordReceived(sessionId, event.batchSize, byteSize);
4861
+ this.metrics.recordReceived(sessionId, event.batchSize, byteSize, {
4862
+ unique: event.uniqueOperations ?? event.batchSize,
4863
+ duplicates: event.duplicateOperations ?? 0,
4864
+ rejected: event.rejectedOperations ?? 0
4865
+ });
4725
4866
  this.logger.log({
4726
4867
  timestamp: Date.now(),
4727
4868
  level: "info",
@@ -4944,7 +5085,11 @@ var KoraSyncServer = class _KoraSyncServer {
4944
5085
  });
4945
5086
  sessionEmitter.on("sync:received", (event) => {
4946
5087
  const byteSize = estimateOperationByteSize(event.operations);
4947
- this.metrics.recordReceived(sessionId, event.batchSize, byteSize);
5088
+ this.metrics.recordReceived(sessionId, event.batchSize, byteSize, {
5089
+ unique: event.uniqueOperations ?? event.batchSize,
5090
+ duplicates: event.duplicateOperations ?? 0,
5091
+ rejected: event.rejectedOperations ?? 0
5092
+ });
4948
5093
  this.logger.log({
4949
5094
  timestamp: Date.now(),
4950
5095
  level: "info",
@@ -4997,7 +5142,9 @@ var KoraSyncServer = class _KoraSyncServer {
4997
5142
  sessionId,
4998
5143
  details: {
4999
5144
  watermark: event.watermark,
5000
- repeatCount: event.repeatCount
5145
+ outstandingMaxDeliverySequence: event.outstandingMaxDeliverySequence,
5146
+ repeatCount: event.repeatCount,
5147
+ reason: event.reason
5001
5148
  }
5002
5149
  });
5003
5150
  });
@@ -5399,6 +5546,22 @@ function createProductionServer(config) {
5399
5546
  "# TYPE kora_operations_received_total counter",
5400
5547
  `kora_operations_received_total ${status.operationsReceived}`,
5401
5548
  "",
5549
+ "# HELP kora_operation_batches_received_total Total operation batches received from clients",
5550
+ "# TYPE kora_operation_batches_received_total counter",
5551
+ `kora_operation_batches_received_total ${status.batchesReceived}`,
5552
+ "",
5553
+ "# HELP kora_unique_operations_received_total Newly materialized operations received from clients",
5554
+ "# TYPE kora_unique_operations_received_total counter",
5555
+ `kora_unique_operations_received_total ${status.uniqueOperationsReceived}`,
5556
+ "",
5557
+ "# HELP kora_duplicate_operations_received_total Duplicate operations received from clients",
5558
+ "# TYPE kora_duplicate_operations_received_total counter",
5559
+ `kora_duplicate_operations_received_total ${status.duplicateOperationsReceived}`,
5560
+ "",
5561
+ "# HELP kora_rejected_operations_total Operations rejected before authoritative materialization",
5562
+ "# TYPE kora_rejected_operations_total counter",
5563
+ `kora_rejected_operations_total ${status.rejectedOperations}`,
5564
+ "",
5402
5565
  "# HELP kora_operations_sent_total Total operations sent to clients",
5403
5566
  "# TYPE kora_operations_sent_total counter",
5404
5567
  `kora_operations_sent_total ${status.operationsSent}`,