@voidhash/mimic 1.0.0-beta.15 → 1.0.0-beta.17
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.
- package/dist/Document.cjs +0 -3
- package/dist/Document.d.mts.map +1 -1
- package/dist/Document.mjs +0 -3
- package/dist/Document.mjs.map +1 -1
- package/dist/EffectSchema.cjs +3 -3
- package/dist/EffectSchema.d.cts +5 -5
- package/dist/EffectSchema.d.cts.map +1 -1
- package/dist/EffectSchema.d.mts +5 -5
- package/dist/EffectSchema.d.mts.map +1 -1
- package/dist/EffectSchema.mjs +3 -3
- package/dist/EffectSchema.mjs.map +1 -1
- package/dist/FractionalIndex.mjs.map +1 -1
- package/dist/Operation.d.cts +4 -4
- package/dist/Operation.d.cts.map +1 -1
- package/dist/Operation.d.mts +4 -4
- package/dist/Operation.d.mts.map +1 -1
- package/dist/Operation.mjs.map +1 -1
- package/dist/OperationDefinition.d.cts +2 -2
- package/dist/OperationDefinition.d.cts.map +1 -1
- package/dist/OperationDefinition.d.mts +2 -2
- package/dist/OperationDefinition.d.mts.map +1 -1
- package/dist/OperationDefinition.mjs.map +1 -1
- package/dist/Presence.mjs.map +1 -1
- package/dist/Primitive.d.cts +2 -2
- package/dist/Primitive.d.mts +2 -2
- package/dist/SchemaJSON.cjs +305 -0
- package/dist/SchemaJSON.d.cts +11 -0
- package/dist/SchemaJSON.d.cts.map +1 -0
- package/dist/SchemaJSON.d.mts +11 -0
- package/dist/SchemaJSON.d.mts.map +1 -0
- package/dist/SchemaJSON.mjs +301 -0
- package/dist/SchemaJSON.mjs.map +1 -0
- package/dist/index.cjs +7 -0
- package/dist/index.d.cts +2 -1
- package/dist/index.d.mts +2 -1
- package/dist/index.mjs +2 -1
- package/dist/primitives/Array.cjs +12 -2
- package/dist/primitives/Array.d.cts.map +1 -1
- package/dist/primitives/Array.d.mts.map +1 -1
- package/dist/primitives/Array.mjs +12 -2
- package/dist/primitives/Array.mjs.map +1 -1
- package/dist/primitives/Boolean.mjs.map +1 -1
- package/dist/primitives/Either.mjs.map +1 -1
- package/dist/primitives/Literal.mjs.map +1 -1
- package/dist/primitives/Number.cjs +27 -5
- package/dist/primitives/Number.d.cts.map +1 -1
- package/dist/primitives/Number.d.mts.map +1 -1
- package/dist/primitives/Number.mjs +27 -5
- package/dist/primitives/Number.mjs.map +1 -1
- package/dist/primitives/String.cjs +44 -13
- package/dist/primitives/String.d.cts.map +1 -1
- package/dist/primitives/String.d.mts.map +1 -1
- package/dist/primitives/String.mjs +44 -13
- package/dist/primitives/String.mjs.map +1 -1
- package/dist/primitives/Struct.cjs +48 -9
- package/dist/primitives/Struct.d.cts +22 -3
- package/dist/primitives/Struct.d.cts.map +1 -1
- package/dist/primitives/Struct.d.mts +22 -3
- package/dist/primitives/Struct.d.mts.map +1 -1
- package/dist/primitives/Struct.mjs +48 -9
- package/dist/primitives/Struct.mjs.map +1 -1
- package/dist/primitives/Union.mjs.map +1 -1
- package/dist/primitives/shared.cjs +2 -5
- package/dist/primitives/shared.d.cts +2 -4
- package/dist/primitives/shared.d.cts.map +1 -1
- package/dist/primitives/shared.d.mts +2 -4
- package/dist/primitives/shared.d.mts.map +1 -1
- package/dist/primitives/shared.mjs +2 -5
- package/dist/primitives/shared.mjs.map +1 -1
- package/package.json +15 -8
- package/src/Document.ts +13 -4
- package/src/EffectSchema.ts +3 -3
- package/src/FractionalIndex.ts +18 -18
- package/src/Operation.ts +5 -5
- package/src/OperationDefinition.ts +2 -2
- package/src/Presence.ts +3 -3
- package/src/SchemaJSON.ts +396 -0
- package/src/index.ts +1 -0
- package/src/primitives/Array.ts +18 -8
- package/src/primitives/Boolean.ts +2 -2
- package/src/primitives/Either.ts +2 -2
- package/src/primitives/Literal.ts +2 -2
- package/src/primitives/Number.ts +44 -22
- package/src/primitives/String.ts +61 -34
- package/src/primitives/Struct.ts +100 -12
- package/src/primitives/Union.ts +1 -1
- package/src/primitives/shared.ts +12 -2
- package/.turbo/turbo-build.log +0 -270
- package/tests/Document.test.ts +0 -557
- package/tests/EffectSchema.test.ts +0 -546
- package/tests/FractionalIndex.test.ts +0 -377
- package/tests/OperationPath.test.ts +0 -151
- package/tests/Presence.test.ts +0 -321
- package/tests/Primitive.test.ts +0 -381
- package/tests/client/ClientDocument.test.ts +0 -1981
- package/tests/client/WebSocketTransport.test.ts +0 -1217
- package/tests/primitives/Array.test.ts +0 -526
- package/tests/primitives/Boolean.test.ts +0 -126
- package/tests/primitives/Either.test.ts +0 -707
- package/tests/primitives/Lazy.test.ts +0 -143
- package/tests/primitives/Literal.test.ts +0 -122
- package/tests/primitives/Number.test.ts +0 -133
- package/tests/primitives/String.test.ts +0 -128
- package/tests/primitives/Struct.test.ts +0 -1044
- package/tests/primitives/Tree.test.ts +0 -1139
- package/tests/primitives/TreeNode.test.ts +0 -50
- package/tests/primitives/Union.test.ts +0 -554
- package/tests/server/ServerDocument.test.ts +0 -903
- package/tsconfig.build.json +0 -24
- package/tsconfig.json +0 -8
- package/tsdown.config.ts +0 -18
- package/vitest.mts +0 -11
|
@@ -1,1981 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
-
import * as Schema from "effect/Schema";
|
|
3
|
-
import * as Primitive from "../../src/Primitive";
|
|
4
|
-
import * as Transaction from "../../src/Transaction";
|
|
5
|
-
import * as OperationPath from "../../src/OperationPath";
|
|
6
|
-
import * as ClientDocument from "../../src/client/ClientDocument";
|
|
7
|
-
import type * as Transport from "../../src/client/Transport";
|
|
8
|
-
import * as Rebase from "../../src/client/Rebase";
|
|
9
|
-
import * as StateMonitor from "../../src/client/StateMonitor";
|
|
10
|
-
import * as Presence from "../../src/Presence";
|
|
11
|
-
import * as Document from "../../src/Document";
|
|
12
|
-
|
|
13
|
-
// =============================================================================
|
|
14
|
-
// Mock Transport
|
|
15
|
-
// =============================================================================
|
|
16
|
-
|
|
17
|
-
interface MockTransport extends Transport.Transport {
|
|
18
|
-
sentTransactions: Transaction.Transaction[];
|
|
19
|
-
handlers: Set<(message: Transport.ServerMessage) => void>;
|
|
20
|
-
simulateServerMessage: (message: Transport.ServerMessage) => void;
|
|
21
|
-
snapshotRequested: boolean;
|
|
22
|
-
autoSendSnapshot?: { state: unknown; version: number };
|
|
23
|
-
// Presence tracking
|
|
24
|
-
presenceSetCalls: unknown[];
|
|
25
|
-
presenceClearCalls: number;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const createMockTransport = (options?: {
|
|
29
|
-
autoSendSnapshot?: { state: unknown; version: number };
|
|
30
|
-
}): MockTransport => {
|
|
31
|
-
const handlers = new Set<(message: Transport.ServerMessage) => void>();
|
|
32
|
-
const sentTransactions: Transaction.Transaction[] = [];
|
|
33
|
-
let _connected = false;
|
|
34
|
-
let snapshotRequested = false;
|
|
35
|
-
const presenceSetCalls: unknown[] = [];
|
|
36
|
-
let presenceClearCalls = 0;
|
|
37
|
-
|
|
38
|
-
const transport: MockTransport = {
|
|
39
|
-
sentTransactions,
|
|
40
|
-
handlers,
|
|
41
|
-
snapshotRequested,
|
|
42
|
-
autoSendSnapshot: options?.autoSendSnapshot,
|
|
43
|
-
get presenceSetCalls() { return presenceSetCalls; },
|
|
44
|
-
get presenceClearCalls() { return presenceClearCalls; },
|
|
45
|
-
|
|
46
|
-
send: (transaction) => {
|
|
47
|
-
sentTransactions.push(transaction);
|
|
48
|
-
},
|
|
49
|
-
|
|
50
|
-
requestSnapshot: () => {
|
|
51
|
-
snapshotRequested = true;
|
|
52
|
-
// If autoSendSnapshot is configured, send it immediately
|
|
53
|
-
if (transport.autoSendSnapshot) {
|
|
54
|
-
// Use setTimeout to simulate async behavior
|
|
55
|
-
setTimeout(() => {
|
|
56
|
-
transport.simulateServerMessage({
|
|
57
|
-
type: "snapshot",
|
|
58
|
-
state: transport.autoSendSnapshot!.state,
|
|
59
|
-
version: transport.autoSendSnapshot!.version,
|
|
60
|
-
});
|
|
61
|
-
}, 0);
|
|
62
|
-
}
|
|
63
|
-
},
|
|
64
|
-
|
|
65
|
-
subscribe: (handler) => {
|
|
66
|
-
handlers.add(handler);
|
|
67
|
-
return () => handlers.delete(handler);
|
|
68
|
-
},
|
|
69
|
-
|
|
70
|
-
connect: async () => {
|
|
71
|
-
_connected = true;
|
|
72
|
-
},
|
|
73
|
-
|
|
74
|
-
disconnect: () => {
|
|
75
|
-
_connected = false;
|
|
76
|
-
},
|
|
77
|
-
|
|
78
|
-
isConnected: () => _connected,
|
|
79
|
-
|
|
80
|
-
sendPresenceSet: (data: unknown) => {
|
|
81
|
-
presenceSetCalls.push(data);
|
|
82
|
-
},
|
|
83
|
-
|
|
84
|
-
sendPresenceClear: () => {
|
|
85
|
-
presenceClearCalls++;
|
|
86
|
-
},
|
|
87
|
-
|
|
88
|
-
simulateServerMessage: (message) => {
|
|
89
|
-
for (const handler of handlers) {
|
|
90
|
-
handler(message);
|
|
91
|
-
}
|
|
92
|
-
},
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
return transport;
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
// =============================================================================
|
|
99
|
-
// Test Schema
|
|
100
|
-
// =============================================================================
|
|
101
|
-
|
|
102
|
-
const TestSchema = Primitive.Struct({
|
|
103
|
-
title: Primitive.String().default(""),
|
|
104
|
-
count: Primitive.Number().default(0),
|
|
105
|
-
items: Primitive.Array(
|
|
106
|
-
Primitive.Struct({
|
|
107
|
-
name: Primitive.String(),
|
|
108
|
-
done: Primitive.Boolean().default(false),
|
|
109
|
-
})
|
|
110
|
-
),
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
type TestState = Primitive.InferState<typeof TestSchema>;
|
|
114
|
-
|
|
115
|
-
// =============================================================================
|
|
116
|
-
// ClientDocument Tests
|
|
117
|
-
// =============================================================================
|
|
118
|
-
|
|
119
|
-
describe("ClientDocument", () => {
|
|
120
|
-
let transport: ReturnType<typeof createMockTransport>;
|
|
121
|
-
|
|
122
|
-
beforeEach(() => {
|
|
123
|
-
transport = createMockTransport();
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
describe("make", () => {
|
|
127
|
-
it("should create a client document with initial state", async () => {
|
|
128
|
-
const initialState: TestState = {
|
|
129
|
-
title: "Test",
|
|
130
|
-
count: 5,
|
|
131
|
-
items: [],
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
const client = ClientDocument.make({
|
|
135
|
-
schema: TestSchema,
|
|
136
|
-
transport,
|
|
137
|
-
initialState,
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
await client.connect();
|
|
141
|
-
|
|
142
|
-
expect(client.get()).toEqual(initialState);
|
|
143
|
-
expect(client.getServerState()).toEqual(initialState);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
it("should create a client document without initial state and wait for snapshot", async () => {
|
|
147
|
-
// Create transport that auto-sends snapshot
|
|
148
|
-
const transportWithSnapshot = createMockTransport({
|
|
149
|
-
autoSendSnapshot: { state: { title: "From Server", count: 42, items: [] }, version: 1 },
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
const client = ClientDocument.make({
|
|
153
|
-
schema: TestSchema,
|
|
154
|
-
transport: transportWithSnapshot,
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
expect(client.isReady()).toBe(false);
|
|
158
|
-
|
|
159
|
-
await client.connect();
|
|
160
|
-
|
|
161
|
-
// Should have state from server snapshot
|
|
162
|
-
expect(client.isReady()).toBe(true);
|
|
163
|
-
expect(client.get()?.title).toBe("From Server");
|
|
164
|
-
expect(client.get()?.count).toBe(42);
|
|
165
|
-
});
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
describe("transaction", () => {
|
|
169
|
-
it("should apply changes optimistically", async () => {
|
|
170
|
-
const client = ClientDocument.make({
|
|
171
|
-
schema: TestSchema,
|
|
172
|
-
transport,
|
|
173
|
-
initialState: { title: "", count: 0, items: [] },
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
await client.connect();
|
|
177
|
-
|
|
178
|
-
client.transaction((root) => {
|
|
179
|
-
root.title.set("New Title");
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
expect(client.get()?.title).toBe("New Title");
|
|
183
|
-
expect(client.hasPendingChanges()).toBe(true);
|
|
184
|
-
expect(client.getPendingCount()).toBe(1);
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
it("should send transaction to server", async () => {
|
|
188
|
-
const client = ClientDocument.make({
|
|
189
|
-
schema: TestSchema,
|
|
190
|
-
transport,
|
|
191
|
-
initialState: { title: "", count: 0, items: [] },
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
await client.connect();
|
|
195
|
-
|
|
196
|
-
client.transaction((root) => {
|
|
197
|
-
root.count.set(42);
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
expect(transport.sentTransactions.length).toBe(1);
|
|
201
|
-
expect(transport.sentTransactions[0]!.ops.length).toBe(1);
|
|
202
|
-
expect(transport.sentTransactions[0]!.ops[0]!.kind).toBe("number.set");
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
it("should queue transactions when not connected", () => {
|
|
206
|
-
const client = ClientDocument.make({
|
|
207
|
-
schema: TestSchema,
|
|
208
|
-
transport,
|
|
209
|
-
initialState: { title: "", count: 0, items: [] },
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
// Transactions should work offline - they get queued in the transport
|
|
213
|
-
client.transaction((root) => {
|
|
214
|
-
root.title.set("Test");
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
// State should be optimistically updated
|
|
218
|
-
expect(client.get()?.title).toBe("Test");
|
|
219
|
-
// Transaction is pending
|
|
220
|
-
expect(client.hasPendingChanges()).toBe(true);
|
|
221
|
-
// Transaction was sent to transport (it will queue it)
|
|
222
|
-
expect(transport.sentTransactions.length).toBe(1);
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
it("should queue multiple transactions when not connected", () => {
|
|
226
|
-
const client = ClientDocument.make({
|
|
227
|
-
schema: TestSchema,
|
|
228
|
-
transport,
|
|
229
|
-
initialState: { title: "", count: 0, items: [] },
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
// Create multiple transactions while offline
|
|
233
|
-
client.transaction((root) => {
|
|
234
|
-
root.title.set("First");
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
client.transaction((root) => {
|
|
238
|
-
root.count.set(10);
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
client.transaction((root) => {
|
|
242
|
-
root.title.set("Second");
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
// All state changes should be applied optimistically
|
|
246
|
-
expect(client.get()?.title).toBe("Second");
|
|
247
|
-
expect(client.get()?.count).toBe(10);
|
|
248
|
-
// All transactions are pending
|
|
249
|
-
expect(client.getPendingCount()).toBe(3);
|
|
250
|
-
// All transactions were sent to transport
|
|
251
|
-
expect(transport.sentTransactions.length).toBe(3);
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
it("should throw when not ready (no initial state)", () => {
|
|
255
|
-
const client = ClientDocument.make({
|
|
256
|
-
schema: TestSchema,
|
|
257
|
-
transport,
|
|
258
|
-
// No initial state - client is not ready until snapshot received
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
expect(() => {
|
|
262
|
-
client.transaction((root) => {
|
|
263
|
-
root.title.set("Test");
|
|
264
|
-
});
|
|
265
|
-
}).toThrow("Client is not ready");
|
|
266
|
-
});
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
describe("offline transaction handling", () => {
|
|
270
|
-
it("should confirm queued transactions after reconnection", async () => {
|
|
271
|
-
const client = ClientDocument.make({
|
|
272
|
-
schema: TestSchema,
|
|
273
|
-
transport,
|
|
274
|
-
initialState: { title: "", count: 0, items: [] },
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
// Create transaction while offline
|
|
278
|
-
client.transaction((root) => {
|
|
279
|
-
root.title.set("Offline Change");
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
expect(client.hasPendingChanges()).toBe(true);
|
|
283
|
-
const pendingTx = transport.sentTransactions[0]!;
|
|
284
|
-
|
|
285
|
-
// Connect and simulate server confirming the transaction
|
|
286
|
-
await client.connect();
|
|
287
|
-
|
|
288
|
-
// Server broadcasts our transaction (confirming it)
|
|
289
|
-
transport.simulateServerMessage({
|
|
290
|
-
type: "transaction",
|
|
291
|
-
transaction: pendingTx,
|
|
292
|
-
version: 1,
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
// Transaction should be confirmed
|
|
296
|
-
expect(client.hasPendingChanges()).toBe(false);
|
|
297
|
-
expect(client.get()?.title).toBe("Offline Change");
|
|
298
|
-
expect(client.getServerState()?.title).toBe("Offline Change");
|
|
299
|
-
});
|
|
300
|
-
|
|
301
|
-
it("should handle multiple queued transactions being confirmed in order", async () => {
|
|
302
|
-
const client = ClientDocument.make({
|
|
303
|
-
schema: TestSchema,
|
|
304
|
-
transport,
|
|
305
|
-
initialState: { title: "", count: 0, items: [] },
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
// Create multiple transactions while offline
|
|
309
|
-
client.transaction((root) => {
|
|
310
|
-
root.title.set("First");
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
client.transaction((root) => {
|
|
314
|
-
root.count.set(42);
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
expect(client.getPendingCount()).toBe(2);
|
|
318
|
-
const tx1 = transport.sentTransactions[0]!;
|
|
319
|
-
const tx2 = transport.sentTransactions[1]!;
|
|
320
|
-
|
|
321
|
-
// Connect
|
|
322
|
-
await client.connect();
|
|
323
|
-
|
|
324
|
-
// Server confirms first transaction
|
|
325
|
-
transport.simulateServerMessage({
|
|
326
|
-
type: "transaction",
|
|
327
|
-
transaction: tx1,
|
|
328
|
-
version: 1,
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
expect(client.getPendingCount()).toBe(1);
|
|
332
|
-
expect(client.getServerState()?.title).toBe("First");
|
|
333
|
-
|
|
334
|
-
// Server confirms second transaction
|
|
335
|
-
transport.simulateServerMessage({
|
|
336
|
-
type: "transaction",
|
|
337
|
-
transaction: tx2,
|
|
338
|
-
version: 2,
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
expect(client.getPendingCount()).toBe(0);
|
|
342
|
-
expect(client.getServerState()?.count).toBe(42);
|
|
343
|
-
});
|
|
344
|
-
|
|
345
|
-
it("should handle rejection of queued transactions", async () => {
|
|
346
|
-
let rejectedTx: Transaction.Transaction | null = null;
|
|
347
|
-
let rejectionReason: string | null = null;
|
|
348
|
-
|
|
349
|
-
const client = ClientDocument.make({
|
|
350
|
-
schema: TestSchema,
|
|
351
|
-
transport,
|
|
352
|
-
initialState: { title: "Original", count: 0, items: [] },
|
|
353
|
-
onRejection: (tx, reason) => {
|
|
354
|
-
rejectedTx = tx;
|
|
355
|
-
rejectionReason = reason;
|
|
356
|
-
},
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
// Create transaction while offline
|
|
360
|
-
client.transaction((root) => {
|
|
361
|
-
root.title.set("Offline Change");
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
const pendingTx = transport.sentTransactions[0]!;
|
|
365
|
-
|
|
366
|
-
// Connect
|
|
367
|
-
await client.connect();
|
|
368
|
-
|
|
369
|
-
// Server rejects the transaction
|
|
370
|
-
transport.simulateServerMessage({
|
|
371
|
-
type: "error",
|
|
372
|
-
transactionId: pendingTx.id,
|
|
373
|
-
reason: "Conflict with another user",
|
|
374
|
-
});
|
|
375
|
-
|
|
376
|
-
// Transaction should be removed from pending
|
|
377
|
-
expect(client.hasPendingChanges()).toBe(false);
|
|
378
|
-
// Optimistic state should revert to server state
|
|
379
|
-
expect(client.get()?.title).toBe("Original");
|
|
380
|
-
// Rejection callback should be called
|
|
381
|
-
expect(rejectedTx).not.toBeNull();
|
|
382
|
-
expect(rejectionReason).toBe("Conflict with another user");
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
it("should rebase queued transactions against concurrent server changes", async () => {
|
|
386
|
-
const client = ClientDocument.make({
|
|
387
|
-
schema: TestSchema,
|
|
388
|
-
transport,
|
|
389
|
-
initialState: { title: "", count: 0, items: [] },
|
|
390
|
-
});
|
|
391
|
-
|
|
392
|
-
// Create transaction while offline
|
|
393
|
-
client.transaction((root) => {
|
|
394
|
-
root.title.set("My Title");
|
|
395
|
-
});
|
|
396
|
-
|
|
397
|
-
await client.connect();
|
|
398
|
-
|
|
399
|
-
// Another user's change comes in first
|
|
400
|
-
const otherUserTx = Transaction.make([
|
|
401
|
-
{
|
|
402
|
-
kind: "number.set" as const,
|
|
403
|
-
path: OperationPath.make("count"),
|
|
404
|
-
payload: 100,
|
|
405
|
-
},
|
|
406
|
-
]);
|
|
407
|
-
|
|
408
|
-
transport.simulateServerMessage({
|
|
409
|
-
type: "transaction",
|
|
410
|
-
transaction: otherUserTx,
|
|
411
|
-
version: 1,
|
|
412
|
-
});
|
|
413
|
-
|
|
414
|
-
// Our transaction should still be pending
|
|
415
|
-
expect(client.hasPendingChanges()).toBe(true);
|
|
416
|
-
// Server state should reflect other user's change
|
|
417
|
-
expect(client.getServerState()?.count).toBe(100);
|
|
418
|
-
// Optimistic state should have both changes
|
|
419
|
-
expect(client.get()?.count).toBe(100);
|
|
420
|
-
expect(client.get()?.title).toBe("My Title");
|
|
421
|
-
});
|
|
422
|
-
|
|
423
|
-
it("should work with disconnect and reconnect cycle", async () => {
|
|
424
|
-
const client = ClientDocument.make({
|
|
425
|
-
schema: TestSchema,
|
|
426
|
-
transport,
|
|
427
|
-
initialState: { title: "", count: 0, items: [] },
|
|
428
|
-
});
|
|
429
|
-
|
|
430
|
-
// Connect first
|
|
431
|
-
await client.connect();
|
|
432
|
-
|
|
433
|
-
client.transaction((root) => {
|
|
434
|
-
root.title.set("Online Change");
|
|
435
|
-
});
|
|
436
|
-
|
|
437
|
-
// Disconnect
|
|
438
|
-
client.disconnect();
|
|
439
|
-
|
|
440
|
-
// Create transaction while disconnected
|
|
441
|
-
client.transaction((root) => {
|
|
442
|
-
root.count.set(50);
|
|
443
|
-
});
|
|
444
|
-
|
|
445
|
-
// State should still be optimistically updated
|
|
446
|
-
expect(client.get()?.title).toBe("Online Change");
|
|
447
|
-
expect(client.get()?.count).toBe(50);
|
|
448
|
-
expect(client.getPendingCount()).toBe(2);
|
|
449
|
-
|
|
450
|
-
// Reconnect
|
|
451
|
-
await client.connect();
|
|
452
|
-
|
|
453
|
-
// Server confirms both transactions
|
|
454
|
-
transport.simulateServerMessage({
|
|
455
|
-
type: "transaction",
|
|
456
|
-
transaction: transport.sentTransactions[0]!,
|
|
457
|
-
version: 1,
|
|
458
|
-
});
|
|
459
|
-
|
|
460
|
-
transport.simulateServerMessage({
|
|
461
|
-
type: "transaction",
|
|
462
|
-
transaction: transport.sentTransactions[1]!,
|
|
463
|
-
version: 2,
|
|
464
|
-
});
|
|
465
|
-
|
|
466
|
-
expect(client.hasPendingChanges()).toBe(false);
|
|
467
|
-
expect(client.getServerState()?.title).toBe("Online Change");
|
|
468
|
-
expect(client.getServerState()?.count).toBe(50);
|
|
469
|
-
});
|
|
470
|
-
|
|
471
|
-
it("should preserve pending transactions during brief disconnection", async () => {
|
|
472
|
-
const client = ClientDocument.make({
|
|
473
|
-
schema: TestSchema,
|
|
474
|
-
transport,
|
|
475
|
-
initialState: { title: "", count: 0, items: [] },
|
|
476
|
-
});
|
|
477
|
-
|
|
478
|
-
await client.connect();
|
|
479
|
-
|
|
480
|
-
// Create a transaction
|
|
481
|
-
client.transaction((root) => {
|
|
482
|
-
root.title.set("Before Disconnect");
|
|
483
|
-
});
|
|
484
|
-
|
|
485
|
-
const pendingCount = client.getPendingCount();
|
|
486
|
-
expect(pendingCount).toBe(1);
|
|
487
|
-
|
|
488
|
-
// Simulate brief disconnection
|
|
489
|
-
client.disconnect();
|
|
490
|
-
|
|
491
|
-
// Pending transactions should still be there
|
|
492
|
-
expect(client.getPendingCount()).toBe(pendingCount);
|
|
493
|
-
expect(client.get()?.title).toBe("Before Disconnect");
|
|
494
|
-
});
|
|
495
|
-
|
|
496
|
-
it("should handle multiple field changes while offline", () => {
|
|
497
|
-
const client = ClientDocument.make({
|
|
498
|
-
schema: TestSchema,
|
|
499
|
-
transport,
|
|
500
|
-
initialState: { title: "", count: 0, items: [] },
|
|
501
|
-
});
|
|
502
|
-
|
|
503
|
-
// Create multiple transactions affecting different fields while offline
|
|
504
|
-
client.transaction((root) => {
|
|
505
|
-
root.title.set("First Title");
|
|
506
|
-
});
|
|
507
|
-
|
|
508
|
-
client.transaction((root) => {
|
|
509
|
-
root.count.set(10);
|
|
510
|
-
});
|
|
511
|
-
|
|
512
|
-
client.transaction((root) => {
|
|
513
|
-
root.title.set("Final Title");
|
|
514
|
-
root.count.set(20);
|
|
515
|
-
});
|
|
516
|
-
|
|
517
|
-
// All changes should be applied optimistically
|
|
518
|
-
expect(client.get()?.title).toBe("Final Title");
|
|
519
|
-
expect(client.get()?.count).toBe(20);
|
|
520
|
-
|
|
521
|
-
// All transactions queued
|
|
522
|
-
expect(transport.sentTransactions.length).toBe(3);
|
|
523
|
-
expect(client.getPendingCount()).toBe(3);
|
|
524
|
-
});
|
|
525
|
-
});
|
|
526
|
-
|
|
527
|
-
describe("server transaction handling", () => {
|
|
528
|
-
it("should confirm our pending transaction when server broadcasts it", async () => {
|
|
529
|
-
const client = ClientDocument.make({
|
|
530
|
-
schema: TestSchema,
|
|
531
|
-
transport,
|
|
532
|
-
initialState: { title: "", count: 0, items: [] },
|
|
533
|
-
});
|
|
534
|
-
|
|
535
|
-
await client.connect();
|
|
536
|
-
|
|
537
|
-
client.transaction((root) => {
|
|
538
|
-
root.title.set("My Change");
|
|
539
|
-
});
|
|
540
|
-
|
|
541
|
-
const sentTx = transport.sentTransactions[0]!;
|
|
542
|
-
expect(client.hasPendingChanges()).toBe(true);
|
|
543
|
-
|
|
544
|
-
// Server broadcasts our transaction
|
|
545
|
-
transport.simulateServerMessage({
|
|
546
|
-
type: "transaction",
|
|
547
|
-
transaction: sentTx,
|
|
548
|
-
version: 1,
|
|
549
|
-
});
|
|
550
|
-
|
|
551
|
-
expect(client.hasPendingChanges()).toBe(false);
|
|
552
|
-
expect(client.get()?.title).toBe("My Change");
|
|
553
|
-
expect(client.getServerState()?.title).toBe("My Change");
|
|
554
|
-
});
|
|
555
|
-
|
|
556
|
-
it("should rebase pending changes when server transaction arrives", async () => {
|
|
557
|
-
const client = ClientDocument.make({
|
|
558
|
-
schema: TestSchema,
|
|
559
|
-
transport,
|
|
560
|
-
initialState: { title: "Original", count: 0, items: [] },
|
|
561
|
-
});
|
|
562
|
-
|
|
563
|
-
await client.connect();
|
|
564
|
-
|
|
565
|
-
// Make a local change to title
|
|
566
|
-
client.transaction((root) => {
|
|
567
|
-
root.title.set("Client Title");
|
|
568
|
-
});
|
|
569
|
-
|
|
570
|
-
expect(client.get()?.title).toBe("Client Title");
|
|
571
|
-
|
|
572
|
-
// Server sends a different transaction (e.g., count change)
|
|
573
|
-
const serverTx = Transaction.make([
|
|
574
|
-
{
|
|
575
|
-
kind: "number.set",
|
|
576
|
-
path: { _tag: "OperationPath" as const, toTokens: () => ["count"], concat: () => ({} as any), append: () => ({} as any), pop: () => ({} as any), shift: () => ({} as any) },
|
|
577
|
-
payload: 100,
|
|
578
|
-
},
|
|
579
|
-
]);
|
|
580
|
-
|
|
581
|
-
transport.simulateServerMessage({
|
|
582
|
-
type: "transaction",
|
|
583
|
-
transaction: serverTx,
|
|
584
|
-
version: 1,
|
|
585
|
-
});
|
|
586
|
-
|
|
587
|
-
// Our pending change should still be there
|
|
588
|
-
expect(client.hasPendingChanges()).toBe(true);
|
|
589
|
-
expect(client.get()?.title).toBe("Client Title");
|
|
590
|
-
expect(client.getServerState()?.count).toBe(100);
|
|
591
|
-
});
|
|
592
|
-
});
|
|
593
|
-
|
|
594
|
-
describe("rejection handling", () => {
|
|
595
|
-
it("should handle transaction rejection and notify callback", async () => {
|
|
596
|
-
let rejectedTx: Transaction.Transaction | null = null;
|
|
597
|
-
let rejectionReason: string | null = null;
|
|
598
|
-
|
|
599
|
-
const client = ClientDocument.make({
|
|
600
|
-
schema: TestSchema,
|
|
601
|
-
transport,
|
|
602
|
-
initialState: { title: "Original", count: 0, items: [] },
|
|
603
|
-
onRejection: (tx, reason) => {
|
|
604
|
-
rejectedTx = tx;
|
|
605
|
-
rejectionReason = reason;
|
|
606
|
-
},
|
|
607
|
-
});
|
|
608
|
-
|
|
609
|
-
await client.connect();
|
|
610
|
-
|
|
611
|
-
client.transaction((root) => {
|
|
612
|
-
root.title.set("Rejected Change");
|
|
613
|
-
});
|
|
614
|
-
|
|
615
|
-
const sentTx = transport.sentTransactions[0]!;
|
|
616
|
-
|
|
617
|
-
// Server rejects the transaction
|
|
618
|
-
transport.simulateServerMessage({
|
|
619
|
-
type: "error",
|
|
620
|
-
transactionId: sentTx.id,
|
|
621
|
-
reason: "Invalid operation",
|
|
622
|
-
});
|
|
623
|
-
|
|
624
|
-
expect(client.hasPendingChanges()).toBe(false);
|
|
625
|
-
expect(client.get()?.title).toBe("Original"); // Rolled back
|
|
626
|
-
expect((rejectedTx as unknown as Transaction.Transaction | null)?.id).toBe(sentTx.id);
|
|
627
|
-
expect(rejectionReason).toBe("Invalid operation");
|
|
628
|
-
});
|
|
629
|
-
});
|
|
630
|
-
|
|
631
|
-
describe("snapshot handling", () => {
|
|
632
|
-
it("should reset state when receiving snapshot", async () => {
|
|
633
|
-
let rejectionCount = 0;
|
|
634
|
-
|
|
635
|
-
const client = ClientDocument.make({
|
|
636
|
-
schema: TestSchema,
|
|
637
|
-
transport,
|
|
638
|
-
initialState: { title: "Old", count: 0, items: [] },
|
|
639
|
-
onRejection: () => {
|
|
640
|
-
rejectionCount++;
|
|
641
|
-
},
|
|
642
|
-
});
|
|
643
|
-
|
|
644
|
-
await client.connect();
|
|
645
|
-
|
|
646
|
-
// Make some pending changes
|
|
647
|
-
client.transaction((root) => {
|
|
648
|
-
root.title.set("Pending 1");
|
|
649
|
-
});
|
|
650
|
-
client.transaction((root) => {
|
|
651
|
-
root.count.set(50);
|
|
652
|
-
});
|
|
653
|
-
|
|
654
|
-
expect(client.getPendingCount()).toBe(2);
|
|
655
|
-
|
|
656
|
-
// Server sends snapshot
|
|
657
|
-
transport.simulateServerMessage({
|
|
658
|
-
type: "snapshot",
|
|
659
|
-
state: { title: "Server Title", count: 100, items: [] },
|
|
660
|
-
version: 10,
|
|
661
|
-
});
|
|
662
|
-
|
|
663
|
-
expect(client.hasPendingChanges()).toBe(false);
|
|
664
|
-
expect(client.get()?.title).toBe("Server Title");
|
|
665
|
-
expect(client.get()?.count).toBe(100);
|
|
666
|
-
expect(client.getServerVersion()).toBe(10);
|
|
667
|
-
expect(rejectionCount).toBe(2); // Both pending were rejected
|
|
668
|
-
});
|
|
669
|
-
});
|
|
670
|
-
|
|
671
|
-
describe("connection management", () => {
|
|
672
|
-
it("should track connection status", async () => {
|
|
673
|
-
const client = ClientDocument.make({
|
|
674
|
-
schema: TestSchema,
|
|
675
|
-
transport,
|
|
676
|
-
initialState: { title: "", count: 0, items: [] },
|
|
677
|
-
});
|
|
678
|
-
|
|
679
|
-
expect(client.isConnected()).toBe(false);
|
|
680
|
-
|
|
681
|
-
await client.connect();
|
|
682
|
-
expect(client.isConnected()).toBe(true);
|
|
683
|
-
|
|
684
|
-
client.disconnect();
|
|
685
|
-
expect(client.isConnected()).toBe(false);
|
|
686
|
-
});
|
|
687
|
-
});
|
|
688
|
-
|
|
689
|
-
describe("initialization", () => {
|
|
690
|
-
it("should be ready immediately with initial state", async () => {
|
|
691
|
-
const client = ClientDocument.make({
|
|
692
|
-
schema: TestSchema,
|
|
693
|
-
transport,
|
|
694
|
-
initialState: { title: "Initial", count: 0, items: [] },
|
|
695
|
-
});
|
|
696
|
-
|
|
697
|
-
expect(client.isReady()).toBe(true);
|
|
698
|
-
await client.connect();
|
|
699
|
-
expect(client.isReady()).toBe(true);
|
|
700
|
-
});
|
|
701
|
-
|
|
702
|
-
it("should buffer transactions during initialization", async () => {
|
|
703
|
-
let readyCalled = false;
|
|
704
|
-
|
|
705
|
-
// Create a transport that doesn't auto-send snapshot
|
|
706
|
-
const manualTransport = createMockTransport();
|
|
707
|
-
|
|
708
|
-
const client = ClientDocument.make({
|
|
709
|
-
schema: TestSchema,
|
|
710
|
-
transport: manualTransport,
|
|
711
|
-
onReady: () => {
|
|
712
|
-
readyCalled = true;
|
|
713
|
-
},
|
|
714
|
-
});
|
|
715
|
-
|
|
716
|
-
// Start connecting (this will enter initializing state and request snapshot)
|
|
717
|
-
const connectPromise = client.connect();
|
|
718
|
-
|
|
719
|
-
// Wait a tick for the connection to start
|
|
720
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
721
|
-
|
|
722
|
-
// Simulate transactions arriving before snapshot
|
|
723
|
-
manualTransport.simulateServerMessage({
|
|
724
|
-
type: "transaction",
|
|
725
|
-
transaction: Transaction.make([
|
|
726
|
-
{
|
|
727
|
-
kind: "string.set" as const,
|
|
728
|
-
path: OperationPath.make("title"),
|
|
729
|
-
payload: "From TX v2",
|
|
730
|
-
},
|
|
731
|
-
]),
|
|
732
|
-
version: 2,
|
|
733
|
-
});
|
|
734
|
-
|
|
735
|
-
manualTransport.simulateServerMessage({
|
|
736
|
-
type: "transaction",
|
|
737
|
-
transaction: Transaction.make([
|
|
738
|
-
{
|
|
739
|
-
kind: "number.set" as const,
|
|
740
|
-
path: OperationPath.make("count"),
|
|
741
|
-
payload: 100,
|
|
742
|
-
},
|
|
743
|
-
]),
|
|
744
|
-
version: 3,
|
|
745
|
-
});
|
|
746
|
-
|
|
747
|
-
// Now send snapshot at version 1 (older than buffered transactions)
|
|
748
|
-
manualTransport.simulateServerMessage({
|
|
749
|
-
type: "snapshot",
|
|
750
|
-
state: { title: "Snapshot Title", count: 0, items: [] },
|
|
751
|
-
version: 1,
|
|
752
|
-
});
|
|
753
|
-
|
|
754
|
-
// Wait for connect to complete
|
|
755
|
-
await connectPromise;
|
|
756
|
-
|
|
757
|
-
// Should be ready now
|
|
758
|
-
expect(client.isReady()).toBe(true);
|
|
759
|
-
expect(readyCalled).toBe(true);
|
|
760
|
-
|
|
761
|
-
// State should include buffered transactions applied on top of snapshot
|
|
762
|
-
expect(client.get()?.title).toBe("From TX v2");
|
|
763
|
-
expect(client.get()?.count).toBe(100);
|
|
764
|
-
expect(client.getServerVersion()).toBe(3);
|
|
765
|
-
});
|
|
766
|
-
|
|
767
|
-
it("should ignore buffered transactions older than snapshot", async () => {
|
|
768
|
-
const manualTransport = createMockTransport();
|
|
769
|
-
|
|
770
|
-
const client = ClientDocument.make({
|
|
771
|
-
schema: TestSchema,
|
|
772
|
-
transport: manualTransport,
|
|
773
|
-
});
|
|
774
|
-
|
|
775
|
-
const connectPromise = client.connect();
|
|
776
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
777
|
-
|
|
778
|
-
// Simulate old transaction arriving before snapshot
|
|
779
|
-
manualTransport.simulateServerMessage({
|
|
780
|
-
type: "transaction",
|
|
781
|
-
transaction: Transaction.make([
|
|
782
|
-
{
|
|
783
|
-
kind: "string.set" as const,
|
|
784
|
-
path: OperationPath.make("title"),
|
|
785
|
-
payload: "Old Title",
|
|
786
|
-
},
|
|
787
|
-
]),
|
|
788
|
-
version: 1,
|
|
789
|
-
});
|
|
790
|
-
|
|
791
|
-
// Send snapshot at version 5 (newer than buffered transaction)
|
|
792
|
-
manualTransport.simulateServerMessage({
|
|
793
|
-
type: "snapshot",
|
|
794
|
-
state: { title: "Snapshot Title", count: 50, items: [] },
|
|
795
|
-
version: 5,
|
|
796
|
-
});
|
|
797
|
-
|
|
798
|
-
await connectPromise;
|
|
799
|
-
|
|
800
|
-
// State should be from snapshot, old transaction should be ignored
|
|
801
|
-
expect(client.get()?.title).toBe("Snapshot Title");
|
|
802
|
-
expect(client.get()?.count).toBe(50);
|
|
803
|
-
expect(client.getServerVersion()).toBe(5);
|
|
804
|
-
});
|
|
805
|
-
|
|
806
|
-
it("should throw when creating transaction before ready", async () => {
|
|
807
|
-
const manualTransport = createMockTransport();
|
|
808
|
-
|
|
809
|
-
const client = ClientDocument.make({
|
|
810
|
-
schema: TestSchema,
|
|
811
|
-
transport: manualTransport,
|
|
812
|
-
});
|
|
813
|
-
|
|
814
|
-
// Start connecting but don't complete
|
|
815
|
-
const connectPromise = client.connect();
|
|
816
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
817
|
-
|
|
818
|
-
// Try to create transaction - should fail
|
|
819
|
-
expect(() => {
|
|
820
|
-
client.transaction((root) => {
|
|
821
|
-
root.title.set("Test");
|
|
822
|
-
});
|
|
823
|
-
}).toThrow("Client is not ready");
|
|
824
|
-
|
|
825
|
-
// Complete initialization
|
|
826
|
-
manualTransport.simulateServerMessage({
|
|
827
|
-
type: "snapshot",
|
|
828
|
-
state: { title: "", count: 0, items: [] },
|
|
829
|
-
version: 1,
|
|
830
|
-
});
|
|
831
|
-
|
|
832
|
-
await connectPromise;
|
|
833
|
-
|
|
834
|
-
// Now transaction should work
|
|
835
|
-
expect(() => {
|
|
836
|
-
client.transaction((root) => {
|
|
837
|
-
root.title.set("Test");
|
|
838
|
-
});
|
|
839
|
-
}).not.toThrow();
|
|
840
|
-
});
|
|
841
|
-
|
|
842
|
-
it("should timeout initialization if snapshot never arrives", async () => {
|
|
843
|
-
const manualTransport = createMockTransport();
|
|
844
|
-
|
|
845
|
-
const client = ClientDocument.make({
|
|
846
|
-
schema: TestSchema,
|
|
847
|
-
transport: manualTransport,
|
|
848
|
-
initTimeout: 50, // Very short timeout for testing
|
|
849
|
-
});
|
|
850
|
-
|
|
851
|
-
// Start connecting - should timeout
|
|
852
|
-
await expect(client.connect()).rejects.toThrow("Initialization timed out");
|
|
853
|
-
|
|
854
|
-
// Should not be ready
|
|
855
|
-
expect(client.isReady()).toBe(false);
|
|
856
|
-
});
|
|
857
|
-
|
|
858
|
-
it("should handle disconnect during initialization", async () => {
|
|
859
|
-
const manualTransport = createMockTransport();
|
|
860
|
-
|
|
861
|
-
const client = ClientDocument.make({
|
|
862
|
-
schema: TestSchema,
|
|
863
|
-
transport: manualTransport,
|
|
864
|
-
});
|
|
865
|
-
|
|
866
|
-
const connectPromise = client.connect();
|
|
867
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
868
|
-
|
|
869
|
-
// Disconnect while waiting for snapshot
|
|
870
|
-
client.disconnect();
|
|
871
|
-
|
|
872
|
-
// Connect should reject
|
|
873
|
-
await expect(connectPromise).rejects.toThrow("Disconnected during initialization");
|
|
874
|
-
|
|
875
|
-
expect(client.isReady()).toBe(false);
|
|
876
|
-
});
|
|
877
|
-
});
|
|
878
|
-
});
|
|
879
|
-
|
|
880
|
-
// =============================================================================
|
|
881
|
-
// Rebase Tests
|
|
882
|
-
// =============================================================================
|
|
883
|
-
|
|
884
|
-
describe("Rebase", () => {
|
|
885
|
-
describe("transformOperation", () => {
|
|
886
|
-
it("should not transform operations on different paths", () => {
|
|
887
|
-
const clientOp = {
|
|
888
|
-
kind: "string.set" as const,
|
|
889
|
-
path: OperationPath.make("title"),
|
|
890
|
-
payload: "client",
|
|
891
|
-
};
|
|
892
|
-
|
|
893
|
-
const serverOp = {
|
|
894
|
-
kind: "number.set" as const,
|
|
895
|
-
path: OperationPath.make("count"),
|
|
896
|
-
payload: 100,
|
|
897
|
-
};
|
|
898
|
-
|
|
899
|
-
const result = Rebase.transformOperation(clientOp, serverOp);
|
|
900
|
-
|
|
901
|
-
expect(result.type).toBe("transformed");
|
|
902
|
-
if (result.type === "transformed") {
|
|
903
|
-
expect(result.operation).toBe(clientOp);
|
|
904
|
-
}
|
|
905
|
-
});
|
|
906
|
-
|
|
907
|
-
it("should handle same-path operations (client wins)", () => {
|
|
908
|
-
const clientOp = {
|
|
909
|
-
kind: "string.set" as const,
|
|
910
|
-
path: OperationPath.make("title"),
|
|
911
|
-
payload: "client",
|
|
912
|
-
};
|
|
913
|
-
|
|
914
|
-
const serverOp = {
|
|
915
|
-
kind: "string.set" as const,
|
|
916
|
-
path: OperationPath.make("title"),
|
|
917
|
-
payload: "server",
|
|
918
|
-
};
|
|
919
|
-
|
|
920
|
-
const result = Rebase.transformOperation(clientOp, serverOp);
|
|
921
|
-
|
|
922
|
-
expect(result.type).toBe("transformed");
|
|
923
|
-
if (result.type === "transformed") {
|
|
924
|
-
expect(result.operation.payload).toBe("client");
|
|
925
|
-
}
|
|
926
|
-
});
|
|
927
|
-
|
|
928
|
-
it("should make client op noop when server removes target element", () => {
|
|
929
|
-
const clientOp = {
|
|
930
|
-
kind: "string.set" as const,
|
|
931
|
-
path: OperationPath.make("items/item-1/name"),
|
|
932
|
-
payload: "new name",
|
|
933
|
-
};
|
|
934
|
-
|
|
935
|
-
const serverOp = {
|
|
936
|
-
kind: "array.remove" as const,
|
|
937
|
-
path: OperationPath.make("items"),
|
|
938
|
-
payload: { id: "item-1" },
|
|
939
|
-
};
|
|
940
|
-
|
|
941
|
-
const result = Rebase.transformOperation(clientOp, serverOp);
|
|
942
|
-
|
|
943
|
-
expect(result.type).toBe("noop");
|
|
944
|
-
});
|
|
945
|
-
});
|
|
946
|
-
|
|
947
|
-
describe("rebasePendingTransactions", () => {
|
|
948
|
-
it("should transform all pending transactions against server transaction", () => {
|
|
949
|
-
const pending1 = Transaction.make([
|
|
950
|
-
{
|
|
951
|
-
kind: "string.set" as const,
|
|
952
|
-
path: OperationPath.make("title"),
|
|
953
|
-
payload: "pending1",
|
|
954
|
-
},
|
|
955
|
-
]);
|
|
956
|
-
|
|
957
|
-
const pending2 = Transaction.make([
|
|
958
|
-
{
|
|
959
|
-
kind: "number.set" as const,
|
|
960
|
-
path: OperationPath.make("count"),
|
|
961
|
-
payload: 10,
|
|
962
|
-
},
|
|
963
|
-
]);
|
|
964
|
-
|
|
965
|
-
const serverTx = Transaction.make([
|
|
966
|
-
{
|
|
967
|
-
kind: "string.set" as const,
|
|
968
|
-
path: OperationPath.make("description"),
|
|
969
|
-
payload: "server desc",
|
|
970
|
-
},
|
|
971
|
-
]);
|
|
972
|
-
|
|
973
|
-
const rebased = Rebase.rebasePendingTransactions([pending1, pending2], serverTx);
|
|
974
|
-
|
|
975
|
-
expect(rebased.length).toBe(2);
|
|
976
|
-
expect(rebased[0]!.id).toBe(pending1.id);
|
|
977
|
-
expect(rebased[1]!.id).toBe(pending2.id);
|
|
978
|
-
});
|
|
979
|
-
});
|
|
980
|
-
});
|
|
981
|
-
|
|
982
|
-
// =============================================================================
|
|
983
|
-
// StateMonitor Tests
|
|
984
|
-
// =============================================================================
|
|
985
|
-
|
|
986
|
-
describe("StateMonitor", () => {
|
|
987
|
-
describe("version tracking", () => {
|
|
988
|
-
it("should accept sequential versions", () => {
|
|
989
|
-
const monitor = StateMonitor.make();
|
|
990
|
-
|
|
991
|
-
expect(monitor.onServerVersion(1)).toBe(true);
|
|
992
|
-
expect(monitor.onServerVersion(2)).toBe(true);
|
|
993
|
-
expect(monitor.onServerVersion(3)).toBe(true);
|
|
994
|
-
});
|
|
995
|
-
|
|
996
|
-
it("should detect large version gaps", () => {
|
|
997
|
-
let driftDetected = false;
|
|
998
|
-
|
|
999
|
-
const monitor = StateMonitor.make({
|
|
1000
|
-
maxVersionGap: 5,
|
|
1001
|
-
onEvent: (event) => {
|
|
1002
|
-
if (event.type === "drift_detected") {
|
|
1003
|
-
driftDetected = true;
|
|
1004
|
-
}
|
|
1005
|
-
},
|
|
1006
|
-
});
|
|
1007
|
-
|
|
1008
|
-
monitor.onServerVersion(1);
|
|
1009
|
-
const result = monitor.onServerVersion(20); // Gap of 19
|
|
1010
|
-
|
|
1011
|
-
expect(result).toBe(false);
|
|
1012
|
-
expect(driftDetected).toBe(true);
|
|
1013
|
-
});
|
|
1014
|
-
});
|
|
1015
|
-
|
|
1016
|
-
describe("pending tracking", () => {
|
|
1017
|
-
it("should track and untrack pending transactions", () => {
|
|
1018
|
-
const monitor = StateMonitor.make();
|
|
1019
|
-
|
|
1020
|
-
monitor.trackPending({ id: "tx-1", sentAt: Date.now() });
|
|
1021
|
-
monitor.trackPending({ id: "tx-2", sentAt: Date.now() });
|
|
1022
|
-
|
|
1023
|
-
expect(monitor.getStatus().pendingCount).toBe(2);
|
|
1024
|
-
|
|
1025
|
-
monitor.untrackPending("tx-1");
|
|
1026
|
-
|
|
1027
|
-
expect(monitor.getStatus().pendingCount).toBe(1);
|
|
1028
|
-
});
|
|
1029
|
-
|
|
1030
|
-
it("should identify stale pending transactions", () => {
|
|
1031
|
-
const monitor = StateMonitor.make({
|
|
1032
|
-
stalePendingThreshold: 100, // 100ms for testing
|
|
1033
|
-
});
|
|
1034
|
-
|
|
1035
|
-
const oldTime = Date.now() - 200; // 200ms ago
|
|
1036
|
-
monitor.trackPending({ id: "tx-old", sentAt: oldTime });
|
|
1037
|
-
monitor.trackPending({ id: "tx-new", sentAt: Date.now() });
|
|
1038
|
-
|
|
1039
|
-
const stale = monitor.getStalePending();
|
|
1040
|
-
|
|
1041
|
-
expect(stale.length).toBe(1);
|
|
1042
|
-
expect(stale[0]!.id).toBe("tx-old");
|
|
1043
|
-
});
|
|
1044
|
-
});
|
|
1045
|
-
|
|
1046
|
-
describe("reset", () => {
|
|
1047
|
-
it("should clear state on reset", () => {
|
|
1048
|
-
let recoveryCompleted = false;
|
|
1049
|
-
|
|
1050
|
-
const monitor = StateMonitor.make({
|
|
1051
|
-
onEvent: (event) => {
|
|
1052
|
-
if (event.type === "recovery_completed") {
|
|
1053
|
-
recoveryCompleted = true;
|
|
1054
|
-
}
|
|
1055
|
-
},
|
|
1056
|
-
});
|
|
1057
|
-
|
|
1058
|
-
monitor.trackPending({ id: "tx-1", sentAt: Date.now() });
|
|
1059
|
-
monitor.onServerVersion(5);
|
|
1060
|
-
|
|
1061
|
-
monitor.reset(10);
|
|
1062
|
-
|
|
1063
|
-
expect(monitor.getStatus().pendingCount).toBe(0);
|
|
1064
|
-
expect(monitor.getStatus().expectedVersion).toBe(10);
|
|
1065
|
-
expect(recoveryCompleted).toBe(true);
|
|
1066
|
-
});
|
|
1067
|
-
});
|
|
1068
|
-
});
|
|
1069
|
-
|
|
1070
|
-
// =============================================================================
|
|
1071
|
-
// ClientDocument Presence Tests
|
|
1072
|
-
// =============================================================================
|
|
1073
|
-
|
|
1074
|
-
const CursorPresenceSchema = Presence.make({
|
|
1075
|
-
schema: Schema.Struct({
|
|
1076
|
-
x: Schema.Number,
|
|
1077
|
-
y: Schema.Number,
|
|
1078
|
-
name: Schema.optional(Schema.String),
|
|
1079
|
-
}),
|
|
1080
|
-
});
|
|
1081
|
-
|
|
1082
|
-
describe("ClientDocument Presence", () => {
|
|
1083
|
-
let transport: ReturnType<typeof createMockTransport>;
|
|
1084
|
-
|
|
1085
|
-
beforeEach(() => {
|
|
1086
|
-
transport = createMockTransport();
|
|
1087
|
-
});
|
|
1088
|
-
|
|
1089
|
-
describe("presence API availability", () => {
|
|
1090
|
-
it("should have undefined presence when no presence schema provided", async () => {
|
|
1091
|
-
const client = ClientDocument.make({
|
|
1092
|
-
schema: TestSchema,
|
|
1093
|
-
transport,
|
|
1094
|
-
initialState: { title: "", count: 0, items: [] },
|
|
1095
|
-
});
|
|
1096
|
-
|
|
1097
|
-
await client.connect();
|
|
1098
|
-
|
|
1099
|
-
expect(client.presence).toBeUndefined();
|
|
1100
|
-
});
|
|
1101
|
-
|
|
1102
|
-
it("should have defined presence when presence schema provided", async () => {
|
|
1103
|
-
const client = ClientDocument.make({
|
|
1104
|
-
schema: TestSchema,
|
|
1105
|
-
transport,
|
|
1106
|
-
initialState: { title: "", count: 0, items: [] },
|
|
1107
|
-
presence: CursorPresenceSchema,
|
|
1108
|
-
});
|
|
1109
|
-
|
|
1110
|
-
await client.connect();
|
|
1111
|
-
|
|
1112
|
-
expect(client.presence).toBeDefined();
|
|
1113
|
-
expect(typeof client.presence!.selfId).toBe("function");
|
|
1114
|
-
expect(typeof client.presence!.self).toBe("function");
|
|
1115
|
-
expect(typeof client.presence!.others).toBe("function");
|
|
1116
|
-
expect(typeof client.presence!.all).toBe("function");
|
|
1117
|
-
expect(typeof client.presence!.set).toBe("function");
|
|
1118
|
-
expect(typeof client.presence!.clear).toBe("function");
|
|
1119
|
-
expect(typeof client.presence!.subscribe).toBe("function");
|
|
1120
|
-
});
|
|
1121
|
-
});
|
|
1122
|
-
|
|
1123
|
-
describe("selfId", () => {
|
|
1124
|
-
it("should return undefined before presence_snapshot received", async () => {
|
|
1125
|
-
const client = ClientDocument.make({
|
|
1126
|
-
schema: TestSchema,
|
|
1127
|
-
transport,
|
|
1128
|
-
initialState: { title: "", count: 0, items: [] },
|
|
1129
|
-
presence: CursorPresenceSchema,
|
|
1130
|
-
});
|
|
1131
|
-
|
|
1132
|
-
await client.connect();
|
|
1133
|
-
|
|
1134
|
-
expect(client.presence!.selfId()).toBeUndefined();
|
|
1135
|
-
});
|
|
1136
|
-
|
|
1137
|
-
it("should return correct id after presence_snapshot received", async () => {
|
|
1138
|
-
const client = ClientDocument.make({
|
|
1139
|
-
schema: TestSchema,
|
|
1140
|
-
transport,
|
|
1141
|
-
initialState: { title: "", count: 0, items: [] },
|
|
1142
|
-
presence: CursorPresenceSchema,
|
|
1143
|
-
});
|
|
1144
|
-
|
|
1145
|
-
await client.connect();
|
|
1146
|
-
|
|
1147
|
-
transport.simulateServerMessage({
|
|
1148
|
-
type: "presence_snapshot",
|
|
1149
|
-
selfId: "conn-my-id",
|
|
1150
|
-
presences: {},
|
|
1151
|
-
});
|
|
1152
|
-
|
|
1153
|
-
expect(client.presence!.selfId()).toBe("conn-my-id");
|
|
1154
|
-
});
|
|
1155
|
-
});
|
|
1156
|
-
|
|
1157
|
-
describe("self", () => {
|
|
1158
|
-
it("should return undefined before set is called", async () => {
|
|
1159
|
-
const client = ClientDocument.make({
|
|
1160
|
-
schema: TestSchema,
|
|
1161
|
-
transport,
|
|
1162
|
-
initialState: { title: "", count: 0, items: [] },
|
|
1163
|
-
presence: CursorPresenceSchema,
|
|
1164
|
-
});
|
|
1165
|
-
|
|
1166
|
-
await client.connect();
|
|
1167
|
-
|
|
1168
|
-
expect(client.presence!.self()).toBeUndefined();
|
|
1169
|
-
});
|
|
1170
|
-
|
|
1171
|
-
it("should return data after set is called", async () => {
|
|
1172
|
-
const client = ClientDocument.make({
|
|
1173
|
-
schema: TestSchema,
|
|
1174
|
-
transport,
|
|
1175
|
-
initialState: { title: "", count: 0, items: [] },
|
|
1176
|
-
presence: CursorPresenceSchema,
|
|
1177
|
-
});
|
|
1178
|
-
|
|
1179
|
-
await client.connect();
|
|
1180
|
-
|
|
1181
|
-
client.presence!.set({ x: 100, y: 200 });
|
|
1182
|
-
|
|
1183
|
-
expect(client.presence!.self()).toEqual({ x: 100, y: 200 });
|
|
1184
|
-
});
|
|
1185
|
-
});
|
|
1186
|
-
|
|
1187
|
-
describe("others", () => {
|
|
1188
|
-
it("should return empty map initially", async () => {
|
|
1189
|
-
const client = ClientDocument.make({
|
|
1190
|
-
schema: TestSchema,
|
|
1191
|
-
transport,
|
|
1192
|
-
initialState: { title: "", count: 0, items: [] },
|
|
1193
|
-
presence: CursorPresenceSchema,
|
|
1194
|
-
});
|
|
1195
|
-
|
|
1196
|
-
await client.connect();
|
|
1197
|
-
|
|
1198
|
-
expect(client.presence!.others().size).toBe(0);
|
|
1199
|
-
});
|
|
1200
|
-
|
|
1201
|
-
it("should return other presences from snapshot", async () => {
|
|
1202
|
-
const client = ClientDocument.make({
|
|
1203
|
-
schema: TestSchema,
|
|
1204
|
-
transport,
|
|
1205
|
-
initialState: { title: "", count: 0, items: [] },
|
|
1206
|
-
presence: CursorPresenceSchema,
|
|
1207
|
-
});
|
|
1208
|
-
|
|
1209
|
-
await client.connect();
|
|
1210
|
-
|
|
1211
|
-
transport.simulateServerMessage({
|
|
1212
|
-
type: "presence_snapshot",
|
|
1213
|
-
selfId: "conn-me",
|
|
1214
|
-
presences: {
|
|
1215
|
-
"conn-other-1": { data: { x: 10, y: 20 }, userId: "user-1" },
|
|
1216
|
-
"conn-other-2": { data: { x: 30, y: 40 } },
|
|
1217
|
-
},
|
|
1218
|
-
});
|
|
1219
|
-
|
|
1220
|
-
const others = client.presence!.others();
|
|
1221
|
-
expect(others.size).toBe(2);
|
|
1222
|
-
expect(others.get("conn-other-1")).toEqual({ data: { x: 10, y: 20 }, userId: "user-1" });
|
|
1223
|
-
expect(others.get("conn-other-2")).toEqual({ data: { x: 30, y: 40 } });
|
|
1224
|
-
});
|
|
1225
|
-
|
|
1226
|
-
it("should update on presence_update", async () => {
|
|
1227
|
-
const client = ClientDocument.make({
|
|
1228
|
-
schema: TestSchema,
|
|
1229
|
-
transport,
|
|
1230
|
-
initialState: { title: "", count: 0, items: [] },
|
|
1231
|
-
presence: CursorPresenceSchema,
|
|
1232
|
-
});
|
|
1233
|
-
|
|
1234
|
-
await client.connect();
|
|
1235
|
-
|
|
1236
|
-
transport.simulateServerMessage({
|
|
1237
|
-
type: "presence_snapshot",
|
|
1238
|
-
selfId: "conn-me",
|
|
1239
|
-
presences: {},
|
|
1240
|
-
});
|
|
1241
|
-
|
|
1242
|
-
transport.simulateServerMessage({
|
|
1243
|
-
type: "presence_update",
|
|
1244
|
-
id: "conn-new-user",
|
|
1245
|
-
data: { x: 50, y: 60 },
|
|
1246
|
-
userId: "user-new",
|
|
1247
|
-
});
|
|
1248
|
-
|
|
1249
|
-
const others = client.presence!.others();
|
|
1250
|
-
expect(others.size).toBe(1);
|
|
1251
|
-
expect(others.get("conn-new-user")).toEqual({ data: { x: 50, y: 60 }, userId: "user-new" });
|
|
1252
|
-
});
|
|
1253
|
-
|
|
1254
|
-
it("should remove on presence_remove", async () => {
|
|
1255
|
-
const client = ClientDocument.make({
|
|
1256
|
-
schema: TestSchema,
|
|
1257
|
-
transport,
|
|
1258
|
-
initialState: { title: "", count: 0, items: [] },
|
|
1259
|
-
presence: CursorPresenceSchema,
|
|
1260
|
-
});
|
|
1261
|
-
|
|
1262
|
-
await client.connect();
|
|
1263
|
-
|
|
1264
|
-
transport.simulateServerMessage({
|
|
1265
|
-
type: "presence_snapshot",
|
|
1266
|
-
selfId: "conn-me",
|
|
1267
|
-
presences: {
|
|
1268
|
-
"conn-leaving": { data: { x: 10, y: 20 } },
|
|
1269
|
-
},
|
|
1270
|
-
});
|
|
1271
|
-
|
|
1272
|
-
expect(client.presence!.others().size).toBe(1);
|
|
1273
|
-
|
|
1274
|
-
transport.simulateServerMessage({
|
|
1275
|
-
type: "presence_remove",
|
|
1276
|
-
id: "conn-leaving",
|
|
1277
|
-
});
|
|
1278
|
-
|
|
1279
|
-
expect(client.presence!.others().size).toBe(0);
|
|
1280
|
-
});
|
|
1281
|
-
});
|
|
1282
|
-
|
|
1283
|
-
describe("all", () => {
|
|
1284
|
-
it("should combine self and others", async () => {
|
|
1285
|
-
const client = ClientDocument.make({
|
|
1286
|
-
schema: TestSchema,
|
|
1287
|
-
transport,
|
|
1288
|
-
initialState: { title: "", count: 0, items: [] },
|
|
1289
|
-
presence: CursorPresenceSchema,
|
|
1290
|
-
});
|
|
1291
|
-
|
|
1292
|
-
await client.connect();
|
|
1293
|
-
|
|
1294
|
-
transport.simulateServerMessage({
|
|
1295
|
-
type: "presence_snapshot",
|
|
1296
|
-
selfId: "conn-me",
|
|
1297
|
-
presences: {
|
|
1298
|
-
"conn-other": { data: { x: 10, y: 20 } },
|
|
1299
|
-
},
|
|
1300
|
-
});
|
|
1301
|
-
|
|
1302
|
-
client.presence!.set({ x: 100, y: 200 });
|
|
1303
|
-
|
|
1304
|
-
const all = client.presence!.all();
|
|
1305
|
-
expect(all.size).toBe(2);
|
|
1306
|
-
expect(all.get("conn-me")).toEqual({ data: { x: 100, y: 200 } });
|
|
1307
|
-
expect(all.get("conn-other")).toEqual({ data: { x: 10, y: 20 } });
|
|
1308
|
-
});
|
|
1309
|
-
|
|
1310
|
-
it("should not include self if self data not set", async () => {
|
|
1311
|
-
const client = ClientDocument.make({
|
|
1312
|
-
schema: TestSchema,
|
|
1313
|
-
transport,
|
|
1314
|
-
initialState: { title: "", count: 0, items: [] },
|
|
1315
|
-
presence: CursorPresenceSchema,
|
|
1316
|
-
});
|
|
1317
|
-
|
|
1318
|
-
await client.connect();
|
|
1319
|
-
|
|
1320
|
-
transport.simulateServerMessage({
|
|
1321
|
-
type: "presence_snapshot",
|
|
1322
|
-
selfId: "conn-me",
|
|
1323
|
-
presences: {
|
|
1324
|
-
"conn-other": { data: { x: 10, y: 20 } },
|
|
1325
|
-
},
|
|
1326
|
-
});
|
|
1327
|
-
|
|
1328
|
-
const all = client.presence!.all();
|
|
1329
|
-
expect(all.size).toBe(1);
|
|
1330
|
-
expect(all.has("conn-me")).toBe(false);
|
|
1331
|
-
expect(all.get("conn-other")).toEqual({ data: { x: 10, y: 20 } });
|
|
1332
|
-
});
|
|
1333
|
-
});
|
|
1334
|
-
|
|
1335
|
-
describe("initialPresence", () => {
|
|
1336
|
-
it("should set presence to initialPresence value on connect", async () => {
|
|
1337
|
-
const client = ClientDocument.make({
|
|
1338
|
-
schema: TestSchema,
|
|
1339
|
-
transport,
|
|
1340
|
-
initialState: { title: "", count: 0, items: [] },
|
|
1341
|
-
presence: CursorPresenceSchema,
|
|
1342
|
-
initialPresence: { x: 50, y: 100, name: "Initial User" },
|
|
1343
|
-
});
|
|
1344
|
-
|
|
1345
|
-
await client.connect();
|
|
1346
|
-
|
|
1347
|
-
expect(client.presence!.self()).toEqual({ x: 50, y: 100, name: "Initial User" });
|
|
1348
|
-
});
|
|
1349
|
-
|
|
1350
|
-
it("should send initialPresence to transport on connect", async () => {
|
|
1351
|
-
const client = ClientDocument.make({
|
|
1352
|
-
schema: TestSchema,
|
|
1353
|
-
transport,
|
|
1354
|
-
initialState: { title: "", count: 0, items: [] },
|
|
1355
|
-
presence: CursorPresenceSchema,
|
|
1356
|
-
initialPresence: { x: 25, y: 75 },
|
|
1357
|
-
});
|
|
1358
|
-
|
|
1359
|
-
await client.connect();
|
|
1360
|
-
|
|
1361
|
-
expect(transport.presenceSetCalls.length).toBe(1);
|
|
1362
|
-
expect(transport.presenceSetCalls[0]).toEqual({ x: 25, y: 75 });
|
|
1363
|
-
});
|
|
1364
|
-
|
|
1365
|
-
it("should notify subscribers when initialPresence is set", async () => {
|
|
1366
|
-
const client = ClientDocument.make({
|
|
1367
|
-
schema: TestSchema,
|
|
1368
|
-
transport,
|
|
1369
|
-
initialState: { title: "", count: 0, items: [] },
|
|
1370
|
-
presence: CursorPresenceSchema,
|
|
1371
|
-
initialPresence: { x: 10, y: 20 },
|
|
1372
|
-
});
|
|
1373
|
-
|
|
1374
|
-
let changeCount = 0;
|
|
1375
|
-
client.presence!.subscribe({
|
|
1376
|
-
onPresenceChange: () => {
|
|
1377
|
-
changeCount++;
|
|
1378
|
-
},
|
|
1379
|
-
});
|
|
1380
|
-
|
|
1381
|
-
await client.connect();
|
|
1382
|
-
|
|
1383
|
-
expect(changeCount).toBe(1);
|
|
1384
|
-
});
|
|
1385
|
-
|
|
1386
|
-
it("should not set presence when initialPresence is not provided", async () => {
|
|
1387
|
-
const client = ClientDocument.make({
|
|
1388
|
-
schema: TestSchema,
|
|
1389
|
-
transport,
|
|
1390
|
-
initialState: { title: "", count: 0, items: [] },
|
|
1391
|
-
presence: CursorPresenceSchema,
|
|
1392
|
-
});
|
|
1393
|
-
|
|
1394
|
-
await client.connect();
|
|
1395
|
-
|
|
1396
|
-
expect(client.presence!.self()).toBeUndefined();
|
|
1397
|
-
expect(transport.presenceSetCalls.length).toBe(0);
|
|
1398
|
-
});
|
|
1399
|
-
});
|
|
1400
|
-
|
|
1401
|
-
describe("set", () => {
|
|
1402
|
-
it("should validate data against schema", async () => {
|
|
1403
|
-
const client = ClientDocument.make({
|
|
1404
|
-
schema: TestSchema,
|
|
1405
|
-
transport,
|
|
1406
|
-
initialState: { title: "", count: 0, items: [] },
|
|
1407
|
-
presence: CursorPresenceSchema,
|
|
1408
|
-
});
|
|
1409
|
-
|
|
1410
|
-
await client.connect();
|
|
1411
|
-
|
|
1412
|
-
// Valid data should not throw
|
|
1413
|
-
expect(() => {
|
|
1414
|
-
client.presence!.set({ x: 100, y: 200 });
|
|
1415
|
-
}).not.toThrow();
|
|
1416
|
-
|
|
1417
|
-
// Invalid data should throw
|
|
1418
|
-
expect(() => {
|
|
1419
|
-
client.presence!.set({ x: "invalid", y: 200 } as any);
|
|
1420
|
-
}).toThrow();
|
|
1421
|
-
});
|
|
1422
|
-
|
|
1423
|
-
it("should send presence data to transport", async () => {
|
|
1424
|
-
const client = ClientDocument.make({
|
|
1425
|
-
schema: TestSchema,
|
|
1426
|
-
transport,
|
|
1427
|
-
initialState: { title: "", count: 0, items: [] },
|
|
1428
|
-
presence: CursorPresenceSchema,
|
|
1429
|
-
});
|
|
1430
|
-
|
|
1431
|
-
await client.connect();
|
|
1432
|
-
|
|
1433
|
-
client.presence!.set({ x: 100, y: 200, name: "Alice" });
|
|
1434
|
-
|
|
1435
|
-
expect(transport.presenceSetCalls.length).toBe(1);
|
|
1436
|
-
expect(transport.presenceSetCalls[0]).toEqual({ x: 100, y: 200, name: "Alice" });
|
|
1437
|
-
});
|
|
1438
|
-
|
|
1439
|
-
it("should update local self state", async () => {
|
|
1440
|
-
const client = ClientDocument.make({
|
|
1441
|
-
schema: TestSchema,
|
|
1442
|
-
transport,
|
|
1443
|
-
initialState: { title: "", count: 0, items: [] },
|
|
1444
|
-
presence: CursorPresenceSchema,
|
|
1445
|
-
});
|
|
1446
|
-
|
|
1447
|
-
await client.connect();
|
|
1448
|
-
|
|
1449
|
-
expect(client.presence!.self()).toBeUndefined();
|
|
1450
|
-
|
|
1451
|
-
client.presence!.set({ x: 50, y: 75 });
|
|
1452
|
-
|
|
1453
|
-
expect(client.presence!.self()).toEqual({ x: 50, y: 75 });
|
|
1454
|
-
});
|
|
1455
|
-
});
|
|
1456
|
-
|
|
1457
|
-
describe("clear", () => {
|
|
1458
|
-
it("should send presence_clear to transport", async () => {
|
|
1459
|
-
const client = ClientDocument.make({
|
|
1460
|
-
schema: TestSchema,
|
|
1461
|
-
transport,
|
|
1462
|
-
initialState: { title: "", count: 0, items: [] },
|
|
1463
|
-
presence: CursorPresenceSchema,
|
|
1464
|
-
});
|
|
1465
|
-
|
|
1466
|
-
await client.connect();
|
|
1467
|
-
|
|
1468
|
-
client.presence!.clear();
|
|
1469
|
-
|
|
1470
|
-
expect(transport.presenceClearCalls).toBe(1);
|
|
1471
|
-
});
|
|
1472
|
-
|
|
1473
|
-
it("should clear local self state", async () => {
|
|
1474
|
-
const client = ClientDocument.make({
|
|
1475
|
-
schema: TestSchema,
|
|
1476
|
-
transport,
|
|
1477
|
-
initialState: { title: "", count: 0, items: [] },
|
|
1478
|
-
presence: CursorPresenceSchema,
|
|
1479
|
-
});
|
|
1480
|
-
|
|
1481
|
-
await client.connect();
|
|
1482
|
-
|
|
1483
|
-
client.presence!.set({ x: 100, y: 200 });
|
|
1484
|
-
expect(client.presence!.self()).toEqual({ x: 100, y: 200 });
|
|
1485
|
-
|
|
1486
|
-
client.presence!.clear();
|
|
1487
|
-
expect(client.presence!.self()).toBeUndefined();
|
|
1488
|
-
});
|
|
1489
|
-
});
|
|
1490
|
-
|
|
1491
|
-
describe("subscribe", () => {
|
|
1492
|
-
it("should notify on presence_snapshot", async () => {
|
|
1493
|
-
const client = ClientDocument.make({
|
|
1494
|
-
schema: TestSchema,
|
|
1495
|
-
transport,
|
|
1496
|
-
initialState: { title: "", count: 0, items: [] },
|
|
1497
|
-
presence: CursorPresenceSchema,
|
|
1498
|
-
});
|
|
1499
|
-
|
|
1500
|
-
await client.connect();
|
|
1501
|
-
|
|
1502
|
-
let changeCount = 0;
|
|
1503
|
-
client.presence!.subscribe({
|
|
1504
|
-
onPresenceChange: () => {
|
|
1505
|
-
changeCount++;
|
|
1506
|
-
},
|
|
1507
|
-
});
|
|
1508
|
-
|
|
1509
|
-
transport.simulateServerMessage({
|
|
1510
|
-
type: "presence_snapshot",
|
|
1511
|
-
selfId: "conn-me",
|
|
1512
|
-
presences: {},
|
|
1513
|
-
});
|
|
1514
|
-
|
|
1515
|
-
expect(changeCount).toBe(1);
|
|
1516
|
-
});
|
|
1517
|
-
|
|
1518
|
-
it("should notify on presence_update", async () => {
|
|
1519
|
-
const client = ClientDocument.make({
|
|
1520
|
-
schema: TestSchema,
|
|
1521
|
-
transport,
|
|
1522
|
-
initialState: { title: "", count: 0, items: [] },
|
|
1523
|
-
presence: CursorPresenceSchema,
|
|
1524
|
-
});
|
|
1525
|
-
|
|
1526
|
-
await client.connect();
|
|
1527
|
-
|
|
1528
|
-
let changeCount = 0;
|
|
1529
|
-
client.presence!.subscribe({
|
|
1530
|
-
onPresenceChange: () => {
|
|
1531
|
-
changeCount++;
|
|
1532
|
-
},
|
|
1533
|
-
});
|
|
1534
|
-
|
|
1535
|
-
transport.simulateServerMessage({
|
|
1536
|
-
type: "presence_update",
|
|
1537
|
-
id: "conn-other",
|
|
1538
|
-
data: { x: 10, y: 20 },
|
|
1539
|
-
});
|
|
1540
|
-
|
|
1541
|
-
expect(changeCount).toBe(1);
|
|
1542
|
-
});
|
|
1543
|
-
|
|
1544
|
-
it("should notify on presence_remove", async () => {
|
|
1545
|
-
const client = ClientDocument.make({
|
|
1546
|
-
schema: TestSchema,
|
|
1547
|
-
transport,
|
|
1548
|
-
initialState: { title: "", count: 0, items: [] },
|
|
1549
|
-
presence: CursorPresenceSchema,
|
|
1550
|
-
});
|
|
1551
|
-
|
|
1552
|
-
await client.connect();
|
|
1553
|
-
|
|
1554
|
-
let changeCount = 0;
|
|
1555
|
-
client.presence!.subscribe({
|
|
1556
|
-
onPresenceChange: () => {
|
|
1557
|
-
changeCount++;
|
|
1558
|
-
},
|
|
1559
|
-
});
|
|
1560
|
-
|
|
1561
|
-
transport.simulateServerMessage({
|
|
1562
|
-
type: "presence_remove",
|
|
1563
|
-
id: "conn-other",
|
|
1564
|
-
});
|
|
1565
|
-
|
|
1566
|
-
expect(changeCount).toBe(1);
|
|
1567
|
-
});
|
|
1568
|
-
|
|
1569
|
-
it("should notify on local set", async () => {
|
|
1570
|
-
const client = ClientDocument.make({
|
|
1571
|
-
schema: TestSchema,
|
|
1572
|
-
transport,
|
|
1573
|
-
initialState: { title: "", count: 0, items: [] },
|
|
1574
|
-
presence: CursorPresenceSchema,
|
|
1575
|
-
});
|
|
1576
|
-
|
|
1577
|
-
await client.connect();
|
|
1578
|
-
|
|
1579
|
-
let changeCount = 0;
|
|
1580
|
-
client.presence!.subscribe({
|
|
1581
|
-
onPresenceChange: () => {
|
|
1582
|
-
changeCount++;
|
|
1583
|
-
},
|
|
1584
|
-
});
|
|
1585
|
-
|
|
1586
|
-
client.presence!.set({ x: 100, y: 200 });
|
|
1587
|
-
|
|
1588
|
-
expect(changeCount).toBe(1);
|
|
1589
|
-
});
|
|
1590
|
-
|
|
1591
|
-
it("should notify on local clear", async () => {
|
|
1592
|
-
const client = ClientDocument.make({
|
|
1593
|
-
schema: TestSchema,
|
|
1594
|
-
transport,
|
|
1595
|
-
initialState: { title: "", count: 0, items: [] },
|
|
1596
|
-
presence: CursorPresenceSchema,
|
|
1597
|
-
});
|
|
1598
|
-
|
|
1599
|
-
await client.connect();
|
|
1600
|
-
|
|
1601
|
-
let changeCount = 0;
|
|
1602
|
-
client.presence!.subscribe({
|
|
1603
|
-
onPresenceChange: () => {
|
|
1604
|
-
changeCount++;
|
|
1605
|
-
},
|
|
1606
|
-
});
|
|
1607
|
-
|
|
1608
|
-
client.presence!.clear();
|
|
1609
|
-
|
|
1610
|
-
expect(changeCount).toBe(1);
|
|
1611
|
-
});
|
|
1612
|
-
|
|
1613
|
-
it("should allow unsubscribing", async () => {
|
|
1614
|
-
const client = ClientDocument.make({
|
|
1615
|
-
schema: TestSchema,
|
|
1616
|
-
transport,
|
|
1617
|
-
initialState: { title: "", count: 0, items: [] },
|
|
1618
|
-
presence: CursorPresenceSchema,
|
|
1619
|
-
});
|
|
1620
|
-
|
|
1621
|
-
await client.connect();
|
|
1622
|
-
|
|
1623
|
-
let changeCount = 0;
|
|
1624
|
-
const unsubscribe = client.presence!.subscribe({
|
|
1625
|
-
onPresenceChange: () => {
|
|
1626
|
-
changeCount++;
|
|
1627
|
-
},
|
|
1628
|
-
});
|
|
1629
|
-
|
|
1630
|
-
client.presence!.set({ x: 100, y: 200 });
|
|
1631
|
-
expect(changeCount).toBe(1);
|
|
1632
|
-
|
|
1633
|
-
unsubscribe();
|
|
1634
|
-
|
|
1635
|
-
client.presence!.set({ x: 200, y: 300 });
|
|
1636
|
-
expect(changeCount).toBe(1); // Should not increment
|
|
1637
|
-
});
|
|
1638
|
-
});
|
|
1639
|
-
|
|
1640
|
-
describe("disconnect behavior", () => {
|
|
1641
|
-
it("should clear presence state on disconnect", async () => {
|
|
1642
|
-
const client = ClientDocument.make({
|
|
1643
|
-
schema: TestSchema,
|
|
1644
|
-
transport,
|
|
1645
|
-
initialState: { title: "", count: 0, items: [] },
|
|
1646
|
-
presence: CursorPresenceSchema,
|
|
1647
|
-
});
|
|
1648
|
-
|
|
1649
|
-
await client.connect();
|
|
1650
|
-
|
|
1651
|
-
transport.simulateServerMessage({
|
|
1652
|
-
type: "presence_snapshot",
|
|
1653
|
-
selfId: "conn-me",
|
|
1654
|
-
presences: {
|
|
1655
|
-
"conn-other": { data: { x: 10, y: 20 } },
|
|
1656
|
-
},
|
|
1657
|
-
});
|
|
1658
|
-
|
|
1659
|
-
client.presence!.set({ x: 100, y: 200 });
|
|
1660
|
-
|
|
1661
|
-
expect(client.presence!.selfId()).toBe("conn-me");
|
|
1662
|
-
expect(client.presence!.self()).toEqual({ x: 100, y: 200 });
|
|
1663
|
-
expect(client.presence!.others().size).toBe(1);
|
|
1664
|
-
|
|
1665
|
-
client.disconnect();
|
|
1666
|
-
|
|
1667
|
-
expect(client.presence!.selfId()).toBeUndefined();
|
|
1668
|
-
expect(client.presence!.self()).toBeUndefined();
|
|
1669
|
-
expect(client.presence!.others().size).toBe(0);
|
|
1670
|
-
});
|
|
1671
|
-
|
|
1672
|
-
it("should notify subscribers on disconnect", async () => {
|
|
1673
|
-
const client = ClientDocument.make({
|
|
1674
|
-
schema: TestSchema,
|
|
1675
|
-
transport,
|
|
1676
|
-
initialState: { title: "", count: 0, items: [] },
|
|
1677
|
-
presence: CursorPresenceSchema,
|
|
1678
|
-
});
|
|
1679
|
-
|
|
1680
|
-
await client.connect();
|
|
1681
|
-
|
|
1682
|
-
transport.simulateServerMessage({
|
|
1683
|
-
type: "presence_snapshot",
|
|
1684
|
-
selfId: "conn-me",
|
|
1685
|
-
presences: {
|
|
1686
|
-
"conn-other": { data: { x: 10, y: 20 } },
|
|
1687
|
-
},
|
|
1688
|
-
});
|
|
1689
|
-
|
|
1690
|
-
let changeCount = 0;
|
|
1691
|
-
client.presence!.subscribe({
|
|
1692
|
-
onPresenceChange: () => {
|
|
1693
|
-
changeCount++;
|
|
1694
|
-
},
|
|
1695
|
-
});
|
|
1696
|
-
|
|
1697
|
-
// Reset count after snapshot notification
|
|
1698
|
-
changeCount = 0;
|
|
1699
|
-
|
|
1700
|
-
client.disconnect();
|
|
1701
|
-
|
|
1702
|
-
// Should notify when clearing presence
|
|
1703
|
-
expect(changeCount).toBe(1);
|
|
1704
|
-
});
|
|
1705
|
-
});
|
|
1706
|
-
|
|
1707
|
-
// ===========================================================================
|
|
1708
|
-
// Draft Tests
|
|
1709
|
-
// ===========================================================================
|
|
1710
|
-
|
|
1711
|
-
describe("drafts", () => {
|
|
1712
|
-
it("should create a draft and preview changes without sending to server", async () => {
|
|
1713
|
-
const initialState: TestState = { title: "Hello", count: 0, items: [] };
|
|
1714
|
-
const client = ClientDocument.make({ schema: TestSchema, transport, initialState });
|
|
1715
|
-
await client.connect();
|
|
1716
|
-
|
|
1717
|
-
const draft = client.createDraft();
|
|
1718
|
-
draft.update((root) => root.title.set("Draft Title"));
|
|
1719
|
-
|
|
1720
|
-
// Optimistic state should include draft
|
|
1721
|
-
expect(client.get()?.title).toBe("Draft Title");
|
|
1722
|
-
// No transaction sent to server
|
|
1723
|
-
expect(transport.sentTransactions.length).toBe(0);
|
|
1724
|
-
});
|
|
1725
|
-
|
|
1726
|
-
it("should replace per-field ops on same path", async () => {
|
|
1727
|
-
const initialState: TestState = { title: "Hello", count: 0, items: [] };
|
|
1728
|
-
const client = ClientDocument.make({ schema: TestSchema, transport, initialState });
|
|
1729
|
-
await client.connect();
|
|
1730
|
-
|
|
1731
|
-
const draft = client.createDraft();
|
|
1732
|
-
draft.update((root) => root.title.set("First"));
|
|
1733
|
-
draft.update((root) => root.title.set("Second"));
|
|
1734
|
-
|
|
1735
|
-
expect(client.get()?.title).toBe("Second");
|
|
1736
|
-
expect(transport.sentTransactions.length).toBe(0);
|
|
1737
|
-
});
|
|
1738
|
-
|
|
1739
|
-
it("should accumulate ops across different fields", async () => {
|
|
1740
|
-
const initialState: TestState = { title: "Hello", count: 0, items: [] };
|
|
1741
|
-
const client = ClientDocument.make({ schema: TestSchema, transport, initialState });
|
|
1742
|
-
await client.connect();
|
|
1743
|
-
|
|
1744
|
-
const draft = client.createDraft();
|
|
1745
|
-
draft.update((root) => root.title.set("New Title"));
|
|
1746
|
-
draft.update((root) => root.count.set(42));
|
|
1747
|
-
|
|
1748
|
-
expect(client.get()?.title).toBe("New Title");
|
|
1749
|
-
expect(client.get()?.count).toBe(42);
|
|
1750
|
-
});
|
|
1751
|
-
|
|
1752
|
-
it("should commit draft as a single transaction", async () => {
|
|
1753
|
-
const initialState: TestState = { title: "Hello", count: 0, items: [] };
|
|
1754
|
-
const client = ClientDocument.make({ schema: TestSchema, transport, initialState });
|
|
1755
|
-
await client.connect();
|
|
1756
|
-
|
|
1757
|
-
const draft = client.createDraft();
|
|
1758
|
-
draft.update((root) => root.title.set("Committed"));
|
|
1759
|
-
draft.update((root) => root.count.set(10));
|
|
1760
|
-
draft.commit();
|
|
1761
|
-
|
|
1762
|
-
// Should have sent exactly one transaction
|
|
1763
|
-
expect(transport.sentTransactions.length).toBe(1);
|
|
1764
|
-
// State should still reflect the changes
|
|
1765
|
-
expect(client.get()?.title).toBe("Committed");
|
|
1766
|
-
expect(client.get()?.count).toBe(10);
|
|
1767
|
-
// Draft should be consumed
|
|
1768
|
-
expect(client.getActiveDraftIds().size).toBe(0);
|
|
1769
|
-
});
|
|
1770
|
-
|
|
1771
|
-
it("should discard draft and revert to non-draft state", async () => {
|
|
1772
|
-
const initialState: TestState = { title: "Hello", count: 0, items: [] };
|
|
1773
|
-
const client = ClientDocument.make({ schema: TestSchema, transport, initialState });
|
|
1774
|
-
await client.connect();
|
|
1775
|
-
|
|
1776
|
-
const draft = client.createDraft();
|
|
1777
|
-
draft.update((root) => root.title.set("Draft"));
|
|
1778
|
-
|
|
1779
|
-
expect(client.get()?.title).toBe("Draft");
|
|
1780
|
-
|
|
1781
|
-
draft.discard();
|
|
1782
|
-
|
|
1783
|
-
expect(client.get()?.title).toBe("Hello");
|
|
1784
|
-
expect(transport.sentTransactions.length).toBe(0);
|
|
1785
|
-
expect(client.getActiveDraftIds().size).toBe(0);
|
|
1786
|
-
});
|
|
1787
|
-
|
|
1788
|
-
it("should throw when using a consumed draft", async () => {
|
|
1789
|
-
const initialState: TestState = { title: "Hello", count: 0, items: [] };
|
|
1790
|
-
const client = ClientDocument.make({ schema: TestSchema, transport, initialState });
|
|
1791
|
-
await client.connect();
|
|
1792
|
-
|
|
1793
|
-
const draft = client.createDraft();
|
|
1794
|
-
draft.commit();
|
|
1795
|
-
|
|
1796
|
-
expect(() => draft.update((root) => root.title.set("x"))).toThrow();
|
|
1797
|
-
expect(() => draft.commit()).toThrow();
|
|
1798
|
-
expect(() => draft.discard()).toThrow();
|
|
1799
|
-
});
|
|
1800
|
-
|
|
1801
|
-
it("should support multiple concurrent drafts", async () => {
|
|
1802
|
-
const initialState: TestState = { title: "Hello", count: 0, items: [] };
|
|
1803
|
-
const client = ClientDocument.make({ schema: TestSchema, transport, initialState });
|
|
1804
|
-
await client.connect();
|
|
1805
|
-
|
|
1806
|
-
const draft1 = client.createDraft();
|
|
1807
|
-
const draft2 = client.createDraft();
|
|
1808
|
-
|
|
1809
|
-
draft1.update((root) => root.title.set("Draft1"));
|
|
1810
|
-
draft2.update((root) => root.count.set(99));
|
|
1811
|
-
|
|
1812
|
-
expect(client.get()?.title).toBe("Draft1");
|
|
1813
|
-
expect(client.get()?.count).toBe(99);
|
|
1814
|
-
expect(client.getActiveDraftIds().size).toBe(2);
|
|
1815
|
-
|
|
1816
|
-
draft1.discard();
|
|
1817
|
-
expect(client.get()?.title).toBe("Hello");
|
|
1818
|
-
expect(client.get()?.count).toBe(99);
|
|
1819
|
-
|
|
1820
|
-
draft2.commit();
|
|
1821
|
-
expect(client.get()?.count).toBe(99);
|
|
1822
|
-
expect(transport.sentTransactions.length).toBe(1);
|
|
1823
|
-
});
|
|
1824
|
-
|
|
1825
|
-
it("should rebase draft ops when server transaction arrives", async () => {
|
|
1826
|
-
const initialState: TestState = { title: "Hello", count: 0, items: [] };
|
|
1827
|
-
const client = ClientDocument.make({ schema: TestSchema, transport, initialState });
|
|
1828
|
-
await client.connect();
|
|
1829
|
-
|
|
1830
|
-
const draft = client.createDraft();
|
|
1831
|
-
draft.update((root) => root.title.set("My Draft"));
|
|
1832
|
-
|
|
1833
|
-
// Create a proper server transaction by using a scratch document
|
|
1834
|
-
const scratchDoc = Document.make(TestSchema, { initialState });
|
|
1835
|
-
scratchDoc.transaction((root) => root.count.set(50));
|
|
1836
|
-
const serverTx = scratchDoc.flush();
|
|
1837
|
-
// Override the ID for clarity
|
|
1838
|
-
const serverTxWithId = { ...serverTx, id: "server-tx-1" };
|
|
1839
|
-
|
|
1840
|
-
transport.simulateServerMessage({
|
|
1841
|
-
type: "transaction",
|
|
1842
|
-
transaction: serverTxWithId,
|
|
1843
|
-
version: 1,
|
|
1844
|
-
});
|
|
1845
|
-
|
|
1846
|
-
// Draft title should survive, server count should be applied
|
|
1847
|
-
expect(client.get()?.title).toBe("My Draft");
|
|
1848
|
-
expect(client.get()?.count).toBe(50);
|
|
1849
|
-
});
|
|
1850
|
-
|
|
1851
|
-
it("should notify onDraftChange listeners", async () => {
|
|
1852
|
-
const initialState: TestState = { title: "Hello", count: 0, items: [] };
|
|
1853
|
-
const client = ClientDocument.make({ schema: TestSchema, transport, initialState });
|
|
1854
|
-
await client.connect();
|
|
1855
|
-
|
|
1856
|
-
let draftChangeCount = 0;
|
|
1857
|
-
client.subscribe({
|
|
1858
|
-
onDraftChange: () => { draftChangeCount++; },
|
|
1859
|
-
});
|
|
1860
|
-
|
|
1861
|
-
const draft = client.createDraft();
|
|
1862
|
-
expect(draftChangeCount).toBe(1); // createDraft
|
|
1863
|
-
|
|
1864
|
-
draft.update((root) => root.title.set("x"));
|
|
1865
|
-
expect(draftChangeCount).toBe(2); // update
|
|
1866
|
-
|
|
1867
|
-
draft.discard();
|
|
1868
|
-
expect(draftChangeCount).toBe(3); // discard
|
|
1869
|
-
});
|
|
1870
|
-
|
|
1871
|
-
it("should commit empty draft without sending transaction", async () => {
|
|
1872
|
-
const initialState: TestState = { title: "Hello", count: 0, items: [] };
|
|
1873
|
-
const client = ClientDocument.make({ schema: TestSchema, transport, initialState });
|
|
1874
|
-
await client.connect();
|
|
1875
|
-
|
|
1876
|
-
const draft = client.createDraft();
|
|
1877
|
-
draft.commit();
|
|
1878
|
-
|
|
1879
|
-
expect(transport.sentTransactions.length).toBe(0);
|
|
1880
|
-
});
|
|
1881
|
-
|
|
1882
|
-
it("should NEVER send transactions to server during draft.update() - explicit verification", async () => {
|
|
1883
|
-
const initialState: TestState = { title: "Hello", count: 0, items: [] };
|
|
1884
|
-
const client = ClientDocument.make({ schema: TestSchema, transport, initialState });
|
|
1885
|
-
await client.connect();
|
|
1886
|
-
|
|
1887
|
-
// Track when transport.send is called
|
|
1888
|
-
const sendCalls: Transaction.Transaction[] = [];
|
|
1889
|
-
const originalSend = transport.send;
|
|
1890
|
-
transport.send = (tx) => {
|
|
1891
|
-
sendCalls.push(tx);
|
|
1892
|
-
originalSend.call(transport, tx);
|
|
1893
|
-
};
|
|
1894
|
-
|
|
1895
|
-
const draft = client.createDraft();
|
|
1896
|
-
|
|
1897
|
-
// Perform multiple updates
|
|
1898
|
-
draft.update((root) => root.title.set("Update 1"));
|
|
1899
|
-
expect(sendCalls.length).toBe(0);
|
|
1900
|
-
|
|
1901
|
-
draft.update((root) => root.count.set(10));
|
|
1902
|
-
expect(sendCalls.length).toBe(0);
|
|
1903
|
-
|
|
1904
|
-
draft.update((root) => root.title.set("Update 2"));
|
|
1905
|
-
expect(sendCalls.length).toBe(0);
|
|
1906
|
-
|
|
1907
|
-
draft.update((root) => {
|
|
1908
|
-
root.title.set("Update 3");
|
|
1909
|
-
root.count.set(20);
|
|
1910
|
-
});
|
|
1911
|
-
expect(sendCalls.length).toBe(0);
|
|
1912
|
-
|
|
1913
|
-
// Verify optimistic state is updated
|
|
1914
|
-
expect(client.get()?.title).toBe("Update 3");
|
|
1915
|
-
expect(client.get()?.count).toBe(20);
|
|
1916
|
-
|
|
1917
|
-
// Still no transactions sent
|
|
1918
|
-
expect(sendCalls.length).toBe(0);
|
|
1919
|
-
expect(transport.sentTransactions.length).toBe(0);
|
|
1920
|
-
|
|
1921
|
-
// Only after commit should transaction be sent
|
|
1922
|
-
draft.commit();
|
|
1923
|
-
expect(sendCalls.length).toBe(1);
|
|
1924
|
-
expect(transport.sentTransactions.length).toBe(1);
|
|
1925
|
-
});
|
|
1926
|
-
|
|
1927
|
-
it("should never call transport.send during draft lifecycle until commit", async () => {
|
|
1928
|
-
const initialState: TestState = { title: "Hello", count: 0, items: [] };
|
|
1929
|
-
const client = ClientDocument.make({ schema: TestSchema, transport, initialState });
|
|
1930
|
-
await client.connect();
|
|
1931
|
-
|
|
1932
|
-
// Create a spy to track exact moments of transport.send calls
|
|
1933
|
-
const sendTimestamps: { time: number; action: string }[] = [];
|
|
1934
|
-
const originalSend = transport.send;
|
|
1935
|
-
transport.send = (tx) => {
|
|
1936
|
-
sendTimestamps.push({ time: Date.now(), action: "send" });
|
|
1937
|
-
originalSend.call(transport, tx);
|
|
1938
|
-
};
|
|
1939
|
-
|
|
1940
|
-
// Draft operations should NOT trigger send
|
|
1941
|
-
const draft = client.createDraft();
|
|
1942
|
-
expect(sendTimestamps.length).toBe(0);
|
|
1943
|
-
|
|
1944
|
-
draft.update((root) => root.title.set("Draft"));
|
|
1945
|
-
expect(sendTimestamps.length).toBe(0);
|
|
1946
|
-
|
|
1947
|
-
// Regular transaction SHOULD trigger send
|
|
1948
|
-
client.transaction((root) => root.count.set(5));
|
|
1949
|
-
expect(sendTimestamps.length).toBe(1);
|
|
1950
|
-
|
|
1951
|
-
// More draft updates should NOT trigger send
|
|
1952
|
-
draft.update((root) => root.title.set("Draft 2"));
|
|
1953
|
-
expect(sendTimestamps.length).toBe(1);
|
|
1954
|
-
|
|
1955
|
-
// Commit SHOULD trigger send
|
|
1956
|
-
draft.commit();
|
|
1957
|
-
expect(sendTimestamps.length).toBe(2);
|
|
1958
|
-
});
|
|
1959
|
-
|
|
1960
|
-
it("should not send transactions when draft is discarded", async () => {
|
|
1961
|
-
const initialState: TestState = { title: "Hello", count: 0, items: [] };
|
|
1962
|
-
const client = ClientDocument.make({ schema: TestSchema, transport, initialState });
|
|
1963
|
-
await client.connect();
|
|
1964
|
-
|
|
1965
|
-
const draft = client.createDraft();
|
|
1966
|
-
draft.update((root) => root.title.set("Will be discarded"));
|
|
1967
|
-
draft.update((root) => root.count.set(999));
|
|
1968
|
-
|
|
1969
|
-
expect(transport.sentTransactions.length).toBe(0);
|
|
1970
|
-
|
|
1971
|
-
draft.discard();
|
|
1972
|
-
|
|
1973
|
-
// Still no transactions should be sent
|
|
1974
|
-
expect(transport.sentTransactions.length).toBe(0);
|
|
1975
|
-
|
|
1976
|
-
// State should revert
|
|
1977
|
-
expect(client.get()?.title).toBe("Hello");
|
|
1978
|
-
expect(client.get()?.count).toBe(0);
|
|
1979
|
-
});
|
|
1980
|
-
});
|
|
1981
|
-
});
|