@sylphx/lens-server 1.11.2 → 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/src/index.ts CHANGED
@@ -2,7 +2,35 @@
2
2
  * @sylphx/lens-server
3
3
  *
4
4
  * Server runtime for Lens API framework.
5
- * Operations-based server with GraphStateManager for reactive updates.
5
+ *
6
+ * Architecture:
7
+ * - App = Executor with optional plugin support
8
+ * - Stateless (default): Pure executor
9
+ * - Stateful (with opLog): Cursor-based state synchronization
10
+ * - Handlers = Pure protocol handlers (HTTP, WebSocket, SSE)
11
+ * - No business logic - just translate protocol to app calls
12
+ * - Plugins = App-level middleware (opLog, auth, logger)
13
+ * - Configured at app level, not handler level
14
+ *
15
+ * @example
16
+ * ```typescript
17
+ * // Stateless mode (default)
18
+ * const app = createApp({ router });
19
+ * const wsHandler = createWSHandler(app);
20
+ *
21
+ * // With opLog plugin (cursor-based state sync)
22
+ * const app = createApp({
23
+ * router,
24
+ * plugins: [opLog()],
25
+ * });
26
+ *
27
+ * // With external storage for serverless (install @sylphx/lens-storage-upstash)
28
+ * import { upstashStorage } from "@sylphx/lens-storage-upstash";
29
+ * const app = createApp({
30
+ * router,
31
+ * plugins: [opLog({ storage: upstashStorage({ redis }) })],
32
+ * });
33
+ * ```
6
34
  */
7
35
 
8
36
  // =============================================================================
@@ -25,21 +53,34 @@ export {
25
53
  } from "@sylphx/lens-core";
26
54
 
27
55
  // =============================================================================
28
- // Server
56
+ // Context System (Server-side implementation)
57
+ // =============================================================================
58
+
59
+ export {
60
+ createContext,
61
+ extendContext,
62
+ hasContext,
63
+ runWithContext,
64
+ runWithContextAsync,
65
+ tryUseContext,
66
+ useContext,
67
+ } from "./context/index.js";
68
+
69
+ // =============================================================================
70
+ // Server (Pure Executor)
29
71
  // =============================================================================
30
72
 
31
73
  export {
74
+ // Types
75
+ type ClientSendFn,
32
76
  // Factory
33
- createServer,
77
+ createApp,
34
78
  type EntitiesMap,
35
- // Type inference utilities (tRPC-style)
36
79
  type InferApi,
37
80
  type InferInput,
38
81
  type InferOutput,
39
- // In-process transport types
40
82
  type LensOperation,
41
83
  type LensResult,
42
- // Types
43
84
  type LensServer,
44
85
  type LensServerConfig as ServerConfig,
45
86
  type MutationsMap,
@@ -47,39 +88,101 @@ export {
47
88
  type OperationsMap,
48
89
  type QueriesMap,
49
90
  type SelectionObject,
50
- // Metadata types (for transport handshake)
51
91
  type ServerMetadata,
52
92
  type WebSocketLike,
53
93
  } from "./server/create.js";
54
94
 
55
95
  // =============================================================================
56
- // State Management
96
+ // Protocol Handlers
97
+ // =============================================================================
98
+
99
+ export {
100
+ // Framework Handler Utilities
101
+ createFrameworkHandler,
102
+ // Unified Handler (HTTP + SSE)
103
+ createHandler,
104
+ // HTTP Handler
105
+ createHTTPHandler,
106
+ createServerClientProxy,
107
+ // SSE Handler
108
+ createSSEHandler,
109
+ // WebSocket Handler
110
+ createWSHandler,
111
+ type FrameworkHandlerOptions,
112
+ type Handler,
113
+ type HandlerOptions,
114
+ type HTTPHandler,
115
+ type HTTPHandlerOptions,
116
+ handleWebMutation,
117
+ handleWebQuery,
118
+ handleWebSSE,
119
+ type SSEHandlerOptions,
120
+ type WSHandler,
121
+ type WSHandlerOptions,
122
+ } from "./handlers/index.js";
123
+
124
+ // =============================================================================
125
+ // Plugin System
126
+ // =============================================================================
127
+
128
+ export {
129
+ // Context types
130
+ type AfterMutationContext,
131
+ type AfterSendContext,
132
+ type BeforeMutationContext,
133
+ type BeforeSendContext,
134
+ // Operation Log Plugin (cursor-based state management)
135
+ type BroadcastResult,
136
+ type ConnectContext,
137
+ // Plugin manager
138
+ createPluginManager,
139
+ type DisconnectContext,
140
+ type EnhanceOperationMetaContext,
141
+ isOpLogPlugin,
142
+ // Optimistic Plugin
143
+ isOptimisticPlugin,
144
+ type OpLogOptions,
145
+ type OpLogPlugin,
146
+ type OptimisticPluginOptions,
147
+ opLog,
148
+ optimisticPlugin,
149
+ PluginManager,
150
+ // Plugin interface
151
+ type ServerPlugin,
152
+ type SubscribeContext,
153
+ type UnsubscribeContext,
154
+ } from "./plugin/index.js";
155
+
156
+ // =============================================================================
157
+ // Storage (for opLog plugin)
57
158
  // =============================================================================
58
159
 
59
160
  export {
60
- // Factory
61
- createGraphStateManager,
62
161
  // Types
63
- type EntityKey,
64
- // Class
65
- GraphStateManager,
66
- type GraphStateManagerConfig,
67
- type StateClient,
68
- type StateFullMessage,
69
- type StateUpdateMessage,
70
- type Subscription,
71
- } from "./state/index.js";
162
+ DEFAULT_STORAGE_CONFIG,
163
+ type EmitResult,
164
+ // In-memory (default)
165
+ memoryStorage,
166
+ type OpLogStorage,
167
+ type OpLogStorageConfig,
168
+ type StoredEntityState,
169
+ type StoredPatchEntry,
170
+ } from "./storage/index.js";
72
171
 
