@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.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]
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
3152
|
+
if (options.serverFrontier !== void 0 && options.serverFrontier <= this.lastAckedDeliverySeq) {
|
|
3153
|
+
this.outstandingDelivery = null;
|
|
3062
3154
|
return;
|
|
3063
3155
|
}
|
|
3064
|
-
if (
|
|
3065
|
-
|
|
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.
|
|
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
|
|
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:
|
|
3324
|
+
authScopes: downlinkAuthScopes
|
|
3233
3325
|
});
|
|
3234
|
-
|
|
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 = {
|
|
3345
|
+
this.authContext = {
|
|
3346
|
+
...this.authContext,
|
|
3347
|
+
scopes: resolvedDownlinkScopes,
|
|
3348
|
+
downlinkScopes: resolvedDownlinkScopes,
|
|
3349
|
+
uplinkScopes: resolvedUplinkScopes
|
|
3350
|
+
};
|
|
3237
3351
|
} else {
|
|
3238
|
-
this.authContext = {
|
|
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,
|
|
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?.
|
|
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?.
|
|
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.
|
|
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
|
|
3343
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
3569
|
-
|
|
3570
|
-
|
|
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(
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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:
|
|
3604
|
-
batchSize:
|
|
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
|
-
|
|
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
|