@syncular/server-hono 0.0.4-26 → 0.0.6-101

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 (51) hide show
  1. package/dist/console/gateway.d.ts +3 -1
  2. package/dist/console/gateway.d.ts.map +1 -1
  3. package/dist/console/gateway.js +218 -41
  4. package/dist/console/gateway.js.map +1 -1
  5. package/dist/console/index.d.ts +1 -0
  6. package/dist/console/index.d.ts.map +1 -1
  7. package/dist/console/index.js +1 -0
  8. package/dist/console/index.js.map +1 -1
  9. package/dist/console/routes.d.ts +3 -97
  10. package/dist/console/routes.d.ts.map +1 -1
  11. package/dist/console/routes.js +507 -80
  12. package/dist/console/routes.js.map +1 -1
  13. package/dist/console/schemas.d.ts +29 -0
  14. package/dist/console/schemas.d.ts.map +1 -1
  15. package/dist/console/schemas.js +22 -0
  16. package/dist/console/schemas.js.map +1 -1
  17. package/dist/console/types.d.ts +175 -0
  18. package/dist/console/types.d.ts.map +1 -0
  19. package/dist/console/types.js +2 -0
  20. package/dist/console/types.js.map +1 -0
  21. package/dist/create-server.d.ts +17 -34
  22. package/dist/create-server.d.ts.map +1 -1
  23. package/dist/create-server.js +26 -26
  24. package/dist/create-server.js.map +1 -1
  25. package/dist/proxy/connection-manager.d.ts +3 -3
  26. package/dist/proxy/connection-manager.d.ts.map +1 -1
  27. package/dist/proxy/routes.d.ts +4 -4
  28. package/dist/proxy/routes.d.ts.map +1 -1
  29. package/dist/proxy/routes.js +1 -1
  30. package/dist/routes.d.ts +33 -9
  31. package/dist/routes.d.ts.map +1 -1
  32. package/dist/routes.js +153 -70
  33. package/dist/routes.js.map +1 -1
  34. package/package.json +21 -7
  35. package/src/__tests__/blob-routes.test.ts +424 -0
  36. package/src/__tests__/console-gateway-live-routes.test.ts +54 -3
  37. package/src/__tests__/console-routes.test.ts +161 -7
  38. package/src/__tests__/console-ui.test.ts +114 -0
  39. package/src/__tests__/create-server.test.ts +233 -10
  40. package/src/__tests__/pull-chunk-storage.test.ts +6 -2
  41. package/src/__tests__/realtime-bridge.test.ts +6 -2
  42. package/src/__tests__/sync-rate-limit-routing.test.ts +6 -2
  43. package/src/console/gateway.ts +277 -53
  44. package/src/console/index.ts +1 -0
  45. package/src/console/routes.ts +654 -198
  46. package/src/console/schemas.ts +29 -0
  47. package/src/console/types.ts +185 -0
  48. package/src/create-server.ts +56 -53
  49. package/src/proxy/connection-manager.ts +3 -3
  50. package/src/proxy/routes.ts +4 -4
  51. package/src/routes.ts +225 -96
package/src/routes.ts CHANGED
@@ -9,7 +9,9 @@
9
9
 
