@voidhash/mimic-effect 1.0.0-beta.1 → 1.0.0-beta.11

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 +264 -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 +265 -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 +40 -12
  22. package/dist/HotStorage.d.cts +21 -1
  23. package/dist/HotStorage.d.cts.map +1 -1
  24. package/dist/HotStorage.d.mts +21 -1
  25. package/dist/HotStorage.d.mts.map +1 -1
  26. package/dist/HotStorage.mjs +40 -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 +858 -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 +858 -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 +531 -0
  113. package/src/Errors.ts +15 -1
  114. package/src/HotStorage.ts +130 -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 +1161 -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
@@ -1,6 +1,6 @@
1
+ import { MissingDocumentIdError } from "./Errors.mjs";
1
2
  import { connectionsActive, connectionsDuration, connectionsErrors, connectionsTotal } from "./Metrics.mjs";
2
3
  import { MimicServerEngineTag } from "./MimicServerEngine.mjs";
3
- import { MissingDocumentIdError } from "./Errors.mjs";
4
4
  import { authResultFailure, authResultSuccess, encodeServerMessage, errorMessage, parseClientMessage, pong, presenceRemoveMessage, presenceSnapshotMessage, presenceUpdateMessage, snapshotMessage } from "./Protocol.mjs";
5
5
  import { MimicAuthServiceTag } from "./MimicAuthService.mjs";
6
6
  import { Duration, Effect, Fiber, Layer, Metric, Stream } from "effect";
