@voidhash/mimic-effect 1.0.0-beta.6 → 1.0.0-beta.8
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/.turbo/turbo-build.log +41 -41
- package/dist/ColdStorage.cjs +9 -5
- package/dist/ColdStorage.d.cts.map +1 -1
- package/dist/ColdStorage.d.mts.map +1 -1
- package/dist/ColdStorage.mjs +9 -5
- package/dist/ColdStorage.mjs.map +1 -1
- package/dist/DocumentInstance.cjs +255 -0
- package/dist/DocumentInstance.d.cts +74 -0
- package/dist/DocumentInstance.d.cts.map +1 -0
- package/dist/DocumentInstance.d.mts +74 -0
- package/dist/DocumentInstance.d.mts.map +1 -0
- package/dist/DocumentInstance.mjs +256 -0
- package/dist/DocumentInstance.mjs.map +1 -0
- package/dist/HotStorage.cjs +17 -13
- package/dist/HotStorage.d.cts.map +1 -1
- package/dist/HotStorage.d.mts.map +1 -1
- package/dist/HotStorage.mjs +17 -13
- package/dist/HotStorage.mjs.map +1 -1
- package/dist/MimicClusterServerEngine.cjs +31 -215
- package/dist/MimicClusterServerEngine.d.cts.map +1 -1
- package/dist/MimicClusterServerEngine.d.mts.map +1 -1
- package/dist/MimicClusterServerEngine.mjs +36 -220
- package/dist/MimicClusterServerEngine.mjs.map +1 -1
- package/dist/MimicServer.cjs +19 -19
- package/dist/MimicServer.d.cts.map +1 -1
- package/dist/MimicServer.d.mts.map +1 -1
- package/dist/MimicServer.mjs +19 -19
- package/dist/MimicServer.mjs.map +1 -1
- package/dist/MimicServerEngine.cjs +71 -9
- package/dist/MimicServerEngine.d.cts +12 -7
- package/dist/MimicServerEngine.d.cts.map +1 -1
- package/dist/MimicServerEngine.d.mts +12 -7
- package/dist/MimicServerEngine.d.mts.map +1 -1
- package/dist/MimicServerEngine.mjs +73 -11
- package/dist/MimicServerEngine.mjs.map +1 -1
- package/dist/PresenceManager.cjs +5 -5
- package/dist/PresenceManager.d.cts.map +1 -1
- package/dist/PresenceManager.d.mts.map +1 -1
- package/dist/PresenceManager.mjs +5 -5
- package/dist/PresenceManager.mjs.map +1 -1
- package/dist/Protocol.d.cts +1 -1
- package/dist/Protocol.d.mts +1 -1
- package/dist/index.cjs +2 -4
- package/dist/index.d.cts +2 -2
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +2 -2
- package/dist/testing/types.d.cts +3 -3
- package/package.json +3 -3
- package/src/ColdStorage.ts +21 -12
- package/src/DocumentInstance.ts +510 -0
- package/src/HotStorage.ts +75 -58
- package/src/MimicClusterServerEngine.ts +93 -398
- package/src/MimicServer.ts +83 -75
- package/src/MimicServerEngine.ts +170 -34
- package/src/PresenceManager.ts +44 -34
- package/src/index.ts +3 -4
- package/tests/DocumentInstance.test.ts +669 -0
- package/dist/DocumentManager.cjs +0 -299
- package/dist/DocumentManager.d.cts +0 -67
- package/dist/DocumentManager.d.cts.map +0 -1
- package/dist/DocumentManager.d.mts +0 -67
- package/dist/DocumentManager.d.mts.map +0 -1
- package/dist/DocumentManager.mjs +0 -297
- package/dist/DocumentManager.mjs.map +0 -1
- package/src/DocumentManager.ts +0 -614
- package/tests/DocumentManager.test.ts +0 -335
package/src/MimicServer.ts
CHANGED
|
@@ -10,7 +10,6 @@ import {
|
|
|
10
10
|
Fiber,
|
|
11
11
|
Layer,
|
|
12
12
|
Metric,
|
|
13
|
-
Scope,
|
|
14
13
|
Stream,
|
|
15
14
|
} from "effect";
|
|
16
15
|
import {
|
|
@@ -93,14 +92,14 @@ interface ConnectionState {
|
|
|
93
92
|
/**
|
|
94
93
|
* Handle a WebSocket connection for a document.
|
|
95
94
|
*/
|
|
96
|
-
const handleWebSocketConnection = (
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
95
|
+
const handleWebSocketConnection = Effect.fn("websocket.connection.handle")(
|
|
96
|
+
function* (
|
|
97
|
+
socket: Socket.Socket,
|
|
98
|
+
documentId: string,
|
|
99
|
+
engine: MimicServerEngine,
|
|
100
|
+
authService: MimicAuthService,
|
|
101
|
+
_routeConfig: ResolvedRouteConfig
|
|
102
|
+
) {
|
|
104
103
|
const connectionId = crypto.randomUUID();
|
|
105
104
|
const connectionStartTime = Date.now();
|
|
106
105
|
|
|
@@ -124,59 +123,62 @@ const handleWebSocketConnection = (
|
|
|
124
123
|
write(Protocol.encodeServerMessage(message));
|
|
125
124
|
|
|
126
125
|
// Send presence snapshot after auth
|
|
127
|
-
const sendPresenceSnapshot = Effect.
|
|
128
|
-
|
|
126
|
+
const sendPresenceSnapshot = Effect.fn("presence.snapshot.send")(
|
|
127
|
+
function* () {
|
|
128
|
+
if (!engine.config.presence) return;
|
|
129
129
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
130
|
+
const snapshot = yield* engine.getPresenceSnapshot(documentId);
|
|
131
|
+
yield* sendMessage(
|
|
132
|
+
Protocol.presenceSnapshotMessage(connectionId, snapshot.presences)
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
);
|
|
135
136
|
|
|
136
137
|
// Send document snapshot after auth
|
|
137
|
-
const sendDocumentSnapshot = Effect.
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
// Handle authentication
|
|
145
|
-
const handleAuth = (token: string) =>
|
|
146
|
-
Effect.gen(function* () {
|
|
147
|
-
const result = yield* Effect.either(
|
|
148
|
-
authService.authenticate(token, documentId)
|
|
138
|
+
const sendDocumentSnapshot = Effect.fn("document.snapshot.send")(
|
|
139
|
+
function* () {
|
|
140
|
+
const snapshot = yield* engine.getSnapshot(documentId);
|
|
141
|
+
yield* sendMessage(
|
|
142
|
+
Protocol.snapshotMessage(snapshot.state, snapshot.version)
|
|
149
143
|
);
|
|
144
|
+
}
|
|
145
|
+
);
|
|
150
146
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
147
|
+
// Handle authentication
|
|
148
|
+
const handleAuth = Effect.fn("auth.handle")(function* (token: string) {
|
|
149
|
+
const result = yield* Effect.either(
|
|
150
|
+
authService.authenticate(token, documentId)
|
|
151
|
+
);
|
|
154
152
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
result.right.permission
|
|
159
|
-
)
|
|
160
|
-
);
|
|
153
|
+
if (result._tag === "Right") {
|
|
154
|
+
state.authenticated = true;
|
|
155
|
+
state.authContext = result.right;
|
|
161
156
|
|
|
162
|
-
|
|
163
|
-
|
|
157
|
+
yield* sendMessage(
|
|
158
|
+
Protocol.authResultSuccess(
|
|
159
|
+
result.right.userId,
|
|
160
|
+
result.right.permission
|
|
161
|
+
)
|
|
162
|
+
);
|
|
164
163
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
164
|
+
// Send document snapshot after successful auth
|
|
165
|
+
yield* sendDocumentSnapshot();
|
|
166
|
+
|
|
167
|
+
// Send presence snapshot after successful auth
|
|
168
|
+
yield* sendPresenceSnapshot();
|
|
169
|
+
} else {
|
|
170
|
+
yield* Metric.increment(Metrics.connectionsErrors);
|
|
171
|
+
yield* sendMessage(
|
|
172
|
+
Protocol.authResultFailure(
|
|
173
|
+
result.left.reason ?? "Authentication failed"
|
|
174
|
+
)
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
176
178
|
|
|
177
179
|
// Handle presence set
|
|
178
|
-
const handlePresenceSet = (
|
|
179
|
-
|
|
180
|
+
const handlePresenceSet = Effect.fn("presence.set.handle")(
|
|
181
|
+
function* (data: unknown) {
|
|
180
182
|
if (!state.authenticated) return;
|
|
181
183
|
if (!state.authContext) return;
|
|
182
184
|
if (!engine.config.presence) return;
|
|
@@ -206,20 +208,23 @@ const handleWebSocketConnection = (
|
|
|
206
208
|
});
|
|
207
209
|
|
|
208
210
|
state.hasPresence = true;
|
|
209
|
-
}
|
|
211
|
+
}
|
|
212
|
+
);
|
|
210
213
|
|
|
211
214
|
// Handle presence clear
|
|
212
|
-
const handlePresenceClear = Effect.
|
|
213
|
-
|
|
214
|
-
|
|
215
|
+
const handlePresenceClear = Effect.fn("presence.clear.handle")(
|
|
216
|
+
function* () {
|
|
217
|
+
if (!state.authenticated) return;
|
|
218
|
+
if (!engine.config.presence) return;
|
|
215
219
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
220
|
+
yield* engine.removePresence(documentId, connectionId);
|
|
221
|
+
state.hasPresence = false;
|
|
222
|
+
}
|
|
223
|
+
);
|
|
219
224
|
|
|
220
225
|
// Handle a client message
|
|
221
|
-
const handleMessage = (message
|
|
222
|
-
|
|
226
|
+
const handleMessage = Effect.fn("message.handle")(
|
|
227
|
+
function* (message: Protocol.ClientMessage) {
|
|
223
228
|
// Touch document on any activity (prevents idle GC)
|
|
224
229
|
yield* engine.touch(documentId);
|
|
225
230
|
|
|
@@ -283,14 +288,15 @@ const handleWebSocketConnection = (
|
|
|
283
288
|
break;
|
|
284
289
|
|
|
285
290
|
case "presence_clear":
|
|
286
|
-
yield* handlePresenceClear;
|
|
291
|
+
yield* handlePresenceClear();
|
|
287
292
|
break;
|
|
288
293
|
}
|
|
289
|
-
}
|
|
294
|
+
}
|
|
295
|
+
);
|
|
290
296
|
|
|
291
297
|
// Subscribe to document broadcasts
|
|
292
298
|
const subscribeFiber = yield* Effect.fork(
|
|
293
|
-
Effect.
|
|
299
|
+
Effect.fn("subscriptions.document.start")(function* () {
|
|
294
300
|
// Wait until authenticated before subscribing
|
|
295
301
|
while (!state.authenticated) {
|
|
296
302
|
yield* Effect.sleep(Duration.millis(100));
|
|
@@ -303,12 +309,12 @@ const handleWebSocketConnection = (
|
|
|
303
309
|
yield* Stream.runForEach(broadcastStream, (broadcast) =>
|
|
304
310
|
sendMessage(broadcast as Protocol.ServerMessage)
|
|
305
311
|
);
|
|
306
|
-
}).pipe(Effect.scoped)
|
|
312
|
+
})().pipe(Effect.scoped)
|
|
307
313
|
);
|
|
308
314
|
|
|
309
315
|
// Subscribe to presence events (if presence is enabled)
|
|
310
316
|
const presenceFiber = yield* Effect.fork(
|
|
311
|
-
Effect.
|
|
317
|
+
Effect.fn("subscriptions.presence.start")(function* () {
|
|
312
318
|
if (!engine.config.presence) return;
|
|
313
319
|
|
|
314
320
|
// Wait until authenticated before subscribing
|
|
@@ -334,12 +340,12 @@ const handleWebSocketConnection = (
|
|
|
334
340
|
}
|
|
335
341
|
})
|
|
336
342
|
);
|
|
337
|
-
}).pipe(Effect.scoped)
|
|
343
|
+
})().pipe(Effect.scoped)
|
|
338
344
|
);
|
|
339
345
|
|
|
340
346
|
// Ensure cleanup on disconnect
|
|
341
347
|
yield* Effect.addFinalizer(() =>
|
|
342
|
-
Effect.
|
|
348
|
+
Effect.fn("connection.cleanup")(function* () {
|
|
343
349
|
// Calculate connection duration
|
|
344
350
|
const duration = Date.now() - connectionStartTime;
|
|
345
351
|
|
|
@@ -361,21 +367,22 @@ const handleWebSocketConnection = (
|
|
|
361
367
|
documentId,
|
|
362
368
|
durationMs: duration,
|
|
363
369
|
});
|
|
364
|
-
})
|
|
370
|
+
})()
|
|
365
371
|
);
|
|
366
372
|
|
|
367
373
|
// Process incoming messages
|
|
368
374
|
yield* socket.runRaw((data) =>
|
|
369
|
-
Effect.
|
|
375
|
+
Effect.fn("message.process")(function* () {
|
|
370
376
|
const message = yield* Protocol.parseClientMessage(data);
|
|
371
377
|
yield* handleMessage(message);
|
|
372
|
-
}).pipe(
|
|
378
|
+
})().pipe(
|
|
373
379
|
Effect.catchAll((error) =>
|
|
374
380
|
Effect.logError("Message handling error", error)
|
|
375
381
|
)
|
|
376
382
|
)
|
|
377
383
|
);
|
|
378
|
-
}
|
|
384
|
+
}
|
|
385
|
+
);
|
|
379
386
|
|
|
380
387
|
// =============================================================================
|
|
381
388
|
// Factory
|
|
@@ -437,8 +444,8 @@ export const layerHttpLayerRouter = (
|
|
|
437
444
|
|
|
438
445
|
// Create the handler that receives the request
|
|
439
446
|
// Engine and authService are captured in closure, not yielded per-request
|
|
440
|
-
const handler = (
|
|
441
|
-
|
|
447
|
+
const handler = Effect.fn("websocket.route.handler")(
|
|
448
|
+
function* (request: HttpServerRequest.HttpServerRequest) {
|
|
442
449
|
// Extract document ID from path
|
|
443
450
|
const documentIdResult = yield* Effect.either(
|
|
444
451
|
extractDocumentId(request.url)
|
|
@@ -470,7 +477,8 @@ export const layerHttpLayerRouter = (
|
|
|
470
477
|
|
|
471
478
|
// Return empty response - the WebSocket upgrade handles the connection
|
|
472
479
|
return HttpServerResponse.empty();
|
|
473
|
-
}
|
|
480
|
+
}
|
|
481
|
+
);
|
|
474
482
|
|
|
475
483
|
yield* router.add("GET", routePath, handler);
|
|
476
484
|
})
|
package/src/MimicServerEngine.ts
CHANGED
|
@@ -10,11 +10,15 @@ import {
|
|
|
10
10
|
Context,
|
|
11
11
|
Duration,
|
|
12
12
|
Effect,
|
|
13
|
+
HashMap,
|
|
13
14
|
Layer,
|
|
15
|
+
Metric,
|
|
16
|
+
Ref,
|
|
17
|
+
Schedule,
|
|
14
18
|
Scope,
|
|
15
19
|
Stream,
|
|
16
20
|
} from "effect";
|
|
17
|
-
import type {
|
|
21
|
+
import type { Primitive, Transaction } from "@voidhash/mimic";
|
|
18
22
|
import type {
|
|
19
23
|
MimicServerEngineConfig,
|
|
20
24
|
PresenceEntry,
|
|
@@ -27,16 +31,25 @@ import { ColdStorageTag } from "./ColdStorage";
|
|
|
27
31
|
import { HotStorageTag } from "./HotStorage";
|
|
28
32
|
import { MimicAuthServiceTag } from "./MimicAuthService";
|
|
29
33
|
import {
|
|
30
|
-
|
|
31
|
-
DocumentManagerConfigTag,
|
|
32
|
-
layer as documentManagerLayer,
|
|
34
|
+
DocumentInstance,
|
|
33
35
|
type SubmitResult,
|
|
34
|
-
type
|
|
35
|
-
} from "./
|
|
36
|
+
type DocumentInstance as DocumentInstanceType,
|
|
37
|
+
} from "./DocumentInstance";
|
|
36
38
|
import {
|
|
37
39
|
PresenceManagerTag,
|
|
38
40
|
layer as presenceManagerLayer,
|
|
39
41
|
} from "./PresenceManager";
|
|
42
|
+
import * as Metrics from "./Metrics";
|
|
43
|
+
import type { ColdStorageError, HotStorageError } from "./Errors";
|
|
44
|
+
|
|
45
|
+
// =============================================================================
|
|
46
|
+
// Types
|
|
47
|
+
// =============================================================================
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Error type for MimicServerEngine operations
|
|
51
|
+
*/
|
|
52
|
+
export type MimicServerEngineError = ColdStorageError | HotStorageError;
|
|
40
53
|
|
|
41
54
|
// =============================================================================
|
|
42
55
|
// MimicServerEngine Interface
|
|
@@ -52,30 +65,30 @@ export interface MimicServerEngine {
|
|
|
52
65
|
/**
|
|
53
66
|
* Submit a transaction to a document.
|
|
54
67
|
* Authorization is checked against the auth service.
|
|
55
|
-
* May fail with
|
|
68
|
+
* May fail with MimicServerEngineError if storage is unavailable.
|
|
56
69
|
*/
|
|
57
70
|
readonly submit: (
|
|
58
71
|
documentId: string,
|
|
59
72
|
transaction: Transaction.Transaction
|
|
60
|
-
) => Effect.Effect<SubmitResult,
|
|
73
|
+
) => Effect.Effect<SubmitResult, MimicServerEngineError>;
|
|
61
74
|
|
|
62
75
|
/**
|
|
63
76
|
* Get document snapshot (current state and version).
|
|
64
|
-
* May fail with
|
|
77
|
+
* May fail with MimicServerEngineError if storage is unavailable.
|
|
65
78
|
*/
|
|
66
79
|
readonly getSnapshot: (
|
|
67
80
|
documentId: string
|
|
68
|
-
) => Effect.Effect<{ state: unknown; version: number },
|
|
81
|
+
) => Effect.Effect<{ state: unknown; version: number }, MimicServerEngineError>;
|
|
69
82
|
|
|
70
83
|
/**
|
|
71
84
|
* Subscribe to document broadcasts (transactions).
|
|
72
85
|
* Returns a stream of server messages.
|
|
73
86
|
* Requires a Scope for cleanup when the subscription ends.
|
|
74
|
-
* May fail with
|
|
87
|
+
* May fail with MimicServerEngineError if storage is unavailable.
|
|
75
88
|
*/
|
|
76
89
|
readonly subscribe: (
|
|
77
90
|
documentId: string
|
|
78
|
-
) => Effect.Effect<Stream.Stream<Protocol.ServerMessage, never, never>,
|
|
91
|
+
) => Effect.Effect<Stream.Stream<Protocol.ServerMessage, never, never>, MimicServerEngineError, Scope.Scope>;
|
|
79
92
|
|
|
80
93
|
/**
|
|
81
94
|
* Touch document to prevent idle garbage collection.
|
|
@@ -164,6 +177,18 @@ const resolveConfig = <TSchema extends Primitive.AnyPrimitive>(
|
|
|
164
177
|
},
|
|
165
178
|
});
|
|
166
179
|
|
|
180
|
+
// =============================================================================
|
|
181
|
+
// Internal Types
|
|
182
|
+
// =============================================================================
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Store entry for a document instance with last activity time
|
|
186
|
+
*/
|
|
187
|
+
interface StoreEntry<TSchema extends Primitive.AnyPrimitive> {
|
|
188
|
+
readonly instance: DocumentInstanceType<TSchema>;
|
|
189
|
+
readonly lastActivityTime: Ref.Ref<number>;
|
|
190
|
+
}
|
|
191
|
+
|
|
167
192
|
// =============================================================================
|
|
168
193
|
// Factory
|
|
169
194
|
// =============================================================================
|
|
@@ -208,37 +233,148 @@ export const make = <TSchema extends Primitive.AnyPrimitive>(
|
|
|
208
233
|
> => {
|
|
209
234
|
const resolvedConfig = resolveConfig(config);
|
|
210
235
|
|
|
211
|
-
// Create config layer for DocumentManager
|
|
212
|
-
const configLayer = Layer.succeed(
|
|
213
|
-
DocumentManagerConfigTag,
|
|
214
|
-
resolvedConfig as ResolvedConfig<Primitive.AnyPrimitive>
|
|
215
|
-
);
|
|
216
|
-
|
|
217
|
-
// Create internal layers
|
|
218
|
-
const internalLayers = Layer.mergeAll(
|
|
219
|
-
documentManagerLayer.pipe(Layer.provide(configLayer)),
|
|
220
|
-
presenceManagerLayer
|
|
221
|
-
);
|
|
222
|
-
|
|
223
236
|
return Layer.scoped(
|
|
224
237
|
MimicServerEngineTag,
|
|
225
238
|
Effect.gen(function* () {
|
|
226
|
-
const
|
|
239
|
+
const coldStorage = yield* ColdStorageTag;
|
|
240
|
+
const hotStorage = yield* HotStorageTag;
|
|
227
241
|
const presenceManager = yield* PresenceManagerTag;
|
|
228
242
|
|
|
243
|
+
// Store: documentId -> StoreEntry
|
|
244
|
+
const store = yield* Ref.make(
|
|
245
|
+
HashMap.empty<string, StoreEntry<TSchema>>()
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Get or create a document instance
|
|
250
|
+
*/
|
|
251
|
+
const getOrCreateDocument = Effect.fn("engine.document.get-or-create")(
|
|
252
|
+
function* (documentId: string) {
|
|
253
|
+
const current = yield* Ref.get(store);
|
|
254
|
+
const existing = HashMap.get(current, documentId);
|
|
255
|
+
|
|
256
|
+
if (existing._tag === "Some") {
|
|
257
|
+
// Update activity time
|
|
258
|
+
yield* Ref.set(existing.value.lastActivityTime, Date.now());
|
|
259
|
+
return existing.value.instance;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Create new document instance
|
|
263
|
+
const instance = yield* DocumentInstance.make(
|
|
264
|
+
documentId,
|
|
265
|
+
{
|
|
266
|
+
schema: config.schema,
|
|
267
|
+
initial: config.initial,
|
|
268
|
+
maxTransactionHistory: resolvedConfig.maxTransactionHistory,
|
|
269
|
+
snapshot: resolvedConfig.snapshot,
|
|
270
|
+
},
|
|
271
|
+
coldStorage,
|
|
272
|
+
hotStorage
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
const lastActivityTime = yield* Ref.make(Date.now());
|
|
276
|
+
|
|
277
|
+
// Store it
|
|
278
|
+
yield* Ref.update(store, (map) =>
|
|
279
|
+
HashMap.set(map, documentId, { instance, lastActivityTime })
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
return instance;
|
|
283
|
+
}
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Start background GC fiber
|
|
288
|
+
*/
|
|
289
|
+
const startGCFiber = Effect.fn("engine.gc.start")(function* () {
|
|
290
|
+
const gcLoop = Effect.fn("engine.gc.loop")(function* () {
|
|
291
|
+
const current = yield* Ref.get(store);
|
|
292
|
+
const now = Date.now();
|
|
293
|
+
const maxIdleMs = Duration.toMillis(resolvedConfig.maxIdleTime);
|
|
294
|
+
|
|
295
|
+
for (const [documentId, entry] of current) {
|
|
296
|
+
const lastActivity = yield* Ref.get(entry.lastActivityTime);
|
|
297
|
+
if (now - lastActivity >= maxIdleMs) {
|
|
298
|
+
// Save final snapshot before eviction (best effort)
|
|
299
|
+
yield* Effect.catchAll(entry.instance.saveSnapshot(), (e) =>
|
|
300
|
+
Effect.logError("Failed to save snapshot during eviction", {
|
|
301
|
+
documentId,
|
|
302
|
+
error: e,
|
|
303
|
+
})
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
// Remove from store
|
|
307
|
+
yield* Ref.update(store, (map) => HashMap.remove(map, documentId));
|
|
308
|
+
|
|
309
|
+
// Track eviction metrics
|
|
310
|
+
yield* Metric.increment(Metrics.documentsEvicted);
|
|
311
|
+
yield* Metric.incrementBy(Metrics.documentsActive, -1);
|
|
312
|
+
|
|
313
|
+
yield* Effect.logInfo("Document evicted due to idle timeout", {
|
|
314
|
+
documentId,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// Run GC every minute
|
|
321
|
+
yield* gcLoop().pipe(
|
|
322
|
+
Effect.repeat(Schedule.spaced("1 minute")),
|
|
323
|
+
Effect.fork
|
|
324
|
+
);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// Start GC fiber
|
|
328
|
+
yield* startGCFiber();
|
|
329
|
+
|
|
330
|
+
// Cleanup on shutdown
|
|
331
|
+
yield* Effect.addFinalizer(() =>
|
|
332
|
+
Effect.fn("engine.shutdown")(function* () {
|
|
333
|
+
const current = yield* Ref.get(store);
|
|
334
|
+
for (const [documentId, entry] of current) {
|
|
335
|
+
// Best effort save - don't fail shutdown if storage is unavailable
|
|
336
|
+
yield* Effect.catchAll(entry.instance.saveSnapshot(), (e) =>
|
|
337
|
+
Effect.logError("Failed to save snapshot during shutdown", {
|
|
338
|
+
documentId,
|
|
339
|
+
error: e,
|
|
340
|
+
})
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
yield* Effect.logInfo("MimicServerEngine shutdown complete");
|
|
344
|
+
})()
|
|
345
|
+
);
|
|
346
|
+
|
|
229
347
|
const engine: MimicServerEngine = {
|
|
230
348
|
submit: (documentId, transaction) =>
|
|
231
|
-
|
|
349
|
+
Effect.gen(function* () {
|
|
350
|
+
const instance = yield* getOrCreateDocument(documentId);
|
|
351
|
+
return yield* instance.submit(transaction);
|
|
352
|
+
}),
|
|
232
353
|
|
|
233
|
-
getSnapshot: (documentId) =>
|
|
354
|
+
getSnapshot: (documentId) =>
|
|
355
|
+
Effect.gen(function* () {
|
|
356
|
+
const instance = yield* getOrCreateDocument(documentId);
|
|
357
|
+
return instance.getSnapshot();
|
|
358
|
+
}),
|
|
234
359
|
|
|
235
360
|
subscribe: (documentId) =>
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
361
|
+
Effect.gen(function* () {
|
|
362
|
+
const instance = yield* getOrCreateDocument(documentId);
|
|
363
|
+
return Stream.fromPubSub(instance.pubsub) as Stream.Stream<
|
|
364
|
+
Protocol.ServerMessage,
|
|
365
|
+
never,
|
|
366
|
+
never
|
|
367
|
+
>;
|
|
368
|
+
}),
|
|
369
|
+
|
|
370
|
+
touch: (documentId) =>
|
|
371
|
+
Effect.gen(function* () {
|
|
372
|
+
const current = yield* Ref.get(store);
|
|
373
|
+
const existing = HashMap.get(current, documentId);
|
|
374
|
+
if (existing._tag === "Some") {
|
|
375
|
+
yield* Ref.set(existing.value.lastActivityTime, Date.now());
|
|
376
|
+
}
|
|
377
|
+
}),
|
|
242
378
|
|
|
243
379
|
getPresenceSnapshot: (documentId) =>
|
|
244
380
|
presenceManager.getSnapshot(documentId),
|
|
@@ -257,7 +393,7 @@ export const make = <TSchema extends Primitive.AnyPrimitive>(
|
|
|
257
393
|
|
|
258
394
|
return engine;
|
|
259
395
|
})
|
|
260
|
-
).pipe(Layer.provide(
|
|
396
|
+
).pipe(Layer.provide(presenceManagerLayer));
|
|
261
397
|
};
|
|
262
398
|
|
|
263
399
|
// =============================================================================
|
package/src/PresenceManager.ts
CHANGED
|
@@ -106,30 +106,29 @@ export const layer: Layer.Layer<PresenceManagerTag> = Layer.effect(
|
|
|
106
106
|
/**
|
|
107
107
|
* Get or create presence state for a document
|
|
108
108
|
*/
|
|
109
|
-
const getOrCreateDocumentState = (
|
|
110
|
-
|
|
111
|
-
):
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
});
|
|
109
|
+
const getOrCreateDocumentState = Effect.fn(
|
|
110
|
+
"presence.document-state.get-or-create"
|
|
111
|
+
)(function* (documentId: string) {
|
|
112
|
+
const current = yield* Ref.get(store);
|
|
113
|
+
const existing = HashMap.get(current, documentId);
|
|
114
|
+
if (existing._tag === "Some") {
|
|
115
|
+
return existing.value;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Create new state for this document
|
|
119
|
+
const pubsub = yield* PubSub.unbounded<PresenceEvent>();
|
|
120
|
+
const state: DocumentPresenceState = {
|
|
121
|
+
presences: HashMap.empty(),
|
|
122
|
+
pubsub,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
yield* Ref.update(store, (map) => HashMap.set(map, documentId, state));
|
|
126
|
+
return state;
|
|
127
|
+
});
|
|
129
128
|
|
|
130
129
|
return {
|
|
131
|
-
getSnapshot: (
|
|
132
|
-
|
|
130
|
+
getSnapshot: Effect.fn("presence.snapshot.get")(
|
|
131
|
+
function* (documentId: string) {
|
|
133
132
|
const current = yield* Ref.get(store);
|
|
134
133
|
const existing = HashMap.get(current, documentId);
|
|
135
134
|
if (existing._tag === "None") {
|
|
@@ -142,10 +141,15 @@ export const layer: Layer.Layer<PresenceManagerTag> = Layer.effect(
|
|
|
142
141
|
presences[id] = entry;
|
|
143
142
|
}
|
|
144
143
|
return { presences };
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
144
|
+
}
|
|
145
|
+
),
|
|
146
|
+
|
|
147
|
+
set: Effect.fn("presence.set")(
|
|
148
|
+
function* (
|
|
149
|
+
documentId: string,
|
|
150
|
+
connectionId: string,
|
|
151
|
+
entry: PresenceEntry
|
|
152
|
+
) {
|
|
149
153
|
const state = yield* getOrCreateDocumentState(documentId);
|
|
150
154
|
|
|
151
155
|
// Update presence in store
|
|
@@ -174,16 +178,20 @@ export const layer: Layer.Layer<PresenceManagerTag> = Layer.effect(
|
|
|
174
178
|
userId: entry.userId,
|
|
175
179
|
};
|
|
176
180
|
yield* PubSub.publish(state.pubsub, event);
|
|
177
|
-
}
|
|
181
|
+
}
|
|
182
|
+
),
|
|
178
183
|
|
|
179
|
-
remove: (
|
|
180
|
-
|
|
184
|
+
remove: Effect.fn("presence.remove")(
|
|
185
|
+
function* (documentId: string, connectionId: string) {
|
|
181
186
|
const current = yield* Ref.get(store);
|
|
182
187
|
const existing = HashMap.get(current, documentId);
|
|
183
188
|
if (existing._tag === "None") return;
|
|
184
189
|
|
|
185
190
|
// Check if presence exists before removing
|
|
186
|
-
const hasPresence = HashMap.has(
|
|
191
|
+
const hasPresence = HashMap.has(
|
|
192
|
+
existing.value.presences,
|
|
193
|
+
connectionId
|
|
194
|
+
);
|
|
187
195
|
if (!hasPresence) return;
|
|
188
196
|
|
|
189
197
|
// Remove presence from store
|
|
@@ -205,13 +213,15 @@ export const layer: Layer.Layer<PresenceManagerTag> = Layer.effect(
|
|
|
205
213
|
id: connectionId,
|
|
206
214
|
};
|
|
207
215
|
yield* PubSub.publish(existing.value.pubsub, event);
|
|
208
|
-
}
|
|
216
|
+
}
|
|
217
|
+
),
|
|
209
218
|
|
|
210
|
-
subscribe: (
|
|
211
|
-
|
|
219
|
+
subscribe: Effect.fn("presence.subscribe")(
|
|
220
|
+
function* (documentId: string) {
|
|
212
221
|
const state = yield* getOrCreateDocumentState(documentId);
|
|
213
222
|
return Stream.fromPubSub(state.pubsub);
|
|
214
|
-
}
|
|
223
|
+
}
|
|
224
|
+
),
|
|
215
225
|
};
|
|
216
226
|
})
|
|
217
227
|
);
|
package/src/index.ts
CHANGED
|
@@ -41,11 +41,10 @@ export * as Protocol from "./Protocol";
|
|
|
41
41
|
// =============================================================================
|
|
42
42
|
|
|
43
43
|
export {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
DocumentManagerConfigTag,
|
|
44
|
+
DocumentInstance,
|
|
45
|
+
type DocumentInstance as DocumentInstanceInterface,
|
|
47
46
|
type SubmitResult,
|
|
48
|
-
} from "./
|
|
47
|
+
} from "./DocumentInstance";
|
|
49
48
|
|
|
50
49
|
export {
|
|
51
50
|
PresenceManager,
|