10
10
  import {
11
11
  captureSyncException,
12
+ countSyncMetric,
12
13
  createSyncTimer,
14
+ distributionSyncMetric,
13
15
  ErrorResponseSchema,
14
16
  logSyncEvent,
15
17
  SyncCombinedRequestSchema,
@@ -17,15 +19,19 @@ import {
17
19
  SyncPushRequestSchema,
18
20
  } from '@syncular/core';
19
21
  import type {
22
+ ScopeCacheBackend,
20
23
  ServerSyncDialect,
21
24
  ServerTableHandler,
22
25
  SnapshotChunkStorage,
26
+ SqlFamily,
23
27
  SyncCoreDb,
24
28
  SyncRealtimeBroadcaster,
25
29
  SyncRealtimeEvent,
30
+ SyncServerAuth,
26
31
  } from '@syncular/server';
27
32
  import {
28
33
  type CompactOptions,
34
+ createServerHandlerCollection,
29
35
  InvalidSubscriptionScopeError,
30
36
  type PruneOptions,
31
37
  type PullResult,
@@ -33,7 +39,6 @@ import {
33
39
  pushCommit,
34
40
  readSnapshotChunk,
35
41
  recordClientCursor,
36
- TableRegistry,
37
42
  } from '@syncular/server';
38
43
  import type { Context, MiddlewareHandler } from 'hono';
39
44
  import { Hono } from 'hono';
@@ -64,10 +69,7 @@ import {
64
69
  const wsConnectionManagerMap = new WeakMap<Hono, WebSocketConnectionManager>();
65
70
  const realtimeUnsubscribeMap = new WeakMap<Hono, () => void>();
66
71
 
67
- export interface SyncAuthResult {
68
- actorId: string;
69
- partitionId?: string;
70
- }
72
+ export interface SyncAuthResult extends SyncServerAuth {}
71
73
 
72
74
  /**
73
75
  * WebSocket configuration for realtime sync.
@@ -119,6 +121,22 @@ export interface SyncRoutesConfigWithRateLimit {
119
121
  * Default: 200
120
122
  */
121
123
  maxOperationsPerPush?: number;
124
+ /**
125
+ * Request/response payload snapshots recorded for console inspection.
126
+ */
127
+ requestPayloadSnapshots?: {
128
+ /**
129
+ * Enable payload snapshot storage in `sync_request_payloads`.
130
+ * Default: true when console event recording is enabled.
131
+ */
132
+ enabled?: boolean;
133
+ /**
134
+ * Max serialized payload size in bytes per request/response snapshot.
135
+ * Larger payloads are truncated with metadata.
136
+ * Default: 128 KiB.
137
+ */
138
+ maxBytes?: number;
139
+ };
122
140
  /**
123
141
  * Rate limiting configuration.
124
142
  * Set to false to disable all rate limiting.
@@ -162,11 +180,15 @@ export interface SyncRoutesConfigWithRateLimit {
162
180
  };
163
181
  }
164
182
 
165
- export interface CreateSyncRoutesOptions<DB extends SyncCoreDb = SyncCoreDb> {
183
+ export interface CreateSyncRoutesOptions<
184
+ DB extends SyncCoreDb = SyncCoreDb,
185
+ Auth extends SyncAuthResult = SyncAuthResult,
186
+ F extends SqlFamily = SqlFamily,
187
+ > {
166
188
  db: Kysely<DB>;
167
- dialect: ServerSyncDialect;
168
- handlers: ServerTableHandler<DB>[];
169
- authenticate: (c: Context) => Promise<SyncAuthResult | null>;
189
+ dialect: ServerSyncDialect<F>;
190
+ handlers: ServerTableHandler<DB, Auth>[];
191
+ authenticate: (c: Context) => Promise<Auth | null>;
170
192
  sync?: SyncRoutesConfigWithRateLimit;
171
193
  wsConnectionManager?: WebSocketConnectionManager;
172
194
  /**
@@ -175,6 +197,11 @@ export interface CreateSyncRoutesOptions<DB extends SyncCoreDb = SyncCoreDb> {
175
197
  * (S3, R2, etc.) instead of inline in the database.
176
198
  */
177
199
  chunkStorage?: SnapshotChunkStorage;
200
+ /**
201
+ * Optional scope cache backend for resolveScopes() results.
202
+ * Request-local memoization is always applied for every pull.
203
+ */
204
+ scopeCache?: ScopeCacheBackend;
178
205
  /**
179
206
  * Optional live emitter for console websocket activity feed.
180
207
  * When provided, sync lifecycle events are published to `/console/events/live`.
@@ -186,6 +213,11 @@ export interface CreateSyncRoutesOptions<DB extends SyncCoreDb = SyncCoreDb> {
186
213
  data: Record<string, unknown>;
187
214
  }): void;
188
215
  };
216
+ /**
217
+ * Optional console schema readiness promise.
218
+ * When provided, request-event recording waits for this promise before writing.
219
+ */
220
+ consoleSchemaReady?: Promise<void>;
189
221
  }
190
222
 
191
223
  // ============================================================================
@@ -196,7 +228,7 @@ const snapshotChunkParamsSchema = z.object({
196
228
  chunkId: z.string().min(1),
197
229
  });
198
230
 
199
- const MAX_REQUEST_PAYLOAD_SNAPSHOT_BYTES = 128 * 1024;
231
+ const DEFAULT_REQUEST_PAYLOAD_SNAPSHOT_MAX_BYTES = 128 * 1024;
200
232
 
201
233
  type TraceContext = {
202
234
  traceId: string | null;
@@ -243,6 +275,19 @@ function parseSentryTraceHeader(
243
275
  return { traceId, spanId };
244
276
  }
245
277
 
278
+ function readPositiveInteger(
279
+ value: number | undefined,
280
+ fallback: number
281
+ ): number {
282
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
283
+ return fallback;
284
+ }
285
+ if (value <= 0) {
286
+ return fallback;
287
+ }
288
+ return Math.floor(value);
289
+ }
290
+
246
291
  function readTraceContext(c: Context): TraceContext {
247
292
  const traceparent = parseW3cTraceparent(c.req.header('traceparent'));
248
293
  if (traceparent) return traceparent;
@@ -390,16 +435,16 @@ function countPullRows(response: PullResult['response']): number {
390
435
  }, 0);
391
436
  }
392
437
 
393
- function encodePayloadSnapshot(value: unknown): string {
438
+ function encodePayloadSnapshot(value: unknown, maxBytes: number): string {
394
439
  try {
395
440
  const serialized = JSON.stringify(value);
396
- if (serialized.length <= MAX_REQUEST_PAYLOAD_SNAPSHOT_BYTES) {
441
+ if (serialized.length <= maxBytes) {
397
442
  return serialized;
398
443
  }
399
444
  return JSON.stringify({
400
445
  truncated: true,
401
446
  originalSizeBytes: serialized.length,
402
- preview: serialized.slice(0, MAX_REQUEST_PAYLOAD_SNAPSHOT_BYTES),
447
+ preview: serialized.slice(0, maxBytes),
403
448
  });
404
449
  } catch {
405
450
  return JSON.stringify({
@@ -430,9 +475,11 @@ function emitConsoleLiveEvent(
430
475
  });
431
476
  }
432
477
 
433
- export function createSyncRoutes<DB extends SyncCoreDb = SyncCoreDb>(
434
- options: CreateSyncRoutesOptions<DB>
435
- ): Hono {
478
+ export function createSyncRoutes<
479
+ DB extends SyncCoreDb = SyncCoreDb,
480
+ Auth extends SyncAuthResult = SyncAuthResult,
481
+ F extends SqlFamily = SqlFamily,
482
+ >(options: CreateSyncRoutesOptions<DB, Auth, F>): Hono {
436
483
  const routes = new Hono();
437
484
  routes.onError((error, c) => {
438
485
  captureSyncException(error, {
@@ -442,10 +489,7 @@ export function createSyncRoutes<DB extends SyncCoreDb = SyncCoreDb>(
442
489
  });
443
490
  return c.text('Internal Server Error', 500);
444
491
  });
445
- const handlerRegistry = new TableRegistry<DB>();
446
- for (const handler of options.handlers) {
447
- handlerRegistry.register(handler);
448
- }
492
+ const handlerRegistry = createServerHandlerCollection(options.handlers);
449
493
  const config = options.sync ?? {};
450
494
  const maxPullLimitCommits = config.maxPullLimitCommits ?? 100;
451
495
  const maxSubscriptionsPerPull = config.maxSubscriptionsPerPull ?? 200;
@@ -455,6 +499,25 @@ export function createSyncRoutes<DB extends SyncCoreDb = SyncCoreDb>(
455
499
  const consoleLiveEmitter = options.consoleLiveEmitter;
456
500
  const shouldEmitConsoleLiveEvents = consoleLiveEmitter !== undefined;
457
501
  const shouldRecordRequestEvents = shouldEmitConsoleLiveEvents;
502
+ const shouldCaptureRequestPayloadSnapshots =
503
+ shouldRecordRequestEvents &&
504
+ config.requestPayloadSnapshots?.enabled !== false;
505
+ const requestPayloadSnapshotMaxBytes = readPositiveInteger(
506
+ config.requestPayloadSnapshots?.maxBytes,
507
+ DEFAULT_REQUEST_PAYLOAD_SNAPSHOT_MAX_BYTES
508
+ );
509
+ const consoleSchemaReadyBase = shouldRecordRequestEvents
510
+ ? (options.consoleSchemaReady ??
511
+ options.dialect.ensureConsoleSchema?.(options.db) ??
512
+ Promise.resolve())
513
+ : Promise.resolve();
514
+ const consoleSchemaReady = consoleSchemaReadyBase.catch((error) => {
515
+ logSyncEvent({
516
+ event: 'sync.console_schema_ready_failed',
517
+ error: error instanceof Error ? error.message : String(error),
518
+ });
519
+ throw error;
520
+ });
458
521
 
459
522
  // -------------------------------------------------------------------------
460
523
  // Optional WebSocket manager (scope-key based wake-ups)
@@ -565,8 +628,14 @@ export function createSyncRoutes<DB extends SyncCoreDb = SyncCoreDb>(
565
628
  payload_ref, partition_id, request_payload, response_payload, created_at
566
629
  ) VALUES (
567
630
  ${nextPayloadRef}, ${event.partitionId},
568
- ${encodePayloadSnapshot(event.payloadSnapshot.request)},
569
- ${encodePayloadSnapshot(event.payloadSnapshot.response)},
631
+ ${encodePayloadSnapshot(
632
+ event.payloadSnapshot.request,
633
+ requestPayloadSnapshotMaxBytes
634
+ )},
635
+ ${encodePayloadSnapshot(
636
+ event.payloadSnapshot.response,
637
+ requestPayloadSnapshotMaxBytes
638
+ )},
570
639
  ${nowIso}
571
640
  )
572
641
  ON CONFLICT (payload_ref) DO UPDATE SET
@@ -620,19 +689,21 @@ export function createSyncRoutes<DB extends SyncCoreDb = SyncCoreDb>(
620
689
 
621
690
  const resolvedEvent = typeof event === 'function' ? event() : event;
622
691
 
623
- void recordRequestEvent(resolvedEvent).catch((error) => {
624
- logAsyncFailureOnce('sync.request_event_record_failed', {
625
- event: 'sync.request_event_record_failed',
626
- userId: resolvedEvent.actorId,
627
- clientId: resolvedEvent.clientId,
628
- requestEventType: resolvedEvent.eventType,
629
- error: error instanceof Error ? error.message : String(error),
692
+ void consoleSchemaReady
693
+ .then(() => recordRequestEvent(resolvedEvent))
694
+ .catch((error) => {
695
+ logAsyncFailureOnce('sync.request_event_record_failed', {
696
+ event: 'sync.request_event_record_failed',
697
+ userId: resolvedEvent.actorId,
698
+ clientId: resolvedEvent.clientId,
699
+ requestEventType: resolvedEvent.eventType,
700
+ error: error instanceof Error ? error.message : String(error),
701
+ });
630
702
  });
631
- });
632
703
  };
633
704
 
634
- const authCache = new WeakMap<Context, Promise<SyncAuthResult | null>>();
635
- const getAuth = (c: Context): Promise<SyncAuthResult | null> => {
705
+ const authCache = new WeakMap<Context, Promise<Auth | null>>();
706
+ const getAuth = (c: Context): Promise<Auth | null> => {
636
707
  const cached = authCache.get(c);
637
708
  if (cached) return cached;
638
709
  const pending = options.authenticate(c);
@@ -787,8 +858,7 @@ export function createSyncRoutes<DB extends SyncCoreDb = SyncCoreDb>(
787
858
  db: options.db,
788
859
  dialect: options.dialect,
789
860
  handlers: handlerRegistry,
790
- actorId: auth.actorId,
791
- partitionId,
861
+ auth,
792
862
  request: {
793
863
  clientId,
794
864
  clientCommitId: pushBody.clientCommitId,
@@ -826,15 +896,17 @@ export function createSyncRoutes<DB extends SyncCoreDb = SyncCoreDb>(
826
896
  commitSeq: pushed.response.commitSeq,
827
897
  operationCount: pushOps.length,
828
898
  tables: pushed.affectedTables,
829
- payloadSnapshot: {
830
- request: {
831
- clientId,
832
- clientCommitId: pushBody.clientCommitId,
833
- schemaVersion: pushBody.schemaVersion,
834
- operations: pushBody.operations,
835
- },
836
- response: pushed.response,
837
- },
899
+ payloadSnapshot: shouldCaptureRequestPayloadSnapshots
900
+ ? {
901
+ request: {
902
+ clientId,
903
+ clientCommitId: pushBody.clientCommitId,
904
+ schemaVersion: pushBody.schemaVersion,
905
+ operations: pushBody.operations,
906
+ },
907
+ response: pushed.response,
908
+ }
909
+ : null,
838
910
  }));
839
911
  emitConsoleLiveEvent(consoleLiveEmitter, 'push', () => ({
840
912
  partitionId,
@@ -979,10 +1051,10 @@ export function createSyncRoutes<DB extends SyncCoreDb = SyncCoreDb>(
979
1051
  db: options.db,
980
1052
  dialect: options.dialect,
981
1053
  handlers: handlerRegistry,
982
- actorId: auth.actorId,
983
- partitionId,
1054
+ auth,
984
1055
  request,
985
1056
  chunkStorage: options.chunkStorage,
1057
+ scopeCache: options.scopeCache,
986
1058
  });
987
1059
  } catch (err) {
988
1060
  if (err instanceof InvalidSubscriptionScopeError) {
@@ -1045,7 +1117,7 @@ export function createSyncRoutes<DB extends SyncCoreDb = SyncCoreDb>(
1045
1117
  const scopesSummary = shouldRecordRequestEvents
1046
1118
  ? summarizeScopeValues(pullResult.effectiveScopes)
1047
1119
  : null;
1048
- const payloadSnapshot = shouldRecordRequestEvents
1120
+ const payloadSnapshot = shouldCaptureRequestPayloadSnapshots
1049
1121
  ? {
1050
1122
  request: {
1051
1123
  clientId,
@@ -1318,6 +1390,32 @@ export function createSyncRoutes<DB extends SyncCoreDb = SyncCoreDb>(
1318
1390
 
1319
1391
  let unregister: (() => void) | null = null;
1320
1392
  let connRef: ReturnType<typeof createWebSocketConnection> | null = null;
1393
+ const connectionCountBeforeUpgrade =
1394
+ wsConnectionManager.getConnectionCount(clientId);
1395
+ let sessionStartedAtMs: number | null = null;
1396
+ let sessionEnded = false;
1397
+
1398
+ const finishRealtimeSession = (reason: 'closed' | 'error') => {
1399
+ if (sessionEnded) return;
1400
+ sessionEnded = true;
1401
+ if (sessionStartedAtMs === null) {
1402
+ return;
1403
+ }
1404
+ const durationMs = Math.max(0, Date.now() - sessionStartedAtMs);
1405
+ countSyncMetric('sync.sessions.ended', 1, {
1406
+ attributes: {
1407
+ transportPath: realtimeTransportPath,
1408
+ reason,
1409
+ },
1410
+ });
1411
+ distributionSyncMetric('sync.sessions.duration_ms', durationMs, {
1412
+ unit: 'millisecond',
1413
+ attributes: {
1414
+ transportPath: realtimeTransportPath,
1415
+ reason,
1416
+ },
1417
+ });
1418
+ };
1321
1419
 
1322
1420
  const upgradeWebSocket = websocketConfig.upgradeWebSocket;
1323
1421
  if (!upgradeWebSocket) {
@@ -1332,6 +1430,20 @@ export function createSyncRoutes<DB extends SyncCoreDb = SyncCoreDb>(
1332
1430
  transportPath: realtimeTransportPath,
1333
1431
  });
1334
1432
  connRef = conn;
1433
+ sessionStartedAtMs = Date.now();
1434
+ countSyncMetric('sync.sessions.started', 1, {
1435
+ attributes: {
1436
+ transportPath: realtimeTransportPath,
1437
+ },
1438
+ });
1439
+ if (connectionCountBeforeUpgrade > 0) {
1440
+ countSyncMetric('sync.transport.reconnects', 1, {
1441
+ attributes: {
1442
+ transportPath: realtimeTransportPath,
1443
+ source: 'server',
1444
+ },
1445
+ });
1446
+ }
1335
1447
 
1336
1448
  unregister = wsConnectionManager.register(conn, initialScopeKeys);
1337
1449
  conn.sendHeartbeat();
@@ -1348,6 +1460,7 @@ export function createSyncRoutes<DB extends SyncCoreDb = SyncCoreDb>(
1348
1460
  unregister?.();
1349
1461
  unregister = null;
1350
1462
  connRef = null;
1463
+ finishRealtimeSession('closed');
1351
1464
  logSyncEvent({
1352
1465
  event: 'sync.realtime.disconnect',
1353
1466
  userId: auth.actorId,
@@ -1363,6 +1476,7 @@ export function createSyncRoutes<DB extends SyncCoreDb = SyncCoreDb>(
1363
1476
  unregister?.();
1364
1477
  unregister = null;
1365
1478
  connRef = null;
1479
+ finishRealtimeSession('error');
1366
1480
  logSyncEvent({
1367
1481
  event: 'sync.realtime.disconnect',
1368
1482
  userId: auth.actorId,
@@ -1383,13 +1497,7 @@ export function createSyncRoutes<DB extends SyncCoreDb = SyncCoreDb>(
1383
1497
  if (!msg || typeof msg !== 'object') return;
1384
1498
 
1385
1499
  if (msg.type === 'push') {
1386
- void handleWsPush(
1387
- msg,
1388
- connRef,
1389
- auth.actorId,
1390
- partitionId,
1391
- clientId
1392
- );
1500
+ void handleWsPush(msg, connRef, auth, clientId);
1393
1501
  return;
1394
1502
  }
1395
1503
 
@@ -1481,10 +1589,11 @@ export function createSyncRoutes<DB extends SyncCoreDb = SyncCoreDb>(
1481
1589
  async function handleWsPush(
1482
1590
  msg: Record<string, unknown>,
1483
1591
  conn: WebSocketConnection,
1484
- actorId: string,
1485
- partitionId: string,
1592
+ auth: Auth,
1486
1593
  clientId: string
1487
1594
  ): Promise<void> {
1595
+ const actorId = auth.actorId;
1596
+ const partitionId = auth.partitionId ?? 'default';
1488
1597
  const requestId = typeof msg.requestId === 'string' ? msg.requestId : '';
1489
1598
  if (!requestId) return;
1490
1599
  const traceContext = readTraceContextFromMessage(msg);
@@ -1521,14 +1630,16 @@ export function createSyncRoutes<DB extends SyncCoreDb = SyncCoreDb>(
1521
1630
  durationMs: invalidDurationMs,
1522
1631
  errorCode: 'INVALID_PUSH_PAYLOAD',
1523
1632
  errorMessage: 'Invalid push payload',
1524
- payloadSnapshot: {
1525
- request: msg,
1526
- response: {
1527
- ok: false,
1528
- status: 'rejected',
1529
- reason: 'invalid_push_payload',
1530
- },
1531
- },
1633
+ payloadSnapshot: shouldCaptureRequestPayloadSnapshots
1634
+ ? {
1635
+ request: msg,
1636
+ response: {
1637
+ ok: false,
1638
+ status: 'rejected',
1639
+ reason: 'invalid_push_payload',
1640
+ },
1641
+ }
1642
+ : null,
1532
1643
  }));
1533
1644
  emitConsoleLiveEvent(consoleLiveEmitter, 'push', () => ({
1534
1645
  partitionId,
@@ -1579,19 +1690,21 @@ export function createSyncRoutes<DB extends SyncCoreDb = SyncCoreDb>(
1579
1690
  errorCode: 'MAX_OPERATIONS_EXCEEDED',
1580
1691
  errorMessage: `Maximum ${maxOperationsPerPush} operations per push`,
1581
1692
  operationCount: pushOps.length,
1582
- payloadSnapshot: {
1583
- request: {
1584
- clientId,
1585
- clientCommitId: parsed.data.clientCommitId,
1586
- schemaVersion: parsed.data.schemaVersion,
1587
- operations: parsed.data.operations,
1588
- },
1589
- response: {
1590
- ok: false,
1591
- status: 'rejected',
1592
- reason: 'max_operations_exceeded',
1593
- },
1594
- },
1693
+ payloadSnapshot: shouldCaptureRequestPayloadSnapshots
1694
+ ? {
1695
+ request: {
1696
+ clientId,
1697
+ clientCommitId: parsed.data.clientCommitId,
1698
+ schemaVersion: parsed.data.schemaVersion,
1699
+ operations: parsed.data.operations,
1700
+ },
1701
+ response: {
1702
+ ok: false,
1703
+ status: 'rejected',
1704
+ reason: 'max_operations_exceeded',
1705
+ },
1706
+ }
1707
+ : null,
1595
1708
  }));
1596
1709
  emitConsoleLiveEvent(consoleLiveEmitter, 'push', () => ({
1597
1710
  partitionId,
@@ -1615,8 +1728,7 @@ export function createSyncRoutes<DB extends SyncCoreDb = SyncCoreDb>(
1615
1728
  db: options.db,
1616
1729
  dialect: options.dialect,
1617
1730
  handlers: handlerRegistry,
1618
- actorId,
1619
- partitionId,
1731
+ auth,
1620
1732
  request: {
1621
1733
  clientId,
1622
1734
  clientCommitId: parsed.data.clientCommitId,
@@ -1654,15 +1766,17 @@ export function createSyncRoutes<DB extends SyncCoreDb = SyncCoreDb>(
1654
1766
  commitSeq: pushed.response.commitSeq,
1655
1767
  operationCount: pushOps.length,
1656
1768
  tables: pushed.affectedTables,
1657
- payloadSnapshot: {
1658
- request: {
1659
- clientId,
1660
- clientCommitId: parsed.data.clientCommitId,
1661
- schemaVersion: parsed.data.schemaVersion,
1662
- operations: parsed.data.operations,
1663
- },
1664
- response: pushed.response,
1665
- },
1769
+ payloadSnapshot: shouldCaptureRequestPayloadSnapshots
1770
+ ? {
1771
+ request: {
1772
+ clientId,
1773
+ clientCommitId: parsed.data.clientCommitId,
1774
+ schemaVersion: parsed.data.schemaVersion,
1775
+ operations: parsed.data.operations,
1776
+ },
1777
+ response: pushed.response,
1778
+ }
1779
+ : null,
1666
1780
  }));
1667
1781
  emitConsoleLiveEvent(consoleLiveEmitter, 'push', () => ({
1668
1782
  partitionId,
@@ -1681,6 +1795,19 @@ export function createSyncRoutes<DB extends SyncCoreDb = SyncCoreDb>(
1681
1795
  tables: pushed.affectedTables,
1682
1796
  }));
1683
1797
 
1798
+ const detectedConflicts = pushed.response.results.reduce(
1799
+ (count, result) => count + (result.status === 'conflict' ? 1 : 0),
1800
+ 0
1801
+ );
1802
+ if (detectedConflicts > 0) {
1803
+ countSyncMetric('sync.conflicts.detected', detectedConflicts, {
1804
+ attributes: {
1805
+ syncPath: 'ws-push',
1806
+ transportPath: conn.transportPath,
1807
+ },
1808
+ });
1809
+ }
1810
+
1684
1811
  // WS notifications to other clients
1685
1812
  if (
1686
1813
  wsConnectionManager &&
@@ -1771,15 +1898,17 @@ export function createSyncRoutes<DB extends SyncCoreDb = SyncCoreDb>(
1771
1898
  durationMs: failedDurationMs,
1772
1899
  errorCode: 'INTERNAL_SERVER_ERROR',
1773
1900
  errorMessage: message,
1774
- payloadSnapshot: {
1775
- request: msg,
1776
- response: {
1777
- ok: false,
1778
- status: 'rejected',
1779
- reason: 'internal_server_error',
1780
- message,
1781
- },
1782
- },
1901
+ payloadSnapshot: shouldCaptureRequestPayloadSnapshots
1902
+ ? {
1903
+ request: msg,
1904
+ response: {
1905
+ ok: false,
1906
+ status: 'rejected',
1907
+ reason: 'internal_server_error',
1908
+ message,
1909
+ },
1910
+ }
1911
+ : null,
1783
1912
  }));
1784
1913
  emitConsoleLiveEvent(consoleLiveEmitter, 'push', () => ({
1785
1914
  partitionId,