@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.
Files changed (142) hide show
  1. package/.turbo/turbo-build.log +116 -74
  2. package/dist/ColdStorage.cjs +9 -5
  3. package/dist/ColdStorage.d.cts.map +1 -1
  4. package/dist/ColdStorage.d.mts.map +1 -1
  5. package/dist/ColdStorage.mjs +9 -5
  6. package/dist/ColdStorage.mjs.map +1 -1
  7. package/dist/DocumentInstance.cjs +263 -0
  8. package/dist/DocumentInstance.d.cts +78 -0
  9. package/dist/DocumentInstance.d.cts.map +1 -0
  10. package/dist/DocumentInstance.d.mts +78 -0
  11. package/dist/DocumentInstance.d.mts.map +1 -0
  12. package/dist/DocumentInstance.mjs +264 -0
  13. package/dist/DocumentInstance.mjs.map +1 -0
  14. package/dist/Errors.cjs +10 -1
  15. package/dist/Errors.d.cts +18 -3
  16. package/dist/Errors.d.cts.map +1 -1
  17. package/dist/Errors.d.mts +18 -3
  18. package/dist/Errors.d.mts.map +1 -1
  19. package/dist/Errors.mjs +9 -1
  20. package/dist/Errors.mjs.map +1 -1
  21. package/dist/HotStorage.cjs +39 -12
  22. package/dist/HotStorage.d.cts +17 -1
  23. package/dist/HotStorage.d.cts.map +1 -1
  24. package/dist/HotStorage.d.mts +17 -1
  25. package/dist/HotStorage.d.mts.map +1 -1
  26. package/dist/HotStorage.mjs +39 -12
  27. package/dist/HotStorage.mjs.map +1 -1
  28. package/dist/Metrics.cjs +29 -1
  29. package/dist/Metrics.d.cts +5 -0
  30. package/dist/Metrics.d.cts.map +1 -1
  31. package/dist/Metrics.d.mts +5 -0
  32. package/dist/Metrics.d.mts.map +1 -1
  33. package/dist/Metrics.mjs +26 -1
  34. package/dist/Metrics.mjs.map +1 -1
  35. package/dist/MimicClusterServerEngine.cjs +44 -139
  36. package/dist/MimicClusterServerEngine.d.cts.map +1 -1
  37. package/dist/MimicClusterServerEngine.d.mts +1 -1
  38. package/dist/MimicClusterServerEngine.d.mts.map +1 -1
  39. package/dist/MimicClusterServerEngine.mjs +46 -141
  40. package/dist/MimicClusterServerEngine.mjs.map +1 -1
  41. package/dist/MimicServer.cjs +20 -20
  42. package/dist/MimicServer.d.cts.map +1 -1
  43. package/dist/MimicServer.d.mts.map +1 -1
  44. package/dist/MimicServer.mjs +20 -20
  45. package/dist/MimicServer.mjs.map +1 -1
  46. package/dist/MimicServerEngine.cjs +92 -11
  47. package/dist/MimicServerEngine.d.cts +12 -4
  48. package/dist/MimicServerEngine.d.cts.map +1 -1
  49. package/dist/MimicServerEngine.d.mts +12 -4
  50. package/dist/MimicServerEngine.d.mts.map +1 -1
  51. package/dist/MimicServerEngine.mjs +94 -13
  52. package/dist/MimicServerEngine.mjs.map +1 -1
  53. package/dist/PresenceManager.cjs +5 -5
  54. package/dist/PresenceManager.d.cts.map +1 -1
  55. package/dist/PresenceManager.d.mts.map +1 -1
  56. package/dist/PresenceManager.mjs +5 -5
  57. package/dist/PresenceManager.mjs.map +1 -1
  58. package/dist/Protocol.d.cts +1 -1
  59. package/dist/Protocol.d.mts +1 -1
  60. package/dist/Types.d.cts +9 -2
  61. package/dist/Types.d.cts.map +1 -1
  62. package/dist/Types.d.mts +9 -2
  63. package/dist/Types.d.mts.map +1 -1
  64. package/dist/index.cjs +5 -6
  65. package/dist/index.d.cts +3 -3
  66. package/dist/index.d.mts +3 -3
  67. package/dist/index.mjs +3 -3
  68. package/dist/testing/ColdStorageTestSuite.cjs +508 -0
  69. package/dist/testing/ColdStorageTestSuite.d.cts +36 -0
  70. package/dist/testing/ColdStorageTestSuite.d.cts.map +1 -0
  71. package/dist/testing/ColdStorageTestSuite.d.mts +36 -0
  72. package/dist/testing/ColdStorageTestSuite.d.mts.map +1 -0
  73. package/dist/testing/ColdStorageTestSuite.mjs +508 -0
  74. package/dist/testing/ColdStorageTestSuite.mjs.map +1 -0
  75. package/dist/testing/FailingStorage.cjs +162 -0
  76. package/dist/testing/FailingStorage.d.cts +43 -0
  77. package/dist/testing/FailingStorage.d.cts.map +1 -0
  78. package/dist/testing/FailingStorage.d.mts +43 -0
  79. package/dist/testing/FailingStorage.d.mts.map +1 -0
  80. package/dist/testing/FailingStorage.mjs +163 -0
  81. package/dist/testing/FailingStorage.mjs.map +1 -0
  82. package/dist/testing/HotStorageTestSuite.cjs +820 -0
  83. package/dist/testing/HotStorageTestSuite.d.cts +42 -0
  84. package/dist/testing/HotStorageTestSuite.d.cts.map +1 -0
  85. package/dist/testing/HotStorageTestSuite.d.mts +42 -0
  86. package/dist/testing/HotStorageTestSuite.d.mts.map +1 -0
  87. package/dist/testing/HotStorageTestSuite.mjs +820 -0
  88. package/dist/testing/HotStorageTestSuite.mjs.map +1 -0
  89. package/dist/testing/StorageIntegrationTestSuite.cjs +487 -0
  90. package/dist/testing/StorageIntegrationTestSuite.d.cts +37 -0
  91. package/dist/testing/StorageIntegrationTestSuite.d.cts.map +1 -0
  92. package/dist/testing/StorageIntegrationTestSuite.d.mts +37 -0
  93. package/dist/testing/StorageIntegrationTestSuite.d.mts.map +1 -0
  94. package/dist/testing/StorageIntegrationTestSuite.mjs +487 -0
  95. package/dist/testing/StorageIntegrationTestSuite.mjs.map +1 -0
  96. package/dist/testing/assertions.cjs +117 -0
  97. package/dist/testing/assertions.mjs +112 -0
  98. package/dist/testing/assertions.mjs.map +1 -0
  99. package/dist/testing/index.cjs +14 -0
  100. package/dist/testing/index.d.cts +6 -0
  101. package/dist/testing/index.d.mts +6 -0
  102. package/dist/testing/index.mjs +7 -0
  103. package/dist/testing/types.cjs +15 -0
  104. package/dist/testing/types.d.cts +90 -0
  105. package/dist/testing/types.d.cts.map +1 -0
  106. package/dist/testing/types.d.mts +90 -0
  107. package/dist/testing/types.d.mts.map +1 -0
  108. package/dist/testing/types.mjs +16 -0
  109. package/dist/testing/types.mjs.map +1 -0
  110. package/package.json +8 -3
  111. package/src/ColdStorage.ts +21 -12
  112. package/src/DocumentInstance.ts +527 -0
  113. package/src/Errors.ts +15 -1
  114. package/src/HotStorage.ts +115 -24
  115. package/src/Metrics.ts +30 -0
  116. package/src/MimicClusterServerEngine.ts +120 -275
  117. package/src/MimicServer.ts +83 -75
  118. package/src/MimicServerEngine.ts +230 -30
  119. package/src/PresenceManager.ts +44 -34
  120. package/src/Types.ts +9 -2
  121. package/src/index.ts +5 -35
  122. package/src/testing/ColdStorageTestSuite.ts +589 -0
  123. package/src/testing/FailingStorage.ts +338 -0
  124. package/src/testing/HotStorageTestSuite.ts +1105 -0
  125. package/src/testing/StorageIntegrationTestSuite.ts +736 -0
  126. package/src/testing/assertions.ts +188 -0
  127. package/src/testing/index.ts +83 -0
  128. package/src/testing/types.ts +100 -0
  129. package/tests/ColdStorage.test.ts +8 -120
  130. package/tests/DocumentInstance.test.ts +669 -0
  131. package/tests/HotStorage.test.ts +7 -126
  132. package/tests/StorageIntegration.test.ts +259 -0
  133. package/tsdown.config.ts +1 -1
  134. package/dist/DocumentManager.cjs +0 -229
  135. package/dist/DocumentManager.d.cts +0 -59
  136. package/dist/DocumentManager.d.cts.map +0 -1
  137. package/dist/DocumentManager.d.mts +0 -59
  138. package/dist/DocumentManager.d.mts.map +0 -1
  139. package/dist/DocumentManager.mjs +0 -227
  140. package/dist/DocumentManager.mjs.map +0 -1
  141. package/src/DocumentManager.ts +0 -506
  142. package/tests/DocumentManager.test.ts +0 -335
