@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.
Files changed (43) hide show
  1. package/dist/blobs/index.js +3 -3
  2. package/dist/client.d.ts +1 -0
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +63 -16
  5. package/dist/client.js.map +1 -1
  6. package/dist/conflicts.d.ts.map +1 -1
  7. package/dist/conflicts.js +1 -7
  8. package/dist/conflicts.js.map +1 -1
  9. package/dist/create-client.js +4 -4
  10. package/dist/engine/SyncEngine.d.ts +2 -1
  11. package/dist/engine/SyncEngine.d.ts.map +1 -1
  12. package/dist/engine/SyncEngine.js +109 -34
  13. package/dist/engine/SyncEngine.js.map +1 -1
  14. package/dist/engine/index.js +2 -2
  15. package/dist/handlers/create-handler.d.ts.map +1 -1
  16. package/dist/handlers/create-handler.js +1 -4
  17. package/dist/handlers/create-handler.js.map +1 -1
  18. package/dist/index.js +19 -19
  19. package/dist/mutations.d.ts.map +1 -1
  20. package/dist/mutations.js +2 -12
  21. package/dist/mutations.js.map +1 -1
  22. package/dist/outbox.d.ts.map +1 -1
  23. package/dist/outbox.js +1 -11
  24. package/dist/outbox.js.map +1 -1
  25. package/dist/plugins/index.js +2 -2
  26. package/dist/proxy/dialect.js +1 -1
  27. package/dist/proxy/driver.js +1 -1
  28. package/dist/proxy/index.js +4 -4
  29. package/dist/proxy/mutations.js +1 -1
  30. package/dist/push-engine.js +2 -2
  31. package/dist/query/QueryContext.js +1 -1
  32. package/dist/query/index.js +3 -3
  33. package/dist/query/tracked-select.js +1 -1
  34. package/dist/sync-loop.js +4 -4
  35. package/package.json +1 -1
  36. package/src/client.test.ts +369 -0
  37. package/src/client.ts +79 -13
  38. package/src/conflicts.ts +1 -10
  39. package/src/engine/SyncEngine.test.ts +157 -0
  40. package/src/engine/SyncEngine.ts +172 -45
  41. package/src/handlers/create-handler.ts +1 -5
  42. package/src/mutations.ts +1 -15
  43. 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
- // 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);
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
- // Mark as failed
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('failed')},
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
  ): {