@syncular/relay 0.0.1 → 0.0.2-126

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 (39) hide show
  1. package/README.md +41 -0
  2. package/dist/client-role/forward-engine.d.ts.map +1 -1
  3. package/dist/client-role/forward-engine.js +11 -11
  4. package/dist/client-role/forward-engine.js.map +1 -1
  5. package/dist/client-role/index.js +3 -3
  6. package/dist/client-role/pull-engine.d.ts +2 -2
  7. package/dist/client-role/pull-engine.d.ts.map +1 -1
  8. package/dist/client-role/pull-engine.js +38 -18
  9. package/dist/client-role/pull-engine.js.map +1 -1
  10. package/dist/index.d.ts +2 -2
  11. package/dist/index.js +9 -9
  12. package/dist/mode-manager.js +1 -1
  13. package/dist/mode-manager.js.map +1 -1
  14. package/dist/relay.d.ts +7 -6
  15. package/dist/relay.d.ts.map +1 -1
  16. package/dist/relay.js +37 -31
  17. package/dist/relay.js.map +1 -1
  18. package/dist/server-role/index.d.ts +1 -1
  19. package/dist/server-role/index.d.ts.map +1 -1
  20. package/dist/server-role/index.js +9 -12
  21. package/dist/server-role/index.js.map +1 -1
  22. package/dist/server-role/pull.d.ts +1 -1
  23. package/dist/server-role/pull.d.ts.map +1 -1
  24. package/dist/server-role/pull.js +2 -2
  25. package/dist/server-role/pull.js.map +1 -1
  26. package/dist/server-role/push.d.ts +1 -1
  27. package/dist/server-role/push.d.ts.map +1 -1
  28. package/dist/server-role/push.js +23 -27
  29. package/dist/server-role/push.js.map +1 -1
  30. package/package.json +28 -6
  31. package/src/__tests__/relay.test.ts +317 -0
  32. package/src/client-role/forward-engine.ts +11 -14
  33. package/src/client-role/pull-engine.ts +47 -21
  34. package/src/index.ts +2 -2
  35. package/src/mode-manager.ts +1 -1
  36. package/src/relay.ts +35 -30
  37. package/src/server-role/index.ts +8 -11
  38. package/src/server-role/pull.ts +3 -3
  39. package/src/server-role/push.ts +27 -34
@@ -4,6 +4,8 @@
4
4
 
5
5
  import Database from 'bun:sqlite';
6
6
  import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
7
+ import type { SyncCombinedRequest, SyncCombinedResponse } from '@syncular/core';
8
+ import { TableRegistry } from '@syncular/server';
7
9
  import { createSqliteServerDialect } from '@syncular/server-dialect-sqlite';
8
10
  import type { Dialect, QueryResult } from 'kysely';
