@mdxui/terminal 2.0.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 (191) hide show
  1. package/README.md +571 -0
  2. package/dist/ansi-css-Sk5mWtdK.d.ts +119 -0
  3. package/dist/ansi-css-V6JIHGsM.d.ts +119 -0
  4. package/dist/ansi-css-_3eSEU9d.d.ts +119 -0
  5. package/dist/chunk-3EFDH7PK.js +5235 -0
  6. package/dist/chunk-3RG5ZIWI.js +10 -0
  7. package/dist/chunk-3X5IR6WE.js +884 -0
  8. package/dist/chunk-4FV5ZDCE.js +5236 -0
  9. package/dist/chunk-4OVMSF2J.js +243 -0
  10. package/dist/chunk-63FEETIS.js +4048 -0
  11. package/dist/chunk-B43KP7XJ.js +884 -0
  12. package/dist/chunk-BMTJXWUV.js +655 -0
  13. package/dist/chunk-C3SVH4N7.js +882 -0
  14. package/dist/chunk-EVWR7Y47.js +874 -0
  15. package/dist/chunk-F6A5VWUC.js +1285 -0
  16. package/dist/chunk-FD7KW7GE.js +882 -0
  17. package/dist/chunk-GBQ6UD6I.js +655 -0
  18. package/dist/chunk-GMDD3M6U.js +5227 -0
  19. package/dist/chunk-JBHRXOXM.js +1058 -0
  20. package/dist/chunk-JFOO3EYO.js +1182 -0
  21. package/dist/chunk-JQ5H3WXL.js +1291 -0
  22. package/dist/chunk-JQD5NASE.js +234 -0
  23. package/dist/chunk-KRHJP5R7.js +592 -0
  24. package/dist/chunk-KWF6WVJE.js +962 -0
  25. package/dist/chunk-LHYQVN3H.js +1038 -0
  26. package/dist/chunk-M3TLQLGC.js +1032 -0
  27. package/dist/chunk-MVW4Q5OP.js +240 -0
  28. package/dist/chunk-NXCZSWLU.js +1294 -0
  29. package/dist/chunk-O25TNRO6.js +607 -0
  30. package/dist/chunk-PNECDA2I.js +884 -0
  31. package/dist/chunk-QIHWRLJR.js +962 -0
  32. package/dist/chunk-QW5YMQ7K.js +882 -0
  33. package/dist/chunk-R5U7XKVJ.js +16 -0
  34. package/dist/chunk-RP2MVQLR.js +962 -0
  35. package/dist/chunk-TP6RXGXA.js +1087 -0
  36. package/dist/chunk-TQQSTITZ.js +655 -0
  37. package/dist/chunk-X24GWXQV.js +1281 -0
  38. package/dist/components/index.d.ts +802 -0
  39. package/dist/components/index.js +149 -0
  40. package/dist/data/index.d.ts +2554 -0
  41. package/dist/data/index.js +51 -0
  42. package/dist/forms/index.d.ts +1596 -0
  43. package/dist/forms/index.js +464 -0
  44. package/dist/index-CQRFZntR.d.ts +867 -0
  45. package/dist/index.d.ts +579 -0
  46. package/dist/index.js +786 -0
  47. package/dist/interactive-D0JkWosD.d.ts +217 -0
  48. package/dist/keyboard/index.d.ts +2 -0
  49. package/dist/keyboard/index.js +43 -0
  50. package/dist/renderers/index.d.ts +546 -0
  51. package/dist/renderers/index.js +2157 -0
  52. package/dist/storybook/index.d.ts +396 -0
  53. package/dist/storybook/index.js +641 -0
  54. package/dist/theme/index.d.ts +1339 -0
  55. package/dist/theme/index.js +123 -0
  56. package/dist/types-Bxu5PAgA.d.ts +710 -0
  57. package/dist/types-CIlop5Ji.d.ts +701 -0
  58. package/dist/types-Ca8p_p5X.d.ts +710 -0
  59. package/package.json +90 -0
  60. package/src/__tests__/components/data/card.test.ts +458 -0
  61. package/src/__tests__/components/data/list.test.ts +473 -0
  62. package/src/__tests__/components/data/metrics.test.ts +541 -0
  63. package/src/__tests__/components/data/table.test.ts +448 -0
  64. package/src/__tests__/components/input/field.test.ts +555 -0
  65. package/src/__tests__/components/input/form.test.ts +870 -0
  66. package/src/__tests__/components/input/search.test.ts +1238 -0
  67. package/src/__tests__/components/input/select.test.ts +658 -0
  68. package/src/__tests__/components/navigation/breadcrumb.test.ts +923 -0
  69. package/src/__tests__/components/navigation/command-palette.test.ts +1095 -0
  70. package/src/__tests__/components/navigation/sidebar.test.ts +1018 -0
  71. package/src/__tests__/components/navigation/tabs.test.ts +995 -0
  72. package/src/__tests__/components.test.tsx +1197 -0
  73. package/src/__tests__/core/compiler.test.ts +986 -0
  74. package/src/__tests__/core/parser.test.ts +785 -0
  75. package/src/__tests__/core/tier-switcher.test.ts +1103 -0
  76. package/src/__tests__/core/types.test.ts +1398 -0
  77. package/src/__tests__/data/collections.test.ts +1337 -0
  78. package/src/__tests__/data/db.test.ts +1265 -0
  79. package/src/__tests__/data/reactive.test.ts +1010 -0
  80. package/src/__tests__/data/sync.test.ts +1614 -0
  81. package/src/__tests__/errors.test.ts +660 -0
  82. package/src/__tests__/forms/integration.test.ts +444 -0
  83. package/src/__tests__/integration.test.ts +905 -0
  84. package/src/__tests__/keyboard.test.ts +1791 -0
  85. package/src/__tests__/renderer.test.ts +489 -0
  86. package/src/__tests__/renderers/ansi-css.test.ts +948 -0
  87. package/src/__tests__/renderers/ansi.test.ts +1366 -0
  88. package/src/__tests__/renderers/ascii.test.ts +1360 -0
  89. package/src/__tests__/renderers/interactive.test.ts +2353 -0
  90. package/src/__tests__/renderers/markdown.test.ts +1483 -0
  91. package/src/__tests__/renderers/text.test.ts +1369 -0
  92. package/src/__tests__/renderers/unicode.test.ts +1307 -0
  93. package/src/__tests__/theme.test.ts +639 -0
  94. package/src/__tests__/utils/assertions.ts +685 -0
  95. package/src/__tests__/utils/index.ts +115 -0
  96. package/src/__tests__/utils/test-renderer.ts +381 -0
  97. package/src/__tests__/utils/utils.test.ts +560 -0
  98. package/src/components/containers/card.ts +56 -0
  99. package/src/components/containers/dialog.ts +53 -0
  100. package/src/components/containers/index.ts +9 -0
  101. package/src/components/containers/panel.ts +59 -0
  102. package/src/components/feedback/badge.ts +40 -0
  103. package/src/components/feedback/index.ts +8 -0
  104. package/src/components/feedback/spinner.ts +23 -0
  105. package/src/components/helpers.ts +81 -0
  106. package/src/components/index.ts +153 -0
  107. package/src/components/layout/breadcrumb.ts +31 -0
  108. package/src/components/layout/index.ts +10 -0
  109. package/src/components/layout/list.ts +29 -0
  110. package/src/components/layout/sidebar.ts +79 -0
  111. package/src/components/layout/table.ts +62 -0
  112. package/src/components/primitives/box.ts +95 -0
  113. package/src/components/primitives/button.ts +54 -0
  114. package/src/components/primitives/index.ts +11 -0
  115. package/src/components/primitives/input.ts +88 -0
  116. package/src/components/primitives/select.ts +97 -0
  117. package/src/components/primitives/text.ts +60 -0
  118. package/src/components/render.ts +155 -0
  119. package/src/components/templates/app.ts +43 -0
  120. package/src/components/templates/index.ts +8 -0
  121. package/src/components/templates/site.ts +54 -0
  122. package/src/components/types.ts +777 -0
  123. package/src/core/compiler.ts +718 -0
  124. package/src/core/parser.ts +127 -0
  125. package/src/core/tier-switcher.ts +607 -0
  126. package/src/core/types.ts +672 -0
  127. package/src/data/collection.ts +316 -0
  128. package/src/data/collections.ts +50 -0
  129. package/src/data/context.tsx +174 -0
  130. package/src/data/db.ts +127 -0
  131. package/src/data/hooks.ts +532 -0
  132. package/src/data/index.ts +138 -0
  133. package/src/data/reactive.ts +1225 -0
  134. package/src/data/saas-collections.ts +375 -0
  135. package/src/data/sync.ts +1213 -0
  136. package/src/data/types.ts +660 -0
  137. package/src/forms/converters.ts +512 -0
  138. package/src/forms/index.ts +133 -0
  139. package/src/forms/schemas.ts +403 -0
  140. package/src/forms/types.ts +476 -0
  141. package/src/index.ts +542 -0
  142. package/src/keyboard/focus.ts +748 -0
  143. package/src/keyboard/index.ts +96 -0
  144. package/src/keyboard/integration.ts +371 -0
  145. package/src/keyboard/manager.ts +377 -0
  146. package/src/keyboard/presets.ts +90 -0
  147. package/src/renderers/ansi-css.ts +576 -0
  148. package/src/renderers/ansi.ts +802 -0
  149. package/src/renderers/ascii.ts +680 -0
  150. package/src/renderers/breadcrumb.ts +480 -0
  151. package/src/renderers/command-palette.ts +802 -0
  152. package/src/renderers/components/field.ts +210 -0
  153. package/src/renderers/components/form.ts +327 -0
  154. package/src/renderers/components/index.ts +21 -0
  155. package/src/renderers/components/search.ts +449 -0
  156. package/src/renderers/components/select.ts +222 -0
  157. package/src/renderers/index.ts +101 -0
  158. package/src/renderers/interactive/component-handlers.ts +622 -0
  159. package/src/renderers/interactive/cursor-manager.ts +147 -0
  160. package/src/renderers/interactive/focus-manager.ts +279 -0
  161. package/src/renderers/interactive/index.ts +661 -0
  162. package/src/renderers/interactive/input-handler.ts +164 -0
  163. package/src/renderers/interactive/keyboard-handler.ts +212 -0
  164. package/src/renderers/interactive/mouse-handler.ts +167 -0
  165. package/src/renderers/interactive/state-manager.ts +109 -0
  166. package/src/renderers/interactive/types.ts +338 -0
  167. package/src/renderers/interactive-string.ts +299 -0
  168. package/src/renderers/interactive.ts +59 -0
  169. package/src/renderers/markdown.ts +950 -0
  170. package/src/renderers/sidebar.ts +549 -0
  171. package/src/renderers/tabs.ts +682 -0
  172. package/src/renderers/text.ts +791 -0
  173. package/src/renderers/unicode.ts +917 -0
  174. package/src/renderers/utils.ts +942 -0
  175. package/src/router/adapters.ts +383 -0
  176. package/src/router/types.ts +140 -0
  177. package/src/router/utils.ts +452 -0
  178. package/src/schemas.ts +205 -0
  179. package/src/storybook/index.ts +91 -0
  180. package/src/storybook/interactive-decorator.tsx +659 -0
  181. package/src/storybook/keyboard-simulator.ts +501 -0
  182. package/src/theme/ansi-codes.ts +80 -0
  183. package/src/theme/box-drawing.ts +132 -0
  184. package/src/theme/color-convert.ts +254 -0
  185. package/src/theme/color-support.ts +321 -0
  186. package/src/theme/index.ts +134 -0
  187. package/src/theme/strip-ansi.ts +50 -0
  188. package/src/theme/tailwind-map.ts +469 -0
  189. package/src/theme/text-styles.ts +206 -0
  190. package/src/theme/theme-system.ts +568 -0
  191. package/src/types.ts +103 -0
