@sylphx/lens-server 2.13.2 → 2.14.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.
- package/dist/index.d.ts +5 -5
- package/dist/index.js +141 -230
- package/package.json +2 -2
- package/src/e2e/server.test.ts +61 -43
- package/src/handlers/framework.ts +25 -10
- package/src/handlers/http.ts +14 -4
- package/src/handlers/ws.ts +37 -29
- package/src/server/create.test.ts +311 -173
- package/src/server/create.ts +106 -309
- package/src/server/dataloader.test.ts +279 -0
- package/src/server/types.ts +5 -5
|
@@ -3,14 +3,75 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Tests for the pure executor server.
|
|
5
5
|
* Server only does: getMetadata() and execute()
|
|
6
|
+
*
|
|
7
|
+
* STATELESS ARCHITECTURE:
|
|
8
|
+
* - Server sends initial data via { $: "snapshot", data }
|
|
9
|
+
* - Server sends updates via { $: "ops", ops: Op[] }
|
|
10
|
+
* - Client applies updates to local state using applyOps
|
|
6
11
|
*/
|
|
7
12
|
|
|
8
13
|
import { describe, expect, it } from "bun:test";
|
|
9
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
applyOps,
|
|
16
|
+
entity,
|
|
17
|
+
firstValueFrom,
|
|
18
|
+
isError,
|
|
19
|
+
isOps,
|
|
20
|
+
isSnapshot,
|
|
21
|
+
type Message,
|
|
22
|
+
mutation,
|
|
23
|
+
query,
|
|
24
|
+
resolver,
|
|
25
|
+
router,
|
|
26
|
+
t,
|
|
27
|
+
} from "@sylphx/lens-core";
|
|
10
28
|
import { z } from "zod";
|
|
11
29
|
import { optimisticPlugin } from "../plugin/optimistic.js";
|
|
12
30
|
import { createApp } from "./create.js";
|
|
13
31
|
|
|
32
|
+
// =============================================================================
|
|
33
|
+
// Test Helpers for Stateless Architecture
|
|
34
|
+
// =============================================================================
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Create a results collector that simulates client behavior.
|
|
38
|
+
* Maintains current state by applying incoming Message updates.
|
|
39
|
+
*
|
|
40
|
+
* NEW PROTOCOL:
|
|
41
|
+
* - { $: "snapshot", data } → replace current state
|
|
42
|
+
* - { $: "ops", ops: Op[] } → apply operations to current state
|
|
43
|
+
* - { $: "error", error } → error message
|
|
44
|
+
*/
|
|
45
|
+
function createResultsCollector() {
|
|
46
|
+
let currentData: unknown = null;
|
|
47
|
+
const rawResults: Message[] = [];
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
/** Raw results (Message format) */
|
|
51
|
+
get raw() {
|
|
52
|
+
return rawResults;
|
|
53
|
+
},
|
|
54
|
+
/** Current accumulated state */
|
|
55
|
+
get current() {
|
|
56
|
+
return currentData;
|
|
57
|
+
},
|
|
58
|
+
/** Number of ops messages received */
|
|
59
|
+
get updateCount() {
|
|
60
|
+
return rawResults.filter((r) => isOps(r)).length;
|
|
61
|
+
},
|
|
62
|
+
/** Push a Message result (applies ops if present) */
|
|
63
|
+
push(result: Message) {
|
|
64
|
+
rawResults.push(result);
|
|
65
|
+
if (isSnapshot(result)) {
|
|
66
|
+
currentData = result.data;
|
|
67
|
+
}
|
|
68
|
+
if (isOps(result)) {
|
|
69
|
+
currentData = applyOps(currentData, result.ops);
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
14
75
|
// =============================================================================
|
|
15
76
|
// Test Entities
|
|
16
77
|
// =============================================================================
|
|
@@ -199,12 +260,14 @@ describe("execute", () => {
|
|
|
199
260
|
}),
|
|
200
261
|
);
|
|
201
262
|
|
|
202
|
-
expect(result
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
263
|
+
expect(isSnapshot(result)).toBe(true);
|
|
264
|
+
if (isSnapshot(result)) {
|
|
265
|
+
expect(result.data).toEqual({
|
|
266
|
+
id: "123",
|
|
267
|
+
name: "Test User",
|
|
268
|
+
email: "test@example.com",
|
|
269
|
+
});
|
|
270
|
+
}
|
|
208
271
|
});
|
|
209
272
|
|
|
210
273
|
it("executes mutation successfully", async () => {
|
|
@@ -219,12 +282,14 @@ describe("execute", () => {
|
|
|
219
282
|
}),
|
|
220
283
|
);
|
|
221
284
|
|
|
222
|
-
expect(result
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
285
|
+
expect(isSnapshot(result)).toBe(true);
|
|
286
|
+
if (isSnapshot(result)) {
|
|
287
|
+
expect(result.data).toEqual({
|
|
288
|
+
id: "new-id",
|
|
289
|
+
name: "New User",
|
|
290
|
+
email: "new@example.com",
|
|
291
|
+
});
|
|
292
|
+
}
|
|
228
293
|
});
|
|
229
294
|
|
|
230
295
|
it("returns error for unknown operation", async () => {
|
|
@@ -239,9 +304,10 @@ describe("execute", () => {
|
|
|
239
304
|
}),
|
|
240
305
|
);
|
|
241
306
|
|
|
242
|
-
expect(result
|
|
243
|
-
|
|
244
|
-
|
|
307
|
+
expect(isError(result)).toBe(true);
|
|
308
|
+
if (isError(result)) {
|
|
309
|
+
expect(result.error).toContain("not found");
|
|
310
|
+
}
|
|
245
311
|
});
|
|
246
312
|
|
|
247
313
|
it("returns error for invalid input", async () => {
|
|
@@ -256,8 +322,7 @@ describe("execute", () => {
|
|
|
256
322
|
}),
|
|
257
323
|
);
|
|
258
324
|
|
|
259
|
-
expect(result
|
|
260
|
-
expect(result.error).toBeInstanceOf(Error);
|
|
325
|
+
expect(isError(result)).toBe(true);
|
|
261
326
|
});
|
|
262
327
|
|
|
263
328
|
it("executes router operations with dot notation", async () => {
|
|
@@ -277,11 +342,14 @@ describe("execute", () => {
|
|
|
277
342
|
}),
|
|
278
343
|
);
|
|
279
344
|
|
|
280
|
-
expect(queryResult
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
345
|
+
expect(isSnapshot(queryResult)).toBe(true);
|
|
346
|
+
if (isSnapshot(queryResult)) {
|
|
347
|
+
expect(queryResult.data).toEqual({
|
|
348
|
+
id: "456",
|
|
349
|
+
name: "Test User",
|
|
350
|
+
email: "test@example.com",
|
|
351
|
+
});
|
|
352
|
+
}
|
|
285
353
|
|
|
286
354
|
const mutationResult = await firstValueFrom(
|
|
287
355
|
server.execute({
|
|
@@ -290,11 +358,14 @@ describe("execute", () => {
|
|
|
290
358
|
}),
|
|
291
359
|
);
|
|
292
360
|
|
|
293
|
-
expect(mutationResult
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
361
|
+
expect(isSnapshot(mutationResult)).toBe(true);
|
|
362
|
+
if (isSnapshot(mutationResult)) {
|
|
363
|
+
expect(mutationResult.data).toEqual({
|
|
364
|
+
id: "new-id",
|
|
365
|
+
name: "Router User",
|
|
366
|
+
email: undefined,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
298
369
|
});
|
|
299
370
|
|
|
300
371
|
it("handles resolver errors gracefully", async () => {
|
|
@@ -315,9 +386,10 @@ describe("execute", () => {
|
|
|
315
386
|
}),
|
|
316
387
|
);
|
|
317
388
|
|
|
318
|
-
expect(result
|
|
319
|
-
|
|
320
|
-
|
|
389
|
+
expect(isError(result)).toBe(true);
|
|
390
|
+
if (isError(result)) {
|
|
391
|
+
expect(result.error).toBe("Resolver error");
|
|
392
|
+
}
|
|
321
393
|
});
|
|
322
394
|
|
|
323
395
|
it("executes query without input", async () => {
|
|
@@ -331,7 +403,10 @@ describe("execute", () => {
|
|
|
331
403
|
}),
|
|
332
404
|
);
|
|
333
405
|
|
|
334
|
-
expect(result
|
|
406
|
+
expect(isSnapshot(result)).toBe(true);
|
|
407
|
+
if (isSnapshot(result)) {
|
|
408
|
+
expect(result.data).toHaveLength(2);
|
|
409
|
+
}
|
|
335
410
|
});
|
|
336
411
|
});
|
|
337
412
|
|
|
@@ -419,11 +494,14 @@ describe("selection", () => {
|
|
|
419
494
|
}),
|
|
420
495
|
);
|
|
421
496
|
|
|
422
|
-
expect(result
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
497
|
+
expect(isSnapshot(result)).toBe(true);
|
|
498
|
+
if (isSnapshot(result)) {
|
|
499
|
+
expect(result.data).toEqual({
|
|
500
|
+
id: "123", // id always included
|
|
501
|
+
name: "Test User",
|
|
502
|
+
});
|
|
503
|
+
expect((result.data as Record<string, unknown>).email).toBeUndefined();
|
|
504
|
+
}
|
|
427
505
|
});
|
|
428
506
|
});
|
|
429
507
|
|
|
@@ -541,14 +619,16 @@ describe("field resolvers", () => {
|
|
|
541
619
|
}),
|
|
542
620
|
);
|
|
543
621
|
|
|
544
|
-
expect(result
|
|
545
|
-
|
|
622
|
+
expect(isSnapshot(result)).toBe(true);
|
|
623
|
+
if (isSnapshot(result)) {
|
|
624
|
+
expect(result.data).toBeDefined();
|
|
546
625
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
626
|
+
const data = result.data as { id: string; name: string; posts: { id: string; title: string }[] };
|
|
627
|
+
expect(data.id).toBe("a1");
|
|
628
|
+
expect(data.name).toBe("Alice");
|
|
629
|
+
expect(data.posts).toHaveLength(2); // limit: 2
|
|
630
|
+
expect(data.posts.every((p) => p.title)).toBe(true); // only selected fields
|
|
631
|
+
}
|
|
552
632
|
});
|
|
553
633
|
|
|
554
634
|
it("passes context to field resolvers", async () => {
|
|
@@ -739,8 +819,9 @@ describe("field resolvers", () => {
|
|
|
739
819
|
expect(data.posts).toHaveLength(3); // All of Alice's posts (default limit 10)
|
|
740
820
|
});
|
|
741
821
|
|
|
742
|
-
it("emit()
|
|
743
|
-
//
|
|
822
|
+
it("emit() sends update command (stateless architecture)", async () => {
|
|
823
|
+
// STATELESS: Field resolvers only run on initial resolve.
|
|
824
|
+
// Emits forward commands to client - no re-resolution on server.
|
|
744
825
|
let postsResolverCallCount = 0;
|
|
745
826
|
|
|
746
827
|
const Author = entity("Author", {
|
|
@@ -792,7 +873,7 @@ describe("field resolvers", () => {
|
|
|
792
873
|
});
|
|
793
874
|
|
|
794
875
|
// Subscribe to query with nested posts
|
|
795
|
-
const
|
|
876
|
+
const collector = createResultsCollector();
|
|
796
877
|
const subscription = server
|
|
797
878
|
.execute({
|
|
798
879
|
path: "getAuthor",
|
|
@@ -807,31 +888,34 @@ describe("field resolvers", () => {
|
|
|
807
888
|
})
|
|
808
889
|
.subscribe({
|
|
809
890
|
next: (result) => {
|
|
810
|
-
|
|
891
|
+
collector.push(result as Message);
|
|
811
892
|
},
|
|
812
893
|
});
|
|
813
894
|
|
|
814
895
|
// Wait for initial result
|
|
815
896
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
816
897
|
|
|
817
|
-
expect(
|
|
898
|
+
expect(collector.raw.length).toBe(1);
|
|
818
899
|
expect(postsResolverCallCount).toBe(1); // Called once for initial query
|
|
819
900
|
|
|
820
|
-
//
|
|
821
|
-
mockDb.posts.push({ id: "p3", title: "Post 3", authorId: "a1" });
|
|
822
|
-
|
|
823
|
-
// Emit updated author (this should trigger field resolvers)
|
|
901
|
+
// Emit updated author
|
|
824
902
|
capturedEmit!({ id: "a1", name: "Alice Updated" });
|
|
825
903
|
|
|
826
904
|
// Wait for emit to process
|
|
827
905
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
828
906
|
|
|
829
|
-
|
|
830
|
-
expect(
|
|
907
|
+
// STATELESS: Server sends ops command, not re-resolved data
|
|
908
|
+
expect(collector.raw.length).toBe(2);
|
|
909
|
+
expect(postsResolverCallCount).toBe(1); // NOT called again - stateless!
|
|
831
910
|
|
|
832
|
-
//
|
|
833
|
-
const
|
|
834
|
-
expect(
|
|
911
|
+
// Second result should be an ops command
|
|
912
|
+
const updateResult = collector.raw[1];
|
|
913
|
+
expect(isOps(updateResult)).toBe(true);
|
|
914
|
+
|
|
915
|
+
// Client applies update: name is updated, posts preserved from initial
|
|
916
|
+
const currentState = collector.current as { name: string; posts: { id: string }[] };
|
|
917
|
+
expect(currentState.name).toBe("Alice Updated");
|
|
918
|
+
expect(currentState.posts).toHaveLength(2); // Original 2 posts preserved
|
|
835
919
|
|
|
836
920
|
subscription.unsubscribe();
|
|
837
921
|
});
|
|
@@ -922,7 +1006,8 @@ describe("field resolvers", () => {
|
|
|
922
1006
|
expect(cleanupCalled).toBe(true);
|
|
923
1007
|
});
|
|
924
1008
|
|
|
925
|
-
it("field-level emit
|
|
1009
|
+
it("field-level emit sends update command (stateless)", async () => {
|
|
1010
|
+
// STATELESS: Field emit sends update command with field path prefix
|
|
926
1011
|
const Author = entity("Author", {
|
|
927
1012
|
id: t.id(),
|
|
928
1013
|
name: t.string(),
|
|
@@ -984,7 +1069,7 @@ describe("field resolvers", () => {
|
|
|
984
1069
|
context: () => ({ db: mockDb }),
|
|
985
1070
|
});
|
|
986
1071
|
|
|
987
|
-
const
|
|
1072
|
+
const collector = createResultsCollector();
|
|
988
1073
|
const subscription = server
|
|
989
1074
|
.execute({
|
|
990
1075
|
path: "getAuthor",
|
|
@@ -999,18 +1084,21 @@ describe("field resolvers", () => {
|
|
|
999
1084
|
})
|
|
1000
1085
|
.subscribe({
|
|
1001
1086
|
next: (result) => {
|
|
1002
|
-
|
|
1087
|
+
collector.push(result as Message);
|
|
1003
1088
|
},
|
|
1004
1089
|
});
|
|
1005
1090
|
|
|
1006
1091
|
// Wait for initial result
|
|
1007
1092
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1008
1093
|
|
|
1009
|
-
expect(
|
|
1094
|
+
expect(collector.raw.length).toBe(1);
|
|
1010
1095
|
expect(capturedFieldEmit).toBeDefined();
|
|
1011
1096
|
|
|
1012
|
-
const initialResult =
|
|
1013
|
-
expect(initialResult
|
|
1097
|
+
const initialResult = collector.raw[0];
|
|
1098
|
+
expect(isSnapshot(initialResult)).toBe(true);
|
|
1099
|
+
if (isSnapshot(initialResult)) {
|
|
1100
|
+
expect((initialResult.data as { posts: { id: string }[] }).posts).toHaveLength(2);
|
|
1101
|
+
}
|
|
1014
1102
|
|
|
1015
1103
|
// Use field-level emit to update just the posts field
|
|
1016
1104
|
const newPosts = [
|
|
@@ -1023,15 +1111,20 @@ describe("field resolvers", () => {
|
|
|
1023
1111
|
// Wait for field emit to process
|
|
1024
1112
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1025
1113
|
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
expect(
|
|
1029
|
-
|
|
1114
|
+
// STATELESS: Second result should be ops command
|
|
1115
|
+
expect(collector.raw.length).toBe(2);
|
|
1116
|
+
expect(isOps(collector.raw[1])).toBe(true);
|
|
1117
|
+
|
|
1118
|
+
// Client applies update to get final state
|
|
1119
|
+
const currentState = collector.current as { posts: { id: string; title: string }[] };
|
|
1120
|
+
expect(currentState.posts).toHaveLength(3);
|
|
1121
|
+
expect(currentState.posts[2].title).toBe("New Post 3");
|
|
1030
1122
|
|
|
1031
1123
|
subscription.unsubscribe();
|
|
1032
1124
|
});
|
|
1033
1125
|
|
|
1034
|
-
it("
|
|
1126
|
+
it("stateless server forwards all emit commands (no deduplication)", async () => {
|
|
1127
|
+
// STATELESS: Server forwards all commands. Deduplication is client/plugin responsibility.
|
|
1035
1128
|
type EmitFn = (data: { id: string; name: string }) => void;
|
|
1036
1129
|
let capturedEmit: EmitFn | undefined;
|
|
1037
1130
|
|
|
@@ -1047,42 +1140,37 @@ describe("field resolvers", () => {
|
|
|
1047
1140
|
const testRouter = router({ liveQuery });
|
|
1048
1141
|
const app = createApp({ router: testRouter });
|
|
1049
1142
|
|
|
1050
|
-
const
|
|
1143
|
+
const collector = createResultsCollector();
|
|
1051
1144
|
const observable = app.execute({
|
|
1052
1145
|
path: "liveQuery",
|
|
1053
1146
|
input: { id: "1" },
|
|
1054
1147
|
});
|
|
1055
1148
|
|
|
1056
1149
|
const subscription = observable.subscribe({
|
|
1057
|
-
next: (value) =>
|
|
1150
|
+
next: (value) => collector.push(value as Message),
|
|
1058
1151
|
});
|
|
1059
1152
|
|
|
1060
1153
|
// Wait for initial result
|
|
1061
1154
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1062
|
-
expect(
|
|
1063
|
-
const firstResult =
|
|
1064
|
-
expect(firstResult
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1069
|
-
expect(results.length).toBe(1); // Still 1, not 2
|
|
1155
|
+
expect(collector.raw.length).toBe(1);
|
|
1156
|
+
const firstResult = collector.raw[0];
|
|
1157
|
+
expect(isSnapshot(firstResult)).toBe(true);
|
|
1158
|
+
if (isSnapshot(firstResult)) {
|
|
1159
|
+
expect(firstResult.data.name).toBe("Initial");
|
|
1160
|
+
}
|
|
1070
1161
|
|
|
1071
|
-
//
|
|
1162
|
+
// STATELESS: All emits are forwarded (no deduplication)
|
|
1072
1163
|
capturedEmit!({ id: "1", name: "Initial" });
|
|
1073
1164
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1074
|
-
expect(
|
|
1165
|
+
expect(collector.raw.length).toBe(2); // Command forwarded
|
|
1075
1166
|
|
|
1076
|
-
// Emit different value
|
|
1167
|
+
// Emit different value
|
|
1077
1168
|
capturedEmit!({ id: "1", name: "Changed" });
|
|
1078
1169
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1079
|
-
expect(
|
|
1080
|
-
expect((results[1] as { data: { name: string } }).data.name).toBe("Changed");
|
|
1170
|
+
expect(collector.raw.length).toBe(3); // Another command
|
|
1081
1171
|
|
|
1082
|
-
//
|
|
1083
|
-
|
|
1084
|
-
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1085
|
-
expect(results.length).toBe(2); // Still 2
|
|
1172
|
+
// Client applies all updates to get final state
|
|
1173
|
+
expect((collector.current as { name: string }).name).toBe("Changed");
|
|
1086
1174
|
|
|
1087
1175
|
subscription.unsubscribe();
|
|
1088
1176
|
});
|
|
@@ -1154,7 +1242,7 @@ describe("field resolvers", () => {
|
|
|
1154
1242
|
context: () => ({ db: mockDb }),
|
|
1155
1243
|
});
|
|
1156
1244
|
|
|
1157
|
-
const
|
|
1245
|
+
const collector = createResultsCollector();
|
|
1158
1246
|
const subscription = server
|
|
1159
1247
|
.execute({
|
|
1160
1248
|
path: "getAuthor",
|
|
@@ -1169,7 +1257,7 @@ describe("field resolvers", () => {
|
|
|
1169
1257
|
})
|
|
1170
1258
|
.subscribe({
|
|
1171
1259
|
next: (result) => {
|
|
1172
|
-
|
|
1260
|
+
collector.push(result as Message);
|
|
1173
1261
|
},
|
|
1174
1262
|
});
|
|
1175
1263
|
|
|
@@ -1177,13 +1265,16 @@ describe("field resolvers", () => {
|
|
|
1177
1265
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1178
1266
|
|
|
1179
1267
|
// Verify initial resolution
|
|
1180
|
-
expect(
|
|
1268
|
+
expect(collector.raw.length).toBe(1);
|
|
1181
1269
|
expect(resolveCallCount).toBe(1); // Resolver called once
|
|
1182
1270
|
expect(subscribeCallCount).toBe(1); // Subscriber called once
|
|
1183
1271
|
expect(capturedFieldEmit).toBeDefined(); // Emit captured from subscribe phase
|
|
1184
1272
|
|
|
1185
|
-
const initialResult =
|
|
1186
|
-
expect(initialResult
|
|
1273
|
+
const initialResult = collector.raw[0];
|
|
1274
|
+
expect(isSnapshot(initialResult)).toBe(true);
|
|
1275
|
+
if (isSnapshot(initialResult)) {
|
|
1276
|
+
expect((initialResult.data as { posts: { id: string }[] }).posts).toHaveLength(2);
|
|
1277
|
+
}
|
|
1187
1278
|
|
|
1188
1279
|
// Use field emit to push update (from subscribe phase)
|
|
1189
1280
|
const updatedPosts = [
|
|
@@ -1196,10 +1287,14 @@ describe("field resolvers", () => {
|
|
|
1196
1287
|
// Wait for update
|
|
1197
1288
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1198
1289
|
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
expect(
|
|
1202
|
-
|
|
1290
|
+
// STATELESS: Second result is ops command
|
|
1291
|
+
expect(collector.raw.length).toBe(2);
|
|
1292
|
+
expect(isOps(collector.raw[1])).toBe(true);
|
|
1293
|
+
|
|
1294
|
+
// Client applies update to get final state
|
|
1295
|
+
const currentState = collector.current as { posts: { id: string; title: string }[] };
|
|
1296
|
+
expect(currentState.posts).toHaveLength(3);
|
|
1297
|
+
expect(currentState.posts[2].title).toBe("New Post");
|
|
1203
1298
|
|
|
1204
1299
|
subscription.unsubscribe();
|
|
1205
1300
|
});
|
|
@@ -1224,8 +1319,10 @@ describe("observable behavior", () => {
|
|
|
1224
1319
|
}),
|
|
1225
1320
|
);
|
|
1226
1321
|
|
|
1227
|
-
expect(result
|
|
1228
|
-
|
|
1322
|
+
expect(isSnapshot(result)).toBe(true);
|
|
1323
|
+
if (isSnapshot(result)) {
|
|
1324
|
+
expect(result.data).toEqual({ id: "1", name: "Test" });
|
|
1325
|
+
}
|
|
1229
1326
|
});
|
|
1230
1327
|
|
|
1231
1328
|
it("keeps subscription open for potential emit", async () => {
|
|
@@ -1276,7 +1373,10 @@ describe("observable behavior", () => {
|
|
|
1276
1373
|
}),
|
|
1277
1374
|
);
|
|
1278
1375
|
|
|
1279
|
-
expect(result
|
|
1376
|
+
expect(isSnapshot(result)).toBe(true);
|
|
1377
|
+
if (isSnapshot(result)) {
|
|
1378
|
+
expect(result.data).toEqual({ id: "new", name: "Test" });
|
|
1379
|
+
}
|
|
1280
1380
|
});
|
|
1281
1381
|
|
|
1282
1382
|
it("can be unsubscribed", async () => {
|
|
@@ -1311,7 +1411,8 @@ describe("observable behavior", () => {
|
|
|
1311
1411
|
// =============================================================================
|
|
1312
1412
|
|
|
1313
1413
|
describe("emit backpressure", () => {
|
|
1314
|
-
it("handles rapid emit calls
|
|
1414
|
+
it("handles rapid emit calls (stateless - forwards all commands)", async () => {
|
|
1415
|
+
// STATELESS: Server forwards all emit commands synchronously
|
|
1315
1416
|
type EmitFn = (data: unknown) => void;
|
|
1316
1417
|
let capturedEmit: EmitFn | undefined;
|
|
1317
1418
|
|
|
@@ -1324,14 +1425,14 @@ describe("emit backpressure", () => {
|
|
|
1324
1425
|
|
|
1325
1426
|
const server = createApp({ queries: { liveQuery } });
|
|
1326
1427
|
|
|
1327
|
-
const
|
|
1428
|
+
const collector = createResultsCollector();
|
|
1328
1429
|
const subscription = server
|
|
1329
1430
|
.execute({
|
|
1330
1431
|
path: "liveQuery",
|
|
1331
1432
|
input: { id: "1" },
|
|
1332
1433
|
})
|
|
1333
1434
|
.subscribe({
|
|
1334
|
-
next: (value) =>
|
|
1435
|
+
next: (value) => collector.push(value as Message),
|
|
1335
1436
|
});
|
|
1336
1437
|
|
|
1337
1438
|
await new Promise((r) => setTimeout(r, 50));
|
|
@@ -1344,13 +1445,11 @@ describe("emit backpressure", () => {
|
|
|
1344
1445
|
// Wait for all emits to process
|
|
1345
1446
|
await new Promise((r) => setTimeout(r, 100));
|
|
1346
1447
|
|
|
1347
|
-
// Should
|
|
1348
|
-
|
|
1349
|
-
expect(results.length).toBeGreaterThan(1);
|
|
1448
|
+
// STATELESS: Should receive 1 initial + 10 updates (all forwarded)
|
|
1449
|
+
expect(collector.raw.length).toBe(11);
|
|
1350
1450
|
|
|
1351
|
-
//
|
|
1352
|
-
|
|
1353
|
-
expect(lastResult.data.count).toBe(10);
|
|
1451
|
+
// Final state after applying all updates
|
|
1452
|
+
expect((collector.current as { count: number }).count).toBe(10);
|
|
1354
1453
|
|
|
1355
1454
|
subscription.unsubscribe();
|
|
1356
1455
|
});
|
|
@@ -1377,8 +1476,10 @@ describe("observable error handling", () => {
|
|
|
1377
1476
|
}),
|
|
1378
1477
|
);
|
|
1379
1478
|
|
|
1380
|
-
expect(result
|
|
1381
|
-
|
|
1479
|
+
expect(isError(result)).toBe(true);
|
|
1480
|
+
if (isError(result)) {
|
|
1481
|
+
expect(result.error).toBe("Test error");
|
|
1482
|
+
}
|
|
1382
1483
|
});
|
|
1383
1484
|
|
|
1384
1485
|
it("handles async resolver errors", async () => {
|
|
@@ -1398,8 +1499,10 @@ describe("observable error handling", () => {
|
|
|
1398
1499
|
}),
|
|
1399
1500
|
);
|
|
1400
1501
|
|
|
1401
|
-
expect(result
|
|
1402
|
-
|
|
1502
|
+
expect(isError(result)).toBe(true);
|
|
1503
|
+
if (isError(result)) {
|
|
1504
|
+
expect(result.error).toBe("Async error");
|
|
1505
|
+
}
|
|
1403
1506
|
});
|
|
1404
1507
|
});
|
|
1405
1508
|
|
|
@@ -1747,21 +1850,25 @@ describe("operation-level .resolve().subscribe() (LiveQueryDef)", () => {
|
|
|
1747
1850
|
|
|
1748
1851
|
const server = createApp({ queries: { liveUser } });
|
|
1749
1852
|
|
|
1750
|
-
const results:
|
|
1853
|
+
const results: Message[] = [];
|
|
1751
1854
|
const subscription = server
|
|
1752
1855
|
.execute({
|
|
1753
1856
|
path: "liveUser",
|
|
1754
1857
|
input: { id: "1" },
|
|
1755
1858
|
})
|
|
1756
1859
|
.subscribe({
|
|
1757
|
-
next: (result) => results.push(result),
|
|
1860
|
+
next: (result) => results.push(result as Message),
|
|
1758
1861
|
});
|
|
1759
1862
|
|
|
1760
1863
|
// Wait for initial result and subscriber setup
|
|
1761
1864
|
await new Promise((r) => setTimeout(r, 50));
|
|
1762
1865
|
|
|
1763
1866
|
expect(results.length).toBe(1);
|
|
1764
|
-
|
|
1867
|
+
const firstResult = results[0];
|
|
1868
|
+
expect(isSnapshot(firstResult)).toBe(true);
|
|
1869
|
+
if (isSnapshot(firstResult)) {
|
|
1870
|
+
expect(firstResult.data.name).toBe("Initial");
|
|
1871
|
+
}
|
|
1765
1872
|
expect(subscriberCalled).toBe(true);
|
|
1766
1873
|
expect(capturedEmit).toBeDefined();
|
|
1767
1874
|
expect(capturedOnCleanup).toBeDefined();
|
|
@@ -1769,7 +1876,7 @@ describe("operation-level .resolve().subscribe() (LiveQueryDef)", () => {
|
|
|
1769
1876
|
subscription.unsubscribe();
|
|
1770
1877
|
});
|
|
1771
1878
|
|
|
1772
|
-
it("emits updates from subscriber emit function", async () => {
|
|
1879
|
+
it("emits updates from subscriber emit function (stateless)", async () => {
|
|
1773
1880
|
let capturedEmit: ((value: { id: string; name: string }) => void) | undefined;
|
|
1774
1881
|
|
|
1775
1882
|
const liveUser = query()
|
|
@@ -1781,34 +1888,39 @@ describe("operation-level .resolve().subscribe() (LiveQueryDef)", () => {
|
|
|
1781
1888
|
|
|
1782
1889
|
const server = createApp({ queries: { liveUser } });
|
|
1783
1890
|
|
|
1784
|
-
const
|
|
1891
|
+
const collector = createResultsCollector();
|
|
1785
1892
|
const subscription = server
|
|
1786
1893
|
.execute({
|
|
1787
1894
|
path: "liveUser",
|
|
1788
1895
|
input: { id: "1" },
|
|
1789
1896
|
})
|
|
1790
1897
|
.subscribe({
|
|
1791
|
-
next: (result) =>
|
|
1898
|
+
next: (result) => collector.push(result as Message),
|
|
1792
1899
|
});
|
|
1793
1900
|
|
|
1794
1901
|
// Wait for initial result
|
|
1795
1902
|
await new Promise((r) => setTimeout(r, 50));
|
|
1796
|
-
expect(
|
|
1797
|
-
|
|
1903
|
+
expect(collector.raw.length).toBe(1);
|
|
1904
|
+
const firstResult = collector.raw[0];
|
|
1905
|
+
expect(isSnapshot(firstResult)).toBe(true);
|
|
1906
|
+
if (isSnapshot(firstResult)) {
|
|
1907
|
+
expect(firstResult.data.name).toBe("Initial");
|
|
1908
|
+
}
|
|
1798
1909
|
|
|
1799
|
-
// Emit update via subscriber
|
|
1910
|
+
// Emit update via subscriber - STATELESS: sends ops command
|
|
1800
1911
|
capturedEmit!({ id: "1", name: "Updated" });
|
|
1801
1912
|
await new Promise((r) => setTimeout(r, 50));
|
|
1802
1913
|
|
|
1803
|
-
expect(
|
|
1804
|
-
expect((
|
|
1914
|
+
expect(collector.raw.length).toBe(2);
|
|
1915
|
+
expect(isOps(collector.raw[1])).toBe(true); // Ops command
|
|
1916
|
+
expect((collector.current as { name: string }).name).toBe("Updated");
|
|
1805
1917
|
|
|
1806
1918
|
// Emit another update
|
|
1807
1919
|
capturedEmit!({ id: "1", name: "Updated Again" });
|
|
1808
1920
|
await new Promise((r) => setTimeout(r, 50));
|
|
1809
1921
|
|
|
1810
|
-
expect(
|
|
1811
|
-
expect((
|
|
1922
|
+
expect(collector.raw.length).toBe(3);
|
|
1923
|
+
expect((collector.current as { name: string }).name).toBe("Updated Again");
|
|
1812
1924
|
|
|
1813
1925
|
subscription.unsubscribe();
|
|
1814
1926
|
});
|
|
@@ -1892,8 +2004,8 @@ describe("operation-level .resolve().subscribe() (LiveQueryDef)", () => {
|
|
|
1892
2004
|
|
|
1893
2005
|
const server = createApp({ queries: { liveUser } });
|
|
1894
2006
|
|
|
1895
|
-
const results:
|
|
1896
|
-
const errors:
|
|
2007
|
+
const results: Message[] = [];
|
|
2008
|
+
const errors: string[] = [];
|
|
1897
2009
|
|
|
1898
2010
|
const subscription = server
|
|
1899
2011
|
.execute({
|
|
@@ -1902,10 +2014,11 @@ describe("operation-level .resolve().subscribe() (LiveQueryDef)", () => {
|
|
|
1902
2014
|
})
|
|
1903
2015
|
.subscribe({
|
|
1904
2016
|
next: (result) => {
|
|
1905
|
-
|
|
1906
|
-
|
|
2017
|
+
const msg = result as Message;
|
|
2018
|
+
if (isError(msg)) {
|
|
2019
|
+
errors.push(msg.error);
|
|
1907
2020
|
} else {
|
|
1908
|
-
results.push(
|
|
2021
|
+
results.push(msg);
|
|
1909
2022
|
}
|
|
1910
2023
|
},
|
|
1911
2024
|
});
|
|
@@ -1917,12 +2030,12 @@ describe("operation-level .resolve().subscribe() (LiveQueryDef)", () => {
|
|
|
1917
2030
|
expect(results.length).toBe(1);
|
|
1918
2031
|
// Error from subscriber should be reported
|
|
1919
2032
|
expect(errors.length).toBe(1);
|
|
1920
|
-
expect(errors[0]
|
|
2033
|
+
expect(errors[0]).toBe("Subscriber error");
|
|
1921
2034
|
|
|
1922
2035
|
subscription.unsubscribe();
|
|
1923
2036
|
});
|
|
1924
2037
|
|
|
1925
|
-
it("works with router-based operations", async () => {
|
|
2038
|
+
it("works with router-based operations (stateless)", async () => {
|
|
1926
2039
|
let capturedEmit: ((value: { id: string; count: number }) => void) | undefined;
|
|
1927
2040
|
|
|
1928
2041
|
const liveCounter = query()
|
|
@@ -1940,36 +2053,40 @@ describe("operation-level .resolve().subscribe() (LiveQueryDef)", () => {
|
|
|
1940
2053
|
|
|
1941
2054
|
const server = createApp({ router: appRouter });
|
|
1942
2055
|
|
|
1943
|
-
const
|
|
2056
|
+
const collector = createResultsCollector();
|
|
1944
2057
|
const subscription = server
|
|
1945
2058
|
.execute({
|
|
1946
2059
|
path: "counter.live",
|
|
1947
2060
|
input: { id: "c1" },
|
|
1948
2061
|
})
|
|
1949
2062
|
.subscribe({
|
|
1950
|
-
next: (result) =>
|
|
2063
|
+
next: (result) => collector.push(result as Message),
|
|
1951
2064
|
});
|
|
1952
2065
|
|
|
1953
2066
|
// Wait for initial result
|
|
1954
2067
|
await new Promise((r) => setTimeout(r, 50));
|
|
1955
|
-
expect(
|
|
1956
|
-
|
|
2068
|
+
expect(collector.raw.length).toBe(1);
|
|
2069
|
+
const firstResult = collector.raw[0];
|
|
2070
|
+
expect(isSnapshot(firstResult)).toBe(true);
|
|
2071
|
+
if (isSnapshot(firstResult)) {
|
|
2072
|
+
expect(firstResult.data.count).toBe(0);
|
|
2073
|
+
}
|
|
1957
2074
|
|
|
1958
|
-
// Emit updates
|
|
2075
|
+
// Emit updates - STATELESS: sends ops commands
|
|
1959
2076
|
capturedEmit!({ id: "c1", count: 1 });
|
|
1960
2077
|
await new Promise((r) => setTimeout(r, 50));
|
|
1961
|
-
expect(
|
|
1962
|
-
expect((
|
|
2078
|
+
expect(collector.raw.length).toBe(2);
|
|
2079
|
+
expect((collector.current as { count: number }).count).toBe(1);
|
|
1963
2080
|
|
|
1964
2081
|
capturedEmit!({ id: "c1", count: 5 });
|
|
1965
2082
|
await new Promise((r) => setTimeout(r, 50));
|
|
1966
|
-
expect(
|
|
1967
|
-
expect((
|
|
2083
|
+
expect(collector.raw.length).toBe(3);
|
|
2084
|
+
expect((collector.current as { count: number }).count).toBe(5);
|
|
1968
2085
|
|
|
1969
2086
|
subscription.unsubscribe();
|
|
1970
2087
|
});
|
|
1971
2088
|
|
|
1972
|
-
it("supports emit.merge for partial updates", async () => {
|
|
2089
|
+
it("supports emit.merge for partial updates (stateless)", async () => {
|
|
1973
2090
|
type EmitFn = ((value: unknown) => void) & { merge: (partial: unknown) => void };
|
|
1974
2091
|
let capturedEmit: EmitFn | undefined;
|
|
1975
2092
|
|
|
@@ -1982,36 +2099,42 @@ describe("operation-level .resolve().subscribe() (LiveQueryDef)", () => {
|
|
|
1982
2099
|
|
|
1983
2100
|
const server = createApp({ queries: { liveUser } });
|
|
1984
2101
|
|
|
1985
|
-
const
|
|
2102
|
+
const collector = createResultsCollector();
|
|
1986
2103
|
const subscription = server
|
|
1987
2104
|
.execute({
|
|
1988
2105
|
path: "liveUser",
|
|
1989
2106
|
input: { id: "1" },
|
|
1990
2107
|
})
|
|
1991
2108
|
.subscribe({
|
|
1992
|
-
next: (result) =>
|
|
2109
|
+
next: (result) => collector.push(result as Message),
|
|
1993
2110
|
});
|
|
1994
2111
|
|
|
1995
2112
|
// Wait for initial result
|
|
1996
2113
|
await new Promise((r) => setTimeout(r, 50));
|
|
1997
|
-
expect(
|
|
1998
|
-
const
|
|
1999
|
-
expect(
|
|
2000
|
-
|
|
2114
|
+
expect(collector.raw.length).toBe(1);
|
|
2115
|
+
const initialResult = collector.raw[0];
|
|
2116
|
+
expect(isSnapshot(initialResult)).toBe(true);
|
|
2117
|
+
if (isSnapshot(initialResult)) {
|
|
2118
|
+
expect(initialResult.data.name).toBe("Initial");
|
|
2119
|
+
expect(initialResult.data.status).toBe("offline");
|
|
2120
|
+
}
|
|
2001
2121
|
|
|
2002
|
-
// Use merge for partial update
|
|
2122
|
+
// Use merge for partial update - STATELESS: sends ops command
|
|
2003
2123
|
capturedEmit!.merge({ status: "online" });
|
|
2004
2124
|
await new Promise((r) => setTimeout(r, 50));
|
|
2005
2125
|
|
|
2006
|
-
expect(
|
|
2007
|
-
|
|
2126
|
+
expect(collector.raw.length).toBe(2);
|
|
2127
|
+
expect(isOps(collector.raw[1])).toBe(true); // Ops command
|
|
2128
|
+
const updated = collector.current as { name: string; status: string };
|
|
2008
2129
|
expect(updated.name).toBe("Initial"); // Preserved
|
|
2009
2130
|
expect(updated.status).toBe("online"); // Updated
|
|
2010
2131
|
|
|
2011
2132
|
subscription.unsubscribe();
|
|
2012
2133
|
});
|
|
2013
2134
|
|
|
2014
|
-
it("
|
|
2135
|
+
it("stateless server forwards all emits (deduplication is client responsibility)", async () => {
|
|
2136
|
+
// STATELESS: Server forwards all emit commands without deduplication.
|
|
2137
|
+
// Deduplication can be done by client or via optional plugins.
|
|
2015
2138
|
let capturedEmit: ((value: { id: string; name: string }) => void) | undefined;
|
|
2016
2139
|
|
|
2017
2140
|
const liveUser = query()
|
|
@@ -2023,34 +2146,37 @@ describe("operation-level .resolve().subscribe() (LiveQueryDef)", () => {
|
|
|
2023
2146
|
|
|
2024
2147
|
const server = createApp({ queries: { liveUser } });
|
|
2025
2148
|
|
|
2026
|
-
const
|
|
2149
|
+
const collector = createResultsCollector();
|
|
2027
2150
|
const subscription = server
|
|
2028
2151
|
.execute({
|
|
2029
2152
|
path: "liveUser",
|
|
2030
2153
|
input: { id: "1" },
|
|
2031
2154
|
})
|
|
2032
2155
|
.subscribe({
|
|
2033
|
-
next: (result) =>
|
|
2156
|
+
next: (result) => collector.push(result as Message),
|
|
2034
2157
|
});
|
|
2035
2158
|
|
|
2036
2159
|
// Wait for initial result
|
|
2037
2160
|
await new Promise((r) => setTimeout(r, 50));
|
|
2038
|
-
expect(
|
|
2161
|
+
expect(collector.raw.length).toBe(1);
|
|
2039
2162
|
|
|
2040
|
-
//
|
|
2163
|
+
// STATELESS: All emits are forwarded (no server-side deduplication)
|
|
2041
2164
|
capturedEmit!({ id: "1", name: "Initial" });
|
|
2042
2165
|
await new Promise((r) => setTimeout(r, 50));
|
|
2043
|
-
expect(
|
|
2166
|
+
expect(collector.raw.length).toBe(2); // Forwarded, not deduplicated
|
|
2044
2167
|
|
|
2045
2168
|
// Emit different value
|
|
2046
2169
|
capturedEmit!({ id: "1", name: "Changed" });
|
|
2047
2170
|
await new Promise((r) => setTimeout(r, 50));
|
|
2048
|
-
expect(
|
|
2171
|
+
expect(collector.raw.length).toBe(3);
|
|
2049
2172
|
|
|
2050
|
-
// Emit same
|
|
2173
|
+
// Emit same value again - still forwarded
|
|
2051
2174
|
capturedEmit!({ id: "1", name: "Changed" });
|
|
2052
2175
|
await new Promise((r) => setTimeout(r, 50));
|
|
2053
|
-
expect(
|
|
2176
|
+
expect(collector.raw.length).toBe(4); // All forwarded
|
|
2177
|
+
|
|
2178
|
+
// Final state is correct after applying all updates
|
|
2179
|
+
expect((collector.current as { name: string }).name).toBe("Changed");
|
|
2054
2180
|
|
|
2055
2181
|
subscription.unsubscribe();
|
|
2056
2182
|
});
|
|
@@ -2143,14 +2269,14 @@ describe("scalar field subscription with emit.delta()", () => {
|
|
|
2143
2269
|
resolvers: [userResolver],
|
|
2144
2270
|
});
|
|
2145
2271
|
|
|
2146
|
-
const results:
|
|
2272
|
+
const results: Message[] = [];
|
|
2147
2273
|
const subscription = server
|
|
2148
2274
|
.execute({
|
|
2149
2275
|
path: "getUserWithBio",
|
|
2150
2276
|
input: { id: "1" },
|
|
2151
2277
|
})
|
|
2152
2278
|
.subscribe({
|
|
2153
|
-
next: (result) => results.push(result),
|
|
2279
|
+
next: (result) => results.push(result as Message),
|
|
2154
2280
|
});
|
|
2155
2281
|
|
|
2156
2282
|
// Wait for subscription setup
|
|
@@ -2158,7 +2284,11 @@ describe("scalar field subscription with emit.delta()", () => {
|
|
|
2158
2284
|
|
|
2159
2285
|
// Initial result
|
|
2160
2286
|
expect(results.length).toBe(1);
|
|
2161
|
-
|
|
2287
|
+
const firstResult = results[0];
|
|
2288
|
+
expect(isSnapshot(firstResult)).toBe(true);
|
|
2289
|
+
if (isSnapshot(firstResult)) {
|
|
2290
|
+
expect((firstResult.data as { bio: string }).bio).toBe("Initial bio");
|
|
2291
|
+
}
|
|
2162
2292
|
|
|
2163
2293
|
// Check that emit has delta method (EmitScalar)
|
|
2164
2294
|
expect(capturedEmit).toBeDefined();
|
|
@@ -2167,7 +2297,7 @@ describe("scalar field subscription with emit.delta()", () => {
|
|
|
2167
2297
|
subscription.unsubscribe();
|
|
2168
2298
|
});
|
|
2169
2299
|
|
|
2170
|
-
it("emit.delta()
|
|
2300
|
+
it("emit.delta() sends delta command (stateless)", async () => {
|
|
2171
2301
|
// UserWithContent - content field resolved by field resolver
|
|
2172
2302
|
const UserWithContent = entity("UserWithContent", {
|
|
2173
2303
|
id: t.id(),
|
|
@@ -2203,32 +2333,40 @@ describe("scalar field subscription with emit.delta()", () => {
|
|
|
2203
2333
|
resolvers: [userResolver],
|
|
2204
2334
|
});
|
|
2205
2335
|
|
|
2206
|
-
const
|
|
2336
|
+
const collector = createResultsCollector();
|
|
2207
2337
|
const subscription = server
|
|
2208
2338
|
.execute({
|
|
2209
2339
|
path: "getUserWithContent",
|
|
2210
2340
|
input: { id: "1" },
|
|
2211
2341
|
})
|
|
2212
2342
|
.subscribe({
|
|
2213
|
-
next: (result) =>
|
|
2343
|
+
next: (result) => collector.push(result as Message),
|
|
2214
2344
|
});
|
|
2215
2345
|
|
|
2216
2346
|
// Wait for subscription setup
|
|
2217
2347
|
await new Promise((r) => setTimeout(r, 50));
|
|
2218
2348
|
|
|
2219
2349
|
// Initial result
|
|
2220
|
-
expect(
|
|
2221
|
-
|
|
2350
|
+
expect(collector.raw.length).toBe(1);
|
|
2351
|
+
const initialResult = collector.raw[0];
|
|
2352
|
+
expect(isSnapshot(initialResult)).toBe(true);
|
|
2353
|
+
if (isSnapshot(initialResult)) {
|
|
2354
|
+
expect((initialResult.data as { content: string }).content).toBe("Hello");
|
|
2355
|
+
}
|
|
2222
2356
|
|
|
2223
|
-
// Use emit.delta() to append text
|
|
2357
|
+
// Use emit.delta() to append text - STATELESS: sends ops command
|
|
2224
2358
|
capturedEmit?.delta?.([{ position: Infinity, insert: " World" }]);
|
|
2225
2359
|
|
|
2226
2360
|
// Wait for update
|
|
2227
2361
|
await new Promise((r) => setTimeout(r, 50));
|
|
2228
2362
|
|
|
2229
|
-
//
|
|
2230
|
-
expect(
|
|
2231
|
-
expect((
|
|
2363
|
+
// STATELESS: Received ops command
|
|
2364
|
+
expect(collector.raw.length).toBe(2);
|
|
2365
|
+
expect(isOps(collector.raw[1])).toBe(true);
|
|
2366
|
+
|
|
2367
|
+
// Client applies delta to get final state
|
|
2368
|
+
const currentState = collector.current as { content: string };
|
|
2369
|
+
expect(currentState.content).toBe("Hello World");
|
|
2232
2370
|
|
|
2233
2371
|
subscription.unsubscribe();
|
|
2234
2372
|
});
|