@voidhash/mimic-effect 1.0.0-beta.6 → 1.0.0-beta.8

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