@voidhash/mimic-effect 0.0.1-alpha.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.
@@ -0,0 +1,157 @@
1
+ /**
2
+ * @since 0.0.1
3
+ * Data storage service interface for Mimic documents.
4
+ * Provides pluggable storage adapters with load/save hooks for data transformation.
5
+ */
6
+ import * as Effect from "effect/Effect";
7
+ import * as Context from "effect/Context";
8
+ import * as Layer from "effect/Layer";
9
+ import * as Data from "effect/Data";
10
+
11
+ // =============================================================================
12
+ // Error Types
13
+ // =============================================================================
14
+
15
+ /**
16
+ * Error when loading a document from storage fails.
17
+ */
18
+ export class StorageLoadError extends Data.TaggedError("StorageLoadError")<{
19
+ readonly documentId: string;
20
+ readonly cause: unknown;
21
+ }> {
22
+ get message(): string {
23
+ return `Failed to load document ${this.documentId}: ${String(this.cause)}`;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Error when saving a document to storage fails.
29
+ */
30
+ export class StorageSaveError extends Data.TaggedError("StorageSaveError")<{
31
+ readonly documentId: string;
32
+ readonly cause: unknown;
33
+ }> {
34
+ get message(): string {
35
+ return `Failed to save document ${this.documentId}: ${String(this.cause)}`;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Error when deleting a document from storage fails.
41
+ */
42
+ export class StorageDeleteError extends Data.TaggedError("StorageDeleteError")<{
43
+ readonly documentId: string;
44
+ readonly cause: unknown;
45
+ }> {
46
+ get message(): string {
47
+ return `Failed to delete document ${this.documentId}: ${String(this.cause)}`;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Union of all storage errors.
53
+ */
54
+ export type StorageError = StorageLoadError | StorageSaveError | StorageDeleteError;
55
+
56
+ // =============================================================================
57
+ // Storage Service Interface
58
+ // =============================================================================
59
+
60
+ /**
61
+ * Data storage service interface.
62
+ * Implementations can persist documents to various backends (memory, S3, database, etc.)
63
+ */
64
+ export interface MimicDataStorage {
65
+ /**
66
+ * Load a document's state from storage.
67
+ * @param documentId - The unique identifier for the document
68
+ * @returns The document state, or undefined if not found
69
+ */
70
+ readonly load: (
71
+ documentId: string
72
+ ) => Effect.Effect<unknown | undefined, StorageLoadError>;
73
+
74
+ /**
75
+ * Save a document's state to storage.
76
+ * @param documentId - The unique identifier for the document
77
+ * @param state - The document state to persist
78
+ */
79
+ readonly save: (
80
+ documentId: string,
81
+ state: unknown
82
+ ) => Effect.Effect<void, StorageSaveError>;
83
+
84
+ /**
85
+ * Delete a document from storage.
86
+ * @param documentId - The unique identifier for the document
87
+ */
88
+ readonly delete: (
89
+ documentId: string
90
+ ) => Effect.Effect<void, StorageDeleteError>;
91
+
92
+ /**
93
+ * Transform data after loading from storage.
94
+ * Useful for migrations, decryption, decompression, etc.
95
+ * @param state - The raw state loaded from storage
96
+ * @returns The transformed state
97
+ */
98
+ readonly onLoad: (state: unknown) => Effect.Effect<unknown>;
99
+
100
+ /**
101
+ * Transform/validate data before saving to storage.
102
+ * Useful for encryption, compression, validation, etc.
103
+ * @param state - The state to be saved
104
+ * @returns The transformed state
105
+ */
106
+ readonly onSave: (state: unknown) => Effect.Effect<unknown>;
107
+ }
108
+
109
+ // =============================================================================
110
+ // Context Tag
111
+ // =============================================================================
112
+
113
+ /**
114
+ * Context tag for MimicDataStorage service.
115
+ */
116
+ export class MimicDataStorageTag extends Context.Tag(
117
+ "@voidhash/mimic-server-effect/MimicDataStorage"
118
+ )<MimicDataStorageTag, MimicDataStorage>() {}
119
+
120
+ // =============================================================================
121
+ // Layer Constructors
122
+ // =============================================================================
123
+
124
+ /**
125
+ * Create a MimicDataStorage layer from a storage implementation.
126
+ */
127
+ export const layer = (storage: MimicDataStorage): Layer.Layer<MimicDataStorageTag> =>
128
+ Layer.succeed(MimicDataStorageTag, storage);
129
+
130
+ /**
131
+ * Create a MimicDataStorage layer from an Effect that produces a storage implementation.
132
+ */
133
+ export const layerEffect = <E, R>(
134
+ effect: Effect.Effect<MimicDataStorage, E, R>
135
+ ): Layer.Layer<MimicDataStorageTag, E, R> =>
136
+ Layer.effect(MimicDataStorageTag, effect);
137
+
138
+ // =============================================================================
139
+ // Helper Functions
140
+ // =============================================================================
141
+
142
+ /**
143
+ * Create a simple storage implementation with minimal configuration.
144
+ */
145
+ export const make = (options: {
146
+ readonly load: (documentId: string) => Effect.Effect<unknown | undefined, StorageLoadError>;
147
+ readonly save: (documentId: string, state: unknown) => Effect.Effect<void, StorageSaveError>;
148
+ readonly delete?: (documentId: string) => Effect.Effect<void, StorageDeleteError>;
149
+ readonly onLoad?: (state: unknown) => Effect.Effect<unknown>;
150
+ readonly onSave?: (state: unknown) => Effect.Effect<unknown>;
151
+ }): MimicDataStorage => ({
152
+ load: options.load,
153
+ save: options.save,
154
+ delete: options.delete ?? (() => Effect.void),
155
+ onLoad: options.onLoad ?? ((state) => Effect.succeed(state)),
156
+ onSave: options.onSave ?? ((state) => Effect.succeed(state)),
157
+ });
@@ -0,0 +1,363 @@
1
+ /**
2
+ * @since 0.0.1
3
+ * Mimic server layer composition.
4
+ */
5
+ import * as Effect from "effect/Effect";
6
+ import * as Layer from "effect/Layer";
7
+ import * as Context from "effect/Context";
8
+ import type * as Socket from "@effect/platform/Socket";
9
+ import { SocketServer } from "@effect/platform/SocketServer";
10
+ import type { Primitive, Presence } from "@voidhash/mimic";
11
+
12
+ import * as DocumentManager from "./DocumentManager.js";
13
+ import * as WebSocketHandler from "./WebSocketHandler.js";
14
+ import * as MimicConfig from "./MimicConfig.js";
15
+ import { MimicDataStorageTag } from "./MimicDataStorage.js";
16
+ import { MimicAuthServiceTag } from "./MimicAuthService.js";
17
+ import * as PresenceManager from "./PresenceManager.js";
18
+ import * as InMemoryDataStorage from "./storage/InMemoryDataStorage.js";
19
+ import * as NoAuth from "./auth/NoAuth.js";
20
+ import { HttpLayerRouter, HttpServerRequest, HttpServerResponse } from "@effect/platform";
21
+ import { PathInput } from "@effect/platform/HttpRouter";
22
+
23
+ // =============================================================================
24
+ // Handler Tag
25
+ // =============================================================================
26
+
27
+ /**
28
+ * Tag for the WebSocket handler function.
29
+ */
30
+ export class MimicWebSocketHandler extends Context.Tag(
31
+ "@voidhash/mimic-server-effect/MimicWebSocketHandler"
32
+ )<
33
+ MimicWebSocketHandler,
34
+ (socket: Socket.Socket, documentId: string) => Effect.Effect<void, unknown>
35
+ >() {}
36
+
37
+ // =============================================================================
38
+ // Layer Composition Options
39
+ // =============================================================================
40
+
41
+ /**
42
+ * Options for creating a Mimic server layer.
43
+ */
44
+ export interface MimicLayerOptions<TSchema extends Primitive.AnyPrimitive> {
45
+ /**
46
+ * Base path for document routes (used for path matching).
47
+ * @example "/mimic/todo" - documents accessed at "/mimic/todo/:documentId"
48
+ */
49
+ readonly basePath?: PathInput;
50
+ /**
51
+ * The schema defining the document structure.
52
+ */
53
+ readonly schema: TSchema;
54
+ /**
55
+ * Maximum number of processed transaction IDs to track for deduplication.
56
+ * @default 1000
57
+ */
58
+ readonly maxTransactionHistory?: number;
59
+ /**
60
+ * Optional presence schema for ephemeral per-user data.
61
+ * When provided, enables presence features on WebSocket connections.
62
+ */
63
+ readonly presence?: Presence.AnyPresence;
64
+ }
65
+
66
+ // =============================================================================
67
+ // Layer Composition
68
+ // =============================================================================
69
+
70
+ /**
71
+ * Create a Mimic WebSocket handler layer.
72
+ *
73
+ * This layer provides a handler function that can be used with any WebSocket server
74
+ * implementation. The handler takes a socket and document ID and manages the
75
+ * document synchronization.
76
+ *
77
+ * By default, uses in-memory storage and no authentication.
78
+ * Override these by providing MimicDataStorage and MimicAuthService layers.
79
+ *
80
+ * @example
81
+ * ```typescript
82
+ * import { MimicServer, MimicAuthService } from "@voidhash/mimic-effect";
83
+ * import { Primitive } from "@voidhash/mimic";
84
+ *
85
+ * const TodoSchema = Primitive.Struct({
86
+ * title: Primitive.String(),
87
+ * completed: Primitive.Boolean(),
88
+ * });
89
+ *
90
+ * // Create the handler layer with defaults
91
+ * const HandlerLayer = MimicServer.layer({
92
+ * basePath: "/mimic/todo",
93
+ * schema: TodoSchema
94
+ * });
95
+ *
96
+ * // Or with custom auth
97
+ * const HandlerLayerWithAuth = MimicServer.layer({
98
+ * basePath: "/mimic/todo",
99
+ * schema: TodoSchema
100
+ * }).pipe(
101
+ * Layer.provideMerge(MimicAuthService.layer({
102
+ * authHandler: (token) => ({ success: true, userId: "user-123" })
103
+ * }))
104
+ * );
105
+ * ```
106
+ */
107
+ export const layer = <TSchema extends Primitive.AnyPrimitive>(
108
+ options: MimicLayerOptions<TSchema>
109
+ ): Layer.Layer<MimicWebSocketHandler | DocumentManager.DocumentManagerTag> => {
110
+ const configLayer = MimicConfig.layer({
111
+ schema: options.schema,
112
+ maxTransactionHistory: options.maxTransactionHistory,
113
+ presence: options.presence,
114
+ });
115
+
116
+ return Layer.merge(
117
+ // Handler layer
118
+ Layer.effect(MimicWebSocketHandler, WebSocketHandler.makeHandler).pipe(
119
+ Layer.provide(DocumentManager.layer),
120
+ Layer.provide(PresenceManager.layer),
121
+ Layer.provide(configLayer)
122
+ ),
123
+ // Document manager layer
124
+ DocumentManager.layer.pipe(Layer.provide(configLayer))
125
+ ).pipe(
126
+ // Provide defaults if not overridden
127
+ Layer.provide(InMemoryDataStorage.layerDefault),
128
+ Layer.provide(NoAuth.layerDefault)
129
+ );
130
+ };
131
+
132
+ /**
133
+ * Create the Mimic server handler layer.
134
+ * This layer provides the WebSocket handler that can be used with any WebSocket server.
135
+ *
136
+ * @example
137
+ * ```typescript
138
+ * import { MimicServer } from "@voidhash/mimic-server-effect";
139
+ * import { SocketServer } from "@effect/platform/SocketServer";
140
+ * import { Primitive } from "@voidhash/mimic";
141
+ *
142
+ * // Define your document schema
143
+ * const TodoSchema = Primitive.Struct({
144
+ * title: Primitive.String(),
145
+ * completed: Primitive.Boolean(),
146
+ * });
147
+ *
148
+ * // Create the server layer
149
+ * const serverLayer = MimicServer.handlerLayer({
150
+ * schema: TodoSchema,
151
+ * });
152
+ *
153
+ * // Run with your socket server
154
+ * Effect.gen(function* () {
155
+ * const handler = yield* MimicServer.MimicWebSocketHandler;
156
+ * const server = yield* SocketServer;
157
+ *
158
+ * yield* server.run((socket) =>
159
+ * // Extract document ID from request and call handler
160
+ * handler(socket, "my-document-id")
161
+ * );
162
+ * }).pipe(
163
+ * Effect.provide(serverLayer),
164
+ * Effect.provide(YourSocketServerLayer),
165
+ * );
166
+ * ```
167
+ */
168
+ export const handlerLayer = <TSchema extends Primitive.AnyPrimitive>(
169
+ options: MimicConfig.MimicServerConfigOptions<TSchema>
170
+ ): Layer.Layer<MimicWebSocketHandler> =>
171
+ Layer.effect(MimicWebSocketHandler, WebSocketHandler.makeHandler).pipe(
172
+ Layer.provide(DocumentManager.layer),
173
+ Layer.provide(PresenceManager.layer),
174
+ Layer.provide(MimicConfig.layer(options)),
175
+ // Provide defaults
176
+ Layer.provide(InMemoryDataStorage.layerDefault),
177
+ Layer.provide(NoAuth.layerDefault)
178
+ );
179
+
180
+ /**
181
+ * Create the document manager layer.
182
+ */
183
+ export const documentManagerLayer = <TSchema extends Primitive.AnyPrimitive>(
184
+ options: MimicConfig.MimicServerConfigOptions<TSchema>
185
+ ): Layer.Layer<DocumentManager.DocumentManagerTag> =>
186
+ DocumentManager.layer.pipe(
187
+ Layer.provide(MimicConfig.layer(options)),
188
+ // Provide defaults
189
+ Layer.provide(InMemoryDataStorage.layerDefault),
190
+ Layer.provide(NoAuth.layerDefault)
191
+ );
192
+
193
+ // =============================================================================
194
+ // Convenience Functions
195
+ // =============================================================================
196
+
197
+ /**
198
+ * Run a Mimic WebSocket server with the provided handler.
199
+ *
200
+ * This is a helper that:
201
+ * 1. Gets the WebSocket handler from context
202
+ * 2. Runs the socket server with the handler
203
+ *
204
+ * Note: The document ID extraction from socket is implementation-specific.
205
+ * You may need to customize this based on your socket server.
206
+ */
207
+ export const run = (
208
+ extractDocumentId: (socket: Socket.Socket) => Effect.Effect<string>
209
+ ) =>
210
+ Effect.gen(function* () {
211
+ const handler = yield* MimicWebSocketHandler;
212
+ const server = yield* SocketServer;
213
+
214
+ yield* server.run((socket) =>
215
+ Effect.gen(function* () {
216
+ const documentId = yield* extractDocumentId(socket);
217
+ yield* handler(socket, documentId);
218
+ }).pipe(
219
+ Effect.catchAll((error) =>
220
+ Effect.logError("Connection error", error)
221
+ )
222
+ )
223
+ );
224
+ });
225
+
226
+
227
+ /**
228
+ * Create the HTTP handler effect for WebSocket upgrade.
229
+ * This handler:
230
+ * 1. Extracts the document ID from the URL path
231
+ * 2. Upgrades the HTTP connection to WebSocket
232
+ * 3. Delegates to the WebSocketHandler for document sync
233
+ */
234
+ const makeMimicHandler = Effect.gen(function* () {
235
+ const config = yield* MimicConfig.MimicServerConfigTag;
236
+ const authService = yield* MimicAuthServiceTag;
237
+ const documentManager = yield* DocumentManager.DocumentManagerTag;
238
+ const presenceManager = yield* PresenceManager.PresenceManagerTag;
239
+
240
+ return Effect.gen(function* () {
241
+ const request = yield* HttpServerRequest.HttpServerRequest;
242
+
243
+ // Extract document ID from the URL path
244
+ // Expected format: /basePath/doc/{documentId}
245
+ const documentId = yield* WebSocketHandler.extractDocumentId(request.url);
246
+
247
+ // Upgrade to WebSocket
248
+ const socket = yield* request.upgrade;
249
+
250
+ // Handle the WebSocket connection
251
+ yield* WebSocketHandler.handleConnection(socket, request.url).pipe(
252
+ Effect.provideService(MimicConfig.MimicServerConfigTag, config),
253
+ Effect.provideService(MimicAuthServiceTag, authService),
254
+ Effect.provideService(DocumentManager.DocumentManagerTag, documentManager),
255
+ Effect.provideService(PresenceManager.PresenceManagerTag, presenceManager),
256
+ Effect.scoped,
257
+ Effect.catchAll((error) =>
258
+ Effect.logError("WebSocket connection error", error)
259
+ )
260
+ );
261
+
262
+ // Return empty response - the WebSocket upgrade handles the connection
263
+ return HttpServerResponse.empty();
264
+ }).pipe(
265
+ Effect.catchAll((error) =>
266
+ Effect.gen(function* () {
267
+ yield* Effect.logWarning("WebSocket upgrade failed", error);
268
+ return HttpServerResponse.text("WebSocket upgrade failed", {
269
+ status: 400,
270
+ });
271
+ })
272
+ )
273
+ );
274
+ });
275
+
276
+
277
+
278
+ /**
279
+ * Create a Mimic server layer that integrates with HttpLayerRouter.
280
+ *
281
+ * This function creates a layer that:
282
+ * 1. Registers a WebSocket route at the specified base path
283
+ * 2. Handles WebSocket upgrades for document sync
284
+ * 3. Provides all required dependencies (config, auth, storage, document manager)
285
+ *
286
+ * By default, uses in-memory storage and no authentication.
287
+ * To override these defaults, provide custom layers before the defaults:
288
+ *
289
+ * @example
290
+ * ```typescript
291
+ * import { MimicServer, MimicAuthService } from "@voidhash/mimic-effect";
292
+ * import { HttpLayerRouter } from "@effect/platform";
293
+ * import { Primitive } from "@voidhash/mimic";
294
+ *
295
+ * const TodoSchema = Primitive.Struct({
296
+ * title: Primitive.String(),
297
+ * completed: Primitive.Boolean(),
298
+ * });
299
+ *
300
+ * // Create the Mimic route layer with defaults
301
+ * const MimicRoute = MimicServer.layerHttpLayerRouter({
302
+ * basePath: "/mimic/todo",
303
+ * schema: TodoSchema
304
+ * });
305
+ *
306
+ * // Or with custom auth - use Layer.provide to inject before defaults
307
+ * const MimicRouteWithAuth = MimicServer.layerHttpLayerRouter({
308
+ * basePath: "/mimic/todo",
309
+ * schema: TodoSchema,
310
+ * authLayer: MimicAuthService.layer({
311
+ * authHandler: (token) => ({ success: true, userId: token })
312
+ * })
313
+ * });
314
+ *
315
+ * // Merge with other routes and serve
316
+ * const AllRoutes = Layer.mergeAll(MimicRoute, OtherRoutes);
317
+ * HttpLayerRouter.serve(AllRoutes).pipe(
318
+ * Layer.provide(BunHttpServer.layer({ port: 3000 })),
319
+ * Layer.launch,
320
+ * BunRuntime.runMain
321
+ * );
322
+ * ```
323
+ */
324
+ export const layerHttpLayerRouter = <TSchema extends Primitive.AnyPrimitive>(
325
+ options: MimicLayerOptions<TSchema> & {
326
+ /** Custom auth layer. Defaults to NoAuth (all connections allowed). */
327
+ readonly authLayer?: Layer.Layer<MimicAuthServiceTag>;
328
+ /** Custom storage layer. Defaults to InMemoryDataStorage. */
329
+ readonly storageLayer?: Layer.Layer<MimicDataStorageTag>;
330
+ }
331
+ ): Layer.Layer<never, never, HttpLayerRouter.HttpRouter> => {
332
+ // Build the base path pattern for WebSocket routes
333
+ // Append /doc/* to match /basePath/doc/{documentId}
334
+ const basePath = options.basePath ?? "/mimic";
335
+ const wsPath: PathInput = `${basePath}/doc/*` as PathInput;
336
+
337
+ // Create the config layer
338
+ const configLayer = MimicConfig.layer({
339
+ schema: options.schema,
340
+ maxTransactionHistory: options.maxTransactionHistory,
341
+ presence: options.presence,
342
+ });
343
+
344
+ // Use provided layers or defaults
345
+ const authLayer = options.authLayer ?? NoAuth.layerDefault;
346
+ const storageLayer = options.storageLayer ?? InMemoryDataStorage.layerDefault;
347
+
348
+ // Create the route registration effect
349
+ const registerRoute = Effect.gen(function* () {
350
+ const router = yield* HttpLayerRouter.HttpRouter;
351
+ const handler = yield* makeMimicHandler;
352
+ yield* router.add("GET", wsPath, handler);
353
+ });
354
+
355
+ // Build the layer with all dependencies
356
+ return Layer.scopedDiscard(registerRoute).pipe(
357
+ Layer.provide(DocumentManager.layer),
358
+ Layer.provide(PresenceManager.layer),
359
+ Layer.provide(configLayer),
360
+ Layer.provide(storageLayer),
361
+ Layer.provide(authLayer)
362
+ );
363
+ };