@korajs/server 1.0.0-beta.10 → 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
@@ -2523,6 +2523,39 @@ function mergeScopeMaps(base, overlay, source) {
2523
2523
  }
2524
2524
 
2525
2525
  // src/scopes/server-scope-filter.ts
2526
+ var DEFAULT_MAX_SCOPE_PREDICATE_VALUES = 100;
2527
+ function normalizeScopeMap(scopes, maxValues = DEFAULT_MAX_SCOPE_PREDICATE_VALUES) {
2528
+ const normalized = {};
2529
+ for (const collection of Object.keys(scopes).sort()) {
2530
+ const predicate = {};
2531
+ for (const field of Object.keys(scopes[collection] ?? {}).sort()) {
2532
+ const expected = scopes[collection]?.[field];
2533
+ if (expected && typeof expected === "object" && !Array.isArray(expected) && "$in" in expected) {
2534
+ const values = expected.$in;
2535
+ if (!Array.isArray(values))
2536
+ throw new Error(`Invalid $in predicate for ${collection}.${field}`);
2537
+ const unique = [...new Map(values.map((value) => [stableValueKey(value), value])).values()];
2538
+ if (unique.length > maxValues)
2539
+ throw new Error(
2540
+ `Scope predicate for ${collection}.${field} exceeds the ${maxValues}-value limit`
2541
+ );
2542
+ predicate[field] = {
2543
+ $in: unique.sort((a, b) => stableValueKey(a).localeCompare(stableValueKey(b)))
2544
+ };
2545
+ } else predicate[field] = expected;
2546
+ }
2547
+ normalized[collection] = predicate;
2548
+ }
2549
+ return normalized;
2550
+ }
2551
+ function stableValueKey(value) {
2552
+ if (Array.isArray(value)) return `[${value.map(stableValueKey).join(",")}]`;
2553
+ if (value && typeof value === "object")
2554
+ return `{${Object.keys(value).sort().map(
2555
+ (key) => `${JSON.stringify(key)}:${stableValueKey(value[key])}`
2556
+ ).join(",")}}`;
2557
+ return JSON.stringify(value);
2558
+ }
2526
2559
  function operationMatchesScopes(op, scopes, fullRecord) {
2527
2560
  if (!scopes) return true;
2528
2561
  const collectionScope = scopes[op.collection];
@@ -2531,12 +2564,33 @@ function operationMatchesScopes(op, scopes, fullRecord) {
2531
2564
  const snapshot = buildSnapshot(op, fullRecord);
2532
2565
  if (!snapshot) return false;
2533
2566
  for (const [field, expected] of Object.entries(collectionScope)) {
2534
- if (snapshot[field] !== expected) {
2567
+ if (!matchesPredicate(snapshot[field], expected)) {
2535
2568
  return false;
2536
2569
  }
2537
2570
  }
2538
2571
  return true;
2539
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
+ }
2587
+ function matchesPredicate(actual, expected) {
2588
+ if (expected && typeof expected === "object" && !Array.isArray(expected) && "$in" in expected) {
2589
+ const values = expected.$in;
2590
+ return Array.isArray(values) && values.some((value) => Object.is(actual, value));
2591
+ }
2592
+ return Object.is(actual, expected);
2593
+ }
2540
2594
  function missingScopeFields(op, scopes) {
2541
2595
  if (!scopes) return [];
2542
2596
  const collectionScope = scopes[op.collection];
@@ -2653,13 +2707,15 @@ function stableStringify(value) {
2653
2707
  return JSON.stringify(value);
2654
2708
  }
2655
2709
  function sameScopeMap(a, b) {
2656
- return stableStringify(a ?? null) === stableStringify(b ?? null);
2710
+ const normalize = (value) => value && typeof value === "object" ? normalizeScopeMap(value) : value;
2711
+ return stableStringify(normalize(a) ?? null) === stableStringify(normalize(b) ?? null);
2657
2712
  }
2658
2713
  var ClientSession = class _ClientSession {
2659
2714
  state = "connected";
2660
2715
  clientNodeId = null;
2661
2716
  authContext = null;
2662
2717
  syncQuerySubsets = [];
2718
+ scopeExitPolicy = "retain";
2663
2719
  resumeDeltaCursor = null;
2664
2720
  /**
2665
2721
  * The delivery watermark the client reported at handshake, or null when the client
@@ -2684,7 +2740,7 @@ var ClientSession = class _ClientSession {
2684
2740
  * batches every server tick while the first batch is still being applied.
2685
2741
  */
2686
2742
  lastDeliveryPushAttemptAtMs = 0;
2687
- deliveryStallRepeatCount = 0;
2743
+ outstandingDelivery = null;
2688
2744
  /** Serializes incremental delivery pushes so their batches never interleave. */
2689
2745
  deliveryPushChain = Promise.resolve();
2690
2746
  /**
@@ -2783,34 +2839,66 @@ var ClientSession = class _ClientSession {
2783
2839
  * from the highest delivery sequence already sent. Pushes are serialized so their
2784
2840
  * batches never interleave (which would break the base/max chain).
2785
2841
  */
2786
- pushDeliveryStream() {
2842
+ pushDeliveryStream(options = {}) {
2787
2843
  this.deliveryPushChain = this.deliveryPushChain.then(async () => {
2788
2844
  if (this.state !== "streaming" || !this.transport.isConnected()) return;
2789
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
+ }
2790
2860
  this.lastDeliveryPushAttemptAtMs = Date.now();
2791
- await this.sendDeliveryStream(
2861
+ const result = await this.sendDeliveryStream(
2792
2862
  this.lastAckedDeliverySeq,
2793
2863
  false,
2794
2864
  this.clientNodeId ?? void 0
2795
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
+ }
2796
2880
  } catch {
2797
2881
  }
2798
2882
  });
2799
2883
  }
2800
2884
  async relayVisibleOperations(operations2) {
2801
2885
  const visibleOperations = [];
2886
+ const retractions = [];
2802
2887
  for (const op of operations2) {
2803
2888
  if (await this.operationVisibleToClient(op)) {
2804
2889
  visibleOperations.push(op);
2890
+ } else if (await this.scopeRetractionFor(op)) {
2891
+ retractions.push({ collection: op.collection, recordId: op.recordId });
2805
2892
  }
2806
2893
  }
2807
- if (visibleOperations.length === 0) return;
2894
+ if (visibleOperations.length === 0 && retractions.length === 0) return;
2808
2895
  if (this.state !== "streaming" || !this.transport.isConnected()) return;
2809
2896
  const serializedOps = visibleOperations.map((op) => this.serializer.encodeOperation(op));
2810
2897
  const msg = {
2811
2898
  type: "operation-batch",
2812
2899
  messageId: generateUUIDv74(),
2813
2900
  operations: serializedOps,
2901
+ ...retractions.length > 0 ? { retractions } : {},
2814
2902
  isFinal: true,
2815
2903
  batchIndex: 0
2816
2904
  };
@@ -2854,21 +2942,14 @@ var ClientSession = class _ClientSession {
2854
2942
  pushDeliveryStreamIfSupported(staleMs = 0, options = {}) {
2855
2943
  if (this.state !== "streaming" || !this.transport.isConnected()) return;
2856
2944
  if (this.clientDeliveryWatermark === null) return;
2857
- if (staleMs > 0 && Date.now() - this.lastDeliveryPushAttemptAtMs < staleMs) {
2945
+ if (options.serverFrontier !== void 0 && options.serverFrontier <= this.lastAckedDeliverySeq) {
2946
+ this.outstandingDelivery = null;
2858
2947
  return;
2859
2948
  }
2860
- if (options.trackStall) {
2861
- this.deliveryStallRepeatCount += 1;
2862
- if (this.deliveryStallRepeatCount >= 3) {
2863
- this.emitter?.emit({
2864
- type: "sync:delivery-stalled",
2865
- sessionId: this.sessionId,
2866
- watermark: this.lastAckedDeliverySeq,
2867
- repeatCount: this.deliveryStallRepeatCount
2868
- });
2869
- }
2949
+ if (staleMs > 0 && Date.now() - this.lastDeliveryPushAttemptAtMs < staleMs) {
2950
+ return;
2870
2951
  }
2871
- this.pushDeliveryStream();
2952
+ this.pushDeliveryStream({ trackStall: options.trackStall });
2872
2953
  }
2873
2954
  /**
2874
2955
  * Close this session.
@@ -2951,7 +3032,11 @@ var ClientSession = class _ClientSession {
2951
3032
  this.pendingRelays.delete(message.acknowledgedMessageId);
2952
3033
  if (message.deliverySequence !== void 0) {
2953
3034
  this.lastAckedDeliverySeq = Math.max(this.lastAckedDeliverySeq, message.deliverySequence);
2954
- 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
+ }
2955
3040
  }
2956
3041
  break;
2957
3042
  case "error":
@@ -3012,6 +3097,7 @@ var ClientSession = class _ClientSession {
3012
3097
  return;
3013
3098
  }
3014
3099
  this.clientNodeId = msg.nodeId;
3100
+ this.scopeExitPolicy = msg.scopeExitPolicy ?? "retain";
3015
3101
  if (this.auth) {
3016
3102
  const token = msg.authToken ?? "";
3017
3103
  const context = await this.auth.authenticate(token);
@@ -3023,18 +3109,48 @@ var ClientSession = class _ClientSession {
3023
3109
  this.authContext = context;
3024
3110
  this.state = "authenticated";
3025
3111
  }
3026
- const resolvedScopes = 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(), {
3027
3116
  handshakeScope: msg.syncScope,
3028
- authScopes: this.authContext?.scopes
3117
+ authScopes: downlinkAuthScopes
3029
3118
  });
3030
- if (resolvedScopes) {
3119
+ const rawResolvedUplinkScopes = directionalScopesConfigured ? uplinkAuthScopes : rawResolvedDownlinkScopes;
3120
+ let resolvedDownlinkScopes;
3121
+ let resolvedUplinkScopes;
3122
+ try {
3123
+ resolvedDownlinkScopes = rawResolvedDownlinkScopes ? normalizeScopeMap(rawResolvedDownlinkScopes) : directionalScopesConfigured ? {} : void 0;
3124
+ resolvedUplinkScopes = rawResolvedUplinkScopes ? normalizeScopeMap(rawResolvedUplinkScopes) : directionalScopesConfigured ? {} : void 0;
3125
+ } catch (error) {
3126
+ this.sendToClient({
3127
+ type: "error",
3128
+ messageId: generateUUIDv74(),
3129
+ code: "SCOPE_PREDICATE_LIMIT",
3130
+ message: error instanceof Error ? error.message : "Invalid scope predicate",
3131
+ retriable: false
3132
+ });
3133
+ this.close("invalid scope predicate");
3134
+ return;
3135
+ }
3136
+ if (resolvedDownlinkScopes || resolvedUplinkScopes) {
3031
3137
  if (this.authContext) {
3032
- this.authContext = { ...this.authContext, scopes: resolvedScopes };
3138
+ this.authContext = {
3139
+ ...this.authContext,
3140
+ scopes: resolvedDownlinkScopes,
3141
+ downlinkScopes: resolvedDownlinkScopes,
3142
+ uplinkScopes: resolvedUplinkScopes
3143
+ };
3033
3144
  } else {
3034
- this.authContext = { userId: msg.nodeId, scopes: resolvedScopes };
3145
+ this.authContext = {
3146
+ userId: msg.nodeId,
3147
+ scopes: resolvedDownlinkScopes,
3148
+ downlinkScopes: resolvedDownlinkScopes,
3149
+ uplinkScopes: resolvedUplinkScopes
3150
+ };
3035
3151
  }
3036
3152
  }
3037
- warnIfMultiTenantWithoutScopes(this.auth, resolvedScopes, this.store.getSchema());
3153
+ warnIfMultiTenantWithoutScopes(this.auth, resolvedDownlinkScopes, this.store.getSchema());
3038
3154
  if (msg.syncQueries && msg.syncQueries.length > 0) {
3039
3155
  this.syncQuerySubsets = dedupeQuerySubsets(msg.syncQueries);
3040
3156
  } else {
@@ -3042,7 +3158,7 @@ var ClientSession = class _ClientSession {
3042
3158
  }
3043
3159
  this.resumeDeltaCursor = msg.deltaCursor ? decodeDeltaCursor(msg.deltaCursor) : null;
3044
3160
  this.clientDeliveryWatermark = msg.lastDeliverySequence ?? null;
3045
- if (this.clientDeliveryWatermark !== null && !sameScopeMap(msg.syncScope, this.authContext?.scopes)) {
3161
+ if (this.clientDeliveryWatermark !== null && !sameScopeMap(msg.syncScope, this.authContext?.downlinkScopes)) {
3046
3162
  this.clientDeliveryWatermark = 0;
3047
3163
  }
3048
3164
  const clientUsesWatermark = this.clientDeliveryWatermark !== null;
@@ -3100,7 +3216,11 @@ var ClientSession = class _ClientSession {
3100
3216
  ...this.persistBlobChunk ? { blobStorageEnabled: true } : {},
3101
3217
  // Confirm the accepted scope so the client knows what data will be synced.
3102
3218
  // This may differ from what the client requested if auth scopes are narrower.
3103
- ...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 } : {}
3104
3224
  };
3105
3225
  this.sendToClient(response);
3106
3226
  this.emitter?.emit({ type: "sync:connected", nodeId: msg.nodeId });
@@ -3127,18 +3247,22 @@ var ClientSession = class _ClientSession {
3127
3247
  const applied = [];
3128
3248
  let acknowledgedThrough = 0;
3129
3249
  let canAdvanceAck = true;
3250
+ let uniqueOperations = 0;
3251
+ let duplicateOperations = 0;
3252
+ let rejectedOperations = 0;
3130
3253
  for (const op of operations2) {
3131
3254
  if (!canAdvanceAck) {
3132
3255
  continue;
3133
3256
  }
3134
- if (!await this.operationVisibleToClient(op)) {
3257
+ if (!await this.operationAllowedFromClient(op)) {
3135
3258
  this.sendOperationRejected(
3136
3259
  op,
3137
3260
  "SCOPE_VIOLATION",
3138
- `Operation "${op.id}" in collection "${op.collection}" is outside the client's sync scope. Refresh sync scopes and retry.`,
3139
- 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
3140
3263
  );
3141
- canAdvanceAck = false;
3264
+ rejectedOperations += 1;
3265
+ acknowledgedThrough = op.sequenceNumber;
3142
3266
  continue;
3143
3267
  }
3144
3268
  if (!isOperationTimestampValid(op)) {
@@ -3177,6 +3301,7 @@ var ClientSession = class _ClientSession {
3177
3301
  `Operation "${op.id}" cannot be transformed from schema v${op.schemaVersion} to server schema v${this.schemaVersion}.`,
3178
3302
  false
3179
3303
  );
3304
+ rejectedOperations += 1;
3180
3305
  acknowledgedThrough = op.sequenceNumber;
3181
3306
  continue;
3182
3307
  }
@@ -3201,6 +3326,7 @@ var ClientSession = class _ClientSession {
3201
3326
  if (decision.action === "reject") {
3202
3327
  const retriable = decision.retriable ?? isRetriableRejection(decision.code);
3203
3328
  this.sendOperationRejected(serverOp, decision.code, decision.message, retriable);
3329
+ rejectedOperations += 1;
3204
3330
  if (retriable) {
3205
3331
  canAdvanceAck = false;
3206
3332
  } else {
@@ -3221,6 +3347,7 @@ var ClientSession = class _ClientSession {
3221
3347
  applyResult.rejection.message,
3222
3348
  applyResult.rejection.retriable
3223
3349
  );
3350
+ rejectedOperations += 1;
3224
3351
  if (applyResult.rejection.retriable) {
3225
3352
  canAdvanceAck = false;
3226
3353
  } else {
@@ -3230,8 +3357,10 @@ var ClientSession = class _ClientSession {
3230
3357
  }
3231
3358
  if (applyResult.result === "applied") {
3232
3359
  applied.push(...applyResult.appliedOperations);
3360
+ uniqueOperations += 1;
3233
3361
  acknowledgedThrough = op.sequenceNumber;
3234
3362
  } else {
3363
+ duplicateOperations += 1;
3235
3364
  acknowledgedThrough = op.sequenceNumber;
3236
3365
  }
3237
3366
  }
@@ -3239,7 +3368,10 @@ var ClientSession = class _ClientSession {
3239
3368
  this.emitter?.emit({
3240
3369
  type: "sync:received",
3241
3370
  operations: operations2,
3242
- batchSize: operations2.length
3371
+ batchSize: operations2.length,
3372
+ uniqueOperations,
3373
+ duplicateOperations,
3374
+ rejectedOperations
3243
3375
  });
3244
3376
  }
3245
3377
  const lastOp = operations2[operations2.length - 1];
@@ -3344,7 +3476,7 @@ var ClientSession = class _ClientSession {
3344
3476
  const scanChunk = Math.max(this.batchSize, 1) * 5;
3345
3477
  let scanCursor = fromDeliverySeq;
3346
3478
  let maxScanned = fromDeliverySeq;
3347
- const visible = [];
3479
+ const deliverable = [];
3348
3480
  while (true) {
3349
3481
  const chunk = await this.store.getOperationsAfterDelivery(scanCursor, scanChunk);
3350
3482
  const last = chunk[chunk.length - 1];
@@ -3356,21 +3488,24 @@ var ClientSession = class _ClientSession {
3356
3488
  continue;
3357
3489
  }
3358
3490
  if (await this.operationVisibleToClient(delivered.operation)) {
3359
- visible.push(delivered);
3491
+ deliverable.push(delivered);
3492
+ } else if (await this.scopeRetractionFor(delivered.operation)) {
3493
+ deliverable.push({ ...delivered, retraction: true });
3360
3494
  }
3361
3495
  }
3362
3496
  if (chunk.length < scanChunk) break;
3363
3497
  }
3364
- if (visible.length === 0) {
3365
- if (finalizeWhenEmpty) {
3366
- 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);
3367
3502
  }
3368
- return 0;
3503
+ return { sentOperations: 0, maxScanned, sent };
3369
3504
  }
3370
- const totalBatches = Math.ceil(visible.length / this.batchSize);
3505
+ const totalBatches = Math.ceil(deliverable.length / this.batchSize);
3371
3506
  let base = fromDeliverySeq;
3372
3507
  for (let i = 0; i < totalBatches; i++) {
3373
- const slice = visible.slice(i * this.batchSize, (i + 1) * this.batchSize);
3508
+ const slice = deliverable.slice(i * this.batchSize, (i + 1) * this.batchSize);
3374
3509
  const lastInSlice = slice[slice.length - 1];
3375
3510
  if (lastInSlice === void 0) continue;
3376
3511
  const isFinal = i === totalBatches - 1;
@@ -3379,30 +3514,42 @@ var ClientSession = class _ClientSession {
3379
3514
  this.sendDeliveryBatch(slice, base, max, i, isFinal);
3380
3515
  base = max;
3381
3516
  }
3382
- return visible.length;
3517
+ return {
3518
+ sentOperations: deliverable.filter((item) => !item.retraction).length,
3519
+ maxScanned,
3520
+ sent: true
3521
+ };
3383
3522
  }
3384
3523
  /** Build, track, and send one chained delivery-stream batch. */
3385
3524
  sendDeliveryBatch(slice, base, max, batchIndex, isFinal) {
3386
3525
  const batchMsg = {
3387
3526
  type: "operation-batch",
3388
3527
  messageId: generateUUIDv74(),
3389
- 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
+ } : {},
3390
3535
  isFinal,
3391
3536
  batchIndex,
3392
3537
  baseDeliverySequence: base,
3393
3538
  maxDeliverySequence: max
3394
3539
  };
3395
- this.sendToClient(batchMsg);
3396
- if (slice.length > 0) {
3540
+ const sent = this.sendToClient(batchMsg);
3541
+ const sentOperations = slice.filter((delivered) => !delivered.retraction);
3542
+ if (sentOperations.length > 0) {
3397
3543
  this.emitter?.emit({
3398
3544
  type: "sync:sent",
3399
- operations: slice.map((delivered) => delivered.operation),
3400
- batchSize: slice.length
3545
+ operations: sentOperations.map((delivered) => delivered.operation),
3546
+ batchSize: sentOperations.length
3401
3547
  });
3402
3548
  }
3549
+ return sent;
3403
3550
  }
3404
3551
  async operationVisibleToClient(op) {
3405
- const scopes = this.authContext?.scopes;
3552
+ const scopes = this.authContext?.downlinkScopes ?? this.authContext?.scopes;
3406
3553
  const subsets = this.syncQuerySubsets;
3407
3554
  const needsBackfill = missingScopeFields(op, scopes).length > 0 || subsets !== void 0 && subsets.length > 0;
3408
3555
  const fullRecord = needsBackfill ? await this.lookupRecordFields(op.collection, op.recordId) : void 0;
@@ -3411,6 +3558,20 @@ var ClientSession = class _ClientSession {
3411
3558
  }
3412
3559
  return operationMatchesQuerySubsets(op, subsets, fullRecord);
3413
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
+ }
3414
3575
  /**
3415
3576
  * Read a record's current field values from the materialized store for scope /
3416
3577
  * query-subset backfill. Includes soft-deleted rows so a relayed delete (whose op
@@ -3599,6 +3760,10 @@ var ServerMetricsCollector = class {
3599
3760
  peakConnections = 0;
3600
3761
  connectionsTotal = 0;
3601
3762
  operationsReceived = 0;
3763
+ batchesReceived = 0;
3764
+ uniqueOperationsReceived = 0;
3765
+ duplicateOperationsReceived = 0;
3766
+ rejectedOperations = 0;
3602
3767
  operationsSent = 0;
3603
3768
  bytesReceived = 0;
3604
3769
  bytesSent = 0;
@@ -3614,6 +3779,10 @@ var ServerMetricsCollector = class {
3614
3779
  state: "connected",
3615
3780
  connectedAt: Date.now(),
3616
3781
  operationsReceived: 0,
3782
+ batchesReceived: 0,
3783
+ uniqueOperationsReceived: 0,
3784
+ duplicateOperationsReceived: 0,
3785
+ rejectedOperations: 0,
3617
3786
  operationsSent: 0,
3618
3787
  authContext: null
3619
3788
  });
@@ -3645,12 +3814,24 @@ var ServerMetricsCollector = class {
3645
3814
  }
3646
3815
  }
3647
3816
  /** Record operations received from a client. */
3648
- recordReceived(sessionId, count3, byteSize) {
3817
+ recordReceived(sessionId, count3, byteSize, outcomes = {
3818
+ unique: count3,
3819
+ duplicates: 0,
3820
+ rejected: 0
3821
+ }) {
3649
3822
  this.operationsReceived += count3;
3823
+ this.batchesReceived += 1;
3824
+ this.uniqueOperationsReceived += outcomes.unique;
3825
+ this.duplicateOperationsReceived += outcomes.duplicates;
3826
+ this.rejectedOperations += outcomes.rejected;
3650
3827
  this.bytesReceived += byteSize;
3651
3828
  const client = this.clientMetrics.get(sessionId);
3652
3829
  if (client) {
3653
3830
  client.operationsReceived += count3;
3831
+ client.batchesReceived += 1;
3832
+ client.uniqueOperationsReceived += outcomes.unique;
3833
+ client.duplicateOperationsReceived += outcomes.duplicates;
3834
+ client.rejectedOperations += outcomes.rejected;
3654
3835
  }
3655
3836
  }
3656
3837
  /** Record operations sent to a client. */
@@ -3678,6 +3859,10 @@ var ServerMetricsCollector = class {
3678
3859
  peakConnections: this.peakConnections,
3679
3860
  connectionsTotal: this.connectionsTotal,
3680
3861
  operationsReceived: this.operationsReceived,
3862
+ batchesReceived: this.batchesReceived,
3863
+ uniqueOperationsReceived: this.uniqueOperationsReceived,
3864
+ duplicateOperationsReceived: this.duplicateOperationsReceived,
3865
+ rejectedOperations: this.rejectedOperations,
3681
3866
  operationsSent: this.operationsSent,
3682
3867
  bytesReceived: this.bytesReceived,
3683
3868
  bytesSent: this.bytesSent,
@@ -3694,6 +3879,10 @@ var ServerMetricsCollector = class {
3694
3879
  this.peakConnections = 0;
3695
3880
  this.connectionsTotal = 0;
3696
3881
  this.operationsReceived = 0;
3882
+ this.batchesReceived = 0;
3883
+ this.uniqueOperationsReceived = 0;
3884
+ this.duplicateOperationsReceived = 0;
3885
+ this.rejectedOperations = 0;
3697
3886
  this.operationsSent = 0;
3698
3887
  this.bytesReceived = 0;
3699
3888
  this.bytesSent = 0;
@@ -4380,14 +4569,17 @@ var KoraSyncServer = class _KoraSyncServer {
4380
4569
  if (maxDeliverySequence <= this.lastObservedDeliverySequence) {
4381
4570
  for (const session of this.sessions.values()) {
4382
4571
  session.pushDeliveryStreamIfSupported(this.deliveryPollIntervalMs, {
4383
- trackStall: true
4572
+ trackStall: true,
4573
+ serverFrontier: maxDeliverySequence
4384
4574
  });
4385
4575
  }
4386
4576
  return;
4387
4577
  }
4388
4578
  this.lastObservedDeliverySequence = maxDeliverySequence;
4389
4579
  for (const session of this.sessions.values()) {
4390
- session.pushDeliveryStreamIfSupported(0);
4580
+ session.pushDeliveryStreamIfSupported(0, {
4581
+ serverFrontier: maxDeliverySequence
4582
+ });
4391
4583
  }
4392
4584
  } catch (error) {
4393
4585
  this.logger.log({
@@ -4461,7 +4653,11 @@ var KoraSyncServer = class _KoraSyncServer {
4461
4653
  });
4462
4654
  sessionEmitter.on("sync:received", (event) => {
4463
4655
  const byteSize = estimateByteSize(event.operations);
4464
- 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
+ });
4465
4661
  this.logger.log({
4466
4662
  timestamp: Date.now(),
4467
4663
  level: "info",
@@ -4684,7 +4880,11 @@ var KoraSyncServer = class _KoraSyncServer {
4684
4880
  });
4685
4881
  sessionEmitter.on("sync:received", (event) => {
4686
4882
  const byteSize = estimateOperationByteSize(event.operations);
4687
- 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
+ });
4688
4888
  this.logger.log({
4689
4889
  timestamp: Date.now(),
4690
4890
  level: "info",
@@ -4737,7 +4937,9 @@ var KoraSyncServer = class _KoraSyncServer {
4737
4937
  sessionId,
4738
4938
  details: {
4739
4939
  watermark: event.watermark,
4740
- repeatCount: event.repeatCount
4940
+ outstandingMaxDeliverySequence: event.outstandingMaxDeliverySequence,
4941
+ repeatCount: event.repeatCount,
4942
+ reason: event.reason
4741
4943
  }
4742
4944
  });
4743
4945
  });
@@ -5134,6 +5336,22 @@ function createProductionServer(config) {
5134
5336
  "# TYPE kora_operations_received_total counter",
5135
5337
  `kora_operations_received_total ${status.operationsReceived}`,
5136
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
+ "",
5137
5355
  "# HELP kora_operations_sent_total Total operations sent to clients",
5138
5356
  "# TYPE kora_operations_sent_total counter",
5139
5357
  `kora_operations_sent_total ${status.operationsSent}`,
@@ -5519,6 +5737,7 @@ function formatUptime(ms) {
5519
5737
  export {
5520
5738
  AwarenessRelay,
5521
5739
  ClientSession,
5740
+ DEFAULT_MAX_SCOPE_PREDICATE_VALUES,
5522
5741
  HttpServerTransport,
5523
5742
  KoraAuthProvider,
5524
5743
  KoraSyncServer,
@@ -5538,6 +5757,8 @@ export {
5538
5757
  createProductionServer,
5539
5758
  createSilentLogger,
5540
5759
  createSqliteServerStore,
5541
- isRetriableRejection
5760
+ isRetriableRejection,
5761
+ normalizeScopeMap,
5762
+ operationMatchesScopes
5542
5763
  };
5543
5764
  //# sourceMappingURL=index.js.map