@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.cjs CHANGED
@@ -169,6 +169,7 @@ var index_exports = {};
169
169
  __export(index_exports, {
170
170
  AwarenessRelay: () => AwarenessRelay,
171
171
  ClientSession: () => ClientSession,
172
+ DEFAULT_MAX_SCOPE_PREDICATE_VALUES: () => DEFAULT_MAX_SCOPE_PREDICATE_VALUES,
172
173
  HttpServerTransport: () => HttpServerTransport,
173
174
  KoraAuthProvider: () => KoraAuthProvider,
174
175
  KoraSyncServer: () => KoraSyncServer,
@@ -188,7 +189,9 @@ __export(index_exports, {
188
189
  createProductionServer: () => createProductionServer,
189
190
  createSilentLogger: () => createSilentLogger,
190
191
  createSqliteServerStore: () => createSqliteServerStore,
191
- isRetriableRejection: () => isRetriableRejection
192
+ isRetriableRejection: () => isRetriableRejection,
193
+ normalizeScopeMap: () => normalizeScopeMap,
194
+ operationMatchesScopes: () => operationMatchesScopes
192
195
  });
193
196
  module.exports = __toCommonJS(index_exports);
194
197
  init_cjs_shims();
@@ -2725,6 +2728,39 @@ function mergeScopeMaps(base, overlay, source) {
2725
2728
 
2726
2729
  // src/scopes/server-scope-filter.ts
2727
2730
  init_cjs_shims();
2731
+ var DEFAULT_MAX_SCOPE_PREDICATE_VALUES = 100;
2732
+ function normalizeScopeMap(scopes, maxValues = DEFAULT_MAX_SCOPE_PREDICATE_VALUES) {
2733
+ const normalized = {};
2734
+ for (const collection of Object.keys(scopes).sort()) {
2735
+ const predicate = {};
2736
+ for (const field of Object.keys(scopes[collection] ?? {}).sort()) {
2737
+ const expected = scopes[collection]?.[field];
2738
+ if (expected && typeof expected === "object" && !Array.isArray(expected) && "$in" in expected) {
2739
+ const values = expected.$in;
2740
+ if (!Array.isArray(values))
2741
+ throw new Error(`Invalid $in predicate for ${collection}.${field}`);
2742
+ const unique = [...new Map(values.map((value) => [stableValueKey(value), value])).values()];
2743
+ if (unique.length > maxValues)
2744
+ throw new Error(
2745
+ `Scope predicate for ${collection}.${field} exceeds the ${maxValues}-value limit`
2746
+ );
2747
+ predicate[field] = {
2748
+ $in: unique.sort((a, b) => stableValueKey(a).localeCompare(stableValueKey(b)))
2749
+ };
2750
+ } else predicate[field] = expected;
2751
+ }
2752
+ normalized[collection] = predicate;
2753
+ }
2754
+ return normalized;
2755
+ }
2756
+ function stableValueKey(value) {
2757
+ if (Array.isArray(value)) return `[${value.map(stableValueKey).join(",")}]`;
2758
+ if (value && typeof value === "object")
2759
+ return `{${Object.keys(value).sort().map(
2760
+ (key) => `${JSON.stringify(key)}:${stableValueKey(value[key])}`
2761
+ ).join(",")}}`;
2762
+ return JSON.stringify(value);
2763
+ }
2728
2764
  function operationMatchesScopes(op, scopes, fullRecord) {
2729
2765
  if (!scopes) return true;
2730
2766
  const collectionScope = scopes[op.collection];
@@ -2733,12 +2769,33 @@ function operationMatchesScopes(op, scopes, fullRecord) {
2733
2769
  const snapshot = buildSnapshot(op, fullRecord);
2734
2770
  if (!snapshot) return false;
2735
2771
  for (const [field, expected] of Object.entries(collectionScope)) {
2736
- if (snapshot[field] !== expected) {
2772
+ if (!matchesPredicate(snapshot[field], expected)) {
2737
2773
  return false;
2738
2774
  }
2739
2775
  }
2740
2776
  return true;
2741
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
+ }
2792
+ function matchesPredicate(actual, expected) {
2793
+ if (expected && typeof expected === "object" && !Array.isArray(expected) && "$in" in expected) {
2794
+ const values = expected.$in;
2795
+ return Array.isArray(values) && values.some((value) => Object.is(actual, value));
2796
+ }
2797
+ return Object.is(actual, expected);
2798
+ }
2742
2799
  function missingScopeFields(op, scopes) {
2743
2800
  if (!scopes) return [];
2744
2801
  const collectionScope = scopes[op.collection];
@@ -2857,13 +2914,15 @@ function stableStringify(value) {
2857
2914
  return JSON.stringify(value);
2858
2915
  }
2859
2916
  function sameScopeMap(a, b) {
2860
- return stableStringify(a ?? null) === stableStringify(b ?? null);
2917
+ const normalize = (value) => value && typeof value === "object" ? normalizeScopeMap(value) : value;
2918
+ return stableStringify(normalize(a) ?? null) === stableStringify(normalize(b) ?? null);
2861
2919
  }
2862
2920
  var ClientSession = class _ClientSession {
2863
2921
  state = "connected";
2864
2922
  clientNodeId = null;
2865
2923
  authContext = null;
2866
2924
  syncQuerySubsets = [];
2925
+ scopeExitPolicy = "retain";
2867
2926
  resumeDeltaCursor = null;
2868
2927
  /**
2869
2928
  * The delivery watermark the client reported at handshake, or null when the client
@@ -2888,7 +2947,7 @@ var ClientSession = class _ClientSession {
2888
2947
  * batches every server tick while the first batch is still being applied.
2889
2948
  */
2890
2949
  lastDeliveryPushAttemptAtMs = 0;
2891
- deliveryStallRepeatCount = 0;
2950
+ outstandingDelivery = null;
2892
2951
  /** Serializes incremental delivery pushes so their batches never interleave. */
2893
2952
  deliveryPushChain = Promise.resolve();
2894
2953
  /**
@@ -2987,34 +3046,66 @@ var ClientSession = class _ClientSession {
2987
3046
  * from the highest delivery sequence already sent. Pushes are serialized so their
2988
3047
  * batches never interleave (which would break the base/max chain).
2989
3048
  */
2990
- pushDeliveryStream() {
3049
+ pushDeliveryStream(options = {}) {
2991
3050
  this.deliveryPushChain = this.deliveryPushChain.then(async () => {
2992
3051
  if (this.state !== "streaming" || !this.transport.isConnected()) return;
2993
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
+ }
2994
3067
  this.lastDeliveryPushAttemptAtMs = Date.now();
2995
- await this.sendDeliveryStream(
3068
+ const result = await this.sendDeliveryStream(
2996
3069
  this.lastAckedDeliverySeq,
2997
3070
  false,
2998
3071
  this.clientNodeId ?? void 0
2999
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
+ }
3000
3087
  } catch {
3001
3088
  }
3002
3089
  });
3003
3090
  }
3004
3091
  async relayVisibleOperations(operations2) {
3005
3092
  const visibleOperations = [];
3093
+ const retractions = [];
3006
3094
  for (const op of operations2) {
3007
3095
  if (await this.operationVisibleToClient(op)) {
3008
3096
  visibleOperations.push(op);
3097
+ } else if (await this.scopeRetractionFor(op)) {
3098
+ retractions.push({ collection: op.collection, recordId: op.recordId });
3009
3099
  }
3010
3100
  }
3011
- if (visibleOperations.length === 0) return;
3101
+ if (visibleOperations.length === 0 && retractions.length === 0) return;
3012
3102
  if (this.state !== "streaming" || !this.transport.isConnected()) return;
3013
3103
  const serializedOps = visibleOperations.map((op) => this.serializer.encodeOperation(op));
3014
3104
  const msg = {
3015
3105
  type: "operation-batch",
3016
3106
  messageId: (0, import_core11.generateUUIDv7)(),
3017
3107
  operations: serializedOps,
3108
+ ...retractions.length > 0 ? { retractions } : {},
3018
3109
  isFinal: true,
3019
3110
  batchIndex: 0
3020
3111
  };
@@ -3058,21 +3149,14 @@ var ClientSession = class _ClientSession {
3058
3149
  pushDeliveryStreamIfSupported(staleMs = 0, options = {}) {
3059
3150
  if (this.state !== "streaming" || !this.transport.isConnected()) return;
3060
3151
  if (this.clientDeliveryWatermark === null) return;
3061
- if (staleMs > 0 && Date.now() - this.lastDeliveryPushAttemptAtMs < staleMs) {
3152
+ if (options.serverFrontier !== void 0 && options.serverFrontier <= this.lastAckedDeliverySeq) {
3153
+ this.outstandingDelivery = null;
3062
3154
  return;
3063
3155
  }
3064
- if (options.trackStall) {
3065
- this.deliveryStallRepeatCount += 1;
3066
- if (this.deliveryStallRepeatCount >= 3) {
3067
- this.emitter?.emit({
3068
- type: "sync:delivery-stalled",
3069
- sessionId: this.sessionId,
3070
- watermark: this.lastAckedDeliverySeq,
3071
- repeatCount: this.deliveryStallRepeatCount
3072
- });
3073
- }
3156
+ if (staleMs > 0 && Date.now() - this.lastDeliveryPushAttemptAtMs < staleMs) {
3157
+ return;
3074
3158
  }
3075
- this.pushDeliveryStream();
3159
+ this.pushDeliveryStream({ trackStall: options.trackStall });
3076
3160
  }
3077
3161
  /**
3078
3162
  * Close this session.
@@ -3155,7 +3239,11 @@ var ClientSession = class _ClientSession {
3155
3239
  this.pendingRelays.delete(message.acknowledgedMessageId);
3156
3240
  if (message.deliverySequence !== void 0) {
3157
3241
  this.lastAckedDeliverySeq = Math.max(this.lastAckedDeliverySeq, message.deliverySequence);
3158
- 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
+ }
3159
3247
  }
3160
3248
  break;
3161
3249
  case "error":
@@ -3216,6 +3304,7 @@ var ClientSession = class _ClientSession {
3216
3304
  return;
3217
3305
  }
3218
3306
  this.clientNodeId = msg.nodeId;
3307
+ this.scopeExitPolicy = msg.scopeExitPolicy ?? "retain";
3219
3308
  if (this.auth) {
3220
3309
  const token = msg.authToken ?? "";
3221
3310
  const context = await this.auth.authenticate(token);
@@ -3227,18 +3316,48 @@ var ClientSession = class _ClientSession {
3227
3316
  this.authContext = context;
3228
3317
  this.state = "authenticated";
3229
3318
  }
3230
- const resolvedScopes = 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(), {
3231
3323
  handshakeScope: msg.syncScope,
3232
- authScopes: this.authContext?.scopes
3324
+ authScopes: downlinkAuthScopes
3233
3325
  });
3234
- if (resolvedScopes) {
3326
+ const rawResolvedUplinkScopes = directionalScopesConfigured ? uplinkAuthScopes : rawResolvedDownlinkScopes;
3327
+ let resolvedDownlinkScopes;
3328
+ let resolvedUplinkScopes;
3329
+ try {
3330
+ resolvedDownlinkScopes = rawResolvedDownlinkScopes ? normalizeScopeMap(rawResolvedDownlinkScopes) : directionalScopesConfigured ? {} : void 0;
3331
+ resolvedUplinkScopes = rawResolvedUplinkScopes ? normalizeScopeMap(rawResolvedUplinkScopes) : directionalScopesConfigured ? {} : void 0;
3332
+ } catch (error) {
3333
+ this.sendToClient({
3334
+ type: "error",
3335
+ messageId: (0, import_core11.generateUUIDv7)(),
3336
+ code: "SCOPE_PREDICATE_LIMIT",
3337
+ message: error instanceof Error ? error.message : "Invalid scope predicate",
3338
+ retriable: false
3339
+ });
3340
+ this.close("invalid scope predicate");
3341
+ return;
3342
+ }
3343
+ if (resolvedDownlinkScopes || resolvedUplinkScopes) {
3235
3344
  if (this.authContext) {
3236
- this.authContext = { ...this.authContext, scopes: resolvedScopes };
3345
+ this.authContext = {
3346
+ ...this.authContext,
3347
+ scopes: resolvedDownlinkScopes,
3348
+ downlinkScopes: resolvedDownlinkScopes,
3349
+ uplinkScopes: resolvedUplinkScopes
3350
+ };
3237
3351
  } else {
3238
- this.authContext = { userId: msg.nodeId, scopes: resolvedScopes };
3352
+ this.authContext = {
3353
+ userId: msg.nodeId,
3354
+ scopes: resolvedDownlinkScopes,
3355
+ downlinkScopes: resolvedDownlinkScopes,
3356
+ uplinkScopes: resolvedUplinkScopes
3357
+ };
3239
3358
  }
3240
3359
  }
3241
- warnIfMultiTenantWithoutScopes(this.auth, resolvedScopes, this.store.getSchema());
3360
+ warnIfMultiTenantWithoutScopes(this.auth, resolvedDownlinkScopes, this.store.getSchema());
3242
3361
  if (msg.syncQueries && msg.syncQueries.length > 0) {
3243
3362
  this.syncQuerySubsets = (0, import_sync3.dedupeQuerySubsets)(msg.syncQueries);
3244
3363
  } else {
@@ -3246,7 +3365,7 @@ var ClientSession = class _ClientSession {
3246
3365
  }
3247
3366
  this.resumeDeltaCursor = msg.deltaCursor ? (0, import_sync3.decodeDeltaCursor)(msg.deltaCursor) : null;
3248
3367
  this.clientDeliveryWatermark = msg.lastDeliverySequence ?? null;
3249
- if (this.clientDeliveryWatermark !== null && !sameScopeMap(msg.syncScope, this.authContext?.scopes)) {
3368
+ if (this.clientDeliveryWatermark !== null && !sameScopeMap(msg.syncScope, this.authContext?.downlinkScopes)) {
3250
3369
  this.clientDeliveryWatermark = 0;
3251
3370
  }
3252
3371
  const clientUsesWatermark = this.clientDeliveryWatermark !== null;
@@ -3304,7 +3423,11 @@ var ClientSession = class _ClientSession {
3304
3423
  ...this.persistBlobChunk ? { blobStorageEnabled: true } : {},
3305
3424
  // Confirm the accepted scope so the client knows what data will be synced.
3306
3425
  // This may differ from what the client requested if auth scopes are narrower.
3307
- ...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 } : {}
3308
3431
  };
3309
3432
  this.sendToClient(response);
3310
3433
  this.emitter?.emit({ type: "sync:connected", nodeId: msg.nodeId });
@@ -3331,18 +3454,22 @@ var ClientSession = class _ClientSession {
3331
3454
  const applied = [];
3332
3455
  let acknowledgedThrough = 0;
3333
3456
  let canAdvanceAck = true;
3457
+ let uniqueOperations = 0;
3458
+ let duplicateOperations = 0;
3459
+ let rejectedOperations = 0;
3334
3460
  for (const op of operations2) {
3335
3461
  if (!canAdvanceAck) {
3336
3462
  continue;
3337
3463
  }
3338
- if (!await this.operationVisibleToClient(op)) {
3464
+ if (!await this.operationAllowedFromClient(op)) {
3339
3465
  this.sendOperationRejected(
3340
3466
  op,
3341
3467
  "SCOPE_VIOLATION",
3342
- `Operation "${op.id}" in collection "${op.collection}" is outside the client's sync scope. Refresh sync scopes and retry.`,
3343
- 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
3344
3470
  );
3345
- canAdvanceAck = false;
3471
+ rejectedOperations += 1;
3472
+ acknowledgedThrough = op.sequenceNumber;
3346
3473
  continue;
3347
3474
  }
3348
3475
  if (!isOperationTimestampValid(op)) {
@@ -3381,6 +3508,7 @@ var ClientSession = class _ClientSession {
3381
3508
  `Operation "${op.id}" cannot be transformed from schema v${op.schemaVersion} to server schema v${this.schemaVersion}.`,
3382
3509
  false
3383
3510
  );
3511
+ rejectedOperations += 1;
3384
3512
  acknowledgedThrough = op.sequenceNumber;
3385
3513
  continue;
3386
3514
  }
@@ -3405,6 +3533,7 @@ var ClientSession = class _ClientSession {
3405
3533
  if (decision.action === "reject") {
3406
3534
  const retriable = decision.retriable ?? isRetriableRejection(decision.code);
3407
3535
  this.sendOperationRejected(serverOp, decision.code, decision.message, retriable);
3536
+ rejectedOperations += 1;
3408
3537
  if (retriable) {
3409
3538
  canAdvanceAck = false;
3410
3539
  } else {
@@ -3425,6 +3554,7 @@ var ClientSession = class _ClientSession {
3425
3554
  applyResult.rejection.message,
3426
3555
  applyResult.rejection.retriable
3427
3556
  );
3557
+ rejectedOperations += 1;
3428
3558
  if (applyResult.rejection.retriable) {
3429
3559
  canAdvanceAck = false;
3430
3560
  } else {
@@ -3434,8 +3564,10 @@ var ClientSession = class _ClientSession {
3434
3564
  }
3435
3565
  if (applyResult.result === "applied") {
3436
3566
  applied.push(...applyResult.appliedOperations);
3567
+ uniqueOperations += 1;
3437
3568
  acknowledgedThrough = op.sequenceNumber;
3438
3569
  } else {
3570
+ duplicateOperations += 1;
3439
3571
  acknowledgedThrough = op.sequenceNumber;
3440
3572
  }
3441
3573
  }
@@ -3443,7 +3575,10 @@ var ClientSession = class _ClientSession {
3443
3575
  this.emitter?.emit({
3444
3576
  type: "sync:received",
3445
3577
  operations: operations2,
3446
- batchSize: operations2.length
3578
+ batchSize: operations2.length,
3579
+ uniqueOperations,
3580
+ duplicateOperations,
3581
+ rejectedOperations
3447
3582
  });
3448
3583
  }
3449
3584
  const lastOp = operations2[operations2.length - 1];
@@ -3548,7 +3683,7 @@ var ClientSession = class _ClientSession {
3548
3683
  const scanChunk = Math.max(this.batchSize, 1) * 5;
3549
3684
  let scanCursor = fromDeliverySeq;
3550
3685
  let maxScanned = fromDeliverySeq;
3551
- const visible = [];
3686
+ const deliverable = [];
3552
3687
  while (true) {
3553
3688
  const chunk = await this.store.getOperationsAfterDelivery(scanCursor, scanChunk);
3554
3689
  const last = chunk[chunk.length - 1];
@@ -3560,21 +3695,24 @@ var ClientSession = class _ClientSession {
3560
3695
  continue;
3561
3696
  }
3562
3697
  if (await this.operationVisibleToClient(delivered.operation)) {
3563
- visible.push(delivered);
3698
+ deliverable.push(delivered);
3699
+ } else if (await this.scopeRetractionFor(delivered.operation)) {
3700
+ deliverable.push({ ...delivered, retraction: true });
3564
3701
  }
3565
3702
  }
3566
3703
  if (chunk.length < scanChunk) break;
3567
3704
  }
3568
- if (visible.length === 0) {
3569
- if (finalizeWhenEmpty) {
3570
- 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);
3571
3709
  }
3572
- return 0;
3710
+ return { sentOperations: 0, maxScanned, sent };
3573
3711
  }
3574
- const totalBatches = Math.ceil(visible.length / this.batchSize);
3712
+ const totalBatches = Math.ceil(deliverable.length / this.batchSize);
3575
3713
  let base = fromDeliverySeq;
3576
3714
  for (let i = 0; i < totalBatches; i++) {
3577
- const slice = visible.slice(i * this.batchSize, (i + 1) * this.batchSize);
3715
+ const slice = deliverable.slice(i * this.batchSize, (i + 1) * this.batchSize);
3578
3716
  const lastInSlice = slice[slice.length - 1];
3579
3717
  if (lastInSlice === void 0) continue;
3580
3718
  const isFinal = i === totalBatches - 1;
@@ -3583,30 +3721,42 @@ var ClientSession = class _ClientSession {
3583
3721
  this.sendDeliveryBatch(slice, base, max, i, isFinal);
3584
3722
  base = max;
3585
3723
  }
3586
- return visible.length;
3724
+ return {
3725
+ sentOperations: deliverable.filter((item) => !item.retraction).length,
3726
+ maxScanned,
3727
+ sent: true
3728
+ };
3587
3729
  }
3588
3730
  /** Build, track, and send one chained delivery-stream batch. */
3589
3731
  sendDeliveryBatch(slice, base, max, batchIndex, isFinal) {
3590
3732
  const batchMsg = {
3591
3733
  type: "operation-batch",
3592
3734
  messageId: (0, import_core11.generateUUIDv7)(),
3593
- 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
+ } : {},
3594
3742
  isFinal,
3595
3743
  batchIndex,
3596
3744
  baseDeliverySequence: base,
3597
3745
  maxDeliverySequence: max
3598
3746
  };
3599
- this.sendToClient(batchMsg);
3600
- if (slice.length > 0) {
3747
+ const sent = this.sendToClient(batchMsg);
3748
+ const sentOperations = slice.filter((delivered) => !delivered.retraction);
3749
+ if (sentOperations.length > 0) {
3601
3750
  this.emitter?.emit({
3602
3751
  type: "sync:sent",
3603
- operations: slice.map((delivered) => delivered.operation),
3604
- batchSize: slice.length
3752
+ operations: sentOperations.map((delivered) => delivered.operation),
3753
+ batchSize: sentOperations.length
3605
3754
  });
3606
3755
  }
3756
+ return sent;
3607
3757
  }
3608
3758
  async operationVisibleToClient(op) {
3609
- const scopes = this.authContext?.scopes;
3759
+ const scopes = this.authContext?.downlinkScopes ?? this.authContext?.scopes;
3610
3760
  const subsets = this.syncQuerySubsets;
3611
3761
  const needsBackfill = missingScopeFields(op, scopes).length > 0 || subsets !== void 0 && subsets.length > 0;
3612
3762
  const fullRecord = needsBackfill ? await this.lookupRecordFields(op.collection, op.recordId) : void 0;
@@ -3615,6 +3765,20 @@ var ClientSession = class _ClientSession {
3615
3765
  }
3616
3766
  return (0, import_sync3.operationMatchesQuerySubsets)(op, subsets, fullRecord);
3617
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
+ }
3618
3782
  /**
3619
3783
  * Read a record's current field values from the materialized store for scope /
3620
3784
  * query-subset backfill. Includes soft-deleted rows so a relayed delete (whose op
@@ -3806,6 +3970,10 @@ var ServerMetricsCollector = class {
3806
3970
  peakConnections = 0;
3807
3971
  connectionsTotal = 0;
3808
3972
  operationsReceived = 0;
3973
+ batchesReceived = 0;
3974
+ uniqueOperationsReceived = 0;
3975
+ duplicateOperationsReceived = 0;
3976
+ rejectedOperations = 0;
3809
3977
  operationsSent = 0;
3810
3978
  bytesReceived = 0;
3811
3979
  bytesSent = 0;
@@ -3821,6 +3989,10 @@ var ServerMetricsCollector = class {
3821
3989
  state: "connected",
3822
3990
  connectedAt: Date.now(),
3823
3991
  operationsReceived: 0,
3992
+ batchesReceived: 0,
3993
+ uniqueOperationsReceived: 0,
3994
+ duplicateOperationsReceived: 0,
3995
+ rejectedOperations: 0,
3824
3996
  operationsSent: 0,
3825
3997
  authContext: null
3826
3998
  });
@@ -3852,12 +4024,24 @@ var ServerMetricsCollector = class {
3852
4024
  }
3853
4025
  }
3854
4026
  /** Record operations received from a client. */
3855
- recordReceived(sessionId, count3, byteSize) {
4027
+ recordReceived(sessionId, count3, byteSize, outcomes = {
4028
+ unique: count3,
4029
+ duplicates: 0,
4030
+ rejected: 0
4031
+ }) {
3856
4032
  this.operationsReceived += count3;
4033
+ this.batchesReceived += 1;
4034
+ this.uniqueOperationsReceived += outcomes.unique;
4035
+ this.duplicateOperationsReceived += outcomes.duplicates;
4036
+ this.rejectedOperations += outcomes.rejected;
3857
4037
  this.bytesReceived += byteSize;
3858
4038
  const client = this.clientMetrics.get(sessionId);
3859
4039
  if (client) {
3860
4040
  client.operationsReceived += count3;
4041
+ client.batchesReceived += 1;
4042
+ client.uniqueOperationsReceived += outcomes.unique;
4043
+ client.duplicateOperationsReceived += outcomes.duplicates;
4044
+ client.rejectedOperations += outcomes.rejected;
3861
4045
  }
3862
4046
  }
3863
4047
  /** Record operations sent to a client. */
@@ -3885,6 +4069,10 @@ var ServerMetricsCollector = class {
3885
4069
  peakConnections: this.peakConnections,
3886
4070
  connectionsTotal: this.connectionsTotal,
3887
4071
  operationsReceived: this.operationsReceived,
4072
+ batchesReceived: this.batchesReceived,
4073
+ uniqueOperationsReceived: this.uniqueOperationsReceived,
4074
+ duplicateOperationsReceived: this.duplicateOperationsReceived,
4075
+ rejectedOperations: this.rejectedOperations,
3888
4076
  operationsSent: this.operationsSent,
3889
4077
  bytesReceived: this.bytesReceived,
3890
4078
  bytesSent: this.bytesSent,
@@ -3901,6 +4089,10 @@ var ServerMetricsCollector = class {
3901
4089
  this.peakConnections = 0;
3902
4090
  this.connectionsTotal = 0;
3903
4091
  this.operationsReceived = 0;
4092
+ this.batchesReceived = 0;
4093
+ this.uniqueOperationsReceived = 0;
4094
+ this.duplicateOperationsReceived = 0;
4095
+ this.rejectedOperations = 0;
3904
4096
  this.operationsSent = 0;
3905
4097
  this.bytesReceived = 0;
3906
4098
  this.bytesSent = 0;
@@ -4582,14 +4774,17 @@ var KoraSyncServer = class _KoraSyncServer {
4582
4774
  if (maxDeliverySequence <= this.lastObservedDeliverySequence) {
4583
4775
  for (const session of this.sessions.values()) {
4584
4776
  session.pushDeliveryStreamIfSupported(this.deliveryPollIntervalMs, {
4585
- trackStall: true
4777
+ trackStall: true,
4778
+ serverFrontier: maxDeliverySequence
4586
4779
  });
4587
4780
  }
4588
4781
  return;
4589
4782
  }
4590
4783
  this.lastObservedDeliverySequence = maxDeliverySequence;
4591
4784
  for (const session of this.sessions.values()) {
4592
- session.pushDeliveryStreamIfSupported(0);
4785
+ session.pushDeliveryStreamIfSupported(0, {
4786
+ serverFrontier: maxDeliverySequence
4787
+ });
4593
4788
  }
4594
4789
  } catch (error) {
4595
4790
  this.logger.log({
@@ -4663,7 +4858,11 @@ var KoraSyncServer = class _KoraSyncServer {
4663
4858
  });
4664
4859
  sessionEmitter.on("sync:received", (event) => {
4665
4860
  const byteSize = estimateByteSize(event.operations);
4666
- 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
+ });
4667
4866
  this.logger.log({
4668
4867
  timestamp: Date.now(),
4669
4868
  level: "info",
@@ -4886,7 +5085,11 @@ var KoraSyncServer = class _KoraSyncServer {
4886
5085
  });
4887
5086
  sessionEmitter.on("sync:received", (event) => {
4888
5087
  const byteSize = estimateOperationByteSize(event.operations);
4889
- 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
+ });
4890
5093
  this.logger.log({
4891
5094
  timestamp: Date.now(),
4892
5095
  level: "info",
@@ -4939,7 +5142,9 @@ var KoraSyncServer = class _KoraSyncServer {
4939
5142
  sessionId,
4940
5143
  details: {
4941
5144
  watermark: event.watermark,
4942
- repeatCount: event.repeatCount
5145
+ outstandingMaxDeliverySequence: event.outstandingMaxDeliverySequence,
5146
+ repeatCount: event.repeatCount,
5147
+ reason: event.reason
4943
5148
  }
4944
5149
  });
4945
5150
  });
@@ -5341,6 +5546,22 @@ function createProductionServer(config) {
5341
5546
  "# TYPE kora_operations_received_total counter",
5342
5547
  `kora_operations_received_total ${status.operationsReceived}`,
5343
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
+ "",
5344
5565
  "# HELP kora_operations_sent_total Total operations sent to clients",
5345
5566
  "# TYPE kora_operations_sent_total counter",
5346
5567
  `kora_operations_sent_total ${status.operationsSent}`,
@@ -5727,6 +5948,7 @@ function formatUptime(ms) {
5727
5948
  0 && (module.exports = {
5728
5949
  AwarenessRelay,
5729
5950
  ClientSession,
5951
+ DEFAULT_MAX_SCOPE_PREDICATE_VALUES,
5730
5952
  HttpServerTransport,
5731
5953
  KoraAuthProvider,
5732
5954
  KoraSyncServer,
@@ -5746,6 +5968,8 @@ function formatUptime(ms) {
5746
5968
  createProductionServer,
5747
5969
  createSilentLogger,
5748
5970
  createSqliteServerStore,
5749
- isRetriableRejection
5971
+ isRetriableRejection,
5972
+ normalizeScopeMap,
5973
+ operationMatchesScopes
5750
5974
  });
5751
5975
  //# sourceMappingURL=index.cjs.map