@voidhash/mimic-effect 0.0.3 → 0.0.5

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.
@@ -20,20 +20,6 @@ import * as NoAuth from "./auth/NoAuth.js";
20
20
  import { HttpLayerRouter, HttpServerRequest, HttpServerResponse } from "@effect/platform";
21
21
  import { PathInput } from "@effect/platform/HttpRouter";
22
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
23
  // =============================================================================
38
24
  // Layer Composition Options
39
25
  // =============================================================================
@@ -41,7 +27,9 @@ export class MimicWebSocketHandler extends Context.Tag(
41
27
  /**
42
28
  * Options for creating a Mimic server layer.
43
29
  */
44
- export interface MimicLayerOptions<TSchema extends Primitive.AnyPrimitive> {
30
+ export interface MimicLayerOptions<
31
+ TSchema extends Primitive.AnyPrimitive,
32
+ > {
45
33
  /**
46
34
  * Base path for document routes (used for path matching).
47
35
  * @example "/mimic/todo" - documents accessed at "/mimic/todo/:documentId"
@@ -61,121 +49,23 @@ export interface MimicLayerOptions<TSchema extends Primitive.AnyPrimitive> {
61
49
  * When provided, enables presence features on WebSocket connections.
62
50
  */
63
51
  readonly presence?: Presence.AnyPresence;
52
+ /**
53
+ * Initial state for new documents.
54
+ * Can be either:
55
+ * - A plain object with the initial state values
56
+ * - A function that receives context (with documentId) and returns an Effect producing the initial state
57
+ *
58
+ * When using a function that requires Effect services (has R requirements),
59
+ * you must also provide `initialLayer` to supply those dependencies.
60
+ *
61
+ * Type-safe: required fields (without defaults) must be provided,
62
+ * while optional fields and fields with defaults can be omitted.
63
+ *
64
+ * @default undefined (documents start empty or use schema defaults)
65
+ */
66
+ readonly initial?: Primitive.InferSetInput<TSchema> | MimicConfig.InitialFn<TSchema>;
64
67
  }
65
68
 
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
69
 
180
70
  /**
181
71
  * Create the document manager layer.
@@ -190,40 +80,6 @@ export const documentManagerLayer = <TSchema extends Primitive.AnyPrimitive>(
190
80
  Layer.provide(NoAuth.layerDefault)
191
81
  );
192
82
 
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
83
  /**
228
84
  * Create the HTTP handler effect for WebSocket upgrade.
229
85
  * This handler:
@@ -275,6 +131,17 @@ const makeMimicHandler = Effect.gen(function* () {
275
131
 
276
132
 
277
133
 
134
+ /**
135
+ * Options for layerHttpLayerRouter including optional custom layers.
136
+ */
137
+ export interface MimicLayerRouterOptions<TSchema extends Primitive.AnyPrimitive>
138
+ extends MimicLayerOptions<TSchema> {
139
+ /** Custom auth layer. Defaults to NoAuth (all connections allowed). */
140
+ readonly authLayer?: Layer.Layer<MimicAuthServiceTag>;
141
+ /** Custom storage layer. Defaults to InMemoryDataStorage. */
142
+ readonly storageLayer?: Layer.Layer<MimicDataStorageTag>;
143
+ }
144
+
278
145
  /**
279
146
  * Create a Mimic server layer that integrates with HttpLayerRouter.
280
147
  *
@@ -321,43 +188,52 @@ const makeMimicHandler = Effect.gen(function* () {
321
188
  * );
322
189
  * ```
323
190
  */
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
- ) => {
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)
191
+ export const layerHttpLayerRouter = <
192
+ TSchema extends Primitive.AnyPrimitive,
193
+ TError,
194
+ TRequirements
195
+ >(
196
+ optionsEf: Effect.Effect<MimicLayerRouterOptions<TSchema>, TError, TRequirements>
197
+ ): Layer.Layer<never, TError, TRequirements | HttpLayerRouter.HttpRouter> => {
198
+ return Layer.unwrapScoped(
199
+ Effect.gen(function* () {
200
+ const options = yield* optionsEf;
201
+
202
+ // Build the base path pattern for WebSocket routes
203
+ // Append /doc/* to match /basePath/doc/{documentId}
204
+ const basePath = options.basePath ?? "/mimic";
205
+ const wsPath: PathInput = `${basePath}/doc/*` as PathInput;
206
+
207
+ // Create the config layer with properly typed initial function
208
+ const configLayer = MimicConfig.layer<TSchema>({
209
+ schema: options.schema,
210
+ maxTransactionHistory: options.maxTransactionHistory,
211
+ presence: options.presence,
212
+ initial: options.initial,
213
+ });
214
+
215
+ // Use provided layers or defaults
216
+ const authLayer = options.authLayer ?? NoAuth.layerDefault;
217
+ const storageLayer = options.storageLayer ?? InMemoryDataStorage.layerDefault;
218
+
219
+ // Combine all dependency layers
220
+ const depsLayer = Layer.mergeAll(configLayer, authLayer, storageLayer);
221
+
222
+ // Create the route registration layer
223
+ const routeLayer = Layer.scopedDiscard(
224
+ Effect.gen(function* () {
225
+ const router = yield* HttpLayerRouter.HttpRouter;
226
+ const handler = yield* makeMimicHandler;
227
+ yield* router.add("GET", wsPath, handler);
228
+ })
229
+ );
230
+
231
+ // Build the complete layer with all dependencies provided
232
+ return routeLayer.pipe(
233
+ Layer.provide(DocumentManager.layer),
234
+ Layer.provide(PresenceManager.layer),
235
+ Layer.provide(depsLayer),
236
+ );
237
+ })
362
238
  );
363
239
  };
@@ -337,4 +337,128 @@ describe("DocumentManager", () => {
337
337
  expect(result).toBe(true);
338
338
  });
339
339
  });
