@syncular/client 0.0.6-56 → 0.0.6-67
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 +2 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/create-client.d.ts +2 -3
- package/dist/create-client.d.ts.map +1 -1
- package/dist/create-client.js +12 -8
- package/dist/create-client.js.map +1 -1
- package/dist/engine/SyncEngine.d.ts.map +1 -1
- package/dist/engine/SyncEngine.js +35 -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/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/pull-engine.d.ts +3 -3
- package/dist/pull-engine.d.ts.map +1 -1
- package/dist/pull-engine.js +11 -4
- package/dist/pull-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.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 +3 -3
- package/src/client.test.ts +4 -4
- package/src/client.ts +2 -2
- package/src/create-client.test.ts +79 -0
- package/src/create-client.ts +15 -10
- package/src/engine/SyncEngine.test.ts +77 -29
- package/src/engine/SyncEngine.ts +39 -33
- package/src/engine/types.ts +3 -3
- package/src/handlers/collection.ts +36 -0
- package/src/index.ts +2 -1
- package/src/pull-engine.test.ts +4 -4
- package/src/pull-engine.ts +16 -7
- package/src/sync-loop.ts +4 -4
- 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
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import type { Kysely } from 'kysely';
|
|
3
|
+
import { createBunSqliteDb } from '../../dialect-bun-sqlite/src';
|
|
4
|
+
import { createClient } from './create-client';
|
|
5
|
+
import type { SyncClientDb } from './schema';
|
|
6
|
+
|
|
7
|
+
interface TasksTable {
|
|
8
|
+
id: string;
|
|
9
|
+
user_id: string;
|
|
10
|
+
title: string;
|
|
11
|
+
server_version: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface TestDb extends SyncClientDb {
|
|
15
|
+
tasks: TasksTable;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function createTestDb(): Promise<Kysely<TestDb>> {
|
|
19
|
+
const db = createBunSqliteDb<TestDb>({ path: ':memory:' });
|
|
20
|
+
await db.schema
|
|
21
|
+
.createTable('tasks')
|
|
22
|
+
.addColumn('id', 'text', (col) => col.primaryKey())
|
|
23
|
+
.addColumn('user_id', 'text', (col) => col.notNull())
|
|
24
|
+
.addColumn('title', 'text', (col) => col.notNull())
|
|
25
|
+
.addColumn('server_version', 'integer', (col) => col.notNull().defaultTo(0))
|
|
26
|
+
.execute();
|
|
27
|
+
return db;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('createClient url normalization', () => {
|
|
31
|
+
const originalFetch = globalThis.fetch;
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
globalThis.fetch = originalFetch;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('accepts sync endpoint URLs without duplicating /sync', async () => {
|
|
38
|
+
const requests: string[] = [];
|
|
39
|
+
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
40
|
+
const request = input instanceof Request ? input : new Request(input);
|
|
41
|
+
requests.push(request.url);
|
|
42
|
+
return new Response(
|
|
43
|
+
JSON.stringify({
|
|
44
|
+
ok: true,
|
|
45
|
+
pull: { ok: true, subscriptions: [] },
|
|
46
|
+
}),
|
|
47
|
+
{
|
|
48
|
+
status: 200,
|
|
49
|
+
headers: { 'content-type': 'application/json' },
|
|
50
|
+
}
|
|
51
|
+
);
|
|
52
|
+
}) as typeof fetch;
|
|
53
|
+
|
|
54
|
+
const db = await createTestDb();
|
|
55
|
+
try {
|
|
56
|
+
const { destroy } = await createClient<TestDb>({
|
|
57
|
+
db,
|
|
58
|
+
actorId: 'user-1',
|
|
59
|
+
clientId: 'client-1',
|
|
60
|
+
url: 'http://localhost:4311/api/sync',
|
|
61
|
+
handlers: [
|
|
62
|
+
{
|
|
63
|
+
table: 'tasks',
|
|
64
|
+
subscribe: false,
|
|
65
|
+
async applySnapshot() {},
|
|
66
|
+
async clearAll() {},
|
|
67
|
+
async applyChange() {},
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
destroy();
|
|
73
|
+
expect(requests).toContain('http://localhost:4311/api/sync');
|
|
74
|
+
expect(requests).not.toContain('http://localhost:4311/api/sync/sync');
|
|
75
|
+
} finally {
|
|
76
|
+
await db.destroy();
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
});
|
package/src/create-client.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Simplified client factory
|
|
3
3
|
*
|
|
4
4
|
* Breaking changes from legacy Client:
|
|
5
|
-
* - handlers: array
|
|
5
|
+
* - handlers: plain array (no registry class)
|
|
6
6
|
* - url: string instead of transport (transport auto-created)
|
|
7
7
|
* - subscriptions: derived from handler.subscribe (no separate param)
|
|
8
8
|
* - clientId: auto-generated (no longer required)
|
|
@@ -19,8 +19,8 @@ import { extractScopeVars } from '@syncular/core';
|
|
|
19
19
|
import { createHttpTransport } from '@syncular/transport-http';
|
|
20
20
|
import type { Kysely } from 'kysely';
|
|
21
21
|
import { Client } from './client';
|
|
22
|
+
import { createClientHandlerCollection } from './handlers/collection';
|
|
22
23
|
import { createClientHandler } from './handlers/create-handler';
|
|
23
|
-
import { ClientTableRegistry } from './handlers/registry';
|
|
24
24
|
import type { ClientTableHandler } from './handlers/types';
|
|
25
25
|
import type { SyncClientDb } from './schema';
|
|
26
26
|
import { randomUUID } from './utils/id';
|
|
@@ -75,12 +75,22 @@ function createAutoHandler<
|
|
|
75
75
|
});
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
function normalizeTransportBaseUrl(url: string): string {
|
|
79
|
+
const trimmed = url.trim().replace(/\/+$/, '');
|
|
80
|
+
if (!trimmed.endsWith('/sync')) {
|
|
81
|
+
return trimmed;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const baseUrl = trimmed.slice(0, -'/sync'.length);
|
|
85
|
+
return baseUrl.length > 0 ? baseUrl : '/';
|
|
86
|
+
}
|
|
87
|
+
|
|
78
88
|
interface CreateClientOptions<DB extends SyncClientDb> {
|
|
79
89
|
/** Kysely database instance */
|
|
80
90
|
db: Kysely<DB>;
|
|
81
91
|
|
|
82
92
|
/**
|
|
83
|
-
*
|
|
93
|
+
* Sync URL (e.g., '/api/sync') or base API URL (e.g., '/api').
|
|
84
94
|
* Defaults to '/api/sync' if not provided.
|
|
85
95
|
* Ignored if transport is provided.
|
|
86
96
|
*/
|
|
@@ -88,7 +98,6 @@ interface CreateClientOptions<DB extends SyncClientDb> {
|
|
|
88
98
|
|
|
89
99
|
/**
|
|
90
100
|
* Table handlers for applying snapshots and changes.
|
|
91
|
-
* Array is auto-converted to ClientTableRegistry.
|
|
92
101
|
* Handlers with `subscribe: true` (or an object) are synced.
|
|
93
102
|
* Handlers with `subscribe: false` are local-only.
|
|
94
103
|
* Either handlers or tables must be provided.
|
|
@@ -234,17 +243,13 @@ export async function createClient<DB extends SyncClientDb>(
|
|
|
234
243
|
})
|
|
235
244
|
);
|
|
236
245
|
|
|
237
|
-
|
|
238
|
-
const tableHandlers = new ClientTableRegistry<DB>();
|
|
239
|
-
for (const handler of handlers) {
|
|
240
|
-
tableHandlers.register(handler);
|
|
241
|
-
}
|
|
246
|
+
const tableHandlers = createClientHandlerCollection(handlers);
|
|
242
247
|
|
|
243
248
|
// Create transport from URL if not provided
|
|
244
249
|
let transport = customTransport;
|
|
245
250
|
if (!transport && url) {
|
|
246
251
|
transport = createHttpTransport({
|
|
247
|
-
baseUrl: url,
|
|
252
|
+
baseUrl: normalizeTransportBaseUrl(url),
|
|
248
253
|
getHeaders,
|
|
249
254
|
});
|
|
250
255
|
}
|
|
@@ -3,7 +3,7 @@ import type { SyncChange, SyncTransport } from '@syncular/core';
|
|
|
3
3
|
import type { Kysely } from 'kysely';
|
|
4
4
|
import { sql } from 'kysely';
|
|
5
5
|
import { createBunSqliteDb } from '../../../dialect-bun-sqlite/src';
|
|
6
|
-
import {
|
|
6
|
+
import type { ClientHandlerCollection } from '../handlers/collection';
|
|
7
7
|
import { ensureClientSyncSchema } from '../migrate';
|
|
8
8
|
import type { SyncClientDb } from '../schema';
|
|
9
9
|
import { SyncEngine } from './SyncEngine';
|
|
@@ -74,29 +74,31 @@ describe('SyncEngine WS inline apply', () => {
|
|
|
74
74
|
});
|
|
75
75
|
|
|
76
76
|
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
|
-
|
|
77
|
+
const handlers: ClientHandlerCollection<TestDb> = [
|
|
78
|
+
{
|
|
79
|
+
table: 'tasks',
|
|
80
|
+
async applySnapshot() {},
|
|
81
|
+
async clearAll() {},
|
|
82
|
+
async applyChange(ctx, change) {
|
|
83
|
+
if (change.row_id === 'fail') {
|
|
84
|
+
throw new Error('forced apply failure');
|
|
85
|
+
}
|
|
86
|
+
const rowJson =
|
|
87
|
+
change.row_json && typeof change.row_json === 'object'
|
|
88
|
+
? change.row_json
|
|
89
|
+
: null;
|
|
90
|
+
const title =
|
|
91
|
+
rowJson && 'title' in rowJson ? String(rowJson.title ?? '') : '';
|
|
92
|
+
await sql`
|
|
93
|
+
update ${sql.table('tasks')}
|
|
94
|
+
set
|
|
95
|
+
${sql.ref('title')} = ${sql.val(title)},
|
|
96
|
+
${sql.ref('server_version')} = ${sql.val(Number(change.row_version ?? 0))}
|
|
97
|
+
where ${sql.ref('id')} = ${sql.val(change.row_id)}
|
|
98
|
+
`.execute(ctx.trx);
|
|
99
|
+
},
|
|
98
100
|
},
|
|
99
|
-
|
|
101
|
+
];
|
|
100
102
|
|
|
101
103
|
const engine = new SyncEngine<TestDb>({
|
|
102
104
|
db,
|
|
@@ -156,12 +158,14 @@ describe('SyncEngine WS inline apply', () => {
|
|
|
156
158
|
});
|
|
157
159
|
|
|
158
160
|
it('returns a bounded inspector snapshot with serializable events', async () => {
|
|
159
|
-
const handlers
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
161
|
+
const handlers: ClientHandlerCollection<TestDb> = [
|
|
162
|
+
{
|
|
163
|
+
table: 'tasks',
|
|
164
|
+
async applySnapshot() {},
|
|
165
|
+
async clearAll() {},
|
|
166
|
+
async applyChange() {},
|
|
167
|
+
},
|
|
168
|
+
];
|
|
165
169
|
|
|
166
170
|
const engine = new SyncEngine<TestDb>({
|
|
167
171
|
db,
|
|
@@ -193,4 +197,48 @@ describe('SyncEngine WS inline apply', () => {
|
|
|
193
197
|
expect(typeof first.payload).toBe('object');
|
|
194
198
|
expect(snapshot.diagnostics).toBeDefined();
|
|
195
199
|
});
|
|
200
|
+
|
|
201
|
+
it('ensures sync schema on start without custom migrate callback', async () => {
|
|
202
|
+
const coldDb = createBunSqliteDb<TestDb>({ path: ':memory:' });
|
|
203
|
+
try {
|
|
204
|
+
await coldDb.schema
|
|
205
|
+
.createTable('tasks')
|
|
206
|
+
.addColumn('id', 'text', (col) => col.primaryKey())
|
|
207
|
+
.addColumn('title', 'text', (col) => col.notNull())
|
|
208
|
+
.addColumn('server_version', 'integer', (col) =>
|
|
209
|
+
col.notNull().defaultTo(0)
|
|
210
|
+
)
|
|
211
|
+
.execute();
|
|
212
|
+
|
|
213
|
+
const handlers: ClientHandlerCollection<TestDb> = [
|
|
214
|
+
{
|
|
215
|
+
table: 'tasks',
|
|
216
|
+
async applySnapshot() {},
|
|
217
|
+
async clearAll() {},
|
|
218
|
+
async applyChange() {},
|
|
219
|
+
},
|
|
220
|
+
];
|
|
221
|
+
|
|
222
|
+
const engine = new SyncEngine<TestDb>({
|
|
223
|
+
db: coldDb,
|
|
224
|
+
transport: noopTransport,
|
|
225
|
+
handlers,
|
|
226
|
+
actorId: 'u1',
|
|
227
|
+
clientId: 'client-migrate',
|
|
228
|
+
subscriptions: [],
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
await engine.start();
|
|
232
|
+
|
|
233
|
+
const exists = await sql<{ count: number }>`
|
|
234
|
+
select count(*) as count
|
|
235
|
+
from sqlite_master
|
|
236
|
+
where type = 'table' and name = 'sync_subscription_state'
|
|
237
|
+
`.execute(coldDb);
|
|
238
|
+
|
|
239
|
+
expect(Number(exists.rows[0]?.count ?? 0)).toBe(1);
|
|
240
|
+
} finally {
|
|
241
|
+
await coldDb.destroy();
|
|
242
|
+
}
|
|
243
|
+
});
|
|
196
244
|
});
|
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}"`
|
|
@@ -2287,7 +2293,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
2287
2293
|
|
|
2288
2294
|
await db.transaction().execute(async (trx) => {
|
|
2289
2295
|
for (const input of inputs) {
|
|
2290
|
-
const handler = handlers
|
|
2296
|
+
const handler = getClientHandler(handlers, input.table);
|
|
2291
2297
|
if (!handler) continue;
|
|
2292
2298
|
|
|
2293
2299
|
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
|
+
}
|
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/pull-engine.test.ts
CHANGED
|
@@ -7,8 +7,8 @@ import {
|
|
|
7
7
|
} from '@syncular/core';
|
|
8
8
|
import { type Kysely, sql } from 'kysely';
|
|
9
9
|
import { createBunSqliteDb } from '../../dialect-bun-sqlite/src';
|
|
10
|
+
import type { ClientHandlerCollection } from './handlers/collection';
|
|
10
11
|
import { createClientHandler } from './handlers/create-handler';
|
|
11
|
-
import { ClientTableRegistry } from './handlers/registry';
|
|
12
12
|
import { ensureClientSyncSchema } from './migrate';
|
|
13
13
|
import { applyPullResponse, buildPullRequest } from './pull-engine';
|
|
14
14
|
import type { SyncClientDb } from './schema';
|
|
@@ -75,12 +75,12 @@ describe('applyPullResponse chunk streaming', () => {
|
|
|
75
75
|
},
|
|
76
76
|
};
|
|
77
77
|
|
|
78
|
-
const handlers
|
|
78
|
+
const handlers: ClientHandlerCollection<TestDb> = [
|
|
79
79
|
createClientHandler({
|
|
80
80
|
table: 'items',
|
|
81
81
|
scopes: ['items:{id}'],
|
|
82
|
-
})
|
|
83
|
-
|
|
82
|
+
}),
|
|
83
|
+
];
|
|
84
84
|
|
|
85
85
|
const options = {
|
|
86
86
|
clientId: 'client-1',
|
package/src/pull-engine.ts
CHANGED
|
@@ -12,7 +12,10 @@ import type {
|
|
|
12
12
|
} from '@syncular/core';
|
|
13
13
|
import { decodeSnapshotRows } from '@syncular/core';
|
|
14
14
|
import { type Kysely, sql, type Transaction } from 'kysely';
|
|
15
|
-
import
|
|
15
|
+
import {
|
|
16
|
+
type ClientHandlerCollection,
|
|
17
|
+
getClientHandlerOrThrow,
|
|
18
|
+
} from './handlers/collection';
|
|
16
19
|
import type { ClientTableHandler } from './handlers/types';
|
|
17
20
|
import type {
|
|
18
21
|
SyncClientPlugin,
|
|
@@ -573,7 +576,7 @@ export async function buildPullRequest<DB extends SyncClientDb>(
|
|
|
573
576
|
export async function applyPullResponse<DB extends SyncClientDb>(
|
|
574
577
|
db: Kysely<DB>,
|
|
575
578
|
transport: SyncTransport,
|
|
576
|
-
handlers:
|
|
579
|
+
handlers: ClientHandlerCollection<DB>,
|
|
577
580
|
options: SyncPullOnceOptions,
|
|
578
581
|
pullState: {
|
|
579
582
|
request: SyncPullRequest;
|
|
@@ -620,7 +623,10 @@ export async function applyPullResponse<DB extends SyncClientDb>(
|
|
|
620
623
|
? JSON.parse(row.scopes_json)
|
|
621
624
|
: row.scopes_json
|
|
622
625
|
: {};
|
|
623
|
-
await handlers
|
|
626
|
+
await getClientHandlerOrThrow(handlers, row.table).clearAll({
|
|
627
|
+
trx,
|
|
628
|
+
scopes,
|
|
629
|
+
});
|
|
624
630
|
} catch {
|
|
625
631
|
// ignore missing table handler
|
|
626
632
|
}
|
|
@@ -649,7 +655,10 @@ export async function applyPullResponse<DB extends SyncClientDb>(
|
|
|
649
655
|
? JSON.parse(prev.scopes_json)
|
|
650
656
|
: prev.scopes_json
|
|
651
657
|
: {};
|
|
652
|
-
await handlers
|
|
658
|
+
await getClientHandlerOrThrow(handlers, prev.table).clearAll({
|
|
659
|
+
trx,
|
|
660
|
+
scopes,
|
|
661
|
+
});
|
|
653
662
|
} catch {
|
|
654
663
|
// ignore missing handler
|
|
655
664
|
}
|
|
@@ -666,7 +675,7 @@ export async function applyPullResponse<DB extends SyncClientDb>(
|
|
|
666
675
|
// Apply snapshots (bootstrap mode)
|
|
667
676
|
if (sub.bootstrap) {
|
|
668
677
|
for (const snapshot of sub.snapshots ?? []) {
|
|
669
|
-
const handler = handlers
|
|
678
|
+
const handler = getClientHandlerOrThrow(handlers, snapshot.table);
|
|
670
679
|
const hasChunkRefs =
|
|
671
680
|
Array.isArray(snapshot.chunks) && snapshot.chunks.length > 0;
|
|
672
681
|
|
|
@@ -698,7 +707,7 @@ export async function applyPullResponse<DB extends SyncClientDb>(
|
|
|
698
707
|
// Apply incremental changes
|
|
699
708
|
for (const commit of sub.commits) {
|
|
700
709
|
for (const change of commit.changes) {
|
|
701
|
-
const handler = handlers
|
|
710
|
+
const handler = getClientHandlerOrThrow(handlers, change.table);
|
|
702
711
|
await handler.applyChange({ trx }, change);
|
|
703
712
|
}
|
|
704
713
|
}
|
|
@@ -763,7 +772,7 @@ export async function applyPullResponse<DB extends SyncClientDb>(
|
|
|
763
772
|
export async function syncPullOnce<DB extends SyncClientDb>(
|
|
764
773
|
db: Kysely<DB>,
|
|
765
774
|
transport: SyncTransport,
|
|
766
|
-
handlers:
|
|
775
|
+
handlers: ClientHandlerCollection<DB>,
|
|
767
776
|
options: SyncPullOnceOptions
|
|
768
777
|
): Promise<SyncPullResponse> {
|
|
769
778
|
const pullState = await buildPullRequest(db, options);
|
package/src/sync-loop.ts
CHANGED
|
@@ -14,7 +14,7 @@ import type {
|
|
|
14
14
|
} from '@syncular/core';
|
|
15
15
|
import type { Kysely } from 'kysely';
|
|
16
16
|
import { upsertConflictsForRejectedCommit } from './conflicts';
|
|
17
|
-
import type {
|
|
17
|
+
import type { ClientHandlerCollection } from './handlers/collection';
|
|
18
18
|
import {
|
|
19
19
|
getNextSendableOutboxCommit,
|
|
20
20
|
markOutboxCommitAcked,
|
|
@@ -119,7 +119,7 @@ function mergePullResponse(
|
|
|
119
119
|
async function syncPullUntilSettled<DB extends SyncClientDb>(
|
|
120
120
|
db: Kysely<DB>,
|
|
121
121
|
transport: SyncTransport,
|
|
122
|
-
handlers:
|
|
122
|
+
handlers: ClientHandlerCollection<DB>,
|
|
123
123
|
options: SyncPullUntilSettledOptions
|
|
124
124
|
): Promise<SyncPullUntilSettledResult> {
|
|
125
125
|
const maxRounds = Math.max(1, Math.min(1000, options.maxRounds ?? 20));
|
|
@@ -182,7 +182,7 @@ export interface SyncOnceResult {
|
|
|
182
182
|
async function syncOnceCombined<DB extends SyncClientDb>(
|
|
183
183
|
db: Kysely<DB>,
|
|
184
184
|
transport: SyncTransport,
|
|
185
|
-
handlers:
|
|
185
|
+
handlers: ClientHandlerCollection<DB>,
|
|
186
186
|
options: SyncOnceOptions
|
|
187
187
|
): Promise<SyncOnceResult> {
|
|
188
188
|
const pullOpts: SyncPullOnceOptions = {
|
|
@@ -364,7 +364,7 @@ async function syncOnceCombined<DB extends SyncClientDb>(
|
|
|
364
364
|
export async function syncOnce<DB extends SyncClientDb>(
|
|
365
365
|
db: Kysely<DB>,
|
|
366
366
|
transport: SyncTransport,
|
|
367
|
-
handlers:
|
|
367
|
+
handlers: ClientHandlerCollection<DB>,
|
|
368
368
|
options: SyncOnceOptions
|
|
369
369
|
): Promise<SyncOnceResult> {
|
|
370
370
|
return syncOnceCombined(db, transport, handlers, options);
|