@korajs/server 1.0.0-beta.7 → 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 +251 -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 +221 -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
|
}
|
|
@@ -2573,6 +2649,13 @@ var ClientSession = class _ClientSession {
|
|
|
2573
2649
|
* client's reported watermark at handshake.
|
|
2574
2650
|
*/
|
|
2575
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;
|
|
2576
2659
|
/** Serializes incremental delivery pushes so their batches never interleave. */
|
|
2577
2660
|
deliveryPushChain = Promise.resolve();
|
|
2578
2661
|
/**
|
|
@@ -2595,6 +2678,7 @@ var ClientSession = class _ClientSession {
|
|
|
2595
2678
|
batchSize;
|
|
2596
2679
|
schemaVersion;
|
|
2597
2680
|
supportedSchemaVersions;
|
|
2681
|
+
operationTransforms;
|
|
2598
2682
|
onRelay;
|
|
2599
2683
|
onAwarenessUpdate;
|
|
2600
2684
|
onYjsDocUpdate;
|
|
@@ -2624,6 +2708,7 @@ var ClientSession = class _ClientSession {
|
|
|
2624
2708
|
min: this.schemaVersion,
|
|
2625
2709
|
max: this.schemaVersion
|
|
2626
2710
|
};
|
|
2711
|
+
this.operationTransforms = options.operationTransforms ?? [];
|
|
2627
2712
|
this.onRelay = options.onRelay ?? null;
|
|
2628
2713
|
this.onAwarenessUpdate = options.onAwarenessUpdate ?? null;
|
|
2629
2714
|
this.onYjsDocUpdate = options.onYjsDocUpdate ?? null;
|
|
@@ -2673,6 +2758,7 @@ var ClientSession = class _ClientSession {
|
|
|
2673
2758
|
this.deliveryPushChain = this.deliveryPushChain.then(async () => {
|
|
2674
2759
|
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
2675
2760
|
try {
|
|
2761
|
+
this.lastDeliveryPushAttemptAtMs = Date.now();
|
|
2676
2762
|
await this.sendDeliveryStream(
|
|
2677
2763
|
this.lastAckedDeliverySeq,
|
|
2678
2764
|
false,
|
|
@@ -2720,6 +2806,18 @@ var ClientSession = class _ClientSession {
|
|
|
2720
2806
|
retransmitPendingRelays(staleMs = 0) {
|
|
2721
2807
|
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
2722
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
|
+
}
|
|
2723
2821
|
this.pushDeliveryStream();
|
|
2724
2822
|
return;
|
|
2725
2823
|
}
|
|
@@ -2812,6 +2910,7 @@ var ClientSession = class _ClientSession {
|
|
|
2812
2910
|
this.pendingRelays.delete(message.acknowledgedMessageId);
|
|
2813
2911
|
if (message.deliverySequence !== void 0) {
|
|
2814
2912
|
this.lastAckedDeliverySeq = Math.max(this.lastAckedDeliverySeq, message.deliverySequence);
|
|
2913
|
+
this.deliveryStallRepeatCount = 0;
|
|
2815
2914
|
}
|
|
2816
2915
|
break;
|
|
2817
2916
|
case "error":
|
|
@@ -2918,6 +3017,14 @@ var ClientSession = class _ClientSession {
|
|
|
2918
3017
|
this.setSerializerWireFormat(selectedWireFormat);
|
|
2919
3018
|
if (!isClientSchemaVersionSupported(msg.schemaVersion, this.supportedSchemaVersions)) {
|
|
2920
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
|
+
});
|
|
2921
3028
|
const response2 = {
|
|
2922
3029
|
type: "handshake-response",
|
|
2923
3030
|
messageId: generateUUIDv74(),
|
|
@@ -2960,6 +3067,7 @@ var ClientSession = class _ClientSession {
|
|
|
2960
3067
|
if (this.clientDeliveryWatermark !== null) {
|
|
2961
3068
|
this.lastAckedDeliverySeq = this.clientDeliveryWatermark;
|
|
2962
3069
|
const excludeOwn = this.clientDeliveryWatermark > 0 ? this.clientNodeId ?? void 0 : void 0;
|
|
3070
|
+
this.lastDeliveryPushAttemptAtMs = Date.now();
|
|
2963
3071
|
await this.sendDeliveryStream(this.clientDeliveryWatermark, true, excludeOwn);
|
|
2964
3072
|
} else {
|
|
2965
3073
|
const clientVector = wireToVersionVector(msg.versionVector);
|
|
@@ -2976,10 +3084,20 @@ var ClientSession = class _ClientSession {
|
|
|
2976
3084
|
async handleOperationBatch(msg) {
|
|
2977
3085
|
const operations2 = msg.operations.map((s) => this.serializer.decodeOperation(s));
|
|
2978
3086
|
const applied = [];
|
|
2979
|
-
|
|
3087
|
+
let acknowledgedThrough = 0;
|
|
3088
|
+
let canAdvanceAck = true;
|
|
2980
3089
|
for (const op of operations2) {
|
|
3090
|
+
if (!canAdvanceAck) {
|
|
3091
|
+
continue;
|
|
3092
|
+
}
|
|
2981
3093
|
if (!await this.operationVisibleToClient(op)) {
|
|
2982
|
-
|
|
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;
|
|
2983
3101
|
continue;
|
|
2984
3102
|
}
|
|
2985
3103
|
if (!isOperationTimestampValid(op)) {
|
|
@@ -2988,6 +3106,7 @@ var ClientSession = class _ClientSession {
|
|
|
2988
3106
|
`Operation "${op.id}" timestamp is too far in the future`,
|
|
2989
3107
|
false
|
|
2990
3108
|
);
|
|
3109
|
+
canAdvanceAck = false;
|
|
2991
3110
|
continue;
|
|
2992
3111
|
}
|
|
2993
3112
|
if (!this.rateLimiter.allow(1)) {
|
|
@@ -2996,6 +3115,7 @@ var ClientSession = class _ClientSession {
|
|
|
2996
3115
|
`Session exceeded operation rate limit (${String(this.maxOpsPerMinute)} ops/min)`,
|
|
2997
3116
|
true
|
|
2998
3117
|
);
|
|
3118
|
+
canAdvanceAck = false;
|
|
2999
3119
|
continue;
|
|
3000
3120
|
}
|
|
3001
3121
|
const sizeCheck = validateOperationSize(op, this.maxOperationBytes);
|
|
@@ -3005,53 +3125,73 @@ var ClientSession = class _ClientSession {
|
|
|
3005
3125
|
sizeCheck.message ?? `Operation "${op.id}" is too large`,
|
|
3006
3126
|
false
|
|
3007
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;
|
|
3008
3140
|
continue;
|
|
3009
3141
|
}
|
|
3010
3142
|
if (this.validateOperation && this.koraContext) {
|
|
3011
3143
|
let decision;
|
|
3012
3144
|
try {
|
|
3013
|
-
decision = await this.validateOperation(
|
|
3145
|
+
decision = await this.validateOperation(serverOp, {
|
|
3014
3146
|
auth: this.authContext,
|
|
3015
3147
|
kora: this.koraContext
|
|
3016
3148
|
});
|
|
3017
3149
|
} catch (error) {
|
|
3018
3150
|
const message = error instanceof Error ? error.message : "validator error";
|
|
3019
|
-
this.sendOperationRejected(
|
|
3151
|
+
this.sendOperationRejected(
|
|
3152
|
+
serverOp,
|
|
3153
|
+
"VALIDATION_ERROR",
|
|
3154
|
+
`Validator threw: ${message}`,
|
|
3155
|
+
true
|
|
3156
|
+
);
|
|
3157
|
+
canAdvanceAck = false;
|
|
3020
3158
|
continue;
|
|
3021
3159
|
}
|
|
3022
3160
|
if (decision.action === "reject") {
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
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
|
+
}
|
|
3029
3168
|
continue;
|
|
3030
3169
|
}
|
|
3031
3170
|
if (decision.action === "ignore") {
|
|
3171
|
+
acknowledgedThrough = op.sequenceNumber;
|
|
3032
3172
|
continue;
|
|
3033
3173
|
}
|
|
3034
3174
|
}
|
|
3035
|
-
const applyResult = await applyServerOperation(this.store,
|
|
3175
|
+
const applyResult = await applyServerOperation(this.store, serverOp);
|
|
3036
3176
|
if (applyResult.rejection) {
|
|
3037
|
-
this.
|
|
3177
|
+
this.sendOperationRejected(
|
|
3178
|
+
serverOp,
|
|
3038
3179
|
applyResult.rejection.code,
|
|
3039
3180
|
applyResult.rejection.message,
|
|
3040
3181
|
applyResult.rejection.retriable
|
|
3041
3182
|
);
|
|
3183
|
+
if (applyResult.rejection.retriable) {
|
|
3184
|
+
canAdvanceAck = false;
|
|
3185
|
+
} else {
|
|
3186
|
+
acknowledgedThrough = op.sequenceNumber;
|
|
3187
|
+
}
|
|
3042
3188
|
continue;
|
|
3043
3189
|
}
|
|
3044
3190
|
if (applyResult.result === "applied") {
|
|
3045
3191
|
applied.push(...applyResult.appliedOperations);
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
for (const op of rejected) {
|
|
3050
|
-
this.sendError(
|
|
3051
|
-
"SCOPE_VIOLATION",
|
|
3052
|
-
`Operation "${op.id}" in collection "${op.collection}" is outside the client's sync scope`,
|
|
3053
|
-
false
|
|
3054
|
-
);
|
|
3192
|
+
acknowledgedThrough = op.sequenceNumber;
|
|
3193
|
+
} else {
|
|
3194
|
+
acknowledgedThrough = op.sequenceNumber;
|
|
3055
3195
|
}
|
|
3056
3196
|
}
|
|
3057
3197
|
if (operations2.length > 0) {
|
|
@@ -3066,13 +3206,19 @@ var ClientSession = class _ClientSession {
|
|
|
3066
3206
|
type: "acknowledgment",
|
|
3067
3207
|
messageId: generateUUIDv74(),
|
|
3068
3208
|
acknowledgedMessageId: msg.messageId,
|
|
3069
|
-
lastSequenceNumber: lastOp ? lastOp.sequenceNumber : 0
|
|
3209
|
+
lastSequenceNumber: lastOp ? Math.min(acknowledgedThrough, lastOp.sequenceNumber) : 0
|
|
3070
3210
|
};
|
|
3071
3211
|
this.sendToClient(ack);
|
|
3072
3212
|
if (applied.length > 0) {
|
|
3073
3213
|
this.onRelay?.(this.sessionId, applied);
|
|
3074
3214
|
}
|
|
3075
3215
|
}
|
|
3216
|
+
transformForServerSchema(op) {
|
|
3217
|
+
if (op.schemaVersion === this.schemaVersion) {
|
|
3218
|
+
return op;
|
|
3219
|
+
}
|
|
3220
|
+
return applyOperationTransforms(op, this.schemaVersion, this.operationTransforms);
|
|
3221
|
+
}
|
|
3076
3222
|
async sendDelta(clientVector) {
|
|
3077
3223
|
const serverVector = this.store.getVersionVector();
|
|
3078
3224
|
const missing = [];
|
|
@@ -4061,6 +4207,7 @@ var KoraSyncServer = class _KoraSyncServer {
|
|
|
4061
4207
|
batchSize;
|
|
4062
4208
|
schemaVersion;
|
|
4063
4209
|
supportedSchemaVersions;
|
|
4210
|
+
operationTransforms;
|
|
4064
4211
|
port;
|
|
4065
4212
|
host;
|
|
4066
4213
|
path;
|
|
@@ -4098,16 +4245,24 @@ var KoraSyncServer = class _KoraSyncServer {
|
|
|
4098
4245
|
static ORPHANED_RELAY_TTL_MS = 5 * 6e4;
|
|
4099
4246
|
constructor(config) {
|
|
4100
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
|
+
}
|
|
4101
4255
|
this.auth = config.auth ?? null;
|
|
4102
4256
|
this.serializer = config.serializer ?? new JsonMessageSerializer2();
|
|
4103
4257
|
this.emitter = config.emitter ?? null;
|
|
4104
4258
|
this.maxConnections = config.maxConnections ?? DEFAULT_MAX_CONNECTIONS;
|
|
4105
4259
|
this.batchSize = config.batchSize ?? DEFAULT_BATCH_SIZE2;
|
|
4106
|
-
this.schemaVersion = config.schemaVersion ?? DEFAULT_SCHEMA_VERSION2;
|
|
4260
|
+
this.schemaVersion = config.schemaVersion ?? storeSchemaVersion ?? DEFAULT_SCHEMA_VERSION2;
|
|
4107
4261
|
this.supportedSchemaVersions = config.supportedSchemaVersions ?? {
|
|
4108
4262
|
min: this.schemaVersion,
|
|
4109
4263
|
max: this.schemaVersion
|
|
4110
4264
|
};
|
|
4265
|
+
this.operationTransforms = config.operationTransforms ?? [];
|
|
4111
4266
|
this.port = config.port;
|
|
4112
4267
|
this.host = config.host ?? DEFAULT_HOST;
|
|
4113
4268
|
this.path = config.path ?? DEFAULT_PATH;
|
|
@@ -4231,6 +4386,21 @@ var KoraSyncServer = class _KoraSyncServer {
|
|
|
4231
4386
|
details: { reason: event.reason }
|
|
4232
4387
|
});
|
|
4233
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
|
+
});
|
|
4234
4404
|
}
|
|
4235
4405
|
/**
|
|
4236
4406
|
* Get the metrics collector for external access (e.g., HTTP endpoints).
|
|
@@ -4433,6 +4603,33 @@ var KoraSyncServer = class _KoraSyncServer {
|
|
|
4433
4603
|
sessionId
|
|
4434
4604
|
});
|
|
4435
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
|
+
});
|
|
4436
4633
|
const session = new ClientSession({
|
|
4437
4634
|
sessionId,
|
|
4438
4635
|
transport,
|
|
@@ -4444,6 +4641,7 @@ var KoraSyncServer = class _KoraSyncServer {
|
|
|
4444
4641
|
batchSize: this.batchSize,
|
|
4445
4642
|
schemaVersion: this.schemaVersion,
|
|
4446
4643
|
supportedSchemaVersions: this.supportedSchemaVersions,
|
|
4644
|
+
operationTransforms: this.operationTransforms,
|
|
4447
4645
|
onRelay: (sourceSessionId, operations2) => {
|
|
4448
4646
|
this.handleRelay(sourceSessionId, operations2);
|
|
4449
4647
|
},
|