@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 +279 -55
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +40 -3
- package/dist/index.d.ts +40 -3
- package/dist/index.js +275 -54
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
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]
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
2945
|
+
if (options.serverFrontier !== void 0 && options.serverFrontier <= this.lastAckedDeliverySeq) {
|
|
2946
|
+
this.outstandingDelivery = null;
|
|
2858
2947
|
return;
|
|
2859
2948
|
}
|
|
2860
|
-
if (
|
|
2861
|
-
|
|
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.
|
|
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
|
|
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:
|
|
3117
|
+
authScopes: downlinkAuthScopes
|
|
3029
3118
|
});
|
|
3030
|
-
|
|
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 = {
|
|
3138
|
+
this.authContext = {
|
|
3139
|
+
...this.authContext,
|
|
3140
|
+
scopes: resolvedDownlinkScopes,
|
|
3141
|
+
downlinkScopes: resolvedDownlinkScopes,
|
|
3142
|
+
uplinkScopes: resolvedUplinkScopes
|
|
3143
|
+
};
|
|
3033
3144
|
} else {
|
|
3034
|
-
this.authContext = {
|
|
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,
|
|
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?.
|
|
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?.
|
|
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.
|
|
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
|
|
3139
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
3365
|
-
|
|
3366
|
-
|
|
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(
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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:
|
|
3400
|
-
batchSize:
|
|
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
|
-
|
|
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
|