340
+
341
+ describe("initial state", () => {
342
+ const makeTestLayerWithInitial = (initial: { title?: string; count?: number }) => {
343
+ const configLayer = MimicConfig.layer({
344
+ schema: TestSchema,
345
+ maxTransactionHistory: 100,
346
+ initial,
347
+ });
348
+
349
+ return DocumentManager.layer.pipe(
350
+ Layer.provide(configLayer),
351
+ Layer.provide(InMemoryDataStorage.layer)
352
+ );
353
+ };
354
+
355
+ const makeTestLayerWithInitialFn = (
356
+ initialFn: (ctx: { documentId: string }) => Effect.Effect<{ title?: string; count?: number }>
357
+ ) => {
358
+ const configLayer = MimicConfig.layer({
359
+ schema: TestSchema,
360
+ maxTransactionHistory: 100,
361
+ initial: initialFn,
362
+ });
363
+
364
+ return DocumentManager.layer.pipe(
365
+ Layer.provide(configLayer),
366
+ Layer.provide(InMemoryDataStorage.layer)
367
+ );
368
+ };
369
+
370
+ it("should use initial state for new documents", async () => {
371
+ const result = await Effect.runPromise(
372
+ Effect.gen(function* () {
373
+ const manager = yield* DocumentManager.DocumentManagerTag;
374
+ return yield* manager.getSnapshot("new-doc");
375
+ }).pipe(Effect.provide(makeTestLayerWithInitial({ title: "Initial Title", count: 42 })))
376
+ );
377
+
378
+ expect(result.type).toBe("snapshot");
379
+ expect(result.version).toBe(0);
380
+ expect(result.state).toEqual({ title: "Initial Title", count: 42 });
381
+ });
382
+
383
+ it("should apply defaults for omitted fields in initial state", async () => {
384
+ const result = await Effect.runPromise(
385
+ Effect.gen(function* () {
386
+ const manager = yield* DocumentManager.DocumentManagerTag;
387
+ return yield* manager.getSnapshot("new-doc");
388
+ }).pipe(Effect.provide(makeTestLayerWithInitial({ title: "Only Title" })))
389
+ );
390
+
391
+ expect(result.type).toBe("snapshot");
392
+ // count should be 0 (default)
393
+ expect(result.state).toEqual({ title: "Only Title", count: 0 });
394
+ });
395
+
396
+ it("should prefer stored state over initial state", async () => {
397
+ const result = await Effect.runPromise(
398
+ Effect.gen(function* () {
399
+ const manager = yield* DocumentManager.DocumentManagerTag;
400
+
401
+ // Apply a transaction to modify the document
402
+ const tx = createValidTransaction("tx-1", "Modified Title");
403
+ yield* manager.submit("doc-1", tx);
404
+
405
+ // Get snapshot - should show modified state, not initial
406
+ return yield* manager.getSnapshot("doc-1");
407
+ }).pipe(Effect.provide(makeTestLayerWithInitial({ title: "Initial Title", count: 42 })))
408
+ );
409
+
410
+ expect(result.type).toBe("snapshot");
411
+ expect((result.state as any).title).toBe("Modified Title");
412
+ // count should still be 42 since we only modified title
413
+ expect((result.state as any).count).toBe(42);
414
+ });
415
+
416
+ it("should use initial state function with documentId for new documents", async () => {
417
+ const result = await Effect.runPromise(
418
+ Effect.gen(function* () {
419
+ const manager = yield* DocumentManager.DocumentManagerTag;
420
+ return yield* manager.getSnapshot("my-special-doc");
421
+ }).pipe(Effect.provide(makeTestLayerWithInitialFn(
422
+ ({ documentId }) => Effect.succeed({ title: `Doc: ${documentId}`, count: documentId.length })
423
+ )))
424
+ );
425
+
426
+ expect(result.type).toBe("snapshot");
427
+ expect(result.version).toBe(0);
428
+ expect(result.state).toEqual({ title: "Doc: my-special-doc", count: 14 });
429
+ });
430
+
431
+ it("should call initial function with different documentIds", async () => {
432
+ const layer = makeTestLayerWithInitialFn(
433
+ ({ documentId }) => Effect.succeed({ title: documentId, count: documentId.length })
434
+ );
435
+
436
+ const result = await Effect.runPromise(
437
+ Effect.gen(function* () {
438
+ const manager = yield* DocumentManager.DocumentManagerTag;
439
+ const snap1 = yield* manager.getSnapshot("short");
440
+ const snap2 = yield* manager.getSnapshot("longer-document-id");
441
+ return { snap1, snap2 };
442
+ }).pipe(Effect.provide(layer))
443
+ );
444
+
445
+ expect(result.snap1.state).toEqual({ title: "short", count: 5 });
446
+ expect(result.snap2.state).toEqual({ title: "longer-document-id", count: 18 });
447
+ });
448
+
449
+ it("should apply defaults to initial function result", async () => {
450
+ const result = await Effect.runPromise(
451
+ Effect.gen(function* () {
452
+ const manager = yield* DocumentManager.DocumentManagerTag;
453
+ return yield* manager.getSnapshot("test-doc");
454
+ }).pipe(Effect.provide(makeTestLayerWithInitialFn(
455
+ ({ documentId }) => Effect.succeed({ title: documentId }) // count omitted
456
+ )))
457
+ );
458
+
459
+ expect(result.type).toBe("snapshot");
460
+ // count should be 0 (default)
461
+ expect(result.state).toEqual({ title: "test-doc", count: 0 });
462
+ });
463
+ });
340
464
  });
