@syncular/server-hono 0.0.6-96 → 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 -3029
  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 -175
  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 -110
  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 -161
  71. package/dist/routes.d.ts.map +0 -1
  72. package/dist/routes.js +0 -1493
  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 -1626
  82. package/src/__tests__/console-ui.test.ts +0 -114
  83. package/src/__tests__/create-server.test.ts +0 -485
  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 -3936
  94. package/src/console/schemas.ts +0 -434
  95. package/src/console/types.ts +0 -185
  96. package/src/console/ui.ts +0 -100
  97. package/src/create-server.ts +0 -207
  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 -2059
  104. package/src/ws.ts +0 -802
package/src/routes.ts DELETED
@@ -1,2059 +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
-
10
- import {
11
- captureSyncException,
12
- countSyncMetric,
13
- createSyncTimer,
14
- distributionSyncMetric,
15
- ErrorResponseSchema,
16
- logSyncEvent,
17
- SyncCombinedRequestSchema,
18
- SyncCombinedResponseSchema,
19
- SyncPushRequestSchema,
20
- } from '@syncular/core';
21
- import type {
22
- ScopeCacheBackend,
23
- ServerSyncDialect,
24
- ServerTableHandler,
25
- SnapshotChunkStorage,
26
- SqlFamily,
27
- SyncCoreDb,
28
- SyncRealtimeBroadcaster,
29
- SyncRealtimeEvent,
30
- SyncServerAuth,
31
- } from '@syncular/server';
32
- import {
33
- type CompactOptions,
34
- createServerHandlerCollection,
35
- InvalidSubscriptionScopeError,
36
- type PruneOptions,
37
- type PullResult,
38
- pull,
39
- pushCommit,
40
- readSnapshotChunk,
41
- recordClientCursor,
42
- } from '@syncular/server';
43
- import type { Context, MiddlewareHandler } from 'hono';
44
- import { Hono } from 'hono';
45
-
46
- import type { UpgradeWebSocket } from 'hono/ws';
47
- import { describeRoute, resolver, validator as zValidator } from 'hono-openapi';
48
- import {
49
- type Kysely,
50
- type SelectQueryBuilder,
51
- type SqlBool,
52
- sql,
53
- } from 'kysely';
54
- import { z } from 'zod';
55
- import {
56
- createRateLimiter,
57
- DEFAULT_SYNC_RATE_LIMITS,
58
- type SyncRateLimitConfig,
59
- } from './rate-limit';
60
- import {
61
- createWebSocketConnection,
62
- type WebSocketConnection,
63
- WebSocketConnectionManager,
64
- } from './ws';
65
-
66
- /**
67
- * WeakMaps for storing Hono-instance-specific data without augmenting the type.
68
- */
69
- const wsConnectionManagerMap = new WeakMap<Hono, WebSocketConnectionManager>();
70
- const realtimeUnsubscribeMap = new WeakMap<Hono, () => void>();
71
-
72
- export interface SyncAuthResult extends SyncServerAuth {}
73
-
74
- /**
75
- * WebSocket configuration for realtime sync.
76
- *
77
- * Note: this endpoint is only a "wake up" mechanism; clients must still pull.
78
- */
79
- export interface SyncWebSocketConfig {
80
- enabled?: boolean;
81
- /**
82
- * Runtime-provided WebSocket upgrader (e.g. from `hono/bun`'s `createBunWebSocket()`).
83
- */
84
- upgradeWebSocket?: UpgradeWebSocket;
85
- heartbeatIntervalMs?: number;
86
- /**
87
- * Maximum number of concurrent WebSocket connections across the entire process.
88
- * Default: 5000
89
- */
90
- maxConnectionsTotal?: number;
91
- /**
92
- * Maximum number of concurrent WebSocket connections per clientId.
93
- * Default: 3
94
- */
95
- maxConnectionsPerClient?: number;
96
- }
97
-
98
- export interface SyncRoutesConfigWithRateLimit {
99
- /**
100
- * Max commits per pull request.
101
- * Default: 100
102
- */
103
- maxPullLimitCommits?: number;
104
- /**
105
- * Max subscriptions per pull request.
106
- * Default: 200
107
- */
108
- maxSubscriptionsPerPull?: number;
109
- /**
110
- * Max snapshot rows per snapshot page.
111
- * Default: 5000
112
- */
113
- maxPullLimitSnapshotRows?: number;
114
- /**
115
- * Max snapshot pages per subscription per pull response.
116
- * Default: 10
117
- */
118
- maxPullMaxSnapshotPages?: number;
119
- /**
120
- * Max operations per pushed commit.
121
- * Default: 200
122
- */
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
- };
140
- /**
141
- * Rate limiting configuration.
142
- * Set to false to disable all rate limiting.
143
- */
144
- rateLimit?: SyncRateLimitConfig | false;
145
- /**
146
- * WebSocket realtime configuration.
147
- */
148
- websocket?: SyncWebSocketConfig;
149
-
150
- /**
151
- * Optional pruning configuration. When enabled, the server periodically prunes
152
- * old commit history based on active client cursors.
153
- */
154
- prune?: {
155
- /** Minimum time between prune runs. Default: 5 minutes. */
156
- minIntervalMs?: number;
157
- /** Pruning watermark options. */
158
- options?: PruneOptions;
159
- };
160
-
161
- /**
162
- * Optional compaction configuration. When enabled, the server periodically
163
- * compacts older change history to reduce storage.
164
- */
165
- compact?: {
166
- /** Minimum time between compaction runs. Default: 30 minutes. */
167
- minIntervalMs?: number;
168
- /** Compaction options. */
169
- options?: CompactOptions;
170
- };
171
-
172
- /**
173
- * Optional multi-instance realtime broadcaster.
174
- * When provided, instances publish/subscribe commit wakeups via the broadcaster.
175
- */
176
- realtime?: {
177
- broadcaster: SyncRealtimeBroadcaster;
178
- /** Optional stable instance id (useful in tests). */
179
- instanceId?: string;
180
- };
181
- }
182
-
183
- export interface CreateSyncRoutesOptions<
184
- DB extends SyncCoreDb = SyncCoreDb,
185
- Auth extends SyncAuthResult = SyncAuthResult,
186
- F extends SqlFamily = SqlFamily,
187
- > {
188
- db: Kysely<DB>;
189
- dialect: ServerSyncDialect<F>;
190
- handlers: ServerTableHandler<DB, Auth>[];
191
- authenticate: (c: Context) => Promise<Auth | null>;
192
- sync?: SyncRoutesConfigWithRateLimit;
193
- wsConnectionManager?: WebSocketConnectionManager;
194
- /**
195
- * Optional snapshot chunk storage adapter.
196
- * When provided, stores snapshot chunk bodies in external storage
197
- * (S3, R2, etc.) instead of inline in the database.
198
- */
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;
205
- /**
206
- * Optional live emitter for console websocket activity feed.
207
- * When provided, sync lifecycle events are published to `/console/events/live`.
208
- */
209
- consoleLiveEmitter?: {
210
- emit(event: {
211
- type: 'push' | 'pull' | 'commit' | 'client_update';
212
- timestamp: string;
213
- data: Record<string, unknown>;
214
- }): void;
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>;
221
- }
222
-
223
- // ============================================================================
224
- // Route Schemas
225
- // ============================================================================
226
-
227
- const snapshotChunkParamsSchema = z.object({
228
- chunkId: z.string().min(1),
229
- });
230
-
231
- const DEFAULT_REQUEST_PAYLOAD_SNAPSHOT_MAX_BYTES = 128 * 1024;
232
-
233
- type TraceContext = {
234
- traceId: string | null;
235
- spanId: string | null;
236
- };
237
-
238
- function createOpaqueId(prefix: string): string {
239
- const randomPart =
240
- typeof crypto !== 'undefined' && 'randomUUID' in crypto
241
- ? crypto.randomUUID()
242
- : `${Date.now()}-${Math.random().toString(16).slice(2)}`;
243
- return `${prefix}-${randomPart}`;
244
- }
245
-
246
- function readRequestId(c: Context): string {
247
- const headerRequestId = c.req.header('x-request-id')?.trim();
248
- if (headerRequestId) return headerRequestId;
249
- return createOpaqueId('req');
250
- }
251
-
252
- function parseW3cTraceparent(
253
- traceparent: string | null | undefined
254
- ): TraceContext | null {
255
- if (!traceparent) return null;
256
- const parsed = traceparent.trim();
257
- const match = /^00-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}$/i.exec(parsed);
258
- if (!match) return null;
259
- const traceId = match[1]?.toLowerCase() ?? null;
260
- const spanId = match[2]?.toLowerCase() ?? null;
261
- if (!traceId || !spanId) return null;
262
- return { traceId, spanId };
263
- }
264
-
265
- function parseSentryTraceHeader(
266
- sentryTrace: string | null | undefined
267
- ): TraceContext | null {
268
- if (!sentryTrace) return null;
269
- const parsed = sentryTrace.trim();
270
- const match = /^([0-9a-f]{32})-([0-9a-f]{16})(?:-[01])?$/i.exec(parsed);
271
- if (!match) return null;
272
- const traceId = match[1]?.toLowerCase() ?? null;
273
- const spanId = match[2]?.toLowerCase() ?? null;
274
- if (!traceId || !spanId) return null;
275
- return { traceId, spanId };
276
- }
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
-
291
- function readTraceContext(c: Context): TraceContext {
292
- const traceparent = parseW3cTraceparent(c.req.header('traceparent'));
293
- if (traceparent) return traceparent;
294
-
295
- const sentryTrace = parseSentryTraceHeader(c.req.header('sentry-trace'));
296
- if (sentryTrace) return sentryTrace;
297
-
298
- return { traceId: null, spanId: null };
299
- }
300
-
301
- function readStringField(
302
- data: Record<string, unknown>,
303
- key: string
304
- ): string | null {
305
- const value = data[key];
306
- if (typeof value !== 'string') return null;
307
- const trimmed = value.trim();
308
- return trimmed.length > 0 ? trimmed : null;
309
- }
310
-
311
- function readTraceContextFromMessage(
312
- msg: Record<string, unknown>
313
- ): TraceContext {
314
- const directTraceId =
315
- readStringField(msg, 'traceId') ?? readStringField(msg, 'trace_id');
316
- const directSpanId =
317
- readStringField(msg, 'spanId') ?? readStringField(msg, 'span_id');
318
- if (directTraceId || directSpanId) {
319
- return { traceId: directTraceId, spanId: directSpanId };
320
- }
321
-
322
- const traceparent =
323
- readStringField(msg, 'traceparent') ?? readStringField(msg, 'traceParent');
324
- const parsedTraceparent = parseW3cTraceparent(traceparent);
325
- if (parsedTraceparent) return parsedTraceparent;
326
-
327
- const sentryTrace =
328
- readStringField(msg, 'sentry-trace') ??
329
- readStringField(msg, 'sentryTrace') ??
330
- readStringField(msg, 'sentry_trace');
331
- const parsedSentryTrace = parseSentryTraceHeader(sentryTrace);
332
- if (parsedSentryTrace) return parsedSentryTrace;
333
-
334
- return { traceId: null, spanId: null };
335
- }
336
-
337
- function normalizeResponseStatus(statusCode: number, outcome: string): string {
338
- if (statusCode >= 500) return 'server_error';
339
- if (statusCode >= 400) return 'client_error';
340
- if (statusCode >= 300) return 'redirect';
341
- if (statusCode >= 200) {
342
- if (outcome === 'error' || outcome === 'rejected') return 'failure';
343
- return 'success';
344
- }
345
- return 'unknown';
346
- }
347
-
348
- function firstPushErrorCode(results: unknown): string | null {
349
- if (!Array.isArray(results)) return null;
350
- for (const result of results) {
351
- if (!result || typeof result !== 'object') continue;
352
- const status = Reflect.get(result, 'status');
353
- if (status !== 'error') continue;
354
- const code = Reflect.get(result, 'code');
355
- if (typeof code === 'string' && code.length > 0) {
356
- return code;
357
- }
358
- }
359
- return null;
360
- }
361
-
362
- function summarizeScopeValues(
363
- scopes: Record<string, string | string[]>
364
- ): Record<string, string | string[]> | null {
365
- const summary: Record<string, string | string[]> = {};
366
- for (const [key, value] of Object.entries(scopes)) {
367
- if (typeof value === 'string') {
368
- summary[key] = value;
369
- continue;
370
- }
371
-
372
- if (Array.isArray(value)) {
373
- const normalized = value
374
- .filter((entry): entry is string => typeof entry === 'string')
375
- .slice(0, 20);
376
- summary[key] = normalized;
377
- }
378
- }
379
-
380
- return Object.keys(summary).length > 0 ? summary : null;
381
- }
382
-
383
- function summarizePullResponse(response: PullResult['response']): {
384
- subscriptions: Array<{
385
- id: string;
386
- status: 'active' | 'revoked';
387
- bootstrap: boolean;
388
- nextCursor: number;
389
- commitCount: number;
390
- changeCount: number;
391
- snapshotCount: number;
392
- snapshotRowCount: number;
393
- }>;
394
- } {
395
- return {
396
- subscriptions: response.subscriptions.map((subscription) => {
397
- const changeCount = subscription.commits.reduce(
398
- (totalChanges, commit) => totalChanges + commit.changes.length,
399
- 0
400
- );
401
- const snapshotCount = subscription.snapshots?.length ?? 0;
402
- const snapshotRowCount =
403
- subscription.snapshots?.reduce(
404
- (totalRows, snapshot) => totalRows + snapshot.rows.length,
405
- 0
406
- ) ?? 0;
407
-
408
- return {
409
- id: subscription.id,
410
- status: subscription.status,
411
- bootstrap: subscription.bootstrap,
412
- nextCursor: subscription.nextCursor,
413
- commitCount: subscription.commits.length,
414
- changeCount,
415
- snapshotCount,
416
- snapshotRowCount,
417
- };
418
- }),
419
- };
420
- }
421
-
422
- function countPullRows(response: PullResult['response']): number {
423
- return response.subscriptions.reduce((totalRows, subscription) => {
424
- const commitRows = subscription.commits.reduce(
425
- (totalChanges, commit) => totalChanges + commit.changes.length,
426
- 0
427
- );
428
- const snapshotRows =
429
- subscription.snapshots?.reduce(
430
- (totalSnapshotRows, snapshot) =>
431
- totalSnapshotRows + snapshot.rows.length,
432
- 0
433
- ) ?? 0;
434
- return totalRows + commitRows + snapshotRows;
435
- }, 0);
436
- }
437
-
438
- function encodePayloadSnapshot(value: unknown, maxBytes: number): string {
439
- try {
440
- const serialized = JSON.stringify(value);
441
- if (serialized.length <= maxBytes) {
442
- return serialized;
443
- }
444
- return JSON.stringify({
445
- truncated: true,
446
- originalSizeBytes: serialized.length,
447
- preview: serialized.slice(0, maxBytes),
448
- });
449
- } catch {
450
- return JSON.stringify({
451
- truncated: false,
452
- serializationError: 'Could not serialize payload snapshot',
453
- });
454
- }
455
- }
456
-
457
- function emitConsoleLiveEvent(
458
- emitter:
459
- | {
460
- emit(event: {
461
- type: 'push' | 'pull' | 'commit' | 'client_update';
462
- timestamp: string;
463
- data: Record<string, unknown>;
464
- }): void;
465
- }
466
- | undefined,
467
- type: 'push' | 'pull' | 'commit' | 'client_update',
468
- data: Record<string, unknown> | (() => Record<string, unknown>)
469
- ): void {
470
- if (!emitter) return;
471
- emitter.emit({
472
- type,
473
- timestamp: new Date().toISOString(),
474
- data: typeof data === 'function' ? data() : data,
475
- });
476
- }
477
-
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 {
483
- const routes = new Hono();
484
- routes.onError((error, c) => {
485
- captureSyncException(error, {
486
- event: 'sync.route.unhandled',
487
- method: c.req.method,
488
- path: c.req.path,
489
- });
490
- return c.text('Internal Server Error', 500);
491
- });
492
- const handlerRegistry = createServerHandlerCollection(options.handlers);
493
- const config = options.sync ?? {};
494
- const maxPullLimitCommits = config.maxPullLimitCommits ?? 100;
495
- const maxSubscriptionsPerPull = config.maxSubscriptionsPerPull ?? 200;
496
- const maxPullLimitSnapshotRows = config.maxPullLimitSnapshotRows ?? 5000;
497
- const maxPullMaxSnapshotPages = config.maxPullMaxSnapshotPages ?? 10;
498
- const maxOperationsPerPush = config.maxOperationsPerPush ?? 200;
499
- const consoleLiveEmitter = options.consoleLiveEmitter;
500
- const shouldEmitConsoleLiveEvents = consoleLiveEmitter !== undefined;
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
- });
521
-
522
- // -------------------------------------------------------------------------
523
- // Optional WebSocket manager (scope-key based wake-ups)
524
- // -------------------------------------------------------------------------
525
-
526
- const websocketConfig = config.websocket;
527
- if (websocketConfig?.enabled && !websocketConfig.upgradeWebSocket) {
528
- throw new Error(
529
- 'sync.websocket.enabled requires sync.websocket.upgradeWebSocket'
530
- );
531
- }
532
-
533
- const wsConnectionManager = websocketConfig?.enabled
534
- ? (options.wsConnectionManager ??
535
- new WebSocketConnectionManager({
536
- heartbeatIntervalMs: websocketConfig.heartbeatIntervalMs ?? 30_000,
537
- }))
538
- : null;
539
-
540
- if (wsConnectionManager) {
541
- wsConnectionManagerMap.set(routes, wsConnectionManager);
542
- }
543
-
544
- // -------------------------------------------------------------------------
545
- // Multi-instance realtime broadcaster (optional)
546
- // -------------------------------------------------------------------------
547
-
548
- const realtimeBroadcaster = config.realtime?.broadcaster ?? null;
549
- const instanceId =
550
- config.realtime?.instanceId ??
551
- (typeof crypto !== 'undefined' && 'randomUUID' in crypto
552
- ? crypto.randomUUID()
553
- : `${Date.now()}-${Math.random().toString(16).slice(2)}`);
554
- const loggedAsyncFailureKeys = new Set<string>();
555
- const logAsyncFailureOnce = (
556
- key: string,
557
- event: {
558
- event: string;
559
- error: string;
560
- [key: string]: unknown;
561
- }
562
- ) => {
563
- if (loggedAsyncFailureKeys.has(key)) return;
564
- loggedAsyncFailureKeys.add(key);
565
- logSyncEvent(event);
566
- };
567
-
568
- if (wsConnectionManager && realtimeBroadcaster) {
569
- const unsubscribe = realtimeBroadcaster.subscribe(
570
- (event: SyncRealtimeEvent) => {
571
- void handleRealtimeEvent(event).catch((error) => {
572
- logAsyncFailureOnce('sync.realtime.broadcast_delivery_failed', {
573
- event: 'sync.realtime.broadcast_delivery_failed',
574
- error: error instanceof Error ? error.message : String(error),
575
- sourceEventType: event.type,
576
- });
577
- });
578
- }
579
- );
580
-
581
- realtimeUnsubscribeMap.set(routes, unsubscribe);
582
- }
583
-
584
- // -------------------------------------------------------------------------
585
- // Request event recording (for console inspector)
586
- // -------------------------------------------------------------------------
587
-
588
- type RequestPayloadSnapshot = {
589
- request: unknown;
590
- response: unknown;
591
- };
592
-
593
- type RequestEvent = {
594
- partitionId: string;
595
- requestId: string;
596
- traceId?: string | null;
597
- spanId?: string | null;
598
- eventType: 'push' | 'pull';
599
- syncPath: 'http-combined' | 'ws-push';
600
- actorId: string;
601
- clientId: string;
602
- transportPath: 'direct' | 'relay';
603
- statusCode: number;
604
- outcome: string;
605
- responseStatus: string;
606
- durationMs: number;
607
- errorCode?: string | null;
608
- commitSeq?: number | null;
609
- operationCount?: number | null;
610
- rowCount?: number | null;
611
- subscriptionCount?: number | null;
612
- scopesSummary?: Record<string, string | string[]> | null;
613
- tables?: string[];
614
- errorMessage?: string | null;
615
- payloadRef?: string | null;
616
- payloadSnapshot?: RequestPayloadSnapshot | null;
617
- };
618
-
619
- const recordRequestEvent = async (event: RequestEvent) => {
620
- let payloadRef = event.payloadRef ?? null;
621
- if (event.payloadSnapshot) {
622
- const nextPayloadRef = payloadRef ?? createOpaqueId('payload');
623
- const nowIso = new Date().toISOString();
624
-
625
- try {
626
- await sql`
627
- INSERT INTO sync_request_payloads (
628
- payload_ref, partition_id, request_payload, response_payload, created_at
629
- ) VALUES (
630
- ${nextPayloadRef}, ${event.partitionId},
631
- ${encodePayloadSnapshot(
632
- event.payloadSnapshot.request,
633
- requestPayloadSnapshotMaxBytes
634
- )},
635
- ${encodePayloadSnapshot(
636
- event.payloadSnapshot.response,
637
- requestPayloadSnapshotMaxBytes
638
- )},
639
- ${nowIso}
640
- )
641
- ON CONFLICT (payload_ref) DO UPDATE SET
642
- partition_id = EXCLUDED.partition_id,
643
- request_payload = EXCLUDED.request_payload,
644
- response_payload = EXCLUDED.response_payload,
645
- created_at = EXCLUDED.created_at
646
- `.execute(options.db);
647
- payloadRef = nextPayloadRef;
648
- } catch (error) {
649
- payloadRef = null;
650
- logAsyncFailureOnce('sync.request_payload_record_failed', {
651
- event: 'sync.request_payload_record_failed',
652
- userId: event.actorId,
653
- clientId: event.clientId,
654
- requestEventType: event.eventType,
655
- error: error instanceof Error ? error.message : String(error),
656
- });
657
- }
658
- }
659
-
660
- const tablesValue = options.dialect.arrayToDb(event.tables ?? []);
661
- const scopesSummaryValue = event.scopesSummary
662
- ? JSON.stringify(event.scopesSummary)
663
- : null;
664
-
665
- await sql`
666
- INSERT INTO sync_request_events (
667
- partition_id, request_id, trace_id, span_id,
668
- event_type, sync_path, actor_id, client_id, transport_path,
669
- status_code, outcome, response_status, error_code,
670
- duration_ms, commit_seq, operation_count, row_count, subscription_count,
671
- scopes_summary, tables, error_message, payload_ref
672
- ) VALUES (
673
- ${event.partitionId}, ${event.requestId}, ${event.traceId ?? null},
674
- ${event.spanId ?? null}, ${event.eventType}, ${event.syncPath},
675
- ${event.actorId}, ${event.clientId}, ${event.transportPath},
676
- ${event.statusCode}, ${event.outcome}, ${event.responseStatus},
677
- ${event.errorCode ?? null}, ${event.durationMs}, ${event.commitSeq ?? null},
678
- ${event.operationCount ?? null}, ${event.rowCount ?? null},
679
- ${event.subscriptionCount ?? null}, ${scopesSummaryValue}, ${tablesValue},
680
- ${event.errorMessage ?? null}, ${payloadRef}
681
- )
682
- `.execute(options.db);
683
- };
684
-
685
- const recordRequestEventInBackground = (
686
- event: RequestEvent | (() => RequestEvent)
687
- ): void => {
688
- if (!shouldRecordRequestEvents) return;
689
-
690
- const resolvedEvent = typeof event === 'function' ? event() : event;
691
-
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
- });
702
- });
703
- };
704
-
705
- const authCache = new WeakMap<Context, Promise<Auth | null>>();
706
- const getAuth = (c: Context): Promise<Auth | null> => {
707
- const cached = authCache.get(c);
708
- if (cached) return cached;
709
- const pending = options.authenticate(c);
710
- authCache.set(c, pending);
711
- return pending;
712
- };
713
-
714
- // -------------------------------------------------------------------------
715
- // Rate limiting (optional)
716
- // -------------------------------------------------------------------------
717
-
718
- const rateLimitConfig = config.rateLimit;
719
- if (rateLimitConfig !== false) {
720
- const pullRateLimit =
721
- rateLimitConfig?.pull ?? DEFAULT_SYNC_RATE_LIMITS.pull;
722
- const pushRateLimit =
723
- rateLimitConfig?.push ?? DEFAULT_SYNC_RATE_LIMITS.push;
724
-
725
- const createAuthBasedRateLimiter = (
726
- limitConfig: Omit<SyncRateLimitConfig['pull'], never> | false | undefined
727
- ) => {
728
- if (limitConfig === false || !limitConfig) return null;
729
- return createRateLimiter({
730
- ...limitConfig,
731
- keyGenerator: async (c) => {
732
- const auth = await getAuth(c);
733
- return auth?.actorId ?? null;
734
- },
735
- });
736
- };
737
-
738
- const pullLimiter = createAuthBasedRateLimiter(pullRateLimit);
739
- const pushLimiter = createAuthBasedRateLimiter(pushRateLimit);
740
-
741
- const syncRateLimiter: MiddlewareHandler = async (c, next) => {
742
- if (!pullLimiter && !pushLimiter) return next();
743
-
744
- let shouldApplyPull = pullLimiter !== null;
745
- let shouldApplyPush = pushLimiter !== null;
746
-
747
- if (pullLimiter && pushLimiter && c.req.method === 'POST') {
748
- try {
749
- const parsed = await c.req.raw.clone().json();
750
- if (parsed !== null && typeof parsed === 'object') {
751
- shouldApplyPull = Reflect.get(parsed, 'pull') !== undefined;
752
- shouldApplyPush = Reflect.get(parsed, 'push') !== undefined;
753
- }
754
- } catch {
755
- // Keep default behavior and apply both limiters when payload parsing fails.
756
- }
757
- }
758
-
759
- if (pullLimiter && shouldApplyPull && pushLimiter && shouldApplyPush) {
760
- return pullLimiter(c, async () => {
761
- const pushResult = await pushLimiter(c, next);
762
- if (pushResult instanceof Response) {
763
- c.res = pushResult;
764
- }
765
- });
766
- }
767
- if (pullLimiter && shouldApplyPull) {
768
- return pullLimiter(c, next);
769
- }
770
- if (pushLimiter && shouldApplyPush) {
771
- return pushLimiter(c, next);
772
- }
773
-
774
- return next();
775
- };
776
-
777
- routes.use('/', syncRateLimiter);
778
- }
779
-
780
- // -------------------------------------------------------------------------
781
- // GET /health
782
- // -------------------------------------------------------------------------
783
-
784
- routes.get('/health', (c) => {
785
- return c.json({
786
- status: 'healthy',
787
- timestamp: new Date().toISOString(),
788
- });
789
- });
790
-
791
- // -------------------------------------------------------------------------
792
- // POST / (combined push + pull in one round-trip)
793
- // -------------------------------------------------------------------------
794
-
795
- routes.post(
796
- '/',
797
- describeRoute({
798
- tags: ['sync'],
799
- summary: 'Combined push and pull',
800
- description:
801
- 'Perform push and/or pull in a single request to reduce round-trips',
802
- responses: {
803
- 200: {
804
- description: 'Combined sync response',
805
- content: {
806
- 'application/json': {
807
- schema: resolver(SyncCombinedResponseSchema),
808
- },
809
- },
810
- },
811
- 400: {
812
- description: 'Invalid request',
813
- content: {
814
- 'application/json': { schema: resolver(ErrorResponseSchema) },
815
- },
816
- },
817
- 401: {
818
- description: 'Unauthenticated',
819
- content: {
820
- 'application/json': { schema: resolver(ErrorResponseSchema) },
821
- },
822
- },
823
- },
824
- }),
825
- zValidator('json', SyncCombinedRequestSchema),
826
- async (c) => {
827
- const auth = await getAuth(c);
828
- if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
829
- const partitionId = auth.partitionId ?? 'default';
830
-
831
- const body = c.req.valid('json');
832
- const clientId = body.clientId;
833
- const requestId = readRequestId(c);
834
- const traceContext = readTraceContext(c);
835
-
836
- let pushResponse:
837
- | undefined
838
- | Awaited<ReturnType<typeof pushCommit>>['response'];
839
- let pullResponse: undefined | PullResult['response'];
840
-
841
- // --- Push phase ---
842
- if (body.push) {
843
- const pushBody = body.push;
844
- const pushOps = pushBody.operations ?? [];
845
- if (pushOps.length > maxOperationsPerPush) {
846
- return c.json(
847
- {
848
- error: 'TOO_MANY_OPERATIONS',
849
- message: `Maximum ${maxOperationsPerPush} operations per push`,
850
- },
851
- 400
852
- );
853
- }
854
-
855
- const timer = createSyncTimer();
856
-
857
- const pushed = await pushCommit({
858
- db: options.db,
859
- dialect: options.dialect,
860
- handlers: handlerRegistry,
861
- auth,
862
- request: {
863
- clientId,
864
- clientCommitId: pushBody.clientCommitId,
865
- operations: pushBody.operations,
866
- schemaVersion: pushBody.schemaVersion,
867
- },
868
- });
869
-
870
- const pushDurationMs = timer();
871
-
872
- logSyncEvent({
873
- event: 'sync.push',
874
- userId: auth.actorId,
875
- durationMs: pushDurationMs,
876
- operationCount: pushOps.length,
877
- status: pushed.response.status,
878
- commitSeq: pushed.response.commitSeq,
879
- });
880
-
881
- recordRequestEventInBackground(() => ({
882
- partitionId,
883
- requestId,
884
- traceId: traceContext.traceId,
885
- spanId: traceContext.spanId,
886
- eventType: 'push',
887
- syncPath: 'http-combined',
888
- actorId: auth.actorId,
889
- clientId,
890
- transportPath: readTransportPath(c),
891
- statusCode: 200,
892
- outcome: pushed.response.status,
893
- responseStatus: normalizeResponseStatus(200, pushed.response.status),
894
- durationMs: pushDurationMs,
895
- errorCode: firstPushErrorCode(pushed.response.results),
896
- commitSeq: pushed.response.commitSeq,
897
- operationCount: pushOps.length,
898
- tables: pushed.affectedTables,
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,
910
- }));
911
- emitConsoleLiveEvent(consoleLiveEmitter, 'push', () => ({
912
- partitionId,
913
- requestId,
914
- traceId: traceContext.traceId,
915
- spanId: traceContext.spanId,
916
- actorId: auth.actorId,
917
- clientId,
918
- transportPath: readTransportPath(c),
919
- syncPath: 'http-combined',
920
- outcome: pushed.response.status,
921
- statusCode: 200,
922
- durationMs: pushDurationMs,
923
- commitSeq: pushed.response.commitSeq ?? null,
924
- operationCount: pushOps.length,
925
- tables: pushed.affectedTables,
926
- }));
927
-
928
- // WS notifications
929
- if (
930
- wsConnectionManager &&
931
- pushed.response.ok === true &&
932
- pushed.response.status === 'applied' &&
933
- typeof pushed.response.commitSeq === 'number'
934
- ) {
935
- const scopeKeys = applyPartitionToScopeKeys(
936
- partitionId,
937
- pushed.scopeKeys
938
- );
939
- if (scopeKeys.length > 0) {
940
- wsConnectionManager.notifyScopeKeys(
941
- scopeKeys,
942
- pushed.response.commitSeq,
943
- {
944
- excludeClientIds: [clientId],
945
- changes: pushed.emittedChanges,
946
- }
947
- );
948
-
949
- if (realtimeBroadcaster) {
950
- realtimeBroadcaster
951
- .publish({
952
- type: 'commit',
953
- commitSeq: pushed.response.commitSeq,
954
- partitionId,
955
- scopeKeys,
956
- sourceInstanceId: instanceId,
957
- })
958
- .catch((error) => {
959
- logAsyncFailureOnce(
960
- 'sync.realtime.broadcast_publish_failed',
961
- {
962
- event: 'sync.realtime.broadcast_publish_failed',
963
- userId: auth.actorId,
964
- clientId,
965
- error:
966
- error instanceof Error ? error.message : String(error),
967
- }
968
- );
969
- });
970
- }
971
- }
972
- }
973
-
974
- if (
975
- pushed.response.ok === true &&
976
- pushed.response.status === 'applied' &&
977
- typeof pushed.response.commitSeq === 'number'
978
- ) {
979
- emitConsoleLiveEvent(consoleLiveEmitter, 'commit', () => ({
980
- partitionId,
981
- commitSeq: pushed.response.commitSeq,
982
- actorId: auth.actorId,
983
- clientId,
984
- affectedTables: pushed.affectedTables,
985
- }));
986
- }
987
-
988
- pushResponse = pushed.response;
989
- }
990
-
991
- // --- Pull phase ---
992
- if (body.pull) {
993
- if (body.pull.subscriptions.length > maxSubscriptionsPerPull) {
994
- return c.json(
995
- {
996
- error: 'INVALID_REQUEST',
997
- message: `Too many subscriptions (max ${maxSubscriptionsPerPull})`,
998
- },
999
- 400
1000
- );
1001
- }
1002
-
1003
- const seenSubscriptionIds = new Set<string>();
1004
- for (const sub of body.pull.subscriptions) {
1005
- const id = sub.id;
1006
- if (seenSubscriptionIds.has(id)) {
1007
- return c.json(
1008
- {
1009
- error: 'INVALID_REQUEST',
1010
- message: `Duplicate subscription id: ${id}`,
1011
- },
1012
- 400
1013
- );
1014
- }
1015
- seenSubscriptionIds.add(id);
1016
- }
1017
-
1018
- const request = {
1019
- clientId,
1020
- limitCommits: clampInt(
1021
- body.pull.limitCommits ?? 50,
1022
- 1,
1023
- maxPullLimitCommits
1024
- ),
1025
- limitSnapshotRows: clampInt(
1026
- body.pull.limitSnapshotRows ?? 1000,
1027
- 1,
1028
- maxPullLimitSnapshotRows
1029
- ),
1030
- maxSnapshotPages: clampInt(
1031
- body.pull.maxSnapshotPages ?? 4,
1032
- 1,
1033
- maxPullMaxSnapshotPages
1034
- ),
1035
- dedupeRows: body.pull.dedupeRows === true,
1036
- subscriptions: body.pull.subscriptions.map((sub) => ({
1037
- id: sub.id,
1038
- table: sub.table,
1039
- scopes: (sub.scopes ?? {}) as Record<string, string | string[]>,
1040
- params: sub.params as Record<string, unknown>,
1041
- cursor: Math.max(-1, sub.cursor),
1042
- bootstrapState: sub.bootstrapState ?? null,
1043
- })),
1044
- };
1045
-
1046
- const timer = createSyncTimer();
1047
-
1048
- let pullResult: PullResult;
1049
- try {
1050
- pullResult = await pull({
1051
- db: options.db,
1052
- dialect: options.dialect,
1053
- handlers: handlerRegistry,
1054
- auth,
1055
- request,
1056
- chunkStorage: options.chunkStorage,
1057
- scopeCache: options.scopeCache,
1058
- });
1059
- } catch (err) {
1060
- if (err instanceof InvalidSubscriptionScopeError) {
1061
- return c.json(
1062
- { error: 'INVALID_SUBSCRIPTION', message: err.message },
1063
- 400
1064
- );
1065
- }
1066
- throw err;
1067
- }
1068
-
1069
- // Fire-and-forget bookkeeping
1070
- void recordClientCursor(options.db, options.dialect, {
1071
- partitionId,
1072
- clientId,
1073
- actorId: auth.actorId,
1074
- cursor: pullResult.clientCursor,
1075
- effectiveScopes: pullResult.effectiveScopes,
1076
- })
1077
- .then(() => {
1078
- emitConsoleLiveEvent(consoleLiveEmitter, 'client_update', () => ({
1079
- action: 'cursor_recorded',
1080
- partitionId,
1081
- actorId: auth.actorId,
1082
- clientId,
1083
- cursor: pullResult.clientCursor,
1084
- }));
1085
- })
1086
- .catch((error) => {
1087
- logAsyncFailureOnce('sync.client_cursor_record_failed', {
1088
- event: 'sync.client_cursor_record_failed',
1089
- userId: auth.actorId,
1090
- clientId,
1091
- error: error instanceof Error ? error.message : String(error),
1092
- });
1093
- });
1094
-
1095
- wsConnectionManager?.updateClientScopeKeys(
1096
- clientId,
1097
- applyPartitionToScopeKeys(
1098
- partitionId,
1099
- scopeValuesToScopeKeys(pullResult.effectiveScopes)
1100
- )
1101
- );
1102
-
1103
- const pullDurationMs = timer();
1104
-
1105
- logSyncEvent({
1106
- event: 'sync.pull',
1107
- userId: auth.actorId,
1108
- durationMs: pullDurationMs,
1109
- subscriptionCount: pullResult.response.subscriptions.length,
1110
- clientCursor: pullResult.clientCursor,
1111
- });
1112
-
1113
- recordRequestEventInBackground(() => {
1114
- const pullRowCount = shouldRecordRequestEvents
1115
- ? countPullRows(pullResult.response)
1116
- : null;
1117
- const scopesSummary = shouldRecordRequestEvents
1118
- ? summarizeScopeValues(pullResult.effectiveScopes)
1119
- : null;
1120
- const payloadSnapshot = shouldCaptureRequestPayloadSnapshots
1121
- ? {
1122
- request: {
1123
- clientId,
1124
- limitCommits: request.limitCommits,
1125
- limitSnapshotRows: request.limitSnapshotRows,
1126
- maxSnapshotPages: request.maxSnapshotPages,
1127
- dedupeRows: request.dedupeRows,
1128
- subscriptions: request.subscriptions.map((subscription) => ({
1129
- id: subscription.id,
1130
- table: subscription.table,
1131
- scopes: subscription.scopes,
1132
- cursor: subscription.cursor,
1133
- bootstrapState: subscription.bootstrapState,
1134
- })),
1135
- },
1136
- response: summarizePullResponse(pullResult.response),
1137
- }
1138
- : null;
1139
-
1140
- return {
1141
- partitionId,
1142
- requestId,
1143
- traceId: traceContext.traceId,
1144
- spanId: traceContext.spanId,
1145
- eventType: 'pull',
1146
- syncPath: 'http-combined',
1147
- actorId: auth.actorId,
1148
- clientId,
1149
- transportPath: readTransportPath(c),
1150
- statusCode: 200,
1151
- outcome: 'applied',
1152
- responseStatus: normalizeResponseStatus(200, 'applied'),
1153
- durationMs: pullDurationMs,
1154
- rowCount: pullRowCount,
1155
- subscriptionCount: request.subscriptions.length,
1156
- scopesSummary,
1157
- payloadSnapshot,
1158
- };
1159
- });
1160
- emitConsoleLiveEvent(consoleLiveEmitter, 'pull', () => {
1161
- const pullRowCount = shouldEmitConsoleLiveEvents
1162
- ? countPullRows(pullResult.response)
1163
- : null;
1164
- return {
1165
- partitionId,
1166
- requestId,
1167
- traceId: traceContext.traceId,
1168
- spanId: traceContext.spanId,
1169
- actorId: auth.actorId,
1170
- clientId,
1171
- transportPath: readTransportPath(c),
1172
- syncPath: 'http-combined',
1173
- outcome: 'applied',
1174
- statusCode: 200,
1175
- durationMs: pullDurationMs,
1176
- rowCount: pullRowCount,
1177
- subscriptionCount: request.subscriptions.length,
1178
- clientCursor: pullResult.clientCursor,
1179
- };
1180
- });
1181
-
1182
- pullResponse = pullResult.response;
1183
- }
1184
-
1185
- return c.json(
1186
- {
1187
- ok: true as const,
1188
- ...(pushResponse ? { push: pushResponse } : {}),
1189
- ...(pullResponse ? { pull: pullResponse } : {}),
1190
- },
1191
- 200
1192
- );
1193
- }
1194
- );
1195
-
1196
- // -------------------------------------------------------------------------
1197
- // GET /snapshot-chunks/:chunkId
1198
- // -------------------------------------------------------------------------
1199
-
1200
- routes.get(
1201
- '/snapshot-chunks/:chunkId',
1202
- describeRoute({
1203
- tags: ['sync'],
1204
- summary: 'Download snapshot chunk',
1205
- description: 'Download an encoded bootstrap snapshot chunk',
1206
- responses: {
1207
- 200: {
1208
- description: 'Snapshot chunk data (gzip-compressed framed JSON rows)',
1209
- content: {
1210
- 'application/octet-stream': {
1211
- schema: resolver(z.string()),
1212
- },
1213
- },
1214
- },
1215
- 304: {
1216
- description: 'Not modified (cached)',
1217
- },
1218
- 401: {
1219
- description: 'Unauthenticated',
1220
- content: {
1221
- 'application/json': { schema: resolver(ErrorResponseSchema) },
1222
- },
1223
- },
1224
- 403: {
1225
- description: 'Forbidden',
1226
- content: {
1227
- 'application/json': { schema: resolver(ErrorResponseSchema) },
1228
- },
1229
- },
1230
- 404: {
1231
- description: 'Not found',
1232
- content: {
1233
- 'application/json': { schema: resolver(ErrorResponseSchema) },
1234
- },
1235
- },
1236
- },
1237
- }),
1238
- zValidator('param', snapshotChunkParamsSchema),
1239
- async (c) => {
1240
- const auth = await getAuth(c);
1241
- if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
1242
- const partitionId = auth.partitionId ?? 'default';
1243
-
1244
- const { chunkId } = c.req.valid('param');
1245
-
1246
- const chunk = await readSnapshotChunk(options.db, chunkId, {
1247
- chunkStorage: options.chunkStorage,
1248
- });
1249
- if (!chunk) return c.json({ error: 'NOT_FOUND' }, 404);
1250
- if (chunk.partitionId !== partitionId) {
1251
- return c.json({ error: 'FORBIDDEN' }, 403);
1252
- }
1253
-
1254
- const nowIso = new Date().toISOString();
1255
- if (chunk.expiresAt <= nowIso) {
1256
- return c.json({ error: 'NOT_FOUND' }, 404);
1257
- }
1258
-
1259
- // Note: Snapshot chunks are created during authorized pull requests
1260
- // and have opaque IDs that expire. Additional authorization is handled
1261
- // at the pull layer via table-level resolveScopes.
1262
-
1263
- const etag = `"sha256:${chunk.sha256}"`;
1264
- const ifNoneMatch = c.req.header('if-none-match');
1265
- if (ifNoneMatch && ifNoneMatch === etag) {
1266
- return new Response(null, {
1267
- status: 304,
1268
- headers: {
1269
- ETag: etag,
1270
- 'Cache-Control': 'private, max-age=0',
1271
- Vary: 'Authorization',
1272
- },
1273
- });
1274
- }
1275
-
1276
- return new Response(chunk.body as BodyInit, {
1277
- status: 200,
1278
- headers: {
1279
- 'Content-Type': 'application/octet-stream',
1280
- 'Content-Encoding': 'gzip',
1281
- 'Content-Length': String(chunk.byteLength),
1282
- ETag: etag,
1283
- 'Cache-Control': 'private, max-age=0',
1284
- Vary: 'Authorization',
1285
- 'X-Sync-Chunk-Id': chunk.chunkId,
1286
- 'X-Sync-Chunk-Sha256': chunk.sha256,
1287
- 'X-Sync-Chunk-Encoding': chunk.encoding,
1288
- 'X-Sync-Chunk-Compression': chunk.compression,
1289
- },
1290
- });
1291
- }
1292
- );
1293
-
1294
- // -------------------------------------------------------------------------
1295
- // GET /realtime (optional WebSocket wake-ups)
1296
- // -------------------------------------------------------------------------
1297
-
1298
- if (wsConnectionManager && websocketConfig?.enabled) {
1299
- routes.get('/realtime', async (c) => {
1300
- const auth = await getAuth(c);
1301
- if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
1302
- const partitionId = auth.partitionId ?? 'default';
1303
-
1304
- const clientId = c.req.query('clientId');
1305
- if (!clientId || typeof clientId !== 'string') {
1306
- return c.json(
1307
- {
1308
- error: 'INVALID_REQUEST',
1309
- message: 'clientId query param is required',
1310
- },
1311
- 400
1312
- );
1313
- }
1314
- const realtimeTransportPath = readTransportPath(
1315
- c,
1316
- c.req.query('transportPath')
1317
- );
1318
-
1319
- // Load last-known effective scopes for this client (best-effort).
1320
- // Keeps /realtime lightweight and avoids sending large subscription payloads over the URL.
1321
- let initialScopeKeys: string[] = [];
1322
- try {
1323
- const cursorsQ = options.db.selectFrom(
1324
- 'sync_client_cursors'
1325
- ) as SelectQueryBuilder<
1326
- DB,
1327
- 'sync_client_cursors',
1328
- // biome-ignore lint/complexity/noBannedTypes: Kysely uses `{}` as the initial "no selected columns yet" marker.
1329
- {}
1330
- >;
1331
-
1332
- const row = await cursorsQ
1333
- .selectAll()
1334
- .where(sql<SqlBool>`partition_id = ${partitionId}`)
1335
- .where(sql<SqlBool>`client_id = ${clientId}`)
1336
- .executeTakeFirst();
1337
-
1338
- if (row && row.actor_id !== auth.actorId) {
1339
- return c.json({ error: 'FORBIDDEN' }, 403);
1340
- }
1341
-
1342
- const raw = row?.effective_scopes;
1343
- let parsed: unknown = raw;
1344
- if (typeof raw === 'string') {
1345
- try {
1346
- parsed = JSON.parse(raw);
1347
- } catch {
1348
- parsed = null;
1349
- }
1350
- }
1351
-
1352
- initialScopeKeys = applyPartitionToScopeKeys(
1353
- partitionId,
1354
- scopeValuesToScopeKeys(parsed)
1355
- );
1356
- } catch {
1357
- // ignore; realtime is best-effort
1358
- }
1359
-
1360
- const maxConnectionsTotal = websocketConfig.maxConnectionsTotal ?? 5000;
1361
- const maxConnectionsPerClient =
1362
- websocketConfig.maxConnectionsPerClient ?? 3;
1363
-
1364
- if (
1365
- maxConnectionsTotal > 0 &&
1366
- wsConnectionManager.getTotalConnections() >= maxConnectionsTotal
1367
- ) {
1368
- logSyncEvent({
1369
- event: 'sync.realtime.rejected',
1370
- userId: auth.actorId,
1371
- reason: 'max_total',
1372
- });
1373
- return c.json({ error: 'WEBSOCKET_CONNECTION_LIMIT_TOTAL' }, 429);
1374
- }
1375
-
1376
- if (
1377
- maxConnectionsPerClient > 0 &&
1378
- wsConnectionManager.getConnectionCount(clientId) >=
1379
- maxConnectionsPerClient
1380
- ) {
1381
- logSyncEvent({
1382
- event: 'sync.realtime.rejected',
1383
- userId: auth.actorId,
1384
- reason: 'max_per_client',
1385
- });
1386
- return c.json({ error: 'WEBSOCKET_CONNECTION_LIMIT_CLIENT' }, 429);
1387
- }
1388
-
1389
- logSyncEvent({ event: 'sync.realtime.connect', userId: auth.actorId });
1390
-
1391
- let unregister: (() => void) | null = null;
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
- };
1419
-
1420
- const upgradeWebSocket = websocketConfig.upgradeWebSocket;
1421
- if (!upgradeWebSocket) {
1422
- return c.json({ error: 'WEBSOCKET_NOT_CONFIGURED' }, 500);
1423
- }
1424
-
1425
- return upgradeWebSocket(c, {
1426
- onOpen(_evt, ws) {
1427
- const conn = createWebSocketConnection(ws, {
1428
- actorId: auth.actorId,
1429
- clientId,
1430
- transportPath: realtimeTransportPath,
1431
- });
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
- }
1447
-
1448
- unregister = wsConnectionManager.register(conn, initialScopeKeys);
1449
- conn.sendHeartbeat();
1450
- emitConsoleLiveEvent(consoleLiveEmitter, 'client_update', () => ({
1451
- action: 'realtime_connected',
1452
- actorId: auth.actorId,
1453
- clientId,
1454
- partitionId,
1455
- transportPath: realtimeTransportPath,
1456
- scopeCount: initialScopeKeys.length,
1457
- }));
1458
- },
1459
- onClose(_evt, _ws) {
1460
- unregister?.();
1461
- unregister = null;
1462
- connRef = null;
1463
- finishRealtimeSession('closed');
1464
- logSyncEvent({
1465
- event: 'sync.realtime.disconnect',
1466
- userId: auth.actorId,
1467
- });
1468
- emitConsoleLiveEvent(consoleLiveEmitter, 'client_update', () => ({
1469
- action: 'realtime_disconnected',
1470
- actorId: auth.actorId,
1471
- clientId,
1472
- partitionId,
1473
- }));
1474
- },
1475
- onError(_evt, _ws) {
1476
- unregister?.();
1477
- unregister = null;
1478
- connRef = null;
1479
- finishRealtimeSession('error');
1480
- logSyncEvent({
1481
- event: 'sync.realtime.disconnect',
1482
- userId: auth.actorId,
1483
- });
1484
- emitConsoleLiveEvent(consoleLiveEmitter, 'client_update', () => ({
1485
- action: 'realtime_error',
1486
- actorId: auth.actorId,
1487
- clientId,
1488
- partitionId,
1489
- }));
1490
- },
1491
- onMessage(evt, _ws) {
1492
- if (!connRef) return;
1493
- try {
1494
- const raw =
1495
- typeof evt.data === 'string' ? evt.data : String(evt.data);
1496
- const msg = JSON.parse(raw);
1497
- if (!msg || typeof msg !== 'object') return;
1498
-
1499
- if (msg.type === 'push') {
1500
- void handleWsPush(msg, connRef, auth, clientId);
1501
- return;
1502
- }
1503
-
1504
- if (msg.type !== 'presence' || !msg.scopeKey) return;
1505
-
1506
- const scopeKey = normalizeScopeKeyForPartition(
1507
- partitionId,
1508
- String(msg.scopeKey)
1509
- );
1510
- if (!scopeKey) return;
1511
-
1512
- switch (msg.action) {
1513
- case 'join':
1514
- if (
1515
- !wsConnectionManager.joinPresence(
1516
- clientId,
1517
- scopeKey,
1518
- msg.metadata
1519
- )
1520
- ) {
1521
- logSyncEvent({
1522
- event: 'sync.realtime.presence.rejected',
1523
- userId: auth.actorId,
1524
- reason: 'scope_not_authorized',
1525
- scopeKey,
1526
- });
1527
- return;
1528
- }
1529
- // Send presence snapshot back to the joining client
1530
- {
1531
- const entries = wsConnectionManager.getPresence(scopeKey);
1532
- connRef.sendPresence({
1533
- action: 'snapshot',
1534
- scopeKey,
1535
- entries,
1536
- });
1537
- }
1538
- break;
1539
- case 'leave':
1540
- wsConnectionManager.leavePresence(clientId, scopeKey);
1541
- break;
1542
- case 'update':
1543
- if (
1544
- !wsConnectionManager.updatePresenceMetadata(
1545
- clientId,
1546
- scopeKey,
1547
- msg.metadata ?? {}
1548
- ) &&
1549
- !wsConnectionManager.isClientSubscribedToScopeKey(
1550
- clientId,
1551
- scopeKey
1552
- )
1553
- ) {
1554
- logSyncEvent({
1555
- event: 'sync.realtime.presence.rejected',
1556
- userId: auth.actorId,
1557
- reason: 'scope_not_authorized',
1558
- scopeKey,
1559
- });
1560
- }
1561
- break;
1562
- }
1563
- } catch {
1564
- // Ignore malformed messages
1565
- }
1566
- },
1567
- });
1568
- });
1569
- }
1570
-
1571
- return routes;
1572
-
1573
- async function handleRealtimeEvent(event: SyncRealtimeEvent): Promise<void> {
1574
- if (!wsConnectionManager) return;
1575
- if (event.type !== 'commit') return;
1576
- if (event.sourceInstanceId && event.sourceInstanceId === instanceId) return;
1577
-
1578
- const commitSeq = event.commitSeq;
1579
- const partitionId = event.partitionId ?? 'default';
1580
- const scopeKeys =
1581
- event.scopeKeys && event.scopeKeys.length > 0
1582
- ? event.scopeKeys
1583
- : await readCommitScopeKeys(options.db, commitSeq, partitionId);
1584
-
1585
- if (scopeKeys.length === 0) return;
1586
- wsConnectionManager.notifyScopeKeys(scopeKeys, commitSeq);
1587
- }
1588
-
1589
- async function handleWsPush(
1590
- msg: Record<string, unknown>,
1591
- conn: WebSocketConnection,
1592
- auth: Auth,
1593
- clientId: string
1594
- ): Promise<void> {
1595
- const actorId = auth.actorId;
1596
- const partitionId = auth.partitionId ?? 'default';
1597
- const requestId = typeof msg.requestId === 'string' ? msg.requestId : '';
1598
- if (!requestId) return;
1599
- const traceContext = readTraceContextFromMessage(msg);
1600
- const timer = createSyncTimer();
1601
-
1602
- try {
1603
- // Validate the push payload
1604
- const parsed = SyncPushRequestSchema.omit({ clientId: true }).safeParse(
1605
- msg
1606
- );
1607
- if (!parsed.success) {
1608
- const invalidDurationMs = timer();
1609
- conn.sendPushResponse({
1610
- requestId,
1611
- ok: false,
1612
- status: 'rejected',
1613
- results: [
1614
- { opIndex: 0, status: 'error', error: 'Invalid push payload' },
1615
- ],
1616
- });
1617
- recordRequestEventInBackground(() => ({
1618
- partitionId,
1619
- requestId,
1620
- traceId: traceContext.traceId,
1621
- spanId: traceContext.spanId,
1622
- eventType: 'push',
1623
- syncPath: 'ws-push',
1624
- actorId,
1625
- clientId,
1626
- transportPath: conn.transportPath,
1627
- statusCode: 400,
1628
- outcome: 'rejected',
1629
- responseStatus: normalizeResponseStatus(400, 'rejected'),
1630
- durationMs: invalidDurationMs,
1631
- errorCode: 'INVALID_PUSH_PAYLOAD',
1632
- errorMessage: 'Invalid push payload',
1633
- payloadSnapshot: shouldCaptureRequestPayloadSnapshots
1634
- ? {
1635
- request: msg,
1636
- response: {
1637
- ok: false,
1638
- status: 'rejected',
1639
- reason: 'invalid_push_payload',
1640
- },
1641
- }
1642
- : null,
1643
- }));
1644
- emitConsoleLiveEvent(consoleLiveEmitter, 'push', () => ({
1645
- partitionId,
1646
- requestId,
1647
- traceId: traceContext.traceId,
1648
- spanId: traceContext.spanId,
1649
- actorId,
1650
- clientId,
1651
- transportPath: conn.transportPath,
1652
- syncPath: 'ws-push',
1653
- outcome: 'rejected',
1654
- statusCode: 400,
1655
- durationMs: invalidDurationMs,
1656
- errorCode: 'INVALID_PUSH_PAYLOAD',
1657
- }));
1658
- return;
1659
- }
1660
-
1661
- const pushOps = parsed.data.operations ?? [];
1662
- if (pushOps.length > maxOperationsPerPush) {
1663
- const rejectedDurationMs = timer();
1664
- conn.sendPushResponse({
1665
- requestId,
1666
- ok: false,
1667
- status: 'rejected',
1668
- results: [
1669
- {
1670
- opIndex: 0,
1671
- status: 'error',
1672
- error: `Maximum ${maxOperationsPerPush} operations per push`,
1673
- },
1674
- ],
1675
- });
1676
- recordRequestEventInBackground(() => ({
1677
- partitionId,
1678
- requestId,
1679
- traceId: traceContext.traceId,
1680
- spanId: traceContext.spanId,
1681
- eventType: 'push',
1682
- syncPath: 'ws-push',
1683
- actorId,
1684
- clientId,
1685
- transportPath: conn.transportPath,
1686
- statusCode: 400,
1687
- outcome: 'rejected',
1688
- responseStatus: normalizeResponseStatus(400, 'rejected'),
1689
- durationMs: rejectedDurationMs,
1690
- errorCode: 'MAX_OPERATIONS_EXCEEDED',
1691
- errorMessage: `Maximum ${maxOperationsPerPush} operations per push`,
1692
- operationCount: pushOps.length,
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,
1708
- }));
1709
- emitConsoleLiveEvent(consoleLiveEmitter, 'push', () => ({
1710
- partitionId,
1711
- requestId,
1712
- traceId: traceContext.traceId,
1713
- spanId: traceContext.spanId,
1714
- actorId,
1715
- clientId,
1716
- transportPath: conn.transportPath,
1717
- syncPath: 'ws-push',
1718
- outcome: 'rejected',
1719
- statusCode: 400,
1720
- durationMs: rejectedDurationMs,
1721
- operationCount: pushOps.length,
1722
- errorCode: 'MAX_OPERATIONS_EXCEEDED',
1723
- }));
1724
- return;
1725
- }
1726
-
1727
- const pushed = await pushCommit({
1728
- db: options.db,
1729
- dialect: options.dialect,
1730
- handlers: handlerRegistry,
1731
- auth,
1732
- request: {
1733
- clientId,
1734
- clientCommitId: parsed.data.clientCommitId,
1735
- operations: parsed.data.operations,
1736
- schemaVersion: parsed.data.schemaVersion,
1737
- },
1738
- });
1739
-
1740
- const pushDurationMs = timer();
1741
-
1742
- logSyncEvent({
1743
- event: 'sync.push',
1744
- userId: actorId,
1745
- durationMs: pushDurationMs,
1746
- operationCount: pushOps.length,
1747
- status: pushed.response.status,
1748
- commitSeq: pushed.response.commitSeq,
1749
- });
1750
-
1751
- recordRequestEventInBackground(() => ({
1752
- partitionId,
1753
- requestId,
1754
- traceId: traceContext.traceId,
1755
- spanId: traceContext.spanId,
1756
- eventType: 'push',
1757
- syncPath: 'ws-push',
1758
- actorId,
1759
- clientId,
1760
- transportPath: conn.transportPath,
1761
- statusCode: 200,
1762
- outcome: pushed.response.status,
1763
- responseStatus: normalizeResponseStatus(200, pushed.response.status),
1764
- durationMs: pushDurationMs,
1765
- errorCode: firstPushErrorCode(pushed.response.results),
1766
- commitSeq: pushed.response.commitSeq,
1767
- operationCount: pushOps.length,
1768
- tables: pushed.affectedTables,
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,
1780
- }));
1781
- emitConsoleLiveEvent(consoleLiveEmitter, 'push', () => ({
1782
- partitionId,
1783
- requestId,
1784
- traceId: traceContext.traceId,
1785
- spanId: traceContext.spanId,
1786
- actorId,
1787
- clientId,
1788
- transportPath: conn.transportPath,
1789
- syncPath: 'ws-push',
1790
- outcome: pushed.response.status,
1791
- statusCode: 200,
1792
- durationMs: pushDurationMs,
1793
- commitSeq: pushed.response.commitSeq ?? null,
1794
- operationCount: pushOps.length,
1795
- tables: pushed.affectedTables,
1796
- }));
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
-
1811
- // WS notifications to other clients
1812
- if (
1813
- wsConnectionManager &&
1814
- pushed.response.ok === true &&
1815
- pushed.response.status === 'applied' &&
1816
- typeof pushed.response.commitSeq === 'number'
1817
- ) {
1818
- const scopeKeys = applyPartitionToScopeKeys(
1819
- partitionId,
1820
- pushed.scopeKeys
1821
- );
1822
- if (scopeKeys.length > 0) {
1823
- wsConnectionManager.notifyScopeKeys(
1824
- scopeKeys,
1825
- pushed.response.commitSeq,
1826
- {
1827
- excludeClientIds: [clientId],
1828
- changes: pushed.emittedChanges,
1829
- }
1830
- );
1831
-
1832
- if (realtimeBroadcaster) {
1833
- realtimeBroadcaster
1834
- .publish({
1835
- type: 'commit',
1836
- commitSeq: pushed.response.commitSeq,
1837
- partitionId,
1838
- scopeKeys,
1839
- sourceInstanceId: instanceId,
1840
- })
1841
- .catch((error) => {
1842
- logAsyncFailureOnce('sync.realtime.broadcast_publish_failed', {
1843
- event: 'sync.realtime.broadcast_publish_failed',
1844
- userId: actorId,
1845
- clientId,
1846
- error: error instanceof Error ? error.message : String(error),
1847
- });
1848
- });
1849
- }
1850
- }
1851
- }
1852
-
1853
- if (
1854
- pushed.response.ok === true &&
1855
- pushed.response.status === 'applied' &&
1856
- typeof pushed.response.commitSeq === 'number'
1857
- ) {
1858
- emitConsoleLiveEvent(consoleLiveEmitter, 'commit', () => ({
1859
- partitionId,
1860
- commitSeq: pushed.response.commitSeq,
1861
- actorId,
1862
- clientId,
1863
- affectedTables: pushed.affectedTables,
1864
- }));
1865
- }
1866
-
1867
- conn.sendPushResponse({
1868
- requestId,
1869
- ok: pushed.response.ok,
1870
- status: pushed.response.status,
1871
- commitSeq: pushed.response.commitSeq,
1872
- results: pushed.response.results,
1873
- });
1874
- } catch (err) {
1875
- const failedDurationMs = timer();
1876
- captureSyncException(err, {
1877
- event: 'sync.realtime.push_failed',
1878
- requestId,
1879
- clientId,
1880
- actorId,
1881
- partitionId,
1882
- });
1883
- const message =
1884
- err instanceof Error ? err.message : 'Internal server error';
1885
- recordRequestEventInBackground(() => ({
1886
- partitionId,
1887
- requestId,
1888
- traceId: traceContext.traceId,
1889
- spanId: traceContext.spanId,
1890
- eventType: 'push',
1891
- syncPath: 'ws-push',
1892
- actorId,
1893
- clientId,
1894
- transportPath: conn.transportPath,
1895
- statusCode: 500,
1896
- outcome: 'error',
1897
- responseStatus: normalizeResponseStatus(500, 'error'),
1898
- durationMs: failedDurationMs,
1899
- errorCode: 'INTERNAL_SERVER_ERROR',
1900
- errorMessage: message,
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,
1912
- }));
1913
- emitConsoleLiveEvent(consoleLiveEmitter, 'push', () => ({
1914
- partitionId,
1915
- requestId,
1916
- traceId: traceContext.traceId,
1917
- spanId: traceContext.spanId,
1918
- actorId,
1919
- clientId,
1920
- transportPath: conn.transportPath,
1921
- syncPath: 'ws-push',
1922
- outcome: 'error',
1923
- statusCode: 500,
1924
- durationMs: failedDurationMs,
1925
- errorCode: 'INTERNAL_SERVER_ERROR',
1926
- }));
1927
- conn.sendPushResponse({
1928
- requestId,
1929
- ok: false,
1930
- status: 'rejected',
1931
- results: [{ opIndex: 0, status: 'error', error: message }],
1932
- });
1933
- }
1934
- }
1935
- }
1936
-
1937
- export function getSyncWebSocketConnectionManager(
1938
- routes: Hono
1939
- ): WebSocketConnectionManager | undefined {
1940
- return wsConnectionManagerMap.get(routes);
1941
- }
1942
-
1943
- export function getSyncRealtimeUnsubscribe(
1944
- routes: Hono
1945
- ): (() => void) | undefined {
1946
- return realtimeUnsubscribeMap.get(routes);
1947
- }
1948
-
1949
- function clampInt(value: number, min: number, max: number): number {
1950
- return Math.max(min, Math.min(max, value));
1951
- }
1952
-
1953
- function readTransportPath(
1954
- c: Context,
1955
- queryValue?: string | null
1956
- ): 'direct' | 'relay' {
1957
- if (queryValue === 'relay' || queryValue === 'direct') {
1958
- return queryValue;
1959
- }
1960
-
1961
- const headerValue = c.req.header('x-syncular-transport-path');
1962
- if (headerValue === 'relay' || headerValue === 'direct') {
1963
- return headerValue;
1964
- }
1965
-
1966
- return 'direct';
1967
- }
1968
-
1969
- function scopeValuesToScopeKeys(scopes: unknown): string[] {
1970
- if (!scopes || typeof scopes !== 'object') return [];
1971
- const scopeKeys = new Set<string>();
1972
-
1973
- for (const [key, value] of Object.entries(scopes)) {
1974
- if (!value) continue;
1975
- const prefix = key.replace(/_id$/, '');
1976
-
1977
- if (Array.isArray(value)) {
1978
- for (const v of value) {
1979
- if (typeof v !== 'string') continue;
1980
- if (!v) continue;
1981
- scopeKeys.add(`${prefix}:${v}`);
1982
- }
1983
- continue;
1984
- }
1985
-
1986
- if (typeof value === 'string') {
1987
- if (!value) continue;
1988
- scopeKeys.add(`${prefix}:${value}`);
1989
- continue;
1990
- }
1991
-
1992
- // Best-effort: stringify scalars.
1993
- if (typeof value === 'number' || typeof value === 'bigint') {
1994
- scopeKeys.add(`${prefix}:${String(value)}`);
1995
- }
1996
- }
1997
-
1998
- return Array.from(scopeKeys);
1999
- }
2000
-
2001
- function partitionScopeKey(partitionId: string, scopeKey: string): string {
2002
- return `${partitionId}::${scopeKey}`;
2003
- }
2004
-
2005
- function applyPartitionToScopeKeys(
2006
- partitionId: string,
2007
- scopeKeys: readonly string[]
2008
- ): string[] {
2009
- const prefixed = new Set<string>();
2010
- for (const scopeKey of scopeKeys) {
2011
- if (!scopeKey) continue;
2012
- if (scopeKey.startsWith(`${partitionId}::`)) {
2013
- prefixed.add(scopeKey);
2014
- continue;
2015
- }
2016
- prefixed.add(partitionScopeKey(partitionId, scopeKey));
2017
- }
2018
- return Array.from(prefixed);
2019
- }
2020
-
2021
- function normalizeScopeKeyForPartition(
2022
- partitionId: string,
2023
- scopeKey: string
2024
- ): string {
2025
- if (scopeKey.startsWith(`${partitionId}::`)) return scopeKey;
2026
- if (scopeKey.includes('::')) return '';
2027
- return partitionScopeKey(partitionId, scopeKey);
2028
- }
2029
-
2030
- async function readCommitScopeKeys<DB extends SyncCoreDb>(
2031
- db: Kysely<DB>,
2032
- commitSeq: number,
2033
- partitionId: string
2034
- ): Promise<string[]> {
2035
- // Read scopes from the JSONB column and convert to scope strings
2036
- const rowsResult = await sql<{ scopes: unknown }>`
2037
- select scopes
2038
- from ${sql.table('sync_changes')}
2039
- where commit_seq = ${commitSeq}
2040
- and partition_id = ${partitionId}
2041
- `.execute(db);
2042
- const rows = rowsResult.rows;
2043
-
2044
- const scopeKeys = new Set<string>();
2045
-
2046
- for (const row of rows) {
2047
- const scopes =
2048
- typeof row.scopes === 'string' ? JSON.parse(row.scopes) : row.scopes;
2049
-
2050
- for (const k of applyPartitionToScopeKeys(
2051
- partitionId,
2052
- scopeValuesToScopeKeys(scopes)
2053
- )) {
2054
- scopeKeys.add(k);
2055
- }
2056
- }
2057
-
2058
- return Array.from(scopeKeys);
2059
- }