@syncular/client 0.0.1-100

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 (178) hide show
  1. package/dist/blobs/index.d.ts +7 -0
  2. package/dist/blobs/index.d.ts.map +1 -0
  3. package/dist/blobs/index.js +7 -0
  4. package/dist/blobs/index.js.map +1 -0
  5. package/dist/blobs/manager.d.ts +345 -0
  6. package/dist/blobs/manager.d.ts.map +1 -0
  7. package/dist/blobs/manager.js +749 -0
  8. package/dist/blobs/manager.js.map +1 -0
  9. package/dist/blobs/migrate.d.ts +14 -0
  10. package/dist/blobs/migrate.d.ts.map +1 -0
  11. package/dist/blobs/migrate.js +59 -0
  12. package/dist/blobs/migrate.js.map +1 -0
  13. package/dist/blobs/types.d.ts +62 -0
  14. package/dist/blobs/types.d.ts.map +1 -0
  15. package/dist/blobs/types.js +5 -0
  16. package/dist/blobs/types.js.map +1 -0
  17. package/dist/client.d.ts +339 -0
  18. package/dist/client.d.ts.map +1 -0
  19. package/dist/client.js +881 -0
  20. package/dist/client.js.map +1 -0
  21. package/dist/conflicts.d.ts +31 -0
  22. package/dist/conflicts.d.ts.map +1 -0
  23. package/dist/conflicts.js +112 -0
  24. package/dist/conflicts.js.map +1 -0
  25. package/dist/create-client.d.ts +115 -0
  26. package/dist/create-client.d.ts.map +1 -0
  27. package/dist/create-client.js +162 -0
  28. package/dist/create-client.js.map +1 -0
  29. package/dist/engine/SyncEngine.d.ts +216 -0
  30. package/dist/engine/SyncEngine.d.ts.map +1 -0
  31. package/dist/engine/SyncEngine.js +1141 -0
  32. package/dist/engine/SyncEngine.js.map +1 -0
  33. package/dist/engine/index.d.ts +6 -0
  34. package/dist/engine/index.d.ts.map +1 -0
  35. package/dist/engine/index.js +6 -0
  36. package/dist/engine/index.js.map +1 -0
  37. package/dist/engine/types.d.ts +230 -0
  38. package/dist/engine/types.d.ts.map +1 -0
  39. package/dist/engine/types.js +7 -0
  40. package/dist/engine/types.js.map +1 -0
  41. package/dist/handlers/create-handler.d.ts +110 -0
  42. package/dist/handlers/create-handler.d.ts.map +1 -0
  43. package/dist/handlers/create-handler.js +142 -0
  44. package/dist/handlers/create-handler.js.map +1 -0
  45. package/dist/handlers/registry.d.ts +15 -0
  46. package/dist/handlers/registry.d.ts.map +1 -0
  47. package/dist/handlers/registry.js +29 -0
  48. package/dist/handlers/registry.js.map +1 -0
  49. package/dist/handlers/types.d.ts +83 -0
  50. package/dist/handlers/types.d.ts.map +1 -0
  51. package/dist/handlers/types.js +5 -0
  52. package/dist/handlers/types.js.map +1 -0
  53. package/dist/index.d.ts +24 -0
  54. package/dist/index.d.ts.map +1 -0
  55. package/dist/index.js +24 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/migrate.d.ts +19 -0
  58. package/dist/migrate.d.ts.map +1 -0
  59. package/dist/migrate.js +106 -0
  60. package/dist/migrate.js.map +1 -0
  61. package/dist/mutations.d.ts +138 -0
  62. package/dist/mutations.d.ts.map +1 -0
  63. package/dist/mutations.js +601 -0
  64. package/dist/mutations.js.map +1 -0
  65. package/dist/outbox.d.ts +112 -0
  66. package/dist/outbox.d.ts.map +1 -0
  67. package/dist/outbox.js +294 -0
  68. package/dist/outbox.js.map +1 -0
  69. package/dist/plugins/incrementing-version.d.ts +34 -0
  70. package/dist/plugins/incrementing-version.d.ts.map +1 -0
  71. package/dist/plugins/incrementing-version.js +83 -0
  72. package/dist/plugins/incrementing-version.js.map +1 -0
  73. package/dist/plugins/index.d.ts +3 -0
  74. package/dist/plugins/index.d.ts.map +1 -0
  75. package/dist/plugins/index.js +3 -0
  76. package/dist/plugins/index.js.map +1 -0
  77. package/dist/plugins/types.d.ts +49 -0
  78. package/dist/plugins/types.d.ts.map +1 -0
  79. package/dist/plugins/types.js +15 -0
  80. package/dist/plugins/types.js.map +1 -0
  81. package/dist/proxy/connection.d.ts +33 -0
  82. package/dist/proxy/connection.d.ts.map +1 -0
  83. package/dist/proxy/connection.js +153 -0
  84. package/dist/proxy/connection.js.map +1 -0
  85. package/dist/proxy/dialect.d.ts +46 -0
  86. package/dist/proxy/dialect.d.ts.map +1 -0
  87. package/dist/proxy/dialect.js +58 -0
  88. package/dist/proxy/dialect.js.map +1 -0
  89. package/dist/proxy/driver.d.ts +42 -0
  90. package/dist/proxy/driver.d.ts.map +1 -0
  91. package/dist/proxy/driver.js +78 -0
  92. package/dist/proxy/driver.js.map +1 -0
  93. package/dist/proxy/index.d.ts +10 -0
  94. package/dist/proxy/index.d.ts.map +1 -0
  95. package/dist/proxy/index.js +10 -0
  96. package/dist/proxy/index.js.map +1 -0
  97. package/dist/proxy/mutations.d.ts +9 -0
  98. package/dist/proxy/mutations.d.ts.map +1 -0
  99. package/dist/proxy/mutations.js +11 -0
  100. package/dist/proxy/mutations.js.map +1 -0
  101. package/dist/pull-engine.d.ts +45 -0
  102. package/dist/pull-engine.d.ts.map +1 -0
  103. package/dist/pull-engine.js +381 -0
  104. package/dist/pull-engine.js.map +1 -0
  105. package/dist/push-engine.d.ts +18 -0
  106. package/dist/push-engine.d.ts.map +1 -0
  107. package/dist/push-engine.js +155 -0
  108. package/dist/push-engine.js.map +1 -0
  109. package/dist/query/FingerprintCollector.d.ts +18 -0
  110. package/dist/query/FingerprintCollector.d.ts.map +1 -0
  111. package/dist/query/FingerprintCollector.js +28 -0
  112. package/dist/query/FingerprintCollector.js.map +1 -0
  113. package/dist/query/QueryContext.d.ts +33 -0
  114. package/dist/query/QueryContext.d.ts.map +1 -0
  115. package/dist/query/QueryContext.js +16 -0
  116. package/dist/query/QueryContext.js.map +1 -0
  117. package/dist/query/fingerprint.d.ts +61 -0
  118. package/dist/query/fingerprint.d.ts.map +1 -0
  119. package/dist/query/fingerprint.js +91 -0
  120. package/dist/query/fingerprint.js.map +1 -0
  121. package/dist/query/index.d.ts +7 -0
  122. package/dist/query/index.d.ts.map +1 -0
  123. package/dist/query/index.js +7 -0
  124. package/dist/query/index.js.map +1 -0
  125. package/dist/query/tracked-select.d.ts +18 -0
  126. package/dist/query/tracked-select.d.ts.map +1 -0
  127. package/dist/query/tracked-select.js +90 -0
  128. package/dist/query/tracked-select.js.map +1 -0
  129. package/dist/schema.d.ts +83 -0
  130. package/dist/schema.d.ts.map +1 -0
  131. package/dist/schema.js +7 -0
  132. package/dist/schema.js.map +1 -0
  133. package/dist/sync-loop.d.ts +32 -0
  134. package/dist/sync-loop.d.ts.map +1 -0
  135. package/dist/sync-loop.js +249 -0
  136. package/dist/sync-loop.js.map +1 -0
  137. package/dist/utils/id.d.ts +8 -0
  138. package/dist/utils/id.d.ts.map +1 -0
  139. package/dist/utils/id.js +19 -0
  140. package/dist/utils/id.js.map +1 -0
  141. package/package.json +59 -0
  142. package/src/blobs/index.ts +7 -0
  143. package/src/blobs/manager.ts +1027 -0
  144. package/src/blobs/migrate.ts +67 -0
  145. package/src/blobs/types.ts +84 -0
  146. package/src/client.test.ts +369 -0
  147. package/src/client.ts +1288 -0
  148. package/src/conflicts.ts +171 -0
  149. package/src/create-client.ts +297 -0
  150. package/src/engine/SyncEngine.test.ts +157 -0
  151. package/src/engine/SyncEngine.ts +1464 -0
  152. package/src/engine/index.ts +6 -0
  153. package/src/engine/types.ts +268 -0
  154. package/src/handlers/create-handler.ts +298 -0
  155. package/src/handlers/registry.ts +36 -0
  156. package/src/handlers/types.ts +102 -0
  157. package/src/index.ts +25 -0
  158. package/src/migrate.ts +122 -0
  159. package/src/mutations.ts +912 -0
  160. package/src/outbox.ts +383 -0
  161. package/src/plugins/incrementing-version.ts +133 -0
  162. package/src/plugins/index.ts +2 -0
  163. package/src/plugins/types.ts +63 -0
  164. package/src/proxy/connection.ts +191 -0
  165. package/src/proxy/dialect.ts +76 -0
  166. package/src/proxy/driver.ts +126 -0
  167. package/src/proxy/index.ts +10 -0
  168. package/src/proxy/mutations.ts +18 -0
  169. package/src/pull-engine.ts +508 -0
  170. package/src/push-engine.ts +201 -0
  171. package/src/query/FingerprintCollector.ts +29 -0
  172. package/src/query/QueryContext.ts +54 -0
  173. package/src/query/fingerprint.ts +109 -0
  174. package/src/query/index.ts +10 -0
  175. package/src/query/tracked-select.ts +139 -0
  176. package/src/schema.ts +94 -0
  177. package/src/sync-loop.ts +368 -0
  178. package/src/utils/id.ts +20 -0
