@voidhash/mimic-effect 1.0.0-beta.1 → 1.0.0-beta.10
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 +116 -74
- 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 +263 -0
- package/dist/DocumentInstance.d.cts +78 -0
- package/dist/DocumentInstance.d.cts.map +1 -0
- package/dist/DocumentInstance.d.mts +78 -0
- package/dist/DocumentInstance.d.mts.map +1 -0
- package/dist/DocumentInstance.mjs +264 -0
- package/dist/DocumentInstance.mjs.map +1 -0
- package/dist/Errors.cjs +10 -1
- package/dist/Errors.d.cts +18 -3
- package/dist/Errors.d.cts.map +1 -1
- package/dist/Errors.d.mts +18 -3
- package/dist/Errors.d.mts.map +1 -1
- package/dist/Errors.mjs +9 -1
- package/dist/Errors.mjs.map +1 -1
- package/dist/HotStorage.cjs +39 -12
- package/dist/HotStorage.d.cts +17 -1
- package/dist/HotStorage.d.cts.map +1 -1
- package/dist/HotStorage.d.mts +17 -1
- package/dist/HotStorage.d.mts.map +1 -1
- package/dist/HotStorage.mjs +39 -12
- package/dist/HotStorage.mjs.map +1 -1
- package/dist/Metrics.cjs +29 -1
- package/dist/Metrics.d.cts +5 -0
- package/dist/Metrics.d.cts.map +1 -1
- package/dist/Metrics.d.mts +5 -0
- package/dist/Metrics.d.mts.map +1 -1
- package/dist/Metrics.mjs +26 -1
- package/dist/Metrics.mjs.map +1 -1
- package/dist/MimicClusterServerEngine.cjs +44 -139
- package/dist/MimicClusterServerEngine.d.cts.map +1 -1
- package/dist/MimicClusterServerEngine.d.mts +1 -1
- package/dist/MimicClusterServerEngine.d.mts.map +1 -1
- package/dist/MimicClusterServerEngine.mjs +46 -141
- package/dist/MimicClusterServerEngine.mjs.map +1 -1
- package/dist/MimicServer.cjs +20 -20
- package/dist/MimicServer.d.cts.map +1 -1
- package/dist/MimicServer.d.mts.map +1 -1
- package/dist/MimicServer.mjs +20 -20
- package/dist/MimicServer.mjs.map +1 -1
- package/dist/MimicServerEngine.cjs +92 -11
- package/dist/MimicServerEngine.d.cts +12 -4
- package/dist/MimicServerEngine.d.cts.map +1 -1
- package/dist/MimicServerEngine.d.mts +12 -4
- package/dist/MimicServerEngine.d.mts.map +1 -1
- package/dist/MimicServerEngine.mjs +94 -13
- 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/Types.d.cts +9 -2
- package/dist/Types.d.cts.map +1 -1
- package/dist/Types.d.mts +9 -2
- package/dist/Types.d.mts.map +1 -1
- package/dist/index.cjs +5 -6
- package/dist/index.d.cts +3 -3
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +3 -3
- package/dist/testing/ColdStorageTestSuite.cjs +508 -0
- package/dist/testing/ColdStorageTestSuite.d.cts +36 -0
- package/dist/testing/ColdStorageTestSuite.d.cts.map +1 -0
- package/dist/testing/ColdStorageTestSuite.d.mts +36 -0
- package/dist/testing/ColdStorageTestSuite.d.mts.map +1 -0
- package/dist/testing/ColdStorageTestSuite.mjs +508 -0
- package/dist/testing/ColdStorageTestSuite.mjs.map +1 -0
- package/dist/testing/FailingStorage.cjs +162 -0
- package/dist/testing/FailingStorage.d.cts +43 -0
- package/dist/testing/FailingStorage.d.cts.map +1 -0
- package/dist/testing/FailingStorage.d.mts +43 -0
- package/dist/testing/FailingStorage.d.mts.map +1 -0
- package/dist/testing/FailingStorage.mjs +163 -0
- package/dist/testing/FailingStorage.mjs.map +1 -0
- package/dist/testing/HotStorageTestSuite.cjs +820 -0
- package/dist/testing/HotStorageTestSuite.d.cts +42 -0
- package/dist/testing/HotStorageTestSuite.d.cts.map +1 -0
- package/dist/testing/HotStorageTestSuite.d.mts +42 -0
- package/dist/testing/HotStorageTestSuite.d.mts.map +1 -0
- package/dist/testing/HotStorageTestSuite.mjs +820 -0
- package/dist/testing/HotStorageTestSuite.mjs.map +1 -0
- package/dist/testing/StorageIntegrationTestSuite.cjs +487 -0
- package/dist/testing/StorageIntegrationTestSuite.d.cts +37 -0
- package/dist/testing/StorageIntegrationTestSuite.d.cts.map +1 -0
- package/dist/testing/StorageIntegrationTestSuite.d.mts +37 -0
- package/dist/testing/StorageIntegrationTestSuite.d.mts.map +1 -0
- package/dist/testing/StorageIntegrationTestSuite.mjs +487 -0
- package/dist/testing/StorageIntegrationTestSuite.mjs.map +1 -0
- package/dist/testing/assertions.cjs +117 -0
- package/dist/testing/assertions.mjs +112 -0
- package/dist/testing/assertions.mjs.map +1 -0
- package/dist/testing/index.cjs +14 -0
- package/dist/testing/index.d.cts +6 -0
- package/dist/testing/index.d.mts +6 -0
- package/dist/testing/index.mjs +7 -0
- package/dist/testing/types.cjs +15 -0
- package/dist/testing/types.d.cts +90 -0
- package/dist/testing/types.d.cts.map +1 -0
- package/dist/testing/types.d.mts +90 -0
- package/dist/testing/types.d.mts.map +1 -0
- package/dist/testing/types.mjs +16 -0
- package/dist/testing/types.mjs.map +1 -0
- package/package.json +8 -3
- package/src/ColdStorage.ts +21 -12
- package/src/DocumentInstance.ts +527 -0
- package/src/Errors.ts +15 -1
- package/src/HotStorage.ts +115 -24
- package/src/Metrics.ts +30 -0
- package/src/MimicClusterServerEngine.ts +120 -275
- package/src/MimicServer.ts +83 -75
- package/src/MimicServerEngine.ts +230 -30
- package/src/PresenceManager.ts +44 -34
- package/src/Types.ts +9 -2
- package/src/index.ts +5 -35
- package/src/testing/ColdStorageTestSuite.ts +589 -0
- package/src/testing/FailingStorage.ts +338 -0
- package/src/testing/HotStorageTestSuite.ts +1105 -0
- package/src/testing/StorageIntegrationTestSuite.ts +736 -0
- package/src/testing/assertions.ts +188 -0
- package/src/testing/index.ts +83 -0
- package/src/testing/types.ts +100 -0
- package/tests/ColdStorage.test.ts +8 -120
- package/tests/DocumentInstance.test.ts +669 -0
- package/tests/HotStorage.test.ts +7 -126
- package/tests/StorageIntegration.test.ts +259 -0
- package/tsdown.config.ts +1 -1
- package/dist/DocumentManager.cjs +0 -229
- package/dist/DocumentManager.d.cts +0 -59
- package/dist/DocumentManager.d.cts.map +0 -1
- package/dist/DocumentManager.d.mts +0 -59
- package/dist/DocumentManager.d.mts.map +0 -1
- package/dist/DocumentManager.mjs +0 -227
- package/dist/DocumentManager.mjs.map +0 -1
- package/src/DocumentManager.ts +0 -506
- 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,15 +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
|
-
|
|
36
|
+
type DocumentInstance as DocumentInstanceType,
|
|
37
|
+
} from "./DocumentInstance";
|
|
35
38
|
import {
|
|
36
39
|
PresenceManagerTag,
|
|
37
40
|
layer as presenceManagerLayer,
|
|
38
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;
|
|
39
53
|
|
|
40
54
|
// =============================================================================
|
|
41
55
|
// MimicServerEngine Interface
|
|
@@ -51,27 +65,30 @@ export interface MimicServerEngine {
|
|
|
51
65
|
/**
|
|
52
66
|
* Submit a transaction to a document.
|
|
53
67
|
* Authorization is checked against the auth service.
|
|
68
|
+
* May fail with MimicServerEngineError if storage is unavailable.
|
|
54
69
|
*/
|
|
55
70
|
readonly submit: (
|
|
56
71
|
documentId: string,
|
|
57
72
|
transaction: Transaction.Transaction
|
|
58
|
-
) => Effect.Effect<SubmitResult,
|
|
73
|
+
) => Effect.Effect<SubmitResult, MimicServerEngineError>;
|
|
59
74
|
|
|
60
75
|
/**
|
|
61
76
|
* Get document snapshot (current state and version).
|
|
77
|
+
* May fail with MimicServerEngineError if storage is unavailable.
|
|
62
78
|
*/
|
|
63
79
|
readonly getSnapshot: (
|
|
64
80
|
documentId: string
|
|
65
|
-
) => Effect.Effect<{ state: unknown; version: number },
|
|
81
|
+
) => Effect.Effect<{ state: unknown; version: number }, MimicServerEngineError>;
|
|
66
82
|
|
|
67
83
|
/**
|
|
68
84
|
* Subscribe to document broadcasts (transactions).
|
|
69
85
|
* Returns a stream of server messages.
|
|
70
86
|
* Requires a Scope for cleanup when the subscription ends.
|
|
87
|
+
* May fail with MimicServerEngineError if storage is unavailable.
|
|
71
88
|
*/
|
|
72
89
|
readonly subscribe: (
|
|
73
90
|
documentId: string
|
|
74
|
-
) => Effect.Effect<Stream.Stream<Protocol.ServerMessage, never, never>,
|
|
91
|
+
) => Effect.Effect<Stream.Stream<Protocol.ServerMessage, never, never>, MimicServerEngineError, Scope.Scope>;
|
|
75
92
|
|
|
76
93
|
/**
|
|
77
94
|
* Touch document to prevent idle garbage collection.
|
|
@@ -136,6 +153,7 @@ const DEFAULT_MAX_IDLE_TIME = Duration.minutes(5);
|
|
|
136
153
|
const DEFAULT_MAX_TRANSACTION_HISTORY = 1000;
|
|
137
154
|
const DEFAULT_SNAPSHOT_INTERVAL = Duration.minutes(5);
|
|
138
155
|
const DEFAULT_SNAPSHOT_THRESHOLD = 100;
|
|
156
|
+
const DEFAULT_SNAPSHOT_IDLE_TIMEOUT = Duration.seconds(30);
|
|
139
157
|
|
|
140
158
|
/**
|
|
141
159
|
* Resolve configuration with defaults
|
|
@@ -157,9 +175,24 @@ const resolveConfig = <TSchema extends Primitive.AnyPrimitive>(
|
|
|
157
175
|
: DEFAULT_SNAPSHOT_INTERVAL,
|
|
158
176
|
transactionThreshold:
|
|
159
177
|
config.snapshot?.transactionThreshold ?? DEFAULT_SNAPSHOT_THRESHOLD,
|
|
178
|
+
idleTimeout: config.snapshot?.idleTimeout
|
|
179
|
+
? Duration.decode(config.snapshot.idleTimeout)
|
|
180
|
+
: DEFAULT_SNAPSHOT_IDLE_TIMEOUT,
|
|
160
181
|
},
|
|
161
182
|
});
|
|
162
183
|
|
|
184
|
+
// =============================================================================
|
|
185
|
+
// Internal Types
|
|
186
|
+
// =============================================================================
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Store entry for a document instance with last activity time
|
|
190
|
+
*/
|
|
191
|
+
interface StoreEntry<TSchema extends Primitive.AnyPrimitive> {
|
|
192
|
+
readonly instance: DocumentInstanceType<TSchema>;
|
|
193
|
+
readonly lastActivityTime: Ref.Ref<number>;
|
|
194
|
+
}
|
|
195
|
+
|
|
163
196
|
// =============================================================================
|
|
164
197
|
// Factory
|
|
165
198
|
// =============================================================================
|
|
@@ -204,37 +237,204 @@ export const make = <TSchema extends Primitive.AnyPrimitive>(
|
|
|
204
237
|
> => {
|
|
205
238
|
const resolvedConfig = resolveConfig(config);
|
|
206
239
|
|
|
207
|
-
// Create config layer for DocumentManager
|
|
208
|
-
const configLayer = Layer.succeed(
|
|
209
|
-
DocumentManagerConfigTag,
|
|
210
|
-
resolvedConfig as ResolvedConfig<Primitive.AnyPrimitive>
|
|
211
|
-
);
|
|
212
|
-
|
|
213
|
-
// Create internal layers
|
|
214
|
-
const internalLayers = Layer.mergeAll(
|
|
215
|
-
documentManagerLayer.pipe(Layer.provide(configLayer)),
|
|
216
|
-
presenceManagerLayer
|
|
217
|
-
);
|
|
218
|
-
|
|
219
240
|
return Layer.scoped(
|
|
220
241
|
MimicServerEngineTag,
|
|
221
242
|
Effect.gen(function* () {
|
|
222
|
-
const
|
|
243
|
+
const coldStorage = yield* ColdStorageTag;
|
|
244
|
+
const hotStorage = yield* HotStorageTag;
|
|
223
245
|
const presenceManager = yield* PresenceManagerTag;
|
|
224
246
|
|
|
247
|
+
// Store: documentId -> StoreEntry
|
|
248
|
+
const store = yield* Ref.make(
|
|
249
|
+
HashMap.empty<string, StoreEntry<TSchema>>()
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Get or create a document instance
|
|
254
|
+
*/
|
|
255
|
+
const getOrCreateDocument = Effect.fn("engine.document.get-or-create")(
|
|
256
|
+
function* (documentId: string) {
|
|
257
|
+
const current = yield* Ref.get(store);
|
|
258
|
+
const existing = HashMap.get(current, documentId);
|
|
259
|
+
|
|
260
|
+
if (existing._tag === "Some") {
|
|
261
|
+
// Update activity time
|
|
262
|
+
yield* Ref.set(existing.value.lastActivityTime, Date.now());
|
|
263
|
+
return existing.value.instance;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Create new document instance
|
|
267
|
+
const instance = yield* DocumentInstance.make(
|
|
268
|
+
documentId,
|
|
269
|
+
{
|
|
270
|
+
schema: config.schema,
|
|
271
|
+
initial: config.initial,
|
|
272
|
+
maxTransactionHistory: resolvedConfig.maxTransactionHistory,
|
|
273
|
+
snapshot: resolvedConfig.snapshot,
|
|
274
|
+
},
|
|
275
|
+
coldStorage,
|
|
276
|
+
hotStorage
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
const lastActivityTime = yield* Ref.make(Date.now());
|
|
280
|
+
|
|
281
|
+
// Store it
|
|
282
|
+
yield* Ref.update(store, (map) =>
|
|
283
|
+
HashMap.set(map, documentId, { instance, lastActivityTime })
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
return instance;
|
|
287
|
+
}
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Start background GC fiber
|
|
292
|
+
*/
|
|
293
|
+
const startGCFiber = Effect.fn("engine.gc.start")(function* () {
|
|
294
|
+
const gcLoop = Effect.fn("engine.gc.loop")(function* () {
|
|
295
|
+
const current = yield* Ref.get(store);
|
|
296
|
+
const now = Date.now();
|
|
297
|
+
const maxIdleMs = Duration.toMillis(resolvedConfig.maxIdleTime);
|
|
298
|
+
|
|
299
|
+
for (const [documentId, entry] of current) {
|
|
300
|
+
const lastActivity = yield* Ref.get(entry.lastActivityTime);
|
|
301
|
+
if (now - lastActivity >= maxIdleMs) {
|
|
302
|
+
// Save final snapshot before eviction (best effort)
|
|
303
|
+
yield* Effect.catchAll(entry.instance.saveSnapshot(), (e) =>
|
|
304
|
+
Effect.logError("Failed to save snapshot during eviction", {
|
|
305
|
+
documentId,
|
|
306
|
+
error: e,
|
|
307
|
+
})
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
// Remove from store
|
|
311
|
+
yield* Ref.update(store, (map) => HashMap.remove(map, documentId));
|
|
312
|
+
|
|
313
|
+
// Track eviction metrics
|
|
314
|
+
yield* Metric.increment(Metrics.documentsEvicted);
|
|
315
|
+
yield* Metric.incrementBy(Metrics.documentsActive, -1);
|
|
316
|
+
|
|
317
|
+
yield* Effect.logInfo("Document evicted due to idle timeout", {
|
|
318
|
+
documentId,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// Run GC every minute
|
|
325
|
+
yield* gcLoop().pipe(
|
|
326
|
+
Effect.repeat(Schedule.spaced("1 minute")),
|
|
327
|
+
Effect.fork
|
|
328
|
+
);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// Start GC fiber
|
|
332
|
+
yield* startGCFiber();
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Start background snapshot fiber for idle documents.
|
|
336
|
+
* This ensures documents with unsnapshot transactions get persisted
|
|
337
|
+
* even without new transaction activity.
|
|
338
|
+
*/
|
|
339
|
+
const startSnapshotFiber = Effect.fn("engine.snapshot.fiber.start")(function* () {
|
|
340
|
+
const idleTimeoutMs = Duration.toMillis(resolvedConfig.snapshot.idleTimeout);
|
|
341
|
+
|
|
342
|
+
// Skip if idle snapshots are disabled
|
|
343
|
+
if (idleTimeoutMs <= 0) {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const snapshotLoop = Effect.fn("engine.snapshot.loop")(function* () {
|
|
348
|
+
const current = yield* Ref.get(store);
|
|
349
|
+
const now = Date.now();
|
|
350
|
+
|
|
351
|
+
for (const [documentId, entry] of current) {
|
|
352
|
+
// Check if document has been idle long enough
|
|
353
|
+
const lastActivity = yield* Ref.get(entry.lastActivityTime);
|
|
354
|
+
const idleDuration = now - lastActivity;
|
|
355
|
+
|
|
356
|
+
if (idleDuration < idleTimeoutMs) {
|
|
357
|
+
// Document not idle long enough, skip
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Check if document has unsnapshot transactions
|
|
362
|
+
const needs = yield* entry.instance.needsSnapshot();
|
|
363
|
+
if (!needs) {
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Save snapshot (with error handling)
|
|
368
|
+
yield* Effect.catchAll(entry.instance.saveSnapshot(), (e) =>
|
|
369
|
+
Effect.logWarning("Periodic snapshot save failed", {
|
|
370
|
+
documentId,
|
|
371
|
+
error: e,
|
|
372
|
+
})
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
// Track metric
|
|
376
|
+
yield* Metric.increment(Metrics.storageIdleSnapshots);
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// Run snapshot check every 10 seconds
|
|
381
|
+
yield* snapshotLoop().pipe(
|
|
382
|
+
Effect.repeat(Schedule.spaced("10 seconds")),
|
|
383
|
+
Effect.fork
|
|
384
|
+
);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// Start snapshot fiber
|
|
388
|
+
yield* startSnapshotFiber();
|
|
389
|
+
|
|
390
|
+
// Cleanup on shutdown
|
|
391
|
+
yield* Effect.addFinalizer(() =>
|
|
392
|
+
Effect.fn("engine.shutdown")(function* () {
|
|
393
|
+
const current = yield* Ref.get(store);
|
|
394
|
+
for (const [documentId, entry] of current) {
|
|
395
|
+
// Best effort save - don't fail shutdown if storage is unavailable
|
|
396
|
+
yield* Effect.catchAll(entry.instance.saveSnapshot(), (e) =>
|
|
397
|
+
Effect.logError("Failed to save snapshot during shutdown", {
|
|
398
|
+
documentId,
|
|
399
|
+
error: e,
|
|
400
|
+
})
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
yield* Effect.logInfo("MimicServerEngine shutdown complete");
|
|
404
|
+
})()
|
|
405
|
+
);
|
|
406
|
+
|
|
225
407
|
const engine: MimicServerEngine = {
|
|
226
408
|
submit: (documentId, transaction) =>
|
|
227
|
-
|
|
409
|
+
Effect.gen(function* () {
|
|
410
|
+
const instance = yield* getOrCreateDocument(documentId);
|
|
411
|
+
return yield* instance.submit(transaction);
|
|
412
|
+
}),
|
|
228
413
|
|
|
229
|
-
getSnapshot: (documentId) =>
|
|
414
|
+
getSnapshot: (documentId) =>
|
|
415
|
+
Effect.gen(function* () {
|
|
416
|
+
const instance = yield* getOrCreateDocument(documentId);
|
|
417
|
+
return instance.getSnapshot();
|
|
418
|
+
}),
|
|
230
419
|
|
|
231
420
|
subscribe: (documentId) =>
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
421
|
+
Effect.gen(function* () {
|
|
422
|
+
const instance = yield* getOrCreateDocument(documentId);
|
|
423
|
+
return Stream.fromPubSub(instance.pubsub) as Stream.Stream<
|
|
424
|
+
Protocol.ServerMessage,
|
|
425
|
+
never,
|
|
426
|
+
never
|
|
427
|
+
>;
|
|
428
|
+
}),
|
|
429
|
+
|
|
430
|
+
touch: (documentId) =>
|
|
431
|
+
Effect.gen(function* () {
|
|
432
|
+
const current = yield* Ref.get(store);
|
|
433
|
+
const existing = HashMap.get(current, documentId);
|
|
434
|
+
if (existing._tag === "Some") {
|
|
435
|
+
yield* Ref.set(existing.value.lastActivityTime, Date.now());
|
|
436
|
+
}
|
|
437
|
+
}),
|
|
238
438
|
|
|
239
439
|
getPresenceSnapshot: (documentId) =>
|
|
240
440
|
presenceManager.getSnapshot(documentId),
|
|
@@ -253,7 +453,7 @@ export const make = <TSchema extends Primitive.AnyPrimitive>(
|
|
|
253
453
|
|
|
254
454
|
return engine;
|
|
255
455
|
})
|
|
256
|
-
).pipe(Layer.provide(
|
|
456
|
+
).pipe(Layer.provide(presenceManagerLayer));
|
|
257
457
|
};
|
|
258
458
|
|
|
259
459
|
// =============================================================================
|