@livestore/livestore 0.4.0-dev.21 → 0.4.0-dev.22

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 (75) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/effect/LiveStore.d.ts +123 -2
  3. package/dist/effect/LiveStore.d.ts.map +1 -1
  4. package/dist/effect/LiveStore.js +195 -1
  5. package/dist/effect/LiveStore.js.map +1 -1
  6. package/dist/effect/mod.d.ts +1 -1
  7. package/dist/effect/mod.d.ts.map +1 -1
  8. package/dist/effect/mod.js +3 -1
  9. package/dist/effect/mod.js.map +1 -1
  10. package/dist/mod.d.ts +1 -0
  11. package/dist/mod.d.ts.map +1 -1
  12. package/dist/mod.js +1 -0
  13. package/dist/mod.js.map +1 -1
  14. package/dist/store/StoreRegistry.d.ts +190 -0
  15. package/dist/store/StoreRegistry.d.ts.map +1 -0
  16. package/dist/store/StoreRegistry.js +244 -0
  17. package/dist/store/StoreRegistry.js.map +1 -0
  18. package/dist/store/StoreRegistry.test.d.ts +2 -0
  19. package/dist/store/StoreRegistry.test.d.ts.map +1 -0
  20. package/dist/store/StoreRegistry.test.js +380 -0
  21. package/dist/store/StoreRegistry.test.js.map +1 -0
  22. package/dist/store/create-store.d.ts +50 -4
  23. package/dist/store/create-store.d.ts.map +1 -1
  24. package/dist/store/create-store.js +19 -0
  25. package/dist/store/create-store.js.map +1 -1
  26. package/dist/store/devtools.d.ts.map +1 -1
  27. package/dist/store/devtools.js +13 -0
  28. package/dist/store/devtools.js.map +1 -1
  29. package/dist/store/store-types.d.ts +10 -25
  30. package/dist/store/store-types.d.ts.map +1 -1
  31. package/dist/store/store-types.js.map +1 -1
  32. package/dist/store/store.d.ts +23 -6
  33. package/dist/store/store.d.ts.map +1 -1
  34. package/dist/store/store.js +20 -2
  35. package/dist/store/store.js.map +1 -1
  36. package/docs/building-with-livestore/complex-ui-state/index.md +0 -2
  37. package/docs/building-with-livestore/crud/index.md +0 -2
  38. package/docs/building-with-livestore/data-modeling/index.md +29 -0
  39. package/docs/building-with-livestore/examples/todo-workspaces/index.md +0 -6
  40. package/docs/building-with-livestore/opentelemetry/index.md +25 -6
  41. package/docs/building-with-livestore/rules-for-ai-agents/index.md +2 -2
  42. package/docs/building-with-livestore/state/sql-queries/index.md +22 -0
  43. package/docs/building-with-livestore/state/sqlite-schema/index.md +2 -2
  44. package/docs/building-with-livestore/store/index.md +344 -0
  45. package/docs/framework-integrations/react-integration/index.md +380 -361
  46. package/docs/framework-integrations/vue-integration/index.md +2 -2
  47. package/docs/getting-started/expo/index.md +189 -43
  48. package/docs/getting-started/react-web/index.md +77 -24
  49. package/docs/getting-started/vue/index.md +3 -3
  50. package/docs/index.md +1 -2
  51. package/docs/llms.txt +0 -1
  52. package/docs/misc/troubleshooting/index.md +3 -3
  53. package/docs/overview/how-livestore-works/index.md +1 -1
  54. package/docs/overview/introduction/index.md +409 -1
  55. package/docs/overview/why-livestore/index.md +108 -2
  56. package/docs/patterns/auth/index.md +185 -34
  57. package/docs/patterns/effect/index.md +11 -1
  58. package/docs/patterns/storybook/index.md +43 -26
  59. package/docs/platform-adapters/expo-adapter/index.md +36 -19
  60. package/docs/platform-adapters/web-adapter/index.md +71 -2
  61. package/docs/tutorial/1-setup-starter-project/index.md +5 -5
  62. package/docs/tutorial/3-read-and-write-todos-via-livestore/index.md +54 -35
  63. package/docs/tutorial/5-expand-business-logic/index.md +1 -1
  64. package/docs/tutorial/6-persist-ui-state/index.md +12 -12
  65. package/package.json +6 -6
  66. package/src/effect/LiveStore.ts +385 -3
  67. package/src/effect/mod.ts +13 -1
  68. package/src/mod.ts +1 -0
  69. package/src/store/StoreRegistry.test.ts +516 -0
  70. package/src/store/StoreRegistry.ts +393 -0
  71. package/src/store/create-store.ts +50 -4
  72. package/src/store/devtools.ts +15 -0
  73. package/src/store/store-types.ts +17 -5
  74. package/src/store/store.ts +25 -5
  75. package/docs/building-with-livestore/examples/index.md +0 -30