73
172
  // =============================================================================
74
- // SSE Transport Adapter
173
+ // SSE Handler (additional exports not in handlers/index.js)
75
174
  // =============================================================================
76
175
 
77
176
  export {
78
- // Factory
79
- createSSEHandler,
80
- type SSEClientInfo,
177
+ // Types
178
+ type SSEClient,
81
179
  // Class
82
180
  SSEHandler,
83
- // Types
84
181
  type SSEHandlerConfig,
85
182
  } from "./sse/handler.js";
183
+
184
+ // =============================================================================
185
+ // Reconnection (Server-side)
186
+ // =============================================================================
187
+
188
+ export { coalescePatches, estimatePatchSize, OperationLog } from "./reconnect/index.js";
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @sylphx/lens-server - Plugin System
3
+ *
4
+ * Export all plugin-related types and utilities.
5
+ */
6
+
7
+ // Operation Log Plugin (cursor-based state management)
8
+ export {
9
+ type BroadcastResult,
10
+ isOpLogPlugin,
11
+ type OpLogOptions,
12
+ type OpLogPlugin,
13
+ opLog,
14
+ } from "./op-log.js";
15
+
16
+ // Optimistic Updates Plugin
17
+ export {
18
+ isOptimisticPlugin,
19
+ type OptimisticPlugin,
20
+ type OptimisticPluginOptions,
21
+ optimisticPlugin,
22
+ } from "./optimistic.js";
23
+
24
+ export {
25
+ // Context types
26
+ type AfterMutationContext,
27
+ type AfterSendContext,
28
+ type BeforeMutationContext,
29
+ type BeforeSendContext,
30
+ type BroadcastContext,
31
+ type ConnectContext,
32
+ // Plugin manager
33
+ createPluginManager,
34
+ type DisconnectContext,
35
+ type EnhanceOperationMetaContext,
36
+ PluginManager,
37
+ // Plugin interface
38
+ type ServerPlugin,
39
+ type SubscribeContext,
40
+ type UnsubscribeContext,
41
+ } from "./types.js";
@@ -0,0 +1,286 @@
1
+ /**
2
+ * @sylphx/lens-server - Operation Log Plugin
3
+ *
4
+ * Server-side plugin for cursor-based state synchronization.
5
+ * Provides:
6
+ * - Canonical state per entity (server truth)
7
+ * - Version tracking (cursor-based)
8
+ * - Operation log for efficient reconnection
9
+ * - Patch computation
10
+ *
11
+ * This plugin ONLY handles state management.
12
+ * Subscription routing is handled by the handler layer.
13
+ *
14
+ * Memory: O(entities × history) - does not scale with client count
15
+ *
16
+ * @example
17
+ * ```typescript
18
+ * // Default (in-memory storage)
19
+ * const server = createApp({
20
+ * router: appRouter,
21
+ * plugins: [opLog()],
22
+ * });
23
+ *
24
+ * // With external storage for serverless
25
+ * const server = createApp({
26
+ * router: appRouter,
27
+ * plugins: [opLog({
28
+ * storage: redisStorage({ url: process.env.REDIS_URL }),
29
+ * })],
30
+ * });
31
+ * ```
32
+ */
33
+
34
+ import type { PatchOperation } from "@sylphx/lens-core";
35
+ import { memoryStorage, type OpLogStorage, type OpLogStorageConfig } from "../storage/index.js";
36
+ import type {
37
+ BroadcastContext,
38
+ ReconnectContext,
39
+ ReconnectHookResult,
40
+ ServerPlugin,
41
+ } from "./types.js";
42
+
43
+ /**
44
+ * Operation log plugin configuration.
45
+ */
46
+ export interface OpLogOptions extends OpLogStorageConfig {
47
+ /**
48
+ * Storage adapter for state/version/patches.
49
+ * Defaults to in-memory storage.
50
+ *
51
+ * @example
52
+ * ```typescript
53
+ * // In-memory (default)
54
+ * opLog()
55
+ *
56
+ * // Redis for serverless
57
+ * opLog({ storage: redisStorage({ url: REDIS_URL }) })
58
+ * ```
59
+ */
60
+ storage?: OpLogStorage;
61
+
62
+ /**
63
+ * Whether to enable debug logging.
64
+ * @default false
65
+ */
66
+ debug?: boolean;
67
+ }
68
+
69
+ /**
70
+ * Broadcast result returned by the plugin.
71
+ * Handler uses this to send updates to subscribers.
72
+ */
73
+ export interface BroadcastResult {
74
+ /** Current version after update */
75
+ version: number;
76
+ /** Patch operations (null if first emit or log evicted) */
77
+ patch: PatchOperation[] | null;
78
+ /** Full data (for initial sends or when patch unavailable) */
79
+ data: Record<string, unknown>;
80
+ }
81
+
82
+ /**
83
+ * OpLog plugin instance type.
84
+ */
85
+ export interface OpLogPlugin extends ServerPlugin {
86
+ /** Get the storage adapter */
87
+ getStorage(): OpLogStorage;
88
+ /** Get version for an entity (async) */
89
+ getVersion(entity: string, entityId: string): Promise<number>;
90
+ /** Get current canonical state for an entity (async) */
91
+ getState(entity: string, entityId: string): Promise<Record<string, unknown> | null>;
92
+ /** Get latest patch for an entity (async) */
93
+ getLatestPatch(entity: string, entityId: string): Promise<PatchOperation[] | null>;
94
+ }
95
+
96
+ /**
97
+ * Create an operation log plugin.
98
+ *
99
+ * This plugin provides cursor-based state synchronization:
100
+ * - Canonical state per entity (server truth)
101
+ * - Version tracking for cursor-based sync
102
+ * - Operation log for efficient reconnection (patches or snapshot)
103
+ *
104
+ * This plugin does NOT handle subscription routing - that's the handler's job.
105
+ * Memory: O(entities × history) - does not scale with client count.
106
+ *
107
+ * @example
108
+ * ```typescript
109
+ * // Default (in-memory)
110
+ * const server = createApp({
111
+ * router: appRouter,
112
+ * plugins: [opLog()],
113
+ * });
114
+ *
115
+ * // With Redis for serverless
116
+ * const server = createApp({
117
+ * router: appRouter,
118
+ * plugins: [opLog({
119
+ * storage: redisStorage({ url: process.env.REDIS_URL }),
120
+ * })],
121
+ * });
122
+ * ```
123
+ */
124
+ export function opLog(options: OpLogOptions = {}): OpLogPlugin {
125
+ const storage = options.storage ?? memoryStorage(options);
126
+ const debug = options.debug ?? false;
127
+
128
+ const log = (...args: unknown[]) => {
129
+ if (debug) {
130
+ console.log("[opLog]", ...args);
131
+ }
132
+ };
133
+
134
+ return {
135
+ name: "opLog",
136
+
137
+ /**
138
+ * Get the storage adapter.
139
+ */
140
+ getStorage(): OpLogStorage {
141
+ return storage;
142
+ },
143
+
144
+ /**
145
+ * Get version for an entity.
146
+ */
147
+ async getVersion(entity: string, entityId: string): Promise<number> {
148
+ return storage.getVersion(entity, entityId);
149
+ },
150
+
151
+ /**
152
+ * Get current canonical state for an entity.
153
+ */
154
+ async getState(entity: string, entityId: string): Promise<Record<string, unknown> | null> {
155
+ return storage.getState(entity, entityId);
156
+ },
157
+
158
+ /**
159
+ * Get latest patch for an entity.
160
+ */
161
+ async getLatestPatch(entity: string, entityId: string): Promise<PatchOperation[] | null> {
162
+ return storage.getLatestPatch(entity, entityId);
163
+ },
164
+
165
+ /**
166
+ * Handle broadcast - update canonical state and return patch info.
167
+ * Handler is responsible for routing to subscribers.
168
+ */
169
+ async onBroadcast(ctx: BroadcastContext): Promise<BroadcastResult> {
170
+ const { entity, entityId, data } = ctx;
171
+
172
+ log("onBroadcast:", entity, entityId);
173
+
174
+ // Update canonical state (computes and logs patch)
175
+ const result = await storage.emit(entity, entityId, data);
176
+
177
+ log(" Version:", result.version, "Patch ops:", result.patch?.length ?? 0);
178
+
179
+ return {
180
+ version: result.version,
181
+ patch: result.patch,
182
+ data,
183
+ };
184
+ },
185
+
186
+ /**
187
+ * Handle reconnection - return patches or snapshot based on client's version.
188
+ */
189
+ async onReconnect(ctx: ReconnectContext): Promise<ReconnectHookResult[]> {
190
+ log("Reconnect:", ctx.clientId, "subscriptions:", ctx.subscriptions.length);
191
+
192
+ const results: ReconnectHookResult[] = [];
193
+
194
+ for (const sub of ctx.subscriptions) {
195
+ const currentVersion = await storage.getVersion(sub.entity, sub.entityId);
196
+ const currentState = await storage.getState(sub.entity, sub.entityId);
197
+
198
+ // Entity doesn't exist (might have been deleted)
199
+ if (currentState === null) {
200
+ results.push({
201
+ id: sub.id,
202
+ entity: sub.entity,
203
+ entityId: sub.entityId,
204
+ status: "deleted",
205
+ version: 0,
206
+ });
207
+ log(" Subscription", sub.id, `${sub.entity}:${sub.entityId}`, "status: deleted");
208
+ continue;
209
+ }
210
+
211
+ // Client is already at latest version
212
+ if (sub.version >= currentVersion) {
213
+ results.push({
214
+ id: sub.id,
215
+ entity: sub.entity,
216
+ entityId: sub.entityId,
217
+ status: "current",
218
+ version: currentVersion,
219
+ });
220
+ log(
221
+ " Subscription",
222
+ sub.id,
223
+ `${sub.entity}:${sub.entityId}`,
224
+ "status: current",
225
+ "version:",
226
+ currentVersion,
227
+ );
228
+ continue;
229
+ }
230
+
231
+ // Try to get patches from operation log
232
+ const patches = await storage.getPatchesSince(sub.entity, sub.entityId, sub.version);
233
+
234
+ if (patches !== null && patches.length > 0) {
235
+ // Can patch - return patches
236
+ results.push({
237
+ id: sub.id,
238
+ entity: sub.entity,
239
+ entityId: sub.entityId,
240
+ status: "patched",
241
+ version: currentVersion,
242
+ patches,
243
+ });
244
+ log(
245
+ " Subscription",
246
+ sub.id,
247
+ `${sub.entity}:${sub.entityId}`,
248
+ "status: patched",
249
+ "version:",
250
+ currentVersion,
251
+ "patches:",
252
+ patches.length,
253
+ );
254
+ continue;
255
+ }
256
+
257
+ // Patches not available - send full snapshot
258
+ results.push({
259
+ id: sub.id,
260
+ entity: sub.entity,
261
+ entityId: sub.entityId,
262
+ status: "snapshot",
263
+ version: currentVersion,
264
+ data: currentState,
265
+ });
266
+ log(
267
+ " Subscription",
268
+ sub.id,
269
+ `${sub.entity}:${sub.entityId}`,
270
+ "status: snapshot",
271
+ "version:",
272
+ currentVersion,
273
+ );
274
+ }
275
+
276
+ return results;
277
+ },
278
+ };
279
+ }
280
+
281
+ /**
282
+ * Check if a plugin is an opLog plugin.
283
+ */
284
+ export function isOpLogPlugin(plugin: ServerPlugin): plugin is OpLogPlugin {
285
+ return plugin.name === "opLog" && "getStorage" in plugin;
286
+ }