package/dist/client.js ADDED
@@ -0,0 +1,881 @@
1
+ /**
2
+ * @syncular/client - Unified Client class
3
+ *
4
+ * Single entry point for offline-first sync with:
5
+ * - Built-in mutations API
6
+ * - Optional blob support
7
+ * - Automatic migrations
8
+ * - Event-driven state management
9
+ * - Conflict handling with events
10
+ */
11
+ import { sql } from 'kysely';
12
+ import { ensureClientBlobSchema } from './blobs/migrate.js';
13
+ import { SyncEngine } from './engine/SyncEngine.js';
14
+ import { ensureClientSyncSchema } from './migrate.js';
15
+ import { createMutationsApi, createOutboxCommit, } from './mutations.js';
16
+ // ============================================================================
17
+ // Client Class
18
+ // ============================================================================
19
+ /**
20
+ * Unified sync client.
21
+ *
22
+ * @example
23
+ * ```typescript
24
+ * import { Client } from '@syncular/client';
25
+ * import { createHttpTransport } from '@syncular/transport-http';
26
+ *
27
+ * const client = new Client({
28
+ * db,
29
+ * transport: createHttpTransport({ baseUrl: '/api/sync', getHeaders }),
30
+ * tableHandlers,
31
+ * clientId: 'device-123',
32
+ * actorId: 'user-456',
33
+ * subscriptions: [{ id: 'tasks', shape: 'tasks', scopes: { user_id: 'user-456' } }],
34
+ * });
35
+ *
36
+ * await client.start();
37
+ *
38
+ * // Mutations
39
+ * await client.mutations.tasks.insert({ title: 'New task' });
40
+ *
41
+ * // Events
42
+ * client.on('sync:complete', () => console.log('synced'));
43
+ * ```
44
+ */
45
+ export class Client {
46
+ options;
47
+ engine = null;
48
+ started = false;
49
+ destroyed = false;
50
+ emittedConflictIds = new Set();
51
+ eventListeners = new Map();
52
+ outboxStats = {
53
+ pending: 0,
54
+ sending: 0,
55
+ failed: 0,
56
+ acked: 0,
57
+ total: 0,
58
+ };
59
+ /** Mutations API (always available) */
60
+ mutations;
61
+ /** Blob client (only available if blobStorage configured) */
62
+ blobs;
63
+ constructor(options) {
64
+ this.options = options;
65
+ // Create mutations API
66
+ const commitFn = createOutboxCommit({
67
+ db: options.db,
68
+ idColumn: options.idColumn ?? 'id',
69
+ versionColumn: options.versionColumn ?? 'server_version',
70
+ omitColumns: options.omitColumns ?? [],
71
+ });
72
+ this.mutations = createMutationsApi(commitFn);
73
+ // Create blob client if storage provided
74
+ if (options.blobStorage && options.transport.blobs) {
75
+ this.blobs = this.createBlobClient(options.blobStorage, options.transport);
76
+ }
77
+ }
78
+ // ===========================================================================
79
+ // Identity Getters
80
+ // ===========================================================================
81
+ /** Client ID */
82
+ get clientId() {
83
+ return this.options.clientId;
84
+ }
85
+ /** Actor ID */
86
+ get actorId() {
87
+ return this.options.actorId;
88
+ }
89
+ /** Database instance */
90
+ get db() {
91
+ return this.options.db;
92
+ }
93
+ // ===========================================================================
94
+ // Lifecycle
95
+ // ===========================================================================
96
+ /**
97
+ * Start the client.
98
+ * Runs migrations and starts sync engine.
99
+ */
100
+ async start() {
101
+ if (this.destroyed) {
102
+ throw new Error('Client has been destroyed');
103
+ }
104
+ if (this.started) {
105
+ return;
106
+ }
107
+ // Run migrations
108
+ await ensureClientSyncSchema(this.options.db);
109
+ if (this.options.blobStorage) {
110
+ await ensureClientBlobSchema(this.options.db);
111
+ }
112
+ // Create and start engine
113
+ this.engine = new SyncEngine({
114
+ db: this.options.db,
115
+ transport: this.options.transport,
116
+ shapes: this.options.tableHandlers,
117
+ clientId: this.options.clientId,
118
+ actorId: this.options.actorId,
119
+ subscriptions: this.options.subscriptions.map((s) => ({
120
+ id: s.id,
121
+ shape: s.shape,
122
+ scopes: s.scopes ?? {},
123
+ params: s.params ?? {},
124
+ })),
125
+ plugins: this.options.plugins,
126
+ realtimeEnabled: this.options.realtimeEnabled,
127
+ pollIntervalMs: this.options.pollIntervalMs,
128
+ stateId: this.options.stateId,
129
+ migrate: undefined, // We already ran migrations
130
+ });
131
+ // Wire up engine events to client events
132
+ this.wireEngineEvents();
133
+ await this.engine.start();
134
+ this.started = true;
135
+ }
136
+ /**
137
+ * Stop the client (can be restarted).
138
+ */
139
+ stop() {
140
+ this.engine?.stop();
141
+ }
142
+ /**
143
+ * Destroy the client (cannot be restarted).
144
+ */
145
+ destroy() {
146
+ this.engine?.destroy();
147
+ this.eventListeners.clear();
148
+ this.destroyed = true;
149
+ }
150
+ // ===========================================================================
151
+ // Sync
152
+ // ===========================================================================
153
+ /**
154
+ * Trigger a manual sync.
155
+ */
156
+ async sync() {
157
+ if (!this.engine) {
158
+ throw new Error('Client not started');
159
+ }
160
+ return this.engine.sync();
161
+ }
162
+ // ===========================================================================
163
+ // Subscriptions
164
+ // ===========================================================================
165
+ /**
166
+ * Update subscriptions.
167
+ */
168
+ updateSubscriptions(subscriptions) {
169
+ this.options.subscriptions = subscriptions;
170
+ if (this.engine) {
171
+ this.engine.updateSubscriptions(subscriptions.map((s) => ({
172
+ id: s.id,
173
+ shape: s.shape,
174
+ scopes: s.scopes ?? {},
175
+ params: s.params ?? {},
176
+ })));
177
+ }
178
+ }
179
+ /**
180
+ * Get current subscriptions.
181
+ */
182
+ getSubscriptions() {
183
+ return this.options.subscriptions.map((s) => ({
184
+ id: s.id,
185
+ shape: s.shape,
186
+ scopes: s.scopes ?? {},
187
+ params: s.params ?? {},
188
+ }));
189
+ }
190
+ // ===========================================================================
191
+ // State
192
+ // ===========================================================================
193
+ /**
194
+ * Get current client state.
195
+ */
196
+ getState() {
197
+ const engineState = this.engine?.getState() ?? this.createInitialEngineState();
198
+ return {
199
+ clientId: this.options.clientId,
200
+ actorId: this.options.actorId,
201
+ enabled: engineState.enabled,
202
+ isSyncing: engineState.isSyncing,
203
+ connectionState: engineState.connectionState,
204
+ lastSyncAt: engineState.lastSyncAt,
205
+ error: engineState.error
206
+ ? { code: engineState.error.code, message: engineState.error.message }
207
+ : null,
208
+ outbox: this.outboxStats,
209
+ };
210
+ }
211
+ /**
212
+ * Subscribe to state changes (for useSyncExternalStore).
213
+ */
214
+ subscribe(callback) {
215
+ if (!this.engine) {
216
+ // Return no-op unsubscribe before engine is started
217
+ return () => { };
218
+ }
219
+ return this.engine.subscribe(callback);
220
+ }
221
+ // ===========================================================================
222
+ // Events
223
+ // ===========================================================================
224
+ /**
225
+ * Subscribe to client events.
226
+ */
227
+ on(event, handler) {
228
+ if (!this.eventListeners.has(event)) {
229
+ this.eventListeners.set(event, new Set());
230
+ }
231
+ this.eventListeners.get(event).add(handler);
232
+ return () => {
233
+ this.eventListeners.get(event)?.delete(handler);
234
+ };
235
+ }
236
+ emit(event, payload) {
237
+ const listeners = this.eventListeners.get(event);
238
+ if (listeners) {
239
+ for (const listener of listeners) {
240
+ try {
241
+ listener(payload);
242
+ }
243
+ catch (err) {
244
+ console.error(`[Client] Error in ${event} listener:`, err);
245
+ }
246
+ }
247
+ }
248
+ }
249
+ // ===========================================================================
250
+ // Conflicts
251
+ // ===========================================================================
252
+ /**
253
+ * Get pending conflicts.
254
+ */
255
+ async getConflicts() {
256
+ if (!this.engine) {
257
+ return [];
258
+ }
259
+ const conflicts = await this.engine.getConflicts();
260
+ return conflicts.map((c) => this.mapConflictInfo(c));
261
+ }
262
+ /**
263
+ * Resolve a conflict.
264
+ */
265
+ async resolveConflict(id, resolution) {
266
+ const { resolveConflict } = await import('./conflicts.js');
267
+ const pendingBeforeResolve = await this.getConflicts();
268
+ const resolvedConflict = pendingBeforeResolve.find((c) => c.id === id);
269
+ // For 'keep-local' and 'keep-server', we just mark it resolved
270
+ // For 'custom', we would need to apply the payload - but that requires
271
+ // creating a new mutation, which the user should do separately
272
+ const resolutionStr = resolution.strategy === 'custom'
273
+ ? `custom:${JSON.stringify(resolution.payload)}`
274
+ : resolution.strategy;
275
+ await resolveConflict(this.options.db, { id, resolution: resolutionStr });
276
+ this.emittedConflictIds.delete(id);
277
+ if (resolvedConflict) {
278
+ this.emit('conflict:resolved', resolvedConflict);
279
+ }
280
+ }
281
+ // ===========================================================================
282
+ // Outbox
283
+ // ===========================================================================
284
+ /**
285
+ * Get outbox statistics.
286
+ */
287
+ async getOutboxStats() {
288
+ if (!this.engine) {
289
+ return this.outboxStats;
290
+ }
291
+ this.outboxStats = await this.engine.refreshOutboxStats({ emit: false });
292
+ return this.outboxStats;
293
+ }
294
+ /**
295
+ * Clear failed commits from outbox.
296
+ */
297
+ async clearFailedCommits() {
298
+ if (!this.engine) {
299
+ return 0;
300
+ }
301
+ return this.engine.clearFailedCommits();
302
+ }
303
+ /**
304
+ * Retry failed commits.
305
+ */
306
+ async retryFailedCommits() {
307
+ // Mark failed commits as pending and trigger sync
308
+ const result = await sql `
309
+ update ${sql.table('sync_outbox_commits')}
310
+ set
311
+ ${sql.ref('status')} = ${sql.val('pending')},
312
+ ${sql.ref('attempt_count')} = ${sql.val(0)},
313
+ ${sql.ref('error')} = ${sql.val(null)}
314
+ where ${sql.ref('status')} = ${sql.val('failed')}
315
+ `.execute(this.options.db);
316
+ const count = Number(result.numAffectedRows ?? 0n);
317
+ if (count > 0 && this.engine) {
318
+ await this.engine.refreshOutboxStats();
319
+ await this.engine.sync();
320
+ }
321
+ return count;
322
+ }
323
+ // ===========================================================================
324
+ // Presence
325
+ // ===========================================================================
326
+ /**
327
+ * Get presence for a scope.
328
+ */
329
+ getPresence(scopeKey) {
330
+ if (!this.engine) {
331
+ return [];
332
+ }
333
+ return this.engine.getPresence(scopeKey);
334
+ }
335
+ /**
336
+ * Join presence for a scope key.
337
+ */
338
+ joinPresence(scopeKey, metadata) {
339
+ this.engine?.joinPresence(scopeKey, metadata);
340
+ }
341
+ /**
342
+ * Leave presence for a scope key.
343
+ */
344
+ leavePresence(scopeKey) {
345
+ this.engine?.leavePresence(scopeKey);
346
+ }
347
+ /**
348
+ * Update presence metadata for a scope key.
349
+ */
350
+ updatePresenceMetadata(scopeKey, metadata) {
351
+ this.engine?.updatePresenceMetadata(scopeKey, metadata);
352
+ }
353
+ // ===========================================================================
354
+ // Migration Info
355
+ // ===========================================================================
356
+ /**
357
+ * Get migration info.
358
+ */
359
+ async getMigrationInfo() {
360
+ // Check if sync tables exist
361
+ let syncMigrated = false;
362
+ try {
363
+ await this.options.db
364
+ .selectFrom('sync_outbox_commits')
365
+ .selectAll()
366
+ .limit(1)
367
+ .execute();
368
+ syncMigrated = true;
369
+ }
370
+ catch {
371
+ syncMigrated = false;
372
+ }
373
+ // Check if blob tables exist
374
+ let blobsMigrated = false;
375
+ try {
376
+ await this.options.db
377
+ .selectFrom('sync_blob_cache')
378
+ .selectAll()
379
+ .limit(1)
380
+ .execute();
381
+ blobsMigrated = true;
382
+ }
383
+ catch {
384
+ blobsMigrated = false;
385
+ }
386
+ return { syncMigrated, blobsMigrated };
387
+ }
388
+ /**
389
+ * Static: Check if migrations are needed.
390
+ */
391
+ static async checkMigrations(db) {
392
+ let syncMigrated = false;
393
+ let blobsMigrated = false;
394
+ try {
395
+ await db.selectFrom('sync_outbox_commits').selectAll().limit(1).execute();
396
+ syncMigrated = true;
397
+ }
398
+ catch {
399
+ syncMigrated = false;
400
+ }
401
+ try {
402
+ await db.selectFrom('sync_blob_cache').selectAll().limit(1).execute();
403
+ blobsMigrated = true;
404
+ }
405
+ catch {
406
+ blobsMigrated = false;
407
+ }
408
+ return {
409
+ needsMigration: !syncMigrated,
410
+ syncMigrated,
411
+ blobsMigrated,
412
+ };
413
+ }
414
+ /**
415
+ * Static: Run migrations.
416
+ */
417
+ static async migrate(db, options) {
418
+ await ensureClientSyncSchema(db);
419
+ if (options?.blobs) {
420
+ await ensureClientBlobSchema(db);
421
+ }
422
+ }
423
+ // ===========================================================================
424
+ // Private Helpers
425
+ // ===========================================================================
426
+ createInitialEngineState() {
427
+ return {
428
+ enabled: false,
429
+ isSyncing: false,
430
+ connectionState: 'disconnected',
431
+ transportMode: 'polling',
432
+ lastSyncAt: null,
433
+ error: null,
434
+ pendingCount: 0,
435
+ retryCount: 0,
436
+ isRetrying: false,
437
+ };
438
+ }
439
+ wireEngineEvents() {
440
+ if (!this.engine)
441
+ return;
442
+ this.engine.on('sync:start', (payload) => {
443
+ this.emit('sync:start', payload);
444
+ });
445
+ this.engine.on('sync:complete', (payload) => {
446
+ this.emit('sync:complete', {
447
+ success: true,
448
+ pushedCommits: payload.pushedCommits,
449
+ pullRounds: payload.pullRounds,
450
+ pullResponse: payload.pullResponse,
451
+ });
452
+ });
453
+ this.engine.on('sync:error', (error) => {
454
+ this.emit('sync:error', { code: error.code, message: error.message });
455
+ // Check for new conflicts after sync error
456
+ this.checkForNewConflicts();
457
+ });
458
+ this.engine.on('connection:change', (payload) => {
459
+ this.emit('connection:change', payload);
460
+ });
461
+ this.engine.on('data:change', (payload) => {
462
+ this.emit('data:change', payload);
463
+ });
464
+ this.engine.on('outbox:change', (payload) => {
465
+ this.outboxStats = {
466
+ pending: payload.pendingCount,
467
+ sending: payload.sendingCount,
468
+ failed: payload.failedCount,
469
+ acked: payload.ackedCount ?? 0,
470
+ total: payload.pendingCount +
471
+ payload.sendingCount +
472
+ payload.failedCount +
473
+ (payload.ackedCount ?? 0),
474
+ };
475
+ this.emit('outbox:change', this.outboxStats);
476
+ });
477
+ this.engine.on('presence:change', (payload) => {
478
+ this.emit('presence:change', payload);
479
+ });
480
+ }
481
+ async checkForNewConflicts() {
482
+ const conflicts = await this.getConflicts();
483
+ const activeIds = new Set(conflicts.map((conflict) => conflict.id));
484
+ for (const id of this.emittedConflictIds) {
485
+ if (!activeIds.has(id)) {
486
+ this.emittedConflictIds.delete(id);
487
+ }
488
+ }
489
+ for (const conflict of conflicts) {
490
+ if (this.emittedConflictIds.has(conflict.id)) {
491
+ continue;
492
+ }
493
+ this.emittedConflictIds.add(conflict.id);
494
+ this.emit('conflict:new', conflict);
495
+ }
496
+ }
497
+ mapConflictInfo(info) {
498
+ let serverPayload = null;
499
+ if (info.serverRowJson) {
500
+ try {
501
+ serverPayload = JSON.parse(info.serverRowJson);
502
+ }
503
+ catch {
504
+ serverPayload = null;
505
+ }
506
+ }
507
+ return {
508
+ id: info.id,
509
+ table: info.table,
510
+ rowId: info.rowId,
511
+ opIndex: info.opIndex,
512
+ localPayload: info.localPayload,
513
+ serverPayload,
514
+ serverVersion: info.serverVersion,
515
+ message: info.message,
516
+ code: info.code,
517
+ createdAt: info.createdAt,
518
+ };
519
+ }
520
+ createBlobClient(storage, transport) {
521
+ const db = this.options.db;
522
+ const blobs = transport.blobs;
523
+ const staleUploadingTimeoutMs = 30_000;
524
+ const maxUploadRetries = 3;
525
+ return {
526
+ async store(data, options) {
527
+ const bytes = await toUint8Array(data);
528
+ const mimeType = data instanceof Blob
529
+ ? data.type
530
+ : (options?.mimeType ?? 'application/octet-stream');
531
+ // Compute hash
532
+ const hashHex = await computeSha256Hex(bytes);
533
+ const hash = `sha256:${hashHex}`;
534
+ // Store locally
535
+ await storage.write(hash, bytes);
536
+ // Store metadata
537
+ const now = Date.now();
538
+ await sql `
539
+ insert into ${sql.table('sync_blob_cache')} (
540
+ ${sql.join([
541
+ sql.ref('hash'),
542
+ sql.ref('size'),
543
+ sql.ref('mime_type'),
544
+ sql.ref('cached_at'),
545
+ sql.ref('last_accessed_at'),
546
+ sql.ref('encrypted'),
547
+ sql.ref('key_id'),
548
+ sql.ref('body'),
549
+ ])}
550
+ ) values (
551
+ ${sql.join([
552
+ sql.val(hash),
553
+ sql.val(bytes.length),
554
+ sql.val(mimeType),
555
+ sql.val(now),
556
+ sql.val(now),
557
+ sql.val(0),
558
+ sql.val(null),
559
+ sql.val(bytes),
560
+ ])}
561
+ )
562
+ on conflict (${sql.ref('hash')}) do nothing
563
+ `.execute(db);
564
+ // Queue for upload or upload immediately
565
+ if (options?.immediate) {
566
+ // Initiate upload
567
+ const initResult = await blobs.initiateUpload({
568
+ hash,
569
+ size: bytes.length,
570
+ mimeType,
571
+ });
572
+ if (!initResult.exists && initResult.uploadUrl) {
573
+ // Upload to presigned URL
574
+ const uploadResponse = await fetch(initResult.uploadUrl, {
575
+ method: initResult.uploadMethod ?? 'PUT',
576
+ body: bytes.buffer,
577
+ headers: initResult.uploadHeaders,
578
+ });
579
+ if (!uploadResponse.ok) {
580
+ throw new Error(`Upload failed: ${uploadResponse.statusText}`);
581
+ }
582
+ // Complete upload
583
+ await blobs.completeUpload(hash);
584
+ }
585
+ }
586
+ else {
587
+ // Queue for later upload
588
+ await sql `
589
+ insert into ${sql.table('sync_blob_outbox')} (
590
+ ${sql.join([
591
+ sql.ref('hash'),
592
+ sql.ref('size'),
593
+ sql.ref('mime_type'),
594
+ sql.ref('status'),
595
+ sql.ref('created_at'),
596
+ sql.ref('updated_at'),
597
+ sql.ref('attempt_count'),
598
+ sql.ref('error'),
599
+ sql.ref('encrypted'),
600
+ sql.ref('key_id'),
601
+ sql.ref('body'),
602
+ ])}
603
+ ) values (
604
+ ${sql.join([
605
+ sql.val(hash),
606
+ sql.val(bytes.length),
607
+ sql.val(mimeType),
608
+ sql.val('pending'),
609
+ sql.val(now),
610
+ sql.val(now),
611
+ sql.val(0),
612
+ sql.val(null),
613
+ sql.val(0),
614
+ sql.val(null),
615
+ sql.val(bytes),
616
+ ])}
617
+ )
618
+ on conflict (${sql.ref('hash')}) do nothing
619
+ `.execute(db);
620
+ }
621
+ return {
622
+ hash,
623
+ size: bytes.length,
624
+ mimeType,
625
+ };
626
+ },
627
+ async retrieve(ref) {
628
+ // Check local storage first
629
+ const local = await storage.read(ref.hash);
630
+ if (local) {
631
+ // Update access time
632
+ await sql `
633
+ update ${sql.table('sync_blob_cache')}
634
+ set ${sql.ref('last_accessed_at')} = ${sql.val(Date.now())}
635
+ where ${sql.ref('hash')} = ${sql.val(ref.hash)}
636
+ `.execute(db);
637
+ return local;
638
+ }
639
+ // Fetch from server
640
+ const { url } = await blobs.getDownloadUrl(ref.hash);
641
+ const response = await fetch(url);
642
+ if (!response.ok) {
643
+ throw new Error(`Download failed: ${response.statusText}`);
644
+ }
645
+ const bytes = new Uint8Array(await response.arrayBuffer());
646
+ // Cache locally
647
+ await storage.write(ref.hash, bytes);
648
+ const now = Date.now();
649
+ await sql `
650
+ insert into ${sql.table('sync_blob_cache')} (
651
+ ${sql.join([
652
+ sql.ref('hash'),
653
+ sql.ref('size'),
654
+ sql.ref('mime_type'),
655
+ sql.ref('cached_at'),
656
+ sql.ref('last_accessed_at'),
657
+ sql.ref('encrypted'),
658
+ sql.ref('key_id'),
659
+ sql.ref('body'),
660
+ ])}
661
+ ) values (
662
+ ${sql.join([
663
+ sql.val(ref.hash),
664
+ sql.val(bytes.length),
665
+ sql.val(ref.mimeType),
666
+ sql.val(now),
667
+ sql.val(now),
668
+ sql.val(0),
669
+ sql.val(null),
670
+ sql.val(bytes),
671
+ ])}
672
+ )
673
+ on conflict (${sql.ref('hash')}) do nothing
674
+ `.execute(db);
675
+ return bytes;
676
+ },
677
+ async isLocal(hash) {
678
+ return storage.exists(hash);
679
+ },
680
+ async preload(refs) {
681
+ await Promise.all(refs.map((ref) => this.retrieve(ref)));
682
+ },
683
+ async processUploadQueue() {
684
+ let uploaded = 0;
685
+ let failed = 0;
686
+ const now = Date.now();
687
+ const staleThreshold = now - staleUploadingTimeoutMs;
688
+ await sql `
689
+ update ${sql.table('sync_blob_outbox')}
690
+ set
691
+ ${sql.ref('status')} = ${sql.val('failed')},
692
+ ${sql.ref('attempt_count')} = ${sql.ref('attempt_count')} + ${sql.val(1)},
693
+ ${sql.ref('error')} = ${sql.val('Upload timed out while in uploading state')},
694
+ ${sql.ref('updated_at')} = ${sql.val(now)}
695
+ where ${sql.ref('status')} = ${sql.val('uploading')}
696
+ and ${sql.ref('updated_at')} < ${sql.val(staleThreshold)}
697
+ and ${sql.ref('attempt_count')} + ${sql.val(1)} >= ${sql.val(maxUploadRetries)}
698
+ `.execute(db);
699
+ await sql `
700
+ update ${sql.table('sync_blob_outbox')}
701
+ set
702
+ ${sql.ref('status')} = ${sql.val('pending')},
703
+ ${sql.ref('attempt_count')} = ${sql.ref('attempt_count')} + ${sql.val(1)},
704
+ ${sql.ref('error')} = ${sql.val('Upload timed out while in uploading state; retrying')},
705
+ ${sql.ref('updated_at')} = ${sql.val(now)}
706
+ where ${sql.ref('status')} = ${sql.val('uploading')}
707
+ and ${sql.ref('updated_at')} < ${sql.val(staleThreshold)}
708
+ and ${sql.ref('attempt_count')} + ${sql.val(1)} < ${sql.val(maxUploadRetries)}
709
+ `.execute(db);
710
+ const pendingResult = await sql `
711
+ select
712
+ ${sql.ref('hash')},
713
+ ${sql.ref('size')},
714
+ ${sql.ref('mime_type')},
715
+ ${sql.ref('body')},
716
+ ${sql.ref('attempt_count')}
717
+ from ${sql.table('sync_blob_outbox')}
718
+ where ${sql.ref('status')} = ${sql.val('pending')}
719
+ and ${sql.ref('attempt_count')} < ${sql.val(maxUploadRetries)}
720
+ limit ${sql.val(10)}
721
+ `.execute(db);
722
+ const pending = pendingResult.rows;
723
+ for (const item of pending) {
724
+ const nextAttemptCount = item.attempt_count + 1;
725
+ try {
726
+ // Mark as uploading
727
+ await sql `
728
+ update ${sql.table('sync_blob_outbox')}
729
+ set
730
+ ${sql.ref('status')} = ${sql.val('uploading')},
731
+ ${sql.ref('attempt_count')} = ${sql.val(nextAttemptCount)},
732
+ ${sql.ref('error')} = ${sql.val(null)},
733
+ ${sql.ref('updated_at')} = ${sql.val(Date.now())}
734
+ where ${sql.ref('hash')} = ${sql.val(item.hash)}
735
+ and ${sql.ref('status')} = ${sql.val('pending')}
736
+ `.execute(db);
737
+ // Initiate upload
738
+ const initResult = await blobs.initiateUpload({
739
+ hash: item.hash,
740
+ size: item.size,
741
+ mimeType: item.mime_type,
742
+ });
743
+ if (!initResult.exists && initResult.uploadUrl && item.body) {
744
+ const uploadBody = new ArrayBuffer(item.body.byteLength);
745
+ new Uint8Array(uploadBody).set(item.body);
746
+ // Upload
747
+ const uploadResponse = await fetch(initResult.uploadUrl, {
748
+ method: initResult.uploadMethod ?? 'PUT',
749
+ body: uploadBody,
750
+ headers: initResult.uploadHeaders,
751
+ });
752
+ if (!uploadResponse.ok) {
753
+ throw new Error(`Upload failed: ${uploadResponse.statusText}`);
754
+ }
755
+ // Complete
756
+ const completeResult = await blobs.completeUpload(item.hash);
757
+ if (!completeResult.ok) {
758
+ throw new Error(completeResult.error ?? 'Failed to complete blob upload');
759
+ }
760
+ }
761
+ // Mark as complete
762
+ await sql `
763
+ delete from ${sql.table('sync_blob_outbox')}
764
+ where ${sql.ref('hash')} = ${sql.val(item.hash)}
765
+ `.execute(db);
766
+ uploaded++;
767
+ }
768
+ catch (err) {
769
+ const nextStatus = nextAttemptCount >= maxUploadRetries ? 'failed' : 'pending';
770
+ await sql `
771
+ update ${sql.table('sync_blob_outbox')}
772
+ set
773
+ ${sql.ref('status')} = ${sql.val(nextStatus)},
774
+ ${sql.ref('error')} = ${sql.val(err instanceof Error ? err.message : 'Unknown error')},
775
+ ${sql.ref('updated_at')} = ${sql.val(Date.now())}
776
+ where ${sql.ref('hash')} = ${sql.val(item.hash)}
777
+ `.execute(db);
778
+ if (nextStatus === 'failed') {
779
+ failed++;
780
+ }
781
+ }
782
+ }
783
+ return { uploaded, failed };
784
+ },
785
+ async getUploadQueueStats() {
786
+ const rowsResult = await sql `
787
+ select
788
+ ${sql.ref('status')} as status,
789
+ count(${sql.ref('hash')}) as count
790
+ from ${sql.table('sync_blob_outbox')}
791
+ group by ${sql.ref('status')}
792
+ `.execute(db);
793
+ const stats = { pending: 0, uploading: 0, failed: 0 };
794
+ for (const row of rowsResult.rows) {
795
+ if (row.status === 'pending')
796
+ stats.pending = Number(row.count);
797
+ if (row.status === 'uploading')
798
+ stats.uploading = Number(row.count);
799
+ if (row.status === 'failed')
800
+ stats.failed = Number(row.count);
801
+ }
802
+ return stats;
803
+ },
804
+ async getCacheStats() {
805
+ const result = await sql `
806
+ select
807
+ count(${sql.ref('hash')}) as count,
808
+ sum(${sql.ref('size')}) as totalBytes
809
+ from ${sql.table('sync_blob_cache')}
810
+ `.execute(db);
811
+ const row = result.rows[0];
812
+ return {
813
+ count: Number(row?.count ?? 0),
814
+ totalBytes: Number(row?.totalBytes ?? 0),
815
+ };
816
+ },
817
+ async pruneCache(maxBytes) {
818
+ if (!maxBytes)
819
+ return 0;
820
+ // Get current size
821
+ const stats = await this.getCacheStats();
822
+ if (stats.totalBytes <= maxBytes)
823
+ return 0;
824
+ // Get oldest entries to delete
825
+ const toFree = stats.totalBytes - maxBytes;
826
+ let freed = 0;
827
+ const oldEntriesResult = await sql `
828
+ select ${sql.ref('hash')}, ${sql.ref('size')}
829
+ from ${sql.table('sync_blob_cache')}
830
+ order by ${sql.ref('last_accessed_at')} asc
831
+ `.execute(db);
832
+ const oldEntries = oldEntriesResult.rows;
833
+ for (const entry of oldEntries) {
834
+ if (freed >= toFree)
835
+ break;
836
+ await storage.delete(entry.hash);
837
+ await sql `
838
+ delete from ${sql.table('sync_blob_cache')}
839
+ where ${sql.ref('hash')} = ${sql.val(entry.hash)}
840
+ `.execute(db);
841
+ freed += entry.size;
842
+ }
843
+ return freed;
844
+ },
845
+ async clearCache() {
846
+ if (storage.clear) {
847
+ await storage.clear();
848
+ }
849
+ else {
850
+ // Delete each entry individually
851
+ const entriesResult = await sql `
852
+ select ${sql.ref('hash')}
853
+ from ${sql.table('sync_blob_cache')}
854
+ `.execute(db);
855
+ for (const entry of entriesResult.rows) {
856
+ await storage.delete(entry.hash);
857
+ }
858
+ }
859
+ await sql `delete from ${sql.table('sync_blob_cache')}`.execute(db);
860
+ },
861
+ };
862
+ }
863
+ }
864
+ // ============================================================================
865
+ // Helpers
866
+ // ============================================================================
867
+ async function toUint8Array(data) {
868
+ if (data instanceof Uint8Array) {
869
+ return data;
870
+ }
871
+ const buffer = await data.arrayBuffer();
872
+ return new Uint8Array(buffer);
873
+ }
874
+ async function computeSha256Hex(data) {
875
+ const hashBuffer = await crypto.subtle.digest('SHA-256', data.buffer);
876
+ const hashArray = new Uint8Array(hashBuffer);
877
+ return Array.from(hashArray)
878
+ .map((b) => b.toString(16).padStart(2, '0'))
879
+ .join('');
880
+ }
881
+ //# sourceMappingURL=client.js.map