@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.
- package/.turbo/turbo-build.log +136 -90
- package/README.md +385 -0
- package/dist/ColdStorage.cjs +60 -0
- package/dist/ColdStorage.d.cts +53 -0
- package/dist/ColdStorage.d.cts.map +1 -0
- package/dist/ColdStorage.d.mts +53 -0
- package/dist/ColdStorage.d.mts.map +1 -0
- package/dist/ColdStorage.mjs +60 -0
- package/dist/ColdStorage.mjs.map +1 -0
- package/dist/DocumentManager.cjs +263 -82
- package/dist/DocumentManager.d.cts +44 -22
- package/dist/DocumentManager.d.cts.map +1 -1
- package/dist/DocumentManager.d.mts +44 -22
- package/dist/DocumentManager.d.mts.map +1 -1
- package/dist/DocumentManager.mjs +259 -67
- package/dist/DocumentManager.mjs.map +1 -1
- package/dist/Errors.cjs +54 -0
- package/dist/Errors.d.cts +96 -0
- package/dist/Errors.d.cts.map +1 -0
- package/dist/Errors.d.mts +96 -0
- package/dist/Errors.d.mts.map +1 -0
- package/dist/Errors.mjs +48 -0
- package/dist/Errors.mjs.map +1 -0
- package/dist/HotStorage.cjs +100 -0
- package/dist/HotStorage.d.cts +70 -0
- package/dist/HotStorage.d.cts.map +1 -0
- package/dist/HotStorage.d.mts +70 -0
- package/dist/HotStorage.d.mts.map +1 -0
- package/dist/HotStorage.mjs +100 -0
- package/dist/HotStorage.mjs.map +1 -0
- package/dist/Metrics.cjs +143 -0
- package/dist/Metrics.d.cts +31 -0
- package/dist/Metrics.d.cts.map +1 -0
- package/dist/Metrics.d.mts +31 -0
- package/dist/Metrics.d.mts.map +1 -0
- package/dist/Metrics.mjs +126 -0
- package/dist/Metrics.mjs.map +1 -0
- package/dist/MimicAuthService.cjs +61 -45
- package/dist/MimicAuthService.d.cts +61 -48
- package/dist/MimicAuthService.d.cts.map +1 -1
- package/dist/MimicAuthService.d.mts +61 -48
- package/dist/MimicAuthService.d.mts.map +1 -1
- package/dist/MimicAuthService.mjs +60 -36
- package/dist/MimicAuthService.mjs.map +1 -1
- package/dist/MimicClusterServerEngine.cjs +521 -0
- package/dist/MimicClusterServerEngine.d.cts +17 -0
- package/dist/MimicClusterServerEngine.d.cts.map +1 -0
- package/dist/MimicClusterServerEngine.d.mts +17 -0
- package/dist/MimicClusterServerEngine.d.mts.map +1 -0
- package/dist/MimicClusterServerEngine.mjs +523 -0
- package/dist/MimicClusterServerEngine.mjs.map +1 -0
- package/dist/MimicServer.cjs +205 -96
- package/dist/MimicServer.d.cts +9 -110
- package/dist/MimicServer.d.cts.map +1 -1
- package/dist/MimicServer.d.mts +9 -110
- package/dist/MimicServer.d.mts.map +1 -1
- package/dist/MimicServer.mjs +206 -90
- package/dist/MimicServer.mjs.map +1 -1
- package/dist/MimicServerEngine.cjs +97 -0
- package/dist/MimicServerEngine.d.cts +78 -0
- package/dist/MimicServerEngine.d.cts.map +1 -0
- package/dist/MimicServerEngine.d.mts +78 -0
- package/dist/MimicServerEngine.d.mts.map +1 -0
- package/dist/MimicServerEngine.mjs +97 -0
- package/dist/MimicServerEngine.mjs.map +1 -0
- package/dist/PresenceManager.cjs +75 -91
- package/dist/PresenceManager.d.cts +17 -66
- package/dist/PresenceManager.d.cts.map +1 -1
- package/dist/PresenceManager.d.mts +17 -66
- package/dist/PresenceManager.d.mts.map +1 -1
- package/dist/PresenceManager.mjs +74 -78
- package/dist/PresenceManager.mjs.map +1 -1
- package/dist/Protocol.cjs +146 -0
- package/dist/Protocol.d.cts +203 -0
- package/dist/Protocol.d.cts.map +1 -0
- package/dist/Protocol.d.mts +203 -0
- package/dist/Protocol.d.mts.map +1 -0
- package/dist/Protocol.mjs +132 -0
- package/dist/Protocol.mjs.map +1 -0
- package/dist/Types.d.cts +172 -0
- package/dist/Types.d.cts.map +1 -0
- package/dist/Types.d.mts +172 -0
- package/dist/Types.d.mts.map +1 -0
- package/dist/_virtual/rolldown_runtime.cjs +1 -25
- package/dist/_virtual/rolldown_runtime.mjs +4 -1
- package/dist/index.cjs +37 -75
- package/dist/index.d.cts +13 -12
- package/dist/index.d.mts +13 -12
- package/dist/index.mjs +12 -12
- package/dist/testing/ColdStorageTestSuite.cjs +508 -0
- package/dist/testing/ColdStorageTestSuite.d.cts +36 -0
- package/dist/testing/ColdStorageTestSuite.d.cts.map +1 -0
- package/dist/testing/ColdStorageTestSuite.d.mts +36 -0
- package/dist/testing/ColdStorageTestSuite.d.mts.map +1 -0
- package/dist/testing/ColdStorageTestSuite.mjs +508 -0
- package/dist/testing/ColdStorageTestSuite.mjs.map +1 -0
- package/dist/testing/FailingStorage.cjs +135 -0
- package/dist/testing/FailingStorage.d.cts +43 -0
- package/dist/testing/FailingStorage.d.cts.map +1 -0
- package/dist/testing/FailingStorage.d.mts +43 -0
- package/dist/testing/FailingStorage.d.mts.map +1 -0
- package/dist/testing/FailingStorage.mjs +136 -0
- package/dist/testing/FailingStorage.mjs.map +1 -0
- package/dist/testing/HotStorageTestSuite.cjs +585 -0
- package/dist/testing/HotStorageTestSuite.d.cts +40 -0
- package/dist/testing/HotStorageTestSuite.d.cts.map +1 -0
- package/dist/testing/HotStorageTestSuite.d.mts +40 -0
- package/dist/testing/HotStorageTestSuite.d.mts.map +1 -0
- package/dist/testing/HotStorageTestSuite.mjs +585 -0
- package/dist/testing/HotStorageTestSuite.mjs.map +1 -0
- package/dist/testing/StorageIntegrationTestSuite.cjs +349 -0
- package/dist/testing/StorageIntegrationTestSuite.d.cts +35 -0
- package/dist/testing/StorageIntegrationTestSuite.d.cts.map +1 -0
- package/dist/testing/StorageIntegrationTestSuite.d.mts +35 -0
- package/dist/testing/StorageIntegrationTestSuite.d.mts.map +1 -0
- package/dist/testing/StorageIntegrationTestSuite.mjs +349 -0
- package/dist/testing/StorageIntegrationTestSuite.mjs.map +1 -0
- package/dist/testing/assertions.cjs +114 -0
- package/dist/testing/assertions.mjs +109 -0
- package/dist/testing/assertions.mjs.map +1 -0
- package/dist/testing/index.cjs +14 -0
- package/dist/testing/index.d.cts +6 -0
- package/dist/testing/index.d.mts +6 -0
- package/dist/testing/index.mjs +7 -0
- package/dist/testing/types.cjs +15 -0
- package/dist/testing/types.d.cts +90 -0
- package/dist/testing/types.d.cts.map +1 -0
- package/dist/testing/types.d.mts +90 -0
- package/dist/testing/types.d.mts.map +1 -0
- package/dist/testing/types.mjs +16 -0
- package/dist/testing/types.mjs.map +1 -0
- package/package.json +18 -3
- package/src/ColdStorage.ts +136 -0
- package/src/DocumentManager.ts +550 -190
- package/src/Errors.ts +114 -0
- package/src/HotStorage.ts +239 -0
- package/src/Metrics.ts +187 -0
- package/src/MimicAuthService.ts +126 -64
- package/src/MimicClusterServerEngine.ts +946 -0
- package/src/MimicServer.ts +448 -195
- package/src/MimicServerEngine.ts +276 -0
- package/src/PresenceManager.ts +169 -240
- package/src/Protocol.ts +350 -0
- package/src/Types.ts +231 -0
- package/src/index.ts +57 -23
- package/src/testing/ColdStorageTestSuite.ts +589 -0
- package/src/testing/FailingStorage.ts +286 -0
- package/src/testing/HotStorageTestSuite.ts +762 -0
- package/src/testing/StorageIntegrationTestSuite.ts +504 -0
- package/src/testing/assertions.ts +181 -0
- package/src/testing/index.ts +83 -0
- package/src/testing/types.ts +100 -0
- package/tests/ColdStorage.test.ts +24 -0
- package/tests/DocumentManager.test.ts +158 -287
- package/tests/HotStorage.test.ts +24 -0
- package/tests/MimicAuthService.test.ts +102 -134
- package/tests/MimicClusterServerEngine.test.ts +587 -0
- package/tests/MimicServer.test.ts +90 -226
- package/tests/MimicServerEngine.test.ts +521 -0
- package/tests/PresenceManager.test.ts +22 -63
- package/tests/Protocol.test.ts +190 -0
- package/tests/StorageIntegration.test.ts +259 -0
- package/tsconfig.json +1 -1
- package/tsdown.config.ts +1 -1
- package/dist/DocumentProtocol.cjs +0 -94
- package/dist/DocumentProtocol.d.cts +0 -113
- package/dist/DocumentProtocol.d.cts.map +0 -1
- package/dist/DocumentProtocol.d.mts +0 -113
- package/dist/DocumentProtocol.d.mts.map +0 -1
- package/dist/DocumentProtocol.mjs +0 -89
- package/dist/DocumentProtocol.mjs.map +0 -1
- package/dist/MimicConfig.cjs +0 -60
- package/dist/MimicConfig.d.cts +0 -141
- package/dist/MimicConfig.d.cts.map +0 -1
- package/dist/MimicConfig.d.mts +0 -141
- package/dist/MimicConfig.d.mts.map +0 -1
- package/dist/MimicConfig.mjs +0 -50
- package/dist/MimicConfig.mjs.map +0 -1
- package/dist/MimicDataStorage.cjs +0 -83
- package/dist/MimicDataStorage.d.cts +0 -113
- package/dist/MimicDataStorage.d.cts.map +0 -1
- package/dist/MimicDataStorage.d.mts +0 -113
- package/dist/MimicDataStorage.d.mts.map +0 -1
- package/dist/MimicDataStorage.mjs +0 -74
- package/dist/MimicDataStorage.mjs.map +0 -1
- package/dist/WebSocketHandler.cjs +0 -365
- package/dist/WebSocketHandler.d.cts +0 -34
- package/dist/WebSocketHandler.d.cts.map +0 -1
- package/dist/WebSocketHandler.d.mts +0 -34
- package/dist/WebSocketHandler.d.mts.map +0 -1
- package/dist/WebSocketHandler.mjs +0 -355
- package/dist/WebSocketHandler.mjs.map +0 -1
- package/dist/auth/NoAuth.cjs +0 -43
- package/dist/auth/NoAuth.d.cts +0 -22
- package/dist/auth/NoAuth.d.cts.map +0 -1
- package/dist/auth/NoAuth.d.mts +0 -22
- package/dist/auth/NoAuth.d.mts.map +0 -1
- package/dist/auth/NoAuth.mjs +0 -36
- package/dist/auth/NoAuth.mjs.map +0 -1
- package/dist/errors.cjs +0 -74
- package/dist/errors.d.cts +0 -89
- package/dist/errors.d.cts.map +0 -1
- package/dist/errors.d.mts +0 -89
- package/dist/errors.d.mts.map +0 -1
- package/dist/errors.mjs +0 -67
- package/dist/errors.mjs.map +0 -1
- package/dist/storage/InMemoryDataStorage.cjs +0 -57
- package/dist/storage/InMemoryDataStorage.d.cts +0 -19
- package/dist/storage/InMemoryDataStorage.d.cts.map +0 -1
- package/dist/storage/InMemoryDataStorage.d.mts +0 -19
- package/dist/storage/InMemoryDataStorage.d.mts.map +0 -1
- package/dist/storage/InMemoryDataStorage.mjs +0 -48
- package/dist/storage/InMemoryDataStorage.mjs.map +0 -1
- package/src/DocumentProtocol.ts +0 -112
- package/src/MimicConfig.ts +0 -211
- package/src/MimicDataStorage.ts +0 -157
- package/src/WebSocketHandler.ts +0 -735
- package/src/auth/NoAuth.ts +0 -46
- package/src/errors.ts +0 -113
- package/src/storage/InMemoryDataStorage.ts +0 -66
- package/tests/DocumentProtocol.test.ts +0 -113
- package/tests/InMemoryDataStorage.test.ts +0 -190
- package/tests/MimicConfig.test.ts +0 -290
- package/tests/MimicDataStorage.test.ts +0 -190
- package/tests/NoAuth.test.ts +0 -94
- package/tests/WebSocketHandler.test.ts +0 -321
- 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
|
+
});
|