Skip to content

Commit 7216d47

Browse files
committed
Add Multiton instance manager and integrate with Evolu
1 parent 3eb6f20 commit 7216d47

6 files changed

Lines changed: 386 additions & 25 deletions

File tree

.changeset/wide-rocks-beam.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@evolu/common": patch
3+
---
4+
5+
Add Multiton
6+
7+
Multiton manages multiple named instances using a key-based registry with structured disposal. It's used internally for Evolu instance caching to support hot reloading and prevent database corruption from multiple connections.
8+
9+
See the Multiton documentation for usage patterns and caveats.

packages/common/src/Evolu/Evolu.ts

Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { RandomBytesDep, SymmetricCryptoDecryptError } from "../Crypto.js";
77
import { eqArrayNumber } from "../Eq.js";
88
import { TransferableError } from "../Error.js";
99
import { exhaustiveCheck } from "../Function.js";
10+
import { createMultiton, Multiton } from "../Multiton.js";
1011
import { err, ok, Result } from "../Result.js";
1112
import { isSqlMutation, SafeSql, SqliteError, SqliteQuery } from "../Sqlite.js";
1213
import { createStore, StoreSubscribe } from "../Store.js";
@@ -19,6 +20,7 @@ import {
1920
InferType,
2021
Mnemonic,
2122
ObjectType,
23+
SimpleName,
2224
ValidMutationSize,
2325
ValidMutationSizeError,
2426
} from "../Type.js";
@@ -92,7 +94,7 @@ export interface EvoluConfig extends Partial<DbConfig> {
9294
readonly reloadUrl?: string;
9395
}
9496

95-
export interface Evolu<S extends EvoluSchema = EvoluSchema> {
97+
export interface Evolu<S extends EvoluSchema = EvoluSchema> extends Disposable {
9698
/**
9799
* Subscribe to {@link EvoluError} changes.
98100
*
@@ -457,8 +459,12 @@ export type EvoluDeps = ConsoleDep &
457459
ReloadAppDep &
458460
TimeDep;
459461

460-
const evoluInstances = new Map<string, InternalEvoluInstance>();
462+
const evoluInstances = createMultiton<SimpleName, InternalEvoluInstance>();
461463

464+
/**
465+
* Unique identifier for the current browser tab or app instance, lazily
466+
* initialized on first use to distinguish between multiple tabs.
467+
*/
462468
let tabId: Id | null = null;
463469

464470
/**
@@ -496,35 +502,25 @@ let tabId: Id | null = null;
496502
*
497503
* ### Instance Caching
498504
*
499-
* Evolu caches instances by {@link EvoluConfig} name to enable hot reloading and
500-
* multitenancy. Multiple calls to `createEvolu` with the same name return the
501-
* same instance, preserving database connections and state across module
502-
* reloads during development. This ensures a seamless developer experience
503-
* where edits don't interrupt ongoing sync or lose in-memory state.
504-
*
505-
* For testing, either dispose of instances after each test (TODO: implement
506-
* dispose method) or use unique instance names to ensure proper isolation
507-
* between test cases.
505+
* `createEvolu` caches instances using {@link Multiton} by {@link EvoluConfig}
506+
* name to enable hot reloading and prevent database corruption from multiple
507+
* connections. For testing, use unique instance names to ensure proper
508+
* isolation.
508509
*/
509510
export const createEvolu =
510511
(deps: EvoluDeps) =>
511512
<S extends EvoluSchema>(
512513
schema: ValidateSchema<S> extends never ? S : ValidateSchema<S>,
513514
config?: EvoluConfig,
514-
): Evolu<S> => {
515-
const name = config?.name ?? defaultDbConfig.name;
516-
let evolu = evoluInstances.get(name);
517-
518-
if (evolu == null) {
519-
evolu = createEvoluInstance(deps)(schema as EvoluSchema, config);
520-
evoluInstances.set(name, evolu);
521-
} else {
522-
// Hot reloading. Note that indexes are intentionally omitted.
523-
evolu.ensureSchema(schema as EvoluSchema);
524-
}
525-
526-
return evolu as Evolu<S>;
527-
};
515+
): Evolu<S> =>
516+
evoluInstances.ensure(
517+
config?.name ?? defaultDbConfig.name,
518+
() => createEvoluInstance(deps)(schema as EvoluSchema, config),
519+
(evolu) => {
520+
// Hot reloading. Note that indexes are intentionally omitted.
521+
evolu.ensureSchema(schema as EvoluSchema);
522+
},
523+
) as Evolu<S>;
528524

529525
const createEvoluInstance =
530526
(deps: EvoluDeps) =>
@@ -943,6 +939,11 @@ const createEvoluInstance =
943939

944940
return unuse;
945941
},
942+
943+
/** Disposal is not implemented yet. */
944+
[Symbol.dispose]: () => {
945+
throw new Error("Evolu instance disposal is not yet implemented");
946+
},
946947
};
947948

