@voidhash/mimic 0.0.1-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +17 -0
  2. package/package.json +33 -0
  3. package/src/Document.ts +256 -0
  4. package/src/FractionalIndex.ts +1249 -0
  5. package/src/Operation.ts +59 -0
  6. package/src/OperationDefinition.ts +23 -0
  7. package/src/OperationPath.ts +197 -0
  8. package/src/Presence.ts +142 -0
  9. package/src/Primitive.ts +32 -0
  10. package/src/Proxy.ts +8 -0
  11. package/src/ProxyEnvironment.ts +52 -0
  12. package/src/Transaction.ts +72 -0
  13. package/src/Transform.ts +13 -0
  14. package/src/client/ClientDocument.ts +1163 -0
  15. package/src/client/Rebase.ts +309 -0
  16. package/src/client/StateMonitor.ts +307 -0
  17. package/src/client/Transport.ts +318 -0
  18. package/src/client/WebSocketTransport.ts +572 -0
  19. package/src/client/errors.ts +145 -0
  20. package/src/client/index.ts +61 -0
  21. package/src/index.ts +12 -0
  22. package/src/primitives/Array.ts +457 -0
  23. package/src/primitives/Boolean.ts +128 -0
  24. package/src/primitives/Lazy.ts +89 -0
  25. package/src/primitives/Literal.ts +128 -0
  26. package/src/primitives/Number.ts +169 -0
  27. package/src/primitives/String.ts +189 -0
  28. package/src/primitives/Struct.ts +348 -0
  29. package/src/primitives/Tree.ts +1120 -0
  30. package/src/primitives/TreeNode.ts +113 -0
  31. package/src/primitives/Union.ts +329 -0
  32. package/src/primitives/shared.ts +122 -0
  33. package/src/server/ServerDocument.ts +267 -0
  34. package/src/server/errors.ts +90 -0
  35. package/src/server/index.ts +40 -0
  36. package/tests/Document.test.ts +556 -0
  37. package/tests/FractionalIndex.test.ts +377 -0
  38. package/tests/OperationPath.test.ts +151 -0
  39. package/tests/Presence.test.ts +321 -0
  40. package/tests/Primitive.test.ts +381 -0
  41. package/tests/client/ClientDocument.test.ts +1398 -0
  42. package/tests/client/WebSocketTransport.test.ts +992 -0
  43. package/tests/primitives/Array.test.ts +418 -0
  44. package/tests/primitives/Boolean.test.ts +126 -0
  45. package/tests/primitives/Lazy.test.ts +143 -0
  46. package/tests/primitives/Literal.test.ts +122 -0
  47. package/tests/primitives/Number.test.ts +133 -0
  48. package/tests/primitives/String.test.ts +128 -0
  49. package/tests/primitives/Struct.test.ts +311 -0
  50. package/tests/primitives/Tree.test.ts +467 -0
  51. package/tests/primitives/TreeNode.test.ts +50 -0
  52. package/tests/primitives/Union.test.ts +210 -0
  53. package/tests/server/ServerDocument.test.ts +528 -0
  54. package/tsconfig.build.json +24 -0
  55. package/tsconfig.json +8 -0
  56. package/tsdown.config.ts +18 -0
  57. package/vitest.mts +11 -0
