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

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 (227) hide show
  1. package/.turbo/turbo-build.log +136 -90
  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 +263 -82
  11. package/dist/DocumentManager.d.cts +44 -22
  12. package/dist/DocumentManager.d.cts.map +1 -1
  13. package/dist/DocumentManager.d.mts +44 -22
  14. package/dist/DocumentManager.d.mts.map +1 -1
  15. package/dist/DocumentManager.mjs +259 -67
  16. package/dist/DocumentManager.mjs.map +1 -1
  17. package/dist/Errors.cjs +54 -0
  18. package/dist/Errors.d.cts +96 -0
  19. package/dist/Errors.d.cts.map +1 -0
  20. package/dist/Errors.d.mts +96 -0
  21. package/dist/Errors.d.mts.map +1 -0
  22. package/dist/Errors.mjs +48 -0
  23. package/dist/Errors.mjs.map +1 -0
  24. package/dist/HotStorage.cjs +100 -0
  25. package/dist/HotStorage.d.cts +70 -0
  26. package/dist/HotStorage.d.cts.map +1 -0
  27. package/dist/HotStorage.d.mts +70 -0
  28. package/dist/HotStorage.d.mts.map +1 -0
  29. package/dist/HotStorage.mjs +100 -0
  30. package/dist/HotStorage.mjs.map +1 -0
  31. package/dist/Metrics.cjs +143 -0
  32. package/dist/Metrics.d.cts +31 -0
  33. package/dist/Metrics.d.cts.map +1 -0
  34. package/dist/Metrics.d.mts +31 -0
  35. package/dist/Metrics.d.mts.map +1 -0
  36. package/dist/Metrics.mjs +126 -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 +521 -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 +523 -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 +78 -0
  61. package/dist/MimicServerEngine.d.cts.map +1 -0
  62. package/dist/MimicServerEngine.d.mts +78 -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/dist/testing/ColdStorageTestSuite.cjs +508 -0
  91. package/dist/testing/ColdStorageTestSuite.d.cts +36 -0
  92. package/dist/testing/ColdStorageTestSuite.d.cts.map +1 -0
  93. package/dist/testing/ColdStorageTestSuite.d.mts +36 -0
  94. package/dist/testing/ColdStorageTestSuite.d.mts.map +1 -0
  95. package/dist/testing/ColdStorageTestSuite.mjs +508 -0
  96. package/dist/testing/ColdStorageTestSuite.mjs.map +1 -0
  97. package/dist/testing/FailingStorage.cjs +135 -0
  98. package/dist/testing/FailingStorage.d.cts +43 -0
  99. package/dist/testing/FailingStorage.d.cts.map +1 -0
  100. package/dist/testing/FailingStorage.d.mts +43 -0
  101. package/dist/testing/FailingStorage.d.mts.map +1 -0
  102. package/dist/testing/FailingStorage.mjs +136 -0
  103. package/dist/testing/FailingStorage.mjs.map +1 -0
  104. package/dist/testing/HotStorageTestSuite.cjs +585 -0
  105. package/dist/testing/HotStorageTestSuite.d.cts +40 -0
  106. package/dist/testing/HotStorageTestSuite.d.cts.map +1 -0
  107. package/dist/testing/HotStorageTestSuite.d.mts +40 -0
  108. package/dist/testing/HotStorageTestSuite.d.mts.map +1 -0
  109. package/dist/testing/HotStorageTestSuite.mjs +585 -0
  110. package/dist/testing/HotStorageTestSuite.mjs.map +1 -0
  111. package/dist/testing/StorageIntegrationTestSuite.cjs +349 -0
  112. package/dist/testing/StorageIntegrationTestSuite.d.cts +35 -0
  113. package/dist/testing/StorageIntegrationTestSuite.d.cts.map +1 -0
  114. package/dist/testing/StorageIntegrationTestSuite.d.mts +35 -0
  115. package/dist/testing/StorageIntegrationTestSuite.d.mts.map +1 -0
  116. package/dist/testing/StorageIntegrationTestSuite.mjs +349 -0
  117. package/dist/testing/StorageIntegrationTestSuite.mjs.map +1 -0
  118. package/dist/testing/assertions.cjs +114 -0
  119. package/dist/testing/assertions.mjs +109 -0
  120. package/dist/testing/assertions.mjs.map +1 -0
  121. package/dist/testing/index.cjs +14 -0
  122. package/dist/testing/index.d.cts +6 -0
  123. package/dist/testing/index.d.mts +6 -0
  124. package/dist/testing/index.mjs +7 -0
  125. package/dist/testing/types.cjs +15 -0
  126. package/dist/testing/types.d.cts +90 -0
  127. package/dist/testing/types.d.cts.map +1 -0
  128. package/dist/testing/types.d.mts +90 -0
  129. package/dist/testing/types.d.mts.map +1 -0
  130. package/dist/testing/types.mjs +16 -0
  131. package/dist/testing/types.mjs.map +1 -0
  132. package/package.json +18 -3
  133. package/src/ColdStorage.ts +136 -0
  134. package/src/DocumentManager.ts +550 -190
  135. package/src/Errors.ts +114 -0
  136. package/src/HotStorage.ts +239 -0
  137. package/src/Metrics.ts +187 -0
  138. package/src/MimicAuthService.ts +126 -64
  139. package/src/MimicClusterServerEngine.ts +946 -0
  140. package/src/MimicServer.ts +448 -195
  141. package/src/MimicServerEngine.ts +276 -0
  142. package/src/PresenceManager.ts +169 -240
  143. package/src/Protocol.ts +350 -0
  144. package/src/Types.ts +231 -0
  145. package/src/index.ts +57 -23
  146. package/src/testing/ColdStorageTestSuite.ts +589 -0
  147. package/src/testing/FailingStorage.ts +286 -0
  148. package/src/testing/HotStorageTestSuite.ts +762 -0
  149. package/src/testing/StorageIntegrationTestSuite.ts +504 -0
  150. package/src/testing/assertions.ts +181 -0
  151. package/src/testing/index.ts +83 -0
  152. package/src/testing/types.ts +100 -0
  153. package/tests/ColdStorage.test.ts +24 -0
  154. package/tests/DocumentManager.test.ts +158 -287
  155. package/tests/HotStorage.test.ts +24 -0
  156. package/tests/MimicAuthService.test.ts +102 -134
  157. package/tests/MimicClusterServerEngine.test.ts +587 -0
  158. package/tests/MimicServer.test.ts +90 -226
  159. package/tests/MimicServerEngine.test.ts +521 -0
  160. package/tests/PresenceManager.test.ts +22 -63
  161. package/tests/Protocol.test.ts +190 -0
  162. package/tests/StorageIntegration.test.ts +259 -0
  163. package/tsconfig.json +1 -1
  164. package/tsdown.config.ts +1 -1
  165. package/dist/DocumentProtocol.cjs +0 -94
  166. package/dist/DocumentProtocol.d.cts +0 -113
  167. package/dist/DocumentProtocol.d.cts.map +0 -1
  168. package/dist/DocumentProtocol.d.mts +0 -113
  169. package/dist/DocumentProtocol.d.mts.map +0 -1
  170. package/dist/DocumentProtocol.mjs +0 -89
  171. package/dist/DocumentProtocol.mjs.map +0 -1
  172. package/dist/MimicConfig.cjs +0 -60
  173. package/dist/MimicConfig.d.cts +0 -141
  174. package/dist/MimicConfig.d.cts.map +0 -1
  175. package/dist/MimicConfig.d.mts +0 -141
  176. package/dist/MimicConfig.d.mts.map +0 -1
  177. package/dist/MimicConfig.mjs +0 -50
  178. package/dist/MimicConfig.mjs.map +0 -1
  179. package/dist/MimicDataStorage.cjs +0 -83
  180. package/dist/MimicDataStorage.d.cts +0 -113
  181. package/dist/MimicDataStorage.d.cts.map +0 -1
  182. package/dist/MimicDataStorage.d.mts +0 -113
  183. package/dist/MimicDataStorage.d.mts.map +0 -1
  184. package/dist/MimicDataStorage.mjs +0 -74
  185. package/dist/MimicDataStorage.mjs.map +0 -1
  186. package/dist/WebSocketHandler.cjs +0 -365
  187. package/dist/WebSocketHandler.d.cts +0 -34
  188. package/dist/WebSocketHandler.d.cts.map +0 -1
  189. package/dist/WebSocketHandler.d.mts +0 -34
  190. package/dist/WebSocketHandler.d.mts.map +0 -1
  191. package/dist/WebSocketHandler.mjs +0 -355
  192. package/dist/WebSocketHandler.mjs.map +0 -1
  193. package/dist/auth/NoAuth.cjs +0 -43
  194. package/dist/auth/NoAuth.d.cts +0 -22
  195. package/dist/auth/NoAuth.d.cts.map +0 -1
  196. package/dist/auth/NoAuth.d.mts +0 -22
  197. package/dist/auth/NoAuth.d.mts.map +0 -1
  198. package/dist/auth/NoAuth.mjs +0 -36
  199. package/dist/auth/NoAuth.mjs.map +0 -1
  200. package/dist/errors.cjs +0 -74
  201. package/dist/errors.d.cts +0 -89
  202. package/dist/errors.d.cts.map +0 -1
  203. package/dist/errors.d.mts +0 -89
  204. package/dist/errors.d.mts.map +0 -1
  205. package/dist/errors.mjs +0 -67
  206. package/dist/errors.mjs.map +0 -1
  207. package/dist/storage/InMemoryDataStorage.cjs +0 -57
  208. package/dist/storage/InMemoryDataStorage.d.cts +0 -19
  209. package/dist/storage/InMemoryDataStorage.d.cts.map +0 -1
  210. package/dist/storage/InMemoryDataStorage.d.mts +0 -19
  211. package/dist/storage/InMemoryDataStorage.d.mts.map +0 -1
  212. package/dist/storage/InMemoryDataStorage.mjs +0 -48
  213. package/dist/storage/InMemoryDataStorage.mjs.map +0 -1
  214. package/src/DocumentProtocol.ts +0 -112
  215. package/src/MimicConfig.ts +0 -211
  216. package/src/MimicDataStorage.ts +0 -157
  217. package/src/WebSocketHandler.ts +0 -735
  218. package/src/auth/NoAuth.ts +0 -46
  219. package/src/errors.ts +0 -113
  220. package/src/storage/InMemoryDataStorage.ts +0 -66
  221. package/tests/DocumentProtocol.test.ts +0 -113
  222. package/tests/InMemoryDataStorage.test.ts +0 -190
  223. package/tests/MimicConfig.test.ts +0 -290
  224. package/tests/MimicDataStorage.test.ts +0 -190
  225. package/tests/NoAuth.test.ts +0 -94
  226. package/tests/WebSocketHandler.test.ts +0 -321
  227. package/tests/errors.test.ts +0 -77
