@syncular/client 0.0.1 → 0.0.2-127
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 +23 -0
- package/dist/blobs/index.js +3 -3
- package/dist/client.d.ts +10 -5
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +70 -21
- package/dist/client.js.map +1 -1
- package/dist/conflicts.d.ts.map +1 -1
- package/dist/conflicts.js +1 -7
- package/dist/conflicts.js.map +1 -1
- package/dist/create-client.d.ts +5 -1
- package/dist/create-client.d.ts.map +1 -1
- package/dist/create-client.js +22 -10
- package/dist/create-client.js.map +1 -1
- package/dist/engine/SyncEngine.d.ts +24 -2
- package/dist/engine/SyncEngine.d.ts.map +1 -1
- package/dist/engine/SyncEngine.js +290 -43
- package/dist/engine/SyncEngine.js.map +1 -1
- package/dist/engine/index.js +2 -2
- package/dist/engine/types.d.ts +16 -4
- package/dist/engine/types.d.ts.map +1 -1
- package/dist/handlers/create-handler.d.ts +15 -5
- package/dist/handlers/create-handler.d.ts.map +1 -1
- package/dist/handlers/create-handler.js +35 -24
- package/dist/handlers/create-handler.js.map +1 -1
- package/dist/handlers/types.d.ts +5 -5
- package/dist/handlers/types.d.ts.map +1 -1
- package/dist/index.js +19 -19
- package/dist/migrate.d.ts +1 -1
- package/dist/migrate.d.ts.map +1 -1
- package/dist/migrate.js +148 -28
- package/dist/migrate.js.map +1 -1
- package/dist/mutations.d.ts +3 -1
- package/dist/mutations.d.ts.map +1 -1
- package/dist/mutations.js +93 -18
- package/dist/mutations.js.map +1 -1
- package/dist/outbox.d.ts.map +1 -1
- package/dist/outbox.js +1 -11
- package/dist/outbox.js.map +1 -1
- package/dist/plugins/incrementing-version.d.ts +1 -1
- package/dist/plugins/incrementing-version.js +2 -2
- package/dist/plugins/index.js +2 -2
- package/dist/proxy/dialect.js +1 -1
- package/dist/proxy/driver.js +1 -1
- package/dist/proxy/index.js +4 -4
- package/dist/proxy/mutations.js +1 -1
- package/dist/pull-engine.d.ts +29 -3
- package/dist/pull-engine.d.ts.map +1 -1
- package/dist/pull-engine.js +314 -78
- package/dist/pull-engine.js.map +1 -1
- package/dist/push-engine.d.ts.map +1 -1
- package/dist/push-engine.js +28 -3
- package/dist/push-engine.js.map +1 -1
- package/dist/query/QueryContext.js +1 -1
- package/dist/query/index.js +3 -3
- package/dist/query/tracked-select.d.ts +2 -1
- package/dist/query/tracked-select.d.ts.map +1 -1
- package/dist/query/tracked-select.js +1 -1
- package/dist/schema.d.ts +2 -2
- package/dist/schema.d.ts.map +1 -1
- package/dist/sync-loop.d.ts +5 -1
- package/dist/sync-loop.d.ts.map +1 -1
- package/dist/sync-loop.js +167 -18
- package/dist/sync-loop.js.map +1 -1
- package/package.json +30 -6
- package/src/client.test.ts +369 -0
- package/src/client.ts +101 -22
- package/src/conflicts.ts +1 -10
- package/src/create-client.ts +33 -5
- package/src/engine/SyncEngine.test.ts +157 -0
- package/src/engine/SyncEngine.ts +359 -40
- package/src/engine/types.ts +22 -4
- package/src/handlers/create-handler.ts +86 -37
- package/src/handlers/types.ts +10 -4
- package/src/migrate.ts +215 -33
- package/src/mutations.ts +143 -21
- package/src/outbox.ts +1 -15
- package/src/plugins/incrementing-version.ts +2 -2
- package/src/pull-engine.test.ts +147 -0
- package/src/pull-engine.ts +392 -77
- package/src/push-engine.ts +33 -1
- package/src/query/tracked-select.ts +1 -1
- package/src/schema.ts +2 -2
- package/src/sync-loop.ts +215 -19
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import type { SyncTransport } from '@syncular/core';
|
|
3
|
+
import type { Kysely } from 'kysely';
|
|
4
|
+
import { sql } from 'kysely';
|
|
5
|
+
import { createBunSqliteDb } from '../../dialect-bun-sqlite/src';
|
|
6
|
+
import { ensureClientBlobSchema } from './blobs/migrate';
|
|
7
|
+
import { Client, type ClientBlobStorage } from './client';
|
|
8
|
+
import { SyncEngine } from './engine/SyncEngine';
|
|
9
|
+
import { ClientTableRegistry } from './handlers/registry';
|
|
10
|
+
import { ensureClientSyncSchema } from './migrate';
|
|
11
|
+
import type { SyncClientDb } from './schema';
|
|
12
|
+
|
|
13
|
+
interface TasksTable {
|
|
14
|
+
id: string;
|
|
15
|
+
title: string;
|
|
16
|
+
server_version: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface TestDb extends SyncClientDb {
|
|
20
|
+
tasks: TasksTable;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const noopTransport: SyncTransport = {
|
|
24
|
+
async sync() {
|
|
25
|
+
return {};
|
|
26
|
+
},
|
|
27
|
+
async fetchSnapshotChunk() {
|
|
28
|
+
return new Uint8Array();
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function createMemoryBlobStorage(): ClientBlobStorage {
|
|
33
|
+
const memory = new Map<string, Uint8Array>();
|
|
34
|
+
return {
|
|
35
|
+
async write(hash, data) {
|
|
36
|
+
if (data instanceof ReadableStream) {
|
|
37
|
+
const reader = data.getReader();
|
|
38
|
+
const chunks: Uint8Array[] = [];
|
|
39
|
+
let total = 0;
|
|
40
|
+
while (true) {
|
|
41
|
+
const chunk = await reader.read();
|
|
42
|
+
if (chunk.done) break;
|
|
43
|
+
chunks.push(chunk.value);
|
|
44
|
+
total += chunk.value.length;
|
|
45
|
+
}
|
|
46
|
+
const combined = new Uint8Array(total);
|
|
47
|
+
let offset = 0;
|
|
48
|
+
for (const chunk of chunks) {
|
|
49
|
+
combined.set(chunk, offset);
|
|
50
|
+
offset += chunk.length;
|
|
51
|
+
}
|
|
52
|
+
memory.set(hash, combined);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
memory.set(hash, new Uint8Array(data));
|
|
56
|
+
},
|
|
57
|
+
async read(hash) {
|
|
58
|
+
const data = memory.get(hash);
|
|
59
|
+
return data ? new Uint8Array(data) : null;
|
|
60
|
+
},
|
|
61
|
+
async delete(hash) {
|
|
62
|
+
memory.delete(hash);
|
|
63
|
+
},
|
|
64
|
+
async exists(hash) {
|
|
65
|
+
return memory.has(hash);
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('Client conflict events', () => {
|
|
71
|
+
let db: Kysely<TestDb>;
|
|
72
|
+
let client: Client<TestDb>;
|
|
73
|
+
let engine: SyncEngine<TestDb>;
|
|
74
|
+
|
|
75
|
+
async function seedConflict(id: string): Promise<void> {
|
|
76
|
+
const now = Date.now();
|
|
77
|
+
await sql`
|
|
78
|
+
insert into ${sql.table('sync_outbox_commits')} (
|
|
79
|
+
${sql.ref('id')},
|
|
80
|
+
${sql.ref('client_commit_id')},
|
|
81
|
+
${sql.ref('status')},
|
|
82
|
+
${sql.ref('operations_json')},
|
|
83
|
+
${sql.ref('last_response_json')},
|
|
84
|
+
${sql.ref('error')},
|
|
85
|
+
${sql.ref('created_at')},
|
|
86
|
+
${sql.ref('updated_at')},
|
|
87
|
+
${sql.ref('attempt_count')},
|
|
88
|
+
${sql.ref('acked_commit_seq')},
|
|
89
|
+
${sql.ref('schema_version')}
|
|
90
|
+
) values (
|
|
91
|
+
${'outbox-1'},
|
|
92
|
+
${'commit-1'},
|
|
93
|
+
${'failed'},
|
|
94
|
+
${JSON.stringify([
|
|
95
|
+
{
|
|
96
|
+
table: 'tasks',
|
|
97
|
+
row_id: 't1',
|
|
98
|
+
op: 'upsert',
|
|
99
|
+
payload: { id: 't1', title: 'local', server_version: 1 },
|
|
100
|
+
},
|
|
101
|
+
])},
|
|
102
|
+
${null},
|
|
103
|
+
${'conflict'},
|
|
104
|
+
${now},
|
|
105
|
+
${now},
|
|
106
|
+
${1},
|
|
107
|
+
${null},
|
|
108
|
+
${1}
|
|
109
|
+
)
|
|
110
|
+
`.execute(db);
|
|
111
|
+
|
|
112
|
+
await sql`
|
|
113
|
+
insert into ${sql.table('sync_conflicts')} (
|
|
114
|
+
${sql.ref('id')},
|
|
115
|
+
${sql.ref('outbox_commit_id')},
|
|
116
|
+
${sql.ref('client_commit_id')},
|
|
117
|
+
${sql.ref('op_index')},
|
|
118
|
+
${sql.ref('result_status')},
|
|
119
|
+
${sql.ref('message')},
|
|
120
|
+
${sql.ref('code')},
|
|
121
|
+
${sql.ref('server_version')},
|
|
122
|
+
${sql.ref('server_row_json')},
|
|
123
|
+
${sql.ref('created_at')},
|
|
124
|
+
${sql.ref('resolved_at')},
|
|
125
|
+
${sql.ref('resolution')}
|
|
126
|
+
) values (
|
|
127
|
+
${id},
|
|
128
|
+
${'outbox-1'},
|
|
129
|
+
${'commit-1'},
|
|
130
|
+
${0},
|
|
131
|
+
${'conflict'},
|
|
132
|
+
${'server conflict'},
|
|
133
|
+
${'CONFLICT'},
|
|
134
|
+
${2},
|
|
135
|
+
${JSON.stringify({ id: 't1', title: 'server', server_version: 2 })},
|
|
136
|
+
${now},
|
|
137
|
+
${null},
|
|
138
|
+
${null}
|
|
139
|
+
)
|
|
140
|
+
`.execute(db);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function runConflictCheck(
|
|
144
|
+
clientInstance: Client<TestDb>
|
|
145
|
+
): Promise<void> {
|
|
146
|
+
const checker = Reflect.get(clientInstance, 'checkForNewConflicts');
|
|
147
|
+
if (typeof checker !== 'function') {
|
|
148
|
+
throw new Error('Expected checkForNewConflicts to be callable');
|
|
149
|
+
}
|
|
150
|
+
await checker.call(clientInstance);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
beforeEach(async () => {
|
|
154
|
+
db = createBunSqliteDb<TestDb>({ path: ':memory:' });
|
|
155
|
+
await ensureClientSyncSchema(db);
|
|
156
|
+
await db.schema
|
|
157
|
+
.createTable('tasks')
|
|
158
|
+
.addColumn('id', 'text', (col) => col.primaryKey())
|
|
159
|
+
.addColumn('title', 'text', (col) => col.notNull())
|
|
160
|
+
.addColumn('server_version', 'integer', (col) =>
|
|
161
|
+
col.notNull().defaultTo(0)
|
|
162
|
+
)
|
|
163
|
+
.execute();
|
|
164
|
+
|
|
165
|
+
const handlers = new ClientTableRegistry<TestDb>();
|
|
166
|
+
client = new Client<TestDb>({
|
|
167
|
+
db,
|
|
168
|
+
transport: noopTransport,
|
|
169
|
+
tableHandlers: handlers,
|
|
170
|
+
clientId: 'client-1',
|
|
171
|
+
actorId: 'u1',
|
|
172
|
+
subscriptions: [],
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
engine = new SyncEngine<TestDb>({
|
|
176
|
+
db,
|
|
177
|
+
transport: noopTransport,
|
|
178
|
+
handlers: handlers,
|
|
179
|
+
actorId: 'u1',
|
|
180
|
+
clientId: 'client-1',
|
|
181
|
+
subscriptions: [],
|
|
182
|
+
});
|
|
183
|
+
Reflect.set(client, 'engine', engine);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
afterEach(async () => {
|
|
187
|
+
client.destroy();
|
|
188
|
+
await db.destroy();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('emits conflict:resolved with the resolved conflict payload', async () => {
|
|
192
|
+
await seedConflict('conflict-1');
|
|
193
|
+
|
|
194
|
+
const resolvedEvents: Array<{ id: string }> = [];
|
|
195
|
+
client.on('conflict:resolved', (conflict) => {
|
|
196
|
+
resolvedEvents.push({ id: conflict.id });
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
await client.resolveConflict('conflict-1', { strategy: 'keep-local' });
|
|
200
|
+
|
|
201
|
+
expect(resolvedEvents).toEqual([{ id: 'conflict-1' }]);
|
|
202
|
+
|
|
203
|
+
const resolvedRow = await sql<{ resolved_at: number | null }>`
|
|
204
|
+
select ${sql.ref('resolved_at')}
|
|
205
|
+
from ${sql.table('sync_conflicts')}
|
|
206
|
+
where ${sql.ref('id')} = ${'conflict-1'}
|
|
207
|
+
limit 1
|
|
208
|
+
`.execute(db);
|
|
209
|
+
expect(resolvedRow.rows[0]?.resolved_at).not.toBeNull();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('emits conflict:new only once per unresolved conflict id', async () => {
|
|
213
|
+
await seedConflict('conflict-1');
|
|
214
|
+
|
|
215
|
+
const newEvents: string[] = [];
|
|
216
|
+
client.on('conflict:new', (conflict) => {
|
|
217
|
+
newEvents.push(conflict.id);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await runConflictCheck(client);
|
|
221
|
+
await runConflictCheck(client);
|
|
222
|
+
|
|
223
|
+
expect(newEvents).toEqual(['conflict-1']);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('Client blob upload queue recovery', () => {
|
|
228
|
+
let db: Kysely<TestDb>;
|
|
229
|
+
let client: Client<TestDb>;
|
|
230
|
+
let initiateCalls = 0;
|
|
231
|
+
|
|
232
|
+
async function insertBlobOutboxRow(input: {
|
|
233
|
+
hash: string;
|
|
234
|
+
status: string;
|
|
235
|
+
attemptCount: number;
|
|
236
|
+
updatedAt: number;
|
|
237
|
+
}): Promise<void> {
|
|
238
|
+
const now = Date.now();
|
|
239
|
+
await sql`
|
|
240
|
+
insert into ${sql.table('sync_blob_outbox')} (
|
|
241
|
+
${sql.join([
|
|
242
|
+
sql.ref('hash'),
|
|
243
|
+
sql.ref('size'),
|
|
244
|
+
sql.ref('mime_type'),
|
|
245
|
+
sql.ref('body'),
|
|
246
|
+
sql.ref('encrypted'),
|
|
247
|
+
sql.ref('key_id'),
|
|
248
|
+
sql.ref('status'),
|
|
249
|
+
sql.ref('attempt_count'),
|
|
250
|
+
sql.ref('error'),
|
|
251
|
+
sql.ref('created_at'),
|
|
252
|
+
sql.ref('updated_at'),
|
|
253
|
+
])}
|
|
254
|
+
) values (
|
|
255
|
+
${sql.join([
|
|
256
|
+
sql.val(input.hash),
|
|
257
|
+
sql.val(3),
|
|
258
|
+
sql.val('application/octet-stream'),
|
|
259
|
+
sql.val(new Uint8Array([1, 2, 3])),
|
|
260
|
+
sql.val(0),
|
|
261
|
+
sql.val(null),
|
|
262
|
+
sql.val(input.status),
|
|
263
|
+
sql.val(input.attemptCount),
|
|
264
|
+
sql.val(null),
|
|
265
|
+
sql.val(now),
|
|
266
|
+
sql.val(input.updatedAt),
|
|
267
|
+
])}
|
|
268
|
+
)
|
|
269
|
+
`.execute(db);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
beforeEach(async () => {
|
|
273
|
+
db = createBunSqliteDb<TestDb>({ path: ':memory:' });
|
|
274
|
+
await ensureClientSyncSchema(db);
|
|
275
|
+
await ensureClientBlobSchema(db);
|
|
276
|
+
initiateCalls = 0;
|
|
277
|
+
|
|
278
|
+
const handlers = new ClientTableRegistry<TestDb>();
|
|
279
|
+
const transport: SyncTransport = {
|
|
280
|
+
...noopTransport,
|
|
281
|
+
blobs: {
|
|
282
|
+
async initiateUpload() {
|
|
283
|
+
initiateCalls++;
|
|
284
|
+
return { exists: true };
|
|
285
|
+
},
|
|
286
|
+
async completeUpload() {
|
|
287
|
+
return { ok: true };
|
|
288
|
+
},
|
|
289
|
+
async getDownloadUrl() {
|
|
290
|
+
return {
|
|
291
|
+
url: 'https://example.invalid/blob',
|
|
292
|
+
expiresAt: new Date(0).toISOString(),
|
|
293
|
+
};
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
client = new Client<TestDb>({
|
|
299
|
+
db,
|
|
300
|
+
transport,
|
|
301
|
+
tableHandlers: handlers,
|
|
302
|
+
blobStorage: createMemoryBlobStorage(),
|
|
303
|
+
clientId: 'client-1',
|
|
304
|
+
actorId: 'u1',
|
|
305
|
+
subscriptions: [],
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
afterEach(async () => {
|
|
310
|
+
client.destroy();
|
|
311
|
+
await db.destroy();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('requeues stale uploading rows and uploads them on the next queue run', async () => {
|
|
315
|
+
await insertBlobOutboxRow({
|
|
316
|
+
hash: 'sha256:stale-upload',
|
|
317
|
+
status: 'uploading',
|
|
318
|
+
attemptCount: 0,
|
|
319
|
+
updatedAt: Date.now() - 31_000,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const result = await client.blobs!.processUploadQueue();
|
|
323
|
+
|
|
324
|
+
expect(result.uploaded).toBe(1);
|
|
325
|
+
expect(result.failed).toBe(0);
|
|
326
|
+
expect(initiateCalls).toBe(1);
|
|
327
|
+
|
|
328
|
+
const remaining = await sql<{ count: number | bigint }>`
|
|
329
|
+
select count(${sql.ref('hash')}) as count
|
|
330
|
+
from ${sql.table('sync_blob_outbox')}
|
|
331
|
+
where ${sql.ref('hash')} = ${'sha256:stale-upload'}
|
|
332
|
+
`.execute(db);
|
|
333
|
+
expect(Number(remaining.rows[0]?.count ?? 0)).toBe(0);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('marks stale uploading rows as failed after max retries', async () => {
|
|
337
|
+
await insertBlobOutboxRow({
|
|
338
|
+
hash: 'sha256:stale-failed',
|
|
339
|
+
status: 'uploading',
|
|
340
|
+
attemptCount: 2,
|
|
341
|
+
updatedAt: Date.now() - 31_000,
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
await client.blobs!.processUploadQueue();
|
|
345
|
+
|
|
346
|
+
expect(initiateCalls).toBe(0);
|
|
347
|
+
|
|
348
|
+
const rowResult = await sql<{
|
|
349
|
+
status: string;
|
|
350
|
+
attempt_count: number;
|
|
351
|
+
error: string | null;
|
|
352
|
+
}>`
|
|
353
|
+
select
|
|
354
|
+
${sql.ref('status')} as status,
|
|
355
|
+
${sql.ref('attempt_count')} as attempt_count,
|
|
356
|
+
${sql.ref('error')} as error
|
|
357
|
+
from ${sql.table('sync_blob_outbox')}
|
|
358
|
+
where ${sql.ref('hash')} = ${'sha256:stale-failed'}
|
|
359
|
+
limit 1
|
|
360
|
+
`.execute(db);
|
|
361
|
+
const row = rowResult.rows[0];
|
|
362
|
+
if (!row) {
|
|
363
|
+
throw new Error('Expected stale failed row to remain in outbox');
|
|
364
|
+
}
|
|
365
|
+
expect(row.status).toBe('failed');
|
|
366
|
+
expect(row.attempt_count).toBe(3);
|
|
367
|
+
expect(row.error).toContain('Upload timed out while in uploading state');
|
|
368
|
+
});
|
|
369
|
+
});
|
package/src/client.ts
CHANGED
|
@@ -9,7 +9,12 @@
|
|
|
9
9
|
* - Conflict handling with events
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import type {
|
|
12
|
+
import type {
|
|
13
|
+
BlobRef,
|
|
14
|
+
ColumnCodecDialect,
|
|
15
|
+
ColumnCodecSource,
|
|
16
|
+
SyncTransport,
|
|
17
|
+
} from '@syncular/core';
|
|
13
18
|
import type { Kysely } from 'kysely';
|
|
14
19
|
import { sql } from 'kysely';
|
|
15
20
|
import { ensureClientBlobSchema } from './blobs/migrate';
|
|
@@ -85,7 +90,7 @@ export interface ClientOptions<DB extends SyncClientDb> {
|
|
|
85
90
|
/** Subscriptions to sync */
|
|
86
91
|
subscriptions: Array<{
|
|
87
92
|
id: string;
|
|
88
|
-
|
|
93
|
+
table: string;
|
|
89
94
|
scopes?: Record<string, string | string[]>;
|
|
90
95
|
params?: Record<string, unknown>;
|
|
91
96
|
}>;
|
|
@@ -113,6 +118,12 @@ export interface ClientOptions<DB extends SyncClientDb> {
|
|
|
113
118
|
|
|
114
119
|
/** Optional: Columns to omit from sync */
|
|
115
120
|
omitColumns?: string[];
|
|
121
|
+
|
|
122
|
+
/** Optional: Column codec resolver */
|
|
123
|
+
columnCodecs?: ColumnCodecSource;
|
|
124
|
+
|
|
125
|
+
/** Optional: Codec dialect override (default: 'sqlite') */
|
|
126
|
+
codecDialect?: ColumnCodecDialect;
|
|
116
127
|
}
|
|
117
128
|
|
|
118
129
|
export interface ClientState {
|
|
@@ -251,7 +262,7 @@ type ClientEventHandler<E extends ClientEventType> = (
|
|
|
251
262
|
* tableHandlers,
|
|
252
263
|
* clientId: 'device-123',
|
|
253
264
|
* actorId: 'user-456',
|
|
254
|
-
* subscriptions: [{ id: 'tasks',
|
|
265
|
+
* subscriptions: [{ id: 'tasks', table: 'tasks', scopes: { user_id: 'user-456' } }],
|
|
255
266
|
* });
|
|
256
267
|
*
|
|
257
268
|
* await client.start();
|
|
@@ -268,6 +279,7 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
|
|
|
268
279
|
private engine: SyncEngine<DB> | null = null;
|
|
269
280
|
private started = false;
|
|
270
281
|
private destroyed = false;
|
|
282
|
+
private emittedConflictIds = new Set<string>();
|
|
271
283
|
private eventListeners = new Map<
|
|
272
284
|
ClientEventType,
|
|
273
285
|
Set<ClientEventHandler<any>>
|
|
@@ -295,6 +307,8 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
|
|
|
295
307
|
idColumn: options.idColumn ?? 'id',
|
|
296
308
|
versionColumn: options.versionColumn ?? 'server_version',
|
|
297
309
|
omitColumns: options.omitColumns ?? [],
|
|
310
|
+
columnCodecs: options.columnCodecs,
|
|
311
|
+
codecDialect: options.codecDialect,
|
|
298
312
|
});
|
|
299
313
|
this.mutations = createMutationsApi(commitFn) as MutationsApi<DB>;
|
|
300
314
|
|
|
@@ -352,12 +366,12 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
|
|
|
352
366
|
this.engine = new SyncEngine({
|
|
353
367
|
db: this.options.db,
|
|
354
368
|
transport: this.options.transport,
|
|
355
|
-
|
|
369
|
+
handlers: this.options.tableHandlers,
|
|
356
370
|
clientId: this.options.clientId,
|
|
357
371
|
actorId: this.options.actorId,
|
|
358
372
|
subscriptions: this.options.subscriptions.map((s) => ({
|
|
359
373
|
id: s.id,
|
|
360
|
-
|
|
374
|
+
table: s.table,
|
|
361
375
|
scopes: s.scopes ?? {},
|
|
362
376
|
params: s.params ?? {},
|
|
363
377
|
})),
|
|
@@ -415,7 +429,7 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
|
|
|
415
429
|
updateSubscriptions(
|
|
416
430
|
subscriptions: Array<{
|
|
417
431
|
id: string;
|
|
418
|
-
|
|
432
|
+
table: string;
|
|
419
433
|
scopes?: Record<string, string | string[]>;
|
|
420
434
|
params?: Record<string, unknown>;
|
|
421
435
|
}>
|
|
@@ -425,7 +439,7 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
|
|
|
425
439
|
this.engine.updateSubscriptions(
|
|
426
440
|
subscriptions.map((s) => ({
|
|
427
441
|
id: s.id,
|
|
428
|
-
|
|
442
|
+
table: s.table,
|
|
429
443
|
scopes: s.scopes ?? {},
|
|
430
444
|
params: s.params ?? {},
|
|
431
445
|
}))
|
|
@@ -438,13 +452,13 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
|
|
|
438
452
|
*/
|
|
439
453
|
getSubscriptions(): Array<{
|
|
440
454
|
id: string;
|
|
441
|
-
|
|
455
|
+
table: string;
|
|
442
456
|
scopes: Record<string, string | string[]>;
|
|
443
457
|
params: Record<string, unknown>;
|
|
444
458
|
}> {
|
|
445
459
|
return this.options.subscriptions.map((s) => ({
|
|
446
460
|
id: s.id,
|
|
447
|
-
|
|
461
|
+
table: s.table,
|
|
448
462
|
scopes: s.scopes ?? {},
|
|
449
463
|
params: s.params ?? {},
|
|
450
464
|
}));
|
|
@@ -545,6 +559,8 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
|
|
|
545
559
|
resolution: ConflictResolution
|
|
546
560
|
): Promise<void> {
|
|
547
561
|
const { resolveConflict } = await import('./conflicts');
|
|
562
|
+
const pendingBeforeResolve = await this.getConflicts();
|
|
563
|
+
const resolvedConflict = pendingBeforeResolve.find((c) => c.id === id);
|
|
548
564
|
|
|
549
565
|
// For 'keep-local' and 'keep-server', we just mark it resolved
|
|
550
566
|
// For 'custom', we would need to apply the payload - but that requires
|
|
@@ -556,11 +572,9 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
|
|
|
556
572
|
|
|
557
573
|
await resolveConflict(this.options.db, { id, resolution: resolutionStr });
|
|
558
574
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
if (resolved) {
|
|
563
|
-
this.emit('conflict:resolved', resolved);
|
|
575
|
+
this.emittedConflictIds.delete(id);
|
|
576
|
+
if (resolvedConflict) {
|
|
577
|
+
this.emit('conflict:resolved', resolvedConflict);
|
|
564
578
|
}
|
|
565
579
|
}
|
|
566
580
|
|
|
@@ -806,7 +820,19 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
|
|
|
806
820
|
|
|
807
821
|
private async checkForNewConflicts(): Promise<void> {
|
|
808
822
|
const conflicts = await this.getConflicts();
|
|
823
|
+
const activeIds = new Set(conflicts.map((conflict) => conflict.id));
|
|
824
|
+
|
|
825
|
+
for (const id of this.emittedConflictIds) {
|
|
826
|
+
if (!activeIds.has(id)) {
|
|
827
|
+
this.emittedConflictIds.delete(id);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
809
831
|
for (const conflict of conflicts) {
|
|
832
|
+
if (this.emittedConflictIds.has(conflict.id)) {
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
835
|
+
this.emittedConflictIds.add(conflict.id);
|
|
810
836
|
this.emit('conflict:new', conflict);
|
|
811
837
|
}
|
|
812
838
|
}
|
|
@@ -841,6 +867,8 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
|
|
|
841
867
|
): BlobClient {
|
|
842
868
|
const db = this.options.db;
|
|
843
869
|
const blobs = transport.blobs!;
|
|
870
|
+
const staleUploadingTimeoutMs = 30_000;
|
|
871
|
+
const maxUploadRetries = 3;
|
|
844
872
|
|
|
845
873
|
return {
|
|
846
874
|
async store(data, options) {
|
|
@@ -1019,33 +1047,78 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1019
1047
|
async processUploadQueue() {
|
|
1020
1048
|
let uploaded = 0;
|
|
1021
1049
|
let failed = 0;
|
|
1050
|
+
const now = Date.now();
|
|
1051
|
+
const staleThreshold = now - staleUploadingTimeoutMs;
|
|
1052
|
+
|
|
1053
|
+
await sql`
|
|
1054
|
+
update ${sql.table('sync_blob_outbox')}
|
|
1055
|
+
set
|
|
1056
|
+
${sql.ref('status')} = ${sql.val('failed')},
|
|
1057
|
+
${sql.ref('attempt_count')} = ${sql.ref('attempt_count')} + ${sql.val(
|
|
1058
|
+
1
|
|
1059
|
+
)},
|
|
1060
|
+
${sql.ref('error')} = ${sql.val(
|
|
1061
|
+
'Upload timed out while in uploading state'
|
|
1062
|
+
)},
|
|
1063
|
+
${sql.ref('updated_at')} = ${sql.val(now)}
|
|
1064
|
+
where ${sql.ref('status')} = ${sql.val('uploading')}
|
|
1065
|
+
and ${sql.ref('updated_at')} < ${sql.val(staleThreshold)}
|
|
1066
|
+
and ${sql.ref('attempt_count')} + ${sql.val(1)} >= ${sql.val(
|
|
1067
|
+
maxUploadRetries
|
|
1068
|
+
)}
|
|
1069
|
+
`.execute(db);
|
|
1070
|
+
|
|
1071
|
+
await sql`
|
|
1072
|
+
update ${sql.table('sync_blob_outbox')}
|
|
1073
|
+
set
|
|
1074
|
+
${sql.ref('status')} = ${sql.val('pending')},
|
|
1075
|
+
${sql.ref('attempt_count')} = ${sql.ref('attempt_count')} + ${sql.val(
|
|
1076
|
+
1
|
|
1077
|
+
)},
|
|
1078
|
+
${sql.ref('error')} = ${sql.val(
|
|
1079
|
+
'Upload timed out while in uploading state; retrying'
|
|
1080
|
+
)},
|
|
1081
|
+
${sql.ref('updated_at')} = ${sql.val(now)}
|
|
1082
|
+
where ${sql.ref('status')} = ${sql.val('uploading')}
|
|
1083
|
+
and ${sql.ref('updated_at')} < ${sql.val(staleThreshold)}
|
|
1084
|
+
and ${sql.ref('attempt_count')} + ${sql.val(1)} < ${sql.val(
|
|
1085
|
+
maxUploadRetries
|
|
1086
|
+
)}
|
|
1087
|
+
`.execute(db);
|
|
1022
1088
|
|
|
1023
1089
|
const pendingResult = await sql<{
|
|
1024
1090
|
hash: string;
|
|
1025
1091
|
size: number;
|
|
1026
1092
|
mime_type: string;
|
|
1027
1093
|
body: Uint8Array | null;
|
|
1094
|
+
attempt_count: number;
|
|
1028
1095
|
}>`
|
|
1029
1096
|
select
|
|
1030
1097
|
${sql.ref('hash')},
|
|
1031
1098
|
${sql.ref('size')},
|
|
1032
1099
|
${sql.ref('mime_type')},
|
|
1033
|
-
${sql.ref('body')}
|
|
1100
|
+
${sql.ref('body')},
|
|
1101
|
+
${sql.ref('attempt_count')}
|
|
1034
1102
|
from ${sql.table('sync_blob_outbox')}
|
|
1035
1103
|
where ${sql.ref('status')} = ${sql.val('pending')}
|
|
1104
|
+
and ${sql.ref('attempt_count')} < ${sql.val(maxUploadRetries)}
|
|
1036
1105
|
limit ${sql.val(10)}
|
|
1037
1106
|
`.execute(db);
|
|
1038
1107
|
const pending = pendingResult.rows;
|
|
1039
1108
|
|
|
1040
1109
|
for (const item of pending) {
|
|
1110
|
+
const nextAttemptCount = item.attempt_count + 1;
|
|
1041
1111
|
try {
|
|
1042
1112
|
// Mark as uploading
|
|
1043
1113
|
await sql`
|
|
1044
1114
|
update ${sql.table('sync_blob_outbox')}
|
|
1045
1115
|
set
|
|
1046
1116
|
${sql.ref('status')} = ${sql.val('uploading')},
|
|
1117
|
+
${sql.ref('attempt_count')} = ${sql.val(nextAttemptCount)},
|
|
1118
|
+
${sql.ref('error')} = ${sql.val(null)},
|
|
1047
1119
|
${sql.ref('updated_at')} = ${sql.val(Date.now())}
|
|
1048
1120
|
where ${sql.ref('hash')} = ${sql.val(item.hash)}
|
|
1121
|
+
and ${sql.ref('status')} = ${sql.val('pending')}
|
|
1049
1122
|
`.execute(db);
|
|
1050
1123
|
|
|
1051
1124
|
// Initiate upload
|
|
@@ -1071,7 +1144,12 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1071
1144
|
}
|
|
1072
1145
|
|
|
1073
1146
|
// Complete
|
|
1074
|
-
await blobs.completeUpload(item.hash);
|
|
1147
|
+
const completeResult = await blobs.completeUpload(item.hash);
|
|
1148
|
+
if (!completeResult.ok) {
|
|
1149
|
+
throw new Error(
|
|
1150
|
+
completeResult.error ?? 'Failed to complete blob upload'
|
|
1151
|
+
);
|
|
1152
|
+
}
|
|
1075
1153
|
}
|
|
1076
1154
|
|
|
1077
1155
|
// Mark as complete
|
|
@@ -1082,22 +1160,23 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1082
1160
|
|
|
1083
1161
|
uploaded++;
|
|
1084
1162
|
} catch (err) {
|
|
1085
|
-
|
|
1163
|
+
const nextStatus =
|
|
1164
|
+
nextAttemptCount >= maxUploadRetries ? 'failed' : 'pending';
|
|
1165
|
+
|
|
1086
1166
|
await sql`
|
|
1087
1167
|
update ${sql.table('sync_blob_outbox')}
|
|
1088
1168
|
set
|
|
1089
|
-
${sql.ref('status')} = ${sql.val(
|
|
1169
|
+
${sql.ref('status')} = ${sql.val(nextStatus)},
|
|
1090
1170
|
${sql.ref('error')} = ${sql.val(
|
|
1091
1171
|
err instanceof Error ? err.message : 'Unknown error'
|
|
1092
1172
|
)},
|
|
1093
|
-
${sql.ref('attempt_count')} = ${sql.ref('attempt_count')} + ${sql.val(
|
|
1094
|
-
1
|
|
1095
|
-
)},
|
|
1096
1173
|
${sql.ref('updated_at')} = ${sql.val(Date.now())}
|
|
1097
1174
|
where ${sql.ref('hash')} = ${sql.val(item.hash)}
|
|
1098
1175
|
`.execute(db);
|
|
1099
1176
|
|
|
1100
|
-
failed
|
|
1177
|
+
if (nextStatus === 'failed') {
|
|
1178
|
+
failed++;
|
|
1179
|
+
}
|
|
1101
1180
|
}
|
|
1102
1181
|
}
|
|
1103
1182
|
|
package/src/conflicts.ts
CHANGED
|
@@ -3,20 +3,11 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { SyncOperationResult, SyncPushResponse } from '@syncular/core';
|
|
6
|
+
import { randomId } from '@syncular/core';
|
|
6
7
|
import type { Kysely } from 'kysely';
|
|
7
8
|
import { sql } from 'kysely';
|
|
8
9
|
import type { SyncClientDb } from './schema';
|
|
9
10
|
|
|
10
|
-
function randomId(): string {
|
|
11
|
-
if (
|
|
12
|
-
typeof crypto !== 'undefined' &&
|
|
13
|
-
typeof crypto.randomUUID === 'function'
|
|
14
|
-
) {
|
|
15
|
-
return crypto.randomUUID();
|
|
16
|
-
}
|
|
17
|
-
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
11
|
function messageFromResult(
|
|
21
12
|
r: Extract<SyncOperationResult, { status: 'conflict' | 'error' }>
|
|
22
13
|
): {
|