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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/.turbo/turbo-build.log +116 -74
  2. package/dist/ColdStorage.cjs +9 -5
  3. package/dist/ColdStorage.d.cts.map +1 -1
  4. package/dist/ColdStorage.d.mts.map +1 -1
  5. package/dist/ColdStorage.mjs +9 -5
  6. package/dist/ColdStorage.mjs.map +1 -1
  7. package/dist/DocumentInstance.cjs +263 -0
  8. package/dist/DocumentInstance.d.cts +78 -0
  9. package/dist/DocumentInstance.d.cts.map +1 -0
  10. package/dist/DocumentInstance.d.mts +78 -0
  11. package/dist/DocumentInstance.d.mts.map +1 -0
  12. package/dist/DocumentInstance.mjs +264 -0
  13. package/dist/DocumentInstance.mjs.map +1 -0
  14. package/dist/Errors.cjs +10 -1
  15. package/dist/Errors.d.cts +18 -3
  16. package/dist/Errors.d.cts.map +1 -1
  17. package/dist/Errors.d.mts +18 -3
  18. package/dist/Errors.d.mts.map +1 -1
  19. package/dist/Errors.mjs +9 -1
  20. package/dist/Errors.mjs.map +1 -1
  21. package/dist/HotStorage.cjs +39 -12
  22. package/dist/HotStorage.d.cts +17 -1
  23. package/dist/HotStorage.d.cts.map +1 -1
  24. package/dist/HotStorage.d.mts +17 -1
  25. package/dist/HotStorage.d.mts.map +1 -1
  26. package/dist/HotStorage.mjs +39 -12
  27. package/dist/HotStorage.mjs.map +1 -1
  28. package/dist/Metrics.cjs +29 -1
  29. package/dist/Metrics.d.cts +5 -0
  30. package/dist/Metrics.d.cts.map +1 -1
  31. package/dist/Metrics.d.mts +5 -0
  32. package/dist/Metrics.d.mts.map +1 -1
  33. package/dist/Metrics.mjs +26 -1
  34. package/dist/Metrics.mjs.map +1 -1
  35. package/dist/MimicClusterServerEngine.cjs +44 -139
  36. package/dist/MimicClusterServerEngine.d.cts.map +1 -1
  37. package/dist/MimicClusterServerEngine.d.mts +1 -1
  38. package/dist/MimicClusterServerEngine.d.mts.map +1 -1
  39. package/dist/MimicClusterServerEngine.mjs +46 -141
  40. package/dist/MimicClusterServerEngine.mjs.map +1 -1
  41. package/dist/MimicServer.cjs +20 -20
  42. package/dist/MimicServer.d.cts.map +1 -1
  43. package/dist/MimicServer.d.mts.map +1 -1
  44. package/dist/MimicServer.mjs +20 -20
  45. package/dist/MimicServer.mjs.map +1 -1
  46. package/dist/MimicServerEngine.cjs +92 -11
  47. package/dist/MimicServerEngine.d.cts +12 -4
  48. package/dist/MimicServerEngine.d.cts.map +1 -1
  49. package/dist/MimicServerEngine.d.mts +12 -4
  50. package/dist/MimicServerEngine.d.mts.map +1 -1
  51. package/dist/MimicServerEngine.mjs +94 -13
  52. package/dist/MimicServerEngine.mjs.map +1 -1
  53. package/dist/PresenceManager.cjs +5 -5
  54. package/dist/PresenceManager.d.cts.map +1 -1
  55. package/dist/PresenceManager.d.mts.map +1 -1
  56. package/dist/PresenceManager.mjs +5 -5
  57. package/dist/PresenceManager.mjs.map +1 -1
  58. package/dist/Protocol.d.cts +1 -1
  59. package/dist/Protocol.d.mts +1 -1
  60. package/dist/Types.d.cts +9 -2
  61. package/dist/Types.d.cts.map +1 -1
  62. package/dist/Types.d.mts +9 -2
  63. package/dist/Types.d.mts.map +1 -1
  64. package/dist/index.cjs +5 -6
  65. package/dist/index.d.cts +3 -3
  66. package/dist/index.d.mts +3 -3
  67. package/dist/index.mjs +3 -3
  68. package/dist/testing/ColdStorageTestSuite.cjs +508 -0
  69. package/dist/testing/ColdStorageTestSuite.d.cts +36 -0
  70. package/dist/testing/ColdStorageTestSuite.d.cts.map +1 -0
  71. package/dist/testing/ColdStorageTestSuite.d.mts +36 -0
  72. package/dist/testing/ColdStorageTestSuite.d.mts.map +1 -0
  73. package/dist/testing/ColdStorageTestSuite.mjs +508 -0
  74. package/dist/testing/ColdStorageTestSuite.mjs.map +1 -0
  75. package/dist/testing/FailingStorage.cjs +162 -0
  76. package/dist/testing/FailingStorage.d.cts +43 -0
  77. package/dist/testing/FailingStorage.d.cts.map +1 -0
  78. package/dist/testing/FailingStorage.d.mts +43 -0
  79. package/dist/testing/FailingStorage.d.mts.map +1 -0
  80. package/dist/testing/FailingStorage.mjs +163 -0
  81. package/dist/testing/FailingStorage.mjs.map +1 -0
  82. package/dist/testing/HotStorageTestSuite.cjs +820 -0
  83. package/dist/testing/HotStorageTestSuite.d.cts +42 -0
  84. package/dist/testing/HotStorageTestSuite.d.cts.map +1 -0
  85. package/dist/testing/HotStorageTestSuite.d.mts +42 -0
  86. package/dist/testing/HotStorageTestSuite.d.mts.map +1 -0
  87. package/dist/testing/HotStorageTestSuite.mjs +820 -0
  88. package/dist/testing/HotStorageTestSuite.mjs.map +1 -0
  89. package/dist/testing/StorageIntegrationTestSuite.cjs +487 -0
  90. package/dist/testing/StorageIntegrationTestSuite.d.cts +37 -0
  91. package/dist/testing/StorageIntegrationTestSuite.d.cts.map +1 -0
  92. package/dist/testing/StorageIntegrationTestSuite.d.mts +37 -0
  93. package/dist/testing/StorageIntegrationTestSuite.d.mts.map +1 -0
  94. package/dist/testing/StorageIntegrationTestSuite.mjs +487 -0
  95. package/dist/testing/StorageIntegrationTestSuite.mjs.map +1 -0
  96. package/dist/testing/assertions.cjs +117 -0
  97. package/dist/testing/assertions.mjs +112 -0
  98. package/dist/testing/assertions.mjs.map +1 -0
  99. package/dist/testing/index.cjs +14 -0
  100. package/dist/testing/index.d.cts +6 -0
  101. package/dist/testing/index.d.mts +6 -0
  102. package/dist/testing/index.mjs +7 -0
  103. package/dist/testing/types.cjs +15 -0
  104. package/dist/testing/types.d.cts +90 -0
  105. package/dist/testing/types.d.cts.map +1 -0
  106. package/dist/testing/types.d.mts +90 -0
  107. package/dist/testing/types.d.mts.map +1 -0
  108. package/dist/testing/types.mjs +16 -0
  109. package/dist/testing/types.mjs.map +1 -0
  110. package/package.json +8 -3
  111. package/src/ColdStorage.ts +21 -12
  112. package/src/DocumentInstance.ts +527 -0
  113. package/src/Errors.ts +15 -1
  114. package/src/HotStorage.ts +115 -24
  115. package/src/Metrics.ts +30 -0
  116. package/src/MimicClusterServerEngine.ts +120 -275
  117. package/src/MimicServer.ts +83 -75
  118. package/src/MimicServerEngine.ts +230 -30
  119. package/src/PresenceManager.ts +44 -34
  120. package/src/Types.ts +9 -2
  121. package/src/index.ts +5 -35
  122. package/src/testing/ColdStorageTestSuite.ts +589 -0
  123. package/src/testing/FailingStorage.ts +338 -0
  124. package/src/testing/HotStorageTestSuite.ts +1105 -0
  125. package/src/testing/StorageIntegrationTestSuite.ts +736 -0
  126. package/src/testing/assertions.ts +188 -0
  127. package/src/testing/index.ts +83 -0
  128. package/src/testing/types.ts +100 -0
  129. package/tests/ColdStorage.test.ts +8 -120
  130. package/tests/DocumentInstance.test.ts +669 -0
  131. package/tests/HotStorage.test.ts +7 -126
  132. package/tests/StorageIntegration.test.ts +259 -0
  133. package/tsdown.config.ts +1 -1
  134. package/dist/DocumentManager.cjs +0 -229
  135. package/dist/DocumentManager.d.cts +0 -59
  136. package/dist/DocumentManager.d.cts.map +0 -1
  137. package/dist/DocumentManager.d.mts +0 -59
  138. package/dist/DocumentManager.d.mts.map +0 -1
  139. package/dist/DocumentManager.mjs +0 -227
  140. package/dist/DocumentManager.mjs.map +0 -1
  141. package/src/DocumentManager.ts +0 -506
  142. package/tests/DocumentManager.test.ts +0 -335
