@syncular/server-hono 0.0.6-95 → 0.2.0

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 (104) hide show
  1. package/README.md +5 -23
  2. package/dist/admin-page.d.ts +14 -0
  3. package/dist/admin-page.js +217 -0
  4. package/dist/admin.d.ts +39 -0
  5. package/dist/admin.js +142 -0
  6. package/dist/index.d.ts +16 -14
  7. package/dist/index.js +134 -23
  8. package/package.json +19 -53
  9. package/src/admin-page.ts +217 -0
  10. package/src/admin.ts +193 -0
  11. package/src/index.ts +175 -31
  12. package/dist/api-key-auth.d.ts +0 -49
  13. package/dist/api-key-auth.d.ts.map +0 -1
  14. package/dist/api-key-auth.js +0 -110
  15. package/dist/api-key-auth.js.map +0 -1
  16. package/dist/blobs.d.ts +0 -69
  17. package/dist/blobs.d.ts.map +0 -1
  18. package/dist/blobs.js +0 -383
  19. package/dist/blobs.js.map +0 -1
  20. package/dist/console/gateway.d.ts +0 -33
  21. package/dist/console/gateway.d.ts.map +0 -1
  22. package/dist/console/gateway.js +0 -2534
  23. package/dist/console/gateway.js.map +0 -1
  24. package/dist/console/index.d.ts +0 -11
  25. package/dist/console/index.d.ts.map +0 -1
  26. package/dist/console/index.js +0 -11
  27. package/dist/console/index.js.map +0 -1
  28. package/dist/console/routes.d.ts +0 -33
  29. package/dist/console/routes.d.ts.map +0 -1
  30. package/dist/console/routes.js +0 -2890
  31. package/dist/console/routes.js.map +0 -1
  32. package/dist/console/schemas.d.ts +0 -490
  33. package/dist/console/schemas.d.ts.map +0 -1
  34. package/dist/console/schemas.js +0 -303
  35. package/dist/console/schemas.js.map +0 -1
  36. package/dist/console/types.d.ts +0 -142
  37. package/dist/console/types.d.ts.map +0 -1
  38. package/dist/console/types.js +0 -2
  39. package/dist/console/types.js.map +0 -1
  40. package/dist/console/ui.d.ts +0 -38
  41. package/dist/console/ui.d.ts.map +0 -1
  42. package/dist/console/ui.js +0 -43
  43. package/dist/console/ui.js.map +0 -1
  44. package/dist/create-server.d.ts +0 -68
  45. package/dist/create-server.d.ts.map +0 -1
  46. package/dist/create-server.js +0 -109
  47. package/dist/create-server.js.map +0 -1
  48. package/dist/index.d.ts.map +0 -1
  49. package/dist/index.js.map +0 -1
  50. package/dist/openapi.d.ts +0 -45
  51. package/dist/openapi.d.ts.map +0 -1
  52. package/dist/openapi.js +0 -59
  53. package/dist/openapi.js.map +0 -1
  54. package/dist/proxy/connection-manager.d.ts +0 -78
  55. package/dist/proxy/connection-manager.d.ts.map +0 -1
  56. package/dist/proxy/connection-manager.js +0 -251
  57. package/dist/proxy/connection-manager.js.map +0 -1
  58. package/dist/proxy/index.d.ts +0 -8
  59. package/dist/proxy/index.d.ts.map +0 -1
  60. package/dist/proxy/index.js +0 -8
  61. package/dist/proxy/index.js.map +0 -1
  62. package/dist/proxy/routes.d.ts +0 -74
  63. package/dist/proxy/routes.d.ts.map +0 -1
  64. package/dist/proxy/routes.js +0 -147
  65. package/dist/proxy/routes.js.map +0 -1
  66. package/dist/rate-limit.d.ts +0 -101
  67. package/dist/rate-limit.d.ts.map +0 -1
  68. package/dist/rate-limit.js +0 -186
  69. package/dist/rate-limit.js.map +0 -1
  70. package/dist/routes.d.ts +0 -145
  71. package/dist/routes.d.ts.map +0 -1
  72. package/dist/routes.js +0 -1471
  73. package/dist/routes.js.map +0 -1
  74. package/dist/ws.d.ts +0 -235
  75. package/dist/ws.d.ts.map +0 -1
  76. package/dist/ws.js +0 -614
  77. package/dist/ws.js.map +0 -1
  78. package/src/__tests__/blob-routes.test.ts +0 -424
  79. package/src/__tests__/console-gateway-live-routes.test.ts +0 -297
  80. package/src/__tests__/console-gateway-routes.test.ts +0 -1364
  81. package/src/__tests__/console-routes.test.ts +0 -1543
  82. package/src/__tests__/console-ui.test.ts +0 -114
  83. package/src/__tests__/create-server.test.ts +0 -342
  84. package/src/__tests__/pull-chunk-storage.test.ts +0 -576
  85. package/src/__tests__/rate-limit.test.ts +0 -78
  86. package/src/__tests__/realtime-bridge.test.ts +0 -135
  87. package/src/__tests__/sync-rate-limit-routing.test.ts +0 -185
  88. package/src/__tests__/ws-connection-manager.test.ts +0 -176
  89. package/src/api-key-auth.ts +0 -179
  90. package/src/blobs.ts +0 -534
  91. package/src/console/gateway.ts +0 -3603
  92. package/src/console/index.ts +0 -11
  93. package/src/console/routes.ts +0 -3748
  94. package/src/console/schemas.ts +0 -434
  95. package/src/console/types.ts +0 -151
  96. package/src/console/ui.ts +0 -100
  97. package/src/create-server.ts +0 -206
  98. package/src/openapi.ts +0 -74
  99. package/src/proxy/connection-manager.ts +0 -340
  100. package/src/proxy/index.ts +0 -8
  101. package/src/proxy/routes.ts +0 -223
  102. package/src/rate-limit.ts +0 -321
  103. package/src/routes.ts +0 -2007
  104. package/src/ws.ts +0 -802
