@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
@@ -1,1860 +0,0 @@
1
- /**
2
- * @syncular/client - Core sync engine
3
- *
4
- * Event-driven sync engine that manages push/pull cycles, connection state,
5
- * and provides a clean API for framework bindings to consume.
6
- */
7
- import { captureSyncException, countSyncMetric, distributionSyncMetric, isRecord, SyncTransportError, startSyncSpan, } from '@syncular/core';
8
- import { sql } from 'kysely';
9
- import { getClientHandler } from '../handlers/collection.js';
10
- import { ensureClientSyncSchema } from '../migrate.js';
11
- import { syncPushOnce } from '../push-engine.js';
12
- import { DEFAULT_SYNC_STATE_ID, getSubscriptionState as readSubscriptionState, listSubscriptionStates as readSubscriptionStates, } from '../subscription-state.js';
13
- import { syncOnce } from '../sync-loop.js';
14
- const DEFAULT_POLL_INTERVAL_MS = 10_000;
15
- const DEFAULT_MAX_RETRIES = 5;
16
- const INITIAL_RETRY_DELAY_MS = 1000;
17
- const MAX_RETRY_DELAY_MS = 60000;
18
- const EXPONENTIAL_FACTOR = 2;
19
- const REALTIME_RECONNECT_CATCHUP_DELAY_MS = 500;
20
- const DEFAULT_AWAIT_TIMEOUT_MS = 60_000;
21
- const DEFAULT_INSPECTOR_EVENT_LIMIT = 100;
22
- const MAX_INSPECTOR_EVENT_LIMIT = 500;
23
- function calculateRetryDelay(attemptIndex) {
24
- return Math.min(INITIAL_RETRY_DELAY_MS * EXPONENTIAL_FACTOR ** attemptIndex, MAX_RETRY_DELAY_MS);
25
- }
26
- function isRealtimeTransport(transport) {
27
- return (typeof transport === 'object' &&
28
- transport !== null &&
29
- typeof transport.connect === 'function');
30
- }
31
- function createSyncError(args) {
32
- return {
33
- code: args.code,
34
- message: args.message,
35
- cause: args.cause,
36
- timestamp: Date.now(),
37
- retryable: args.retryable ?? false,
38
- httpStatus: args.httpStatus,
39
- subscriptionId: args.subscriptionId,
40
- stateId: args.stateId,
41
- };
42
- }
43
- function classifySyncFailure(error) {
44
- const cause = error instanceof Error ? error : new Error(String(error));
45
- const message = cause.message || 'Sync failed';
46
- const normalized = message.toLowerCase();
47
- if (cause instanceof SyncTransportError) {
48
- if (cause.status === 401 || cause.status === 403) {
49
- return {
50
- code: 'AUTH_FAILED',
51
- message,
52
- cause,
53
- retryable: false,
54
- httpStatus: cause.status,
55
- };
56
- }
57
- if (cause.status === 404 &&
58
- normalized.includes('snapshot') &&
59
- normalized.includes('chunk')) {
60
- return {
61
- code: 'SNAPSHOT_CHUNK_NOT_FOUND',
62
- message,
63
- cause,
64
- retryable: false,
65
- httpStatus: cause.status,
66
- };
67
- }
68
- if (cause.status !== undefined &&
69
- (cause.status >= 500 || cause.status === 408 || cause.status === 429)) {
70
- return {
71
- code: 'NETWORK_ERROR',
72
- message,
73
- cause,
74
- retryable: true,
75
- httpStatus: cause.status,
76
- };
77
- }
78
- return {
79
- code: 'SYNC_ERROR',
80
- message,
81
- cause,
82
- retryable: false,
83
- httpStatus: cause.status,
84
- };
85
- }
86
- if (normalized.includes('network') ||
87
- normalized.includes('fetch') ||
88
- normalized.includes('timeout') ||
89
- normalized.includes('offline')) {
90
- return {
91
- code: 'NETWORK_ERROR',
92
- message,
93
- cause,
94
- retryable: true,
95
- };
96
- }
97
- if (normalized.includes('conflict')) {
98
- return {
99
- code: 'CONFLICT',
100
- message,
101
- cause,
102
- retryable: false,
103
- };
104
- }
105
- return {
106
- code: 'SYNC_ERROR',
107
- message,
108
- cause,
109
- retryable: false,
110
- };
111
- }
112
- function resolveSyncTriggerLabel(trigger) {
113
- return trigger ?? 'auto';
114
- }
115
- function serializeInspectorValue(value) {
116
- const encoded = JSON.stringify(value, (_key, nextValue) => {
117
- if (nextValue instanceof Error) {
118
- return {
119
- name: nextValue.name,
120
- message: nextValue.message,
121
- stack: nextValue.stack,
122
- };
123
- }
124
- if (typeof nextValue === 'bigint') {
125
- return nextValue.toString();
126
- }
127
- return nextValue;
128
- });
129
- if (!encoded)
130
- return null;
131
- return JSON.parse(encoded);
132
- }
133
- function serializeInspectorRecord(value) {
134
- const serialized = serializeInspectorValue(value);
135
- if (isRecord(serialized)) {
136
- return serialized;
137
- }
138
- return { value: serialized };
139
- }
140
- export class SyncEngine {
141
- config;
142
- state;
143
- listeners;
144
- pollerId = null;
145
- fallbackPollerId = null;
146
- realtimeDisconnect = null;
147
- realtimePresenceUnsub = null;
148
- isDestroyed = false;
149
- migrated = false;
150
- syncPromise = null;
151
- syncRequestedWhileRunning = false;
152
- retryTimeoutId = null;
153
- realtimeCatchupTimeoutId = null;
154
- hasRealtimeConnectedOnce = false;
155
- transportHealth = {
156
- mode: 'disconnected',
157
- connected: false,
158
- lastSuccessfulPollAt: null,
159
- lastRealtimeMessageAt: null,
160
- fallbackReason: null,
161
- };
162
- activeBootstrapSubscriptions = new Set();
163
- bootstrapStartedAt = new Map();
164
- inspectorEvents = [];
165
- nextInspectorEventId = 1;
166
- /**
167
- * In-memory map tracking local mutation timestamps by rowId.
168
- * Used for efficient fingerprint-based rerender optimization.
169
- * Key format: `${table}:${rowId}`, Value: timestamp (Date.now())
170
- */
171
- mutationTimestamps = new Map();
172
- /**
173
- * In-memory map tracking table-level mutation timestamps.
174
- * Used for coarse invalidation during large bootstrap snapshots to avoid
175
- * storing timestamps for every row.
176
- */
177
- tableMutationTimestamps = new Map();
178
- /**
179
- * In-memory presence state by scope key.
180
- * Updated via realtime presence events.
181
- */
182
- presenceByScopeKey = new Map();
183
- constructor(config) {
184
- this.config = config;
185
- this.listeners = new Map();
186
- this.state = this.createInitialState();
187
- this.transportHealth = {
188
- mode: this.state.transportMode === 'polling' ? 'polling' : 'disconnected',
189
- connected: false,
190
- lastSuccessfulPollAt: null,
191
- lastRealtimeMessageAt: null,
192
- fallbackReason: null,
193
- };
194
- }
195
- /**
196
- * Get mutation timestamp for a row (used by query hooks for fingerprinting).
197
- * Returns 0 if row has no recorded mutation timestamp.
198
- */
199
- getMutationTimestamp(table, rowId) {
200
- const rowTs = this.mutationTimestamps.get(`${table}:${rowId}`) ?? 0;
201
- const tableTs = this.tableMutationTimestamps.get(table) ?? 0;
202
- return Math.max(rowTs, tableTs);
203
- }
204
- /**
205
- * Get presence entries for a scope key.
206
- * Returns empty array if no presence data for the scope.
207
- */
208
- getPresence(scopeKey) {
209
- return (this.presenceByScopeKey.get(scopeKey) ??
210
- []);
211
- }
212
- /**
213
- * Update presence for a scope key (called by realtime transport).
214
- * Emits presence:change event for listeners.
215
- */
216
- updatePresence(scopeKey, presence) {
217
- this.presenceByScopeKey.set(scopeKey, presence);
218
- this.emit('presence:change', { scopeKey, presence });
219
- }
220
- /**
221
- * Join presence for a scope key.
222
- * Sends via transport (if available) and updates local state optimistically.
223
- */
224
- joinPresence(scopeKey, metadata) {
225
- if (isRealtimeTransport(this.config.transport)) {
226
- const transport = this.config.transport;
227
- transport.sendPresenceJoin?.(scopeKey, metadata);
228
- }
229
- // Optimistic local update
230
- this.handlePresenceEvent({
231
- action: 'join',
232
- scopeKey,
233
- clientId: this.config.clientId,
234
- actorId: this.config.actorId,
235
- metadata,
236
- });
237
- }
238
- /**
239
- * Leave presence for a scope key.
240
- */
241
- leavePresence(scopeKey) {
242
- if (isRealtimeTransport(this.config.transport)) {
243
- const transport = this.config.transport;
244
- transport.sendPresenceLeave?.(scopeKey);
245
- }
246
- this.handlePresenceEvent({
247
- action: 'leave',
248
- scopeKey,
249
- clientId: this.config.clientId,
250
- actorId: this.config.actorId,
251
- });
252
- }
253
- /**
254
- * Update presence metadata for a scope key.
255
- */
256
- updatePresenceMetadata(scopeKey, metadata) {
257
- if (isRealtimeTransport(this.config.transport)) {
258
- const transport = this.config.transport;
259
- transport.sendPresenceUpdate?.(scopeKey, metadata);
260
- }
261
- this.handlePresenceEvent({
262
- action: 'update',
263
- scopeKey,
264
- clientId: this.config.clientId,
265
- actorId: this.config.actorId,
266
- metadata,
267
- });
268
- }
269
- /**
270
- * Handle a single presence event (join/leave/update).
271
- * Updates the in-memory presence state and emits change event.
272
- */
273
- handlePresenceEvent(event) {
274
- const current = this.presenceByScopeKey.get(event.scopeKey) ?? [];
275
- let updated;
276
- switch (event.action) {
277
- case 'join':
278
- // Add new entry (remove existing if present to update)
279
- updated = [
280
- ...current.filter((e) => e.clientId !== event.clientId),
281
- {
282
- clientId: event.clientId,
283
- actorId: event.actorId,
284
- joinedAt: Date.now(),
285
- metadata: event.metadata,
286
- },
287
- ];
288
- break;
289
- case 'leave':
290
- updated = current.filter((e) => e.clientId !== event.clientId);
291
- break;
292
- case 'update':
293
- updated = current.map((e) => e.clientId === event.clientId ? { ...e, metadata: event.metadata } : e);
294
- break;
295
- }
296
- this.presenceByScopeKey.set(event.scopeKey, updated);
297
- this.emit('presence:change', {
298
- scopeKey: event.scopeKey,
299
- presence: updated,
300
- });
301
- }
302
- createInitialState() {
303
- const enabled = this.isEnabled();
304
- return {
305
- enabled,
306
- isSyncing: false,
307
- connectionState: enabled ? 'disconnected' : 'disconnected',
308
- transportMode: this.detectTransportMode(),
309
- lastSyncAt: null,
310
- error: null,
311
- pendingCount: 0,
312
- retryCount: 0,
313
- isRetrying: false,
314
- };
315
- }
316
- isEnabled() {
317
- const { actorId, clientId } = this.config;
318
- return (typeof actorId === 'string' &&
319
- actorId.length > 0 &&
320
- typeof clientId === 'string' &&
321
- clientId.length > 0);
322
- }
323
- detectTransportMode() {
324
- if (this.config.realtimeEnabled !== false &&
325
- isRealtimeTransport(this.config.transport)) {
326
- return 'realtime';
327
- }
328
- return 'polling';
329
- }
330
- /**
331
- * Get current engine state.
332
- * Returns the same object reference to avoid useSyncExternalStore infinite loops.
333
- */
334
- getState() {
335
- return this.state;
336
- }
337
- /**
338
- * Get transport health details (realtime/polling/fallback).
339
- */
340
- getTransportHealth() {
341
- return this.transportHealth;
342
- }
343
- /**
344
- * Get subscription state metadata for the current profile.
345
- */
346
- async listSubscriptionStates(args) {
347
- return readSubscriptionStates(this.config.db, {
348
- stateId: args?.stateId ?? this.getStateId(),
349
- table: args?.table,
350
- status: args?.status,
351
- });
352
- }
353
- /**
354
- * Get a single subscription state by id.
355
- */
356
- async getSubscriptionState(subscriptionId, options) {
357
- return readSubscriptionState(this.config.db, {
358
- stateId: options?.stateId ?? this.getStateId(),
359
- subscriptionId,
360
- });
361
- }
362
- /**
363
- * Get normalized progress for all active subscriptions in this state profile.
364
- */
365
- async getProgress() {
366
- const subscriptions = await this.listSubscriptionStates();
367
- const progress = subscriptions.map((sub) => this.mapSubscriptionToProgress(sub));
368
- const channelPhase = this.resolveChannelPhase(progress);
369
- const hasSubscriptions = progress.length > 0;
370
- const basePercent = hasSubscriptions
371
- ? Math.round(progress.reduce((sum, item) => sum + item.progressPercent, 0) /
372
- progress.length)
373
- : this.state.lastSyncAt !== null
374
- ? 100
375
- : 0;
376
- const progressPercent = channelPhase === 'live'
377
- ? 100
378
- : Math.max(0, Math.min(100, Math.trunc(basePercent)));
379
- return {
380
- channelPhase,
381
- progressPercent,
382
- subscriptions: progress,
383
- };
384
- }
385
- /**
386
- * Wait until the channel reaches a target phase.
387
- */
388
- async awaitPhase(phase, options = {}) {
389
- const timeoutMs = Math.max(0, options.timeoutMs ?? DEFAULT_AWAIT_TIMEOUT_MS);
390
- const deadline = Date.now() + timeoutMs;
391
- while (true) {
392
- const progress = await this.getProgress();
393
- if (progress.channelPhase === phase) {
394
- return progress;
395
- }
396
- if (progress.channelPhase === 'error') {
397
- const message = this.state.error?.message ?? 'Sync entered error state';
398
- throw new Error(`[SyncEngine.awaitPhase] Failed while waiting for "${phase}": ${message}`);
399
- }
400
- const remainingMs = deadline - Date.now();
401
- if (remainingMs <= 0) {
402
- throw new Error(`[SyncEngine.awaitPhase] Timed out after ${timeoutMs}ms waiting for phase "${phase}"`);
403
- }
404
- await this.waitForProgressSignal(remainingMs);
405
- }
406
- }
407
- /**
408
- * Wait until bootstrap finishes for a state or a specific subscription.
409
- */
410
- async awaitBootstrapComplete(options = {}) {
411
- const timeoutMs = Math.max(0, options.timeoutMs ?? DEFAULT_AWAIT_TIMEOUT_MS);
412
- const stateId = options.stateId ?? this.getStateId();
413
- const deadline = Date.now() + timeoutMs;
414
- while (true) {
415
- const states = await this.listSubscriptionStates({ stateId });
416
- const relevantStates = options.subscriptionId === undefined
417
- ? states
418
- : states.filter((state) => state.subscriptionId === options.subscriptionId);
419
- const hasPendingBootstrap = relevantStates.some((state) => state.status === 'active' && state.bootstrapState !== null);
420
- if (!hasPendingBootstrap) {
421
- return this.getProgress();
422
- }
423
- if (this.state.error) {
424
- throw new Error(`[SyncEngine.awaitBootstrapComplete] Failed while waiting for bootstrap completion: ${this.state.error.message}`);
425
- }
426
- const remainingMs = deadline - Date.now();
427
- if (remainingMs <= 0) {
428
- const target = options.subscriptionId === undefined
429
- ? `state "${stateId}"`
430
- : `subscription "${options.subscriptionId}" in state "${stateId}"`;
431
- throw new Error(`[SyncEngine.awaitBootstrapComplete] Timed out after ${timeoutMs}ms waiting for ${target}`);
432
- }
433
- await this.waitForProgressSignal(remainingMs);
434
- }
435
- }
436
- /**
437
- * Get a diagnostics snapshot suitable for debug UIs and bug reports.
438
- */
439
- async getDiagnostics() {
440
- const [subscriptions, progress, outbox, conflicts] = await Promise.all([
441
- this.listSubscriptionStates(),
442
- this.getProgress(),
443
- this.refreshOutboxStats({ emit: false }),
444
- this.getConflicts(),
445
- ]);
446
- return {
447
- timestamp: Date.now(),
448
- state: this.state,
449
- transport: this.transportHealth,
450
- progress,
451
- outbox,
452
- conflictCount: conflicts.length,
453
- subscriptions,
454
- };
455
- }
456
- /**
457
- * Get a serializable inspector snapshot for app debug UIs and support tooling.
458
- */
459
- async getInspectorSnapshot(options = {}) {
460
- const diagnostics = await this.getDiagnostics();
461
- const requestedLimit = options.eventLimit ?? DEFAULT_INSPECTOR_EVENT_LIMIT;
462
- const eventLimit = Math.max(0, Math.min(MAX_INSPECTOR_EVENT_LIMIT, requestedLimit));
463
- const recentEvents = eventLimit === 0 ? [] : this.inspectorEvents.slice(-eventLimit);
464
- return {
465
- version: 1,
466
- generatedAt: Date.now(),
467
- diagnostics: serializeInspectorRecord(diagnostics),
468
- recentEvents,
469
- };
470
- }
471
- /**
472
- * Get database instance
473
- */
474
- getDb() {
475
- return this.config.db;
476
- }
477
- /**
478
- * Get current actor id (sync scoping).
479
- */
480
- getActorId() {
481
- return this.config.actorId;
482
- }
483
- /**
484
- * Get current client id (device/app install id).
485
- */
486
- getClientId() {
487
- return this.config.clientId;
488
- }
489
- getStateId() {
490
- return this.config.stateId ?? DEFAULT_SYNC_STATE_ID;
491
- }
492
- makeBootstrapKey(stateId, subscriptionId) {
493
- return `${stateId}:${subscriptionId}`;
494
- }
495
- updateTransportHealth(partial) {
496
- this.transportHealth = {
497
- ...this.transportHealth,
498
- ...partial,
499
- };
500
- this.emit('state:change', {});
501
- }
502
- waitForProgressSignal(timeoutMs) {
503
- return new Promise((resolve) => {
504
- const cleanups = [];
505
- let settled = false;
506
- const finish = () => {
507
- if (settled)
508
- return;
509
- settled = true;
510
- clearTimeout(timeoutId);
511
- for (const cleanup of cleanups)
512
- cleanup();
513
- resolve();
514
- };
515
- const listen = (event) => {
516
- cleanups.push(this.on(event, finish));
517
- };
518
- listen('sync:start');
519
- listen('sync:complete');
520
- listen('sync:error');
521
- listen('sync:live');
522
- listen('bootstrap:start');
523
- listen('bootstrap:progress');
524
- listen('bootstrap:complete');
525
- const timeoutId = setTimeout(finish, Math.max(1, timeoutMs));
526
- });
527
- }
528
- mapSubscriptionToProgress(subscription) {
529
- if (subscription.status === 'revoked') {
530
- return {
531
- stateId: subscription.stateId,
532
- id: subscription.subscriptionId,
533
- table: subscription.table,
534
- phase: 'error',
535
- progressPercent: 0,
536
- startedAt: subscription.createdAt,
537
- completedAt: subscription.updatedAt,
538
- lastErrorCode: 'SUBSCRIPTION_REVOKED',
539
- lastErrorMessage: 'Subscription is revoked',
540
- };
541
- }
542
- if (subscription.bootstrapState) {
543
- const tableCount = Math.max(0, subscription.bootstrapState.tables.length);
544
- const tableIndex = Math.max(0, subscription.bootstrapState.tableIndex);
545
- const tablesProcessed = Math.min(tableCount, tableIndex);
546
- const progressPercent = tableCount === 0
547
- ? 0
548
- : Math.max(0, Math.min(100, Math.round((tablesProcessed / tableCount) * 100)));
549
- return {
550
- stateId: subscription.stateId,
551
- id: subscription.subscriptionId,
552
- table: subscription.table,
553
- phase: 'bootstrapping',
554
- progressPercent,
555
- tablesProcessed,
556
- tablesTotal: tableCount,
557
- startedAt: this.bootstrapStartedAt.get(this.makeBootstrapKey(subscription.stateId, subscription.subscriptionId)),
558
- };
559
- }
560
- if (this.state.error) {
561
- return {
562
- stateId: subscription.stateId,
563
- id: subscription.subscriptionId,
564
- table: subscription.table,
565
- phase: 'error',
566
- progressPercent: subscription.cursor >= 0 ? 100 : 0,
567
- startedAt: subscription.createdAt,
568
- lastErrorCode: this.state.error.code,
569
- lastErrorMessage: this.state.error.message,
570
- };
571
- }
572
- if (this.state.isSyncing) {
573
- return {
574
- stateId: subscription.stateId,
575
- id: subscription.subscriptionId,
576
- table: subscription.table,
577
- phase: 'catching_up',
578
- progressPercent: subscription.cursor >= 0 ? 90 : 0,
579
- startedAt: subscription.createdAt,
580
- };
581
- }
582
- if (subscription.cursor >= 0 || this.state.lastSyncAt !== null) {
583
- return {
584
- stateId: subscription.stateId,
585
- id: subscription.subscriptionId,
586
- table: subscription.table,
587
- phase: 'live',
588
- progressPercent: 100,
589
- startedAt: subscription.createdAt,
590
- completedAt: subscription.updatedAt,
591
- };
592
- }
593
- return {
594
- stateId: subscription.stateId,
595
- id: subscription.subscriptionId,
596
- table: subscription.table,
597
- phase: 'idle',
598
- progressPercent: 0,
599
- startedAt: subscription.createdAt,
600
- };
601
- }
602
- resolveChannelPhase(subscriptions) {
603
- if (this.state.error)
604
- return 'error';
605
- if (subscriptions.some((sub) => sub.phase === 'error'))
606
- return 'error';
607
- if (subscriptions.some((sub) => sub.phase === 'bootstrapping')) {
608
- return 'bootstrapping';
609
- }
610
- if (this.state.isSyncing) {
611
- return this.state.lastSyncAt === null ? 'starting' : 'catching_up';
612
- }
613
- if (this.state.lastSyncAt !== null)
614
- return 'live';
615
- return 'idle';
616
- }
617
- deriveProgressFromPullSubscription(sub) {
618
- const stateId = this.getStateId();
619
- const key = this.makeBootstrapKey(stateId, sub.id);
620
- const startedAt = this.bootstrapStartedAt.get(key);
621
- if (sub.status === 'revoked') {
622
- return {
623
- stateId,
624
- id: sub.id,
625
- phase: 'error',
626
- progressPercent: 0,
627
- startedAt,
628
- completedAt: Date.now(),
629
- lastErrorCode: 'SUBSCRIPTION_REVOKED',
630
- lastErrorMessage: 'Subscription is revoked',
631
- };
632
- }
633
- if (sub.bootstrap && sub.bootstrapState) {
634
- const tableCount = Math.max(0, sub.bootstrapState.tables.length);
635
- const tableIndex = Math.max(0, sub.bootstrapState.tableIndex);
636
- const tablesProcessed = Math.min(tableCount, tableIndex);
637
- const progressPercent = tableCount === 0
638
- ? 0
639
- : Math.max(0, Math.min(100, Math.round((tablesProcessed / tableCount) * 100)));
640
- return {
641
- stateId,
642
- id: sub.id,
643
- phase: 'bootstrapping',
644
- progressPercent,
645
- tablesProcessed,
646
- tablesTotal: tableCount,
647
- startedAt,
648
- };
649
- }
650
- return {
651
- stateId,
652
- id: sub.id,
653
- phase: this.state.isSyncing ? 'catching_up' : 'live',
654
- progressPercent: this.state.isSyncing ? 90 : 100,
655
- startedAt,
656
- completedAt: this.state.isSyncing ? undefined : Date.now(),
657
- };
658
- }
659
- handleBootstrapLifecycle(response) {
660
- const stateId = this.getStateId();
661
- const now = Date.now();
662
- const seenKeys = new Set();
663
- for (const sub of response.subscriptions ?? []) {
664
- const key = this.makeBootstrapKey(stateId, sub.id);
665
- seenKeys.add(key);
666
- const isBootstrapping = sub.bootstrap === true;
667
- const wasBootstrapping = this.activeBootstrapSubscriptions.has(key);
668
- if (isBootstrapping && !wasBootstrapping) {
669
- this.activeBootstrapSubscriptions.add(key);
670
- this.bootstrapStartedAt.set(key, now);
671
- this.emit('bootstrap:start', {
672
- timestamp: now,
673
- stateId,
674
- subscriptionId: sub.id,
675
- });
676
- }
677
- if (isBootstrapping) {
678
- this.emit('bootstrap:progress', {
679
- timestamp: now,
680
- stateId,
681
- subscriptionId: sub.id,
682
- progress: this.deriveProgressFromPullSubscription(sub),
683
- });
684
- }
685
- if (!isBootstrapping && wasBootstrapping) {
686
- const startedAt = this.bootstrapStartedAt.get(key) ?? now;
687
- this.activeBootstrapSubscriptions.delete(key);
688
- this.bootstrapStartedAt.delete(key);
689
- this.emit('bootstrap:complete', {
690
- timestamp: now,
691
- stateId,
692
- subscriptionId: sub.id,
693
- durationMs: Math.max(0, now - startedAt),
694
- });
695
- }
696
- }
697
- for (const key of Array.from(this.activeBootstrapSubscriptions)) {
698
- if (seenKeys.has(key))
699
- continue;
700
- if (!key.startsWith(`${stateId}:`))
701
- continue;
702
- const subscriptionId = key.slice(stateId.length + 1);
703
- if (!subscriptionId)
704
- continue;
705
- const startedAt = this.bootstrapStartedAt.get(key) ?? now;
706
- this.activeBootstrapSubscriptions.delete(key);
707
- this.bootstrapStartedAt.delete(key);
708
- this.emit('bootstrap:complete', {
709
- timestamp: now,
710
- stateId,
711
- subscriptionId,
712
- durationMs: Math.max(0, now - startedAt),
713
- });
714
- }
715
- if (this.activeBootstrapSubscriptions.size === 0 && !this.state.error) {
716
- this.emit('sync:live', { timestamp: now });
717
- }
718
- }
719
- async resolveResetTargets(options) {
720
- const stateId = options.stateId ?? this.getStateId();
721
- if (options.scope === 'all') {
722
- return readSubscriptionStates(this.config.db);
723
- }
724
- if (options.scope === 'state') {
725
- return readSubscriptionStates(this.config.db, { stateId });
726
- }
727
- const subscriptionIds = options.subscriptionIds ?? [];
728
- if (subscriptionIds.length === 0) {
729
- throw new Error('[SyncEngine.reset] subscriptionIds is required when scope="subscription"');
730
- }
731
- const allInState = await readSubscriptionStates(this.config.db, {
732
- stateId,
733
- });
734
- const wanted = new Set(subscriptionIds);
735
- return allInState.filter((state) => wanted.has(state.subscriptionId));
736
- }
737
- async clearSyncedTablesForReset(trx, options, targets) {
738
- const clearedTables = [];
739
- if (!options.clearSyncedTables) {
740
- return clearedTables;
741
- }
742
- if (options.scope === 'all') {
743
- for (const handler of this.config.handlers) {
744
- await handler.clearAll({ trx, scopes: {} });
745
- clearedTables.push(handler.table);
746
- }
747
- return clearedTables;
748
- }
749
- const seen = new Set();
750
- for (const target of targets) {
751
- const handler = getClientHandler(this.config.handlers, target.table);
752
- if (!handler)
753
- continue;
754
- const key = `${target.table}:${JSON.stringify(target.scopes)}`;
755
- if (seen.has(key))
756
- continue;
757
- seen.add(key);
758
- await handler.clearAll({ trx, scopes: target.scopes });
759
- clearedTables.push(target.table);
760
- }
761
- return clearedTables;
762
- }
763
- async reset(options) {
764
- const resetOptions = {
765
- clearOutbox: false,
766
- clearConflicts: false,
767
- clearSyncedTables: false,
768
- ...options,
769
- };
770
- const targets = await this.resolveResetTargets(resetOptions);
771
- const stateId = resetOptions.stateId ?? this.getStateId();
772
- this.stop();
773
- const result = await this.config.db.transaction().execute(async (trx) => {
774
- const clearedTables = await this.clearSyncedTablesForReset(trx, resetOptions, targets);
775
- let deletedSubscriptionStates = 0;
776
- if (resetOptions.scope === 'all') {
777
- const res = await sql `
778
- delete from ${sql.table('sync_subscription_state')}
779
- `.execute(trx);
780
- deletedSubscriptionStates = Number(res.numAffectedRows ?? 0);
781
- }
782
- else if (resetOptions.scope === 'state') {
783
- const res = await sql `
784
- delete from ${sql.table('sync_subscription_state')}
785
- where ${sql.ref('state_id')} = ${sql.val(stateId)}
786
- `.execute(trx);
787
- deletedSubscriptionStates = Number(res.numAffectedRows ?? 0);
788
- }
789
- else {
790
- const subscriptionIds = resetOptions.subscriptionIds ?? [];
791
- const res = await sql `
792
- delete from ${sql.table('sync_subscription_state')}
793
- where
794
- ${sql.ref('state_id')} = ${sql.val(stateId)}
795
- and ${sql.ref('subscription_id')} in (${sql.join(subscriptionIds.map((id) => sql.val(id)))})
796
- `.execute(trx);
797
- deletedSubscriptionStates = Number(res.numAffectedRows ?? 0);
798
- }
799
- let deletedOutboxCommits = 0;
800
- if (resetOptions.clearOutbox) {
801
- const res = await sql `
802
- delete from ${sql.table('sync_outbox_commits')}
803
- `.execute(trx);
804
- deletedOutboxCommits = Number(res.numAffectedRows ?? 0);
805
- }
806
- let deletedConflicts = 0;
807
- if (resetOptions.clearConflicts) {
808
- const res = await sql `
809
- delete from ${sql.table('sync_conflicts')}
810
- `.execute(trx);
811
- deletedConflicts = Number(res.numAffectedRows ?? 0);
812
- }
813
- return {
814
- deletedSubscriptionStates,
815
- deletedOutboxCommits,
816
- deletedConflicts,
817
- clearedTables,
818
- };
819
- });
820
- if (resetOptions.scope === 'all') {
821
- this.activeBootstrapSubscriptions.clear();
822
- this.bootstrapStartedAt.clear();
823
- }
824
- else {
825
- for (const target of targets) {
826
- const key = this.makeBootstrapKey(target.stateId, target.subscriptionId);
827
- this.activeBootstrapSubscriptions.delete(key);
828
- this.bootstrapStartedAt.delete(key);
829
- }
830
- }
831
- this.resetLocalState();
832
- await this.refreshOutboxStats();
833
- this.updateState({ error: null });
834
- return result;
835
- }
836
- async repair(options) {
837
- if (options.mode !== 'rebootstrap-missing-chunks') {
838
- throw new Error(`[SyncEngine.repair] Unsupported repair mode: ${options.mode}`);
839
- }
840
- return this.reset({
841
- scope: options.subscriptionIds ? 'subscription' : 'state',
842
- stateId: options.stateId,
843
- subscriptionIds: options.subscriptionIds,
844
- clearOutbox: options.clearOutbox ?? false,
845
- clearConflicts: options.clearConflicts ?? false,
846
- clearSyncedTables: true,
847
- });
848
- }
849
- /**
850
- * Subscribe to sync events
851
- */
852
- on(event, listener) {
853
- if (!this.listeners.has(event)) {
854
- this.listeners.set(event, new Set());
855
- }
856
- const wrapped = (payload) => {
857
- listener(payload);
858
- };
859
- this.listeners.get(event).add(wrapped);
860
- return () => {
861
- this.listeners.get(event)?.delete(wrapped);
862
- };
863
- }
864
- /**
865
- * Subscribe to any state change (for useSyncExternalStore)
866
- */
867
- subscribe(callback) {
868
- // Subscribe to state:change which is emitted by updateState()
869
- return this.on('state:change', callback);
870
- }
871
- emit(event, payload) {
872
- this.inspectorEvents.push({
873
- id: this.nextInspectorEventId++,
874
- event,
875
- timestamp: Date.now(),
876
- payload: serializeInspectorRecord(payload),
877
- });
878
- if (this.inspectorEvents.length > MAX_INSPECTOR_EVENT_LIMIT) {
879
- this.inspectorEvents.splice(0, this.inspectorEvents.length - MAX_INSPECTOR_EVENT_LIMIT);
880
- }
881
- const eventListeners = this.listeners.get(event);
882
- if (eventListeners) {
883
- for (const listener of eventListeners) {
884
- try {
885
- listener(payload);
886
- }
887
- catch (err) {
888
- console.error(`[SyncEngine] Error in ${event} listener:`, err);
889
- }
890
- }
891
- }
892
- }
893
- updateState(partial) {
894
- this.state = { ...this.state, ...partial };
895
- // Emit state:change to notify useSyncExternalStore subscribers
896
- this.emit('state:change', {});
897
- }
898
- setConnectionState(state) {
899
- const previous = this.state.connectionState;
900
- if (previous !== state) {
901
- this.updateState({ connectionState: state });
902
- this.emit('connection:change', { previous, current: state });
903
- }
904
- }
905
- /**
906
- * Start the sync engine
907
- */
908
- async start() {
909
- if (this.isDestroyed) {
910
- throw new Error('SyncEngine has been destroyed');
911
- }
912
- if (!this.isEnabled()) {
913
- this.updateState({ enabled: false });
914
- return;
915
- }
916
- this.updateState({ enabled: true });
917
- // Run migrations before first sync.
918
- if (!this.migrated) {
919
- // Best-effort: push pending commits before user migration, because
920
- // app migrations may reset tables and discard unsynced local writes.
921
- if (this.config.migrate) {
922
- try {
923
- const hasOutbox = await sql `
924
- select 1 from ${sql.table('sync_outbox_commits')} limit 1
925
- `
926
- .execute(this.config.db)
927
- .then((r) => r.rows.length > 0)
928
- .catch(() => false);
929
- if (hasOutbox) {
930
- let pushed = true;
931
- while (pushed) {
932
- const result = await syncPushOnce(this.config.db, this.config.transport, {
933
- clientId: this.config.clientId,
934
- actorId: this.config.actorId ?? undefined,
935
- plugins: this.config.plugins,
936
- });
937
- pushed = result.pushed;
938
- }
939
- }
940
- }
941
- catch {
942
- // Best-effort: continue even if pre-migration push fails.
943
- }
944
- }
945
- try {
946
- if (this.config.migrate) {
947
- await this.config.migrate(this.config.db);
948
- }
949
- await ensureClientSyncSchema(this.config.db);
950
- this.migrated = true;
951
- }
952
- catch (err) {
953
- const migrationError = err instanceof Error ? err : new Error(String(err));
954
- this.config.onMigrationError?.(migrationError);
955
- const error = createSyncError({
956
- code: 'MIGRATION_FAILED',
957
- message: 'Migration failed',
958
- cause: migrationError,
959
- retryable: false,
960
- stateId: this.getStateId(),
961
- });
962
- this.updateState({
963
- isSyncing: false,
964
- error,
965
- });
966
- this.handleError(error);
967
- return;
968
- }
969
- }
970
- // Setup transport-specific handling
971
- if (this.state.transportMode === 'realtime') {
972
- this.setupRealtime();
973
- }
974
- else {
975
- this.setupPolling();
976
- }
977
- // Initial sync
978
- await this.sync();
979
- }
980
- /**
981
- * Stop the sync engine (cleanup without destroy)
982
- */
983
- stop() {
984
- this.stopPolling();
985
- this.stopRealtime();
986
- this.setConnectionState('disconnected');
987
- }
988
- /**
989
- * Destroy the engine (cannot be restarted)
990
- */
991
- destroy() {
992
- this.stop();
993
- this.listeners.clear();
994
- this.isDestroyed = true;
995
- if (this.retryTimeoutId) {
996
- clearTimeout(this.retryTimeoutId);
997
- this.retryTimeoutId = null;
998
- }
999
- if (this.realtimeCatchupTimeoutId) {
1000
- clearTimeout(this.realtimeCatchupTimeoutId);
1001
- this.realtimeCatchupTimeoutId = null;
1002
- }
1003
- }
1004
- /**
1005
- * Trigger a manual sync
1006
- */
1007
- async sync(opts) {
1008
- // Dedupe concurrent sync calls
1009
- if (this.syncPromise) {
1010
- // A sync is already in-flight; queue one more run so we don't miss
1011
- // mutations enqueued during the current cycle (important in realtime mode).
1012
- this.syncRequestedWhileRunning = true;
1013
- return this.syncPromise;
1014
- }
1015
- if (!this.isEnabled() ||
1016
- this.isDestroyed ||
1017
- this.state.connectionState === 'disconnected') {
1018
- return {
1019
- success: false,
1020
- pushedCommits: 0,
1021
- pullRounds: 0,
1022
- pullResponse: { ok: true, subscriptions: [] },
1023
- error: createSyncError({
1024
- code: 'SYNC_ERROR',
1025
- message: 'Sync not enabled',
1026
- retryable: false,
1027
- stateId: this.getStateId(),
1028
- }),
1029
- };
1030
- }
1031
- this.syncPromise = this.performSyncLoop(opts?.trigger);
1032
- try {
1033
- return await this.syncPromise;
1034
- }
1035
- finally {
1036
- this.syncPromise = null;
1037
- }
1038
- }
1039
- async performSyncLoop(trigger) {
1040
- let lastResult = {
1041
- success: false,
1042
- pushedCommits: 0,
1043
- pullRounds: 0,
1044
- pullResponse: { ok: true, subscriptions: [] },
1045
- error: createSyncError({
1046
- code: 'SYNC_ERROR',
1047
- message: 'Sync not started',
1048
- retryable: false,
1049
- stateId: this.getStateId(),
1050
- }),
1051
- };
1052
- do {
1053
- this.syncRequestedWhileRunning = false;
1054
- lastResult = await this.performSyncOnce(trigger);
1055
- // After the first iteration, clear trigger context
1056
- trigger = undefined;
1057
- // If the sync failed, let retry logic handle backoff instead of tight looping.
1058
- if (!lastResult.success)
1059
- break;
1060
- } while (this.syncRequestedWhileRunning &&
1061
- !this.isDestroyed &&
1062
- this.isEnabled());
1063
- return lastResult;
1064
- }
1065
- async performSyncOnce(trigger) {
1066
- const timestamp = Date.now();
1067
- const startedAtMs = timestamp;
1068
- const triggerLabel = resolveSyncTriggerLabel(trigger);
1069
- this.updateState({ isSyncing: true });
1070
- this.emit('sync:start', { timestamp });
1071
- countSyncMetric('sync.client.sync.attempts', 1, {
1072
- attributes: { trigger: triggerLabel },
1073
- });
1074
- try {
1075
- const pullApplyTimestamp = Date.now();
1076
- const result = await startSyncSpan({
1077
- name: 'sync.client.sync',
1078
- op: 'sync.client.sync',
1079
- attributes: { trigger: triggerLabel },
1080
- }, () => syncOnce(this.config.db, this.config.transport, this.config.handlers, {
1081
- clientId: this.config.clientId,
1082
- actorId: this.config.actorId ?? undefined,
1083
- plugins: this.config.plugins,
1084
- subscriptions: this.config
1085
- .subscriptions,
1086
- limitCommits: this.config.limitCommits,
1087
- limitSnapshotRows: this.config.limitSnapshotRows,
1088
- maxSnapshotPages: this.config.maxSnapshotPages,
1089
- stateId: this.config.stateId,
1090
- sha256: this.config.sha256,
1091
- trigger,
1092
- }));
1093
- const syncResult = {
1094
- success: true,
1095
- pushedCommits: result.pushedCommits,
1096
- pullRounds: result.pullRounds,
1097
- pullResponse: result.pullResponse,
1098
- };
1099
- // Update fingerprint mutation timestamps for server-applied changes so wa-sqlite
1100
- // query hooks rerender on remote changes (not just local mutations).
1101
- this.recordMutationTimestampsFromPullResponse(result.pullResponse, pullApplyTimestamp);
1102
- this.updateState({
1103
- isSyncing: false,
1104
- lastSyncAt: Date.now(),
1105
- error: null,
1106
- retryCount: 0,
1107
- isRetrying: false,
1108
- });
1109
- this.updateTransportHealth({
1110
- lastSuccessfulPollAt: Date.now(),
1111
- });
1112
- this.emit('sync:complete', {
1113
- timestamp: Date.now(),
1114
- pushedCommits: result.pushedCommits,
1115
- pullRounds: result.pullRounds,
1116
- pullResponse: result.pullResponse,
1117
- });
1118
- // Emit data change for any tables that had changes
1119
- const changedTables = this.extractChangedTables(result.pullResponse);
1120
- if (changedTables.length > 0) {
1121
- this.emit('data:change', {
1122
- scopes: changedTables,
1123
- timestamp: Date.now(),
1124
- });
1125
- this.config.onDataChange?.(changedTables);
1126
- }
1127
- this.handleBootstrapLifecycle(result.pullResponse);
1128
- // Refresh outbox stats (fire-and-forget — don't block sync:complete)
1129
- this.refreshOutboxStats().catch((error) => {
1130
- console.warn('[SyncEngine] Failed to refresh outbox stats after sync:', error);
1131
- });
1132
- const durationMs = Math.max(0, Date.now() - startedAtMs);
1133
- countSyncMetric('sync.client.sync.results', 1, {
1134
- attributes: {
1135
- trigger: triggerLabel,
1136
- status: 'success',
1137
- },
1138
- });
1139
- distributionSyncMetric('sync.client.sync.duration_ms', durationMs, {
1140
- unit: 'millisecond',
1141
- attributes: {
1142
- trigger: triggerLabel,
1143
- status: 'success',
1144
- },
1145
- });
1146
- distributionSyncMetric('sync.client.sync.pushed_commits', result.pushedCommits, {
1147
- attributes: { trigger: triggerLabel },
1148
- });
1149
- distributionSyncMetric('sync.client.sync.pull_rounds', result.pullRounds, {
1150
- attributes: { trigger: triggerLabel },
1151
- });
1152
- return syncResult;
1153
- }
1154
- catch (err) {
1155
- const classified = classifySyncFailure(err);
1156
- const error = createSyncError({
1157
- code: classified.code,
1158
- message: classified.message,
1159
- cause: classified.cause,
1160
- retryable: classified.retryable,
1161
- httpStatus: classified.httpStatus,
1162
- stateId: this.getStateId(),
1163
- });
1164
- this.updateState({
1165
- isSyncing: false,
1166
- error,
1167
- retryCount: this.state.retryCount + 1,
1168
- isRetrying: false,
1169
- });
1170
- this.handleError(error);
1171
- const durationMs = Math.max(0, Date.now() - startedAtMs);
1172
- countSyncMetric('sync.client.sync.results', 1, {
1173
- attributes: {
1174
- trigger: triggerLabel,
1175
- status: 'error',
1176
- },
1177
- });
1178
- distributionSyncMetric('sync.client.sync.duration_ms', durationMs, {
1179
- unit: 'millisecond',
1180
- attributes: {
1181
- trigger: triggerLabel,
1182
- status: 'error',
1183
- },
1184
- });
1185
- captureSyncException(err, {
1186
- event: 'sync.client.sync',
1187
- trigger: triggerLabel,
1188
- });
1189
- // Schedule retry if under max retries
1190
- const maxRetries = this.config.maxRetries ?? DEFAULT_MAX_RETRIES;
1191
- if (error.retryable && this.state.retryCount < maxRetries) {
1192
- this.scheduleRetry();
1193
- }
1194
- return {
1195
- success: false,
1196
- pushedCommits: 0,
1197
- pullRounds: 0,
1198
- pullResponse: { ok: true, subscriptions: [] },
1199
- error,
1200
- };
1201
- }
1202
- }
1203
- extractChangedTables(response) {
1204
- const tables = new Set();
1205
- for (const sub of response.subscriptions ?? []) {
1206
- // Extract tables from snapshots
1207
- for (const snapshot of sub.snapshots ?? []) {
1208
- if (snapshot.table) {
1209
- tables.add(snapshot.table);
1210
- }
1211
- }
1212
- // Extract tables from commits
1213
- for (const commit of sub.commits ?? []) {
1214
- for (const change of commit.changes ?? []) {
1215
- if (change.table) {
1216
- tables.add(change.table);
1217
- }
1218
- }
1219
- }
1220
- }
1221
- return Array.from(tables);
1222
- }
1223
- /**
1224
- * Apply changes delivered inline over WebSocket for instant UI updates.
1225
- * Returns true if changes were applied and cursor updated successfully,
1226
- * false if anything failed (caller should fall back to HTTP sync).
1227
- */
1228
- async applyWsDeliveredChanges(changes, cursor) {
1229
- try {
1230
- await this.config.db.transaction().execute(async (trx) => {
1231
- for (const change of changes) {
1232
- const handler = getClientHandler(this.config.handlers, change.table);
1233
- if (!handler) {
1234
- throw new Error(`Missing client table handler for WS change table "${change.table}"`);
1235
- }
1236
- await handler.applyChange({ trx }, change);
1237
- }
1238
- // Update subscription cursors
1239
- const stateId = this.config.stateId ?? 'default';
1240
- await sql `
1241
- update ${sql.table('sync_subscription_state')}
1242
- set ${sql.ref('cursor')} = ${sql.val(cursor)}
1243
- where ${sql.ref('state_id')} = ${sql.val(stateId)}
1244
- and ${sql.ref('cursor')} < ${sql.val(cursor)}
1245
- `.execute(trx);
1246
- });
1247
- // Update mutation timestamps BEFORE emitting data:change so that
1248
- // React hooks re-querying the DB see fresh fingerprints immediately.
1249
- const now = Date.now();
1250
- for (const change of changes) {
1251
- if (!change.table || !change.row_id)
1252
- continue;
1253
- if (change.op === 'delete') {
1254
- this.mutationTimestamps.delete(`${change.table}:${change.row_id}`);
1255
- }
1256
- else {
1257
- this.bumpMutationTimestamp(change.table, change.row_id, now);
1258
- }
1259
- }
1260
- // Emit data change for immediate UI update
1261
- const changedTables = [...new Set(changes.map((c) => c.table))];
1262
- if (changedTables.length > 0) {
1263
- this.emit('data:change', {
1264
- scopes: changedTables,
1265
- timestamp: Date.now(),
1266
- });
1267
- this.config.onDataChange?.(changedTables);
1268
- }
1269
- return true;
1270
- }
1271
- catch {
1272
- return false;
1273
- }
1274
- }
1275
- /**
1276
- * Handle WS-delivered changes: apply them and decide whether to skip HTTP pull.
1277
- * Falls back to full HTTP sync when conditions require it.
1278
- */
1279
- async handleWsDelivery(changes, cursor) {
1280
- // If a sync is already in-flight, let it handle everything
1281
- if (this.syncPromise) {
1282
- countSyncMetric('sync.client.ws.delivery.events', 1, {
1283
- attributes: { path: 'inflight_sync' },
1284
- });
1285
- this.triggerSyncInBackground({ trigger: 'ws' }, 'ws delivery with in-flight sync');
1286
- return;
1287
- }
1288
- // If there are pending outbox commits, need to push via HTTP
1289
- if (this.state.pendingCount > 0) {
1290
- countSyncMetric('sync.client.ws.delivery.events', 1, {
1291
- attributes: { path: 'pending_outbox' },
1292
- });
1293
- this.triggerSyncInBackground({ trigger: 'ws' }, 'ws delivery with pending outbox');
1294
- return;
1295
- }
1296
- // If afterPull plugins exist, inline WS changes may require transforms
1297
- // (e.g. decryption). Fall back to HTTP sync and do not apply inline payload.
1298
- const hasAfterPullPlugins = this.config.plugins?.some((p) => typeof p.afterPull === 'function');
1299
- if (hasAfterPullPlugins) {
1300
- countSyncMetric('sync.client.ws.delivery.events', 1, {
1301
- attributes: { path: 'after_pull_plugins' },
1302
- });
1303
- this.triggerSyncInBackground({ trigger: 'ws' }, 'ws delivery with afterPull plugins');
1304
- return;
1305
- }
1306
- // Apply changes + update cursor
1307
- const inlineApplyStartedAtMs = Date.now();
1308
- const applied = await this.applyWsDeliveredChanges(changes, cursor);
1309
- const inlineApplyDurationMs = Math.max(0, Date.now() - inlineApplyStartedAtMs);
1310
- distributionSyncMetric('sync.client.ws.inline_apply.duration_ms', inlineApplyDurationMs, {
1311
- unit: 'millisecond',
1312
- });
1313
- if (!applied) {
1314
- countSyncMetric('sync.client.ws.delivery.events', 1, {
1315
- attributes: { path: 'inline_fallback' },
1316
- });
1317
- this.triggerSyncInBackground({ trigger: 'ws' }, 'ws inline apply fallback');
1318
- return;
1319
- }
1320
- // All clear — skip HTTP pull entirely
1321
- countSyncMetric('sync.client.ws.delivery.events', 1, {
1322
- attributes: { path: 'inline_applied' },
1323
- });
1324
- this.updateState({
1325
- lastSyncAt: Date.now(),
1326
- error: null,
1327
- retryCount: 0,
1328
- isRetrying: false,
1329
- });
1330
- this.updateTransportHealth({
1331
- mode: 'realtime',
1332
- connected: true,
1333
- fallbackReason: null,
1334
- lastSuccessfulPollAt: Date.now(),
1335
- });
1336
- this.emit('sync:complete', {
1337
- timestamp: Date.now(),
1338
- pushedCommits: 0,
1339
- pullRounds: 0,
1340
- pullResponse: { ok: true, subscriptions: [] },
1341
- });
1342
- this.emit('sync:live', { timestamp: Date.now() });
1343
- this.refreshOutboxStats().catch((error) => {
1344
- console.warn('[SyncEngine] Failed to refresh outbox stats after WS apply:', error);
1345
- });
1346
- }
1347
- timestampCounter = 0;
1348
- nextPreciseTimestamp(now) {
1349
- // Use sub-millisecond precision by combining timestamp with atomic counter
1350
- // This prevents race conditions in concurrent mutations while maintaining
1351
- // millisecond-level compatibility with existing code.
1352
- return now + (this.timestampCounter++ % 1000) / 1000;
1353
- }
1354
- bumpMutationTimestamp(table, rowId, now) {
1355
- const key = `${table}:${rowId}`;
1356
- const preciseNow = this.nextPreciseTimestamp(now);
1357
- const prev = this.mutationTimestamps.get(key) ?? 0;
1358
- this.mutationTimestamps.set(key, Math.max(preciseNow, prev + 0.001));
1359
- }
1360
- bumpTableMutationTimestamp(table, now) {
1361
- const preciseNow = this.nextPreciseTimestamp(now);
1362
- const prev = this.tableMutationTimestamps.get(table) ?? 0;
1363
- this.tableMutationTimestamps.set(table, Math.max(preciseNow, prev + 0.001));
1364
- }
1365
- /**
1366
- * Record local mutations that were already applied to the DB.
1367
- *
1368
- * This updates in-memory mutation timestamps (for fingerprint-based rerenders),
1369
- * and emits a single `data:change` event for the affected tables.
1370
- *
1371
- * This is intentionally separate from applyLocalMutation() so callers that
1372
- * perform their own DB transactions (e.g. `useMutations`) can still keep UI
1373
- * updates correct without double-writing.
1374
- */
1375
- recordLocalMutations(inputs, now = Date.now()) {
1376
- const affectedTables = new Set();
1377
- for (const input of inputs) {
1378
- if (!input.table || !input.rowId)
1379
- continue;
1380
- affectedTables.add(input.table);
1381
- if (input.op === 'delete') {
1382
- this.mutationTimestamps.delete(`${input.table}:${input.rowId}`);
1383
- continue;
1384
- }
1385
- this.bumpMutationTimestamp(input.table, input.rowId, now);
1386
- }
1387
- if (affectedTables.size > 0) {
1388
- this.emit('data:change', {
1389
- scopes: Array.from(affectedTables),
1390
- timestamp: Date.now(),
1391
- });
1392
- this.config.onDataChange?.(Array.from(affectedTables));
1393
- }
1394
- }
1395
- recordMutationTimestampsFromPullResponse(response, now) {
1396
- for (const sub of response.subscriptions ?? []) {
1397
- // Mark snapshot tables as changed so bootstrap/resnapshot updates
1398
- // propagate without storing per-row timestamps for massive snapshots.
1399
- for (const snapshot of sub.snapshots ?? []) {
1400
- if (!snapshot.table)
1401
- continue;
1402
- this.bumpTableMutationTimestamp(snapshot.table, now);
1403
- }
1404
- for (const commit of sub.commits ?? []) {
1405
- for (const change of commit.changes ?? []) {
1406
- const table = change.table;
1407
- const rowId = change.row_id;
1408
- if (!table || !rowId)
1409
- continue;
1410
- if (change.op === 'delete') {
1411
- this.mutationTimestamps.delete(`${table}:${rowId}`);
1412
- }
1413
- else {
1414
- this.bumpMutationTimestamp(table, rowId, now);
1415
- }
1416
- }
1417
- }
1418
- }
1419
- }
1420
- scheduleRetry() {
1421
- if (this.retryTimeoutId) {
1422
- clearTimeout(this.retryTimeoutId);
1423
- }
1424
- const delay = calculateRetryDelay(this.state.retryCount);
1425
- if (this.state.pendingCount > 0) {
1426
- countSyncMetric('sync.outbox.retry_count', 1, {
1427
- attributes: {
1428
- retryCount: this.state.retryCount,
1429
- },
1430
- });
1431
- }
1432
- this.updateState({ isRetrying: true });
1433
- this.retryTimeoutId = setTimeout(() => {
1434
- this.retryTimeoutId = null;
1435
- if (!this.isDestroyed) {
1436
- this.triggerSyncInBackground(undefined, 'retry timer');
1437
- }
1438
- }, delay);
1439
- }
1440
- handleError(error) {
1441
- this.emit('sync:error', error);
1442
- this.config.onError?.(error);
1443
- }
1444
- triggerSyncInBackground(opts, reason = 'background') {
1445
- void this.sync(opts).catch((error) => {
1446
- console.error(`[SyncEngine] Unexpected sync failure during ${reason}:`, error);
1447
- });
1448
- }
1449
- setupPolling() {
1450
- this.stopPolling();
1451
- const interval = this.config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
1452
- this.pollerId = setInterval(() => {
1453
- if (!this.state.isSyncing && !this.isDestroyed) {
1454
- this.triggerSyncInBackground(undefined, 'polling interval');
1455
- }
1456
- }, interval);
1457
- this.setConnectionState('connected');
1458
- this.updateTransportHealth({
1459
- mode: 'polling',
1460
- connected: true,
1461
- fallbackReason: null,
1462
- });
1463
- }
1464
- stopPolling() {
1465
- if (this.pollerId) {
1466
- clearInterval(this.pollerId);
1467
- this.pollerId = null;
1468
- }
1469
- }
1470
- setupRealtime() {
1471
- if (!isRealtimeTransport(this.config.transport)) {
1472
- console.warn('[SyncEngine] realtimeEnabled=true but transport does not support realtime. Falling back to polling.');
1473
- this.updateState({ transportMode: 'polling' });
1474
- this.setupPolling();
1475
- return;
1476
- }
1477
- this.setConnectionState('connecting');
1478
- this.updateTransportHealth({
1479
- mode: 'disconnected',
1480
- connected: false,
1481
- fallbackReason: null,
1482
- });
1483
- const transport = this.config.transport;
1484
- // Wire up presence events if transport supports them
1485
- if (transport.onPresenceEvent) {
1486
- this.realtimePresenceUnsub = transport.onPresenceEvent((event) => {
1487
- if (event.action === 'snapshot' && event.entries) {
1488
- this.updatePresence(event.scopeKey, event.entries);
1489
- }
1490
- else if (event.action === 'join' ||
1491
- event.action === 'leave' ||
1492
- event.action === 'update') {
1493
- this.handlePresenceEvent({
1494
- action: event.action,
1495
- scopeKey: event.scopeKey,
1496
- clientId: event.clientId ?? '',
1497
- actorId: event.actorId ?? '',
1498
- metadata: event.metadata,
1499
- });
1500
- }
1501
- });
1502
- }
1503
- this.realtimeDisconnect = transport.connect({ clientId: this.config.clientId }, (event) => {
1504
- if (event.event === 'sync') {
1505
- this.updateTransportHealth({
1506
- lastRealtimeMessageAt: Date.now(),
1507
- });
1508
- countSyncMetric('sync.client.ws.events', 1, {
1509
- attributes: { type: 'sync' },
1510
- });
1511
- const hasInlineChanges = Array.isArray(event.data.changes) && event.data.changes.length > 0;
1512
- const cursor = event.data.cursor;
1513
- if (hasInlineChanges && typeof cursor === 'number') {
1514
- // WS delivered changes + cursor — may skip HTTP pull
1515
- this.handleWsDelivery(event.data.changes, cursor);
1516
- }
1517
- else {
1518
- // Cursor-only wake-up or no cursor — must HTTP sync
1519
- countSyncMetric('sync.client.ws.delivery.events', 1, {
1520
- attributes: { path: 'cursor_wakeup' },
1521
- });
1522
- this.triggerSyncInBackground({ trigger: 'ws' }, 'ws cursor wakeup');
1523
- }
1524
- }
1525
- }, (state) => {
1526
- switch (state) {
1527
- case 'connected': {
1528
- const wasConnectedBefore = this.hasRealtimeConnectedOnce;
1529
- this.hasRealtimeConnectedOnce = true;
1530
- if (wasConnectedBefore) {
1531
- countSyncMetric('sync.transport.reconnects', 1, {
1532
- attributes: {
1533
- source: 'client',
1534
- },
1535
- });
1536
- }
1537
- this.setConnectionState('connected');
1538
- this.updateTransportHealth({
1539
- mode: 'realtime',
1540
- connected: true,
1541
- fallbackReason: null,
1542
- });
1543
- this.stopFallbackPolling();
1544
- this.triggerSyncInBackground(undefined, 'realtime connected state');
1545
- if (wasConnectedBefore) {
1546
- this.scheduleRealtimeReconnectCatchupSync();
1547
- }
1548
- break;
1549
- }
1550
- case 'connecting':
1551
- this.setConnectionState('connecting');
1552
- this.updateTransportHealth({
1553
- mode: 'disconnected',
1554
- connected: false,
1555
- });
1556
- break;
1557
- case 'disconnected':
1558
- this.setConnectionState('reconnecting');
1559
- this.updateTransportHealth({
1560
- mode: 'disconnected',
1561
- connected: false,
1562
- });
1563
- this.startFallbackPolling();
1564
- break;
1565
- }
1566
- });
1567
- }
1568
- stopRealtime() {
1569
- if (this.realtimeCatchupTimeoutId) {
1570
- clearTimeout(this.realtimeCatchupTimeoutId);
1571
- this.realtimeCatchupTimeoutId = null;
1572
- }
1573
- if (this.realtimePresenceUnsub) {
1574
- this.realtimePresenceUnsub();
1575
- this.realtimePresenceUnsub = null;
1576
- }
1577
- if (this.realtimeDisconnect) {
1578
- this.realtimeDisconnect();
1579
- this.realtimeDisconnect = null;
1580
- }
1581
- this.stopFallbackPolling();
1582
- this.updateTransportHealth({
1583
- mode: 'disconnected',
1584
- connected: false,
1585
- });
1586
- }
1587
- scheduleRealtimeReconnectCatchupSync() {
1588
- if (this.realtimeCatchupTimeoutId) {
1589
- clearTimeout(this.realtimeCatchupTimeoutId);
1590
- }
1591
- this.realtimeCatchupTimeoutId = setTimeout(() => {
1592
- this.realtimeCatchupTimeoutId = null;
1593
- if (this.isDestroyed || !this.isEnabled())
1594
- return;
1595
- if (this.state.connectionState !== 'connected')
1596
- return;
1597
- this.triggerSyncInBackground(undefined, 'realtime reconnect catchup');
1598
- }, REALTIME_RECONNECT_CATCHUP_DELAY_MS);
1599
- }
1600
- startFallbackPolling() {
1601
- if (this.fallbackPollerId)
1602
- return;
1603
- const interval = this.config.realtimeFallbackPollMs ?? 30_000;
1604
- this.updateTransportHealth({
1605
- mode: 'polling',
1606
- connected: false,
1607
- fallbackReason: 'network',
1608
- });
1609
- this.fallbackPollerId = setInterval(() => {
1610
- if (!this.state.isSyncing && !this.isDestroyed) {
1611
- this.triggerSyncInBackground(undefined, 'realtime fallback poll');
1612
- }
1613
- }, interval);
1614
- }
1615
- stopFallbackPolling() {
1616
- if (this.fallbackPollerId) {
1617
- clearInterval(this.fallbackPollerId);
1618
- this.fallbackPollerId = null;
1619
- }
1620
- this.updateTransportHealth({ fallbackReason: null });
1621
- }
1622
- /**
1623
- * Clear all in-memory mutation state and emit data:change so UI re-renders.
1624
- * Call this after deleting local data (e.g. reset flow) so that React hooks
1625
- * recompute fingerprints from scratch instead of seeing stale timestamps.
1626
- */
1627
- resetLocalState() {
1628
- const tables = [...this.tableMutationTimestamps.keys()];
1629
- this.mutationTimestamps.clear();
1630
- this.tableMutationTimestamps.clear();
1631
- if (tables.length > 0) {
1632
- this.emit('data:change', {
1633
- scopes: tables,
1634
- timestamp: Date.now(),
1635
- });
1636
- this.config.onDataChange?.(tables);
1637
- }
1638
- }
1639
- /**
1640
- * Reconnect
1641
- */
1642
- reconnect() {
1643
- if (this.isDestroyed || !this.isEnabled())
1644
- return;
1645
- if (this.state.transportMode === 'realtime' &&
1646
- isRealtimeTransport(this.config.transport)) {
1647
- // If we previously disconnected, we need to re-register callbacks via connect().
1648
- if (!this.realtimeDisconnect) {
1649
- this.setupRealtime();
1650
- }
1651
- else {
1652
- this.config.transport.reconnect();
1653
- }
1654
- return;
1655
- }
1656
- // Polling mode: restart the poller and trigger a sync immediately.
1657
- if (this.state.transportMode === 'polling') {
1658
- this.setupPolling();
1659
- this.triggerSyncInBackground(undefined, 'reconnect');
1660
- }
1661
- }
1662
- /**
1663
- * Disconnect (pause syncing)
1664
- */
1665
- disconnect() {
1666
- this.stop();
1667
- }
1668
- /**
1669
- * Refresh outbox statistics
1670
- */
1671
- async refreshOutboxStats(options) {
1672
- const db = this.config.db;
1673
- const res = await sql `
1674
- select
1675
- ${sql.ref('status')},
1676
- count(${sql.ref('id')}) as ${sql.ref('count')}
1677
- from ${sql.table('sync_outbox_commits')}
1678
- group by ${sql.ref('status')}
1679
- `.execute(db);
1680
- const rows = res.rows;
1681
- const stats = {
1682
- pending: 0,
1683
- sending: 0,
1684
- failed: 0,
1685
- acked: 0,
1686
- total: 0,
1687
- };
1688
- for (const row of rows) {
1689
- const count = Number(row.count);
1690
- switch (row.status) {
1691
- case 'pending':
1692
- stats.pending = count;
1693
- break;
1694
- case 'sending':
1695
- stats.sending = count;
1696
- break;
1697
- case 'failed':
1698
- stats.failed = count;
1699
- break;
1700
- case 'acked':
1701
- stats.acked = count;
1702
- break;
1703
- }
1704
- stats.total += count;
1705
- }
1706
- this.updateState({ pendingCount: stats.pending + stats.failed });
1707
- if (options?.emit !== false) {
1708
- this.emit('outbox:change', {
1709
- pendingCount: stats.pending,
1710
- sendingCount: stats.sending,
1711
- failedCount: stats.failed,
1712
- ackedCount: stats.acked,
1713
- });
1714
- }
1715
- return stats;
1716
- }
1717
- /**
1718
- * Get pending conflicts with operation details from outbox
1719
- */
1720
- async getConflicts() {
1721
- // Join with outbox to get operation details
1722
- const res = await sql `
1723
- select
1724
- ${sql.ref('c.id')},
1725
- ${sql.ref('c.outbox_commit_id')},
1726
- ${sql.ref('c.client_commit_id')},
1727
- ${sql.ref('c.op_index')},
1728
- ${sql.ref('c.result_status')},
1729
- ${sql.ref('c.message')},
1730
- ${sql.ref('c.code')},
1731
- ${sql.ref('c.server_version')},
1732
- ${sql.ref('c.server_row_json')},
1733
- ${sql.ref('c.created_at')},
1734
- ${sql.ref('oc.operations_json')}
1735
- from ${sql.table('sync_conflicts')} as ${sql.ref('c')}
1736
- inner join ${sql.table('sync_outbox_commits')} as ${sql.ref('oc')}
1737
- on ${sql.ref('oc.id')} = ${sql.ref('c.outbox_commit_id')}
1738
- where ${sql.ref('c.resolved_at')} is null
1739
- order by ${sql.ref('c.created_at')} desc
1740
- `.execute(this.config.db);
1741
- const rows = res.rows;
1742
- return rows.map((row) => {
1743
- // Extract operation details from outbox
1744
- let table = '';
1745
- let rowId = '';
1746
- let localPayload = null;
1747
- if (row.operations_json) {
1748
- try {
1749
- const operations = JSON.parse(row.operations_json);
1750
- if (Array.isArray(operations)) {
1751
- const op = operations[row.op_index];
1752
- if (isRecord(op)) {
1753
- if (typeof op.table === 'string')
1754
- table = op.table;
1755
- if (typeof op.row_id === 'string')
1756
- rowId = op.row_id;
1757
- localPayload =
1758
- op.payload === null
1759
- ? null
1760
- : isRecord(op.payload)
1761
- ? op.payload
1762
- : null;
1763
- }
1764
- }
1765
- }
1766
- catch {
1767
- // Ignore parse errors
1768
- }
1769
- }
1770
- return {
1771
- id: row.id,
1772
- outboxCommitId: row.outbox_commit_id,
1773
- clientCommitId: row.client_commit_id,
1774
- opIndex: row.op_index,
1775
- resultStatus: row.result_status,
1776
- message: row.message,
1777
- code: row.code,
1778
- serverVersion: row.server_version,
1779
- serverRowJson: row.server_row_json,
1780
- createdAt: row.created_at,
1781
- table,
1782
- rowId,
1783
- localPayload,
1784
- };
1785
- });
1786
- }
1787
- /**
1788
- * Update subscriptions dynamically
1789
- */
1790
- updateSubscriptions(subscriptions) {
1791
- this.config.subscriptions = subscriptions;
1792
- // Trigger a sync to apply new subscriptions
1793
- this.triggerSyncInBackground(undefined, 'subscription update');
1794
- }
1795
- /**
1796
- * Apply local mutations immediately to the database and emit change events.
1797
- * Used for instant UI updates before the sync cycle completes.
1798
- */
1799
- async applyLocalMutation(inputs) {
1800
- const db = this.config.db;
1801
- const handlers = this.config.handlers;
1802
- const affectedTables = new Set();
1803
- const now = Date.now();
1804
- await db.transaction().execute(async (trx) => {
1805
- for (const input of inputs) {
1806
- const handler = getClientHandler(handlers, input.table);
1807
- if (!handler)
1808
- continue;
1809
- affectedTables.add(input.table);
1810
- const change = {
1811
- table: input.table,
1812
- row_id: input.rowId,
1813
- op: input.op,
1814
- scopes: {},
1815
- // For delete ops, row_json should be null; for upserts, default to empty object
1816
- row_json: input.op === 'delete' ? null : (input.payload ?? {}),
1817
- // null indicates local optimistic change (no server version yet)
1818
- row_version: null,
1819
- };
1820
- await handler.applyChange({ trx }, change);
1821
- }
1822
- });
1823
- // Track mutation timestamps for fingerprint-based rerender optimization (in-memory only)
1824
- this.recordLocalMutations(inputs
1825
- .filter((i) => affectedTables.has(i.table))
1826
- .map((i) => ({ table: i.table, rowId: i.rowId, op: i.op })), now);
1827
- }
1828
- /**
1829
- * Clear failed commits from the outbox.
1830
- * Use this to discard commits that keep failing (e.g., version conflicts).
1831
- */
1832
- async clearFailedCommits() {
1833
- const db = this.config.db;
1834
- const res = await sql `
1835
- delete from ${sql.table('sync_outbox_commits')}
1836
- where ${sql.ref('status')} = ${sql.val('failed')}
1837
- `.execute(db);
1838
- const count = Number(res.numAffectedRows ?? 0);
1839
- await this.refreshOutboxStats();
1840
- return count;
1841
- }
1842
- /**
1843
- * Clear all pending and failed commits from the outbox.
1844
- * Use this to reset the outbox completely (e.g., for testing).
1845
- */
1846
- async clearAllCommits() {
1847
- const db = this.config.db;
1848
- const res = await sql `
1849
- delete from ${sql.table('sync_outbox_commits')}
1850
- where ${sql.ref('status')} in (${sql.join([
1851
- sql.val('pending'),
1852
- sql.val('failed'),
1853
- ])})
1854
- `.execute(db);
1855
- const count = Number(res.numAffectedRows ?? 0);
1856
- await this.refreshOutboxStats();
1857
- return count;
1858
- }
1859
- }
1860
- //# sourceMappingURL=SyncEngine.js.map