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