@syncular/client 0.0.5-42 → 0.0.6-101
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.d.ts +3 -3
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +7 -1
- package/dist/client.js.map +1 -1
- package/dist/create-client.d.ts +3 -4
- package/dist/create-client.d.ts.map +1 -1
- package/dist/create-client.js +16 -12
- package/dist/create-client.js.map +1 -1
- package/dist/engine/SyncEngine.d.ts.map +1 -1
- package/dist/engine/SyncEngine.js +49 -29
- package/dist/engine/SyncEngine.js.map +1 -1
- package/dist/engine/types.d.ts +3 -3
- package/dist/engine/types.d.ts.map +1 -1
- package/dist/handlers/collection.d.ts +6 -0
- package/dist/handlers/collection.d.ts.map +1 -0
- package/dist/handlers/collection.js +21 -0
- package/dist/handlers/collection.js.map +1 -0
- package/dist/handlers/create-handler.d.ts +1 -1
- package/dist/handlers/create-handler.d.ts.map +1 -1
- package/dist/handlers/create-handler.js +3 -3
- package/dist/handlers/create-handler.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/migrate.d.ts.map +1 -1
- package/dist/migrate.js +12 -0
- package/dist/migrate.js.map +1 -1
- package/dist/mutations.d.ts +1 -1
- package/dist/mutations.d.ts.map +1 -1
- package/dist/mutations.js +3 -3
- package/dist/mutations.js.map +1 -1
- package/dist/pull-engine.d.ts +11 -14
- package/dist/pull-engine.d.ts.map +1 -1
- package/dist/pull-engine.js +68 -6
- package/dist/pull-engine.js.map +1 -1
- package/dist/push-engine.d.ts.map +1 -1
- package/dist/push-engine.js +12 -0
- package/dist/push-engine.js.map +1 -1
- package/dist/sync-loop.d.ts +2 -2
- package/dist/sync-loop.d.ts.map +1 -1
- package/dist/sync-loop.js +5 -2
- package/dist/sync-loop.js.map +1 -1
- package/dist/sync.d.ts +32 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +55 -0
- package/dist/sync.js.map +1 -0
- package/package.json +4 -4
- package/src/client.test.ts +18 -9
- package/src/client.ts +11 -4
- package/src/create-client.test.ts +83 -0
- package/src/create-client.ts +21 -16
- package/src/engine/SyncEngine.test.ts +241 -32
- package/src/engine/SyncEngine.ts +53 -33
- package/src/engine/types.ts +3 -3
- package/src/handlers/collection.ts +36 -0
- package/src/handlers/create-handler.ts +4 -4
- package/src/index.ts +2 -1
- package/src/migrate.ts +14 -0
- package/src/mutations.ts +4 -4
- package/src/pull-engine.test.ts +151 -6
- package/src/pull-engine.ts +93 -21
- package/src/push-engine.ts +15 -0
- package/src/sync-loop.ts +13 -5
- package/src/sync.ts +170 -0
- package/dist/handlers/registry.d.ts +0 -15
- package/dist/handlers/registry.d.ts.map +0 -1
- package/dist/handlers/registry.js +0 -29
- package/dist/handlers/registry.js.map +0 -1
- package/src/handlers/registry.ts +0 -36
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
2
|
-
import
|
|
2
|
+
import {
|
|
3
|
+
createDatabase,
|
|
4
|
+
type SyncChange,
|
|
5
|
+
type SyncTransport,
|
|
6
|
+
SyncTransportError,
|
|
7
|
+
} from '@syncular/core';
|
|
3
8
|
import type { Kysely } from 'kysely';
|
|
4
9
|
import { sql } from 'kysely';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
10
|
+
import { createBunSqliteDialect } from '../../../dialect-bun-sqlite/src';
|
|
11
|
+
import type { ClientHandlerCollection } from '../handlers/collection';
|
|
7
12
|
import { ensureClientSyncSchema } from '../migrate';
|
|
8
13
|
import type { SyncClientDb } from '../schema';
|
|
9
14
|
import { SyncEngine } from './SyncEngine';
|
|
@@ -31,7 +36,10 @@ describe('SyncEngine WS inline apply', () => {
|
|
|
31
36
|
let db: Kysely<TestDb>;
|
|
32
37
|
|
|
33
38
|
beforeEach(async () => {
|
|
34
|
-
db =
|
|
39
|
+
db = createDatabase<TestDb>({
|
|
40
|
+
dialect: createBunSqliteDialect({ path: ':memory:' }),
|
|
41
|
+
family: 'sqlite',
|
|
42
|
+
});
|
|
35
43
|
await ensureClientSyncSchema(db);
|
|
36
44
|
|
|
37
45
|
await db.schema
|
|
@@ -74,29 +82,31 @@ describe('SyncEngine WS inline apply', () => {
|
|
|
74
82
|
});
|
|
75
83
|
|
|
76
84
|
it('rolls back row updates and cursor when any inline WS change fails', async () => {
|
|
77
|
-
const handlers
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
85
|
+
const handlers: ClientHandlerCollection<TestDb> = [
|
|
86
|
+
{
|
|
87
|
+
table: 'tasks',
|
|
88
|
+
async applySnapshot() {},
|
|
89
|
+
async clearAll() {},
|
|
90
|
+
async applyChange(ctx, change) {
|
|
91
|
+
if (change.row_id === 'fail') {
|
|
92
|
+
throw new Error('forced apply failure');
|
|
93
|
+
}
|
|
94
|
+
const rowJson =
|
|
95
|
+
change.row_json && typeof change.row_json === 'object'
|
|
96
|
+
? change.row_json
|
|
97
|
+
: null;
|
|
98
|
+
const title =
|
|
99
|
+
rowJson && 'title' in rowJson ? String(rowJson.title ?? '') : '';
|
|
100
|
+
await sql`
|
|
101
|
+
update ${sql.table('tasks')}
|
|
102
|
+
set
|
|
103
|
+
${sql.ref('title')} = ${sql.val(title)},
|
|
104
|
+
${sql.ref('server_version')} = ${sql.val(Number(change.row_version ?? 0))}
|
|
105
|
+
where ${sql.ref('id')} = ${sql.val(change.row_id)}
|
|
106
|
+
`.execute(ctx.trx);
|
|
107
|
+
},
|
|
98
108
|
},
|
|
99
|
-
|
|
109
|
+
];
|
|
100
110
|
|
|
101
111
|
const engine = new SyncEngine<TestDb>({
|
|
102
112
|
db,
|
|
@@ -156,12 +166,14 @@ describe('SyncEngine WS inline apply', () => {
|
|
|
156
166
|
});
|
|
157
167
|
|
|
158
168
|
it('returns a bounded inspector snapshot with serializable events', async () => {
|
|
159
|
-
const handlers
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
169
|
+
const handlers: ClientHandlerCollection<TestDb> = [
|
|
170
|
+
{
|
|
171
|
+
table: 'tasks',
|
|
172
|
+
async applySnapshot() {},
|
|
173
|
+
async clearAll() {},
|
|
174
|
+
async applyChange() {},
|
|
175
|
+
},
|
|
176
|
+
];
|
|
165
177
|
|
|
166
178
|
const engine = new SyncEngine<TestDb>({
|
|
167
179
|
db,
|
|
@@ -193,4 +205,201 @@ describe('SyncEngine WS inline apply', () => {
|
|
|
193
205
|
expect(typeof first.payload).toBe('object');
|
|
194
206
|
expect(snapshot.diagnostics).toBeDefined();
|
|
195
207
|
});
|
|
208
|
+
|
|
209
|
+
it('ensures sync schema on start without custom migrate callback', async () => {
|
|
210
|
+
const coldDb = createDatabase<TestDb>({
|
|
211
|
+
dialect: createBunSqliteDialect({ path: ':memory:' }),
|
|
212
|
+
family: 'sqlite',
|
|
213
|
+
});
|
|
214
|
+
try {
|
|
215
|
+
await coldDb.schema
|
|
216
|
+
.createTable('tasks')
|
|
217
|
+
.addColumn('id', 'text', (col) => col.primaryKey())
|
|
218
|
+
.addColumn('title', 'text', (col) => col.notNull())
|
|
219
|
+
.addColumn('server_version', 'integer', (col) =>
|
|
220
|
+
col.notNull().defaultTo(0)
|
|
221
|
+
)
|
|
222
|
+
.execute();
|
|
223
|
+
|
|
224
|
+
const handlers: ClientHandlerCollection<TestDb> = [
|
|
225
|
+
{
|
|
226
|
+
table: 'tasks',
|
|
227
|
+
async applySnapshot() {},
|
|
228
|
+
async clearAll() {},
|
|
229
|
+
async applyChange() {},
|
|
230
|
+
},
|
|
231
|
+
];
|
|
232
|
+
|
|
233
|
+
const engine = new SyncEngine<TestDb>({
|
|
234
|
+
db: coldDb,
|
|
235
|
+
transport: noopTransport,
|
|
236
|
+
handlers,
|
|
237
|
+
actorId: 'u1',
|
|
238
|
+
clientId: 'client-migrate',
|
|
239
|
+
subscriptions: [],
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
await engine.start();
|
|
243
|
+
|
|
244
|
+
const exists = await sql<{ count: number }>`
|
|
245
|
+
select count(*) as count
|
|
246
|
+
from sqlite_master
|
|
247
|
+
where type = 'table' and name = 'sync_subscription_state'
|
|
248
|
+
`.execute(coldDb);
|
|
249
|
+
|
|
250
|
+
expect(Number(exists.rows[0]?.count ?? 0)).toBe(1);
|
|
251
|
+
} finally {
|
|
252
|
+
await coldDb.destroy();
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('classifies missing snapshot chunk pull failures as non-retryable', async () => {
|
|
257
|
+
const missingChunkTransport: SyncTransport = {
|
|
258
|
+
async sync() {
|
|
259
|
+
throw new SyncTransportError('snapshot chunk not found', 404);
|
|
260
|
+
},
|
|
261
|
+
async fetchSnapshotChunk() {
|
|
262
|
+
return new Uint8Array();
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const handlers: ClientHandlerCollection<TestDb> = [
|
|
267
|
+
{
|
|
268
|
+
table: 'tasks',
|
|
269
|
+
async applySnapshot() {},
|
|
270
|
+
async clearAll() {},
|
|
271
|
+
async applyChange() {},
|
|
272
|
+
},
|
|
273
|
+
];
|
|
274
|
+
|
|
275
|
+
const engine = new SyncEngine<TestDb>({
|
|
276
|
+
db,
|
|
277
|
+
transport: missingChunkTransport,
|
|
278
|
+
handlers,
|
|
279
|
+
actorId: 'u1',
|
|
280
|
+
clientId: 'client-missing-chunk',
|
|
281
|
+
subscriptions: [
|
|
282
|
+
{
|
|
283
|
+
id: 'sub-1',
|
|
284
|
+
table: 'tasks',
|
|
285
|
+
scopes: {},
|
|
286
|
+
},
|
|
287
|
+
],
|
|
288
|
+
stateId: 'default',
|
|
289
|
+
pollIntervalMs: 60_000,
|
|
290
|
+
maxRetries: 3,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
await engine.start();
|
|
294
|
+
engine.stop();
|
|
295
|
+
|
|
296
|
+
const state = engine.getState();
|
|
297
|
+
expect(state.error?.code).toBe('SNAPSHOT_CHUNK_NOT_FOUND');
|
|
298
|
+
expect(state.error?.retryable).toBe(false);
|
|
299
|
+
expect(state.retryCount).toBe(1);
|
|
300
|
+
expect(state.isRetrying).toBe(false);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('repairs rebootstrap-missing-chunks by clearing synced state and data', async () => {
|
|
304
|
+
const outboxId = 'outbox-1';
|
|
305
|
+
const now = Date.now();
|
|
306
|
+
|
|
307
|
+
await db
|
|
308
|
+
.insertInto('tasks')
|
|
309
|
+
.values({
|
|
310
|
+
id: 't2',
|
|
311
|
+
title: 'to-clear',
|
|
312
|
+
server_version: 2,
|
|
313
|
+
})
|
|
314
|
+
.execute();
|
|
315
|
+
|
|
316
|
+
await db
|
|
317
|
+
.insertInto('sync_outbox_commits')
|
|
318
|
+
.values({
|
|
319
|
+
id: outboxId,
|
|
320
|
+
client_commit_id: 'client-commit-1',
|
|
321
|
+
status: 'pending',
|
|
322
|
+
operations_json: '[]',
|
|
323
|
+
last_response_json: null,
|
|
324
|
+
error: null,
|
|
325
|
+
created_at: now,
|
|
326
|
+
updated_at: now,
|
|
327
|
+
acked_commit_seq: null,
|
|
328
|
+
})
|
|
329
|
+
.execute();
|
|
330
|
+
|
|
331
|
+
await db
|
|
332
|
+
.insertInto('sync_conflicts')
|
|
333
|
+
.values({
|
|
334
|
+
id: 'conflict-1',
|
|
335
|
+
outbox_commit_id: outboxId,
|
|
336
|
+
client_commit_id: 'client-commit-1',
|
|
337
|
+
op_index: 0,
|
|
338
|
+
result_status: 'conflict',
|
|
339
|
+
message: 'forced conflict',
|
|
340
|
+
code: 'TEST_CONFLICT',
|
|
341
|
+
server_version: 1,
|
|
342
|
+
server_row_json: '{}',
|
|
343
|
+
created_at: now,
|
|
344
|
+
resolved_at: null,
|
|
345
|
+
resolution: null,
|
|
346
|
+
})
|
|
347
|
+
.execute();
|
|
348
|
+
|
|
349
|
+
const handlers: ClientHandlerCollection<TestDb> = [
|
|
350
|
+
{
|
|
351
|
+
table: 'tasks',
|
|
352
|
+
async applySnapshot() {},
|
|
353
|
+
async clearAll(ctx) {
|
|
354
|
+
await sql`delete from ${sql.table('tasks')}`.execute(ctx.trx);
|
|
355
|
+
},
|
|
356
|
+
async applyChange() {},
|
|
357
|
+
},
|
|
358
|
+
];
|
|
359
|
+
|
|
360
|
+
const engine = new SyncEngine<TestDb>({
|
|
361
|
+
db,
|
|
362
|
+
transport: noopTransport,
|
|
363
|
+
handlers,
|
|
364
|
+
actorId: 'u1',
|
|
365
|
+
clientId: 'client-repair',
|
|
366
|
+
subscriptions: [],
|
|
367
|
+
stateId: 'default',
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
const result = await engine.repair({
|
|
371
|
+
mode: 'rebootstrap-missing-chunks',
|
|
372
|
+
clearOutbox: true,
|
|
373
|
+
clearConflicts: true,
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
expect(result.deletedSubscriptionStates).toBe(1);
|
|
377
|
+
expect(result.deletedOutboxCommits).toBe(1);
|
|
378
|
+
expect(result.deletedConflicts).toBe(1);
|
|
379
|
+
expect(result.clearedTables).toEqual(['tasks']);
|
|
380
|
+
|
|
381
|
+
const tasksCount = await db
|
|
382
|
+
.selectFrom('tasks')
|
|
383
|
+
.select(({ fn }) => fn.countAll().as('total'))
|
|
384
|
+
.executeTakeFirst();
|
|
385
|
+
expect(Number(tasksCount?.total ?? 0)).toBe(0);
|
|
386
|
+
|
|
387
|
+
const subscriptionsCount = await db
|
|
388
|
+
.selectFrom('sync_subscription_state')
|
|
389
|
+
.select(({ fn }) => fn.countAll().as('total'))
|
|
390
|
+
.executeTakeFirst();
|
|
391
|
+
expect(Number(subscriptionsCount?.total ?? 0)).toBe(0);
|
|
392
|
+
|
|
393
|
+
const outboxCount = await db
|
|
394
|
+
.selectFrom('sync_outbox_commits')
|
|
395
|
+
.select(({ fn }) => fn.countAll().as('total'))
|
|
396
|
+
.executeTakeFirst();
|
|
397
|
+
expect(Number(outboxCount?.total ?? 0)).toBe(0);
|
|
398
|
+
|
|
399
|
+
const conflictsCount = await db
|
|
400
|
+
.selectFrom('sync_conflicts')
|
|
401
|
+
.select(({ fn }) => fn.countAll().as('total'))
|
|
402
|
+
.executeTakeFirst();
|
|
403
|
+
expect(Number(conflictsCount?.total ?? 0)).toBe(0);
|
|
404
|
+
});
|
|
196
405
|
});
|
package/src/engine/SyncEngine.ts
CHANGED
|
@@ -18,6 +18,8 @@ import {
|
|
|
18
18
|
startSyncSpan,
|
|
19
19
|
} from '@syncular/core';
|
|
20
20
|
import { type Kysely, sql, type Transaction } from 'kysely';
|
|
21
|
+
import { getClientHandler } from '../handlers/collection';
|
|
22
|
+
import { ensureClientSyncSchema } from '../migrate';
|
|
21
23
|
import { syncPushOnce } from '../push-engine';
|
|
22
24
|
import type {
|
|
23
25
|
ConflictResultStatus,
|
|
@@ -1005,7 +1007,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1005
1007
|
}
|
|
1006
1008
|
|
|
1007
1009
|
if (options.scope === 'all') {
|
|
1008
|
-
for (const handler of this.config.handlers
|
|
1010
|
+
for (const handler of this.config.handlers) {
|
|
1009
1011
|
await handler.clearAll({ trx, scopes: {} });
|
|
1010
1012
|
clearedTables.push(handler.table);
|
|
1011
1013
|
}
|
|
@@ -1014,7 +1016,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1014
1016
|
|
|
1015
1017
|
const seen = new Set<string>();
|
|
1016
1018
|
for (const target of targets) {
|
|
1017
|
-
const handler = this.config.handlers
|
|
1019
|
+
const handler = getClientHandler(this.config.handlers, target.table);
|
|
1018
1020
|
if (!handler) continue;
|
|
1019
1021
|
|
|
1020
1022
|
const key = `${target.table}:${JSON.stringify(target.scopes)}`;
|
|
@@ -1220,40 +1222,44 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1220
1222
|
|
|
1221
1223
|
this.updateState({ enabled: true });
|
|
1222
1224
|
|
|
1223
|
-
// Run
|
|
1224
|
-
if (
|
|
1225
|
-
// Best-effort: push
|
|
1226
|
-
//
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1225
|
+
// Run migrations before first sync.
|
|
1226
|
+
if (!this.migrated) {
|
|
1227
|
+
// Best-effort: push pending commits before user migration, because
|
|
1228
|
+
// app migrations may reset tables and discard unsynced local writes.
|
|
1229
|
+
if (this.config.migrate) {
|
|
1230
|
+
try {
|
|
1231
|
+
const hasOutbox = await sql`
|
|
1232
|
+
select 1 from ${sql.table('sync_outbox_commits')} limit 1
|
|
1233
|
+
`
|
|
1234
|
+
.execute(this.config.db)
|
|
1235
|
+
.then((r) => r.rows.length > 0)
|
|
1236
|
+
.catch(() => false);
|
|
1237
|
+
|
|
1238
|
+
if (hasOutbox) {
|
|
1239
|
+
let pushed = true;
|
|
1240
|
+
while (pushed) {
|
|
1241
|
+
const result = await syncPushOnce(
|
|
1242
|
+
this.config.db,
|
|
1243
|
+
this.config.transport,
|
|
1244
|
+
{
|
|
1245
|
+
clientId: this.config.clientId!,
|
|
1246
|
+
actorId: this.config.actorId ?? undefined,
|
|
1247
|
+
plugins: this.config.plugins,
|
|
1248
|
+
}
|
|
1249
|
+
);
|
|
1250
|
+
pushed = result.pushed;
|
|
1251
|
+
}
|
|
1249
1252
|
}
|
|
1253
|
+
} catch {
|
|
1254
|
+
// Best-effort: continue even if pre-migration push fails.
|
|
1250
1255
|
}
|
|
1251
|
-
} catch {
|
|
1252
|
-
// Best-effort: if push fails (network down, table missing), continue
|
|
1253
1256
|
}
|
|
1254
1257
|
|
|
1255
1258
|
try {
|
|
1256
|
-
|
|
1259
|
+
if (this.config.migrate) {
|
|
1260
|
+
await this.config.migrate(this.config.db);
|
|
1261
|
+
}
|
|
1262
|
+
await ensureClientSyncSchema(this.config.db);
|
|
1257
1263
|
this.migrated = true;
|
|
1258
1264
|
} catch (err) {
|
|
1259
1265
|
const migrationError =
|
|
@@ -1597,7 +1603,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1597
1603
|
try {
|
|
1598
1604
|
await this.config.db.transaction().execute(async (trx) => {
|
|
1599
1605
|
for (const change of changes) {
|
|
1600
|
-
const handler = this.config.handlers
|
|
1606
|
+
const handler = getClientHandler(this.config.handlers, change.table);
|
|
1601
1607
|
if (!handler) {
|
|
1602
1608
|
throw new Error(
|
|
1603
1609
|
`Missing client table handler for WS change table "${change.table}"`
|
|
@@ -1852,6 +1858,13 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1852
1858
|
}
|
|
1853
1859
|
|
|
1854
1860
|
const delay = calculateRetryDelay(this.state.retryCount);
|
|
1861
|
+
if (this.state.pendingCount > 0) {
|
|
1862
|
+
countSyncMetric('sync.outbox.retry_count', 1, {
|
|
1863
|
+
attributes: {
|
|
1864
|
+
retryCount: this.state.retryCount,
|
|
1865
|
+
},
|
|
1866
|
+
});
|
|
1867
|
+
}
|
|
1855
1868
|
this.updateState({ isRetrying: true });
|
|
1856
1869
|
|
|
1857
1870
|
this.retryTimeoutId = setTimeout(() => {
|
|
@@ -1975,6 +1988,13 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1975
1988
|
case 'connected': {
|
|
1976
1989
|
const wasConnectedBefore = this.hasRealtimeConnectedOnce;
|
|
1977
1990
|
this.hasRealtimeConnectedOnce = true;
|
|
1991
|
+
if (wasConnectedBefore) {
|
|
1992
|
+
countSyncMetric('sync.transport.reconnects', 1, {
|
|
1993
|
+
attributes: {
|
|
1994
|
+
source: 'client',
|
|
1995
|
+
},
|
|
1996
|
+
});
|
|
1997
|
+
}
|
|
1978
1998
|
this.setConnectionState('connected');
|
|
1979
1999
|
this.updateTransportHealth({
|
|
1980
2000
|
mode: 'realtime',
|
|
@@ -2287,7 +2307,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
2287
2307
|
|
|
2288
2308
|
await db.transaction().execute(async (trx) => {
|
|
2289
2309
|
for (const input of inputs) {
|
|
2290
|
-
const handler = handlers
|
|
2310
|
+
const handler = getClientHandler(handlers, input.table);
|
|
2291
2311
|
if (!handler) continue;
|
|
2292
2312
|
|
|
2293
2313
|
affectedTables.add(input.table);
|
package/src/engine/types.ts
CHANGED
|
@@ -12,7 +12,7 @@ import type {
|
|
|
12
12
|
SyncTransport,
|
|
13
13
|
} from '@syncular/core';
|
|
14
14
|
import type { Kysely } from 'kysely';
|
|
15
|
-
import type {
|
|
15
|
+
import type { ClientHandlerCollection } from '../handlers/collection';
|
|
16
16
|
import type { SyncClientPlugin } from '../plugins/types';
|
|
17
17
|
import type { SyncClientDb } from '../schema';
|
|
18
18
|
import type { SubscriptionState } from '../subscription-state';
|
|
@@ -225,7 +225,7 @@ export interface SyncEngineConfig<DB extends SyncClientDb = SyncClientDb> {
|
|
|
225
225
|
/** Sync transport */
|
|
226
226
|
transport: SyncTransport;
|
|
227
227
|
/** Client table handler registry */
|
|
228
|
-
handlers:
|
|
228
|
+
handlers: ClientHandlerCollection<DB>;
|
|
229
229
|
/** Actor id for sync scoping (null/undefined disables sync) */
|
|
230
230
|
actorId: string | null | undefined;
|
|
231
231
|
/** Stable device/app installation id */
|
|
@@ -244,7 +244,7 @@ export interface SyncEngineConfig<DB extends SyncClientDb = SyncClientDb> {
|
|
|
244
244
|
pollIntervalMs?: number;
|
|
245
245
|
/** Max retries before giving up */
|
|
246
246
|
maxRetries?: number;
|
|
247
|
-
/**
|
|
247
|
+
/** Optional app migration to run before sync schema migration. */
|
|
248
248
|
migrate?: (db: Kysely<DB>) => Promise<void>;
|
|
249
249
|
/** Called when migration fails. Receives the error. */
|
|
250
250
|
onMigrationError?: (error: Error) => void;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ClientTableHandler } from './types';
|
|
2
|
+
|
|
3
|
+
export type ClientHandlerCollection<DB> = ClientTableHandler<DB>[];
|
|
4
|
+
|
|
5
|
+
export function createClientHandlerCollection<DB>(
|
|
6
|
+
handlers: ClientTableHandler<DB>[]
|
|
7
|
+
): ClientHandlerCollection<DB> {
|
|
8
|
+
const tables = new Set<string>();
|
|
9
|
+
for (const handler of handlers) {
|
|
10
|
+
if (tables.has(handler.table)) {
|
|
11
|
+
throw new Error(
|
|
12
|
+
`Client table handler already registered: ${handler.table}`
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
tables.add(handler.table);
|
|
16
|
+
}
|
|
17
|
+
return handlers;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getClientHandler<DB>(
|
|
21
|
+
handlers: ClientHandlerCollection<DB>,
|
|
22
|
+
table: string
|
|
23
|
+
): ClientTableHandler<DB> | undefined {
|
|
24
|
+
return handlers.find((handler) => handler.table === table);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getClientHandlerOrThrow<DB>(
|
|
28
|
+
handlers: ClientHandlerCollection<DB>,
|
|
29
|
+
table: string
|
|
30
|
+
): ClientTableHandler<DB> {
|
|
31
|
+
const handler = getClientHandler(handlers, table);
|
|
32
|
+
if (!handler) {
|
|
33
|
+
throw new Error(`Missing client table handler for table: ${table}`);
|
|
34
|
+
}
|
|
35
|
+
return handler;
|
|
36
|
+
}
|
|
@@ -82,7 +82,7 @@ export interface CreateClientHandlerOptions<
|
|
|
82
82
|
* Optional column codec resolver.
|
|
83
83
|
* Receives `{ table, column, sqlType?, dialect? }` and returns a codec.
|
|
84
84
|
*/
|
|
85
|
-
|
|
85
|
+
codecs?: ColumnCodecSource;
|
|
86
86
|
|
|
87
87
|
/**
|
|
88
88
|
* Dialect used for codec dialect overrides.
|
|
@@ -178,14 +178,14 @@ export function createClientHandler<
|
|
|
178
178
|
const codecDialect = options.codecDialect ?? 'sqlite';
|
|
179
179
|
const codecCache = new Map<string, ReturnType<typeof toTableColumnCodecs>>();
|
|
180
180
|
const resolveTableCodecs = (row: Record<string, unknown>) => {
|
|
181
|
-
const
|
|
182
|
-
if (!
|
|
181
|
+
const codecs = options.codecs;
|
|
182
|
+
if (!codecs) return {};
|
|
183
183
|
const columns = Object.keys(row);
|
|
184
184
|
if (columns.length === 0) return {};
|
|
185
185
|
const cacheKey = columns.slice().sort().join('\u0000');
|
|
186
186
|
const cached = codecCache.get(cacheKey);
|
|
187
187
|
if (cached) return cached;
|
|
188
|
-
const resolved = toTableColumnCodecs(table,
|
|
188
|
+
const resolved = toTableColumnCodecs(table, codecs, columns, {
|
|
189
189
|
dialect: codecDialect,
|
|
190
190
|
});
|
|
191
191
|
codecCache.set(cacheKey, resolved);
|
package/src/index.ts
CHANGED
|
@@ -9,8 +9,8 @@ export * from './client';
|
|
|
9
9
|
export * from './conflicts';
|
|
10
10
|
export * from './create-client';
|
|
11
11
|
export * from './engine';
|
|
12
|
+
export * from './handlers/collection';
|
|
12
13
|
export * from './handlers/create-handler';
|
|
13
|
-
export * from './handlers/registry';
|
|
14
14
|
export * from './handlers/types';
|
|
15
15
|
export * from './migrate';
|
|
16
16
|
export * from './mutations';
|
|
@@ -22,5 +22,6 @@ export * from './push-engine';
|
|
|
22
22
|
export * from './query';
|
|
23
23
|
export * from './schema';
|
|
24
24
|
export * from './subscription-state';
|
|
25
|
+
export * from './sync';
|
|
25
26
|
export * from './sync-loop';
|
|
26
27
|
export * from './utils/id';
|
package/src/migrate.ts
CHANGED
|
@@ -182,6 +182,13 @@ async function ensureClientSyncSchemaCompat<DB extends SyncClientDb>(
|
|
|
182
182
|
.addColumn('resolution', 'text')
|
|
183
183
|
.execute();
|
|
184
184
|
});
|
|
185
|
+
|
|
186
|
+
await db.schema
|
|
187
|
+
.createIndex('idx_sync_outbox_commits_status_updated_at')
|
|
188
|
+
.ifNotExists()
|
|
189
|
+
.on('sync_outbox_commits')
|
|
190
|
+
.columns(['status', 'updated_at', 'created_at'])
|
|
191
|
+
.execute();
|
|
185
192
|
}
|
|
186
193
|
|
|
187
194
|
/**
|
|
@@ -275,6 +282,13 @@ export async function ensureClientSyncSchema<DB extends SyncClientDb>(
|
|
|
275
282
|
.columns(['status', 'created_at'])
|
|
276
283
|
.execute();
|
|
277
284
|
|
|
285
|
+
await db.schema
|
|
286
|
+
.createIndex('idx_sync_outbox_commits_status_updated_at')
|
|
287
|
+
.ifNotExists()
|
|
288
|
+
.on('sync_outbox_commits')
|
|
289
|
+
.columns(['status', 'updated_at', 'created_at'])
|
|
290
|
+
.execute();
|
|
291
|
+
|
|
278
292
|
await db.schema
|
|
279
293
|
.createIndex('idx_sync_conflicts_outbox_commit')
|
|
280
294
|
.ifNotExists()
|
package/src/mutations.ts
CHANGED
|
@@ -400,7 +400,7 @@ export interface OutboxCommitConfig<DB extends SyncClientDb> {
|
|
|
400
400
|
idColumn?: string;
|
|
401
401
|
versionColumn?: string | null;
|
|
402
402
|
omitColumns?: string[];
|
|
403
|
-
|
|
403
|
+
codecs?: ColumnCodecSource;
|
|
404
404
|
codecDialect?: ColumnCodecDialect;
|
|
405
405
|
}
|
|
406
406
|
|
|
@@ -432,8 +432,8 @@ export function createOutboxCommit<DB extends SyncClientDb>(
|
|
|
432
432
|
table: string,
|
|
433
433
|
row: Record<string, unknown>
|
|
434
434
|
) => {
|
|
435
|
-
const
|
|
436
|
-
if (!
|
|
435
|
+
const codecs = config.codecs;
|
|
436
|
+
if (!codecs) return {};
|
|
437
437
|
const columns = Object.keys(row);
|
|
438
438
|
if (columns.length === 0) return {};
|
|
439
439
|
|
|
@@ -450,7 +450,7 @@ export function createOutboxCommit<DB extends SyncClientDb>(
|
|
|
450
450
|
const cached = tableCache.get(cacheKey);
|
|
451
451
|
if (cached) return cached;
|
|
452
452
|
|
|
453
|
-
const resolved = toTableColumnCodecs(table,
|
|
453
|
+
const resolved = toTableColumnCodecs(table, codecs, columns, {
|
|
454
454
|
dialect: codecDialect,
|
|
455
455
|
});
|
|
456
456
|
tableCache.set(cacheKey, resolved);
|