@sylphx/lens-server 1.11.3 → 2.0.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 +1244 -260
- package/dist/index.js +1700 -1158
- package/package.json +2 -2
- package/src/context/index.test.ts +425 -0
- package/src/context/index.ts +90 -0
- package/src/e2e/server.test.ts +215 -433
- package/src/handlers/framework.ts +294 -0
- package/src/handlers/http.test.ts +215 -0
- package/src/handlers/http.ts +189 -0
- package/src/handlers/index.ts +55 -0
- package/src/handlers/unified.ts +114 -0
- package/src/handlers/ws-types.ts +126 -0
- package/src/handlers/ws.ts +669 -0
- package/src/index.ts +127 -24
- package/src/plugin/index.ts +41 -0
- package/src/plugin/op-log.ts +286 -0
- package/src/plugin/optimistic.ts +375 -0
- package/src/plugin/types.ts +551 -0
- package/src/reconnect/index.ts +9 -0
- package/src/reconnect/operation-log.test.ts +480 -0
- package/src/reconnect/operation-log.ts +450 -0
- package/src/server/create.test.ts +256 -2193
- package/src/server/create.ts +285 -1481
- package/src/server/dataloader.ts +60 -0
- package/src/server/selection.ts +44 -0
- package/src/server/types.ts +289 -0
- package/src/sse/handler.ts +123 -56
- package/src/state/index.ts +9 -11
- package/src/storage/index.ts +26 -0
- package/src/storage/memory.ts +279 -0
- package/src/storage/types.ts +205 -0
- package/src/state/graph-state-manager.test.ts +0 -1105
- package/src/state/graph-state-manager.ts +0 -890
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sylphx/lens-server - Optimistic Updates Plugin
|
|
3
|
+
*
|
|
4
|
+
* Server-side plugin that enables optimistic update configuration.
|
|
5
|
+
* Processes mutation definitions and adds optimistic config to handshake metadata.
|
|
6
|
+
*
|
|
7
|
+
* This plugin implements both:
|
|
8
|
+
* - RuntimePlugin<OptimisticPluginExtension> for lens() type extensions
|
|
9
|
+
* - ServerPlugin for server-side metadata processing
|
|
10
|
+
*
|
|
11
|
+
* @example With lens() for type-safe .optimistic()
|
|
12
|
+
* ```typescript
|
|
13
|
+
* const { mutation, plugins } = lens<AppContext>({ plugins: [optimisticPlugin()] });
|
|
14
|
+
*
|
|
15
|
+
* // .optimistic() is now type-safe (compile error without plugin)
|
|
16
|
+
* const updateUser = mutation()
|
|
17
|
+
* .input(z.object({ id: z.string(), name: z.string() }))
|
|
18
|
+
* .returns(User)
|
|
19
|
+
* .optimistic('merge') // ✅ Type-safe
|
|
20
|
+
* .resolve(({ input }) => db.user.update(input));
|
|
21
|
+
*
|
|
22
|
+
* const server = createApp({ router, plugins });
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* @example Direct server usage
|
|
26
|
+
* ```typescript
|
|
27
|
+
* const server = createApp({
|
|
28
|
+
* router,
|
|
29
|
+
* plugins: [optimisticPlugin()],
|
|
30
|
+
* });
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import {
|
|
35
|
+
isPipeline,
|
|
36
|
+
OPTIMISTIC_PLUGIN_SYMBOL,
|
|
37
|
+
type OptimisticPluginMarker,
|
|
38
|
+
type Pipeline,
|
|
39
|
+
} from "@sylphx/lens-core";
|
|
40
|
+
import type { EnhanceOperationMetaContext, ServerPlugin } from "./types.js";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Optimistic plugin configuration.
|
|
44
|
+
*/
|
|
45
|
+
export interface OptimisticPluginOptions {
|
|
46
|
+
/**
|
|
47
|
+
* Whether to auto-derive optimistic config from mutation naming.
|
|
48
|
+
* - `updateX` → "merge"
|
|
49
|
+
* - `createX` / `addX` → "create"
|
|
50
|
+
* - `deleteX` / `removeX` → "delete"
|
|
51
|
+
* @default true
|
|
52
|
+
*/
|
|
53
|
+
autoDerive?: boolean;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Enable debug logging.
|
|
57
|
+
* @default false
|
|
58
|
+
*/
|
|
59
|
+
debug?: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Sugar syntax types for optimistic updates.
|
|
64
|
+
*/
|
|
65
|
+
type OptimisticSugar = "merge" | "create" | "delete";
|
|
66
|
+
type OptimisticMerge = { merge: Record<string, unknown> };
|
|
67
|
+
type OptimisticDSL = OptimisticSugar | OptimisticMerge | Pipeline;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* MutationDef shape for type checking.
|
|
71
|
+
*/
|
|
72
|
+
interface MutationDefLike {
|
|
73
|
+
_optimistic?: OptimisticDSL;
|
|
74
|
+
_output?: unknown;
|
|
75
|
+
_input?: { shape?: Record<string, unknown> };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Extract entity type name from return spec.
|
|
80
|
+
*
|
|
81
|
+
* Entity definitions can have different formats:
|
|
82
|
+
* 1. Direct entity: { _name: "User", fields: {...}, "~entity": { name: "User" } }
|
|
83
|
+
* 2. Return spec wrapper: { _tag: "entity", entityDef: { _name: "User" } }
|
|
84
|
+
* 3. Array: { _tag: "array", element: <entity> }
|
|
85
|
+
*/
|
|
86
|
+
function getEntityTypeName(returnSpec: unknown): string | undefined {
|
|
87
|
+
if (!returnSpec) return undefined;
|
|
88
|
+
if (typeof returnSpec !== "object") return undefined;
|
|
89
|
+
|
|
90
|
+
const spec = returnSpec as Record<string, unknown>;
|
|
91
|
+
|
|
92
|
+
// Direct entity definition with _name
|
|
93
|
+
if ("_name" in spec && typeof spec._name === "string") {
|
|
94
|
+
return spec._name;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ~entity marker (entity definitions have this)
|
|
98
|
+
if ("~entity" in spec) {
|
|
99
|
+
const entity = spec["~entity"] as { name?: string } | undefined;
|
|
100
|
+
if (entity?.name) return entity.name;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Return spec wrapper with _tag
|
|
104
|
+
if ("_tag" in spec) {
|
|
105
|
+
if (spec._tag === "entity" && spec.entityDef) {
|
|
106
|
+
const entityDef = spec.entityDef as { _name?: string };
|
|
107
|
+
if (entityDef._name) return entityDef._name;
|
|
108
|
+
}
|
|
109
|
+
if (spec._tag === "array" && spec.element) {
|
|
110
|
+
return getEntityTypeName(spec.element);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Get input field names from Zod schema.
|
|
119
|
+
*/
|
|
120
|
+
function getInputFields(schema: { shape?: Record<string, unknown> } | undefined): string[] {
|
|
121
|
+
if (!schema?.shape) return [];
|
|
122
|
+
return Object.keys(schema.shape);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Create a Reify $input reference.
|
|
127
|
+
*/
|
|
128
|
+
function $input(field: string): { $input: string } {
|
|
129
|
+
return { $input: field };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Create a Reify Pipeline step.
|
|
134
|
+
*/
|
|
135
|
+
interface ReifyPipelineStep {
|
|
136
|
+
$do: string;
|
|
137
|
+
$with: Record<string, unknown>;
|
|
138
|
+
$as: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Create a Reify Pipeline.
|
|
143
|
+
*/
|
|
144
|
+
interface ReifyPipeline {
|
|
145
|
+
$pipe: ReifyPipelineStep[];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Convert sugar syntax to Reify Pipeline.
|
|
150
|
+
*
|
|
151
|
+
* Sugar syntax:
|
|
152
|
+
* - "merge" → entity.update with input fields
|
|
153
|
+
* - "create" → entity.create from output
|
|
154
|
+
* - "delete" → entity.delete by input.id
|
|
155
|
+
*
|
|
156
|
+
* Returns the original value if already a Pipeline.
|
|
157
|
+
*
|
|
158
|
+
* Output format (Reify DSL):
|
|
159
|
+
* {
|
|
160
|
+
* "$pipe": [{
|
|
161
|
+
* "$do": "entity.create",
|
|
162
|
+
* "$with": { "type": "Entity", "field": { "$input": "field" } },
|
|
163
|
+
* "$as": "result"
|
|
164
|
+
* }]
|
|
165
|
+
* }
|
|
166
|
+
*/
|
|
167
|
+
function sugarToPipeline(
|
|
168
|
+
sugar: OptimisticDSL | undefined,
|
|
169
|
+
entityType: string | undefined,
|
|
170
|
+
inputFields: string[],
|
|
171
|
+
): Pipeline | undefined {
|
|
172
|
+
if (!sugar) return undefined;
|
|
173
|
+
if (isPipeline(sugar)) return sugar;
|
|
174
|
+
|
|
175
|
+
const entity = entityType ?? "Entity";
|
|
176
|
+
|
|
177
|
+
switch (sugar) {
|
|
178
|
+
case "merge": {
|
|
179
|
+
// entity.update('Entity', { id: input.id, ...fields })
|
|
180
|
+
const updateData: Record<string, unknown> = {
|
|
181
|
+
type: entity,
|
|
182
|
+
id: $input("id"),
|
|
183
|
+
};
|
|
184
|
+
// Add all input fields as $input references
|
|
185
|
+
for (const field of inputFields) {
|
|
186
|
+
if (field !== "id") {
|
|
187
|
+
updateData[field] = $input(field);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const pipeline: ReifyPipeline = {
|
|
191
|
+
$pipe: [
|
|
192
|
+
{
|
|
193
|
+
$do: "entity.update",
|
|
194
|
+
$with: updateData,
|
|
195
|
+
$as: "result",
|
|
196
|
+
},
|
|
197
|
+
],
|
|
198
|
+
};
|
|
199
|
+
return pipeline as unknown as Pipeline;
|
|
200
|
+
}
|
|
201
|
+
case "create": {
|
|
202
|
+
// entity.create('Entity', { id: temp(), ...from output })
|
|
203
|
+
// For create, we use a special marker that client interprets as "use mutation output"
|
|
204
|
+
const pipeline: ReifyPipeline = {
|
|
205
|
+
$pipe: [
|
|
206
|
+
{
|
|
207
|
+
$do: "entity.create",
|
|
208
|
+
$with: {
|
|
209
|
+
type: entity,
|
|
210
|
+
id: { $temp: true },
|
|
211
|
+
$fromOutput: true, // Special marker: use mutation output data
|
|
212
|
+
},
|
|
213
|
+
$as: "result",
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
};
|
|
217
|
+
return pipeline as unknown as Pipeline;
|
|
218
|
+
}
|
|
219
|
+
case "delete": {
|
|
220
|
+
// entity.delete('Entity', { id: input.id })
|
|
221
|
+
const pipeline: ReifyPipeline = {
|
|
222
|
+
$pipe: [
|
|
223
|
+
{
|
|
224
|
+
$do: "entity.delete",
|
|
225
|
+
$with: {
|
|
226
|
+
type: entity,
|
|
227
|
+
id: { id: $input("id") },
|
|
228
|
+
},
|
|
229
|
+
$as: "result",
|
|
230
|
+
},
|
|
231
|
+
],
|
|
232
|
+
};
|
|
233
|
+
return pipeline as unknown as Pipeline;
|
|
234
|
+
}
|
|
235
|
+
default:
|
|
236
|
+
// Handle { merge: {...} } sugar
|
|
237
|
+
if (typeof sugar === "object" && "merge" in sugar) {
|
|
238
|
+
const updateData: Record<string, unknown> = {
|
|
239
|
+
type: entity,
|
|
240
|
+
id: $input("id"),
|
|
241
|
+
};
|
|
242
|
+
// Add input fields
|
|
243
|
+
for (const field of inputFields) {
|
|
244
|
+
if (field !== "id") {
|
|
245
|
+
updateData[field] = $input(field);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// Add extra static fields from merge object
|
|
249
|
+
for (const [key, value] of Object.entries(sugar.merge)) {
|
|
250
|
+
updateData[key] = value;
|
|
251
|
+
}
|
|
252
|
+
const pipeline: ReifyPipeline = {
|
|
253
|
+
$pipe: [
|
|
254
|
+
{
|
|
255
|
+
$do: "entity.update",
|
|
256
|
+
$with: updateData,
|
|
257
|
+
$as: "result",
|
|
258
|
+
},
|
|
259
|
+
],
|
|
260
|
+
};
|
|
261
|
+
return pipeline as unknown as Pipeline;
|
|
262
|
+
}
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Check if a value is optimistic DSL.
|
|
269
|
+
*/
|
|
270
|
+
function isOptimisticDSL(value: unknown): value is OptimisticDSL {
|
|
271
|
+
if (value === "merge" || value === "create" || value === "delete") return true;
|
|
272
|
+
if (isPipeline(value)) return true;
|
|
273
|
+
if (typeof value === "object" && value !== null && "merge" in value) return true;
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Combined plugin type that works with both lens() and createApp().
|
|
279
|
+
*
|
|
280
|
+
* This type satisfies:
|
|
281
|
+
* - OptimisticPluginMarker (RuntimePlugin<OptimisticPluginExtension>) for lens() type extensions
|
|
282
|
+
* - ServerPlugin for server-side metadata processing
|
|
283
|
+
*/
|
|
284
|
+
export type OptimisticPlugin = OptimisticPluginMarker & ServerPlugin;
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Create an optimistic plugin.
|
|
288
|
+
*
|
|
289
|
+
* This plugin enables type-safe .optimistic() on mutation builders when used
|
|
290
|
+
* with lens(), and processes mutation definitions for server metadata.
|
|
291
|
+
*
|
|
292
|
+
* @example With lens() for type-safe builders
|
|
293
|
+
* ```typescript
|
|
294
|
+
* const { mutation, plugins } = lens<AppContext>({ plugins: [optimisticPlugin()] });
|
|
295
|
+
*
|
|
296
|
+
* // .optimistic() is type-safe (compile error without plugin)
|
|
297
|
+
* const updateUser = mutation()
|
|
298
|
+
* .input(z.object({ id: z.string(), name: z.string() }))
|
|
299
|
+
* .returns(User)
|
|
300
|
+
* .optimistic('merge')
|
|
301
|
+
* .resolve(({ input }) => db.user.update(input));
|
|
302
|
+
*
|
|
303
|
+
* const server = createApp({ router, plugins });
|
|
304
|
+
* ```
|
|
305
|
+
*
|
|
306
|
+
* @example Direct server usage
|
|
307
|
+
* ```typescript
|
|
308
|
+
* const server = createApp({
|
|
309
|
+
* router: appRouter,
|
|
310
|
+
* plugins: [optimisticPlugin()],
|
|
311
|
+
* });
|
|
312
|
+
* ```
|
|
313
|
+
*/
|
|
314
|
+
export function optimisticPlugin(options: OptimisticPluginOptions = {}): OptimisticPlugin {
|
|
315
|
+
const { autoDerive = true, debug = false } = options;
|
|
316
|
+
|
|
317
|
+
const log = (...args: unknown[]) => {
|
|
318
|
+
if (debug) {
|
|
319
|
+
console.log("[optimisticPlugin]", ...args);
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
// RuntimePlugin (OptimisticPluginMarker) interface
|
|
325
|
+
name: "optimistic" as const,
|
|
326
|
+
[OPTIMISTIC_PLUGIN_SYMBOL]: true as const,
|
|
327
|
+
|
|
328
|
+
// ServerPlugin interface
|
|
329
|
+
/**
|
|
330
|
+
* Enhance operation metadata with optimistic config.
|
|
331
|
+
* Called for each operation when building handshake metadata.
|
|
332
|
+
*/
|
|
333
|
+
enhanceOperationMeta(ctx: EnhanceOperationMetaContext): void {
|
|
334
|
+
// Only process mutations
|
|
335
|
+
if (ctx.type !== "mutation") return;
|
|
336
|
+
|
|
337
|
+
const def = ctx.definition as MutationDefLike;
|
|
338
|
+
let optimisticSpec = def._optimistic;
|
|
339
|
+
|
|
340
|
+
// Auto-derive from naming convention if enabled and not explicitly set
|
|
341
|
+
if (!optimisticSpec && autoDerive) {
|
|
342
|
+
const lastSegment = ctx.path.includes(".") ? ctx.path.split(".").pop()! : ctx.path;
|
|
343
|
+
|
|
344
|
+
if (lastSegment.startsWith("update")) {
|
|
345
|
+
optimisticSpec = "merge";
|
|
346
|
+
} else if (lastSegment.startsWith("create") || lastSegment.startsWith("add")) {
|
|
347
|
+
optimisticSpec = "create";
|
|
348
|
+
} else if (lastSegment.startsWith("delete") || lastSegment.startsWith("remove")) {
|
|
349
|
+
optimisticSpec = "delete";
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
log(`Auto-derived optimistic for ${ctx.path}:`, optimisticSpec);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Convert to pipeline and add to metadata
|
|
356
|
+
if (optimisticSpec && isOptimisticDSL(optimisticSpec)) {
|
|
357
|
+
const entityType = getEntityTypeName(def._output);
|
|
358
|
+
const inputFields = getInputFields(def._input);
|
|
359
|
+
const pipeline = sugarToPipeline(optimisticSpec, entityType, inputFields);
|
|
360
|
+
|
|
361
|
+
if (pipeline) {
|
|
362
|
+
ctx.meta.optimistic = pipeline;
|
|
363
|
+
log(`Added optimistic config for ${ctx.path}:`, pipeline);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Check if a plugin is an optimistic plugin.
|
|
372
|
+
*
|
|
373
|
+
* Uses the OPTIMISTIC_PLUGIN_SYMBOL for type-safe identification.
|
|
374
|
+
*/
|
|
375
|
+
export { isOptimisticPlugin } from "@sylphx/lens-core";
|