@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,321 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Layer from "effect/Layer";
4
+ import * as Stream from "effect/Stream";
5
+ import * as Chunk from "effect/Chunk";
6
+ import * as Fiber from "effect/Fiber";
7
+ import * as Schema from "effect/Schema";
8
+ import { Primitive, Presence } from "@voidhash/mimic";
9
+ import * as WebSocketHandler from "../src/WebSocketHandler";
10
+ import * as MimicConfig from "../src/MimicConfig";
11
+ import * as MimicAuthService from "../src/MimicAuthService";
12
+ import * as DocumentManager from "../src/DocumentManager";
13
+ import * as PresenceManager from "../src/PresenceManager";
14
+ import * as InMemoryDataStorage from "../src/storage/InMemoryDataStorage";
15
+ import { MissingDocumentIdError } from "../src/errors";
16
+
17
+ // =============================================================================
18
+ // Test Schema
19
+ // =============================================================================
20
+
21
+ const TestSchema = Primitive.Struct({
22
+ title: Primitive.String().default(""),
23
+ count: Primitive.Number().default(0),
24
+ });
25
+
26
+ const CursorPresence = Presence.make({
27
+ schema: Schema.Struct({
28
+ x: Schema.Number,
29
+ y: Schema.Number,
30
+ name: Schema.optional(Schema.String),
31
+ }),
32
+ });
33
+
34
+ // =============================================================================
35
+ // Test Layer Factory
36
+ // =============================================================================
37
+
38
+ const makeTestLayer = (options?: { withPresence?: boolean }) => {
39
+ const configLayer = MimicConfig.layer({
40
+ schema: TestSchema,
41
+ presence: options?.withPresence ? CursorPresence : undefined,
42
+ });
43
+
44
+ const authLayer = MimicAuthService.layer({
45
+ authHandler: (token) => ({ success: true, userId: token || "anonymous" }),
46
+ });
47
+
48
+ return Layer.mergeAll(
49
+ configLayer,
50
+ authLayer,
51
+ DocumentManager.layer.pipe(
52
+ Layer.provide(configLayer),
53
+ Layer.provide(InMemoryDataStorage.layer)
54
+ ),
55
+ PresenceManager.layer
56
+ );
57
+ };
58
+
59
+ // =============================================================================
60
+ // extractDocumentId Tests
61
+ // =============================================================================
62
+
63
+ describe("WebSocketHandler", () => {
64
+ describe("extractDocumentId", () => {
65
+ it("should extract document ID from /doc/{id} path", () => {
66
+ const result = Effect.runSync(
67
+ WebSocketHandler.extractDocumentId("/doc/my-document-id")
68
+ );
69
+ expect(result).toBe("my-document-id");
70
+ });
71
+
72
+ it("should extract document ID from /doc/{id} with leading slashes", () => {
73
+ const result = Effect.runSync(
74
+ WebSocketHandler.extractDocumentId("///doc/my-document-id")
75
+ );
76
+ expect(result).toBe("my-document-id");
77
+ });
78
+
79
+ it("should extract document ID from nested paths like /mimic/todo/doc/{id}", () => {
80
+ const result = Effect.runSync(
81
+ WebSocketHandler.extractDocumentId("/mimic/todo/doc/my-document-id")
82
+ );
83
+ expect(result).toBe("my-document-id");
84
+ });
85
+
86
+ it("should handle URL-encoded document IDs", () => {
87
+ const result = Effect.runSync(
88
+ WebSocketHandler.extractDocumentId("/doc/my%20document%3Aid")
89
+ );
90
+ expect(result).toBe("my document:id");
91
+ });
92
+
93
+ it("should handle document IDs with colons (type:id format)", () => {
94
+ const result = Effect.runSync(
95
+ WebSocketHandler.extractDocumentId("/doc/todo:abc-123")
96
+ );
97
+ expect(result).toBe("todo:abc-123");
98
+ });
99
+
100
+ it("should fail for empty path", () => {
101
+ const result = Effect.runSyncExit(
102
+ WebSocketHandler.extractDocumentId("/")
103
+ );
104
+ expect(result._tag).toBe("Failure");
105
+ });
106
+
107
+ it("should fail for /doc without document ID", () => {
108
+ const result = Effect.runSyncExit(
109
+ WebSocketHandler.extractDocumentId("/doc")
110
+ );
111
+ expect(result._tag).toBe("Failure");
112
+ });
113
+
114
+ it("should fail for /doc/ without document ID", () => {
115
+ const result = Effect.runSyncExit(
116
+ WebSocketHandler.extractDocumentId("/doc/")
117
+ );
118
+ // This will fail because after split, parts[1] will be empty string
119
+ expect(result._tag).toBe("Failure");
120
+ });
121
+ });
122
+
123
+ describe("makeHandler", () => {
124
+ it("should create a handler function", async () => {
125
+ const result = await Effect.runPromise(
126
+ Effect.gen(function* () {
127
+ const handler = yield* WebSocketHandler.makeHandler;
128
+ return typeof handler === "function";
129
+ }).pipe(Effect.provide(makeTestLayer()))
130
+ );
131
+
132
+ expect(result).toBe(true);
133
+ });
134
+
135
+ it("should create a handler with presence enabled", async () => {
136
+ const result = await Effect.runPromise(
137
+ Effect.gen(function* () {
138
+ const handler = yield* WebSocketHandler.makeHandler;
139
+ return typeof handler === "function";
140
+ }).pipe(Effect.provide(makeTestLayer({ withPresence: true })))
141
+ );
142
+
143
+ expect(result).toBe(true);
144
+ });
145
+ });
146
+
147
+ describe("presence integration with PresenceManager", () => {
148
+ it("should store presence data through PresenceManager", async () => {
149
+ // This tests that the PresenceManager is properly integrated
150
+ // with the WebSocketHandler layer composition
151
+ const result = await Effect.runPromise(
152
+ Effect.gen(function* () {
153
+ const pm = yield* PresenceManager.PresenceManagerTag;
154
+
155
+ // Simulate what the WebSocketHandler would do when receiving presence_set
156
+ yield* pm.set("doc-1", "conn-1", {
157
+ data: { x: 100, y: 200 },
158
+ userId: "user-1",
159
+ });
160
+
161
+ const snapshot = yield* pm.getSnapshot("doc-1");
162
+ return snapshot;
163
+ }).pipe(Effect.provide(makeTestLayer({ withPresence: true })))
164
+ );
165
+
166
+ expect(result.presences["conn-1"]).toEqual({
167
+ data: { x: 100, y: 200 },
168
+ userId: "user-1",
169
+ });
170
+ });
171
+
172
+ it("should remove presence data through PresenceManager", async () => {
173
+ const result = await Effect.runPromise(
174
+ Effect.gen(function* () {
175
+ const pm = yield* PresenceManager.PresenceManagerTag;
176
+
177
+ // Set presence
178
+ yield* pm.set("doc-1", "conn-1", {
179
+ data: { x: 100, y: 200 },
180
+ });
181
+
182
+ // Simulate disconnect - remove presence
183
+ yield* pm.remove("doc-1", "conn-1");
184
+
185
+ const snapshot = yield* pm.getSnapshot("doc-1");
186
+ return snapshot;
187
+ }).pipe(Effect.provide(makeTestLayer({ withPresence: true })))
188
+ );
189
+
190
+ expect(result.presences).toEqual({});
191
+ });
192
+
193
+ it("should broadcast presence events to subscribers", async () => {
194
+ const result = await Effect.runPromise(
195
+ Effect.scoped(
196
+ Effect.gen(function* () {
197
+ const pm = yield* PresenceManager.PresenceManagerTag;
198
+
199
+ // Subscribe to presence events
200
+ const eventStream = yield* pm.subscribe("doc-1");
201
+
202
+ // Collect events in background
203
+ const eventsFiber = yield* Effect.fork(
204
+ Stream.runCollect(Stream.take(eventStream, 2))
205
+ );
206
+
207
+ yield* Effect.sleep("10 millis");
208
+
209
+ // Simulate presence set and remove
210
+ yield* pm.set("doc-1", "conn-1", { data: { x: 10, y: 20 } });
211
+ yield* pm.remove("doc-1", "conn-1");
212
+
213
+ const events = yield* Fiber.join(eventsFiber);
214
+ return Chunk.toArray(events);
215
+ })
216
+ ).pipe(Effect.provide(makeTestLayer({ withPresence: true })))
217
+ );
218
+
219
+ expect(result.length).toBe(2);
220
+ expect(result[0]!.type).toBe("presence_update");
221
+ expect(result[1]!.type).toBe("presence_remove");
222
+ });
223
+ });
224
+
225
+ describe("presence validation", () => {
226
+ it("should validate presence data against schema using Presence module", () => {
227
+ // This tests the validation logic that WebSocketHandler uses
228
+ const validData = { x: 100, y: 200 };
229
+ const invalidData = { x: "invalid", y: 200 };
230
+
231
+ // Valid data should pass validation
232
+ const validated = Presence.validateSafe(CursorPresence, validData);
233
+ expect(validated).toEqual({ x: 100, y: 200 });
234
+
235
+ // Invalid data should return undefined
236
+ const invalidResult = Presence.validateSafe(CursorPresence, invalidData);
237
+ expect(invalidResult).toBeUndefined();
238
+ });
239
+
240
+ it("should handle optional fields in presence schema", () => {
241
+ // Without optional field
242
+ const withoutName = Presence.validateSafe(CursorPresence, {
243
+ x: 10,
244
+ y: 20,
245
+ });
246
+ expect(withoutName).toEqual({ x: 10, y: 20 });
247
+
248
+ // With optional field
249
+ const withName = Presence.validateSafe(CursorPresence, {
250
+ x: 10,
251
+ y: 20,
252
+ name: "Alice",
253
+ });
254
+ expect(withName).toEqual({ x: 10, y: 20, name: "Alice" });
255
+ });
256
+ });
257
+
258
+ describe("presence message types", () => {
259
+ // These tests document the expected presence message types
260
+ // that the WebSocketHandler should handle
261
+
262
+ it("should define presence_set client message format", () => {
263
+ const message = {
264
+ type: "presence_set" as const,
265
+ data: { x: 100, y: 200 },
266
+ };
267
+
268
+ expect(message.type).toBe("presence_set");
269
+ expect(message.data).toEqual({ x: 100, y: 200 });
270
+ });
271
+
272
+ it("should define presence_clear client message format", () => {
273
+ const message = {
274
+ type: "presence_clear" as const,
275
+ };
276
+
277
+ expect(message.type).toBe("presence_clear");
278
+ });
279
+
280
+ it("should define presence_snapshot server message format", () => {
281
+ const message = {
282
+ type: "presence_snapshot" as const,
283
+ selfId: "conn-123",
284
+ presences: {
285
+ "conn-456": { data: { x: 10, y: 20 }, userId: "user-1" },
286
+ },
287
+ };
288
+
289
+ expect(message.type).toBe("presence_snapshot");
290
+ expect(message.selfId).toBe("conn-123");
291
+ expect(message.presences["conn-456"]).toEqual({
292
+ data: { x: 10, y: 20 },
293
+ userId: "user-1",
294
+ });
295
+ });
296
+
297
+ it("should define presence_update server message format", () => {
298
+ const message = {
299
+ type: "presence_update" as const,
300
+ id: "conn-789",
301
+ data: { x: 50, y: 75 },
302
+ userId: "user-2",
303
+ };
304
+
305
+ expect(message.type).toBe("presence_update");
306
+ expect(message.id).toBe("conn-789");
307
+ expect(message.data).toEqual({ x: 50, y: 75 });
308
+ expect(message.userId).toBe("user-2");
309
+ });
310
+
311
+ it("should define presence_remove server message format", () => {
312
+ const message = {
313
+ type: "presence_remove" as const,
314
+ id: "conn-disconnected",
315
+ };
316
+
317
+ expect(message.type).toBe("presence_remove");
318
+ expect(message.id).toBe("conn-disconnected");
319
+ });
320
+ });
321
+ });
@@ -0,0 +1,77 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import * as errors from "../src/errors";
3
+
4
+ // =============================================================================
5
+ // Error Tests
6
+ // =============================================================================
7
+
8
+ describe("errors", () => {
9
+ describe("DocumentTypeNotFoundError", () => {
10
+ it("should have correct message", () => {
11
+ const error = new errors.DocumentTypeNotFoundError({
12
+ documentType: "unknown-type",
13
+ });
14
+ expect(error.message).toBe("Document type not found: unknown-type");
15
+ expect(error._tag).toBe("DocumentTypeNotFoundError");
16
+ });
17
+ });
18
+
19
+ describe("DocumentNotFoundError", () => {
20
+ it("should have correct message", () => {
21
+ const error = new errors.DocumentNotFoundError({
22
+ documentId: "doc-123",
23
+ });
24
+ expect(error.message).toBe("Document not found: doc-123");
25
+ expect(error._tag).toBe("DocumentNotFoundError");
26
+ });
27
+ });
28
+
29
+ describe("AuthenticationError", () => {
30
+ it("should have correct message", () => {
31
+ const error = new errors.AuthenticationError({
32
+ reason: "Invalid token",
33
+ });
34
+ expect(error.message).toBe("Authentication failed: Invalid token");
35
+ expect(error._tag).toBe("AuthenticationError");
36
+ });
37
+ });
38
+
39
+ describe("TransactionRejectedError", () => {
40
+ it("should have correct message", () => {
41
+ const error = new errors.TransactionRejectedError({
42
+ transactionId: "tx-456",
43
+ reason: "Transaction is empty",
44
+ });
45
+ expect(error.message).toBe("Transaction tx-456 rejected: Transaction is empty");
46
+ expect(error._tag).toBe("TransactionRejectedError");
47
+ });
48
+ });
49
+
50
+ describe("MessageParseError", () => {
51
+ it("should have correct message", () => {
52
+ const error = new errors.MessageParseError({
53
+ cause: new SyntaxError("Unexpected token"),
54
+ });
55
+ expect(error.message).toContain("Failed to parse message");
56
+ expect(error._tag).toBe("MessageParseError");
57
+ });
58
+ });
59
+
60
+ describe("InvalidConnectionError", () => {
61
+ it("should have correct message", () => {
62
+ const error = new errors.InvalidConnectionError({
63
+ reason: "Connection closed",
64
+ });
65
+ expect(error.message).toBe("Invalid connection: Connection closed");
66
+ expect(error._tag).toBe("InvalidConnectionError");
67
+ });
68
+ });
69
+
70
+ describe("MissingDocumentIdError", () => {
71
+ it("should have correct message", () => {
72
+ const error = new errors.MissingDocumentIdError({});
73
+ expect(error.message).toBe("Document ID is required in the URL path");
74
+ expect(error._tag).toBe("MissingDocumentIdError");
75
+ });
76
+ });
77
+ });
@@ -0,0 +1,24 @@
1
+ {
2
+ "compilerOptions": {
3
+ "module": "Preserve",
4
+ "lib": ["es2022", "dom", "dom.iterable"],
5
+ "target": "es2022",
6
+ "declaration": true,
7
+ "declarationMap": true,
8
+ "declarationDir": "dist",
9
+ "outDir": "./dist",
10
+ "strict": true,
11
+ "strictNullChecks": true,
12
+ "noUnusedLocals": false,
13
+ "noUnusedParameters": true,
14
+ "noImplicitReturns": true,
15
+ "noFallthroughCasesInSwitch": true,
16
+ "noUncheckedIndexedAccess": true,
17
+ "esModuleInterop": true,
18
+ "skipLibCheck": true,
19
+ "noPropertyAccessFromIndexSignature": true,
20
+ "noImplicitOverride": true
21
+ },
22
+ "include": ["src"],
23
+ "exclude": ["test", "**/*.test.ts", "**/*.test.tsx", "__tests__"]
24
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "./tsconfig.build.json",
3
+ "include": ["src", "test"],
4
+ "compilerOptions": {
5
+ "allowJs": false,
6
+ "strictNullChecks": true
7
+ }
8
+ }
@@ -0,0 +1,18 @@
1
+ import { defineConfig } from "tsdown";
2
+
3
+ export const input = ["./src/index.ts"];
4
+
5
+ export default defineConfig({
6
+ target: ["es2017"],
7
+ entry: input,
8
+ dts: {
9
+ sourcemap: true,
10
+ tsconfig: "./tsconfig.build.json",
11
+ },
12
+ // unbundle: true,
13
+ format: ["cjs", "esm"],
14
+ outExtensions: (ctx) => ({
15
+ dts: ctx.format === "cjs" ? ".d.cts" : ".d.mts",
16
+ js: ctx.format === "cjs" ? ".cjs" : ".mjs",
17
+ }),
18
+ });
package/vitest.mts ADDED
@@ -0,0 +1,11 @@
1
+ import tsconfigPaths from "vite-tsconfig-paths";
2
+ import { defineConfig } from "vitest/config";
3
+
4
+ export default defineConfig({
5
+ plugins: [tsconfigPaths()],
6
+ test: {
7
+ include: ["./**/*.test.ts"],
8
+ exclude: ["./node_modules/**"],
9
+ reporters: ["verbose"],
10
+ },
11
+ });