@syncular/client 0.0.1-73 → 0.0.1-89
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/blobs/index.js +3 -3
- package/dist/client.d.ts +1 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +63 -16
- 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.js +4 -4
- package/dist/engine/SyncEngine.d.ts +2 -1
- package/dist/engine/SyncEngine.d.ts.map +1 -1
- package/dist/engine/SyncEngine.js +109 -34
- package/dist/engine/SyncEngine.js.map +1 -1
- package/dist/engine/index.js +2 -2
- package/dist/handlers/create-handler.d.ts.map +1 -1
- package/dist/handlers/create-handler.js +1 -4
- package/dist/handlers/create-handler.js.map +1 -1
- package/dist/index.js +19 -19
- package/dist/mutations.d.ts.map +1 -1
- package/dist/mutations.js +2 -12
- 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/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/push-engine.js +2 -2
- package/dist/query/QueryContext.js +1 -1
- package/dist/query/index.js +3 -3
- package/dist/query/tracked-select.js +1 -1
- package/dist/sync-loop.js +4 -4
- package/package.json +1 -1
- package/src/client.test.ts +369 -0
- package/src/client.ts +79 -13
- package/src/conflicts.ts +1 -10
- package/src/engine/SyncEngine.test.ts +157 -0
- package/src/engine/SyncEngine.ts +172 -45
- package/src/handlers/create-handler.ts +1 -5
- package/src/mutations.ts +1 -15
- package/src/outbox.ts +1 -15
|
@@ -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
|
+
shapes: 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
|
@@ -268,6 +268,7 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
|
|
|
268
268
|
private engine: SyncEngine<DB> | null = null;
|
|
269
269
|
private started = false;
|
|
270
270
|
private destroyed = false;
|
|
271
|
+
private emittedConflictIds = new Set<string>();
|
|
271
272
|
private eventListeners = new Map<
|
|
272
273
|
ClientEventType,
|
|
273
274
|
Set<ClientEventHandler<any>>
|
|
@@ -545,6 +546,8 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
|
|
|
545
546
|
resolution: ConflictResolution
|
|
546
547
|
): Promise<void> {
|
|
547
548
|
const { resolveConflict } = await import('./conflicts');
|
|
549
|
+
const pendingBeforeResolve = await this.getConflicts();
|
|
550
|
+
const resolvedConflict = pendingBeforeResolve.find((c) => c.id === id);
|
|
548
551
|
|
|
549
552
|
// For 'keep-local' and 'keep-server', we just mark it resolved
|
|
550
553
|
// For 'custom', we would need to apply the payload - but that requires
|
|
@@ -556,11 +559,9 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
|
|
|
556
559
|
|
|
557
560
|
await resolveConflict(this.options.db, { id, resolution: resolutionStr });
|
|
558
561
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
if (resolved) {
|
|
563
|
-
this.emit('conflict:resolved', resolved);
|
|
562
|
+
this.emittedConflictIds.delete(id);
|
|
563
|
+
if (resolvedConflict) {
|
|
564
|
+
this.emit('conflict:resolved', resolvedConflict);
|
|
564
565
|
}
|
|
565
566
|
}
|
|
566
567
|
|
|
@@ -806,7 +807,19 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
|
|
|
806
807
|
|
|
807
808
|
private async checkForNewConflicts(): Promise<void> {
|
|
808
809
|
const conflicts = await this.getConflicts();
|
|
810
|
+
const activeIds = new Set(conflicts.map((conflict) => conflict.id));
|
|
811
|
+
|
|
812
|
+
for (const id of this.emittedConflictIds) {
|
|
813
|
+
if (!activeIds.has(id)) {
|
|
814
|
+
this.emittedConflictIds.delete(id);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
809
818
|
for (const conflict of conflicts) {
|
|
819
|
+
if (this.emittedConflictIds.has(conflict.id)) {
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
822
|
+
this.emittedConflictIds.add(conflict.id);
|
|
810
823
|
this.emit('conflict:new', conflict);
|
|
811
824
|
}
|
|
812
825
|
}
|
|
@@ -841,6 +854,8 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
|
|
|
841
854
|
): BlobClient {
|
|
842
855
|
const db = this.options.db;
|
|
843
856
|
const blobs = transport.blobs!;
|
|
857
|
+
const staleUploadingTimeoutMs = 30_000;
|
|
858
|
+
const maxUploadRetries = 3;
|
|
844
859
|
|
|
845
860
|
return {
|
|
846
861
|
async store(data, options) {
|
|
@@ -1019,33 +1034,78 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1019
1034
|
async processUploadQueue() {
|
|
1020
1035
|
let uploaded = 0;
|
|
1021
1036
|
let failed = 0;
|
|
1037
|
+
const now = Date.now();
|
|
1038
|
+
const staleThreshold = now - staleUploadingTimeoutMs;
|
|
1039
|
+
|
|
1040
|
+
await sql`
|
|
1041
|
+
update ${sql.table('sync_blob_outbox')}
|
|
1042
|
+
set
|
|
1043
|
+
${sql.ref('status')} = ${sql.val('failed')},
|
|
1044
|
+
${sql.ref('attempt_count')} = ${sql.ref('attempt_count')} + ${sql.val(
|
|
1045
|
+
1
|
|
1046
|
+
)},
|
|
1047
|
+
${sql.ref('error')} = ${sql.val(
|
|
1048
|
+
'Upload timed out while in uploading state'
|
|
1049
|
+
)},
|
|
1050
|
+
${sql.ref('updated_at')} = ${sql.val(now)}
|
|
1051
|
+
where ${sql.ref('status')} = ${sql.val('uploading')}
|
|
1052
|
+
and ${sql.ref('updated_at')} < ${sql.val(staleThreshold)}
|
|
1053
|
+
and ${sql.ref('attempt_count')} + ${sql.val(1)} >= ${sql.val(
|
|
1054
|
+
maxUploadRetries
|
|
1055
|
+
)}
|
|
1056
|
+
`.execute(db);
|
|
1057
|
+
|
|
1058
|
+
await sql`
|
|
1059
|
+
update ${sql.table('sync_blob_outbox')}
|
|
1060
|
+
set
|
|
1061
|
+
${sql.ref('status')} = ${sql.val('pending')},
|
|
1062
|
+
${sql.ref('attempt_count')} = ${sql.ref('attempt_count')} + ${sql.val(
|
|
1063
|
+
1
|
|
1064
|
+
)},
|
|
1065
|
+
${sql.ref('error')} = ${sql.val(
|
|
1066
|
+
'Upload timed out while in uploading state; retrying'
|
|
1067
|
+
)},
|
|
1068
|
+
${sql.ref('updated_at')} = ${sql.val(now)}
|
|
1069
|
+
where ${sql.ref('status')} = ${sql.val('uploading')}
|
|
1070
|
+
and ${sql.ref('updated_at')} < ${sql.val(staleThreshold)}
|
|
1071
|
+
and ${sql.ref('attempt_count')} + ${sql.val(1)} < ${sql.val(
|
|
1072
|
+
maxUploadRetries
|
|
1073
|
+
)}
|
|
1074
|
+
`.execute(db);
|
|
1022
1075
|
|
|
1023
1076
|
const pendingResult = await sql<{
|
|
1024
1077
|
hash: string;
|
|
1025
1078
|
size: number;
|
|
1026
1079
|
mime_type: string;
|
|
1027
1080
|
body: Uint8Array | null;
|
|
1081
|
+
attempt_count: number;
|
|
1028
1082
|
}>`
|
|
1029
1083
|
select
|
|
1030
1084
|
${sql.ref('hash')},
|
|
1031
1085
|
${sql.ref('size')},
|
|
1032
1086
|
${sql.ref('mime_type')},
|
|
1033
|
-
${sql.ref('body')}
|
|
1087
|
+
${sql.ref('body')},
|
|
1088
|
+
${sql.ref('attempt_count')}
|
|
1034
1089
|
from ${sql.table('sync_blob_outbox')}
|
|
1035
1090
|
where ${sql.ref('status')} = ${sql.val('pending')}
|
|
1091
|
+
and ${sql.ref('attempt_count')} < ${sql.val(maxUploadRetries)}
|
|
1036
1092
|
limit ${sql.val(10)}
|
|
1037
1093
|
`.execute(db);
|
|
1038
1094
|
const pending = pendingResult.rows;
|
|
1039
1095
|
|
|
1040
1096
|
for (const item of pending) {
|
|
1097
|
+
const nextAttemptCount = item.attempt_count + 1;
|
|
1041
1098
|
try {
|
|
1042
1099
|
// Mark as uploading
|
|
1043
1100
|
await sql`
|
|
1044
1101
|
update ${sql.table('sync_blob_outbox')}
|
|
1045
1102
|
set
|
|
1046
1103
|
${sql.ref('status')} = ${sql.val('uploading')},
|
|
1104
|
+
${sql.ref('attempt_count')} = ${sql.val(nextAttemptCount)},
|
|
1105
|
+
${sql.ref('error')} = ${sql.val(null)},
|
|
1047
1106
|
${sql.ref('updated_at')} = ${sql.val(Date.now())}
|
|
1048
1107
|
where ${sql.ref('hash')} = ${sql.val(item.hash)}
|
|
1108
|
+
and ${sql.ref('status')} = ${sql.val('pending')}
|
|
1049
1109
|
`.execute(db);
|
|
1050
1110
|
|
|
1051
1111
|
// Initiate upload
|
|
@@ -1071,7 +1131,12 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1071
1131
|
}
|
|
1072
1132
|
|
|
1073
1133
|
// Complete
|
|
1074
|
-
await blobs.completeUpload(item.hash);
|
|
1134
|
+
const completeResult = await blobs.completeUpload(item.hash);
|
|
1135
|
+
if (!completeResult.ok) {
|
|
1136
|
+
throw new Error(
|
|
1137
|
+
completeResult.error ?? 'Failed to complete blob upload'
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1075
1140
|
}
|
|
1076
1141
|
|
|
1077
1142
|
// Mark as complete
|
|
@@ -1082,22 +1147,23 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1082
1147
|
|
|
1083
1148
|
uploaded++;
|
|
1084
1149
|
} catch (err) {
|
|
1085
|
-
|
|
1150
|
+
const nextStatus =
|
|
1151
|
+
nextAttemptCount >= maxUploadRetries ? 'failed' : 'pending';
|
|
1152
|
+
|
|
1086
1153
|
await sql`
|
|
1087
1154
|
update ${sql.table('sync_blob_outbox')}
|
|
1088
1155
|
set
|
|
1089
|
-
${sql.ref('status')} = ${sql.val(
|
|
1156
|
+
${sql.ref('status')} = ${sql.val(nextStatus)},
|
|
1090
1157
|
${sql.ref('error')} = ${sql.val(
|
|
1091
1158
|
err instanceof Error ? err.message : 'Unknown error'
|
|
1092
1159
|
)},
|
|
1093
|
-
${sql.ref('attempt_count')} = ${sql.ref('attempt_count')} + ${sql.val(
|
|
1094
|
-
1
|
|
1095
|
-
)},
|
|
1096
1160
|
${sql.ref('updated_at')} = ${sql.val(Date.now())}
|
|
1097
1161
|
where ${sql.ref('hash')} = ${sql.val(item.hash)}
|
|
1098
1162
|
`.execute(db);
|
|
1099
1163
|
|
|
1100
|
-
failed
|
|
1164
|
+
if (nextStatus === 'failed') {
|
|
1165
|
+
failed++;
|
|
1166
|
+
}
|
|
1101
1167
|
}
|
|
1102
1168
|
}
|
|
1103
1169
|
|
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
|
): {
|