@syncular/client 0.0.6-96 → 0.1.0

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 (356) hide show
  1. package/README.md +713 -11
  2. package/dist/auth-leases.d.ts +11 -0
  3. package/dist/auth-leases.d.ts.map +1 -0
  4. package/dist/auth-leases.js +140 -0
  5. package/dist/auth-leases.js.map +1 -0
  6. package/dist/blob-limits.d.ts +15 -0
  7. package/dist/blob-limits.d.ts.map +1 -0
  8. package/dist/blob-limits.js +66 -0
  9. package/dist/blob-limits.js.map +1 -0
  10. package/dist/bridge-client.d.ts +69 -0
  11. package/dist/bridge-client.d.ts.map +1 -0
  12. package/dist/bridge-client.js +282 -0
  13. package/dist/bridge-client.js.map +1 -0
  14. package/dist/client-config.d.ts +6 -0
  15. package/dist/client-config.d.ts.map +1 -0
  16. package/dist/client-config.js +18 -0
  17. package/dist/client-config.js.map +1 -0
  18. package/dist/client.d.ts +65 -397
  19. package/dist/client.d.ts.map +1 -1
  20. package/dist/client.js +150 -960
  21. package/dist/client.js.map +1 -1
  22. package/dist/command-history.d.ts +35 -0
  23. package/dist/command-history.d.ts.map +1 -0
  24. package/dist/command-history.js +378 -0
  25. package/dist/command-history.js.map +1 -0
  26. package/dist/console-diagnostics.d.ts +33 -0
  27. package/dist/console-diagnostics.d.ts.map +1 -0
  28. package/dist/console-diagnostics.js +496 -0
  29. package/dist/console-diagnostics.js.map +1 -0
  30. package/dist/crdt-yjs/index.d.ts +4 -0
  31. package/dist/crdt-yjs/index.d.ts.map +1 -0
  32. package/dist/crdt-yjs/index.js +4 -0
  33. package/dist/crdt-yjs/index.js.map +1 -0
  34. package/dist/crdt-yjs/webview-host-facade.d.ts +126 -0
  35. package/dist/crdt-yjs/webview-host-facade.d.ts.map +1 -0
  36. package/dist/crdt-yjs/webview-host-facade.js +284 -0
  37. package/dist/crdt-yjs/webview-host-facade.js.map +1 -0
  38. package/dist/crdt-yjs/yjs-document-field-adapter.d.ts +153 -0
  39. package/dist/crdt-yjs/yjs-document-field-adapter.d.ts.map +1 -0
  40. package/dist/crdt-yjs/yjs-document-field-adapter.js +406 -0
  41. package/dist/crdt-yjs/yjs-document-field-adapter.js.map +1 -0
  42. package/dist/crdt-yjs/yjs-prosemirror-bridge.d.ts +73 -0
  43. package/dist/crdt-yjs/yjs-prosemirror-bridge.d.ts.map +1 -0
  44. package/dist/crdt-yjs/yjs-prosemirror-bridge.js +169 -0
  45. package/dist/crdt-yjs/yjs-prosemirror-bridge.js.map +1 -0
  46. package/dist/database.d.ts +83 -0
  47. package/dist/database.d.ts.map +1 -0
  48. package/dist/database.js +916 -0
  49. package/dist/database.js.map +1 -0
  50. package/dist/diagnostics.d.ts +10 -0
  51. package/dist/diagnostics.d.ts.map +1 -0
  52. package/dist/diagnostics.js +83 -0
  53. package/dist/diagnostics.js.map +1 -0
  54. package/dist/errors.d.ts +24 -0
  55. package/dist/errors.d.ts.map +1 -0
  56. package/dist/errors.js +211 -0
  57. package/dist/errors.js.map +1 -0
  58. package/dist/generated-bridge.d.ts +365 -0
  59. package/dist/generated-bridge.d.ts.map +1 -0
  60. package/dist/generated-bridge.js +250 -0
  61. package/dist/generated-bridge.js.map +1 -0
  62. package/dist/index.d.ts +13 -23
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/index.js +14 -25
  65. package/dist/index.js.map +1 -1
  66. package/dist/mutations.d.ts +4 -72
  67. package/dist/mutations.d.ts.map +1 -1
  68. package/dist/mutations.js +2 -568
  69. package/dist/mutations.js.map +1 -1
  70. package/dist/network.d.ts +3 -0
  71. package/dist/network.d.ts.map +1 -0
  72. package/dist/network.js +17 -0
  73. package/dist/network.js.map +1 -0
  74. package/dist/react/index.d.ts +169 -0
  75. package/dist/react/index.d.ts.map +1 -0
  76. package/dist/react/index.js +628 -0
  77. package/dist/react/index.js.map +1 -0
  78. package/dist/react-native/index.d.ts +35 -0
  79. package/dist/react-native/index.d.ts.map +1 -0
  80. package/dist/react-native/index.js +49 -0
  81. package/dist/react-native/index.js.map +1 -0
  82. package/dist/runtime-contract.d.ts +13 -0
  83. package/dist/runtime-contract.d.ts.map +1 -0
  84. package/dist/runtime-contract.js +24 -0
  85. package/dist/runtime-contract.js.map +1 -0
  86. package/dist/rust-client.d.ts +106 -0
  87. package/dist/rust-client.d.ts.map +1 -0
  88. package/dist/rust-client.js +936 -0
  89. package/dist/rust-client.js.map +1 -0
  90. package/dist/sentry.d.ts +35 -0
  91. package/dist/sentry.d.ts.map +1 -0
  92. package/dist/sentry.js +155 -0
  93. package/dist/sentry.js.map +1 -0
  94. package/dist/sql-safety.d.ts +3 -0
  95. package/dist/sql-safety.d.ts.map +1 -0
  96. package/dist/sql-safety.js +62 -0
  97. package/dist/sql-safety.js.map +1 -0
  98. package/dist/syncular-runtime-artifacts.json +61 -0
  99. package/dist/tauri/index.d.ts +35 -0
  100. package/dist/tauri/index.d.ts.map +1 -0
  101. package/dist/tauri/index.js +114 -0
  102. package/dist/tauri/index.js.map +1 -0
  103. package/dist/types.d.ts +1059 -0
  104. package/dist/types.d.ts.map +1 -0
  105. package/dist/types.js +2 -0
  106. package/dist/types.js.map +1 -0
  107. package/dist/wasm/.syncular-wasm-profile +1 -0
  108. package/dist/wasm/syncular-runtime-artifact.json +21 -0
  109. package/dist/wasm/syncular.d.ts +207 -0
  110. package/dist/wasm/syncular.js +2341 -0
  111. package/dist/wasm/syncular_bg.wasm +0 -0
  112. package/dist/wasm/syncular_bg.wasm.d.ts +97 -0
  113. package/dist/wasm-bindings/runtime-contract.d.ts +22 -0
  114. package/dist/wasm-bindings/runtime-contract.d.ts.map +1 -0
  115. package/dist/wasm-bindings/runtime-contract.js +112 -0
  116. package/dist/wasm-bindings/runtime-contract.js.map +1 -0
  117. package/dist/wasm-core/.syncular-wasm-profile +1 -0
  118. package/dist/wasm-core/syncular-runtime-artifact.json +17 -0
  119. package/dist/wasm-core/syncular.d.ts +162 -0
  120. package/dist/wasm-core/syncular.js +1847 -0
  121. package/dist/wasm-core/syncular_bg.wasm +0 -0
  122. package/dist/wasm-core/syncular_bg.wasm.d.ts +77 -0
  123. package/dist/wasm-perf/.syncular-wasm-profile +1 -0
  124. package/dist/wasm-perf/syncular-runtime-artifact.json +21 -0
  125. package/dist/wasm-perf/syncular.d.ts +207 -0
  126. package/dist/wasm-perf/syncular.js +2341 -0
  127. package/dist/wasm-perf/syncular_bg.wasm +0 -0
  128. package/dist/wasm-perf/syncular_bg.wasm.d.ts +97 -0
  129. package/dist/wasm-runtime.d.ts +18 -0
  130. package/dist/wasm-runtime.d.ts.map +1 -0
  131. package/dist/wasm-runtime.js +28 -0
  132. package/dist/wasm-runtime.js.map +1 -0
  133. package/dist/worker-client.d.ts +123 -0
  134. package/dist/worker-client.d.ts.map +1 -0
  135. package/dist/worker-client.js +1686 -0
  136. package/dist/worker-client.js.map +1 -0
  137. package/dist/worker-entry.d.ts +2 -0
  138. package/dist/worker-entry.d.ts.map +1 -0
  139. package/dist/worker-entry.js +479 -0
  140. package/dist/worker-entry.js.map +1 -0
  141. package/dist/worker-protocol.d.ts +64 -0
  142. package/dist/worker-protocol.d.ts.map +1 -0
  143. package/dist/worker-protocol.js +16 -0
  144. package/dist/worker-protocol.js.map +1 -0
  145. package/dist/worker-realtime.d.ts +39 -0
  146. package/dist/worker-realtime.d.ts.map +1 -0
  147. package/dist/worker-realtime.js +677 -0
  148. package/dist/worker-realtime.js.map +1 -0
  149. package/package.json +124 -17
  150. package/src/auth-leases.ts +251 -0
  151. package/src/blob-limits.ts +98 -0
  152. package/src/bridge-client.ts +512 -0
  153. package/src/client-config.ts +29 -0
  154. package/src/client.ts +256 -1414
  155. package/src/command-history.ts +623 -0
  156. package/src/console-diagnostics.ts +617 -0
  157. package/src/crdt-yjs/index.ts +3 -0
  158. package/src/crdt-yjs/webview-host-facade.ts +477 -0
  159. package/src/crdt-yjs/yjs-document-field-adapter.ts +733 -0
  160. package/src/crdt-yjs/yjs-prosemirror-bridge.ts +272 -0
  161. package/src/database.ts +1342 -0
  162. package/src/diagnostics.ts +116 -0
  163. package/src/errors.ts +269 -0
  164. package/src/generated-bridge.ts +741 -0
  165. package/src/index.ts +21 -25
  166. package/src/mutations.ts +12 -821
  167. package/src/network.ts +32 -0
  168. package/src/react/index.ts +1036 -0
  169. package/src/react-native/index.ts +152 -0
  170. package/src/runtime-contract.ts +48 -0
  171. package/src/rust-client.ts +1488 -0
  172. package/src/sentry.ts +215 -0
  173. package/src/sql-safety.ts +61 -0
  174. package/src/tauri/index.ts +211 -0
  175. package/src/types.ts +1356 -0
  176. package/src/wasm-bindings/generated-wasm-bindings.d.ts +70 -0
  177. package/src/wasm-bindings/runtime-contract.ts +158 -0
  178. package/src/wasm-runtime.ts +76 -0
  179. package/src/worker-client.ts +2215 -0
  180. package/src/worker-entry.ts +584 -0
  181. package/src/worker-protocol.ts +109 -0
  182. package/src/worker-realtime.ts +843 -0
  183. package/dist/blobs/index.d.ts +0 -7
  184. package/dist/blobs/index.d.ts.map +0 -1
  185. package/dist/blobs/index.js +0 -7
  186. package/dist/blobs/index.js.map +0 -1
  187. package/dist/blobs/manager.d.ts +0 -345
  188. package/dist/blobs/manager.d.ts.map +0 -1
  189. package/dist/blobs/manager.js +0 -749
  190. package/dist/blobs/manager.js.map +0 -1
  191. package/dist/blobs/migrate.d.ts +0 -14
  192. package/dist/blobs/migrate.d.ts.map +0 -1
  193. package/dist/blobs/migrate.js +0 -59
  194. package/dist/blobs/migrate.js.map +0 -1
  195. package/dist/blobs/types.d.ts +0 -62
  196. package/dist/blobs/types.d.ts.map +0 -1
  197. package/dist/blobs/types.js +0 -5
  198. package/dist/blobs/types.js.map +0 -1
  199. package/dist/conflicts.d.ts +0 -31
  200. package/dist/conflicts.d.ts.map +0 -1
  201. package/dist/conflicts.js +0 -112
  202. package/dist/conflicts.js.map +0 -1
  203. package/dist/create-client.d.ts +0 -118
  204. package/dist/create-client.d.ts.map +0 -1
  205. package/dist/create-client.js +0 -178
  206. package/dist/create-client.js.map +0 -1
  207. package/dist/engine/SyncEngine.d.ts +0 -272
  208. package/dist/engine/SyncEngine.d.ts.map +0 -1
  209. package/dist/engine/SyncEngine.js +0 -1860
  210. package/dist/engine/SyncEngine.js.map +0 -1
  211. package/dist/engine/index.d.ts +0 -6
  212. package/dist/engine/index.d.ts.map +0 -1
  213. package/dist/engine/index.js +0 -6
  214. package/dist/engine/index.js.map +0 -1
  215. package/dist/engine/types.d.ts +0 -345
  216. package/dist/engine/types.d.ts.map +0 -1
  217. package/dist/engine/types.js +0 -7
  218. package/dist/engine/types.js.map +0 -1
  219. package/dist/handlers/collection.d.ts +0 -6
  220. package/dist/handlers/collection.d.ts.map +0 -1
  221. package/dist/handlers/collection.js +0 -21
  222. package/dist/handlers/collection.js.map +0 -1
  223. package/dist/handlers/create-handler.d.ts +0 -120
  224. package/dist/handlers/create-handler.d.ts.map +0 -1
  225. package/dist/handlers/create-handler.js +0 -145
  226. package/dist/handlers/create-handler.js.map +0 -1
  227. package/dist/handlers/types.d.ts +0 -83
  228. package/dist/handlers/types.d.ts.map +0 -1
  229. package/dist/handlers/types.js +0 -5
  230. package/dist/handlers/types.js.map +0 -1
  231. package/dist/migrate.d.ts +0 -19
  232. package/dist/migrate.d.ts.map +0 -1
  233. package/dist/migrate.js +0 -238
  234. package/dist/migrate.js.map +0 -1
  235. package/dist/outbox.d.ts +0 -112
  236. package/dist/outbox.d.ts.map +0 -1
  237. package/dist/outbox.js +0 -294
  238. package/dist/outbox.js.map +0 -1
  239. package/dist/plugins/incrementing-version.d.ts +0 -34
  240. package/dist/plugins/incrementing-version.d.ts.map +0 -1
  241. package/dist/plugins/incrementing-version.js +0 -83
  242. package/dist/plugins/incrementing-version.js.map +0 -1
  243. package/dist/plugins/index.d.ts +0 -3
  244. package/dist/plugins/index.d.ts.map +0 -1
  245. package/dist/plugins/index.js +0 -3
  246. package/dist/plugins/index.js.map +0 -1
  247. package/dist/plugins/types.d.ts +0 -49
  248. package/dist/plugins/types.d.ts.map +0 -1
  249. package/dist/plugins/types.js +0 -15
  250. package/dist/plugins/types.js.map +0 -1
  251. package/dist/proxy/connection.d.ts +0 -33
  252. package/dist/proxy/connection.d.ts.map +0 -1
  253. package/dist/proxy/connection.js +0 -153
  254. package/dist/proxy/connection.js.map +0 -1
  255. package/dist/proxy/dialect.d.ts +0 -46
  256. package/dist/proxy/dialect.d.ts.map +0 -1
  257. package/dist/proxy/dialect.js +0 -58
  258. package/dist/proxy/dialect.js.map +0 -1
  259. package/dist/proxy/driver.d.ts +0 -42
  260. package/dist/proxy/driver.d.ts.map +0 -1
  261. package/dist/proxy/driver.js +0 -78
  262. package/dist/proxy/driver.js.map +0 -1
  263. package/dist/proxy/index.d.ts +0 -10
  264. package/dist/proxy/index.d.ts.map +0 -1
  265. package/dist/proxy/index.js +0 -10
  266. package/dist/proxy/index.js.map +0 -1
  267. package/dist/proxy/mutations.d.ts +0 -9
  268. package/dist/proxy/mutations.d.ts.map +0 -1
  269. package/dist/proxy/mutations.js +0 -11
  270. package/dist/proxy/mutations.js.map +0 -1
  271. package/dist/pull-engine.d.ts +0 -48
  272. package/dist/pull-engine.d.ts.map +0 -1
  273. package/dist/pull-engine.js +0 -648
  274. package/dist/pull-engine.js.map +0 -1
  275. package/dist/push-engine.d.ts +0 -18
  276. package/dist/push-engine.d.ts.map +0 -1
  277. package/dist/push-engine.js +0 -167
  278. package/dist/push-engine.js.map +0 -1
  279. package/dist/query/FingerprintCollector.d.ts +0 -18
  280. package/dist/query/FingerprintCollector.d.ts.map +0 -1
  281. package/dist/query/FingerprintCollector.js +0 -28
  282. package/dist/query/FingerprintCollector.js.map +0 -1
  283. package/dist/query/QueryContext.d.ts +0 -33
  284. package/dist/query/QueryContext.d.ts.map +0 -1
  285. package/dist/query/QueryContext.js +0 -16
  286. package/dist/query/QueryContext.js.map +0 -1
  287. package/dist/query/fingerprint.d.ts +0 -61
  288. package/dist/query/fingerprint.d.ts.map +0 -1
  289. package/dist/query/fingerprint.js +0 -91
  290. package/dist/query/fingerprint.js.map +0 -1
  291. package/dist/query/index.d.ts +0 -7
  292. package/dist/query/index.d.ts.map +0 -1
  293. package/dist/query/index.js +0 -7
  294. package/dist/query/index.js.map +0 -1
  295. package/dist/query/tracked-select.d.ts +0 -18
  296. package/dist/query/tracked-select.d.ts.map +0 -1
  297. package/dist/query/tracked-select.js +0 -90
  298. package/dist/query/tracked-select.js.map +0 -1
  299. package/dist/schema.d.ts +0 -83
  300. package/dist/schema.d.ts.map +0 -1
  301. package/dist/schema.js +0 -7
  302. package/dist/schema.js.map +0 -1
  303. package/dist/subscription-state.d.ts +0 -46
  304. package/dist/subscription-state.d.ts.map +0 -1
  305. package/dist/subscription-state.js +0 -185
  306. package/dist/subscription-state.js.map +0 -1
  307. package/dist/sync-loop.d.ts +0 -34
  308. package/dist/sync-loop.d.ts.map +0 -1
  309. package/dist/sync-loop.js +0 -253
  310. package/dist/sync-loop.js.map +0 -1
  311. package/dist/sync.d.ts +0 -32
  312. package/dist/sync.d.ts.map +0 -1
  313. package/dist/sync.js +0 -55
  314. package/dist/sync.js.map +0 -1
  315. package/dist/utils/id.d.ts +0 -17
  316. package/dist/utils/id.d.ts.map +0 -1
  317. package/dist/utils/id.js +0 -46
  318. package/dist/utils/id.js.map +0 -1
  319. package/src/blobs/index.ts +0 -7
  320. package/src/blobs/manager.ts +0 -1027
  321. package/src/blobs/migrate.ts +0 -67
  322. package/src/blobs/types.ts +0 -84
  323. package/src/client.test.ts +0 -417
  324. package/src/conflicts.ts +0 -171
  325. package/src/create-client.test.ts +0 -83
  326. package/src/create-client.ts +0 -330
  327. package/src/engine/SyncEngine.test.ts +0 -254
  328. package/src/engine/SyncEngine.ts +0 -2375
  329. package/src/engine/index.ts +0 -6
  330. package/src/engine/types.ts +0 -419
  331. package/src/handlers/collection.ts +0 -36
  332. package/src/handlers/create-handler.ts +0 -330
  333. package/src/handlers/types.ts +0 -108
  334. package/src/migrate.ts +0 -318
  335. package/src/outbox.ts +0 -383
  336. package/src/plugins/incrementing-version.ts +0 -133
  337. package/src/plugins/index.ts +0 -2
  338. package/src/plugins/types.ts +0 -63
  339. package/src/proxy/connection.ts +0 -191
  340. package/src/proxy/dialect.ts +0 -76
  341. package/src/proxy/driver.ts +0 -126
  342. package/src/proxy/index.ts +0 -10
  343. package/src/proxy/mutations.ts +0 -18
  344. package/src/pull-engine.test.ts +0 -151
  345. package/src/pull-engine.ts +0 -855
  346. package/src/push-engine.ts +0 -216
  347. package/src/query/FingerprintCollector.ts +0 -29
  348. package/src/query/QueryContext.ts +0 -54
  349. package/src/query/fingerprint.ts +0 -109
  350. package/src/query/index.ts +0 -10
  351. package/src/query/tracked-select.ts +0 -139
  352. package/src/schema.ts +0 -94
  353. package/src/subscription-state.ts +0 -259
  354. package/src/sync-loop.ts +0 -379
  355. package/src/sync.ts +0 -170
  356. package/src/utils/id.ts +0 -55