948949
return evolu;

packages/common/src/Multiton.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* Manages multiple named instances using the Multiton pattern.
3+
*
4+
* Unlike Singleton (one instance globally), Multiton maintains one instance per
5+
* unique key.
6+
*
7+
* **Note:** Multiton is generally considered an anti-pattern because it
8+
* introduces hidden global state and makes testing harder. Use it only when
9+
* there's a compelling reason, such as:
10+
*
11+
* - Supporting hot reloading while preserving state across module reloads
12+
* - Enforcing physical constraints (e.g., preventing multiple SQLite connections
13+
* to the same database, which causes corruption)
14+
* - Managing resources where instance identity is intrinsic to correctness
15+
*
16+
* For most cases, prefer explicit dependency injection and instance management.
17+
*
18+
* Compatibility and future work:
19+
*
20+
* - We will adopt the ECMAScript `DisposableStack` for structured cleanup and
21+
* robust error handling as runtimes converge (Node.js ≥ 24, Safari stable).
22+
* Safari Technology Preview already includes support, so broad availability
23+
* is expected soon.
24+
* - Until then, this module uses a simple Map-based approach and calls
25+
* `instance[Symbol.dispose]()` directly during disposal.
26+
* - We don't use a polyfill because we avoid global mutation, keep bundles lean,
27+
* and prefer explicit feature detection.
28+
* - MDN reference:
29+
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DisposableStack
30+
*/
31+
export interface Multiton<K extends string, T extends Disposable>
32+
extends Disposable {
33+
/**
34+
* Ensures an instance exists for the given key, creating it if necessary. If
35+
* the instance already exists, the optional `onCacheHit` callback is invoked
36+
* to update the existing instance.
37+
*/
38+
readonly ensure: (
39+
key: K,
40+
create: () => T,
41+
onCacheHit?: (instance: T) => void,
42+
) => T;
43+
44+
/** Gets an instance by key, or returns `null` if it doesn't exist. */
45+
readonly get: (key: K) => T | null;
46+
47+
/** Checks if an instance exists for the given key. */
48+
readonly has: (key: K) => boolean;
49+
50+
/**
51+
* Removes and disposes an instance by key. Returns `true` if the instance
52+
* existed and was disposed, `false` otherwise.
53+
*/
54+
readonly disposeInstance: (key: K) => boolean;
55+
}
56+
57+
/** Creates a {@link Multiton} instance manager. */
58+
export const createMultiton = <
59+
K extends string,
60+
T extends Disposable,
61+
>(): Multiton<K, T> => {
62+
const instances = new Map<K, T>();
63+
64+
return {
65+
ensure: (key, create, onCacheHit) => {
66+
let instance = instances.get(key);
67+
68+
if (instance == null) {
69+
instance = create();
70+
instances.set(key, instance);
71+
} else if (onCacheHit) {
72+
onCacheHit(instance);
73+
}
74+
75+
return instance;
76+
},
77+
78+
get: (key) => instances.get(key) ?? null,
79+
80+
has: (key) => instances.has(key),
81+
82+
disposeInstance: (key) => {
83+
const instance = instances.get(key);
84+
if (instance) {
85+
instance[Symbol.dispose]();
86+
return instances.delete(key);
87+
}
88+
return false;
89+
},
90+
91+
[Symbol.dispose]: () => {
92+
for (const instance of instances.values()) {
93+
instance[Symbol.dispose]();
94+
}
95+
instances.clear();
96+
},
97+
};
98+
};

packages/common/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export * from "./Evolu/Public.js";
1313
export * from "./Function.js";
1414
export * from "./Identicon.js";
1515
export * from "./ManyToManyMap.js";
16+
export * from "./Multiton.js";
1617
export * from "./Number.js";
1718
export * from "./Object.js";
1819
export * from "./Order.js";

0 commit comments

Comments
 (0)