@syncular/relay 0.0.1-100

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 +257 -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 +247 -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 +189 -0
  34. package/dist/relay.d.ts.map +1 -0
  35. package/dist/relay.js +319 -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 +195 -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 +94 -0
  52. package/dist/server-role/push.js.map +1 -0
  53. package/package.json +61 -0
  54. package/src/__tests__/relay.test.ts +781 -0
  55. package/src/bun-types.d.ts +50 -0
  56. package/src/client-role/forward-engine.ts +343 -0
  57. package/src/client-role/index.ts +9 -0
  58. package/src/client-role/pull-engine.ts +321 -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 +424 -0
  65. package/src/schema.ts +171 -0
  66. package/src/server-role/index.ts +339 -0
  67. package/src/server-role/pull.ts +37 -0
  68. package/src/server-role/push.ts +123 -0
@@ -0,0 +1,50 @@
1
+ // Type declarations for bun modules when running tsgo
2
+ declare module 'bun:sqlite' {
3
+ export default class Database {
4
+ constructor(path: string);
5
+ close(): void;
6
+ exec(sql: string): void;
7
+ query<T = unknown>(sql: string): Statement<T>;
8
+ prepare<T = unknown>(sql: string): Statement<T>;
9
+ transaction<T>(fn: () => T): () => T;
10
+ }
11
+
12
+ export class Statement<T = unknown> {
13
+ run(
14
+ ...params: unknown[]
15
+ ): { changes: number; lastInsertRowid: number | bigint | null };
16
+ get(...params: unknown[]): T | null;
17
+ all(...params: unknown[]): T[];
18
+ values(...params: unknown[]): unknown[][];
19
+ finalize(): void;
20
+ }
21
+ }
22
+
23
+ declare module 'bun:test' {
24
+ export function describe(name: string, fn: () => void): void;
25
+ export function it(name: string, fn: () => void | Promise<void>): void;
26
+ export function test(name: string, fn: () => void | Promise<void>): void;
27
+ export function beforeEach(fn: () => void | Promise<void>): void;
28
+ export function afterEach(fn: () => void | Promise<void>): void;
29
+ export function beforeAll(fn: () => void | Promise<void>): void;
30
+ export function afterAll(fn: () => void | Promise<void>): void;
31
+ export function expect<T>(value: T): Matchers<T>;
32
+
33
+ interface Matchers<T> {
34
+ toBe(expected: unknown): void;
35
+ toEqual(expected: unknown): void;
36
+ toBeTruthy(): void;
37
+ toBeFalsy(): void;
38
+ toBeNull(): void;
39
+ toBeUndefined(): void;
40
+ toBeDefined(): void;
41
+ toBeGreaterThan(expected: number): void;
42
+ toBeGreaterThanOrEqual(expected: number): void;
43
+ toBeLessThan(expected: number): void;
44
+ toBeLessThanOrEqual(expected: number): void;
45
+ toContain(expected: unknown): void;
46
+ toHaveLength(expected: number): void;
47
+ toThrow(expected?: string | RegExp | Error): void;
48
+ not: Matchers<T>;
49
+ }
50
+ }
@@ -0,0 +1,343 @@
1
+ /**
2
+ * @syncular/relay - Forward Engine
3
+ *
4
+ * Forwards commits from the relay's local outbox to the main server.
5
+ * Preserves original client_id + client_commit_id for idempotency.
6
+ */
7
+
8
+ import type {
9
+ SyncOperation,
10
+ SyncPushResponse,
11
+ SyncTransport,
12
+ } from '@syncular/core';
13
+ import { randomId } from '@syncular/core';
14
+ import type { Kysely } from 'kysely';
15
+ import { sql } from 'kysely';
16
+ import type {
17
+ ForwardConflictEntry,
18
+ ForwardOutboxEntry,
19
+ RelayDatabase,
20
+ RelayForwardOutboxStatus,
21
+ } from '../schema';
22
+ import type { SequenceMapper } from './sequence-mapper';
23
+
24
+ /**
25
+ * Forward engine options.
26
+ */
27
+ export interface ForwardEngineOptions<
28
+ DB extends RelayDatabase = RelayDatabase,
29
+ > {
30
+ db: Kysely<DB>;
31
+ transport: SyncTransport;
32
+ clientId: string;
33
+ sequenceMapper: SequenceMapper<DB>;
34
+ retryIntervalMs?: number;
35
+ onConflict?: (conflict: ForwardConflictEntry) => void;
36
+ onError?: (error: Error) => void;
37
+ }
38
+
39
+ /**
40
+ * Forward engine for sending local commits to the main server.
41
+ */
42
+ export class ForwardEngine<DB extends RelayDatabase = RelayDatabase> {
43
+ private readonly db: Kysely<DB>;
44
+ private readonly transport: SyncTransport;
45
+ private readonly clientId: string;
46
+ private readonly sequenceMapper: SequenceMapper<DB>;
47
+ private readonly retryIntervalMs: number;
48
+ private readonly onConflict?: (conflict: ForwardConflictEntry) => void;
49
+ private readonly onError?: (error: Error) => void;
50
+
51
+ private running = false;
52
+ private timer: ReturnType<typeof setTimeout> | null = null;
53
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: assigned in scheduleWakeUp and read in tick
54
+ private wakeUpRequested = false;
55
+
56
+ constructor(options: ForwardEngineOptions<DB>) {
57
+ this.db = options.db;
58
+ this.transport = options.transport;
59
+ this.clientId = options.clientId;
60
+ this.sequenceMapper = options.sequenceMapper;
61
+ this.retryIntervalMs = options.retryIntervalMs ?? 5000;
62
+ this.onConflict = options.onConflict;
63
+ this.onError = options.onError;
64
+ }
65
+
66
+ /**
67
+ * Start the forward engine loop.
68
+ */
69
+ start(): void {
70
+ if (this.running) return;
71
+ this.running = true;
72
+ this.scheduleNext(0);
73
+ }
74
+
75
+ /**
76
+ * Stop the forward engine.
77
+ */
78
+ stop(): void {
79
+ this.running = false;
80
+ if (this.timer) {
81
+ clearTimeout(this.timer);
82
+ this.timer = null;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Wake up the engine to process immediately.
88
+ */
89
+ wakeUp(): void {
90
+ if (!this.running) return;
91
+ this.wakeUpRequested = true;
92
+ if (this.timer) {
93
+ clearTimeout(this.timer);
94
+ this.timer = null;
95
+ }
96
+ this.scheduleNext(0);
97
+ }
98
+
99
+ /**
100
+ * Forward a single commit (for manual/testing use).
101
+ */
102
+ async forwardOnce(): Promise<boolean> {
103
+ return this.processOne();
104
+ }
105
+
106
+ private scheduleNext(delayMs: number): void {
107
+ if (!this.running) return;
108
+ if (this.timer) return;
109
+
110
+ this.timer = setTimeout(async () => {
111
+ this.timer = null;
112
+ this.wakeUpRequested = false;
113
+
114
+ try {
115
+ const forwarded = await this.processOne();
116
+ // If we forwarded something, immediately try again
117
+ const nextDelay = forwarded ? 0 : this.retryIntervalMs;
118
+ this.scheduleNext(nextDelay);
119
+ } catch (err) {
120
+ this.onError?.(err instanceof Error ? err : new Error(String(err)));
121
+ this.scheduleNext(this.retryIntervalMs);
122
+ }
123
+ }, delayMs);
124
+ }
125
+
126
+ private async processOne(): Promise<boolean> {
127
+ const next = await this.getNextSendable();
128
+ if (!next) return false;
129
+
130
+ await this.markSending(next.id);
131
+
132
+ let response: SyncPushResponse;
133
+ try {
134
+ const combined = await this.transport.sync({
135
+ clientId: next.client_id,
136
+ push: {
137
+ clientCommitId: next.client_commit_id,
138
+ operations: next.operations,
139
+ schemaVersion: next.schema_version,
140
+ },
141
+ });
142
+ if (!combined.push) {
143
+ throw new Error('Server returned no push response');
144
+ }
145
+ response = combined.push;
146
+ } catch (err) {
147
+ // Network error - mark as pending for retry
148
+ await this.markPending(next.id, String(err));
149
+ throw err;
150
+ }
151
+
152
+ const responseJson = JSON.stringify(response);
153
+
154
+ if (response.status === 'applied' || response.status === 'cached') {
155
+ const mainCommitSeq = response.commitSeq ?? null;
156
+
157
+ // Update outbox entry
158
+ await this.markForwarded(next.id, mainCommitSeq, responseJson);
159
+
160
+ // Update sequence mapper
161
+ if (mainCommitSeq != null) {
162
+ await this.sequenceMapper.markForwarded(
163
+ next.local_commit_seq,
164
+ mainCommitSeq
165
+ );
166
+ }
167
+
168
+ return true;
169
+ }
170
+
171
+ // Rejected - store conflict and mark as failed
172
+ const conflict = await this.recordConflict(next, responseJson);
173
+ await this.markFailed(next.id, 'REJECTED', responseJson);
174
+
175
+ this.onConflict?.(conflict);
176
+
177
+ return true;
178
+ }
179
+
180
+ private async getNextSendable(): Promise<ForwardOutboxEntry | null> {
181
+ const staleThreshold = Date.now() - 30000;
182
+
183
+ const rowResult = await sql<{
184
+ id: string;
185
+ local_commit_seq: number;
186
+ client_id: string;
187
+ client_commit_id: string;
188
+ operations_json: string;
189
+ schema_version: number;
190
+ status: RelayForwardOutboxStatus;
191
+ main_commit_seq: number | null;
192
+ error: string | null;
193
+ last_response_json: string | null;
194
+ created_at: number;
195
+ updated_at: number;
196
+ attempt_count: number;
197
+ }>`
198
+ select
199
+ id,
200
+ local_commit_seq,
201
+ client_id,
202
+ client_commit_id,
203
+ operations_json,
204
+ schema_version,
205
+ status,
206
+ main_commit_seq,
207
+ error,
208
+ last_response_json,
209
+ created_at,
210
+ updated_at,
211
+ attempt_count
212
+ from ${sql.table('relay_forward_outbox')}
213
+ where
214
+ status = 'pending'
215
+ or (status = 'forwarding' and updated_at < ${staleThreshold})
216
+ order by created_at asc
217
+ limit 1
218
+ `.execute(this.db);
219
+ const row = rowResult.rows[0];
220
+
221
+ if (!row) return null;
222
+
223
+ const operations =
224
+ typeof row.operations_json === 'string'
225
+ ? (JSON.parse(row.operations_json) as SyncOperation[])
226
+ : (row.operations_json as SyncOperation[]);
227
+
228
+ return {
229
+ id: row.id,
230
+ local_commit_seq: row.local_commit_seq,
231
+ client_id: row.client_id,
232
+ client_commit_id: row.client_commit_id,
233
+ operations,
234
+ schema_version: row.schema_version,
235
+ status: row.status,
236
+ main_commit_seq: row.main_commit_seq,
237
+ error: row.error,
238
+ created_at: row.created_at,
239
+ updated_at: row.updated_at,
240
+ attempt_count: row.attempt_count,
241
+ };
242
+ }
243
+
244
+ private async markSending(id: string): Promise<void> {
245
+ const now = Date.now();
246
+
247
+ await sql`
248
+ update ${sql.table('relay_forward_outbox')}
249
+ set
250
+ status = 'forwarding',
251
+ updated_at = ${now},
252
+ attempt_count = attempt_count + 1,
253
+ error = ${null}
254
+ where id = ${id}
255
+ `.execute(this.db);
256
+ }
257
+
258
+ private async markPending(id: string, error: string): Promise<void> {
259
+ const now = Date.now();
260
+
261
+ await sql`
262
+ update ${sql.table('relay_forward_outbox')}
263
+ set status = 'pending', updated_at = ${now}, error = ${error}
264
+ where id = ${id}
265
+ `.execute(this.db);
266
+ }
267
+
268
+ private async markForwarded(
269
+ id: string,
270
+ mainCommitSeq: number | null,
271
+ responseJson: string
272
+ ): Promise<void> {
273
+ const now = Date.now();
274
+
275
+ await sql`
276
+ update ${sql.table('relay_forward_outbox')}
277
+ set
278
+ status = 'forwarded',
279
+ main_commit_seq = ${mainCommitSeq},
280
+ updated_at = ${now},
281
+ error = ${null},
282
+ last_response_json = ${responseJson}
283
+ where id = ${id}
284
+ `.execute(this.db);
285
+ }
286
+
287
+ private async markFailed(
288
+ id: string,
289
+ error: string,
290
+ responseJson: string
291
+ ): Promise<void> {
292
+ const now = Date.now();
293
+
294
+ await sql`
295
+ update ${sql.table('relay_forward_outbox')}
296
+ set
297
+ status = 'failed',
298
+ updated_at = ${now},
299
+ error = ${error},
300
+ last_response_json = ${responseJson}
301
+ where id = ${id}
302
+ `.execute(this.db);
303
+ }
304
+
305
+ private async recordConflict(
306
+ entry: ForwardOutboxEntry,
307
+ responseJson: string
308
+ ): Promise<ForwardConflictEntry> {
309
+ const now = Date.now();
310
+ const id = randomId();
311
+
312
+ await sql`
313
+ insert into ${sql.table('relay_forward_conflicts')} (
314
+ id,
315
+ local_commit_seq,
316
+ client_id,
317
+ client_commit_id,
318
+ response_json,
319
+ created_at,
320
+ resolved_at
321
+ )
322
+ values (
323
+ ${id},
324
+ ${entry.local_commit_seq},
325
+ ${entry.client_id},
326
+ ${entry.client_commit_id},
327
+ ${responseJson},
328
+ ${now},
329
+ ${null}
330
+ )
331
+ `.execute(this.db);
332
+
333
+ return {
334
+ id,
335
+ local_commit_seq: entry.local_commit_seq,
336
+ client_id: entry.client_id,
337
+ client_commit_id: entry.client_commit_id,
338
+ response: JSON.parse(responseJson),
339
+ created_at: now,
340
+ resolved_at: null,
341
+ };
342
+ }
343
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @syncular/relay - Client Role
3
+ *
4
+ * Components for the relay's role as a client to the main server.
5
+ */
6
+
7
+ export * from './forward-engine';
8
+ export * from './pull-engine';
9
+ export * from './sequence-mapper';