@syncular/server-hono 0.0.6-159 → 0.0.6-165
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/blobs.d.ts +10 -4
- package/dist/blobs.d.ts.map +1 -1
- package/dist/blobs.js +260 -26
- package/dist/blobs.js.map +1 -1
- package/dist/console/gateway.d.ts +4 -0
- package/dist/console/gateway.d.ts.map +1 -1
- package/dist/console/gateway.js +97 -60
- package/dist/console/gateway.js.map +1 -1
- package/dist/console/route-descriptor.d.ts +6 -0
- package/dist/console/route-descriptor.d.ts.map +1 -0
- package/dist/console/route-descriptor.js +16 -0
- package/dist/console/route-descriptor.js.map +1 -0
- package/dist/console/routes.d.ts.map +1 -1
- package/dist/console/routes.js +153 -108
- package/dist/console/routes.js.map +1 -1
- package/dist/console/schema-errors.d.ts +2 -0
- package/dist/console/schema-errors.d.ts.map +1 -0
- package/dist/console/schema-errors.js +17 -0
- package/dist/console/schema-errors.js.map +1 -0
- package/dist/console/schemas.js +1 -1
- package/dist/console/schemas.js.map +1 -1
- package/dist/console/types.d.ts +32 -0
- package/dist/console/types.d.ts.map +1 -1
- package/dist/create-server.d.ts.map +1 -1
- package/dist/create-server.js +13 -10
- package/dist/create-server.js.map +1 -1
- package/dist/proxy/routes.d.ts +10 -0
- package/dist/proxy/routes.d.ts.map +1 -1
- package/dist/proxy/routes.js +57 -6
- package/dist/proxy/routes.js.map +1 -1
- package/dist/routes.d.ts +21 -0
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +338 -352
- package/dist/routes.js.map +1 -1
- package/package.json +7 -6
- package/src/__tests__/blob-routes.test.ts +286 -18
- package/src/__tests__/console-gateway-live-routes.test.ts +61 -1
- package/src/__tests__/console-routes.test.ts +30 -1
- package/src/__tests__/create-server.test.ts +237 -1
- package/src/__tests__/pull-chunk-storage.test.ts +98 -0
- package/src/blobs.ts +360 -34
- package/src/console/gateway.ts +335 -288
- package/src/console/route-descriptor.ts +22 -0
- package/src/console/routes.ts +327 -248
- package/src/console/schema-errors.ts +23 -0
- package/src/console/schemas.ts +1 -1
- package/src/console/types.ts +32 -0
- package/src/create-server.ts +13 -10
- package/src/proxy/routes.ts +73 -9
- package/src/routes.ts +449 -396
package/dist/routes.js
CHANGED
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
* - GET /snapshot-chunks/:chunkId (download encoded snapshot chunks)
|
|
7
7
|
* - GET /realtime (optional WebSocket "wake up" notifications)
|
|
8
8
|
*/
|
|
9
|
-
import { captureSyncException, countSyncMetric, createSyncTimer, distributionSyncMetric, ErrorResponseSchema, logSyncEvent, SyncCombinedRequestSchema, SyncCombinedResponseSchema, SyncPushRequestSchema, } from '@syncular/core';
|
|
10
|
-
import { createServerHandlerCollection, InvalidSubscriptionScopeError, maybeCompactChanges, maybePruneSync, pull, pushCommit, readSnapshotChunk, recordClientCursor, } from '@syncular/server';
|
|
9
|
+
import { captureSyncException, countSyncMetric, createSyncTimer, distributionSyncMetric, ErrorResponseSchema, logSyncEvent, ScopeValuesSchema, SyncCombinedRequestSchema, SyncCombinedResponseSchema, SyncPushRequestSchema, } from '@syncular/core';
|
|
10
|
+
import { createServerHandlerCollection, InvalidSubscriptionScopeError, maybeCompactChanges, maybePruneSync, parseJsonValue, pull, pushCommit, readSnapshotChunk, recordClientCursor, resolveEffectiveScopesForSubscriptions, scopesToSnapshotChunkScopeKey, } from '@syncular/server';
|
|
11
11
|
import { Hono } from 'hono';
|
|
12
12
|
import { describeRoute, resolver, validator as zValidator } from 'hono-openapi';
|
|
13
13
|
import { sql, } from 'kysely';
|
|
14
14
|
import { z } from 'zod';
|
|
15
|
+
import { isBenignConsoleSchemaError } from './console/schema-errors.js';
|
|
15
16
|
import { createRateLimiter, DEFAULT_SYNC_RATE_LIMITS, } from './rate-limit.js';
|
|
16
17
|
import { createWebSocketConnection, WebSocketConnectionManager, } from './ws.js';
|
|
17
18
|
/**
|
|
@@ -25,6 +26,9 @@ const realtimeUnsubscribeMap = new WeakMap();
|
|
|
25
26
|
const snapshotChunkParamsSchema = z.object({
|
|
26
27
|
chunkId: z.string().min(1),
|
|
27
28
|
});
|
|
29
|
+
const snapshotChunkQuerySchema = z.object({
|
|
30
|
+
scopes: z.string().optional(),
|
|
31
|
+
});
|
|
28
32
|
const auditCommitListQuerySchema = z.object({
|
|
29
33
|
limit: z.coerce.number().int().min(1).max(200).optional(),
|
|
30
34
|
beforeCommitSeq: z.coerce.number().int().min(1).optional(),
|
|
@@ -65,6 +69,7 @@ const auditCommitDetailResponseSchema = z.object({
|
|
|
65
69
|
changes: z.array(auditChangeSchema),
|
|
66
70
|
});
|
|
67
71
|
const DEFAULT_REQUEST_PAYLOAD_SNAPSHOT_MAX_BYTES = 128 * 1024;
|
|
72
|
+
const SNAPSHOT_SCOPES_HEADER = 'x-syncular-snapshot-scopes';
|
|
68
73
|
function createOpaqueId(prefix) {
|
|
69
74
|
const randomPart = typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
|
70
75
|
? crypto.randomUUID()
|
|
@@ -218,6 +223,16 @@ function countPullRows(response) {
|
|
|
218
223
|
return totalRows + commitRows + snapshotRows;
|
|
219
224
|
}, 0);
|
|
220
225
|
}
|
|
226
|
+
function readSnapshotScopeValues(c, queryScopes) {
|
|
227
|
+
const rawValue = queryScopes ?? c.req.header(SNAPSHOT_SCOPES_HEADER);
|
|
228
|
+
if (!rawValue)
|
|
229
|
+
return null;
|
|
230
|
+
const parsed = parseJsonValue(rawValue);
|
|
231
|
+
const validated = ScopeValuesSchema.safeParse(parsed);
|
|
232
|
+
if (!validated.success)
|
|
233
|
+
return null;
|
|
234
|
+
return validated.data;
|
|
235
|
+
}
|
|
221
236
|
function encodePayloadSnapshot(value, maxBytes) {
|
|
222
237
|
try {
|
|
223
238
|
const serialized = JSON.stringify(value);
|
|
@@ -282,6 +297,9 @@ export function createSyncRoutes(options) {
|
|
|
282
297
|
Promise.resolve())
|
|
283
298
|
: Promise.resolve();
|
|
284
299
|
const consoleSchemaReady = consoleSchemaReadyBase.catch((error) => {
|
|
300
|
+
if (isBenignConsoleSchemaError(error)) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
285
303
|
logSyncEvent({
|
|
286
304
|
event: 'sync.console_schema_ready_failed',
|
|
287
305
|
error: error instanceof Error ? error.message : String(error),
|
|
@@ -469,6 +487,133 @@ export function createSyncRoutes(options) {
|
|
|
469
487
|
});
|
|
470
488
|
});
|
|
471
489
|
};
|
|
490
|
+
async function executePushCommitWithSideEffects(ctx, pushBody, execOptions = {}) {
|
|
491
|
+
const timer = createSyncTimer();
|
|
492
|
+
const pushOps = pushBody.operations ?? [];
|
|
493
|
+
const pushed = await pushCommit({
|
|
494
|
+
db: options.db,
|
|
495
|
+
dialect: options.dialect,
|
|
496
|
+
handlers: handlerRegistry,
|
|
497
|
+
plugins: options.plugins,
|
|
498
|
+
auth: ctx.auth,
|
|
499
|
+
request: {
|
|
500
|
+
clientId: ctx.clientId,
|
|
501
|
+
clientCommitId: pushBody.clientCommitId,
|
|
502
|
+
operations: pushBody.operations,
|
|
503
|
+
schemaVersion: pushBody.schemaVersion,
|
|
504
|
+
},
|
|
505
|
+
});
|
|
506
|
+
const pushDurationMs = timer();
|
|
507
|
+
logSyncEvent({
|
|
508
|
+
event: 'sync.push',
|
|
509
|
+
userId: ctx.auth.actorId,
|
|
510
|
+
durationMs: pushDurationMs,
|
|
511
|
+
operationCount: pushOps.length,
|
|
512
|
+
status: pushed.response.status,
|
|
513
|
+
commitSeq: pushed.response.commitSeq,
|
|
514
|
+
});
|
|
515
|
+
recordRequestEventInBackground(() => ({
|
|
516
|
+
partitionId: ctx.partitionId,
|
|
517
|
+
requestId: ctx.requestId,
|
|
518
|
+
traceId: ctx.traceContext.traceId,
|
|
519
|
+
spanId: ctx.traceContext.spanId,
|
|
520
|
+
eventType: 'push',
|
|
521
|
+
syncPath: ctx.syncPath,
|
|
522
|
+
actorId: ctx.auth.actorId,
|
|
523
|
+
clientId: ctx.clientId,
|
|
524
|
+
transportPath: ctx.transportPath,
|
|
525
|
+
statusCode: 200,
|
|
526
|
+
outcome: pushed.response.status,
|
|
527
|
+
responseStatus: normalizeResponseStatus(200, pushed.response.status),
|
|
528
|
+
durationMs: pushDurationMs,
|
|
529
|
+
errorCode: firstPushErrorCode(pushed.response.results),
|
|
530
|
+
commitSeq: pushed.response.commitSeq,
|
|
531
|
+
operationCount: pushOps.length,
|
|
532
|
+
tables: pushed.affectedTables,
|
|
533
|
+
payloadSnapshot: shouldCaptureRequestPayloadSnapshots
|
|
534
|
+
? {
|
|
535
|
+
request: {
|
|
536
|
+
clientId: ctx.clientId,
|
|
537
|
+
clientCommitId: pushBody.clientCommitId,
|
|
538
|
+
schemaVersion: pushBody.schemaVersion,
|
|
539
|
+
operations: pushBody.operations,
|
|
540
|
+
},
|
|
541
|
+
response: pushed.response,
|
|
542
|
+
}
|
|
543
|
+
: null,
|
|
544
|
+
}));
|
|
545
|
+
emitConsoleLiveEvent(consoleLiveEmitter, 'push', () => ({
|
|
546
|
+
partitionId: ctx.partitionId,
|
|
547
|
+
requestId: ctx.requestId,
|
|
548
|
+
traceId: ctx.traceContext.traceId,
|
|
549
|
+
spanId: ctx.traceContext.spanId,
|
|
550
|
+
actorId: ctx.auth.actorId,
|
|
551
|
+
clientId: ctx.clientId,
|
|
552
|
+
transportPath: ctx.transportPath,
|
|
553
|
+
syncPath: ctx.syncPath,
|
|
554
|
+
outcome: pushed.response.status,
|
|
555
|
+
statusCode: 200,
|
|
556
|
+
durationMs: pushDurationMs,
|
|
557
|
+
commitSeq: pushed.response.commitSeq ?? null,
|
|
558
|
+
operationCount: pushOps.length,
|
|
559
|
+
tables: pushed.affectedTables,
|
|
560
|
+
}));
|
|
561
|
+
if (execOptions.countConflictsMetric === true) {
|
|
562
|
+
const detectedConflicts = pushed.response.results.reduce((count, result) => count + (result.status === 'conflict' ? 1 : 0), 0);
|
|
563
|
+
if (detectedConflicts > 0) {
|
|
564
|
+
countSyncMetric('sync.conflicts.detected', detectedConflicts, {
|
|
565
|
+
attributes: {
|
|
566
|
+
syncPath: ctx.syncPath,
|
|
567
|
+
transportPath: ctx.transportPath,
|
|
568
|
+
},
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
if (wsConnectionManager &&
|
|
573
|
+
pushed.response.ok === true &&
|
|
574
|
+
pushed.response.status === 'applied' &&
|
|
575
|
+
typeof pushed.response.commitSeq === 'number') {
|
|
576
|
+
const scopeKeys = applyPartitionToScopeKeys(ctx.partitionId, pushed.scopeKeys);
|
|
577
|
+
if (scopeKeys.length > 0) {
|
|
578
|
+
wsConnectionManager.notifyScopeKeys(scopeKeys, pushed.response.commitSeq, {
|
|
579
|
+
excludeClientIds: [ctx.clientId],
|
|
580
|
+
changes: pushed.emittedChanges,
|
|
581
|
+
actorId: pushed.commitActorId ?? ctx.auth.actorId,
|
|
582
|
+
createdAt: pushed.commitCreatedAt ?? new Date().toISOString(),
|
|
583
|
+
});
|
|
584
|
+
if (realtimeBroadcaster) {
|
|
585
|
+
realtimeBroadcaster
|
|
586
|
+
.publish({
|
|
587
|
+
type: 'commit',
|
|
588
|
+
commitSeq: pushed.response.commitSeq,
|
|
589
|
+
partitionId: ctx.partitionId,
|
|
590
|
+
scopeKeys,
|
|
591
|
+
sourceInstanceId: instanceId,
|
|
592
|
+
})
|
|
593
|
+
.catch((error) => {
|
|
594
|
+
logAsyncFailureOnce('sync.realtime.broadcast_publish_failed', {
|
|
595
|
+
event: 'sync.realtime.broadcast_publish_failed',
|
|
596
|
+
userId: ctx.auth.actorId,
|
|
597
|
+
clientId: ctx.clientId,
|
|
598
|
+
error: error instanceof Error ? error.message : String(error),
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
if (pushed.response.ok === true &&
|
|
605
|
+
pushed.response.status === 'applied' &&
|
|
606
|
+
typeof pushed.response.commitSeq === 'number') {
|
|
607
|
+
emitConsoleLiveEvent(consoleLiveEmitter, 'commit', () => ({
|
|
608
|
+
partitionId: ctx.partitionId,
|
|
609
|
+
commitSeq: pushed.response.commitSeq,
|
|
610
|
+
actorId: ctx.auth.actorId,
|
|
611
|
+
clientId: ctx.clientId,
|
|
612
|
+
affectedTables: pushed.affectedTables,
|
|
613
|
+
}));
|
|
614
|
+
}
|
|
615
|
+
return pushed;
|
|
616
|
+
}
|
|
472
617
|
const authCache = new WeakMap();
|
|
473
618
|
const getAuth = (c) => {
|
|
474
619
|
const cached = authCache.get(c);
|
|
@@ -713,8 +858,8 @@ export function createSyncRoutes(options) {
|
|
|
713
858
|
rowId: change.row_id,
|
|
714
859
|
op: change.op,
|
|
715
860
|
rowVersion: change.row_version === null ? null : Number(change.row_version),
|
|
716
|
-
rowJson:
|
|
717
|
-
scopes:
|
|
861
|
+
rowJson: parseJsonValue(change.row_json),
|
|
862
|
+
scopes: parseJsonValue(change.scopes),
|
|
718
863
|
})),
|
|
719
864
|
}, 200);
|
|
720
865
|
});
|
|
@@ -752,6 +897,7 @@ export function createSyncRoutes(options) {
|
|
|
752
897
|
if (!auth)
|
|
753
898
|
return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
754
899
|
const partitionId = auth.partitionId ?? 'default';
|
|
900
|
+
const transportPath = readTransportPath(c);
|
|
755
901
|
const body = c.req.valid('json');
|
|
756
902
|
const clientId = body.clientId;
|
|
757
903
|
const requestId = readRequestId(c);
|
|
@@ -768,119 +914,15 @@ export function createSyncRoutes(options) {
|
|
|
768
914
|
message: `Maximum ${maxOperationsPerPush} operations per push`,
|
|
769
915
|
}, 400);
|
|
770
916
|
}
|
|
771
|
-
const
|
|
772
|
-
const pushed = await pushCommit({
|
|
773
|
-
db: options.db,
|
|
774
|
-
dialect: options.dialect,
|
|
775
|
-
handlers: handlerRegistry,
|
|
776
|
-
plugins: options.plugins,
|
|
917
|
+
const pushed = await executePushCommitWithSideEffects({
|
|
777
918
|
auth,
|
|
778
|
-
request: {
|
|
779
|
-
clientId,
|
|
780
|
-
clientCommitId: pushBody.clientCommitId,
|
|
781
|
-
operations: pushBody.operations,
|
|
782
|
-
schemaVersion: pushBody.schemaVersion,
|
|
783
|
-
},
|
|
784
|
-
});
|
|
785
|
-
const pushDurationMs = timer();
|
|
786
|
-
logSyncEvent({
|
|
787
|
-
event: 'sync.push',
|
|
788
|
-
userId: auth.actorId,
|
|
789
|
-
durationMs: pushDurationMs,
|
|
790
|
-
operationCount: pushOps.length,
|
|
791
|
-
status: pushed.response.status,
|
|
792
|
-
commitSeq: pushed.response.commitSeq,
|
|
793
|
-
});
|
|
794
|
-
recordRequestEventInBackground(() => ({
|
|
795
|
-
partitionId,
|
|
796
|
-
requestId,
|
|
797
|
-
traceId: traceContext.traceId,
|
|
798
|
-
spanId: traceContext.spanId,
|
|
799
|
-
eventType: 'push',
|
|
800
|
-
syncPath: 'http-combined',
|
|
801
|
-
actorId: auth.actorId,
|
|
802
919
|
clientId,
|
|
803
|
-
transportPath: readTransportPath(c),
|
|
804
|
-
statusCode: 200,
|
|
805
|
-
outcome: pushed.response.status,
|
|
806
|
-
responseStatus: normalizeResponseStatus(200, pushed.response.status),
|
|
807
|
-
durationMs: pushDurationMs,
|
|
808
|
-
errorCode: firstPushErrorCode(pushed.response.results),
|
|
809
|
-
commitSeq: pushed.response.commitSeq,
|
|
810
|
-
operationCount: pushOps.length,
|
|
811
|
-
tables: pushed.affectedTables,
|
|
812
|
-
payloadSnapshot: shouldCaptureRequestPayloadSnapshots
|
|
813
|
-
? {
|
|
814
|
-
request: {
|
|
815
|
-
clientId,
|
|
816
|
-
clientCommitId: pushBody.clientCommitId,
|
|
817
|
-
schemaVersion: pushBody.schemaVersion,
|
|
818
|
-
operations: pushBody.operations,
|
|
819
|
-
},
|
|
820
|
-
response: pushed.response,
|
|
821
|
-
}
|
|
822
|
-
: null,
|
|
823
|
-
}));
|
|
824
|
-
emitConsoleLiveEvent(consoleLiveEmitter, 'push', () => ({
|
|
825
920
|
partitionId,
|
|
826
921
|
requestId,
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
actorId: auth.actorId,
|
|
830
|
-
clientId,
|
|
831
|
-
transportPath: readTransportPath(c),
|
|
922
|
+
traceContext,
|
|
923
|
+
transportPath,
|
|
832
924
|
syncPath: 'http-combined',
|
|
833
|
-
|
|
834
|
-
statusCode: 200,
|
|
835
|
-
durationMs: pushDurationMs,
|
|
836
|
-
commitSeq: pushed.response.commitSeq ?? null,
|
|
837
|
-
operationCount: pushOps.length,
|
|
838
|
-
tables: pushed.affectedTables,
|
|
839
|
-
}));
|
|
840
|
-
// WS notifications
|
|
841
|
-
if (wsConnectionManager &&
|
|
842
|
-
pushed.response.ok === true &&
|
|
843
|
-
pushed.response.status === 'applied' &&
|
|
844
|
-
typeof pushed.response.commitSeq === 'number') {
|
|
845
|
-
const scopeKeys = applyPartitionToScopeKeys(partitionId, pushed.scopeKeys);
|
|
846
|
-
if (scopeKeys.length > 0) {
|
|
847
|
-
wsConnectionManager.notifyScopeKeys(scopeKeys, pushed.response.commitSeq, {
|
|
848
|
-
excludeClientIds: [clientId],
|
|
849
|
-
changes: pushed.emittedChanges,
|
|
850
|
-
actorId: pushed.commitActorId ?? auth.actorId,
|
|
851
|
-
createdAt: pushed.commitCreatedAt ?? new Date().toISOString(),
|
|
852
|
-
});
|
|
853
|
-
if (realtimeBroadcaster) {
|
|
854
|
-
realtimeBroadcaster
|
|
855
|
-
.publish({
|
|
856
|
-
type: 'commit',
|
|
857
|
-
commitSeq: pushed.response.commitSeq,
|
|
858
|
-
partitionId,
|
|
859
|
-
scopeKeys,
|
|
860
|
-
sourceInstanceId: instanceId,
|
|
861
|
-
})
|
|
862
|
-
.catch((error) => {
|
|
863
|
-
logAsyncFailureOnce('sync.realtime.broadcast_publish_failed', {
|
|
864
|
-
event: 'sync.realtime.broadcast_publish_failed',
|
|
865
|
-
userId: auth.actorId,
|
|
866
|
-
clientId,
|
|
867
|
-
error: error instanceof Error ? error.message : String(error),
|
|
868
|
-
});
|
|
869
|
-
});
|
|
870
|
-
}
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
|
-
if (pushed.response.ok === true &&
|
|
874
|
-
pushed.response.status === 'applied' &&
|
|
875
|
-
typeof pushed.response.commitSeq === 'number') {
|
|
876
|
-
emitConsoleLiveEvent(consoleLiveEmitter, 'commit', () => ({
|
|
877
|
-
partitionId,
|
|
878
|
-
commitSeq: pushed.response.commitSeq,
|
|
879
|
-
actorId: auth.actorId,
|
|
880
|
-
clientId,
|
|
881
|
-
affectedTables: pushed.affectedTables,
|
|
882
|
-
}));
|
|
883
|
-
}
|
|
925
|
+
}, pushBody);
|
|
884
926
|
pushResponse = pushed.response;
|
|
885
927
|
}
|
|
886
928
|
// --- Pull phase ---
|
|
@@ -1005,7 +1047,7 @@ export function createSyncRoutes(options) {
|
|
|
1005
1047
|
syncPath: 'http-combined',
|
|
1006
1048
|
actorId: auth.actorId,
|
|
1007
1049
|
clientId,
|
|
1008
|
-
transportPath
|
|
1050
|
+
transportPath,
|
|
1009
1051
|
statusCode: 200,
|
|
1010
1052
|
outcome: 'applied',
|
|
1011
1053
|
responseStatus: normalizeResponseStatus(200, 'applied'),
|
|
@@ -1027,7 +1069,7 @@ export function createSyncRoutes(options) {
|
|
|
1027
1069
|
spanId: traceContext.spanId,
|
|
1028
1070
|
actorId: auth.actorId,
|
|
1029
1071
|
clientId,
|
|
1030
|
-
transportPath
|
|
1072
|
+
transportPath,
|
|
1031
1073
|
syncPath: 'http-combined',
|
|
1032
1074
|
outcome: 'applied',
|
|
1033
1075
|
statusCode: 200,
|
|
@@ -1088,11 +1130,13 @@ export function createSyncRoutes(options) {
|
|
|
1088
1130
|
},
|
|
1089
1131
|
},
|
|
1090
1132
|
},
|
|
1091
|
-
}), zValidator('param', snapshotChunkParamsSchema), async (c) => {
|
|
1133
|
+
}), zValidator('param', snapshotChunkParamsSchema), zValidator('query', snapshotChunkQuerySchema), async (c) => {
|
|
1092
1134
|
const auth = await getAuth(c);
|
|
1093
1135
|
if (!auth)
|
|
1094
1136
|
return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
1095
1137
|
const partitionId = auth.partitionId ?? 'default';
|
|
1138
|
+
const query = c.req.valid('query');
|
|
1139
|
+
const requestedChunkScopes = readSnapshotScopeValues(c, query.scopes);
|
|
1096
1140
|
const { chunkId } = c.req.valid('param');
|
|
1097
1141
|
const chunk = await readSnapshotChunk(options.db, chunkId, {
|
|
1098
1142
|
chunkStorage: options.chunkStorage,
|
|
@@ -1106,9 +1150,38 @@ export function createSyncRoutes(options) {
|
|
|
1106
1150
|
if (chunk.expiresAt <= nowIso) {
|
|
1107
1151
|
return c.json({ error: 'NOT_FOUND' }, 404);
|
|
1108
1152
|
}
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1153
|
+
if (requestedChunkScopes) {
|
|
1154
|
+
try {
|
|
1155
|
+
const resolved = await resolveEffectiveScopesForSubscriptions({
|
|
1156
|
+
db: options.db,
|
|
1157
|
+
auth,
|
|
1158
|
+
subscriptions: [
|
|
1159
|
+
{
|
|
1160
|
+
id: 'snapshot-chunk-authz',
|
|
1161
|
+
table: chunk.scope,
|
|
1162
|
+
scopes: requestedChunkScopes,
|
|
1163
|
+
cursor: 0,
|
|
1164
|
+
},
|
|
1165
|
+
],
|
|
1166
|
+
handlers: handlerRegistry,
|
|
1167
|
+
scopeCache: options.scopeCache,
|
|
1168
|
+
});
|
|
1169
|
+
const scopeAuth = resolved[0];
|
|
1170
|
+
if (!scopeAuth || scopeAuth.status !== 'active') {
|
|
1171
|
+
return c.json({ error: 'FORBIDDEN' }, 403);
|
|
1172
|
+
}
|
|
1173
|
+
const scopeKey = `${partitionId}:${await scopesToSnapshotChunkScopeKey(scopeAuth.scopes)}`;
|
|
1174
|
+
if (scopeKey !== chunk.scopeKey) {
|
|
1175
|
+
return c.json({ error: 'FORBIDDEN' }, 403);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
catch (error) {
|
|
1179
|
+
if (error instanceof InvalidSubscriptionScopeError) {
|
|
1180
|
+
return c.json({ error: 'FORBIDDEN' }, 403);
|
|
1181
|
+
}
|
|
1182
|
+
throw error;
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1112
1185
|
const etag = `"sha256:${chunk.sha256}"`;
|
|
1113
1186
|
const ifNoneMatch = c.req.header('if-none-match');
|
|
1114
1187
|
if (ifNoneMatch && ifNoneMatch === etag) {
|
|
@@ -1145,6 +1218,9 @@ export function createSyncRoutes(options) {
|
|
|
1145
1218
|
const auth = await getAuth(c);
|
|
1146
1219
|
if (!auth)
|
|
1147
1220
|
return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
1221
|
+
if (!isWebSocketOriginAllowed(c, websocketConfig.allowedOrigins)) {
|
|
1222
|
+
return c.json({ error: 'FORBIDDEN_ORIGIN' }, 403);
|
|
1223
|
+
}
|
|
1148
1224
|
const partitionId = auth.partitionId ?? 'default';
|
|
1149
1225
|
const clientId = c.req.query('clientId');
|
|
1150
1226
|
if (!clientId || typeof clientId !== 'string') {
|
|
@@ -1184,6 +1260,11 @@ export function createSyncRoutes(options) {
|
|
|
1184
1260
|
}
|
|
1185
1261
|
const maxConnectionsTotal = websocketConfig.maxConnectionsTotal ?? 5000;
|
|
1186
1262
|
const maxConnectionsPerClient = websocketConfig.maxConnectionsPerClient ?? 3;
|
|
1263
|
+
const maxMessageBytes = websocketConfig.maxMessageBytes ?? 1024 * 1024;
|
|
1264
|
+
const maxMessagesPerWindow = websocketConfig.maxMessagesPerWindow ?? 120;
|
|
1265
|
+
const messageRateWindowMs = websocketConfig.messageRateWindowMs ?? 10000;
|
|
1266
|
+
let messageRateWindowStartedAtMs = Date.now();
|
|
1267
|
+
let messageRateWindowCount = 0;
|
|
1187
1268
|
if (maxConnectionsTotal > 0 &&
|
|
1188
1269
|
wsConnectionManager.getTotalConnections() >= maxConnectionsTotal) {
|
|
1189
1270
|
logSyncEvent({
|
|
@@ -1231,6 +1312,30 @@ export function createSyncRoutes(options) {
|
|
|
1231
1312
|
},
|
|
1232
1313
|
});
|
|
1233
1314
|
};
|
|
1315
|
+
const teardownRealtimeConnection = (args) => {
|
|
1316
|
+
unregister?.();
|
|
1317
|
+
unregister = null;
|
|
1318
|
+
connRef = null;
|
|
1319
|
+
finishRealtimeSession(args.reason);
|
|
1320
|
+
logSyncEvent({
|
|
1321
|
+
event: 'sync.realtime.disconnect',
|
|
1322
|
+
userId: auth.actorId,
|
|
1323
|
+
});
|
|
1324
|
+
emitConsoleLiveEvent(consoleLiveEmitter, 'client_update', () => ({
|
|
1325
|
+
action: args.action,
|
|
1326
|
+
actorId: auth.actorId,
|
|
1327
|
+
clientId,
|
|
1328
|
+
partitionId,
|
|
1329
|
+
}));
|
|
1330
|
+
};
|
|
1331
|
+
const logPresenceRejected = (scopeKey) => {
|
|
1332
|
+
logSyncEvent({
|
|
1333
|
+
event: 'sync.realtime.presence.rejected',
|
|
1334
|
+
userId: auth.actorId,
|
|
1335
|
+
reason: 'scope_not_authorized',
|
|
1336
|
+
scopeKey,
|
|
1337
|
+
});
|
|
1338
|
+
};
|
|
1234
1339
|
const upgradeWebSocket = websocketConfig.upgradeWebSocket;
|
|
1235
1340
|
if (!upgradeWebSocket) {
|
|
1236
1341
|
return c.json({ error: 'WEBSOCKET_NOT_CONFIGURED' }, 500);
|
|
@@ -1269,41 +1374,38 @@ export function createSyncRoutes(options) {
|
|
|
1269
1374
|
}));
|
|
1270
1375
|
},
|
|
1271
1376
|
onClose(_evt, _ws) {
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
connRef = null;
|
|
1275
|
-
finishRealtimeSession('closed');
|
|
1276
|
-
logSyncEvent({
|
|
1277
|
-
event: 'sync.realtime.disconnect',
|
|
1278
|
-
userId: auth.actorId,
|
|
1279
|
-
});
|
|
1280
|
-
emitConsoleLiveEvent(consoleLiveEmitter, 'client_update', () => ({
|
|
1377
|
+
teardownRealtimeConnection({
|
|
1378
|
+
reason: 'closed',
|
|
1281
1379
|
action: 'realtime_disconnected',
|
|
1282
|
-
|
|
1283
|
-
clientId,
|
|
1284
|
-
partitionId,
|
|
1285
|
-
}));
|
|
1380
|
+
});
|
|
1286
1381
|
},
|
|
1287
1382
|
onError(_evt, _ws) {
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
connRef = null;
|
|
1291
|
-
finishRealtimeSession('error');
|
|
1292
|
-
logSyncEvent({
|
|
1293
|
-
event: 'sync.realtime.disconnect',
|
|
1294
|
-
userId: auth.actorId,
|
|
1295
|
-
});
|
|
1296
|
-
emitConsoleLiveEvent(consoleLiveEmitter, 'client_update', () => ({
|
|
1383
|
+
teardownRealtimeConnection({
|
|
1384
|
+
reason: 'error',
|
|
1297
1385
|
action: 'realtime_error',
|
|
1298
|
-
|
|
1299
|
-
clientId,
|
|
1300
|
-
partitionId,
|
|
1301
|
-
}));
|
|
1386
|
+
});
|
|
1302
1387
|
},
|
|
1303
1388
|
onMessage(evt, _ws) {
|
|
1304
1389
|
if (!connRef)
|
|
1305
1390
|
return;
|
|
1306
1391
|
try {
|
|
1392
|
+
const messageBytes = measureWebSocketMessageBytes(evt.data);
|
|
1393
|
+
if (messageBytes > maxMessageBytes) {
|
|
1394
|
+
connRef.sendError(`WebSocket message exceeds max size (${maxMessageBytes} bytes)`);
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
if (maxMessagesPerWindow > 0 && messageRateWindowMs > 0) {
|
|
1398
|
+
const nowMs = Date.now();
|
|
1399
|
+
if (nowMs - messageRateWindowStartedAtMs >= messageRateWindowMs) {
|
|
1400
|
+
messageRateWindowStartedAtMs = nowMs;
|
|
1401
|
+
messageRateWindowCount = 0;
|
|
1402
|
+
}
|
|
1403
|
+
messageRateWindowCount += 1;
|
|
1404
|
+
if (messageRateWindowCount > maxMessagesPerWindow) {
|
|
1405
|
+
connRef.sendError(`WebSocket message rate exceeded (${maxMessagesPerWindow}/${messageRateWindowMs}ms)`);
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1307
1409
|
const raw = typeof evt.data === 'string' ? evt.data : String(evt.data);
|
|
1308
1410
|
const msg = JSON.parse(raw);
|
|
1309
1411
|
if (!msg || typeof msg !== 'object')
|
|
@@ -1320,12 +1422,7 @@ export function createSyncRoutes(options) {
|
|
|
1320
1422
|
switch (msg.action) {
|
|
1321
1423
|
case 'join':
|
|
1322
1424
|
if (!wsConnectionManager.joinPresence(clientId, scopeKey, msg.metadata)) {
|
|
1323
|
-
|
|
1324
|
-
event: 'sync.realtime.presence.rejected',
|
|
1325
|
-
userId: auth.actorId,
|
|
1326
|
-
reason: 'scope_not_authorized',
|
|
1327
|
-
scopeKey,
|
|
1328
|
-
});
|
|
1425
|
+
logPresenceRejected(scopeKey);
|
|
1329
1426
|
return;
|
|
1330
1427
|
}
|
|
1331
1428
|
// Send presence snapshot back to the joining client
|
|
@@ -1344,12 +1441,7 @@ export function createSyncRoutes(options) {
|
|
|
1344
1441
|
case 'update':
|
|
1345
1442
|
if (!wsConnectionManager.updatePresenceMetadata(clientId, scopeKey, msg.metadata ?? {}) &&
|
|
1346
1443
|
!wsConnectionManager.isClientSubscribedToScopeKey(clientId, scopeKey)) {
|
|
1347
|
-
|
|
1348
|
-
event: 'sync.realtime.presence.rejected',
|
|
1349
|
-
userId: auth.actorId,
|
|
1350
|
-
reason: 'scope_not_authorized',
|
|
1351
|
-
scopeKey,
|
|
1352
|
-
});
|
|
1444
|
+
logPresenceRejected(scopeKey);
|
|
1353
1445
|
}
|
|
1354
1446
|
break;
|
|
1355
1447
|
}
|
|
@@ -1361,7 +1453,6 @@ export function createSyncRoutes(options) {
|
|
|
1361
1453
|
});
|
|
1362
1454
|
});
|
|
1363
1455
|
}
|
|
1364
|
-
return routes;
|
|
1365
1456
|
async function handleRealtimeEvent(event) {
|
|
1366
1457
|
if (!wsConnectionManager)
|
|
1367
1458
|
return;
|
|
@@ -1378,6 +1469,42 @@ export function createSyncRoutes(options) {
|
|
|
1378
1469
|
return;
|
|
1379
1470
|
wsConnectionManager.notifyScopeKeys(scopeKeys, commitSeq);
|
|
1380
1471
|
}
|
|
1472
|
+
const recordWsPushFailure = (args) => {
|
|
1473
|
+
recordRequestEventInBackground(() => ({
|
|
1474
|
+
partitionId: args.partitionId,
|
|
1475
|
+
requestId: args.requestId,
|
|
1476
|
+
traceId: args.traceContext.traceId,
|
|
1477
|
+
spanId: args.traceContext.spanId,
|
|
1478
|
+
eventType: 'push',
|
|
1479
|
+
syncPath: 'ws-push',
|
|
1480
|
+
actorId: args.actorId,
|
|
1481
|
+
clientId: args.clientId,
|
|
1482
|
+
transportPath: args.transportPath,
|
|
1483
|
+
statusCode: args.statusCode,
|
|
1484
|
+
outcome: args.outcome,
|
|
1485
|
+
responseStatus: normalizeResponseStatus(args.statusCode, args.outcome),
|
|
1486
|
+
durationMs: args.durationMs,
|
|
1487
|
+
errorCode: args.errorCode,
|
|
1488
|
+
errorMessage: args.errorMessage,
|
|
1489
|
+
operationCount: args.operationCount ?? null,
|
|
1490
|
+
payloadSnapshot: args.payloadSnapshot ?? null,
|
|
1491
|
+
}));
|
|
1492
|
+
emitConsoleLiveEvent(consoleLiveEmitter, 'push', () => ({
|
|
1493
|
+
partitionId: args.partitionId,
|
|
1494
|
+
requestId: args.requestId,
|
|
1495
|
+
traceId: args.traceContext.traceId,
|
|
1496
|
+
spanId: args.traceContext.spanId,
|
|
1497
|
+
actorId: args.actorId,
|
|
1498
|
+
clientId: args.clientId,
|
|
1499
|
+
transportPath: args.transportPath,
|
|
1500
|
+
syncPath: 'ws-push',
|
|
1501
|
+
outcome: args.outcome,
|
|
1502
|
+
statusCode: args.statusCode,
|
|
1503
|
+
durationMs: args.durationMs,
|
|
1504
|
+
operationCount: args.operationCount ?? null,
|
|
1505
|
+
errorCode: args.errorCode,
|
|
1506
|
+
}));
|
|
1507
|
+
};
|
|
1381
1508
|
async function handleWsPush(msg, conn, auth, clientId) {
|
|
1382
1509
|
const actorId = auth.actorId;
|
|
1383
1510
|
const partitionId = auth.partitionId ?? 'default';
|
|
@@ -1391,30 +1518,25 @@ export function createSyncRoutes(options) {
|
|
|
1391
1518
|
const parsed = SyncPushRequestSchema.omit({ clientId: true }).safeParse(msg);
|
|
1392
1519
|
if (!parsed.success) {
|
|
1393
1520
|
const invalidDurationMs = timer();
|
|
1521
|
+
const errorMessage = 'Invalid push payload';
|
|
1394
1522
|
conn.sendPushResponse({
|
|
1395
1523
|
requestId,
|
|
1396
1524
|
ok: false,
|
|
1397
1525
|
status: 'rejected',
|
|
1398
|
-
results: [
|
|
1399
|
-
{ opIndex: 0, status: 'error', error: 'Invalid push payload' },
|
|
1400
|
-
],
|
|
1526
|
+
results: [{ opIndex: 0, status: 'error', error: errorMessage }],
|
|
1401
1527
|
});
|
|
1402
|
-
|
|
1528
|
+
recordWsPushFailure({
|
|
1403
1529
|
partitionId,
|
|
1404
1530
|
requestId,
|
|
1405
|
-
traceId: traceContext.traceId,
|
|
1406
|
-
spanId: traceContext.spanId,
|
|
1407
|
-
eventType: 'push',
|
|
1408
|
-
syncPath: 'ws-push',
|
|
1409
1531
|
actorId,
|
|
1410
1532
|
clientId,
|
|
1411
1533
|
transportPath: conn.transportPath,
|
|
1412
1534
|
statusCode: 400,
|
|
1413
1535
|
outcome: 'rejected',
|
|
1414
|
-
responseStatus: normalizeResponseStatus(400, 'rejected'),
|
|
1415
1536
|
durationMs: invalidDurationMs,
|
|
1416
1537
|
errorCode: 'INVALID_PUSH_PAYLOAD',
|
|
1417
|
-
errorMessage
|
|
1538
|
+
errorMessage,
|
|
1539
|
+
traceContext,
|
|
1418
1540
|
payloadSnapshot: shouldCaptureRequestPayloadSnapshots
|
|
1419
1541
|
? {
|
|
1420
1542
|
request: msg,
|
|
@@ -1425,26 +1547,13 @@ export function createSyncRoutes(options) {
|
|
|
1425
1547
|
},
|
|
1426
1548
|
}
|
|
1427
1549
|
: null,
|
|
1428
|
-
})
|
|
1429
|
-
emitConsoleLiveEvent(consoleLiveEmitter, 'push', () => ({
|
|
1430
|
-
partitionId,
|
|
1431
|
-
requestId,
|
|
1432
|
-
traceId: traceContext.traceId,
|
|
1433
|
-
spanId: traceContext.spanId,
|
|
1434
|
-
actorId,
|
|
1435
|
-
clientId,
|
|
1436
|
-
transportPath: conn.transportPath,
|
|
1437
|
-
syncPath: 'ws-push',
|
|
1438
|
-
outcome: 'rejected',
|
|
1439
|
-
statusCode: 400,
|
|
1440
|
-
durationMs: invalidDurationMs,
|
|
1441
|
-
errorCode: 'INVALID_PUSH_PAYLOAD',
|
|
1442
|
-
}));
|
|
1550
|
+
});
|
|
1443
1551
|
return;
|
|
1444
1552
|
}
|
|
1445
1553
|
const pushOps = parsed.data.operations ?? [];
|
|
1446
1554
|
if (pushOps.length > maxOperationsPerPush) {
|
|
1447
1555
|
const rejectedDurationMs = timer();
|
|
1556
|
+
const errorMessage = `Maximum ${maxOperationsPerPush} operations per push`;
|
|
1448
1557
|
conn.sendPushResponse({
|
|
1449
1558
|
requestId,
|
|
1450
1559
|
ok: false,
|
|
@@ -1453,26 +1562,22 @@ export function createSyncRoutes(options) {
|
|
|
1453
1562
|
{
|
|
1454
1563
|
opIndex: 0,
|
|
1455
1564
|
status: 'error',
|
|
1456
|
-
error:
|
|
1565
|
+
error: errorMessage,
|
|
1457
1566
|
},
|
|
1458
1567
|
],
|
|
1459
1568
|
});
|
|
1460
|
-
|
|
1569
|
+
recordWsPushFailure({
|
|
1461
1570
|
partitionId,
|
|
1462
1571
|
requestId,
|
|
1463
|
-
traceId: traceContext.traceId,
|
|
1464
|
-
spanId: traceContext.spanId,
|
|
1465
|
-
eventType: 'push',
|
|
1466
|
-
syncPath: 'ws-push',
|
|
1467
1572
|
actorId,
|
|
1468
1573
|
clientId,
|
|
1469
1574
|
transportPath: conn.transportPath,
|
|
1470
1575
|
statusCode: 400,
|
|
1471
1576
|
outcome: 'rejected',
|
|
1472
|
-
responseStatus: normalizeResponseStatus(400, 'rejected'),
|
|
1473
1577
|
durationMs: rejectedDurationMs,
|
|
1474
1578
|
errorCode: 'MAX_OPERATIONS_EXCEEDED',
|
|
1475
|
-
errorMessage
|
|
1579
|
+
errorMessage,
|
|
1580
|
+
traceContext,
|
|
1476
1581
|
operationCount: pushOps.length,
|
|
1477
1582
|
payloadSnapshot: shouldCaptureRequestPayloadSnapshots
|
|
1478
1583
|
? {
|
|
@@ -1489,145 +1594,22 @@ export function createSyncRoutes(options) {
|
|
|
1489
1594
|
},
|
|
1490
1595
|
}
|
|
1491
1596
|
: null,
|
|
1492
|
-
})
|
|
1493
|
-
emitConsoleLiveEvent(consoleLiveEmitter, 'push', () => ({
|
|
1494
|
-
partitionId,
|
|
1495
|
-
requestId,
|
|
1496
|
-
traceId: traceContext.traceId,
|
|
1497
|
-
spanId: traceContext.spanId,
|
|
1498
|
-
actorId,
|
|
1499
|
-
clientId,
|
|
1500
|
-
transportPath: conn.transportPath,
|
|
1501
|
-
syncPath: 'ws-push',
|
|
1502
|
-
outcome: 'rejected',
|
|
1503
|
-
statusCode: 400,
|
|
1504
|
-
durationMs: rejectedDurationMs,
|
|
1505
|
-
operationCount: pushOps.length,
|
|
1506
|
-
errorCode: 'MAX_OPERATIONS_EXCEEDED',
|
|
1507
|
-
}));
|
|
1597
|
+
});
|
|
1508
1598
|
return;
|
|
1509
1599
|
}
|
|
1510
|
-
const pushed = await
|
|
1511
|
-
db: options.db,
|
|
1512
|
-
dialect: options.dialect,
|
|
1513
|
-
handlers: handlerRegistry,
|
|
1514
|
-
plugins: options.plugins,
|
|
1600
|
+
const pushed = await executePushCommitWithSideEffects({
|
|
1515
1601
|
auth,
|
|
1516
|
-
request: {
|
|
1517
|
-
clientId,
|
|
1518
|
-
clientCommitId: parsed.data.clientCommitId,
|
|
1519
|
-
operations: parsed.data.operations,
|
|
1520
|
-
schemaVersion: parsed.data.schemaVersion,
|
|
1521
|
-
},
|
|
1522
|
-
});
|
|
1523
|
-
const pushDurationMs = timer();
|
|
1524
|
-
logSyncEvent({
|
|
1525
|
-
event: 'sync.push',
|
|
1526
|
-
userId: actorId,
|
|
1527
|
-
durationMs: pushDurationMs,
|
|
1528
|
-
operationCount: pushOps.length,
|
|
1529
|
-
status: pushed.response.status,
|
|
1530
|
-
commitSeq: pushed.response.commitSeq,
|
|
1531
|
-
});
|
|
1532
|
-
recordRequestEventInBackground(() => ({
|
|
1533
|
-
partitionId,
|
|
1534
|
-
requestId,
|
|
1535
|
-
traceId: traceContext.traceId,
|
|
1536
|
-
spanId: traceContext.spanId,
|
|
1537
|
-
eventType: 'push',
|
|
1538
|
-
syncPath: 'ws-push',
|
|
1539
|
-
actorId,
|
|
1540
1602
|
clientId,
|
|
1541
|
-
transportPath: conn.transportPath,
|
|
1542
|
-
statusCode: 200,
|
|
1543
|
-
outcome: pushed.response.status,
|
|
1544
|
-
responseStatus: normalizeResponseStatus(200, pushed.response.status),
|
|
1545
|
-
durationMs: pushDurationMs,
|
|
1546
|
-
errorCode: firstPushErrorCode(pushed.response.results),
|
|
1547
|
-
commitSeq: pushed.response.commitSeq,
|
|
1548
|
-
operationCount: pushOps.length,
|
|
1549
|
-
tables: pushed.affectedTables,
|
|
1550
|
-
payloadSnapshot: shouldCaptureRequestPayloadSnapshots
|
|
1551
|
-
? {
|
|
1552
|
-
request: {
|
|
1553
|
-
clientId,
|
|
1554
|
-
clientCommitId: parsed.data.clientCommitId,
|
|
1555
|
-
schemaVersion: parsed.data.schemaVersion,
|
|
1556
|
-
operations: parsed.data.operations,
|
|
1557
|
-
},
|
|
1558
|
-
response: pushed.response,
|
|
1559
|
-
}
|
|
1560
|
-
: null,
|
|
1561
|
-
}));
|
|
1562
|
-
emitConsoleLiveEvent(consoleLiveEmitter, 'push', () => ({
|
|
1563
1603
|
partitionId,
|
|
1564
1604
|
requestId,
|
|
1565
|
-
|
|
1566
|
-
spanId: traceContext.spanId,
|
|
1567
|
-
actorId,
|
|
1568
|
-
clientId,
|
|
1605
|
+
traceContext,
|
|
1569
1606
|
transportPath: conn.transportPath,
|
|
1570
1607
|
syncPath: 'ws-push',
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
tables: pushed.affectedTables,
|
|
1577
|
-
}));
|
|
1578
|
-
const detectedConflicts = pushed.response.results.reduce((count, result) => count + (result.status === 'conflict' ? 1 : 0), 0);
|
|
1579
|
-
if (detectedConflicts > 0) {
|
|
1580
|
-
countSyncMetric('sync.conflicts.detected', detectedConflicts, {
|
|
1581
|
-
attributes: {
|
|
1582
|
-
syncPath: 'ws-push',
|
|
1583
|
-
transportPath: conn.transportPath,
|
|
1584
|
-
},
|
|
1585
|
-
});
|
|
1586
|
-
}
|
|
1587
|
-
// WS notifications to other clients
|
|
1588
|
-
if (wsConnectionManager &&
|
|
1589
|
-
pushed.response.ok === true &&
|
|
1590
|
-
pushed.response.status === 'applied' &&
|
|
1591
|
-
typeof pushed.response.commitSeq === 'number') {
|
|
1592
|
-
const scopeKeys = applyPartitionToScopeKeys(partitionId, pushed.scopeKeys);
|
|
1593
|
-
if (scopeKeys.length > 0) {
|
|
1594
|
-
wsConnectionManager.notifyScopeKeys(scopeKeys, pushed.response.commitSeq, {
|
|
1595
|
-
excludeClientIds: [clientId],
|
|
1596
|
-
changes: pushed.emittedChanges,
|
|
1597
|
-
actorId: pushed.commitActorId ?? actorId,
|
|
1598
|
-
createdAt: pushed.commitCreatedAt ?? new Date().toISOString(),
|
|
1599
|
-
});
|
|
1600
|
-
if (realtimeBroadcaster) {
|
|
1601
|
-
realtimeBroadcaster
|
|
1602
|
-
.publish({
|
|
1603
|
-
type: 'commit',
|
|
1604
|
-
commitSeq: pushed.response.commitSeq,
|
|
1605
|
-
partitionId,
|
|
1606
|
-
scopeKeys,
|
|
1607
|
-
sourceInstanceId: instanceId,
|
|
1608
|
-
})
|
|
1609
|
-
.catch((error) => {
|
|
1610
|
-
logAsyncFailureOnce('sync.realtime.broadcast_publish_failed', {
|
|
1611
|
-
event: 'sync.realtime.broadcast_publish_failed',
|
|
1612
|
-
userId: actorId,
|
|
1613
|
-
clientId,
|
|
1614
|
-
error: error instanceof Error ? error.message : String(error),
|
|
1615
|
-
});
|
|
1616
|
-
});
|
|
1617
|
-
}
|
|
1618
|
-
}
|
|
1619
|
-
}
|
|
1620
|
-
if (pushed.response.ok === true &&
|
|
1621
|
-
pushed.response.status === 'applied' &&
|
|
1622
|
-
typeof pushed.response.commitSeq === 'number') {
|
|
1623
|
-
emitConsoleLiveEvent(consoleLiveEmitter, 'commit', () => ({
|
|
1624
|
-
partitionId,
|
|
1625
|
-
commitSeq: pushed.response.commitSeq,
|
|
1626
|
-
actorId,
|
|
1627
|
-
clientId,
|
|
1628
|
-
affectedTables: pushed.affectedTables,
|
|
1629
|
-
}));
|
|
1630
|
-
}
|
|
1608
|
+
}, {
|
|
1609
|
+
clientCommitId: parsed.data.clientCommitId,
|
|
1610
|
+
operations: parsed.data.operations,
|
|
1611
|
+
schemaVersion: parsed.data.schemaVersion,
|
|
1612
|
+
}, { countConflictsMetric: true });
|
|
1631
1613
|
triggerAutoMaintenance({
|
|
1632
1614
|
actorId,
|
|
1633
1615
|
clientId,
|
|
@@ -1651,22 +1633,18 @@ export function createSyncRoutes(options) {
|
|
|
1651
1633
|
partitionId,
|
|
1652
1634
|
});
|
|
1653
1635
|
const message = err instanceof Error ? err.message : 'Internal server error';
|
|
1654
|
-
|
|
1636
|
+
recordWsPushFailure({
|
|
1655
1637
|
partitionId,
|
|
1656
1638
|
requestId,
|
|
1657
|
-
traceId: traceContext.traceId,
|
|
1658
|
-
spanId: traceContext.spanId,
|
|
1659
|
-
eventType: 'push',
|
|
1660
|
-
syncPath: 'ws-push',
|
|
1661
1639
|
actorId,
|
|
1662
1640
|
clientId,
|
|
1663
1641
|
transportPath: conn.transportPath,
|
|
1664
1642
|
statusCode: 500,
|
|
1665
1643
|
outcome: 'error',
|
|
1666
|
-
responseStatus: normalizeResponseStatus(500, 'error'),
|
|
1667
1644
|
durationMs: failedDurationMs,
|
|
1668
1645
|
errorCode: 'INTERNAL_SERVER_ERROR',
|
|
1669
1646
|
errorMessage: message,
|
|
1647
|
+
traceContext,
|
|
1670
1648
|
payloadSnapshot: shouldCaptureRequestPayloadSnapshots
|
|
1671
1649
|
? {
|
|
1672
1650
|
request: msg,
|
|
@@ -1678,21 +1656,7 @@ export function createSyncRoutes(options) {
|
|
|
1678
1656
|
},
|
|
1679
1657
|
}
|
|
1680
1658
|
: null,
|
|
1681
|
-
})
|
|
1682
|
-
emitConsoleLiveEvent(consoleLiveEmitter, 'push', () => ({
|
|
1683
|
-
partitionId,
|
|
1684
|
-
requestId,
|
|
1685
|
-
traceId: traceContext.traceId,
|
|
1686
|
-
spanId: traceContext.spanId,
|
|
1687
|
-
actorId,
|
|
1688
|
-
clientId,
|
|
1689
|
-
transportPath: conn.transportPath,
|
|
1690
|
-
syncPath: 'ws-push',
|
|
1691
|
-
outcome: 'error',
|
|
1692
|
-
statusCode: 500,
|
|
1693
|
-
durationMs: failedDurationMs,
|
|
1694
|
-
errorCode: 'INTERNAL_SERVER_ERROR',
|
|
1695
|
-
}));
|
|
1659
|
+
});
|
|
1696
1660
|
conn.sendPushResponse({
|
|
1697
1661
|
requestId,
|
|
1698
1662
|
ok: false,
|
|
@@ -1701,6 +1665,7 @@ export function createSyncRoutes(options) {
|
|
|
1701
1665
|
});
|
|
1702
1666
|
}
|
|
1703
1667
|
}
|
|
1668
|
+
return routes;
|
|
1704
1669
|
}
|
|
1705
1670
|
export function getSyncWebSocketConnectionManager(routes) {
|
|
1706
1671
|
return wsConnectionManagerMap.get(routes);
|
|
@@ -1711,6 +1676,37 @@ export function getSyncRealtimeUnsubscribe(routes) {
|
|
|
1711
1676
|
function clampInt(value, min, max) {
|
|
1712
1677
|
return Math.max(min, Math.min(max, value));
|
|
1713
1678
|
}
|
|
1679
|
+
function isWebSocketOriginAllowed(c, allowedOrigins) {
|
|
1680
|
+
if (!allowedOrigins)
|
|
1681
|
+
return true;
|
|
1682
|
+
if (allowedOrigins === '*')
|
|
1683
|
+
return true;
|
|
1684
|
+
const origin = c.req.header('origin');
|
|
1685
|
+
if (!origin)
|
|
1686
|
+
return false;
|
|
1687
|
+
try {
|
|
1688
|
+
const normalizedOrigin = new URL(origin).origin;
|
|
1689
|
+
return allowedOrigins.includes(normalizedOrigin);
|
|
1690
|
+
}
|
|
1691
|
+
catch {
|
|
1692
|
+
return false;
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
function measureWebSocketMessageBytes(data) {
|
|
1696
|
+
if (typeof data === 'string') {
|
|
1697
|
+
return new TextEncoder().encode(data).byteLength;
|
|
1698
|
+
}
|
|
1699
|
+
if (data instanceof ArrayBuffer) {
|
|
1700
|
+
return data.byteLength;
|
|
1701
|
+
}
|
|
1702
|
+
if (ArrayBuffer.isView(data)) {
|
|
1703
|
+
return data.byteLength;
|
|
1704
|
+
}
|
|
1705
|
+
if (typeof Blob !== 'undefined' && data instanceof Blob) {
|
|
1706
|
+
return data.size;
|
|
1707
|
+
}
|
|
1708
|
+
return new TextEncoder().encode(String(data)).byteLength;
|
|
1709
|
+
}
|
|
1714
1710
|
function readTransportPath(c, queryValue) {
|
|
1715
1711
|
if (queryValue === 'relay' || queryValue === 'direct') {
|
|
1716
1712
|
return queryValue;
|
|
@@ -1721,16 +1717,6 @@ function readTransportPath(c, queryValue) {
|
|
|
1721
1717
|
}
|
|
1722
1718
|
return 'direct';
|
|
1723
1719
|
}
|
|
1724
|
-
function parseJsonColumn(value) {
|
|
1725
|
-
if (typeof value !== 'string')
|
|
1726
|
-
return value;
|
|
1727
|
-
try {
|
|
1728
|
-
return JSON.parse(value);
|
|
1729
|
-
}
|
|
1730
|
-
catch {
|
|
1731
|
-
return value;
|
|
1732
|
-
}
|
|
1733
|
-
}
|
|
1734
1720
|
function scopeValuesToScopeKeys(scopes) {
|
|
1735
1721
|
if (!scopes || typeof scopes !== 'object')
|
|
1736
1722
|
return [];
|