@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.
- package/README.md +0 -0
- package/package.json +40 -0
- package/src/DocumentManager.ts +252 -0
- package/src/DocumentProtocol.ts +112 -0
- package/src/MimicAuthService.ts +103 -0
- package/src/MimicConfig.ts +131 -0
- package/src/MimicDataStorage.ts +157 -0
- package/src/MimicServer.ts +363 -0
- package/src/PresenceManager.ts +297 -0
- package/src/WebSocketHandler.ts +735 -0
- package/src/auth/NoAuth.ts +46 -0
- package/src/errors.ts +113 -0
- package/src/index.ts +48 -0
- package/src/storage/InMemoryDataStorage.ts +66 -0
- package/tests/DocumentManager.test.ts +340 -0
- package/tests/DocumentProtocol.test.ts +113 -0
- package/tests/InMemoryDataStorage.test.ts +190 -0
- package/tests/MimicAuthService.test.ts +185 -0
- package/tests/MimicConfig.test.ts +175 -0
- package/tests/MimicDataStorage.test.ts +190 -0
- package/tests/MimicServer.test.ts +385 -0
- package/tests/NoAuth.test.ts +94 -0
- package/tests/PresenceManager.test.ts +421 -0
- package/tests/WebSocketHandler.test.ts +321 -0
- package/tests/errors.test.ts +77 -0
- package/tsconfig.build.json +24 -0
- package/tsconfig.json +8 -0
- package/tsdown.config.ts +18 -0
- package/vitest.mts +11 -0
|
@@ -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
|
+
});
|