package/src/client.ts CHANGED
@@ -1,1466 +1,308 @@
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 {
13
- BlobRef,
14
- ColumnCodecDialect,
15
- ColumnCodecSource,
16
- SyncTransport,
17
- } from '@syncular/core';
18
- import { countSyncMetric } from '@syncular/core';
19
- import type { Kysely } from 'kysely';
20
- import { sql } from 'kysely';
21
- import { ensureClientBlobSchema } from './blobs/migrate';
22
- import { SyncEngine } from './engine/SyncEngine';
1
+ import type { BlobRef, SyncAuthLeaseIssueRequest } from '@syncular/core';
2
+ import type { SyncularDatabase } from './database';
3
+ import { isSyncularOfflineError } from './errors';
4
+ import type { MutationsApi } from './mutations';
5
+ import { browserSyncularNetworkStatusSource } from './network';
23
6
  import type {
24
- ConflictInfo,
25
- OutboxStats,
26
- PresenceEntry,
27
- SubscriptionProgress,
28
- SyncAwaitBootstrapOptions,
29
- SyncAwaitPhaseOptions,
30
- SyncDiagnostics,
31
- SyncEngineState,
32
- SyncInspectorOptions,
33
- SyncInspectorSnapshot,
34
- SyncProgress,
35
- SyncRepairOptions,
36
- SyncResetOptions,
37
- SyncResetResult,
38
- SyncResult,
39
- TransportHealth,
40
- } from './engine/types';
41
- import type { ClientHandlerCollection } from './handlers/collection';
42
- import { ensureClientSyncSchema } from './migrate';
43
- import {
44
- createMutationsApi,
45
- createOutboxCommit,
46
- type MutationsApi,
47
- } from './mutations';
48
- import type { SyncClientPlugin } from './plugins/types';
49
- import type { SyncClientDb } from './schema';
50
- import type { SubscriptionState } from './subscription-state';
51
-
52
- // ============================================================================
53
- // Types
54
- // ============================================================================
55
-
56
- /**
57
- * Pluggable client-side blob storage adapter.
58
- * Implementations handle platform-specific binary storage (OPFS, Expo FileSystem, etc.)
59
- * Metadata is stored separately in the main SQLite db.
60
- */
61
- export interface ClientBlobStorage {
62
- /** Write blob data from bytes or stream */
63
- write(
64
- hash: string,
65
- data: Uint8Array | ReadableStream<Uint8Array>
66
- ): Promise<void>;
67
-
68
- /** Read blob data, null if not found */
69
- read(hash: string): Promise<Uint8Array | null>;
70
-
71
- /** Read blob data as stream, null if not found */
72
- readStream?(hash: string): Promise<ReadableStream<Uint8Array> | null>;
73
-
74
- /** Delete blob data */
75
- delete(hash: string): Promise<void>;
76
-
77
- /** Check if blob exists in storage */
78
- exists(hash: string): Promise<boolean>;
79
-
80
- /** Get total storage usage in bytes (for cache management) */
81
- getUsage?(): Promise<number>;
82
-
83
- /** Clear all blobs (for cache reset) */
84
- clear?(): Promise<void>;
7
+ SyncularAuthLeaseRecord,
8
+ SyncularBlobUploadQueueProcessOptions,
9
+ SyncularBlobUploadQueueStats,
10
+ SyncularClientEventSink,
11
+ SyncularClientEventType,
12
+ SyncularConflictResolution,
13
+ SyncularConflictStats,
14
+ SyncularConflictSummary,
15
+ SyncularConnectionState,
16
+ SyncularDiagnosticEvent,
17
+ SyncularDiagnosticSnapshot,
18
+ SyncularLifecycleState,
19
+ SyncularNetworkStatusSource,
20
+ SyncularOutboxStats,
21
+ SyncularPresenceEntry,
22
+ SyncularPresenceSink,
23
+ SyncularRealtimeConnectionState,
24
+ SyncularRealtimeOptions,
25
+ SyncularRuntimeClient,
26
+ SyncularSubscriptionSpec,
27
+ SyncularSyncRequestOptions,
28
+ SyncularSyncResult,
29
+ } from './types';
30
+
31
+ export interface SyncularClientLifecycleOptions {
32
+ initialSync?: boolean;
33
+ realtime?: boolean | SyncularRealtimeOptions;
34
+ syncOnRealtimeConnect?: boolean;
35
+ pollIntervalMs?: number | false;
36
+ network?: SyncularNetworkStatusSource | false;
37
+ subscriptions?: readonly SyncularSubscriptionSpec[];
85
38
  }