@@ -0,0 +1,1163 @@
1
+ import * as Document from "../Document";
2
+ import * as Transaction from "../Transaction";
3
+ import * as Presence from "../Presence";
4
+ import type * as Primitive from "../Primitive";
5
+ import type * as Transport from "./Transport";
6
+ import * as Rebase from "./Rebase";
7
+ import {
8
+ TransactionRejectedError,
9
+ NotConnectedError,
10
+ InvalidStateError,
11
+ } from "./errors";
12
+
13
+ // =============================================================================
14
+ // Client Document Types
15
+ // =============================================================================
16
+
17
+ /**
18
+ * Pending transaction with metadata for tracking.
19
+ */
20
+ interface PendingTransaction {
21
+ /** The transaction */
22
+ readonly transaction: Transaction.Transaction;
23
+ /** Original transaction before any rebasing */
24
+ readonly original: Transaction.Transaction;
25
+ /** Timestamp when the transaction was sent */
26
+ readonly sentAt: number;
27
+ }
28
+
29
+ /**
30
+ * Initialization state for the client document.
31
+ * Handles the race condition during startup where transactions
32
+ * may arrive while fetching the initial snapshot.
33
+ */
34
+ type InitState =
35
+ | { readonly type: "uninitialized" }
36
+ | { readonly type: "initializing"; readonly bufferedMessages: Transport.ServerMessage[] }
37
+ | { readonly type: "ready" };
38
+
39
+ // =============================================================================
40
+ // Presence Types
41
+ // =============================================================================
42
+
43
+ /**
44
+ * Listener for presence changes.
45
+ */
46
+ export interface PresenceListener<TData> {
47
+ /** Called when any presence changes (self or others) */
48
+ readonly onPresenceChange?: () => void;
49
+ }
50
+
51
+ /**
52
+ * Presence API exposed on the ClientDocument.
53
+ */
54
+ export interface ClientPresence<TData> {
55
+ /**
56
+ * Returns this client's connection ID (set after receiving presence_snapshot).
57
+ * Returns undefined before the snapshot is received.
58
+ */
59
+ readonly selfId: () => string | undefined;
60
+
61
+ /**
62
+ * Returns this client's current presence data.
63
+ * Returns undefined if not set.
64
+ */
65
+ readonly self: () => TData | undefined;
66
+
67
+ /**
68
+ * Returns a map of other clients' presence data.
69
+ * Keys are connection IDs.
70
+ */
71
+ readonly others: () => ReadonlyMap<string, Presence.PresenceEntry<TData>>;
72
+
73
+ /**
74
+ * Returns all presence entries including self.
75
+ */
76
+ readonly all: () => ReadonlyMap<string, Presence.PresenceEntry<TData>>;
77
+
78
+ /**
79
+ * Sets this client's presence data.
80
+ * Validates against the presence schema before sending.
81
+ * @throws ParseError if validation fails
82
+ */
83
+ readonly set: (data: TData) => void;
84
+
85
+ /**
86
+ * Clears this client's presence data.
87
+ */
88
+ readonly clear: () => void;
89
+
90
+ /**
91
+ * Subscribes to presence changes.
92
+ * @returns Unsubscribe function
93
+ */
94
+ readonly subscribe: (listener: PresenceListener<TData>) => () => void;
95
+ }
96
+
97
+ /**
98
+ * Options for creating a ClientDocument.
99
+ */
100
+ export interface ClientDocumentOptions<
101
+ TSchema extends Primitive.AnyPrimitive,
102
+ TPresence extends Presence.AnyPresence | undefined = undefined
103
+ > {
104
+ /** The schema defining the document structure */
105
+ readonly schema: TSchema;
106
+ /** Transport for server communication */
107
+ readonly transport: Transport.Transport;
108
+ /** Initial state (optional, will sync from server if not provided) */
109
+ readonly initialState?: Primitive.InferState<TSchema>;
110
+ /** Initial server version (optional) */
111
+ readonly initialVersion?: number;
112
+ /** Called when server rejects a transaction */
113
+ readonly onRejection?: (
114
+ transaction: Transaction.Transaction,
115
+ reason: string
116
+ ) => void;
117
+ /** Called when optimistic state changes */
118
+ readonly onStateChange?: (state: Primitive.InferState<TSchema> | undefined) => void;
119
+ /** Called when connection status changes */
120
+ readonly onConnectionChange?: (connected: boolean) => void;
121
+ /** Called when client is fully initialized and ready */
122
+ readonly onReady?: () => void;
123
+ /** Timeout in ms for pending transactions (default: 30000) */
124
+ readonly transactionTimeout?: number;
125
+ /** Timeout in ms for initialization (default: 10000) */
126
+ readonly initTimeout?: number;
127
+ /** Enable debug logging for all activity (default: false) */
128
+ readonly debug?: boolean;
129
+ /**
130
+ * Optional presence schema for ephemeral per-user data.
131
+ * When provided, enables the presence API on the ClientDocument.
132
+ */
133
+ readonly presence?: TPresence;
134
+ /** Initial presence data, that will be set on the ClientDocument when it is created */
135
+ readonly initialPresence?: TPresence extends Presence.AnyPresence ? Presence.Infer<TPresence> : undefined;
136
+ }
137
+
138
+ /**
139
+ * Listener callbacks for subscribing to ClientDocument events.
140
+ */
141
+ export interface ClientDocumentListener<TSchema extends Primitive.AnyPrimitive> {
142
+ /** Called when optimistic state changes */
143
+ readonly onStateChange?: (state: Primitive.InferState<TSchema> | undefined) => void;
144
+ /** Called when connection status changes */
145
+ readonly onConnectionChange?: (connected: boolean) => void;
146
+ /** Called when client is fully initialized and ready */
147
+ readonly onReady?: () => void;
148
+ }
149
+
150
+ /**
151
+ * A ClientDocument provides optimistic updates with server synchronization.
152
+ */
153
+ export interface ClientDocument<
154
+ TSchema extends Primitive.AnyPrimitive,
155
+ TPresence extends Presence.AnyPresence | undefined = undefined
156
+ > {
157
+ /** The schema defining this document's structure */
158
+ readonly schema: TSchema;
159
+
160
+ /** Root proxy for accessing and modifying document data (optimistic) */
161
+ readonly root: Primitive.InferProxy<TSchema>;
162
+
163
+ /** Returns the current optimistic state (server + pending) */
164
+ get(): Primitive.InferState<TSchema> | undefined;
165
+
166
+ /** Returns the confirmed server state */
167
+ getServerState(): Primitive.InferState<TSchema> | undefined;
168
+
169
+ /** Returns the current server version */
170
+ getServerVersion(): number;
171
+
172
+ /** Returns pending transactions count */
173
+ getPendingCount(): number;
174
+
175
+ /** Returns whether there are pending transactions */
176
+ hasPendingChanges(): boolean;
177
+
178
+ /**
179
+ * Runs a function within a transaction.
180
+ * Changes are applied optimistically and sent to the server.
181
+ */
182
+ transaction<R>(fn: (root: Primitive.InferProxy<TSchema>) => R): R;
183
+
184
+ /**
185
+ * Connects to the server and starts syncing.
186
+ */
187
+ connect(): Promise<void>;
188
+
189
+ /**
190
+ * Disconnects from the server.
191
+ */
192
+ disconnect(): void;
193
+
194
+ /**
195
+ * Returns whether currently connected to the server.
196
+ */
197
+ isConnected(): boolean;
198
+
199
+ /**
200
+ * Forces a full resync from the server.
201
+ */
202
+ resync(): void;
203
+
204
+ /**
205
+ * Returns whether the client is fully initialized and ready.
206
+ */
207
+ isReady(): boolean;
208
+
209
+ /**
210
+ * Subscribes to document events (state changes, connection changes, ready).
211
+ * @returns Unsubscribe function
212
+ */
213
+ subscribe(listener: ClientDocumentListener<TSchema>): () => void;
214
+
215
+ /**
216
+ * Presence API for ephemeral per-user data.
217
+ * Only available when presence schema is provided in options.
218
+ */
219
+ readonly presence: TPresence extends Presence.AnyPresence
220
+ ? ClientPresence<Presence.Infer<TPresence>>
221
+ : undefined;
222
+ }
223
+
224
+ // =============================================================================
225
+ // Client Document Implementation
226
+ // =============================================================================
227
+
228
+ /**
229
+ * Creates a new ClientDocument for the given schema.
230
+ */
231
+ export const make = <
232
+ TSchema extends Primitive.AnyPrimitive,
233
+ TPresence extends Presence.AnyPresence | undefined = undefined
234
+ >(
235
+ options: ClientDocumentOptions<TSchema, TPresence>
236
+ ): ClientDocument<TSchema, TPresence> => {
237
+ const {
238
+ schema,
239
+ transport,
240
+ initialState,
241
+ initialVersion = 0,
242
+ onRejection,
243
+ onStateChange,
244
+ onConnectionChange,
245
+ onReady,
246
+ transactionTimeout = 30000,
247
+ initTimeout = 10000,
248
+ debug = false,
249
+ presence: presenceSchema,
250
+ initialPresence,
251
+ } = options;
252
+
253
+ // ==========================================================================
254
+ // Internal State
255
+ // ==========================================================================
256
+
257
+ // Server-confirmed state
258
+ let _serverState: Primitive.InferState<TSchema> | undefined = initialState;
259
+ let _serverVersion = initialVersion;
260
+
261
+ // Pending transactions queue
262
+ let _pending: PendingTransaction[] = [];
263
+
264
+ // Server transactions received (for rebase after rejection)
265
+ let _serverTransactionHistory: Transaction.Transaction[] = [];
266
+ const MAX_HISTORY_SIZE = 100;
267
+
268
+ // The underlying document for optimistic state
269
+ let _optimisticDoc = Document.make(schema, { initial: _serverState });
270
+
271
+ // Subscription cleanup
272
+ let _unsubscribe: (() => void) | null = null;
273
+
274
+ // Timeout handles for pending transactions
275
+ const _timeoutHandles = new Map<string, ReturnType<typeof setTimeout>>();
276
+
277
+ // Initialization state - handles buffering during startup
278
+ let _initState: InitState = initialState !== undefined
279
+ ? { type: "ready" }
280
+ : { type: "uninitialized" };
281
+
282
+ // Init timeout handle
283
+ let _initTimeoutHandle: ReturnType<typeof setTimeout> | null = null;
284
+
285
+ // Promise resolver for connect() to wait for ready state
286
+ let _initResolver: (() => void) | null = null;
287
+ let _initRejecter: ((error: Error) => void) | null = null;
288
+
289
+ // Subscribers for events (added after creation via subscribe())
290
+ const _subscribers = new Set<ClientDocumentListener<TSchema>>();
291
+
292
+ // ==========================================================================
293
+ // Presence State (only used when presenceSchema is provided)
294
+ // ==========================================================================
295
+
296
+ // This client's connection ID (received from presence_snapshot)
297
+ let _presenceSelfId: string | undefined = undefined;
298
+
299
+ // This client's current presence data
300
+ let _presenceSelfData: unknown = undefined;
301
+
302
+ // Other clients' presence entries (connectionId -> entry)
303
+ const _presenceOthers = new Map<string, Presence.PresenceEntry<unknown>>();
304
+
305
+ // Presence change subscribers
306
+ const _presenceSubscribers = new Set<PresenceListener<unknown>>();
307
+
308
+ // ==========================================================================
309
+ // Debug Logging
310
+ // ==========================================================================
311
+
312
+ /**
313
+ * Debug logging helper that only logs when debug is enabled.
314
+ */
315
+ const debugLog = (...args: unknown[]): void => {
316
+ if (debug) {
317
+ console.log("[ClientDocument]", ...args);
318
+ }
319
+ };
320
+
321
+ // ==========================================================================
322
+ // Notification Helpers
323
+ // ==========================================================================
324
+
325
+ /**
326
+ * Notifies all listeners of a state change.
327
+ */
328
+ const notifyStateChange = (state: Primitive.InferState<TSchema> | undefined): void => {
329
+ debugLog("notifyStateChange", {
330
+ state,
331
+ subscriberCount: _subscribers.size,
332
+ hasOnStateChange: !!onStateChange,
333
+ });
334
+ onStateChange?.(state);
335
+ for (const listener of _subscribers) {
336
+ listener.onStateChange?.(state);
337
+ }
338
+ };
339
+
340
+ /**
341
+ * Notifies all listeners of a connection change.
342
+ */
343
+ const notifyConnectionChange = (connected: boolean): void => {
344
+ debugLog("notifyConnectionChange", {
345
+ connected,
346
+ subscriberCount: _subscribers.size,
347
+ hasOnConnectionChange: !!onConnectionChange,
348
+ });
349
+ onConnectionChange?.(connected);
350
+ for (const listener of _subscribers) {
351
+ listener.onConnectionChange?.(connected);
352
+ }
353
+ };
354
+
355
+ /**
356
+ * Notifies all listeners when ready.
357
+ */
358
+ const notifyReady = (): void => {
359
+ debugLog("notifyReady", {
360
+ subscriberCount: _subscribers.size,
361
+ hasOnReady: !!onReady,
362
+ });
363
+ onReady?.();
364
+ for (const listener of _subscribers) {
365
+ listener.onReady?.();
366
+ }
367
+ };
368
+
369
+ /**
370
+ * Notifies all presence listeners of a change.
371
+ */
372
+ const notifyPresenceChange = (): void => {
373
+ debugLog("notifyPresenceChange", {
374
+ subscriberCount: _presenceSubscribers.size,
375
+ });
376
+ for (const listener of _presenceSubscribers) {
377
+ try {
378
+ listener.onPresenceChange?.();
379
+ } catch {
380
+ // Ignore listener errors
381
+ }
382
+ }
383
+ };
384
+
385
+ // ==========================================================================
386
+ // Presence Handlers
387
+ // ==========================================================================
388
+
389
+ /**
390
+ * Handles incoming presence snapshot from server.
391
+ */
392
+ const handlePresenceSnapshot = (message: Transport.PresenceSnapshotMessage): void => {
393
+ if (!presenceSchema) return;
394
+
395
+ debugLog("handlePresenceSnapshot", {
396
+ selfId: message.selfId,
397
+ presenceCount: Object.keys(message.presences).length,
398
+ });
399
+
400
+ _presenceSelfId = message.selfId;
401
+ _presenceOthers.clear();
402
+
403
+ // Populate others from snapshot (exclude self)
404
+ for (const [id, entry] of Object.entries(message.presences)) {
405
+ if (id !== message.selfId) {
406
+ _presenceOthers.set(id, entry);
407
+ }
408
+ }
409
+
410
+ notifyPresenceChange();
411
+ };
412
+
413
+ /**
414
+ * Handles incoming presence update from server (another user).
415
+ */
416
+ const handlePresenceUpdate = (message: Transport.PresenceUpdateMessage): void => {
417
+ if (!presenceSchema) return;
418
+
419
+ debugLog("handlePresenceUpdate", {
420
+ id: message.id,
421
+ userId: message.userId,
422
+ });
423
+
424
+ _presenceOthers.set(message.id, {
425
+ data: message.data,
426
+ userId: message.userId,
427
+ });
428
+
429
+ notifyPresenceChange();
430
+ };
431
+
432
+ /**
433
+ * Handles incoming presence remove from server (user disconnected).
434
+ */
435
+ const handlePresenceRemove = (message: Transport.PresenceRemoveMessage): void => {
436
+ if (!presenceSchema) return;
437
+
438
+ debugLog("handlePresenceRemove", {
439
+ id: message.id,
440
+ });
441
+
442
+ _presenceOthers.delete(message.id);
443
+ notifyPresenceChange();
444
+ };
445
+
446
+ /**
447
+ * Clears all presence state (on disconnect).
448
+ */
449
+ const clearPresenceState = (): void => {
450
+ _presenceSelfId = undefined;
451
+ _presenceSelfData = undefined;
452
+ _presenceOthers.clear();
453
+ notifyPresenceChange();
454
+ };
455
+
456
+ // ==========================================================================
457
+ // Helper Functions
458
+ // ==========================================================================
459
+
460
+ /**
461
+ * Recomputes the optimistic document from server state + pending transactions.
462
+ */
463
+ const recomputeOptimisticState = (): void => {
464
+ debugLog("recomputeOptimisticState", {
465
+ serverVersion: _serverVersion,
466
+ pendingCount: _pending.length,
467
+ serverState: _serverState,
468
+ });
469
+
470
+ // Create fresh document from server state
471
+ _optimisticDoc = Document.make(schema, { initial: _serverState });
472
+
473
+ // Apply all pending transactions
474
+ for (const pending of _pending) {
475
+ _optimisticDoc.apply(pending.transaction.ops);
476
+ }
477
+
478
+ const newState = _optimisticDoc.get();
479
+ debugLog("recomputeOptimisticState: new optimistic state", newState);
480
+
481
+ // Notify state change
482
+ notifyStateChange(newState);
483
+ };
484
+
485
+ /**
486
+ * Adds a transaction to pending queue and sends to server.
487
+ */
488
+ const submitTransaction = (tx: Transaction.Transaction): void => {
489
+ if (!transport.isConnected()) {
490
+ throw new NotConnectedError();
491
+ }
492
+
493
+ debugLog("submitTransaction", {
494
+ txId: tx.id,
495
+ ops: tx.ops,
496
+ pendingCount: _pending.length + 1,
497
+ });
498
+
499
+ const pending: PendingTransaction = {
500
+ transaction: tx,
501
+ original: tx,
502
+ sentAt: Date.now(),
503
+ };
504
+
505
+ _pending.push(pending);
506
+
507
+ // Set timeout for this transaction
508
+ const timeoutHandle = setTimeout(() => {
509
+ handleTransactionTimeout(tx.id);
510
+ }, transactionTimeout);
511
+ _timeoutHandles.set(tx.id, timeoutHandle);
512
+
513
+ // Send to server
514
+ transport.send(tx);
515
+ debugLog("submitTransaction: sent to server", { txId: tx.id });
516
+ };
517
+
518
+ /**
519
+ * Handles a transaction timeout.
520
+ */
521
+ const handleTransactionTimeout = (txId: string): void => {
522
+ debugLog("handleTransactionTimeout", { txId });
523
+ const index = _pending.findIndex((p) => p.transaction.id === txId);
524
+ if (index === -1) {
525
+ debugLog("handleTransactionTimeout: transaction not found (already confirmed/rejected)", { txId });
526
+ return; // Already confirmed or rejected
527
+ }
528
+
529
+ // Remove from pending
530
+ const [removed] = _pending.splice(index, 1);
531
+ _timeoutHandles.delete(txId);
532
+
533
+ debugLog("handleTransactionTimeout: removed from pending", {
534
+ txId,
535
+ remainingPending: _pending.length,
536
+ });
537
+
538
+ // Recompute state
539
+ recomputeOptimisticState();
540
+
541
+ // Notify as rejection
542
+ onRejection?.(removed!.transaction, "Transaction timed out");
543
+ };
544
+
545
+ /**
546
+ * Handles an incoming server transaction.
547
+ */
548
+ const handleServerTransaction = (
549
+ serverTx: Transaction.Transaction,
550
+ version: number
551
+ ): void => {
552
+ debugLog("handleServerTransaction", {
553
+ txId: serverTx.id,
554
+ version,
555
+ ops: serverTx.ops,
556
+ currentServerVersion: _serverVersion,
557
+ pendingCount: _pending.length,
558
+ });
559
+
560
+ // Update server version
561
+ _serverVersion = version;
562
+
563
+ // Check if this is one of our pending transactions (ACK)
564
+ const pendingIndex = _pending.findIndex(
565
+ (p) => p.transaction.id === serverTx.id
566
+ );
567
+
568
+ if (pendingIndex !== -1) {
569
+ // This is our transaction - confirmed!
570
+ debugLog("handleServerTransaction: transaction confirmed (ACK)", {
571
+ txId: serverTx.id,
572
+ pendingIndex,
573
+ });
574
+
575
+ const confirmed = _pending[pendingIndex]!;
576
+
577
+ // Clear timeout
578
+ const timeoutHandle = _timeoutHandles.get(serverTx.id);
579
+ if (timeoutHandle) {
580
+ clearTimeout(timeoutHandle);
581
+ _timeoutHandles.delete(serverTx.id);
582
+ }
583
+
584
+ // Remove from pending
585
+ _pending.splice(pendingIndex, 1);
586
+
587
+ // Apply to server state
588
+ const tempDoc = Document.make(schema, { initial: _serverState });
589
+ tempDoc.apply(serverTx.ops);
590
+ _serverState = tempDoc.get();
591
+
592
+ debugLog("handleServerTransaction: updated server state", {
593
+ txId: serverTx.id,
594
+ newServerState: _serverState,
595
+ remainingPending: _pending.length,
596
+ });
597
+
598
+ // Recompute optimistic state (pending txs already applied, just need to update base)
599
+ recomputeOptimisticState();
600
+ } else {
601
+ // This is someone else's transaction - need to rebase
602
+ debugLog("handleServerTransaction: remote transaction, rebasing pending", {
603
+ txId: serverTx.id,
604
+ pendingCount: _pending.length,
605
+ });
606
+
607
+ // Apply to server state
608
+ const tempDoc = Document.make(schema, { initial: _serverState });
609
+ tempDoc.apply(serverTx.ops);
610
+ _serverState = tempDoc.get();
611
+
612
+ // Add to history for potential rebase after rejection
613
+ _serverTransactionHistory.push(serverTx);
614
+ if (_serverTransactionHistory.length > MAX_HISTORY_SIZE) {
615
+ _serverTransactionHistory.shift();
616
+ }
617
+
618
+ // Rebase all pending transactions using primitive-based transformation
619
+ const rebasedPending = _pending.map((p) => ({
620
+ ...p,
621
+ transaction: Rebase.transformTransactionWithPrimitive(p.transaction, serverTx, schema),
622
+ }));
623
+
624
+ debugLog("handleServerTransaction: rebased pending transactions", {
625
+ txId: serverTx.id,
626
+ rebasedCount: rebasedPending.length,
627
+ originalPendingIds: _pending.map((p) => p.transaction.id),
628
+ rebasedPendingIds: rebasedPending.map((p) => p.transaction.id),
629
+ });
630
+
631
+ _pending = rebasedPending;
632
+
633
+ // Recompute optimistic state
634
+ recomputeOptimisticState();
635
+ }
636
+ };
637
+
638
+ /**
639
+ * Handles a transaction rejection from the server.
640
+ */
641
+ const handleRejection = (txId: string, reason: string): void => {
642
+ debugLog("handleRejection", {
643
+ txId,
644
+ reason,
645
+ pendingCount: _pending.length,
646
+ });
647
+
648
+ const index = _pending.findIndex((p) => p.transaction.id === txId);
649
+ if (index === -1) {
650
+ debugLog("handleRejection: transaction not found (already removed)", { txId });
651
+ return; // Already removed
652
+ }
653
+
654
+ const rejected = _pending[index]!;
655
+
656
+ // Clear timeout
657
+ const timeoutHandle = _timeoutHandles.get(txId);
658
+ if (timeoutHandle) {
659
+ clearTimeout(timeoutHandle);
660
+ _timeoutHandles.delete(txId);
661
+ }
662
+
663
+ // Remove rejected transaction
664
+ _pending.splice(index, 1);
665
+
666
+ debugLog("handleRejection: removed rejected transaction, rebasing remaining", {
667
+ txId,
668
+ remainingPending: _pending.length,
669
+ serverHistorySize: _serverTransactionHistory.length,
670
+ });
671
+
672
+ // Re-transform remaining pending transactions without the rejected one
673
+ // We need to replay from their original state
674
+ const remainingOriginals = _pending.map((p) => p.original);
675
+ const retransformed = Rebase.rebaseAfterRejectionWithPrimitive(
676
+ [...remainingOriginals, rejected.original],
677
+ txId,
678
+ _serverTransactionHistory,
679
+ schema
680
+ );
681
+
682
+ // Update pending with retransformed versions
683
+ _pending = _pending.map((p, i) => ({
684
+ ...p,
685
+ transaction: retransformed[i] ?? p.transaction,
686
+ }));
687
+
688
+ debugLog("handleRejection: rebased remaining transactions", {
689
+ txId,
690
+ rebasedCount: _pending.length,
691
+ });
692
+
693
+ // Recompute optimistic state
694
+ recomputeOptimisticState();
695
+
696
+ // Notify rejection
697
+ onRejection?.(rejected.original, reason);
698
+ };
699
+
700
+ /**
701
+ * Handles a snapshot from the server.
702
+ * @param isInitialSnapshot - If true, this is the initial sync snapshot
703
+ */
704
+ const handleSnapshot = (state: unknown, version: number, isInitialSnapshot: boolean = false): void => {
705
+ debugLog("handleSnapshot", {
706
+ isInitialSnapshot,
707
+ version,
708
+ currentServerVersion: _serverVersion,
709
+ pendingCount: _pending.length,
710
+ state,
711
+ });
712
+
713
+ if (!isInitialSnapshot) {
714
+ debugLog("handleSnapshot: non-initial snapshot, clearing pending transactions", {
715
+ clearedPendingCount: _pending.length,
716
+ });
717
+
718
+ // For non-initial snapshots, clear all pending (they're now invalid)
719
+ for (const handle of _timeoutHandles.values()) {
720
+ clearTimeout(handle);
721
+ }
722
+ _timeoutHandles.clear();
723
+
724
+ // Notify rejections for all pending
725
+ for (const pending of _pending) {
726
+ onRejection?.(pending.original, "State reset due to resync");
727
+ }
728
+
729
+ _pending = [];
730
+ }
731
+
732
+ _serverTransactionHistory = [];
733
+ _serverState = state as Primitive.InferState<TSchema>;
734
+ _serverVersion = version;
735
+
736
+ debugLog("handleSnapshot: updated server state", {
737
+ newVersion: _serverVersion,
738
+ newState: _serverState,
739
+ });
740
+
741
+ // Recompute optimistic state (now equals server state)
742
+ recomputeOptimisticState();
743
+ };
744
+
745
+ /**
746
+ * Processes buffered messages after receiving the initial snapshot.
747
+ * Filters out transactions already included in the snapshot (version <= snapshotVersion)
748
+ * and applies newer transactions in order.
749
+ */
750
+ const processBufferedMessages = (
751
+ bufferedMessages: Transport.ServerMessage[],
752
+ snapshotVersion: number
753
+ ): void => {
754
+ debugLog("processBufferedMessages", {
755
+ bufferedCount: bufferedMessages.length,
756
+ snapshotVersion,
757
+ });
758
+
759
+ // Sort transactions by version to ensure correct order
760
+ const sortedMessages = [...bufferedMessages].sort((a, b) => {
761
+ if (a.type === "transaction" && b.type === "transaction") {
762
+ return a.version - b.version;
763
+ }
764
+ return 0;
765
+ });
766
+
767
+ // Process each buffered message
768
+ for (const message of sortedMessages) {
769
+ switch (message.type) {
770
+ case "transaction":
771
+ // Only apply transactions with version > snapshot version
772
+ if (message.version > snapshotVersion) {
773
+ debugLog("processBufferedMessages: applying buffered transaction", {
774
+ txId: message.transaction.id,
775
+ version: message.version,
776
+ snapshotVersion,
777
+ });
778
+ handleServerTransaction(message.transaction, message.version);
779
+ } else {
780
+ debugLog("processBufferedMessages: skipping buffered transaction (already in snapshot)", {
781
+ txId: message.transaction.id,
782
+ version: message.version,
783
+ snapshotVersion,
784
+ });
785
+ }
786
+ break;
787
+ case "error":
788
+ // Errors are still relevant - pass through
789
+ debugLog("processBufferedMessages: processing buffered error", {
790
+ txId: message.transactionId,
791
+ reason: message.reason,
792
+ });
793
+ handleRejection(message.transactionId, message.reason);
794
+ break;
795
+ // Ignore additional snapshots in buffer - we already have one
796
+ }
797
+ }
798
+ };
799
+
800
+ /**
801
+ * Completes initialization and transitions to ready state.
802
+ */
803
+ const completeInitialization = (): void => {
804
+ debugLog("completeInitialization");
805
+
806
+ // Clear init timeout
807
+ if (_initTimeoutHandle !== null) {
808
+ clearTimeout(_initTimeoutHandle);
809
+ _initTimeoutHandle = null;
810
+ }
811
+
812
+ _initState = { type: "ready" };
813
+
814
+ // Resolve the connect promise
815
+ if (_initResolver) {
816
+ _initResolver();
817
+ _initResolver = null;
818
+ _initRejecter = null;
819
+ }
820
+
821
+ debugLog("completeInitialization: ready", {
822
+ serverVersion: _serverVersion,
823
+ serverState: _serverState,
824
+ });
825
+
826
+ // Notify ready
827
+ notifyReady();
828
+ };
829
+
830
+ /**
831
+ * Handles initialization timeout.
832
+ */
833
+ const handleInitTimeout = (): void => {
834
+ debugLog("handleInitTimeout: initialization timed out");
835
+ _initTimeoutHandle = null;
836
+
837
+ // Reject the connect promise
838
+ if (_initRejecter) {
839
+ const error = new Error("Initialization timed out waiting for snapshot");
840
+ _initRejecter(error);
841
+ _initResolver = null;
842
+ _initRejecter = null;
843
+ }
844
+
845
+ // Reset to uninitialized state
846
+ _initState = { type: "uninitialized" };
847
+ };
848
+
849
+ /**
850
+ * Handles incoming server messages.
851
+ * During initialization, messages are buffered until the snapshot arrives.
852
+ * Presence messages are always processed immediately (not buffered).
853
+ */
854
+ const handleServerMessage = (message: Transport.ServerMessage): void => {
855
+ debugLog("handleServerMessage", {
856
+ messageType: message.type,
857
+ initState: _initState.type,
858
+ });
859
+
860
+ // Presence messages are always handled immediately (not buffered)
861
+ // This allows presence to work even during document initialization
862
+ if (message.type === "presence_snapshot") {
863
+ handlePresenceSnapshot(message);
864
+ return;
865
+ }
866
+ if (message.type === "presence_update") {
867
+ handlePresenceUpdate(message);
868
+ return;
869
+ }
870
+ if (message.type === "presence_remove") {
871
+ handlePresenceRemove(message);
872
+ return;
873
+ }
874
+
875
+ // Handle based on initialization state
876
+ if (_initState.type === "initializing") {
877
+ if (message.type === "snapshot") {
878
+ debugLog("handleServerMessage: received snapshot during initialization", {
879
+ version: message.version,
880
+ bufferedCount: _initState.bufferedMessages.length,
881
+ });
882
+ // Snapshot received - apply it and process buffered messages
883
+ const buffered = _initState.bufferedMessages;
884
+ handleSnapshot(message.state, message.version, true);
885
+ processBufferedMessages(buffered, message.version);
886
+ completeInitialization();
887
+ } else {
888
+ debugLog("handleServerMessage: buffering message during initialization", {
889
+ messageType: message.type,
890
+ bufferedCount: _initState.bufferedMessages.length + 1,
891
+ });
892
+ // Buffer other messages during initialization
893
+ _initState.bufferedMessages.push(message);
894
+ }
895
+ return;
896
+ }
897
+
898
+ // Normal message handling when ready (or uninitialized with initial state)
899
+ switch (message.type) {
900
+ case "transaction":
901
+ handleServerTransaction(message.transaction, message.version);
902
+ break;
903
+ case "snapshot":
904
+ handleSnapshot(message.state, message.version, false);
905
+ break;
906
+ case "error":
907
+ handleRejection(message.transactionId, message.reason);
908
+ break;
909
+ }
910
+ };
911
+
912
+ // ==========================================================================
913
+ // Public API
914
+ // ==========================================================================
915
+
916
+ const clientDocument = {
917
+ schema,
918
+
919
+ get root() {
920
+ return _optimisticDoc.root;
921
+ },
922
+
923
+ get: () => _optimisticDoc.get(),
924
+
925
+ getServerState: () => _serverState,
926
+
927
+ getServerVersion: () => _serverVersion,
928
+
929
+ getPendingCount: () => _pending.length,
930
+
931
+ hasPendingChanges: () => _pending.length > 0,
932
+
933
+ transaction: <R,>(fn: (root: Primitive.InferProxy<TSchema>) => R): R => {
934
+ debugLog("transaction: starting", {
935
+ isConnected: transport.isConnected(),
936
+ isReady: _initState.type === "ready",
937
+ pendingCount: _pending.length,
938
+ });
939
+
940
+ if (!transport.isConnected()) {
941
+ throw new NotConnectedError();
942
+ }
943
+
944
+ if (_initState.type !== "ready") {
945
+ throw new InvalidStateError("Client is not ready. Wait for initialization to complete.");
946
+ }
947
+
948
+ // Run the transaction on the optimistic document
949
+ const result = _optimisticDoc.transaction(fn);
950
+
951
+ // Flush and get the transaction
952
+ const tx = _optimisticDoc.flush();
953
+
954
+ // If there are operations, submit to server
955
+ if (!Transaction.isEmpty(tx)) {
956
+ debugLog("transaction: flushed, submitting", {
957
+ txId: tx.id,
958
+ opsCount: tx.ops.length,
959
+ });
960
+ submitTransaction(tx);
961
+ } else {
962
+ debugLog("transaction: flushed, empty transaction (no ops)");
963
+ }
964
+
965
+ // Notify state change
966
+ notifyStateChange(_optimisticDoc.get());
967
+
968
+ return result;
969
+ },
970
+
971
+ connect: async (): Promise<void> => {
972
+ debugLog("connect: starting");
973
+ // Subscribe to server messages
974
+ _unsubscribe = transport.subscribe(handleServerMessage);
975
+
976
+ // Connect transport
977
+ await transport.connect();
978
+ debugLog("connect: transport connected");
979
+
980
+ notifyConnectionChange(true);
981
+
982
+ // Set initial presence if provided
983
+ if (presenceSchema && initialPresence !== undefined) {
984
+ debugLog("connect: setting initial presence", { initialPresence });
985
+ const validated = Presence.validate(presenceSchema, initialPresence);
986
+ _presenceSelfData = validated;
987
+ transport.sendPresenceSet(validated);
988
+ notifyPresenceChange();
989
+ }
990
+
991
+ // If we already have initial state, we're ready immediately
992
+ if (_initState.type === "ready") {
993
+ debugLog("connect: already ready (has initial state)");
994
+ notifyReady();
995
+ return;
996
+ }
997
+
998
+ // Enter initializing state - buffer messages until snapshot arrives
999
+ _initState = { type: "initializing", bufferedMessages: [] };
1000
+ debugLog("connect: entering initializing state", {
1001
+ initTimeout,
1002
+ });
1003
+
1004
+ // Set up initialization timeout
1005
+ _initTimeoutHandle = setTimeout(handleInitTimeout, initTimeout);
1006
+
1007
+ // Create a promise that resolves when we're ready
1008
+ const readyPromise = new Promise<void>((resolve, reject) => {
1009
+ _initResolver = resolve;
1010
+ _initRejecter = reject;
1011
+ });
1012
+
1013
+ // Request initial snapshot
1014
+ debugLog("connect: requesting initial snapshot");
1015
+
1016
+ transport.requestSnapshot();
1017
+
1018
+
1019
+ // Wait for initialization to complete
1020
+ await readyPromise;
1021
+ debugLog("connect: completed");
1022
+ },
1023
+
1024
+ disconnect: (): void => {
1025
+ debugLog("disconnect: starting", {
1026
+ pendingCount: _pending.length,
1027
+ initState: _initState.type,
1028
+ });
1029
+
1030
+ // Clear all timeouts
1031
+ for (const handle of _timeoutHandles.values()) {
1032
+ clearTimeout(handle);
1033
+ }
1034
+ _timeoutHandles.clear();
1035
+
1036
+ // Clear init timeout
1037
+ if (_initTimeoutHandle !== null) {
1038
+ clearTimeout(_initTimeoutHandle);
1039
+ _initTimeoutHandle = null;
1040
+ }
1041
+
1042
+ // Reject any pending init promise
1043
+ if (_initRejecter) {
1044
+ _initRejecter(new Error("Disconnected during initialization"));
1045
+ _initResolver = null;
1046
+ _initRejecter = null;
1047
+ }
1048
+
1049
+ // Reset init state
1050
+ if (_initState.type === "initializing") {
1051
+ _initState = { type: "uninitialized" };
1052
+ }
1053
+
1054
+ // Clear presence state
1055
+ clearPresenceState();
1056
+
1057
+ // Unsubscribe
1058
+ if (_unsubscribe) {
1059
+ _unsubscribe();
1060
+ _unsubscribe = null;
1061
+ }
1062
+
1063
+ // Disconnect transport
1064
+ transport.disconnect();
1065
+
1066
+ notifyConnectionChange(false);
1067
+ debugLog("disconnect: completed");
1068
+ },
1069
+
1070
+ isConnected: () => transport.isConnected(),
1071
+
1072
+ isReady: () => _initState.type === "ready",
1073
+
1074
+ resync: (): void => {
1075
+ debugLog("resync: requesting snapshot", {
1076
+ currentVersion: _serverVersion,
1077
+ pendingCount: _pending.length,
1078
+ });
1079
+ if (!transport.isConnected()) {
1080
+ throw new NotConnectedError();
1081
+ }
1082
+ transport.requestSnapshot();
1083
+ },
1084
+
1085
+ subscribe: (listener: ClientDocumentListener<TSchema>): (() => void) => {
1086
+ _subscribers.add(listener);
1087
+ return () => {
1088
+ _subscribers.delete(listener);
1089
+ };
1090
+ },
1091
+
1092
+ // =========================================================================
1093
+ // Presence API
1094
+ // =========================================================================
1095
+
1096
+ presence: (presenceSchema
1097
+ ? {
1098
+ selfId: () => _presenceSelfId,
1099
+
1100
+ self: () => _presenceSelfData as Presence.Infer<NonNullable<TPresence>> | undefined,
1101
+
1102
+ others: () => _presenceOthers as ReadonlyMap<string, Presence.PresenceEntry<Presence.Infer<NonNullable<TPresence>>>>,
1103
+
1104
+ all: () => {
1105
+ const all = new Map<string, Presence.PresenceEntry<unknown>>();
1106
+ // Add others
1107
+ for (const [id, entry] of _presenceOthers) {
1108
+ all.set(id, entry);
1109
+ }
1110
+ // Add self if we have data
1111
+ if (_presenceSelfId !== undefined && _presenceSelfData !== undefined) {
1112
+ all.set(_presenceSelfId, { data: _presenceSelfData });
1113
+ }
1114
+ return all as ReadonlyMap<string, Presence.PresenceEntry<Presence.Infer<NonNullable<TPresence>>>>;
1115
+ },
1116
+
1117
+ set: (data: Presence.Infer<NonNullable<TPresence>>) => {
1118
+ if (!presenceSchema) return;
1119
+
1120
+ // Validate against schema (throws if invalid)
1121
+ const validated = Presence.validate(presenceSchema, data);
1122
+
1123
+ debugLog("presence.set", { data: validated });
1124
+
1125
+ // Update local state
1126
+ _presenceSelfData = validated;
1127
+
1128
+ // Send to server
1129
+ transport.sendPresenceSet(validated);
1130
+
1131
+ // Notify listeners
1132
+ notifyPresenceChange();
1133
+ },
1134
+
1135
+ clear: () => {
1136
+ if (!presenceSchema) return;
1137
+
1138
+ debugLog("presence.clear");
1139
+
1140
+ // Clear local state
1141
+ _presenceSelfData = undefined;
1142
+
1143
+ // Send to server
1144
+ transport.sendPresenceClear();
1145
+
1146
+ // Notify listeners
1147
+ notifyPresenceChange();
1148
+ },
1149
+
1150
+ subscribe: (listener: PresenceListener<Presence.Infer<NonNullable<TPresence>>>) => {
1151
+ _presenceSubscribers.add(listener as PresenceListener<unknown>);
1152
+ return () => {
1153
+ _presenceSubscribers.delete(listener as PresenceListener<unknown>);
1154
+ };
1155
+ },
1156
+ }
1157
+ : undefined) as TPresence extends Presence.AnyPresence
1158
+ ? ClientPresence<Presence.Infer<TPresence>>
1159
+ : undefined,
1160
+ } as ClientDocument<TSchema, TPresence>;
1161
+
1162
+ return clientDocument;
1163
+ };