@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,309 @@
1
+ import type * as Operation from "../Operation";
2
+ import type * as Transaction from "../Transaction";
3
+ import type * as Primitive from "../Primitive";
4
+ import * as OperationPath from "../OperationPath";
5
+ import * as Transform from "../Transform";
6
+
7
+ // =============================================================================
8
+ // Re-export Transform types from mimic for backwards compatibility
9
+ // =============================================================================
10
+
11
+ export type TransformResult = Transform.TransformResult;
12
+
13
+ // =============================================================================
14
+ // Operation Transformation
15
+ // =============================================================================
16
+
17
+ /**
18
+ * Transforms a client operation against a server operation using a primitive.
19
+ *
20
+ * This delegates to the primitive's transformOperation method, which handles
21
+ * type-specific conflict resolution.
22
+ *
23
+ * @param clientOp - The client's operation to transform
24
+ * @param serverOp - The server's operation that has already been applied
25
+ * @param primitive - The root primitive to use for transformation
26
+ * @returns TransformResult indicating how the client operation should be handled
27
+ */
28
+ export const transformOperationWithPrimitive = (
29
+ clientOp: Operation.Operation<any, any, any>,
30
+ serverOp: Operation.Operation<any, any, any>,
31
+ primitive: Primitive.AnyPrimitive
32
+ ): TransformResult => {
33
+ return primitive._internal.transformOperation(clientOp, serverOp);
34
+ };
35
+
36
+ /**
37
+ * Transforms a client operation against a server operation.
38
+ *
39
+ * This is a standalone implementation for cases where the primitive is not available.
40
+ * For schema-aware transformation, use transformOperationWithPrimitive instead.
41
+ *
42
+ * The key principle: client ops "shadow" server ops for the same path,
43
+ * meaning if both touch the same field, the client's intention wins
44
+ * (since it was made with knowledge of the server state at that time).
45
+ */
46
+ export const transformOperation = (
47
+ clientOp: Operation.Operation<any, any, any>,
48
+ serverOp: Operation.Operation<any, any, any>
49
+ ): TransformResult => {
50
+ const clientPath = clientOp.path;
51
+ const serverPath = serverOp.path;
52
+
53
+ // If paths don't overlap at all, no transformation needed
54
+ if (!OperationPath.pathsOverlap(clientPath, serverPath)) {
55
+ return { type: "transformed", operation: clientOp };
56
+ }
57
+
58
+ // Handle array operations specially
59
+ if (serverOp.kind === "array.remove") {
60
+ // If server removed an array element that client is operating on
61
+ const removedId = (serverOp.payload as { id: string }).id;
62
+ const clientTokens = clientPath.toTokens().filter((t: string) => t !== "");
63
+ const serverTokens = serverPath.toTokens().filter((t: string) => t !== "");
64
+
65
+ // Check if client is operating on the removed element or its children
66
+ if (clientTokens.length > serverTokens.length) {
67
+ const elementId = clientTokens[serverTokens.length];
68
+ if (elementId === removedId) {
69
+ // Client operation targets a removed element - becomes noop
70
+ return { type: "noop" };
71
+ }
72
+ }
73
+ }
74
+
75
+ if (serverOp.kind === "array.insert" && clientOp.kind === "array.insert") {
76
+ // Both inserting into same array - positions are independent (fractional indexing)
77
+ // No transformation needed as fractional indices handle ordering
78
+ return { type: "transformed", operation: clientOp };
79
+ }
80
+
81
+ if (serverOp.kind === "array.move" && clientOp.kind === "array.move") {
82
+ // Both moving elements - if same element, client wins (last-write-wins for position)
83
+ const serverMoveId = (serverOp.payload as { id: string }).id;
84
+ const clientMoveId = (clientOp.payload as { id: string }).id;
85
+
86
+ if (serverMoveId === clientMoveId) {
87
+ // Client's move supersedes server's move
88
+ return { type: "transformed", operation: clientOp };
89
+ }
90
+ // Different elements - no conflict
91
+ return { type: "transformed", operation: clientOp };
92
+ }
93
+
94
+ // For set operations on the same exact path: client wins (last-write-wins)
95
+ if (OperationPath.pathsEqual(clientPath, serverPath)) {
96
+ // Both operations target the same path
97
+ // Client operation was made with intent to set this value,
98
+ // so it should override the server's change
99
+ return { type: "transformed", operation: clientOp };
100
+ }
101
+
102
+ // If server set a parent path, client's child operation might be invalid
103
+ if (OperationPath.isPrefix(serverPath, clientPath)) {
104
+ const serverKind = serverOp.kind;
105
+ if (
106
+ serverKind === "struct.set" ||
107
+ serverKind === "array.set" ||
108
+ serverKind === "union.set"
109
+ ) {
110
+ // Server replaced the entire parent - client's child op may be invalid
111
+ // However, for optimistic updates, we let the client op proceed
112
+ // and the server will validate/reject if needed
113
+ return { type: "transformed", operation: clientOp };
114
+ }
115
+ }
116
+
117
+ // Default: no transformation needed, client op proceeds as-is
118
+ return { type: "transformed", operation: clientOp };
119
+ };
120
+
121
+ /**
122
+ * Transforms all operations in a client transaction against a server transaction.
123
+ * Uses the primitive's transformOperation for schema-aware transformation.
124
+ */
125
+ export const transformTransactionWithPrimitive = (
126
+ clientTx: Transaction.Transaction,
127
+ serverTx: Transaction.Transaction,
128
+ primitive: Primitive.AnyPrimitive
129
+ ): Transaction.Transaction => {
130
+ const transformedOps: Operation.Operation<any, any, any>[] = [];
131
+
132
+ for (const clientOp of clientTx.ops) {
133
+ let currentOp: Operation.Operation<any, any, any> | null = clientOp;
134
+
135
+ // Transform against each server operation
136
+ for (const serverOp of serverTx.ops) {
137
+ if (currentOp === null) break;
138
+
139
+ const result = transformOperationWithPrimitive(currentOp, serverOp, primitive);
140
+
141
+ switch (result.type) {
142
+ case "transformed":
143
+ currentOp = result.operation;
144
+ break;
145
+ case "noop":
146
+ currentOp = null;
147
+ break;
148
+ case "conflict":
149
+ // For now, treat conflicts as the client op proceeding
150
+ // Server will ultimately validate
151
+ break;
152
+ }
153
+ }
154
+
155
+ if (currentOp !== null) {
156
+ transformedOps.push(currentOp);
157
+ }
158
+ }
159
+
160
+ // Return a new transaction with the same ID but transformed ops
161
+ return {
162
+ id: clientTx.id,
163
+ ops: transformedOps,
164
+ timestamp: clientTx.timestamp,
165
+ };
166
+ };
167
+
168
+ /**
169
+ * Transforms all operations in a client transaction against a server transaction.
170
+ * This is a standalone version that doesn't require a primitive.
171
+ */
172
+ export const transformTransaction = (
173
+ clientTx: Transaction.Transaction,
174
+ serverTx: Transaction.Transaction
175
+ ): Transaction.Transaction => {
176
+ const transformedOps: Operation.Operation<any, any, any>[] = [];
177
+
178
+ for (const clientOp of clientTx.ops) {
179
+ let currentOp: Operation.Operation<any, any, any> | null = clientOp;
180
+
181
+ // Transform against each server operation
182
+ for (const serverOp of serverTx.ops) {
183
+ if (currentOp === null) break;
184
+
185
+ const result = transformOperation(currentOp, serverOp);
186
+
187
+ switch (result.type) {
188
+ case "transformed":
189
+ currentOp = result.operation;
190
+ break;
191
+ case "noop":
192
+ currentOp = null;
193
+ break;
194
+ case "conflict":
195
+ // For now, treat conflicts as the client op proceeding
196
+ // Server will ultimately validate
197
+ break;
198
+ }
199
+ }
200
+
201
+ if (currentOp !== null) {
202
+ transformedOps.push(currentOp);
203
+ }
204
+ }
205
+
206
+ // Return a new transaction with the same ID but transformed ops
207
+ return {
208
+ id: clientTx.id,
209
+ ops: transformedOps,
210
+ timestamp: clientTx.timestamp,
211
+ };
212
+ };
213
+
214
+ /**
215
+ * Rebases a list of pending transactions against a server transaction using a primitive.
216
+ *
217
+ * This is called when a server transaction arrives that is NOT one of our pending
218
+ * transactions. We need to transform all pending transactions to work correctly
219
+ * on top of the new server state.
220
+ */
221
+ export const rebasePendingTransactionsWithPrimitive = (
222
+ pendingTxs: ReadonlyArray<Transaction.Transaction>,
223
+ serverTx: Transaction.Transaction,
224
+ primitive: Primitive.AnyPrimitive
225
+ ): Transaction.Transaction[] => {
226
+ return pendingTxs.map((pendingTx) =>
227
+ transformTransactionWithPrimitive(pendingTx, serverTx, primitive)
228
+ );
229
+ };
230
+
231
+ /**
232
+ * Rebases a list of pending transactions against a server transaction.
233
+ *
234
+ * This is called when a server transaction arrives that is NOT one of our pending
235
+ * transactions. We need to transform all pending transactions to work correctly
236
+ * on top of the new server state.
237
+ */
238
+ export const rebasePendingTransactions = (
239
+ pendingTxs: ReadonlyArray<Transaction.Transaction>,
240
+ serverTx: Transaction.Transaction
241
+ ): Transaction.Transaction[] => {
242
+ return pendingTxs.map((pendingTx) =>
243
+ transformTransaction(pendingTx, serverTx)
244
+ );
245
+ };
246
+
247
+ /**
248
+ * Rebases pending transactions after a rejection using a primitive.
249
+ *
250
+ * When a transaction is rejected, we need to re-transform remaining pending
251
+ * transactions as if the rejected transaction never happened. This is done by
252
+ * rebuilding from the original operations against the current server state.
253
+ *
254
+ * @param originalPendingTxs - The original pending transactions before any rebasing
255
+ * @param rejectedTxId - ID of the rejected transaction
256
+ * @param serverTxsSinceOriginal - Server transactions that have arrived since original
257
+ * @param primitive - The root primitive to use for transformation
258
+ */
259
+ export const rebaseAfterRejectionWithPrimitive = (
260
+ originalPendingTxs: ReadonlyArray<Transaction.Transaction>,
261
+ rejectedTxId: string,
262
+ serverTxsSinceOriginal: ReadonlyArray<Transaction.Transaction>,
263
+ primitive: Primitive.AnyPrimitive
264
+ ): Transaction.Transaction[] => {
265
+ // Filter out the rejected transaction
266
+ const remainingOriginals = originalPendingTxs.filter(
267
+ (tx) => tx.id !== rejectedTxId
268
+ );
269
+
270
+ // Re-transform each remaining transaction against all server transactions
271
+ let result = [...remainingOriginals];
272
+
273
+ for (const serverTx of serverTxsSinceOriginal) {
274
+ result = rebasePendingTransactionsWithPrimitive(result, serverTx, primitive);
275
+ }
276
+
277
+ return result;
278
+ };
279
+
280
+ /**
281
+ * Rebases pending transactions after a rejection.
282
+ *
283
+ * When a transaction is rejected, we need to re-transform remaining pending
284
+ * transactions as if the rejected transaction never happened. This is done by
285
+ * rebuilding from the original operations against the current server state.
286
+ *
287
+ * @param originalPendingTxs - The original pending transactions before any rebasing
288
+ * @param rejectedTxId - ID of the rejected transaction
289
+ * @param serverTxsSinceOriginal - Server transactions that have arrived since original
290
+ */
291
+ export const rebaseAfterRejection = (
292
+ originalPendingTxs: ReadonlyArray<Transaction.Transaction>,
293
+ rejectedTxId: string,
294
+ serverTxsSinceOriginal: ReadonlyArray<Transaction.Transaction>
295
+ ): Transaction.Transaction[] => {
296
+ // Filter out the rejected transaction
297
+ const remainingOriginals = originalPendingTxs.filter(
298
+ (tx) => tx.id !== rejectedTxId
299
+ );
300
+
301
+ // Re-transform each remaining transaction against all server transactions
302
+ let result = [...remainingOriginals];
303
+
304
+ for (const serverTx of serverTxsSinceOriginal) {
305
+ result = rebasePendingTransactions(result, serverTx);
306
+ }
307
+
308
+ return result;
309
+ };
@@ -0,0 +1,307 @@
1
+ import { StateDriftError } from "./errors";
2
+
3
+ // =============================================================================
4
+ // State Monitor Types
5
+ // =============================================================================
6
+
7
+ /**
8
+ * Events emitted by the state monitor.
9
+ */
10
+ export type StateMonitorEvent =
11
+ | { type: "drift_detected"; expectedVersion: number; receivedVersion: number }
12
+ | { type: "recovery_started" }
13
+ | { type: "recovery_completed"; version: number }
14
+ | { type: "recovery_failed"; error: Error }
15
+ | { type: "pending_timeout"; transactionId: string; elapsedMs: number }
16
+ | { type: "health_check"; pendingCount: number; oldestPendingMs: number | null };
17
+
18
+ /**
19
+ * Handler for state monitor events.
20
+ */
21
+ export type StateMonitorEventHandler = (event: StateMonitorEvent) => void;
22
+
23
+ /**
24
+ * Options for creating a StateMonitor.
25
+ */
26
+ export interface StateMonitorOptions {
27
+ /** Handler for monitor events */
28
+ readonly onEvent?: StateMonitorEventHandler;
29
+ /** Interval for health checks in ms (default: 5000) */
30
+ readonly healthCheckInterval?: number;
31
+ /** Threshold for considering a pending transaction "stale" in ms (default: 10000) */
32
+ readonly stalePendingThreshold?: number;
33
+ /** Maximum allowed version gap before triggering recovery (default: 10) */
34
+ readonly maxVersionGap?: number;
35
+ }
36
+
37
+ /**
38
+ * Pending transaction info for monitoring.
39
+ */
40
+ export interface PendingInfo {
41
+ readonly id: string;
42
+ readonly sentAt: number;
43
+ }
44
+
45
+ /**
46
+ * A StateMonitor watches for state drift and triggers recovery.
47
+ */
48
+ export interface StateMonitor {
49
+ /**
50
+ * Called when a server transaction is received.
51
+ * Returns true if the version is valid, false if drift is detected.
52
+ */
53
+ readonly onServerVersion: (version: number) => boolean;
54
+
55
+ /**
56
+ * Called when a pending transaction is added.
57
+ */
58
+ readonly trackPending: (info: PendingInfo) => void;
59
+
60
+ /**
61
+ * Called when a pending transaction is confirmed or rejected.
62
+ */
63
+ readonly untrackPending: (id: string) => void;
64
+
65
+ /**
66
+ * Returns pending transactions that have exceeded the stale threshold.
67
+ */
68
+ readonly getStalePending: () => PendingInfo[];
69
+
70
+ /**
71
+ * Returns current monitoring status.
72
+ */
73
+ readonly getStatus: () => StateMonitorStatus;
74
+
75
+ /**
76
+ * Starts the health check loop.
77
+ */
78
+ readonly start: () => void;
79
+
80
+ /**
81
+ * Stops the health check loop.
82
+ */
83
+ readonly stop: () => void;
84
+
85
+ /**
86
+ * Resets the monitor state (called after recovery).
87
+ */
88
+ readonly reset: (newVersion: number) => void;
89
+ }
90
+
91
+ /**
92
+ * Current monitoring status.
93
+ */
94
+ export interface StateMonitorStatus {
95
+ readonly expectedVersion: number;
96
+ readonly pendingCount: number;
97
+ readonly oldestPendingMs: number | null;
98
+ readonly isHealthy: boolean;
99
+ readonly isRecovering: boolean;
100
+ }
101
+
102
+ // =============================================================================
103
+ // State Monitor Implementation
104
+ // =============================================================================
105
+
106
+ /**
107
+ * Creates a new StateMonitor.
108
+ */
109
+ export const make = (options: StateMonitorOptions = {}): StateMonitor => {
110
+ const {
111
+ onEvent,
112
+ healthCheckInterval = 5000,
113
+ stalePendingThreshold = 10000,
114
+ maxVersionGap = 10,
115
+ } = options;
116
+
117
+ // Internal state
118
+ let _expectedVersion = 0;
119
+ let _pendingMap = new Map<string, PendingInfo>();
120
+ let _isRecovering = false;
121
+ let _healthCheckHandle: ReturnType<typeof setInterval> | null = null;
122
+
123
+ /**
124
+ * Emits an event if handler is provided.
125
+ */
126
+ const emit = (event: StateMonitorEvent): void => {
127
+ onEvent?.(event);
128
+ };
129
+
130
+ /**
131
+ * Checks if there's a version gap indicating drift.
132
+ */
133
+ const checkVersionGap = (receivedVersion: number): boolean => {
134
+ // Expected next version is current + 1
135
+ const expectedNext = _expectedVersion + 1;
136
+
137
+ if (receivedVersion < expectedNext) {
138
+ // Duplicate or out-of-order - might be OK
139
+ return true;
140
+ }
141
+
142
+ if (receivedVersion > expectedNext + maxVersionGap) {
143
+ // Large gap - drift detected
144
+ emit({
145
+ type: "drift_detected",
146
+ expectedVersion: expectedNext,
147
+ receivedVersion,
148
+ });
149
+ return false;
150
+ }
151
+
152
+ // Small gap - could be network reordering, allow it
153
+ return true;
154
+ };
155
+
156
+ /**
157
+ * Runs a health check.
158
+ */
159
+ const runHealthCheck = (): void => {
160
+ const now = Date.now();
161
+ let oldestPendingMs: number | null = null;
162
+
163
+ // Find stale pending transactions
164
+ for (const [id, info] of _pendingMap) {
165
+ const elapsed = now - info.sentAt;
166
+
167
+ if (oldestPendingMs === null || elapsed > oldestPendingMs) {
168
+ oldestPendingMs = elapsed;
169
+ }
170
+
171
+ if (elapsed > stalePendingThreshold) {
172
+ emit({
173
+ type: "pending_timeout",
174
+ transactionId: id,
175
+ elapsedMs: elapsed,
176
+ });
177
+ }
178
+ }
179
+
180
+ emit({
181
+ type: "health_check",
182
+ pendingCount: _pendingMap.size,
183
+ oldestPendingMs,
184
+ });
185
+ };
186
+
187
+ const monitor: StateMonitor = {
188
+ onServerVersion: (version: number): boolean => {
189
+ const isValid = checkVersionGap(version);
190
+
191
+ if (isValid) {
192
+ // Update expected version
193
+ _expectedVersion = Math.max(_expectedVersion, version);
194
+ }
195
+
196
+ return isValid;
197
+ },
198
+
199
+ trackPending: (info: PendingInfo): void => {
200
+ _pendingMap.set(info.id, info);
201
+ },
202
+
203
+ untrackPending: (id: string): void => {
204
+ _pendingMap.delete(id);
205
+ },
206
+
207
+ getStalePending: (): PendingInfo[] => {
208
+ const now = Date.now();
209
+ const stale: PendingInfo[] = [];
210
+
211
+ for (const info of _pendingMap.values()) {
212
+ if (now - info.sentAt > stalePendingThreshold) {
213
+ stale.push(info);
214
+ }
215
+ }
216
+
217
+ return stale;
218
+ },
219
+
220
+ getStatus: (): StateMonitorStatus => {
221
+ const now = Date.now();
222
+ let oldestPendingMs: number | null = null;
223
+
224
+ for (const info of _pendingMap.values()) {
225
+ const elapsed = now - info.sentAt;
226
+ if (oldestPendingMs === null || elapsed > oldestPendingMs) {
227
+ oldestPendingMs = elapsed;
228
+ }
229
+ }
230
+
231
+ // Consider unhealthy if recovering or has very stale pending
232
+ const isHealthy =
233
+ !_isRecovering &&
234
+ (oldestPendingMs === null || oldestPendingMs < stalePendingThreshold * 2);
235
+
236
+ return {
237
+ expectedVersion: _expectedVersion,
238
+ pendingCount: _pendingMap.size,
239
+ oldestPendingMs,
240
+ isHealthy,
241
+ isRecovering: _isRecovering,
242
+ };
243
+ },
244
+
245
+ start: (): void => {
246
+ if (_healthCheckHandle !== null) return;
247
+
248
+ _healthCheckHandle = setInterval(runHealthCheck, healthCheckInterval);
249
+ },
250
+
251
+ stop: (): void => {
252
+ if (_healthCheckHandle !== null) {
253
+ clearInterval(_healthCheckHandle);
254
+ _healthCheckHandle = null;
255
+ }
256
+ },
257
+
258
+ reset: (newVersion: number): void => {
259
+ _expectedVersion = newVersion;
260
+ _pendingMap.clear();
261
+ _isRecovering = false;
262
+
263
+ emit({
264
+ type: "recovery_completed",
265
+ version: newVersion,
266
+ });
267
+ },
268
+ };
269
+
270
+ return monitor;
271
+ };
272
+
273
+ // =============================================================================
274
+ // Recovery Strategy
275
+ // =============================================================================
276
+
277
+ /**
278
+ * Recovery actions that can be taken.
279
+ */
280
+ export type RecoveryAction =
281
+ | { type: "request_snapshot" }
282
+ | { type: "retry_pending"; transactionIds: string[] }
283
+ | { type: "drop_pending"; transactionIds: string[] };
284
+
285
+ /**
286
+ * Determines the appropriate recovery action based on current state.
287
+ */
288
+ export const determineRecoveryAction = (
289
+ status: StateMonitorStatus,
290
+ stalePending: PendingInfo[]
291
+ ): RecoveryAction => {
292
+ // If recovering or unhealthy with stale pending, request full snapshot
293
+ if (!status.isHealthy || stalePending.length > 3) {
294
+ return { type: "request_snapshot" };
295
+ }
296
+
297
+ // If just a few stale pending, drop them
298
+ if (stalePending.length > 0) {
299
+ return {
300
+ type: "drop_pending",
301
+ transactionIds: stalePending.map((p) => p.id),
302
+ };
303
+ }
304
+
305
+ // Default: request snapshot for safety
306
+ return { type: "request_snapshot" };
307
+ };