86
39
 
87
- export interface ClientOptions<DB extends SyncClientDb> {
88
- /** Kysely database instance */
89
- db: Kysely<DB>;
90
-
91
- /** Transport for server communication (includes sync and blob operations) */
92
- transport: SyncTransport;
93
-
94
- /** Table handlers for applying snapshots and changes */
95
- tableHandlers: ClientHandlerCollection<DB>;
96
-
97
- /** Unique client identifier (e.g., device ID) */
98
- clientId: string;
99
-
100
- /** Current actor/user identifier */
101
- actorId: string;
102
-
103
- /** Subscriptions to sync */
104
- subscriptions: Array<{
105
- id: string;
106
- table: string;
107
- scopes?: Record<string, string | string[]>;
108
- params?: Record<string, unknown>;
109
- }>;
110
-
111
- /** Optional: Local blob storage adapter (enables blob support) */
112
- blobStorage?: ClientBlobStorage;
113
-
114
- /** Optional: Sync plugins */
115
- plugins?: SyncClientPlugin[];
116
-
117
- /** Optional: Enable realtime transport mode */
118
- realtimeEnabled?: boolean;
119
-
120
- /** Optional: Polling interval in milliseconds (default: 10000) */
121
- pollIntervalMs?: number;
122
-
123
- /** Optional: State ID for multi-tenant scenarios */
124
- stateId?: string;
125
-
126
- /** Optional: ID column name (default: 'id') */
127
- idColumn?: string;
128
-
129
- /** Optional: Version column name (default: 'server_version') */
130
- versionColumn?: string;
131
-
132
- /** Optional: Columns to omit from sync */
133
- omitColumns?: string[];
134
-
135
- /** Optional: Column codec resolver */
136
- codecs?: ColumnCodecSource;
137
-
138
- /** Optional: Codec dialect override (default: 'sqlite') */
139
- codecDialect?: ColumnCodecDialect;
140
- }
141
-
142
- export interface ClientState {
143
- /** Client ID */
144
- clientId: string;
145
- /** Actor ID */
146
- actorId: string;
147
- /** Whether sync is enabled (actorId and clientId are set) */
148
- enabled: boolean;
149
- /** Whether a sync is currently in progress */
40
+ export interface SyncularClientStatus {
41
+ lifecycle: SyncularLifecycleState;
42
+ connection: SyncularConnectionState;
43
+ outbox: SyncularOutboxStats | null;
44
+ conflicts: SyncularConflictStats | null;
45
+ isConnected: boolean;
150
46
  isSyncing: boolean;
151
- /** Connection state */
152
- connectionState: 'connected' | 'connecting' | 'disconnected' | 'reconnecting';
153
- /** Last successful sync timestamp */
154
- lastSyncAt: number | null;
155
- /** Current error if any */
156
- error: { code: string; message: string } | null;
157
- /** Outbox statistics */
158
- outbox: OutboxStats;
47
+ hasPendingMutations: boolean;
48
+ hasConflicts: boolean;
49
+ requiresAction: boolean;
159
50
  }
160
51
 
161
- export interface Conflict {
162
- id: string;
163
- table: string;
164
- rowId: string;
165
- opIndex: number;
166
- localPayload: Record<string, unknown> | null;
167
- serverPayload: Record<string, unknown> | null;
168
- serverVersion: number | null;
169
- message: string;
170
- code: string | null;
171
- createdAt: number;
52
+ export interface SyncularBlobClientLike {
53
+ getUploadQueueStats(): Promise<SyncularBlobUploadQueueStats>;
54
+ processUploadQueue(
55
+ options?: SyncularBlobUploadQueueProcessOptions
56
+ ): Promise<{ uploaded: number; failed: number }>;
57
+ retrieve(ref: BlobRef): Promise<Uint8Array>;
172
58
  }
173
59
 
174
- export interface ConflictResolution {
175
- strategy: 'keep-local' | 'keep-server' | 'custom';
176
- payload?: Record<string, unknown>;
60
+ export interface SyncularPresenceClientLike {
61
+ get<TMetadata = Record<string, unknown>>(
62
+ scopeKey: string
63
+ ): SyncularPresenceEntry<TMetadata>[];
64
+ join(scopeKey: string, metadata?: Record<string, unknown>): void;
65
+ leave(scopeKey: string): void;
66
+ updateMetadata(scopeKey: string, metadata: Record<string, unknown>): void;
67
+ onChange<TMetadata = Record<string, unknown>>(
68
+ listener: SyncularPresenceSink<TMetadata>
69
+ ): () => void;
177
70
  }
178
71
 
179
- interface BlobStoreOptions {
180
- /** MIME type of the blob */
181
- mimeType?: string;
182
- /** Upload immediately vs queue for later */
183
- immediate?: boolean;
72
+ export interface SyncularConflictsClientLike {
73
+ list(): Promise<SyncularConflictSummary[]>;
74
+ retryKeepLocal(id: string): Promise<string>;
75
+ resolve(id: string, resolution: SyncularConflictResolution): Promise<void>;
184
76
  }
185
77
 
186
- export interface BlobClient {
187
- /** Store a blob locally and queue for upload */
188
- store(
189
- data: Blob | File | Uint8Array,
190
- options?: BlobStoreOptions
191
- ): Promise<BlobRef>;
192
-
193
- /** Retrieve a blob (from local storage or fetch from server) */
194
- retrieve(ref: BlobRef): Promise<Uint8Array>;
195
-
196
- /** Check if blob is available locally */
197
- isLocal(hash: string): Promise<boolean>;
198
-
199
- /** Preload blobs for offline use */
200
- preload(refs: BlobRef[]): Promise<void>;
201
-
202
- /** Process pending uploads */
203
- processUploadQueue(): Promise<{ uploaded: number; failed: number }>;
204
-
205
- /** Get upload queue statistics */
206
- getUploadQueueStats(): Promise<{
207
- pending: number;
208
- uploading: number;
209
- failed: number;
210
- }>;
211
-
212
- /** Get cache statistics */
213
- getCacheStats(): Promise<{ count: number; totalBytes: number }>;
214
-
215
- /** Prune cache to free space */
216
- pruneCache(maxBytes?: number): Promise<number>;
217
-
218
- /** Clear all cached blobs */
219
- clearCache(): Promise<void>;
220
- }
221
-
222
- export interface MigrationInfo {
223
- /** Whether sync schema is migrated */
224
- syncMigrated: boolean;
225
- /** Whether blob schema is migrated */
226
- blobsMigrated: boolean;
78
+ export interface SyncularClientLike<DB> {
79
+ db: SyncularDatabase<DB>['db'];
80
+ dialect?: SyncularDatabase<DB>['dialect'] | unknown;
81
+ mutations: MutationsApi<DB, any>;
82
+ leasedMutations: MutationsApi<DB, any>;
83
+ blobs: SyncularBlobClientLike;
84
+ on<T extends SyncularClientEventType>(
85
+ event: T,
86
+ listener: SyncularClientEventSink<T>
87
+ ): () => void;
88
+ getStatus(): SyncularClientStatus;
89
+ setSubscriptions(
90
+ subscriptions: readonly SyncularSubscriptionSpec[]
91
+ ): Promise<void>;
92
+ resumeFromBackground(
93
+ options?: SyncularSyncRequestOptions
94
+ ): Promise<SyncularSyncResult>;
95
+ issueAuthLease(
96
+ request: SyncAuthLeaseIssueRequest
97
+ ): Promise<SyncularAuthLeaseRecord>;
98
+ upsertAuthLease(lease: SyncularAuthLeaseRecord): Promise<void>;
99
+ authLease(leaseId: string): Promise<SyncularAuthLeaseRecord | null>;
100
+ activeAuthLeases(
101
+ actorId?: string | null,
102
+ nowMs?: number
103
+ ): Promise<SyncularAuthLeaseRecord[]>;
104
+ diagnosticSnapshot(): Promise<SyncularDiagnosticSnapshot>;
105
+ presence: SyncularPresenceClientLike;
106
+ conflicts: SyncularConflictsClientLike;
107
+ start(): Promise<void>;
108
+ stop(): Promise<void>;
109
+ sync(): Promise<SyncularSyncResult>;
110
+ close(): Promise<void>;
227
111
  }
228
112
 
229
- type ClientEventType =
230
- | 'sync:start'
231
- | 'sync:complete'
232
- | 'sync:live'
233
- | 'sync:error'
234
- | 'bootstrap:start'
235
- | 'bootstrap:progress'
236
- | 'bootstrap:complete'
237
- | 'connection:change'
238
- | 'data:change'
239
- | 'outbox:change'
240
- | 'conflict:new'
241
- | 'conflict:resolved'
242
- | 'blob:upload:complete'
243
- | 'blob:upload:error'
244
- | 'presence:change';
245
-
246
- type ClientEventPayloads = {
247
- 'sync:start': { timestamp: number };
248
- 'sync:complete': SyncResult;
249
- 'sync:live': { timestamp: number };
250
- 'sync:error': { code: string; message: string };
251
- 'bootstrap:start': {
252
- timestamp: number;
253
- stateId: string;
254
- subscriptionId: string;
255
- };
256
- 'bootstrap:progress': {
257
- timestamp: number;
258
- stateId: string;
259
- subscriptionId: string;
260
- progress: SubscriptionProgress;
261
- };
262
- 'bootstrap:complete': {
263
- timestamp: number;
264
- stateId: string;
265
- subscriptionId: string;
266
- durationMs: number;
267
- };
268
- 'connection:change': { previous: string; current: string };
269
- 'data:change': { scopes: string[]; timestamp: number };
270
- 'outbox:change': OutboxStats;
271
- 'conflict:new': Conflict;
272
- 'conflict:resolved': Conflict;
273
- 'blob:upload:complete': BlobRef;
274
- 'blob:upload:error': { hash: string; error: string };
275
- 'presence:change': { scopeKey: string; presence: PresenceEntry[] };
276
- };
277
-
278
- type ClientEventHandler<E extends ClientEventType> = (
279
- payload: ClientEventPayloads[E]
280
- ) => void;
281
-
282
- // ============================================================================
283
- // Client Class
284
- // ============================================================================
285
-
286
- /**
287
- * Unified sync client.
288
- *
289
- * @example
290
- * ```typescript
291
- * import { Client } from '@syncular/client';
292
- * import { createHttpTransport } from '@syncular/transport-http';
293
- *
294
- * const client = new Client({
295
- * db,
296
- * transport: createHttpTransport({ baseUrl: '/api/sync', getHeaders }),
297
- * tableHandlers,
298
- * clientId: 'device-123',
299
- * actorId: 'user-456',
300
- * subscriptions: [{ id: 'tasks', table: 'tasks', scopes: { user_id: 'user-456' } }],
301
- * });
302
- *
303
- * await client.start();
304
- *
305
- * // Mutations
306
- * await client.mutations.tasks.insert({ title: 'New task' });
307
- *
308
- * // Events
309
- * client.on('sync:complete', () => console.log('synced'));
310
- * ```
311
- */
312
- export class Client<DB extends SyncClientDb = SyncClientDb> {
313
- private readonly options: ClientOptions<DB>;
314
- private engine: SyncEngine<DB> | null = null;
315
- private started = false;
316
- private destroyed = false;
317
- private emittedConflictIds = new Set<string>();
318
- private eventListeners = new Map<
319
- ClientEventType,
320
- Set<ClientEventHandler<any>>
321
- >();
322
- private outboxStats: OutboxStats = {
323
- pending: 0,
324
- sending: 0,
325
- failed: 0,
326
- acked: 0,
327
- total: 0,
113
+ type LifecycleClient = Pick<
114
+ SyncularRuntimeClient,
115
+ | 'addDiagnosticListener'
116
+ | 'connectionState'
117
+ | 'forceSubscriptionsBootstrap'
118
+ | 'setSubscriptions'
119
+ | 'startRealtime'
120
+ | 'stopRealtime'
121
+ | 'syncOnce'
122
+ >;
123
+
124
+ export function getSyncularClientStatus(
125
+ client: Pick<SyncularRuntimeClient, 'connectionState' | 'lifecycleState'>
126
+ ): SyncularClientStatus {
127
+ const lifecycle = client.lifecycleState();
128
+ const connection = client.connectionState();
129
+ const outbox = lifecycle.outbox ?? null;
130
+ const conflicts = lifecycle.conflicts ?? null;
131
+ return {
132
+ lifecycle,
133
+ connection,
134
+ outbox,
135
+ conflicts,
136
+ isConnected: connection.realtime === 'connected' && !connection.closed,
137
+ isSyncing:
138
+ lifecycle.phase === 'syncing' || lifecycle.phase === 'recovering',
139
+ hasPendingMutations: (outbox?.pending ?? 0) + (outbox?.sending ?? 0) > 0,
140
+ hasConflicts: (conflicts?.unresolved ?? 0) > 0,
141
+ requiresAction: lifecycle.requiresAction,
328
142
  };
143
+ }
329
144
 
330
- /** Mutations API (always available) */
331
- public readonly mutations: MutationsApi<DB>;
332
-
333
- /** Blob client (only available if blobStorage configured) */
334
- public readonly blobs: BlobClient | undefined;
335
-
336
- constructor(options: ClientOptions<DB>) {
337
- this.options = options;
338
-
339
- // Create mutations API
340
- const commitFn = createOutboxCommit({
341
- db: options.db,
342
- idColumn: options.idColumn ?? 'id',
343
- versionColumn: options.versionColumn ?? 'server_version',
344
- omitColumns: options.omitColumns ?? [],
345
- codecs: options.codecs,
346
- codecDialect: options.codecDialect,
347
- });
348
- this.mutations = createMutationsApi(commitFn) as MutationsApi<DB>;
349
-
350
- // Create blob client if storage provided
351
- if (options.blobStorage && options.transport.blobs) {
352
- this.blobs = this.createBlobClient(
353
- options.blobStorage,
354
- options.transport
355
- );
356
- }
357
- }
358
-
359
- // ===========================================================================
360
- // Identity Getters
361
- // ===========================================================================
362
-
363
- /** Client ID */
364
- get clientId(): string {
365
- return this.options.clientId;
366
- }
367
-
368
- /** Actor ID */
369
- get actorId(): string {
370
- return this.options.actorId;
371
- }
372
-
373
- /** Database instance */
374
- get db(): Kysely<DB> {
375
- return this.options.db;
145
+ export class SyncularClientLifecycle {
146
+ #started = false;
147
+ #pollTimer: ReturnType<typeof setInterval> | undefined;
148
+ #unsubscribeDiagnostics: (() => void) | undefined;
149
+ #unsubscribeNetwork: (() => void) | undefined;
150
+ #syncInFlight: Promise<SyncularSyncResult> | undefined;
151
+ #syncAgain = false;
152
+ #hasConnectedRealtime = false;
153
+ #realtimeStarted = false;
154
+ readonly #network: SyncularNetworkStatusSource | undefined;
155
+
156
+ constructor(
157
+ private readonly client: LifecycleClient,
158
+ private readonly options: SyncularClientLifecycleOptions = {}
159
+ ) {
160
+ this.#network =
161
+ options.network === false
162
+ ? undefined
163
+ : (options.network ?? browserSyncularNetworkStatusSource());
376
164
  }
377
165
 
378
- // ===========================================================================
379
- // Lifecycle
380
- // ===========================================================================
381
-
382
- /**
383
- * Start the client.
384
- * Runs migrations and starts sync engine.
385
- */
386
166
  async start(): Promise<void> {
387
- if (this.destroyed) {
388
- throw new Error('Client has been destroyed');
389
- }
390
- if (this.started) {
391
- return;
392
- }
393
-
394
- // Run migrations
395
- await ensureClientSyncSchema(this.options.db);
396
- if (this.options.blobStorage) {
397
- await ensureClientBlobSchema(this.options.db);
398
- }
399
-
400
- // Create and start engine
401
- this.engine = new SyncEngine({
402
- db: this.options.db,
403
- transport: this.options.transport,
404
- handlers: this.options.tableHandlers,
405
- clientId: this.options.clientId,
406
- actorId: this.options.actorId,
407
- subscriptions: this.options.subscriptions.map((s) => ({
408
- id: s.id,
409
- table: s.table,
410
- scopes: s.scopes ?? {},
411
- params: s.params ?? {},
412
- })),
413
- plugins: this.options.plugins,
414
- realtimeEnabled: this.options.realtimeEnabled,
415
- pollIntervalMs: this.options.pollIntervalMs,
416
- stateId: this.options.stateId,
417
- migrate: undefined, // We already ran migrations
418
- });
419
-
420
- // Wire up engine events to client events
421
- this.wireEngineEvents();
422
-
423
- await this.engine.start();
424
- this.started = true;
425
- }
426
-
427
- /**
428
- * Stop the client (can be restarted).
429
- */
430
- stop(): void {
431
- this.engine?.stop();
432
- }
433
-
434
- /**
435
- * Destroy the client (cannot be restarted).
436
- */
437
- destroy(): void {
438
- this.engine?.destroy();
439
- this.eventListeners.clear();
440
- this.destroyed = true;
441
- }
442
-
443
- // ===========================================================================
444
- // Sync
445
- // ===========================================================================
446
-
447
- /**
448
- * Trigger a manual sync.
449
- */
450
- async sync(): Promise<SyncResult> {
451
- if (!this.engine) {
452
- throw new Error('Client not started');
453
- }
454
- return this.engine.sync();
455
- }
456
-
457
- // ===========================================================================
458
- // Subscriptions
459
- // ===========================================================================
460
-
461
- /**
462
- * Update subscriptions.
463
- */
464
- updateSubscriptions(
465
- subscriptions: Array<{
466
- id: string;
467
- table: string;
468
- scopes?: Record<string, string | string[]>;
469
- params?: Record<string, unknown>;
470
- }>
471
- ): void {
472
- this.options.subscriptions = subscriptions;
473
- if (this.engine) {
474
- this.engine.updateSubscriptions(
475
- subscriptions.map((s) => ({
476
- id: s.id,
477
- table: s.table,
478
- scopes: s.scopes ?? {},
479
- params: s.params ?? {},
480
- }))
481
- );
482
- }
483
- }
484
-
485
- /**
486
- * Get current subscriptions.
487
- */
488
- getSubscriptions(): Array<{
489
- id: string;
490
- table: string;
491
- scopes: Record<string, string | string[]>;
492
- params: Record<string, unknown>;
493
- }> {
494
- return this.options.subscriptions.map((s) => ({
495
- id: s.id,
496
- table: s.table,
497
- scopes: s.scopes ?? {},
498
- params: s.params ?? {},
499
- }));
500
- }
501
-
502
- /**
503
- * List persisted subscription metadata rows.
504
- */
505
- async listSubscriptionStates(args?: {
506
- stateId?: string;
507
- table?: string;
508
- status?: 'active' | 'revoked';
509
- }): Promise<SubscriptionState[]> {
510
- if (!this.engine) return [];
511
- return this.engine.listSubscriptionStates(args);
512
- }
513
-
514
- /**
515
- * Read one persisted subscription metadata row.
516
- */
517
- async getSubscriptionState(
518
- subscriptionId: string,
519
- options?: { stateId?: string }
520
- ): Promise<SubscriptionState | null> {
521
- if (!this.engine) return null;
522
- return this.engine.getSubscriptionState(subscriptionId, options);
523
- }
524
-
525
- // ===========================================================================
526
- // State
527
- // ===========================================================================
528
-
529
- /**
530
- * Get current client state.
531
- */
532
- getState(): ClientState {
533
- const engineState =
534
- this.engine?.getState() ?? this.createInitialEngineState();
535
- return {
536
- clientId: this.options.clientId,
537
- actorId: this.options.actorId,
538
- enabled: engineState.enabled,
539
- isSyncing: engineState.isSyncing,
540
- connectionState: engineState.connectionState,
541
- lastSyncAt: engineState.lastSyncAt,
542
- error: engineState.error
543
- ? { code: engineState.error.code, message: engineState.error.message }
544
- : null,
545
- outbox: this.outboxStats,
546
- };
547
- }
548
-
549
- /**
550
- * Get current transport health details.
551
- */
552
- getTransportHealth(): TransportHealth | null {
553
- if (!this.engine) return null;
554
- return this.engine.getTransportHealth();
555
- }
556
-
557
- /**
558
- * Get computed sync progress across subscriptions.
559
- */
560
- async getProgress(): Promise<SyncProgress | null> {
561
- if (!this.engine) return null;
562
- return this.engine.getProgress();
563
- }
564
-
565
- /**
566
- * Get a diagnostics snapshot for support/debug flows.
567
- */
568
- async getDiagnostics(): Promise<SyncDiagnostics | null> {
569
- if (!this.engine) return null;
570
- return this.engine.getDiagnostics();
571
- }
572
-
573
- /**
574
- * Get a serializable inspector snapshot for in-app debug tooling.
575
- */
576
- async getInspectorSnapshot(
577
- options?: SyncInspectorOptions
578
- ): Promise<SyncInspectorSnapshot | null> {
579
- if (!this.engine) return null;
580
- return this.engine.getInspectorSnapshot(options);
581
- }
582
-
583
- /**
584
- * Reset local sync metadata (and optionally synced app rows/outbox/conflicts).
585
- */
586
- async reset(options: SyncResetOptions): Promise<SyncResetResult> {
587
- if (!this.engine) {
588
- return {
589
- deletedSubscriptionStates: 0,
590
- deletedOutboxCommits: 0,
591
- deletedConflicts: 0,
592
- clearedTables: [],
593
- };
594
- }
595
- return this.engine.reset(options);
596
- }
597
-
598
- /**
599
- * Run a built-in repair flow for common corruption scenarios.
600
- */
601
- async repair(options: SyncRepairOptions): Promise<SyncResetResult> {
602
- if (!this.engine) {
603
- return {
604
- deletedSubscriptionStates: 0,
605
- deletedOutboxCommits: 0,
606
- deletedConflicts: 0,
607
- clearedTables: [],
608
- };
167
+ if (this.#started) return;
168
+ this.#started = true;
169
+ this.#hasConnectedRealtime =
170
+ this.client.connectionState().realtime === 'connected';
171
+ this.#realtimeStarted = this.#hasConnectedRealtime;
172
+ this.#unsubscribeDiagnostics = this.client.addDiagnosticListener((event) =>
173
+ this.#handleDiagnostic(event)
174
+ );
175
+ this.#unsubscribeNetwork = this.#subscribeNetworkEvents();
176
+ try {
177
+ if (this.options.subscriptions) {
178
+ await this.client.setSubscriptions(this.options.subscriptions);
179
+ }
180
+ if (this.options.initialSync !== false && this.#isOnline()) {
181
+ await this.#syncForLifecycle();
182
+ }
183
+ if (this.options.realtime !== false && this.#isOnline()) {
184
+ await this.#startRealtimeForLifecycle();
185
+ }
186
+ this.#startPolling();
187
+ } catch (error) {
188
+ await this.stop().catch(() => undefined);
189
+ throw error;
609
190
  }
610
- return this.engine.repair(options);
611
- }
612
-
613
- /**
614
- * Wait until the channel reaches a target phase.
615
- */
616
- async awaitPhase(
617
- phase: SyncProgress['channelPhase'],
618
- options: SyncAwaitPhaseOptions = {}
619
- ): Promise<SyncProgress | null> {
620
- if (!this.engine) return null;
621
- return this.engine.awaitPhase(phase, options);
622
- }
623
-
624
- /**
625
- * Wait until bootstrap completes for the default state or a specific subscription.
626
- */
627
- async awaitBootstrapComplete(
628
- options: SyncAwaitBootstrapOptions = {}
629
- ): Promise<SyncProgress | null> {
630
- if (!this.engine) return null;
631
- return this.engine.awaitBootstrapComplete(options);
632
191
  }
633
192
 
634
- /**
635
- * Subscribe to state changes (for useSyncExternalStore).
636
- */
637
- subscribe(callback: () => void): () => void {
638
- if (!this.engine) {
639
- // Return no-op unsubscribe before engine is started
640
- return () => {};
193
+ async stop(): Promise<void> {
194
+ if (!this.#started) return;
195
+ this.#started = false;
196
+ this.#stopPolling();
197
+ this.#unsubscribeDiagnostics?.();
198
+ this.#unsubscribeDiagnostics = undefined;
199
+ this.#unsubscribeNetwork?.();
200
+ this.#unsubscribeNetwork = undefined;
201
+ this.#syncAgain = false;
202
+ if (this.options.realtime !== false) {
203
+ await this.client.stopRealtime();
641
204
  }
642
- return this.engine.subscribe(callback);
205
+ this.#realtimeStarted = false;
643
206
  }
644
207
 
645
- // ===========================================================================
646
- // Events
647
- // ===========================================================================
648
-
649
- /**
650
- * Subscribe to client events.
651
- */
652
- on<E extends ClientEventType>(
653
- event: E,
654
- handler: ClientEventHandler<E>
655
- ): () => void {
656
- if (!this.eventListeners.has(event)) {
657
- this.eventListeners.set(event, new Set());
208
+ async sync(): Promise<SyncularSyncResult> {
209
+ if (this.#syncInFlight) {
210
+ this.#syncAgain = true;
211
+ return this.#syncInFlight;
658
212
  }
659
- this.eventListeners.get(event)!.add(handler);
660
-
661
- return () => {
662
- this.eventListeners.get(event)?.delete(handler);
663
- };
664
- }
665
-
666
- private emit<E extends ClientEventType>(
667
- event: E,
668
- payload: ClientEventPayloads[E]
669
- ): void {
670
- const listeners = this.eventListeners.get(event);
671
- if (listeners) {
672
- for (const listener of listeners) {
673
- try {
674
- listener(payload);
675
- } catch (err) {
676
- console.error(`[Client] Error in ${event} listener:`, err);
677
- }
213
+ this.#syncInFlight = this.client.syncOnce().finally(() => {
214
+ this.#syncInFlight = undefined;
215
+ if (this.#syncAgain && this.#started) {
216
+ this.#syncAgain = false;
217
+ void this.sync().catch(() => undefined);
678
218
  }
679
- }
680
- }
681
-
682
- // ===========================================================================
683
- // Conflicts
684
- // ===========================================================================
685
-
686
- /**
687
- * Get pending conflicts.
688
- */
689
- async getConflicts(): Promise<Conflict[]> {
690
- if (!this.engine) {
691
- return [];
692
- }
693
- const conflicts = await this.engine.getConflicts();
694
- return conflicts.map((c) => this.mapConflictInfo(c));
695
- }
696
-
697
- /**
698
- * Resolve a conflict.
699
- */
700
- async resolveConflict(
701
- id: string,
702
- resolution: ConflictResolution
703
- ): Promise<void> {
704
- const { resolveConflict } = await import('./conflicts');
705
- const pendingBeforeResolve = await this.getConflicts();
706
- const resolvedConflict = pendingBeforeResolve.find((c) => c.id === id);
707
-
708
- // For 'keep-local' and 'keep-server', we just mark it resolved
709
- // For 'custom', we would need to apply the payload - but that requires
710
- // creating a new mutation, which the user should do separately
711
- const resolutionStr =
712
- resolution.strategy === 'custom'
713
- ? `custom:${JSON.stringify(resolution.payload)}`
714
- : resolution.strategy;
715
-
716
- await resolveConflict(this.options.db, { id, resolution: resolutionStr });
717
-
718
- countSyncMetric('sync.conflicts.resolved', 1, {
719
- attributes: {
720
- strategy: resolution.strategy,
721
- },
722
219
  });
723
-
724
- this.emittedConflictIds.delete(id);
725
- if (resolvedConflict) {
726
- this.emit('conflict:resolved', resolvedConflict);
727
- }
220
+ return this.#syncInFlight;
728
221
  }
729
222
 
730
- // ===========================================================================
731
- // Outbox
732
- // ===========================================================================
733
-
734
- /**
735
- * Get outbox statistics.
736
- */
737
- async getOutboxStats(): Promise<OutboxStats> {
738
- if (!this.engine) {
739
- return this.outboxStats;
223
+ #handleDiagnostic(event: SyncularDiagnosticEvent): void {
224
+ if (event.source === 'sync' && event.details?.resyncRequired === true) {
225
+ void this.client
226
+ .forceSubscriptionsBootstrap()
227
+ .then(() => this.sync())
228
+ .catch(() => undefined);
229
+ return;
740
230
  }
741
- this.outboxStats = await this.engine.refreshOutboxStats({ emit: false });
742
- return this.outboxStats;
743
- }
744
-
745
- /**
746
- * Clear failed commits from outbox.
747
- */
748
- async clearFailedCommits(): Promise<number> {
749
- if (!this.engine) {
750
- return 0;
231
+ if (
232
+ event.source !== 'realtime' ||
233
+ event.code !== 'realtime.state' ||
234
+ event.details?.state == null
235
+ ) {
236
+ return;
751
237
  }
752
- return this.engine.clearFailedCommits();
238
+ const state = event.details.state as SyncularRealtimeConnectionState;
239
+ if (state !== 'connected') return;
240
+ const wasReconnect = this.#hasConnectedRealtime;
241
+ this.#hasConnectedRealtime = true;
242
+ const shouldSync =
243
+ this.options.syncOnRealtimeConnect !== false &&
244
+ (wasReconnect || this.options.initialSync === false);
245
+ if (!shouldSync) return;
246
+ void this.sync().catch(() => undefined);
247
+ }
248
+
249
+ #subscribeNetworkEvents(): (() => void) | undefined {
250
+ const network = this.#network;
251
+ if (!network?.addEventListener || !network.removeEventListener) return;
252
+ const handleOnline = () => {
253
+ if (!this.#started) return;
254
+ void this.#resumeOnline().catch(() => undefined);
255
+ };
256
+ network.addEventListener('online', handleOnline);
257
+ return () => {
258
+ network.removeEventListener?.('online', handleOnline);
259
+ };
753
260
  }
754
261
 
755
- /**
756
- * Retry failed commits.
757
- */
758
- async retryFailedCommits(): Promise<number> {
759
- // Mark failed commits as pending and trigger sync
760
- const result = await sql`
761
- update ${sql.table('sync_outbox_commits')}
762
- set
763
- ${sql.ref('status')} = ${sql.val('pending')},
764
- ${sql.ref('attempt_count')} = ${sql.val(0)},
765
- ${sql.ref('error')} = ${sql.val(null)}
766
- where ${sql.ref('status')} = ${sql.val('failed')}
767
- `.execute(this.options.db);
768
-
769
- const count = Number(result.numAffectedRows ?? 0n);
770
- if (count > 0 && this.engine) {
771
- await this.engine.refreshOutboxStats();
772
- await this.engine.sync();
262
+ async #resumeOnline(): Promise<void> {
263
+ if (!this.#started || !this.#isOnline()) return;
264
+ if (this.options.initialSync !== false) {
265
+ await this.#syncForLifecycle();
773
266
  }
774
- return count;
775
- }
776
-
777
- // ===========================================================================
778
- // Presence
779
- // ===========================================================================
780
-
781
- /**
782
- * Get presence for a scope.
783
- */
784
- getPresence<TMetadata = Record<string, unknown>>(
785
- scopeKey: string
786
- ): PresenceEntry<TMetadata>[] {
787
- if (!this.engine) {
788
- return [];
267
+ if (this.options.realtime !== false && !this.#realtimeStarted) {
268
+ await this.#startRealtimeForLifecycle();
789
269
  }
790
- return this.engine.getPresence<TMetadata>(scopeKey);
791
- }
792
-
793
- /**
794
- * Join presence for a scope key.
795
- */
796
- joinPresence(scopeKey: string, metadata?: Record<string, unknown>): void {
797
- this.engine?.joinPresence(scopeKey, metadata);
798
- }
799
-
800
- /**
801
- * Leave presence for a scope key.
802
- */
803
- leavePresence(scopeKey: string): void {
804
- this.engine?.leavePresence(scopeKey);
805
- }
806
-
807
- /**
808
- * Update presence metadata for a scope key.
809
- */
810
- updatePresenceMetadata(
811
- scopeKey: string,
812
- metadata: Record<string, unknown>
813
- ): void {
814
- this.engine?.updatePresenceMetadata(scopeKey, metadata);
815
270
  }
816
271
 
817
- // ===========================================================================
818
- // Migration Info
819
- // ===========================================================================
820
-
821
- /**
822
- * Get migration info.
823
- */
824
- async getMigrationInfo(): Promise<MigrationInfo> {
825
- // Check if sync tables exist
826
- let syncMigrated = false;
272
+ async #syncForLifecycle(): Promise<void> {
827
273
  try {
828
- await this.options.db
829
- .selectFrom('sync_outbox_commits')
830
- .selectAll()
831
- .limit(1)
832
- .execute();
833
- syncMigrated = true;
834
- } catch {
835
- syncMigrated = false;
274
+ await this.sync();
275
+ } catch (error) {
276
+ if (!isSyncularOfflineError(error)) throw error;
836
277
  }
837
-
838
- // Check if blob tables exist
839
- let blobsMigrated = false;
840
- try {
841
- await this.options.db
842
- .selectFrom('sync_blob_cache')
843
- .selectAll()
844
- .limit(1)
845
- .execute();
846
- blobsMigrated = true;
847
- } catch {
848
- blobsMigrated = false;
849
- }
850
-
851
- return { syncMigrated, blobsMigrated };
852
278
  }
853
279
 
854
- /**
855
- * Static: Check if migrations are needed.
856
- */
857
- static async checkMigrations<DB extends SyncClientDb>(
858
- db: Kysely<DB>
859
- ): Promise<{
860
- needsMigration: boolean;
861
- syncMigrated: boolean;
862
- blobsMigrated: boolean;
863
- }> {
864
- let syncMigrated = false;
865
- let blobsMigrated = false;
866
-
867
- try {
868
- await db.selectFrom('sync_outbox_commits').selectAll().limit(1).execute();
869
- syncMigrated = true;
870
- } catch {
871
- syncMigrated = false;
872
- }
873
-
280
+ async #startRealtimeForLifecycle(): Promise<void> {
874
281
  try {
875
- await db.selectFrom('sync_blob_cache').selectAll().limit(1).execute();
876
- blobsMigrated = true;
877
- } catch {
878
- blobsMigrated = false;
282
+ await this.client.startRealtime(this.options.realtime);
283
+ this.#realtimeStarted = true;
284
+ } catch (error) {
285
+ this.#realtimeStarted = false;
286
+ if (!isSyncularOfflineError(error)) throw error;
879
287
  }
880
-
881
- return {
882
- needsMigration: !syncMigrated,
883
- syncMigrated,
884
- blobsMigrated,
885
- };
886
288
  }
887
289
 
888
- /**
889
- * Static: Run migrations.
890
- */
891
- static async migrate<DB extends SyncClientDb>(
892
- db: Kysely<DB>,
893
- options?: { blobs?: boolean }
894
- ): Promise<void> {
895
- await ensureClientSyncSchema(db);
896
- if (options?.blobs) {
897
- await ensureClientBlobSchema(db);
898
- }
290
+ #isOnline(): boolean {
291
+ return this.#network?.isOnline() !== false;
899
292
  }
900
293
 
901
- // ===========================================================================
902
- // Private Helpers
903
- // ===========================================================================
904
-
905
- private createInitialEngineState(): SyncEngineState {
906
- return {
907
- enabled: false,
908
- isSyncing: false,
909
- connectionState: 'disconnected',
910
- transportMode: 'polling',
911
- lastSyncAt: null,
912
- error: null,
913
- pendingCount: 0,
914
- retryCount: 0,
915
- isRetrying: false,
916
- };
294
+ #startPolling(): void {
295
+ const interval = this.options.pollIntervalMs;
296
+ if (interval === false || interval === undefined || interval <= 0) return;
297
+ this.#pollTimer = setInterval(() => {
298
+ if (!this.#isOnline()) return;
299
+ void this.#syncForLifecycle();
300
+ }, interval);
917
301
  }
918
302
 
919
- private wireEngineEvents(): void {
920
- if (!this.engine) return;
921
-
922
- this.engine.on('sync:start', (payload) => {
923
- this.emit('sync:start', payload);
924
- });
925
-
926
- this.engine.on('sync:complete', (payload) => {
927
- this.emit('sync:complete', {
928
- success: true,
929
- pushedCommits: payload.pushedCommits,
930
- pullRounds: payload.pullRounds,
931
- pullResponse: payload.pullResponse,
932
- });
933
- });
934
-
935
- this.engine.on('sync:live', (payload) => {
936
- this.emit('sync:live', payload);
937
- });
938
-
939
- this.engine.on('sync:error', (error) => {
940
- this.emit('sync:error', { code: error.code, message: error.message });
941
-
942
- // Check for new conflicts after sync error
943
- this.checkForNewConflicts();
944
- });
945
-
946
- this.engine.on('bootstrap:start', (payload) => {
947
- this.emit('bootstrap:start', payload);
948
- });
949
-
950
- this.engine.on('bootstrap:progress', (payload) => {
951
- this.emit('bootstrap:progress', payload);
952
- });
953
-
954
- this.engine.on('bootstrap:complete', (payload) => {
955
- this.emit('bootstrap:complete', payload);
956
- });
957
-
958
- this.engine.on('connection:change', (payload) => {
959
- this.emit('connection:change', payload);
960
- });
961
-
962
- this.engine.on('data:change', (payload) => {
963
- this.emit('data:change', payload);
964
- });
965
-
966
- this.engine.on('outbox:change', (payload) => {
967
- this.outboxStats = {
968
- pending: payload.pendingCount,
969
- sending: payload.sendingCount,
970
- failed: payload.failedCount,
971
- acked: payload.ackedCount ?? 0,
972
- total:
973
- payload.pendingCount +
974
- payload.sendingCount +
975
- payload.failedCount +
976
- (payload.ackedCount ?? 0),
977
- };
978
- this.emit('outbox:change', this.outboxStats);
979
- });
980
-
981
- this.engine.on('presence:change', (payload) => {
982
- this.emit('presence:change', payload);
983
- });
303
+ #stopPolling(): void {
304
+ if (!this.#pollTimer) return;
305
+ clearInterval(this.#pollTimer);
306
+ this.#pollTimer = undefined;
984
307
  }
985
-
986
- private async checkForNewConflicts(): Promise<void> {
987
- const conflicts = await this.getConflicts();
988
- const activeIds = new Set(conflicts.map((conflict) => conflict.id));
989
-
990
- for (const id of this.emittedConflictIds) {
991
- if (!activeIds.has(id)) {
992
- this.emittedConflictIds.delete(id);
993
- }
994
- }
995
-
996
- for (const conflict of conflicts) {
997
- if (this.emittedConflictIds.has(conflict.id)) {
998
- continue;
999
- }
1000
- this.emittedConflictIds.add(conflict.id);
1001
- this.emit('conflict:new', conflict);
1002
- }
1003
- }
1004
-
1005
- private mapConflictInfo(info: ConflictInfo): Conflict {
1006
- let serverPayload: Record<string, unknown> | null = null;
1007
- if (info.serverRowJson) {
1008
- try {
1009
- serverPayload = JSON.parse(info.serverRowJson);
1010
- } catch {
1011
- serverPayload = null;
1012
- }
1013
- }
1014
-
1015
- return {
1016
- id: info.id,
1017
- table: info.table,
1018
- rowId: info.rowId,
1019
- opIndex: info.opIndex,
1020
- localPayload: info.localPayload,
1021
- serverPayload,
1022
- serverVersion: info.serverVersion,
1023
- message: info.message,
1024
- code: info.code,
1025
- createdAt: info.createdAt,
1026
- };
1027
- }
1028
-
1029
- private createBlobClient(
1030
- storage: ClientBlobStorage,
1031
- transport: SyncTransport
1032
- ): BlobClient {
1033
- const db = this.options.db;
1034
- const blobs = transport.blobs!;
1035
- const staleUploadingTimeoutMs = 30_000;
1036
- const maxUploadRetries = 3;
1037
-
1038
- return {
1039
- async store(data, options) {
1040
- const bytes = await toUint8Array(data);
1041
- const mimeType =
1042
- data instanceof Blob
1043
- ? data.type
1044
- : (options?.mimeType ?? 'application/octet-stream');
1045
-
1046
- // Compute hash
1047
- const hashHex = await computeSha256Hex(bytes);
1048
- const hash = `sha256:${hashHex}`;
1049
-
1050
- // Store locally
1051
- await storage.write(hash, bytes);
1052
-
1053
- // Store metadata
1054
- const now = Date.now();
1055
- await sql`
1056
- insert into ${sql.table('sync_blob_cache')} (
1057
- ${sql.join([
1058
- sql.ref('hash'),
1059
- sql.ref('size'),
1060
- sql.ref('mime_type'),
1061
- sql.ref('cached_at'),
1062
- sql.ref('last_accessed_at'),
1063
- sql.ref('encrypted'),
1064
- sql.ref('key_id'),
1065
- sql.ref('body'),
1066
- ])}
1067
- ) values (
1068
- ${sql.join([
1069
- sql.val(hash),
1070
- sql.val(bytes.length),
1071
- sql.val(mimeType),
1072
- sql.val(now),
1073
- sql.val(now),
1074
- sql.val(0),
1075
- sql.val(null),
1076
- sql.val(bytes),
1077
- ])}
1078
- )
1079
- on conflict (${sql.ref('hash')}) do nothing
1080
- `.execute(db);
1081
-
1082
- // Queue for upload or upload immediately
1083
- if (options?.immediate) {
1084
- // Initiate upload
1085
- const initResult = await blobs.initiateUpload({
1086
- hash,
1087
- size: bytes.length,
1088
- mimeType,
1089
- });
1090
-
1091
- if (!initResult.exists && initResult.uploadUrl) {
1092
- // Upload to presigned URL
1093
- const uploadResponse = await fetch(initResult.uploadUrl, {
1094
- method: initResult.uploadMethod ?? 'PUT',
1095
- body: bytes.buffer as ArrayBuffer,
1096
- headers: initResult.uploadHeaders,
1097
- });
1098
-
1099
- if (!uploadResponse.ok) {
1100
- throw new Error(`Upload failed: ${uploadResponse.statusText}`);
1101
- }
1102
-
1103
- // Complete upload
1104
- await blobs.completeUpload(hash);
1105
- }
1106
- } else {
1107
- // Queue for later upload
1108
- await sql`
1109
- insert into ${sql.table('sync_blob_outbox')} (
1110
- ${sql.join([
1111
- sql.ref('hash'),
1112
- sql.ref('size'),
1113
- sql.ref('mime_type'),
1114
- sql.ref('status'),
1115
- sql.ref('created_at'),
1116
- sql.ref('updated_at'),
1117
- sql.ref('attempt_count'),
1118
- sql.ref('error'),
1119
- sql.ref('encrypted'),
1120
- sql.ref('key_id'),
1121
- sql.ref('body'),
1122
- ])}
1123
- ) values (
1124
- ${sql.join([
1125
- sql.val(hash),
1126
- sql.val(bytes.length),
1127
- sql.val(mimeType),
1128
- sql.val('pending'),
1129
- sql.val(now),
1130
- sql.val(now),
1131
- sql.val(0),
1132
- sql.val(null),
1133
- sql.val(0),
1134
- sql.val(null),
1135
- sql.val(bytes),
1136
- ])}
1137
- )
1138
- on conflict (${sql.ref('hash')}) do nothing
1139
- `.execute(db);
1140
- }
1141
-
1142
- return {
1143
- hash,
1144
- size: bytes.length,
1145
- mimeType,
1146
- };
1147
- },
1148
-
1149
- async retrieve(ref) {
1150
- // Check local storage first
1151
- const local = await storage.read(ref.hash);
1152
- if (local) {
1153
- // Update access time
1154
- await sql`
1155
- update ${sql.table('sync_blob_cache')}
1156
- set ${sql.ref('last_accessed_at')} = ${sql.val(Date.now())}
1157
- where ${sql.ref('hash')} = ${sql.val(ref.hash)}
1158
- `.execute(db);
1159
- return local;
1160
- }
1161
-
1162
- // Fetch from server
1163
- const { url } = await blobs.getDownloadUrl(ref.hash);
1164
- const response = await fetch(url);
1165
- if (!response.ok) {
1166
- throw new Error(`Download failed: ${response.statusText}`);
1167
- }
1168
-
1169
- const bytes = new Uint8Array(await response.arrayBuffer());
1170
-
1171
- // Cache locally
1172
- await storage.write(ref.hash, bytes);
1173
- const now = Date.now();
1174
- await sql`
1175
- insert into ${sql.table('sync_blob_cache')} (
1176
- ${sql.join([
1177
- sql.ref('hash'),
1178
- sql.ref('size'),
1179
- sql.ref('mime_type'),
1180
- sql.ref('cached_at'),
1181
- sql.ref('last_accessed_at'),
1182
- sql.ref('encrypted'),
1183
- sql.ref('key_id'),
1184
- sql.ref('body'),
1185
- ])}
1186
- ) values (
1187
- ${sql.join([
1188
- sql.val(ref.hash),
1189
- sql.val(bytes.length),
1190
- sql.val(ref.mimeType),
1191
- sql.val(now),
1192
- sql.val(now),
1193
- sql.val(0),
1194
- sql.val(null),
1195
- sql.val(bytes),
1196
- ])}
1197
- )
1198
- on conflict (${sql.ref('hash')}) do nothing
1199
- `.execute(db);
1200
-
1201
- return bytes;
1202
- },
1203
-
1204
- async isLocal(hash) {
1205
- return storage.exists(hash);
1206
- },
1207
-
1208
- async preload(refs) {
1209
- await Promise.all(refs.map((ref) => this.retrieve(ref)));
1210
- },
1211
-
1212
- async processUploadQueue() {
1213
- let uploaded = 0;
1214
- let failed = 0;
1215
- const now = Date.now();
1216
- const staleThreshold = now - staleUploadingTimeoutMs;
1217
-
1218
- await sql`
1219
- update ${sql.table('sync_blob_outbox')}
1220
- set
1221
- ${sql.ref('status')} = ${sql.val('failed')},
1222
- ${sql.ref('attempt_count')} = ${sql.ref('attempt_count')} + ${sql.val(
1223
- 1
1224
- )},
1225
- ${sql.ref('error')} = ${sql.val(
1226
- 'Upload timed out while in uploading state'
1227
- )},
1228
- ${sql.ref('updated_at')} = ${sql.val(now)}
1229
- where ${sql.ref('status')} = ${sql.val('uploading')}
1230
- and ${sql.ref('updated_at')} < ${sql.val(staleThreshold)}
1231
- and ${sql.ref('attempt_count')} + ${sql.val(1)} >= ${sql.val(
1232
- maxUploadRetries
1233
- )}
1234
- `.execute(db);
1235
-
1236
- await sql`
1237
- update ${sql.table('sync_blob_outbox')}
1238
- set
1239
- ${sql.ref('status')} = ${sql.val('pending')},
1240
- ${sql.ref('attempt_count')} = ${sql.ref('attempt_count')} + ${sql.val(
1241
- 1
1242
- )},
1243
- ${sql.ref('error')} = ${sql.val(
1244
- 'Upload timed out while in uploading state; retrying'
1245
- )},
1246
- ${sql.ref('updated_at')} = ${sql.val(now)}
1247
- where ${sql.ref('status')} = ${sql.val('uploading')}
1248
- and ${sql.ref('updated_at')} < ${sql.val(staleThreshold)}
1249
- and ${sql.ref('attempt_count')} + ${sql.val(1)} < ${sql.val(
1250
- maxUploadRetries
1251
- )}
1252
- `.execute(db);
1253
-
1254
- const pendingResult = await sql<{
1255
- hash: string;
1256
- size: number;
1257
- mime_type: string;
1258
- body: Uint8Array | null;
1259
- attempt_count: number;
1260
- }>`
1261
- select
1262
- ${sql.ref('hash')},
1263
- ${sql.ref('size')},
1264
- ${sql.ref('mime_type')},
1265
- ${sql.ref('body')},
1266
- ${sql.ref('attempt_count')}
1267
- from ${sql.table('sync_blob_outbox')}
1268
- where ${sql.ref('status')} = ${sql.val('pending')}
1269
- and ${sql.ref('attempt_count')} < ${sql.val(maxUploadRetries)}
1270
- limit ${sql.val(10)}
1271
- `.execute(db);
1272
- const pending = pendingResult.rows;
1273
-
1274
- for (const item of pending) {
1275
- const nextAttemptCount = item.attempt_count + 1;
1276
- try {
1277
- // Mark as uploading
1278
- await sql`
1279
- update ${sql.table('sync_blob_outbox')}
1280
- set
1281
- ${sql.ref('status')} = ${sql.val('uploading')},
1282
- ${sql.ref('attempt_count')} = ${sql.val(nextAttemptCount)},
1283
- ${sql.ref('error')} = ${sql.val(null)},
1284
- ${sql.ref('updated_at')} = ${sql.val(Date.now())}
1285
- where ${sql.ref('hash')} = ${sql.val(item.hash)}
1286
- and ${sql.ref('status')} = ${sql.val('pending')}
1287
- `.execute(db);
1288
-
1289
- // Initiate upload
1290
- const initResult = await blobs.initiateUpload({
1291
- hash: item.hash,
1292
- size: item.size,
1293
- mimeType: item.mime_type,
1294
- });
1295
-
1296
- if (!initResult.exists && initResult.uploadUrl && item.body) {
1297
- const uploadBody = new ArrayBuffer(item.body.byteLength);
1298
- new Uint8Array(uploadBody).set(item.body);
1299
-
1300
- // Upload
1301
- const uploadResponse = await fetch(initResult.uploadUrl, {
1302
- method: initResult.uploadMethod ?? 'PUT',
1303
- body: uploadBody,
1304
- headers: initResult.uploadHeaders,
1305
- });
1306
-
1307
- if (!uploadResponse.ok) {
1308
- throw new Error(`Upload failed: ${uploadResponse.statusText}`);
1309
- }
1310
-
1311
- // Complete
1312
- const completeResult = await blobs.completeUpload(item.hash);
1313
- if (!completeResult.ok) {
1314
- throw new Error(
1315
- completeResult.error ?? 'Failed to complete blob upload'
1316
- );
1317
- }
1318
- }
1319
-
1320
- // Mark as complete
1321
- await sql`
1322
- delete from ${sql.table('sync_blob_outbox')}
1323
- where ${sql.ref('hash')} = ${sql.val(item.hash)}
1324
- `.execute(db);
1325
-
1326
- uploaded++;
1327
- } catch (err) {
1328
- const nextStatus =
1329
- nextAttemptCount >= maxUploadRetries ? 'failed' : 'pending';
1330
-
1331
- await sql`
1332
- update ${sql.table('sync_blob_outbox')}
1333
- set
1334
- ${sql.ref('status')} = ${sql.val(nextStatus)},
1335
- ${sql.ref('error')} = ${sql.val(
1336
- err instanceof Error ? err.message : 'Unknown error'
1337
- )},
1338
- ${sql.ref('updated_at')} = ${sql.val(Date.now())}
1339
- where ${sql.ref('hash')} = ${sql.val(item.hash)}
1340
- `.execute(db);
1341
-
1342
- if (nextStatus === 'failed') {
1343
- failed++;
1344
- }
1345
- }
1346
- }
1347
-
1348
- return { uploaded, failed };
1349
- },
1350
-
1351
- async getUploadQueueStats() {
1352
- const rowsResult = await sql<{
1353
- status: string;
1354
- count: number | bigint;
1355
- }>`
1356
- select
1357
- ${sql.ref('status')} as status,
1358
- count(${sql.ref('hash')}) as count
1359
- from ${sql.table('sync_blob_outbox')}
1360
- group by ${sql.ref('status')}
1361
- `.execute(db);
1362
-
1363
- const stats = { pending: 0, uploading: 0, failed: 0 };
1364
- for (const row of rowsResult.rows) {
1365
- if (row.status === 'pending') stats.pending = Number(row.count);
1366
- if (row.status === 'uploading') stats.uploading = Number(row.count);
1367
- if (row.status === 'failed') stats.failed = Number(row.count);
1368
- }
1369
- return stats;
1370
- },
1371
-
1372
- async getCacheStats() {
1373
- const result = await sql<{
1374
- count: number | bigint;
1375
- totalBytes: number | bigint | null;
1376
- }>`
1377
- select
1378
- count(${sql.ref('hash')}) as count,
1379
- sum(${sql.ref('size')}) as totalBytes
1380
- from ${sql.table('sync_blob_cache')}
1381
- `.execute(db);
1382
- const row = result.rows[0];
1383
-
1384
- return {
1385
- count: Number(row?.count ?? 0),
1386
- totalBytes: Number(row?.totalBytes ?? 0),
1387
- };
1388
- },
1389
-
1390
- async pruneCache(maxBytes) {
1391
- if (!maxBytes) return 0;
1392
-
1393
- // Get current size
1394
- const stats = await this.getCacheStats();
1395
- if (stats.totalBytes <= maxBytes) return 0;
1396
-
1397
- // Get oldest entries to delete
1398
- const toFree = stats.totalBytes - maxBytes;
1399
- let freed = 0;
1400
-
1401
- const oldEntriesResult = await sql<{ hash: string; size: number }>`
1402
- select ${sql.ref('hash')}, ${sql.ref('size')}
1403
- from ${sql.table('sync_blob_cache')}
1404
- order by ${sql.ref('last_accessed_at')} asc
1405
- `.execute(db);
1406
- const oldEntries = oldEntriesResult.rows;
1407
-
1408
- for (const entry of oldEntries) {
1409
- if (freed >= toFree) break;
1410
-
1411
- await storage.delete(entry.hash);
1412
- await sql`
1413
- delete from ${sql.table('sync_blob_cache')}
1414
- where ${sql.ref('hash')} = ${sql.val(entry.hash)}
1415
- `.execute(db);
1416
- freed += entry.size;
1417
- }
1418
-
1419
- return freed;
1420
- },
1421
-
1422
- async clearCache() {
1423
- if (storage.clear) {
1424
- await storage.clear();
1425
- } else {
1426
- // Delete each entry individually
1427
- const entriesResult = await sql<{ hash: string }>`
1428
- select ${sql.ref('hash')}
1429
- from ${sql.table('sync_blob_cache')}
1430
- `.execute(db);
1431
-
1432
- for (const entry of entriesResult.rows) {
1433
- await storage.delete(entry.hash);
1434
- }
1435
- }
1436
-
1437
- await sql`delete from ${sql.table('sync_blob_cache')}`.execute(db);
1438
- },
1439
- };
1440
- }
1441
- }
1442
-
1443
- // ============================================================================
1444
- // Helpers
1445
- // ============================================================================
1446
-
1447
- async function toUint8Array(
1448
- data: Blob | File | Uint8Array
1449
- ): Promise<Uint8Array> {
1450
- if (data instanceof Uint8Array) {
1451
- return data;
1452
- }
1453
- const buffer = await data.arrayBuffer();
1454
- return new Uint8Array(buffer);
1455
- }
1456
-
1457
- async function computeSha256Hex(data: Uint8Array): Promise<string> {
1458
- const hashBuffer = await crypto.subtle.digest(
1459
- 'SHA-256',
1460
- data.buffer as ArrayBuffer
1461
- );
1462
- const hashArray = new Uint8Array(hashBuffer);
1463
- return Array.from(hashArray)
1464
- .map((b) => b.toString(16).padStart(2, '0'))
1465
- .join('');
1466
308
  }