@syncular/relay 0.0.1-60

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 (68) hide show
  1. package/dist/client-role/forward-engine.d.ts +63 -0
  2. package/dist/client-role/forward-engine.d.ts.map +1 -0
  3. package/dist/client-role/forward-engine.js +263 -0
  4. package/dist/client-role/forward-engine.js.map +1 -0
  5. package/dist/client-role/index.d.ts +9 -0
  6. package/dist/client-role/index.d.ts.map +1 -0
  7. package/dist/client-role/index.js +9 -0
  8. package/dist/client-role/index.js.map +1 -0
  9. package/dist/client-role/pull-engine.d.ts +70 -0
  10. package/dist/client-role/pull-engine.d.ts.map +1 -0
  11. package/dist/client-role/pull-engine.js +233 -0
  12. package/dist/client-role/pull-engine.js.map +1 -0
  13. package/dist/client-role/sequence-mapper.d.ts +65 -0
  14. package/dist/client-role/sequence-mapper.d.ts.map +1 -0
  15. package/dist/client-role/sequence-mapper.js +161 -0
  16. package/dist/client-role/sequence-mapper.js.map +1 -0
  17. package/dist/index.d.ts +37 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +44 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/migrate.d.ts +18 -0
  22. package/dist/migrate.d.ts.map +1 -0
  23. package/dist/migrate.js +99 -0
  24. package/dist/migrate.js.map +1 -0
  25. package/dist/mode-manager.d.ts +60 -0
  26. package/dist/mode-manager.d.ts.map +1 -0
  27. package/dist/mode-manager.js +114 -0
  28. package/dist/mode-manager.js.map +1 -0
  29. package/dist/realtime.d.ts +102 -0
  30. package/dist/realtime.d.ts.map +1 -0
  31. package/dist/realtime.js +305 -0
  32. package/dist/realtime.js.map +1 -0
  33. package/dist/relay.d.ts +188 -0
  34. package/dist/relay.d.ts.map +1 -0
  35. package/dist/relay.js +315 -0
  36. package/dist/relay.js.map +1 -0
  37. package/dist/schema.d.ts +158 -0
  38. package/dist/schema.d.ts.map +1 -0
  39. package/dist/schema.js +7 -0
  40. package/dist/schema.js.map +1 -0
  41. package/dist/server-role/index.d.ts +54 -0
  42. package/dist/server-role/index.d.ts.map +1 -0
  43. package/dist/server-role/index.js +198 -0
  44. package/dist/server-role/index.js.map +1 -0
  45. package/dist/server-role/pull.d.ts +25 -0
  46. package/dist/server-role/pull.d.ts.map +1 -0
  47. package/dist/server-role/pull.js +24 -0
  48. package/dist/server-role/pull.js.map +1 -0
  49. package/dist/server-role/push.d.ts +27 -0
  50. package/dist/server-role/push.d.ts.map +1 -0
  51. package/dist/server-role/push.js +98 -0
  52. package/dist/server-role/push.js.map +1 -0
  53. package/package.json +61 -0
  54. package/src/__tests__/relay.test.ts +464 -0
  55. package/src/bun-types.d.ts +50 -0
  56. package/src/client-role/forward-engine.ts +352 -0
  57. package/src/client-role/index.ts +9 -0
  58. package/src/client-role/pull-engine.ts +301 -0
  59. package/src/client-role/sequence-mapper.ts +201 -0
  60. package/src/index.ts +50 -0
  61. package/src/migrate.ts +113 -0
  62. package/src/mode-manager.ts +142 -0
  63. package/src/realtime.ts +370 -0
  64. package/src/relay.ts +421 -0
  65. package/src/schema.ts +171 -0
  66. package/src/server-role/index.ts +342 -0
  67. package/src/server-role/pull.ts +37 -0
  68. package/src/server-role/push.ts +130 -0