@@ -1,254 +1,614 @@
1
1
  /**
2
- * @since 0.0.1
3
- * Document manager that handles multiple document instances.
2
+ * @voidhash/mimic-effect - DocumentManager
3
+ *
4
+ * Internal service for managing document lifecycle, including:
5
+ * - Document creation and restoration
6
+ * - Transaction processing
7
+ * - WAL management
8
+ * - Snapshot scheduling
9
+ * - Idle document GC
4
10
  */
5
- import * as Effect from "effect/Effect";
6
- import * as Layer from "effect/Layer";
7
- import * as PubSub from "effect/PubSub";
8
- import * as Ref from "effect/Ref";
9
- import * as HashMap from "effect/HashMap";
10
- import * as Context from "effect/Context";
11
- import * as Scope from "effect/Scope";
12
- import * as Stream from "effect/Stream";
13
- import type { Primitive, Transaction } from "@voidhash/mimic";
11
+ import {
12
+ Context,
13
+ Duration,
14
+ Effect,
15
+ HashMap,
16
+ Layer,
17
+ Metric,
18
+ PubSub,
19
+ Ref,
20
+ Schedule,
21
+ Scope,
22
+ Stream,
23
+ } from "effect";
24
+ import { Document, Primitive, Transaction } from "@voidhash/mimic";
14
25
  import { ServerDocument } from "@voidhash/mimic/server";
