@syncular/client 0.0.6-95 → 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
@@ -0,0 +1,2215 @@
1
+ import type {
2
+ BlobRef,
3
+ SyncAuthLeaseIssueRequest,
4
+ SyncAuthOperation,
5
+ SyncOperation,
6
+ } from '@syncular/core';
7
+ import { issueSyncularAuthLease } from './auth-leases';
8
+ import { assertSyncularBlobPayloadLimit } from './blob-limits';
9
+ import {
10
+ isSyncularRemoteMode,
11
+ resolveSyncularClientConfig,
12
+ SYNCULAR_DEFAULT_STORAGE,
13
+ } from './client-config';
14
+ import {
15
+ appendSyncularDiagnosticEvent,
16
+ appendSyncularSyncTimings,
17
+ createSyncularSyncAttempt,
18
+ summarizeSyncularDiagnosticSubscriptions,
19
+ } from './diagnostics';
20
+ import {
21
+ isGeneratedSyncularOperationalStateWorkerRequestType,
22
+ type SyncularGeneratedWorkerRequestInput,
23
+ } from './generated-bridge';
24
+ import { browserSyncularNetworkStatusSource } from './network';
25
+ import { assertSyncularReadonlySql } from './sql-safety';
26
+ import type {
27
+ CreateSyncularDatabaseOptions,
28
+ ResolvedSyncularClientConfig,
29
+ SyncularApplyYjsEnvelopeToPayloadArgs,
30
+ SyncularApplyYjsTextUpdatesArgs,
31
+ SyncularApplyYjsTextUpdatesResult,
32
+ SyncularAuthHeaders,
33
+ SyncularAuthLeaseRecord,
34
+ SyncularBlobCacheStats,
35
+ SyncularBlobEncryptionConfig,
36
+ SyncularBlobStoreOptions,
37
+ SyncularBlobUploadQueueProcessOptions,
38
+ SyncularBlobUploadQueueStats,
39
+ SyncularBootstrapStatus,
40
+ SyncularBuildYjsTextUpdateArgs,
41
+ SyncularBuildYjsTextUpdateResult,
42
+ SyncularClientEventMap,
43
+ SyncularClientEventSink,
44
+ SyncularClientEventType,
45
+ SyncularConflictResolution,
46
+ SyncularConflictStats,
47
+ SyncularConflictSummary,
48
+ SyncularConnectionState,
49
+ SyncularCrdtDocumentSnapshot,
50
+ SyncularCrdtFieldCompactionReceipt,
51
+ SyncularCrdtFieldCompactionRequest,
52
+ SyncularCrdtFieldDescriptor,
53
+ SyncularCrdtFieldMaterialization,
54
+ SyncularCrdtFieldRequest,
55
+ SyncularCrdtFieldTextRequest,
56
+ SyncularCrdtFieldWriteReceipt,
57
+ SyncularCrdtFieldYjsUpdateRequest,
58
+ SyncularCrdtUpdateLogEntry,
59
+ SyncularDiagnosticEvent,
60
+ SyncularDiagnosticSink,
61
+ SyncularDiagnosticSnapshot,
62
+ SyncularEncryptedCrdtConfig,
63
+ SyncularEncryptionHelperMethod,
64
+ SyncularFieldEncryptionConfig,
65
+ SyncularLifecycleState,
66
+ SyncularLiveQueryDependencyHint,
67
+ SyncularLiveQueryDiagnostics,
68
+ SyncularLiveQueryEvent,
69
+ SyncularLiveQuerySnapshot,
70
+ SyncularLocalHealthRepairReport,
71
+ SyncularLocalHealthRepairRequest,
72
+ SyncularLocalHealthReport,
73
+ SyncularLocalSupportBundle,
74
+ SyncularLocalSupportBundleImportReport,
75
+ SyncularLocalSyncResetReport,
76
+ SyncularLocalSyncResetRequest,
77
+ SyncularNetworkStatusSource,
78
+ SyncularOutboxStats,
79
+ SyncularPresenceEntry,
80
+ SyncularPresenceSink,
81
+ SyncularRealtimeConnectionState,
82
+ SyncularRealtimeOptions,
83
+ SyncularRowsChangedSink,
84
+ SyncularRuntimeArtifact,
85
+ SyncularRuntimeClient,
86
+ SyncularRuntimeInfo,
87
+ SyncularSchemaState,
88
+ SyncularSqlResult,
89
+ SyncularStorageCompactionOptions,
90
+ SyncularStorageCompactionReport,
91
+ SyncularStorageFallbackInfo,
92
+ SyncularSubscriptionSpec,
93
+ SyncularSyncRequestOptions,
94
+ SyncularSyncResult,
95
+ SyncularSyncTimings,
96
+ SyncularTransportStats,
97
+ } from './types';
98
+ import {
99
+ getSyncularRuntimeArtifact,
100
+ selectSyncularRuntimeArtifact,
101
+ } from './wasm-runtime';
102
+ import type {
103
+ SyncularWorkerErrorPayload,
104
+ SyncularWorkerOutboundMessage,
105
+ SyncularWorkerRealtimeOptions,
106
+ SyncularWorkerRequest,
107
+ SyncularWorkerResponse,
108
+ SyncularWorkerRuntimeArtifact,
109
+ } from './worker-protocol';
110
+ import {
111
+ createSyncularWorkerErrorPayload,
112
+ SYNCULAR_WORKER_PROTOCOL_VERSION,
113
+ } from './worker-protocol';
114
+
115
+ type PendingRequest = {
116
+ type: SyncularWorkerRequestInput['type'];
117
+ timeout: ReturnType<typeof setTimeout> | undefined;
118
+ resolve(value: unknown): void;
119
+ reject(reason: unknown): void;
120
+ };
121
+
122
+ type BlobOutboxRow = {
123
+ hash: string;
124
+ size: number;
125
+ mime_type: string;
126
+ encrypted: number | boolean;
127
+ key_id: string | null;
128
+ status: string;
129
+ error: string | null;
130
+ };
131
+
132
+ type SyncularWorkerRequestInput = SyncularGeneratedWorkerRequestInput;
133
+
134
+ const DEFAULT_SYNCULAR_WORKER_REQUEST_TIMEOUT_MS = 30_000;
135
+
136
+ export async function createSyncularWorkerClient(
137
+ options: CreateSyncularDatabaseOptions
138
+ ): Promise<SyncularWorkerClient> {
139
+ const config = resolveSyncularClientConfig(options.config);
140
+ const runtime =
141
+ typeof options.runtime === 'string'
142
+ ? getSyncularRuntimeArtifact(options.runtime)
143
+ : (options.runtime ??
144
+ selectSyncularRuntimeArtifact(
145
+ options.requiredRuntimeFeatures,
146
+ options.runtimeArtifacts
147
+ ));
148
+ const worker =
149
+ typeof options.worker === 'function'
150
+ ? options.worker()
151
+ : (options.worker ?? createDefaultSyncularWorker());
152
+ const client = new SyncularWorkerClient(worker, {
153
+ ownsWorker: options.worker == null,
154
+ requestTimeoutMs: options.requestTimeoutMs,
155
+ getHeaders: options.getHeaders,
156
+ authLifecycle: options.authLifecycle,
157
+ diagnostics: options.diagnostics,
158
+ rowsChangedDebounceMs: options.sync?.rowsChangedDebounceMs,
159
+ network:
160
+ options.sync?.network === false
161
+ ? undefined
162
+ : (options.sync?.network ?? browserSyncularNetworkStatusSource()),
163
+ blobLimits: options.blobLimits,
164
+ });
165
+ try {
166
+ await client.open(config, runtime);
167
+ } catch (err) {
168
+ if (
169
+ options.config.storage == null &&
170
+ config.storage === SYNCULAR_DEFAULT_STORAGE &&
171
+ isOpfsOpenFailure(err)
172
+ ) {
173
+ const fallbackConfig = { ...config, storage: 'indexedDb' as const };
174
+ client.setStorageFallback({
175
+ from: SYNCULAR_DEFAULT_STORAGE,
176
+ to: fallbackConfig.storage,
177
+ reason: errorMessage(err),
178
+ });
179
+ await client.open(fallbackConfig, runtime);
180
+ } else {
181
+ throw err;
182
+ }
183
+ }
184
+ return client;
185
+ }
186
+
187
+ export function getDefaultSyncularWorkerUrl(): URL {
188
+ const runtimeUrl = new URL(import.meta.url);
189
+ const sourceRuntime = runtimeUrl.pathname.endsWith('/src/worker-client.ts');
190
+ return new URL(
191
+ sourceRuntime ? './worker-entry.ts' : './worker-entry.js',
192
+ runtimeUrl
193
+ );
194
+ }
195
+
196
+ export function createDefaultSyncularWorker(): Worker {
197
+ return new Worker(getDefaultSyncularWorkerUrl(), {
198
+ type: 'module',
199
+ credentials: 'same-origin',
200
+ });
201
+ }
202
+
203
+ export class SyncularWorkerError extends Error {
204
+ readonly code: SyncularWorkerErrorPayload['code'];
205
+ readonly category: SyncularWorkerErrorPayload['category'];
206
+ readonly retryable: boolean;
207
+ readonly recommendedAction: SyncularWorkerErrorPayload['recommendedAction'];
208
+ readonly details: unknown;
209
+
210
+ constructor(payload: SyncularWorkerErrorPayload) {
211
+ super(payload.message);
212
+ this.name = payload.name ?? 'SyncularWorkerError';
213
+ this.code = payload.code;
214
+ this.category = payload.category;
215
+ this.retryable = payload.retryable === true;
216
+ this.recommendedAction = payload.recommendedAction;
217
+ this.details = payload.details;
218
+ if (payload.stack) this.stack = payload.stack;
219
+ }
220
+ }
221
+
222
+ export class SyncularWorkerClient implements SyncularRuntimeClient {
223
+ #nextId = 1;
224
+ #pending = new Map<number, PendingRequest>();
225
+ #closed = false;
226
+ #requestTimeoutMs: number;
227
+ #getHeaders: CreateSyncularDatabaseOptions['getHeaders'] | undefined;
228
+ #authHeaders: SyncularAuthHeaders = {};
229
+ #authLifecycle: CreateSyncularDatabaseOptions['authLifecycle'] | undefined;
230
+ #authRefreshInFlight: Promise<boolean> | undefined;
231
+ #config: ResolvedSyncularClientConfig | undefined;
232
+ #realtimeOptions: SyncularRealtimeOptions | undefined;
233
+ #realtimeState: SyncularRealtimeConnectionState = 'disconnected';
234
+ #storageFallback: SyncularStorageFallbackInfo | undefined;
235
+ #lastDiagnostic: SyncularDiagnosticEvent | undefined;
236
+ #lastError: { message: string; code?: string } | undefined;
237
+ #lastLifecycleState: SyncularLifecycleState | undefined;
238
+ #recoveryRequired = false;
239
+ #authRequired = false;
240
+ #subscriptions: SyncularSubscriptionSpec[] = [];
241
+ #lastBootstrap: SyncularBootstrapStatus | undefined;
242
+ #recentDiagnostics: SyncularDiagnosticEvent[] = [];
243
+ #recentSyncTimings: SyncularSyncTimings[] = [];
244
+ #rowsChangedDebounceMs: number | false;
245
+ #rowsChangedDebounceTimer: ReturnType<typeof setTimeout> | undefined;
246
+ #pendingRowsChanged:
247
+ | {
248
+ source: string;
249
+ changedTables: Set<string>;
250
+ changedRows: SyncularSyncResult['changedRows'];
251
+ changedRowsTruncated: boolean;
252
+ }
253
+ | undefined;
254
+ #lastOutboxStats: SyncularOutboxStats | undefined;
255
+ #lastConflictStats: SyncularConflictStats | undefined;
256
+ #lastBlobUploadStats: SyncularBlobUploadQueueStats | undefined;
257
+ #blobLimits: CreateSyncularDatabaseOptions['blobLimits'];
258
+ #network: SyncularNetworkStatusSource | undefined;
259
+ #networkOnline: boolean | undefined;
260
+ #unsubscribeNetwork: (() => void) | undefined;
261
+ #diagnosticListeners = new Set<SyncularDiagnosticSink>();
262
+ #rowsChangedListeners = new Set<SyncularRowsChangedSink>();
263
+ #eventListeners = new Map<
264
+ SyncularClientEventType,
265
+ Set<SyncularClientEventSink<SyncularClientEventType>>
266
+ >();
267
+ #presenceByScopeKey = new Map<string, SyncularPresenceEntry[]>();
268
+ #joinedPresence = new Map<string, Record<string, unknown> | undefined>();
269
+ #liveListeners = new Map<
270
+ string,
271
+ (event: SyncularLiveQueryEvent<Record<string, unknown>>) => void
272
+ >();
273
+
274
+ constructor(
275
+ private readonly worker: Worker,
276
+ options: {
277
+ ownsWorker: boolean;
278
+ requestTimeoutMs?: number;
279
+ getHeaders?: CreateSyncularDatabaseOptions['getHeaders'];
280
+ authLifecycle?: CreateSyncularDatabaseOptions['authLifecycle'];
281
+ diagnostics?: SyncularDiagnosticSink;
282
+ rowsChangedDebounceMs?: number | false;
283
+ network?: SyncularNetworkStatusSource;
284
+ blobLimits?: CreateSyncularDatabaseOptions['blobLimits'];
285
+ }
286
+ ) {
287
+ this.#requestTimeoutMs =
288
+ options.requestTimeoutMs ?? DEFAULT_SYNCULAR_WORKER_REQUEST_TIMEOUT_MS;
289
+ this.#getHeaders = options.getHeaders;
290
+ this.#authLifecycle = options.authLifecycle;
291
+ this.#blobLimits = options.blobLimits;
292
+ this.#network = options.network;
293
+ this.#networkOnline = this.#network?.isOnline();
294
+ this.#unsubscribeNetwork = this.#subscribeNetworkEvents();
295
+ this.#rowsChangedDebounceMs =
296
+ options.rowsChangedDebounceMs === false
297
+ ? false
298
+ : Math.max(0, options.rowsChangedDebounceMs ?? 0);
299
+ if (options.diagnostics) {
300
+ this.#diagnosticListeners.add(options.diagnostics);
301
+ }
302
+ this.worker.onmessage = (
303
+ event: MessageEvent<SyncularWorkerOutboundMessage>
304
+ ) => {
305
+ this.#handleWorkerMessage(event.data);
306
+ };
307
+ this.worker.onerror = (event) => {
308
+ const payload = createSyncularWorkerErrorPayload(
309
+ 'worker.failed',
310
+ event.message || 'Syncular worker failed'
311
+ );
312
+ this.#emitDiagnostic({
313
+ level: 'error',
314
+ source: 'worker',
315
+ code: 'worker.failed',
316
+ message: payload.message,
317
+ });
318
+ this.#rejectAll(payload);
319
+ };
320
+ this.worker.onmessageerror = () => {
321
+ const payload = createSyncularWorkerErrorPayload(
322
+ 'worker.message_unreadable',
323
+ 'Syncular worker sent an unreadable message'
324
+ );
325
+ this.#emitDiagnostic({
326
+ level: 'error',
327
+ source: 'worker',
328
+ code: 'worker.message_unreadable',
329
+ message: payload.message,
330
+ });
331
+ this.#rejectAll(payload);
332
+ };
333
+ this.ownsWorker = options.ownsWorker;
334
+ }
335
+
336
+ private readonly ownsWorker: boolean;
337
+
338
+ async open(
339
+ config: CreateSyncularDatabaseOptions['config'],
340
+ runtime?: CreateSyncularDatabaseOptions['runtime']
341
+ ): Promise<void> {
342
+ const resolvedConfig = resolveSyncularClientConfig(config);
343
+ const resolvedRuntime =
344
+ typeof runtime === 'string'
345
+ ? getSyncularRuntimeArtifact(runtime)
346
+ : runtime;
347
+ await this.#request({
348
+ type: 'open',
349
+ config: resolvedConfig,
350
+ runtime: serializeRuntimeArtifact(resolvedRuntime),
351
+ });
352
+ this.#config = resolvedConfig;
353
+ this.#realtimeOptions = undefined;
354
+ this.#subscriptions = [];
355
+ this.#lastBootstrap = undefined;
356
+ await this.#refreshAuthHeaders();
357
+ }
358
+
359
+ async setAuthHeaders(headers: SyncularAuthHeaders): Promise<void> {
360
+ await this.#setAuthHeaders(headers, { restartRealtime: true });
361
+ }
362
+
363
+ async issueAuthLease(
364
+ request: SyncAuthLeaseIssueRequest
365
+ ): Promise<SyncularAuthLeaseRecord> {
366
+ await this.#refreshAuthHeaders();
367
+ try {
368
+ return await this.#issueAuthLeaseWithCurrentHeaders(request);
369
+ } catch (error) {
370
+ const shouldRetry = await this.#resolveAuthRetry(error, 'authLeaseIssue');
371
+ if (!shouldRetry) throw error;
372
+ await this.#refreshAuthHeaders();
373
+ return this.#issueAuthLeaseWithCurrentHeaders(request);
374
+ }
375
+ }
376
+
377
+ async upsertAuthLease(lease: SyncularAuthLeaseRecord): Promise<void> {
378
+ await this.#requestAndDrain({
379
+ type: 'upsertAuthLease',
380
+ lease,
381
+ });
382
+ }
383
+
384
+ async authLease(leaseId: string): Promise<SyncularAuthLeaseRecord | null> {
385
+ return this.#request({
386
+ type: 'authLease',
387
+ leaseId,
388
+ });
389
+ }
390
+
391
+ async activeAuthLeases(
392
+ actorId?: string | null,
393
+ nowMs = Date.now()
394
+ ): Promise<SyncularAuthLeaseRecord[]> {
395
+ return this.#request({
396
+ type: 'activeAuthLeases',
397
+ actorId: actorId ?? null,
398
+ nowMs: Math.trunc(nowMs),
399
+ });
400
+ }
401
+
402
+ async setFieldEncryption(
403
+ config: SyncularFieldEncryptionConfig | null
404
+ ): Promise<void> {
405
+ await this.#request({
406
+ type: 'setFieldEncryption',
407
+ config: config == null ? null : normalizeFieldEncryptionConfig(config),
408
+ });
409
+ }
410
+
411
+ async setEncryptedCrdt(
412
+ config: SyncularEncryptedCrdtConfig | null
413
+ ): Promise<void> {
414
+ await this.#request({
415
+ type: 'setEncryptedCrdt',
416
+ config: config == null ? null : normalizeEncryptedCrdtConfig(config),
417
+ });
418
+ }
419
+
420
+ async setBlobEncryption(
421
+ config: SyncularBlobEncryptionConfig | null
422
+ ): Promise<void> {
423
+ await this.#request({
424
+ type: 'setBlobEncryption',
425
+ config: config == null ? null : normalizeBlobEncryptionConfig(config),
426
+ });
427
+ }
428
+
429
+ async #setAuthHeaders(
430
+ headers: SyncularAuthHeaders,
431
+ options: { restartRealtime: boolean }
432
+ ): Promise<void> {
433
+ this.#authHeaders = cloneAuthHeaders(headers);
434
+ await this.#request({
435
+ type: 'setAuthHeaders',
436
+ headers: cloneAuthHeaders(this.#authHeaders),
437
+ });
438
+ if (options.restartRealtime) await this.#restartRealtime();
439
+ }
440
+
441
+ async #issueAuthLeaseWithCurrentHeaders(
442
+ request: SyncAuthLeaseIssueRequest
443
+ ): Promise<SyncularAuthLeaseRecord> {
444
+ const config = this.#config;
445
+ if (!config) {
446
+ throw new Error(
447
+ 'Syncular worker client must be opened before auth lease issue'
448
+ );
449
+ }
450
+ assertSyncularRemoteMode(config, 'auth lease issue');
451
+ const lease = await issueSyncularAuthLease({
452
+ baseUrl: config.baseUrl,
453
+ headers: this.#authHeaders,
454
+ request,
455
+ appSchema: config.appSchema,
456
+ });
457
+ await this.upsertAuthLease(lease);
458
+ this.#emitDiagnostic({
459
+ level: 'info',
460
+ source: 'auth',
461
+ code: 'auth_lease.issued',
462
+ message: 'Syncular auth lease issued and stored',
463
+ details: {
464
+ leaseId: lease.leaseId,
465
+ expiresAtMs: lease.expiresAtMs,
466
+ schemaVersion: lease.schemaVersion,
467
+ },
468
+ });
469
+ return lease;
470
+ }
471
+
472
+ async startRealtime(
473
+ options: boolean | SyncularRealtimeOptions = {}
474
+ ): Promise<void> {
475
+ if (options === false) {
476
+ await this.stopRealtime();
477
+ return;
478
+ }
479
+ const config = this.#config;
480
+ if (!config) {
481
+ throw new Error('Syncular worker client must be opened before realtime');
482
+ }
483
+ assertSyncularRemoteMode(config, 'realtime');
484
+ const realtimeOptions = options === true ? {} : options;
485
+ if (realtimeOptions.enabled === false) {
486
+ await this.stopRealtime();
487
+ return;
488
+ }
489
+ this.#realtimeOptions = realtimeOptions;
490
+ await this.#refreshAuthHeaders({ restartRealtime: false });
491
+ await this.#request({
492
+ type: 'startRealtime',
493
+ options: await resolveRealtimeWorkerOptions(realtimeOptions, config),
494
+ });
495
+ }
496
+
497
+ async stopRealtime(): Promise<void> {
498
+ this.#realtimeOptions = undefined;
499
+ await this.#request({ type: 'stopRealtime' });
500
+ }
501
+
502
+ async setSubscriptions(
503
+ subscriptions: readonly SyncularSubscriptionSpec[]
504
+ ): Promise<void> {
505
+ this.#subscriptions = [...subscriptions];
506
+ this.#lastBootstrap = undefined;
507
+ await this.#request({
508
+ type: 'setSubscriptions',
509
+ subscriptions: this.#subscriptions,
510
+ });
511
+ }
512
+
513
+ async forceSubscriptionsBootstrap(
514
+ subscriptionIds: readonly string[] = []
515
+ ): Promise<number> {
516
+ return this.#request({
517
+ type: 'forceSubscriptionsBootstrap',
518
+ subscriptionIds: [...subscriptionIds],
519
+ });
520
+ }
521
+
522
+ async executeSql<
523
+ Row extends Record<string, unknown> = Record<string, unknown>,
524
+ >(
525
+ sql: string,
526
+ params: readonly unknown[] = []
527
+ ): Promise<SyncularSqlResult<Row>> {
528
+ assertSyncularReadonlySql(sql);
529
+ return this.#request({
530
+ type: 'executeSql',
531
+ sql,
532
+ params: [...params],
533
+ });
534
+ }
535
+
536
+ executeUnsafeSql<
537
+ Row extends Record<string, unknown> = Record<string, unknown>,
538
+ >(
539
+ sql: string,
540
+ params: readonly unknown[] = []
541
+ ): Promise<SyncularSqlResult<Row>> {
542
+ return this.#request({
543
+ type: 'executeUnsafeSql',
544
+ sql,
545
+ params: [...params],
546
+ });
547
+ }
548
+
549
+ subscribeQuery<Row extends Record<string, unknown> = Record<string, unknown>>(
550
+ sql: string,
551
+ params: readonly unknown[],
552
+ tables: readonly string[],
553
+ hints: readonly SyncularLiveQueryDependencyHint[] = []
554
+ ): Promise<SyncularLiveQuerySnapshot<Row>> {
555
+ return this.#request({
556
+ type: 'subscribeQuery',
557
+ sql,
558
+ params: [...params],
559
+ tables: [...tables],
560
+ hints: hints.map((hint) => ({
561
+ table: hint.table,
562
+ rowIds: [...(hint.rowIds ?? [])],
563
+ fields: [...(hint.fields ?? [])],
564
+ })),
565
+ });
566
+ }
567
+
568
+ async unsubscribeQuery(id: string): Promise<void> {
569
+ await this.#request({ type: 'unsubscribeQuery', queryId: id });
570
+ }
571
+
572
+ drainLiveQueryEvents<
573
+ Row extends Record<string, unknown> = Record<string, unknown>,
574
+ >(): Promise<Array<SyncularLiveQueryEvent<Row>>> {
575
+ return this.#request({ type: 'drainLiveQueryEvents' });
576
+ }
577
+
578
+ liveQueryDiagnostics(): Promise<SyncularLiveQueryDiagnostics> {
579
+ return this.#request({ type: 'liveQueryDiagnostics' });
580
+ }
581
+
582
+ async applyMutation(
583
+ operation: SyncOperation,
584
+ localRow?: unknown
585
+ ): Promise<string> {
586
+ return this.#requestAndDrain({
587
+ type: 'applyMutation',
588
+ operation,
589
+ localRow,
590
+ });
591
+ }
592
+
593
+ async applyLeasedMutation(
594
+ operation: SyncOperation,
595
+ localRow?: unknown
596
+ ): Promise<string> {
597
+ return this.#requestAndDrain({
598
+ type: 'applyLeasedMutation',
599
+ operation,
600
+ localRow,
601
+ });
602
+ }
603
+
604
+ async applyMutationsBatch(
605
+ operations: Array<{ operation: SyncOperation; localRow?: unknown | null }>
606
+ ): Promise<string[]> {
607
+ return this.#requestAndDrain({
608
+ type: 'applyMutationsBatch',
609
+ operations,
610
+ });
611
+ }
612
+
613
+ async applyMutationsCommit(
614
+ operations: Array<{ operation: SyncOperation; localRow?: unknown | null }>
615
+ ): Promise<string> {
616
+ return this.#requestAndDrain({
617
+ type: 'applyMutationsCommit',
618
+ operations,
619
+ });
620
+ }
621
+
622
+ async applyLeasedMutationsCommit(
623
+ operations: Array<{ operation: SyncOperation; localRow?: unknown | null }>
624
+ ): Promise<string> {
625
+ return this.#requestAndDrain({
626
+ type: 'applyLeasedMutationsCommit',
627
+ operations,
628
+ });
629
+ }
630
+
631
+ async syncPull(
632
+ options: SyncularSyncRequestOptions = {}
633
+ ): Promise<SyncularSyncResult> {
634
+ return this.#syncWithAuthRetry({
635
+ type: 'syncPull',
636
+ syncAttempt: options.syncAttempt ?? createSyncularSyncAttempt(),
637
+ });
638
+ }
639
+
640
+ async syncPush(
641
+ options: SyncularSyncRequestOptions = {}
642
+ ): Promise<SyncularSyncResult> {
643
+ return this.#syncWithAuthRetry({
644
+ type: 'syncPush',
645
+ syncAttempt: options.syncAttempt ?? createSyncularSyncAttempt(),
646
+ });
647
+ }
648
+
649
+ async syncOnce(
650
+ options: SyncularSyncRequestOptions = {}
651
+ ): Promise<SyncularSyncResult> {
652
+ return this.#syncWithAuthRetry({
653
+ type: 'syncOnce',
654
+ syncAttempt: options.syncAttempt ?? createSyncularSyncAttempt(),
655
+ });
656
+ }
657
+
658
+ async resumeFromBackground(
659
+ options: SyncularSyncRequestOptions = {}
660
+ ): Promise<SyncularSyncResult> {
661
+ this.#recoveryRequired = true;
662
+ this.#emitDiagnostic({
663
+ level: 'info',
664
+ source: 'client',
665
+ code: 'lifecycle.resume_from_background',
666
+ message: 'Syncular resumed from background',
667
+ });
668
+ await this.#refreshAuthHeaders({ restartRealtime: false });
669
+ await this.#restartRealtime();
670
+ return this.#syncWithAuthRetry(
671
+ {
672
+ type: 'syncOnce',
673
+ syncAttempt: options.syncAttempt ?? createSyncularSyncAttempt(),
674
+ },
675
+ { refreshAuthHeaders: false }
676
+ );
677
+ }
678
+
679
+ transportStats(): Promise<SyncularTransportStats> {
680
+ return this.#request({ type: 'transportStats' });
681
+ }
682
+
683
+ async resetTransportStats(): Promise<void> {
684
+ await this.#request({ type: 'resetTransportStats' });
685
+ }
686
+
687
+ conflictSummaries(): Promise<SyncularConflictSummary[]> {
688
+ return this.#request({ type: 'conflictSummaries' });
689
+ }
690
+
691
+ retryConflictKeepLocal(id: string): Promise<string> {
692
+ return this.#requestAndDrain({
693
+ type: 'retryConflictKeepLocal',
694
+ conflictId: id,
695
+ });
696
+ }
697
+
698
+ async resolveConflict(
699
+ id: string,
700
+ resolution: SyncularConflictResolution
701
+ ): Promise<void> {
702
+ await this.#requestAndDrain({
703
+ type: 'resolveConflict',
704
+ conflictId: id,
705
+ resolution,
706
+ });
707
+ }
708
+
709
+ listTable<Row extends Record<string, unknown> = Record<string, unknown>>(
710
+ table: string
711
+ ): Promise<Row[]> {
712
+ return this.#request({ type: 'listTable', table });
713
+ }
714
+
715
+ storeBlob(
716
+ data: Uint8Array,
717
+ options?: SyncularBlobStoreOptions
718
+ ): Promise<BlobRef> {
719
+ try {
720
+ assertSyncularBlobPayloadLimit({
721
+ operation: 'store',
722
+ size: data.byteLength,
723
+ limits: this.#blobLimits,
724
+ options,
725
+ diagnostics: (event) => this.#emitDiagnostic(event),
726
+ });
727
+ } catch (error) {
728
+ return Promise.reject(error);
729
+ }
730
+ const request = { type: 'storeBlob' as const, data, options };
731
+ const result = options?.immediate
732
+ ? this.#requestWithAuthRetry<BlobRef>(request, 'blobInitiateUpload')
733
+ : this.#request<BlobRef>(request);
734
+ return result.then((ref) => {
735
+ if (options?.immediate) {
736
+ this.#emitClientEvent('blobUploadCompleted', { ref });
737
+ }
738
+ void this.#emitOperationalState();
739
+ return ref;
740
+ });
741
+ }
742
+
743
+ async retrieveBlob(ref: BlobRef): Promise<Uint8Array> {
744
+ assertSyncularBlobPayloadLimit({
745
+ operation: 'retrieve',
746
+ size: ref.size,
747
+ limits: this.#blobLimits,
748
+ refHash: ref.hash,
749
+ diagnostics: (event) => this.#emitDiagnostic(event),
750
+ });
751
+ const wasLocal = this.#hasDiagnosticListeners()
752
+ ? await this.#request<boolean>({
753
+ type: 'isBlobLocal',
754
+ hash: ref.hash,
755
+ }).catch(() => undefined)
756
+ : undefined;
757
+ try {
758
+ const bytes = await this.#requestWithAuthRetry<Uint8Array>(
759
+ { type: 'retrieveBlob', ref },
760
+ 'blobGetDownloadUrl'
761
+ );
762
+ if (wasLocal !== undefined) {
763
+ this.#emitDiagnostic({
764
+ level: 'info',
765
+ source: 'blob',
766
+ code: wasLocal ? 'blob.cache_hit' : 'blob.cache_miss',
767
+ message: wasLocal
768
+ ? 'Syncular blob served from local cache'
769
+ : 'Syncular blob fetched after local cache miss',
770
+ details: {
771
+ hash: ref.hash,
772
+ size: ref.size,
773
+ mimeType: ref.mimeType,
774
+ encrypted: ref.encrypted === true,
775
+ ...(ref.keyId ? { keyId: ref.keyId } : {}),
776
+ },
777
+ });
778
+ }
779
+ return bytes;
780
+ } catch (error) {
781
+ if (this.#hasDiagnosticListeners()) {
782
+ this.#emitDiagnostic({
783
+ level: 'warn',
784
+ source: 'blob',
785
+ code: 'blob.download_failed',
786
+ message: `Syncular blob download failed: ${errorMessage(error)}`,
787
+ details: {
788
+ hash: ref.hash,
789
+ size: ref.size,
790
+ mimeType: ref.mimeType,
791
+ encrypted: ref.encrypted === true,
792
+ ...(ref.keyId ? { keyId: ref.keyId } : {}),
793
+ },
794
+ });
795
+ }
796
+ throw error;
797
+ }
798
+ }
799
+
800
+ async isBlobLocal(hash: string): Promise<boolean> {
801
+ const local = await this.#request<boolean>({ type: 'isBlobLocal', hash });
802
+ if (this.#hasDiagnosticListeners()) {
803
+ this.#emitDiagnostic({
804
+ level: 'debug',
805
+ source: 'blob',
806
+ code: local ? 'blob.cache_hit' : 'blob.cache_miss',
807
+ message: local
808
+ ? 'Syncular blob is available in local cache'
809
+ : 'Syncular blob is not available in local cache',
810
+ details: { hash },
811
+ });
812
+ }
813
+ return local;
814
+ }
815
+
816
+ async processBlobUploadQueue(
817
+ options?: SyncularBlobUploadQueueProcessOptions
818
+ ): Promise<{
819
+ uploaded: number;
820
+ failed: number;
821
+ }> {
822
+ const observeBlobEvents =
823
+ this.#hasClientEventListeners('blobUploadCompleted') ||
824
+ this.#hasClientEventListeners('blobUploadFailed') ||
825
+ this.#hasDiagnosticListeners();
826
+ const before = observeBlobEvents
827
+ ? await this.#readBlobOutboxRows().catch(() => [])
828
+ : [];
829
+ const result = await this.#requestWithAuthRetry<{
830
+ uploaded: number;
831
+ failed: number;
832
+ }>({ type: 'processBlobUploadQueue', options }, 'blobInitiateUpload');
833
+ if (observeBlobEvents) {
834
+ const after = await this.#readBlobOutboxRows().catch(() => []);
835
+ this.#emitBlobUploadEvents(before, after);
836
+ }
837
+ if (this.#hasDiagnosticListeners()) {
838
+ this.#emitDiagnostic({
839
+ level: result.failed > 0 ? 'warn' : 'info',
840
+ source: 'blob',
841
+ code: 'blob.upload_queue_processed',
842
+ message: 'Syncular blob upload queue processed',
843
+ details: result,
844
+ });
845
+ }
846
+ void this.#emitOperationalState();
847
+ return result;
848
+ }
849
+
850
+ async blobUploadQueueStats(): Promise<SyncularBlobUploadQueueStats> {
851
+ const stats = await this.#readBlobUploadStats();
852
+ this.#lastBlobUploadStats = stats;
853
+ return stats;
854
+ }
855
+
856
+ blobCacheStats(): Promise<SyncularBlobCacheStats> {
857
+ return this.#request({ type: 'blobCacheStats' });
858
+ }
859
+
860
+ async pruneBlobCache(maxBytes?: number): Promise<number> {
861
+ const prunedBytes = await this.#request<number>({
862
+ type: 'pruneBlobCache',
863
+ maxBytes,
864
+ });
865
+ if (this.#hasDiagnosticListeners()) {
866
+ this.#emitDiagnostic({
867
+ level: 'info',
868
+ source: 'blob',
869
+ code: 'blob.cache_pruned',
870
+ message: 'Syncular blob cache pruned',
871
+ details: {
872
+ prunedBytes,
873
+ ...(maxBytes !== undefined ? { maxBytes } : {}),
874
+ },
875
+ });
876
+ }
877
+ return prunedBytes;
878
+ }
879
+
880
+ async clearBlobCache(): Promise<void> {
881
+ await this.#request({ type: 'clearBlobCache' });
882
+ if (this.#hasDiagnosticListeners()) {
883
+ this.#emitDiagnostic({
884
+ level: 'info',
885
+ source: 'blob',
886
+ code: 'blob.cache_cleared',
887
+ message: 'Syncular blob cache cleared',
888
+ });
889
+ }
890
+ }
891
+
892
+ compactStorage(
893
+ options: SyncularStorageCompactionOptions = {}
894
+ ): Promise<SyncularStorageCompactionReport> {
895
+ return this.#request({ type: 'compactStorage', options });
896
+ }
897
+
898
+ generatedSchemaState(): Promise<SyncularSchemaState> {
899
+ return this.#request({ type: 'generatedSchemaState' });
900
+ }
901
+
902
+ localHealthCheck(): Promise<SyncularLocalHealthReport> {
903
+ return this.#request({ type: 'localHealthCheck' });
904
+ }
905
+
906
+ exportLocalSupportBundle(): Promise<SyncularLocalSupportBundle> {
907
+ return this.#request({ type: 'exportLocalSupportBundle' });
908
+ }
909
+
910
+ importLocalSupportBundle(
911
+ bundle: SyncularLocalSupportBundle | string
912
+ ): Promise<SyncularLocalSupportBundleImportReport> {
913
+ return this.#request({
914
+ type: 'importLocalSupportBundle',
915
+ bundleJson: typeof bundle === 'string' ? bundle : JSON.stringify(bundle),
916
+ });
917
+ }
918
+
919
+ async repairLocalHealth(
920
+ request: SyncularLocalHealthRepairRequest
921
+ ): Promise<SyncularLocalHealthRepairReport> {
922
+ const message = {
923
+ type: 'repairLocalHealth',
924
+ request: {
925
+ action: request.action,
926
+ subscriptionIds: [...(request.subscriptionIds ?? [])],
927
+ tables: [...(request.tables ?? [])],
928
+ },
929
+ } satisfies SyncularWorkerRequestInput;
930
+ const result =
931
+ request.action === 'clearOrphanedSyncedRows'
932
+ ? await this.#requestAndDrain<SyncularLocalHealthRepairReport>(message)
933
+ : await this.#request<SyncularLocalHealthRepairReport>(message);
934
+ if (request.action === 'clearOrphanedSyncedRows') {
935
+ this.#emitLifecycleChanged();
936
+ }
937
+ return result;
938
+ }
939
+
940
+ async resetLocalSyncState(
941
+ request: SyncularLocalSyncResetRequest = {}
942
+ ): Promise<SyncularLocalSyncResetReport> {
943
+ const normalized = {
944
+ subscriptionIds: [...(request.subscriptionIds ?? [])],
945
+ clearSyncedRows: request.clearSyncedRows === true,
946
+ };
947
+ const result = await this.#requestAndDrain<SyncularLocalSyncResetReport>({
948
+ type: 'resetLocalSyncState',
949
+ request: normalized,
950
+ });
951
+ this.#lastBootstrap = undefined;
952
+ this.#emitLifecycleChanged();
953
+ return result;
954
+ }
955
+
956
+ buildYjsTextUpdate(
957
+ args: SyncularBuildYjsTextUpdateArgs
958
+ ): Promise<SyncularBuildYjsTextUpdateResult> {
959
+ return this.#request({ type: 'buildYjsTextUpdate', args });
960
+ }
961
+
962
+ applyYjsTextUpdates(
963
+ args: SyncularApplyYjsTextUpdatesArgs
964
+ ): Promise<SyncularApplyYjsTextUpdatesResult> {
965
+ return this.#request({ type: 'applyYjsTextUpdates', args });
966
+ }
967
+
968
+ applyYjsEnvelopeToPayload(
969
+ args: SyncularApplyYjsEnvelopeToPayloadArgs
970
+ ): Promise<Record<string, unknown>> {
971
+ return this.#request({ type: 'applyYjsEnvelopeToPayload', args });
972
+ }
973
+
974
+ openCrdtField(
975
+ request: SyncularCrdtFieldRequest
976
+ ): Promise<SyncularCrdtFieldDescriptor> {
977
+ return this.#request({ type: 'openCrdtField', request });
978
+ }
979
+
980
+ applyCrdtFieldText(
981
+ request: SyncularCrdtFieldTextRequest
982
+ ): Promise<SyncularCrdtFieldWriteReceipt> {
983
+ return this.#requestAndDrain({ type: 'applyCrdtFieldText', request });
984
+ }
985
+
986
+ applyCrdtFieldYjsUpdate(
987
+ request: SyncularCrdtFieldYjsUpdateRequest
988
+ ): Promise<SyncularCrdtFieldWriteReceipt> {
989
+ return this.#requestAndDrain({
990
+ type: 'applyCrdtFieldYjsUpdate',
991
+ request,
992
+ });
993
+ }
994
+
995
+ materializeCrdtField(
996
+ request: SyncularCrdtFieldRequest
997
+ ): Promise<SyncularCrdtFieldMaterialization> {
998
+ return this.#request({ type: 'materializeCrdtField', request });
999
+ }
1000
+
1001
+ crdtDocumentSnapshot(
1002
+ request: SyncularCrdtFieldRequest
1003
+ ): Promise<SyncularCrdtDocumentSnapshot> {
1004
+ return this.#request({ type: 'crdtDocumentSnapshot', request });
1005
+ }
1006
+
1007
+ crdtUpdateLog(
1008
+ request: SyncularCrdtFieldRequest & { limit?: number }
1009
+ ): Promise<SyncularCrdtUpdateLogEntry[]> {
1010
+ return this.#request({ type: 'crdtUpdateLog', request });
1011
+ }
1012
+
1013
+ snapshotCrdtFieldStateVector(
1014
+ request: SyncularCrdtFieldRequest
1015
+ ): Promise<{ stateVectorBase64: string }> {
1016
+ return this.#request({ type: 'snapshotCrdtFieldStateVector', request });
1017
+ }
1018
+
1019
+ compactCrdtField(
1020
+ request: SyncularCrdtFieldCompactionRequest
1021
+ ): Promise<SyncularCrdtFieldCompactionReceipt> {
1022
+ return this.#requestAndDrain({ type: 'compactCrdtField', request });
1023
+ }
1024
+
1025
+ encryptionHelper(
1026
+ method: SyncularEncryptionHelperMethod,
1027
+ args: unknown = {}
1028
+ ): Promise<unknown> {
1029
+ return this.#request({ type: 'encryptionHelper', method, args });
1030
+ }
1031
+
1032
+ runtimeInfo(): Promise<SyncularRuntimeInfo> {
1033
+ return this.#request<SyncularRuntimeInfo>({ type: 'runtimeInfo' }).then(
1034
+ (info) => ({
1035
+ ...info,
1036
+ storageFallback: this.#storageFallback,
1037
+ })
1038
+ );
1039
+ }
1040
+
1041
+ connectionState(): SyncularConnectionState {
1042
+ return {
1043
+ closed: this.#closed,
1044
+ pendingRequests: this.#pending.size,
1045
+ realtime: this.#realtimeState,
1046
+ ...(this.#storageFallback
1047
+ ? { storageFallback: this.#storageFallback }
1048
+ : {}),
1049
+ ...(this.#lastDiagnostic ? { lastDiagnostic: this.#lastDiagnostic } : {}),
1050
+ ...(this.#lastError ? { lastError: this.#lastError } : {}),
1051
+ };
1052
+ }
1053
+
1054
+ lifecycleState(): SyncularLifecycleState {
1055
+ return this.#computeLifecycleState();
1056
+ }
1057
+
1058
+ #computeLifecycleState(): SyncularLifecycleState {
1059
+ const outbox = this.#lastOutboxStats;
1060
+ const conflicts = this.#lastConflictStats;
1061
+ const blobUploads = this.#lastBlobUploadStats;
1062
+ const bootstrap = this.#lastBootstrap
1063
+ ? {
1064
+ complete: this.#lastBootstrap.complete,
1065
+ criticalReady: this.#lastBootstrap.criticalReady,
1066
+ interactiveReady: this.#lastBootstrap.interactiveReady,
1067
+ isBootstrapping: this.#lastBootstrap.isBootstrapping,
1068
+ progressPercent: this.#lastBootstrap.progressPercent,
1069
+ }
1070
+ : undefined;
1071
+ const hasFailedOutbox = (outbox?.failed ?? 0) > 0;
1072
+ const hasConflicts = (conflicts?.unresolved ?? 0) > 0;
1073
+ const hasFailedBlobUploads = (blobUploads?.failed ?? 0) > 0;
1074
+ const authRequired =
1075
+ this.#authRequired || this.#lastError?.code === 'sync.auth_required';
1076
+ const offline = this.#networkOnline === false;
1077
+ const pendingSyncRequests = this.#pendingSyncRequestCount();
1078
+ const phase: SyncularLifecycleState['phase'] = this.#closed
1079
+ ? 'closed'
1080
+ : authRequired
1081
+ ? 'authRequired'
1082
+ : offline
1083
+ ? 'offline'
1084
+ : this.#recoveryRequired || bootstrap?.isBootstrapping === true
1085
+ ? 'recovering'
1086
+ : pendingSyncRequests > 0
1087
+ ? 'syncing'
1088
+ : this.#realtimeState === 'connecting'
1089
+ ? 'connecting'
1090
+ : hasFailedOutbox || hasConflicts || hasFailedBlobUploads
1091
+ ? 'degraded'
1092
+ : bootstrap?.complete === true
1093
+ ? 'complete'
1094
+ : 'offline';
1095
+
1096
+ return {
1097
+ phase,
1098
+ realtime: this.#realtimeState,
1099
+ online: this.#networkOnline ?? this.#realtimeState === 'connected',
1100
+ requiresAction:
1101
+ authRequired || hasFailedOutbox || hasConflicts || hasFailedBlobUploads,
1102
+ pendingRequests: this.#pending.size,
1103
+ ...(bootstrap ? { bootstrap } : {}),
1104
+ ...(outbox ? { outbox } : {}),
1105
+ ...(conflicts ? { conflicts } : {}),
1106
+ ...(blobUploads ? { blobUploads } : {}),
1107
+ ...(this.#lastDiagnostic ? { lastDiagnostic: this.#lastDiagnostic } : {}),
1108
+ ...(this.#lastError ? { lastError: this.#lastError } : {}),
1109
+ };
1110
+ }
1111
+
1112
+ async diagnosticSnapshot(): Promise<SyncularDiagnosticSnapshot> {
1113
+ const [runtime, transportStats] = await Promise.all([
1114
+ this.runtimeInfo(),
1115
+ this.transportStats().catch(() => undefined),
1116
+ ]);
1117
+ const outboxStats =
1118
+ this.#lastOutboxStats ??
1119
+ (await this.#readOutboxStats().catch(() => undefined));
1120
+ if (outboxStats) this.#lastOutboxStats = outboxStats;
1121
+ const conflictStats =
1122
+ this.#lastConflictStats ??
1123
+ (await this.#readConflictStats().catch(() => undefined));
1124
+ if (conflictStats) this.#lastConflictStats = conflictStats;
1125
+ const blobUploadStats =
1126
+ this.#lastBlobUploadStats ??
1127
+ (await this.blobUploadQueueStats().catch(() => undefined));
1128
+ return {
1129
+ generatedAt: Date.now(),
1130
+ runtime,
1131
+ connection: this.connectionState(),
1132
+ subscriptions: summarizeSyncularDiagnosticSubscriptions(
1133
+ this.#subscriptions,
1134
+ this.#lastBootstrap
1135
+ ),
1136
+ recentDiagnostics: [...this.#recentDiagnostics],
1137
+ recentSyncTimings: [...this.#recentSyncTimings],
1138
+ ...(this.#lastBootstrap ? { bootstrap: this.#lastBootstrap } : {}),
1139
+ ...(transportStats ? { transportStats } : {}),
1140
+ ...(outboxStats ? { outboxStats } : {}),
1141
+ ...(conflictStats ? { conflictStats } : {}),
1142
+ ...(blobUploadStats ? { blobUploadStats } : {}),
1143
+ };
1144
+ }
1145
+
1146
+ setStorageFallback(fallback: SyncularStorageFallbackInfo): void {
1147
+ this.#storageFallback = fallback;
1148
+ this.#emitDiagnostic({
1149
+ level: 'warn',
1150
+ source: 'storage',
1151
+ code: 'storage.fallback',
1152
+ message: `Syncular storage fell back from ${fallback.from} to ${fallback.to}`,
1153
+ details: {
1154
+ from: fallback.from,
1155
+ to: fallback.to,
1156
+ reason: fallback.reason,
1157
+ },
1158
+ });
1159
+ }
1160
+
1161
+ addDiagnosticListener(listener: SyncularDiagnosticSink): () => void {
1162
+ this.#diagnosticListeners.add(listener);
1163
+ return () => {
1164
+ this.#diagnosticListeners.delete(listener);
1165
+ };
1166
+ }
1167
+
1168
+ addEventListener<T extends SyncularClientEventType>(
1169
+ event: T,
1170
+ listener: SyncularClientEventSink<T>
1171
+ ): () => void {
1172
+ const listeners = this.#eventListeners.get(event) ?? new Set();
1173
+ listeners.add(listener as SyncularClientEventSink<SyncularClientEventType>);
1174
+ this.#eventListeners.set(event, listeners);
1175
+ return () => {
1176
+ listeners.delete(
1177
+ listener as SyncularClientEventSink<SyncularClientEventType>
1178
+ );
1179
+ if (listeners.size === 0) this.#eventListeners.delete(event);
1180
+ };
1181
+ }
1182
+
1183
+ addRowsChangedListener(listener: SyncularRowsChangedSink): () => void {
1184
+ this.#rowsChangedListeners.add(listener);
1185
+ return () => {
1186
+ this.#rowsChangedListeners.delete(listener);
1187
+ };
1188
+ }
1189
+
1190
+ getPresence<TMetadata = Record<string, unknown>>(
1191
+ scopeKey: string
1192
+ ): SyncularPresenceEntry<TMetadata>[] {
1193
+ return (this.#presenceByScopeKey.get(scopeKey) ??
1194
+ []) as SyncularPresenceEntry<TMetadata>[];
1195
+ }
1196
+
1197
+ joinPresence(scopeKey: string, metadata?: Record<string, unknown>): void {
1198
+ this.#joinedPresence.set(scopeKey, metadata);
1199
+ void this.#request({
1200
+ type: 'sendPresence',
1201
+ action: 'join',
1202
+ scopeKey,
1203
+ ...(metadata === undefined ? {} : { metadata }),
1204
+ }).catch((error) => {
1205
+ this.#emitDiagnostic({
1206
+ level: 'warn',
1207
+ source: 'realtime',
1208
+ code: 'realtime.presence_join_failed',
1209
+ message: `Syncular presence join failed: ${errorMessage(error)}`,
1210
+ details: { scopeKey },
1211
+ });
1212
+ });
1213
+ this.#applyPresenceEvent({
1214
+ action: 'join',
1215
+ scopeKey,
1216
+ clientId: this.#config?.clientId ?? '',
1217
+ actorId: this.#config?.actorId ?? '',
1218
+ metadata,
1219
+ });
1220
+ }
1221
+
1222
+ leavePresence(scopeKey: string): void {
1223
+ this.#joinedPresence.delete(scopeKey);
1224
+ void this.#request({
1225
+ type: 'sendPresence',
1226
+ action: 'leave',
1227
+ scopeKey,
1228
+ }).catch((error) => {
1229
+ this.#emitDiagnostic({
1230
+ level: 'warn',
1231
+ source: 'realtime',
1232
+ code: 'realtime.presence_leave_failed',
1233
+ message: `Syncular presence leave failed: ${errorMessage(error)}`,
1234
+ details: { scopeKey },
1235
+ });
1236
+ });
1237
+ this.#applyPresenceEvent({
1238
+ action: 'leave',
1239
+ scopeKey,
1240
+ clientId: this.#config?.clientId ?? '',
1241
+ actorId: this.#config?.actorId ?? '',
1242
+ });
1243
+ }
1244
+
1245
+ updatePresenceMetadata(
1246
+ scopeKey: string,
1247
+ metadata: Record<string, unknown>
1248
+ ): void {
1249
+ if (this.#joinedPresence.has(scopeKey)) {
1250
+ this.#joinedPresence.set(scopeKey, metadata);
1251
+ }
1252
+ void this.#request({
1253
+ type: 'sendPresence',
1254
+ action: 'update',
1255
+ scopeKey,
1256
+ metadata,
1257
+ }).catch((error) => {
1258
+ this.#emitDiagnostic({
1259
+ level: 'warn',
1260
+ source: 'realtime',
1261
+ code: 'realtime.presence_update_failed',
1262
+ message: `Syncular presence update failed: ${errorMessage(error)}`,
1263
+ details: { scopeKey },
1264
+ });
1265
+ });
1266
+ this.#applyPresenceEvent({
1267
+ action: 'update',
1268
+ scopeKey,
1269
+ clientId: this.#config?.clientId ?? '',
1270
+ actorId: this.#config?.actorId ?? '',
1271
+ metadata,
1272
+ });
1273
+ }
1274
+
1275
+ addPresenceListener<TMetadata = Record<string, unknown>>(
1276
+ listener: SyncularPresenceSink<TMetadata>
1277
+ ): () => void {
1278
+ return this.addEventListener(
1279
+ 'presenceChanged',
1280
+ listener as SyncularClientEventSink<'presenceChanged'>
1281
+ );
1282
+ }
1283
+
1284
+ addLiveQueryListener(
1285
+ queryId: string,
1286
+ listener: (event: SyncularLiveQueryEvent<Record<string, unknown>>) => void
1287
+ ): void {
1288
+ this.#liveListeners.set(queryId, listener);
1289
+ }
1290
+
1291
+ removeLiveQueryListener(queryId: string): void {
1292
+ this.#liveListeners.delete(queryId);
1293
+ }
1294
+
1295
+ async close(): Promise<void> {
1296
+ if (this.#closed) return;
1297
+ this.#closed = true;
1298
+ this.#realtimeState = 'disconnected';
1299
+ this.#unsubscribeNetwork?.();
1300
+ this.#unsubscribeNetwork = undefined;
1301
+ this.#emitLifecycleChanged();
1302
+ try {
1303
+ await this.#request({ type: 'close' });
1304
+ } finally {
1305
+ this.#flushRowsChanged();
1306
+ this.#rejectAll(
1307
+ createSyncularWorkerErrorPayload(
1308
+ 'worker.closed',
1309
+ 'Syncular worker client closed'
1310
+ )
1311
+ );
1312
+ if (this.ownsWorker) this.worker.terminate();
1313
+ }
1314
+ }
1315
+
1316
+ async #requestAndDrain<T>(request: SyncularWorkerRequestInput): Promise<T> {
1317
+ const value = await this.#request<T>(request);
1318
+ await this.#emitLiveEvents();
1319
+ if (shouldEmitOperationalState(request.type)) {
1320
+ void this.#emitOperationalState();
1321
+ }
1322
+ return value;
1323
+ }
1324
+
1325
+ async #syncWithAuthRetry(
1326
+ request: Extract<
1327
+ SyncularWorkerRequestInput,
1328
+ { type: 'syncPull' | 'syncPush' | 'syncOnce' }
1329
+ >,
1330
+ options: { refreshAuthHeaders: boolean } = { refreshAuthHeaders: true }
1331
+ ): Promise<SyncularSyncResult> {
1332
+ const config = this.#config;
1333
+ if (!config) {
1334
+ throw new Error('Syncular worker client must be opened before sync');
1335
+ }
1336
+ assertSyncularRemoteMode(config, request.type);
1337
+ if (options.refreshAuthHeaders) {
1338
+ await this.#refreshAuthHeaders({ restartRealtime: false });
1339
+ }
1340
+ try {
1341
+ const result = await this.#requestAndDrain<SyncularSyncResult>(request);
1342
+ this.#emitBootstrapChanged(result);
1343
+ return result;
1344
+ } catch (error) {
1345
+ const shouldRetry = await this.#resolveAuthRetry(error, 'sync');
1346
+ if (!shouldRetry) throw error;
1347
+ await this.#refreshAuthHeaders({ restartRealtime: false });
1348
+ const result = await this.#requestAndDrain<SyncularSyncResult>(request);
1349
+ this.#emitBootstrapChanged(result);
1350
+ return result;
1351
+ }
1352
+ }
1353
+
1354
+ async #requestWithAuthRetry<T>(
1355
+ request: SyncularWorkerRequestInput,
1356
+ operation: SyncAuthOperation
1357
+ ): Promise<T> {
1358
+ await this.#refreshAuthHeaders();
1359
+ try {
1360
+ return await this.#request<T>(request);
1361
+ } catch (error) {
1362
+ const shouldRetry = await this.#resolveAuthRetry(error, operation);
1363
+ if (!shouldRetry) throw error;
1364
+ await this.#refreshAuthHeaders();
1365
+ return this.#request<T>(request);
1366
+ }
1367
+ }
1368
+
1369
+ async #refreshAuthHeaders(
1370
+ options: { restartRealtime: boolean } = { restartRealtime: true }
1371
+ ): Promise<void> {
1372
+ if (!this.#getHeaders) return;
1373
+ const headers = await this.#getHeaders();
1374
+ this.#emitDiagnostic({
1375
+ level: 'debug',
1376
+ source: 'auth',
1377
+ code: 'auth.headers_refreshed',
1378
+ message: 'Syncular auth headers refreshed',
1379
+ details: { headerCount: Object.keys(headers).length },
1380
+ });
1381
+ await this.#setAuthHeaders(headers, options);
1382
+ }
1383
+
1384
+ async #restartRealtime(): Promise<void> {
1385
+ const realtimeOptions = this.#realtimeOptions;
1386
+ const config = this.#config;
1387
+ if (!realtimeOptions || !config || this.#closed) return;
1388
+ await this.#request({
1389
+ type: 'startRealtime',
1390
+ options: await resolveRealtimeWorkerOptions(realtimeOptions, config),
1391
+ });
1392
+ }
1393
+
1394
+ async #resolveAuthRetry(
1395
+ error: unknown,
1396
+ operation: SyncAuthOperation
1397
+ ): Promise<boolean> {
1398
+ const status = authStatusFromError(error);
1399
+ const lifecycle = this.#authLifecycle;
1400
+ if (!status || !lifecycle) return false;
1401
+
1402
+ const context = { operation, status };
1403
+ this.#emitDiagnostic({
1404
+ level: 'warn',
1405
+ source: 'auth',
1406
+ code: 'auth.expired',
1407
+ message: `Syncular auth expired during ${operation}`,
1408
+ details: context,
1409
+ });
1410
+ await lifecycle.onAuthExpired?.(context);
1411
+ const refreshResult = await this.#refreshAuthSingleFlight(context);
1412
+ this.#emitDiagnostic({
1413
+ level: refreshResult ? 'info' : 'warn',
1414
+ source: 'auth',
1415
+ code: refreshResult ? 'auth.refresh_succeeded' : 'auth.refresh_failed',
1416
+ message: refreshResult
1417
+ ? 'Syncular auth refresh succeeded'
1418
+ : 'Syncular auth refresh did not produce fresh credentials',
1419
+ details: context,
1420
+ });
1421
+ if (lifecycle.retryWithFreshToken) {
1422
+ return Boolean(
1423
+ await lifecycle.retryWithFreshToken({ ...context, refreshResult })
1424
+ );
1425
+ }
1426
+ return refreshResult;
1427
+ }
1428
+
1429
+ #refreshAuthSingleFlight(context: {
1430
+ operation: SyncAuthOperation;
1431
+ status: 401 | 403;
1432
+ }): Promise<boolean> {
1433
+ const refreshToken = this.#authLifecycle?.refreshToken;
1434
+ if (!refreshToken) return Promise.resolve(false);
1435
+ this.#authRefreshInFlight ??= Promise.resolve(refreshToken(context))
1436
+ .then(Boolean)
1437
+ .finally(() => {
1438
+ this.#authRefreshInFlight = undefined;
1439
+ });
1440
+ return this.#authRefreshInFlight;
1441
+ }
1442
+
1443
+ async #emitLiveEvents(): Promise<void> {
1444
+ const events = await this.drainLiveQueryEvents<Record<string, unknown>>();
1445
+ for (const event of events) {
1446
+ this.#liveListeners.get(event.queryId)?.(event);
1447
+ }
1448
+ }
1449
+
1450
+ #request<T>(request: SyncularWorkerRequestInput): Promise<T> {
1451
+ if (this.#closed && request.type !== 'close') {
1452
+ return Promise.reject(
1453
+ new SyncularWorkerError(
1454
+ createSyncularWorkerErrorPayload(
1455
+ 'worker.closed',
1456
+ 'Syncular worker client is closed'
1457
+ )
1458
+ )
1459
+ );
1460
+ }
1461
+ const id = this.#nextId++;
1462
+ const message = {
1463
+ ...request,
1464
+ id,
1465
+ protocolVersion: SYNCULAR_WORKER_PROTOCOL_VERSION,
1466
+ } as SyncularWorkerRequest;
1467
+ return new Promise<T>((resolve, reject) => {
1468
+ const timeout =
1469
+ this.#requestTimeoutMs > 0
1470
+ ? setTimeout(() => {
1471
+ this.#pending.delete(id);
1472
+ this.#sendCancel(id);
1473
+ this.#emitDiagnostic({
1474
+ level: 'warn',
1475
+ source: 'worker',
1476
+ code: 'worker.request_timeout',
1477
+ message: `Syncular worker request ${request.type} timed out`,
1478
+ details: {
1479
+ requestId: id,
1480
+ requestType: request.type,
1481
+ timeoutMs: this.#requestTimeoutMs,
1482
+ },
1483
+ });
1484
+ const error = new SyncularWorkerError(
1485
+ createSyncularWorkerErrorPayload(
1486
+ 'worker.request_timeout',
1487
+ `Syncular worker request ${request.type} timed out after ${this.#requestTimeoutMs}ms`,
1488
+ { details: { requestId: id, requestType: request.type } }
1489
+ )
1490
+ );
1491
+ this.#lastError = {
1492
+ message: error.message,
1493
+ code: error.code,
1494
+ };
1495
+ this.#emitLifecycleChanged();
1496
+ reject(error);
1497
+ }, this.#requestTimeoutMs)
1498
+ : undefined;
1499
+ this.#pending.set(id, {
1500
+ type: request.type,
1501
+ timeout,
1502
+ resolve: (value) => resolve(value as T),
1503
+ reject,
1504
+ });
1505
+ this.#emitLifecycleChanged();
1506
+ this.worker.postMessage(message);
1507
+ });
1508
+ }
1509
+
1510
+ #handleWorkerMessage(message: SyncularWorkerOutboundMessage): void {
1511
+ if (message.protocolVersion !== SYNCULAR_WORKER_PROTOCOL_VERSION) {
1512
+ this.#rejectAll(
1513
+ createSyncularWorkerErrorPayload(
1514
+ 'worker.protocol_mismatch',
1515
+ `Unsupported Syncular worker protocol ${message.protocolVersion}`,
1516
+ { details: { supported: SYNCULAR_WORKER_PROTOCOL_VERSION } }
1517
+ )
1518
+ );
1519
+ return;
1520
+ }
1521
+ if (isWorkerEvent(message)) {
1522
+ this.#handleWorkerEvent(message);
1523
+ return;
1524
+ }
1525
+ this.#handleResponse(message);
1526
+ }
1527
+
1528
+ #handleResponse(response: SyncularWorkerResponse): void {
1529
+ const pending = this.#pending.get(response.id);
1530
+ if (!pending) return;
1531
+ this.#pending.delete(response.id);
1532
+ if (pending.timeout) clearTimeout(pending.timeout);
1533
+ if (response.ok) {
1534
+ pending.resolve(response.value);
1535
+ } else {
1536
+ const error = new SyncularWorkerError(response.error);
1537
+ this.#lastError = {
1538
+ message: error.message,
1539
+ code: error.code,
1540
+ };
1541
+ pending.reject(error);
1542
+ }
1543
+ this.#emitLifecycleChanged();
1544
+ }
1545
+
1546
+ #handleWorkerEvent(
1547
+ event: Exclude<SyncularWorkerOutboundMessage, SyncularWorkerResponse>
1548
+ ): void {
1549
+ if (event.type === 'liveQueryEvents') {
1550
+ for (const liveEvent of event.events) {
1551
+ this.#liveListeners.get(liveEvent.queryId)?.(liveEvent);
1552
+ }
1553
+ return;
1554
+ }
1555
+ if (event.type === 'rowsChanged') {
1556
+ this.#emitRowsChanged({
1557
+ source: event.source,
1558
+ changedTables: event.changedTables,
1559
+ changedRows: event.changedRows,
1560
+ changedRowsTruncated: event.changedRowsTruncated,
1561
+ });
1562
+ return;
1563
+ }
1564
+ if (event.type === 'bootstrapChanged') {
1565
+ this.#lastBootstrap = event.bootstrap;
1566
+ if (event.bootstrap.complete) {
1567
+ this.#recoveryRequired = false;
1568
+ this.#authRequired = false;
1569
+ this.#lastError = undefined;
1570
+ }
1571
+ this.#emitClientEvent('bootstrapChanged', event.bootstrap);
1572
+ this.#emitLifecycleChanged();
1573
+ return;
1574
+ }
1575
+ if (event.type === 'realtimeState') {
1576
+ this.#realtimeState = event.state;
1577
+ this.#emitDiagnostic({
1578
+ level: 'info',
1579
+ source: 'realtime',
1580
+ code: 'realtime.state',
1581
+ message: `Syncular realtime is ${event.state}`,
1582
+ details: { state: event.state },
1583
+ });
1584
+ if (event.state === 'connected') {
1585
+ this.#rejoinPresence();
1586
+ }
1587
+ this.#emitLifecycleChanged();
1588
+ return;
1589
+ }
1590
+ if (event.type === 'presenceEvent') {
1591
+ this.#applyPresenceEvent(event);
1592
+ return;
1593
+ }
1594
+ this.#emitDiagnostic(event.event);
1595
+ }
1596
+
1597
+ #emitClientEvent<T extends SyncularClientEventType>(
1598
+ event: T,
1599
+ payload: SyncularClientEventMap[T]
1600
+ ): void {
1601
+ const listeners = this.#eventListeners.get(event);
1602
+ if (!listeners || listeners.size === 0) return;
1603
+ for (const listener of listeners) {
1604
+ try {
1605
+ listener(payload as SyncularClientEventMap[SyncularClientEventType]);
1606
+ } catch {
1607
+ // Client event listeners must never break sync control flow.
1608
+ }
1609
+ }
1610
+ }
1611
+
1612
+ #emitLifecycleChanged(): void {
1613
+ const state = this.#computeLifecycleState();
1614
+ if (this.#lastLifecycleState && sameJson(this.#lastLifecycleState, state)) {
1615
+ return;
1616
+ }
1617
+ this.#lastLifecycleState = state;
1618
+ this.#emitClientEvent('lifecycleChanged', state);
1619
+ }
1620
+
1621
+ #pendingSyncRequestCount(): number {
1622
+ let count = 0;
1623
+ for (const pending of this.#pending.values()) {
1624
+ if (
1625
+ pending.type === 'syncOnce' ||
1626
+ pending.type === 'syncPull' ||
1627
+ pending.type === 'syncPush'
1628
+ ) {
1629
+ count += 1;
1630
+ }
1631
+ }
1632
+ return count;
1633
+ }
1634
+
1635
+ #subscribeNetworkEvents(): (() => void) | undefined {
1636
+ const network = this.#network;
1637
+ if (!network?.addEventListener || !network.removeEventListener) return;
1638
+ const refresh = () => {
1639
+ const next = network.isOnline();
1640
+ if (next === this.#networkOnline) return;
1641
+ this.#networkOnline = next;
1642
+ this.#emitLifecycleChanged();
1643
+ };
1644
+ network.addEventListener('online', refresh);
1645
+ network.addEventListener('offline', refresh);
1646
+ return () => {
1647
+ network.removeEventListener?.('online', refresh);
1648
+ network.removeEventListener?.('offline', refresh);
1649
+ };
1650
+ }
1651
+
1652
+ #hasClientEventListeners(event: SyncularClientEventType): boolean {
1653
+ return (this.#eventListeners.get(event)?.size ?? 0) > 0;
1654
+ }
1655
+
1656
+ #hasDiagnosticListeners(): boolean {
1657
+ return this.#diagnosticListeners.size > 0;
1658
+ }
1659
+
1660
+ #emitRowsChanged(event: SyncularClientEventMap['rowsChanged']): void {
1661
+ if (
1662
+ this.#rowsChangedDebounceMs === false ||
1663
+ this.#rowsChangedDebounceMs <= 0
1664
+ ) {
1665
+ this.#deliverRowsChanged(event);
1666
+ return;
1667
+ }
1668
+ if (!this.#pendingRowsChanged) {
1669
+ this.#pendingRowsChanged = {
1670
+ source: event.source,
1671
+ changedTables: new Set(event.changedTables),
1672
+ changedRows: [...event.changedRows],
1673
+ changedRowsTruncated: event.changedRowsTruncated === true,
1674
+ };
1675
+ } else {
1676
+ const pending = this.#pendingRowsChanged;
1677
+ pending.source =
1678
+ pending.source === event.source ? pending.source : 'mixed';
1679
+ for (const table of event.changedTables) pending.changedTables.add(table);
1680
+ pending.changedRows.push(...event.changedRows);
1681
+ pending.changedRowsTruncated ||= event.changedRowsTruncated === true;
1682
+ }
1683
+ if (this.#rowsChangedDebounceTimer) return;
1684
+ this.#rowsChangedDebounceTimer = setTimeout(() => {
1685
+ this.#rowsChangedDebounceTimer = undefined;
1686
+ this.#flushRowsChanged();
1687
+ }, this.#rowsChangedDebounceMs);
1688
+ }
1689
+
1690
+ #flushRowsChanged(): void {
1691
+ if (this.#rowsChangedDebounceTimer) {
1692
+ clearTimeout(this.#rowsChangedDebounceTimer);
1693
+ this.#rowsChangedDebounceTimer = undefined;
1694
+ }
1695
+ const pending = this.#pendingRowsChanged;
1696
+ this.#pendingRowsChanged = undefined;
1697
+ if (!pending) return;
1698
+ this.#deliverRowsChanged({
1699
+ source: pending.source,
1700
+ changedTables: [...pending.changedTables],
1701
+ changedRows: pending.changedRows,
1702
+ changedRowsTruncated: pending.changedRowsTruncated,
1703
+ });
1704
+ }
1705
+
1706
+ #deliverRowsChanged(event: SyncularClientEventMap['rowsChanged']): void {
1707
+ for (const listener of this.#rowsChangedListeners) {
1708
+ try {
1709
+ listener(event);
1710
+ } catch {
1711
+ // Row-change listeners must never break worker event handling.
1712
+ }
1713
+ }
1714
+ this.#emitClientEvent('rowsChanged', event);
1715
+ }
1716
+
1717
+ #emitBootstrapChanged(result: SyncularSyncResult): void {
1718
+ this.#lastBootstrap = result.bootstrap;
1719
+ this.#recoveryRequired = false;
1720
+ this.#authRequired = false;
1721
+ this.#lastError = undefined;
1722
+ appendSyncularSyncTimings(this.#recentSyncTimings, result.timings);
1723
+ this.#emitClientEvent('bootstrapChanged', result.bootstrap);
1724
+ this.#emitLifecycleChanged();
1725
+ }
1726
+
1727
+ async #emitOperationalState(): Promise<void> {
1728
+ const observeLifecycle = this.#hasClientEventListeners('lifecycleChanged');
1729
+ const observeOutbox =
1730
+ observeLifecycle || this.#hasClientEventListeners('outboxChanged');
1731
+ const observeConflicts =
1732
+ observeLifecycle || this.#hasClientEventListeners('conflictsChanged');
1733
+ const observeBlobUploads =
1734
+ observeLifecycle || this.#hasClientEventListeners('blobUploadsChanged');
1735
+ if (!observeOutbox && !observeConflicts && !observeBlobUploads) {
1736
+ return;
1737
+ }
1738
+ const [outboxStats, conflictStats, blobUploadStats] = await Promise.all([
1739
+ observeOutbox
1740
+ ? this.#readOutboxStats().catch(() => undefined)
1741
+ : Promise.resolve(undefined),
1742
+ observeConflicts
1743
+ ? this.#readConflictStats().catch(() => undefined)
1744
+ : Promise.resolve(undefined),
1745
+ observeBlobUploads
1746
+ ? this.#readBlobUploadStats().catch(() => undefined)
1747
+ : Promise.resolve(undefined),
1748
+ ]);
1749
+ if (outboxStats && !sameJson(this.#lastOutboxStats, outboxStats)) {
1750
+ this.#lastOutboxStats = outboxStats;
1751
+ this.#emitClientEvent('outboxChanged', outboxStats);
1752
+ }
1753
+ if (conflictStats && !sameJson(this.#lastConflictStats, conflictStats)) {
1754
+ this.#lastConflictStats = conflictStats;
1755
+ this.#emitClientEvent('conflictsChanged', conflictStats);
1756
+ }
1757
+ if (
1758
+ blobUploadStats &&
1759
+ !sameJson(this.#lastBlobUploadStats, blobUploadStats)
1760
+ ) {
1761
+ this.#lastBlobUploadStats = blobUploadStats;
1762
+ this.#emitClientEvent('blobUploadsChanged', blobUploadStats);
1763
+ }
1764
+ this.#emitLifecycleChanged();
1765
+ }
1766
+
1767
+ #readBlobUploadStats(): Promise<SyncularBlobUploadQueueStats> {
1768
+ return this.#request({ type: 'blobUploadQueueStats' });
1769
+ }
1770
+
1771
+ async #readOutboxStats(): Promise<SyncularOutboxStats> {
1772
+ const result = await this.#request<
1773
+ SyncularSqlResult<{ status: string; count: number }>
1774
+ >({
1775
+ type: 'executeUnsafeSql',
1776
+ sql: 'select status, count(*) as count from sync_outbox_commits group by status',
1777
+ params: [],
1778
+ });
1779
+ const stats: SyncularOutboxStats = {
1780
+ pending: 0,
1781
+ sending: 0,
1782
+ failed: 0,
1783
+ acked: 0,
1784
+ total: 0,
1785
+ };
1786
+ for (const row of result.rows) {
1787
+ const count = coerceCount(row.count);
1788
+ if (row.status === 'pending') stats.pending = count;
1789
+ if (row.status === 'sending') stats.sending = count;
1790
+ if (row.status === 'failed') stats.failed = count;
1791
+ if (row.status === 'acked') stats.acked = count;
1792
+ stats.total += count;
1793
+ }
1794
+ return stats;
1795
+ }
1796
+
1797
+ async #readConflictStats(): Promise<SyncularConflictStats> {
1798
+ const result = await this.#request<
1799
+ SyncularSqlResult<{
1800
+ unresolved: number;
1801
+ resolved: number;
1802
+ total: number;
1803
+ }>
1804
+ >({
1805
+ type: 'executeUnsafeSql',
1806
+ sql:
1807
+ 'select ' +
1808
+ 'coalesce(sum(case when resolved_at is null then 1 else 0 end), 0) as unresolved, ' +
1809
+ 'coalesce(sum(case when resolved_at is not null then 1 else 0 end), 0) as resolved, ' +
1810
+ 'count(*) as total from sync_conflicts',
1811
+ params: [],
1812
+ });
1813
+ const row = result.rows[0];
1814
+ return {
1815
+ unresolved: coerceCount(row?.unresolved),
1816
+ resolved: coerceCount(row?.resolved),
1817
+ total: coerceCount(row?.total),
1818
+ };
1819
+ }
1820
+
1821
+ async #readBlobOutboxRows(): Promise<BlobOutboxRow[]> {
1822
+ const result = await this.#request<SyncularSqlResult<BlobOutboxRow>>({
1823
+ type: 'executeUnsafeSql',
1824
+ sql:
1825
+ 'select hash, size, mime_type, encrypted, key_id, status, error ' +
1826
+ 'from sync_blob_outbox order by created_at asc',
1827
+ params: [],
1828
+ });
1829
+ return result.rows;
1830
+ }
1831
+
1832
+ #emitBlobUploadEvents(
1833
+ beforeRows: readonly BlobOutboxRow[],
1834
+ afterRows: readonly BlobOutboxRow[]
1835
+ ): void {
1836
+ const after = new Map(afterRows.map((row) => [row.hash, row]));
1837
+ for (const before of beforeRows) {
1838
+ const next = after.get(before.hash);
1839
+ if (!next) {
1840
+ const ref = {
1841
+ hash: before.hash,
1842
+ size: coerceCount(before.size),
1843
+ mimeType: before.mime_type,
1844
+ encrypted: before.encrypted === true || before.encrypted === 1,
1845
+ ...(before.key_id ? { keyId: before.key_id } : {}),
1846
+ };
1847
+ this.#emitClientEvent('blobUploadCompleted', {
1848
+ ref,
1849
+ });
1850
+ if (this.#hasDiagnosticListeners()) {
1851
+ this.#emitDiagnostic({
1852
+ level: 'info',
1853
+ source: 'blob',
1854
+ code: 'blob.upload_completed',
1855
+ message: 'Syncular blob upload completed',
1856
+ details: ref,
1857
+ });
1858
+ }
1859
+ continue;
1860
+ }
1861
+ if (before.status !== 'failed' && next.status === 'failed') {
1862
+ const ref = {
1863
+ hash: next.hash,
1864
+ size: coerceCount(next.size),
1865
+ mimeType: next.mime_type,
1866
+ encrypted: next.encrypted === true || next.encrypted === 1,
1867
+ ...(next.key_id ? { keyId: next.key_id } : {}),
1868
+ };
1869
+ this.#emitClientEvent('blobUploadFailed', {
1870
+ hash: next.hash,
1871
+ error: next.error ?? 'Blob upload failed',
1872
+ ref,
1873
+ });
1874
+ if (this.#hasDiagnosticListeners()) {
1875
+ this.#emitDiagnostic({
1876
+ level: 'warn',
1877
+ source: 'blob',
1878
+ code: 'blob.upload_failed',
1879
+ message: next.error ?? 'Syncular blob upload failed',
1880
+ details: {
1881
+ ...ref,
1882
+ error: next.error ?? 'Blob upload failed',
1883
+ },
1884
+ });
1885
+ }
1886
+ }
1887
+ }
1888
+ }
1889
+
1890
+ #applyPresenceEvent(event: {
1891
+ action: 'join' | 'leave' | 'update' | 'snapshot';
1892
+ scopeKey: string;
1893
+ clientId?: string;
1894
+ actorId?: string;
1895
+ metadata?: Record<string, unknown>;
1896
+ entries?: SyncularPresenceEntry[];
1897
+ }): void {
1898
+ const scopeKey = normalizePresenceScopeKey(event.scopeKey);
1899
+ if (!scopeKey) return;
1900
+ const current = this.#presenceByScopeKey.get(scopeKey) ?? [];
1901
+ let next: SyncularPresenceEntry[];
1902
+ if (event.action === 'snapshot') {
1903
+ next = event.entries ?? [];
1904
+ } else {
1905
+ if (!event.clientId || !event.actorId) return;
1906
+ if (event.action === 'leave') {
1907
+ next = current.filter((entry) => entry.clientId !== event.clientId);
1908
+ } else if (event.action === 'update') {
1909
+ const existing = current.find(
1910
+ (entry) => entry.clientId === event.clientId
1911
+ );
1912
+ if (!existing) return;
1913
+ next = current.map((entry) =>
1914
+ entry.clientId === event.clientId
1915
+ ? { ...entry, metadata: event.metadata }
1916
+ : entry
1917
+ );
1918
+ } else {
1919
+ const existing = current.find(
1920
+ (entry) => entry.clientId === event.clientId
1921
+ );
1922
+ next = [
1923
+ ...current.filter((entry) => entry.clientId !== event.clientId),
1924
+ {
1925
+ clientId: event.clientId,
1926
+ actorId: event.actorId,
1927
+ joinedAt: existing?.joinedAt ?? Date.now(),
1928
+ metadata: event.metadata,
1929
+ },
1930
+ ];
1931
+ }
1932
+ }
1933
+ if (arePresenceEntriesEqual(current, next)) return;
1934
+ if (next.length === 0) {
1935
+ this.#presenceByScopeKey.delete(scopeKey);
1936
+ } else {
1937
+ this.#presenceByScopeKey.set(scopeKey, next);
1938
+ }
1939
+ this.#emitClientEvent('presenceChanged', {
1940
+ scopeKey,
1941
+ presence: next,
1942
+ });
1943
+ }
1944
+
1945
+ #rejoinPresence(): void {
1946
+ if (this.#joinedPresence.size === 0) return;
1947
+ for (const [scopeKey, metadata] of this.#joinedPresence) {
1948
+ void this.#request({
1949
+ type: 'sendPresence',
1950
+ action: 'join',
1951
+ scopeKey,
1952
+ ...(metadata === undefined ? {} : { metadata }),
1953
+ }).catch((error) => {
1954
+ this.#emitDiagnostic({
1955
+ level: 'warn',
1956
+ source: 'realtime',
1957
+ code: 'realtime.presence_rejoin_failed',
1958
+ message: `Syncular presence rejoin failed: ${errorMessage(error)}`,
1959
+ details: { scopeKey },
1960
+ });
1961
+ });
1962
+ }
1963
+ }
1964
+
1965
+ #rejectAll(reason: SyncularWorkerErrorPayload | Error): void {
1966
+ const error =
1967
+ reason instanceof Error ? reason : new SyncularWorkerError(reason);
1968
+ this.#lastError = {
1969
+ message: error.message,
1970
+ ...(error instanceof SyncularWorkerError ? { code: error.code } : {}),
1971
+ };
1972
+ for (const pending of this.#pending.values()) {
1973
+ if (pending.timeout) clearTimeout(pending.timeout);
1974
+ pending.reject(error);
1975
+ }
1976
+ this.#pending.clear();
1977
+ }
1978
+
1979
+ #sendCancel(requestId: number): void {
1980
+ const id = this.#nextId++;
1981
+ this.worker.postMessage({
1982
+ id,
1983
+ protocolVersion: SYNCULAR_WORKER_PROTOCOL_VERSION,
1984
+ type: 'cancel',
1985
+ requestId,
1986
+ } satisfies SyncularWorkerRequest);
1987
+ }
1988
+
1989
+ #emitDiagnostic(
1990
+ event: Omit<SyncularDiagnosticEvent, 'at'> & { at?: number }
1991
+ ): void {
1992
+ const diagnostic: SyncularDiagnosticEvent = {
1993
+ at: event.at ?? Date.now(),
1994
+ level: event.level,
1995
+ source: event.source,
1996
+ code: event.code,
1997
+ message: event.message,
1998
+ ...(event.syncAttemptId ? { syncAttemptId: event.syncAttemptId } : {}),
1999
+ ...(event.traceId ? { traceId: event.traceId } : {}),
2000
+ ...(event.spanId ? { spanId: event.spanId } : {}),
2001
+ ...(event.clientId ? { clientId: event.clientId } : {}),
2002
+ ...(event.subscriptionId ? { subscriptionId: event.subscriptionId } : {}),
2003
+ ...(event.table ? { table: event.table } : {}),
2004
+ ...(event.rowId ? { rowId: event.rowId } : {}),
2005
+ ...(event.cursor !== undefined ? { cursor: event.cursor } : {}),
2006
+ ...(event.details ? { details: event.details } : {}),
2007
+ };
2008
+ if (diagnostic.details?.resyncRequired === true) {
2009
+ this.#recoveryRequired = true;
2010
+ }
2011
+ if (
2012
+ diagnostic.code === 'auth.expired' ||
2013
+ diagnostic.code === 'auth.refresh_failed' ||
2014
+ diagnostic.code === 'sync.auth_required'
2015
+ ) {
2016
+ this.#authRequired = true;
2017
+ }
2018
+ if (diagnostic.code === 'auth.refresh_succeeded') {
2019
+ this.#authRequired = false;
2020
+ }
2021
+ this.#lastDiagnostic = diagnostic;
2022
+ appendSyncularDiagnosticEvent(this.#recentDiagnostics, diagnostic);
2023
+ this.#emitLifecycleChanged();
2024
+ if (this.#diagnosticListeners.size === 0) return;
2025
+ for (const listener of this.#diagnosticListeners) {
2026
+ try {
2027
+ listener(diagnostic);
2028
+ } catch {
2029
+ // Diagnostics must never break client control flow.
2030
+ }
2031
+ }
2032
+ }
2033
+ }
2034
+
2035
+ function isOpfsOpenFailure(error: unknown): boolean {
2036
+ if (!(error instanceof SyncularWorkerError)) return false;
2037
+ const message = errorMessage(error).toLowerCase();
2038
+ return (
2039
+ message.includes('opfs') ||
2040
+ message.includes('sahpool') ||
2041
+ message.includes('sync access handle')
2042
+ );
2043
+ }
2044
+
2045
+ function shouldEmitOperationalState(
2046
+ type: SyncularWorkerRequestInput['type']
2047
+ ): boolean {
2048
+ return isGeneratedSyncularOperationalStateWorkerRequestType(type);
2049
+ }
2050
+
2051
+ function errorMessage(error: unknown): string {
2052
+ return error instanceof Error ? error.message : String(error);
2053
+ }
2054
+
2055
+ function normalizePresenceScopeKey(scopeKey: string): string {
2056
+ const separator = scopeKey.indexOf('::');
2057
+ return separator >= 0 ? scopeKey.slice(separator + 2) : scopeKey;
2058
+ }
2059
+
2060
+ function coerceCount(value: unknown): number {
2061
+ const numeric = typeof value === 'number' ? value : Number(value ?? 0);
2062
+ return Number.isFinite(numeric) && numeric > 0 ? Math.trunc(numeric) : 0;
2063
+ }
2064
+
2065
+ function sameJson(left: unknown, right: unknown): boolean {
2066
+ return JSON.stringify(left) === JSON.stringify(right);
2067
+ }
2068
+
2069
+ function cloneAuthHeaders(headers: SyncularAuthHeaders): SyncularAuthHeaders {
2070
+ return Object.fromEntries(Object.entries(headers));
2071
+ }
2072
+
2073
+ function serializeRuntimeArtifact(
2074
+ runtime: SyncularRuntimeArtifact | undefined
2075
+ ): SyncularWorkerRuntimeArtifact | undefined {
2076
+ if (!runtime) return undefined;
2077
+ return {
2078
+ wasmGlueUrl:
2079
+ runtime.wasmGlueUrl == null
2080
+ ? undefined
2081
+ : runtimeUrlHref(runtime.wasmGlueUrl),
2082
+ wasmUrl:
2083
+ runtime.wasmUrl == null ? undefined : runtimeUrlHref(runtime.wasmUrl),
2084
+ };
2085
+ }
2086
+
2087
+ function runtimeUrlHref(value: string | URL | Request): string {
2088
+ if (value instanceof Request) return value.url;
2089
+ return value instanceof URL ? value.href : value;
2090
+ }
2091
+
2092
+ function normalizeFieldEncryptionConfig(
2093
+ config: SyncularFieldEncryptionConfig
2094
+ ): Omit<SyncularFieldEncryptionConfig, 'keys'> & {
2095
+ keys: Record<string, string>;
2096
+ } {
2097
+ const keys: Record<string, string> = {};
2098
+ for (const [kid, value] of Object.entries(config.keys)) {
2099
+ keys[kid] = value instanceof Uint8Array ? bytesToBase64Url(value) : value;
2100
+ }
2101
+ return { ...config, keys };
2102
+ }
2103
+
2104
+ function normalizeEncryptedCrdtConfig(
2105
+ config: SyncularEncryptedCrdtConfig
2106
+ ): Omit<SyncularEncryptedCrdtConfig, 'keys'> & {
2107
+ keys: Record<string, string>;
2108
+ } {
2109
+ const keys: Record<string, string> = {};
2110
+ for (const [kid, value] of Object.entries(config.keys)) {
2111
+ keys[kid] = value instanceof Uint8Array ? bytesToBase64Url(value) : value;
2112
+ }
2113
+ return { ...config, keys };
2114
+ }
2115
+
2116
+ function normalizeBlobEncryptionConfig(
2117
+ config: SyncularBlobEncryptionConfig
2118
+ ): Omit<SyncularBlobEncryptionConfig, 'keys'> & {
2119
+ keys: Record<string, string>;
2120
+ } {
2121
+ const keys: Record<string, string> = {};
2122
+ for (const [kid, value] of Object.entries(config.keys)) {
2123
+ keys[kid] = value instanceof Uint8Array ? bytesToBase64Url(value) : value;
2124
+ }
2125
+ return { ...config, keys };
2126
+ }
2127
+
2128
+ function arePresenceEntriesEqual(
2129
+ left: readonly SyncularPresenceEntry[],
2130
+ right: readonly SyncularPresenceEntry[]
2131
+ ): boolean {
2132
+ if (left.length !== right.length) return false;
2133
+ for (let index = 0; index < left.length; index += 1) {
2134
+ const a = left[index]!;
2135
+ const b = right[index]!;
2136
+ if (
2137
+ a.clientId !== b.clientId ||
2138
+ a.actorId !== b.actorId ||
2139
+ a.joinedAt !== b.joinedAt ||
2140
+ JSON.stringify(a.metadata ?? {}) !== JSON.stringify(b.metadata ?? {})
2141
+ ) {
2142
+ return false;
2143
+ }
2144
+ }
2145
+ return true;
2146
+ }
2147
+
2148
+ function bytesToBase64Url(bytes: Uint8Array): string {
2149
+ let binary = '';
2150
+ for (const byte of bytes) binary += String.fromCharCode(byte);
2151
+ return btoa(binary)
2152
+ .replace(/\+/g, '-')
2153
+ .replace(/\//g, '_')
2154
+ .replace(/=+$/g, '');
2155
+ }
2156
+
2157
+ async function resolveRealtimeWorkerOptions(
2158
+ options: SyncularRealtimeOptions,
2159
+ config: ResolvedSyncularClientConfig
2160
+ ): Promise<SyncularWorkerRealtimeOptions> {
2161
+ const params = {
2162
+ ...(options.params ?? {}),
2163
+ ...((await options.getParams?.({ clientId: config.clientId })) ?? {}),
2164
+ };
2165
+ return {
2166
+ ...(options.wsUrl ? { wsUrl: options.wsUrl } : {}),
2167
+ ...(Object.keys(params).length > 0 ? { params } : {}),
2168
+ ...(options.initialReconnectDelayMs != null
2169
+ ? { initialReconnectDelayMs: options.initialReconnectDelayMs }
2170
+ : {}),
2171
+ ...(options.maxReconnectDelayMs != null
2172
+ ? { maxReconnectDelayMs: options.maxReconnectDelayMs }
2173
+ : {}),
2174
+ ...(options.reconnectBackoffFactor != null
2175
+ ? { reconnectBackoffFactor: options.reconnectBackoffFactor }
2176
+ : {}),
2177
+ ...(options.heartbeatTimeoutMs != null
2178
+ ? { heartbeatTimeoutMs: options.heartbeatTimeoutMs }
2179
+ : {}),
2180
+ };
2181
+ }
2182
+
2183
+ function assertSyncularRemoteMode(
2184
+ config: ResolvedSyncularClientConfig,
2185
+ operation: string
2186
+ ): void {
2187
+ if (isSyncularRemoteMode(config)) return;
2188
+ throw new Error(
2189
+ `Syncular ${operation} requires remote mode; current mode is ${config.mode}`
2190
+ );
2191
+ }
2192
+
2193
+ function isWorkerEvent(
2194
+ message: SyncularWorkerOutboundMessage
2195
+ ): message is Exclude<SyncularWorkerOutboundMessage, SyncularWorkerResponse> {
2196
+ return 'type' in message && !('id' in message);
2197
+ }
2198
+
2199
+ function authStatusFromError(error: unknown): 401 | 403 | undefined {
2200
+ const details =
2201
+ error instanceof SyncularWorkerError ? error.details : undefined;
2202
+ if (
2203
+ details &&
2204
+ typeof details === 'object' &&
2205
+ 'status' in details &&
2206
+ (details.status === 401 || details.status === 403)
2207
+ ) {
2208
+ return details.status;
2209
+ }
2210
+
2211
+ const message = errorMessage(error);
2212
+ const match = /\bHTTP (401|403)\b/.exec(message);
2213
+ if (!match) return undefined;
2214
+ return match[1] === '401' ? 401 : 403;
2215
+ }