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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/.turbo/turbo-build.log +93 -89
  2. package/README.md +385 -0
  3. package/dist/ColdStorage.cjs +60 -0
  4. package/dist/ColdStorage.d.cts +53 -0
  5. package/dist/ColdStorage.d.cts.map +1 -0
  6. package/dist/ColdStorage.d.mts +53 -0
  7. package/dist/ColdStorage.d.mts.map +1 -0
  8. package/dist/ColdStorage.mjs +60 -0
  9. package/dist/ColdStorage.mjs.map +1 -0
  10. package/dist/DocumentManager.cjs +193 -82
  11. package/dist/DocumentManager.d.cts +33 -19
  12. package/dist/DocumentManager.d.cts.map +1 -1
  13. package/dist/DocumentManager.d.mts +33 -19
  14. package/dist/DocumentManager.d.mts.map +1 -1
  15. package/dist/DocumentManager.mjs +189 -67
  16. package/dist/DocumentManager.mjs.map +1 -1
  17. package/dist/Errors.cjs +45 -0
  18. package/dist/Errors.d.cts +81 -0
  19. package/dist/Errors.d.cts.map +1 -0
  20. package/dist/Errors.d.mts +81 -0
  21. package/dist/Errors.d.mts.map +1 -0
  22. package/dist/Errors.mjs +40 -0
  23. package/dist/Errors.mjs.map +1 -0
  24. package/dist/HotStorage.cjs +77 -0
  25. package/dist/HotStorage.d.cts +54 -0
  26. package/dist/HotStorage.d.cts.map +1 -0
  27. package/dist/HotStorage.d.mts +54 -0
  28. package/dist/HotStorage.d.mts.map +1 -0
  29. package/dist/HotStorage.mjs +77 -0
  30. package/dist/HotStorage.mjs.map +1 -0
  31. package/dist/Metrics.cjs +121 -0
  32. package/dist/Metrics.d.cts +27 -0
  33. package/dist/Metrics.d.cts.map +1 -0
  34. package/dist/Metrics.d.mts +27 -0
  35. package/dist/Metrics.d.mts.map +1 -0
  36. package/dist/Metrics.mjs +106 -0
  37. package/dist/Metrics.mjs.map +1 -0
  38. package/dist/MimicAuthService.cjs +61 -45
  39. package/dist/MimicAuthService.d.cts +61 -48
  40. package/dist/MimicAuthService.d.cts.map +1 -1
  41. package/dist/MimicAuthService.d.mts +61 -48
  42. package/dist/MimicAuthService.d.mts.map +1 -1
  43. package/dist/MimicAuthService.mjs +60 -36
  44. package/dist/MimicAuthService.mjs.map +1 -1
  45. package/dist/MimicClusterServerEngine.cjs +443 -0
  46. package/dist/MimicClusterServerEngine.d.cts +17 -0
  47. package/dist/MimicClusterServerEngine.d.cts.map +1 -0
  48. package/dist/MimicClusterServerEngine.d.mts +17 -0
  49. package/dist/MimicClusterServerEngine.d.mts.map +1 -0
  50. package/dist/MimicClusterServerEngine.mjs +445 -0
  51. package/dist/MimicClusterServerEngine.mjs.map +1 -0
  52. package/dist/MimicServer.cjs +205 -96
  53. package/dist/MimicServer.d.cts +9 -110
  54. package/dist/MimicServer.d.cts.map +1 -1
  55. package/dist/MimicServer.d.mts +9 -110
  56. package/dist/MimicServer.d.mts.map +1 -1
  57. package/dist/MimicServer.mjs +206 -90
  58. package/dist/MimicServer.mjs.map +1 -1
  59. package/dist/MimicServerEngine.cjs +97 -0
  60. package/dist/MimicServerEngine.d.cts +75 -0
  61. package/dist/MimicServerEngine.d.cts.map +1 -0
  62. package/dist/MimicServerEngine.d.mts +75 -0
  63. package/dist/MimicServerEngine.d.mts.map +1 -0
  64. package/dist/MimicServerEngine.mjs +97 -0
  65. package/dist/MimicServerEngine.mjs.map +1 -0
  66. package/dist/PresenceManager.cjs +75 -91
  67. package/dist/PresenceManager.d.cts +17 -66
  68. package/dist/PresenceManager.d.cts.map +1 -1
  69. package/dist/PresenceManager.d.mts +17 -66
  70. package/dist/PresenceManager.d.mts.map +1 -1
  71. package/dist/PresenceManager.mjs +74 -78
  72. package/dist/PresenceManager.mjs.map +1 -1
  73. package/dist/Protocol.cjs +146 -0
  74. package/dist/Protocol.d.cts +203 -0
  75. package/dist/Protocol.d.cts.map +1 -0
  76. package/dist/Protocol.d.mts +203 -0
  77. package/dist/Protocol.d.mts.map +1 -0
  78. package/dist/Protocol.mjs +132 -0
  79. package/dist/Protocol.mjs.map +1 -0
  80. package/dist/Types.d.cts +172 -0
  81. package/dist/Types.d.cts.map +1 -0
  82. package/dist/Types.d.mts +172 -0
  83. package/dist/Types.d.mts.map +1 -0
  84. package/dist/_virtual/rolldown_runtime.cjs +1 -25
  85. package/dist/_virtual/rolldown_runtime.mjs +4 -1
  86. package/dist/index.cjs +37 -75
  87. package/dist/index.d.cts +13 -12
  88. package/dist/index.d.mts +13 -12
  89. package/dist/index.mjs +12 -12
  90. package/package.json +13 -3
  91. package/src/ColdStorage.ts +136 -0
  92. package/src/DocumentManager.ts +445 -193
  93. package/src/Errors.ts +100 -0
  94. package/src/HotStorage.ts +165 -0
  95. package/src/Metrics.ts +163 -0
  96. package/src/MimicAuthService.ts +126 -64
  97. package/src/MimicClusterServerEngine.ts +824 -0
  98. package/src/MimicServer.ts +448 -195
  99. package/src/MimicServerEngine.ts +272 -0
  100. package/src/PresenceManager.ts +169 -240
  101. package/src/Protocol.ts +350 -0
  102. package/src/Types.ts +231 -0
  103. package/src/index.ts +57 -23
  104. package/tests/ColdStorage.test.ts +136 -0
  105. package/tests/DocumentManager.test.ts +158 -287
  106. package/tests/HotStorage.test.ts +143 -0
  107. package/tests/MimicAuthService.test.ts +102 -134
  108. package/tests/MimicClusterServerEngine.test.ts +587 -0
  109. package/tests/MimicServer.test.ts +90 -226
  110. package/tests/MimicServerEngine.test.ts +521 -0
  111. package/tests/PresenceManager.test.ts +22 -63
  112. package/tests/Protocol.test.ts +190 -0
  113. package/tsconfig.json +1 -1
  114. package/dist/DocumentProtocol.cjs +0 -94
  115. package/dist/DocumentProtocol.d.cts +0 -113
  116. package/dist/DocumentProtocol.d.cts.map +0 -1
  117. package/dist/DocumentProtocol.d.mts +0 -113
  118. package/dist/DocumentProtocol.d.mts.map +0 -1
  119. package/dist/DocumentProtocol.mjs +0 -89
  120. package/dist/DocumentProtocol.mjs.map +0 -1
  121. package/dist/MimicConfig.cjs +0 -60
  122. package/dist/MimicConfig.d.cts +0 -141
  123. package/dist/MimicConfig.d.cts.map +0 -1
  124. package/dist/MimicConfig.d.mts +0 -141
  125. package/dist/MimicConfig.d.mts.map +0 -1
  126. package/dist/MimicConfig.mjs +0 -50
  127. package/dist/MimicConfig.mjs.map +0 -1
  128. package/dist/MimicDataStorage.cjs +0 -83
  129. package/dist/MimicDataStorage.d.cts +0 -113
  130. package/dist/MimicDataStorage.d.cts.map +0 -1
  131. package/dist/MimicDataStorage.d.mts +0 -113
  132. package/dist/MimicDataStorage.d.mts.map +0 -1
  133. package/dist/MimicDataStorage.mjs +0 -74
  134. package/dist/MimicDataStorage.mjs.map +0 -1
  135. package/dist/WebSocketHandler.cjs +0 -365
  136. package/dist/WebSocketHandler.d.cts +0 -34
  137. package/dist/WebSocketHandler.d.cts.map +0 -1
  138. package/dist/WebSocketHandler.d.mts +0 -34
  139. package/dist/WebSocketHandler.d.mts.map +0 -1
  140. package/dist/WebSocketHandler.mjs +0 -355
  141. package/dist/WebSocketHandler.mjs.map +0 -1
  142. package/dist/auth/NoAuth.cjs +0 -43
  143. package/dist/auth/NoAuth.d.cts +0 -22
  144. package/dist/auth/NoAuth.d.cts.map +0 -1
  145. package/dist/auth/NoAuth.d.mts +0 -22
  146. package/dist/auth/NoAuth.d.mts.map +0 -1
  147. package/dist/auth/NoAuth.mjs +0 -36
  148. package/dist/auth/NoAuth.mjs.map +0 -1
  149. package/dist/errors.cjs +0 -74
  150. package/dist/errors.d.cts +0 -89
  151. package/dist/errors.d.cts.map +0 -1
  152. package/dist/errors.d.mts +0 -89
  153. package/dist/errors.d.mts.map +0 -1
  154. package/dist/errors.mjs +0 -67
  155. package/dist/errors.mjs.map +0 -1
  156. package/dist/storage/InMemoryDataStorage.cjs +0 -57
  157. package/dist/storage/InMemoryDataStorage.d.cts +0 -19
  158. package/dist/storage/InMemoryDataStorage.d.cts.map +0 -1
  159. package/dist/storage/InMemoryDataStorage.d.mts +0 -19
  160. package/dist/storage/InMemoryDataStorage.d.mts.map +0 -1
  161. package/dist/storage/InMemoryDataStorage.mjs +0 -48
  162. package/dist/storage/InMemoryDataStorage.mjs.map +0 -1
  163. package/src/DocumentProtocol.ts +0 -112
  164. package/src/MimicConfig.ts +0 -211
  165. package/src/MimicDataStorage.ts +0 -157
  166. package/src/WebSocketHandler.ts +0 -735
  167. package/src/auth/NoAuth.ts +0 -46
  168. package/src/errors.ts +0 -113
  169. package/src/storage/InMemoryDataStorage.ts +0 -66
  170. package/tests/DocumentProtocol.test.ts +0 -113
  171. package/tests/InMemoryDataStorage.test.ts +0 -190
  172. package/tests/MimicConfig.test.ts +0 -290
  173. package/tests/MimicDataStorage.test.ts +0 -190
  174. package/tests/NoAuth.test.ts +0 -94
  175. package/tests/WebSocketHandler.test.ts +0 -321
  176. package/tests/errors.test.ts +0 -77