@@ -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
- socket: Socket.Socket,
98
- documentId: string,
99
- engine: MimicServerEngine,
100
- authService: MimicAuthService,
101
- _routeConfig: ResolvedRouteConfig
102
- ): Effect.Effect<void, Socket.SocketError, Scope.Scope> =>
103
- Effect.gen(function* () {
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.gen(function* () {
128
- if (!engine.config.presence) return;
126
+ const sendPresenceSnapshot = Effect.fn("presence.snapshot.send")(
127
+ function* () {
128
+ if (!engine.config.presence) return;
129
129
 
130
- const snapshot = yield* engine.getPresenceSnapshot(documentId);
131
- yield* sendMessage(
132
- Protocol.presenceSnapshotMessage(connectionId, snapshot.presences)
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.gen(function* () {
138
- const snapshot = yield* engine.getSnapshot(documentId);
139
- yield* sendMessage(
140
- Protocol.snapshotMessage(snapshot.state, snapshot.version)
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
- if (result._tag === "Right") {
152
- state.authenticated = true;
153
- state.authContext = result.right;
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
- yield* sendMessage(
156
- Protocol.authResultSuccess(
157
- result.right.userId,
158
- result.right.permission
159
- )
160
- );
153
+ if (result._tag === "Right") {
154
+ state.authenticated = true;
155
+ state.authContext = result.right;
161
156
 
162
- // Send document snapshot after successful auth
163
- yield* sendDocumentSnapshot;
157
+ yield* sendMessage(
158
+ Protocol.authResultSuccess(
159
+ result.right.userId,
160
+ result.right.permission
161
+ )
162
+ );
164
163
 
165
- // Send presence snapshot after successful auth
166
- yield* sendPresenceSnapshot;
167
- } else {
168
- yield* Metric.increment(Metrics.connectionsErrors);
169
- yield* sendMessage(
170
- Protocol.authResultFailure(
171
- result.left.reason ?? "Authentication failed"
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 = (data: unknown) =>
179
- Effect.gen(function* () {
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.gen(function* () {
213
- if (!state.authenticated) return;
214
- if (!engine.config.presence) return;
215
+ const handlePresenceClear = Effect.fn("presence.clear.handle")(
216
+ function* () {
217
+ if (!state.authenticated) return;
218
+ if (!engine.config.presence) return;
215
219
 
216
- yield* engine.removePresence(documentId, connectionId);
217
- state.hasPresence = false;
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: Protocol.ClientMessage) =>
222
- Effect.gen(function* () {
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.gen(function* () {
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.gen(function* () {
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.gen(function* () {
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.gen(function* () {
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 = (request: HttpServerRequest.HttpServerRequest) =>
441
- Effect.gen(function* () {
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
  })
@@ -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 { Presence, Primitive, Transaction } from "@voidhash/mimic";
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
- DocumentManagerTag,
31
- DocumentManagerConfigTag,
32
- layer as documentManagerLayer,
34
+ DocumentInstance,
33
35
  type SubmitResult,
34
- } from "./DocumentManager";
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, never>;
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 }, never>;
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>, never, Scope.Scope>;
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 documentManager = yield* DocumentManagerTag;
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
- documentManager.submit(documentId, transaction),
409
+ Effect.gen(function* () {
410
+ const instance = yield* getOrCreateDocument(documentId);
411
+ return yield* instance.submit(transaction);
412
+ }),
228
413
 
229
- getSnapshot: (documentId) => documentManager.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
- documentManager.subscribe(documentId) as Effect.Effect<
233
- Stream.Stream<Protocol.ServerMessage, never, never>,
234
- never
235
- >,
236
-
237
- touch: (documentId) => documentManager.touch(documentId),
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(internalLayers));
456
+ ).pipe(Layer.provide(presenceManagerLayer));
257
457
  };
258
458
 
259
459
  // =============================================================================