@@ -42,7 +42,7 @@ const extractDocumentId = (path) => {
42
42
  /**
43
43
  * Handle a WebSocket connection for a document.
44
44
  */
45
- const handleWebSocketConnection = (socket, documentId, engine, authService, _routeConfig) => Effect.gen(function* () {
45
+ const handleWebSocketConnection = Effect.fn("websocket.connection.handle")(function* (socket, documentId, engine, authService, _routeConfig) {
46
46
  const connectionId = crypto.randomUUID();
47
47
  const connectionStartTime = Date.now();
48
48
  yield* Metric.increment(connectionsTotal);
@@ -55,30 +55,30 @@ const handleWebSocketConnection = (socket, documentId, engine, authService, _rou
55
55
  };
56
56
  const write = yield* socket.writer;
57
57
  const sendMessage = (message) => write(encodeServerMessage(message));
58
- const sendPresenceSnapshot = Effect.gen(function* () {
58
+ const sendPresenceSnapshot = Effect.fn("presence.snapshot.send")(function* () {
59
59
  if (!engine.config.presence) return;
60
60
  const snapshot = yield* engine.getPresenceSnapshot(documentId);
61
61
  yield* sendMessage(presenceSnapshotMessage(connectionId, snapshot.presences));
62
62
  });
63
- const sendDocumentSnapshot = Effect.gen(function* () {
63
+ const sendDocumentSnapshot = Effect.fn("document.snapshot.send")(function* () {
64
64
  const snapshot = yield* engine.getSnapshot(documentId);
65
65
  yield* sendMessage(snapshotMessage(snapshot.state, snapshot.version));
66
66
  });
67
- const handleAuth = (token) => Effect.gen(function* () {
67
+ const handleAuth = Effect.fn("auth.handle")(function* (token) {
68
68
  const result = yield* Effect.either(authService.authenticate(token, documentId));
69
69
  if (result._tag === "Right") {
70
70
  state.authenticated = true;
71
71
  state.authContext = result.right;
72
72
  yield* sendMessage(authResultSuccess(result.right.userId, result.right.permission));
73
- yield* sendDocumentSnapshot;
74
- yield* sendPresenceSnapshot;
73
+ yield* sendDocumentSnapshot();
74
+ yield* sendPresenceSnapshot();
75
75
  } else {
76
76
  var _result$left$reason;
77
77
  yield* Metric.increment(connectionsErrors);
78
78
  yield* sendMessage(authResultFailure((_result$left$reason = result.left.reason) !== null && _result$left$reason !== void 0 ? _result$left$reason : "Authentication failed"));
79
79
  }
80
80
  });
81
- const handlePresenceSet = (data) => Effect.gen(function* () {
81
+ const handlePresenceSet = Effect.fn("presence.set.handle")(function* (data) {
82
82
  if (!state.authenticated) return;
83
83
  if (!state.authContext) return;
84
84
  if (!engine.config.presence) return;
@@ -100,13 +100,13 @@ const handleWebSocketConnection = (socket, documentId, engine, authService, _rou
100
100
  });
101
101
  state.hasPresence = true;
102
102
  });
103
- const handlePresenceClear = Effect.gen(function* () {
103
+ const handlePresenceClear = Effect.fn("presence.clear.handle")(function* () {
104
104
  if (!state.authenticated) return;
105
105
  if (!engine.config.presence) return;
106
106
  yield* engine.removePresence(documentId, connectionId);
107
107
  state.hasPresence = false;
108
108
  });
109
- const handleMessage = (message) => Effect.gen(function* () {
109
+ const handleMessage = Effect.fn("message.handle")(function* (message) {
110
110
  yield* engine.touch(documentId);
111
111
  switch (message.type) {
112
112
  case "auth":
@@ -137,16 +137,16 @@ const handleWebSocketConnection = (socket, documentId, engine, authService, _rou
137
137
  yield* handlePresenceSet(message.data);
138
138
  break;
139
139
  case "presence_clear":
140
- yield* handlePresenceClear;
140
+ yield* handlePresenceClear();
141
141
  break;
142
142
  }
143
143
  });
144
- const subscribeFiber = yield* Effect.fork(Effect.gen(function* () {
144
+ const subscribeFiber = yield* Effect.fork(Effect.fn("subscriptions.document.start")(function* () {
145
145
  while (!state.authenticated) yield* Effect.sleep(Duration.millis(100));
146
146
  const broadcastStream = yield* engine.subscribe(documentId);
147
147
  yield* Stream.runForEach(broadcastStream, (broadcast) => sendMessage(broadcast));
148
- }).pipe(Effect.scoped));
149
- const presenceFiber = yield* Effect.fork(Effect.gen(function* () {
148
+ })().pipe(Effect.scoped));
149
+ const presenceFiber = yield* Effect.fork(Effect.fn("subscriptions.presence.start")(function* () {
150
150
  if (!engine.config.presence) return;
151
151
  while (!state.authenticated) yield* Effect.sleep(Duration.millis(100));
152
152
  const presenceStream = yield* engine.subscribePresence(documentId);
@@ -155,8 +155,8 @@ const handleWebSocketConnection = (socket, documentId, engine, authService, _rou
155
155
  if (event.type === "presence_update") yield* sendMessage(presenceUpdateMessage(event.id, event.data, event.userId));
156
156
  else if (event.type === "presence_remove") yield* sendMessage(presenceRemoveMessage(event.id));
157
157
  }));
158
- }).pipe(Effect.scoped));
159
- yield* Effect.addFinalizer(() => Effect.gen(function* () {
158
+ })().pipe(Effect.scoped));
159
+ yield* Effect.addFinalizer(() => Effect.fn("connection.cleanup")(function* () {
160
160
  const duration = Date.now() - connectionStartTime;
161
161
  yield* Fiber.interrupt(subscribeFiber);
162
162
  yield* Fiber.interrupt(presenceFiber);
@@ -168,10 +168,10 @@ const handleWebSocketConnection = (socket, documentId, engine, authService, _rou
168
168
  documentId,
169
169
  durationMs: duration
170
170
  });
171
- }));
172
- yield* socket.runRaw((data) => Effect.gen(function* () {
171
+ })());
172
+ yield* socket.runRaw((data) => Effect.fn("message.process")(function* () {
173
173
  yield* handleMessage(yield* parseClientMessage(data));
174
- }).pipe(Effect.catchAll((error) => Effect.logError("Message handling error", error))));
174
+ })().pipe(Effect.catchAll((error) => Effect.logError("Message handling error", error))));
175
175
  });
176
176
  /**
177
177
  * Create a route layer for MimicServerEngine.
@@ -218,7 +218,7 @@ const layerHttpLayerRouter = (options) => {
218
218
  const router = yield* HttpLayerRouter.HttpRouter;
219
219
  const engine = yield* MimicServerEngineTag;
220
220
  const authService = yield* MimicAuthServiceTag;
221
- const handler = (request) => Effect.gen(function* () {
221
+ const handler = Effect.fn("websocket.route.handler")(function* (request) {
222
222
  const documentIdResult = yield* Effect.either(extractDocumentId(request.url));
223
223
  if (documentIdResult._tag === "Left") return HttpServerResponse.text(`Missing document ID in path: ${request.url}`, { status: 400 });
224
224
  const documentId = documentIdResult.right;
@@ -1 +1 @@
1
- {"version":3,"file":"MimicServer.mjs","names":["Metrics.connectionsTotal","Metrics.connectionsActive","state: ConnectionState","Protocol.encodeServerMessage","Protocol.presenceSnapshotMessage","Protocol.snapshotMessage","Protocol.authResultSuccess","Metrics.connectionsErrors","Protocol.authResultFailure","Protocol.pong","Protocol.errorMessage","Protocol.presenceUpdateMessage","Protocol.presenceRemoveMessage","Metrics.connectionsDuration","Protocol.parseClientMessage"],"sources":["../src/MimicServer.ts"],"sourcesContent":["/**\n * @voidhash/mimic-effect - MimicServer\n *\n * WebSocket route layer for MimicServerEngine.\n * Creates routes compatible with HttpLayerRouter.\n */\nimport {\n Duration,\n Effect,\n Fiber,\n Layer,\n Metric,\n Scope,\n Stream,\n} from \"effect\";\nimport {\n HttpLayerRouter,\n HttpServerRequest,\n HttpServerResponse,\n} from \"@effect/platform\";\nimport type * as Socket from \"@effect/platform/Socket\";\nimport { Presence } from \"@voidhash/mimic\";\nimport type { MimicServerRouteConfig, ResolvedRouteConfig } from \"./Types\";\nimport * as Protocol from \"./Protocol\";\nimport { MissingDocumentIdError } from \"./Errors\";\nimport { MimicServerEngineTag, type MimicServerEngine } from \"./MimicServerEngine\";\nimport { MimicAuthServiceTag, type MimicAuthService } from \"./MimicAuthService\";\nimport * as Metrics from \"./Metrics\";\nimport type { AuthContext } from \"./Types\";\n\n// =============================================================================\n// Default Configuration\n// =============================================================================\n\nconst DEFAULT_PATH = \"/mimic\";\nconst DEFAULT_HEARTBEAT_INTERVAL = Duration.seconds(30);\nconst DEFAULT_HEARTBEAT_TIMEOUT = Duration.seconds(10);\n\n/**\n * Resolve route configuration with defaults\n */\nconst resolveRouteConfig = (\n config?: MimicServerRouteConfig\n): ResolvedRouteConfig => ({\n path: config?.path ?? DEFAULT_PATH,\n heartbeatInterval: config?.heartbeatInterval\n ? Duration.decode(config.heartbeatInterval)\n : DEFAULT_HEARTBEAT_INTERVAL,\n heartbeatTimeout: config?.heartbeatTimeout\n ? Duration.decode(config.heartbeatTimeout)\n : DEFAULT_HEARTBEAT_TIMEOUT,\n});\n\n// =============================================================================\n// URL Path Parsing\n// =============================================================================\n\n/**\n * Extract document ID from URL path.\n * Expected format: /basePath/doc/{documentId}\n */\nconst extractDocumentId = (\n path: string\n): Effect.Effect<string, MissingDocumentIdError> => {\n // Remove leading slash and split\n const parts = path.replace(/^\\/+/, \"\").split(\"/\");\n\n // Find the last occurrence of 'doc' in the path\n const docIndex = parts.lastIndexOf(\"doc\");\n const part = parts[docIndex + 1];\n if (docIndex !== -1 && part) {\n return Effect.succeed(decodeURIComponent(part));\n }\n return Effect.fail(new MissingDocumentIdError({ path }));\n};\n\n// =============================================================================\n// Connection State\n// =============================================================================\n\ninterface ConnectionState {\n readonly documentId: string;\n readonly connectionId: string;\n authenticated: boolean;\n authContext?: AuthContext;\n hasPresence: boolean;\n}\n\n// =============================================================================\n// WebSocket Connection Handler\n// =============================================================================\n\n/**\n * Handle a WebSocket connection for a document.\n */\nconst handleWebSocketConnection = (\n socket: Socket.Socket,\n documentId: string,\n engine: MimicServerEngine,\n authService: MimicAuthService,\n _routeConfig: ResolvedRouteConfig\n): Effect.Effect<void, Socket.SocketError, Scope.Scope> =>\n Effect.gen(function* () {\n const connectionId = crypto.randomUUID();\n const connectionStartTime = Date.now();\n\n // Track connection metrics\n yield* Metric.increment(Metrics.connectionsTotal);\n yield* Metric.incrementBy(Metrics.connectionsActive, 1);\n\n // Track connection state (mutable for simplicity)\n const state: ConnectionState = {\n documentId,\n connectionId,\n authenticated: false,\n hasPresence: false,\n };\n\n // Get the socket writer\n const write = yield* socket.writer;\n\n // Helper to send a message to the client\n const sendMessage = (message: Protocol.ServerMessage) =>\n write(Protocol.encodeServerMessage(message));\n\n // Send presence snapshot after auth\n const sendPresenceSnapshot = Effect.gen(function* () {\n if (!engine.config.presence) return;\n\n const snapshot = yield* engine.getPresenceSnapshot(documentId);\n yield* sendMessage(\n Protocol.presenceSnapshotMessage(connectionId, snapshot.presences)\n );\n });\n\n // Send document snapshot after auth\n const sendDocumentSnapshot = Effect.gen(function* () {\n const snapshot = yield* engine.getSnapshot(documentId);\n yield* sendMessage(\n Protocol.snapshotMessage(snapshot.state, snapshot.version)\n );\n });\n\n // Handle authentication\n const handleAuth = (token: string) =>\n Effect.gen(function* () {\n const result = yield* Effect.either(\n authService.authenticate(token, documentId)\n );\n\n if (result._tag === \"Right\") {\n state.authenticated = true;\n state.authContext = result.right;\n\n yield* sendMessage(\n Protocol.authResultSuccess(\n result.right.userId,\n result.right.permission\n )\n );\n\n // Send document snapshot after successful auth\n yield* sendDocumentSnapshot;\n\n // Send presence snapshot after successful auth\n yield* sendPresenceSnapshot;\n } else {\n yield* Metric.increment(Metrics.connectionsErrors);\n yield* sendMessage(\n Protocol.authResultFailure(\n result.left.reason ?? \"Authentication failed\"\n )\n );\n }\n });\n\n // Handle presence set\n const handlePresenceSet = (data: unknown) =>\n Effect.gen(function* () {\n if (!state.authenticated) return;\n if (!state.authContext) return;\n if (!engine.config.presence) return;\n\n // Check write permission\n if (state.authContext.permission !== \"write\") {\n yield* Effect.logWarning(\"Presence set rejected - read-only user\", {\n connectionId,\n });\n return;\n }\n\n // Validate presence data against schema\n const validated = Presence.validateSafe(engine.config.presence, data);\n if (validated === undefined) {\n yield* Effect.logWarning(\"Invalid presence data received\", {\n connectionId,\n data,\n });\n return;\n }\n\n // Store in engine\n yield* engine.setPresence(documentId, connectionId, {\n data: validated,\n userId: state.authContext.userId,\n });\n\n state.hasPresence = true;\n });\n\n // Handle presence clear\n const handlePresenceClear = Effect.gen(function* () {\n if (!state.authenticated) return;\n if (!engine.config.presence) return;\n\n yield* engine.removePresence(documentId, connectionId);\n state.hasPresence = false;\n });\n\n // Handle a client message\n const handleMessage = (message: Protocol.ClientMessage) =>\n Effect.gen(function* () {\n // Touch document on any activity (prevents idle GC)\n yield* engine.touch(documentId);\n\n switch (message.type) {\n case \"auth\":\n yield* handleAuth(message.token);\n break;\n\n case \"ping\":\n yield* sendMessage(Protocol.pong());\n break;\n\n case \"submit\":\n if (!state.authenticated) {\n yield* sendMessage(\n Protocol.errorMessage(\n message.transaction.id,\n \"Not authenticated\"\n )\n );\n return;\n }\n\n // Check write permission\n if (state.authContext?.permission !== \"write\") {\n yield* sendMessage(\n Protocol.errorMessage(\n message.transaction.id,\n \"Write permission required\"\n )\n );\n return;\n }\n\n // Submit to the engine\n const submitResult = yield* engine.submit(\n documentId,\n message.transaction\n );\n\n // If rejected, send error (success is broadcast to all)\n if (!submitResult.success) {\n yield* sendMessage(\n Protocol.errorMessage(message.transaction.id, submitResult.reason)\n );\n }\n break;\n\n case \"request_snapshot\":\n if (!state.authenticated) {\n return;\n }\n const snapshot = yield* engine.getSnapshot(documentId);\n yield* sendMessage(\n Protocol.snapshotMessage(snapshot.state, snapshot.version)\n );\n break;\n\n case \"presence_set\":\n yield* handlePresenceSet(message.data);\n break;\n\n case \"presence_clear\":\n yield* handlePresenceClear;\n break;\n }\n });\n\n // Subscribe to document broadcasts\n const subscribeFiber = yield* Effect.fork(\n Effect.gen(function* () {\n // Wait until authenticated before subscribing\n while (!state.authenticated) {\n yield* Effect.sleep(Duration.millis(100));\n }\n\n // Subscribe to the document\n const broadcastStream = yield* engine.subscribe(documentId);\n\n // Forward broadcasts to the WebSocket\n yield* Stream.runForEach(broadcastStream, (broadcast) =>\n sendMessage(broadcast as Protocol.ServerMessage)\n );\n }).pipe(Effect.scoped)\n );\n\n // Subscribe to presence events (if presence is enabled)\n const presenceFiber = yield* Effect.fork(\n Effect.gen(function* () {\n if (!engine.config.presence) return;\n\n // Wait until authenticated before subscribing\n while (!state.authenticated) {\n yield* Effect.sleep(Duration.millis(100));\n }\n\n // Subscribe to presence events\n const presenceStream = yield* engine.subscribePresence(documentId);\n\n // Forward presence events to the WebSocket, filtering out our own events (no-echo)\n yield* Stream.runForEach(presenceStream, (event) =>\n Effect.gen(function* () {\n // Don't echo our own presence events\n if (event.id === connectionId) return;\n\n if (event.type === \"presence_update\") {\n yield* sendMessage(\n Protocol.presenceUpdateMessage(event.id, event.data, event.userId)\n );\n } else if (event.type === \"presence_remove\") {\n yield* sendMessage(Protocol.presenceRemoveMessage(event.id));\n }\n })\n );\n }).pipe(Effect.scoped)\n );\n\n // Ensure cleanup on disconnect\n yield* Effect.addFinalizer(() =>\n Effect.gen(function* () {\n // Calculate connection duration\n const duration = Date.now() - connectionStartTime;\n\n // Interrupt the subscribe fibers\n yield* Fiber.interrupt(subscribeFiber);\n yield* Fiber.interrupt(presenceFiber);\n\n // Remove presence if we had any\n if (state.hasPresence && engine.config.presence) {\n yield* engine.removePresence(documentId, connectionId);\n }\n\n // Update connection metrics\n yield* Metric.incrementBy(Metrics.connectionsActive, -1);\n yield* Metric.update(Metrics.connectionsDuration, duration);\n\n yield* Effect.logDebug(\"WebSocket connection closed\", {\n connectionId,\n documentId,\n durationMs: duration,\n });\n })\n );\n\n // Process incoming messages\n yield* socket.runRaw((data) =>\n Effect.gen(function* () {\n const message = yield* Protocol.parseClientMessage(data);\n yield* handleMessage(message);\n }).pipe(\n Effect.catchAll((error) =>\n Effect.logError(\"Message handling error\", error)\n )\n )\n );\n });\n\n// =============================================================================\n// Factory\n// =============================================================================\n\n/**\n * Create a route layer for MimicServerEngine.\n *\n * This creates a WebSocket route that connects to the engine.\n * Use Layer.mergeAll to compose with other routes.\n *\n * @example\n * ```typescript\n * // 1. Create the engine\n * const Engine = MimicServerEngine.make({\n * schema: DocSchema,\n * initial: { title: \"Untitled\" },\n * })\n *\n * // 2. Create the WebSocket route\n * const MimicRoute = MimicServer.layerHttpLayerRouter({\n * path: \"/mimic\",\n * })\n *\n * // 3. Wire together\n * const MimicLive = MimicRoute.pipe(\n * Layer.provide(Engine),\n * Layer.provide(ColdStorage.InMemory.make()),\n * Layer.provide(HotStorage.InMemory.make()),\n * Layer.provide(MimicAuthService.NoAuth.make()),\n * )\n *\n * // 4. Compose with other routes\n * const AllRoutes = Layer.mergeAll(MimicLive, DocsRoute, OtherRoutes)\n *\n * // 5. Serve\n * HttpLayerRouter.serve(AllRoutes).pipe(\n * Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })),\n * Layer.launch,\n * NodeRuntime.runMain\n * )\n * ```\n */\nexport const layerHttpLayerRouter = (\n options?: MimicServerRouteConfig\n) => {\n const routeConfig = resolveRouteConfig(options);\n\n // Build the route path pattern: {path}/doc/:documentId\n const routePath =\n `${routeConfig.path}/doc/:documentId` as HttpLayerRouter.PathInput;\n\n return Layer.scopedDiscard(\n Effect.gen(function* () {\n const router = yield* HttpLayerRouter.HttpRouter;\n // Capture engine and auth service at layer creation time\n const engine = yield* MimicServerEngineTag;\n const authService = yield* MimicAuthServiceTag;\n\n // Create the handler that receives the request\n // Engine and authService are captured in closure, not yielded per-request\n const handler = (request: HttpServerRequest.HttpServerRequest) =>\n Effect.gen(function* () {\n // Extract document ID from path\n const documentIdResult = yield* Effect.either(\n extractDocumentId(request.url)\n );\n if (documentIdResult._tag === \"Left\") {\n return HttpServerResponse.text(\n `Missing document ID in path: ${request.url}`,\n { status: 400 }\n );\n }\n const documentId = documentIdResult.right;\n\n // Upgrade to WebSocket\n const socket = yield* request.upgrade;\n\n // Handle the WebSocket connection\n yield* handleWebSocketConnection(\n socket,\n documentId,\n engine,\n authService,\n routeConfig\n ).pipe(\n Effect.scoped,\n Effect.catchAll((error) =>\n Effect.logError(\"WebSocket connection error\", error)\n )\n );\n\n // Return empty response - the WebSocket upgrade handles the connection\n return HttpServerResponse.empty();\n });\n\n yield* router.add(\"GET\", routePath, handler);\n })\n );\n};\n\n// =============================================================================\n// Re-export namespace\n// =============================================================================\n\nexport const MimicServer = {\n layerHttpLayerRouter,\n};\n\n// =============================================================================\n// Re-export types\n// =============================================================================\n\nexport type { MimicServerRouteConfig };\n"],"mappings":"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"}
1
+ {"version":3,"file":"MimicServer.mjs","names":["Metrics.connectionsTotal","Metrics.connectionsActive","state: ConnectionState","Protocol.encodeServerMessage","Protocol.presenceSnapshotMessage","Protocol.snapshotMessage","Protocol.authResultSuccess","Metrics.connectionsErrors","Protocol.authResultFailure","Protocol.pong","Protocol.errorMessage","Protocol.presenceUpdateMessage","Protocol.presenceRemoveMessage","Metrics.connectionsDuration","Protocol.parseClientMessage"],"sources":["../src/MimicServer.ts"],"sourcesContent":["/**\n * @voidhash/mimic-effect - MimicServer\n *\n * WebSocket route layer for MimicServerEngine.\n * Creates routes compatible with HttpLayerRouter.\n */\nimport {\n Duration,\n Effect,\n Fiber,\n Layer,\n Metric,\n Stream,\n} from \"effect\";\nimport {\n HttpLayerRouter,\n HttpServerRequest,\n HttpServerResponse,\n} from \"@effect/platform\";\nimport type * as Socket from \"@effect/platform/Socket\";\nimport { Presence } from \"@voidhash/mimic\";\nimport type { MimicServerRouteConfig, ResolvedRouteConfig } from \"./Types\";\nimport * as Protocol from \"./Protocol\";\nimport { MissingDocumentIdError } from \"./Errors\";\nimport { MimicServerEngineTag, type MimicServerEngine } from \"./MimicServerEngine\";\nimport { MimicAuthServiceTag, type MimicAuthService } from \"./MimicAuthService\";\nimport * as Metrics from \"./Metrics\";\nimport type { AuthContext } from \"./Types\";\n\n// =============================================================================\n// Default Configuration\n// =============================================================================\n\nconst DEFAULT_PATH = \"/mimic\";\nconst DEFAULT_HEARTBEAT_INTERVAL = Duration.seconds(30);\nconst DEFAULT_HEARTBEAT_TIMEOUT = Duration.seconds(10);\n\n/**\n * Resolve route configuration with defaults\n */\nconst resolveRouteConfig = (\n config?: MimicServerRouteConfig\n): ResolvedRouteConfig => ({\n path: config?.path ?? DEFAULT_PATH,\n heartbeatInterval: config?.heartbeatInterval\n ? Duration.decode(config.heartbeatInterval)\n : DEFAULT_HEARTBEAT_INTERVAL,\n heartbeatTimeout: config?.heartbeatTimeout\n ? Duration.decode(config.heartbeatTimeout)\n : DEFAULT_HEARTBEAT_TIMEOUT,\n});\n\n// =============================================================================\n// URL Path Parsing\n// =============================================================================\n\n/**\n * Extract document ID from URL path.\n * Expected format: /basePath/doc/{documentId}\n */\nconst extractDocumentId = (\n path: string\n): Effect.Effect<string, MissingDocumentIdError> => {\n // Remove leading slash and split\n const parts = path.replace(/^\\/+/, \"\").split(\"/\");\n\n // Find the last occurrence of 'doc' in the path\n const docIndex = parts.lastIndexOf(\"doc\");\n const part = parts[docIndex + 1];\n if (docIndex !== -1 && part) {\n return Effect.succeed(decodeURIComponent(part));\n }\n return Effect.fail(new MissingDocumentIdError({ path }));\n};\n\n// =============================================================================\n// Connection State\n// =============================================================================\n\ninterface ConnectionState {\n readonly documentId: string;\n readonly connectionId: string;\n authenticated: boolean;\n authContext?: AuthContext;\n hasPresence: boolean;\n}\n\n// =============================================================================\n// WebSocket Connection Handler\n// =============================================================================\n\n/**\n * Handle a WebSocket connection for a document.\n */\nconst handleWebSocketConnection = Effect.fn(\"websocket.connection.handle\")(\n function* (\n socket: Socket.Socket,\n documentId: string,\n engine: MimicServerEngine,\n authService: MimicAuthService,\n _routeConfig: ResolvedRouteConfig\n ) {\n const connectionId = crypto.randomUUID();\n const connectionStartTime = Date.now();\n\n // Track connection metrics\n yield* Metric.increment(Metrics.connectionsTotal);\n yield* Metric.incrementBy(Metrics.connectionsActive, 1);\n\n // Track connection state (mutable for simplicity)\n const state: ConnectionState = {\n documentId,\n connectionId,\n authenticated: false,\n hasPresence: false,\n };\n\n // Get the socket writer\n const write = yield* socket.writer;\n\n // Helper to send a message to the client\n const sendMessage = (message: Protocol.ServerMessage) =>\n write(Protocol.encodeServerMessage(message));\n\n // Send presence snapshot after auth\n const sendPresenceSnapshot = Effect.fn(\"presence.snapshot.send\")(\n function* () {\n if (!engine.config.presence) return;\n\n const snapshot = yield* engine.getPresenceSnapshot(documentId);\n yield* sendMessage(\n Protocol.presenceSnapshotMessage(connectionId, snapshot.presences)\n );\n }\n );\n\n // Send document snapshot after auth\n const sendDocumentSnapshot = Effect.fn(\"document.snapshot.send\")(\n function* () {\n const snapshot = yield* engine.getSnapshot(documentId);\n yield* sendMessage(\n Protocol.snapshotMessage(snapshot.state, snapshot.version)\n );\n }\n );\n\n // Handle authentication\n const handleAuth = Effect.fn(\"auth.handle\")(function* (token: string) {\n const result = yield* Effect.either(\n authService.authenticate(token, documentId)\n );\n\n if (result._tag === \"Right\") {\n state.authenticated = true;\n state.authContext = result.right;\n\n yield* sendMessage(\n Protocol.authResultSuccess(\n result.right.userId,\n result.right.permission\n )\n );\n\n // Send document snapshot after successful auth\n yield* sendDocumentSnapshot();\n\n // Send presence snapshot after successful auth\n yield* sendPresenceSnapshot();\n } else {\n yield* Metric.increment(Metrics.connectionsErrors);\n yield* sendMessage(\n Protocol.authResultFailure(\n result.left.reason ?? \"Authentication failed\"\n )\n );\n }\n });\n\n // Handle presence set\n const handlePresenceSet = Effect.fn(\"presence.set.handle\")(\n function* (data: unknown) {\n if (!state.authenticated) return;\n if (!state.authContext) return;\n if (!engine.config.presence) return;\n\n // Check write permission\n if (state.authContext.permission !== \"write\") {\n yield* Effect.logWarning(\"Presence set rejected - read-only user\", {\n connectionId,\n });\n return;\n }\n\n // Validate presence data against schema\n const validated = Presence.validateSafe(engine.config.presence, data);\n if (validated === undefined) {\n yield* Effect.logWarning(\"Invalid presence data received\", {\n connectionId,\n data,\n });\n return;\n }\n\n // Store in engine\n yield* engine.setPresence(documentId, connectionId, {\n data: validated,\n userId: state.authContext.userId,\n });\n\n state.hasPresence = true;\n }\n );\n\n // Handle presence clear\n const handlePresenceClear = Effect.fn(\"presence.clear.handle\")(\n function* () {\n if (!state.authenticated) return;\n if (!engine.config.presence) return;\n\n yield* engine.removePresence(documentId, connectionId);\n state.hasPresence = false;\n }\n );\n\n // Handle a client message\n const handleMessage = Effect.fn(\"message.handle\")(\n function* (message: Protocol.ClientMessage) {\n // Touch document on any activity (prevents idle GC)\n yield* engine.touch(documentId);\n\n switch (message.type) {\n case \"auth\":\n yield* handleAuth(message.token);\n break;\n\n case \"ping\":\n yield* sendMessage(Protocol.pong());\n break;\n\n case \"submit\":\n if (!state.authenticated) {\n yield* sendMessage(\n Protocol.errorMessage(\n message.transaction.id,\n \"Not authenticated\"\n )\n );\n return;\n }\n\n // Check write permission\n if (state.authContext?.permission !== \"write\") {\n yield* sendMessage(\n Protocol.errorMessage(\n message.transaction.id,\n \"Write permission required\"\n )\n );\n return;\n }\n\n // Submit to the engine\n const submitResult = yield* engine.submit(\n documentId,\n message.transaction\n );\n\n // If rejected, send error (success is broadcast to all)\n if (!submitResult.success) {\n yield* sendMessage(\n Protocol.errorMessage(message.transaction.id, submitResult.reason)\n );\n }\n break;\n\n case \"request_snapshot\":\n if (!state.authenticated) {\n return;\n }\n const snapshot = yield* engine.getSnapshot(documentId);\n yield* sendMessage(\n Protocol.snapshotMessage(snapshot.state, snapshot.version)\n );\n break;\n\n case \"presence_set\":\n yield* handlePresenceSet(message.data);\n break;\n\n case \"presence_clear\":\n yield* handlePresenceClear();\n break;\n }\n }\n );\n\n // Subscribe to document broadcasts\n const subscribeFiber = yield* Effect.fork(\n Effect.fn(\"subscriptions.document.start\")(function* () {\n // Wait until authenticated before subscribing\n while (!state.authenticated) {\n yield* Effect.sleep(Duration.millis(100));\n }\n\n // Subscribe to the document\n const broadcastStream = yield* engine.subscribe(documentId);\n\n // Forward broadcasts to the WebSocket\n yield* Stream.runForEach(broadcastStream, (broadcast) =>\n sendMessage(broadcast as Protocol.ServerMessage)\n );\n })().pipe(Effect.scoped)\n );\n\n // Subscribe to presence events (if presence is enabled)\n const presenceFiber = yield* Effect.fork(\n Effect.fn(\"subscriptions.presence.start\")(function* () {\n if (!engine.config.presence) return;\n\n // Wait until authenticated before subscribing\n while (!state.authenticated) {\n yield* Effect.sleep(Duration.millis(100));\n }\n\n // Subscribe to presence events\n const presenceStream = yield* engine.subscribePresence(documentId);\n\n // Forward presence events to the WebSocket, filtering out our own events (no-echo)\n yield* Stream.runForEach(presenceStream, (event) =>\n Effect.gen(function* () {\n // Don't echo our own presence events\n if (event.id === connectionId) return;\n\n if (event.type === \"presence_update\") {\n yield* sendMessage(\n Protocol.presenceUpdateMessage(event.id, event.data, event.userId)\n );\n } else if (event.type === \"presence_remove\") {\n yield* sendMessage(Protocol.presenceRemoveMessage(event.id));\n }\n })\n );\n })().pipe(Effect.scoped)\n );\n\n // Ensure cleanup on disconnect\n yield* Effect.addFinalizer(() =>\n Effect.fn(\"connection.cleanup\")(function* () {\n // Calculate connection duration\n const duration = Date.now() - connectionStartTime;\n\n // Interrupt the subscribe fibers\n yield* Fiber.interrupt(subscribeFiber);\n yield* Fiber.interrupt(presenceFiber);\n\n // Remove presence if we had any\n if (state.hasPresence && engine.config.presence) {\n yield* engine.removePresence(documentId, connectionId);\n }\n\n // Update connection metrics\n yield* Metric.incrementBy(Metrics.connectionsActive, -1);\n yield* Metric.update(Metrics.connectionsDuration, duration);\n\n yield* Effect.logDebug(\"WebSocket connection closed\", {\n connectionId,\n documentId,\n durationMs: duration,\n });\n })()\n );\n\n // Process incoming messages\n yield* socket.runRaw((data) =>\n Effect.fn(\"message.process\")(function* () {\n const message = yield* Protocol.parseClientMessage(data);\n yield* handleMessage(message);\n })().pipe(\n Effect.catchAll((error) =>\n Effect.logError(\"Message handling error\", error)\n )\n )\n );\n }\n);\n\n// =============================================================================\n// Factory\n// =============================================================================\n\n/**\n * Create a route layer for MimicServerEngine.\n *\n * This creates a WebSocket route that connects to the engine.\n * Use Layer.mergeAll to compose with other routes.\n *\n * @example\n * ```typescript\n * // 1. Create the engine\n * const Engine = MimicServerEngine.make({\n * schema: DocSchema,\n * initial: { title: \"Untitled\" },\n * })\n *\n * // 2. Create the WebSocket route\n * const MimicRoute = MimicServer.layerHttpLayerRouter({\n * path: \"/mimic\",\n * })\n *\n * // 3. Wire together\n * const MimicLive = MimicRoute.pipe(\n * Layer.provide(Engine),\n * Layer.provide(ColdStorage.InMemory.make()),\n * Layer.provide(HotStorage.InMemory.make()),\n * Layer.provide(MimicAuthService.NoAuth.make()),\n * )\n *\n * // 4. Compose with other routes\n * const AllRoutes = Layer.mergeAll(MimicLive, DocsRoute, OtherRoutes)\n *\n * // 5. Serve\n * HttpLayerRouter.serve(AllRoutes).pipe(\n * Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })),\n * Layer.launch,\n * NodeRuntime.runMain\n * )\n * ```\n */\nexport const layerHttpLayerRouter = (\n options?: MimicServerRouteConfig\n) => {\n const routeConfig = resolveRouteConfig(options);\n\n // Build the route path pattern: {path}/doc/:documentId\n const routePath =\n `${routeConfig.path}/doc/:documentId` as HttpLayerRouter.PathInput;\n\n return Layer.scopedDiscard(\n Effect.gen(function* () {\n const router = yield* HttpLayerRouter.HttpRouter;\n // Capture engine and auth service at layer creation time\n const engine = yield* MimicServerEngineTag;\n const authService = yield* MimicAuthServiceTag;\n\n // Create the handler that receives the request\n // Engine and authService are captured in closure, not yielded per-request\n const handler = Effect.fn(\"websocket.route.handler\")(\n function* (request: HttpServerRequest.HttpServerRequest) {\n // Extract document ID from path\n const documentIdResult = yield* Effect.either(\n extractDocumentId(request.url)\n );\n if (documentIdResult._tag === \"Left\") {\n return HttpServerResponse.text(\n `Missing document ID in path: ${request.url}`,\n { status: 400 }\n );\n }\n const documentId = documentIdResult.right;\n\n // Upgrade to WebSocket\n const socket = yield* request.upgrade;\n\n // Handle the WebSocket connection\n yield* handleWebSocketConnection(\n socket,\n documentId,\n engine,\n authService,\n routeConfig\n ).pipe(\n Effect.scoped,\n Effect.catchAll((error) =>\n Effect.logError(\"WebSocket connection error\", error)\n )\n );\n\n // Return empty response - the WebSocket upgrade handles the connection\n return HttpServerResponse.empty();\n }\n );\n\n yield* router.add(\"GET\", routePath, handler);\n })\n );\n};\n\n// =============================================================================\n// Re-export namespace\n// =============================================================================\n\nexport const MimicServer = {\n layerHttpLayerRouter,\n};\n\n// =============================================================================\n// Re-export types\n// =============================================================================\n\nexport type { MimicServerRouteConfig };\n"],"mappings":"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"}
@@ -1,4 +1,7 @@
1
- const require_DocumentManager = require('./DocumentManager.cjs');
1
+ const require_ColdStorage = require('./ColdStorage.cjs');
2
+ const require_HotStorage = require('./HotStorage.cjs');
3
+ const require_Metrics = require('./Metrics.cjs');
4
+ const require_DocumentInstance = require('./DocumentInstance.cjs');
2
5
  const require_PresenceManager = require('./PresenceManager.cjs');
3
6
  let effect = require("effect");
4
7
 
@@ -19,11 +22,12 @@ const DEFAULT_MAX_IDLE_TIME = effect.Duration.minutes(5);
19
22
  const DEFAULT_MAX_TRANSACTION_HISTORY = 1e3;
20
23
  const DEFAULT_SNAPSHOT_INTERVAL = effect.Duration.minutes(5);
21
24
  const DEFAULT_SNAPSHOT_THRESHOLD = 100;
25
+ const DEFAULT_SNAPSHOT_IDLE_TIMEOUT = effect.Duration.seconds(30);
22
26
  /**
23
27
  * Resolve configuration with defaults
24
28
  */
25
29
  const resolveConfig = (config) => {
26
- var _config$maxTransactio, _config$snapshot, _config$snapshot$tran, _config$snapshot2;
30
+ var _config$maxTransactio, _config$snapshot, _config$snapshot$tran, _config$snapshot2, _config$snapshot3;
27
31
  return {
28
32
  schema: config.schema,
29
33
  initial: config.initial,
@@ -32,7 +36,8 @@ const resolveConfig = (config) => {
32
36
  maxTransactionHistory: (_config$maxTransactio = config.maxTransactionHistory) !== null && _config$maxTransactio !== void 0 ? _config$maxTransactio : DEFAULT_MAX_TRANSACTION_HISTORY,
33
37
  snapshot: {
34
38
  interval: ((_config$snapshot = config.snapshot) === null || _config$snapshot === void 0 ? void 0 : _config$snapshot.interval) ? effect.Duration.decode(config.snapshot.interval) : DEFAULT_SNAPSHOT_INTERVAL,
35
- transactionThreshold: (_config$snapshot$tran = (_config$snapshot2 = config.snapshot) === null || _config$snapshot2 === void 0 ? void 0 : _config$snapshot2.transactionThreshold) !== null && _config$snapshot$tran !== void 0 ? _config$snapshot$tran : DEFAULT_SNAPSHOT_THRESHOLD
39
+ transactionThreshold: (_config$snapshot$tran = (_config$snapshot2 = config.snapshot) === null || _config$snapshot2 === void 0 ? void 0 : _config$snapshot2.transactionThreshold) !== null && _config$snapshot$tran !== void 0 ? _config$snapshot$tran : DEFAULT_SNAPSHOT_THRESHOLD,
40
+ idleTimeout: ((_config$snapshot3 = config.snapshot) === null || _config$snapshot3 === void 0 ? void 0 : _config$snapshot3.idleTimeout) ? effect.Duration.decode(config.snapshot.idleTimeout) : DEFAULT_SNAPSHOT_IDLE_TIMEOUT
36
41
  }
37
42
  };
38
43
  };
@@ -69,23 +74,99 @@ const resolveConfig = (config) => {
69
74
  */
70
75
  const make = (config) => {
71
76
  const resolvedConfig = resolveConfig(config);
72
- const configLayer = effect.Layer.succeed(require_DocumentManager.DocumentManagerConfigTag, resolvedConfig);
73
- const internalLayers = effect.Layer.mergeAll(require_DocumentManager.layer.pipe(effect.Layer.provide(configLayer)), require_PresenceManager.layer);
74
77
  return effect.Layer.scoped(MimicServerEngineTag, effect.Effect.gen(function* () {
75
- const documentManager = yield* require_DocumentManager.DocumentManagerTag;
78
+ const coldStorage = yield* require_ColdStorage.ColdStorageTag;
79
+ const hotStorage = yield* require_HotStorage.HotStorageTag;
76
80
  const presenceManager = yield* require_PresenceManager.PresenceManagerTag;
81
+ const store = yield* effect.Ref.make(effect.HashMap.empty());
82
+ /**
83
+ * Get or create a document instance
84
+ */
85
+ const getOrCreateDocument = effect.Effect.fn("engine.document.get-or-create")(function* (documentId) {
86
+ const current = yield* effect.Ref.get(store);
87
+ const existing = effect.HashMap.get(current, documentId);
88
+ if (existing._tag === "Some") {
89
+ yield* effect.Ref.set(existing.value.lastActivityTime, Date.now());
90
+ return existing.value.instance;
91
+ }
92
+ const instance = yield* require_DocumentInstance.DocumentInstance.make(documentId, {
93
+ schema: config.schema,
94
+ initial: config.initial,
95
+ maxTransactionHistory: resolvedConfig.maxTransactionHistory,
96
+ snapshot: resolvedConfig.snapshot
97
+ }, coldStorage, hotStorage);
98
+ const lastActivityTime = yield* effect.Ref.make(Date.now());
99
+ yield* effect.Ref.update(store, (map) => effect.HashMap.set(map, documentId, {
100
+ instance,
101
+ lastActivityTime
102
+ }));
103
+ return instance;
104
+ });
105
+ yield* effect.Effect.fn("engine.gc.start")(function* () {
106
+ yield* effect.Effect.fn("engine.gc.loop")(function* () {
107
+ const current = yield* effect.Ref.get(store);
108
+ const now = Date.now();
109
+ const maxIdleMs = effect.Duration.toMillis(resolvedConfig.maxIdleTime);
110
+ for (const [documentId, entry] of current) if (now - (yield* effect.Ref.get(entry.lastActivityTime)) >= maxIdleMs) {
111
+ yield* effect.Effect.catchAll(entry.instance.saveSnapshot(), (e) => effect.Effect.logError("Failed to save snapshot during eviction", {
112
+ documentId,
113
+ error: e
114
+ }));
115
+ yield* effect.Ref.update(store, (map) => effect.HashMap.remove(map, documentId));
116
+ yield* effect.Metric.increment(require_Metrics.documentsEvicted);
117
+ yield* effect.Metric.incrementBy(require_Metrics.documentsActive, -1);
118
+ yield* effect.Effect.logInfo("Document evicted due to idle timeout", { documentId });
119
+ }
120
+ })().pipe(effect.Effect.repeat(effect.Schedule.spaced("1 minute")), effect.Effect.fork);
121
+ })();
122
+ yield* effect.Effect.fn("engine.snapshot.fiber.start")(function* () {
123
+ const idleTimeoutMs = effect.Duration.toMillis(resolvedConfig.snapshot.idleTimeout);
124
+ if (idleTimeoutMs <= 0) return;
125
+ yield* effect.Effect.fn("engine.snapshot.loop")(function* () {
126
+ const current = yield* effect.Ref.get(store);
127
+ const now = Date.now();
128
+ for (const [documentId, entry] of current) {
129
+ if (now - (yield* effect.Ref.get(entry.lastActivityTime)) < idleTimeoutMs) continue;
130
+ if (!(yield* entry.instance.needsSnapshot())) continue;
131
+ yield* effect.Effect.catchAll(entry.instance.saveSnapshot(), (e) => effect.Effect.logWarning("Periodic snapshot save failed", {
132
+ documentId,
133
+ error: e
134
+ }));
135
+ yield* effect.Metric.increment(require_Metrics.storageIdleSnapshots);
136
+ }
137
+ })().pipe(effect.Effect.repeat(effect.Schedule.spaced("10 seconds")), effect.Effect.fork);
138
+ })();
139
+ yield* effect.Effect.addFinalizer(() => effect.Effect.fn("engine.shutdown")(function* () {
140
+ const current = yield* effect.Ref.get(store);
141
+ for (const [documentId, entry] of current) yield* effect.Effect.catchAll(entry.instance.saveSnapshot(), (e) => effect.Effect.logError("Failed to save snapshot during shutdown", {
142
+ documentId,
143
+ error: e
144
+ }));
145
+ yield* effect.Effect.logInfo("MimicServerEngine shutdown complete");
146
+ })());
77
147
  return {
78
- submit: (documentId, transaction) => documentManager.submit(documentId, transaction),
79
- getSnapshot: (documentId) => documentManager.getSnapshot(documentId),
80
- subscribe: (documentId) => documentManager.subscribe(documentId),
81
- touch: (documentId) => documentManager.touch(documentId),
148
+ submit: (documentId, transaction) => effect.Effect.gen(function* () {
149
+ return yield* (yield* getOrCreateDocument(documentId)).submit(transaction);
150
+ }),
151
+ getSnapshot: (documentId) => effect.Effect.gen(function* () {
152
+ return (yield* getOrCreateDocument(documentId)).getSnapshot();
153
+ }),
154
+ subscribe: (documentId) => effect.Effect.gen(function* () {
155
+ const instance = yield* getOrCreateDocument(documentId);
156
+ return effect.Stream.fromPubSub(instance.pubsub);
157
+ }),
158
+ touch: (documentId) => effect.Effect.gen(function* () {
159
+ const current = yield* effect.Ref.get(store);
160
+ const existing = effect.HashMap.get(current, documentId);
161
+ if (existing._tag === "Some") yield* effect.Ref.set(existing.value.lastActivityTime, Date.now());
162
+ }),
82
163
  getPresenceSnapshot: (documentId) => presenceManager.getSnapshot(documentId),
83
164
  setPresence: (documentId, connectionId, entry) => presenceManager.set(documentId, connectionId, entry),
84
165
  removePresence: (documentId, connectionId) => presenceManager.remove(documentId, connectionId),
85
166
  subscribePresence: (documentId) => presenceManager.subscribe(documentId),
86
167
  config: resolvedConfig
87
168
  };
88
- })).pipe(effect.Layer.provide(internalLayers));
169
+ })).pipe(effect.Layer.provide(require_PresenceManager.layer));
89
170
  };
90
171
  const MimicServerEngine = {
91
172
  Tag: MimicServerEngineTag,
@@ -1,14 +1,19 @@
1
1
  import { MimicServerEngineConfig, PresenceEntry, PresenceEvent, PresenceSnapshot, ResolvedConfig } from "./Types.cjs";
2
+ import { ColdStorageError, HotStorageError } from "./Errors.cjs";
2
3
  import { ServerMessage } from "./Protocol.cjs";
3
4
  import { ColdStorageTag } from "./ColdStorage.cjs";
4
5
  import { HotStorageTag } from "./HotStorage.cjs";
5
6
  import { MimicAuthServiceTag } from "./MimicAuthService.cjs";
6
- import { SubmitResult } from "./DocumentManager.cjs";
7
+ import { SubmitResult } from "./DocumentInstance.cjs";
7
8
  import { Context, Effect, Layer, Scope, Stream } from "effect";
8
9
  import { Primitive, Transaction } from "@voidhash/mimic";
9
10
 
10
11
  //#region src/MimicServerEngine.d.ts
11
12
 
13
+ /**
14
+ * Error type for MimicServerEngine operations
15
+ */
16
+ type MimicServerEngineError = ColdStorageError | HotStorageError;
12
17
  /**
13
18
  * MimicServerEngine service interface.
14
19
  *
@@ -19,21 +24,24 @@ interface MimicServerEngine {
19
24
  /**
20
25
  * Submit a transaction to a document.
21
26
  * Authorization is checked against the auth service.
27
+ * May fail with MimicServerEngineError if storage is unavailable.
22
28
  */
23
- readonly submit: (documentId: string, transaction: Transaction.Transaction) => Effect.Effect<SubmitResult, never>;
29
+ readonly submit: (documentId: string, transaction: Transaction.Transaction) => Effect.Effect<SubmitResult, MimicServerEngineError>;
24
30
  /**
25
31
  * Get document snapshot (current state and version).
32
+ * May fail with MimicServerEngineError if storage is unavailable.
26
33
  */
27
34
  readonly getSnapshot: (documentId: string) => Effect.Effect<{
28
35
  state: unknown;
29
36
  version: number;
30
- }, never>;
37
+ }, MimicServerEngineError>;
31
38
  /**
32
39
  * Subscribe to document broadcasts (transactions).
33
40
  * Returns a stream of server messages.
34
41
  * Requires a Scope for cleanup when the subscription ends.
42
+ * May fail with MimicServerEngineError if storage is unavailable.
35
43
  */
36
- readonly subscribe: (documentId: string) => Effect.Effect<Stream.Stream<ServerMessage, never, never>, never, Scope.Scope>;
44
+ readonly subscribe: (documentId: string) => Effect.Effect<Stream.Stream<ServerMessage, never, never>, MimicServerEngineError, Scope.Scope>;
37
45
  /**
38
46
  * Touch document to prevent idle garbage collection.
39
47
  */
@@ -1 +1 @@
1
- {"version":3,"file":"MimicServerEngine.d.cts","names":[],"sources":["../src/MimicServerEngine.ts"],"sourcesContent":[],"mappings":";;;;;;;;;;;;;;;;;AA6FW,UA5CM,iBAAA,CA4CN;EACJ;;;;EAgBiE,SAAM,MAAA,EAAA,CAAA,UAAA,EAAA,MAAA,EAAA,WAAA,EAtD7D,WAAA,CAAY,WAsDiD,EAAA,GArDvE,MAAA,CAAO,MAqDgE,CArDzD,YAqDyD,EAAA,KAAA,CAAA;EAAvE;;;EAM0B,SAAA,WAAA,EAAA,CAAA,UAAA,EAAA,MAAA,EAAA,GApD1B,MAAA,CAAO,MAoDmB,CAAA;IAChC,KAAA,EAAA,OAAA;;;;;AASD;AAwIA;;EAjEqC,SAAU,SAAA,EAAA,CAAA,UAAA,EAAA,MAAA,EAAA,GA5HxC,MAAA,CAAO,MA4HiC,CA5H1B,MAAA,CAAO,MA4HmB,CA5HZ,aA4HY,EAAA,KAAA,EAAA,KAAA,CAAA,EAAA,KAAA,EA5HkC,KAAA,CAAM,KA4HxC,CAAA;EACb;;;EAIhC,SAAA,KAAA,EAAA,CAAA,UAAA,EAAA,MAAA,EAAA,GA5HwC,MAAA,CAAO,MA4H/C,CAAA,IAAA,EAAA,KAAA,CAAA;EAAiB;;;EAHL,SAAA,mBAAA,EAAA,CAAA,UAAA,EAAA,MAAA,EAAA,GAlHP,MAAA,CAAO,MAkHA,CAlHO,gBAkHP,EAAA,KAAA,CAAA;;;;0EA1GH,kBACJ,MAAA,CAAO;;;;yEAQP,MAAA,CAAO;;;;;sDAQP,MAAA,CAAO,OAAO,MAAA,CAAO,OAAO,qCAAqC,KAAA,CAAM;;;;;mBAM3D,eAAe,SAAA,CAAU;;cAC3C;;;;cASY,oBAAA,SAA6B,yBAAA;cAwI7B;;yBAjEwB,SAAA,CAAU,sBACrC,wBAAwB,aAC/B,KAAA,CAAM,MACP,6BAEA,iBAAiB,gBAAgB"}
1
+ {"version":3,"file":"MimicServerEngine.d.cts","names":[],"sources":["../src/MimicServerEngine.ts"],"sourcesContent":[],"mappings":";;;;;;;;;;;;;;;AA0FqB,KAvCT,sBAAA,GAAyB,gBAuCT,GAvC4B,eAuC5B;;;;;;;AAoBjB,UA/CM,iBAAA,CA+CN;EACJ;;;;;EAgBA,SAAO,MAAA,EAAA,CAAA,UAAA,EAAA,MAAA,EAAA,WAAA,EAxDG,WAAA,CAAY,WAwDf,EAAA,GAvDP,MAAA,CAAO,MAuDA,CAvDO,YAuDP,EAvDqB,sBAuDrB,CAAA;EAMoB;;;AACjC;gDAtDM,MAAA,CAAO;;;KAA4C;EA+D7C;AA+Tb;;;;;EArOE,SAAA,SAAA,EAAA,CAAA,UAAA,EAAA,MAAA,EAAA,GA/IK,MAAA,CAAO,MA+IZ,CA/ImB,MAAA,CAAO,MA+I1B,CA/IiC,aA+IjC,EAAA,KAAA,EAAA,KAAA,CAAA,EA/IwE,sBA+IxE,EA/IgG,KAAA,CAAM,KA+ItG,CAAA;EAEA;;;EAHC,SAAM,KAAA,EAAA,CAAA,UAAA,EAAA,MAAA,EAAA,GAzIiC,MAAA,CAAO,MAyIxC,CAAA,IAAA,EAAA,KAAA,CAAA;EAAK;;;wDAlIP,MAAA,CAAO,OAAO;;;;0EAQV,kBACJ,MAAA,CAAO;;;;yEAQP,MAAA,CAAO;;;;;sDAQP,MAAA,CAAO,OAAO,MAAA,CAAO,OAAO,qCAAqC,KAAA,CAAM;;;;;mBAM3D,eAAe,SAAA,CAAU;;cAC3C;;;;cASY,oBAAA,SAA6B,yBAAA;cA+T7B;;yBAxOwB,SAAA,CAAU,sBACrC,wBAAwB,aAC/B,KAAA,CAAM,MACP,6BAEA,iBAAiB,gBAAgB"}
@@ -1,14 +1,19 @@
1
1
  import { MimicServerEngineConfig, PresenceEntry, PresenceEvent, PresenceSnapshot, ResolvedConfig } from "./Types.mjs";
2
+ import { ColdStorageError, HotStorageError } from "./Errors.mjs";
2
3
  import { ServerMessage } from "./Protocol.mjs";
3
4
  import { ColdStorageTag } from "./ColdStorage.mjs";
4
5
  import { HotStorageTag } from "./HotStorage.mjs";
5
6
  import { MimicAuthServiceTag } from "./MimicAuthService.mjs";
6
- import { SubmitResult } from "./DocumentManager.mjs";
7
+ import { SubmitResult } from "./DocumentInstance.mjs";
7
8
  import { Context, Effect, Layer, Scope, Stream } from "effect";
8
9
  import { Primitive, Transaction } from "@voidhash/mimic";
9
10
 
10
11
  //#region src/MimicServerEngine.d.ts
11
12
 
13
+ /**
14
+ * Error type for MimicServerEngine operations
15
+ */
16
+ type MimicServerEngineError = ColdStorageError | HotStorageError;
12
17
  /**
13
18
  * MimicServerEngine service interface.
14
19
  *
@@ -19,21 +24,24 @@ interface MimicServerEngine {
19
24
  /**
20
25
  * Submit a transaction to a document.
21
26
  * Authorization is checked against the auth service.
27
+ * May fail with MimicServerEngineError if storage is unavailable.
22
28
  */
23
- readonly submit: (documentId: string, transaction: Transaction.Transaction) => Effect.Effect<SubmitResult, never>;
29
+ readonly submit: (documentId: string, transaction: Transaction.Transaction) => Effect.Effect<SubmitResult, MimicServerEngineError>;
24
30
  /**
25
31
  * Get document snapshot (current state and version).
32
+ * May fail with MimicServerEngineError if storage is unavailable.
26
33
  */
27
34
  readonly getSnapshot: (documentId: string) => Effect.Effect<{
28
35
  state: unknown;
29
36
  version: number;
30
- }, never>;
37
+ }, MimicServerEngineError>;
31
38
  /**
32
39
  * Subscribe to document broadcasts (transactions).
33
40
  * Returns a stream of server messages.
34
41
  * Requires a Scope for cleanup when the subscription ends.
42
+ * May fail with MimicServerEngineError if storage is unavailable.
35
43
  */
36
- readonly subscribe: (documentId: string) => Effect.Effect<Stream.Stream<ServerMessage, never, never>, never, Scope.Scope>;
44
+ readonly subscribe: (documentId: string) => Effect.Effect<Stream.Stream<ServerMessage, never, never>, MimicServerEngineError, Scope.Scope>;
37
45
  /**
38
46
  * Touch document to prevent idle garbage collection.
39
47
  */
@@ -1 +1 @@
1
- {"version":3,"file":"MimicServerEngine.d.mts","names":[],"sources":["../src/MimicServerEngine.ts"],"sourcesContent":[],"mappings":";;;;;;;;;;;;;;;;;AA6FW,UA5CM,iBAAA,CA4CN;EACJ;;;;EAgBiE,SAAM,MAAA,EAAA,CAAA,UAAA,EAAA,MAAA,EAAA,WAAA,EAtD7D,WAAA,CAAY,WAsDiD,EAAA,GArDvE,MAAA,CAAO,MAqDgE,CArDzD,YAqDyD,EAAA,KAAA,CAAA;EAAvE;;;EAM0B,SAAA,WAAA,EAAA,CAAA,UAAA,EAAA,MAAA,EAAA,GApD1B,MAAA,CAAO,MAoDmB,CAAA;IAChC,KAAA,EAAA,OAAA;;;;;AASD;AAwIA;;EAjEqC,SAAU,SAAA,EAAA,CAAA,UAAA,EAAA,MAAA,EAAA,GA5HxC,MAAA,CAAO,MA4HiC,CA5H1B,MAAA,CAAO,MA4HmB,CA5HZ,aA4HY,EAAA,KAAA,EAAA,KAAA,CAAA,EAAA,KAAA,EA5HkC,KAAA,CAAM,KA4HxC,CAAA;EACb;;;EAIhC,SAAA,KAAA,EAAA,CAAA,UAAA,EAAA,MAAA,EAAA,GA5HwC,MAAA,CAAO,MA4H/C,CAAA,IAAA,EAAA,KAAA,CAAA;EAAiB;;;EAHL,SAAA,mBAAA,EAAA,CAAA,UAAA,EAAA,MAAA,EAAA,GAlHP,MAAA,CAAO,MAkHA,CAlHO,gBAkHP,EAAA,KAAA,CAAA;;;;0EA1GH,kBACJ,MAAA,CAAO;;;;yEAQP,MAAA,CAAO;;;;;sDAQP,MAAA,CAAO,OAAO,MAAA,CAAO,OAAO,qCAAqC,KAAA,CAAM;;;;;mBAM3D,eAAe,SAAA,CAAU;;cAC3C;;;;cASY,oBAAA,SAA6B,yBAAA;cAwI7B;;yBAjEwB,SAAA,CAAU,sBACrC,wBAAwB,aAC/B,KAAA,CAAM,MACP,6BAEA,iBAAiB,gBAAgB"}
1
+ {"version":3,"file":"MimicServerEngine.d.mts","names":[],"sources":["../src/MimicServerEngine.ts"],"sourcesContent":[],"mappings":";;;;;;;;;;;;;;;AA0FqB,KAvCT,sBAAA,GAAyB,gBAuCT,GAvC4B,eAuC5B;;;;;;;AAoBjB,UA/CM,iBAAA,CA+CN;EACJ;;;;;EAgBA,SAAO,MAAA,EAAA,CAAA,UAAA,EAAA,MAAA,EAAA,WAAA,EAxDG,WAAA,CAAY,WAwDf,EAAA,GAvDP,MAAA,CAAO,MAuDA,CAvDO,YAuDP,EAvDqB,sBAuDrB,CAAA;EAMoB;;;AACjC;gDAtDM,MAAA,CAAO;;;KAA4C;EA+D7C;AA+Tb;;;;;EArOE,SAAA,SAAA,EAAA,CAAA,UAAA,EAAA,MAAA,EAAA,GA/IK,MAAA,CAAO,MA+IZ,CA/ImB,MAAA,CAAO,MA+I1B,CA/IiC,aA+IjC,EAAA,KAAA,EAAA,KAAA,CAAA,EA/IwE,sBA+IxE,EA/IgG,KAAA,CAAM,KA+ItG,CAAA;EAEA;;;EAHC,SAAM,KAAA,EAAA,CAAA,UAAA,EAAA,MAAA,EAAA,GAzIiC,MAAA,CAAO,MAyIxC,CAAA,IAAA,EAAA,KAAA,CAAA;EAAK;;;wDAlIP,MAAA,CAAO,OAAO;;;;0EAQV,kBACJ,MAAA,CAAO;;;;yEAQP,MAAA,CAAO;;;;;sDAQP,MAAA,CAAO,OAAO,MAAA,CAAO,OAAO,qCAAqC,KAAA,CAAM;;;;;mBAM3D,eAAe,SAAA,CAAU;;cAC3C;;;;cASY,oBAAA,SAA6B,yBAAA;cA+T7B;;yBAxOwB,SAAA,CAAU,sBACrC,wBAAwB,aAC/B,KAAA,CAAM,MACP,6BAEA,iBAAiB,gBAAgB"}
@@ -1,6 +1,9 @@
1
- import { DocumentManagerConfigTag, DocumentManagerTag, layer } from "./DocumentManager.mjs";
2
- import { PresenceManagerTag, layer as layer$1 } from "./PresenceManager.mjs";
3
- import { Context, Duration, Effect, Layer } from "effect";
1
+ import { ColdStorageTag } from "./ColdStorage.mjs";
2
+ import { HotStorageTag } from "./HotStorage.mjs";
3
+ import { documentsActive, documentsEvicted, storageIdleSnapshots } from "./Metrics.mjs";
4
+ import { DocumentInstance } from "./DocumentInstance.mjs";
5
+ import { PresenceManagerTag, layer } from "./PresenceManager.mjs";
6
+ import { Context, Duration, Effect, HashMap, Layer, Metric, Ref, Schedule, Stream } from "effect";
4
7
 
5
8
  //#region src/MimicServerEngine.ts
6
9
  /**
@@ -19,11 +22,12 @@ const DEFAULT_MAX_IDLE_TIME = Duration.minutes(5);
19
22
  const DEFAULT_MAX_TRANSACTION_HISTORY = 1e3;
20
23
  const DEFAULT_SNAPSHOT_INTERVAL = Duration.minutes(5);
21
24
  const DEFAULT_SNAPSHOT_THRESHOLD = 100;
25
+ const DEFAULT_SNAPSHOT_IDLE_TIMEOUT = Duration.seconds(30);
22
26
  /**
23
27
  * Resolve configuration with defaults
24
28
  */
25
29
  const resolveConfig = (config) => {
26
- var _config$maxTransactio, _config$snapshot, _config$snapshot$tran, _config$snapshot2;
30
+ var _config$maxTransactio, _config$snapshot, _config$snapshot$tran, _config$snapshot2, _config$snapshot3;
27
31
  return {
28
32
  schema: config.schema,
29
33
  initial: config.initial,
@@ -32,7 +36,8 @@ const resolveConfig = (config) => {
32
36
  maxTransactionHistory: (_config$maxTransactio = config.maxTransactionHistory) !== null && _config$maxTransactio !== void 0 ? _config$maxTransactio : DEFAULT_MAX_TRANSACTION_HISTORY,
33
37
  snapshot: {
34
38
  interval: ((_config$snapshot = config.snapshot) === null || _config$snapshot === void 0 ? void 0 : _config$snapshot.interval) ? Duration.decode(config.snapshot.interval) : DEFAULT_SNAPSHOT_INTERVAL,
35
- transactionThreshold: (_config$snapshot$tran = (_config$snapshot2 = config.snapshot) === null || _config$snapshot2 === void 0 ? void 0 : _config$snapshot2.transactionThreshold) !== null && _config$snapshot$tran !== void 0 ? _config$snapshot$tran : DEFAULT_SNAPSHOT_THRESHOLD
39
+ transactionThreshold: (_config$snapshot$tran = (_config$snapshot2 = config.snapshot) === null || _config$snapshot2 === void 0 ? void 0 : _config$snapshot2.transactionThreshold) !== null && _config$snapshot$tran !== void 0 ? _config$snapshot$tran : DEFAULT_SNAPSHOT_THRESHOLD,
40
+ idleTimeout: ((_config$snapshot3 = config.snapshot) === null || _config$snapshot3 === void 0 ? void 0 : _config$snapshot3.idleTimeout) ? Duration.decode(config.snapshot.idleTimeout) : DEFAULT_SNAPSHOT_IDLE_TIMEOUT
36
41
  }
37
42
  };
38
43
  };
@@ -69,23 +74,99 @@ const resolveConfig = (config) => {
69
74
  */
70
75
  const make = (config) => {
71
76
  const resolvedConfig = resolveConfig(config);
72
- const configLayer = Layer.succeed(DocumentManagerConfigTag, resolvedConfig);
73
- const internalLayers = Layer.mergeAll(layer.pipe(Layer.provide(configLayer)), layer$1);
74
77
  return Layer.scoped(MimicServerEngineTag, Effect.gen(function* () {
75
- const documentManager = yield* DocumentManagerTag;
78
+ const coldStorage = yield* ColdStorageTag;
79
+ const hotStorage = yield* HotStorageTag;
76
80
  const presenceManager = yield* PresenceManagerTag;
81
+ const store = yield* Ref.make(HashMap.empty());
82
+ /**
83
+ * Get or create a document instance
84
+ */
85
+ const getOrCreateDocument = Effect.fn("engine.document.get-or-create")(function* (documentId) {
86
+ const current = yield* Ref.get(store);
87
+ const existing = HashMap.get(current, documentId);
88
+ if (existing._tag === "Some") {
89
+ yield* Ref.set(existing.value.lastActivityTime, Date.now());
90
+ return existing.value.instance;
91
+ }
92
+ const instance = yield* DocumentInstance.make(documentId, {
93
+ schema: config.schema,
94
+ initial: config.initial,
95
+ maxTransactionHistory: resolvedConfig.maxTransactionHistory,
96
+ snapshot: resolvedConfig.snapshot
97
+ }, coldStorage, hotStorage);
98
+ const lastActivityTime = yield* Ref.make(Date.now());
99
+ yield* Ref.update(store, (map) => HashMap.set(map, documentId, {
100
+ instance,
101
+ lastActivityTime
102
+ }));
103
+ return instance;
104
+ });
105
+ yield* Effect.fn("engine.gc.start")(function* () {
106
+ yield* Effect.fn("engine.gc.loop")(function* () {
107
+ const current = yield* Ref.get(store);
108
+ const now = Date.now();
109
+ const maxIdleMs = Duration.toMillis(resolvedConfig.maxIdleTime);
110
+ for (const [documentId, entry] of current) if (now - (yield* Ref.get(entry.lastActivityTime)) >= maxIdleMs) {
111
+ yield* Effect.catchAll(entry.instance.saveSnapshot(), (e) => Effect.logError("Failed to save snapshot during eviction", {
112
+ documentId,
113
+ error: e
114
+ }));
115
+ yield* Ref.update(store, (map) => HashMap.remove(map, documentId));
116
+ yield* Metric.increment(documentsEvicted);
117
+ yield* Metric.incrementBy(documentsActive, -1);
118
+ yield* Effect.logInfo("Document evicted due to idle timeout", { documentId });
119
+ }
120
+ })().pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.fork);
121
+ })();
122
+ yield* Effect.fn("engine.snapshot.fiber.start")(function* () {
123
+ const idleTimeoutMs = Duration.toMillis(resolvedConfig.snapshot.idleTimeout);
124
+ if (idleTimeoutMs <= 0) return;
125
+ yield* Effect.fn("engine.snapshot.loop")(function* () {
126
+ const current = yield* Ref.get(store);
127
+ const now = Date.now();
128
+ for (const [documentId, entry] of current) {
129
+ if (now - (yield* Ref.get(entry.lastActivityTime)) < idleTimeoutMs) continue;
130
+ if (!(yield* entry.instance.needsSnapshot())) continue;
131
+ yield* Effect.catchAll(entry.instance.saveSnapshot(), (e) => Effect.logWarning("Periodic snapshot save failed", {
132
+ documentId,
133
+ error: e
134
+ }));
135
+ yield* Metric.increment(storageIdleSnapshots);
136
+ }
137
+ })().pipe(Effect.repeat(Schedule.spaced("10 seconds")), Effect.fork);
138
+ })();
139
+ yield* Effect.addFinalizer(() => Effect.fn("engine.shutdown")(function* () {
140
+ const current = yield* Ref.get(store);
141
+ for (const [documentId, entry] of current) yield* Effect.catchAll(entry.instance.saveSnapshot(), (e) => Effect.logError("Failed to save snapshot during shutdown", {
142
+ documentId,
143
+ error: e
144
+ }));
145
+ yield* Effect.logInfo("MimicServerEngine shutdown complete");
146
+ })());
77
147
  return {
78
- submit: (documentId, transaction) => documentManager.submit(documentId, transaction),
79
- getSnapshot: (documentId) => documentManager.getSnapshot(documentId),
80
- subscribe: (documentId) => documentManager.subscribe(documentId),
81
- touch: (documentId) => documentManager.touch(documentId),
148
+ submit: (documentId, transaction) => Effect.gen(function* () {
149
+ return yield* (yield* getOrCreateDocument(documentId)).submit(transaction);
150
+ }),
151
+ getSnapshot: (documentId) => Effect.gen(function* () {
152
+ return (yield* getOrCreateDocument(documentId)).getSnapshot();
153
+ }),
154
+ subscribe: (documentId) => Effect.gen(function* () {
155
+ const instance = yield* getOrCreateDocument(documentId);
156
+ return Stream.fromPubSub(instance.pubsub);
157
+ }),
158
+ touch: (documentId) => Effect.gen(function* () {
159
+ const current = yield* Ref.get(store);
160
+ const existing = HashMap.get(current, documentId);
161
+ if (existing._tag === "Some") yield* Ref.set(existing.value.lastActivityTime, Date.now());
162
+ }),
82
163
  getPresenceSnapshot: (documentId) => presenceManager.getSnapshot(documentId),
83
164
  setPresence: (documentId, connectionId, entry) => presenceManager.set(documentId, connectionId, entry),
84
165
  removePresence: (documentId, connectionId) => presenceManager.remove(documentId, connectionId),
85
166
  subscribePresence: (documentId) => presenceManager.subscribe(documentId),
86
167
  config: resolvedConfig
87
168
  };
88
- })).pipe(Layer.provide(internalLayers));
169
+ })).pipe(Layer.provide(layer));
89
170
  };
90
171
  const MimicServerEngine = {
91
172
  Tag: MimicServerEngineTag,