@@ -0,0 +1,824 @@
1
+ /**
2
+ * @voidhash/mimic-effect - MimicClusterServerEngine
3
+ *
4
+ * Clustered document management service using Effect Cluster for horizontal scaling.
5
+ * Each document becomes a cluster Entity with automatic sharding, failover, and location-transparent routing.
6
+ *
7
+ * This is an alternative to MimicServerEngine for distributed deployments.
8
+ */
9
+ import {
10
+ Context,
11
+ Duration,
12
+ Effect,
13
+ HashMap,
14
+ Layer,
15
+ Metric,
16
+ Option,
17
+ PubSub,
18
+ Ref,
19
+ Schema,
20
+ Scope,
21
+ Stream,
22
+ } from "effect";
23
+ import { Entity, Sharding } from "@effect/cluster";
24
+ import { Rpc } from "@effect/rpc";
25
+ import type { Presence, Primitive, Transaction } from "@voidhash/mimic";
26
+ import { ServerDocument } from "@voidhash/mimic/server";
27
+ import type {
28
+ MimicClusterServerEngineConfig,
29
+ PresenceEntry,
30
+ PresenceEvent,
31
+ PresenceSnapshot,
32
+ ResolvedClusterConfig,
33
+ StoredDocument,
34
+ WalEntry,
35
+ } from "./Types";
36
+ import type * as Protocol from "./Protocol";
37
+ import { ColdStorageTag, type ColdStorage } from "./ColdStorage";
38
+ import { HotStorageTag, type HotStorage } from "./HotStorage";
39
+ import { MimicAuthServiceTag } from "./MimicAuthService";
40
+ import { MimicServerEngineTag, type MimicServerEngine } from "./MimicServerEngine";
41
+ import type { SubmitResult } from "./DocumentManager";
42
+ import * as Metrics from "./Metrics";
43
+
44
+ // =============================================================================
45
+ // Default Configuration
46
+ // =============================================================================
47
+
48
+ const DEFAULT_MAX_IDLE_TIME = Duration.minutes(5);
49
+ const DEFAULT_MAX_TRANSACTION_HISTORY = 1000;
50
+ const DEFAULT_SNAPSHOT_INTERVAL = Duration.minutes(5);
51
+ const DEFAULT_SNAPSHOT_THRESHOLD = 100;
52
+ const DEFAULT_SHARD_GROUP = "mimic-documents";
53
+
54
+ // =============================================================================
55
+ // RPC Schemas
56
+ // =============================================================================
57
+
58
+ /**
59
+ * Schema for encoded transaction (wire format)
60
+ */
61
+ const EncodedTransactionSchema = Schema.Struct({
62
+ id: Schema.String,
63
+ ops: Schema.Array(Schema.Unknown),
64
+ });
65
+
66
+ /**
67
+ * Schema for submit result
68
+ */
69
+ const SubmitResultSchema = Schema.Union(
70
+ Schema.Struct({
71
+ success: Schema.Literal(true),
72
+ version: Schema.Number,
73
+ }),
74
+ Schema.Struct({
75
+ success: Schema.Literal(false),
76
+ reason: Schema.String,
77
+ })
78
+ );
79
+
80
+ /**
81
+ * Schema for snapshot response
82
+ */
83
+ const SnapshotResponseSchema = Schema.Struct({
84
+ state: Schema.Unknown,
85
+ version: Schema.Number,
86
+ });
87
+
88
+ /**
89
+ * Schema for presence entry
90
+ */
91
+ const PresenceEntrySchema = Schema.Struct({
92
+ data: Schema.Unknown,
93
+ userId: Schema.optional(Schema.String),
94
+ });
95
+
96
+ /**
97
+ * Schema for presence snapshot response
98
+ */
99
+ const PresenceSnapshotResponseSchema = Schema.Struct({
100
+ presences: Schema.Record({ key: Schema.String, value: PresenceEntrySchema }),
101
+ });
102
+
103
+ /**
104
+ * Schema for presence event
105
+ */
106
+ const PresenceEventSchema = Schema.Union(
107
+ Schema.Struct({
108
+ type: Schema.Literal("presence_update"),
109
+ id: Schema.String,
110
+ data: Schema.Unknown,
111
+ userId: Schema.optional(Schema.String),
112
+ }),
113
+ Schema.Struct({
114
+ type: Schema.Literal("presence_remove"),
115
+ id: Schema.String,
116
+ })
117
+ );
118
+
119
+ /**
120
+ * Schema for server message (for broadcasts)
121
+ */
122
+ const ServerMessageSchema = Schema.Unknown;
123
+
124
+ // =============================================================================
125
+ // Mimic Document Entity Definition
126
+ // =============================================================================
127
+
128
+ /**
129
+ * Define the Mimic Document Entity with its RPC protocol.
130
+ * This entity handles document operations for a single documentId.
131
+ */
132
+ const MimicDocumentEntity = Entity.make("MimicDocument", [
133
+ // Submit a transaction
134
+ Rpc.make("Submit", {
135
+ payload: { transaction: EncodedTransactionSchema },
136
+ success: SubmitResultSchema,
137
+ }),
138
+
139
+ // Get document snapshot
140
+ Rpc.make("GetSnapshot", {
141
+ success: SnapshotResponseSchema,
142
+ }),
143
+
144
+ // Touch document to prevent idle GC
145
+ Rpc.make("Touch", {
146
+ success: Schema.Void,
147
+ }),
148
+
149
+ // Set presence for a connection
150
+ Rpc.make("SetPresence", {
151
+ payload: {
152
+ connectionId: Schema.String,
153
+ entry: PresenceEntrySchema,
154
+ },
155
+ success: Schema.Void,
156
+ }),
157
+
158
+ // Remove presence for a connection
159
+ Rpc.make("RemovePresence", {
160
+ payload: { connectionId: Schema.String },
161
+ success: Schema.Void,
162
+ }),
163
+
164
+ // Get presence snapshot
165
+ Rpc.make("GetPresenceSnapshot", {
166
+ success: PresenceSnapshotResponseSchema,
167
+ }),
168
+ ]);
169
+
170
+ // =============================================================================
171
+ // Entity State Types
172
+ // =============================================================================
173
+
174
+ /**
175
+ * Document state managed by the entity
176
+ */
177
+ interface EntityDocumentState<TSchema extends Primitive.AnyPrimitive> {
178
+ readonly document: ServerDocument.ServerDocument<TSchema>;
179
+ readonly broadcastPubSub: PubSub.PubSub<Protocol.ServerMessage>;
180
+ readonly presences: HashMap.HashMap<string, PresenceEntry>;
181
+ readonly presencePubSub: PubSub.PubSub<PresenceEvent>;
182
+ readonly lastSnapshotVersion: number;
183
+ readonly lastSnapshotTime: number;
184
+ readonly transactionsSinceSnapshot: number;
185
+ }
186
+
187
+ // =============================================================================
188
+ // Config Context Tag
189
+ // =============================================================================
190
+
191
+ /**
192
+ * Context tag for cluster engine configuration
193
+ */
194
+ class MimicClusterConfigTag extends Context.Tag(
195
+ "@voidhash/mimic-effect/MimicClusterConfig"
196
+ )<MimicClusterConfigTag, ResolvedClusterConfig<Primitive.AnyPrimitive>>() {}
197
+
198
+ // =============================================================================
199
+ // Resolve Configuration
200
+ // =============================================================================
201
+
202
+ const resolveClusterConfig = <TSchema extends Primitive.AnyPrimitive>(
203
+ config: MimicClusterServerEngineConfig<TSchema>
204
+ ): ResolvedClusterConfig<TSchema> => ({
205
+ schema: config.schema,
206
+ initial: config.initial,
207
+ presence: config.presence,
208
+ maxIdleTime: config.maxIdleTime
209
+ ? Duration.decode(config.maxIdleTime)
210
+ : DEFAULT_MAX_IDLE_TIME,
211
+ maxTransactionHistory:
212
+ config.maxTransactionHistory ?? DEFAULT_MAX_TRANSACTION_HISTORY,
213
+ snapshot: {
214
+ interval: config.snapshot?.interval
215
+ ? Duration.decode(config.snapshot.interval)
216
+ : DEFAULT_SNAPSHOT_INTERVAL,
217
+ transactionThreshold:
218
+ config.snapshot?.transactionThreshold ?? DEFAULT_SNAPSHOT_THRESHOLD,
219
+ },
220
+ shardGroup: config.shardGroup ?? DEFAULT_SHARD_GROUP,
221
+ });
222
+
223
+ // =============================================================================
224
+ // Helper to decode/encode transactions
225
+ // =============================================================================
226
+
227
+ /**
228
+ * Decode an encoded transaction to a Transaction object
229
+ */
230
+ const decodeTransaction = (
231
+ encoded: { id: string; ops: readonly unknown[] }
232
+ ): Transaction.Transaction => {
233
+ // Import Transaction dynamically to avoid circular deps
234
+ const { Transaction } = require("@voidhash/mimic");
235
+ return Transaction.decode(encoded as Transaction.EncodedTransaction);
236
+ };
237
+
238
+ /**
239
+ * Encode a Transaction to wire format
240
+ */
241
+ const encodeTransaction = (
242
+ tx: Transaction.Transaction
243
+ ): { id: string; ops: readonly unknown[] } => {
244
+ const { Transaction } = require("@voidhash/mimic");
245
+ return Transaction.encode(tx);
246
+ };
247
+
248
+ // =============================================================================
249
+ // Entity Handler Factory
250
+ // =============================================================================
251
+
252
+ /**
253
+ * Create the entity handler for MimicDocument
254
+ */
255
+ const createEntityHandler = <TSchema extends Primitive.AnyPrimitive>(
256
+ config: ResolvedClusterConfig<TSchema>,
257
+ coldStorage: ColdStorage,
258
+ hotStorage: HotStorage
259
+ ) =>
260
+ Effect.gen(function* () {
261
+ // Get entity address to determine documentId
262
+ const address = yield* Entity.CurrentAddress;
263
+ const documentId = address.entityId;
264
+
265
+ // Current schema version (hard-coded to 1 for now)
266
+ const SCHEMA_VERSION = 1;
267
+
268
+ // Compute initial state
269
+ const computeInitialState = (): Effect.Effect<
270
+ Primitive.InferSetInput<TSchema> | undefined
271
+ > => {
272
+ if (config.initial === undefined) {
273
+ return Effect.succeed(undefined);
274
+ }
275
+
276
+ if (typeof config.initial === "function") {
277
+ return (
278
+ config.initial as (ctx: {
279
+ documentId: string;
280
+ }) => Effect.Effect<Primitive.InferSetInput<TSchema>>
281
+ )({ documentId });
282
+ }
283
+
284
+ return Effect.succeed(
285
+ config.initial as Primitive.InferSetInput<TSchema>
286
+ );
287
+ };
288
+
289
+ // Load snapshot from ColdStorage
290
+ const storedDoc = yield* Effect.catchAll(
291
+ coldStorage.load(documentId),
292
+ () => Effect.succeed(undefined)
293
+ );
294
+
295
+ let initialState: Primitive.InferSetInput<TSchema> | undefined;
296
+ let initialVersion = 0;
297
+
298
+ if (storedDoc) {
299
+ initialState =
300
+ storedDoc.state as Primitive.InferSetInput<TSchema>;
301
+ initialVersion = storedDoc.version;
302
+ } else {
303
+ initialState = yield* computeInitialState();
304
+ }
305
+
306
+ // Create PubSubs for broadcasting
307
+ const broadcastPubSub = yield* PubSub.unbounded<Protocol.ServerMessage>();
308
+ const presencePubSub = yield* PubSub.unbounded<PresenceEvent>();
309
+
310
+ // Create state ref
311
+ const stateRef = yield* Ref.make<EntityDocumentState<TSchema>>({
312
+ document: undefined as unknown as ServerDocument.ServerDocument<TSchema>,
313
+ broadcastPubSub,
314
+ presences: HashMap.empty(),
315
+ presencePubSub,
316
+ lastSnapshotVersion: initialVersion,
317
+ lastSnapshotTime: Date.now(),
318
+ transactionsSinceSnapshot: 0,
319
+ });
320
+
321
+ // Create ServerDocument with callbacks
322
+ const document = ServerDocument.make({
323
+ schema: config.schema,
324
+ initialState,
325
+ initialVersion,
326
+ maxTransactionHistory: config.maxTransactionHistory,
327
+ onBroadcast: (message: ServerDocument.TransactionMessage) => {
328
+ Effect.runSync(
329
+ PubSub.publish(broadcastPubSub, {
330
+ type: "transaction",
331
+ transaction: message.transaction,
332
+ version: message.version,
333
+ } as Protocol.ServerMessage)
334
+ );
335
+ },
336
+ onRejection: (transactionId: string, reason: string) => {
337
+ Effect.runSync(
338
+ PubSub.publish(broadcastPubSub, {
339
+ type: "error",
340
+ transactionId,
341
+ reason,
342
+ } as Protocol.ServerMessage)
343
+ );
344
+ },
345
+ });
346
+
347
+ // Update state with document
348
+ yield* Ref.update(stateRef, (s) => ({ ...s, document }));
349
+
350
+ // Replay WAL entries
351
+ const walEntries = yield* Effect.catchAll(
352
+ hotStorage.getEntries(documentId, initialVersion),
353
+ () => Effect.succeed([] as WalEntry[])
354
+ );
355
+
356
+ for (const entry of walEntries) {
357
+ const result = document.submit(entry.transaction);
358
+ if (!result.success) {
359
+ yield* Effect.logWarning("Skipping corrupted WAL entry", {
360
+ documentId,
361
+ version: entry.version,
362
+ reason: result.reason,
363
+ });
364
+ }
365
+ }
366
+
367
+ // Track metrics
368
+ if (storedDoc) {
369
+ yield* Metric.increment(Metrics.documentsRestored);
370
+ } else {
371
+ yield* Metric.increment(Metrics.documentsCreated);
372
+ }
373
+ yield* Metric.incrementBy(Metrics.documentsActive, 1);
374
+
375
+ /**
376
+ * Save snapshot to ColdStorage
377
+ */
378
+ const saveSnapshot = Effect.gen(function* () {
379
+ const state = yield* Ref.get(stateRef);
380
+ const docState = state.document.get();
381
+ const version = state.document.getVersion();
382
+
383
+ if (docState === undefined) {
384
+ return;
385
+ }
386
+
387
+ const storedDocument: StoredDocument = {
388
+ state: docState,
389
+ version,
390
+ schemaVersion: SCHEMA_VERSION,
391
+ savedAt: Date.now(),
392
+ };
393
+
394
+ const snapshotStartTime = Date.now();
395
+
396
+ yield* Effect.catchAll(
397
+ coldStorage.save(documentId, storedDocument),
398
+ (e) =>
399
+ Effect.logError("Failed to save snapshot", { documentId, error: e })
400
+ );
401
+
402
+ const snapshotDuration = Date.now() - snapshotStartTime;
403
+ yield* Metric.increment(Metrics.storageSnapshots);
404
+ yield* Metric.update(Metrics.storageSnapshotLatency, snapshotDuration);
405
+
406
+ yield* Effect.catchAll(hotStorage.truncate(documentId, version), (e) =>
407
+ Effect.logError("Failed to truncate WAL", { documentId, error: e })
408
+ );
409
+
410
+ yield* Ref.update(stateRef, (s) => ({
411
+ ...s,
412
+ lastSnapshotVersion: version,
413
+ lastSnapshotTime: Date.now(),
414
+ transactionsSinceSnapshot: 0,
415
+ }));
416
+ });
417
+
418
+ /**
419
+ * Check if snapshot should be triggered
420
+ */
421
+ const checkSnapshotTriggers = Effect.gen(function* () {
422
+ const state = yield* Ref.get(stateRef);
423
+ const now = Date.now();
424
+
425
+ const intervalMs = Duration.toMillis(config.snapshot.interval);
426
+ const threshold = config.snapshot.transactionThreshold;
427
+
428
+ if (state.transactionsSinceSnapshot >= threshold) {
429
+ yield* saveSnapshot;
430
+ return;
431
+ }
432
+
433
+ if (now - state.lastSnapshotTime >= intervalMs) {
434
+ yield* saveSnapshot;
435
+ return;
436
+ }
437
+ });
438
+
439
+ // Cleanup on entity finalization
440
+ yield* Effect.addFinalizer(() =>
441
+ Effect.gen(function* () {
442
+ // Save final snapshot before entity is garbage collected
443
+ yield* saveSnapshot;
444
+ yield* Metric.incrementBy(Metrics.documentsActive, -1);
445
+ yield* Metric.increment(Metrics.documentsEvicted);
446
+ yield* Effect.logDebug("Entity finalized", { documentId });
447
+ })
448
+ );
449
+
450
+ // Return RPC handlers
451
+ return {
452
+ Submit: Effect.fnUntraced(function* ({ payload }) {
453
+ const submitStartTime = Date.now();
454
+ const state = yield* Ref.get(stateRef);
455
+
456
+ // Decode transaction
457
+ const transaction = decodeTransaction(payload.transaction);
458
+
459
+ // Submit to ServerDocument
460
+ const result = state.document.submit(transaction);
461
+
462
+ // Track latency
463
+ const latency = Date.now() - submitStartTime;
464
+ yield* Metric.update(Metrics.transactionsLatency, latency);
465
+
466
+ if (result.success) {
467
+ yield* Metric.increment(Metrics.transactionsProcessed);
468
+
469
+ // Append to WAL
470
+ const walEntry: WalEntry = {
471
+ transaction,
472
+ version: result.version,
473
+ timestamp: Date.now(),
474
+ };
475
+
476
+ yield* Effect.catchAll(hotStorage.append(documentId, walEntry), (e) =>
477
+ Effect.logError("Failed to append to WAL", {
478
+ documentId,
479
+ error: e,
480
+ })
481
+ );
482
+
483
+ yield* Metric.increment(Metrics.storageWalAppends);
484
+
485
+ // Increment transaction count
486
+ yield* Ref.update(stateRef, (s) => ({
487
+ ...s,
488
+ transactionsSinceSnapshot: s.transactionsSinceSnapshot + 1,
489
+ }));
490
+
491
+ // Check snapshot triggers
492
+ yield* checkSnapshotTriggers;
493
+ } else {
494
+ yield* Metric.increment(Metrics.transactionsRejected);
495
+ }
496
+
497
+ return result;
498
+ }),
499
+
500
+ GetSnapshot: Effect.fnUntraced(function* () {
501
+ const state = yield* Ref.get(stateRef);
502
+ return state.document.getSnapshot();
503
+ }),
504
+
505
+ Touch: Effect.fnUntraced(function* () {
506
+ // Entity touch is handled automatically by the cluster framework
507
+ // Just update last activity time conceptually
508
+ return void 0;
509
+ }),
510
+
511
+ SetPresence: Effect.fnUntraced(function* ({ payload }) {
512
+ const { connectionId, entry } = payload;
513
+
514
+ yield* Ref.update(stateRef, (s) => ({
515
+ ...s,
516
+ presences: HashMap.set(s.presences, connectionId, entry),
517
+ }));
518
+
519
+ yield* Metric.increment(Metrics.presenceUpdates);
520
+ yield* Metric.incrementBy(Metrics.presenceActive, 1);
521
+
522
+ const state = yield* Ref.get(stateRef);
523
+ const event: PresenceEvent = {
524
+ type: "presence_update",
525
+ id: connectionId,
526
+ data: entry.data,
527
+ userId: entry.userId,
528
+ };
529
+ yield* PubSub.publish(state.presencePubSub, event);
530
+ }),
531
+
532
+ RemovePresence: Effect.fnUntraced(function* ({ payload }) {
533
+ const { connectionId } = payload;
534
+ const state = yield* Ref.get(stateRef);
535
+
536
+ if (!HashMap.has(state.presences, connectionId)) {
537
+ return;
538
+ }
539
+
540
+ yield* Ref.update(stateRef, (s) => ({
541
+ ...s,
542
+ presences: HashMap.remove(s.presences, connectionId),
543
+ }));
544
+
545
+ yield* Metric.incrementBy(Metrics.presenceActive, -1);
546
+
547
+ const event: PresenceEvent = {
548
+ type: "presence_remove",
549
+ id: connectionId,
550
+ };
551
+ yield* PubSub.publish(state.presencePubSub, event);
552
+ }),
553
+
554
+ GetPresenceSnapshot: Effect.fnUntraced(function* () {
555
+ const state = yield* Ref.get(stateRef);
556
+ const presences: Record<string, PresenceEntry> = {};
557
+ for (const [id, entry] of state.presences) {
558
+ presences[id] = entry;
559
+ }
560
+ return { presences };
561
+ }),
562
+ };
563
+ });
564
+
565
+ // =============================================================================
566
+ // Subscription Store (for managing subscriptions at the gateway level)
567
+ // =============================================================================
568
+
569
+ /**
570
+ * Store for managing document subscriptions
571
+ * This is needed because cluster entities don't support streaming directly
572
+ */
573
+ interface SubscriptionStore {
574
+ readonly getOrCreatePubSub: (
575
+ documentId: string
576
+ ) => Effect.Effect<PubSub.PubSub<Protocol.ServerMessage>>;
577
+ readonly getOrCreatePresencePubSub: (
578
+ documentId: string
579
+ ) => Effect.Effect<PubSub.PubSub<PresenceEvent>>;
580
+ }
581
+
582
+ class SubscriptionStoreTag extends Context.Tag(
583
+ "@voidhash/mimic-effect/SubscriptionStore"
584
+ )<SubscriptionStoreTag, SubscriptionStore>() {}
585
+
586
+ const subscriptionStoreLayer = Layer.effect(
587
+ SubscriptionStoreTag,
588
+ Effect.gen(function* () {
589
+ const documentPubSubs = yield* Ref.make(
590
+ HashMap.empty<string, PubSub.PubSub<Protocol.ServerMessage>>()
591
+ );
592
+ const presencePubSubs = yield* Ref.make(
593
+ HashMap.empty<string, PubSub.PubSub<PresenceEvent>>()
594
+ );
595
+
596
+ return {
597
+ getOrCreatePubSub: (documentId: string) =>
598
+ Effect.gen(function* () {
599
+ const current = yield* Ref.get(documentPubSubs);
600
+ const existing = HashMap.get(current, documentId);
601
+ if (existing._tag === "Some") {
602
+ return existing.value;
603
+ }
604
+
605
+ const pubsub = yield* PubSub.unbounded<Protocol.ServerMessage>();
606
+ yield* Ref.update(documentPubSubs, (map) =>
607
+ HashMap.set(map, documentId, pubsub)
608
+ );
609
+ return pubsub;
610
+ }),
611
+
612
+ getOrCreatePresencePubSub: (documentId: string) =>
613
+ Effect.gen(function* () {
614
+ const current = yield* Ref.get(presencePubSubs);
615
+ const existing = HashMap.get(current, documentId);
616
+ if (existing._tag === "Some") {
617
+ return existing.value;
618
+ }
619
+
620
+ const pubsub = yield* PubSub.unbounded<PresenceEvent>();
621
+ yield* Ref.update(presencePubSubs, (map) =>
622
+ HashMap.set(map, documentId, pubsub)
623
+ );
624
+ return pubsub;
625
+ }),
626
+ };
627
+ })
628
+ );
629
+
630
+ // =============================================================================
631
+ // Factory
632
+ // =============================================================================
633
+
634
+ /**
635
+ * Create a MimicClusterServerEngine layer.
636
+ *
637
+ * This creates a clustered document management service using Effect Cluster.
638
+ * Each document becomes a cluster Entity with automatic sharding and failover.
639
+ *
640
+ * @example
641
+ * ```typescript
642
+ * // 1. Create the engine
643
+ * const Engine = MimicClusterServerEngine.make({
644
+ * schema: DocSchema,
645
+ * initial: { title: "Untitled" },
646
+ * presence: CursorPresence,
647
+ * maxIdleTime: "5 minutes",
648
+ * snapshot: { interval: "5 minutes", transactionThreshold: 100 },
649
+ * shardGroup: "documents",
650
+ * })
651
+ *
652
+ * // 2. Create the WebSocket route
653
+ * const MimicRoute = MimicServer.layerHttpLayerRouter({
654
+ * path: "/mimic",
655
+ * })
656
+ *
657
+ * // 3. Wire together with cluster infrastructure
658
+ * const MimicLive = MimicRoute.pipe(
659
+ * Layer.provide(Engine),
660
+ * Layer.provide(ColdStorage.S3.make(...)),
661
+ * Layer.provide(HotStorage.Redis.make(...)),
662
+ * Layer.provide(MimicAuthService.make(...)),
663
+ * Layer.provide(ClusterInfrastructure),
664
+ * )
665
+ * ```
666
+ */
667
+ export const make = <TSchema extends Primitive.AnyPrimitive>(
668
+ config: MimicClusterServerEngineConfig<TSchema>
669
+ ): Layer.Layer<
670
+ MimicServerEngineTag,
671
+ never,
672
+ ColdStorageTag | HotStorageTag | MimicAuthServiceTag | Sharding.Sharding
673
+ > => {
674
+ const resolvedConfig = resolveClusterConfig(config);
675
+
676
+ // Create config layer
677
+ const configLayer = Layer.succeed(
678
+ MimicClusterConfigTag,
679
+ resolvedConfig as ResolvedClusterConfig<Primitive.AnyPrimitive>
680
+ );
681
+
682
+ // Create entity layer that registers with sharding
683
+ const entityLayer = MimicDocumentEntity.toLayer(
684
+ Effect.gen(function* () {
685
+ const clusterConfig = yield* MimicClusterConfigTag;
686
+ const coldStorage = yield* ColdStorageTag;
687
+ const hotStorage = yield* HotStorageTag;
688
+
689
+ return yield* createEntityHandler(
690
+ clusterConfig as ResolvedClusterConfig<TSchema>,
691
+ coldStorage,
692
+ hotStorage
693
+ );
694
+ }),
695
+ {
696
+ maxIdleTime: resolvedConfig.maxIdleTime,
697
+ concurrency: 1, // Sequential message processing per document
698
+ mailboxCapacity: 4096,
699
+ }
700
+ );
701
+
702
+ // Create the engine service
703
+ const engineLayer = Layer.scoped(
704
+ MimicServerEngineTag,
705
+ Effect.gen(function* () {
706
+ // Get entity client maker
707
+ const makeClient = yield* MimicDocumentEntity.client;
708
+
709
+ // Get subscription store
710
+ const subscriptionStore = yield* SubscriptionStoreTag;
711
+
712
+ const engine: MimicServerEngine = {
713
+ submit: (documentId, transaction) =>
714
+ Effect.gen(function* () {
715
+ const client = makeClient(documentId);
716
+ const encodedTx = encodeTransaction(transaction);
717
+ const result = yield* client.Submit({
718
+ transaction: encodedTx as { id: string; ops: unknown[] },
719
+ }).pipe(
720
+ Effect.catchAll((error) =>
721
+ Effect.succeed({
722
+ success: false as const,
723
+ reason: `Cluster error: ${String(error)}`,
724
+ })
725
+ )
726
+ );
727
+
728
+ // Broadcast to local subscribers if success
729
+ if (result.success) {
730
+ const pubsub =
731
+ yield* subscriptionStore.getOrCreatePubSub(documentId);
732
+ yield* PubSub.publish(pubsub, {
733
+ type: "transaction",
734
+ transaction,
735
+ version: result.version,
736
+ } as Protocol.ServerMessage);
737
+ }
738
+
739
+ return result;
740
+ }),
741
+
742
+ getSnapshot: (documentId) =>
743
+ Effect.gen(function* () {
744
+ const client = makeClient(documentId);
745
+ return yield* client.GetSnapshot(undefined as void).pipe(Effect.orDie);
746
+ }),
747
+
748
+ subscribe: (documentId) =>
749
+ Effect.gen(function* () {
750
+ const pubsub =
751
+ yield* subscriptionStore.getOrCreatePubSub(documentId);
752
+ return Stream.fromPubSub(pubsub);
753
+ }),
754
+
755
+ touch: (documentId) =>
756
+ Effect.gen(function* () {
757
+ const client = makeClient(documentId);
758
+ yield* client.Touch(undefined as void).pipe(Effect.orDie);
759
+ }),
760
+
761
+ getPresenceSnapshot: (documentId) =>
762
+ Effect.gen(function* () {
763
+ const client = makeClient(documentId);
764
+ return yield* client.GetPresenceSnapshot(undefined as void).pipe(Effect.orDie);
765
+ }),
766
+
767
+ setPresence: (documentId, connectionId, entry) =>
768
+ Effect.gen(function* () {
769
+ const client = makeClient(documentId);
770
+ yield* client.SetPresence({ connectionId, entry }).pipe(Effect.orDie);
771
+
772
+ // Broadcast to local presence subscribers
773
+ const pubsub =
774
+ yield* subscriptionStore.getOrCreatePresencePubSub(documentId);
775
+ yield* PubSub.publish(pubsub, {
776
+ type: "presence_update",
777
+ id: connectionId,
778
+ data: entry.data,
779
+ userId: entry.userId,
780
+ });
781
+ }),
782
+
783
+ removePresence: (documentId, connectionId) =>
784
+ Effect.gen(function* () {
785
+ const client = makeClient(documentId);
786
+ yield* client.RemovePresence({ connectionId }).pipe(Effect.orDie);
787
+
788
+ // Broadcast to local presence subscribers
789
+ const pubsub =
790
+ yield* subscriptionStore.getOrCreatePresencePubSub(documentId);
791
+ yield* PubSub.publish(pubsub, {
792
+ type: "presence_remove",
793
+ id: connectionId,
794
+ });
795
+ }),
796
+
797
+ subscribePresence: (documentId) =>
798
+ Effect.gen(function* () {
799
+ const pubsub =
800
+ yield* subscriptionStore.getOrCreatePresencePubSub(documentId);
801
+ return Stream.fromPubSub(pubsub);
802
+ }),
803
+
804
+ config: resolvedConfig as ResolvedClusterConfig<Primitive.AnyPrimitive>,
805
+ };
806
+
807
+ return engine;
808
+ })
809
+ );
810
+
811
+ // Compose all layers
812
+ return Layer.mergeAll(entityLayer, engineLayer).pipe(
813
+ Layer.provideMerge(subscriptionStoreLayer),
814
+ Layer.provideMerge(configLayer)
815
+ );
816
+ };
817
+
818
+ // =============================================================================
819
+ // Re-export namespace
820
+ // =============================================================================
821
+
822
+ export const MimicClusterServerEngine = {
823
+ make,
824
+ };