@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.
- package/README.md +41 -0
- package/dist/client-role/forward-engine.d.ts.map +1 -1
- package/dist/client-role/forward-engine.js +11 -11
- package/dist/client-role/forward-engine.js.map +1 -1
- package/dist/client-role/index.js +3 -3
- package/dist/client-role/pull-engine.d.ts +2 -2
- package/dist/client-role/pull-engine.d.ts.map +1 -1
- package/dist/client-role/pull-engine.js +38 -18
- package/dist/client-role/pull-engine.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +9 -9
- package/dist/mode-manager.js +1 -1
- package/dist/mode-manager.js.map +1 -1
- package/dist/relay.d.ts +7 -6
- package/dist/relay.d.ts.map +1 -1
- package/dist/relay.js +37 -31
- package/dist/relay.js.map +1 -1
- package/dist/server-role/index.d.ts +1 -1
- package/dist/server-role/index.d.ts.map +1 -1
- package/dist/server-role/index.js +9 -12
- package/dist/server-role/index.js.map +1 -1
- package/dist/server-role/pull.d.ts +1 -1
- package/dist/server-role/pull.d.ts.map +1 -1
- package/dist/server-role/pull.js +2 -2
- package/dist/server-role/pull.js.map +1 -1
- package/dist/server-role/push.d.ts +1 -1
- package/dist/server-role/push.d.ts.map +1 -1
- package/dist/server-role/push.js +23 -27
- package/dist/server-role/push.js.map +1 -1
- package/package.json +28 -6
- package/src/__tests__/relay.test.ts +317 -0
- package/src/client-role/forward-engine.ts +11 -14
- package/src/client-role/pull-engine.ts +47 -21
- package/src/index.ts +2 -2
- package/src/mode-manager.ts +1 -1
- package/src/relay.ts +35 -30
- package/src/server-role/index.ts +8 -11
- package/src/server-role/pull.ts +3 -3
- 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
|
-
|
|
134
|
+
const combined = await this.transport.sync({
|
|
144
135
|
clientId: next.client_id,
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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()
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
188
|
+
const combined = await this.transport.sync({
|
|
181
189
|
clientId: this.clientId,
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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<
|
|
242
|
-
if (commit.changes.length === 0) return
|
|
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
|
-
|
|
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
|
|
301
|
+
return 'applied';
|
|
280
302
|
}
|
|
281
303
|
|
|
282
304
|
// Already applied (cached) - that's fine
|
|
283
305
|
if (result.response.status === 'cached') {
|
|
284
|
-
return
|
|
306
|
+
return 'cached';
|
|
285
307
|
}
|
|
286
308
|
|
|
287
309
|
// Rejected - this shouldn't happen for pulls from main
|
|
288
|
-
//
|
|
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
|
-
|
|
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
|
-
*
|
|
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();
|
package/src/mode-manager.ts
CHANGED
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
|
-
/**
|
|
52
|
-
|
|
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
|
-
*
|
|
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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
268
|
+
await this.mainServerTransport.sync({
|
|
268
269
|
clientId: this.mainServerClientId,
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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>(
|