@@ -172,4 +172,119 @@ describe("MimicConfig", () => {
172
172
  expect(config.presence).toBe(CursorPresence);
173
173
  });
174
174
  });
175
+
176
+ describe("initial state configuration", () => {
177
+ it("should have undefined initial state by default", () => {
178
+ const config = MimicConfig.make({
179
+ schema: TestSchema,
180
+ });
181
+
182
+ expect(config.initial).toBeUndefined();
183
+ });
184
+
185
+ it("should accept initial state object and convert to function", async () => {
186
+ const config = MimicConfig.make({
187
+ schema: TestSchema,
188
+ initial: { title: "My Document", count: 42 },
189
+ });
190
+
191
+ expect(typeof config.initial).toBe("function");
192
+ // The function should return the initial state when called
193
+ const result = await Effect.runPromise(config.initial!({ documentId: "test-doc" }));
194
+ expect(result).toEqual({ title: "My Document", count: 42 });
195
+ });
196
+
197
+ it("should apply defaults for omitted fields in initial state object", async () => {
198
+ const config = MimicConfig.make({
199
+ schema: TestSchema,
200
+ initial: { title: "My Document" }, // count has default of 0
201
+ });
202
+
203
+ const result = await Effect.runPromise(config.initial!({ documentId: "test-doc" }));
204
+ expect(result).toEqual({ title: "My Document", count: 0 });
205
+ });
206
+
207
+ it("should accept initial state function", async () => {
208
+ const config = MimicConfig.make({
209
+ schema: TestSchema,
210
+ initial: ({ documentId }) => Effect.succeed({ title: `Doc ${documentId}`, count: 123 }),
211
+ });
212
+
213
+ expect(typeof config.initial).toBe("function");
214
+ const result = await Effect.runPromise(config.initial!({ documentId: "my-doc-id" }));
215
+ expect(result).toEqual({ title: "Doc my-doc-id", count: 123 });
216
+ });
217
+
218
+ it("should apply defaults to initial state function result", async () => {
219
+ const config = MimicConfig.make({
220
+ schema: TestSchema,
221
+ initial: ({ documentId }) => Effect.succeed({ title: documentId }), // count omitted
222
+ });
223
+
224
+ const result = await Effect.runPromise(config.initial!({ documentId: "test" }));
225
+ expect(result).toEqual({ title: "test", count: 0 });
226
+ });
227
+
228
+ it("should provide initial function through layer", async () => {
229
+ const testLayer = MimicConfig.layer({
230
+ schema: TestSchema,
231
+ initial: { title: "From Layer", count: 100 },
232
+ });
233
+
234
+ const result = await Effect.runPromise(
235
+ Effect.gen(function* () {
236
+ const config = yield* MimicConfig.MimicServerConfigTag;
237
+ return yield* config.initial!({ documentId: "layer-doc" });
238
+ }).pipe(Effect.provide(testLayer))
239
+ );
240
+
241
+ expect(result).toEqual({ title: "From Layer", count: 100 });
242
+ });
243
+
244
+ it("should work with schema that has required fields without defaults", async () => {
245
+ const SchemaWithRequired = Primitive.Struct({
246
+ name: Primitive.String().required(),
247
+ optional: Primitive.String().default("default"),
248
+ });
249
+
250
+ const config = MimicConfig.make({
251
+ schema: SchemaWithRequired,
252
+ initial: { name: "Required Name" },
253
+ });
254
+
255
+ const result = await Effect.runPromise(config.initial!({ documentId: "test" }));
256
+ expect(result).toEqual({ name: "Required Name", optional: "default" });
257
+ });
258
+
259
+ it("should work with all options including initial object", async () => {
260
+ const config = MimicConfig.make({
261
+ schema: TestSchema,
262
+ maxIdleTime: "10 minutes",
263
+ maxTransactionHistory: 500,
264
+ initial: { title: "Full Options", count: 999 },
265
+ });
266
+
267
+ expect(config.schema).toBe(TestSchema);
268
+ expect(Duration.toMillis(config.maxIdleTime)).toBe(10 * 60 * 1000);
269
+ expect(config.maxTransactionHistory).toBe(500);
270
+ const result = await Effect.runPromise(config.initial!({ documentId: "test" }));
271
+ expect(result).toEqual({ title: "Full Options", count: 999 });
272
+ });
273
+
274
+ it("should work with initial function that uses documentId", async () => {
275
+ const config = MimicConfig.make({
276
+ schema: TestSchema,
277
+ initial: ({ documentId }) => Effect.succeed({
278
+ title: `Document: ${documentId}`,
279
+ count: documentId.length,
280
+ }),
281
+ });
282
+
283
+ const result1 = await Effect.runPromise(config.initial!({ documentId: "short" }));
284
+ expect(result1).toEqual({ title: "Document: short", count: 5 });
285
+
286
+ const result2 = await Effect.runPromise(config.initial!({ documentId: "longer-id" }));
287
+ expect(result2).toEqual({ title: "Document: longer-id", count: 9 });
288
+ });
289
+ });
175
290
  });