15
-
16
- import * as Protocol from "./DocumentProtocol.js";
17
- import { MimicServerConfigTag } from "./MimicConfig.js";
18
- import { MimicDataStorageTag } from "./MimicDataStorage.js";
19
- import { DocumentNotFoundError } from "./errors.js";
26
+ import type {
27
+ ResolvedConfig,
28
+ StoredDocument,
29
+ WalEntry,
30
+ } from "./Types";
31
+ import type { SnapshotMessage, ServerBroadcast } from "./Protocol";
32
+ import { ColdStorageTag } from "./ColdStorage";
33
+ import { HotStorageTag } from "./HotStorage";
34
+ import { ColdStorageError, HotStorageError } from "./Errors";
35
+ import * as Metrics from "./Metrics";
20
36
 
21
37
  // =============================================================================
22
- // Document Instance
38
+ // Submit Result Types
23
39
  // =============================================================================
24
40
 
25
41
  /**
26
- * A managed document instance that holds state and manages subscribers.
42
+ * Result of submitting a transaction
27
43
  */
28
- interface DocumentInstance {
29
- /** The underlying ServerDocument */
30
- readonly document: ServerDocument.ServerDocument<Primitive.AnyPrimitive>;
31
- /** PubSub for broadcasting messages to subscribers */
32
- readonly pubsub: PubSub.PubSub<Protocol.ServerBroadcast>;
33
- /** Reference count for cleanup */
34
- readonly refCount: Ref.Ref<number>;
35
- }
44
+ export type SubmitResult =
45
+ | { readonly success: true; readonly version: number }
46
+ | { readonly success: false; readonly reason: string };
36
47
 
37
48
  // =============================================================================
38
- // Document Manager Service
49
+ // DocumentManager Interface
39
50
  // =============================================================================
40
51
 
41
52
  /**
42
- * Service interface for the DocumentManager.
53
+ * Error type for DocumentManager operations
54
+ */
55
+ export type DocumentManagerError = ColdStorageError | HotStorageError;
56
+
57
+ /**
58
+ * Internal service for managing document lifecycle.
43
59
  */
44
60
  export interface DocumentManager {
45
61
  /**
46
62
  * Submit a transaction to a document.
63
+ * May fail with ColdStorageError or HotStorageError if storage is unavailable.
47
64
  */
48
65
  readonly submit: (
49
66
  documentId: string,
50
67
  transaction: Transaction.Transaction
51
- ) => Effect.Effect<Protocol.SubmitResult>;
68
+ ) => Effect.Effect<SubmitResult, DocumentManagerError>;
52
69
 
53
70
  /**
54
71
  * Get a snapshot of a document.
72
+ * May fail with ColdStorageError or HotStorageError if storage is unavailable.
55
73
  */
56
- readonly getSnapshot: (
57
- documentId: string
58
- ) => Effect.Effect<Protocol.SnapshotMessage>;
74
+ readonly getSnapshot: (documentId: string) => Effect.Effect<SnapshotMessage, DocumentManagerError>;
59
75
 
60
76
  /**
61
77
  * Subscribe to broadcasts for a document.
62
- * Returns a Stream of server broadcasts.
78
+ * May fail with ColdStorageError or HotStorageError if storage is unavailable.
63
79
  */
64
80
  readonly subscribe: (
65
81
  documentId: string
66
- ) => Effect.Effect<
67
- Stream.Stream<Protocol.ServerBroadcast>,
68
- never,
69
- Scope.Scope
70
- >;
82
+ ) => Effect.Effect<Stream.Stream<ServerBroadcast>, DocumentManagerError, Scope.Scope>;
83
+
84
+ /**
85
+ * Touch a document to update its last activity time.
86
+ * Call this on any client activity to prevent idle GC.
87
+ */
88
+ readonly touch: (documentId: string) => Effect.Effect<void>;
71
89
  }