@@ -0,0 +1,342 @@
1
+ /**
2
+ * @syncular/relay - Server Role
3
+ *
4
+ * Hono routes for serving local clients.
5
+ */
6
+
7
+ import type { SyncPullRequest, SyncPushRequest } from '@syncular/core';
8
+ import {
9
+ createSyncTimer,
10
+ logSyncEvent,
11
+ ScopeValuesSchema,
12
+ SyncBootstrapStateSchema,
13
+ SyncPullRequestSchema,
14
+ SyncPushRequestSchema,
15
+ } from '@syncular/core';
16
+ import type { ServerSyncDialect, TableRegistry } from '@syncular/server';
17
+ import { recordClientCursor } from '@syncular/server';
18
+ import type { Context } from 'hono';
19
+ import { Hono } from 'hono';
20
+ import type { Kysely } from 'kysely';
21
+ import type { RelayRealtime } from '../realtime';
22
+ import type { RelayDatabase } from '../schema';
23
+ import { relayPull } from './pull';
24
+ import { relayPushCommit } from './push';
25
+
26
+ /**
27
+ * Options for creating relay routes.
28
+ */
29
+ export interface CreateRelayRoutesOptions<
30
+ DB extends RelayDatabase = RelayDatabase,
31
+ > {
32
+ db: Kysely<DB>;
33
+ dialect: ServerSyncDialect;
34
+ shapes: TableRegistry<DB>;
35
+ realtime: RelayRealtime;
36
+ /**
37
+ * Called after a commit is successfully applied locally.
38
+ * Use this to trigger forwarding and notify local clients.
39
+ */
40
+ onCommit?: (
41
+ localCommitSeq: number,
42
+ affectedTables: string[]
43
+ ) => Promise<void>;
44
+ /**
45
+ * Optional: authenticate requests. Return actor ID or null for unauthorized.
46
+ * If not provided, all requests are allowed with actor ID 'anonymous'.
47
+ */
48
+ authenticate?: (c: Context) => Promise<{ actorId: string } | null>;
49
+ /**
50
+ * Max operations per pushed commit (default: 200).
51
+ */
52
+ maxOperationsPerPush?: number;
53
+ /**
54
+ * Max subscriptions per pull request (default: 200).
55
+ */
56
+ maxSubscriptionsPerPull?: number;
57
+ /**
58
+ * Max commits per pull request (default: 100).
59
+ */
60
+ maxPullLimitCommits?: number;
61
+ }
62
+
63
+ function clampInt(value: number, min: number, max: number): number {
64
+ return Math.max(min, Math.min(max, value));
65
+ }
66
+
67
+ function isRecord(value: unknown): value is Record<string, unknown> {
68
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
69
+ }
70
+
71
+ /**
72
+ * Create Hono routes for relay server-role endpoints.
73
+ *
74
+ * Provides:
75
+ * - POST /pull (commit stream + optional bootstrap snapshots)
76
+ * - POST /push (commit ingestion)
77
+ * - GET /realtime (WebSocket wake-up notifications)
78
+ */
79
+ export function createRelayRoutes<DB extends RelayDatabase = RelayDatabase>(
80
+ options: CreateRelayRoutesOptions<DB>
81
+ ): Hono {
82
+ const routes = new Hono();
83
+ const maxOperationsPerPush = options.maxOperationsPerPush ?? 200;
84
+ const maxSubscriptionsPerPull = options.maxSubscriptionsPerPull ?? 200;
85
+ const maxPullLimitCommits = options.maxPullLimitCommits ?? 100;
86
+
87
+ const authenticate =
88
+ options.authenticate ?? (async () => ({ actorId: 'anonymous' }));
89
+
90
+ // POST /pull
91
+ routes.post('/pull', async (c) => {
92
+ const auth = await authenticate(c);
93
+ if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
94
+
95
+ const rawBody: unknown = await c.req.json();
96
+ const timer = createSyncTimer();
97
+
98
+ if (!isRecord(rawBody)) {
99
+ return c.json(
100
+ { error: 'INVALID_REQUEST', message: 'Invalid JSON body' },
101
+ 400
102
+ );
103
+ }
104
+
105
+ if (!rawBody.clientId || typeof rawBody.clientId !== 'string') {
106
+ return c.json(
107
+ { error: 'INVALID_REQUEST', message: 'clientId is required' },
108
+ 400
109
+ );
110
+ }
111
+
112
+ if (!Array.isArray(rawBody.subscriptions)) {
113
+ return c.json(
114
+ {
115
+ error: 'INVALID_REQUEST',
116
+ message: 'subscriptions array is required',
117
+ },
118
+ 400
119
+ );
120
+ }
121
+
122
+ if (rawBody.subscriptions.length > maxSubscriptionsPerPull) {
123
+ return c.json(
124
+ {
125
+ error: 'INVALID_REQUEST',
126
+ message: `Too many subscriptions (max ${maxSubscriptionsPerPull})`,
127
+ },
128
+ 400
129
+ );
130
+ }
131
+
132
+ const subscriptions: SyncPullRequest['subscriptions'] = [];
133
+ for (const subValue of rawBody.subscriptions) {
134
+ if (!isRecord(subValue)) {
135
+ return c.json(
136
+ { error: 'INVALID_REQUEST', message: 'Invalid subscription entry' },
137
+ 400
138
+ );
139
+ }
140
+
141
+ const id = typeof subValue.id === 'string' ? subValue.id : null;
142
+ const shape = typeof subValue.shape === 'string' ? subValue.shape : null;
143
+ if (!id || !shape) {
144
+ return c.json(
145
+ {
146
+ error: 'INVALID_REQUEST',
147
+ message: 'Subscription id/shape required',
148
+ },
149
+ 400
150
+ );
151
+ }
152
+
153
+ const scopesParsed = ScopeValuesSchema.safeParse(subValue.scopes);
154
+ if (!scopesParsed.success) {
155
+ return c.json(
156
+ { error: 'INVALID_REQUEST', message: 'Invalid subscription scopes' },
157
+ 400
158
+ );
159
+ }
160
+
161
+ const rawParams = subValue.params;
162
+ if (rawParams !== undefined && !isRecord(rawParams)) {
163
+ return c.json(
164
+ { error: 'INVALID_REQUEST', message: 'Invalid subscription params' },
165
+ 400
166
+ );
167
+ }
168
+
169
+ const cursor =
170
+ typeof subValue.cursor === 'number' && Number.isInteger(subValue.cursor)
171
+ ? Math.max(-1, subValue.cursor)
172
+ : -1;
173
+
174
+ const rawBootstrapState = subValue.bootstrapState;
175
+ const bootstrapState =
176
+ rawBootstrapState === undefined || rawBootstrapState === null
177
+ ? null
178
+ : (() => {
179
+ const parsed =
180
+ SyncBootstrapStateSchema.safeParse(rawBootstrapState);
181
+ if (!parsed.success) return null;
182
+ return parsed.data;
183
+ })();
184
+
185
+ if (
186
+ rawBootstrapState !== undefined &&
187
+ rawBootstrapState !== null &&
188
+ bootstrapState === null
189
+ ) {
190
+ return c.json(
191
+ {
192
+ error: 'INVALID_REQUEST',
193
+ message: 'Invalid subscription bootstrapState',
194
+ },
195
+ 400
196
+ );
197
+ }
198
+
199
+ subscriptions.push({
200
+ id,
201
+ shape,
202
+ scopes: scopesParsed.data,
203
+ params: rawParams,
204
+ cursor,
205
+ bootstrapState,
206
+ });
207
+ }
208
+
209
+ const request: SyncPullRequest = {
210
+ clientId: rawBody.clientId,
211
+ limitCommits: clampInt(
212
+ typeof rawBody.limitCommits === 'number' &&
213
+ Number.isInteger(rawBody.limitCommits)
214
+ ? rawBody.limitCommits
215
+ : 50,
216
+ 1,
217
+ maxPullLimitCommits
218
+ ),
219
+ limitSnapshotRows: clampInt(
220
+ typeof rawBody.limitSnapshotRows === 'number' &&
221
+ Number.isInteger(rawBody.limitSnapshotRows)
222
+ ? rawBody.limitSnapshotRows
223
+ : 1000,
224
+ 1,
225
+ 5000
226
+ ),
227
+ maxSnapshotPages: clampInt(
228
+ typeof rawBody.maxSnapshotPages === 'number' &&
229
+ Number.isInteger(rawBody.maxSnapshotPages)
230
+ ? rawBody.maxSnapshotPages
231
+ : 1,
232
+ 1,
233
+ 10
234
+ ),
235
+ dedupeRows:
236
+ typeof rawBody.dedupeRows === 'boolean'
237
+ ? rawBody.dedupeRows
238
+ : undefined,
239
+ subscriptions,
240
+ };
241
+
242
+ const validatedRequest = SyncPullRequestSchema.safeParse(request);
243
+ if (!validatedRequest.success) {
244
+ return c.json(
245
+ { error: 'INVALID_REQUEST', message: 'Invalid pull request' },
246
+ 400
247
+ );
248
+ }
249
+
250
+ const pullResult = await relayPull({
251
+ db: options.db,
252
+ dialect: options.dialect,
253
+ shapes: options.shapes,
254
+ actorId: auth.actorId,
255
+ request: validatedRequest.data,
256
+ });
257
+
258
+ await recordClientCursor(options.db, options.dialect, {
259
+ clientId: validatedRequest.data.clientId,
260
+ actorId: auth.actorId,
261
+ cursor: pullResult.clientCursor,
262
+ effectiveScopes: pullResult.effectiveScopes,
263
+ });
264
+
265
+ // Notify realtime about updated scope values
266
+ const tables = Object.keys(pullResult.effectiveScopes);
267
+ options.realtime.updateClientTables(request.clientId, tables);
268
+
269
+ logSyncEvent({
270
+ event: 'relay.pull',
271
+ userId: auth.actorId,
272
+ durationMs: timer(),
273
+ subscriptionCount: pullResult.response.subscriptions.length,
274
+ clientCursor: pullResult.clientCursor,
275
+ });
276
+
277
+ return c.json(pullResult.response);
278
+ });
279
+
280
+ // POST /push
281
+ routes.post('/push', async (c) => {
282
+ const auth = await authenticate(c);
283
+ if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
284
+
285
+ const rawBody: unknown = await c.req.json();
286
+ const parsed = SyncPushRequestSchema.safeParse(rawBody);
287
+ if (!parsed.success) {
288
+ return c.json(
289
+ { error: 'INVALID_REQUEST', message: 'Invalid push request' },
290
+ 400
291
+ );
292
+ }
293
+
294
+ const body: SyncPushRequest = parsed.data;
295
+
296
+ if (body.operations.length > maxOperationsPerPush) {
297
+ return c.json(
298
+ {
299
+ error: 'TOO_MANY_OPERATIONS',
300
+ message: `Maximum ${maxOperationsPerPush} operations per push`,
301
+ },
302
+ 400
303
+ );
304
+ }
305
+
306
+ const timer = createSyncTimer();
307
+
308
+ const pushed = await relayPushCommit({
309
+ db: options.db,
310
+ dialect: options.dialect,
311
+ shapes: options.shapes,
312
+ actorId: auth.actorId,
313
+ request: body,
314
+ });
315
+
316
+ logSyncEvent({
317
+ event: 'relay.push',
318
+ userId: auth.actorId,
319
+ durationMs: timer(),
320
+ operationCount: body.operations.length,
321
+ status: pushed.response.status,
322
+ commitSeq: pushed.response.commitSeq,
323
+ });
324
+
325
+ // Notify about the commit
326
+ if (
327
+ pushed.response.ok === true &&
328
+ pushed.response.status === 'applied' &&
329
+ typeof pushed.response.commitSeq === 'number' &&
330
+ pushed.affectedTables.length > 0
331
+ ) {
332
+ await options.onCommit?.(
333
+ pushed.response.commitSeq,
334
+ pushed.affectedTables
335
+ );
336
+ }
337
+
338
+ return c.json(pushed.response);
339
+ });
340
+
341
+ return routes;
342
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * @syncular/relay - Server Role Pull Handler
3
+ *
4
+ * Handles pulls from local clients, serving changes stored locally
5
+ * on the relay.
6
+ */
7
+
8
+ import type { SyncPullRequest } from '@syncular/core';
9
+ import type { ServerSyncDialect, TableRegistry } from '@syncular/server';
10
+ import { type PullResult, pull } from '@syncular/server';
11
+ import type { Kysely } from 'kysely';
12
+ import type { RelayDatabase } from '../schema';
13
+
14
+ /**
15
+ * Pull commits for a local client from the relay.
16
+ *
17
+ * This wraps the standard server pull with relay-specific logic:
18
+ * - Restricts scopes to those the relay subscribes to
19
+ */
20
+ export async function relayPull<
21
+ DB extends RelayDatabase = RelayDatabase,
22
+ >(args: {
23
+ db: Kysely<DB>;
24
+ dialect: ServerSyncDialect;
25
+ shapes: TableRegistry<DB>;
26
+ actorId: string;
27
+ request: SyncPullRequest;
28
+ }): Promise<PullResult> {
29
+ // Use the standard pull - scope authorization is handled by shapes
30
+ return pull({
31
+ db: args.db,
32
+ dialect: args.dialect,
33
+ shapes: args.shapes,
34
+ actorId: args.actorId,
35
+ request: args.request,
36
+ });
37
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * @syncular/relay - Server Role Push Handler
3
+ *
4
+ * Handles pushes from local clients, storing them locally and
5
+ * queueing them for forwarding to the main server.
6
+ */
7
+
8
+ import type { SyncPushRequest } from '@syncular/core';
9
+ import type { ServerSyncDialect, TableRegistry } from '@syncular/server';
10
+ import { type PushCommitResult, pushCommit } from '@syncular/server';
11
+ import { type Kysely, sql } from 'kysely';
12
+ import type { RelayDatabase } from '../schema';
13
+
14
+ function randomId(): string {
15
+ if (
16
+ typeof crypto !== 'undefined' &&
17
+ typeof crypto.randomUUID === 'function'
18
+ ) {
19
+ return crypto.randomUUID();
20
+ }
21
+ return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
22
+ }
23
+
24
+ /**
25
+ * Push a commit from a local client to the relay.
26
+ *
27
+ * This wraps the standard server pushCommit with relay-specific logic:
28
+ * 1. Validates that operations are within the relay's scope
29
+ * 2. Stores the commit locally
30
+ * 3. Enqueues the commit for forwarding to the main server
31
+ */
32
+ export async function relayPushCommit<
33
+ DB extends RelayDatabase = RelayDatabase,
34
+ >(args: {
35
+ db: Kysely<DB>;
36
+ dialect: ServerSyncDialect;
37
+ shapes: TableRegistry<DB>;
38
+ actorId: string;
39
+ request: SyncPushRequest;
40
+ }): Promise<PushCommitResult> {
41
+ const { request } = args;
42
+
43
+ // Use the standard pushCommit - scope authorization is handled by shapes
44
+ const result = await pushCommit({
45
+ db: args.db,
46
+ dialect: args.dialect,
47
+ shapes: args.shapes,
48
+ actorId: args.actorId,
49
+ request,
50
+ });
51
+
52
+ // If the commit was applied, enqueue it for forwarding to main server
53
+ if (
54
+ result.response.ok === true &&
55
+ result.response.status === 'applied' &&
56
+ typeof result.response.commitSeq === 'number'
57
+ ) {
58
+ await enqueueForForwarding(args.db, {
59
+ localCommitSeq: result.response.commitSeq,
60
+ clientId: request.clientId,
61
+ clientCommitId: request.clientCommitId,
62
+ operations: request.operations,
63
+ schemaVersion: request.schemaVersion,
64
+ });
65
+ }
66
+
67
+ return result;
68
+ }
69
+
70
+ /**
71
+ * Enqueue a locally-committed change for forwarding to the main server.
72
+ */
73
+ async function enqueueForForwarding<DB extends RelayDatabase>(
74
+ db: Kysely<DB>,
75
+ args: {
76
+ localCommitSeq: number;
77
+ clientId: string;
78
+ clientCommitId: string;
79
+ operations: SyncPushRequest['operations'];
80
+ schemaVersion: number;
81
+ }
82
+ ): Promise<void> {
83
+ const now = Date.now();
84
+
85
+ await sql`
86
+ insert into ${sql.table('relay_forward_outbox')} (
87
+ id,
88
+ local_commit_seq,
89
+ client_id,
90
+ client_commit_id,
91
+ operations_json,
92
+ schema_version,
93
+ status,
94
+ main_commit_seq,
95
+ error,
96
+ last_response_json,
97
+ created_at,
98
+ updated_at,
99
+ attempt_count
100
+ )
101
+ values (
102
+ ${randomId()},
103
+ ${args.localCommitSeq},
104
+ ${args.clientId},
105
+ ${args.clientCommitId},
106
+ ${JSON.stringify(args.operations)},
107
+ ${args.schemaVersion},
108
+ 'pending',
109
+ ${null},
110
+ ${null},
111
+ ${null},
112
+ ${now},
113
+ ${now},
114
+ ${0}
115
+ )
116
+ `.execute(db);
117
+
118
+ // Also create a sequence map entry for this commit
119
+ await sql`
120
+ insert into ${sql.table('relay_sequence_map')} (
121
+ local_commit_seq,
122
+ main_commit_seq,
123
+ status,
124
+ created_at,
125
+ updated_at
126
+ )
127
+ values (${args.localCommitSeq}, ${null}, 'pending', ${now}, ${now})
128
+ on conflict (local_commit_seq) do nothing
129
+ `.execute(db);
130
+ }