@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.
Files changed (83) hide show
  1. package/README.md +23 -0
  2. package/dist/blobs/index.js +3 -3
  3. package/dist/client.d.ts +10 -5
  4. package/dist/client.d.ts.map +1 -1
  5. package/dist/client.js +70 -21
  6. package/dist/client.js.map +1 -1
  7. package/dist/conflicts.d.ts.map +1 -1
  8. package/dist/conflicts.js +1 -7
  9. package/dist/conflicts.js.map +1 -1
  10. package/dist/create-client.d.ts +5 -1
  11. package/dist/create-client.d.ts.map +1 -1
  12. package/dist/create-client.js +22 -10
  13. package/dist/create-client.js.map +1 -1
  14. package/dist/engine/SyncEngine.d.ts +24 -2
  15. package/dist/engine/SyncEngine.d.ts.map +1 -1
  16. package/dist/engine/SyncEngine.js +290 -43
  17. package/dist/engine/SyncEngine.js.map +1 -1
  18. package/dist/engine/index.js +2 -2
  19. package/dist/engine/types.d.ts +16 -4
  20. package/dist/engine/types.d.ts.map +1 -1
  21. package/dist/handlers/create-handler.d.ts +15 -5
  22. package/dist/handlers/create-handler.d.ts.map +1 -1
  23. package/dist/handlers/create-handler.js +35 -24
  24. package/dist/handlers/create-handler.js.map +1 -1
  25. package/dist/handlers/types.d.ts +5 -5
  26. package/dist/handlers/types.d.ts.map +1 -1
  27. package/dist/index.js +19 -19
  28. package/dist/migrate.d.ts +1 -1
  29. package/dist/migrate.d.ts.map +1 -1
  30. package/dist/migrate.js +148 -28
  31. package/dist/migrate.js.map +1 -1
  32. package/dist/mutations.d.ts +3 -1
  33. package/dist/mutations.d.ts.map +1 -1
  34. package/dist/mutations.js +93 -18
  35. package/dist/mutations.js.map +1 -1
  36. package/dist/outbox.d.ts.map +1 -1
  37. package/dist/outbox.js +1 -11
  38. package/dist/outbox.js.map +1 -1
  39. package/dist/plugins/incrementing-version.d.ts +1 -1
  40. package/dist/plugins/incrementing-version.js +2 -2
  41. package/dist/plugins/index.js +2 -2
  42. package/dist/proxy/dialect.js +1 -1
  43. package/dist/proxy/driver.js +1 -1
  44. package/dist/proxy/index.js +4 -4
  45. package/dist/proxy/mutations.js +1 -1
  46. package/dist/pull-engine.d.ts +29 -3
  47. package/dist/pull-engine.d.ts.map +1 -1
  48. package/dist/pull-engine.js +314 -78
  49. package/dist/pull-engine.js.map +1 -1
  50. package/dist/push-engine.d.ts.map +1 -1
  51. package/dist/push-engine.js +28 -3
  52. package/dist/push-engine.js.map +1 -1
  53. package/dist/query/QueryContext.js +1 -1
  54. package/dist/query/index.js +3 -3
  55. package/dist/query/tracked-select.d.ts +2 -1
  56. package/dist/query/tracked-select.d.ts.map +1 -1
  57. package/dist/query/tracked-select.js +1 -1
  58. package/dist/schema.d.ts +2 -2
  59. package/dist/schema.d.ts.map +1 -1
  60. package/dist/sync-loop.d.ts +5 -1
  61. package/dist/sync-loop.d.ts.map +1 -1
  62. package/dist/sync-loop.js +167 -18
  63. package/dist/sync-loop.js.map +1 -1
  64. package/package.json +30 -6
  65. package/src/client.test.ts +369 -0
  66. package/src/client.ts +101 -22
  67. package/src/conflicts.ts +1 -10
  68. package/src/create-client.ts +33 -5
  69. package/src/engine/SyncEngine.test.ts +157 -0
  70. package/src/engine/SyncEngine.ts +359 -40
  71. package/src/engine/types.ts +22 -4
  72. package/src/handlers/create-handler.ts +86 -37
  73. package/src/handlers/types.ts +10 -4
  74. package/src/migrate.ts +215 -33
  75. package/src/mutations.ts +143 -21
  76. package/src/outbox.ts +1 -15
  77. package/src/plugins/incrementing-version.ts +2 -2
  78. package/src/pull-engine.test.ts +147 -0
  79. package/src/pull-engine.ts +392 -77
  80. package/src/push-engine.ts +33 -1
  81. package/src/query/tracked-select.ts +1 -1
  82. package/src/schema.ts +2 -2
  83. 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 { BlobRef, SyncTransport } from '@syncular/core';
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
- shape: string;
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', shape: 'tasks', scopes: { user_id: 'user-456' } }],
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
- shapes: this.options.tableHandlers,
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
- shape: s.shape,
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
- shape: string;
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
- shape: s.shape,
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
- shape: string;
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
- shape: s.shape,
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
- // Get the conflict for the event
560
- const conflicts = await this.getConflicts();
561
- const resolved = conflicts.find((c) => c.id === id);
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
- // Mark as failed
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('failed')},
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
  ): {