9
11
  import {
@@ -11,12 +13,16 @@ import {
11
13
  SqliteAdapter,
12
14
  SqliteIntrospector,
13
15
  SqliteQueryCompiler,
16
+ sql,
14
17
  } from 'kysely';
18
+ import { PullEngine } from '../client-role/pull-engine';
15
19
  import { SequenceMapper } from '../client-role/sequence-mapper';
16
20
  import { ensureRelaySchema } from '../migrate';
17
21
  import { ModeManager, type RelayMode } from '../mode-manager';
18
22
  import { createRelayWebSocketConnection, RelayRealtime } from '../realtime';
23
+ import { RelayServer } from '../relay';
19
24
  import type { RelayDatabase } from '../schema';
25
+ import { relayPushCommit } from '../server-role/push';
20
26
 
21
27
  // Helper to create in-memory SQLite database
22
28
  function createTestDb() {
@@ -133,6 +139,317 @@ describe('ModeManager', () => {
133
139
  manager.reportSuccess();
134
140
  expect(manager.getMode()).toBe('online');
135
141
  });
142
+
143
+ it('should transition from offline to reconnecting on initial failure', async () => {
144
+ const manager = new ModeManager({
145
+ healthCheckIntervalMs: 100,
146
+ reconnectBackoffMs: 50,
147
+ });
148
+
149
+ manager.start(async () => false);
150
+ await new Promise((resolve) => setTimeout(resolve, 20));
151
+
152
+ expect(manager.getMode()).toBe('reconnecting');
153
+ manager.stop();
154
+ });
155
+ });
156
+
157
+ describe('RelayServer health check', () => {
158
+ let db: Kysely<RelayDatabase>;
159
+ let sqlite: Database;
160
+
161
+ beforeEach(() => {
162
+ const setup = createTestDb();
163
+ db = setup.db;
164
+ sqlite = setup.sqlite;
165
+ });
166
+
167
+ afterEach(async () => {
168
+ await db.destroy();
169
+ sqlite.close();
170
+ });
171
+
172
+ it('uses a protocol-valid pull limit during startup health checks', async () => {
173
+ const syncCalls: SyncCombinedRequest[] = [];
174
+ const mainServerTransport = {
175
+ async sync(request: SyncCombinedRequest): Promise<SyncCombinedResponse> {
176
+ syncCalls.push(request);
177
+ return {};
178
+ },
179
+ async fetchSnapshotChunk(): Promise<Uint8Array> {
180
+ return new Uint8Array();
181
+ },
182
+ };
183
+
184
+ const relay = new RelayServer({
185
+ db,
186
+ dialect: createSqliteServerDialect(),
187
+ mainServerTransport,
188
+ mainServerClientId: 'relay-main-client',
189
+ mainServerActorId: 'relay-main-actor',
190
+ tables: [],
191
+ scopes: {},
192
+ handlers: new TableRegistry<RelayDatabase>(),
193
+ healthCheckIntervalMs: 50,
194
+ pullIntervalMs: 10_000,
195
+ forwardRetryIntervalMs: 10_000,
196
+ pruneIntervalMs: 0,
197
+ });
198
+
199
+ await relay.start();
200
+ await new Promise((resolve) => setTimeout(resolve, 25));
201
+ await relay.stop();
202
+
203
+ expect(
204
+ syncCalls.some(
205
+ (request) =>
206
+ request.pull?.subscriptions.length === 0 &&
207
+ request.pull.limitCommits === 1
208
+ )
209
+ ).toBe(true);
210
+ });
211
+ });
212
+
213
+ describe('relayPushCommit atomic enqueue', () => {
214
+ let db: Kysely<RelayDatabase>;
215
+ let sqlite: Database;
216
+
217
+ beforeEach(async () => {
218
+ const setup = createTestDb();
219
+ db = setup.db;
220
+ sqlite = setup.sqlite;
221
+ await ensureRelaySchema(db, createSqliteServerDialect());
222
+ });
223
+
224
+ afterEach(async () => {
225
+ await db.destroy();
226
+ sqlite.close();
227
+ });
228
+
229
+ it('rolls back local commit when forwarding enqueue fails', async () => {
230
+ const now = Date.now();
231
+ await sql`
232
+ insert into ${sql.table('relay_forward_outbox')} (
233
+ id,
234
+ local_commit_seq,
235
+ client_id,
236
+ client_commit_id,
237
+ operations_json,
238
+ schema_version,
239
+ status,
240
+ main_commit_seq,
241
+ error,
242
+ last_response_json,
243
+ created_at,
244
+ updated_at,
245
+ attempt_count
246
+ )
247
+ values (
248
+ ${'fixed-outbox-id'},
249
+ ${999},
250
+ ${'seed-client'},
251
+ ${'seed-commit'},
252
+ ${'[]'},
253
+ ${1},
254
+ ${'pending'},
255
+ ${null},
256
+ ${null},
257
+ ${null},
258
+ ${now},
259
+ ${now},
260
+ ${0}
261
+ )
262
+ `.execute(db);
263
+
264
+ const handlers = new TableRegistry<RelayDatabase>();
265
+ handlers.register({
266
+ table: 'tasks',
267
+ scopePatterns: ['user:{user_id}'],
268
+ resolveScopes: async () => ({ user_id: ['u1'] }),
269
+ extractScopes: () => ({ user_id: 'u1' }),
270
+ snapshot: async () => ({ rows: [], nextCursor: null }),
271
+ async applyOperation(_ctx, op, opIndex) {
272
+ return {
273
+ result: {
274
+ opIndex,
275
+ status: 'applied',
276
+ },
277
+ emittedChanges: [
278
+ {
279
+ table: 'tasks',
280
+ row_id: op.row_id,
281
+ op: op.op,
282
+ row_json: op.payload,
283
+ row_version: 1,
284
+ scopes: { user_id: 'u1' },
285
+ },
286
+ ],
287
+ };
288
+ },
289
+ });
290
+
291
+ const originalRandomUUID = crypto.randomUUID;
292
+ crypto.randomUUID = () => 'fixed-outbox-id';
293
+ try {
294
+ await expect(
295
+ relayPushCommit({
296
+ db,
297
+ dialect: createSqliteServerDialect(),
298
+ handlers,
299
+ actorId: 'u1',
300
+ request: {
301
+ clientId: 'relay-client-1',
302
+ clientCommitId: 'relay-commit-1',
303
+ schemaVersion: 1,
304
+ operations: [
305
+ {
306
+ table: 'tasks',
307
+ row_id: 'task-1',
308
+ op: 'upsert',
309
+ payload: { id: 'task-1', title: 'hello' },
310
+ base_version: null,
311
+ },
312
+ ],
313
+ },
314
+ })
315
+ ).rejects.toThrow();
316
+ } finally {
317
+ crypto.randomUUID = originalRandomUUID;
318
+ }
319
+
320
+ const commitRows = await sql<{ count: number | bigint }>`
321
+ select count(*) as count
322
+ from ${sql.table('sync_commits')}
323
+ where ${sql.ref('client_id')} = ${'relay-client-1'}
324
+ and ${sql.ref('client_commit_id')} = ${'relay-commit-1'}
325
+ `.execute(db);
326
+ expect(Number(commitRows.rows[0]?.count ?? 0)).toBe(0);
327
+ });
328
+ });
329
+
330
+ describe('PullEngine cursor safety', () => {
331
+ let db: Kysely<RelayDatabase>;
332
+ let sqlite: Database;
333
+
334
+ beforeEach(async () => {
335
+ const setup = createTestDb();
336
+ db = setup.db;
337
+ sqlite = setup.sqlite;
338
+ await ensureRelaySchema(db, createSqliteServerDialect());
339
+ });
340
+
341
+ afterEach(async () => {
342
+ await db.destroy();
343
+ sqlite.close();
344
+ });
345
+
346
+ it('does not advance cursor when a pulled commit is rejected locally', async () => {
347
+ const syncCalls: SyncCombinedRequest[] = [];
348
+ const transport = {
349
+ async sync(request: SyncCombinedRequest): Promise<SyncCombinedResponse> {
350
+ syncCalls.push(request);
351
+ return {
352
+ pull: {
353
+ ok: true,
354
+ subscriptions: [
355
+ {
356
+ id: 'tasks',
357
+ status: 'active',
358
+ scopes: {},
359
+ bootstrap: false,
360
+ nextCursor: 25,
361
+ commits: [
362
+ {
363
+ commitSeq: 25,
364
+ createdAt: new Date(0).toISOString(),
365
+ actorId: 'main-actor',
366
+ changes: [
367
+ {
368
+ table: 'tasks',
369
+ row_id: 'task-1',
370
+ op: 'upsert',
371
+ row_json: { id: 'task-1', title: 'from-main' },
372
+ row_version: 1,
373
+ scopes: {},
374
+ },
375
+ ],
376
+ },
377
+ ],
378
+ },
379
+ ],
380
+ },
381
+ };
382
+ },
383
+ async fetchSnapshotChunk(): Promise<Uint8Array> {
384
+ return new Uint8Array();
385
+ },
386
+ };
387
+
388
+ const handlers = new TableRegistry<RelayDatabase>();
389
+ handlers.register({
390
+ table: 'tasks',
391
+ scopePatterns: ['user:{user_id}'],
392
+ resolveScopes: async () => ({}),
393
+ extractScopes: () => ({}),
394
+ snapshot: async () => ({ rows: [], nextCursor: null }),
395
+ async applyOperation(_ctx, _op, opIndex) {
396
+ return {
397
+ result: {
398
+ opIndex,
399
+ status: 'conflict',
400
+ message: 'reject locally',
401
+ server_version: 1,
402
+ server_row: {},
403
+ },
404
+ emittedChanges: [],
405
+ };
406
+ },
407
+ });
408
+
409
+ const pullErrors: Error[] = [];
410
+ const pullEngine = new PullEngine({
411
+ db,
412
+ dialect: createSqliteServerDialect(),
413
+ transport,
414
+ clientId: 'relay-client',
415
+ tables: ['tasks'],
416
+ scopes: {},
417
+ handlers,
418
+ sequenceMapper: new SequenceMapper({ db }),
419
+ realtime: new RelayRealtime({ heartbeatIntervalMs: 0 }),
420
+ onError: (error) => pullErrors.push(error),
421
+ });
422
+
423
+ await pullEngine.pullOnce();
424
+ await pullEngine.pullOnce();
425
+
426
+ expect(pullErrors.length).toBe(2);
427
+ expect(syncCalls.length).toBe(2);
428
+ expect(syncCalls[0]?.pull?.subscriptions[0]?.cursor).toBe(-1);
429
+ expect(syncCalls[1]?.pull?.subscriptions[0]?.cursor).toBe(-1);
430
+
431
+ const rowResult = await sql<{ value_json: string }>`
432
+ select value_json
433
+ from ${sql.table('relay_config')}
434
+ where key = 'main_cursors'
435
+ limit 1
436
+ `.execute(db);
437
+ const row = rowResult.rows[0];
438
+
439
+ const cursorState: Record<string, number> = {};
440
+ if (row?.value_json) {
441
+ const parsed = JSON.parse(row.value_json);
442
+ if (typeof parsed === 'object' && parsed !== null) {
443
+ for (const [key, value] of Object.entries(parsed)) {
444
+ if (typeof value === 'number') {
445
+ cursorState[key] = value;
446
+ }
447
+ }
448
+ }
449
+ }
450
+
451
+ expect(cursorState.tasks).toBeUndefined();
452
+ });
136
453
  });
