@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,46 @@
1
+ /**
2
+ * @since 0.0.1
3
+ * No authentication implementation for Mimic connections.
4
+ * All connections are automatically authenticated (open access).
5
+ */
6
+ import * as Effect from "effect/Effect";
7
+ import * as Layer from "effect/Layer";
8
+
9
+ import {
10
+ MimicAuthServiceTag,
11
+ type MimicAuthService,
12
+ } from "../MimicAuthService.js";
13
+
14
+ // =============================================================================
15
+ // No-Auth Implementation
16
+ // =============================================================================
17
+
18
+ /**
19
+ * Authentication service that auto-succeeds all authentication requests.
20
+ * Use this for development or when authentication is handled externally.
21
+ */
22
+ const noAuthService: MimicAuthService = {
23
+ authenticate: (_token: string) =>
24
+ Effect.succeed({ success: true as const }),
25
+ };
26
+
27
+ // =============================================================================
28
+ // Layer
29
+ // =============================================================================
30
+
31
+ /**
32
+ * Layer that provides no authentication (open access).
33
+ * All connections are automatically authenticated.
34
+ *
35
+ * WARNING: Only use this for development or when authentication
36
+ * is handled at a different layer (e.g., API gateway, reverse proxy).
37
+ */
38
+ export const layer: Layer.Layer<MimicAuthServiceTag> = Layer.succeed(
39
+ MimicAuthServiceTag,
40
+ noAuthService
41
+ );
42
+
43
+ /**
44
+ * Default layer alias for convenience.
45
+ */
46
+ export const layerDefault = layer;
package/src/errors.ts ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * @since 0.0.1
3
+ * Error types for the Mimic server.
4
+ */
5
+ import * as Data from "effect/Data";
6
+
7
+ // =============================================================================
8
+ // Error Types
9
+ // =============================================================================
10
+
11
+ /**
12
+ * Error when a document type is not found in the schema registry.
13
+ */
14
+ export class DocumentTypeNotFoundError extends Data.TaggedError(
15
+ "DocumentTypeNotFoundError"
16
+ )<{
17
+ readonly documentType: string;
18
+ }> {
19
+ get message(): string {
20
+ return `Document type not found: ${this.documentType}`;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Error when a document is not found.
26
+ */
27
+ export class DocumentNotFoundError extends Data.TaggedError(
28
+ "DocumentNotFoundError"
29
+ )<{
30
+ readonly documentId: string;
31
+ }> {
32
+ get message(): string {
33
+ return `Document not found: ${this.documentId}`;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Error when authentication fails.
39
+ */
40
+ export class AuthenticationError extends Data.TaggedError(
41
+ "AuthenticationError"
42
+ )<{
43
+ readonly reason: string;
44
+ }> {
45
+ get message(): string {
46
+ return `Authentication failed: ${this.reason}`;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Error when a transaction is rejected.
52
+ */
53
+ export class TransactionRejectedError extends Data.TaggedError(
54
+ "TransactionRejectedError"
55
+ )<{
56
+ readonly transactionId: string;
57
+ readonly reason: string;
58
+ }> {
59
+ get message(): string {
60
+ return `Transaction ${this.transactionId} rejected: ${this.reason}`;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Error when parsing a client message fails.
66
+ */
67
+ export class MessageParseError extends Data.TaggedError("MessageParseError")<{
68
+ readonly cause: unknown;
69
+ }> {
70
+ get message(): string {
71
+ return `Failed to parse message: ${String(this.cause)}`;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Error when the WebSocket connection is invalid.
77
+ */
78
+ export class InvalidConnectionError extends Data.TaggedError(
79
+ "InvalidConnectionError"
80
+ )<{
81
+ readonly reason: string;
82
+ }> {
83
+ get message(): string {
84
+ return `Invalid connection: ${this.reason}`;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Error when the document ID is missing from the URL path.
90
+ */
91
+ export class MissingDocumentIdError extends Data.TaggedError(
92
+ "MissingDocumentIdError"
93
+ )<{
94
+ readonly path?: string;
95
+ }> {
96
+ get message(): string {
97
+ return this.path
98
+ ? `Document ID is required in the URL path: ${this.path}`
99
+ : "Document ID is required in the URL path";
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Union of all Mimic server errors.
105
+ */
106
+ export type MimicServerError =
107
+ | DocumentTypeNotFoundError
108
+ | DocumentNotFoundError
109
+ | AuthenticationError
110
+ | TransactionRejectedError
111
+ | MessageParseError
112
+ | InvalidConnectionError
113
+ | MissingDocumentIdError;
package/src/index.ts ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @voidhash/mimic-server-effect
3
+ *
4
+ * Effect-based server implementation for Mimic sync engine.
5
+ *
6
+ * @since 0.0.1
7
+ */
8
+
9
+ // =============================================================================
10
+ // Main Server
11
+ // =============================================================================
12
+
13
+ export * as MimicServer from "./MimicServer.js";
14
+
15
+ // =============================================================================
16
+ // Service Interfaces
17
+ // =============================================================================
18
+
19
+ export * as MimicDataStorage from "./MimicDataStorage.js";
20
+ export * as MimicAuthService from "./MimicAuthService.js";
21
+
22
+ // =============================================================================
23
+ // Default Implementations
24
+ // =============================================================================
25
+
26
+ export * as MimicInMemoryDataStorage from "./storage/InMemoryDataStorage.js";
27
+ export * as MimicNoAuth from "./auth/NoAuth.js";
28
+
29
+ // =============================================================================
30
+ // Configuration
31
+ // =============================================================================
32
+
33
+ export * as MimicConfig from "./MimicConfig.js";
34
+
35
+ // =============================================================================
36
+ // Internal Components (for advanced usage)
37
+ // =============================================================================
38
+
39
+ export * as DocumentManager from "./DocumentManager.js";
40
+ export * as PresenceManager from "./PresenceManager.js";
41
+ export * as WebSocketHandler from "./WebSocketHandler.js";
42
+ export * as DocumentProtocol from "./DocumentProtocol.js";
43
+
44
+ // =============================================================================
45
+ // Errors
46
+ // =============================================================================
47
+
48
+ export * from "./errors.js";
@@ -0,0 +1,66 @@
1
+ /**
2
+ * @since 0.0.1
3
+ * In-memory data storage implementation for Mimic documents.
4
+ * Provides ephemeral storage - data is lost when the server restarts.
5
+ */
6
+ import * as Effect from "effect/Effect";
7
+ import * as Layer from "effect/Layer";
8
+ import * as Ref from "effect/Ref";
9
+ import * as HashMap from "effect/HashMap";
10
+
11
+ import {
12
+ MimicDataStorageTag,
13
+ type MimicDataStorage,
14
+ } from "../MimicDataStorage.js";
15
+
16
+ // =============================================================================
17
+ // In-Memory Storage Implementation
18
+ // =============================================================================
19
+
20
+ /**
21
+ * Create an in-memory storage service.
22
+ * Uses a HashMap to store documents in memory.
23
+ */
24
+ const makeInMemoryStorage = Effect.gen(function* () {
25
+ // Create a mutable reference to a HashMap for storing documents
26
+ const store = yield* Ref.make(HashMap.empty<string, unknown>());
27
+
28
+ const storage: MimicDataStorage = {
29
+ load: (documentId: string) =>
30
+ Effect.gen(function* () {
31
+ const current = yield* Ref.get(store);
32
+ const result = HashMap.get(current, documentId);
33
+ return result._tag === "Some" ? result.value : undefined;
34
+ }),
35
+
36
+ save: (documentId: string, state: unknown) =>
37
+ Ref.update(store, (map) => HashMap.set(map, documentId, state)),
38
+
39
+ delete: (documentId: string) =>
40
+ Ref.update(store, (map) => HashMap.remove(map, documentId)),
41
+
42
+ onLoad: (state: unknown) => Effect.succeed(state),
43
+
44
+ onSave: (state: unknown) => Effect.succeed(state),
45
+ };
46
+
47
+ return storage;
48
+ });
49
+
50
+ // =============================================================================
51
+ // Layer
52
+ // =============================================================================
53
+
54
+ /**
55
+ * Layer that provides in-memory data storage.
56
+ * This is the default storage implementation - ephemeral and non-persistent.
57
+ */
58
+ export const layer: Layer.Layer<MimicDataStorageTag> = Layer.effect(
59
+ MimicDataStorageTag,
60
+ makeInMemoryStorage
61
+ );
62
+
63
+ /**
64
+ * Default layer alias for convenience.
65
+ */
66
+ export const layerDefault = layer;
@@ -0,0 +1,340 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Stream from "effect/Stream";
4
+ import * as Layer from "effect/Layer";
5
+ import * as Fiber from "effect/Fiber";
6
+ import { Primitive, OperationPath, Document, Transaction } from "@voidhash/mimic";
7
+ import * as DocumentManager from "../src/DocumentManager";
8
+ import * as MimicConfig from "../src/MimicConfig";
9
+ import * as InMemoryDataStorage from "../src/storage/InMemoryDataStorage";
10
+
11
+ // =============================================================================
12
+ // Test Schema
13
+ // =============================================================================
14
+
15
+ const TestSchema = Primitive.Struct({
16
+ title: Primitive.String().default(""),
17
+ count: Primitive.Number().default(0),
18
+ });
19
+
20
+ // =============================================================================
21
+ // Test Layer
22
+ // =============================================================================
23
+
24
+ const makeTestLayer = () => {
25
+ const configLayer = MimicConfig.layer({
26
+ schema: TestSchema,
27
+ maxTransactionHistory: 100,
28
+ });
29
+
30
+ return DocumentManager.layer.pipe(
31
+ Layer.provide(configLayer),
32
+ Layer.provide(InMemoryDataStorage.layer)
33
+ );
34
+ };
35
+
36
+ // =============================================================================
37
+ // Helper Functions
38
+ // =============================================================================
39
+
40
+ /**
41
+ * Create a valid operation using the Document API
42
+ */
43
+ const createValidTransaction = (id: string, title: string): Transaction.Transaction => {
44
+ const doc = Document.make(TestSchema);
45
+ doc.transaction((root) => {
46
+ root.title.set(title);
47
+ });
48
+ const tx = doc.flush();
49
+ // Override the ID to make it deterministic for tests
50
+ return {
51
+ ...tx,
52
+ id,
53
+ };
54
+ };
55
+
56
+ const createEmptyTransaction = (id: string): Transaction.Transaction => ({
57
+ id,
58
+ ops: [],
59
+ timestamp: Date.now(),
60
+ });
61
+
62
+ // =============================================================================
63
+ // DocumentManager Tests
64
+ // =============================================================================
65
+
66
+ describe("DocumentManager", () => {
67
+ describe("submit", () => {
68
+ it("should accept valid transactions", async () => {
69
+ const result = await Effect.runPromise(
70
+ Effect.gen(function* () {
71
+ const manager = yield* DocumentManager.DocumentManagerTag;
72
+ const tx = createValidTransaction("tx-1", "Hello World");
73
+ return yield* manager.submit("doc-1", tx);
74
+ }).pipe(Effect.provide(makeTestLayer()))
75
+ );
76
+
77
+ expect(result.success).toBe(true);
78
+ if (result.success) {
79
+ expect(result.version).toBe(1);
80
+ }
81
+ });
82
+
83
+ it("should reject empty transactions", async () => {
84
+ const result = await Effect.runPromise(
85
+ Effect.gen(function* () {
86
+ const manager = yield* DocumentManager.DocumentManagerTag;
87
+ const tx = createEmptyTransaction("tx-empty");
88
+ return yield* manager.submit("doc-1", tx);
89
+ }).pipe(Effect.provide(makeTestLayer()))
90
+ );
91
+
92
+ expect(result.success).toBe(false);
93
+ if (!result.success) {
94
+ expect(result.reason).toBe("Transaction is empty");
95
+ }
96
+ });
97
+
98
+ it("should reject duplicate transactions", async () => {
99
+ const result = await Effect.runPromise(
100
+ Effect.gen(function* () {
101
+ const manager = yield* DocumentManager.DocumentManagerTag;
102
+ const tx = createValidTransaction("tx-dup", "First");
103
+
104
+ // Submit first time
105
+ const first = yield* manager.submit("doc-1", tx);
106
+
107
+ // Submit same transaction again
108
+ const second = yield* manager.submit("doc-1", tx);
109
+
110
+ return { first, second };
111
+ }).pipe(Effect.provide(makeTestLayer()))
112
+ );
113
+
114
+ expect(result.first.success).toBe(true);
115
+ expect(result.second.success).toBe(false);
116
+ if (!result.second.success) {
117
+ expect(result.second.reason).toBe(
118
+ "Transaction has already been processed"
119
+ );
120
+ }
121
+ });
122
+
123
+ it("should increment version with each successful transaction", async () => {
124
+ const result = await Effect.runPromise(
125
+ Effect.gen(function* () {
126
+ const manager = yield* DocumentManager.DocumentManagerTag;
127
+
128
+ const tx1 = createValidTransaction("tx-1", "One");
129
+ const tx2 = createValidTransaction("tx-2", "Two");
130
+ const tx3 = createValidTransaction("tx-3", "Three");
131
+
132
+ const r1 = yield* manager.submit("doc-1", tx1);
133
+ const r2 = yield* manager.submit("doc-1", tx2);
134
+ const r3 = yield* manager.submit("doc-1", tx3);
135
+
136
+ return { r1, r2, r3 };
137
+ }).pipe(Effect.provide(makeTestLayer()))
138
+ );
139
+
140
+ expect(result.r1.success).toBe(true);
141
+ expect(result.r2.success).toBe(true);
142
+ expect(result.r3.success).toBe(true);
143
+
144
+ if (result.r1.success && result.r2.success && result.r3.success) {
145
+ expect(result.r1.version).toBe(1);
146
+ expect(result.r2.version).toBe(2);
147
+ expect(result.r3.version).toBe(3);
148
+ }
149
+ });
150
+
151
+ it("should handle different documents independently", async () => {
152
+ const result = await Effect.runPromise(
153
+ Effect.gen(function* () {
154
+ const manager = yield* DocumentManager.DocumentManagerTag;
155
+
156
+ const txDoc1 = createValidTransaction("tx-doc1", "Doc 1");
157
+ const txDoc2 = createValidTransaction("tx-doc2", "Doc 2");
158
+
159
+ const r1 = yield* manager.submit("doc-1", txDoc1);
160
+ const r2 = yield* manager.submit("doc-2", txDoc2);
161
+
162
+ return { r1, r2 };
163
+ }).pipe(Effect.provide(makeTestLayer()))
164
+ );
165
+
166
+ expect(result.r1.success).toBe(true);
167
+ expect(result.r2.success).toBe(true);
168
+
169
+ // Both should have version 1 since they are independent documents
170
+ if (result.r1.success && result.r2.success) {
171
+ expect(result.r1.version).toBe(1);
172
+ expect(result.r2.version).toBe(1);
173
+ }
174
+ });
175
+ });
176
+
177
+ describe("getSnapshot", () => {
178
+ it("should return initial snapshot for new document", async () => {
179
+ const result = await Effect.runPromise(
180
+ Effect.gen(function* () {
181
+ const manager = yield* DocumentManager.DocumentManagerTag;
182
+ return yield* manager.getSnapshot("new-doc");
183
+ }).pipe(Effect.provide(makeTestLayer()))
184
+ );
185
+
186
+ expect(result.type).toBe("snapshot");
187
+ expect(result.version).toBe(0);
188
+ // Initial state from schema defaults
189
+ expect(result.state).toEqual({ title: "", count: 0 });
190
+ });
191
+
192
+ it("should return current state after transactions", async () => {
193
+ const result = await Effect.runPromise(
194
+ Effect.gen(function* () {
195
+ const manager = yield* DocumentManager.DocumentManagerTag;
196
+
197
+ // Apply a transaction
198
+ const tx = createValidTransaction("tx-1", "Updated Title");
199
+ yield* manager.submit("doc-1", tx);
200
+
201
+ return yield* manager.getSnapshot("doc-1");
202
+ }).pipe(Effect.provide(makeTestLayer()))
203
+ );
204
+
205
+ expect(result.type).toBe("snapshot");
206
+ expect(result.version).toBe(1);
207
+ expect((result.state as any).title).toBe("Updated Title");
208
+ });
209
+
210
+ it("should return snapshot for specific document", async () => {
211
+ const result = await Effect.runPromise(
212
+ Effect.gen(function* () {
213
+ const manager = yield* DocumentManager.DocumentManagerTag;
214
+
215
+ // Apply transactions to different documents
216
+ const tx1 = createValidTransaction("tx-1", "Doc One");
217
+ const tx2 = createValidTransaction("tx-2", "Doc Two");
218
+
219
+ yield* manager.submit("doc-1", tx1);
220
+ yield* manager.submit("doc-2", tx2);
221
+
222
+ const snap1 = yield* manager.getSnapshot("doc-1");
223
+ const snap2 = yield* manager.getSnapshot("doc-2");
224
+
225
+ return { snap1, snap2 };
226
+ }).pipe(Effect.provide(makeTestLayer()))
227
+ );
228
+
229
+ expect((result.snap1.state as any).title).toBe("Doc One");
230
+ expect((result.snap2.state as any).title).toBe("Doc Two");
231
+ });
232
+ });
233
+
234
+ describe("subscribe", () => {
235
+ it("should receive broadcasts for submitted transactions", async () => {
236
+ const result = await Effect.runPromise(
237
+ Effect.gen(function* () {
238
+ const manager = yield* DocumentManager.DocumentManagerTag;
239
+
240
+ // Subscribe to the document
241
+ const broadcastStream = yield* manager.subscribe("doc-1");
242
+
243
+ // Submit a transaction
244
+ const tx = createValidTransaction("tx-broadcast", "Broadcast Test");
245
+
246
+ // Start collecting broadcasts in parallel
247
+ const collectFiber = yield* Effect.fork(
248
+ broadcastStream.pipe(Stream.take(1), Stream.runCollect)
249
+ );
250
+
251
+ // Small delay to ensure subscription is ready
252
+ yield* Effect.sleep(50);
253
+
254
+ // Submit the transaction
255
+ yield* manager.submit("doc-1", tx);
256
+
257
+ // Wait for the broadcast with Fiber.join
258
+ const broadcasts = yield* Fiber.join(collectFiber).pipe(
259
+ Effect.timeout(2000)
260
+ );
261
+
262
+ return broadcasts;
263
+ }).pipe(Effect.scoped, Effect.provide(makeTestLayer()))
264
+ );
265
+
266
+ expect(result).toBeDefined();
267
+ if (result) {
268
+ const broadcasts = Array.from(result);
269
+ expect(broadcasts.length).toBe(1);
270
+ expect(broadcasts[0].type).toBe("transaction");
271
+ }
272
+ });
273
+
274
+ it("should broadcast to multiple subscribers", async () => {
275
+ const result = await Effect.runPromise(
276
+ Effect.gen(function* () {
277
+ const manager = yield* DocumentManager.DocumentManagerTag;
278
+
279
+ // Subscribe twice to the same document
280
+ const stream1 = yield* manager.subscribe("doc-1");
281
+ const stream2 = yield* manager.subscribe("doc-1");
282
+
283
+ // Start collecting broadcasts in parallel
284
+ const collectFiber1 = yield* Effect.fork(
285
+ stream1.pipe(Stream.take(1), Stream.runCollect)
286
+ );
287
+ const collectFiber2 = yield* Effect.fork(
288
+ stream2.pipe(Stream.take(1), Stream.runCollect)
289
+ );
290
+
291
+ // Small delay to ensure subscriptions are ready
292
+ yield* Effect.sleep(50);
293
+
294
+ // Submit a transaction
295
+ const tx = createValidTransaction("tx-multi", "Multi Broadcast");
296
+ yield* manager.submit("doc-1", tx);
297
+
298
+ // Wait for both broadcasts with Fiber.join
299
+ const broadcasts1 = yield* Fiber.join(collectFiber1).pipe(
300
+ Effect.timeout(2000)
301
+ );
302
+ const broadcasts2 = yield* Fiber.join(collectFiber2).pipe(
303
+ Effect.timeout(2000)
304
+ );
305
+
306
+ return { broadcasts1, broadcasts2 };
307
+ }).pipe(Effect.scoped, Effect.provide(makeTestLayer()))
308
+ );
309
+
310
+ expect(result.broadcasts1).toBeDefined();
311
+ expect(result.broadcasts2).toBeDefined();
312
+ if (result.broadcasts1 && result.broadcasts2) {
313
+ expect(Array.from(result.broadcasts1).length).toBe(1);
314
+ expect(Array.from(result.broadcasts2).length).toBe(1);
315
+ }
316
+ });
317
+ });
318
+
319
+ describe("DocumentManagerTag", () => {
320
+ it("should have the correct tag identifier", () => {
321
+ expect(DocumentManager.DocumentManagerTag.key).toBe(
322
+ "@voidhash/mimic-server-effect/DocumentManager"
323
+ );
324
+ });
325
+ });
326
+
327
+ describe("layer", () => {
328
+ it("should require MimicServerConfigTag and MimicDataStorageTag", async () => {
329
+ // This test verifies the layer composition works correctly
330
+ const result = await Effect.runPromise(
331
+ Effect.gen(function* () {
332
+ const manager = yield* DocumentManager.DocumentManagerTag;
333
+ return typeof manager.submit === "function";
334
+ }).pipe(Effect.provide(makeTestLayer()))
335
+ );
336
+
337
+ expect(result).toBe(true);
338
+ });
339
+ });
340
+ });