@@ -0,0 +1,244 @@
1
+ import { LogConfig, OtelLiveDummy, provideOtel, UnknownError } from '@livestore/common';
2
+ import { omitUndefineds } from '@livestore/utils';
3
+ import { Cause, Effect, Equal, Exit, Fiber, Hash, Layer, ManagedRuntime, RcMap, Runtime, } from '@livestore/utils/effect';
4
+ import { createStore } from "./create-store.js";
5
+ /**
6
+ * Default time to keep unused stores in cache.
7
+ *
8
+ * - Browser: 60 seconds (60,000 ms)
9
+ * - SSR: Infinity (disables disposal to avoid disposing stores before server render completes)
10
+ *
11
+ * @internal Exported primarily for testing purposes.
12
+ */
13
+ export const DEFAULT_UNUSED_CACHE_TIME = typeof window === 'undefined' ? Number.POSITIVE_INFINITY : 60_000;
14
+ /**
15
+ * RcMap cache key that uses storeId for equality/hashing but carries full options.
16
+ * This allows RcMap to deduplicate by storeId while the lookup function has access to all options.
17
+ *
18
+ * @remarks
19
+ * Only `storeId` is used for equality and hashing. This means if `getOrLoadPromise` is called
20
+ * with different options (e.g., different `adapter`) but the same `storeId`, the cached store
21
+ * from the first call will be returned. This is intentional - a store's identity is determined
22
+ * solely by its `storeId`, and callers should not expect to get different stores by varying
23
+ * other options while keeping the same `storeId`.
24
+ */
25
+ class StoreCacheKey {
26
+ options;
27
+ constructor(options) {
28
+ this.options = options;
29
+ }
30
+ /**
31
+ * Equality is based solely on `storeId`. Other options in `RegistryStoreOptions` are ignored
32
+ * for cache key comparison. The first options used for a given `storeId` determine the
33
+ * store's configuration.
34
+ */
35
+ [Equal.symbol](that) {
36
+ return that instanceof StoreCacheKey && this.options.storeId === that.options.storeId;
37
+ }
38
+ [Hash.symbol]() {
39
+ return Hash.string(this.options.storeId);
40
+ }
41
+ }
42
+ /**
43
+ * Store Registry coordinating store loading, caching, and retention
44
+ *
45
+ * @public
46
+ */
47
+ export class StoreRegistry {
48
+ /**
49
+ * Reference-counted cache mapping storeId to Store instances.
50
+ * Stores are created on first access and disposed after `unusedCacheTime` when all references are released.
51
+ */
52
+ #rcMap;
53
+ /**
54
+ * Effect runtime providing Scope and OtelTracer for all registry operations.
55
+ * When the runtime's scope closes, all managed stores are automatically shut down.
56
+ */
57
+ #runtime;
58
+ /**
59
+ * In-flight loading promises keyed by storeId.
60
+ * Ensures concurrent `getOrLoadPromise` calls receive the same Promise reference.
61
+ */
62
+ #loadingPromises = new Map();
63
+ /**
64
+ * Default options merged into all store configurations at load time.
65
+ */
66
+ #defaultOptions;
67
+ /**
68
+ * Creates a new StoreRegistry instance.
69
+ *
70
+ * @example
71
+ * ```ts
72
+ * const registry = new StoreRegistry({
73
+ * defaultOptions: {
74
+ * batchUpdates,
75
+ * unusedCacheTime: 30_000,
76
+ * }
77
+ * })
78
+ * ```
79
+ */
80
+ constructor(config = {}) {
81
+ this.#defaultOptions = config.defaultOptions;
82
+ this.#runtime =
83
+ config.runtime ??
84
+ ManagedRuntime.make(Layer.mergeAll(Layer.scope, OtelLiveDummy)).runtimeEffect.pipe(Effect.runSync);
85
+ this.#rcMap = RcMap.make({
86
+ lookup: ({ options }) => {
87
+ // Merge registry defaults with call-site options (call-site takes precedence)
88
+ const mergedOptions = { ...this.#defaultOptions, ...options };
89
+ return createStore(mergedOptions).pipe(Effect.catchAllDefect((cause) => UnknownError.make({ cause })), Effect.withSpan(`StoreRegistry.lookup:${mergedOptions.storeId}`), LogConfig.withLoggerConfig(mergedOptions, { threadName: 'window' }), provideOtel(omitUndefineds({
90
+ parentSpanContext: mergedOptions.otelOptions?.rootSpanContext,
91
+ otelTracer: mergedOptions.otelOptions?.tracer,
92
+ })));
93
+ },
94
+ // TODO: Make idleTimeToLive vary for each store when Effect supports per-resource TTL
95
+ // See https://github.com/livestorejs/livestore/issues/917
96
+ idleTimeToLive: config.defaultOptions?.unusedCacheTime ?? DEFAULT_UNUSED_CACHE_TIME,
97
+ }).pipe(Runtime.runSync(this.#runtime));
98
+ }
99
+ /**
100
+ * Gets a cached store or loads a new one, with the store lifetime scoped to the caller.
101
+ *
102
+ * @typeParam TSchema - The schema type for the store
103
+ * @typeParam TContext - The context type for the store
104
+ * @typeParam TSyncPayloadSchema - The sync payload schema type
105
+ * @returns An Effect that yields the store, scoped to the provided Scope
106
+ *
107
+ * @remarks
108
+ * - Stores are kept in cache and reused while any scope holds them
109
+ * - When the scope closes, the reference is released; the store is disposed after `unusedCacheTime`
110
+ * if no other scopes retain it
111
+ * - Concurrent calls with the same storeId share the same store instance
112
+ */
113
+ getOrLoad = (options) => Effect.gen(this, function* () {
114
+ // Cast options to satisfy StoreCacheKey's wider type (type safety enforced at API boundary)
115
+ const key = new StoreCacheKey(options);
116
+ const store = yield* RcMap.get(this.#rcMap, key);
117
+ return store;
118
+ }).pipe(Effect.withSpan(`StoreRegistry.getOrLoad:${options.storeId}`));
119
+ /**
120
+ * Get or load a store, returning it directly if already loaded or a promise if loading.
121
+ *
122
+ * @typeParam TSchema - The schema type for the store
123
+ * @typeParam TContext - The context type for the store
124
+ * @typeParam TSyncPayloadSchema - The sync payload schema type
125
+ * @returns The loaded store if available, or a Promise that resolves to the loaded store
126
+ * @throws unknown - store loading error
127
+ *
128
+ * @remarks
129
+ * - Returns the store instance directly (synchronous) when already loaded
130
+ * - Returns a stable Promise reference when loading is in progress or needs to be initiated
131
+ * - Throws with the same error instance on subsequent calls after failure
132
+ * - Applies default options from registry config, with call-site options taking precedence
133
+ * - Concurrent calls with the same storeId share the same store instance
134
+ */
135
+ getOrLoadPromise = (options) => {
136
+ const exit = this.getOrLoad(options).pipe(Effect.scoped, Runtime.runSyncExit(this.#runtime));
137
+ if (Exit.isSuccess(exit))
138
+ return exit.value;
139
+ // Check if the failure is due to async work
140
+ const defect = Cause.dieOption(exit.cause);
141
+ if (defect._tag === 'Some' && Runtime.isAsyncFiberException(defect.value)) {
142
+ const { storeId } = options;
143
+ // Return cached promise if one exists (ensures concurrent calls get the same Promise reference)
144
+ const cached = this.#loadingPromises.get(storeId);
145
+ if (cached)
146
+ return cached;
147
+ // Create and cache the promise
148
+ const fiber = defect.value.fiber;
149
+ const promise = Fiber.join(fiber)
150
+ .pipe(Runtime.runPromise(this.#runtime))
151
+ .finally(() => this.#loadingPromises.delete(storeId));
152
+ this.#loadingPromises.set(storeId, promise);
153
+ return promise;
154
+ }
155
+ // Handle synchronous failure
156
+ throw Cause.squash(exit.cause);
157
+ };
158
+ /**
159
+ * Retains the store in cache.
160
+ *
161
+ * @typeParam TSchema - The schema type for the store
162
+ * @typeParam TContext - The context type for the store
163
+ * @typeParam TSyncPayloadSchema - The sync payload schema type
164
+ * @returns A release function that, when called, removes this retention hold
165
+ *
166
+ * @remarks
167
+ * - Multiple retains on the same store are independent; each must be released separately
168
+ * - If the store isn't cached yet, it will be loaded and then retained
169
+ * - The store will remain in cache until all retains are released and after `unusedCacheTime` expires
170
+ */
171
+ retain = (options) => {
172
+ const release = Effect.gen(this, function* () {
173
+ // Cast options to satisfy StoreCacheKey's wider type (type safety enforced at API boundary)
174
+ const key = new StoreCacheKey(options);
175
+ yield* RcMap.get(this.#rcMap, key);
176
+ // Effect.never suspends indefinitely, keeping the RcMap reference alive.
177
+ // When `release()` is called, the fiber is interrupted, closing the scope
178
+ // and releasing the RcMap entry (which may trigger disposal after idleTimeToLive).
179
+ yield* Effect.never;
180
+ }).pipe(Effect.scoped, Runtime.runCallback(this.#runtime));
181
+ return () => release();
182
+ };
183
+ /**
184
+ * Loads a store (without suspending) to warm up the cache.
185
+ *
186
+ * @typeParam TSchema - The schema of the store to preload
187
+ * @typeParam TContext - The context type for the store
188
+ * @typeParam TSyncPayloadSchema - The sync payload schema type
189
+ * @returns A promise that resolves when the loading is complete (success or failure)
190
+ *
191
+ * @remarks
192
+ * - We don't return the store or throw as this is a fire-and-forget operation.
193
+ * - If the entry remains unused after preload resolves/rejects, it is scheduled for disposal.
194
+ * - Does not affect the retention of the store in cache.
195
+ */
196
+ preload = async (options) => {
197
+ try {
198
+ await this.getOrLoadPromise(options);
199
+ }
200
+ catch {
201
+ // Do nothing; preload is best-effort
202
+ }
203
+ };
204
+ }
205
+ /**
206
+ * Helper for defining reusable store options with full type inference. Returns
207
+ * options that can be passed to `useStore()` or `storeRegistry.preload()`.
208
+ *
209
+ * @remarks
210
+ * At runtime this is an identity function that returns the input unchanged.
211
+ * Its value lies in enabling TypeScript's excess property checking to catch
212
+ * typos and configuration errors, while allowing options to be shared across
213
+ * `useStore()`, `storeRegistry.preload()`, `storeRegistry.getOrLoad()`, etc.
214
+ *
215
+ * @typeParam TSchema - The LiveStore schema type
216
+ * @typeParam TContext - User-defined context attached to the store
217
+ * @typeParam TSyncPayloadSchema - Schema for the sync payload sent to the backend
218
+ * @param options - The store configuration options
219
+ * @returns The same options object, unchanged
220
+ *
221
+ * @example
222
+ * ```ts
223
+ * export const issueStoreOptions = (issueId: string) =>
224
+ * storeOptions({
225
+ * storeId: `issue-${issueId}`,
226
+ * schema,
227
+ * adapter,
228
+ * unusedCacheTime: 30_000,
229
+ * })
230
+ *
231
+ * // In a component
232
+ * const issueStore = useStore(issueStoreOptions(issueId))
233
+ *
234
+ * // In a route loader or event handler
235
+ * storeRegistry.preload({
236
+ * ...issueStoreOptions(issueId),
237
+ * unusedCacheTime: 10_000,
238
+ * });
239
+ * ```
240
+ */
241
+ export function storeOptions(options) {
242
+ return options;
243
+ }
244
+ //# sourceMappingURL=StoreRegistry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"StoreRegistry.js","sourceRoot":"","sources":["../../src/store/StoreRegistry.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=StoreRegistry.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"StoreRegistry.test.d.ts","sourceRoot":"","sources":["../../src/store/StoreRegistry.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,380 @@
1
+ import { makeInMemoryAdapter } from '@livestore/adapter-web';
2
+ import { UnknownError } from '@livestore/common';
3
+ import { sleep } from '@livestore/utils';
4
+ import { Effect } from '@livestore/utils/effect';
5
+ import { describe, expect, it } from 'vitest';
6
+ import { schema } from "../utils/tests/fixture.js";
7
+ import { StoreRegistry, storeOptions } from "./StoreRegistry.js";
8
+ import { StoreInternalsSymbol } from "./store-types.js";
9
+ describe('StoreRegistry', () => {
10
+ it('returns a promise when the store is loading', async () => {
11
+ const storeRegistry = new StoreRegistry();
12
+ const result = storeRegistry.getOrLoadPromise(testStoreOptions());
13
+ expect(result).toBeInstanceOf(Promise);
14
+ // Clean up
15
+ const store = await result;
16
+ await store.shutdownPromise();
17
+ });
18
+ it('returns cached store synchronously after first load resolves', async () => {
19
+ const storeRegistry = new StoreRegistry();
20
+ const initial = storeRegistry.getOrLoadPromise(testStoreOptions());
21
+ expect(initial).toBeInstanceOf(Promise);
22
+ const store = await initial;
23
+ const cached = storeRegistry.getOrLoadPromise(testStoreOptions());
24
+ expect(cached).toBe(store);
25
+ expect(cached).not.toBeInstanceOf(Promise);
26
+ // Clean up
27
+ await store.shutdownPromise();
28
+ });
29
+ it('reuses the same promise for concurrent getOrLoadPromise calls while loading', async () => {
30
+ const storeRegistry = new StoreRegistry();
31
+ const options = testStoreOptions();
32
+ const first = storeRegistry.getOrLoadPromise(options);
33
+ const second = storeRegistry.getOrLoadPromise(options);
34
+ // Both should be the same promise
35
+ expect(first).toBe(second);
36
+ expect(first).toBeInstanceOf(Promise);
37
+ const store = await first;
38
+ // Both promises should resolve to the same store
39
+ expect(await second).toBe(store);
40
+ // Clean up
41
+ await store.shutdownPromise();
42
+ });
43
+ it('throws synchronously and rethrows on subsequent calls for sync failures', () => {
44
+ const storeRegistry = new StoreRegistry();
45
+ const badOptions = testStoreOptions({
46
+ // @ts-expect-error - intentionally passing invalid adapter to trigger error
47
+ adapter: null,
48
+ });
49
+ // First call throws synchronously
50
+ expect(() => storeRegistry.getOrLoadPromise(badOptions)).toThrow();
51
+ // Subsequent call should also throw synchronously (cached error)
52
+ expect(() => storeRegistry.getOrLoadPromise(badOptions)).toThrow();
53
+ });
54
+ it('caches and rethrows rejection on subsequent calls for async failures', async () => {
55
+ const storeRegistry = new StoreRegistry();
56
+ // Create an adapter that fails asynchronously (after yielding to the event loop)
57
+ const failingAdapter = () => Effect.gen(function* () {
58
+ yield* Effect.sleep(0); // Force async execution
59
+ return yield* UnknownError.make({ cause: new Error('Async failure') });
60
+ });
61
+ const badOptions = testStoreOptions({
62
+ adapter: failingAdapter,
63
+ });
64
+ // First call returns a promise that rejects
65
+ await expect(storeRegistry.getOrLoadPromise(badOptions)).rejects.toThrow();
66
+ // Subsequent call should throw the cached error synchronously (RcMap caches failures)
67
+ expect(() => storeRegistry.getOrLoadPromise(badOptions)).toThrow();
68
+ });
69
+ it('throws the same error instance on multiple calls after failure', async () => {
70
+ const storeRegistry = new StoreRegistry();
71
+ // Create an adapter that fails asynchronously
72
+ const failingAdapter = () => Effect.gen(function* () {
73
+ yield* Effect.sleep(0); // Force async execution
74
+ return yield* UnknownError.make({ cause: new Error('Async failure') });
75
+ });
76
+ const badOptions = testStoreOptions({
77
+ adapter: failingAdapter,
78
+ });
79
+ // Wait for the first failure
80
+ await expect(storeRegistry.getOrLoadPromise(badOptions)).rejects.toThrow();
81
+ // Capture the errors from subsequent calls
82
+ let error1;
83
+ let error2;
84
+ try {
85
+ storeRegistry.getOrLoadPromise(badOptions);
86
+ }
87
+ catch (err) {
88
+ error1 = err;
89
+ }
90
+ try {
91
+ storeRegistry.getOrLoadPromise(badOptions);
92
+ }
93
+ catch (err) {
94
+ error2 = err;
95
+ }
96
+ // Both should be the exact same error instance (cached)
97
+ expect(error1).toBeDefined();
98
+ expect(error1).toBe(error2);
99
+ });
100
+ it('disposes store after unusedCacheTime expires', async () => {
101
+ const unusedCacheTime = 25;
102
+ const storeRegistry = new StoreRegistry({ defaultOptions: { unusedCacheTime } });
103
+ const options = testStoreOptions();
104
+ const store = await storeRegistry.getOrLoadPromise(options);
105
+ // Store should be cached
106
+ expect(storeRegistry.getOrLoadPromise(options)).toBe(store);
107
+ // Wait for disposal
108
+ await sleep(unusedCacheTime + 50);
109
+ // After disposal, store should be removed
110
+ // The store is removed from cache, so next getOrLoadStore creates a new one
111
+ const nextStore = await storeRegistry.getOrLoadPromise(options);
112
+ // Should be a different store instance
113
+ expect(nextStore).not.toBe(store);
114
+ expect(nextStore[StoreInternalsSymbol].clientSession.debugInstanceId).toBeDefined();
115
+ // Clean up the second store (first one was disposed)
116
+ await nextStore.shutdownPromise();
117
+ });
118
+ it('does not dispose when unusedCacheTime is Infinity', async () => {
119
+ const storeRegistry = new StoreRegistry({ defaultOptions: { unusedCacheTime: Number.POSITIVE_INFINITY } });
120
+ const options = testStoreOptions();
121
+ const store = await storeRegistry.getOrLoadPromise(options);
122
+ // Store should be cached
123
+ expect(storeRegistry.getOrLoadPromise(options)).toBe(store);
124
+ // Wait a reasonable duration to verify no disposal
125
+ await sleep(100);
126
+ // Store should still be cached (not disposed)
127
+ expect(storeRegistry.getOrLoadPromise(options)).toBe(store);
128
+ // Clean up manually
129
+ await store.shutdownPromise();
130
+ });
131
+ it('schedules disposal if store becomes unused during loading', async () => {
132
+ const unusedCacheTime = 50;
133
+ const storeRegistry = new StoreRegistry({ defaultOptions: { unusedCacheTime } });
134
+ const options = testStoreOptions();
135
+ // Start loading without any retain
136
+ const storePromise = storeRegistry.getOrLoadPromise(options);
137
+ // Wait for store to load (no retain registered)
138
+ const store = await storePromise;
139
+ // Since there were no retain when loading completed, disposal should be scheduled
140
+ await sleep(unusedCacheTime + 50);
141
+ // Store should be disposed
142
+ const nextStore = await storeRegistry.getOrLoadPromise(options);
143
+ expect(nextStore).not.toBe(store);
144
+ await nextStore.shutdownPromise();
145
+ });
146
+ // This test is skipped because Effect doesn't yet support different `idleTimeToLive` values for each resource in `RcMap`
147
+ // See https://github.com/livestorejs/livestore/issues/917
148
+ it.skip('allows call-site options to override default options', async () => {
149
+ const storeRegistry = new StoreRegistry({
150
+ defaultOptions: {
151
+ unusedCacheTime: 1000, // Default is long
152
+ },
153
+ });
154
+ const options = testStoreOptions({
155
+ unusedCacheTime: 10, // Override with shorter time
156
+ });
157
+ const store = await storeRegistry.getOrLoadPromise(options);
158
+ // Wait for the override time (10ms)
159
+ await sleep(10);
160
+ // Should be disposed according to the override time, not default
161
+ const nextStore = await storeRegistry.getOrLoadPromise(options);
162
+ expect(nextStore).not.toBe(store);
163
+ await nextStore.shutdownPromise();
164
+ });
165
+ // This test is skipped because we don't yet support dynamic `unusedCacheTime` updates for cached stores.
166
+ // See https://github.com/livestorejs/livestore/issues/918
167
+ it.skip('keeps the longest unusedCacheTime seen for a store when options vary across calls', async () => {
168
+ const storeRegistry = new StoreRegistry();
169
+ const options = testStoreOptions({ unusedCacheTime: 10 });
170
+ const release = storeRegistry.retain(options);
171
+ const store = await storeRegistry.getOrLoadPromise(options);
172
+ // Call with longer unusedCacheTime
173
+ await storeRegistry.getOrLoadPromise(testStoreOptions({ unusedCacheTime: 100 }));
174
+ release();
175
+ // After 99ms, store should still be alive (100ms unusedCacheTime used)
176
+ await sleep(99);
177
+ // Store should still be cached
178
+ expect(storeRegistry.getOrLoadPromise(options)).toBe(store);
179
+ // After the full 100ms, store should be disposed
180
+ await sleep(1);
181
+ // Next getOrLoadStore should create a new store
182
+ const nextStore = await storeRegistry.getOrLoadPromise(options);
183
+ expect(nextStore).not.toBe(store);
184
+ // Clean up the second store (first one was disposed)
185
+ await nextStore.shutdownPromise();
186
+ });
187
+ it('preload does not throw', async () => {
188
+ const storeRegistry = new StoreRegistry();
189
+ // Create invalid options that would cause an error
190
+ const badOptions = testStoreOptions({
191
+ // @ts-expect-error - intentionally passing invalid adapter to trigger error
192
+ adapter: null,
193
+ });
194
+ // preload should not throw
195
+ await expect(storeRegistry.preload(badOptions)).resolves.toBeUndefined();
196
+ // But subsequent getOrLoadStore should throw the cached error
197
+ expect(() => storeRegistry.getOrLoadPromise(badOptions)).toThrow();
198
+ });
199
+ it('handles rapid retain/release cycles without errors', async () => {
200
+ const unusedCacheTime = 50;
201
+ const storeRegistry = new StoreRegistry({ defaultOptions: { unusedCacheTime } });
202
+ const options = testStoreOptions();
203
+ const store = await storeRegistry.getOrLoadPromise(options);
204
+ // Rapidly retain and release multiple times
205
+ for (let i = 0; i < 10; i++) {
206
+ const release = storeRegistry.retain(options);
207
+ release();
208
+ }
209
+ // Wait for disposal to trigger
210
+ await sleep(unusedCacheTime + 50);
211
+ // Store should be disposed after the last release
212
+ const nextStore = await storeRegistry.getOrLoadPromise(options);
213
+ expect(nextStore).not.toBe(store);
214
+ await nextStore.shutdownPromise();
215
+ });
216
+ it('cancels disposal when new retain', async () => {
217
+ const unusedCacheTime = 50;
218
+ const storeRegistry = new StoreRegistry({ defaultOptions: { unusedCacheTime } });
219
+ const options = testStoreOptions();
220
+ const store = await storeRegistry.getOrLoadPromise(options);
221
+ // Wait almost to disposal threshold
222
+ await sleep(unusedCacheTime - 5);
223
+ // Add a new retain before disposal triggers
224
+ const release = storeRegistry.retain(options);
225
+ // Complete the original unusedCacheTime
226
+ await sleep(5);
227
+ // Store should not have been disposed because we added a retain
228
+ expect(storeRegistry.getOrLoadPromise(options)).toBe(store);
229
+ // Clean up
230
+ release();
231
+ await sleep(unusedCacheTime + 50);
232
+ // Now it should be disposed
233
+ const nextStore = await storeRegistry.getOrLoadPromise(options);
234
+ expect(nextStore).not.toBe(store);
235
+ await nextStore.shutdownPromise();
236
+ });
237
+ it('aborts loading when disposal fires while store is still loading', async () => {
238
+ const unusedCacheTime = 10;
239
+ const storeRegistry = new StoreRegistry({ defaultOptions: { unusedCacheTime } });
240
+ const options = testStoreOptions();
241
+ // Retain briefly to trigger getOrLoadStore and then release
242
+ const release = storeRegistry.retain(options);
243
+ // Start loading
244
+ const loadPromise = storeRegistry.getOrLoadPromise(options);
245
+ // Attach a catch handler to prevent unhandled rejection when the load is aborted
246
+ const abortedPromise = loadPromise.catch(() => {
247
+ // Expected: load was aborted by disposal
248
+ });
249
+ // Release immediately, which schedules disposal
250
+ release();
251
+ // Wait for disposal to trigger
252
+ await sleep(unusedCacheTime + 50);
253
+ // Wait for the abort to complete
254
+ await abortedPromise;
255
+ // After abort, a new getOrLoadStore should start a fresh load
256
+ const freshLoadPromise = storeRegistry.getOrLoadPromise(options);
257
+ // This should be a new promise (not the aborted one)
258
+ expect(freshLoadPromise).toBeInstanceOf(Promise);
259
+ expect(freshLoadPromise).not.toBe(loadPromise);
260
+ // Wait for fresh load to complete
261
+ const store = await freshLoadPromise;
262
+ expect(store).toBeDefined();
263
+ await store.shutdownPromise();
264
+ });
265
+ it('retain keeps store alive past unusedCacheTime', async () => {
266
+ const unusedCacheTime = 50;
267
+ const storeRegistry = new StoreRegistry({ defaultOptions: { unusedCacheTime } });
268
+ const options = testStoreOptions();
269
+ // Load the store
270
+ const store = await storeRegistry.getOrLoadPromise(options);
271
+ // Retain the store before disposal could fire
272
+ const release = storeRegistry.retain(options);
273
+ // Wait past the unusedCacheTime
274
+ await sleep(unusedCacheTime + 50);
275
+ // Store should still be cached because retain keeps it alive
276
+ const cachedStore = storeRegistry.getOrLoadPromise(options);
277
+ expect(cachedStore).toBe(store);
278
+ release();
279
+ await store.shutdownPromise();
280
+ });
281
+ it('manages multiple stores with different IDs independently', async () => {
282
+ const unusedCacheTime = 50;
283
+ const storeRegistry = new StoreRegistry({ defaultOptions: { unusedCacheTime } });
284
+ const options1 = testStoreOptions({ storeId: 'store-1' });
285
+ const options2 = testStoreOptions({ storeId: 'store-2' });
286
+ const store1 = await storeRegistry.getOrLoadPromise(options1);
287
+ const store2 = await storeRegistry.getOrLoadPromise(options2);
288
+ // Should be different store instances
289
+ expect(store1).not.toBe(store2);
290
+ // Both should be cached independently
291
+ expect(storeRegistry.getOrLoadPromise(options1)).toBe(store1);
292
+ expect(storeRegistry.getOrLoadPromise(options2)).toBe(store2);
293
+ // Wait for both stores to be disposed
294
+ await sleep(unusedCacheTime + 50);
295
+ // Both stores should be disposed, so next getOrLoadStore creates new ones
296
+ const newStore1 = await storeRegistry.getOrLoadPromise(options1);
297
+ expect(newStore1).not.toBe(store1);
298
+ const newStore2 = await storeRegistry.getOrLoadPromise(options2);
299
+ expect(newStore2).not.toBe(store2);
300
+ // Clean up
301
+ await newStore1.shutdownPromise();
302
+ await newStore2.shutdownPromise();
303
+ });
304
+ it('applies default options from constructor', async () => {
305
+ const storeRegistry = new StoreRegistry({
306
+ defaultOptions: {
307
+ unusedCacheTime: 100,
308
+ },
309
+ });
310
+ const options = testStoreOptions();
311
+ const store = await storeRegistry.getOrLoadPromise(options);
312
+ // Verify the store loads successfully
313
+ expect(store).toBeDefined();
314
+ expect(store[StoreInternalsSymbol].clientSession.debugInstanceId).toBeDefined();
315
+ // Verify configured default unusedCacheTime is applied by checking disposal doesn't happen before it
316
+ await sleep(50);
317
+ // Store should still be cached after 50ms (default is 100ms)
318
+ expect(storeRegistry.getOrLoadPromise(options)).toBe(store);
319
+ await store.shutdownPromise();
320
+ });
321
+ it('prevents getOrLoadStore from returning a dying store', async () => {
322
+ const unusedCacheTime = 25;
323
+ const storeRegistry = new StoreRegistry({ defaultOptions: { unusedCacheTime } });
324
+ const options = testStoreOptions();
325
+ // Load the store and wait for it to be ready
326
+ const originalStore = await storeRegistry.getOrLoadPromise(options);
327
+ // Verify store is cached
328
+ expect(storeRegistry.getOrLoadPromise(options)).toBe(originalStore);
329
+ // Wait for disposal to trigger
330
+ await sleep(unusedCacheTime + 50);
331
+ // After disposal, the cache should be cleared
332
+ // Calling getOrLoadStore should start a fresh load (return Promise)
333
+ const storeOrPromise = storeRegistry.getOrLoadPromise(options);
334
+ if (!(storeOrPromise instanceof Promise)) {
335
+ expect.fail('getOrLoadStore returned dying store synchronously instead of starting fresh load');
336
+ }
337
+ const freshStore = await storeOrPromise;
338
+ // A fresh load was triggered because cache was cleared
339
+ expect(freshStore).not.toBe(originalStore);
340
+ await freshStore.shutdownPromise();
341
+ });
342
+ it('warms the cache so subsequent getOrLoadStore is synchronous after preload', async () => {
343
+ const storeRegistry = new StoreRegistry();
344
+ const options = testStoreOptions();
345
+ // Preload the store
346
+ await storeRegistry.preload(options);
347
+ // Subsequent getOrLoadStore should return synchronously (not a Promise)
348
+ const store = storeRegistry.getOrLoadPromise(options);
349
+ expect(store).not.toBeInstanceOf(Promise);
350
+ // TypeScript doesn't narrow the type, so we need to assert
351
+ if (store instanceof Promise) {
352
+ throw new Error('Expected store, got Promise');
353
+ }
354
+ // Clean up
355
+ await store.shutdownPromise();
356
+ });
357
+ it('schedules disposal after preload if no retainers are added', async () => {
358
+ const unusedCacheTime = 50;
359
+ const storeRegistry = new StoreRegistry({ defaultOptions: { unusedCacheTime } });
360
+ const options = testStoreOptions();
361
+ // Preload without retaining
362
+ await storeRegistry.preload(options);
363
+ // Get the store
364
+ const store = storeRegistry.getOrLoadPromise(options);
365
+ expect(store).not.toBeInstanceOf(Promise);
366
+ // Wait for disposal to trigger
367
+ await sleep(unusedCacheTime + 50);
368
+ // Store should be disposed since no retainers were added
369
+ const nextStore = await storeRegistry.getOrLoadPromise(options);
370
+ expect(nextStore).not.toBe(store);
371
+ await nextStore.shutdownPromise();
372
+ });
373
+ });
374
+ const testStoreOptions = (overrides = {}) => storeOptions({
375
+ storeId: 'test-store',
376
+ schema,
377
+ adapter: makeInMemoryAdapter(),
378
+ ...overrides,
379
+ });
380
+ //# sourceMappingURL=StoreRegistry.test.js.map