@syncular/client 0.0.1-60

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