@voidhash/mimic-effect 1.0.0-beta.7 → 1.0.0-beta.9

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