@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
@@ -13,32 +13,29 @@ import {
13
13
  HashMap,
14
14
  Layer,
15
15
  Metric,
16
- Option,
17
16
  PubSub,
18
17
  Ref,
19
18
  Schema,
20
- Scope,
21
19
  Stream,
22
20
  } from "effect";
23
21
  import { Entity, Sharding } from "@effect/cluster";
24
22
  import { Rpc } from "@effect/rpc";
25
- import { Document, type Presence, type Primitive, type Transaction } from "@voidhash/mimic";
26
- import { ServerDocument } from "@voidhash/mimic/server";
23
+ import { type Primitive, type Transaction } from "@voidhash/mimic";
27
24
  import type {
28
25
  MimicClusterServerEngineConfig,
29
26
  PresenceEntry,
30
27
  PresenceEvent,
31
- PresenceSnapshot,
32
28
  ResolvedClusterConfig,
33
- StoredDocument,
34
- WalEntry,
35
29
  } from "./Types";
36
30
  import type * as Protocol from "./Protocol";
37
31
  import { ColdStorageTag, type ColdStorage } from "./ColdStorage";
38
32
  import { HotStorageTag, type HotStorage } from "./HotStorage";
39
33
  import { MimicAuthServiceTag } from "./MimicAuthService";
40
34
  import { MimicServerEngineTag, type MimicServerEngine } from "./MimicServerEngine";
41
- import type { SubmitResult } from "./DocumentManager";
35
+ import {
36
+ DocumentInstance,
37
+ type DocumentInstance as DocumentInstanceInterface,
38
+ } from "./DocumentInstance";
42
39
  import * as Metrics from "./Metrics";
43
40
 
44
41
  // =============================================================================
