@syncular/client 0.0.6-96 → 0.1.1

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