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