137
454
 
138
455
  describe('RelayRealtime', () => {
@@ -10,6 +10,7 @@ import type {
10
10
  SyncPushResponse,
11
11
  SyncTransport,
12
12
  } from '@syncular/core';
13
+ import { randomId } from '@syncular/core';
13
14
  import type { Kysely } from 'kysely';
14
15
  import { sql } from 'kysely';
15
16
  import type {
@@ -20,16 +21,6 @@ import type {
20
21
  } from '../schema';
21
22
  import type { SequenceMapper } from './sequence-mapper';
22
23
 
23
- function randomId(): string {
24
- if (
25
- typeof crypto !== 'undefined' &&
26
- typeof crypto.randomUUID === 'function'
27
- ) {
28
- return crypto.randomUUID();
29
- }
30
- return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
31
- }
32
-
33
24
  /**
34
25
  * Forward engine options.
35
26
  */
@@ -140,12 +131,18 @@ export class ForwardEngine<DB extends RelayDatabase = RelayDatabase> {
140
131
 
141
132
  let response: SyncPushResponse;
142
133
  try {
143
- response = await this.transport.push({
134
+ const combined = await this.transport.sync({
144
135
  clientId: next.client_id,
145
- clientCommitId: next.client_commit_id,
146
- operations: next.operations,
147
- schemaVersion: next.schema_version,
136
+ push: {
137
+ clientCommitId: next.client_commit_id,
138
+ operations: next.operations,
139
+ schemaVersion: next.schema_version,
140
+ },
148
141
  });
142
+ if (!combined.push) {
143
+ throw new Error('Server returned no push response');
144
+ }
145
+ response = combined.push;
149
146
  } catch (err) {
150
147
  // Network error - mark as pending for retry
151
148
  await this.markPending(next.id, String(err));
@@ -31,7 +31,7 @@ export interface PullEngineOptions<DB extends RelayDatabase = RelayDatabase> {
31
31
  tables: string[];
32
32
  /** Scope values for subscriptions */
33
33
  scopes: ScopeValues;
34
- shapes: TableRegistry<DB>;
34
+ handlers: TableRegistry<DB>;
35
35
  sequenceMapper: SequenceMapper<DB>;
36
36
  realtime: RelayRealtime;
37
37
  intervalMs?: number;
@@ -49,7 +49,7 @@ export class PullEngine<DB extends RelayDatabase = RelayDatabase> {
49
49
  private readonly clientId: string;
50
50
  private readonly tables: string[];
51
51
  private readonly scopes: ScopeValues;
52
- private readonly shapes: TableRegistry<DB>;
52
+ private readonly handlers: TableRegistry<DB>;
53
53
  private readonly sequenceMapper: SequenceMapper<DB>;
54
54
  private readonly realtime: RelayRealtime;
55
55
  private readonly intervalMs: number;
@@ -67,7 +67,7 @@ export class PullEngine<DB extends RelayDatabase = RelayDatabase> {
67
67
  this.clientId = options.clientId;
68
68
  this.tables = options.tables;
69
69
  this.scopes = options.scopes;
70
- this.shapes = options.shapes;
70
+ this.handlers = options.handlers;
71
71
  this.sequenceMapper = options.sequenceMapper;
72
72
  this.realtime = options.realtime;
73
73
  this.intervalMs = options.intervalMs ?? 10000;
@@ -81,9 +81,17 @@ export class PullEngine<DB extends RelayDatabase = RelayDatabase> {
81
81
  start(): void {
82
82
  if (this.running) return;
83
83
  this.running = true;
84
- this.loadCursors().then(() => {
85
- this.scheduleNext(0);
86
- });
84
+ void this.loadCursors()
85
+ .catch((error) => {
86
+ this.onError?.(
87
+ error instanceof Error ? error : new Error(String(error))
88
+ );
89
+ })
90
+ .finally(() => {
91
+ if (this.running) {
92
+ this.scheduleNext(0);
93
+ }
94
+ });
87
95
  }
88
96
 
89
97
  /**
@@ -169,7 +177,7 @@ export class PullEngine<DB extends RelayDatabase = RelayDatabase> {
169
177
  const subscriptionRequests: SyncSubscriptionRequest[] = this.tables.map(
170
178
  (table) => ({
171
179
  id: table,
172
- shape: table,
180
+ table,
173
181
  scopes: this.scopes,
174
182
  cursor: this.cursors.get(table) ?? -1,
175
183
  })
@@ -177,11 +185,17 @@ export class PullEngine<DB extends RelayDatabase = RelayDatabase> {
177
185
 
178
186
  let response: SyncPullResponse;
179
187
  try {
180
- response = await this.transport.pull({
188
+ const combined = await this.transport.sync({
181
189
  clientId: this.clientId,
182
- subscriptions: subscriptionRequests,
183
- limitCommits: 100,
190
+ pull: {
191
+ subscriptions: subscriptionRequests,
192
+ limitCommits: 100,
193
+ },
184
194
  });
195
+ if (!combined.pull) {
196
+ return false;
197
+ }
198
+ response = combined.pull;
185
199
  } catch {
186
200
  // Network error - will retry
187
201
  return false;
@@ -200,16 +214,24 @@ export class PullEngine<DB extends RelayDatabase = RelayDatabase> {
200
214
  const table = sub.id;
201
215
 
202
216
  // Process commits
217
+ let canAdvanceCursor = true;
203
218
  for (const commit of sub.commits) {
204
- const applied = await this.applyCommitLocally(commit, table);
205
- if (applied) {
219
+ const outcome = await this.applyCommitLocally(commit, table);
220
+ if (outcome === 'applied') {
206
221
  hasChanges = true;
207
222
  affectedTables.add(table);
208
223
  }
224
+ if (outcome === 'rejected') {
225
+ canAdvanceCursor = false;
226
+ break;
227
+ }
209
228
  }
210
229
 
211
230
  // Update cursor
212
- if (sub.nextCursor > (this.cursors.get(table) ?? -1)) {
231
+ if (
232
+ canAdvanceCursor &&
233
+ sub.nextCursor > (this.cursors.get(table) ?? -1)
234
+ ) {
213
235
  this.cursors.set(table, sub.nextCursor);
214
236
  }
215
237
  }
@@ -232,14 +254,14 @@ export class PullEngine<DB extends RelayDatabase = RelayDatabase> {
232
254
  /**
233
255
  * Apply a commit from main server locally.
234
256
  *
235
- * This re-applies the commit through the local shape handlers
257
+ * This re-applies the commit through the local table handlers
236
258
  * to ensure proper indexing and scope assignment.
237
259
  */
238
260
  private async applyCommitLocally(
239
261
  commit: SyncCommit,
240
262
  table: string
241
- ): Promise<boolean> {
242
- if (commit.changes.length === 0) return false;
263
+ ): Promise<'applied' | 'cached' | 'rejected'> {
264
+ if (commit.changes.length === 0) return 'cached';
243
265
 
244
266
  // Convert changes to operations
245
267
  const operations = commit.changes.map((change) => ({
@@ -256,7 +278,7 @@ export class PullEngine<DB extends RelayDatabase = RelayDatabase> {
256
278
  const result = await pushCommit({
257
279
  db: this.db,
258
280
  dialect: this.dialect,
259
- shapes: this.shapes,
281
+ handlers: this.handlers,
260
282
  actorId: commit.actorId,
261
283
  request: {
262
284
  clientId: `relay:${this.clientId}`,
@@ -276,20 +298,24 @@ export class PullEngine<DB extends RelayDatabase = RelayDatabase> {
276
298
  result.response.commitSeq,
277
299
  commit.commitSeq
278
300
  );
279
- return true;
301
+ return 'applied';
280
302
  }
281
303
 
282
304
  // Already applied (cached) - that's fine
283
305
  if (result.response.status === 'cached') {
284
- return false;
306
+ return 'cached';
285
307
  }
286
308
 
287
309
  // Rejected - this shouldn't happen for pulls from main
288
- // Log but don't fail
310
+ // Do not advance cursor; signal error so caller can react.
311
+ const error = new Error(
312
+ `Relay: Failed to apply commit ${commit.commitSeq} locally (status=${result.response.status})`
313
+ );
289
314
  console.warn(
290
315
  `Relay: Failed to apply commit ${commit.commitSeq} locally:`,
291
316
  result.response
292
317
  );
293
- return false;
318
+ this.onError?.(error);
319
+ return 'rejected';
294
320
  }
295
321
  }
package/src/index.ts CHANGED
@@ -16,12 +16,12 @@
16
16
  * mainServerClientId: 'relay-branch-001',
17
17
  * mainServerActorId: 'relay-service',
18
18
  * scopeKeys: ['client:acme'],
19
- * shapes: shapeRegistry,
19
+ * handlers: shapeRegistry,
20
20
  * subscriptions: subscriptionRegistry,
21
21
  * });
22
22
  *
23
23
  * // Mount routes for local clients
24
- * app.route('/sync', relay.getRoutes());
24
+ * app.route('/sync', await relay.getRoutes());
25
25
  *
26
26
  * // Start background sync with main
27
27
  * await relay.start();
@@ -94,7 +94,7 @@ export class ModeManager {
94
94
  reportFailure(): void {
95
95
  this.consecutiveFailures++;
96
96
 
97
- if (this.mode === 'online') {
97
+ if (this.mode !== 'reconnecting') {
98
98
  this.setMode('reconnecting');
99
99
  }
100
100
 
package/src/relay.ts CHANGED
@@ -48,8 +48,8 @@ export interface RelayServerOptions<DB extends RelayDatabase = RelayDatabase> {
48
48
  tables: string[];
49
49
  /** Scope values for subscriptions to the main server */
50
50
  scopes: ScopeValues;
51
- /** Shape registry for handling operations */
52
- shapes: TableRegistry<DB>;
51
+ /** Handler registry for handling operations */
52
+ handlers: TableRegistry<DB>;
53
53
  /** Optional: WebSocket heartbeat interval in milliseconds (default: 30000) */
54
54
  heartbeatIntervalMs?: number;
55
55
  /** Optional: Forward engine retry interval in milliseconds (default: 5000) */
@@ -91,11 +91,11 @@ type EventHandler<K extends keyof RelayEvents> = RelayEvents[K];
91
91
  * mainServerActorId: 'relay-service',
92
92
  * tables: ['tasks', 'projects'],
93
93
  * scopes: { project_id: 'acme' },
94
- * shapes: shapeRegistry,
94
+ * handlers: shapeRegistry,
95
95
  * });
96
96
  *
97
97
  * // Mount routes for local clients
98
- * app.route('/sync', relay.getRoutes());
98
+ * app.route('/sync', await relay.getRoutes());
99
99
  *
100
100
  * // Start background sync with main
101
101
  * await relay.start();
@@ -113,7 +113,7 @@ export class RelayServer<DB extends RelayDatabase = RelayDatabase> {
113
113
  private readonly mainServerActorId: string;
114
114
  private readonly tables: string[];
115
115
  private readonly scopes: ScopeValues;
116
- private readonly shapes: TableRegistry<DB>;
116
+ private readonly handlers: TableRegistry<DB>;
117
117
 
118
118
  private readonly modeManager: ModeManager;
119
119
  private readonly sequenceMapper: SequenceMapper<DB>;
@@ -134,6 +134,7 @@ export class RelayServer<DB extends RelayDatabase = RelayDatabase> {
134
134
  private started = false;
135
135
  private schemaInitialized = false;
136
136
  private routes: Hono | null = null;
137
+ private routesPromise: Promise<Hono> | null = null;
137
138
 
138
139
  constructor(options: RelayServerOptions<DB>) {
139
140
  this.db = options.db;
@@ -143,7 +144,7 @@ export class RelayServer<DB extends RelayDatabase = RelayDatabase> {
143
144
  this.mainServerActorId = options.mainServerActorId;
144
145
  this.tables = options.tables;
145
146
  this.scopes = options.scopes;
146
- this.shapes = options.shapes;
147
+ this.handlers = options.handlers;
147
148
 
148
149
  this.pruneIntervalMs = options.pruneIntervalMs ?? 3600000;
149
150
  this.pruneMaxAgeMs = options.pruneMaxAgeMs ?? 7 * 24 * 60 * 60 * 1000;
@@ -183,7 +184,7 @@ export class RelayServer<DB extends RelayDatabase = RelayDatabase> {
183
184
  clientId: this.mainServerClientId,
184
185
  tables: this.tables,
185
186
  scopes: this.scopes,
186
- shapes: this.shapes,
187
+ handlers: this.handlers,
187
188
  sequenceMapper: this.sequenceMapper,
188
189
  realtime: this.realtime,
189
190
  intervalMs: options.pullIntervalMs ?? 10000,
@@ -264,10 +265,12 @@ export class RelayServer<DB extends RelayDatabase = RelayDatabase> {
264
265
  this.modeManager.start(async () => {
265
266
  // Health check: try an empty pull
266
267
  try {
267
- await this.mainServerTransport.pull({
268
+ await this.mainServerTransport.sync({
268
269
  clientId: this.mainServerClientId,
269
- subscriptions: [],
270
- limitCommits: 0,
270
+ pull: {
271
+ subscriptions: [],
272
+ limitCommits: 1,
273
+ },
271
274
  });
272
275
  return true;
273
276
  } catch {
@@ -370,28 +373,30 @@ export class RelayServer<DB extends RelayDatabase = RelayDatabase> {
370
373
  * - POST /push
371
374
  * - GET /realtime (WebSocket)
372
375
  */
373
- getRoutes(): Hono {
376
+ async getRoutes(): Promise<Hono> {
374
377
  if (this.routes) return this.routes;
378
+ if (this.routesPromise) return this.routesPromise;
379
+
380
+ this.routesPromise = (async () => {
381
+ const { createRelayRoutes } = await import('./server-role');
382
+
383
+ const routes = createRelayRoutes({
384
+ db: this.db,
385
+ dialect: this.dialect,
386
+ handlers: this.handlers,
387
+ realtime: this.realtime,
388
+ onCommit: async (localCommitSeq: number, affectedTables: string[]) => {
389
+ this.realtime.notifyScopeKeys(affectedTables, localCommitSeq);
390
+ this.forwardEngine.wakeUp();
391
+ },
392
+ });
393
+
394
+ this.routes = routes;
395
+ this.routesPromise = null;
396
+ return routes;
397
+ })();
375
398
 
376
- // Lazy import to avoid requiring hono as a hard dependency
377
- const { createRelayRoutes } = require('./server-role');
378
-
379
- const routes = createRelayRoutes({
380
- db: this.db,
381
- dialect: this.dialect,
382
- shapes: this.shapes,
383
- realtime: this.realtime,
384
- onCommit: async (localCommitSeq: number, affectedTables: string[]) => {
385
- // Notify local clients via WebSocket
386
- this.realtime.notifyScopeKeys(affectedTables, localCommitSeq);
387
-
388
- // Wake up forward engine to send to main
389
- this.forwardEngine.wakeUp();
390
- },
391
- });
392
-
393
- this.routes = routes;
394
- return routes;
399
+ return this.routesPromise;
395
400
  }
396
401
 
397
402
  private emit<K extends keyof RelayEvents>(