@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/src/client.ts ADDED
@@ -0,0 +1,1222 @@
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
+
12
+ import type { BlobRef, SyncTransport } from '@syncular/core';
13
+ import type { Kysely } from 'kysely';
14
+ import { sql } from 'kysely';
15
+ import { ensureClientBlobSchema } from './blobs/migrate';
16
+ import { SyncEngine } from './engine/SyncEngine';
17
+ import type {
18
+ ConflictInfo,
19
+ OutboxStats,
20
+ PresenceEntry,
21
+ SyncEngineState,
22
+ SyncResult,
23
+ } from './engine/types';
24
+ import type { ClientTableRegistry } from './handlers/registry';
25
+ import { ensureClientSyncSchema } from './migrate';
26
+ import {
27
+ createMutationsApi,
28
+ createOutboxCommit,
29
+ type MutationsApi,
30
+ } from './mutations';
31
+ import type { SyncClientPlugin } from './plugins/types';
32
+ import type { SyncClientDb } from './schema';
33
+
34
+ // ============================================================================
35
+ // Types
36
+ // ============================================================================
37
+
38
+ /**
39
+ * Pluggable client-side blob storage adapter.
40
+ * Implementations handle platform-specific binary storage (OPFS, Expo FileSystem, etc.)
41
+ * Metadata is stored separately in the main SQLite db.
42
+ */
43
+ export interface ClientBlobStorage {
44
+ /** Write blob data from bytes or stream */
45
+ write(
46
+ hash: string,
47
+ data: Uint8Array | ReadableStream<Uint8Array>
48
+ ): Promise<void>;
49
+
50
+ /** Read blob data, null if not found */
51
+ read(hash: string): Promise<Uint8Array | null>;
52
+
53
+ /** Read blob data as stream, null if not found */
54
+ readStream?(hash: string): Promise<ReadableStream<Uint8Array> | null>;
55
+
56
+ /** Delete blob data */
57
+ delete(hash: string): Promise<void>;
58
+
59
+ /** Check if blob exists in storage */
60
+ exists(hash: string): Promise<boolean>;
61
+
62
+ /** Get total storage usage in bytes (for cache management) */
63
+ getUsage?(): Promise<number>;
64
+
65
+ /** Clear all blobs (for cache reset) */
66
+ clear?(): Promise<void>;
67
+ }
68
+
69
+ export interface ClientOptions<DB extends SyncClientDb> {
70
+ /** Kysely database instance */
71
+ db: Kysely<DB>;
72
+
73
+ /** Transport for server communication (includes sync and blob operations) */
74
+ transport: SyncTransport;
75
+
76
+ /** Table handlers for applying snapshots and changes */
77
+ tableHandlers: ClientTableRegistry<DB>;
78
+
79
+ /** Unique client identifier (e.g., device ID) */
80
+ clientId: string;
81
+
82
+ /** Current actor/user identifier */
83
+ actorId: string;
84
+
85
+ /** Subscriptions to sync */
86
+ subscriptions: Array<{
87
+ id: string;
88
+ shape: string;
89
+ scopes?: Record<string, string | string[]>;
90
+ params?: Record<string, unknown>;
91
+ }>;
92
+
93
+ /** Optional: Local blob storage adapter (enables blob support) */
94
+ blobStorage?: ClientBlobStorage;
95
+
96
+ /** Optional: Sync plugins */
97
+ plugins?: SyncClientPlugin[];
98
+
99
+ /** Optional: Enable realtime transport mode */
100
+ realtimeEnabled?: boolean;
101
+
102
+ /** Optional: Polling interval in milliseconds (default: 10000) */
103
+ pollIntervalMs?: number;
104
+
105
+ /** Optional: State ID for multi-tenant scenarios */
106
+ stateId?: string;
107
+
108
+ /** Optional: ID column name (default: 'id') */
109
+ idColumn?: string;
110
+
111
+ /** Optional: Version column name (default: 'server_version') */
112
+ versionColumn?: string;
113
+
114
+ /** Optional: Columns to omit from sync */
115
+ omitColumns?: string[];
116
+ }
117
+
118
+ export interface ClientState {
119
+ /** Client ID */
120
+ clientId: string;
121
+ /** Actor ID */
122
+ actorId: string;
123
+ /** Whether sync is enabled (actorId and clientId are set) */
124
+ enabled: boolean;
125
+ /** Whether a sync is currently in progress */
126
+ isSyncing: boolean;
127
+ /** Connection state */
128
+ connectionState: 'connected' | 'connecting' | 'disconnected' | 'reconnecting';
129
+ /** Last successful sync timestamp */
130
+ lastSyncAt: number | null;
131
+ /** Current error if any */
132
+ error: { code: string; message: string } | null;
133
+ /** Outbox statistics */
134
+ outbox: OutboxStats;
135
+ }
136
+
137
+ export interface Conflict {
138
+ id: string;
139
+ table: string;
140
+ rowId: string;
141
+ opIndex: number;
142
+ localPayload: Record<string, unknown> | null;
143
+ serverPayload: Record<string, unknown> | null;
144
+ serverVersion: number | null;
145
+ message: string;
146
+ code: string | null;
147
+ createdAt: number;
148
+ }
149
+
150
+ export interface ConflictResolution {
151
+ strategy: 'keep-local' | 'keep-server' | 'custom';
152
+ payload?: Record<string, unknown>;
153
+ }
154
+
155
+ interface BlobStoreOptions {
156
+ /** MIME type of the blob */
157
+ mimeType?: string;
158
+ /** Upload immediately vs queue for later */
159
+ immediate?: boolean;
160
+ }
161
+
162
+ export interface BlobClient {
163
+ /** Store a blob locally and queue for upload */
164
+ store(
165
+ data: Blob | File | Uint8Array,
166
+ options?: BlobStoreOptions
167
+ ): Promise<BlobRef>;
168
+
169
+ /** Retrieve a blob (from local storage or fetch from server) */
170
+ retrieve(ref: BlobRef): Promise<Uint8Array>;
171
+
172
+ /** Check if blob is available locally */
173
+ isLocal(hash: string): Promise<boolean>;
174
+
175
+ /** Preload blobs for offline use */
176
+ preload(refs: BlobRef[]): Promise<void>;
177
+
178
+ /** Process pending uploads */
179
+ processUploadQueue(): Promise<{ uploaded: number; failed: number }>;
180
+
181
+ /** Get upload queue statistics */
182
+ getUploadQueueStats(): Promise<{
183
+ pending: number;
184
+ uploading: number;
185
+ failed: number;
186
+ }>;
187
+
188
+ /** Get cache statistics */
189
+ getCacheStats(): Promise<{ count: number; totalBytes: number }>;
190
+
191
+ /** Prune cache to free space */
192
+ pruneCache(maxBytes?: number): Promise<number>;
193
+
194
+ /** Clear all cached blobs */
195
+ clearCache(): Promise<void>;
196
+ }
197
+
198
+ export interface MigrationInfo {
199
+ /** Whether sync schema is migrated */
200
+ syncMigrated: boolean;
201
+ /** Whether blob schema is migrated */
202
+ blobsMigrated: boolean;
203
+ }
204
+
205
+ type ClientEventType =
206
+ | 'sync:start'
207
+ | 'sync:complete'
208
+ | 'sync:error'
209
+ | 'connection:change'
210
+ | 'data:change'
211
+ | 'outbox:change'
212
+ | 'conflict:new'
213
+ | 'conflict:resolved'
214
+ | 'blob:upload:complete'
215
+ | 'blob:upload:error'
216
+ | 'presence:change';
217
+
218
+ type ClientEventPayloads = {
219
+ 'sync:start': { timestamp: number };
220
+ 'sync:complete': SyncResult;
221
+ 'sync:error': { code: string; message: string };
222
+ 'connection:change': { previous: string; current: string };
223
+ 'data:change': { scopes: string[]; timestamp: number };
224
+ 'outbox:change': OutboxStats;
225
+ 'conflict:new': Conflict;
226
+ 'conflict:resolved': Conflict;
227
+ 'blob:upload:complete': BlobRef;
228
+ 'blob:upload:error': { hash: string; error: string };
229
+ 'presence:change': { scopeKey: string; presence: PresenceEntry[] };
230
+ };
231
+
232
+ type ClientEventHandler<E extends ClientEventType> = (
233
+ payload: ClientEventPayloads[E]
234
+ ) => void;
235
+
236
+ // ============================================================================
237
+ // Client Class
238
+ // ============================================================================
239
+
240
+ /**
241
+ * Unified sync client.
242
+ *
243
+ * @example
244
+ * ```typescript
245
+ * import { Client } from '@syncular/client';
246
+ * import { createHttpTransport } from '@syncular/transport-http';
247
+ *
248
+ * const client = new Client({
249
+ * db,
250
+ * transport: createHttpTransport({ baseUrl: '/api/sync', getHeaders }),
251
+ * tableHandlers,
252
+ * clientId: 'device-123',
253
+ * actorId: 'user-456',
254
+ * subscriptions: [{ id: 'tasks', shape: 'tasks', scopes: { user_id: 'user-456' } }],
255
+ * });
256
+ *
257
+ * await client.start();
258
+ *
259
+ * // Mutations
260
+ * await client.mutations.tasks.insert({ title: 'New task' });
261
+ *
262
+ * // Events
263
+ * client.on('sync:complete', () => console.log('synced'));
264
+ * ```
265
+ */
266
+ export class Client<DB extends SyncClientDb = SyncClientDb> {
267
+ private readonly options: ClientOptions<DB>;
268
+ private engine: SyncEngine<DB> | null = null;
269
+ private started = false;
270
+ private destroyed = false;
271
+ private eventListeners = new Map<
272
+ ClientEventType,
273
+ Set<ClientEventHandler<any>>
274
+ >();
275
+ private outboxStats: OutboxStats = {
276
+ pending: 0,
277
+ sending: 0,
278
+ failed: 0,
279
+ acked: 0,
280
+ total: 0,
281
+ };
282
+
283
+ /** Mutations API (always available) */
284
+ public readonly mutations: MutationsApi<DB>;
285
+
286
+ /** Blob client (only available if blobStorage configured) */
287
+ public readonly blobs: BlobClient | undefined;
288
+
289
+ constructor(options: ClientOptions<DB>) {
290
+ this.options = options;
291
+
292
+ // Create mutations API
293
+ const commitFn = createOutboxCommit({
294
+ db: options.db,
295
+ idColumn: options.idColumn ?? 'id',
296
+ versionColumn: options.versionColumn ?? 'server_version',
297
+ omitColumns: options.omitColumns ?? [],
298
+ });
299
+ this.mutations = createMutationsApi(commitFn) as MutationsApi<DB>;
300
+
301
+ // Create blob client if storage provided
302
+ if (options.blobStorage && options.transport.blobs) {
303
+ this.blobs = this.createBlobClient(
304
+ options.blobStorage,
305
+ options.transport
306
+ );
307
+ }
308
+ }
309
+
310
+ // ===========================================================================
311
+ // Identity Getters
312
+ // ===========================================================================
313
+
314
+ /** Client ID */
315
+ get clientId(): string {
316
+ return this.options.clientId;
317
+ }
318
+
319
+ /** Actor ID */
320
+ get actorId(): string {
321
+ return this.options.actorId;
322
+ }
323
+
324
+ /** Database instance */
325
+ get db(): Kysely<DB> {
326
+ return this.options.db;
327
+ }
328
+
329
+ // ===========================================================================
330
+ // Lifecycle
331
+ // ===========================================================================
332
+
333
+ /**
334
+ * Start the client.
335
+ * Runs migrations and starts sync engine.
336
+ */
337
+ async start(): Promise<void> {
338
+ if (this.destroyed) {
339
+ throw new Error('Client has been destroyed');
340
+ }
341
+ if (this.started) {
342
+ return;
343
+ }
344
+
345
+ // Run migrations
346
+ await ensureClientSyncSchema(this.options.db);
347
+ if (this.options.blobStorage) {
348
+ await ensureClientBlobSchema(this.options.db);
349
+ }
350
+
351
+ // Create and start engine
352
+ this.engine = new SyncEngine({
353
+ db: this.options.db,
354
+ transport: this.options.transport,
355
+ shapes: this.options.tableHandlers,
356
+ clientId: this.options.clientId,
357
+ actorId: this.options.actorId,
358
+ subscriptions: this.options.subscriptions.map((s) => ({
359
+ id: s.id,
360
+ shape: s.shape,
361
+ scopes: s.scopes ?? {},
362
+ params: s.params ?? {},
363
+ })),
364
+ plugins: this.options.plugins,
365
+ realtimeEnabled: this.options.realtimeEnabled,
366
+ pollIntervalMs: this.options.pollIntervalMs,
367
+ stateId: this.options.stateId,
368
+ migrate: undefined, // We already ran migrations
369
+ });
370
+
371
+ // Wire up engine events to client events
372
+ this.wireEngineEvents();
373
+
374
+ await this.engine.start();
375
+ this.started = true;
376
+ }
377
+
378
+ /**
379
+ * Stop the client (can be restarted).
380
+ */
381
+ stop(): void {
382
+ this.engine?.stop();
383
+ }
384
+
385
+ /**
386
+ * Destroy the client (cannot be restarted).
387
+ */
388
+ destroy(): void {
389
+ this.engine?.destroy();
390
+ this.eventListeners.clear();
391
+ this.destroyed = true;
392
+ }
393
+
394
+ // ===========================================================================
395
+ // Sync
396
+ // ===========================================================================
397
+
398
+ /**
399
+ * Trigger a manual sync.
400
+ */
401
+ async sync(): Promise<SyncResult> {
402
+ if (!this.engine) {
403
+ throw new Error('Client not started');
404
+ }
405
+ return this.engine.sync();
406
+ }
407
+
408
+ // ===========================================================================
409
+ // Subscriptions
410
+ // ===========================================================================
411
+
412
+ /**
413
+ * Update subscriptions.
414
+ */
415
+ updateSubscriptions(
416
+ subscriptions: Array<{
417
+ id: string;
418
+ shape: string;
419
+ scopes?: Record<string, string | string[]>;
420
+ params?: Record<string, unknown>;
421
+ }>
422
+ ): void {
423
+ this.options.subscriptions = subscriptions;
424
+ if (this.engine) {
425
+ this.engine.updateSubscriptions(
426
+ subscriptions.map((s) => ({
427
+ id: s.id,
428
+ shape: s.shape,
429
+ scopes: s.scopes ?? {},
430
+ params: s.params ?? {},
431
+ }))
432
+ );
433
+ }
434
+ }
435
+
436
+ /**
437
+ * Get current subscriptions.
438
+ */
439
+ getSubscriptions(): Array<{
440
+ id: string;
441
+ shape: string;
442
+ scopes: Record<string, string | string[]>;
443
+ params: Record<string, unknown>;
444
+ }> {
445
+ return this.options.subscriptions.map((s) => ({
446
+ id: s.id,
447
+ shape: s.shape,
448
+ scopes: s.scopes ?? {},
449
+ params: s.params ?? {},
450
+ }));
451
+ }
452
+
453
+ // ===========================================================================
454
+ // State
455
+ // ===========================================================================
456
+
457
+ /**
458
+ * Get current client state.
459
+ */
460
+ getState(): ClientState {
461
+ const engineState =
462
+ this.engine?.getState() ?? this.createInitialEngineState();
463
+ return {
464
+ clientId: this.options.clientId,
465
+ actorId: this.options.actorId,
466
+ enabled: engineState.enabled,
467
+ isSyncing: engineState.isSyncing,
468
+ connectionState: engineState.connectionState,
469
+ lastSyncAt: engineState.lastSyncAt,
470
+ error: engineState.error
471
+ ? { code: engineState.error.code, message: engineState.error.message }
472
+ : null,
473
+ outbox: this.outboxStats,
474
+ };
475
+ }
476
+
477
+ /**
478
+ * Subscribe to state changes (for useSyncExternalStore).
479
+ */
480
+ subscribe(callback: () => void): () => void {
481
+ if (!this.engine) {
482
+ // Return no-op unsubscribe before engine is started
483
+ return () => {};
484
+ }
485
+ return this.engine.subscribe(callback);
486
+ }
487
+
488
+ // ===========================================================================
489
+ // Events
490
+ // ===========================================================================
491
+
492
+ /**
493
+ * Subscribe to client events.
494
+ */
495
+ on<E extends ClientEventType>(
496
+ event: E,
497
+ handler: ClientEventHandler<E>
498
+ ): () => void {
499
+ if (!this.eventListeners.has(event)) {
500
+ this.eventListeners.set(event, new Set());
501
+ }
502
+ this.eventListeners.get(event)!.add(handler);
503
+
504
+ return () => {
505
+ this.eventListeners.get(event)?.delete(handler);
506
+ };
507
+ }
508
+
509
+ private emit<E extends ClientEventType>(
510
+ event: E,
511
+ payload: ClientEventPayloads[E]
512
+ ): void {
513
+ const listeners = this.eventListeners.get(event);
514
+ if (listeners) {
515
+ for (const listener of listeners) {
516
+ try {
517
+ listener(payload);
518
+ } catch (err) {
519
+ console.error(`[Client] Error in ${event} listener:`, err);
520
+ }
521
+ }
522
+ }
523
+ }
524
+
525
+ // ===========================================================================
526
+ // Conflicts
527
+ // ===========================================================================
528
+
529
+ /**
530
+ * Get pending conflicts.
531
+ */
532
+ async getConflicts(): Promise<Conflict[]> {
533
+ if (!this.engine) {
534
+ return [];
535
+ }
536
+ const conflicts = await this.engine.getConflicts();
537
+ return conflicts.map((c) => this.mapConflictInfo(c));
538
+ }
539
+
540
+ /**
541
+ * Resolve a conflict.
542
+ */
543
+ async resolveConflict(
544
+ id: string,
545
+ resolution: ConflictResolution
546
+ ): Promise<void> {
547
+ const { resolveConflict } = await import('./conflicts');
548
+
549
+ // For 'keep-local' and 'keep-server', we just mark it resolved
550
+ // For 'custom', we would need to apply the payload - but that requires
551
+ // creating a new mutation, which the user should do separately
552
+ const resolutionStr =
553
+ resolution.strategy === 'custom'
554
+ ? `custom:${JSON.stringify(resolution.payload)}`
555
+ : resolution.strategy;
556
+
557
+ await resolveConflict(this.options.db, { id, resolution: resolutionStr });
558
+
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);
564
+ }
565
+ }
566
+
567
+ // ===========================================================================
568
+ // Outbox
569
+ // ===========================================================================
570
+
571
+ /**
572
+ * Get outbox statistics.
573
+ */
574
+ async getOutboxStats(): Promise<OutboxStats> {
575
+ if (!this.engine) {
576
+ return this.outboxStats;
577
+ }
578
+ this.outboxStats = await this.engine.refreshOutboxStats({ emit: false });
579
+ return this.outboxStats;
580
+ }
581
+
582
+ /**
583
+ * Clear failed commits from outbox.
584
+ */
585
+ async clearFailedCommits(): Promise<number> {
586
+ if (!this.engine) {
587
+ return 0;
588
+ }
589
+ return this.engine.clearFailedCommits();
590
+ }
591
+
592
+ /**
593
+ * Retry failed commits.
594
+ */
595
+ async retryFailedCommits(): Promise<number> {
596
+ // Mark failed commits as pending and trigger sync
597
+ const result = await sql`
598
+ update ${sql.table('sync_outbox_commits')}
599
+ set
600
+ ${sql.ref('status')} = ${sql.val('pending')},
601
+ ${sql.ref('attempt_count')} = ${sql.val(0)},
602
+ ${sql.ref('error')} = ${sql.val(null)}
603
+ where ${sql.ref('status')} = ${sql.val('failed')}
604
+ `.execute(this.options.db);
605
+
606
+ const count = Number(result.numAffectedRows ?? 0n);
607
+ if (count > 0 && this.engine) {
608
+ await this.engine.refreshOutboxStats();
609
+ await this.engine.sync();
610
+ }
611
+ return count;
612
+ }
613
+
614
+ // ===========================================================================
615
+ // Presence
616
+ // ===========================================================================
617
+
618
+ /**
619
+ * Get presence for a scope.
620
+ */
621
+ getPresence<TMetadata = Record<string, unknown>>(
622
+ scopeKey: string
623
+ ): PresenceEntry<TMetadata>[] {
624
+ if (!this.engine) {
625
+ return [];
626
+ }
627
+ return this.engine.getPresence<TMetadata>(scopeKey);
628
+ }
629
+
630
+ /**
631
+ * Join presence for a scope key.
632
+ */
633
+ joinPresence(scopeKey: string, metadata?: Record<string, unknown>): void {
634
+ this.engine?.joinPresence(scopeKey, metadata);
635
+ }
636
+
637
+ /**
638
+ * Leave presence for a scope key.
639
+ */
640
+ leavePresence(scopeKey: string): void {
641
+ this.engine?.leavePresence(scopeKey);
642
+ }
643
+
644
+ /**
645
+ * Update presence metadata for a scope key.
646
+ */
647
+ updatePresenceMetadata(
648
+ scopeKey: string,
649
+ metadata: Record<string, unknown>
650
+ ): void {
651
+ this.engine?.updatePresenceMetadata(scopeKey, metadata);
652
+ }
653
+
654
+ // ===========================================================================
655
+ // Migration Info
656
+ // ===========================================================================
657
+
658
+ /**
659
+ * Get migration info.
660
+ */
661
+ async getMigrationInfo(): Promise<MigrationInfo> {
662
+ // Check if sync tables exist
663
+ let syncMigrated = false;
664
+ try {
665
+ await this.options.db
666
+ .selectFrom('sync_outbox_commits')
667
+ .selectAll()
668
+ .limit(1)
669
+ .execute();
670
+ syncMigrated = true;
671
+ } catch {
672
+ syncMigrated = false;
673
+ }
674
+
675
+ // Check if blob tables exist
676
+ let blobsMigrated = false;
677
+ try {
678
+ await this.options.db
679
+ .selectFrom('sync_blob_cache')
680
+ .selectAll()
681
+ .limit(1)
682
+ .execute();
683
+ blobsMigrated = true;
684
+ } catch {
685
+ blobsMigrated = false;
686
+ }
687
+
688
+ return { syncMigrated, blobsMigrated };
689
+ }
690
+
691
+ /**
692
+ * Static: Check if migrations are needed.
693
+ */
694
+ static async checkMigrations<DB extends SyncClientDb>(
695
+ db: Kysely<DB>
696
+ ): Promise<{
697
+ needsMigration: boolean;
698
+ syncMigrated: boolean;
699
+ blobsMigrated: boolean;
700
+ }> {
701
+ let syncMigrated = false;
702
+ let blobsMigrated = false;
703
+
704
+ try {
705
+ await db.selectFrom('sync_outbox_commits').selectAll().limit(1).execute();
706
+ syncMigrated = true;
707
+ } catch {
708
+ syncMigrated = false;
709
+ }
710
+
711
+ try {
712
+ await db.selectFrom('sync_blob_cache').selectAll().limit(1).execute();
713
+ blobsMigrated = true;
714
+ } catch {
715
+ blobsMigrated = false;
716
+ }
717
+
718
+ return {
719
+ needsMigration: !syncMigrated,
720
+ syncMigrated,
721
+ blobsMigrated,
722
+ };
723
+ }
724
+
725
+ /**
726
+ * Static: Run migrations.
727
+ */
728
+ static async migrate<DB extends SyncClientDb>(
729
+ db: Kysely<DB>,
730
+ options?: { blobs?: boolean }
731
+ ): Promise<void> {
732
+ await ensureClientSyncSchema(db);
733
+ if (options?.blobs) {
734
+ await ensureClientBlobSchema(db);
735
+ }
736
+ }
737
+
738
+ // ===========================================================================
739
+ // Private Helpers
740
+ // ===========================================================================
741
+
742
+ private createInitialEngineState(): SyncEngineState {
743
+ return {
744
+ enabled: false,
745
+ isSyncing: false,
746
+ connectionState: 'disconnected',
747
+ transportMode: 'polling',
748
+ lastSyncAt: null,
749
+ error: null,
750
+ pendingCount: 0,
751
+ retryCount: 0,
752
+ isRetrying: false,
753
+ };
754
+ }
755
+
756
+ private wireEngineEvents(): void {
757
+ if (!this.engine) return;
758
+
759
+ this.engine.on('sync:start', (payload) => {
760
+ this.emit('sync:start', payload);
761
+ });
762
+
763
+ this.engine.on('sync:complete', (payload) => {
764
+ this.emit('sync:complete', {
765
+ success: true,
766
+ pushedCommits: payload.pushedCommits,
767
+ pullRounds: payload.pullRounds,
768
+ pullResponse: payload.pullResponse,
769
+ });
770
+ });
771
+
772
+ this.engine.on('sync:error', (error) => {
773
+ this.emit('sync:error', { code: error.code, message: error.message });
774
+
775
+ // Check for new conflicts after sync error
776
+ this.checkForNewConflicts();
777
+ });
778
+
779
+ this.engine.on('connection:change', (payload) => {
780
+ this.emit('connection:change', payload);
781
+ });
782
+
783
+ this.engine.on('data:change', (payload) => {
784
+ this.emit('data:change', payload);
785
+ });
786
+
787
+ this.engine.on('outbox:change', (payload) => {
788
+ this.outboxStats = {
789
+ pending: payload.pendingCount,
790
+ sending: payload.sendingCount,
791
+ failed: payload.failedCount,
792
+ acked: payload.ackedCount ?? 0,
793
+ total:
794
+ payload.pendingCount +
795
+ payload.sendingCount +
796
+ payload.failedCount +
797
+ (payload.ackedCount ?? 0),
798
+ };
799
+ this.emit('outbox:change', this.outboxStats);
800
+ });
801
+
802
+ this.engine.on('presence:change', (payload) => {
803
+ this.emit('presence:change', payload);
804
+ });
805
+ }
806
+
807
+ private async checkForNewConflicts(): Promise<void> {
808
+ const conflicts = await this.getConflicts();
809
+ for (const conflict of conflicts) {
810
+ this.emit('conflict:new', conflict);
811
+ }
812
+ }
813
+
814
+ private mapConflictInfo(info: ConflictInfo): Conflict {
815
+ let serverPayload: Record<string, unknown> | null = null;
816
+ if (info.serverRowJson) {
817
+ try {
818
+ serverPayload = JSON.parse(info.serverRowJson);
819
+ } catch {
820
+ serverPayload = null;
821
+ }
822
+ }
823
+
824
+ return {
825
+ id: info.id,
826
+ table: info.table,
827
+ rowId: info.rowId,
828
+ opIndex: info.opIndex,
829
+ localPayload: info.localPayload,
830
+ serverPayload,
831
+ serverVersion: info.serverVersion,
832
+ message: info.message,
833
+ code: info.code,
834
+ createdAt: info.createdAt,
835
+ };
836
+ }
837
+
838
+ private createBlobClient(
839
+ storage: ClientBlobStorage,
840
+ transport: SyncTransport
841
+ ): BlobClient {
842
+ const db = this.options.db;
843
+ const blobs = transport.blobs!;
844
+
845
+ return {
846
+ async store(data, options) {
847
+ const bytes = await toUint8Array(data);
848
+ const mimeType =
849
+ data instanceof Blob
850
+ ? data.type
851
+ : (options?.mimeType ?? 'application/octet-stream');
852
+
853
+ // Compute hash
854
+ const hashHex = await computeSha256Hex(bytes);
855
+ const hash = `sha256:${hashHex}`;
856
+
857
+ // Store locally
858
+ await storage.write(hash, bytes);
859
+
860
+ // Store metadata
861
+ const now = Date.now();
862
+ await sql`
863
+ insert into ${sql.table('sync_blob_cache')} (
864
+ ${sql.join([
865
+ sql.ref('hash'),
866
+ sql.ref('size'),
867
+ sql.ref('mime_type'),
868
+ sql.ref('cached_at'),
869
+ sql.ref('last_accessed_at'),
870
+ sql.ref('encrypted'),
871
+ sql.ref('key_id'),
872
+ sql.ref('body'),
873
+ ])}
874
+ ) values (
875
+ ${sql.join([
876
+ sql.val(hash),
877
+ sql.val(bytes.length),
878
+ sql.val(mimeType),
879
+ sql.val(now),
880
+ sql.val(now),
881
+ sql.val(0),
882
+ sql.val(null),
883
+ sql.val(bytes),
884
+ ])}
885
+ )
886
+ on conflict (${sql.ref('hash')}) do nothing
887
+ `.execute(db);
888
+
889
+ // Queue for upload or upload immediately
890
+ if (options?.immediate) {
891
+ // Initiate upload
892
+ const initResult = await blobs.initiateUpload({
893
+ hash,
894
+ size: bytes.length,
895
+ mimeType,
896
+ });
897
+
898
+ if (!initResult.exists && initResult.uploadUrl) {
899
+ // Upload to presigned URL
900
+ const uploadResponse = await fetch(initResult.uploadUrl, {
901
+ method: initResult.uploadMethod ?? 'PUT',
902
+ body: bytes.buffer as ArrayBuffer,
903
+ headers: initResult.uploadHeaders,
904
+ });
905
+
906
+ if (!uploadResponse.ok) {
907
+ throw new Error(`Upload failed: ${uploadResponse.statusText}`);
908
+ }
909
+
910
+ // Complete upload
911
+ await blobs.completeUpload(hash);
912
+ }
913
+ } else {
914
+ // Queue for later upload
915
+ await sql`
916
+ insert into ${sql.table('sync_blob_outbox')} (
917
+ ${sql.join([
918
+ sql.ref('hash'),
919
+ sql.ref('size'),
920
+ sql.ref('mime_type'),
921
+ sql.ref('status'),
922
+ sql.ref('created_at'),
923
+ sql.ref('updated_at'),
924
+ sql.ref('attempt_count'),
925
+ sql.ref('error'),
926
+ sql.ref('encrypted'),
927
+ sql.ref('key_id'),
928
+ sql.ref('body'),
929
+ ])}
930
+ ) values (
931
+ ${sql.join([
932
+ sql.val(hash),
933
+ sql.val(bytes.length),
934
+ sql.val(mimeType),
935
+ sql.val('pending'),
936
+ sql.val(now),
937
+ sql.val(now),
938
+ sql.val(0),
939
+ sql.val(null),
940
+ sql.val(0),
941
+ sql.val(null),
942
+ sql.val(bytes),
943
+ ])}
944
+ )
945
+ on conflict (${sql.ref('hash')}) do nothing
946
+ `.execute(db);
947
+ }
948
+
949
+ return {
950
+ hash,
951
+ size: bytes.length,
952
+ mimeType,
953
+ };
954
+ },
955
+
956
+ async retrieve(ref) {
957
+ // Check local storage first
958
+ const local = await storage.read(ref.hash);
959
+ if (local) {
960
+ // Update access time
961
+ await sql`
962
+ update ${sql.table('sync_blob_cache')}
963
+ set ${sql.ref('last_accessed_at')} = ${sql.val(Date.now())}
964
+ where ${sql.ref('hash')} = ${sql.val(ref.hash)}
965
+ `.execute(db);
966
+ return local;
967
+ }
968
+
969
+ // Fetch from server
970
+ const { url } = await blobs.getDownloadUrl(ref.hash);
971
+ const response = await fetch(url);
972
+ if (!response.ok) {
973
+ throw new Error(`Download failed: ${response.statusText}`);
974
+ }
975
+
976
+ const bytes = new Uint8Array(await response.arrayBuffer());
977
+
978
+ // Cache locally
979
+ await storage.write(ref.hash, bytes);
980
+ const now = Date.now();
981
+ await sql`
982
+ insert into ${sql.table('sync_blob_cache')} (
983
+ ${sql.join([
984
+ sql.ref('hash'),
985
+ sql.ref('size'),
986
+ sql.ref('mime_type'),
987
+ sql.ref('cached_at'),
988
+ sql.ref('last_accessed_at'),
989
+ sql.ref('encrypted'),
990
+ sql.ref('key_id'),
991
+ sql.ref('body'),
992
+ ])}
993
+ ) values (
994
+ ${sql.join([
995
+ sql.val(ref.hash),
996
+ sql.val(bytes.length),
997
+ sql.val(ref.mimeType),
998
+ sql.val(now),
999
+ sql.val(now),
1000
+ sql.val(0),
1001
+ sql.val(null),
1002
+ sql.val(bytes),
1003
+ ])}
1004
+ )
1005
+ on conflict (${sql.ref('hash')}) do nothing
1006
+ `.execute(db);
1007
+
1008
+ return bytes;
1009
+ },
1010
+
1011
+ async isLocal(hash) {
1012
+ return storage.exists(hash);
1013
+ },
1014
+
1015
+ async preload(refs) {
1016
+ await Promise.all(refs.map((ref) => this.retrieve(ref)));
1017
+ },
1018
+
1019
+ async processUploadQueue() {
1020
+ let uploaded = 0;
1021
+ let failed = 0;
1022
+
1023
+ const pendingResult = await sql<{
1024
+ hash: string;
1025
+ size: number;
1026
+ mime_type: string;
1027
+ body: Uint8Array | null;
1028
+ }>`
1029
+ select
1030
+ ${sql.ref('hash')},
1031
+ ${sql.ref('size')},
1032
+ ${sql.ref('mime_type')},
1033
+ ${sql.ref('body')}
1034
+ from ${sql.table('sync_blob_outbox')}
1035
+ where ${sql.ref('status')} = ${sql.val('pending')}
1036
+ limit ${sql.val(10)}
1037
+ `.execute(db);
1038
+ const pending = pendingResult.rows;
1039
+
1040
+ for (const item of pending) {
1041
+ try {
1042
+ // Mark as uploading
1043
+ await sql`
1044
+ update ${sql.table('sync_blob_outbox')}
1045
+ set
1046
+ ${sql.ref('status')} = ${sql.val('uploading')},
1047
+ ${sql.ref('updated_at')} = ${sql.val(Date.now())}
1048
+ where ${sql.ref('hash')} = ${sql.val(item.hash)}
1049
+ `.execute(db);
1050
+
1051
+ // Initiate upload
1052
+ const initResult = await blobs.initiateUpload({
1053
+ hash: item.hash,
1054
+ size: item.size,
1055
+ mimeType: item.mime_type,
1056
+ });
1057
+
1058
+ if (!initResult.exists && initResult.uploadUrl && item.body) {
1059
+ const uploadBody = new ArrayBuffer(item.body.byteLength);
1060
+ new Uint8Array(uploadBody).set(item.body);
1061
+
1062
+ // Upload
1063
+ const uploadResponse = await fetch(initResult.uploadUrl, {
1064
+ method: initResult.uploadMethod ?? 'PUT',
1065
+ body: uploadBody,
1066
+ headers: initResult.uploadHeaders,
1067
+ });
1068
+
1069
+ if (!uploadResponse.ok) {
1070
+ throw new Error(`Upload failed: ${uploadResponse.statusText}`);
1071
+ }
1072
+
1073
+ // Complete
1074
+ await blobs.completeUpload(item.hash);
1075
+ }
1076
+
1077
+ // Mark as complete
1078
+ await sql`
1079
+ delete from ${sql.table('sync_blob_outbox')}
1080
+ where ${sql.ref('hash')} = ${sql.val(item.hash)}
1081
+ `.execute(db);
1082
+
1083
+ uploaded++;
1084
+ } catch (err) {
1085
+ // Mark as failed
1086
+ await sql`
1087
+ update ${sql.table('sync_blob_outbox')}
1088
+ set
1089
+ ${sql.ref('status')} = ${sql.val('failed')},
1090
+ ${sql.ref('error')} = ${sql.val(
1091
+ err instanceof Error ? err.message : 'Unknown error'
1092
+ )},
1093
+ ${sql.ref('attempt_count')} = ${sql.ref('attempt_count')} + ${sql.val(
1094
+ 1
1095
+ )},
1096
+ ${sql.ref('updated_at')} = ${sql.val(Date.now())}
1097
+ where ${sql.ref('hash')} = ${sql.val(item.hash)}
1098
+ `.execute(db);
1099
+
1100
+ failed++;
1101
+ }
1102
+ }
1103
+
1104
+ return { uploaded, failed };
1105
+ },
1106
+
1107
+ async getUploadQueueStats() {
1108
+ const rowsResult = await sql<{
1109
+ status: string;
1110
+ count: number | bigint;
1111
+ }>`
1112
+ select
1113
+ ${sql.ref('status')} as status,
1114
+ count(${sql.ref('hash')}) as count
1115
+ from ${sql.table('sync_blob_outbox')}
1116
+ group by ${sql.ref('status')}
1117
+ `.execute(db);
1118
+
1119
+ const stats = { pending: 0, uploading: 0, failed: 0 };
1120
+ for (const row of rowsResult.rows) {
1121
+ if (row.status === 'pending') stats.pending = Number(row.count);
1122
+ if (row.status === 'uploading') stats.uploading = Number(row.count);
1123
+ if (row.status === 'failed') stats.failed = Number(row.count);
1124
+ }
1125
+ return stats;
1126
+ },
1127
+
1128
+ async getCacheStats() {
1129
+ const result = await sql<{
1130
+ count: number | bigint;
1131
+ totalBytes: number | bigint | null;
1132
+ }>`
1133
+ select
1134
+ count(${sql.ref('hash')}) as count,
1135
+ sum(${sql.ref('size')}) as totalBytes
1136
+ from ${sql.table('sync_blob_cache')}
1137
+ `.execute(db);
1138
+ const row = result.rows[0];
1139
+
1140
+ return {
1141
+ count: Number(row?.count ?? 0),
1142
+ totalBytes: Number(row?.totalBytes ?? 0),
1143
+ };
1144
+ },
1145
+
1146
+ async pruneCache(maxBytes) {
1147
+ if (!maxBytes) return 0;
1148
+
1149
+ // Get current size
1150
+ const stats = await this.getCacheStats();
1151
+ if (stats.totalBytes <= maxBytes) return 0;
1152
+
1153
+ // Get oldest entries to delete
1154
+ const toFree = stats.totalBytes - maxBytes;
1155
+ let freed = 0;
1156
+
1157
+ const oldEntriesResult = await sql<{ hash: string; size: number }>`
1158
+ select ${sql.ref('hash')}, ${sql.ref('size')}
1159
+ from ${sql.table('sync_blob_cache')}
1160
+ order by ${sql.ref('last_accessed_at')} asc
1161
+ `.execute(db);
1162
+ const oldEntries = oldEntriesResult.rows;
1163
+
1164
+ for (const entry of oldEntries) {
1165
+ if (freed >= toFree) break;
1166
+
1167
+ await storage.delete(entry.hash);
1168
+ await sql`
1169
+ delete from ${sql.table('sync_blob_cache')}
1170
+ where ${sql.ref('hash')} = ${sql.val(entry.hash)}
1171
+ `.execute(db);
1172
+ freed += entry.size;
1173
+ }
1174
+
1175
+ return freed;
1176
+ },
1177
+
1178
+ async clearCache() {
1179
+ if (storage.clear) {
1180
+ await storage.clear();
1181
+ } else {
1182
+ // Delete each entry individually
1183
+ const entriesResult = await sql<{ hash: string }>`
1184
+ select ${sql.ref('hash')}
1185
+ from ${sql.table('sync_blob_cache')}
1186
+ `.execute(db);
1187
+
1188
+ for (const entry of entriesResult.rows) {
1189
+ await storage.delete(entry.hash);
1190
+ }
1191
+ }
1192
+
1193
+ await sql`delete from ${sql.table('sync_blob_cache')}`.execute(db);
1194
+ },
1195
+ };
1196
+ }
1197
+ }
1198
+
1199
+ // ============================================================================
1200
+ // Helpers
1201
+ // ============================================================================
1202
+
1203
+ async function toUint8Array(
1204
+ data: Blob | File | Uint8Array
1205
+ ): Promise<Uint8Array> {
1206
+ if (data instanceof Uint8Array) {
1207
+ return data;
1208
+ }
1209
+ const buffer = await data.arrayBuffer();
1210
+ return new Uint8Array(buffer);
1211
+ }
1212
+
1213
+ async function computeSha256Hex(data: Uint8Array): Promise<string> {
1214
+ const hashBuffer = await crypto.subtle.digest(
1215
+ 'SHA-256',
1216
+ data.buffer as ArrayBuffer
1217
+ );
1218
+ const hashArray = new Uint8Array(hashBuffer);
1219
+ return Array.from(hashArray)
1220
+ .map((b) => b.toString(16).padStart(2, '0'))
1221
+ .join('');
1222
+ }