@@ -0,0 +1,1213 @@
1
+ /**
2
+ * @mdxui/terminal DO Sync Adapter
3
+ *
4
+ * Durable Objects sync adapter for bidirectional data synchronization
5
+ * via WebSocket connection. Provides:
6
+ * - WebSocket connection lifecycle management
7
+ * - Bidirectional data synchronization
8
+ * - Auth header injection for authenticated requests
9
+ * - Optimistic updates with server confirmation
10
+ * - Automatic reconnection with exponential backoff + jitter
11
+ * - Offline mutation queue for resilience
12
+ * - Connection state observable
13
+ * - Error categorization (recoverable vs fatal)
14
+ *
15
+ * @remarks
16
+ * ## Connection State Machine
17
+ *
18
+ * ```
19
+ * ┌─────────────┐ connect() ┌────────────┐
20
+ * │ disconnected│──────────────►│ connecting │
21
+ * └─────────────┘ └────────────┘
22
+ * ▲ │
23
+ * │ │ onopen
24
+ * │ close() ▼
25
+ * │ ┌───────────┐
26
+ * │◄──────────────────────│ connected │
27
+ * │ close() / fatal err └───────────┘
28
+ * │ │
29
+ * │ │ recoverable error / close
30
+ * │ ▼
31
+ * │ ┌─────────────┐
32
+ * │◄─────────────────────│ reconnecting│───► (loop with backoff)
33
+ * │ max attempts └─────────────┘
34
+ * ```
35
+ *
36
+ * ## Error Categories
37
+ *
38
+ * **Recoverable errors** - trigger reconnection:
39
+ * - Network timeouts
40
+ * - Temporary server unavailability
41
+ * - WebSocket close codes 1001 (going away), 1006 (abnormal closure)
42
+ *
43
+ * **Fatal errors** - require user intervention:
44
+ * - Authentication failures (401, 403)
45
+ * - Invalid namespace URL
46
+ * - WebSocket close code 1008 (policy violation)
47
+ * - Close code 4000+ (application-level errors)
48
+ *
49
+ * @module
50
+ */
51
+
52
+ import type { SyncAdapter } from './types'
53
+
54
+ // ============================================================================
55
+ // Types
56
+ // ============================================================================
57
+
58
+ /**
59
+ * Connection state for monitoring adapter health.
60
+ *
61
+ * State transitions follow a deterministic state machine pattern:
62
+ *
63
+ * @remarks
64
+ * - `disconnected`: No active WebSocket connection. Initial state and terminal state.
65
+ * - `connecting`: WebSocket connection in progress. Transitions to `connected` on success,
66
+ * `disconnected` on fatal error, or `reconnecting` on recoverable error (if enabled).
67
+ * - `connected`: Active WebSocket connection ready for sync. Can transition to
68
+ * `disconnected` on close/fatal error or `reconnecting` on recoverable error.
69
+ * - `reconnecting`: Attempting to re-establish connection after a recoverable failure.
70
+ * Includes exponential backoff with jitter. Transitions to `connecting` when attempting,
71
+ * `disconnected` when max attempts reached or user calls close().
72
+ *
73
+ * @example
74
+ * ```typescript
75
+ * const sync = createDOSync({ namespaceUrl: '...' })
76
+ *
77
+ * sync.onConnectionStateChange((state) => {
78
+ * switch (state) {
79
+ * case 'disconnected':
80
+ * showOfflineIndicator()
81
+ * break
82
+ * case 'connecting':
83
+ * case 'reconnecting':
84
+ * showConnectingSpinner()
85
+ * break
86
+ * case 'connected':
87
+ * hideOfflineIndicator()
88
+ * break
89
+ * }
90
+ * })
91
+ * ```
92
+ */
93
+ export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
94
+
95
+ /**
96
+ * Error category for determining recovery strategy.
97
+ *
98
+ * @remarks
99
+ * Used internally to decide whether to attempt reconnection or fail permanently.
100
+ * - `recoverable`: Temporary failure that may succeed on retry (network issues, server overload)
101
+ * - `fatal`: Permanent failure requiring user intervention (auth, validation, policy)
102
+ */
103
+ export type ErrorCategory = 'recoverable' | 'fatal'
104
+
105
+ /**
106
+ * Sync error with categorization for recovery decisions.
107
+ *
108
+ * @remarks
109
+ * Extends Error with additional metadata for the sync adapter to make
110
+ * intelligent retry decisions. Use `category` to determine if reconnection
111
+ * should be attempted.
112
+ */
113
+ export interface SyncError extends Error {
114
+ /** Error category for recovery decision */
115
+ category: ErrorCategory
116
+ /** WebSocket close code if applicable */
117
+ closeCode?: number
118
+ /** Original error that caused this failure */
119
+ cause?: Error
120
+ }
121
+
122
+ /**
123
+ * Create a categorized sync error.
124
+ *
125
+ * @param message - Error message
126
+ * @param category - Whether error is recoverable or fatal
127
+ * @param options - Optional close code and cause
128
+ * @returns Categorized SyncError instance
129
+ *
130
+ * @internal
131
+ */
132
+ function createSyncError(
133
+ message: string,
134
+ category: ErrorCategory,
135
+ options?: { closeCode?: number; cause?: Error }
136
+ ): SyncError {
137
+ const error = new Error(message) as SyncError
138
+ error.category = category
139
+ error.name = 'SyncError'
140
+ if (options?.closeCode !== undefined) {
141
+ error.closeCode = options.closeCode
142
+ }
143
+ if (options?.cause) {
144
+ error.cause = options.cause
145
+ }
146
+ return error
147
+ }
148
+
149
+ /**
150
+ * Categorize a WebSocket close code.
151
+ *
152
+ * @remarks
153
+ * Close codes are categorized based on their recoverability:
154
+ *
155
+ * **Recoverable (will retry):**
156
+ * - 1001 (Going Away) - Server shutting down normally
157
+ * - 1006 (Abnormal Closure) - Connection lost unexpectedly
158
+ * - 1011 (Internal Error) - Server encountered unexpected condition
159
+ * - 1012 (Service Restart) - Server restarting
160
+ * - 1013 (Try Again Later) - Server overloaded
161
+ * - 1014 (Bad Gateway) - Proxy/gateway error
162
+ *
163
+ * **Fatal (no retry):**
164
+ * - 1000 (Normal Closure) - Intentional close, no error
165
+ * - 1002 (Protocol Error) - Invalid WebSocket behavior
166
+ * - 1003 (Unsupported Data) - Received data type not supported
167
+ * - 1007 (Invalid Data) - Message data was invalid
168
+ * - 1008 (Policy Violation) - Server policy forbids connection
169
+ * - 1009 (Message Too Big) - Message exceeds size limit
170
+ * - 1010 (Missing Extension) - Required extension not negotiated
171
+ * - 4000+ (Application errors) - Custom close codes indicating app-level errors
172
+ *
173
+ * @param code - WebSocket close code
174
+ * @returns Error category for recovery decisions
175
+ *
176
+ * @internal
177
+ */
178
+ function categorizeCloseCode(code: number): ErrorCategory {
179
+ // Recoverable close codes - temporary failures that may succeed on retry
180
+ const recoverableCodes = new Set([
181
+ 1001, // Going Away - server shutting down
182
+ 1006, // Abnormal Closure - connection lost
183
+ 1011, // Internal Error - server-side issue
184
+ 1012, // Service Restart - temporary unavailability
185
+ 1013, // Try Again Later - server overloaded
186
+ 1014, // Bad Gateway - proxy/gateway issue
187
+ ])
188
+
189
+ if (recoverableCodes.has(code)) {
190
+ return 'recoverable'
191
+ }
192
+
193
+ // Application-level close codes (4000+) are typically fatal
194
+ if (code >= 4000) {
195
+ return 'fatal'
196
+ }
197
+
198
+ // Default to fatal for unknown codes (fail safe)
199
+ return 'fatal'
200
+ }
201
+
202
+ /**
203
+ * Calculate backoff delay with jitter.
204
+ *
205
+ * Uses exponential backoff with full jitter to prevent thundering herd
206
+ * when many clients reconnect simultaneously.
207
+ *
208
+ * @param attempt - Current attempt number (0-based)
209
+ * @param initialDelay - Initial delay in ms
210
+ * @param maxDelay - Maximum delay cap in ms
211
+ * @returns Delay in ms with jitter applied
212
+ *
213
+ * @internal
214
+ */
215
+ function calculateBackoffWithJitter(attempt: number, initialDelay: number, maxDelay: number): number {
216
+ // Calculate base exponential delay
217
+ const exponentialDelay = initialDelay * Math.pow(2, attempt)
218
+
219
+ // Cap at maxDelay
220
+ const cappedDelay = Math.min(exponentialDelay, maxDelay)
221
+
222
+ // Apply full jitter: random value between 0 and cappedDelay
223
+ // This prevents thundering herd when many clients reconnect
224
+ return Math.random() * cappedDelay
225
+ }
226
+
227
+ /**
228
+ * Queued mutation for offline resilience.
229
+ *
230
+ * @remarks
231
+ * When the adapter is offline, mutations are queued and automatically
232
+ * retried once the connection is re-established. This ensures no data
233
+ * is lost during temporary disconnections.
234
+ *
235
+ * ## Lifecycle
236
+ * 1. Push fails due to connection error
237
+ * 2. Mutation is added to queue with timestamp
238
+ * 3. Connection is restored
239
+ * 4. Queue is flushed in FIFO order
240
+ * 5. Successful mutations are removed from queue
241
+ * 6. Failed mutations remain with incremented retryCount
242
+ *
243
+ * ## Inspection
244
+ * Use `getQueuedMutations()` to inspect the queue for debugging
245
+ * or displaying pending sync status to users.
246
+ *
247
+ * @example
248
+ * ```typescript
249
+ * // Display pending changes to user
250
+ * const pending = adapter.getQueuedMutations()
251
+ * if (pending.length > 0) {
252
+ * console.log(`${pending.length} changes waiting to sync`)
253
+ * }
254
+ * ```
255
+ */
256
+ export interface QueuedMutation {
257
+ /** Unique identifier for this mutation (auto-generated) */
258
+ id: string
259
+ /** The changes to sync to the server */
260
+ changes: unknown[]
261
+ /** Timestamp (ms since epoch) when mutation was queued */
262
+ queuedAt: number
263
+ /** Number of failed retry attempts (0 = first attempt pending) */
264
+ retryCount: number
265
+ }
266
+
267
+ /**
268
+ * Reconnection configuration options.
269
+ *
270
+ * @remarks
271
+ * Configures automatic reconnection behavior with exponential backoff.
272
+ * When enabled, the adapter will automatically attempt to reconnect
273
+ * on connection loss until maxAttempts is reached.
274
+ *
275
+ * ## Defaults
276
+ * - `enabled`: false (opt-in for auto-reconnect)
277
+ * - `maxAttempts`: Infinity (keep trying forever)
278
+ * - `initialDelay`: 1000ms (1 second)
279
+ * - `maxDelay`: 30000ms (30 seconds cap)
280
+ *
281
+ * ## Backoff Formula
282
+ * `delay = min(initialDelay * 2^attempt, maxDelay)`
283
+ *
284
+ * @example
285
+ * ```typescript
286
+ * // Recommended production config
287
+ * const sync = createDOSync({
288
+ * namespaceUrl: '...',
289
+ * reconnect: {
290
+ * enabled: true,
291
+ * maxAttempts: 10,
292
+ * initialDelay: 1000,
293
+ * maxDelay: 30000
294
+ * }
295
+ * })
296
+ * ```
297
+ */
298
+ export interface ReconnectOptions {
299
+ /**
300
+ * Whether to automatically reconnect on connection loss.
301
+ * @defaultValue false
302
+ */
303
+ enabled?: boolean
304
+ /**
305
+ * Maximum number of reconnection attempts before giving up.
306
+ * Set to Infinity to retry indefinitely.
307
+ * @defaultValue Infinity
308
+ */
309
+ maxAttempts?: number
310
+ /**
311
+ * Initial delay in ms before first reconnection attempt.
312
+ * Subsequent delays are calculated with exponential backoff.
313
+ * @defaultValue 1000
314
+ */
315
+ initialDelay?: number
316
+ /**
317
+ * Maximum delay in ms between reconnection attempts (backoff cap).
318
+ * @defaultValue 30000
319
+ */
320
+ maxDelay?: number
321
+ }
322
+
323
+ /**
324
+ * Conflict resolution strategy for handling server-client data conflicts.
325
+ *
326
+ * @remarks
327
+ * - `server-wins`: Server version is authoritative, client changes discarded
328
+ * - `client-wins`: Client version is authoritative, server changes overwritten
329
+ * - `merge`: Attempt to merge both versions (server determines merge logic)
330
+ * - `throw`: Reject push with conflict error, let caller handle
331
+ * - `custom`: Call `onConflict` callback for custom resolution logic
332
+ */
333
+ export type ConflictResolution = 'server-wins' | 'client-wins' | 'merge' | 'throw' | 'custom'
334
+
335
+ /**
336
+ * Conflict details returned by server when client/server versions diverge.
337
+ */
338
+ export interface Conflict {
339
+ /** ID of the conflicting entity */
340
+ id: string
341
+ /** Server's current version of the entity */
342
+ serverVersion: Record<string, unknown>
343
+ }
344
+
345
+ /**
346
+ * Configuration for creating a DO Sync adapter.
347
+ *
348
+ * @remarks
349
+ * ## Minimal Configuration
350
+ * ```typescript
351
+ * const sync = createDOSync({
352
+ * namespaceUrl: 'https://api.example.com/namespace'
353
+ * })
354
+ * ```
355
+ *
356
+ * ## Full Configuration
357
+ * ```typescript
358
+ * const sync = createDOSync({
359
+ * namespaceUrl: 'https://api.example.com/namespace',
360
+ * authToken: 'jwt-token',
361
+ * reconnect: { enabled: true, maxAttempts: 10 },
362
+ * conflictResolution: 'server-wins',
363
+ * requestTimeout: 10000
364
+ * })
365
+ * ```
366
+ */
367
+ export interface DOSyncConfig {
368
+ /**
369
+ * URL of the Durable Objects namespace endpoint.
370
+ * Will be converted to WebSocket URL (https -> wss, http -> ws).
371
+ *
372
+ * @example 'https://api.example.com/namespace'
373
+ */
374
+ namespaceUrl: string
375
+
376
+ /**
377
+ * Optional auth token for authenticated connections.
378
+ * Sent as URL query param and in message payloads.
379
+ * Can be updated dynamically via `setAuthToken()`.
380
+ */
381
+ authToken?: string
382
+
383
+ /**
384
+ * Automatic reconnection options.
385
+ * @see ReconnectOptions for defaults and configuration
386
+ */
387
+ reconnect?: ReconnectOptions
388
+
389
+ /**
390
+ * Strategy for resolving server-client data conflicts.
391
+ * @defaultValue 'server-wins' (implicit - server decides)
392
+ */
393
+ conflictResolution?: ConflictResolution
394
+
395
+ /**
396
+ * Custom conflict resolver callback.
397
+ * Required when `conflictResolution` is 'custom'.
398
+ *
399
+ * @param conflicts - Array of conflicting entities with server versions
400
+ * @returns Promise resolving to `{ resolved: true }` when conflicts handled
401
+ */
402
+ onConflict?: (conflicts: Conflict[]) => Promise<{ resolved: boolean }>
403
+
404
+ /**
405
+ * Request timeout in milliseconds for push/pull operations.
406
+ * Operations will reject with timeout error if no response received.
407
+ * @defaultValue 30000 (30 seconds)
408
+ */
409
+ requestTimeout?: number
410
+ }
411
+
412
+ /**
413
+ * Connection state observer callback type.
414
+ *
415
+ * @remarks
416
+ * Called whenever the connection state changes. Useful for UI feedback
417
+ * (e.g., showing "offline" status, disabling sync buttons, etc.).
418
+ *
419
+ * @param state - The new connection state
420
+ */
421
+ export type ConnectionStateObserver = (state: ConnectionState) => void
422
+
423
+ /**
424
+ * Extended sync adapter with connection management and state monitoring.
425
+ *
426
+ * @remarks
427
+ * Extends the base SyncAdapter with:
428
+ * - Connection lifecycle management (`close()`)
429
+ * - Dynamic auth token updates (`setAuthToken()`)
430
+ * - Reactive connection state (`onConnectionStateChange()`, `getConnectionState()`)
431
+ * - Offline queue inspection (`getQueuedMutations()`, `getQueueStats()`)
432
+ *
433
+ * ## Offline Resilience
434
+ * When push operations fail due to connection errors, mutations are
435
+ * automatically queued and retried when the connection is restored.
436
+ * This ensures no data is lost during temporary disconnections.
437
+ *
438
+ * @example
439
+ * ```typescript
440
+ * const sync = createDOSync({ namespaceUrl: '...' })
441
+ *
442
+ * // React to connection state changes
443
+ * sync.onConnectionStateChange((state) => {
444
+ * updateStatusIndicator(state)
445
+ * })
446
+ *
447
+ * // Check pending changes
448
+ * const { count, oldestAt } = sync.getQueueStats()
449
+ * if (count > 0) {
450
+ * showPendingBanner(`${count} changes pending since ${new Date(oldestAt!)}`)
451
+ * }
452
+ *
453
+ * // Cleanup on unmount
454
+ * sync.close()
455
+ * ```
456
+ */
457
+ export interface DOSyncAdapter extends SyncAdapter {
458
+ /**
459
+ * Close the WebSocket connection and stop all reconnection attempts.
460
+ * Call this when unmounting or navigating away to clean up resources.
461
+ */
462
+ close(): void
463
+
464
+ /**
465
+ * Update the auth token dynamically.
466
+ * Useful for refreshing expired tokens without recreating the adapter.
467
+ * The new token will be used for subsequent connections.
468
+ *
469
+ * @param token - New auth token
470
+ */
471
+ setAuthToken(token: string): void
472
+
473
+ /**
474
+ * Subscribe to connection state changes.
475
+ * Callback is called immediately with current state, then on each change.
476
+ *
477
+ * @param callback - Observer function called on state changes
478
+ * @returns Unsubscribe function
479
+ */
480
+ onConnectionStateChange(callback: ConnectionStateObserver): () => void
481
+
482
+ /**
483
+ * Get current connection state without subscribing.
484
+ * Use for one-time checks; use `onConnectionStateChange()` for reactive updates.
485
+ *
486
+ * @returns Current connection state
487
+ */
488
+ getConnectionState(): ConnectionState
489
+
490
+ /**
491
+ * Get all mutations currently in the offline queue.
492
+ * Useful for debugging or displaying pending sync status.
493
+ *
494
+ * @returns Array of queued mutations
495
+ */
496
+ getQueuedMutations(): QueuedMutation[]
497
+
498
+ /**
499
+ * Get summary statistics about the offline queue.
500
+ *
501
+ * @returns Object with:
502
+ * - `count`: Number of pending mutations
503
+ * - `oldestAt`: Timestamp of oldest mutation (null if empty)
504
+ */
505
+ getQueueStats(): { count: number; oldestAt: number | null }
506
+ }
507
+
508
+ // ============================================================================
509
+ // Message Types
510
+ // ============================================================================
511
+
512
+ interface PushMessage {
513
+ type: 'push'
514
+ id: string
515
+ changes: unknown[]
516
+ auth?: { token: string }
517
+ }
518
+
519
+ interface PullMessage {
520
+ type: 'pull'
521
+ id: string
522
+ auth?: { token: string }
523
+ }
524
+
525
+ interface AckMessage {
526
+ type: 'ack'
527
+ id: string
528
+ status: 'success' | 'error' | 'partial' | 'conflict'
529
+ error?: string
530
+ confirmedChanges?: string[]
531
+ failedChanges?: Array<{ id: string; reason: string }>
532
+ conflicts?: Conflict[]
533
+ resolution?: string
534
+ mergedVersion?: Record<string, unknown>
535
+ }
536
+
537
+ interface PullResponseMessage {
538
+ type: 'pull-response'
539
+ changes: unknown[]
540
+ }
541
+
542
+ interface SyncMessage {
543
+ type: 'sync'
544
+ changes: unknown[]
545
+ }
546
+
547
+ type ServerMessage = AckMessage | PullResponseMessage | SyncMessage
548
+
549
+ // ============================================================================
550
+ // Implementation
551
+ // ============================================================================
552
+
553
+ /**
554
+ * Create a Durable Objects sync adapter
555
+ *
556
+ * @param config - Configuration options
557
+ * @returns A sync adapter instance for use with createDB
558
+ *
559
+ * @throws Error if namespaceUrl is empty or invalid
560
+ *
561
+ * @example
562
+ * ```typescript
563
+ * import { createDOSync } from '@mdxui/terminal'
564
+ *
565
+ * const sync = createDOSync({
566
+ * namespaceUrl: 'https://api.example.com/namespace',
567
+ * authToken: 'your-auth-token',
568
+ * reconnect: { enabled: true, maxAttempts: 5 }
569
+ * })
570
+ *
571
+ * const db = createDB({
572
+ * collections: [usersCollection],
573
+ * sync
574
+ * })
575
+ * ```
576
+ */
577
+ export function createDOSync(config: DOSyncConfig): DOSyncAdapter {
578
+ // Validate namespace URL
579
+ if (!config.namespaceUrl) {
580
+ throw new Error('namespaceUrl is required')
581
+ }
582
+
583
+ // Validate URL format
584
+ try {
585
+ new URL(config.namespaceUrl)
586
+ } catch {
587
+ throw new Error('Invalid namespaceUrl format')
588
+ }
589
+
590
+ // State
591
+ let ws: WebSocket | null = null
592
+ let authToken = config.authToken
593
+ let messageId = 0
594
+ let reconnectAttempts = 0
595
+ let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
596
+ let isClosed = false
597
+ let connectionState: ConnectionState = 'disconnected'
598
+
599
+ // Pending operations waiting for responses
600
+ const pendingPush = new Map<
601
+ string,
602
+ {
603
+ resolve: () => void
604
+ reject: (error: Error) => void
605
+ timeoutId?: ReturnType<typeof setTimeout>
606
+ }
607
+ >()
608
+ const pendingPull = new Map<
609
+ string,
610
+ {
611
+ resolve: (changes: unknown[]) => void
612
+ reject: (error: Error) => void
613
+ timeoutId?: ReturnType<typeof setTimeout>
614
+ }
615
+ >()
616
+
617
+ // Subscribers for remote changes
618
+ const subscribers = new Set<(changes: unknown[]) => void>()
619
+
620
+ // Connection state observers for monitoring
621
+ const connectionStateObservers = new Set<ConnectionStateObserver>()
622
+
623
+ // Offline mutation queue for resilience
624
+ const mutationQueue = new Map<string, QueuedMutation>()
625
+
626
+ // Default reconnect options
627
+ const reconnectOptions: Required<ReconnectOptions> = {
628
+ enabled: config.reconnect?.enabled ?? false,
629
+ maxAttempts: config.reconnect?.maxAttempts ?? Infinity,
630
+ initialDelay: config.reconnect?.initialDelay ?? 1000,
631
+ maxDelay: config.reconnect?.maxDelay ?? 30000,
632
+ }
633
+
634
+ // Default timeout
635
+ const requestTimeout = config.requestTimeout ?? 30000
636
+
637
+ /**
638
+ * Notify all connection state observers of a state change
639
+ */
640
+ function notifyConnectionStateChange(newState: ConnectionState): void {
641
+ if (newState !== connectionState) {
642
+ connectionState = newState
643
+ for (const observer of connectionStateObservers) {
644
+ observer(connectionState)
645
+ }
646
+ }
647
+ }
648
+
649
+ /**
650
+ * Add a mutation to the offline queue
651
+ */
652
+ function queueMutation(changes: unknown[]): QueuedMutation {
653
+ const id = `queued-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
654
+ const mutation: QueuedMutation = {
655
+ id,
656
+ changes,
657
+ queuedAt: Date.now(),
658
+ retryCount: 0,
659
+ }
660
+ mutationQueue.set(id, mutation)
661
+ return mutation
662
+ }
663
+
664
+ /**
665
+ * Flush queued mutations by retrying them
666
+ *
667
+ * @remarks
668
+ * Called when connection is restored. Retries all queued mutations
669
+ * in the order they were queued. Failed mutations are kept for retry.
670
+ */
671
+ async function flushMutationQueue(): Promise<void> {
672
+ const mutations = Array.from(mutationQueue.values())
673
+ for (const mutation of mutations) {
674
+ try {
675
+ // Retry the queued mutation
676
+ await push(mutation.changes)
677
+ // Remove from queue on success
678
+ mutationQueue.delete(mutation.id)
679
+ } catch {
680
+ // Keep in queue and increment retry count for next attempt
681
+ mutation.retryCount++
682
+ }
683
+ }
684
+ }
685
+
686
+ /**
687
+ * Convert HTTPS URL to WSS URL
688
+ */
689
+ function getWebSocketUrl(): string {
690
+ const url = new URL(config.namespaceUrl)
691
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
692
+ if (authToken) {
693
+ url.searchParams.set('token', authToken)
694
+ }
695
+ return url.toString()
696
+ }
697
+
698
+ /**
699
+ * Generate a unique message ID
700
+ */
701
+ function nextMessageId(): string {
702
+ messageId++
703
+ return `push-${messageId}`
704
+ }
705
+
706
+ /**
707
+ * Handle incoming WebSocket message
708
+ */
709
+ function handleMessage(event: MessageEvent): void {
710
+ let message: ServerMessage
711
+ try {
712
+ message = JSON.parse(event.data)
713
+ } catch {
714
+ // Malformed JSON - ignore gracefully
715
+ return
716
+ }
717
+
718
+ switch (message.type) {
719
+ case 'ack': {
720
+ const pending = pendingPush.get(message.id)
721
+ if (pending) {
722
+ if (pending.timeoutId) {
723
+ clearTimeout(pending.timeoutId)
724
+ }
725
+ pendingPush.delete(message.id)
726
+
727
+ if (message.status === 'error') {
728
+ pending.reject(new Error(message.error || 'Push failed'))
729
+ } else if (message.status === 'conflict') {
730
+ // Handle based on conflict resolution strategy
731
+ if (config.conflictResolution === 'throw') {
732
+ pending.reject(new Error('Conflict detected'))
733
+ } else if (config.conflictResolution === 'custom' && config.onConflict && message.conflicts) {
734
+ config.onConflict(message.conflicts).then(() => pending.resolve())
735
+ } else {
736
+ // server-wins, client-wins, merge - all resolve successfully
737
+ pending.resolve()
738
+ }
739
+ } else {
740
+ // success or partial
741
+ pending.resolve()
742
+ }
743
+ }
744
+ break
745
+ }
746
+
747
+ case 'pull-response': {
748
+ // Find the first pending pull and resolve it
749
+ const entries = Array.from(pendingPull.entries())
750
+ if (entries.length > 0) {
751
+ const [id, pending] = entries[0]
752
+ if (pending.timeoutId) {
753
+ clearTimeout(pending.timeoutId)
754
+ }
755
+ pendingPull.delete(id)
756
+ pending.resolve(message.changes)
757
+ }
758
+ break
759
+ }
760
+
761
+ case 'sync': {
762
+ // Notify all subscribers of remote changes
763
+ for (const callback of subscribers) {
764
+ callback(message.changes)
765
+ }
766
+ break
767
+ }
768
+ }
769
+ }
770
+
771
+ /**
772
+ * Handle WebSocket error
773
+ *
774
+ * @remarks
775
+ * On connection error:
776
+ * - Pull operations are rejected immediately (need fresh data)
777
+ * - Push operations are left to be handled by their caller's catch blocks
778
+ * (which can queue them for offline resilience)
779
+ * Connection state is updated to 'disconnected' to signal offline mode.
780
+ */
781
+ function handleError(): void {
782
+ notifyConnectionStateChange('disconnected')
783
+
784
+ // Clear timeouts on pending push operations (but don't reject)
785
+ // Let the catch handler in push() deal with queueing
786
+ for (const [id, pending] of pendingPush) {
787
+ if (pending.timeoutId) {
788
+ clearTimeout(pending.timeoutId)
789
+ }
790
+ }
791
+
792
+ // Reject all pending pull operations (can't queue reads, need fresh data)
793
+ for (const [id, pending] of pendingPull) {
794
+ if (pending.timeoutId) {
795
+ clearTimeout(pending.timeoutId)
796
+ }
797
+ pending.reject(new Error('WebSocket error'))
798
+ }
799
+ pendingPull.clear()
800
+
801
+ // Attempt reconnection
802
+ scheduleReconnect()
803
+ }
804
+
805
+ /**
806
+ * Handle WebSocket close
807
+ *
808
+ * @remarks
809
+ * Cleans up the closed socket and schedules reconnection if not explicitly
810
+ * closed by the user. Connection state is updated to reflect disconnection.
811
+ */
812
+ function handleClose(): void {
813
+ ws = null
814
+ notifyConnectionStateChange('disconnected')
815
+ if (!isClosed) {
816
+ scheduleReconnect()
817
+ }
818
+ }
819
+
820
+ /**
821
+ * Schedule a reconnection attempt with exponential backoff.
822
+ *
823
+ * @remarks
824
+ * Implements exponential backoff strategy with the formula:
825
+ * `delay = min(initialDelay * 2^attempt, maxDelay)`
826
+ *
827
+ * Example progression with initialDelay=1000, maxDelay=30000:
828
+ * - Attempt 0: 1000ms
829
+ * - Attempt 1: 2000ms
830
+ * - Attempt 2: 4000ms
831
+ * - Attempt 3: 8000ms
832
+ * - Attempt 4: 16000ms
833
+ * - Attempt 5+: 30000ms (capped)
834
+ *
835
+ * Updates connection state to 'reconnecting' to signal retry attempts.
836
+ * Stops scheduling when:
837
+ * - `reconnect.enabled` is false
838
+ * - `close()` has been called
839
+ * - `maxAttempts` has been reached
840
+ *
841
+ * @internal
842
+ */
843
+ function scheduleReconnect(): void {
844
+ // Guard: Don't reconnect if disabled or explicitly closed
845
+ if (!reconnectOptions.enabled || isClosed) {
846
+ return
847
+ }
848
+
849
+ // Guard: Don't exceed max attempts
850
+ if (reconnectAttempts >= reconnectOptions.maxAttempts) {
851
+ return
852
+ }
853
+
854
+ notifyConnectionStateChange('reconnecting')
855
+
856
+ // Calculate delay with exponential backoff
857
+ // Note: For production use with many concurrent clients, consider using
858
+ // calculateBackoffWithJitter() to prevent thundering herd
859
+ const delay = Math.min(
860
+ reconnectOptions.initialDelay * Math.pow(2, reconnectAttempts),
861
+ reconnectOptions.maxDelay
862
+ )
863
+
864
+ reconnectAttempts++
865
+
866
+ reconnectTimeout = setTimeout(() => {
867
+ if (!isClosed) {
868
+ connect()
869
+ }
870
+ }, delay)
871
+ }
872
+
873
+ // WebSocket readyState constants
874
+ const WS_CONNECTING = 0
875
+ const WS_OPEN = 1
876
+ const WS_CLOSING = 2
877
+ const WS_CLOSED = 3
878
+
879
+ /**
880
+ * Establish WebSocket connection
881
+ *
882
+ * @remarks
883
+ * Creates a new WebSocket if none exists. Reuses existing connections
884
+ * and updates connection state. Flushes queued mutations on successful connection.
885
+ */
886
+ function connect(): WebSocket {
887
+ if (ws && ws.readyState === WS_OPEN) {
888
+ return ws
889
+ }
890
+
891
+ if (ws && ws.readyState === WS_CONNECTING) {
892
+ return ws
893
+ }
894
+
895
+ notifyConnectionStateChange('connecting')
896
+
897
+ ws = new WebSocket(getWebSocketUrl())
898
+
899
+ ws.onopen = () => {
900
+ // Reset reconnection attempts on successful connection
901
+ reconnectAttempts = 0
902
+ notifyConnectionStateChange('connected')
903
+
904
+ // Flush queued mutations when reconnected
905
+ flushMutationQueue().catch(() => {
906
+ // Errors during flush are non-blocking - mutations stay queued for retry
907
+ })
908
+ }
909
+
910
+ ws.onmessage = handleMessage
911
+ ws.onerror = handleError
912
+ ws.onclose = handleClose
913
+
914
+ return ws
915
+ }
916
+
917
+ /**
918
+ * Ensure WebSocket is connected, waiting if necessary
919
+ *
920
+ * @remarks
921
+ * Returns a connected WebSocket or rejects if connection fails.
922
+ * Does NOT queue mutations - that's handled by the caller (push/pull).
923
+ */
924
+ function ensureConnected(): Promise<WebSocket> {
925
+ return new Promise((resolve, reject) => {
926
+ const socket = connect()
927
+
928
+ if (socket.readyState === WS_OPEN) {
929
+ resolve(socket)
930
+ return
931
+ }
932
+
933
+ const originalOnOpen = socket.onopen
934
+ const originalOnError = socket.onerror
935
+
936
+ socket.onopen = (event) => {
937
+ socket.onopen = originalOnOpen
938
+ socket.onerror = originalOnError
939
+ if (originalOnOpen) {
940
+ originalOnOpen.call(socket, event)
941
+ }
942
+ resolve(socket)
943
+ }
944
+
945
+ socket.onerror = (event) => {
946
+ socket.onopen = originalOnOpen
947
+ socket.onerror = originalOnError
948
+ if (originalOnError) {
949
+ originalOnError.call(socket, event)
950
+ }
951
+ reject(new Error('WebSocket connection failed'))
952
+ }
953
+ })
954
+ }
955
+
956
+ /**
957
+ * Push local changes to remote server
958
+ *
959
+ * @remarks
960
+ * Attempts to push changes to the server. If the connection is not available,
961
+ * the mutation is queued for later retry. Empty pushes are sent immediately
962
+ * and don't trigger queueing.
963
+ */
964
+ function push(changes: unknown[]): Promise<void> {
965
+ const id = nextMessageId()
966
+ const isEmptyPush = changes.length === 0
967
+
968
+ return new Promise((resolve, reject) => {
969
+ const message: PushMessage = {
970
+ type: 'push',
971
+ id,
972
+ changes,
973
+ }
974
+
975
+ if (authToken) {
976
+ message.auth = { token: authToken }
977
+ }
978
+
979
+ // For non-empty pushes, set up timeout and wait for ACK
980
+ let timeoutId: ReturnType<typeof setTimeout> | undefined
981
+ if (!isEmptyPush && requestTimeout > 0) {
982
+ timeoutId = setTimeout(() => {
983
+ const pending = pendingPush.get(id)
984
+ if (pending) {
985
+ pendingPush.delete(id)
986
+ pending.reject(new Error('Push timeout'))
987
+ }
988
+ }, requestTimeout)
989
+ }
990
+
991
+ // For non-empty pushes, register pending request BEFORE waiting for connection
992
+ // This ensures ACK messages are processed correctly even if they
993
+ // arrive in the same event loop tick as connection opens
994
+ if (!isEmptyPush) {
995
+ pendingPush.set(id, { resolve, reject, timeoutId })
996
+ }
997
+
998
+ // Wait for connection, then send
999
+ ensureConnected()
1000
+ .then((socket) => {
1001
+ socket.send(JSON.stringify(message))
1002
+ // For empty pushes, resolve immediately after sending (no ACK needed)
1003
+ if (isEmptyPush) {
1004
+ resolve()
1005
+ }
1006
+ // For non-empty pushes, wait for ACK (pendingPush will resolve via handleMessage)
1007
+ })
1008
+ .catch((error) => {
1009
+ if (!isEmptyPush) {
1010
+ // Remove from pending map so ACK handler won't try to resolve it again
1011
+ pendingPush.delete(id)
1012
+ if (timeoutId) clearTimeout(timeoutId)
1013
+
1014
+ // Queue the mutation for offline resilience
1015
+ queueMutation(changes)
1016
+ resolve() // Resolve after queueing to signal acceptance
1017
+ } else {
1018
+ // Empty pushes also queue on connection failure for consistency
1019
+ // Just silently ignore the error (empty push is fire-and-forget with queueing)
1020
+ // Don't reject to avoid unhandled rejection
1021
+ }
1022
+ })
1023
+ })
1024
+ }
1025
+
1026
+ /**
1027
+ * Pull remote changes from server
1028
+ */
1029
+ function pull(): Promise<unknown[]> {
1030
+ const id = nextMessageId()
1031
+
1032
+ return new Promise((resolve, reject) => {
1033
+ const message: PullMessage = {
1034
+ type: 'pull',
1035
+ id,
1036
+ }
1037
+
1038
+ if (authToken) {
1039
+ message.auth = { token: authToken }
1040
+ }
1041
+
1042
+ // Set up timeout
1043
+ let timeoutId: ReturnType<typeof setTimeout> | undefined
1044
+ if (requestTimeout > 0) {
1045
+ timeoutId = setTimeout(() => {
1046
+ const pending = pendingPull.get(id)
1047
+ if (pending) {
1048
+ pendingPull.delete(id)
1049
+ pending.reject(new Error('Pull timeout'))
1050
+ }
1051
+ }, requestTimeout)
1052
+ }
1053
+
1054
+ // Register pending request BEFORE waiting for connection
1055
+ pendingPull.set(id, { resolve, reject, timeoutId })
1056
+
1057
+ // Wait for connection, then send
1058
+ ensureConnected()
1059
+ .then((socket) => {
1060
+ socket.send(JSON.stringify(message))
1061
+ })
1062
+ .catch((error) => {
1063
+ pendingPull.delete(id)
1064
+ if (timeoutId) clearTimeout(timeoutId)
1065
+ reject(error)
1066
+ })
1067
+ })
1068
+ }
1069
+
1070
+ /**
1071
+ * Subscribe to remote changes
1072
+ */
1073
+ function subscribe(callback: (changes: unknown[]) => void): () => void {
1074
+ subscribers.add(callback)
1075
+
1076
+ // Ensure connection is established for subscription
1077
+ connect()
1078
+
1079
+ return () => {
1080
+ subscribers.delete(callback)
1081
+ }
1082
+ }
1083
+
1084
+ /**
1085
+ * Close the WebSocket connection
1086
+ *
1087
+ * @remarks
1088
+ * Stops all reconnection attempts and closes the active WebSocket.
1089
+ * Connection state is updated to 'disconnected' and remains so until
1090
+ * the adapter is recreated.
1091
+ */
1092
+ function close(): void {
1093
+ isClosed = true
1094
+
1095
+ if (reconnectTimeout) {
1096
+ clearTimeout(reconnectTimeout)
1097
+ reconnectTimeout = null
1098
+ }
1099
+
1100
+ if (ws) {
1101
+ ws.close()
1102
+ ws = null
1103
+ }
1104
+
1105
+ notifyConnectionStateChange('disconnected')
1106
+ }
1107
+
1108
+ /**
1109
+ * Update auth token dynamically
1110
+ *
1111
+ * @remarks
1112
+ * Updates the token used for all future connections. Existing connection
1113
+ * will use the new token on reconnection. Useful for refreshing expired
1114
+ * credentials without recreating the adapter.
1115
+ *
1116
+ * @example
1117
+ * ```typescript
1118
+ * adapter.setAuthToken(newToken)
1119
+ * ```
1120
+ */
1121
+ function setAuthToken(token: string): void {
1122
+ authToken = token
1123
+ }
1124
+
1125
+ /**
1126
+ * Subscribe to connection state changes
1127
+ *
1128
+ * @remarks
1129
+ * Returns an unsubscribe function. Observers are called synchronously
1130
+ * whenever the connection state changes. The callback is called immediately
1131
+ * with the current state when subscribed.
1132
+ *
1133
+ * @example
1134
+ * ```typescript
1135
+ * const unsubscribe = adapter.onConnectionStateChange((state) => {
1136
+ * console.log('Connection state:', state)
1137
+ * })
1138
+ * unsubscribe() // Stop listening
1139
+ * ```
1140
+ */
1141
+ function onConnectionStateChange(callback: ConnectionStateObserver): () => void {
1142
+ connectionStateObservers.add(callback)
1143
+ // Call immediately with current state
1144
+ callback(connectionState)
1145
+ return () => {
1146
+ connectionStateObservers.delete(callback)
1147
+ }
1148
+ }
1149
+
1150
+ /**
1151
+ * Get the current connection state
1152
+ *
1153
+ * @remarks
1154
+ * Returns the current state without subscribing. Use `onConnectionStateChange`
1155
+ * for reactive updates. Possible states:
1156
+ * - `disconnected`: No active connection
1157
+ * - `connecting`: Attempting to establish connection
1158
+ * - `connected`: Active connection ready for sync
1159
+ * - `reconnecting`: In exponential backoff before retry
1160
+ *
1161
+ * @returns Current connection state
1162
+ */
1163
+ function getConnectionState(): ConnectionState {
1164
+ return connectionState
1165
+ }
1166
+
1167
+ /**
1168
+ * Get all queued mutations (for inspection/debugging)
1169
+ *
1170
+ * @remarks
1171
+ * Returns a snapshot of all mutations currently in the offline queue.
1172
+ * Useful for debugging or displaying pending sync status to users.
1173
+ * The queue is automatically flushed when the connection is restored.
1174
+ *
1175
+ * @returns Array of queued mutations with retry counts
1176
+ */
1177
+ function getQueuedMutations(): QueuedMutation[] {
1178
+ return Array.from(mutationQueue.values())
1179
+ }
1180
+
1181
+ /**
1182
+ * Get statistics about the offline mutation queue
1183
+ *
1184
+ * @remarks
1185
+ * Provides queue statistics:
1186
+ * - `count`: Number of mutations waiting to be synced
1187
+ * - `oldestAt`: Timestamp of the oldest queued mutation (null if empty)
1188
+ *
1189
+ * Useful for UI to show "N pending changes" or "syncing..." indicators.
1190
+ *
1191
+ * @returns Queue statistics with count and oldest mutation timestamp
1192
+ */
1193
+ function getQueueStats(): { count: number; oldestAt: number | null } {
1194
+ const mutations = Array.from(mutationQueue.values())
1195
+ if (mutations.length === 0) {
1196
+ return { count: 0, oldestAt: null }
1197
+ }
1198
+ const oldest = Math.min(...mutations.map((m) => m.queuedAt))
1199
+ return { count: mutations.length, oldestAt: oldest }
1200
+ }
1201
+
1202
+ return {
1203
+ push,
1204
+ pull,
1205
+ subscribe,
1206
+ close,
1207
+ setAuthToken,
1208
+ onConnectionStateChange,
1209
+ getConnectionState,
1210
+ getQueuedMutations,
1211
+ getQueueStats,
1212
+ }
1213
+ }