@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.
- package/dist/client-role/forward-engine.d.ts +63 -0
- package/dist/client-role/forward-engine.d.ts.map +1 -0
- package/dist/client-role/forward-engine.js +257 -0
- package/dist/client-role/forward-engine.js.map +1 -0
- package/dist/client-role/index.d.ts +9 -0
- package/dist/client-role/index.d.ts.map +1 -0
- package/dist/client-role/index.js +9 -0
- package/dist/client-role/index.js.map +1 -0
- package/dist/client-role/pull-engine.d.ts +70 -0
- package/dist/client-role/pull-engine.d.ts.map +1 -0
- package/dist/client-role/pull-engine.js +247 -0
- package/dist/client-role/pull-engine.js.map +1 -0
- package/dist/client-role/sequence-mapper.d.ts +65 -0
- package/dist/client-role/sequence-mapper.d.ts.map +1 -0
- package/dist/client-role/sequence-mapper.js +161 -0
- package/dist/client-role/sequence-mapper.js.map +1 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +44 -0
- package/dist/index.js.map +1 -0
- package/dist/migrate.d.ts +18 -0
- package/dist/migrate.d.ts.map +1 -0
- package/dist/migrate.js +99 -0
- package/dist/migrate.js.map +1 -0
- package/dist/mode-manager.d.ts +60 -0
- package/dist/mode-manager.d.ts.map +1 -0
- package/dist/mode-manager.js +114 -0
- package/dist/mode-manager.js.map +1 -0
- package/dist/realtime.d.ts +102 -0
- package/dist/realtime.d.ts.map +1 -0
- package/dist/realtime.js +305 -0
- package/dist/realtime.js.map +1 -0
- package/dist/relay.d.ts +189 -0
- package/dist/relay.d.ts.map +1 -0
- package/dist/relay.js +319 -0
- package/dist/relay.js.map +1 -0
- package/dist/schema.d.ts +158 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +7 -0
- package/dist/schema.js.map +1 -0
- package/dist/server-role/index.d.ts +54 -0
- package/dist/server-role/index.d.ts.map +1 -0
- package/dist/server-role/index.js +195 -0
- package/dist/server-role/index.js.map +1 -0
- package/dist/server-role/pull.d.ts +25 -0
- package/dist/server-role/pull.d.ts.map +1 -0
- package/dist/server-role/pull.js +24 -0
- package/dist/server-role/pull.js.map +1 -0
- package/dist/server-role/push.d.ts +27 -0
- package/dist/server-role/push.d.ts.map +1 -0
- package/dist/server-role/push.js +94 -0
- package/dist/server-role/push.js.map +1 -0
- package/package.json +61 -0
- package/src/__tests__/relay.test.ts +781 -0
- package/src/bun-types.d.ts +50 -0
- package/src/client-role/forward-engine.ts +343 -0
- package/src/client-role/index.ts +9 -0
- package/src/client-role/pull-engine.ts +321 -0
- package/src/client-role/sequence-mapper.ts +201 -0
- package/src/index.ts +50 -0
- package/src/migrate.ts +113 -0
- package/src/mode-manager.ts +142 -0
- package/src/realtime.ts +370 -0
- package/src/relay.ts +424 -0
- package/src/schema.ts +171 -0
- package/src/server-role/index.ts +339 -0
- package/src/server-role/pull.ts +37 -0
- 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
|
+
}
|