@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.
Files changed (50) hide show
  1. package/dist/blobs.d.ts +10 -4
  2. package/dist/blobs.d.ts.map +1 -1
  3. package/dist/blobs.js +260 -26
  4. package/dist/blobs.js.map +1 -1
  5. package/dist/console/gateway.d.ts +4 -0
  6. package/dist/console/gateway.d.ts.map +1 -1
  7. package/dist/console/gateway.js +97 -60
  8. package/dist/console/gateway.js.map +1 -1
  9. package/dist/console/route-descriptor.d.ts +6 -0
  10. package/dist/console/route-descriptor.d.ts.map +1 -0
  11. package/dist/console/route-descriptor.js +16 -0
  12. package/dist/console/route-descriptor.js.map +1 -0
  13. package/dist/console/routes.d.ts.map +1 -1
  14. package/dist/console/routes.js +153 -108
  15. package/dist/console/routes.js.map +1 -1
  16. package/dist/console/schema-errors.d.ts +2 -0
  17. package/dist/console/schema-errors.d.ts.map +1 -0
  18. package/dist/console/schema-errors.js +17 -0
  19. package/dist/console/schema-errors.js.map +1 -0
  20. package/dist/console/schemas.js +1 -1
  21. package/dist/console/schemas.js.map +1 -1
  22. package/dist/console/types.d.ts +32 -0
  23. package/dist/console/types.d.ts.map +1 -1
  24. package/dist/create-server.d.ts.map +1 -1
  25. package/dist/create-server.js +13 -10
  26. package/dist/create-server.js.map +1 -1
  27. package/dist/proxy/routes.d.ts +10 -0
  28. package/dist/proxy/routes.d.ts.map +1 -1
  29. package/dist/proxy/routes.js +57 -6
  30. package/dist/proxy/routes.js.map +1 -1
  31. package/dist/routes.d.ts +21 -0
  32. package/dist/routes.d.ts.map +1 -1
  33. package/dist/routes.js +338 -352
  34. package/dist/routes.js.map +1 -1
  35. package/package.json +7 -6
  36. package/src/__tests__/blob-routes.test.ts +286 -18
  37. package/src/__tests__/console-gateway-live-routes.test.ts +61 -1
  38. package/src/__tests__/console-routes.test.ts +30 -1
  39. package/src/__tests__/create-server.test.ts +237 -1
  40. package/src/__tests__/pull-chunk-storage.test.ts +98 -0
  41. package/src/blobs.ts +360 -34
  42. package/src/console/gateway.ts +335 -288
  43. package/src/console/route-descriptor.ts +22 -0
  44. package/src/console/routes.ts +327 -248
  45. package/src/console/schema-errors.ts +23 -0
  46. package/src/console/schemas.ts +1 -1
  47. package/src/console/types.ts +32 -0
  48. package/src/create-server.ts +13 -10
  49. package/src/proxy/routes.ts +73 -9
  50. 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: parseJsonColumn(change.row_json),
717
- scopes: parseJsonColumn(change.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 timer = createSyncTimer();
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
- traceId: traceContext.traceId,
828
- spanId: traceContext.spanId,
829
- actorId: auth.actorId,
830
- clientId,
831
- transportPath: readTransportPath(c),
922
+ traceContext,
923
+ transportPath,
832
924
  syncPath: 'http-combined',
833
- outcome: pushed.response.status,
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: readTransportPath(c),
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: readTransportPath(c),
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
- // Note: Snapshot chunks are created during authorized pull requests
1110
- // and have opaque IDs that expire. Additional authorization is handled
1111
- // at the pull layer via table-level resolveScopes.
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
- unregister?.();
1273
- unregister = null;
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
- actorId: auth.actorId,
1283
- clientId,
1284
- partitionId,
1285
- }));
1380
+ });
1286
1381
  },
1287
1382
  onError(_evt, _ws) {
1288
- unregister?.();
1289
- unregister = null;
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
- actorId: auth.actorId,
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
- logSyncEvent({
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
- logSyncEvent({
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
- recordRequestEventInBackground(() => ({
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: 'Invalid push payload',
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: `Maximum ${maxOperationsPerPush} operations per push`,
1565
+ error: errorMessage,
1457
1566
  },
1458
1567
  ],
1459
1568
  });
1460
- recordRequestEventInBackground(() => ({
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: `Maximum ${maxOperationsPerPush} operations per push`,
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 pushCommit({
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
- traceId: traceContext.traceId,
1566
- spanId: traceContext.spanId,
1567
- actorId,
1568
- clientId,
1605
+ traceContext,
1569
1606
  transportPath: conn.transportPath,
1570
1607
  syncPath: 'ws-push',
1571
- outcome: pushed.response.status,
1572
- statusCode: 200,
1573
- durationMs: pushDurationMs,
1574
- commitSeq: pushed.response.commitSeq ?? null,
1575
- operationCount: pushOps.length,
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
- recordRequestEventInBackground(() => ({
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 [];