72
90
 
91
+ // =============================================================================
92
+ // Context Tag
93
+ // =============================================================================
94
+
73
95
  /**
74
- * Context tag for DocumentManager.
96
+ * Context tag for DocumentManager service
75
97
  */
76
98
  export class DocumentManagerTag extends Context.Tag(
77
- "@voidhash/mimic-server-effect/DocumentManager"
99
+ "@voidhash/mimic-effect/DocumentManager"
78
100
  )<DocumentManagerTag, DocumentManager>() {}
79
101
 
80
102
  // =============================================================================
81
- // Document Manager Implementation
103
+ // Internal Types
82
104
  // =============================================================================
83
105
 
84
106
  /**
85
- * Create the DocumentManager service.
107
+ * Document instance state
86
108
  */
87
- const makeDocumentManager = Effect.gen(function* () {
88
- const config = yield* MimicServerConfigTag;
89
- const storage = yield* MimicDataStorageTag;
90
-
91
- // Map of document ID to document instance
92
- const documents = yield* Ref.make(
93
- HashMap.empty<string, DocumentInstance>()
94
- );
95
-
96
- // Get or create a document instance
97
- const getOrCreateDocument = (
98
- documentId: string
99
- ): Effect.Effect<DocumentInstance> =>
100
- Effect.gen(function* () {
101
- const current = yield* Ref.get(documents);
102
- const existing = HashMap.get(current, documentId);
103
-
104
- if (existing._tag === "Some") {
105
- // Increment ref count
106
- yield* Ref.update(existing.value.refCount, (n) => n + 1);
107
- return existing.value;
109
+ interface DocumentInstance<TSchema extends Primitive.AnyPrimitive> {
110
+ /** The underlying ServerDocument */
111
+ readonly document: ServerDocument.ServerDocument<TSchema>;
112
+ /** PubSub for broadcasting messages */
113
+ readonly pubsub: PubSub.PubSub<ServerBroadcast>;
114
+ /** Version at last snapshot */
115
+ readonly lastSnapshotVersion: Ref.Ref<number>;
116
+ /** Timestamp of last snapshot (ms) */
117
+ readonly lastSnapshotTime: Ref.Ref<number>;
118
+ /** Transactions since last snapshot */
119
+ readonly transactionsSinceSnapshot: Ref.Ref<number>;
120
+ /** Last activity timestamp (ms) */
121
+ readonly lastActivityTime: Ref.Ref<number>;
122
+ }
123
+
124
+ // =============================================================================
125
+ // Config Context Tag
126
+ // =============================================================================
127
+
128
+ /**
129
+ * Context tag for DocumentManager configuration
130
+ */
131
+ export class DocumentManagerConfigTag extends Context.Tag(
132
+ "@voidhash/mimic-effect/DocumentManagerConfig"
133
+ )<DocumentManagerConfigTag, ResolvedConfig<Primitive.AnyPrimitive>>() {}
134
+
135
+ // =============================================================================
136
+ // Layer Implementation
137
+ // =============================================================================
138
+
139
+ /**
140
+ * Create the DocumentManager layer.
141
+ * Requires ColdStorage, HotStorage, and DocumentManagerConfig.
142
+ */
143
+ export const layer = Layer.scoped(
144
+ DocumentManagerTag,
145
+ Effect.gen(function* () {
146
+ const coldStorage = yield* ColdStorageTag;
147
+ const hotStorage = yield* HotStorageTag;
148
+ const config = yield* DocumentManagerConfigTag;
149
+
150
+ // Store: documentId -> DocumentInstance
151
+ const store = yield* Ref.make(
152
+ HashMap.empty<string, DocumentInstance<Primitive.AnyPrimitive>>()
153
+ );
154
+
155
+ // Current schema version (hard-coded to 1 for now)
156
+ const SCHEMA_VERSION = 1;
157
+
158
+ /**
159
+ * Compute initial state for a new document
160
+ */
161
+ const computeInitialState = (
162
+ documentId: string
163
+ ): Effect.Effect<Primitive.InferSetInput<typeof config.schema> | undefined> => {
164
+ if (config.initial === undefined) {
165
+ return Effect.succeed(undefined);
108
166
  }
109
167
 
110
- // Load initial state from storage
111
- const rawState = yield* Effect.catchAll(
112
- storage.load(documentId),
113
- () => Effect.succeed(undefined)
114
- );
168
+ // Check if it's a function or static value
169
+ if (typeof config.initial === "function") {
170
+ return (config.initial as (ctx: { documentId: string }) => Effect.Effect<Primitive.InferSetInput<typeof config.schema>>)({ documentId });
171
+ }
115
172
 
116
- // Transform loaded state with onLoad hook, or compute initial state for new docs
117
- const initialState = rawState !== undefined
118
- ? yield* storage.onLoad(rawState)
119
- : config.initial !== undefined
120
- ? yield* config.initial({ documentId })
121
- : undefined;
122
-
123
- // Create PubSub for broadcasting
124
- const pubsub = yield* PubSub.unbounded<Protocol.ServerBroadcast>();
125
-
126
- // Create ServerDocument with broadcast callback
127
- const serverDocument = ServerDocument.make({
128
- schema: config.schema,
129
- initialState: initialState as Primitive.InferSetInput<typeof config.schema> | undefined,
130
- maxTransactionHistory: config.maxTransactionHistory,
131
- onBroadcast: (transactionMessage) => {
132
- // Get current state and save to storage
133
- const currentState = serverDocument.get();
134
-
135
- // Run save in background (fire-and-forget with error logging)
136
- Effect.runFork(
137
- Effect.gen(function* () {
138
- if (currentState !== undefined) {
139
- const transformedState = yield* storage.onSave(currentState);
140
- yield* Effect.catchAll(
141
- storage.save(documentId, transformedState),
142
- (error) => Effect.logError("Failed to save document", error)
143
- );
144
- }
145
- })
146
- );
173
+ return Effect.succeed(config.initial as Primitive.InferSetInput<typeof config.schema>);
174
+ };
147
175
 
148
- // Broadcast to subscribers
149
- Effect.runSync(
150
- PubSub.publish(pubsub, {
151
- type: "transaction",
152
- transaction: transactionMessage.transaction as Protocol.Transaction,
153
- version: transactionMessage.version,
154
- })
155
- );
156
- },
157
- onRejection: (transactionId, reason) => {
158
- Effect.runSync(
159
- PubSub.publish(pubsub, {
160
- type: "error",
161
- transactionId,
162
- reason,
163
- })
164
- );
165
- },
176
+ /**
177
+ * Restore a document from storage
178
+ */
179
+ const restoreDocument = (
180
+ documentId: string
181
+ ): Effect.Effect<DocumentInstance<typeof config.schema>, ColdStorageError | HotStorageError> =>
182
+ Effect.gen(function* () {
183
+ // 1. Load snapshot from ColdStorage (errors propagate - do not silently fallback)
184
+ const storedDoc = yield* coldStorage.load(documentId);
185
+
186
+ let initialState: Primitive.InferSetInput<typeof config.schema> | undefined;
187
+ let initialVersion = 0;
188
+
189
+ if (storedDoc) {
190
+ // Use stored state
191
+ initialState = storedDoc.state as Primitive.InferSetInput<typeof config.schema>;
192
+ initialVersion = storedDoc.version;
193
+ } else {
194
+ // Compute initial state
195
+ initialState = yield* computeInitialState(documentId);
196
+ }
197
+
198
+ // 2. Create PubSub for broadcasting
199
+ const pubsub = yield* PubSub.unbounded<ServerBroadcast>();
200
+
201
+ // 3. Create refs for tracking
202
+ const lastSnapshotVersion = yield* Ref.make(initialVersion);
203
+ const lastSnapshotTime = yield* Ref.make(Date.now());
204
+ const transactionsSinceSnapshot = yield* Ref.make(0);
205
+ const lastActivityTime = yield* Ref.make(Date.now());
206
+
207
+ // 4. Create ServerDocument with callbacks
208
+ const document = ServerDocument.make({
209
+ schema: config.schema,
210
+ initialState,
211
+ initialVersion,
212
+ maxTransactionHistory: config.maxTransactionHistory,
213
+ onBroadcast: (message: ServerDocument.TransactionMessage) => {
214
+ // This is called synchronously by ServerDocument
215
+ // We need to publish to PubSub
216
+ Effect.runSync(
217
+ PubSub.publish(pubsub, {
218
+ type: "transaction",
219
+ transaction: message.transaction,
220
+ version: message.version,
221
+ })
222
+ );
223
+ },
224
+ onRejection: (transactionId: string, reason: string) => {
225
+ Effect.runSync(
226
+ PubSub.publish(pubsub, {
227
+ type: "error",
228
+ transactionId,
229
+ reason,
230
+ })
231
+ );
232
+ },
233
+ });
234
+
235
+ // 5. Load WAL entries (errors propagate - do not silently fallback)
236
+ const walEntries = yield* hotStorage.getEntries(documentId, initialVersion);
237
+
238
+ // 6. Verify WAL continuity (warning only, non-blocking)
239
+ if (walEntries.length > 0) {
240
+ const firstWalVersion = walEntries[0]!.version;
241
+ const expectedFirst = initialVersion + 1;
242
+
243
+ if (firstWalVersion !== expectedFirst) {
244
+ yield* Effect.logWarning("WAL version gap detected", {
245
+ documentId,
246
+ snapshotVersion: initialVersion,
247
+ firstWalVersion,
248
+ expectedFirst,
249
+ });
250
+ yield* Metric.increment(Metrics.storageVersionGaps);
251
+ }
252
+
253
+ // Check internal gaps
254
+ for (let i = 1; i < walEntries.length; i++) {
255
+ const prev = walEntries[i - 1]!.version;
256
+ const curr = walEntries[i]!.version;
257
+ if (curr !== prev + 1) {
258
+ yield* Effect.logWarning("WAL internal gap detected", {
259
+ documentId,
260
+ previousVersion: prev,
261
+ currentVersion: curr,
262
+ });
263
+ }
264
+ }
265
+ }
266
+
267
+ // 7. Replay WAL entries
268
+ for (const entry of walEntries) {
269
+ const result = document.submit(entry.transaction);
270
+ if (!result.success) {
271
+ yield* Effect.logWarning("Skipping corrupted WAL entry", {
272
+ documentId,
273
+ version: entry.version,
274
+ reason: result.reason,
275
+ });
276
+ }
277
+ }
278
+
279
+ const instance: DocumentInstance<typeof config.schema> = {
280
+ document,
281
+ pubsub,
282
+ lastSnapshotVersion,
283
+ lastSnapshotTime,
284
+ transactionsSinceSnapshot,
285
+ lastActivityTime,
286
+ };
287
+
288
+ // Track metrics - determine if restored or created
289
+ if (storedDoc) {
290
+ yield* Metric.increment(Metrics.documentsRestored);
291
+ } else {
292
+ yield* Metric.increment(Metrics.documentsCreated);
293
+ }
294
+ yield* Metric.incrementBy(Metrics.documentsActive, 1);
295
+
296
+ return instance;
166
297
  });
167
298
 
168
- const refCount = yield* Ref.make(1);
299
+ /**
300
+ * Get or create a document instance
301
+ */
302
+ const getOrCreateDocument = (
303
+ documentId: string
304
+ ): Effect.Effect<DocumentInstance<typeof config.schema>, ColdStorageError | HotStorageError> =>
305
+ Effect.gen(function* () {
306
+ const current = yield* Ref.get(store);
307
+ const existing = HashMap.get(current, documentId);
169
308
 
170
- const instance: DocumentInstance = {
171
- document: serverDocument,
172
- pubsub,
173
- refCount,
174
- };
309
+ if (existing._tag === "Some") {
310
+ // Update activity time
311
+ yield* Ref.set(existing.value.lastActivityTime, Date.now());
312
+ return existing.value as DocumentInstance<typeof config.schema>;
313
+ }
175
314
 
176
- // Store in map
177
- yield* Ref.update(documents, (map) =>
178
- HashMap.set(map, documentId, instance)
179
- );
315
+ // Restore document
316
+ const instance = yield* restoreDocument(documentId);
180
317
 
181
- return instance;
182
- });
318
+ // Store it
319
+ yield* Ref.update(store, (map) =>
320
+ HashMap.set(map, documentId, instance)
321
+ );
183
322
 
184
- // Submit a transaction
185
- const submit = (
186
- documentId: string,
187
- transaction: Transaction.Transaction
188
- ): Effect.Effect<Protocol.SubmitResult> =>
189
- Effect.gen(function* () {
190
- const instance = yield* getOrCreateDocument(documentId);
191
- const result = instance.document.submit(transaction);
192
- return result;
193
- });
323
+ return instance;
324
+ });
194
325
 
195
- // Get a snapshot
196
- const getSnapshot = (
197
- documentId: string
198
- ): Effect.Effect<Protocol.SnapshotMessage> =>
199
- Effect.gen(function* () {
200
- const instance = yield* getOrCreateDocument(documentId);
201
- const snapshot = instance.document.getSnapshot();
202
- return snapshot;
326
+ /**
327
+ * Save a snapshot to ColdStorage derived from WAL entries.
328
+ * This ensures snapshots are always based on durable WAL data.
329
+ * Idempotent: skips save if already snapshotted at target version.
330
+ * Truncate failures are non-fatal and will be retried on next snapshot.
331
+ */
332
+ const saveSnapshot = (
333
+ documentId: string,
334
+ instance: DocumentInstance<typeof config.schema>,
335
+ targetVersion: number
336
+ ): Effect.Effect<void, ColdStorageError | HotStorageError> =>
337
+ Effect.gen(function* () {
338
+ const lastSnapshotVersion = yield* Ref.get(instance.lastSnapshotVersion);
339
+
340
+ // Idempotency check: skip if already snapshotted at this version
341
+ if (targetVersion <= lastSnapshotVersion) {
342
+ return;
343
+ }
344
+
345
+ const snapshotStartTime = Date.now();
346
+
347
+ // Load base snapshot from cold storage
348
+ const baseSnapshot = yield* coldStorage.load(documentId);
349
+ const baseVersion = baseSnapshot?.version ?? 0;
350
+ const baseState = baseSnapshot?.state as Primitive.InferState<typeof config.schema> | undefined;
351
+
352
+ // Load WAL entries from base to target
353
+ const walEntries = yield* hotStorage.getEntries(documentId, baseVersion);
354
+ const relevantEntries = walEntries.filter(e => e.version <= targetVersion);
355
+
356
+ if (relevantEntries.length === 0 && !baseSnapshot) {
357
+ // Nothing to snapshot
358
+ return;
359
+ }
360
+
361
+ // Rebuild state by replaying WAL on base
362
+ let snapshotState: Primitive.InferState<typeof config.schema> | undefined = baseState;
363
+ for (const entry of relevantEntries) {
364
+ // Create a temporary document to apply the transaction
365
+ const tempDoc = Document.make(config.schema, { initialState: snapshotState });
366
+ tempDoc.apply(entry.transaction.ops);
367
+ snapshotState = tempDoc.get();
368
+ }
369
+
370
+ if (snapshotState === undefined) {
371
+ return;
372
+ }
373
+
374
+ const snapshotVersion = relevantEntries.length > 0
375
+ ? relevantEntries[relevantEntries.length - 1]!.version
376
+ : baseVersion;
377
+
378
+ // Re-check before saving (in case another snapshot completed while we were working)
379
+ // This prevents a slower snapshot from overwriting a more recent one
380
+ const currentLastSnapshot = yield* Ref.get(instance.lastSnapshotVersion);
381
+ if (snapshotVersion <= currentLastSnapshot) {
382
+ return;
383
+ }
384
+
385
+ const storedDoc: StoredDocument = {
386
+ state: snapshotState,
387
+ version: snapshotVersion,
388
+ schemaVersion: SCHEMA_VERSION,
389
+ savedAt: Date.now(),
390
+ };
391
+
392
+ // Save to ColdStorage - let errors propagate
393
+ yield* coldStorage.save(documentId, storedDoc);
394
+
395
+ // Track snapshot metrics
396
+ const snapshotDuration = Date.now() - snapshotStartTime;
397
+ yield* Metric.increment(Metrics.storageSnapshots);
398
+ yield* Metric.update(Metrics.storageSnapshotLatency, snapshotDuration);
399
+
400
+ // Update tracking BEFORE truncate (for idempotency on retry)
401
+ yield* Ref.set(instance.lastSnapshotVersion, snapshotVersion);
402
+ yield* Ref.set(instance.lastSnapshotTime, Date.now());
403
+ yield* Ref.set(instance.transactionsSinceSnapshot, 0);
404
+
405
+ // Truncate WAL - non-fatal, will be retried on next snapshot
406
+ yield* Effect.catchAll(hotStorage.truncate(documentId, snapshotVersion), (e) =>
407
+ Effect.logWarning("WAL truncate failed - will retry on next snapshot", {
408
+ documentId,
409
+ version: snapshotVersion,
410
+ error: e,
411
+ })
412
+ );
413
+ });
414
+
415
+ /**
416
+ * Check if snapshot should be triggered
417
+ */
418
+ const checkSnapshotTriggers = (
419
+ documentId: string,
420
+ instance: DocumentInstance<typeof config.schema>
421
+ ): Effect.Effect<void, ColdStorageError | HotStorageError> =>
422
+ Effect.gen(function* () {
423
+ const txCount = yield* Ref.get(instance.transactionsSinceSnapshot);
424
+ const lastTime = yield* Ref.get(instance.lastSnapshotTime);
425
+ const now = Date.now();
426
+ const currentVersion = instance.document.getVersion();
427
+
428
+ const intervalMs = Duration.toMillis(config.snapshot.interval);
429
+ const threshold = config.snapshot.transactionThreshold;
430
+
431
+ // Check transaction threshold
432
+ if (txCount >= threshold) {
433
+ yield* saveSnapshot(documentId, instance, currentVersion);
434
+ return;
435
+ }
436
+
437
+ // Check time interval
438
+ if (now - lastTime >= intervalMs) {
439
+ yield* saveSnapshot(documentId, instance, currentVersion);
440
+ return;
441
+ }
442
+ });
443
+
444
+ /**
445
+ * Start background GC fiber
446
+ */
447
+ const startGCFiber = Effect.gen(function* () {
448
+ const gcLoop = Effect.gen(function* () {
449
+ const current = yield* Ref.get(store);
450
+ const now = Date.now();
451
+ const maxIdleMs = Duration.toMillis(config.maxIdleTime);
452
+
453
+ for (const [documentId, instance] of current) {
454
+ const lastActivity = yield* Ref.get(instance.lastActivityTime);
455
+ if (now - lastActivity >= maxIdleMs) {
456
+ // Save final snapshot before eviction (best effort)
457
+ const currentVersion = instance.document.getVersion();
458
+ yield* Effect.catchAll(saveSnapshot(documentId, instance, currentVersion), (e) =>
459
+ Effect.logError("Failed to save snapshot during eviction", {
460
+ documentId,
461
+ error: e,
462
+ })
463
+ );
464
+
465
+ // Remove from store
466
+ yield* Ref.update(store, (map) => HashMap.remove(map, documentId));
467
+
468
+ // Track eviction metrics
469
+ yield* Metric.increment(Metrics.documentsEvicted);
470
+ yield* Metric.incrementBy(Metrics.documentsActive, -1);
471
+
472
+ yield* Effect.logInfo("Document evicted due to idle timeout", {
473
+ documentId,
474
+ });
475
+ }
476
+ }
477
+ });
478
+
479
+ // Run GC every minute
480
+ yield* gcLoop.pipe(
481
+ Effect.repeat(Schedule.spaced("1 minute")),
482
+ Effect.fork
483
+ );
203
484
  });
204
485
 
205
- // Subscribe to broadcasts
206
- const subscribe = (
207
- documentId: string
208
- ): Effect.Effect<
209
- Stream.Stream<Protocol.ServerBroadcast>,
210
- never,
211
- Scope.Scope
212
- > =>
213
- Effect.gen(function* () {
214
- const instance = yield* getOrCreateDocument(documentId);
215
-
216
- // Subscribe to the PubSub
217
- const queue = yield* PubSub.subscribe(instance.pubsub);
218
-
219
- // Ensure cleanup on scope close
220
- yield* Effect.addFinalizer(() =>
486
+ // Start GC fiber
487
+ yield* startGCFiber;
488
+
489
+ // Cleanup on shutdown
490
+ yield* Effect.addFinalizer(() =>
491
+ Effect.gen(function* () {
492
+ const current = yield* Ref.get(store);
493
+ for (const [documentId, instance] of current) {
494
+ // Best effort save - don't fail shutdown if storage is unavailable
495
+ const currentVersion = instance.document.getVersion();
496
+ yield* Effect.catchAll(saveSnapshot(documentId, instance, currentVersion), (e) =>
497
+ Effect.logError("Failed to save snapshot during shutdown", {
498
+ documentId,
499
+ error: e,
500
+ })
501
+ );
502
+ }
503
+ yield* Effect.logInfo("DocumentManager shutdown complete");
504
+ })
505
+ );
506
+
507
+ return {
508
+ submit: (documentId, transaction) =>
221
509
  Effect.gen(function* () {
222
- // Decrement ref count
223
- const count = yield* Ref.updateAndGet(
224
- instance.refCount,
225
- (n) => n - 1
510
+ const instance = yield* getOrCreateDocument(documentId);
511
+ const submitStartTime = Date.now();
512
+
513
+ // Phase 1: Validate (no side effects)
514
+ const validation = instance.document.validate(transaction);
515
+
516
+ if (!validation.valid) {
517
+ // Track rejection
518
+ yield* Metric.increment(Metrics.transactionsRejected);
519
+ const latency = Date.now() - submitStartTime;
520
+ yield* Metric.update(Metrics.transactionsLatency, latency);
521
+
522
+ return {
523
+ success: false as const,
524
+ reason: validation.reason,
525
+ };
526
+ }
527
+
528
+ // Phase 2: Append to WAL with gap check (BEFORE state mutation)
529
+ const walEntry: WalEntry = {
530
+ transaction,
531
+ version: validation.nextVersion,
532
+ timestamp: Date.now(),
533
+ };
534
+
535
+ const appendResult = yield* Effect.either(
536
+ hotStorage.appendWithCheck(documentId, walEntry, validation.nextVersion)
226
537
  );
227
538
 
228
- // If no more subscribers, we could clean up the document
229
- // For now, we keep it alive (could add idle timeout)
230
- })
231
- );
539
+ if (appendResult._tag === "Left") {
540
+ // WAL append failed - do NOT apply, state unchanged
541
+ yield* Effect.logError("WAL append failed", {
542
+ documentId,
543
+ version: validation.nextVersion,
544
+ error: appendResult.left,
545
+ });
546
+ yield* Metric.increment(Metrics.walAppendFailures);
232
547
 
233
- // Convert queue to stream
234
- return Stream.fromQueue(queue);
235
- });
548
+ const latency = Date.now() - submitStartTime;
549
+ yield* Metric.update(Metrics.transactionsLatency, latency);
236
550
 
237
- const manager: DocumentManager = {
238
- submit,
239
- getSnapshot,
240
- subscribe,
241
- };
551
+ // Return failure - client must retry
552
+ return {
553
+ success: false as const,
554
+ reason: "Storage unavailable. Please retry.",
555
+ };
556
+ }
242
557
 
243
- return manager;
244
- });
558
+ // Phase 3: Apply (state mutation + broadcast)
559
+ instance.document.apply(transaction);
245
560
 
246
- /**
247
- * Layer that provides DocumentManager.
248
- * Requires MimicServerConfigTag and MimicDataStorageTag.
249
- */
250
- export const layer: Layer.Layer<
251
- DocumentManagerTag,
252
- never,
253
- MimicServerConfigTag | MimicDataStorageTag
254
- > = Layer.effect(DocumentManagerTag, makeDocumentManager);
561
+ // Track metrics
562
+ const latency = Date.now() - submitStartTime;
563
+ yield* Metric.update(Metrics.transactionsLatency, latency);
564
+ yield* Metric.increment(Metrics.transactionsProcessed);
565
+ yield* Metric.increment(Metrics.storageWalAppends);
566
+
567
+ // Increment transaction count
568
+ yield* Ref.update(
569
+ instance.transactionsSinceSnapshot,
570
+ (n) => n + 1
571
+ );
572
+
573
+ // Check snapshot triggers
574
+ yield* checkSnapshotTriggers(documentId, instance);
575
+
576
+ return {
577
+ success: true as const,
578
+ version: validation.nextVersion,
579
+ };
580
+ }),
581
+
582
+ getSnapshot: (documentId) =>
583
+ Effect.gen(function* () {
584
+ const instance = yield* getOrCreateDocument(documentId);
585
+ return instance.document.getSnapshot();
586
+ }),
587
+
588
+ subscribe: (documentId) =>
589
+ Effect.gen(function* () {
590
+ const instance = yield* getOrCreateDocument(documentId);
591
+ return Stream.fromPubSub(instance.pubsub);
592
+ }),
593
+
594
+ touch: (documentId) =>
595
+ Effect.gen(function* () {
596
+ const current = yield* Ref.get(store);
597
+ const existing = HashMap.get(current, documentId);
598
+ if (existing._tag === "Some") {
599
+ yield* Ref.set(existing.value.lastActivityTime, Date.now());
600
+ }
601
+ }),
602
+ };
603
+ })
604
+ );
605
+
606
+ // =============================================================================
607
+ // Re-export namespace
608
+ // =============================================================================
609
+
610
+ export const DocumentManager = {
611
+ Tag: DocumentManagerTag,
612
+ ConfigTag: DocumentManagerConfigTag,
613
+ layer,
614
+ };