@sylphx/lens-server 1.5.2 → 1.5.6
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 +8 -8
- package/dist/index.js +70 -2
- package/package.json +3 -3
- package/src/index.ts +3 -3
- package/src/server/create.test.ts +21 -5
- package/src/server/create.ts +135 -13
- package/src/sse/handler.ts +1 -1
- package/src/state/graph-state-manager.test.ts +24 -24
- package/src/state/index.ts +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -238,21 +238,21 @@ interface LensServerConfig<
|
|
|
238
238
|
TRouter extends RouterDef = RouterDef
|
|
239
239
|
> {
|
|
240
240
|
/** Entity definitions */
|
|
241
|
-
entities?: EntitiesMap;
|
|
241
|
+
entities?: EntitiesMap | undefined;
|
|
242
242
|
/** Router definition (namespaced operations) - context type is inferred */
|
|
243
|
-
router?: TRouter;
|
|
243
|
+
router?: TRouter | undefined;
|
|
244
244
|
/** Query definitions (flat, legacy) */
|
|
245
|
-
queries?: QueriesMap;
|
|
245
|
+
queries?: QueriesMap | undefined;
|
|
246
246
|
/** Mutation definitions (flat, legacy) */
|
|
247
|
-
mutations?: MutationsMap;
|
|
247
|
+
mutations?: MutationsMap | undefined;
|
|
248
248
|
/** Field resolvers array (use lens() factory to create) */
|
|
249
|
-
resolvers?: Resolvers;
|
|
249
|
+
resolvers?: Resolvers | undefined;
|
|
250
250
|
/** Logger for server messages (default: silent) */
|
|
251
|
-
logger?: LensLogger;
|
|
251
|
+
logger?: LensLogger | undefined;
|
|
252
252
|
/** Context factory - must return the context type expected by the router */
|
|
253
|
-
context?: (req?: unknown) => TContext | Promise<TContext
|
|
253
|
+
context?: ((req?: unknown) => TContext | Promise<TContext>) | undefined;
|
|
254
254
|
/** Server version */
|
|
255
|
-
version?: string;
|
|
255
|
+
version?: string | undefined;
|
|
256
256
|
}
|
|
257
257
|
/** Server metadata for transport handshake */
|
|
258
258
|
interface ServerMetadata {
|
package/dist/index.js
CHANGED
|
@@ -11,7 +11,9 @@ import {
|
|
|
11
11
|
createEmit,
|
|
12
12
|
createUpdate as createUpdate2,
|
|
13
13
|
flattenRouter,
|
|
14
|
+
isEntityDef,
|
|
14
15
|
isMutationDef,
|
|
16
|
+
isPipeline,
|
|
15
17
|
isQueryDef,
|
|
16
18
|
runWithContext,
|
|
17
19
|
toResolverMap
|
|
@@ -457,6 +459,70 @@ function createGraphStateManager(config) {
|
|
|
457
459
|
}
|
|
458
460
|
|
|
459
461
|
// src/server/create.ts
|
|
462
|
+
function getEntityTypeName(returnSpec) {
|
|
463
|
+
if (!returnSpec)
|
|
464
|
+
return;
|
|
465
|
+
if (isEntityDef(returnSpec)) {
|
|
466
|
+
return returnSpec._name;
|
|
467
|
+
}
|
|
468
|
+
if (Array.isArray(returnSpec) && returnSpec.length === 1 && isEntityDef(returnSpec[0])) {
|
|
469
|
+
return returnSpec[0]._name;
|
|
470
|
+
}
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
function getInputFields(inputSchema) {
|
|
474
|
+
if (!inputSchema?.shape)
|
|
475
|
+
return [];
|
|
476
|
+
return Object.keys(inputSchema.shape);
|
|
477
|
+
}
|
|
478
|
+
function sugarToPipeline(optimistic, entityType, inputFields) {
|
|
479
|
+
if (isPipeline(optimistic)) {
|
|
480
|
+
return optimistic;
|
|
481
|
+
}
|
|
482
|
+
if (!entityType) {
|
|
483
|
+
return optimistic;
|
|
484
|
+
}
|
|
485
|
+
if (optimistic === "merge") {
|
|
486
|
+
const args = { type: entityType };
|
|
487
|
+
for (const field of inputFields) {
|
|
488
|
+
args[field] = { $input: field };
|
|
489
|
+
}
|
|
490
|
+
return {
|
|
491
|
+
$pipe: [{ $do: "entity.update", $with: args }]
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
if (optimistic === "create") {
|
|
495
|
+
const args = { type: entityType, id: { $temp: true } };
|
|
496
|
+
for (const field of inputFields) {
|
|
497
|
+
if (field !== "id") {
|
|
498
|
+
args[field] = { $input: field };
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return {
|
|
502
|
+
$pipe: [{ $do: "entity.create", $with: args }]
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
if (optimistic === "delete") {
|
|
506
|
+
return {
|
|
507
|
+
$pipe: [{ $do: "entity.delete", $with: { type: entityType, id: { $input: "id" } } }]
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
if (typeof optimistic === "object" && optimistic !== null && "merge" in optimistic && typeof optimistic["merge"] === "object") {
|
|
511
|
+
const extra = optimistic["merge"];
|
|
512
|
+
const args = { type: entityType };
|
|
513
|
+
for (const field of inputFields) {
|
|
514
|
+
args[field] = { $input: field };
|
|
515
|
+
}
|
|
516
|
+
for (const [key, value] of Object.entries(extra)) {
|
|
517
|
+
args[key] = value;
|
|
518
|
+
}
|
|
519
|
+
return {
|
|
520
|
+
$pipe: [{ $do: "entity.update", $with: args }]
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
return optimistic;
|
|
524
|
+
}
|
|
525
|
+
|
|
460
526
|
class DataLoader {
|
|
461
527
|
batchFn;
|
|
462
528
|
batch = new Map;
|
|
@@ -627,7 +693,9 @@ class LensServerImpl {
|
|
|
627
693
|
for (const [name, def] of Object.entries(this.mutations)) {
|
|
628
694
|
const meta = { type: "mutation" };
|
|
629
695
|
if (def._optimistic) {
|
|
630
|
-
|
|
696
|
+
const entityType = getEntityTypeName(def._output);
|
|
697
|
+
const inputFields = getInputFields(def._input);
|
|
698
|
+
meta.optimistic = sugarToPipeline(def._optimistic, entityType, inputFields);
|
|
631
699
|
}
|
|
632
700
|
setNested(name, meta);
|
|
633
701
|
}
|
|
@@ -1141,7 +1209,7 @@ class LensServerImpl {
|
|
|
1141
1209
|
const result = {};
|
|
1142
1210
|
const obj = data;
|
|
1143
1211
|
if ("id" in obj) {
|
|
1144
|
-
result
|
|
1212
|
+
result["id"] = obj["id"];
|
|
1145
1213
|
}
|
|
1146
1214
|
if (Array.isArray(fields)) {
|
|
1147
1215
|
for (const field of fields) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sylphx/lens-server",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.6",
|
|
4
4
|
"description": "Server runtime for Lens API framework",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"build": "bunup",
|
|
16
16
|
"typecheck": "tsc --noEmit",
|
|
17
17
|
"test": "bun test",
|
|
18
|
-
"prepack": "bun run build"
|
|
18
|
+
"prepack": "[ -d dist ] || bun run build"
|
|
19
19
|
},
|
|
20
20
|
"files": [
|
|
21
21
|
"dist",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"author": "SylphxAI",
|
|
31
31
|
"license": "MIT",
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@sylphx/lens-core": "^1.
|
|
33
|
+
"@sylphx/lens-core": "^1.15.0"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"typescript": "^5.9.3",
|
package/src/index.ts
CHANGED
|
@@ -50,7 +50,7 @@ export {
|
|
|
50
50
|
// Metadata types (for transport handshake)
|
|
51
51
|
type ServerMetadata,
|
|
52
52
|
type WebSocketLike,
|
|
53
|
-
} from "./server/create";
|
|
53
|
+
} from "./server/create.js";
|
|
54
54
|
|
|
55
55
|
// =============================================================================
|
|
56
56
|
// State Management
|
|
@@ -68,7 +68,7 @@ export {
|
|
|
68
68
|
type StateFullMessage,
|
|
69
69
|
type StateUpdateMessage,
|
|
70
70
|
type Subscription,
|
|
71
|
-
} from "./state";
|
|
71
|
+
} from "./state/index.js";
|
|
72
72
|
|
|
73
73
|
// =============================================================================
|
|
74
74
|
// SSE Transport Adapter
|
|
@@ -82,4 +82,4 @@ export {
|
|
|
82
82
|
SSEHandler,
|
|
83
83
|
// Types
|
|
84
84
|
type SSEHandlerConfig,
|
|
85
|
-
} from "./sse/handler";
|
|
85
|
+
} from "./sse/handler.js";
|
|
@@ -950,8 +950,10 @@ describe("getMetadata", () => {
|
|
|
950
950
|
expect(metadata.version).toBe("1.2.3");
|
|
951
951
|
expect(metadata.operations).toBeDefined();
|
|
952
952
|
expect(metadata.operations.getUser).toEqual({ type: "query" });
|
|
953
|
-
// createUser auto-derives optimistic "create" from naming convention
|
|
954
|
-
expect(metadata.operations.createUser).
|
|
953
|
+
// createUser auto-derives optimistic "create" from naming convention (converted to Pipeline)
|
|
954
|
+
expect((metadata.operations.createUser as any).type).toBe("mutation");
|
|
955
|
+
expect((metadata.operations.createUser as any).optimistic.$pipe).toBeDefined();
|
|
956
|
+
expect((metadata.operations.createUser as any).optimistic.$pipe[0].$do).toBe("entity.create");
|
|
955
957
|
});
|
|
956
958
|
|
|
957
959
|
it("builds nested operations map from namespaced routes", () => {
|
|
@@ -973,8 +975,10 @@ describe("getMetadata", () => {
|
|
|
973
975
|
const metadata = server.getMetadata();
|
|
974
976
|
expect(metadata.operations.user).toBeDefined();
|
|
975
977
|
expect((metadata.operations.user as any).get).toEqual({ type: "query" });
|
|
976
|
-
// Auto-derives optimistic "create" from naming convention
|
|
977
|
-
expect((metadata.operations.user as any).create).
|
|
978
|
+
// Auto-derives optimistic "create" from naming convention (converted to Pipeline)
|
|
979
|
+
expect((metadata.operations.user as any).create.type).toBe("mutation");
|
|
980
|
+
expect((metadata.operations.user as any).create.optimistic.$pipe).toBeDefined();
|
|
981
|
+
expect((metadata.operations.user as any).create.optimistic.$pipe[0].$do).toBe("entity.create");
|
|
978
982
|
});
|
|
979
983
|
|
|
980
984
|
it("includes optimistic config in mutation metadata", () => {
|
|
@@ -990,9 +994,21 @@ describe("getMetadata", () => {
|
|
|
990
994
|
});
|
|
991
995
|
|
|
992
996
|
const metadata = server.getMetadata();
|
|
997
|
+
// Sugar "merge" is converted to Reify Pipeline
|
|
993
998
|
expect(metadata.operations.updateUser).toEqual({
|
|
994
999
|
type: "mutation",
|
|
995
|
-
optimistic:
|
|
1000
|
+
optimistic: {
|
|
1001
|
+
$pipe: [
|
|
1002
|
+
{
|
|
1003
|
+
$do: "entity.update",
|
|
1004
|
+
$with: {
|
|
1005
|
+
type: "User",
|
|
1006
|
+
id: { $input: "id" },
|
|
1007
|
+
name: { $input: "name" },
|
|
1008
|
+
},
|
|
1009
|
+
},
|
|
1010
|
+
],
|
|
1011
|
+
},
|
|
996
1012
|
});
|
|
997
1013
|
});
|
|
998
1014
|
|
package/src/server/create.ts
CHANGED
|
@@ -18,12 +18,16 @@ import {
|
|
|
18
18
|
type FieldType,
|
|
19
19
|
flattenRouter,
|
|
20
20
|
type InferRouterContext,
|
|
21
|
+
isEntityDef,
|
|
21
22
|
isMutationDef,
|
|
23
|
+
isPipeline,
|
|
22
24
|
isQueryDef,
|
|
23
25
|
type MutationDef,
|
|
26
|
+
type Pipeline,
|
|
24
27
|
type QueryDef,
|
|
25
28
|
type ResolverDef,
|
|
26
29
|
type Resolvers,
|
|
30
|
+
type ReturnSpec,
|
|
27
31
|
type RouterDef,
|
|
28
32
|
runWithContext,
|
|
29
33
|
toResolverMap,
|
|
@@ -35,7 +39,7 @@ export interface SelectionObject {
|
|
|
35
39
|
[key: string]: boolean | SelectionObject | { select: SelectionObject };
|
|
36
40
|
}
|
|
37
41
|
|
|
38
|
-
import { GraphStateManager } from "../state/graph-state-manager";
|
|
42
|
+
import { GraphStateManager } from "../state/graph-state-manager.js";
|
|
39
43
|
|
|
40
44
|
// =============================================================================
|
|
41
45
|
// Types
|
|
@@ -77,21 +81,21 @@ export interface LensServerConfig<
|
|
|
77
81
|
TRouter extends RouterDef = RouterDef,
|
|
78
82
|
> {
|
|
79
83
|
/** Entity definitions */
|
|
80
|
-
entities?: EntitiesMap;
|
|
84
|
+
entities?: EntitiesMap | undefined;
|
|
81
85
|
/** Router definition (namespaced operations) - context type is inferred */
|
|
82
|
-
router?: TRouter;
|
|
86
|
+
router?: TRouter | undefined;
|
|
83
87
|
/** Query definitions (flat, legacy) */
|
|
84
|
-
queries?: QueriesMap;
|
|
88
|
+
queries?: QueriesMap | undefined;
|
|
85
89
|
/** Mutation definitions (flat, legacy) */
|
|
86
|
-
mutations?: MutationsMap;
|
|
90
|
+
mutations?: MutationsMap | undefined;
|
|
87
91
|
/** Field resolvers array (use lens() factory to create) */
|
|
88
|
-
resolvers?: Resolvers;
|
|
92
|
+
resolvers?: Resolvers | undefined;
|
|
89
93
|
/** Logger for server messages (default: silent) */
|
|
90
|
-
logger?: LensLogger;
|
|
94
|
+
logger?: LensLogger | undefined;
|
|
91
95
|
/** Context factory - must return the context type expected by the router */
|
|
92
|
-
context?: (req?: unknown) => TContext | Promise<TContext
|
|
96
|
+
context?: ((req?: unknown) => TContext | Promise<TContext>) | undefined;
|
|
93
97
|
/** Server version */
|
|
94
|
-
version?: string;
|
|
98
|
+
version?: string | undefined;
|
|
95
99
|
}
|
|
96
100
|
|
|
97
101
|
/** Server metadata for transport handshake */
|
|
@@ -149,6 +153,121 @@ export interface WebSocketLike {
|
|
|
149
153
|
onerror?: ((error: unknown) => void) | null;
|
|
150
154
|
}
|
|
151
155
|
|
|
156
|
+
// =============================================================================
|
|
157
|
+
// Sugar to Reify Pipeline Conversion
|
|
158
|
+
// =============================================================================
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Extract entity type name from return spec.
|
|
162
|
+
* Returns undefined if not an entity.
|
|
163
|
+
*/
|
|
164
|
+
function getEntityTypeName(returnSpec: ReturnSpec | undefined): string | undefined {
|
|
165
|
+
if (!returnSpec) return undefined;
|
|
166
|
+
|
|
167
|
+
// Single entity: EntityDef
|
|
168
|
+
if (isEntityDef(returnSpec)) {
|
|
169
|
+
return returnSpec._name;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Array of entities: [EntityDef]
|
|
173
|
+
if (Array.isArray(returnSpec) && returnSpec.length === 1 && isEntityDef(returnSpec[0])) {
|
|
174
|
+
return returnSpec[0]._name;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Get input field keys from a Zod-like schema.
|
|
182
|
+
* Falls back to empty array if schema doesn't have shape.
|
|
183
|
+
*/
|
|
184
|
+
function getInputFields(inputSchema: { shape?: Record<string, unknown> } | undefined): string[] {
|
|
185
|
+
if (!inputSchema?.shape) return [];
|
|
186
|
+
return Object.keys(inputSchema.shape);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Convert sugar syntax to Reify Pipeline.
|
|
191
|
+
*
|
|
192
|
+
* Sugar syntax:
|
|
193
|
+
* - "merge" → entity.update with input fields merged
|
|
194
|
+
* - "create" → entity.create with temp ID
|
|
195
|
+
* - "delete" → entity.delete by input.id
|
|
196
|
+
* - { merge: {...} } → entity.update with input + extra fields
|
|
197
|
+
*
|
|
198
|
+
* Returns the original value if already a Pipeline or not sugar.
|
|
199
|
+
*/
|
|
200
|
+
function sugarToPipeline(
|
|
201
|
+
optimistic: unknown,
|
|
202
|
+
entityType: string | undefined,
|
|
203
|
+
inputFields: string[],
|
|
204
|
+
): Pipeline | unknown {
|
|
205
|
+
// Already a Pipeline - pass through
|
|
206
|
+
if (isPipeline(optimistic)) {
|
|
207
|
+
return optimistic;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// No entity type - can't convert sugar
|
|
211
|
+
if (!entityType) {
|
|
212
|
+
return optimistic;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// "merge" sugar - update entity with input fields
|
|
216
|
+
if (optimistic === "merge") {
|
|
217
|
+
const args: Record<string, unknown> = { type: entityType };
|
|
218
|
+
for (const field of inputFields) {
|
|
219
|
+
args[field] = { $input: field };
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
$pipe: [{ $do: "entity.update", $with: args }],
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// "create" sugar - create entity with temp ID
|
|
227
|
+
if (optimistic === "create") {
|
|
228
|
+
const args: Record<string, unknown> = { type: entityType, id: { $temp: true } };
|
|
229
|
+
for (const field of inputFields) {
|
|
230
|
+
if (field !== "id") {
|
|
231
|
+
args[field] = { $input: field };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
$pipe: [{ $do: "entity.create", $with: args }],
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// "delete" sugar - delete entity by input.id
|
|
240
|
+
if (optimistic === "delete") {
|
|
241
|
+
return {
|
|
242
|
+
$pipe: [{ $do: "entity.delete", $with: { type: entityType, id: { $input: "id" } } }],
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// { merge: {...} } sugar - update with input + extra fields
|
|
247
|
+
if (
|
|
248
|
+
typeof optimistic === "object" &&
|
|
249
|
+
optimistic !== null &&
|
|
250
|
+
"merge" in optimistic &&
|
|
251
|
+
typeof (optimistic as Record<string, unknown>)["merge"] === "object"
|
|
252
|
+
) {
|
|
253
|
+
const extra = (optimistic as { merge: Record<string, unknown> })["merge"];
|
|
254
|
+
const args: Record<string, unknown> = { type: entityType };
|
|
255
|
+
for (const field of inputFields) {
|
|
256
|
+
args[field] = { $input: field };
|
|
257
|
+
}
|
|
258
|
+
// Extra fields override input refs
|
|
259
|
+
for (const [key, value] of Object.entries(extra)) {
|
|
260
|
+
args[key] = value;
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
$pipe: [{ $do: "entity.update", $with: args }],
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Unknown format - pass through
|
|
268
|
+
return optimistic;
|
|
269
|
+
}
|
|
270
|
+
|
|
152
271
|
// =============================================================================
|
|
153
272
|
// Protocol Messages
|
|
154
273
|
// =============================================================================
|
|
@@ -309,7 +428,7 @@ class LensServerImpl<
|
|
|
309
428
|
private queries: Q;
|
|
310
429
|
private mutations: M;
|
|
311
430
|
private entities: EntitiesMap;
|
|
312
|
-
private resolverMap?: ResolverMap;
|
|
431
|
+
private resolverMap?: ResolverMap | undefined;
|
|
313
432
|
private contextFactory: (req?: unknown) => TContext | Promise<TContext>;
|
|
314
433
|
private version: string;
|
|
315
434
|
private logger: LensLogger;
|
|
@@ -479,11 +598,14 @@ class LensServerImpl<
|
|
|
479
598
|
setNested(name, { type: "query" });
|
|
480
599
|
}
|
|
481
600
|
|
|
482
|
-
// Add mutations with optimistic config
|
|
601
|
+
// Add mutations with optimistic config (convert sugar to Reify Pipeline)
|
|
483
602
|
for (const [name, def] of Object.entries(this.mutations)) {
|
|
484
603
|
const meta: OperationMeta = { type: "mutation" };
|
|
485
604
|
if (def._optimistic) {
|
|
486
|
-
|
|
605
|
+
// Convert sugar syntax to Reify Pipeline
|
|
606
|
+
const entityType = getEntityTypeName(def._output);
|
|
607
|
+
const inputFields = getInputFields(def._input as { shape?: Record<string, unknown> });
|
|
608
|
+
meta.optimistic = sugarToPipeline(def._optimistic, entityType, inputFields);
|
|
487
609
|
}
|
|
488
610
|
setNested(name, meta);
|
|
489
611
|
}
|
|
@@ -1178,7 +1300,7 @@ class LensServerImpl<
|
|
|
1178
1300
|
|
|
1179
1301
|
// Always include id
|
|
1180
1302
|
if ("id" in obj) {
|
|
1181
|
-
result
|
|
1303
|
+
result["id"] = obj["id"];
|
|
1182
1304
|
}
|
|
1183
1305
|
|
|
1184
1306
|
// Handle string array (simple field list)
|
package/src/sse/handler.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Connects SSE streams to GraphStateManager.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type { GraphStateManager, StateClient } from "../state/graph-state-manager";
|
|
8
|
+
import type { GraphStateManager, StateClient } from "../state/graph-state-manager.js";
|
|
9
9
|
|
|
10
10
|
// =============================================================================
|
|
11
11
|
// Types
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
|
6
|
-
import { GraphStateManager, type StateClient, type StateUpdateMessage } from "./graph-state-manager";
|
|
6
|
+
import { GraphStateManager, type StateClient, type StateUpdateMessage } from "./graph-state-manager.js";
|
|
7
7
|
|
|
8
8
|
describe("GraphStateManager", () => {
|
|
9
9
|
let manager: GraphStateManager;
|
|
@@ -62,7 +62,7 @@ describe("GraphStateManager", () => {
|
|
|
62
62
|
entity: "Post",
|
|
63
63
|
id: "123",
|
|
64
64
|
});
|
|
65
|
-
expect(mockClient.messages[0].updates
|
|
65
|
+
expect(mockClient.messages[0].updates["title"]).toMatchObject({
|
|
66
66
|
strategy: "value",
|
|
67
67
|
data: "Hello",
|
|
68
68
|
});
|
|
@@ -179,7 +179,7 @@ describe("GraphStateManager", () => {
|
|
|
179
179
|
|
|
180
180
|
manager.emit("Post", "123", { title: "World" });
|
|
181
181
|
|
|
182
|
-
expect(mockClient.messages[0].updates
|
|
182
|
+
expect(mockClient.messages[0].updates["title"].strategy).toBe("value");
|
|
183
183
|
});
|
|
184
184
|
|
|
185
185
|
it("uses delta strategy for long strings with small changes", () => {
|
|
@@ -191,7 +191,7 @@ describe("GraphStateManager", () => {
|
|
|
191
191
|
manager.emit("Post", "123", { content: `${longText} appended` });
|
|
192
192
|
|
|
193
193
|
// Should use delta for efficient transfer
|
|
194
|
-
const update = mockClient.messages[0].updates
|
|
194
|
+
const update = mockClient.messages[0].updates["content"];
|
|
195
195
|
expect(["delta", "value"]).toContain(update.strategy);
|
|
196
196
|
});
|
|
197
197
|
|
|
@@ -206,7 +206,7 @@ describe("GraphStateManager", () => {
|
|
|
206
206
|
metadata: { views: 101, likes: 10, tags: ["a", "b"] },
|
|
207
207
|
});
|
|
208
208
|
|
|
209
|
-
const update = mockClient.messages[0].updates
|
|
209
|
+
const update = mockClient.messages[0].updates["metadata"];
|
|
210
210
|
expect(["patch", "value"]).toContain(update.strategy);
|
|
211
211
|
});
|
|
212
212
|
});
|
|
@@ -258,12 +258,12 @@ describe("GraphStateManager", () => {
|
|
|
258
258
|
|
|
259
259
|
// client-1 got incremental update
|
|
260
260
|
expect(mockClient.messages.length).toBe(1);
|
|
261
|
-
expect(mockClient.messages[0].updates
|
|
261
|
+
expect(mockClient.messages[0].updates["title"].data).toBe("Updated");
|
|
262
262
|
|
|
263
263
|
// client-2 got full current state
|
|
264
264
|
expect(client2.messages.length).toBe(1);
|
|
265
|
-
expect(client2.messages[0].updates
|
|
266
|
-
expect(client2.messages[0].updates
|
|
265
|
+
expect(client2.messages[0].updates["title"].data).toBe("Updated");
|
|
266
|
+
expect(client2.messages[0].updates["content"].data).toBe("World");
|
|
267
267
|
});
|
|
268
268
|
});
|
|
269
269
|
|
|
@@ -390,7 +390,7 @@ describe("GraphStateManager", () => {
|
|
|
390
390
|
manager.emitField("Post", "123", "title", { strategy: "value", data: "Hello World" });
|
|
391
391
|
|
|
392
392
|
expect(mockClient.messages.length).toBe(1);
|
|
393
|
-
expect(mockClient.messages[0].updates
|
|
393
|
+
expect(mockClient.messages[0].updates["title"]).toEqual({
|
|
394
394
|
strategy: "value",
|
|
395
395
|
data: "Hello World",
|
|
396
396
|
});
|
|
@@ -424,7 +424,7 @@ describe("GraphStateManager", () => {
|
|
|
424
424
|
});
|
|
425
425
|
|
|
426
426
|
const state = manager.getState("Post", "123");
|
|
427
|
-
expect(state?.metadata).toEqual({ views: 101, likes: 10 });
|
|
427
|
+
expect(state?.["metadata"]).toEqual({ views: 101, likes: 10 });
|
|
428
428
|
});
|
|
429
429
|
|
|
430
430
|
it("sends field update to subscribed clients only for subscribed fields", () => {
|
|
@@ -480,9 +480,9 @@ describe("GraphStateManager", () => {
|
|
|
480
480
|
]);
|
|
481
481
|
|
|
482
482
|
expect(mockClient.messages.length).toBe(1);
|
|
483
|
-
expect(mockClient.messages[0].updates
|
|
484
|
-
expect(mockClient.messages[0].updates
|
|
485
|
-
expect(mockClient.messages[0].updates
|
|
483
|
+
expect(mockClient.messages[0].updates["title"].data).toBe("Hello");
|
|
484
|
+
expect(mockClient.messages[0].updates["content"].data).toBe("World");
|
|
485
|
+
expect(mockClient.messages[0].updates["author"].data).toBe("Alice");
|
|
486
486
|
});
|
|
487
487
|
|
|
488
488
|
it("applies batch updates to canonical state", () => {
|
|
@@ -712,7 +712,7 @@ describe("GraphStateManager", () => {
|
|
|
712
712
|
});
|
|
713
713
|
|
|
714
714
|
expect(mockClient.messages.length).toBe(1);
|
|
715
|
-
expect(mockClient.messages[0].updates
|
|
715
|
+
expect(mockClient.messages[0].updates["author"].data).toEqual({
|
|
716
716
|
id: "1",
|
|
717
717
|
name: "Alice",
|
|
718
718
|
profile: {
|
|
@@ -792,7 +792,7 @@ describe("GraphStateManager", () => {
|
|
|
792
792
|
|
|
793
793
|
// Should have 10 updates
|
|
794
794
|
expect(mockClient.messages.length).toBe(10);
|
|
795
|
-
expect(mockClient.messages[9].updates
|
|
795
|
+
expect(mockClient.messages[9].updates["counter"].data).toBe(9);
|
|
796
796
|
});
|
|
797
797
|
|
|
798
798
|
it("handles large number of subscribers to same entity", () => {
|
|
@@ -815,7 +815,7 @@ describe("GraphStateManager", () => {
|
|
|
815
815
|
// All clients should receive the update
|
|
816
816
|
for (const client of clients) {
|
|
817
817
|
expect(client.messages.length).toBe(1);
|
|
818
|
-
expect(client.messages[0].updates
|
|
818
|
+
expect(client.messages[0].updates["title"].data).toBe("Broadcast");
|
|
819
819
|
}
|
|
820
820
|
});
|
|
821
821
|
|
|
@@ -842,14 +842,14 @@ describe("GraphStateManager", () => {
|
|
|
842
842
|
manager.emit("Post", "123", { tags: ["javascript", "typescript"] });
|
|
843
843
|
|
|
844
844
|
expect(mockClient.messages.length).toBe(1);
|
|
845
|
-
expect(mockClient.messages[0].updates
|
|
845
|
+
expect(mockClient.messages[0].updates["tags"].data).toEqual(["javascript", "typescript"]);
|
|
846
846
|
|
|
847
847
|
// Update array
|
|
848
848
|
mockClient.messages = [];
|
|
849
849
|
manager.emit("Post", "123", { tags: ["javascript", "typescript", "react"] });
|
|
850
850
|
|
|
851
851
|
expect(mockClient.messages.length).toBe(1);
|
|
852
|
-
expect(mockClient.messages[0].updates
|
|
852
|
+
expect(mockClient.messages[0].updates["tags"].data).toEqual(["javascript", "typescript", "react"]);
|
|
853
853
|
});
|
|
854
854
|
|
|
855
855
|
it("handles boolean field values", () => {
|
|
@@ -859,14 +859,14 @@ describe("GraphStateManager", () => {
|
|
|
859
859
|
manager.emit("Post", "123", { published: true });
|
|
860
860
|
|
|
861
861
|
expect(mockClient.messages.length).toBe(1);
|
|
862
|
-
expect(mockClient.messages[0].updates
|
|
862
|
+
expect(mockClient.messages[0].updates["published"].data).toBe(true);
|
|
863
863
|
|
|
864
864
|
// Toggle boolean
|
|
865
865
|
mockClient.messages = [];
|
|
866
866
|
manager.emit("Post", "123", { published: false });
|
|
867
867
|
|
|
868
868
|
expect(mockClient.messages.length).toBe(1);
|
|
869
|
-
expect(mockClient.messages[0].updates
|
|
869
|
+
expect(mockClient.messages[0].updates["published"].data).toBe(false);
|
|
870
870
|
});
|
|
871
871
|
|
|
872
872
|
it("handles number field values including 0", () => {
|
|
@@ -876,14 +876,14 @@ describe("GraphStateManager", () => {
|
|
|
876
876
|
manager.emit("Post", "123", { likes: 0 });
|
|
877
877
|
|
|
878
878
|
expect(mockClient.messages.length).toBe(1);
|
|
879
|
-
expect(mockClient.messages[0].updates
|
|
879
|
+
expect(mockClient.messages[0].updates["likes"].data).toBe(0);
|
|
880
880
|
|
|
881
881
|
// Update to positive number
|
|
882
882
|
mockClient.messages = [];
|
|
883
883
|
manager.emit("Post", "123", { likes: 5 });
|
|
884
884
|
|
|
885
885
|
expect(mockClient.messages.length).toBe(1);
|
|
886
|
-
expect(mockClient.messages[0].updates
|
|
886
|
+
expect(mockClient.messages[0].updates["likes"].data).toBe(5);
|
|
887
887
|
});
|
|
888
888
|
});
|
|
889
889
|
|
|
@@ -909,7 +909,7 @@ describe("GraphStateManager", () => {
|
|
|
909
909
|
entity: "Users",
|
|
910
910
|
id: "list",
|
|
911
911
|
});
|
|
912
|
-
expect(mockClient.messages[0].updates
|
|
912
|
+
expect(mockClient.messages[0].updates["_items"].data).toEqual([
|
|
913
913
|
{ id: "1", name: "Alice" },
|
|
914
914
|
{ id: "2", name: "Bob" },
|
|
915
915
|
]);
|
|
@@ -1086,7 +1086,7 @@ describe("GraphStateManager", () => {
|
|
|
1086
1086
|
|
|
1087
1087
|
expect(mockClient.messages.length).toBe(2);
|
|
1088
1088
|
// Second message should be incremental diff (push operation)
|
|
1089
|
-
const update = mockClient.messages[1].updates
|
|
1089
|
+
const update = mockClient.messages[1].updates["_items"];
|
|
1090
1090
|
expect(update.strategy).toBe("array");
|
|
1091
1091
|
expect(update.data).toEqual([{ op: "push", item: { id: "2", name: "Bob" } }]);
|
|
1092
1092
|
});
|
package/src/state/index.ts
CHANGED