@@ -172,16 +169,12 @@ const MimicDocumentEntity = Entity.make("MimicDocument", [
172
169
  // =============================================================================
173
170
 
174
171
  /**
175
- * Document state managed by the entity
172
+ * Entity state that wraps DocumentInstance and adds presence management
176
173
  */
177
- interface EntityDocumentState<TSchema extends Primitive.AnyPrimitive> {
178
- readonly document: ServerDocument.ServerDocument<TSchema>;
179
- readonly broadcastPubSub: PubSub.PubSub<Protocol.ServerMessage>;
174
+ interface EntityState<TSchema extends Primitive.AnyPrimitive> {
175
+ readonly instance: DocumentInstanceInterface<TSchema>;
180
176
  readonly presences: HashMap.HashMap<string, PresenceEntry>;
181
177
  readonly presencePubSub: PubSub.PubSub<PresenceEvent>;
182
- readonly lastSnapshotVersion: number;
183
- readonly lastSnapshotTime: number;
184
- readonly transactionsSinceSnapshot: number;
185
178
  }
186
179
 
187
180
  // =============================================================================
@@ -262,283 +255,37 @@ const createEntityHandler = <TSchema extends Primitive.AnyPrimitive>(
262
255
  const address = yield* Entity.CurrentAddress;
263
256
  const documentId = address.entityId;
264
257
 
265
- // Current schema version (hard-coded to 1 for now)
266
- const SCHEMA_VERSION = 1;
267
-
268
- // Compute initial state
269
- const computeInitialState = (): Effect.Effect<
270
- Primitive.InferSetInput<TSchema> | undefined
271
- > => {
272
- if (config.initial === undefined) {
273
- return Effect.succeed(undefined);
274
- }
275
-
276
- if (typeof config.initial === "function") {
277
- return (
278
- config.initial as (ctx: {
279
- documentId: string;
280
- }) => Effect.Effect<Primitive.InferSetInput<TSchema>>
281
- )({ documentId });
282
- }
283
-
284
- return Effect.succeed(
285
- config.initial as Primitive.InferSetInput<TSchema>
286
- );
287
- };
288
-
289
- // Load snapshot from ColdStorage (fatal if unavailable - entity cannot start)
290
- const storedDoc = yield* coldStorage.load(documentId).pipe(
291
- Effect.orDie // Entity cannot initialize without storage
292
- );
293
-
294
- let initialState: Primitive.InferSetInput<TSchema> | undefined;
295
- let initialVersion = 0;
296
-
297
- if (storedDoc) {
298
- initialState =
299
- storedDoc.state as Primitive.InferSetInput<TSchema>;
300
- initialVersion = storedDoc.version;
301
- } else {
302
- initialState = yield* computeInitialState();
303
- }
258
+ // Create DocumentInstance (fatal if unavailable - entity cannot start)
259
+ const instance = yield* DocumentInstance.make(
260
+ documentId,
261
+ {
262
+ schema: config.schema,
263
+ initial: config.initial,
264
+ maxTransactionHistory: config.maxTransactionHistory,
265
+ snapshot: config.snapshot,
266
+ },
267
+ coldStorage,
268
+ hotStorage
269
+ ).pipe(Effect.orDie);
304
270
 
305
- // Create PubSubs for broadcasting
306
- const broadcastPubSub = yield* PubSub.unbounded<Protocol.ServerMessage>();
271
+ // Create presence PubSub and state ref
307
272
  const presencePubSub = yield* PubSub.unbounded<PresenceEvent>();
308
-
309
- // Create state ref
310
- const stateRef = yield* Ref.make<EntityDocumentState<TSchema>>({
311
- document: undefined as unknown as ServerDocument.ServerDocument<TSchema>,
312
- broadcastPubSub,
273
+ const stateRef = yield* Ref.make<EntityState<TSchema>>({
274
+ instance,
313
275
  presences: HashMap.empty(),
314
276
  presencePubSub,
315
- lastSnapshotVersion: initialVersion,
316
- lastSnapshotTime: Date.now(),
317
- transactionsSinceSnapshot: 0,
318
- });
319
-
320
- // Create ServerDocument with callbacks
321
- const document = ServerDocument.make({
322
- schema: config.schema,
323
- initialState,
324
- initialVersion,
325
- maxTransactionHistory: config.maxTransactionHistory,
326
- onBroadcast: (message: ServerDocument.TransactionMessage) => {
327
- Effect.runSync(
328
- PubSub.publish(broadcastPubSub, {
329
- type: "transaction",
330
- transaction: message.transaction,
331
- version: message.version,
332
- } as Protocol.ServerMessage)
333
- );
334
- },
335
- onRejection: (transactionId: string, reason: string) => {
336
- Effect.runSync(
337
- PubSub.publish(broadcastPubSub, {
338
- type: "error",
339
- transactionId,
340
- reason,
341
- } as Protocol.ServerMessage)
342
- );
343
- },
344
- });
345
-
346
- // Update state with document
347
- yield* Ref.update(stateRef, (s) => ({ ...s, document }));
348
-
349
- // Load WAL entries (fatal if unavailable - entity cannot start)
350
- const walEntries = yield* hotStorage.getEntries(documentId, initialVersion).pipe(
351
- Effect.orDie // Entity cannot initialize without storage
352
- );
353
-
354
- // Verify WAL continuity (warning only, non-blocking)
355
- if (walEntries.length > 0) {
356
- const firstWalVersion = walEntries[0]!.version;
357
- const expectedFirst = initialVersion + 1;
358
-
359
- if (firstWalVersion !== expectedFirst) {
360
- yield* Effect.logWarning("WAL version gap detected", {
361
- documentId,
362
- snapshotVersion: initialVersion,
363
- firstWalVersion,
364
- expectedFirst,
365
- });
366
- yield* Metric.increment(Metrics.storageVersionGaps);
367
- }
368
-
369
- // Check internal gaps
370
- for (let i = 1; i < walEntries.length; i++) {
371
- const prev = walEntries[i - 1]!.version;
372
- const curr = walEntries[i]!.version;
373
- if (curr !== prev + 1) {
374
- yield* Effect.logWarning("WAL internal gap detected", {
375
- documentId,
376
- previousVersion: prev,
377
- currentVersion: curr,
378
- });
379
- }
380
- }
381
- }
382
-
383
- // Replay WAL entries
384
- for (const entry of walEntries) {
385
- const result = document.submit(entry.transaction);
386
- if (!result.success) {
387
- yield* Effect.logWarning("Skipping corrupted WAL entry", {
388
- documentId,
389
- version: entry.version,
390
- reason: result.reason,
391
- });
392
- }
393
- }
394
-
395
- // Track metrics
396
- if (storedDoc) {
397
- yield* Metric.increment(Metrics.documentsRestored);
398
- } else {
399
- yield* Metric.increment(Metrics.documentsCreated);
400
- }
401
- yield* Metric.incrementBy(Metrics.documentsActive, 1);
402
-
403
- /**
404
- * Save snapshot to ColdStorage derived from WAL entries.
405
- * This ensures snapshots are always based on durable WAL data.
406
- * Idempotent: skips save if already snapshotted at target version.
407
- * Truncate failures are non-fatal and will be retried on next snapshot.
408
- */
409
- const saveSnapshot = Effect.fn("cluster.document.snapshot.save")(
410
- function* (targetVersion: number) {
411
- const state = yield* Ref.get(stateRef);
412
-
413
- // Idempotency check: skip if already snapshotted at this version
414
- if (targetVersion <= state.lastSnapshotVersion) {
415
- return;
416
- }
417
-
418
- const snapshotStartTime = Date.now();
419
-
420
- // Load base snapshot from cold storage (best effort - log error but don't crash entity)
421
- const baseSnapshotResult = yield* Effect.either(coldStorage.load(documentId));
422
- if (baseSnapshotResult._tag === "Left") {
423
- yield* Effect.logError("Failed to load base snapshot for WAL replay", {
424
- documentId,
425
- error: baseSnapshotResult.left,
426
- });
427
- return;
428
- }
429
- const baseSnapshot = baseSnapshotResult.right;
430
- const baseVersion = baseSnapshot?.version ?? 0;
431
- const baseState = baseSnapshot?.state as Primitive.InferState<TSchema> | undefined;
432
-
433
- // Load WAL entries from base to target
434
- const walEntriesResult = yield* Effect.either(hotStorage.getEntries(documentId, baseVersion));
435
- if (walEntriesResult._tag === "Left") {
436
- yield* Effect.logError("Failed to load WAL entries for snapshot", {
437
- documentId,
438
- error: walEntriesResult.left,
439
- });
440
- return;
441
- }
442
- const walEntries = walEntriesResult.right;
443
- const relevantEntries = walEntries.filter(e => e.version <= targetVersion);
444
-
445
- if (relevantEntries.length === 0 && !baseSnapshot) {
446
- // Nothing to snapshot
447
- return;
448
- }
449
-
450
- // Rebuild state by replaying WAL on base
451
- let snapshotState: Primitive.InferState<TSchema> | undefined = baseState;
452
- for (const entry of relevantEntries) {
453
- // Create a temporary document to apply the transaction
454
- const tempDoc = Document.make(config.schema, { initialState: snapshotState });
455
- tempDoc.apply(entry.transaction.ops);
456
- snapshotState = tempDoc.get();
457
- }
458
-
459
- if (snapshotState === undefined) {
460
- return;
461
- }
462
-
463
- const snapshotVersion = relevantEntries.length > 0
464
- ? relevantEntries[relevantEntries.length - 1]!.version
465
- : baseVersion;
466
-
467
- // Re-check before saving (in case another snapshot completed while we were working)
468
- // This prevents a slower snapshot from overwriting a more recent one
469
- const currentState = yield* Ref.get(stateRef);
470
- if (snapshotVersion <= currentState.lastSnapshotVersion) {
471
- return;
472
- }
473
-
474
- const storedDocument: StoredDocument = {
475
- state: snapshotState,
476
- version: snapshotVersion,
477
- schemaVersion: SCHEMA_VERSION,
478
- savedAt: Date.now(),
479
- };
480
-
481
- // Save to ColdStorage (best effort - log error but don't crash entity)
482
- yield* Effect.catchAll(
483
- coldStorage.save(documentId, storedDocument),
484
- (e) =>
485
- Effect.logError("Failed to save snapshot", { documentId, error: e })
486
- );
487
-
488
- const snapshotDuration = Date.now() - snapshotStartTime;
489
- yield* Metric.increment(Metrics.storageSnapshots);
490
- yield* Metric.update(Metrics.storageSnapshotLatency, snapshotDuration);
491
-
492
- // Update tracking BEFORE truncate (for idempotency on retry)
493
- yield* Ref.update(stateRef, (s) => ({
494
- ...s,
495
- lastSnapshotVersion: snapshotVersion,
496
- lastSnapshotTime: Date.now(),
497
- transactionsSinceSnapshot: 0,
498
- }));
499
-
500
- // Truncate WAL - non-fatal, will be retried on next snapshot
501
- yield* Effect.catchAll(hotStorage.truncate(documentId, snapshotVersion), (e) =>
502
- Effect.logWarning("WAL truncate failed - will retry on next snapshot", {
503
- documentId,
504
- version: snapshotVersion,
505
- error: e,
506
- })
507
- );
508
- }
509
- );
510
-
511
- /**
512
- * Check if snapshot should be triggered
513
- */
514
- const checkSnapshotTriggers = Effect.fn(
515
- "cluster.document.snapshot.check-triggers"
516
- )(function* () {
517
- const state = yield* Ref.get(stateRef);
518
- const now = Date.now();
519
- const currentVersion = state.document.getVersion();
520
-
521
- const intervalMs = Duration.toMillis(config.snapshot.interval);
522
- const threshold = config.snapshot.transactionThreshold;
523
-
524
- if (state.transactionsSinceSnapshot >= threshold) {
525
- yield* saveSnapshot(currentVersion);
526
- return;
527
- }
528
-
529
- if (now - state.lastSnapshotTime >= intervalMs) {
530
- yield* saveSnapshot(currentVersion);
531
- return;
532
- }
533
277
  });
534
278
 
535
279
  // Cleanup on entity finalization
536
280
  yield* Effect.addFinalizer(() =>
537
281
  Effect.fn("cluster.entity.finalize")(function* () {
538
- // Save final snapshot before entity is garbage collected
539
- const state = yield* Ref.get(stateRef);
540
- const currentVersion = state.document.getVersion();
541
- yield* saveSnapshot(currentVersion);
282
+ // Best effort save - don't fail shutdown if storage is unavailable
283
+ yield* Effect.catchAll(instance.saveSnapshot(), (e) =>
284
+ Effect.logError("Failed to save snapshot during entity finalization", {
285
+ documentId,
286
+ error: e,
287
+ })
288
+ );
542
289
  yield* Metric.incrementBy(Metrics.documentsActive, -1);
543
290
  yield* Metric.increment(Metrics.documentsEvicted);
544
291
  yield* Effect.logDebug("Entity finalized", { documentId });
@@ -550,90 +297,26 @@ const createEntityHandler = <TSchema extends Primitive.AnyPrimitive>(
550
297
  Submit: Effect.fn("cluster.document.transaction.submit")(function* ({
551
298
  payload,
552
299
  }) {
553
- const submitStartTime = Date.now();
554
- const state = yield* Ref.get(stateRef);
555
-
556
300
  // Decode transaction
557
301
  const transaction = decodeTransaction(payload.transaction);
558
302
 
559
- // Phase 1: Validate (no side effects)
560
- const validation = state.document.validate(transaction);
561
-
562
- if (!validation.valid) {
563
- // Track rejection
564
- yield* Metric.increment(Metrics.transactionsRejected);
565
- const latency = Date.now() - submitStartTime;
566
- yield* Metric.update(Metrics.transactionsLatency, latency);
567
-
568
- return {
569
- success: false as const,
570
- reason: validation.reason,
571
- };
572
- }
573
-
574
- // Phase 2: Append to WAL with gap check (BEFORE state mutation)
575
- const walEntry: WalEntry = {
576
- transaction,
577
- version: validation.nextVersion,
578
- timestamp: Date.now(),
579
- };
580
-
581
- const appendResult = yield* Effect.either(
582
- hotStorage.appendWithCheck(documentId, walEntry, validation.nextVersion)
303
+ // Use DocumentInstance's submit method, catching storage errors
304
+ return yield* instance.submit(transaction).pipe(
305
+ Effect.catchAll((error) =>
306
+ Effect.succeed({
307
+ success: false as const,
308
+ reason: `Storage error: ${String(error)}`,
309
+ })
310
+ )
583
311
  );
584
-
585
- if (appendResult._tag === "Left") {
586
- // WAL append failed - do NOT apply, state unchanged
587
- yield* Effect.logError("WAL append failed", {
588
- documentId,
589
- version: validation.nextVersion,
590
- error: appendResult.left,
591
- });
592
- yield* Metric.increment(Metrics.walAppendFailures);
593
-
594
- const latency = Date.now() - submitStartTime;
595
- yield* Metric.update(Metrics.transactionsLatency, latency);
596
-
597
- // Return failure - client must retry
598
- return {
599
- success: false as const,
600
- reason: "Storage unavailable. Please retry.",
601
- };
602
- }
603
-
604
- // Phase 3: Apply (state mutation + broadcast)
605
- state.document.apply(transaction);
606
-
607
- // Track metrics
608
- const latency = Date.now() - submitStartTime;
609
- yield* Metric.update(Metrics.transactionsLatency, latency);
610
- yield* Metric.increment(Metrics.transactionsProcessed);
611
- yield* Metric.increment(Metrics.storageWalAppends);
612
-
613
- // Increment transaction count
614
- yield* Ref.update(stateRef, (s) => ({
615
- ...s,
616
- transactionsSinceSnapshot: s.transactionsSinceSnapshot + 1,
617
- }));
618
-
619
- // Check snapshot triggers
620
- yield* checkSnapshotTriggers();
621
-
622
- return {
623
- success: true as const,
624
- version: validation.nextVersion,
625
- };
626
312
  }),
627
313
 
628
314
  GetSnapshot: Effect.fn("cluster.document.snapshot.get")(function* () {
629
- const state = yield* Ref.get(stateRef);
630
- return state.document.getSnapshot();
315
+ return instance.getSnapshot();
631
316
  }),
632
317
 
633
318
  Touch: Effect.fn("cluster.document.touch")(function* () {
634
- // Entity touch is handled automatically by the cluster framework
635
- // Just update last activity time conceptually
636
- return void 0;
319
+ yield* instance.touch();
637
320
  }),
638
321
 
639
322
  SetPresence: Effect.fn("cluster.presence.set")(function* ({ payload }) {