@sylphx/lens-server 1.11.3 → 2.1.0

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.
@@ -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";