@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,527 @@
1
+ /**
2
+ * @voidhash/mimic-effect - DocumentInstance
3
+ *
4
+ * Manages the lifecycle of a single document including:
5
+ * - Restoration from storage (cold storage + WAL replay)
6
+ * - Transaction submission with WAL persistence
7
+ * - Snapshot saving and trigger checking
8
+ *
9
+ * Used by both MimicServerEngine (single-node) and MimicClusterServerEngine (clustered).
10
+ */
11
+ import { Duration, Effect, Metric, PubSub, Ref } from "effect";
12
+ import { Document, type Primitive, type Transaction } from "@voidhash/mimic";
13
+ import { ServerDocument } from "@voidhash/mimic/server";
14
+ import type { StoredDocument, WalEntry } from "./Types";
15
+ import type { ServerBroadcast } from "./Protocol";
16
+ import type { ColdStorage } from "./ColdStorage";
17
+ import type { HotStorage } from "./HotStorage";
18
+ import type { ColdStorageError } from "./Errors";
19
+ import type { HotStorageError } from "./Errors";
20
+ import * as Metrics from "./Metrics";
21
+
22
+ // =============================================================================
23
+ // Types
24
+ // =============================================================================
25
+
26
+ /**
27
+ * Result of submitting a transaction
28
+ */
29
+ export type SubmitResult =
30
+ | { readonly success: true; readonly version: number }
31
+ | { readonly success: false; readonly reason: string };
32
+
33
+ /**
34
+ * Configuration for a DocumentInstance
35
+ */
36
+ export interface DocumentInstanceConfig<TSchema extends Primitive.AnyPrimitive> {
37
+ readonly schema: TSchema;
38
+ readonly initial?:
39
+ | Primitive.InferSetInput<TSchema>
40
+ | ((ctx: { documentId: string }) => Effect.Effect<Primitive.InferSetInput<TSchema>>);
41
+ readonly maxTransactionHistory: number;
42
+ readonly snapshot: {
43
+ readonly interval: Duration.Duration;
44
+ readonly transactionThreshold: number;
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Snapshot tracking state
50
+ */
51
+ export interface SnapshotTracking {
52
+ readonly lastSnapshotVersion: number;
53
+ readonly lastSnapshotTime: number;
54
+ readonly transactionsSinceSnapshot: number;
55
+ }
56
+
57
+ /**
58
+ * A DocumentInstance manages a single document's lifecycle
59
+ */
60
+ export interface DocumentInstance<TSchema extends Primitive.AnyPrimitive> {
61
+ /** The underlying ServerDocument */
62
+ readonly document: ServerDocument.ServerDocument<TSchema>;
63
+ /** PubSub for broadcasting messages to subscribers */
64
+ readonly pubsub: PubSub.PubSub<ServerBroadcast>;
65
+ /** Current snapshot tracking state */
66
+ readonly getSnapshotTracking: Effect.Effect<SnapshotTracking>;
67
+ /** Submit a transaction */
68
+ readonly submit: (transaction: Transaction.Transaction) => Effect.Effect<SubmitResult, ColdStorageError | HotStorageError>;
69
+ /** Save a snapshot to cold storage */
70
+ readonly saveSnapshot: () => Effect.Effect<void, ColdStorageError | HotStorageError>;
71
+ /** Check if snapshot should be triggered and save if needed */
72
+ readonly checkSnapshotTriggers: () => Effect.Effect<void, ColdStorageError | HotStorageError>;
73
+ /** Update last activity time (for external tracking) */
74
+ readonly touch: () => Effect.Effect<void>;
75
+ /** Get current document version */
76
+ readonly getVersion: () => number;
77
+ /** Get document snapshot */
78
+ readonly getSnapshot: () => { state: unknown; version: number };
79
+ /** Check if document has unsnapshot transactions that need persisting */
80
+ readonly needsSnapshot: () => Effect.Effect<boolean>;
81
+ /** Get the last activity timestamp for idle detection */
82
+ readonly getLastActivityTime: () => Effect.Effect<number>;
83
+ }
84
+
85
+ // =============================================================================
86
+ // Factory
87
+ // =============================================================================
88
+
89
+ /**
90
+ * Create a DocumentInstance for a single document.
91
+ *
92
+ * This handles:
93
+ * - Loading from cold storage or computing initial state
94
+ * - Persisting initial state immediately (crash safety)
95
+ * - Replaying WAL entries
96
+ * - Transaction submission with WAL persistence
97
+ * - Snapshot saving
98
+ */
99
+ export const make = <TSchema extends Primitive.AnyPrimitive>(
100
+ documentId: string,
101
+ config: DocumentInstanceConfig<TSchema>,
102
+ coldStorage: ColdStorage,
103
+ hotStorage: HotStorage
104
+ ): Effect.Effect<DocumentInstance<TSchema>, ColdStorageError | HotStorageError> =>
105
+ Effect.gen(function* () {
106
+ // Current schema version (hard-coded to 1 for now)
107
+ const SCHEMA_VERSION = 1;
108
+
109
+ // 1. Load snapshot from ColdStorage
110
+ const storedDoc = yield* coldStorage.load(documentId);
111
+
112
+ // Track initial values - only one will be set:
113
+ // - initialState: raw state from storage (already in internal format)
114
+ // - initial: computed from config (needs conversion to state format)
115
+ let initialState: Primitive.InferState<TSchema> | undefined;
116
+ let initial: Primitive.InferSetInput<TSchema> | undefined;
117
+ let initialVersion = 0;
118
+
119
+ if (storedDoc) {
120
+ // Loading from storage - state is already in internal format
121
+ initialState = storedDoc.state as Primitive.InferState<TSchema>;
122
+ initialVersion = storedDoc.version;
123
+ } else {
124
+ // New document - compute initial value (set input format)
125
+ initial = yield* computeInitialState(config, documentId);
126
+ }
127
+
128
+ // 2. Create PubSub for broadcasting
129
+ const pubsub = yield* PubSub.unbounded<ServerBroadcast>();
130
+
131
+ // 3. Create refs for tracking
132
+ const lastSnapshotVersionRef = yield* Ref.make(initialVersion);
133
+ const lastSnapshotTimeRef = yield* Ref.make(Date.now());
134
+ const transactionsSinceSnapshotRef = yield* Ref.make(0);
135
+ const lastActivityTimeRef = yield* Ref.make(Date.now());
136
+
137
+ // 4. Create ServerDocument with callbacks
138
+ const document = ServerDocument.make({
139
+ schema: config.schema,
140
+ initial,
141
+ initialState,
142
+ initialVersion,
143
+ maxTransactionHistory: config.maxTransactionHistory,
144
+ onBroadcast: (message: ServerDocument.TransactionMessage) => {
145
+ Effect.runSync(
146
+ PubSub.publish(pubsub, {
147
+ type: "transaction",
148
+ transaction: message.transaction,
149
+ version: message.version,
150
+ })
151
+ );
152
+ },
153
+ onRejection: (transactionId: string, reason: string) => {
154
+ Effect.runSync(
155
+ PubSub.publish(pubsub, {
156
+ type: "error",
157
+ transactionId,
158
+ reason,
159
+ })
160
+ );
161
+ },
162
+ });
163
+
164
+ // 5. If this is a new document, immediately save to cold storage
165
+ // This ensures the initial state is durable before any transactions are accepted.
166
+ if (!storedDoc) {
167
+ const initialStoredDoc = createStoredDocument(document.get(), 0, SCHEMA_VERSION);
168
+ yield* coldStorage.save(documentId, initialStoredDoc);
169
+ yield* Effect.logDebug("Initial state persisted to cold storage", { documentId });
170
+ }
171
+
172
+ // 6. Load WAL entries
173
+ const walEntries = yield* hotStorage.getEntries(documentId, initialVersion);
174
+
175
+ // 7. Verify WAL continuity (warning only, non-blocking)
176
+ yield* verifyWalContinuity(documentId, walEntries, initialVersion);
177
+
178
+ // 8. Replay WAL entries
179
+ yield* replayWalEntries(documentId, document, walEntries);
180
+
181
+ // Track metrics
182
+ if (storedDoc) {
183
+ yield* Metric.increment(Metrics.documentsRestored);
184
+ } else {
185
+ yield* Metric.increment(Metrics.documentsCreated);
186
+ }
187
+ yield* Metric.incrementBy(Metrics.documentsActive, 1);
188
+
189
+ // ==========================================================================
190
+ // Instance Methods
191
+ // ==========================================================================
192
+
193
+ const getSnapshotTracking = Effect.gen(function* () {
194
+ return {
195
+ lastSnapshotVersion: yield* Ref.get(lastSnapshotVersionRef),
196
+ lastSnapshotTime: yield* Ref.get(lastSnapshotTimeRef),
197
+ transactionsSinceSnapshot: yield* Ref.get(transactionsSinceSnapshotRef),
198
+ };
199
+ });
200
+
201
+ const saveSnapshot = Effect.fn("document.snapshot.save")(function* () {
202
+ const targetVersion = document.getVersion();
203
+ const lastSnapshotVersion = yield* Ref.get(lastSnapshotVersionRef);
204
+
205
+ // Idempotency check: skip if already snapshotted at this version
206
+ if (targetVersion <= lastSnapshotVersion) {
207
+ return;
208
+ }
209
+
210
+ const snapshotStartTime = Date.now();
211
+
212
+ // Load base snapshot from cold storage
213
+ const baseSnapshot = yield* coldStorage.load(documentId);
214
+ const baseVersion = baseSnapshot?.version ?? 0;
215
+ const baseState = baseSnapshot?.state as Primitive.InferState<TSchema> | undefined;
216
+
217
+ // Load WAL entries from base to target
218
+ const walEntries = yield* hotStorage.getEntries(documentId, baseVersion);
219
+
220
+ // Compute snapshot state by replaying WAL on base
221
+ const snapshotResult = computeSnapshotState(
222
+ config.schema,
223
+ baseState,
224
+ walEntries,
225
+ targetVersion
226
+ );
227
+
228
+ if (!snapshotResult) {
229
+ return;
230
+ }
231
+
232
+ // Re-check before saving (in case another snapshot completed while we were working)
233
+ const currentLastSnapshot = yield* Ref.get(lastSnapshotVersionRef);
234
+ if (snapshotResult.version <= currentLastSnapshot) {
235
+ return;
236
+ }
237
+
238
+ const storedDoc = createStoredDocument(
239
+ snapshotResult.state,
240
+ snapshotResult.version,
241
+ SCHEMA_VERSION
242
+ );
243
+
244
+ // Save to ColdStorage
245
+ yield* coldStorage.save(documentId, storedDoc);
246
+
247
+ // Track snapshot metrics
248
+ const snapshotDuration = Date.now() - snapshotStartTime;
249
+ yield* Metric.increment(Metrics.storageSnapshots);
250
+ yield* Metric.update(Metrics.storageSnapshotLatency, snapshotDuration);
251
+
252
+ // Update tracking BEFORE truncate (for idempotency on retry)
253
+ yield* Ref.set(lastSnapshotVersionRef, snapshotResult.version);
254
+ yield* Ref.set(lastSnapshotTimeRef, Date.now());
255
+ yield* Ref.set(transactionsSinceSnapshotRef, 0);
256
+
257
+ // Truncate WAL - non-fatal, will be retried on next snapshot
258
+ yield* Effect.catchAll(hotStorage.truncate(documentId, snapshotResult.version), (e) =>
259
+ Effect.logWarning("WAL truncate failed - will retry on next snapshot", {
260
+ documentId,
261
+ version: snapshotResult.version,
262
+ error: e,
263
+ })
264
+ );
265
+ });
266
+
267
+ const checkSnapshotTriggers = Effect.fn("document.snapshot.check-triggers")(function* () {
268
+ const txCount = yield* Ref.get(transactionsSinceSnapshotRef);
269
+ const lastTime = yield* Ref.get(lastSnapshotTimeRef);
270
+
271
+ if (shouldTriggerSnapshot(txCount, lastTime, config.snapshot)) {
272
+ yield* saveSnapshot();
273
+ }
274
+ });
275
+
276
+ const submit = Effect.fn("document.transaction.submit")(function* (
277
+ transaction: Transaction.Transaction
278
+ ) {
279
+ const submitStartTime = Date.now();
280
+
281
+ // Update activity time
282
+ yield* Ref.set(lastActivityTimeRef, Date.now());
283
+
284
+ // Phase 1: Validate (no side effects)
285
+ const validation = document.validate(transaction);
286
+
287
+ if (!validation.valid) {
288
+ yield* Metric.increment(Metrics.transactionsRejected);
289
+ const latency = Date.now() - submitStartTime;
290
+ yield* Metric.update(Metrics.transactionsLatency, latency);
291
+
292
+ return {
293
+ success: false as const,
294
+ reason: validation.reason,
295
+ };
296
+ }
297
+
298
+ // Phase 2: Append to WAL with gap check (BEFORE state mutation)
299
+ const walEntry: WalEntry = {
300
+ transaction,
301
+ version: validation.nextVersion,
302
+ timestamp: Date.now(),
303
+ };
304
+
305
+ const appendResult = yield* Effect.either(
306
+ hotStorage.appendWithCheck(documentId, walEntry, validation.nextVersion)
307
+ );
308
+
309
+ if (appendResult._tag === "Left") {
310
+ yield* Effect.logError("WAL append failed", {
311
+ documentId,
312
+ version: validation.nextVersion,
313
+ error: appendResult.left,
314
+ });
315
+ yield* Metric.increment(Metrics.walAppendFailures);
316
+
317
+ const latency = Date.now() - submitStartTime;
318
+ yield* Metric.update(Metrics.transactionsLatency, latency);
319
+
320
+ return {
321
+ success: false as const,
322
+ reason: "Storage unavailable. Please retry.",
323
+ };
324
+ }
325
+
326
+ // Phase 3: Apply (state mutation + broadcast)
327
+ document.apply(transaction);
328
+
329
+ // Track metrics
330
+ const latency = Date.now() - submitStartTime;
331
+ yield* Metric.update(Metrics.transactionsLatency, latency);
332
+ yield* Metric.increment(Metrics.transactionsProcessed);
333
+ yield* Metric.increment(Metrics.storageWalAppends);
334
+
335
+ // Increment transaction count
336
+ yield* Ref.update(transactionsSinceSnapshotRef, (n) => n + 1);
337
+
338
+ // Check snapshot triggers
339
+ yield* checkSnapshotTriggers();
340
+
341
+ return {
342
+ success: true as const,
343
+ version: validation.nextVersion,
344
+ };
345
+ });
346
+
347
+ const touch = Effect.fn("document.touch")(function* () {
348
+ yield* Ref.set(lastActivityTimeRef, Date.now());
349
+ });
350
+
351
+ const needsSnapshot = () =>
352
+ Effect.map(Ref.get(transactionsSinceSnapshotRef), (n) => n > 0);
353
+
354
+ const getLastActivityTime = () => Ref.get(lastActivityTimeRef);
355
+
356
+ return {
357
+ document,
358
+ pubsub,
359
+ getSnapshotTracking,
360
+ submit,
361
+ saveSnapshot,
362
+ checkSnapshotTriggers,
363
+ touch,
364
+ getVersion: () => document.getVersion(),
365
+ getSnapshot: () => document.getSnapshot(),
366
+ needsSnapshot,
367
+ getLastActivityTime,
368
+ };
369
+ });
370
+
371
+ // =============================================================================
372
+ // Helper Functions
373
+ // =============================================================================
374
+
375
+ /**
376
+ * Compute initial state for a new document.
377
+ */
378
+ const computeInitialState = <TSchema extends Primitive.AnyPrimitive>(
379
+ config: DocumentInstanceConfig<TSchema>,
380
+ documentId: string
381
+ ): Effect.Effect<Primitive.InferSetInput<TSchema> | undefined> => {
382
+ if (config.initial === undefined) {
383
+ return Effect.succeed(undefined);
384
+ }
385
+
386
+ if (typeof config.initial === "function") {
387
+ return (config.initial as (ctx: { documentId: string }) => Effect.Effect<Primitive.InferSetInput<TSchema>>)({
388
+ documentId,
389
+ });
390
+ }
391
+
392
+ return Effect.succeed(config.initial as Primitive.InferSetInput<TSchema>);
393
+ };
394
+
395
+ /**
396
+ * Verify WAL continuity and log warnings for any gaps.
397
+ */
398
+ const verifyWalContinuity = Effect.fn("document.wal.verify")(function* (
399
+ documentId: string,
400
+ walEntries: readonly WalEntry[],
401
+ baseVersion: number
402
+ ) {
403
+ if (walEntries.length === 0) {
404
+ return;
405
+ }
406
+
407
+ const firstWalVersion = walEntries[0]!.version;
408
+ const expectedFirst = baseVersion + 1;
409
+
410
+ if (firstWalVersion !== expectedFirst) {
411
+ yield* Effect.logWarning("WAL version gap detected", {
412
+ documentId,
413
+ snapshotVersion: baseVersion,
414
+ firstWalVersion,
415
+ expectedFirst,
416
+ });
417
+ yield* Metric.increment(Metrics.storageVersionGaps);
418
+ }
419
+
420
+ for (let i = 1; i < walEntries.length; i++) {
421
+ const prev = walEntries[i - 1]!.version;
422
+ const curr = walEntries[i]!.version;
423
+ if (curr !== prev + 1) {
424
+ yield* Effect.logWarning("WAL internal gap detected", {
425
+ documentId,
426
+ previousVersion: prev,
427
+ currentVersion: curr,
428
+ });
429
+ }
430
+ }
431
+ });
432
+
433
+ /**
434
+ * Replay WAL entries onto a ServerDocument.
435
+ */
436
+ const replayWalEntries = Effect.fn("document.wal.replay")(function* (
437
+ documentId: string,
438
+ document: ServerDocument.ServerDocument<Primitive.AnyPrimitive>,
439
+ walEntries: readonly WalEntry[]
440
+ ) {
441
+ for (const entry of walEntries) {
442
+ const result = document.submit(entry.transaction);
443
+ if (!result.success) {
444
+ yield* Effect.logWarning("Skipping corrupted WAL entry", {
445
+ documentId,
446
+ version: entry.version,
447
+ reason: result.reason,
448
+ });
449
+ }
450
+ }
451
+ });
452
+
453
+ /**
454
+ * Compute snapshot state by replaying WAL entries on a base state.
455
+ */
456
+ const computeSnapshotState = <TSchema extends Primitive.AnyPrimitive>(
457
+ schema: TSchema,
458
+ baseState: Primitive.InferState<TSchema> | undefined,
459
+ walEntries: readonly WalEntry[],
460
+ targetVersion: number
461
+ ): { state: Primitive.InferState<TSchema>; version: number } | undefined => {
462
+ const relevantEntries = walEntries.filter((e) => e.version <= targetVersion);
463
+
464
+ if (relevantEntries.length === 0 && baseState === undefined) {
465
+ return undefined;
466
+ }
467
+
468
+ let snapshotState: Primitive.InferState<TSchema> | undefined = baseState;
469
+ for (const entry of relevantEntries) {
470
+ const tempDoc = Document.make(schema, { initialState: snapshotState });
471
+ tempDoc.apply(entry.transaction.ops);
472
+ snapshotState = tempDoc.get();
473
+ }
474
+
475
+ if (snapshotState === undefined) {
476
+ return undefined;
477
+ }
478
+
479
+ const snapshotVersion =
480
+ relevantEntries.length > 0 ? relevantEntries[relevantEntries.length - 1]!.version : 0;
481
+
482
+ return { state: snapshotState, version: snapshotVersion };
483
+ };
484
+
485
+ /**
486
+ * Check if a snapshot should be triggered.
487
+ */
488
+ const shouldTriggerSnapshot = (
489
+ transactionsSinceSnapshot: number,
490
+ lastSnapshotTime: number,
491
+ config: { interval: Duration.Duration; transactionThreshold: number }
492
+ ): boolean => {
493
+ const now = Date.now();
494
+ const intervalMs = Duration.toMillis(config.interval);
495
+
496
+ if (transactionsSinceSnapshot >= config.transactionThreshold) {
497
+ return true;
498
+ }
499
+
500
+ if (now - lastSnapshotTime >= intervalMs) {
501
+ return true;
502
+ }
503
+
504
+ return false;
505
+ };
506
+
507
+ /**
508
+ * Create a StoredDocument for persistence.
509
+ */
510
+ const createStoredDocument = (
511
+ state: unknown,
512
+ version: number,
513
+ schemaVersion: number
514
+ ): StoredDocument => ({
515
+ state,
516
+ version,
517
+ schemaVersion,
518
+ savedAt: Date.now(),
519
+ });
520
+
521
+ // =============================================================================
522
+ // Re-export namespace
523
+ // =============================================================================
524
+
525
+ export const DocumentInstance = {
526
+ make,
527
+ };
package/src/Errors.ts CHANGED
@@ -23,10 +23,23 @@ export class ColdStorageError extends Data.TaggedError("ColdStorageError")<{
23
23
  */
24
24
  export class HotStorageError extends Data.TaggedError("HotStorageError")<{
25
25
  readonly documentId: string;
26
- readonly operation: "append" | "getEntries" | "truncate";
26
+ readonly operation: "append" | "getEntries" | "truncate" | "appendWithCheck";
27
27
  readonly cause: unknown;
28
28
  }> {}
29
29
 
30
+ /**
31
+ * Error when WAL append detects a version gap.
32
+ * This indicates either:
33
+ * - A bug in the application (skipped a version)
34
+ * - Concurrent writes to the same document
35
+ * - Data corruption in WAL storage
36
+ */
37
+ export class WalVersionGapError extends Data.TaggedError("WalVersionGapError")<{
38
+ readonly documentId: string;
39
+ readonly expectedVersion: number;
40
+ readonly actualPreviousVersion: number | undefined;
41
+ }> {}
42
+
30
43
  // =============================================================================
31
44
  // Auth Errors
32
45
  // =============================================================================
@@ -93,6 +106,7 @@ export class TransactionRejectedError extends Data.TaggedError(
93
106
  export type MimicError =
94
107
  | ColdStorageError
95
108
  | HotStorageError
109
+ | WalVersionGapError
96
110
  | AuthenticationError
97
111
  | AuthorizationError
98
112
  | MissingDocumentIdError