@korajs/server 1.0.0-beta.6 → 1.0.0-beta.9
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 +268 -53
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +24 -2
- package/dist/index.d.ts +24 -2
- package/dist/index.js +238 -23
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/dist/index.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Operation, HybridLogicalClock, SchemaDefinition, BlobRef, KoraEventEmitter, VersionVector, TimeSource } from '@korajs/core';
|
|
1
|
+
import { Operation, HybridLogicalClock, SchemaDefinition, BlobRef, KoraEventEmitter, OperationTransform, VersionVector, TimeSource } from '@korajs/core';
|
|
2
2
|
import * as _korajs_sync from '@korajs/sync';
|
|
3
3
|
import { SyncStore, ApplyResult, MessageSerializer, SyncMessage, AwarenessUpdateMessage, YjsDocUpdateMessage, BlobChunkRequestMessage, BlobChunkResponseMessage } from '@korajs/sync';
|
|
4
4
|
import { S as ServerTransport, a as ServerMessageHandler, b as ServerCloseHandler, c as ServerErrorHandler } from './server-transport-CU1BLWgr.cjs';
|
|
@@ -449,6 +449,7 @@ declare class KoraSyncServer {
|
|
|
449
449
|
private readonly batchSize;
|
|
450
450
|
private readonly schemaVersion;
|
|
451
451
|
private readonly supportedSchemaVersions;
|
|
452
|
+
private readonly operationTransforms;
|
|
452
453
|
private readonly port;
|
|
453
454
|
private readonly host;
|
|
454
455
|
private readonly path;
|
|
@@ -808,7 +809,10 @@ interface KoraSyncServerConfig {
|
|
|
808
809
|
maxConnections?: number;
|
|
809
810
|
/** Maximum operations per sync batch. Defaults to 100. */
|
|
810
811
|
batchSize?: number;
|
|
811
|
-
/**
|
|
812
|
+
/**
|
|
813
|
+
* Schema version the server expects. Defaults to `store.getSchema()?.version`
|
|
814
|
+
* when the store has been configured with a schema, otherwise `1`.
|
|
815
|
+
*/
|
|
812
816
|
schemaVersion?: number;
|
|
813
817
|
/**
|
|
814
818
|
* Inclusive range of client schema versions accepted at handshake.
|
|
@@ -818,6 +822,13 @@ interface KoraSyncServerConfig {
|
|
|
818
822
|
min: number;
|
|
819
823
|
max: number;
|
|
820
824
|
};
|
|
825
|
+
/**
|
|
826
|
+
* Transform accepted legacy client operations into the server schema version
|
|
827
|
+
* before validation and materialization. Required when
|
|
828
|
+
* `supportedSchemaVersions.min` is lower than `schemaVersion` and operation
|
|
829
|
+
* shapes changed across versions.
|
|
830
|
+
*/
|
|
831
|
+
operationTransforms?: OperationTransform[];
|
|
821
832
|
/** WebSocket path (standalone mode). Defaults to '/'. */
|
|
822
833
|
path?: string;
|
|
823
834
|
/** Structured logger. Defaults to pretty-print in dev, JSON lines in production. */
|
|
@@ -1062,6 +1073,8 @@ interface ClientSessionOptions {
|
|
|
1062
1073
|
min: number;
|
|
1063
1074
|
max: number;
|
|
1064
1075
|
};
|
|
1076
|
+
/** Transform accepted legacy operations into the server schema before validation. */
|
|
1077
|
+
operationTransforms?: OperationTransform[];
|
|
1065
1078
|
/** Called when this session has operations to relay to other sessions */
|
|
1066
1079
|
onRelay?: RelayCallback;
|
|
1067
1080
|
/** Called when this session receives an awareness update to broadcast */
|
|
@@ -1139,6 +1152,13 @@ declare class ClientSession {
|
|
|
1139
1152
|
* client's reported watermark at handshake.
|
|
1140
1153
|
*/
|
|
1141
1154
|
private lastAckedDeliverySeq;
|
|
1155
|
+
/**
|
|
1156
|
+
* Timestamp of the last delivery-stream send attempt. Periodic retransmission uses
|
|
1157
|
+
* this as its stale window so a slow client does not receive duplicate full-stream
|
|
1158
|
+
* batches every server tick while the first batch is still being applied.
|
|
1159
|
+
*/
|
|
1160
|
+
private lastDeliveryPushAttemptAtMs;
|
|
1161
|
+
private deliveryStallRepeatCount;
|
|
1142
1162
|
/** Serializes incremental delivery pushes so their batches never interleave. */
|
|
1143
1163
|
private deliveryPushChain;
|
|
1144
1164
|
/**
|
|
@@ -1161,6 +1181,7 @@ declare class ClientSession {
|
|
|
1161
1181
|
private readonly batchSize;
|
|
1162
1182
|
private readonly schemaVersion;
|
|
1163
1183
|
private readonly supportedSchemaVersions;
|
|
1184
|
+
private readonly operationTransforms;
|
|
1164
1185
|
private readonly onRelay;
|
|
1165
1186
|
private readonly onAwarenessUpdate;
|
|
1166
1187
|
private readonly onYjsDocUpdate;
|
|
@@ -1234,6 +1255,7 @@ declare class ClientSession {
|
|
|
1234
1255
|
private handleMessageFailure;
|
|
1235
1256
|
private handleHandshake;
|
|
1236
1257
|
private handleOperationBatch;
|
|
1258
|
+
private transformForServerSchema;
|
|
1237
1259
|
private sendDelta;
|
|
1238
1260
|
/**
|
|
1239
1261
|
* Send the gap-free server->client delivery stream, resuming just after
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Operation, HybridLogicalClock, SchemaDefinition, BlobRef, KoraEventEmitter, VersionVector, TimeSource } from '@korajs/core';
|
|
1
|
+
import { Operation, HybridLogicalClock, SchemaDefinition, BlobRef, KoraEventEmitter, OperationTransform, VersionVector, TimeSource } from '@korajs/core';
|
|
2
2
|
import * as _korajs_sync from '@korajs/sync';
|
|
3
3
|
import { SyncStore, ApplyResult, MessageSerializer, SyncMessage, AwarenessUpdateMessage, YjsDocUpdateMessage, BlobChunkRequestMessage, BlobChunkResponseMessage } from '@korajs/sync';
|
|
4
4
|
import { S as ServerTransport, a as ServerMessageHandler, b as ServerCloseHandler, c as ServerErrorHandler } from './server-transport-CU1BLWgr.js';
|
|
@@ -449,6 +449,7 @@ declare class KoraSyncServer {
|
|
|
449
449
|
private readonly batchSize;
|
|
450
450
|
private readonly schemaVersion;
|
|
451
451
|
private readonly supportedSchemaVersions;
|
|
452
|
+
private readonly operationTransforms;
|
|
452
453
|
private readonly port;
|
|
453
454
|
private readonly host;
|
|
454
455
|
private readonly path;
|
|
@@ -808,7 +809,10 @@ interface KoraSyncServerConfig {
|
|
|
808
809
|
maxConnections?: number;
|
|
809
810
|
/** Maximum operations per sync batch. Defaults to 100. */
|
|
810
811
|
batchSize?: number;
|
|
811
|
-
/**
|
|
812
|
+
/**
|
|
813
|
+
* Schema version the server expects. Defaults to `store.getSchema()?.version`
|
|
814
|
+
* when the store has been configured with a schema, otherwise `1`.
|
|
815
|
+
*/
|
|
812
816
|
schemaVersion?: number;
|
|
813
817
|
/**
|
|
814
818
|
* Inclusive range of client schema versions accepted at handshake.
|
|
@@ -818,6 +822,13 @@ interface KoraSyncServerConfig {
|
|
|
818
822
|
min: number;
|
|
819
823
|
max: number;
|
|
820
824
|
};
|
|
825
|
+
/**
|
|
826
|
+
* Transform accepted legacy client operations into the server schema version
|
|
827
|
+
* before validation and materialization. Required when
|
|
828
|
+
* `supportedSchemaVersions.min` is lower than `schemaVersion` and operation
|
|
829
|
+
* shapes changed across versions.
|
|
830
|
+
*/
|
|
831
|
+
operationTransforms?: OperationTransform[];
|
|
821
832
|
/** WebSocket path (standalone mode). Defaults to '/'. */
|
|
822
833
|
path?: string;
|
|
823
834
|
/** Structured logger. Defaults to pretty-print in dev, JSON lines in production. */
|
|
@@ -1062,6 +1073,8 @@ interface ClientSessionOptions {
|
|
|
1062
1073
|
min: number;
|
|
1063
1074
|
max: number;
|
|
1064
1075
|
};
|
|
1076
|
+
/** Transform accepted legacy operations into the server schema before validation. */
|
|
1077
|
+
operationTransforms?: OperationTransform[];
|
|
1065
1078
|
/** Called when this session has operations to relay to other sessions */
|
|
1066
1079
|
onRelay?: RelayCallback;
|
|
1067
1080
|
/** Called when this session receives an awareness update to broadcast */
|
|
@@ -1139,6 +1152,13 @@ declare class ClientSession {
|
|
|
1139
1152
|
* client's reported watermark at handshake.
|
|
1140
1153
|
*/
|
|
1141
1154
|
private lastAckedDeliverySeq;
|
|
1155
|
+
/**
|
|
1156
|
+
* Timestamp of the last delivery-stream send attempt. Periodic retransmission uses
|
|
1157
|
+
* this as its stale window so a slow client does not receive duplicate full-stream
|
|
1158
|
+
* batches every server tick while the first batch is still being applied.
|
|
1159
|
+
*/
|
|
1160
|
+
private lastDeliveryPushAttemptAtMs;
|
|
1161
|
+
private deliveryStallRepeatCount;
|
|
1142
1162
|
/** Serializes incremental delivery pushes so their batches never interleave. */
|
|
1143
1163
|
private deliveryPushChain;
|
|
1144
1164
|
/**
|
|
@@ -1161,6 +1181,7 @@ declare class ClientSession {
|
|
|
1161
1181
|
private readonly batchSize;
|
|
1162
1182
|
private readonly schemaVersion;
|
|
1163
1183
|
private readonly supportedSchemaVersions;
|
|
1184
|
+
private readonly operationTransforms;
|
|
1164
1185
|
private readonly onRelay;
|
|
1165
1186
|
private readonly onAwarenessUpdate;
|
|
1166
1187
|
private readonly onYjsDocUpdate;
|
|
@@ -1234,6 +1255,7 @@ declare class ClientSession {
|
|
|
1234
1255
|
private handleMessageFailure;
|
|
1235
1256
|
private handleHandshake;
|
|
1236
1257
|
private handleOperationBatch;
|
|
1258
|
+
private transformForServerSchema;
|
|
1237
1259
|
private sendDelta;
|
|
1238
1260
|
/**
|
|
1239
1261
|
* Send the gap-free server->client delivery stream, resuming just after
|
package/dist/index.js
CHANGED
|
@@ -2190,6 +2190,7 @@ var WsServerTransport = class {
|
|
|
2190
2190
|
};
|
|
2191
2191
|
|
|
2192
2192
|
// src/session/client-session.ts
|
|
2193
|
+
import { applyOperationTransforms } from "@korajs/core";
|
|
2193
2194
|
import { generateUUIDv7 as generateUUIDv74, hashBlob } from "@korajs/core";
|
|
2194
2195
|
import { topologicalSort } from "@korajs/core/internal";
|
|
2195
2196
|
import { decodeBlobChunkBytes } from "@korajs/sync";
|
|
@@ -2323,6 +2324,18 @@ function nextServerSequenceNumber(store) {
|
|
|
2323
2324
|
async function applyServerOperation(store, op, relationLookup) {
|
|
2324
2325
|
const schema = store.getSchema();
|
|
2325
2326
|
const lookup = relationLookup ?? (schema ? buildMergeRelationLookup(schema) : /* @__PURE__ */ new Map());
|
|
2327
|
+
const shapeCheck = validateOperationShape(op, schema);
|
|
2328
|
+
if (!shapeCheck.valid) {
|
|
2329
|
+
return {
|
|
2330
|
+
result: "skipped",
|
|
2331
|
+
appliedOperations: [],
|
|
2332
|
+
rejection: {
|
|
2333
|
+
code: shapeCheck.code ?? "SCHEMA_VALIDATION_ERROR",
|
|
2334
|
+
message: shapeCheck.message ?? `Operation "${op.id}" does not match the server schema`,
|
|
2335
|
+
retriable: false
|
|
2336
|
+
}
|
|
2337
|
+
};
|
|
2338
|
+
}
|
|
2326
2339
|
const constraintCheck = await validateIncomingOperationConstraints(store, op, schema);
|
|
2327
2340
|
if (!constraintCheck.valid) {
|
|
2328
2341
|
const code = constraintCheck.code ?? "CONSTRAINT_VIOLATION";
|
|
@@ -2376,6 +2389,67 @@ async function applyServerOperation(store, op, relationLookup) {
|
|
|
2376
2389
|
appliedOperations: result === "applied" ? [op] : []
|
|
2377
2390
|
};
|
|
2378
2391
|
}
|
|
2392
|
+
function validateOperationShape(op, schema) {
|
|
2393
|
+
if (!schema) {
|
|
2394
|
+
return { valid: true };
|
|
2395
|
+
}
|
|
2396
|
+
const collection = schema.collections[op.collection];
|
|
2397
|
+
if (!collection) {
|
|
2398
|
+
return {
|
|
2399
|
+
valid: false,
|
|
2400
|
+
code: "UNKNOWN_COLLECTION",
|
|
2401
|
+
message: `Operation "${op.id}" targets unknown collection "${op.collection}".`
|
|
2402
|
+
};
|
|
2403
|
+
}
|
|
2404
|
+
const declaredFields = new Set(Object.keys(collection.fields));
|
|
2405
|
+
const systemPreviousFields = /* @__PURE__ */ new Set(["id", "_created_at", "_updated_at", "_deleted"]);
|
|
2406
|
+
const invalidDataField = firstInvalidField(op.data, declaredFields);
|
|
2407
|
+
if (invalidDataField) {
|
|
2408
|
+
return {
|
|
2409
|
+
valid: false,
|
|
2410
|
+
code: "SCHEMA_VALIDATION_ERROR",
|
|
2411
|
+
message: `Operation "${op.id}" contains undeclared field "${invalidDataField}" in data for collection "${op.collection}".`
|
|
2412
|
+
};
|
|
2413
|
+
}
|
|
2414
|
+
const invalidPreviousField = firstInvalidField(
|
|
2415
|
+
op.previousData,
|
|
2416
|
+
declaredFields,
|
|
2417
|
+
systemPreviousFields
|
|
2418
|
+
);
|
|
2419
|
+
if (invalidPreviousField) {
|
|
2420
|
+
return {
|
|
2421
|
+
valid: false,
|
|
2422
|
+
code: "SCHEMA_VALIDATION_ERROR",
|
|
2423
|
+
message: `Operation "${op.id}" contains undeclared field "${invalidPreviousField}" in previousData for collection "${op.collection}".`
|
|
2424
|
+
};
|
|
2425
|
+
}
|
|
2426
|
+
if (op.atomicOps) {
|
|
2427
|
+
for (const field of Object.keys(op.atomicOps)) {
|
|
2428
|
+
if (!declaredFields.has(field)) {
|
|
2429
|
+
return {
|
|
2430
|
+
valid: false,
|
|
2431
|
+
code: "SCHEMA_VALIDATION_ERROR",
|
|
2432
|
+
message: `Operation "${op.id}" contains undeclared atomic field "${field}" for collection "${op.collection}".`
|
|
2433
|
+
};
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
return { valid: true };
|
|
2438
|
+
}
|
|
2439
|
+
function firstInvalidField(value, declaredFields, extraAllowedFields) {
|
|
2440
|
+
if (value === null || value === void 0) {
|
|
2441
|
+
return null;
|
|
2442
|
+
}
|
|
2443
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
2444
|
+
return null;
|
|
2445
|
+
}
|
|
2446
|
+
for (const field of Object.keys(value)) {
|
|
2447
|
+
if (!declaredFields.has(field) && !extraAllowedFields?.has(field)) {
|
|
2448
|
+
return field;
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
return null;
|
|
2452
|
+
}
|
|
2379
2453
|
|
|
2380
2454
|
// src/auth/no-auth.ts
|
|
2381
2455
|
var NoAuthProvider = class {
|
|
@@ -2424,6 +2498,7 @@ function operationMatchesScopes(op, scopes, fullRecord) {
|
|
|
2424
2498
|
if (!scopes) return true;
|
|
2425
2499
|
const collectionScope = scopes[op.collection];
|
|
2426
2500
|
if (!collectionScope) return false;
|
|
2501
|
+
if (Object.keys(collectionScope).length === 0) return true;
|
|
2427
2502
|
const snapshot = buildSnapshot(op, fullRecord);
|
|
2428
2503
|
if (!snapshot) return false;
|
|
2429
2504
|
for (const [field, expected] of Object.entries(collectionScope)) {
|
|
@@ -2437,6 +2512,7 @@ function missingScopeFields(op, scopes) {
|
|
|
2437
2512
|
if (!scopes) return [];
|
|
2438
2513
|
const collectionScope = scopes[op.collection];
|
|
2439
2514
|
if (!collectionScope) return [];
|
|
2515
|
+
if (Object.keys(collectionScope).length === 0) return [];
|
|
2440
2516
|
const snapshot = buildSnapshot(op);
|
|
2441
2517
|
return Object.keys(collectionScope).filter((field) => !snapshot || !(field in snapshot));
|
|
2442
2518
|
}
|
|
@@ -2536,6 +2612,20 @@ function warnIfMultiTenantWithoutScopes(auth, resolvedScopes, schema) {
|
|
|
2536
2612
|
"[kora] An authenticated session resolved to no sync scopes, so every user will sync every other user's data. Return per-user sync scopes from your auth provider (for example KoraAuthProvider's resolveScopes) to isolate tenants. Note: declaring sync rules in your schema is not enough on its own \u2014 the per-user values come from the auth provider. (This warning is expected for single-tenant apps where all authenticated users are meant to share the same data.)"
|
|
2537
2613
|
);
|
|
2538
2614
|
}
|
|
2615
|
+
function stableStringify(value) {
|
|
2616
|
+
if (Array.isArray(value)) {
|
|
2617
|
+
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
|
|
2618
|
+
}
|
|
2619
|
+
if (value && typeof value === "object") {
|
|
2620
|
+
const record = value;
|
|
2621
|
+
const entries = Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`);
|
|
2622
|
+
return `{${entries.join(",")}}`;
|
|
2623
|
+
}
|
|
2624
|
+
return JSON.stringify(value);
|
|
2625
|
+
}
|
|
2626
|
+
function sameScopeMap(a, b) {
|
|
2627
|
+
return stableStringify(a ?? null) === stableStringify(b ?? null);
|
|
2628
|
+
}
|
|
2539
2629
|
var ClientSession = class _ClientSession {
|
|
2540
2630
|
state = "connected";
|
|
2541
2631
|
clientNodeId = null;
|
|
@@ -2559,6 +2649,13 @@ var ClientSession = class _ClientSession {
|
|
|
2559
2649
|
* client's reported watermark at handshake.
|
|
2560
2650
|
*/
|
|
2561
2651
|
lastAckedDeliverySeq = 0;
|
|
2652
|
+
/**
|
|
2653
|
+
* Timestamp of the last delivery-stream send attempt. Periodic retransmission uses
|
|
2654
|
+
* this as its stale window so a slow client does not receive duplicate full-stream
|
|
2655
|
+
* batches every server tick while the first batch is still being applied.
|
|
2656
|
+
*/
|
|
2657
|
+
lastDeliveryPushAttemptAtMs = 0;
|
|
2658
|
+
deliveryStallRepeatCount = 0;
|
|
2562
2659
|
/** Serializes incremental delivery pushes so their batches never interleave. */
|
|
2563
2660
|
deliveryPushChain = Promise.resolve();
|
|
2564
2661
|
/**
|
|
@@ -2581,6 +2678,7 @@ var ClientSession = class _ClientSession {
|
|
|
2581
2678
|
batchSize;
|
|
2582
2679
|
schemaVersion;
|
|
2583
2680
|
supportedSchemaVersions;
|
|
2681
|
+
operationTransforms;
|
|
2584
2682
|
onRelay;
|
|
2585
2683
|
onAwarenessUpdate;
|
|
2586
2684
|
onYjsDocUpdate;
|
|
@@ -2610,6 +2708,7 @@ var ClientSession = class _ClientSession {
|
|
|
2610
2708
|
min: this.schemaVersion,
|
|
2611
2709
|
max: this.schemaVersion
|
|
2612
2710
|
};
|
|
2711
|
+
this.operationTransforms = options.operationTransforms ?? [];
|
|
2613
2712
|
this.onRelay = options.onRelay ?? null;
|
|
2614
2713
|
this.onAwarenessUpdate = options.onAwarenessUpdate ?? null;
|
|
2615
2714
|
this.onYjsDocUpdate = options.onYjsDocUpdate ?? null;
|
|
@@ -2659,6 +2758,7 @@ var ClientSession = class _ClientSession {
|
|
|
2659
2758
|
this.deliveryPushChain = this.deliveryPushChain.then(async () => {
|
|
2660
2759
|
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
2661
2760
|
try {
|
|
2761
|
+
this.lastDeliveryPushAttemptAtMs = Date.now();
|
|
2662
2762
|
await this.sendDeliveryStream(
|
|
2663
2763
|
this.lastAckedDeliverySeq,
|
|
2664
2764
|
false,
|
|
@@ -2706,6 +2806,18 @@ var ClientSession = class _ClientSession {
|
|
|
2706
2806
|
retransmitPendingRelays(staleMs = 0) {
|
|
2707
2807
|
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
2708
2808
|
if (this.clientDeliveryWatermark !== null) {
|
|
2809
|
+
if (staleMs > 0 && Date.now() - this.lastDeliveryPushAttemptAtMs < staleMs) {
|
|
2810
|
+
return;
|
|
2811
|
+
}
|
|
2812
|
+
this.deliveryStallRepeatCount += 1;
|
|
2813
|
+
if (this.deliveryStallRepeatCount >= 3) {
|
|
2814
|
+
this.emitter?.emit({
|
|
2815
|
+
type: "sync:delivery-stalled",
|
|
2816
|
+
sessionId: this.sessionId,
|
|
2817
|
+
watermark: this.lastAckedDeliverySeq,
|
|
2818
|
+
repeatCount: this.deliveryStallRepeatCount
|
|
2819
|
+
});
|
|
2820
|
+
}
|
|
2709
2821
|
this.pushDeliveryStream();
|
|
2710
2822
|
return;
|
|
2711
2823
|
}
|
|
@@ -2798,6 +2910,7 @@ var ClientSession = class _ClientSession {
|
|
|
2798
2910
|
this.pendingRelays.delete(message.acknowledgedMessageId);
|
|
2799
2911
|
if (message.deliverySequence !== void 0) {
|
|
2800
2912
|
this.lastAckedDeliverySeq = Math.max(this.lastAckedDeliverySeq, message.deliverySequence);
|
|
2913
|
+
this.deliveryStallRepeatCount = 0;
|
|
2801
2914
|
}
|
|
2802
2915
|
break;
|
|
2803
2916
|
case "error":
|
|
@@ -2888,6 +3001,9 @@ var ClientSession = class _ClientSession {
|
|
|
2888
3001
|
}
|
|
2889
3002
|
this.resumeDeltaCursor = msg.deltaCursor ? decodeDeltaCursor(msg.deltaCursor) : null;
|
|
2890
3003
|
this.clientDeliveryWatermark = msg.lastDeliverySequence ?? null;
|
|
3004
|
+
if (this.clientDeliveryWatermark !== null && !sameScopeMap(msg.syncScope, this.authContext?.scopes)) {
|
|
3005
|
+
this.clientDeliveryWatermark = 0;
|
|
3006
|
+
}
|
|
2891
3007
|
const clientUsesWatermark = this.clientDeliveryWatermark !== null;
|
|
2892
3008
|
let serverMaxDelivery = 0;
|
|
2893
3009
|
if (clientUsesWatermark) {
|
|
@@ -2901,6 +3017,14 @@ var ClientSession = class _ClientSession {
|
|
|
2901
3017
|
this.setSerializerWireFormat(selectedWireFormat);
|
|
2902
3018
|
if (!isClientSchemaVersionSupported(msg.schemaVersion, this.supportedSchemaVersions)) {
|
|
2903
3019
|
const { min, max } = this.supportedSchemaVersions;
|
|
3020
|
+
this.emitter?.emit({
|
|
3021
|
+
type: "sync:schema-mismatch",
|
|
3022
|
+
clientSchemaVersion: msg.schemaVersion,
|
|
3023
|
+
serverSchemaVersion: this.schemaVersion,
|
|
3024
|
+
supportedMin: min,
|
|
3025
|
+
supportedMax: max,
|
|
3026
|
+
reason: `${SCHEMA_MISMATCH_PREFIX}: client schema version ${msg.schemaVersion} not in supported range [${min}, ${max}]`
|
|
3027
|
+
});
|
|
2904
3028
|
const response2 = {
|
|
2905
3029
|
type: "handshake-response",
|
|
2906
3030
|
messageId: generateUUIDv74(),
|
|
@@ -2943,6 +3067,7 @@ var ClientSession = class _ClientSession {
|
|
|
2943
3067
|
if (this.clientDeliveryWatermark !== null) {
|
|
2944
3068
|
this.lastAckedDeliverySeq = this.clientDeliveryWatermark;
|
|
2945
3069
|
const excludeOwn = this.clientDeliveryWatermark > 0 ? this.clientNodeId ?? void 0 : void 0;
|
|
3070
|
+
this.lastDeliveryPushAttemptAtMs = Date.now();
|
|
2946
3071
|
await this.sendDeliveryStream(this.clientDeliveryWatermark, true, excludeOwn);
|
|
2947
3072
|
} else {
|
|
2948
3073
|
const clientVector = wireToVersionVector(msg.versionVector);
|
|
@@ -2959,10 +3084,20 @@ var ClientSession = class _ClientSession {
|
|
|
2959
3084
|
async handleOperationBatch(msg) {
|
|
2960
3085
|
const operations2 = msg.operations.map((s) => this.serializer.decodeOperation(s));
|
|
2961
3086
|
const applied = [];
|
|
2962
|
-
|
|
3087
|
+
let acknowledgedThrough = 0;
|
|
3088
|
+
let canAdvanceAck = true;
|
|
2963
3089
|
for (const op of operations2) {
|
|
3090
|
+
if (!canAdvanceAck) {
|
|
3091
|
+
continue;
|
|
3092
|
+
}
|
|
2964
3093
|
if (!await this.operationVisibleToClient(op)) {
|
|
2965
|
-
|
|
3094
|
+
this.sendOperationRejected(
|
|
3095
|
+
op,
|
|
3096
|
+
"SCOPE_VIOLATION",
|
|
3097
|
+
`Operation "${op.id}" in collection "${op.collection}" is outside the client's sync scope. Refresh sync scopes and retry.`,
|
|
3098
|
+
true
|
|
3099
|
+
);
|
|
3100
|
+
canAdvanceAck = false;
|
|
2966
3101
|
continue;
|
|
2967
3102
|
}
|
|
2968
3103
|
if (!isOperationTimestampValid(op)) {
|
|
@@ -2971,6 +3106,7 @@ var ClientSession = class _ClientSession {
|
|
|
2971
3106
|
`Operation "${op.id}" timestamp is too far in the future`,
|
|
2972
3107
|
false
|
|
2973
3108
|
);
|
|
3109
|
+
canAdvanceAck = false;
|
|
2974
3110
|
continue;
|
|
2975
3111
|
}
|
|
2976
3112
|
if (!this.rateLimiter.allow(1)) {
|
|
@@ -2979,6 +3115,7 @@ var ClientSession = class _ClientSession {
|
|
|
2979
3115
|
`Session exceeded operation rate limit (${String(this.maxOpsPerMinute)} ops/min)`,
|
|
2980
3116
|
true
|
|
2981
3117
|
);
|
|
3118
|
+
canAdvanceAck = false;
|
|
2982
3119
|
continue;
|
|
2983
3120
|
}
|
|
2984
3121
|
const sizeCheck = validateOperationSize(op, this.maxOperationBytes);
|
|
@@ -2988,53 +3125,73 @@ var ClientSession = class _ClientSession {
|
|
|
2988
3125
|
sizeCheck.message ?? `Operation "${op.id}" is too large`,
|
|
2989
3126
|
false
|
|
2990
3127
|
);
|
|
3128
|
+
canAdvanceAck = false;
|
|
3129
|
+
continue;
|
|
3130
|
+
}
|
|
3131
|
+
const serverOp = this.transformForServerSchema(op);
|
|
3132
|
+
if (serverOp === null) {
|
|
3133
|
+
this.sendOperationRejected(
|
|
3134
|
+
op,
|
|
3135
|
+
"SCHEMA_TRANSFORM_UNAVAILABLE",
|
|
3136
|
+
`Operation "${op.id}" cannot be transformed from schema v${op.schemaVersion} to server schema v${this.schemaVersion}.`,
|
|
3137
|
+
false
|
|
3138
|
+
);
|
|
3139
|
+
acknowledgedThrough = op.sequenceNumber;
|
|
2991
3140
|
continue;
|
|
2992
3141
|
}
|
|
2993
3142
|
if (this.validateOperation && this.koraContext) {
|
|
2994
3143
|
let decision;
|
|
2995
3144
|
try {
|
|
2996
|
-
decision = await this.validateOperation(
|
|
3145
|
+
decision = await this.validateOperation(serverOp, {
|
|
2997
3146
|
auth: this.authContext,
|
|
2998
3147
|
kora: this.koraContext
|
|
2999
3148
|
});
|
|
3000
3149
|
} catch (error) {
|
|
3001
3150
|
const message = error instanceof Error ? error.message : "validator error";
|
|
3002
|
-
this.sendOperationRejected(
|
|
3151
|
+
this.sendOperationRejected(
|
|
3152
|
+
serverOp,
|
|
3153
|
+
"VALIDATION_ERROR",
|
|
3154
|
+
`Validator threw: ${message}`,
|
|
3155
|
+
true
|
|
3156
|
+
);
|
|
3157
|
+
canAdvanceAck = false;
|
|
3003
3158
|
continue;
|
|
3004
3159
|
}
|
|
3005
3160
|
if (decision.action === "reject") {
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3161
|
+
const retriable = decision.retriable ?? isRetriableRejection(decision.code);
|
|
3162
|
+
this.sendOperationRejected(serverOp, decision.code, decision.message, retriable);
|
|
3163
|
+
if (retriable) {
|
|
3164
|
+
canAdvanceAck = false;
|
|
3165
|
+
} else {
|
|
3166
|
+
acknowledgedThrough = op.sequenceNumber;
|
|
3167
|
+
}
|
|
3012
3168
|
continue;
|
|
3013
3169
|
}
|
|
3014
3170
|
if (decision.action === "ignore") {
|
|
3171
|
+
acknowledgedThrough = op.sequenceNumber;
|
|
3015
3172
|
continue;
|
|
3016
3173
|
}
|
|
3017
3174
|
}
|
|
3018
|
-
const applyResult = await applyServerOperation(this.store,
|
|
3175
|
+
const applyResult = await applyServerOperation(this.store, serverOp);
|
|
3019
3176
|
if (applyResult.rejection) {
|
|
3020
|
-
this.
|
|
3177
|
+
this.sendOperationRejected(
|
|
3178
|
+
serverOp,
|
|
3021
3179
|
applyResult.rejection.code,
|
|
3022
3180
|
applyResult.rejection.message,
|
|
3023
3181
|
applyResult.rejection.retriable
|
|
3024
3182
|
);
|
|
3183
|
+
if (applyResult.rejection.retriable) {
|
|
3184
|
+
canAdvanceAck = false;
|
|
3185
|
+
} else {
|
|
3186
|
+
acknowledgedThrough = op.sequenceNumber;
|
|
3187
|
+
}
|
|
3025
3188
|
continue;
|
|
3026
3189
|
}
|
|
3027
3190
|
if (applyResult.result === "applied") {
|
|
3028
3191
|
applied.push(...applyResult.appliedOperations);
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
for (const op of rejected) {
|
|
3033
|
-
this.sendError(
|
|
3034
|
-
"SCOPE_VIOLATION",
|
|
3035
|
-
`Operation "${op.id}" in collection "${op.collection}" is outside the client's sync scope`,
|
|
3036
|
-
false
|
|
3037
|
-
);
|
|
3192
|
+
acknowledgedThrough = op.sequenceNumber;
|
|
3193
|
+
} else {
|
|
3194
|
+
acknowledgedThrough = op.sequenceNumber;
|
|
3038
3195
|
}
|
|
3039
3196
|
}
|
|
3040
3197
|
if (operations2.length > 0) {
|
|
@@ -3049,13 +3206,19 @@ var ClientSession = class _ClientSession {
|
|
|
3049
3206
|
type: "acknowledgment",
|
|
3050
3207
|
messageId: generateUUIDv74(),
|
|
3051
3208
|
acknowledgedMessageId: msg.messageId,
|
|
3052
|
-
lastSequenceNumber: lastOp ? lastOp.sequenceNumber : 0
|
|
3209
|
+
lastSequenceNumber: lastOp ? Math.min(acknowledgedThrough, lastOp.sequenceNumber) : 0
|
|
3053
3210
|
};
|
|
3054
3211
|
this.sendToClient(ack);
|
|
3055
3212
|
if (applied.length > 0) {
|
|
3056
3213
|
this.onRelay?.(this.sessionId, applied);
|
|
3057
3214
|
}
|
|
3058
3215
|
}
|
|
3216
|
+
transformForServerSchema(op) {
|
|
3217
|
+
if (op.schemaVersion === this.schemaVersion) {
|
|
3218
|
+
return op;
|
|
3219
|
+
}
|
|
3220
|
+
return applyOperationTransforms(op, this.schemaVersion, this.operationTransforms);
|
|
3221
|
+
}
|
|
3059
3222
|
async sendDelta(clientVector) {
|
|
3060
3223
|
const serverVector = this.store.getVersionVector();
|
|
3061
3224
|
const missing = [];
|
|
@@ -4044,6 +4207,7 @@ var KoraSyncServer = class _KoraSyncServer {
|
|
|
4044
4207
|
batchSize;
|
|
4045
4208
|
schemaVersion;
|
|
4046
4209
|
supportedSchemaVersions;
|
|
4210
|
+
operationTransforms;
|
|
4047
4211
|
port;
|
|
4048
4212
|
host;
|
|
4049
4213
|
path;
|
|
@@ -4081,16 +4245,24 @@ var KoraSyncServer = class _KoraSyncServer {
|
|
|
4081
4245
|
static ORPHANED_RELAY_TTL_MS = 5 * 6e4;
|
|
4082
4246
|
constructor(config) {
|
|
4083
4247
|
this.store = config.store;
|
|
4248
|
+
const storeSchemaVersion = this.store.getSchema()?.version;
|
|
4249
|
+
if (config.schemaVersion !== void 0 && storeSchemaVersion !== void 0 && config.schemaVersion !== storeSchemaVersion) {
|
|
4250
|
+
throw new SyncError4("Sync schema version does not match the configured store schema.", {
|
|
4251
|
+
syncSchemaVersion: config.schemaVersion,
|
|
4252
|
+
storeSchemaVersion
|
|
4253
|
+
});
|
|
4254
|
+
}
|
|
4084
4255
|
this.auth = config.auth ?? null;
|
|
4085
4256
|
this.serializer = config.serializer ?? new JsonMessageSerializer2();
|
|
4086
4257
|
this.emitter = config.emitter ?? null;
|
|
4087
4258
|
this.maxConnections = config.maxConnections ?? DEFAULT_MAX_CONNECTIONS;
|
|
4088
4259
|
this.batchSize = config.batchSize ?? DEFAULT_BATCH_SIZE2;
|
|
4089
|
-
this.schemaVersion = config.schemaVersion ?? DEFAULT_SCHEMA_VERSION2;
|
|
4260
|
+
this.schemaVersion = config.schemaVersion ?? storeSchemaVersion ?? DEFAULT_SCHEMA_VERSION2;
|
|
4090
4261
|
this.supportedSchemaVersions = config.supportedSchemaVersions ?? {
|
|
4091
4262
|
min: this.schemaVersion,
|
|
4092
4263
|
max: this.schemaVersion
|
|
4093
4264
|
};
|
|
4265
|
+
this.operationTransforms = config.operationTransforms ?? [];
|
|
4094
4266
|
this.port = config.port;
|
|
4095
4267
|
this.host = config.host ?? DEFAULT_HOST;
|
|
4096
4268
|
this.path = config.path ?? DEFAULT_PATH;
|
|
@@ -4214,6 +4386,21 @@ var KoraSyncServer = class _KoraSyncServer {
|
|
|
4214
4386
|
details: { reason: event.reason }
|
|
4215
4387
|
});
|
|
4216
4388
|
});
|
|
4389
|
+
sessionEmitter.on("sync:schema-mismatch", (event) => {
|
|
4390
|
+
this.logger.log({
|
|
4391
|
+
timestamp: Date.now(),
|
|
4392
|
+
level: "warn",
|
|
4393
|
+
event: "session.schema_mismatch",
|
|
4394
|
+
sessionId,
|
|
4395
|
+
details: {
|
|
4396
|
+
clientSchemaVersion: event.clientSchemaVersion,
|
|
4397
|
+
serverSchemaVersion: event.serverSchemaVersion,
|
|
4398
|
+
supportedMin: event.supportedMin,
|
|
4399
|
+
supportedMax: event.supportedMax,
|
|
4400
|
+
reason: event.reason
|
|
4401
|
+
}
|
|
4402
|
+
});
|
|
4403
|
+
});
|
|
4217
4404
|
}
|
|
4218
4405
|
/**
|
|
4219
4406
|
* Get the metrics collector for external access (e.g., HTTP endpoints).
|
|
@@ -4416,6 +4603,33 @@ var KoraSyncServer = class _KoraSyncServer {
|
|
|
4416
4603
|
sessionId
|
|
4417
4604
|
});
|
|
4418
4605
|
});
|
|
4606
|
+
sessionEmitter.on("sync:schema-mismatch", (event) => {
|
|
4607
|
+
this.logger.log({
|
|
4608
|
+
timestamp: Date.now(),
|
|
4609
|
+
level: "warn",
|
|
4610
|
+
event: "session.schema_mismatch",
|
|
4611
|
+
sessionId,
|
|
4612
|
+
details: {
|
|
4613
|
+
clientSchemaVersion: event.clientSchemaVersion,
|
|
4614
|
+
serverSchemaVersion: event.serverSchemaVersion,
|
|
4615
|
+
supportedMin: event.supportedMin,
|
|
4616
|
+
supportedMax: event.supportedMax,
|
|
4617
|
+
reason: event.reason
|
|
4618
|
+
}
|
|
4619
|
+
});
|
|
4620
|
+
});
|
|
4621
|
+
sessionEmitter.on("sync:delivery-stalled", (event) => {
|
|
4622
|
+
this.logger.log({
|
|
4623
|
+
timestamp: Date.now(),
|
|
4624
|
+
level: "warn",
|
|
4625
|
+
event: "session.delivery_stalled",
|
|
4626
|
+
sessionId,
|
|
4627
|
+
details: {
|
|
4628
|
+
watermark: event.watermark,
|
|
4629
|
+
repeatCount: event.repeatCount
|
|
4630
|
+
}
|
|
4631
|
+
});
|
|
4632
|
+
});
|
|
4419
4633
|
const session = new ClientSession({
|
|
4420
4634
|
sessionId,
|
|
4421
4635
|
transport,
|
|
@@ -4427,6 +4641,7 @@ var KoraSyncServer = class _KoraSyncServer {
|
|
|
4427
4641
|
batchSize: this.batchSize,
|
|
4428
4642
|
schemaVersion: this.schemaVersion,
|
|
4429
4643
|
supportedSchemaVersions: this.supportedSchemaVersions,
|
|
4644
|
+
operationTransforms: this.operationTransforms,
|
|
4430
4645
|
onRelay: (sourceSessionId, operations2) => {
|
|
4431
4646
|
this.handleRelay(sourceSessionId, operations2);
|
|
4432
4647
|
},
|