@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
@@ -0,0 +1,504 @@
1
+ /**
2
+ * @voidhash/mimic-effect/testing - StorageIntegrationTestSuite
3
+ *
4
+ * Integration tests for verifying Hot/Cold storage coordination.
5
+ * Tests snapshot + WAL replay, failure handling, and version verification.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { StorageIntegrationTestSuite } from "@voidhash/mimic-effect/testing";
10
+ * import { describe, it } from "vitest";
11
+ * import { Effect, Layer } from "effect";
12
+ * import { ColdStorage, HotStorage } from "@voidhash/mimic-effect";
13
+ *
14
+ * describe("Storage Integration", () => {
15
+ * const layer = Layer.mergeAll(
16
+ * ColdStorage.InMemory.make(),
17
+ * HotStorage.InMemory.make()
18
+ * );
19
+ *
20
+ * for (const test of StorageIntegrationTestSuite.makeTests()) {
21
+ * it(test.name, () =>
22
+ * Effect.runPromise(test.run.pipe(Effect.provide(layer)))
23
+ * );
24
+ * }
25
+ * });
26
+ * ```
27
+ */
28
+ import { Effect } from "effect";
29
+ import { ColdStorageTag } from "../ColdStorage";
30
+ import { HotStorageTag } from "../HotStorage";
31
+ import { ColdStorageError, HotStorageError } from "../Errors";
32
+ import type { StoredDocument, WalEntry } from "../Types";
33
+ import type { StorageTestCase } from "./types";
34
+ import {
35
+ assertEqual,
36
+ assertTrue,
37
+ assertLength,
38
+ assertEmpty,
39
+ assertDefined,
40
+ assertUndefined,
41
+ } from "./assertions";
42
+
43
+ // =============================================================================
44
+ // Test Categories
45
+ // =============================================================================
46
+
47
+ export const Categories = {
48
+ SNAPSHOT_WAL_COORDINATION: "Snapshot + WAL Coordination",
49
+ VERSION_VERIFICATION: "Version Verification",
50
+ RECOVERY_SCENARIOS: "Recovery Scenarios",
51
+ } as const;
52
+
53
+ // =============================================================================
54
+ // Test Helpers
55
+ // =============================================================================
56
+
57
+ const makeSnapshot = (
58
+ version: number,
59
+ state: unknown = { data: `v${version}` }
60
+ ): StoredDocument => ({
61
+ state,
62
+ version,
63
+ schemaVersion: 1,
64
+ savedAt: Date.now(),
65
+ });
66
+
67
+ const makeWalEntry = (
68
+ version: number,
69
+ ops: unknown[] = [{ type: "set", path: ["data"], value: `v${version}` }]
70
+ ): WalEntry => ({
71
+ transaction: {
72
+ id: `tx-${version}`,
73
+ ops,
74
+ timestamp: Date.now(),
75
+ },
76
+ version,
77
+ timestamp: Date.now(),
78
+ });
79
+
80
+ // =============================================================================
81
+ // Test Definitions
82
+ // =============================================================================
83
+
84
+ type IntegrationTestCase = StorageTestCase<ColdStorageError | HotStorageError, ColdStorageTag | HotStorageTag>;
85
+
86
+ const snapshotWalCoordinationTests: IntegrationTestCase[] = [
87
+ {
88
+ name: "load empty document returns undefined snapshot and empty WAL",
89
+ category: Categories.SNAPSHOT_WAL_COORDINATION,
90
+ run: Effect.gen(function* () {
91
+ const cold = yield* ColdStorageTag;
92
+ const hot = yield* HotStorageTag;
93
+
94
+ const snapshot = yield* cold.load("empty-doc");
95
+ const walEntries = yield* hot.getEntries("empty-doc", 0);
96
+
97
+ assertUndefined(snapshot, "Snapshot should be undefined for new doc");
98
+ assertEmpty(walEntries, "WAL should be empty for new doc");
99
+ }),
100
+ },
101
+
102
+ {
103
+ name: "restore from snapshot only (no WAL)",
104
+ category: Categories.SNAPSHOT_WAL_COORDINATION,
105
+ run: Effect.gen(function* () {
106
+ const cold = yield* ColdStorageTag;
107
+ const hot = yield* HotStorageTag;
108
+
109
+ const docId = "snapshot-only";
110
+ const snapshot = makeSnapshot(5, { title: "Hello" });
111
+
112
+ yield* cold.save(docId, snapshot);
113
+
114
+ const loaded = yield* cold.load(docId);
115
+ const walEntries = yield* hot.getEntries(docId, 5);
116
+
117
+ assertDefined(loaded, "Snapshot should be loaded");
118
+ assertEqual(loaded!.version, 5, "Snapshot version should match");
119
+ assertEqual(loaded!.state, { title: "Hello" }, "Snapshot state should match");
120
+ assertEmpty(walEntries, "WAL should be empty after snapshot version");
121
+ }),
122
+ },
123
+
124
+ {
125
+ name: "restore from WAL only (no snapshot)",
126
+ category: Categories.SNAPSHOT_WAL_COORDINATION,
127
+ run: Effect.gen(function* () {
128
+ const cold = yield* ColdStorageTag;
129
+ const hot = yield* HotStorageTag;
130
+
131
+ const docId = "wal-only";
132
+
133
+ yield* hot.append(docId, makeWalEntry(1));
134
+ yield* hot.append(docId, makeWalEntry(2));
135
+ yield* hot.append(docId, makeWalEntry(3));
136
+
137
+ const snapshot = yield* cold.load(docId);
138
+ const walEntries = yield* hot.getEntries(docId, 0);
139
+
140
+ assertUndefined(snapshot, "No snapshot should exist");
141
+ assertLength(walEntries, 3, "Should have 3 WAL entries");
142
+ assertEqual(walEntries[0]!.version, 1, "First entry should be v1");
143
+ assertEqual(walEntries[2]!.version, 3, "Last entry should be v3");
144
+ }),
145
+ },
146
+
147
+ {
148
+ name: "restore from snapshot + WAL replay",
149
+ category: Categories.SNAPSHOT_WAL_COORDINATION,
150
+ run: Effect.gen(function* () {
151
+ const cold = yield* ColdStorageTag;
152
+ const hot = yield* HotStorageTag;
153
+
154
+ const docId = "snapshot-plus-wal";
155
+
156
+ // Save snapshot at v5
157
+ yield* cold.save(docId, makeSnapshot(5));
158
+
159
+ // Add WAL entries for v6, v7, v8
160
+ yield* hot.append(docId, makeWalEntry(6));
161
+ yield* hot.append(docId, makeWalEntry(7));
162
+ yield* hot.append(docId, makeWalEntry(8));
163
+
164
+ const snapshot = yield* cold.load(docId);
165
+ const walEntries = yield* hot.getEntries(docId, snapshot!.version);
166
+
167
+ assertEqual(snapshot!.version, 5, "Snapshot at v5");
168
+ assertLength(walEntries, 3, "3 WAL entries after snapshot");
169
+ assertEqual(walEntries[0]!.version, 6, "First WAL entry is v6");
170
+ assertEqual(walEntries[2]!.version, 8, "Last WAL entry is v8");
171
+ }),
172
+ },
173
+
174
+ {
175
+ name: "truncate WAL after snapshot",
176
+ category: Categories.SNAPSHOT_WAL_COORDINATION,
177
+ run: Effect.gen(function* () {
178
+ const cold = yield* ColdStorageTag;
179
+ const hot = yield* HotStorageTag;
180
+
181
+ const docId = "truncate-test";
182
+
183
+ // Add WAL entries 1-5
184
+ for (let i = 1; i <= 5; i++) {
185
+ yield* hot.append(docId, makeWalEntry(i));
186
+ }
187
+
188
+ // Save snapshot at v3
189
+ yield* cold.save(docId, makeSnapshot(3));
190
+
191
+ // Truncate WAL up to v3
192
+ yield* hot.truncate(docId, 3);
193
+
194
+ const walEntries = yield* hot.getEntries(docId, 0);
195
+
196
+ assertLength(walEntries, 2, "Only v4 and v5 should remain");
197
+ assertEqual(walEntries[0]!.version, 4, "First remaining is v4");
198
+ assertEqual(walEntries[1]!.version, 5, "Last remaining is v5");
199
+ }),
200
+ },
201
+
202
+ {
203
+ name: "snapshot overwrites previous snapshot",
204
+ category: Categories.SNAPSHOT_WAL_COORDINATION,
205
+ run: Effect.gen(function* () {
206
+ const cold = yield* ColdStorageTag;
207
+
208
+ const docId = "overwrite-test";
209
+
210
+ yield* cold.save(docId, makeSnapshot(1, { old: true }));
211
+ yield* cold.save(docId, makeSnapshot(5, { new: true }));
212
+
213
+ const loaded = yield* cold.load(docId);
214
+
215
+ assertEqual(loaded!.version, 5, "Should have newer version");
216
+ assertEqual(loaded!.state, { new: true }, "Should have newer state");
217
+ }),
218
+ },
219
+ ];
220
+
221
+ const versionVerificationTests: IntegrationTestCase[] = [
222
+ {
223
+ name: "WAL entries are ordered by version",
224
+ category: Categories.VERSION_VERIFICATION,
225
+ run: Effect.gen(function* () {
226
+ const hot = yield* HotStorageTag;
227
+
228
+ const docId = "ordering-test";
229
+
230
+ // Append out of order
231
+ yield* hot.append(docId, makeWalEntry(3));
232
+ yield* hot.append(docId, makeWalEntry(1));
233
+ yield* hot.append(docId, makeWalEntry(2));
234
+
235
+ const entries = yield* hot.getEntries(docId, 0);
236
+
237
+ assertLength(entries, 3, "All entries should be returned");
238
+ assertEqual(entries[0]!.version, 1, "First should be v1");
239
+ assertEqual(entries[1]!.version, 2, "Second should be v2");
240
+ assertEqual(entries[2]!.version, 3, "Third should be v3");
241
+ }),
242
+ },
243
+
244
+ {
245
+ name: "getEntries filters by sinceVersion correctly",
246
+ category: Categories.VERSION_VERIFICATION,
247
+ run: Effect.gen(function* () {
248
+ const hot = yield* HotStorageTag;
249
+
250
+ const docId = "filter-test";
251
+
252
+ for (let i = 1; i <= 10; i++) {
253
+ yield* hot.append(docId, makeWalEntry(i));
254
+ }
255
+
256
+ const fromV5 = yield* hot.getEntries(docId, 5);
257
+ const fromV8 = yield* hot.getEntries(docId, 8);
258
+ const fromV10 = yield* hot.getEntries(docId, 10);
259
+
260
+ assertLength(fromV5, 5, "v6-v10 = 5 entries");
261
+ assertEqual(fromV5[0]!.version, 6, "First entry after v5 is v6");
262
+
263
+ assertLength(fromV8, 2, "v9-v10 = 2 entries");
264
+ assertEqual(fromV8[0]!.version, 9, "First entry after v8 is v9");
265
+
266
+ assertEmpty(fromV10, "No entries after v10");
267
+ }),
268
+ },
269
+
270
+ {
271
+ name: "detect version gap between snapshot and WAL",
272
+ category: Categories.VERSION_VERIFICATION,
273
+ run: Effect.gen(function* () {
274
+ const cold = yield* ColdStorageTag;
275
+ const hot = yield* HotStorageTag;
276
+
277
+ const docId = "gap-detection";
278
+
279
+ // Snapshot at v5
280
+ yield* cold.save(docId, makeSnapshot(5));
281
+
282
+ // WAL starts at v7 (gap: v6 missing)
283
+ yield* hot.append(docId, makeWalEntry(7));
284
+ yield* hot.append(docId, makeWalEntry(8));
285
+
286
+ const snapshot = yield* cold.load(docId);
287
+ const walEntries = yield* hot.getEntries(docId, snapshot!.version);
288
+
289
+ assertEqual(snapshot!.version, 5, "Snapshot at v5");
290
+ assertLength(walEntries, 2, "Two WAL entries");
291
+
292
+ // Gap detection: first WAL entry should be v6, but it's v7
293
+ const firstWalVersion = walEntries[0]!.version;
294
+ const expectedFirst = snapshot!.version + 1;
295
+ const hasGap = firstWalVersion !== expectedFirst;
296
+
297
+ assertTrue(hasGap, "Should detect gap (v7 != v6)");
298
+ }),
299
+ },
300
+
301
+ {
302
+ name: "detect internal WAL gaps",
303
+ category: Categories.VERSION_VERIFICATION,
304
+ run: Effect.gen(function* () {
305
+ const hot = yield* HotStorageTag;
306
+
307
+ const docId = "internal-gap";
308
+
309
+ yield* hot.append(docId, makeWalEntry(1));
310
+ yield* hot.append(docId, makeWalEntry(2));
311
+ // Skip v3
312
+ yield* hot.append(docId, makeWalEntry(4));
313
+ yield* hot.append(docId, makeWalEntry(5));
314
+
315
+ const entries = yield* hot.getEntries(docId, 0);
316
+
317
+ // Check for internal gaps
318
+ let gapFound = false;
319
+ for (let i = 1; i < entries.length; i++) {
320
+ const prev = entries[i - 1]!.version;
321
+ const curr = entries[i]!.version;
322
+ if (curr !== prev + 1) {
323
+ gapFound = true;
324
+ break;
325
+ }
326
+ }
327
+
328
+ assertTrue(gapFound, "Should detect internal gap between v2 and v4");
329
+ }),
330
+ },
331
+
332
+ {
333
+ name: "no gap when WAL is continuous",
334
+ category: Categories.VERSION_VERIFICATION,
335
+ run: Effect.gen(function* () {
336
+ const cold = yield* ColdStorageTag;
337
+ const hot = yield* HotStorageTag;
338
+
339
+ const docId = "no-gap";
340
+
341
+ yield* cold.save(docId, makeSnapshot(5));
342
+ yield* hot.append(docId, makeWalEntry(6));
343
+ yield* hot.append(docId, makeWalEntry(7));
344
+ yield* hot.append(docId, makeWalEntry(8));
345
+
346
+ const snapshot = yield* cold.load(docId);
347
+ const walEntries = yield* hot.getEntries(docId, snapshot!.version);
348
+
349
+ const firstWalVersion = walEntries[0]!.version;
350
+ const expectedFirst = snapshot!.version + 1;
351
+ const hasGap = firstWalVersion !== expectedFirst;
352
+
353
+ assertTrue(!hasGap, "Should not detect gap (v6 == v6)");
354
+ }),
355
+ },
356
+ ];
357
+
358
+ const recoveryScenarioTests: IntegrationTestCase[] = [
359
+ {
360
+ name: "full recovery: snapshot + WAL + new transactions",
361
+ category: Categories.RECOVERY_SCENARIOS,
362
+ run: Effect.gen(function* () {
363
+ const cold = yield* ColdStorageTag;
364
+ const hot = yield* HotStorageTag;
365
+
366
+ const docId = "full-recovery";
367
+
368
+ // Initial state: snapshot at v3, WAL v4-v5
369
+ yield* cold.save(docId, makeSnapshot(3, { count: 3 }));
370
+ yield* hot.append(docId, makeWalEntry(4));
371
+ yield* hot.append(docId, makeWalEntry(5));
372
+
373
+ // "Recovery" - load snapshot and WAL
374
+ const snapshot = yield* cold.load(docId);
375
+ const walEntries = yield* hot.getEntries(docId, snapshot!.version);
376
+
377
+ assertEqual(snapshot!.version, 3, "Snapshot version");
378
+ assertLength(walEntries, 2, "WAL entries to replay");
379
+
380
+ // Simulate new transaction after recovery
381
+ yield* hot.append(docId, makeWalEntry(6));
382
+
383
+ const newWal = yield* hot.getEntries(docId, 5);
384
+ assertLength(newWal, 1, "One new entry after recovery");
385
+ assertEqual(newWal[0]!.version, 6, "New entry is v6");
386
+ }),
387
+ },
388
+
389
+ {
390
+ name: "recovery from only WAL (cold start)",
391
+ category: Categories.RECOVERY_SCENARIOS,
392
+ run: Effect.gen(function* () {
393
+ const cold = yield* ColdStorageTag;
394
+ const hot = yield* HotStorageTag;
395
+
396
+ const docId = "cold-start";
397
+
398
+ // Only WAL entries, no snapshot (new document that hasn't been snapshotted)
399
+ yield* hot.append(docId, makeWalEntry(1));
400
+ yield* hot.append(docId, makeWalEntry(2));
401
+
402
+ const snapshot = yield* cold.load(docId);
403
+ const walEntries = yield* hot.getEntries(docId, 0);
404
+
405
+ assertUndefined(snapshot, "No snapshot exists");
406
+ assertLength(walEntries, 2, "All WAL entries from beginning");
407
+ }),
408
+ },
409
+
410
+ {
411
+ name: "recovery after truncation failure (WAL has old entries)",
412
+ category: Categories.RECOVERY_SCENARIOS,
413
+ run: Effect.gen(function* () {
414
+ const cold = yield* ColdStorageTag;
415
+ const hot = yield* HotStorageTag;
416
+
417
+ const docId = "truncate-failed";
418
+
419
+ // Simulate: snapshot saved at v5, but truncate failed
420
+ // So WAL still has v3, v4, v5, v6
421
+ yield* hot.append(docId, makeWalEntry(3));
422
+ yield* hot.append(docId, makeWalEntry(4));
423
+ yield* hot.append(docId, makeWalEntry(5));
424
+ yield* hot.append(docId, makeWalEntry(6));
425
+
426
+ yield* cold.save(docId, makeSnapshot(5));
427
+
428
+ // Recovery should only replay v6
429
+ const snapshot = yield* cold.load(docId);
430
+ const walEntries = yield* hot.getEntries(docId, snapshot!.version);
431
+
432
+ assertEqual(snapshot!.version, 5, "Snapshot at v5");
433
+ assertLength(walEntries, 1, "Only v6 should be replayed");
434
+ assertEqual(walEntries[0]!.version, 6, "Entry is v6");
435
+ }),
436
+ },
437
+
438
+ {
439
+ name: "idempotent snapshot save",
440
+ category: Categories.RECOVERY_SCENARIOS,
441
+ run: Effect.gen(function* () {
442
+ const cold = yield* ColdStorageTag;
443
+
444
+ const docId = "idempotent";
445
+
446
+ const snapshot1 = makeSnapshot(5, { first: true });
447
+ const snapshot2 = makeSnapshot(5, { second: true });
448
+
449
+ yield* cold.save(docId, snapshot1);
450
+ yield* cold.save(docId, snapshot2);
451
+
452
+ const loaded = yield* cold.load(docId);
453
+
454
+ // Last write wins
455
+ assertEqual(loaded!.state, { second: true }, "Second save overwrites");
456
+ }),
457
+ },
458
+ ];
459
+
460
+ // =============================================================================
461
+ // Test Suite Export
462
+ // =============================================================================
463
+
464
+ /**
465
+ * Generate all integration test cases
466
+ */
467
+ export const makeTests = (): IntegrationTestCase[] => [
468
+ ...snapshotWalCoordinationTests,
469
+ ...versionVerificationTests,
470
+ ...recoveryScenarioTests,
471
+ ];
472
+
473
+ /**
474
+ * Run all integration tests and collect results
475
+ */
476
+ export const runAll = <R>(
477
+ layer: import("effect").Layer.Layer<ColdStorageTag | HotStorageTag, never, R>
478
+ ) =>
479
+ Effect.gen(function* () {
480
+ const tests = makeTests();
481
+ const results: Array<{ name: string; passed: boolean; error?: unknown }> = [];
482
+
483
+ for (const test of tests) {
484
+ const result = yield* Effect.either(test.run.pipe(Effect.provide(layer)));
485
+
486
+ if (result._tag === "Right") {
487
+ results.push({ name: test.name, passed: true });
488
+ } else {
489
+ results.push({ name: test.name, passed: false, error: result.left });
490
+ }
491
+ }
492
+
493
+ return results;
494
+ });
495
+
496
+ // =============================================================================
497
+ // Export Namespace
498
+ // =============================================================================
499
+
500
+ export const StorageIntegrationTestSuite = {
501
+ Categories,
502
+ makeTests,
503
+ runAll,
504
+ };
@@ -0,0 +1,181 @@
1
+ /**
2
+ * @voidhash/mimic-effect/testing - Assertion Helpers
3
+ *
4
+ * Internal assertion helpers used by the test suites.
5
+ */
6
+ import { Effect } from "effect";
7
+ import { TestError } from "./types";
8
+
9
+ // =============================================================================
10
+ // Deep Equality
11
+ // =============================================================================
12
+
13
+ /**
14
+ * Deep equality check that handles objects, arrays, and primitives.
15
+ */
16
+ export const isDeepEqual = (a: unknown, b: unknown): boolean => {
17
+ if (a === b) return true;
18
+
19
+ if (a === null || b === null) return a === b;
20
+ if (a === undefined || b === undefined) return a === b;
21
+
22
+ if (typeof a !== typeof b) return false;
23
+
24
+ if (typeof a === "number" && typeof b === "number") {
25
+ if (Number.isNaN(a) && Number.isNaN(b)) return true;
26
+ return a === b;
27
+ }
28
+
29
+ if (Array.isArray(a) && Array.isArray(b)) {
30
+ if (a.length !== b.length) return false;
31
+ for (let i = 0; i < a.length; i++) {
32
+ if (!isDeepEqual(a[i], b[i])) return false;
33
+ }
34
+ return true;
35
+ }
36
+
37
+ if (typeof a === "object" && typeof b === "object") {
38
+ const aObj = a as Record<string, unknown>;
39
+ const bObj = b as Record<string, unknown>;
40
+ const aKeys = Object.keys(aObj);
41
+ const bKeys = Object.keys(bObj);
42
+
43
+ if (aKeys.length !== bKeys.length) return false;
44
+
45
+ for (const key of aKeys) {
46
+ if (!Object.prototype.hasOwnProperty.call(bObj, key)) return false;
47
+ if (!isDeepEqual(aObj[key], bObj[key])) return false;
48
+ }
49
+
50
+ return true;
51
+ }
52
+
53
+ return false;
54
+ };
55
+
56
+ // =============================================================================
57
+ // Assertion Helpers
58
+ // =============================================================================
59
+
60
+ /**
61
+ * Assert that two values are deeply equal.
62
+ */
63
+ export const assertEqual = <T>(
64
+ actual: T,
65
+ expected: T,
66
+ message: string
67
+ ): Effect.Effect<void, TestError> =>
68
+ Effect.gen(function* () {
69
+ if (!isDeepEqual(actual, expected)) {
70
+ yield* Effect.fail(new TestError({ message, expected, actual }));
71
+ }
72
+ });
73
+
74
+ /**
75
+ * Assert that a condition is true.
76
+ */
77
+ export const assertTrue = (
78
+ condition: boolean,
79
+ message: string
80
+ ): Effect.Effect<void, TestError> =>
81
+ Effect.gen(function* () {
82
+ if (!condition) {
83
+ yield* Effect.fail(new TestError({ message }));
84
+ }
85
+ });
86
+
87
+ /**
88
+ * Assert that a condition is false.
89
+ */
90
+ export const assertFalse = (
91
+ condition: boolean,
92
+ message: string
93
+ ): Effect.Effect<void, TestError> =>
94
+ Effect.gen(function* () {
95
+ if (condition) {
96
+ yield* Effect.fail(new TestError({ message }));
97
+ }
98
+ });
99
+
100
+ /**
101
+ * Assert that a value is undefined.
102
+ */
103
+ export const assertUndefined = (
104
+ value: unknown,
105
+ message: string
106
+ ): Effect.Effect<void, TestError> =>
107
+ Effect.gen(function* () {
108
+ if (value !== undefined) {
109
+ yield* Effect.fail(
110
+ new TestError({ message, expected: undefined, actual: value })
111
+ );
112
+ }
113
+ });
114
+
115
+ /**
116
+ * Assert that a value is defined (not undefined).
117
+ */
118
+ export const assertDefined = <T>(
119
+ value: T | undefined,
120
+ message: string
121
+ ): Effect.Effect<T, TestError> =>
122
+ Effect.gen(function* () {
123
+ if (value === undefined) {
124
+ yield* Effect.fail(
125
+ new TestError({ message, expected: "defined value", actual: undefined })
126
+ );
127
+ }
128
+ return value as T;
129
+ });
130
+
131
+ /**
132
+ * Assert that an array has the expected length.
133
+ */
134
+ export const assertLength = <T>(
135
+ array: T[],
136
+ expectedLength: number,
137
+ message: string
138
+ ): Effect.Effect<void, TestError> =>
139
+ Effect.gen(function* () {
140
+ if (array.length !== expectedLength) {
141
+ yield* Effect.fail(
142
+ new TestError({
143
+ message,
144
+ expected: expectedLength,
145
+ actual: array.length,
146
+ })
147
+ );
148
+ }
149
+ });
150
+
151
+ /**
152
+ * Assert that an array is empty.
153
+ */
154
+ export const assertEmpty = <T>(
155
+ array: T[],
156
+ message: string
157
+ ): Effect.Effect<void, TestError> => assertLength(array, 0, message);
158
+
159
+ /**
160
+ * Assert that an array is sorted by a key.
161
+ */
162
+ export const assertSortedBy = <T>(
163
+ array: T[],
164
+ key: keyof T,
165
+ message: string
166
+ ): Effect.Effect<void, TestError> =>
167
+ Effect.gen(function* () {
168
+ for (let i = 1; i < array.length; i++) {
169
+ const prev = array[i - 1]![key];
170
+ const curr = array[i]![key];
171
+ if (prev > curr) {
172
+ yield* Effect.fail(
173
+ new TestError({
174
+ message,
175
+ expected: `array sorted by ${String(key)}`,
176
+ actual: `element at index ${i - 1} (${prev}) > element at index ${i} (${curr})`,
177
+ })
178
+ );
179
+ }
180
+ }
181
+ });