package/dist/routes.js DELETED
@@ -1,1471 +0,0 @@
1
- /**
2
- * @syncular/server-hono - Sync routes for Hono
3
- *
4
- * Provides:
5
- * - POST / (combined push + pull in one round-trip)
6
- * - GET /snapshot-chunks/:chunkId (download encoded snapshot chunks)
7
- * - GET /realtime (optional WebSocket "wake up" notifications)
8
- */
9
- import { captureSyncException, countSyncMetric, createSyncTimer, distributionSyncMetric, ErrorResponseSchema, logSyncEvent, SyncCombinedRequestSchema, SyncCombinedResponseSchema, SyncPushRequestSchema, } from '@syncular/core';
10
- import { createServerHandlerCollection, InvalidSubscriptionScopeError, pull, pushCommit, readSnapshotChunk, recordClientCursor, } from '@syncular/server';
11
- import { Hono } from 'hono';
12
- import { describeRoute, resolver, validator as zValidator } from 'hono-openapi';
13
- import { sql, } from 'kysely';
14
- import { z } from 'zod';
15
- import { createRateLimiter, DEFAULT_SYNC_RATE_LIMITS, } from './rate-limit.js';
16
- import { createWebSocketConnection, WebSocketConnectionManager, } from './ws.js';
17
- /**
18
- * WeakMaps for storing Hono-instance-specific data without augmenting the type.
19
- */
20
- const wsConnectionManagerMap = new WeakMap();
21
- const realtimeUnsubscribeMap = new WeakMap();
22
- // ============================================================================
23
- // Route Schemas
24
- // ============================================================================
25
- const snapshotChunkParamsSchema = z.object({
26
- chunkId: z.string().min(1),
27
- });
28
- const MAX_REQUEST_PAYLOAD_SNAPSHOT_BYTES = 128 * 1024;
29
- function createOpaqueId(prefix) {
30
- const randomPart = typeof crypto !== 'undefined' && 'randomUUID' in crypto
31
- ? crypto.randomUUID()
32
- : `${Date.now()}-${Math.random().toString(16).slice(2)}`;
33
- return `${prefix}-${randomPart}`;
34
- }
35
- function readRequestId(c) {
36
- const headerRequestId = c.req.header('x-request-id')?.trim();
37
- if (headerRequestId)
38
- return headerRequestId;
39
- return createOpaqueId('req');
40
- }
41
- function parseW3cTraceparent(traceparent) {
42
- if (!traceparent)
43
- return null;
44
- const parsed = traceparent.trim();
45
- const match = /^00-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}$/i.exec(parsed);
46
- if (!match)
47
- return null;
48
- const traceId = match[1]?.toLowerCase() ?? null;
49
- const spanId = match[2]?.toLowerCase() ?? null;
50
- if (!traceId || !spanId)
51
- return null;
52
- return { traceId, spanId };
53
- }
54
- function parseSentryTraceHeader(sentryTrace) {
55
- if (!sentryTrace)
56
- return null;
57
- const parsed = sentryTrace.trim();
58
- const match = /^([0-9a-f]{32})-([0-9a-f]{16})(?:-[01])?$/i.exec(parsed);
59
- if (!match)
60
- return null;
61
- const traceId = match[1]?.toLowerCase() ?? null;
62
- const spanId = match[2]?.toLowerCase() ?? null;
63
- if (!traceId || !spanId)
64
- return null;
65
- return { traceId, spanId };
66
- }
67
- function readTraceContext(c) {
68
- const traceparent = parseW3cTraceparent(c.req.header('traceparent'));
69
- if (traceparent)
70
- return traceparent;
71
- const sentryTrace = parseSentryTraceHeader(c.req.header('sentry-trace'));
72
- if (sentryTrace)
73
- return sentryTrace;
74
- return { traceId: null, spanId: null };
75
- }
76
- function readStringField(data, key) {
77
- const value = data[key];
78
- if (typeof value !== 'string')
79
- return null;
80
- const trimmed = value.trim();
81
- return trimmed.length > 0 ? trimmed : null;
82
- }
83
- function readTraceContextFromMessage(msg) {
84
- const directTraceId = readStringField(msg, 'traceId') ?? readStringField(msg, 'trace_id');
85
- const directSpanId = readStringField(msg, 'spanId') ?? readStringField(msg, 'span_id');
86
- if (directTraceId || directSpanId) {
87
- return { traceId: directTraceId, spanId: directSpanId };
88
- }
89
- const traceparent = readStringField(msg, 'traceparent') ?? readStringField(msg, 'traceParent');
90
- const parsedTraceparent = parseW3cTraceparent(traceparent);
91
- if (parsedTraceparent)
92
- return parsedTraceparent;
93
- const sentryTrace = readStringField(msg, 'sentry-trace') ??
94
- readStringField(msg, 'sentryTrace') ??
95
- readStringField(msg, 'sentry_trace');
96
- const parsedSentryTrace = parseSentryTraceHeader(sentryTrace);
97
- if (parsedSentryTrace)
98
- return parsedSentryTrace;
99
- return { traceId: null, spanId: null };
100
- }
101
- function normalizeResponseStatus(statusCode, outcome) {
102
- if (statusCode >= 500)
103
- return 'server_error';
104
- if (statusCode >= 400)
105
- return 'client_error';
106
- if (statusCode >= 300)
107
- return 'redirect';
108
- if (statusCode >= 200) {
109
- if (outcome === 'error' || outcome === 'rejected')
110
- return 'failure';
111
- return 'success';
112
- }
113
- return 'unknown';
114
- }
115
- function firstPushErrorCode(results) {
116
- if (!Array.isArray(results))
117
- return null;
118
- for (const result of results) {
119
- if (!result || typeof result !== 'object')
120
- continue;
121
- const status = Reflect.get(result, 'status');
122
- if (status !== 'error')
123
- continue;
124
- const code = Reflect.get(result, 'code');
125
- if (typeof code === 'string' && code.length > 0) {
126
- return code;
127
- }
128
- }
129
- return null;
130
- }
131
- function summarizeScopeValues(scopes) {
132
- const summary = {};
133
- for (const [key, value] of Object.entries(scopes)) {
134
- if (typeof value === 'string') {
135
- summary[key] = value;
136
- continue;
137
- }
138
- if (Array.isArray(value)) {
139
- const normalized = value
140
- .filter((entry) => typeof entry === 'string')
141
- .slice(0, 20);
142
- summary[key] = normalized;
143
- }
144
- }
145
- return Object.keys(summary).length > 0 ? summary : null;
146
- }
147
- function summarizePullResponse(response) {
148
- return {
149
- subscriptions: response.subscriptions.map((subscription) => {
150
- const changeCount = subscription.commits.reduce((totalChanges, commit) => totalChanges + commit.changes.length, 0);
151
- const snapshotCount = subscription.snapshots?.length ?? 0;
152
- const snapshotRowCount = subscription.snapshots?.reduce((totalRows, snapshot) => totalRows + snapshot.rows.length, 0) ?? 0;
153
- return {
154
- id: subscription.id,
155
- status: subscription.status,
156
- bootstrap: subscription.bootstrap,
157
- nextCursor: subscription.nextCursor,
158
- commitCount: subscription.commits.length,
159
- changeCount,
160
- snapshotCount,
161
- snapshotRowCount,
162
- };
163
- }),
164
- };
165
- }
166
- function countPullRows(response) {
167
- return response.subscriptions.reduce((totalRows, subscription) => {
168
- const commitRows = subscription.commits.reduce((totalChanges, commit) => totalChanges + commit.changes.length, 0);
169
- const snapshotRows = subscription.snapshots?.reduce((totalSnapshotRows, snapshot) => totalSnapshotRows + snapshot.rows.length, 0) ?? 0;
170
- return totalRows + commitRows + snapshotRows;
171
- }, 0);
172
- }
173
- function encodePayloadSnapshot(value) {
174
- try {
175
- const serialized = JSON.stringify(value);
176
- if (serialized.length <= MAX_REQUEST_PAYLOAD_SNAPSHOT_BYTES) {
177
- return serialized;
178
- }
179
- return JSON.stringify({
180
- truncated: true,
181
- originalSizeBytes: serialized.length,
182
- preview: serialized.slice(0, MAX_REQUEST_PAYLOAD_SNAPSHOT_BYTES),
183
- });
184
- }
185
- catch {
186
- return JSON.stringify({
187
- truncated: false,
188
- serializationError: 'Could not serialize payload snapshot',
189
- });
190
- }
191
- }
192
- function emitConsoleLiveEvent(emitter, type, data) {
193
- if (!emitter)
194
- return;
195
- emitter.emit({
196
- type,
197
- timestamp: new Date().toISOString(),
198
- data: typeof data === 'function' ? data() : data,
199
- });
200
- }
201
- export function createSyncRoutes(options) {
202
- const routes = new Hono();
203
- routes.onError((error, c) => {
204
- captureSyncException(error, {
205
- event: 'sync.route.unhandled',
206
- method: c.req.method,
207
- path: c.req.path,
208
- });
209
- return c.text('Internal Server Error', 500);
210
- });
211
- const handlerRegistry = createServerHandlerCollection(options.handlers);
212
- const config = options.sync ?? {};
213
- const maxPullLimitCommits = config.maxPullLimitCommits ?? 100;
214
- const maxSubscriptionsPerPull = config.maxSubscriptionsPerPull ?? 200;
215
- const maxPullLimitSnapshotRows = config.maxPullLimitSnapshotRows ?? 5000;
216
- const maxPullMaxSnapshotPages = config.maxPullMaxSnapshotPages ?? 10;
217
- const maxOperationsPerPush = config.maxOperationsPerPush ?? 200;
218
- const consoleLiveEmitter = options.consoleLiveEmitter;
219
- const shouldEmitConsoleLiveEvents = consoleLiveEmitter !== undefined;
220
- const shouldRecordRequestEvents = shouldEmitConsoleLiveEvents;
221
- const consoleSchemaReadyBase = shouldRecordRequestEvents
222
- ? (options.consoleSchemaReady ??
223
- options.dialect.ensureConsoleSchema?.(options.db) ??
224
- Promise.resolve())
225
- : Promise.resolve();
226
- const consoleSchemaReady = consoleSchemaReadyBase.catch((error) => {
227
- logSyncEvent({
228
- event: 'sync.console_schema_ready_failed',
229
- error: error instanceof Error ? error.message : String(error),
230
- });
231
- throw error;
232
- });
233
- // -------------------------------------------------------------------------
234
- // Optional WebSocket manager (scope-key based wake-ups)
235
- // -------------------------------------------------------------------------
236
- const websocketConfig = config.websocket;
237
- if (websocketConfig?.enabled && !websocketConfig.upgradeWebSocket) {
238
- throw new Error('sync.websocket.enabled requires sync.websocket.upgradeWebSocket');
239
- }
240
- const wsConnectionManager = websocketConfig?.enabled
241
- ? (options.wsConnectionManager ??
242
- new WebSocketConnectionManager({
243
- heartbeatIntervalMs: websocketConfig.heartbeatIntervalMs ?? 30_000,
244
- }))
245
- : null;
246
- if (wsConnectionManager) {
247
- wsConnectionManagerMap.set(routes, wsConnectionManager);
248
- }
249
- // -------------------------------------------------------------------------
250
- // Multi-instance realtime broadcaster (optional)
251
- // -------------------------------------------------------------------------
252
- const realtimeBroadcaster = config.realtime?.broadcaster ?? null;
253
- const instanceId = config.realtime?.instanceId ??
254
- (typeof crypto !== 'undefined' && 'randomUUID' in crypto
255
- ? crypto.randomUUID()
256
- : `${Date.now()}-${Math.random().toString(16).slice(2)}`);
257
- const loggedAsyncFailureKeys = new Set();
258
- const logAsyncFailureOnce = (key, event) => {
259
- if (loggedAsyncFailureKeys.has(key))
260
- return;
261
- loggedAsyncFailureKeys.add(key);
262
- logSyncEvent(event);
263
- };
264
- if (wsConnectionManager && realtimeBroadcaster) {
265
- const unsubscribe = realtimeBroadcaster.subscribe((event) => {
266
- void handleRealtimeEvent(event).catch((error) => {
267
- logAsyncFailureOnce('sync.realtime.broadcast_delivery_failed', {
268
- event: 'sync.realtime.broadcast_delivery_failed',
269
- error: error instanceof Error ? error.message : String(error),
270
- sourceEventType: event.type,
271
- });
272
- });
273
- });
274
- realtimeUnsubscribeMap.set(routes, unsubscribe);
275
- }
276
- const recordRequestEvent = async (event) => {
277
- let payloadRef = event.payloadRef ?? null;
278
- if (event.payloadSnapshot) {
279
- const nextPayloadRef = payloadRef ?? createOpaqueId('payload');
280
- const nowIso = new Date().toISOString();
281
- try {
282
- await sql `
283
- INSERT INTO sync_request_payloads (
284
- payload_ref, partition_id, request_payload, response_payload, created_at
285
- ) VALUES (
286
- ${nextPayloadRef}, ${event.partitionId},
287
- ${encodePayloadSnapshot(event.payloadSnapshot.request)},
288
- ${encodePayloadSnapshot(event.payloadSnapshot.response)},
289
- ${nowIso}
290
- )
291
- ON CONFLICT (payload_ref) DO UPDATE SET
292
- partition_id = EXCLUDED.partition_id,
293
- request_payload = EXCLUDED.request_payload,
294
- response_payload = EXCLUDED.response_payload,
295
- created_at = EXCLUDED.created_at
296
- `.execute(options.db);
297
- payloadRef = nextPayloadRef;
298
- }
299
- catch (error) {
300
- payloadRef = null;
301
- logAsyncFailureOnce('sync.request_payload_record_failed', {
302
- event: 'sync.request_payload_record_failed',
303
- userId: event.actorId,
304
- clientId: event.clientId,
305
- requestEventType: event.eventType,
306
- error: error instanceof Error ? error.message : String(error),
307
- });
308
- }
309
- }
310
- const tablesValue = options.dialect.arrayToDb(event.tables ?? []);
311
- const scopesSummaryValue = event.scopesSummary
312
- ? JSON.stringify(event.scopesSummary)
313
- : null;
314
- await sql `
315
- INSERT INTO sync_request_events (
316
- partition_id, request_id, trace_id, span_id,
317
- event_type, sync_path, actor_id, client_id, transport_path,
318
- status_code, outcome, response_status, error_code,
319
- duration_ms, commit_seq, operation_count, row_count, subscription_count,
320
- scopes_summary, tables, error_message, payload_ref
321
- ) VALUES (
322
- ${event.partitionId}, ${event.requestId}, ${event.traceId ?? null},
323
- ${event.spanId ?? null}, ${event.eventType}, ${event.syncPath},
324
- ${event.actorId}, ${event.clientId}, ${event.transportPath},
325
- ${event.statusCode}, ${event.outcome}, ${event.responseStatus},
326
- ${event.errorCode ?? null}, ${event.durationMs}, ${event.commitSeq ?? null},
327
- ${event.operationCount ?? null}, ${event.rowCount ?? null},
328
- ${event.subscriptionCount ?? null}, ${scopesSummaryValue}, ${tablesValue},
329
- ${event.errorMessage ?? null}, ${payloadRef}
330
- )
331
- `.execute(options.db);
332
- };
333
- const recordRequestEventInBackground = (event) => {
334
- if (!shouldRecordRequestEvents)
335
- return;
336
- const resolvedEvent = typeof event === 'function' ? event() : event;
337
- void consoleSchemaReady
338
- .then(() => recordRequestEvent(resolvedEvent))
339
- .catch((error) => {
340
- logAsyncFailureOnce('sync.request_event_record_failed', {
341
- event: 'sync.request_event_record_failed',
342
- userId: resolvedEvent.actorId,
343
- clientId: resolvedEvent.clientId,
344
- requestEventType: resolvedEvent.eventType,
345
- error: error instanceof Error ? error.message : String(error),
346
- });
347
- });
348
- };
349
- const authCache = new WeakMap();
350
- const getAuth = (c) => {
351
- const cached = authCache.get(c);
352
- if (cached)
353
- return cached;
354
- const pending = options.authenticate(c);
355
- authCache.set(c, pending);
356
- return pending;
357
- };
358
- // -------------------------------------------------------------------------
359
- // Rate limiting (optional)
360
- // -------------------------------------------------------------------------
361
- const rateLimitConfig = config.rateLimit;
362
- if (rateLimitConfig !== false) {
363
- const pullRateLimit = rateLimitConfig?.pull ?? DEFAULT_SYNC_RATE_LIMITS.pull;
364
- const pushRateLimit = rateLimitConfig?.push ?? DEFAULT_SYNC_RATE_LIMITS.push;
365
- const createAuthBasedRateLimiter = (limitConfig) => {
366
- if (limitConfig === false || !limitConfig)
367
- return null;
368
- return createRateLimiter({
369
- ...limitConfig,
370
- keyGenerator: async (c) => {
371
- const auth = await getAuth(c);
372
- return auth?.actorId ?? null;
373
- },
374
- });
375
- };
376
- const pullLimiter = createAuthBasedRateLimiter(pullRateLimit);
377
- const pushLimiter = createAuthBasedRateLimiter(pushRateLimit);
378
- const syncRateLimiter = async (c, next) => {
379
- if (!pullLimiter && !pushLimiter)
380
- return next();
381
- let shouldApplyPull = pullLimiter !== null;
382
- let shouldApplyPush = pushLimiter !== null;
383
- if (pullLimiter && pushLimiter && c.req.method === 'POST') {
384
- try {
385
- const parsed = await c.req.raw.clone().json();
386
- if (parsed !== null && typeof parsed === 'object') {
387
- shouldApplyPull = Reflect.get(parsed, 'pull') !== undefined;
388
- shouldApplyPush = Reflect.get(parsed, 'push') !== undefined;
389
- }
390
- }
391
- catch {
392
- // Keep default behavior and apply both limiters when payload parsing fails.
393
- }
394
- }
395
- if (pullLimiter && shouldApplyPull && pushLimiter && shouldApplyPush) {
396
- return pullLimiter(c, async () => {
397
- const pushResult = await pushLimiter(c, next);
398
- if (pushResult instanceof Response) {
399
- c.res = pushResult;
400
- }
401
- });
402
- }
403
- if (pullLimiter && shouldApplyPull) {
404
- return pullLimiter(c, next);
405
- }
406
- if (pushLimiter && shouldApplyPush) {
407
- return pushLimiter(c, next);
408
- }
409
- return next();
410
- };
411
- routes.use('/', syncRateLimiter);
412
- }
413
- // -------------------------------------------------------------------------
414
- // GET /health
415
- // -------------------------------------------------------------------------
416
- routes.get('/health', (c) => {
417
- return c.json({
418
- status: 'healthy',
419
- timestamp: new Date().toISOString(),
420
- });
421
- });
422
- // -------------------------------------------------------------------------
423
- // POST / (combined push + pull in one round-trip)
424
- // -------------------------------------------------------------------------
425
- routes.post('/', describeRoute({
426
- tags: ['sync'],
427
- summary: 'Combined push and pull',
428
- description: 'Perform push and/or pull in a single request to reduce round-trips',
429
- responses: {
430
- 200: {
431
- description: 'Combined sync response',
432
- content: {
433
- 'application/json': {
434
- schema: resolver(SyncCombinedResponseSchema),
435
- },
436
- },
437
- },
438
- 400: {
439
- description: 'Invalid request',
440
- content: {
441
- 'application/json': { schema: resolver(ErrorResponseSchema) },
442
- },
443
- },
444
- 401: {
445
- description: 'Unauthenticated',
446
- content: {
447
- 'application/json': { schema: resolver(ErrorResponseSchema) },
448
- },
449
- },
450
- },
451
- }), zValidator('json', SyncCombinedRequestSchema), async (c) => {
452
- const auth = await getAuth(c);
453
- if (!auth)
454
- return c.json({ error: 'UNAUTHENTICATED' }, 401);
455
- const partitionId = auth.partitionId ?? 'default';
456
- const body = c.req.valid('json');
457
- const clientId = body.clientId;
458
- const requestId = readRequestId(c);
459
- const traceContext = readTraceContext(c);
460
- let pushResponse;
461
- let pullResponse;
462
- // --- Push phase ---
463
- if (body.push) {
464
- const pushBody = body.push;
465
- const pushOps = pushBody.operations ?? [];
466
- if (pushOps.length > maxOperationsPerPush) {
467
- return c.json({
468
- error: 'TOO_MANY_OPERATIONS',
469
- message: `Maximum ${maxOperationsPerPush} operations per push`,
470
- }, 400);
471
- }
472
- const timer = createSyncTimer();
473
- const pushed = await pushCommit({
474
- db: options.db,
475
- dialect: options.dialect,
476
- handlers: handlerRegistry,
477
- auth,
478
- request: {
479
- clientId,
480
- clientCommitId: pushBody.clientCommitId,
481
- operations: pushBody.operations,
482
- schemaVersion: pushBody.schemaVersion,
483
- },
484
- });
485
- const pushDurationMs = timer();
486
- logSyncEvent({
487
- event: 'sync.push',
488
- userId: auth.actorId,
489
- durationMs: pushDurationMs,
490
- operationCount: pushOps.length,
491
- status: pushed.response.status,
492
- commitSeq: pushed.response.commitSeq,
493
- });
494
- recordRequestEventInBackground(() => ({
495
- partitionId,
496
- requestId,
497
- traceId: traceContext.traceId,
498
- spanId: traceContext.spanId,
499
- eventType: 'push',
500
- syncPath: 'http-combined',
501
- actorId: auth.actorId,
502
- clientId,
503
- transportPath: readTransportPath(c),
504
- statusCode: 200,
505
- outcome: pushed.response.status,
506
- responseStatus: normalizeResponseStatus(200, pushed.response.status),
507
- durationMs: pushDurationMs,
508
- errorCode: firstPushErrorCode(pushed.response.results),
509
- commitSeq: pushed.response.commitSeq,
510
- operationCount: pushOps.length,
511
- tables: pushed.affectedTables,
512
- payloadSnapshot: {
513
- request: {
514
- clientId,
515
- clientCommitId: pushBody.clientCommitId,
516
- schemaVersion: pushBody.schemaVersion,
517
- operations: pushBody.operations,
518
- },
519
- response: pushed.response,
520
- },
521
- }));
522
- emitConsoleLiveEvent(consoleLiveEmitter, 'push', () => ({
523
- partitionId,
524
- requestId,
525
- traceId: traceContext.traceId,
526
- spanId: traceContext.spanId,
527
- actorId: auth.actorId,
528
- clientId,
529
- transportPath: readTransportPath(c),
530
- syncPath: 'http-combined',
531
- outcome: pushed.response.status,
532
- statusCode: 200,
533
- durationMs: pushDurationMs,
534
- commitSeq: pushed.response.commitSeq ?? null,
535
- operationCount: pushOps.length,
536
- tables: pushed.affectedTables,
537
- }));
538
- // WS notifications
539
- if (wsConnectionManager &&
540
- pushed.response.ok === true &&
541
- pushed.response.status === 'applied' &&
542
- typeof pushed.response.commitSeq === 'number') {
543
- const scopeKeys = applyPartitionToScopeKeys(partitionId, pushed.scopeKeys);
544
- if (scopeKeys.length > 0) {
545
- wsConnectionManager.notifyScopeKeys(scopeKeys, pushed.response.commitSeq, {
546
- excludeClientIds: [clientId],
547
- changes: pushed.emittedChanges,
548
- });
549
- if (realtimeBroadcaster) {
550
- realtimeBroadcaster
551
- .publish({
552
- type: 'commit',
553
- commitSeq: pushed.response.commitSeq,
554
- partitionId,
555
- scopeKeys,
556
- sourceInstanceId: instanceId,
557
- })
558
- .catch((error) => {
559
- logAsyncFailureOnce('sync.realtime.broadcast_publish_failed', {
560
- event: 'sync.realtime.broadcast_publish_failed',
561
- userId: auth.actorId,
562
- clientId,
563
- error: error instanceof Error ? error.message : String(error),
564
- });
565
- });
566
- }
567
- }
568
- }
569
- if (pushed.response.ok === true &&
570
- pushed.response.status === 'applied' &&
571
- typeof pushed.response.commitSeq === 'number') {
572
- emitConsoleLiveEvent(consoleLiveEmitter, 'commit', () => ({
573
- partitionId,
574
- commitSeq: pushed.response.commitSeq,
575
- actorId: auth.actorId,
576
- clientId,
577
- affectedTables: pushed.affectedTables,
578
- }));
579
- }
580
- pushResponse = pushed.response;
581
- }
582
- // --- Pull phase ---
583
- if (body.pull) {
584
- if (body.pull.subscriptions.length > maxSubscriptionsPerPull) {
585
- return c.json({
586
- error: 'INVALID_REQUEST',
587
- message: `Too many subscriptions (max ${maxSubscriptionsPerPull})`,
588
- }, 400);
589
- }
590
- const seenSubscriptionIds = new Set();
591
- for (const sub of body.pull.subscriptions) {
592
- const id = sub.id;
593
- if (seenSubscriptionIds.has(id)) {
594
- return c.json({
595
- error: 'INVALID_REQUEST',
596
- message: `Duplicate subscription id: ${id}`,
597
- }, 400);
598
- }
599
- seenSubscriptionIds.add(id);
600
- }
601
- const request = {
602
- clientId,
603
- limitCommits: clampInt(body.pull.limitCommits ?? 50, 1, maxPullLimitCommits),
604
- limitSnapshotRows: clampInt(body.pull.limitSnapshotRows ?? 1000, 1, maxPullLimitSnapshotRows),
605
- maxSnapshotPages: clampInt(body.pull.maxSnapshotPages ?? 4, 1, maxPullMaxSnapshotPages),
606
- dedupeRows: body.pull.dedupeRows === true,
607
- subscriptions: body.pull.subscriptions.map((sub) => ({
608
- id: sub.id,
609
- table: sub.table,
610
- scopes: (sub.scopes ?? {}),
611
- params: sub.params,
612
- cursor: Math.max(-1, sub.cursor),
613
- bootstrapState: sub.bootstrapState ?? null,
614
- })),
615
- };
616
- const timer = createSyncTimer();
617
- let pullResult;
618
- try {
619
- pullResult = await pull({
620
- db: options.db,
621
- dialect: options.dialect,
622
- handlers: handlerRegistry,
623
- auth,
624
- request,
625
- chunkStorage: options.chunkStorage,
626
- scopeCache: options.scopeCache,
627
- });
628
- }
629
- catch (err) {
630
- if (err instanceof InvalidSubscriptionScopeError) {
631
- return c.json({ error: 'INVALID_SUBSCRIPTION', message: err.message }, 400);
632
- }
633
- throw err;
634
- }
635
- // Fire-and-forget bookkeeping
636
- void recordClientCursor(options.db, options.dialect, {
637
- partitionId,
638
- clientId,
639
- actorId: auth.actorId,
640
- cursor: pullResult.clientCursor,
641
- effectiveScopes: pullResult.effectiveScopes,
642
- })
643
- .then(() => {
644
- emitConsoleLiveEvent(consoleLiveEmitter, 'client_update', () => ({
645
- action: 'cursor_recorded',
646
- partitionId,
647
- actorId: auth.actorId,
648
- clientId,
649
- cursor: pullResult.clientCursor,
650
- }));
651
- })
652
- .catch((error) => {
653
- logAsyncFailureOnce('sync.client_cursor_record_failed', {
654
- event: 'sync.client_cursor_record_failed',
655
- userId: auth.actorId,
656
- clientId,
657
- error: error instanceof Error ? error.message : String(error),
658
- });
659
- });
660
- wsConnectionManager?.updateClientScopeKeys(clientId, applyPartitionToScopeKeys(partitionId, scopeValuesToScopeKeys(pullResult.effectiveScopes)));
661
- const pullDurationMs = timer();
662
- logSyncEvent({
663
- event: 'sync.pull',
664
- userId: auth.actorId,
665
- durationMs: pullDurationMs,
666
- subscriptionCount: pullResult.response.subscriptions.length,
667
- clientCursor: pullResult.clientCursor,
668
- });
669
- recordRequestEventInBackground(() => {
670
- const pullRowCount = shouldRecordRequestEvents
671
- ? countPullRows(pullResult.response)
672
- : null;
673
- const scopesSummary = shouldRecordRequestEvents
674
- ? summarizeScopeValues(pullResult.effectiveScopes)
675
- : null;
676
- const payloadSnapshot = shouldRecordRequestEvents
677
- ? {
678
- request: {
679
- clientId,
680
- limitCommits: request.limitCommits,
681
- limitSnapshotRows: request.limitSnapshotRows,
682
- maxSnapshotPages: request.maxSnapshotPages,
683
- dedupeRows: request.dedupeRows,
684
- subscriptions: request.subscriptions.map((subscription) => ({
685
- id: subscription.id,
686
- table: subscription.table,
687
- scopes: subscription.scopes,
688
- cursor: subscription.cursor,
689
- bootstrapState: subscription.bootstrapState,
690
- })),
691
- },
692
- response: summarizePullResponse(pullResult.response),
693
- }
694
- : null;
695
- return {
696
- partitionId,
697
- requestId,
698
- traceId: traceContext.traceId,
699
- spanId: traceContext.spanId,
700
- eventType: 'pull',
701
- syncPath: 'http-combined',
702
- actorId: auth.actorId,
703
- clientId,
704
- transportPath: readTransportPath(c),
705
- statusCode: 200,
706
- outcome: 'applied',
707
- responseStatus: normalizeResponseStatus(200, 'applied'),
708
- durationMs: pullDurationMs,
709
- rowCount: pullRowCount,
710
- subscriptionCount: request.subscriptions.length,
711
- scopesSummary,
712
- payloadSnapshot,
713
- };
714
- });
715
- emitConsoleLiveEvent(consoleLiveEmitter, 'pull', () => {
716
- const pullRowCount = shouldEmitConsoleLiveEvents
717
- ? countPullRows(pullResult.response)
718
- : null;
719
- return {
720
- partitionId,
721
- requestId,
722
- traceId: traceContext.traceId,
723
- spanId: traceContext.spanId,
724
- actorId: auth.actorId,
725
- clientId,
726
- transportPath: readTransportPath(c),
727
- syncPath: 'http-combined',
728
- outcome: 'applied',
729
- statusCode: 200,
730
- durationMs: pullDurationMs,
731
- rowCount: pullRowCount,
732
- subscriptionCount: request.subscriptions.length,
733
- clientCursor: pullResult.clientCursor,
734
- };
735
- });
736
- pullResponse = pullResult.response;
737
- }
738
- return c.json({
739
- ok: true,
740
- ...(pushResponse ? { push: pushResponse } : {}),
741
- ...(pullResponse ? { pull: pullResponse } : {}),
742
- }, 200);
743
- });
744
- // -------------------------------------------------------------------------
745
- // GET /snapshot-chunks/:chunkId
746
- // -------------------------------------------------------------------------
747
- routes.get('/snapshot-chunks/:chunkId', describeRoute({
748
- tags: ['sync'],
749
- summary: 'Download snapshot chunk',
750
- description: 'Download an encoded bootstrap snapshot chunk',
751
- responses: {
752
- 200: {
753
- description: 'Snapshot chunk data (gzip-compressed framed JSON rows)',
754
- content: {
755
- 'application/octet-stream': {
756
- schema: resolver(z.string()),
757
- },
758
- },
759
- },
760
- 304: {
761
- description: 'Not modified (cached)',
762
- },
763
- 401: {
764
- description: 'Unauthenticated',
765
- content: {
766
- 'application/json': { schema: resolver(ErrorResponseSchema) },
767
- },
768
- },
769
- 403: {
770
- description: 'Forbidden',
771
- content: {
772
- 'application/json': { schema: resolver(ErrorResponseSchema) },
773
- },
774
- },
775
- 404: {
776
- description: 'Not found',
777
- content: {
778
- 'application/json': { schema: resolver(ErrorResponseSchema) },
779
- },
780
- },
781
- },
782
- }), zValidator('param', snapshotChunkParamsSchema), async (c) => {
783
- const auth = await getAuth(c);
784
- if (!auth)
785
- return c.json({ error: 'UNAUTHENTICATED' }, 401);
786
- const partitionId = auth.partitionId ?? 'default';
787
- const { chunkId } = c.req.valid('param');
788
- const chunk = await readSnapshotChunk(options.db, chunkId, {
789
- chunkStorage: options.chunkStorage,
790
- });
791
- if (!chunk)
792
- return c.json({ error: 'NOT_FOUND' }, 404);
793
- if (chunk.partitionId !== partitionId) {
794
- return c.json({ error: 'FORBIDDEN' }, 403);
795
- }
796
- const nowIso = new Date().toISOString();
797
- if (chunk.expiresAt <= nowIso) {
798
- return c.json({ error: 'NOT_FOUND' }, 404);
799
- }
800
- // Note: Snapshot chunks are created during authorized pull requests
801
- // and have opaque IDs that expire. Additional authorization is handled
802
- // at the pull layer via table-level resolveScopes.
803
- const etag = `"sha256:${chunk.sha256}"`;
804
- const ifNoneMatch = c.req.header('if-none-match');
805
- if (ifNoneMatch && ifNoneMatch === etag) {
806
- return new Response(null, {
807
- status: 304,
808
- headers: {
809
- ETag: etag,
810
- 'Cache-Control': 'private, max-age=0',
811
- Vary: 'Authorization',
812
- },
813
- });
814
- }
815
- return new Response(chunk.body, {
816
- status: 200,
817
- headers: {
818
- 'Content-Type': 'application/octet-stream',
819
- 'Content-Encoding': 'gzip',
820
- 'Content-Length': String(chunk.byteLength),
821
- ETag: etag,
822
- 'Cache-Control': 'private, max-age=0',
823
- Vary: 'Authorization',
824
- 'X-Sync-Chunk-Id': chunk.chunkId,
825
- 'X-Sync-Chunk-Sha256': chunk.sha256,
826
- 'X-Sync-Chunk-Encoding': chunk.encoding,
827
- 'X-Sync-Chunk-Compression': chunk.compression,
828
- },
829
- });
830
- });
831
- // -------------------------------------------------------------------------
832
- // GET /realtime (optional WebSocket wake-ups)
833
- // -------------------------------------------------------------------------
834
- if (wsConnectionManager && websocketConfig?.enabled) {
835
- routes.get('/realtime', async (c) => {
836
- const auth = await getAuth(c);
837
- if (!auth)
838
- return c.json({ error: 'UNAUTHENTICATED' }, 401);
839
- const partitionId = auth.partitionId ?? 'default';
840
- const clientId = c.req.query('clientId');
841
- if (!clientId || typeof clientId !== 'string') {
842
- return c.json({
843
- error: 'INVALID_REQUEST',
844
- message: 'clientId query param is required',
845
- }, 400);
846
- }
847
- const realtimeTransportPath = readTransportPath(c, c.req.query('transportPath'));
848
- // Load last-known effective scopes for this client (best-effort).
849
- // Keeps /realtime lightweight and avoids sending large subscription payloads over the URL.
850
- let initialScopeKeys = [];
851
- try {
852
- const cursorsQ = options.db.selectFrom('sync_client_cursors');
853
- const row = await cursorsQ
854
- .selectAll()
855
- .where(sql `partition_id = ${partitionId}`)
856
- .where(sql `client_id = ${clientId}`)
857
- .executeTakeFirst();
858
- if (row && row.actor_id !== auth.actorId) {
859
- return c.json({ error: 'FORBIDDEN' }, 403);
860
- }
861
- const raw = row?.effective_scopes;
862
- let parsed = raw;
863
- if (typeof raw === 'string') {
864
- try {
865
- parsed = JSON.parse(raw);
866
- }
867
- catch {
868
- parsed = null;
869
- }
870
- }
871
- initialScopeKeys = applyPartitionToScopeKeys(partitionId, scopeValuesToScopeKeys(parsed));
872
- }
873
- catch {
874
- // ignore; realtime is best-effort
875
- }
876
- const maxConnectionsTotal = websocketConfig.maxConnectionsTotal ?? 5000;
877
- const maxConnectionsPerClient = websocketConfig.maxConnectionsPerClient ?? 3;
878
- if (maxConnectionsTotal > 0 &&
879
- wsConnectionManager.getTotalConnections() >= maxConnectionsTotal) {
880
- logSyncEvent({
881
- event: 'sync.realtime.rejected',
882
- userId: auth.actorId,
883
- reason: 'max_total',
884
- });
885
- return c.json({ error: 'WEBSOCKET_CONNECTION_LIMIT_TOTAL' }, 429);
886
- }
887
- if (maxConnectionsPerClient > 0 &&
888
- wsConnectionManager.getConnectionCount(clientId) >=
889
- maxConnectionsPerClient) {
890
- logSyncEvent({
891
- event: 'sync.realtime.rejected',
892
- userId: auth.actorId,
893
- reason: 'max_per_client',
894
- });
895
- return c.json({ error: 'WEBSOCKET_CONNECTION_LIMIT_CLIENT' }, 429);
896
- }
897
- logSyncEvent({ event: 'sync.realtime.connect', userId: auth.actorId });
898
- let unregister = null;
899
- let connRef = null;
900
- const connectionCountBeforeUpgrade = wsConnectionManager.getConnectionCount(clientId);
901
- let sessionStartedAtMs = null;
902
- let sessionEnded = false;
903
- const finishRealtimeSession = (reason) => {
904
- if (sessionEnded)
905
- return;
906
- sessionEnded = true;
907
- if (sessionStartedAtMs === null) {
908
- return;
909
- }
910
- const durationMs = Math.max(0, Date.now() - sessionStartedAtMs);
911
- countSyncMetric('sync.sessions.ended', 1, {
912
- attributes: {
913
- transportPath: realtimeTransportPath,
914
- reason,
915
- },
916
- });
917
- distributionSyncMetric('sync.sessions.duration_ms', durationMs, {
918
- unit: 'millisecond',
919
- attributes: {
920
- transportPath: realtimeTransportPath,
921
- reason,
922
- },
923
- });
924
- };
925
- const upgradeWebSocket = websocketConfig.upgradeWebSocket;
926
- if (!upgradeWebSocket) {
927
- return c.json({ error: 'WEBSOCKET_NOT_CONFIGURED' }, 500);
928
- }
929
- return upgradeWebSocket(c, {
930
- onOpen(_evt, ws) {
931
- const conn = createWebSocketConnection(ws, {
932
- actorId: auth.actorId,
933
- clientId,
934
- transportPath: realtimeTransportPath,
935
- });
936
- connRef = conn;
937
- sessionStartedAtMs = Date.now();
938
- countSyncMetric('sync.sessions.started', 1, {
939
- attributes: {
940
- transportPath: realtimeTransportPath,
941
- },
942
- });
943
- if (connectionCountBeforeUpgrade > 0) {
944
- countSyncMetric('sync.transport.reconnects', 1, {
945
- attributes: {
946
- transportPath: realtimeTransportPath,
947
- source: 'server',
948
- },
949
- });
950
- }
951
- unregister = wsConnectionManager.register(conn, initialScopeKeys);
952
- conn.sendHeartbeat();
953
- emitConsoleLiveEvent(consoleLiveEmitter, 'client_update', () => ({
954
- action: 'realtime_connected',
955
- actorId: auth.actorId,
956
- clientId,
957
- partitionId,
958
- transportPath: realtimeTransportPath,
959
- scopeCount: initialScopeKeys.length,
960
- }));
961
- },
962
- onClose(_evt, _ws) {
963
- unregister?.();
964
- unregister = null;
965
- connRef = null;
966
- finishRealtimeSession('closed');
967
- logSyncEvent({
968
- event: 'sync.realtime.disconnect',
969
- userId: auth.actorId,
970
- });
971
- emitConsoleLiveEvent(consoleLiveEmitter, 'client_update', () => ({
972
- action: 'realtime_disconnected',
973
- actorId: auth.actorId,
974
- clientId,
975
- partitionId,
976
- }));
977
- },
978
- onError(_evt, _ws) {
979
- unregister?.();
980
- unregister = null;
981
- connRef = null;
982
- finishRealtimeSession('error');
983
- logSyncEvent({
984
- event: 'sync.realtime.disconnect',
985
- userId: auth.actorId,
986
- });
987
- emitConsoleLiveEvent(consoleLiveEmitter, 'client_update', () => ({
988
- action: 'realtime_error',
989
- actorId: auth.actorId,
990
- clientId,
991
- partitionId,
992
- }));
993
- },
994
- onMessage(evt, _ws) {
995
- if (!connRef)
996
- return;
997
- try {
998
- const raw = typeof evt.data === 'string' ? evt.data : String(evt.data);
999
- const msg = JSON.parse(raw);
1000
- if (!msg || typeof msg !== 'object')
1001
- return;
1002
- if (msg.type === 'push') {
1003
- void handleWsPush(msg, connRef, auth, clientId);
1004
- return;
1005
- }
1006
- if (msg.type !== 'presence' || !msg.scopeKey)
1007
- return;
1008
- const scopeKey = normalizeScopeKeyForPartition(partitionId, String(msg.scopeKey));
1009
- if (!scopeKey)
1010
- return;
1011
- switch (msg.action) {
1012
- case 'join':
1013
- if (!wsConnectionManager.joinPresence(clientId, scopeKey, msg.metadata)) {
1014
- logSyncEvent({
1015
- event: 'sync.realtime.presence.rejected',
1016
- userId: auth.actorId,
1017
- reason: 'scope_not_authorized',
1018
- scopeKey,
1019
- });
1020
- return;
1021
- }
1022
- // Send presence snapshot back to the joining client
1023
- {
1024
- const entries = wsConnectionManager.getPresence(scopeKey);
1025
- connRef.sendPresence({
1026
- action: 'snapshot',
1027
- scopeKey,
1028
- entries,
1029
- });
1030
- }
1031
- break;
1032
- case 'leave':
1033
- wsConnectionManager.leavePresence(clientId, scopeKey);
1034
- break;
1035
- case 'update':
1036
- if (!wsConnectionManager.updatePresenceMetadata(clientId, scopeKey, msg.metadata ?? {}) &&
1037
- !wsConnectionManager.isClientSubscribedToScopeKey(clientId, scopeKey)) {
1038
- logSyncEvent({
1039
- event: 'sync.realtime.presence.rejected',
1040
- userId: auth.actorId,
1041
- reason: 'scope_not_authorized',
1042
- scopeKey,
1043
- });
1044
- }
1045
- break;
1046
- }
1047
- }
1048
- catch {
1049
- // Ignore malformed messages
1050
- }
1051
- },
1052
- });
1053
- });
1054
- }
1055
- return routes;
1056
- async function handleRealtimeEvent(event) {
1057
- if (!wsConnectionManager)
1058
- return;
1059
- if (event.type !== 'commit')
1060
- return;
1061
- if (event.sourceInstanceId && event.sourceInstanceId === instanceId)
1062
- return;
1063
- const commitSeq = event.commitSeq;
1064
- const partitionId = event.partitionId ?? 'default';
1065
- const scopeKeys = event.scopeKeys && event.scopeKeys.length > 0
1066
- ? event.scopeKeys
1067
- : await readCommitScopeKeys(options.db, commitSeq, partitionId);
1068
- if (scopeKeys.length === 0)
1069
- return;
1070
- wsConnectionManager.notifyScopeKeys(scopeKeys, commitSeq);
1071
- }
1072
- async function handleWsPush(msg, conn, auth, clientId) {
1073
- const actorId = auth.actorId;
1074
- const partitionId = auth.partitionId ?? 'default';
1075
- const requestId = typeof msg.requestId === 'string' ? msg.requestId : '';
1076
- if (!requestId)
1077
- return;
1078
- const traceContext = readTraceContextFromMessage(msg);
1079
- const timer = createSyncTimer();
1080
- try {
1081
- // Validate the push payload
1082
- const parsed = SyncPushRequestSchema.omit({ clientId: true }).safeParse(msg);
1083
- if (!parsed.success) {
1084
- const invalidDurationMs = timer();
1085
- conn.sendPushResponse({
1086
- requestId,
1087
- ok: false,
1088
- status: 'rejected',
1089
- results: [
1090
- { opIndex: 0, status: 'error', error: 'Invalid push payload' },
1091
- ],
1092
- });
1093
- recordRequestEventInBackground(() => ({
1094
- partitionId,
1095
- requestId,
1096
- traceId: traceContext.traceId,
1097
- spanId: traceContext.spanId,
1098
- eventType: 'push',
1099
- syncPath: 'ws-push',
1100
- actorId,
1101
- clientId,
1102
- transportPath: conn.transportPath,
1103
- statusCode: 400,
1104
- outcome: 'rejected',
1105
- responseStatus: normalizeResponseStatus(400, 'rejected'),
1106
- durationMs: invalidDurationMs,
1107
- errorCode: 'INVALID_PUSH_PAYLOAD',
1108
- errorMessage: 'Invalid push payload',
1109
- payloadSnapshot: {
1110
- request: msg,
1111
- response: {
1112
- ok: false,
1113
- status: 'rejected',
1114
- reason: 'invalid_push_payload',
1115
- },
1116
- },
1117
- }));
1118
- emitConsoleLiveEvent(consoleLiveEmitter, 'push', () => ({
1119
- partitionId,
1120
- requestId,
1121
- traceId: traceContext.traceId,
1122
- spanId: traceContext.spanId,
1123
- actorId,
1124
- clientId,
1125
- transportPath: conn.transportPath,
1126
- syncPath: 'ws-push',
1127
- outcome: 'rejected',
1128
- statusCode: 400,
1129
- durationMs: invalidDurationMs,
1130
- errorCode: 'INVALID_PUSH_PAYLOAD',
1131
- }));
1132
- return;
1133
- }
1134
- const pushOps = parsed.data.operations ?? [];
1135
- if (pushOps.length > maxOperationsPerPush) {
1136
- const rejectedDurationMs = timer();
1137
- conn.sendPushResponse({
1138
- requestId,
1139
- ok: false,
1140
- status: 'rejected',
1141
- results: [
1142
- {
1143
- opIndex: 0,
1144
- status: 'error',
1145
- error: `Maximum ${maxOperationsPerPush} operations per push`,
1146
- },
1147
- ],
1148
- });
1149
- recordRequestEventInBackground(() => ({
1150
- partitionId,
1151
- requestId,
1152
- traceId: traceContext.traceId,
1153
- spanId: traceContext.spanId,
1154
- eventType: 'push',
1155
- syncPath: 'ws-push',
1156
- actorId,
1157
- clientId,
1158
- transportPath: conn.transportPath,
1159
- statusCode: 400,
1160
- outcome: 'rejected',
1161
- responseStatus: normalizeResponseStatus(400, 'rejected'),
1162
- durationMs: rejectedDurationMs,
1163
- errorCode: 'MAX_OPERATIONS_EXCEEDED',
1164
- errorMessage: `Maximum ${maxOperationsPerPush} operations per push`,
1165
- operationCount: pushOps.length,
1166
- payloadSnapshot: {
1167
- request: {
1168
- clientId,
1169
- clientCommitId: parsed.data.clientCommitId,
1170
- schemaVersion: parsed.data.schemaVersion,
1171
- operations: parsed.data.operations,
1172
- },
1173
- response: {
1174
- ok: false,
1175
- status: 'rejected',
1176
- reason: 'max_operations_exceeded',
1177
- },
1178
- },
1179
- }));
1180
- emitConsoleLiveEvent(consoleLiveEmitter, 'push', () => ({
1181
- partitionId,
1182
- requestId,
1183
- traceId: traceContext.traceId,
1184
- spanId: traceContext.spanId,
1185
- actorId,
1186
- clientId,
1187
- transportPath: conn.transportPath,
1188
- syncPath: 'ws-push',
1189
- outcome: 'rejected',
1190
- statusCode: 400,
1191
- durationMs: rejectedDurationMs,
1192
- operationCount: pushOps.length,
1193
- errorCode: 'MAX_OPERATIONS_EXCEEDED',
1194
- }));
1195
- return;
1196
- }
1197
- const pushed = await pushCommit({
1198
- db: options.db,
1199
- dialect: options.dialect,
1200
- handlers: handlerRegistry,
1201
- auth,
1202
- request: {
1203
- clientId,
1204
- clientCommitId: parsed.data.clientCommitId,
1205
- operations: parsed.data.operations,
1206
- schemaVersion: parsed.data.schemaVersion,
1207
- },
1208
- });
1209
- const pushDurationMs = timer();
1210
- logSyncEvent({
1211
- event: 'sync.push',
1212
- userId: actorId,
1213
- durationMs: pushDurationMs,
1214
- operationCount: pushOps.length,
1215
- status: pushed.response.status,
1216
- commitSeq: pushed.response.commitSeq,
1217
- });
1218
- recordRequestEventInBackground(() => ({
1219
- partitionId,
1220
- requestId,
1221
- traceId: traceContext.traceId,
1222
- spanId: traceContext.spanId,
1223
- eventType: 'push',
1224
- syncPath: 'ws-push',
1225
- actorId,
1226
- clientId,
1227
- transportPath: conn.transportPath,
1228
- statusCode: 200,
1229
- outcome: pushed.response.status,
1230
- responseStatus: normalizeResponseStatus(200, pushed.response.status),
1231
- durationMs: pushDurationMs,
1232
- errorCode: firstPushErrorCode(pushed.response.results),
1233
- commitSeq: pushed.response.commitSeq,
1234
- operationCount: pushOps.length,
1235
- tables: pushed.affectedTables,
1236
- payloadSnapshot: {
1237
- request: {
1238
- clientId,
1239
- clientCommitId: parsed.data.clientCommitId,
1240
- schemaVersion: parsed.data.schemaVersion,
1241
- operations: parsed.data.operations,
1242
- },
1243
- response: pushed.response,
1244
- },
1245
- }));
1246
- emitConsoleLiveEvent(consoleLiveEmitter, 'push', () => ({
1247
- partitionId,
1248
- requestId,
1249
- traceId: traceContext.traceId,
1250
- spanId: traceContext.spanId,
1251
- actorId,
1252
- clientId,
1253
- transportPath: conn.transportPath,
1254
- syncPath: 'ws-push',
1255
- outcome: pushed.response.status,
1256
- statusCode: 200,
1257
- durationMs: pushDurationMs,
1258
- commitSeq: pushed.response.commitSeq ?? null,
1259
- operationCount: pushOps.length,
1260
- tables: pushed.affectedTables,
1261
- }));
1262
- const detectedConflicts = pushed.response.results.reduce((count, result) => count + (result.status === 'conflict' ? 1 : 0), 0);
1263
- if (detectedConflicts > 0) {
1264
- countSyncMetric('sync.conflicts.detected', detectedConflicts, {
1265
- attributes: {
1266
- syncPath: 'ws-push',
1267
- transportPath: conn.transportPath,
1268
- },
1269
- });
1270
- }
1271
- // WS notifications to other clients
1272
- if (wsConnectionManager &&
1273
- pushed.response.ok === true &&
1274
- pushed.response.status === 'applied' &&
1275
- typeof pushed.response.commitSeq === 'number') {
1276
- const scopeKeys = applyPartitionToScopeKeys(partitionId, pushed.scopeKeys);
1277
- if (scopeKeys.length > 0) {
1278
- wsConnectionManager.notifyScopeKeys(scopeKeys, pushed.response.commitSeq, {
1279
- excludeClientIds: [clientId],
1280
- changes: pushed.emittedChanges,
1281
- });
1282
- if (realtimeBroadcaster) {
1283
- realtimeBroadcaster
1284
- .publish({
1285
- type: 'commit',
1286
- commitSeq: pushed.response.commitSeq,
1287
- partitionId,
1288
- scopeKeys,
1289
- sourceInstanceId: instanceId,
1290
- })
1291
- .catch((error) => {
1292
- logAsyncFailureOnce('sync.realtime.broadcast_publish_failed', {
1293
- event: 'sync.realtime.broadcast_publish_failed',
1294
- userId: actorId,
1295
- clientId,
1296
- error: error instanceof Error ? error.message : String(error),
1297
- });
1298
- });
1299
- }
1300
- }
1301
- }
1302
- if (pushed.response.ok === true &&
1303
- pushed.response.status === 'applied' &&
1304
- typeof pushed.response.commitSeq === 'number') {
1305
- emitConsoleLiveEvent(consoleLiveEmitter, 'commit', () => ({
1306
- partitionId,
1307
- commitSeq: pushed.response.commitSeq,
1308
- actorId,
1309
- clientId,
1310
- affectedTables: pushed.affectedTables,
1311
- }));
1312
- }
1313
- conn.sendPushResponse({
1314
- requestId,
1315
- ok: pushed.response.ok,
1316
- status: pushed.response.status,
1317
- commitSeq: pushed.response.commitSeq,
1318
- results: pushed.response.results,
1319
- });
1320
- }
1321
- catch (err) {
1322
- const failedDurationMs = timer();
1323
- captureSyncException(err, {
1324
- event: 'sync.realtime.push_failed',
1325
- requestId,
1326
- clientId,
1327
- actorId,
1328
- partitionId,
1329
- });
1330
- const message = err instanceof Error ? err.message : 'Internal server error';
1331
- recordRequestEventInBackground(() => ({
1332
- partitionId,
1333
- requestId,
1334
- traceId: traceContext.traceId,
1335
- spanId: traceContext.spanId,
1336
- eventType: 'push',
1337
- syncPath: 'ws-push',
1338
- actorId,
1339
- clientId,
1340
- transportPath: conn.transportPath,
1341
- statusCode: 500,
1342
- outcome: 'error',
1343
- responseStatus: normalizeResponseStatus(500, 'error'),
1344
- durationMs: failedDurationMs,
1345
- errorCode: 'INTERNAL_SERVER_ERROR',
1346
- errorMessage: message,
1347
- payloadSnapshot: {
1348
- request: msg,
1349
- response: {
1350
- ok: false,
1351
- status: 'rejected',
1352
- reason: 'internal_server_error',
1353
- message,
1354
- },
1355
- },
1356
- }));
1357
- emitConsoleLiveEvent(consoleLiveEmitter, 'push', () => ({
1358
- partitionId,
1359
- requestId,
1360
- traceId: traceContext.traceId,
1361
- spanId: traceContext.spanId,
1362
- actorId,
1363
- clientId,
1364
- transportPath: conn.transportPath,
1365
- syncPath: 'ws-push',
1366
- outcome: 'error',
1367
- statusCode: 500,
1368
- durationMs: failedDurationMs,
1369
- errorCode: 'INTERNAL_SERVER_ERROR',
1370
- }));
1371
- conn.sendPushResponse({
1372
- requestId,
1373
- ok: false,
1374
- status: 'rejected',
1375
- results: [{ opIndex: 0, status: 'error', error: message }],
1376
- });
1377
- }
1378
- }
1379
- }
1380
- export function getSyncWebSocketConnectionManager(routes) {
1381
- return wsConnectionManagerMap.get(routes);
1382
- }
1383
- export function getSyncRealtimeUnsubscribe(routes) {
1384
- return realtimeUnsubscribeMap.get(routes);
1385
- }
1386
- function clampInt(value, min, max) {
1387
- return Math.max(min, Math.min(max, value));
1388
- }
1389
- function readTransportPath(c, queryValue) {
1390
- if (queryValue === 'relay' || queryValue === 'direct') {
1391
- return queryValue;
1392
- }
1393
- const headerValue = c.req.header('x-syncular-transport-path');
1394
- if (headerValue === 'relay' || headerValue === 'direct') {
1395
- return headerValue;
1396
- }
1397
- return 'direct';
1398
- }
1399
- function scopeValuesToScopeKeys(scopes) {
1400
- if (!scopes || typeof scopes !== 'object')
1401
- return [];
1402
- const scopeKeys = new Set();
1403
- for (const [key, value] of Object.entries(scopes)) {
1404
- if (!value)
1405
- continue;
1406
- const prefix = key.replace(/_id$/, '');
1407
- if (Array.isArray(value)) {
1408
- for (const v of value) {
1409
- if (typeof v !== 'string')
1410
- continue;
1411
- if (!v)
1412
- continue;
1413
- scopeKeys.add(`${prefix}:${v}`);
1414
- }
1415
- continue;
1416
- }
1417
- if (typeof value === 'string') {
1418
- if (!value)
1419
- continue;
1420
- scopeKeys.add(`${prefix}:${value}`);
1421
- continue;
1422
- }
1423
- // Best-effort: stringify scalars.
1424
- if (typeof value === 'number' || typeof value === 'bigint') {
1425
- scopeKeys.add(`${prefix}:${String(value)}`);
1426
- }
1427
- }
1428
- return Array.from(scopeKeys);
1429
- }
1430
- function partitionScopeKey(partitionId, scopeKey) {
1431
- return `${partitionId}::${scopeKey}`;
1432
- }
1433
- function applyPartitionToScopeKeys(partitionId, scopeKeys) {
1434
- const prefixed = new Set();
1435
- for (const scopeKey of scopeKeys) {
1436
- if (!scopeKey)
1437
- continue;
1438
- if (scopeKey.startsWith(`${partitionId}::`)) {
1439
- prefixed.add(scopeKey);
1440
- continue;
1441
- }
1442
- prefixed.add(partitionScopeKey(partitionId, scopeKey));
1443
- }
1444
- return Array.from(prefixed);
1445
- }
1446
- function normalizeScopeKeyForPartition(partitionId, scopeKey) {
1447
- if (scopeKey.startsWith(`${partitionId}::`))
1448
- return scopeKey;
1449
- if (scopeKey.includes('::'))
1450
- return '';
1451
- return partitionScopeKey(partitionId, scopeKey);
1452
- }
1453
- async function readCommitScopeKeys(db, commitSeq, partitionId) {
1454
- // Read scopes from the JSONB column and convert to scope strings
1455
- const rowsResult = await sql `
1456
- select scopes
1457
- from ${sql.table('sync_changes')}
1458
- where commit_seq = ${commitSeq}
1459
- and partition_id = ${partitionId}
1460
- `.execute(db);
1461
- const rows = rowsResult.rows;
1462
- const scopeKeys = new Set();
1463
- for (const row of rows) {
1464
- const scopes = typeof row.scopes === 'string' ? JSON.parse(row.scopes) : row.scopes;
1465
- for (const k of applyPartitionToScopeKeys(partitionId, scopeValuesToScopeKeys(scopes))) {
1466
- scopeKeys.add(k);
1467
- }
1468
- }
1469
- return Array.from(scopeKeys);
1470
- }
1471
- //# sourceMappingURL=routes.js.map