@@ -0,0 +1,736 @@
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, Schema } from "effect";
29
+ import { Transaction, OperationPath, Operation, OperationDefinition } from "@voidhash/mimic";
30
+ import { ColdStorageTag } from "../ColdStorage";
31
+ import { HotStorageTag } from "../HotStorage";
32
+ import { ColdStorageError, HotStorageError } from "../Errors";
33
+ import type { StoredDocument, WalEntry } from "../Types";
34
+ import type { StorageTestCase } from "./types";
35
+ import {
36
+ assertEqual,
37
+ assertTrue,
38
+ assertLength,
39
+ assertEmpty,
40
+ assertDefined,
41
+ assertUndefined,
42
+ } from "./assertions";
43
+
44
+ // =============================================================================
45
+ // Test Categories
46
+ // =============================================================================
47
+
48
+ export const Categories = {
49
+ SNAPSHOT_WAL_COORDINATION: "Snapshot + WAL Coordination",
50
+ VERSION_VERIFICATION: "Version Verification",
51
+ RECOVERY_SCENARIOS: "Recovery Scenarios",
52
+ TRANSACTION_ENCODING: "Transaction Encoding",
53
+ } as const;
54
+
55
+ // =============================================================================
56
+ // Test Operation Definitions
57
+ // =============================================================================
58
+
59
+ /**
60
+ * Test operation definition for creating proper Operation objects in tests.
61
+ */
62
+ const TestSetDefinition = OperationDefinition.make({
63
+ kind: "test.set" as const,
64
+ payload: Schema.Unknown,
65
+ target: Schema.Unknown,
66
+ apply: (payload: unknown) => payload,
67
+ });
68
+
69
+ // =============================================================================
70
+ // Test Helpers
71
+ // =============================================================================
72
+
73
+ const makeSnapshot = (
74
+ version: number,
75
+ state: unknown = { data: `v${version}` }
76
+ ): StoredDocument => ({
77
+ state,
78
+ version,
79
+ schemaVersion: 1,
80
+ savedAt: Date.now(),
81
+ });
82
+
83
+ const makeWalEntry = (
84
+ version: number,
85
+ pathString: string = "data",
86
+ payload: unknown = `v${version}`
87
+ ): WalEntry => ({
88
+ transaction: Transaction.make([
89
+ Operation.fromDefinition(OperationPath.make(pathString), TestSetDefinition, payload),
90
+ ]),
91
+ version,
92
+ timestamp: Date.now(),
93
+ });
94
+
95
+ // =============================================================================
96
+ // Test Definitions
97
+ // =============================================================================
98
+
99
+ type IntegrationTestCase = StorageTestCase<ColdStorageError | HotStorageError, ColdStorageTag | HotStorageTag>;
100
+
101
+ const snapshotWalCoordinationTests: IntegrationTestCase[] = [
102
+ {
103
+ name: "load empty document returns undefined snapshot and empty 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 snapshot = yield* cold.load("empty-doc");
110
+ const walEntries = yield* hot.getEntries("empty-doc", 0);
111
+
112
+ assertUndefined(snapshot, "Snapshot should be undefined for new doc");
113
+ assertEmpty(walEntries, "WAL should be empty for new doc");
114
+ }),
115
+ },
116
+
117
+ {
118
+ name: "restore from snapshot only (no WAL)",
119
+ category: Categories.SNAPSHOT_WAL_COORDINATION,
120
+ run: Effect.gen(function* () {
121
+ const cold = yield* ColdStorageTag;
122
+ const hot = yield* HotStorageTag;
123
+
124
+ const docId = "snapshot-only";
125
+ const snapshot = makeSnapshot(5, { title: "Hello" });
126
+
127
+ yield* cold.save(docId, snapshot);
128
+
129
+ const loaded = yield* cold.load(docId);
130
+ const walEntries = yield* hot.getEntries(docId, 5);
131
+
132
+ assertDefined(loaded, "Snapshot should be loaded");
133
+ assertEqual(loaded!.version, 5, "Snapshot version should match");
134
+ assertEqual(loaded!.state, { title: "Hello" }, "Snapshot state should match");
135
+ assertEmpty(walEntries, "WAL should be empty after snapshot version");
136
+ }),
137
+ },
138
+
139
+ {
140
+ name: "restore from WAL only (no snapshot)",
141
+ category: Categories.SNAPSHOT_WAL_COORDINATION,
142
+ run: Effect.gen(function* () {
143
+ const cold = yield* ColdStorageTag;
144
+ const hot = yield* HotStorageTag;
145
+
146
+ const docId = "wal-only";
147
+
148
+ yield* hot.append(docId, makeWalEntry(1));
149
+ yield* hot.append(docId, makeWalEntry(2));
150
+ yield* hot.append(docId, makeWalEntry(3));
151
+
152
+ const snapshot = yield* cold.load(docId);
153
+ const walEntries = yield* hot.getEntries(docId, 0);
154
+
155
+ assertUndefined(snapshot, "No snapshot should exist");
156
+ assertLength(walEntries, 3, "Should have 3 WAL entries");
157
+ assertEqual(walEntries[0]!.version, 1, "First entry should be v1");
158
+ assertEqual(walEntries[2]!.version, 3, "Last entry should be v3");
159
+ }),
160
+ },
161
+
162
+ {
163
+ name: "restore from snapshot + WAL replay",
164
+ category: Categories.SNAPSHOT_WAL_COORDINATION,
165
+ run: Effect.gen(function* () {
166
+ const cold = yield* ColdStorageTag;
167
+ const hot = yield* HotStorageTag;
168
+
169
+ const docId = "snapshot-plus-wal";
170
+
171
+ // Save snapshot at v5
172
+ yield* cold.save(docId, makeSnapshot(5));
173
+
174
+ // Add WAL entries for v6, v7, v8
175
+ yield* hot.append(docId, makeWalEntry(6));
176
+ yield* hot.append(docId, makeWalEntry(7));
177
+ yield* hot.append(docId, makeWalEntry(8));
178
+
179
+ const snapshot = yield* cold.load(docId);
180
+ const walEntries = yield* hot.getEntries(docId, snapshot!.version);
181
+
182
+ assertEqual(snapshot!.version, 5, "Snapshot at v5");
183
+ assertLength(walEntries, 3, "3 WAL entries after snapshot");
184
+ assertEqual(walEntries[0]!.version, 6, "First WAL entry is v6");
185
+ assertEqual(walEntries[2]!.version, 8, "Last WAL entry is v8");
186
+ }),
187
+ },
188
+
189
+ {
190
+ name: "truncate WAL after snapshot",
191
+ category: Categories.SNAPSHOT_WAL_COORDINATION,
192
+ run: Effect.gen(function* () {
193
+ const cold = yield* ColdStorageTag;
194
+ const hot = yield* HotStorageTag;
195
+
196
+ const docId = "truncate-test";
197
+
198
+ // Add WAL entries 1-5
199
+ for (let i = 1; i <= 5; i++) {
200
+ yield* hot.append(docId, makeWalEntry(i));
201
+ }
202
+
203
+ // Save snapshot at v3
204
+ yield* cold.save(docId, makeSnapshot(3));
205
+
206
+ // Truncate WAL up to v3
207
+ yield* hot.truncate(docId, 3);
208
+
209
+ const walEntries = yield* hot.getEntries(docId, 0);
210
+
211
+ assertLength(walEntries, 2, "Only v4 and v5 should remain");
212
+ assertEqual(walEntries[0]!.version, 4, "First remaining is v4");
213
+ assertEqual(walEntries[1]!.version, 5, "Last remaining is v5");
214
+ }),
215
+ },
216
+
217
+ {
218
+ name: "snapshot overwrites previous snapshot",
219
+ category: Categories.SNAPSHOT_WAL_COORDINATION,
220
+ run: Effect.gen(function* () {
221
+ const cold = yield* ColdStorageTag;
222
+
223
+ const docId = "overwrite-test";
224
+
225
+ yield* cold.save(docId, makeSnapshot(1, { old: true }));
226
+ yield* cold.save(docId, makeSnapshot(5, { new: true }));
227
+
228
+ const loaded = yield* cold.load(docId);
229
+
230
+ assertEqual(loaded!.version, 5, "Should have newer version");
231
+ assertEqual(loaded!.state, { new: true }, "Should have newer state");
232
+ }),
233
+ },
234
+ ];
235
+
236
+ const versionVerificationTests: IntegrationTestCase[] = [
237
+ {
238
+ name: "WAL entries are ordered by version",
239
+ category: Categories.VERSION_VERIFICATION,
240
+ run: Effect.gen(function* () {
241
+ const hot = yield* HotStorageTag;
242
+
243
+ const docId = "ordering-test";
244
+
245
+ // Append out of order
246
+ yield* hot.append(docId, makeWalEntry(3));
247
+ yield* hot.append(docId, makeWalEntry(1));
248
+ yield* hot.append(docId, makeWalEntry(2));
249
+
250
+ const entries = yield* hot.getEntries(docId, 0);
251
+
252
+ assertLength(entries, 3, "All entries should be returned");
253
+ assertEqual(entries[0]!.version, 1, "First should be v1");
254
+ assertEqual(entries[1]!.version, 2, "Second should be v2");
255
+ assertEqual(entries[2]!.version, 3, "Third should be v3");
256
+ }),
257
+ },
258
+
259
+ {
260
+ name: "getEntries filters by sinceVersion correctly",
261
+ category: Categories.VERSION_VERIFICATION,
262
+ run: Effect.gen(function* () {
263
+ const hot = yield* HotStorageTag;
264
+
265
+ const docId = "filter-test";
266
+
267
+ for (let i = 1; i <= 10; i++) {
268
+ yield* hot.append(docId, makeWalEntry(i));
269
+ }
270
+
271
+ const fromV5 = yield* hot.getEntries(docId, 5);
272
+ const fromV8 = yield* hot.getEntries(docId, 8);
273
+ const fromV10 = yield* hot.getEntries(docId, 10);
274
+
275
+ assertLength(fromV5, 5, "v6-v10 = 5 entries");
276
+ assertEqual(fromV5[0]!.version, 6, "First entry after v5 is v6");
277
+
278
+ assertLength(fromV8, 2, "v9-v10 = 2 entries");
279
+ assertEqual(fromV8[0]!.version, 9, "First entry after v8 is v9");
280
+
281
+ assertEmpty(fromV10, "No entries after v10");
282
+ }),
283
+ },
284
+
285
+ {
286
+ name: "detect version gap between snapshot and WAL",
287
+ category: Categories.VERSION_VERIFICATION,
288
+ run: Effect.gen(function* () {
289
+ const cold = yield* ColdStorageTag;
290
+ const hot = yield* HotStorageTag;
291
+
292
+ const docId = "gap-detection";
293
+
294
+ // Snapshot at v5
295
+ yield* cold.save(docId, makeSnapshot(5));
296
+
297
+ // WAL starts at v7 (gap: v6 missing)
298
+ yield* hot.append(docId, makeWalEntry(7));
299
+ yield* hot.append(docId, makeWalEntry(8));
300
+
301
+ const snapshot = yield* cold.load(docId);
302
+ const walEntries = yield* hot.getEntries(docId, snapshot!.version);
303
+
304
+ assertEqual(snapshot!.version, 5, "Snapshot at v5");
305
+ assertLength(walEntries, 2, "Two WAL entries");
306
+
307
+ // Gap detection: first WAL entry should be v6, but it's v7
308
+ const firstWalVersion = walEntries[0]!.version;
309
+ const expectedFirst = snapshot!.version + 1;
310
+ const hasGap = firstWalVersion !== expectedFirst;
311
+
312
+ assertTrue(hasGap, "Should detect gap (v7 != v6)");
313
+ }),
314
+ },
315
+
316
+ {
317
+ name: "detect internal WAL gaps",
318
+ category: Categories.VERSION_VERIFICATION,
319
+ run: Effect.gen(function* () {
320
+ const hot = yield* HotStorageTag;
321
+
322
+ const docId = "internal-gap";
323
+
324
+ yield* hot.append(docId, makeWalEntry(1));
325
+ yield* hot.append(docId, makeWalEntry(2));
326
+ // Skip v3
327
+ yield* hot.append(docId, makeWalEntry(4));
328
+ yield* hot.append(docId, makeWalEntry(5));
329
+
330
+ const entries = yield* hot.getEntries(docId, 0);
331
+
332
+ // Check for internal gaps
333
+ let gapFound = false;
334
+ for (let i = 1; i < entries.length; i++) {
335
+ const prev = entries[i - 1]!.version;
336
+ const curr = entries[i]!.version;
337
+ if (curr !== prev + 1) {
338
+ gapFound = true;
339
+ break;
340
+ }
341
+ }
342
+
343
+ assertTrue(gapFound, "Should detect internal gap between v2 and v4");
344
+ }),
345
+ },
346
+
347
+ {
348
+ name: "no gap when WAL is continuous",
349
+ category: Categories.VERSION_VERIFICATION,
350
+ run: Effect.gen(function* () {
351
+ const cold = yield* ColdStorageTag;
352
+ const hot = yield* HotStorageTag;
353
+
354
+ const docId = "no-gap";
355
+
356
+ yield* cold.save(docId, makeSnapshot(5));
357
+ yield* hot.append(docId, makeWalEntry(6));
358
+ yield* hot.append(docId, makeWalEntry(7));
359
+ yield* hot.append(docId, makeWalEntry(8));
360
+
361
+ const snapshot = yield* cold.load(docId);
362
+ const walEntries = yield* hot.getEntries(docId, snapshot!.version);
363
+
364
+ const firstWalVersion = walEntries[0]!.version;
365
+ const expectedFirst = snapshot!.version + 1;
366
+ const hasGap = firstWalVersion !== expectedFirst;
367
+
368
+ assertTrue(!hasGap, "Should not detect gap (v6 == v6)");
369
+ }),
370
+ },
371
+ ];
372
+
373
+ const recoveryScenarioTests: IntegrationTestCase[] = [
374
+ {
375
+ name: "full recovery: snapshot + WAL + new transactions",
376
+ category: Categories.RECOVERY_SCENARIOS,
377
+ run: Effect.gen(function* () {
378
+ const cold = yield* ColdStorageTag;
379
+ const hot = yield* HotStorageTag;
380
+
381
+ const docId = "full-recovery";
382
+
383
+ // Initial state: snapshot at v3, WAL v4-v5
384
+ yield* cold.save(docId, makeSnapshot(3, { count: 3 }));
385
+ yield* hot.append(docId, makeWalEntry(4));
386
+ yield* hot.append(docId, makeWalEntry(5));
387
+
388
+ // "Recovery" - load snapshot and WAL
389
+ const snapshot = yield* cold.load(docId);
390
+ const walEntries = yield* hot.getEntries(docId, snapshot!.version);
391
+
392
+ assertEqual(snapshot!.version, 3, "Snapshot version");
393
+ assertLength(walEntries, 2, "WAL entries to replay");
394
+
395
+ // Simulate new transaction after recovery
396
+ yield* hot.append(docId, makeWalEntry(6));
397
+
398
+ const newWal = yield* hot.getEntries(docId, 5);
399
+ assertLength(newWal, 1, "One new entry after recovery");
400
+ assertEqual(newWal[0]!.version, 6, "New entry is v6");
401
+ }),
402
+ },
403
+
404
+ {
405
+ name: "recovery from only WAL (cold start)",
406
+ category: Categories.RECOVERY_SCENARIOS,
407
+ run: Effect.gen(function* () {
408
+ const cold = yield* ColdStorageTag;
409
+ const hot = yield* HotStorageTag;
410
+
411
+ const docId = "cold-start";
412
+
413
+ // Only WAL entries, no snapshot (new document that hasn't been snapshotted)
414
+ yield* hot.append(docId, makeWalEntry(1));
415
+ yield* hot.append(docId, makeWalEntry(2));
416
+
417
+ const snapshot = yield* cold.load(docId);
418
+ const walEntries = yield* hot.getEntries(docId, 0);
419
+
420
+ assertUndefined(snapshot, "No snapshot exists");
421
+ assertLength(walEntries, 2, "All WAL entries from beginning");
422
+ }),
423
+ },
424
+
425
+ {
426
+ name: "recovery after truncation failure (WAL has old entries)",
427
+ category: Categories.RECOVERY_SCENARIOS,
428
+ run: Effect.gen(function* () {
429
+ const cold = yield* ColdStorageTag;
430
+ const hot = yield* HotStorageTag;
431
+
432
+ const docId = "truncate-failed";
433
+
434
+ // Simulate: snapshot saved at v5, but truncate failed
435
+ // So WAL still has v3, v4, v5, v6
436
+ yield* hot.append(docId, makeWalEntry(3));
437
+ yield* hot.append(docId, makeWalEntry(4));
438
+ yield* hot.append(docId, makeWalEntry(5));
439
+ yield* hot.append(docId, makeWalEntry(6));
440
+
441
+ yield* cold.save(docId, makeSnapshot(5));
442
+
443
+ // Recovery should only replay v6
444
+ const snapshot = yield* cold.load(docId);
445
+ const walEntries = yield* hot.getEntries(docId, snapshot!.version);
446
+
447
+ assertEqual(snapshot!.version, 5, "Snapshot at v5");
448
+ assertLength(walEntries, 1, "Only v6 should be replayed");
449
+ assertEqual(walEntries[0]!.version, 6, "Entry is v6");
450
+ }),
451
+ },
452
+
453
+ {
454
+ name: "idempotent snapshot save",
455
+ category: Categories.RECOVERY_SCENARIOS,
456
+ run: Effect.gen(function* () {
457
+ const cold = yield* ColdStorageTag;
458
+
459
+ const docId = "idempotent";
460
+
461
+ const snapshot1 = makeSnapshot(5, { first: true });
462
+ const snapshot2 = makeSnapshot(5, { second: true });
463
+
464
+ yield* cold.save(docId, snapshot1);
465
+ yield* cold.save(docId, snapshot2);
466
+
467
+ const loaded = yield* cold.load(docId);
468
+
469
+ // Last write wins
470
+ assertEqual(loaded!.state, { second: true }, "Second save overwrites");
471
+ }),
472
+ },
473
+ ];
474
+
475
+ const transactionEncodingTests: IntegrationTestCase[] = [
476
+ {
477
+ name: "OperationPath survives full recovery cycle (snapshot + WAL)",
478
+ category: Categories.TRANSACTION_ENCODING,
479
+ run: Effect.gen(function* () {
480
+ const cold = yield* ColdStorageTag;
481
+ const hot = yield* HotStorageTag;
482
+
483
+ const docId = "op-path-recovery";
484
+
485
+ // Save snapshot at v3
486
+ yield* cold.save(docId, makeSnapshot(3, { count: 3 }));
487
+
488
+ // Add WAL entries with proper OperationPath
489
+ yield* hot.append(docId, makeWalEntry(4, "users/0/name", "Alice"));
490
+ yield* hot.append(docId, makeWalEntry(5, "users/1/name", "Bob"));
491
+
492
+ // Simulate recovery
493
+ const snapshot = yield* cold.load(docId);
494
+ const walEntries = yield* hot.getEntries(docId, snapshot!.version);
495
+
496
+ assertLength(walEntries, 2, "Should have 2 WAL entries");
497
+
498
+ // Verify OperationPath is properly reconstructed
499
+ const firstOp = walEntries[0]!.transaction.ops[0]!;
500
+ const secondOp = walEntries[1]!.transaction.ops[0]!;
501
+
502
+ assertTrue(
503
+ firstOp.path._tag === "OperationPath",
504
+ "First op path should be OperationPath"
505
+ );
506
+ assertTrue(
507
+ typeof firstOp.path.toTokens === "function",
508
+ "First op path should have toTokens method"
509
+ );
510
+ assertEqual(
511
+ firstOp.path.toTokens(),
512
+ ["users", "0", "name"],
513
+ "First op path tokens should be correct"
514
+ );
515
+ assertEqual(
516
+ secondOp.path.toTokens(),
517
+ ["users", "1", "name"],
518
+ "Second op path tokens should be correct"
519
+ );
520
+ }),
521
+ },
522
+
523
+ {
524
+ name: "OperationPath methods work after WAL-only recovery",
525
+ category: Categories.TRANSACTION_ENCODING,
526
+ run: Effect.gen(function* () {
527
+ const cold = yield* ColdStorageTag;
528
+ const hot = yield* HotStorageTag;
529
+
530
+ const docId = "op-path-wal-only";
531
+
532
+ // No snapshot, only WAL
533
+ yield* hot.append(docId, makeWalEntry(1, "config/theme", "dark"));
534
+ yield* hot.append(docId, makeWalEntry(2, "config/language", "en"));
535
+
536
+ const snapshot = yield* cold.load(docId);
537
+ assertUndefined(snapshot, "No snapshot should exist");
538
+
539
+ const walEntries = yield* hot.getEntries(docId, 0);
540
+ assertLength(walEntries, 2, "Should have 2 WAL entries");
541
+
542
+ // Test OperationPath methods
543
+ const path = walEntries[0]!.transaction.ops[0]!.path;
544
+
545
+ // Test concat
546
+ const extended = path.concat(OperationPath.make("subkey"));
547
+ assertEqual(
548
+ extended.toTokens(),
549
+ ["config", "theme", "subkey"],
550
+ "concat should work"
551
+ );
552
+
553
+ // Test pop
554
+ const popped = path.pop();
555
+ assertEqual(popped.toTokens(), ["config"], "pop should work");
556
+
557
+ // Test append
558
+ const appended = path.append("extra");
559
+ assertEqual(
560
+ appended.toTokens(),
561
+ ["config", "theme", "extra"],
562
+ "append should work"
563
+ );
564
+ }),
565
+ },
566
+
567
+ {
568
+ name: "transaction encoding survives truncation cycle",
569
+ category: Categories.TRANSACTION_ENCODING,
570
+ run: Effect.gen(function* () {
571
+ const cold = yield* ColdStorageTag;
572
+ const hot = yield* HotStorageTag;
573
+
574
+ const docId = "op-path-truncate-cycle";
575
+
576
+ // Add WAL entries 1-5
577
+ for (let i = 1; i <= 5; i++) {
578
+ yield* hot.append(docId, makeWalEntry(i, `path/${i}`, `value${i}`));
579
+ }
580
+
581
+ // Save snapshot at v3 and truncate
582
+ yield* cold.save(docId, makeSnapshot(3));
583
+ yield* hot.truncate(docId, 3);
584
+
585
+ // Verify remaining entries have proper OperationPath
586
+ const walEntries = yield* hot.getEntries(docId, 0);
587
+ assertLength(walEntries, 2, "Should have versions 4 and 5");
588
+
589
+ for (const entry of walEntries) {
590
+ const op = entry.transaction.ops[0]!;
591
+ assertTrue(
592
+ op.path._tag === "OperationPath",
593
+ "Path should be OperationPath after truncation"
594
+ );
595
+ assertTrue(
596
+ typeof op.path.toTokens === "function",
597
+ "Path should have toTokens method after truncation"
598
+ );
599
+ }
600
+
601
+ assertEqual(
602
+ walEntries[0]!.transaction.ops[0]!.path.toTokens(),
603
+ ["path", "4"],
604
+ "Version 4 path should be correct"
605
+ );
606
+ assertEqual(
607
+ walEntries[1]!.transaction.ops[0]!.path.toTokens(),
608
+ ["path", "5"],
609
+ "Version 5 path should be correct"
610
+ );
611
+ }),
612
+ },
613
+
614
+ {
615
+ name: "complex nested paths survive integration roundtrip",
616
+ category: Categories.TRANSACTION_ENCODING,
617
+ run: Effect.gen(function* () {
618
+ const hot = yield* HotStorageTag;
619
+
620
+ const docId = "complex-nested-paths";
621
+
622
+ const complexPath = "documents/users/0/profile/settings/notifications";
623
+ yield* hot.append(docId, makeWalEntry(1, complexPath, { enabled: true }));
624
+
625
+ const entries = yield* hot.getEntries(docId, 0);
626
+ assertLength(entries, 1, "Should have one entry");
627
+
628
+ const op = entries[0]!.transaction.ops[0]!;
629
+ assertEqual(
630
+ op.path.toTokens(),
631
+ ["documents", "users", "0", "profile", "settings", "notifications"],
632
+ "Complex nested path should survive roundtrip"
633
+ );
634
+
635
+ // Test that path operations work on complex paths
636
+ const shifted = op.path.shift();
637
+ assertEqual(
638
+ shifted.toTokens(),
639
+ ["users", "0", "profile", "settings", "notifications"],
640
+ "shift should work on complex path"
641
+ );
642
+ }),
643
+ },
644
+
645
+ {
646
+ name: "multiple operations per transaction preserve all OperationPaths",
647
+ category: Categories.TRANSACTION_ENCODING,
648
+ run: Effect.gen(function* () {
649
+ const hot = yield* HotStorageTag;
650
+
651
+ const docId = "multi-op-integration";
652
+
653
+ const entry: WalEntry = {
654
+ transaction: Transaction.make([
655
+ Operation.fromDefinition(OperationPath.make("users/0"), TestSetDefinition, { name: "Alice" }),
656
+ Operation.fromDefinition(OperationPath.make("users/1"), TestSetDefinition, { name: "Bob" }),
657
+ Operation.fromDefinition(OperationPath.make("meta/count"), TestSetDefinition, 2),
658
+ ]),
659
+ version: 1,
660
+ timestamp: Date.now(),
661
+ };
662
+
663
+ yield* hot.append(docId, entry);
664
+
665
+ const entries = yield* hot.getEntries(docId, 0);
666
+ assertLength(entries, 1, "Should have one entry");
667
+
668
+ const ops = entries[0]!.transaction.ops;
669
+ assertEqual(ops.length, 3, "Should have 3 operations");
670
+
671
+ // Verify all paths
672
+ assertEqual(ops[0]!.path.toTokens(), ["users", "0"], "First path correct");
673
+ assertEqual(ops[1]!.path.toTokens(), ["users", "1"], "Second path correct");
674
+ assertEqual(ops[2]!.path.toTokens(), ["meta", "count"], "Third path correct");
675
+
676
+ // Verify all have methods
677
+ for (const op of ops) {
678
+ assertTrue(
679
+ typeof op.path.concat === "function",
680
+ "Each path should have concat method"
681
+ );
682
+ assertTrue(
683
+ typeof op.path.append === "function",
684
+ "Each path should have append method"
685
+ );
686
+ }
687
+ }),
688
+ },
689
+ ];
690
+
691
+ // =============================================================================
692
+ // Test Suite Export
693
+ // =============================================================================
694
+
695
+ /**
696
+ * Generate all integration test cases
697
+ */
698
+ export const makeTests = (): IntegrationTestCase[] => [
699
+ ...snapshotWalCoordinationTests,
700
+ ...versionVerificationTests,
701
+ ...recoveryScenarioTests,
702
+ ...transactionEncodingTests,
703
+ ];
704
+
705
+ /**
706
+ * Run all integration tests and collect results
707
+ */
708
+ export const runAll = <R>(
709
+ layer: import("effect").Layer.Layer<ColdStorageTag | HotStorageTag, never, R>
710
+ ) =>
711
+ Effect.gen(function* () {
712
+ const tests = makeTests();
713
+ const results: Array<{ name: string; passed: boolean; error?: unknown }> = [];
714
+
715
+ for (const test of tests) {
716
+ const result = yield* Effect.either(test.run.pipe(Effect.provide(layer)));
717
+
718
+ if (result._tag === "Right") {
719
+ results.push({ name: test.name, passed: true });
720
+ } else {
721
+ results.push({ name: test.name, passed: false, error: result.left });
722
+ }
723
+ }
724
+
725
+ return results;
726
+ });
727
+
728
+ // =============================================================================
729
+ // Export Namespace
730
+ // =============================================================================
731
+
732
+ export const StorageIntegrationTestSuite = {
733
+ Categories,
734
+ makeTests,
735
+ runAll,
736
+ };