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

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 (207) hide show
  1. package/README.md +0 -1
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/QueryCache.js +1 -1
  4. package/dist/QueryCache.js.map +1 -1
  5. package/dist/SqliteDbWrapper.d.ts +5 -5
  6. package/dist/SqliteDbWrapper.d.ts.map +1 -1
  7. package/dist/SqliteDbWrapper.js +8 -8
  8. package/dist/SqliteDbWrapper.js.map +1 -1
  9. package/dist/SqliteDbWrapper.test.js +2 -2
  10. package/dist/SqliteDbWrapper.test.js.map +1 -1
  11. package/dist/effect/LiveStore.d.ts +14 -7
  12. package/dist/effect/LiveStore.d.ts.map +1 -1
  13. package/dist/effect/LiveStore.js +0 -15
  14. package/dist/effect/LiveStore.js.map +1 -1
  15. package/dist/effect/LiveStore.test.d.ts +2 -0
  16. package/dist/effect/LiveStore.test.d.ts.map +1 -0
  17. package/dist/effect/LiveStore.test.js +42 -0
  18. package/dist/effect/LiveStore.test.js.map +1 -0
  19. package/dist/live-queries/base-class.d.ts +3 -3
  20. package/dist/live-queries/base-class.d.ts.map +1 -1
  21. package/dist/live-queries/base-class.js +2 -2
  22. package/dist/live-queries/base-class.js.map +1 -1
  23. package/dist/live-queries/client-document-get-query.d.ts +1 -1
  24. package/dist/live-queries/client-document-get-query.d.ts.map +1 -1
  25. package/dist/live-queries/client-document-get-query.js +1 -1
  26. package/dist/live-queries/client-document-get-query.js.map +1 -1
  27. package/dist/live-queries/computed.d.ts.map +1 -1
  28. package/dist/live-queries/computed.js +2 -2
  29. package/dist/live-queries/computed.js.map +1 -1
  30. package/dist/live-queries/db-query.js +14 -14
  31. package/dist/live-queries/db-query.js.map +1 -1
  32. package/dist/live-queries/db-query.test.js +2 -2
  33. package/dist/live-queries/db-query.test.js.map +1 -1
  34. package/dist/live-queries/signal.test.js +2 -2
  35. package/dist/live-queries/signal.test.js.map +1 -1
  36. package/dist/mod.d.ts +1 -1
  37. package/dist/mod.d.ts.map +1 -1
  38. package/dist/mod.js.map +1 -1
  39. package/dist/reactive.d.ts +9 -9
  40. package/dist/reactive.d.ts.map +1 -1
  41. package/dist/reactive.js +9 -26
  42. package/dist/reactive.js.map +1 -1
  43. package/dist/reactive.test.js +2 -2
  44. package/dist/reactive.test.js.map +1 -1
  45. package/dist/store/StoreRegistry.d.ts +30 -5
  46. package/dist/store/StoreRegistry.d.ts.map +1 -1
  47. package/dist/store/StoreRegistry.js +54 -31
  48. package/dist/store/StoreRegistry.js.map +1 -1
  49. package/dist/store/StoreRegistry.test.js +251 -250
  50. package/dist/store/StoreRegistry.test.js.map +1 -1
  51. package/dist/store/create-store.d.ts +6 -2
  52. package/dist/store/create-store.d.ts.map +1 -1
  53. package/dist/store/create-store.js +13 -7
  54. package/dist/store/create-store.js.map +1 -1
  55. package/dist/store/devtools.d.ts +1 -1
  56. package/dist/store/devtools.d.ts.map +1 -1
  57. package/dist/store/devtools.js +3 -3
  58. package/dist/store/devtools.js.map +1 -1
  59. package/dist/store/store-eventstream.test.js +2 -2
  60. package/dist/store/store-eventstream.test.js.map +1 -1
  61. package/dist/store/store-types.d.ts +70 -5
  62. package/dist/store/store-types.d.ts.map +1 -1
  63. package/dist/store/store-types.js.map +1 -1
  64. package/dist/store/store-types.test.js +1 -1
  65. package/dist/store/store-types.test.js.map +1 -1
  66. package/dist/store/store.d.ts +81 -2
  67. package/dist/store/store.d.ts.map +1 -1
  68. package/dist/store/store.js +128 -45
  69. package/dist/store/store.js.map +1 -1
  70. package/dist/utils/dev.js.map +1 -1
  71. package/dist/utils/stack-info.js +2 -2
  72. package/dist/utils/stack-info.js.map +1 -1
  73. package/dist/utils/tests/fixture.d.ts +1 -1
  74. package/dist/utils/tests/fixture.d.ts.map +1 -1
  75. package/dist/utils/tests/fixture.js.map +1 -1
  76. package/dist/utils/tests/otel.d.ts.map +1 -1
  77. package/dist/utils/tests/otel.js +5 -5
  78. package/dist/utils/tests/otel.js.map +1 -1
  79. package/package.json +58 -17
  80. package/src/QueryCache.ts +1 -1
  81. package/src/SqliteDbWrapper.test.ts +4 -2
  82. package/src/SqliteDbWrapper.ts +12 -11
  83. package/src/ambient.d.ts +0 -7
  84. package/src/effect/LiveStore.test.ts +61 -0
  85. package/src/effect/LiveStore.ts +17 -26
  86. package/src/live-queries/__snapshots__/db-query.test.ts.snap +336 -231
  87. package/src/live-queries/base-class.ts +7 -6
  88. package/src/live-queries/client-document-get-query.ts +4 -2
  89. package/src/live-queries/computed.ts +3 -2
  90. package/src/live-queries/db-query.test.ts +3 -2
  91. package/src/live-queries/db-query.ts +15 -15
  92. package/src/live-queries/signal.test.ts +3 -2
  93. package/src/mod.ts +1 -0
  94. package/src/reactive.test.ts +3 -2
  95. package/src/reactive.ts +22 -23
  96. package/src/store/StoreRegistry.test.ts +317 -293
  97. package/src/store/StoreRegistry.ts +63 -38
  98. package/src/store/create-store.ts +26 -11
  99. package/src/store/devtools.ts +5 -6
  100. package/src/store/store-eventstream.test.ts +4 -2
  101. package/src/store/store-types.test.ts +3 -1
  102. package/src/store/store-types.ts +47 -8
  103. package/src/store/store.ts +172 -55
  104. package/src/utils/dev.ts +2 -2
  105. package/src/utils/stack-info.ts +2 -2
  106. package/src/utils/tests/fixture.ts +2 -1
  107. package/src/utils/tests/otel.ts +8 -7
  108. package/docs/api/index.md +0 -3
  109. package/docs/building-with-livestore/complex-ui-state/index.md +0 -3
  110. package/docs/building-with-livestore/crud/index.md +0 -3
  111. package/docs/building-with-livestore/data-modeling/index.md +0 -30
  112. package/docs/building-with-livestore/debugging/index.md +0 -17
  113. package/docs/building-with-livestore/devtools/index.md +0 -79
  114. package/docs/building-with-livestore/events/index.md +0 -355
  115. package/docs/building-with-livestore/examples/ai-agent/index.md +0 -5
  116. package/docs/building-with-livestore/examples/todo-workspaces/index.md +0 -885
  117. package/docs/building-with-livestore/examples/turnbased-game/index.md +0 -7
  118. package/docs/building-with-livestore/opentelemetry/index.md +0 -227
  119. package/docs/building-with-livestore/production-checklist/index.md +0 -5
  120. package/docs/building-with-livestore/reactivity-system/index.md +0 -202
  121. package/docs/building-with-livestore/rules-for-ai-agents/index.md +0 -9
  122. package/docs/building-with-livestore/state/materializers/index.md +0 -300
  123. package/docs/building-with-livestore/state/sql-queries/index.md +0 -94
  124. package/docs/building-with-livestore/state/sqlite/index.md +0 -45
  125. package/docs/building-with-livestore/state/sqlite-schema/index.md +0 -306
  126. package/docs/building-with-livestore/state/sqlite-schema-effect/index.md +0 -300
  127. package/docs/building-with-livestore/store/index.md +0 -625
  128. package/docs/building-with-livestore/syncing/index.md +0 -136
  129. package/docs/building-with-livestore/tools/cli/index.md +0 -177
  130. package/docs/building-with-livestore/tools/mcp/index.md +0 -187
  131. package/docs/examples/cloudflare-adapter/index.md +0 -44
  132. package/docs/examples/expo-adapter/index.md +0 -44
  133. package/docs/examples/index.md +0 -55
  134. package/docs/examples/node-adapter/index.md +0 -44
  135. package/docs/examples/web-adapter/index.md +0 -52
  136. package/docs/framework-integrations/custom-elements/index.md +0 -142
  137. package/docs/framework-integrations/react-integration/index.md +0 -937
  138. package/docs/framework-integrations/solid-integration/index.md +0 -293
  139. package/docs/framework-integrations/svelte-integration/index.md +0 -42
  140. package/docs/framework-integrations/vue-integration/index.md +0 -294
  141. package/docs/getting-started/expo/index.md +0 -882
  142. package/docs/getting-started/node/index.md +0 -115
  143. package/docs/getting-started/react-web/index.md +0 -626
  144. package/docs/getting-started/solid/index.md +0 -3
  145. package/docs/getting-started/vue/index.md +0 -471
  146. package/docs/index.md +0 -208
  147. package/docs/llms.txt +0 -146
  148. package/docs/misc/CODE_OF_CONDUCT/index.md +0 -133
  149. package/docs/misc/FAQ/index.md +0 -37
  150. package/docs/misc/community/index.md +0 -88
  151. package/docs/misc/credits/index.md +0 -14
  152. package/docs/misc/design-partners/index.md +0 -13
  153. package/docs/misc/package-management/index.md +0 -21
  154. package/docs/misc/performance/index.md +0 -25
  155. package/docs/misc/resources/index.md +0 -46
  156. package/docs/misc/state-of-the-project/index.md +0 -37
  157. package/docs/misc/troubleshooting/index.md +0 -82
  158. package/docs/overview/concepts/index.md +0 -78
  159. package/docs/overview/how-livestore-works/index.md +0 -56
  160. package/docs/overview/introduction/index.md +0 -413
  161. package/docs/overview/technology-comparison/index.md +0 -40
  162. package/docs/overview/when-livestore/index.md +0 -81
  163. package/docs/overview/why-livestore/index.md +0 -111
  164. package/docs/patterns/ai/index.md +0 -15
  165. package/docs/patterns/anonymous-user-transition/index.md +0 -10
  166. package/docs/patterns/app-evolution/index.md +0 -72
  167. package/docs/patterns/auth/index.md +0 -377
  168. package/docs/patterns/effect/index.md +0 -1505
  169. package/docs/patterns/encryption/index.md +0 -6
  170. package/docs/patterns/external-data/index.md +0 -5
  171. package/docs/patterns/file-management/index.md +0 -11
  172. package/docs/patterns/file-structure/index.md +0 -14
  173. package/docs/patterns/list-ordering/index.md +0 -369
  174. package/docs/patterns/offline/index.md +0 -32
  175. package/docs/patterns/orm/index.md +0 -18
  176. package/docs/patterns/presence/index.md +0 -11
  177. package/docs/patterns/rich-text-editing/index.md +0 -11
  178. package/docs/patterns/server-side-clients/index.md +0 -97
  179. package/docs/patterns/side-effects/index.md +0 -11
  180. package/docs/patterns/state-machines/index.md +0 -11
  181. package/docs/patterns/storybook/index.md +0 -209
  182. package/docs/patterns/undo-redo/index.md +0 -9
  183. package/docs/patterns/version-control/index.md +0 -8
  184. package/docs/platform-adapters/cloudflare-durable-object-adapter/index.md +0 -453
  185. package/docs/platform-adapters/electron-adapter/index.md +0 -15
  186. package/docs/platform-adapters/expo-adapter/index.md +0 -262
  187. package/docs/platform-adapters/node-adapter/index.md +0 -160
  188. package/docs/platform-adapters/tauri-adapter/index.md +0 -15
  189. package/docs/platform-adapters/web-adapter/index.md +0 -287
  190. package/docs/sustainable-open-source/contributing/docs/index.md +0 -94
  191. package/docs/sustainable-open-source/contributing/info/index.md +0 -63
  192. package/docs/sustainable-open-source/contributing/monorepo/index.md +0 -195
  193. package/docs/sustainable-open-source/sponsoring/index.md +0 -104
  194. package/docs/sync-providers/cloudflare/index.md +0 -773
  195. package/docs/sync-providers/custom/index.md +0 -65
  196. package/docs/sync-providers/electricsql/index.md +0 -159
  197. package/docs/sync-providers/s2/index.md +0 -230
  198. package/docs/tutorial/0-welcome/index.md +0 -48
  199. package/docs/tutorial/1-setup-starter-project/index.md +0 -105
  200. package/docs/tutorial/2-deploy-to-cloudflare/index.md +0 -195
  201. package/docs/tutorial/3-read-and-write-todos-via-livestore/index.md +0 -530
  202. package/docs/tutorial/4-sync-data-via-cloudflare/index.md +0 -210
  203. package/docs/tutorial/5-expand-business-logic/index.md +0 -174
  204. package/docs/tutorial/6-persist-ui-state/index.md +0 -453
  205. package/docs/tutorial/7-next-steps/index.md +0 -22
  206. package/docs/understanding-livestore/design-decisions/index.md +0 -33
  207. package/docs/understanding-livestore/event-sourcing/index.md +0 -40
@@ -1,11 +1,12 @@
1
+ import { describe, expect, it } from '@effect/vitest'
2
+
1
3
  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'
4
+ import { OtelLiveDummy, UnknownError } from '@livestore/common'
5
+ import { Effect, Fiber, type OtelTracer, type Scope, TestClock } from '@livestore/utils/effect'
6
+
6
7
  import { schema } from '../utils/tests/fixture.ts'
7
- import { type RegistryStoreOptions, StoreRegistry, storeOptions } from './StoreRegistry.ts'
8
8
  import { StoreInternalsSymbol } from './store-types.ts'
9
+ import { type RegistryStoreOptions, StoreRegistry, storeOptions } from './StoreRegistry.ts'
9
10
 
10
11
  describe('StoreRegistry', () => {
11
12
  it('returns a promise when the store is loading', async () => {
@@ -128,382 +129,405 @@ describe('StoreRegistry', () => {
128
129
  expect(error1).toBe(error2)
129
130
  })
130
131
 
131
- it('disposes store after unusedCacheTime expires', async () => {
132
- const unusedCacheTime = 25
133
- const storeRegistry = new StoreRegistry({ defaultOptions: { unusedCacheTime } })
134
- const options = testStoreOptions()
135
-
136
- const store = await storeRegistry.getOrLoadPromise(options)
137
-
138
- // Store should be cached
139
- expect(storeRegistry.getOrLoadPromise(options)).toBe(store)
140
-
141
- // Wait for disposal
142
- await sleep(unusedCacheTime + 50)
132
+ it('preload does not throw', async () => {
133
+ const storeRegistry = new StoreRegistry()
143
134
 
144
- // After disposal, store should be removed
145
- // The store is removed from cache, so next getOrLoadStore creates a new one
146
- const nextStore = await storeRegistry.getOrLoadPromise(options)
135
+ // Create invalid options that would cause an error
136
+ const badOptions = testStoreOptions({
137
+ // @ts-expect-error - intentionally passing invalid adapter to trigger error
138
+ adapter: null,
139
+ })
147
140
 
148
- // Should be a different store instance
149
- expect(nextStore).not.toBe(store)
150
- expect(nextStore[StoreInternalsSymbol].clientSession.debugInstanceId).toBeDefined()
141
+ // preload should not throw
142
+ await expect(storeRegistry.preload(badOptions)).resolves.toBeUndefined()
151
143
 
152
- // Clean up the second store (first one was disposed)
153
- await nextStore.shutdownPromise()
144
+ // But subsequent getOrLoadStore should throw the cached error
145
+ expect(() => storeRegistry.getOrLoadPromise(badOptions)).toThrow()
154
146
  })
155
147
 
156
- it('does not dispose when unusedCacheTime is Infinity', async () => {
157
- const storeRegistry = new StoreRegistry({ defaultOptions: { unusedCacheTime: Number.POSITIVE_INFINITY } })
148
+ it('warms the cache so subsequent getOrLoadStore is synchronous after preload', async () => {
149
+ const storeRegistry = new StoreRegistry()
158
150
  const options = testStoreOptions()
159
151
 
160
- const store = await storeRegistry.getOrLoadPromise(options)
161
-
162
- // Store should be cached
163
- expect(storeRegistry.getOrLoadPromise(options)).toBe(store)
152
+ // Preload the store
153
+ await storeRegistry.preload(options)
164
154
 
165
- // Wait a reasonable duration to verify no disposal
166
- await sleep(100)
155
+ // Subsequent getOrLoadStore should return synchronously (not a Promise)
156
+ const store = storeRegistry.getOrLoadPromise(options)
157
+ expect(store).not.toBeInstanceOf(Promise)
167
158
 
168
- // Store should still be cached (not disposed)
169
- expect(storeRegistry.getOrLoadPromise(options)).toBe(store)
159
+ // TypeScript doesn't narrow the type, so we need to assert
160
+ if (store instanceof Promise) {
161
+ throw new Error('Expected store, got Promise')
162
+ }
170
163
 
171
- // Clean up manually
164
+ // Clean up
172
165
  await store.shutdownPromise()
173
166
  })
174
167
 
175
- it('schedules disposal if store becomes unused during loading', async () => {
176
- const unusedCacheTime = 50
177
- const storeRegistry = new StoreRegistry({ defaultOptions: { unusedCacheTime } })
178
- const options = testStoreOptions()
179
-
180
- // Start loading without any retain
181
- const storePromise = storeRegistry.getOrLoadPromise(options)
182
-
183
- // Wait for store to load (no retain registered)
184
- const store = await storePromise
185
-
186
- // Since there were no retain when loading completed, disposal should be scheduled
187
- await sleep(unusedCacheTime + 50)
188
-
189
- // Store should be disposed
190
- const nextStore = await storeRegistry.getOrLoadPromise(options)
191
- expect(nextStore).not.toBe(store)
192
-
193
- await nextStore.shutdownPromise()
194
- })
195
-
196
- // This test is skipped because Effect doesn't yet support different `idleTimeToLive` values for each resource in `RcMap`
197
- // See https://github.com/livestorejs/livestore/issues/917
198
- it.skip('allows call-site options to override default options', async () => {
199
- const storeRegistry = new StoreRegistry({
200
- defaultOptions: {
201
- unusedCacheTime: 1000, // Default is long
202
- },
203
- })
204
-
205
- const options = testStoreOptions({
206
- unusedCacheTime: 10, // Override with shorter time
207
- })
208
-
209
- const store = await storeRegistry.getOrLoadPromise(options)
210
-
211
- // Wait for the override time (10ms)
212
- await sleep(10)
213
-
214
- // Should be disposed according to the override time, not default
215
- const nextStore = await storeRegistry.getOrLoadPromise(options)
216
- expect(nextStore).not.toBe(store)
217
-
218
- await nextStore.shutdownPromise()
219
- })
220
-
221
- // This test is skipped because we don't yet support dynamic `unusedCacheTime` updates for cached stores.
222
- // See https://github.com/livestorejs/livestore/issues/918
223
- it.skip('keeps the longest unusedCacheTime seen for a store when options vary across calls', async () => {
224
- const storeRegistry = new StoreRegistry()
225
-
226
- const options = testStoreOptions({ unusedCacheTime: 10 })
227
- const release = storeRegistry.retain(options)
228
-
229
- const store = await storeRegistry.getOrLoadPromise(options)
230
-
231
- // Call with longer unusedCacheTime
232
- await storeRegistry.getOrLoadPromise(testStoreOptions({ unusedCacheTime: 100 }))
233
-
234
- release()
168
+ it.layer(OtelLiveDummy)('time-dependent (using TestClock)', (it) => {
169
+ it.scoped('disposes store after unusedCacheTime expires', () =>
170
+ Effect.gen(function* () {
171
+ const unusedCacheTime = 25
172
+ const runtime = yield* Effect.runtime<Scope.Scope | OtelTracer.OtelTracer>()
173
+ const registry = new StoreRegistry({ runtime, defaultOptions: { unusedCacheTime } })
174
+ const options = testStoreOptions()
235
175
 
236
- // After 99ms, store should still be alive (100ms unusedCacheTime used)
237
- await sleep(99)
176
+ const store = yield* registry.getOrLoad(options).pipe(Effect.scoped)
238
177
 
239
- // Store should still be cached
240
- expect(storeRegistry.getOrLoadPromise(options)).toBe(store)
178
+ // Store should still be in cache
179
+ const cached = yield* registry.getOrLoad(options).pipe(Effect.scoped)
180
+ expect(cached).toBe(store)
241
181
 
242
- // After the full 100ms, store should be disposed
243
- await sleep(1)
182
+ // Let the idle timer fiber register its sleep with TestClock
183
+ yield* Effect.yieldNow()
244
184
 
245
- // Next getOrLoadStore should create a new store
246
- const nextStore = await storeRegistry.getOrLoadPromise(options)
247
- expect(nextStore).not.toBe(store)
185
+ // Advance time past unusedCacheTime idle timer fires → entry evicted
186
+ yield* TestClock.adjust(unusedCacheTime)
248
187
 
249
- // Clean up the second store (first one was disposed)
250
- await nextStore.shutdownPromise()
251
- })
188
+ // After eviction, a new load should produce a different store
189
+ const nextStore = yield* registry.getOrLoad(options).pipe(Effect.scoped)
190
+ expect(nextStore).not.toBe(store)
191
+ expect(nextStore[StoreInternalsSymbol].clientSession.debugInstanceId).toBeDefined()
192
+ }),
193
+ )
252
194
 
253
- it('preload does not throw', async () => {
254
- const storeRegistry = new StoreRegistry()
195
+ it.scoped('does not dispose when unusedCacheTime is Infinity', () =>
196
+ Effect.gen(function* () {
197
+ const runtime = yield* Effect.runtime<Scope.Scope | OtelTracer.OtelTracer>()
198
+ const registry = new StoreRegistry({ runtime, defaultOptions: { unusedCacheTime: Number.POSITIVE_INFINITY } })
199
+ const options = testStoreOptions()
255
200
 
256
- // Create invalid options that would cause an error
257
- const badOptions = testStoreOptions({
258
- // @ts-expect-error - intentionally passing invalid adapter to trigger error
259
- adapter: null,
260
- })
201
+ const store = yield* registry.getOrLoad(options).pipe(Effect.scoped)
261
202
 
262
- // preload should not throw
263
- await expect(storeRegistry.preload(badOptions)).resolves.toBeUndefined()
203
+ // Advance a large amount of time — no idle timer was started for Infinity
204
+ yield* TestClock.adjust(100_000)
264
205
 
265
- // But subsequent getOrLoadStore should throw the cached error
266
- expect(() => storeRegistry.getOrLoadPromise(badOptions)).toThrow()
267
- })
206
+ // Store should still be cached
207
+ const cached = yield* registry.getOrLoad(options).pipe(Effect.scoped)
208
+ expect(cached).toBe(store)
209
+ }),
210
+ )
268
211
 
269
- it('handles rapid retain/release cycles without errors', async () => {
270
- const unusedCacheTime = 50
271
- const storeRegistry = new StoreRegistry({ defaultOptions: { unusedCacheTime } })
272
- const options = testStoreOptions()
212
+ it.scoped('schedules disposal if store becomes unused during loading', () =>
213
+ Effect.gen(function* () {
214
+ const unusedCacheTime = 50
215
+ const runtime = yield* Effect.runtime<Scope.Scope | OtelTracer.OtelTracer>()
216
+ const registry = new StoreRegistry({ runtime, defaultOptions: { unusedCacheTime } })
217
+ const options = testStoreOptions()
273
218
 
274
- const store = await storeRegistry.getOrLoadPromise(options)
219
+ // Load without retaining — disposal is scheduled when scope closes
220
+ const store = yield* registry.getOrLoad(options).pipe(Effect.scoped)
275
221
 
276
- // Rapidly retain and release multiple times
277
- for (let i = 0; i < 10; i++) {
278
- const release = storeRegistry.retain(options)
279
- release()
280
- }
222
+ yield* Effect.yieldNow()
223
+ yield* TestClock.adjust(unusedCacheTime)
281
224
 
282
- // Wait for disposal to trigger
283
- await sleep(unusedCacheTime + 50)
225
+ // Store should be disposed
226
+ const nextStore = yield* registry.getOrLoad(options).pipe(Effect.scoped)
227
+ expect(nextStore).not.toBe(store)
228
+ }),
229
+ )
284
230
 
285
- // Store should be disposed after the last release
286
- const nextStore = await storeRegistry.getOrLoadPromise(options)
287
- expect(nextStore).not.toBe(store)
231
+ it.scoped('allows call-site options to override default options', () =>
232
+ Effect.gen(function* () {
233
+ const runtime = yield* Effect.runtime<Scope.Scope | OtelTracer.OtelTracer>()
234
+ const registry = new StoreRegistry({ runtime, defaultOptions: { unusedCacheTime: 10_000 } }) // Long default
288
235
 
289
- await nextStore.shutdownPromise()
290
- })
236
+ const unusedCacheTimeOverride = 25
237
+ const options = testStoreOptions({ unusedCacheTime: unusedCacheTimeOverride })
291
238
 
292
- it('cancels disposal when new retain', async () => {
293
- const unusedCacheTime = 50
294
- const storeRegistry = new StoreRegistry({ defaultOptions: { unusedCacheTime } })
295
- const options = testStoreOptions()
239
+ const store = yield* registry.getOrLoad(options).pipe(Effect.scoped)
296
240
 
297
- const store = await storeRegistry.getOrLoadPromise(options)
241
+ yield* Effect.yieldNow()
242
+ yield* TestClock.adjust(unusedCacheTimeOverride)
298
243
 
299
- // Wait almost to disposal threshold
300
- await sleep(unusedCacheTime - 5)
244
+ // Should be disposed according to the override time, not default
245
+ const nextStore = yield* registry.getOrLoad(options).pipe(Effect.scoped)
246
+ expect(nextStore).not.toBe(store)
247
+ }),
248
+ )
301
249
 
302
- // Add a new retain before disposal triggers
303
- const release = storeRegistry.retain(options)
250
+ it.scoped('disposes different stores according to their own unusedCacheTime', () =>
251
+ Effect.gen(function* () {
252
+ const runtime = yield* Effect.runtime<Scope.Scope | OtelTracer.OtelTracer>()
253
+ const registry = new StoreRegistry({ runtime, defaultOptions: { unusedCacheTime: 1000 } })
304
254
 
305
- // Complete the original unusedCacheTime
306
- await sleep(5)
255
+ const shortOptions = testStoreOptions({ storeId: 'short-lived', unusedCacheTime: 25 })
256
+ const longOptions = testStoreOptions({ storeId: 'long-lived', unusedCacheTime: 10_000 })
307
257
 
308
- // Store should not have been disposed because we added a retain
309
- expect(storeRegistry.getOrLoadPromise(options)).toBe(store)
258
+ const shortStore = yield* registry.getOrLoad(shortOptions).pipe(Effect.scoped)
259
+ const longStore = yield* registry.getOrLoad(longOptions).pipe(Effect.scoped)
310
260
 
311
- // Clean up
312
- release()
313
- await sleep(unusedCacheTime + 50)
261
+ yield* Effect.yieldNow()
314
262
 
315
- // Now it should be disposed
316
- const nextStore = await storeRegistry.getOrLoadPromise(options)
317
- expect(nextStore).not.toBe(store)
263
+ // Advance past short store's unusedCacheTime only
264
+ yield* TestClock.adjust(25)
318
265
 
319
- await nextStore.shutdownPromise()
320
- })
266
+ // Short store should be disposed, long store should still be cached
267
+ const nextShortStore = yield* registry.getOrLoad(shortOptions).pipe(Effect.scoped)
268
+ expect(nextShortStore).not.toBe(shortStore)
321
269
 
322
- it('aborts loading when disposal fires while store is still loading', async () => {
323
- const unusedCacheTime = 10
324
- const storeRegistry = new StoreRegistry({ defaultOptions: { unusedCacheTime } })
325
- const options = testStoreOptions()
270
+ const cachedLongStore = yield* registry.getOrLoad(longOptions).pipe(Effect.scoped)
271
+ expect(cachedLongStore).toBe(longStore)
272
+ }),
273
+ )
326
274
 
327
- // Retain briefly to trigger getOrLoadStore and then release
328
- const release = storeRegistry.retain(options)
275
+ // This test is skipped because we don't yet support dynamic `unusedCacheTime` updates for cached stores.
276
+ // See https://github.com/livestorejs/livestore/issues/918
277
+ it.scoped.skip('keeps the longest unusedCacheTime seen for a store when options vary across calls', () =>
278
+ Effect.gen(function* () {
279
+ const runtime = yield* Effect.runtime<Scope.Scope | OtelTracer.OtelTracer>()
280
+ const registry = new StoreRegistry({ runtime })
329
281
 
330
- // Start loading
331
- const loadPromise = storeRegistry.getOrLoadPromise(options)
282
+ const options = testStoreOptions({ unusedCacheTime: 10 })
283
+ const release = registry.retain(options)
332
284
 
333
- // Attach a catch handler to prevent unhandled rejection when the load is aborted
334
- const abortedPromise = (loadPromise as Promise<unknown>).catch(() => {
335
- // Expected: load was aborted by disposal
336
- })
285
+ const store = yield* registry.getOrLoad(options).pipe(Effect.scoped)
337
286
 
338
- // Release immediately, which schedules disposal
339
- release()
287
+ // Call with longer unusedCacheTime
288
+ yield* registry.getOrLoad(testStoreOptions({ unusedCacheTime: 100 })).pipe(Effect.scoped)
340
289
 
341
- // Wait for disposal to trigger
342
- await sleep(unusedCacheTime + 50)
290
+ release()
291
+ yield* Effect.yieldNow()
343
292
 
344
- // Wait for the abort to complete
345
- await abortedPromise
293
+ // After 99ms, store should still be alive (100ms unusedCacheTime used)
294
+ yield* TestClock.adjust(99)
346
295
 
347
- // After abort, a new getOrLoadStore should start a fresh load
348
- const freshLoadPromise = storeRegistry.getOrLoadPromise(options)
296
+ const cached = yield* registry.getOrLoad(options).pipe(Effect.scoped)
297
+ expect(cached).toBe(store)
349
298
 
350
- // This should be a new promise (not the aborted one)
351
- expect(freshLoadPromise).toBeInstanceOf(Promise)
352
- expect(freshLoadPromise).not.toBe(loadPromise)
299
+ // After 1 more ms, store should be disposed
300
+ yield* TestClock.adjust(1)
353
301
 
354
- // Wait for fresh load to complete
355
- const store = await freshLoadPromise
356
- expect(store).toBeDefined()
302
+ const nextStore = yield* registry.getOrLoad(options).pipe(Effect.scoped)
303
+ expect(nextStore).not.toBe(store)
304
+ }),
305
+ )
357
306
 
358
- await store.shutdownPromise()
359
- })
307
+ it.scoped('handles rapid retain/release cycles without errors', () =>
308
+ Effect.gen(function* () {
309
+ const unusedCacheTime = 50
310
+ const runtime = yield* Effect.runtime<Scope.Scope | OtelTracer.OtelTracer>()
311
+ const registry = new StoreRegistry({ runtime, defaultOptions: { unusedCacheTime } })
312
+ const options = testStoreOptions()
360
313
 
361
- it('retain keeps store alive past unusedCacheTime', async () => {
362
- const unusedCacheTime = 50
363
- const storeRegistry = new StoreRegistry({ defaultOptions: { unusedCacheTime } })
364
- const options = testStoreOptions()
314
+ const store = yield* registry.getOrLoad(options).pipe(Effect.scoped)
365
315
 
366
- // Load the store
367
- const store = await storeRegistry.getOrLoadPromise(options)
316
+ // Rapidly retain and release multiple times
317
+ for (let i = 0; i < 10; i++) {
318
+ const release = registry.retain(options)
319
+ release()
320
+ }
368
321
 
369
- // Retain the store before disposal could fire
370
- const release = storeRegistry.retain(options)
322
+ yield* Effect.yieldNow()
323
+ yield* TestClock.adjust(unusedCacheTime)
371
324
 
372
- // Wait past the unusedCacheTime
373
- await sleep(unusedCacheTime + 50)
325
+ // Store should be disposed after the last release
326
+ const nextStore = yield* registry.getOrLoad(options).pipe(Effect.scoped)
327
+ expect(nextStore).not.toBe(store)
328
+ }),
329
+ )
374
330
 
375
- // Store should still be cached because retain keeps it alive
376
- const cachedStore = storeRegistry.getOrLoadPromise(options)
377
- expect(cachedStore).toBe(store)
331
+ it.scoped('cancels disposal when new retain', () =>
332
+ Effect.gen(function* () {
333
+ const unusedCacheTime = 50
334
+ const runtime = yield* Effect.runtime<Scope.Scope | OtelTracer.OtelTracer>()
335
+ const registry = new StoreRegistry({ runtime, defaultOptions: { unusedCacheTime } })
336
+ const options = testStoreOptions()
378
337
 
379
- release()
380
- await store.shutdownPromise()
381
- })
338
+ const store = yield* registry.getOrLoad(options).pipe(Effect.scoped)
382
339
 
383
- it('manages multiple stores with different IDs independently', async () => {
384
- const unusedCacheTime = 50
385
- const storeRegistry = new StoreRegistry({ defaultOptions: { unusedCacheTime } })
340
+ yield* Effect.yieldNow()
386
341
 
387
- const options1 = testStoreOptions({ storeId: 'store-1' })
388
- const options2 = testStoreOptions({ storeId: 'store-2' })
342
+ // Advance almost to disposal threshold
343
+ yield* TestClock.adjust(unusedCacheTime - 5)
389
344
 
390
- const store1 = await storeRegistry.getOrLoadPromise(options1)
391
- const store2 = await storeRegistry.getOrLoadPromise(options2)
345
+ // Add a new retain before disposal triggers
346
+ const release = registry.retain(options)
392
347
 
393
- // Should be different store instances
394
- expect(store1).not.toBe(store2)
348
+ // Complete the original unusedCacheTime
349
+ yield* TestClock.adjust(5)
395
350
 
396
- // Both should be cached independently
397
- expect(storeRegistry.getOrLoadPromise(options1)).toBe(store1)
398
- expect(storeRegistry.getOrLoadPromise(options2)).toBe(store2)
351
+ // Store should not have been disposed because retain keeps it alive
352
+ const cached = yield* registry.getOrLoad(options).pipe(Effect.scoped)
353
+ expect(cached).toBe(store)
399
354
 
400
- // Wait for both stores to be disposed
401
- await sleep(unusedCacheTime + 50)
355
+ // Release retain new idle timer starts
356
+ release()
357
+ yield* Effect.yieldNow()
402
358
 
403
- // Both stores should be disposed, so next getOrLoadStore creates new ones
404
- const newStore1 = await storeRegistry.getOrLoadPromise(options1)
405
- expect(newStore1).not.toBe(store1)
359
+ yield* TestClock.adjust(unusedCacheTime)
406
360
 
407
- const newStore2 = await storeRegistry.getOrLoadPromise(options2)
408
- expect(newStore2).not.toBe(store2)
361
+ // Now it should be disposed
362
+ const nextStore = yield* registry.getOrLoad(options).pipe(Effect.scoped)
363
+ expect(nextStore).not.toBe(store)
364
+ }),
365
+ )
409
366
 
410
- // Clean up
411
- await newStore1.shutdownPromise()
412
- await newStore2.shutdownPromise()
413
- })
367
+ it.scoped('aborts loading when disposal fires while store is still loading', () =>
368
+ Effect.gen(function* () {
369
+ const unusedCacheTime = 10
370
+ const loadDelay = 1000
371
+ const runtime = yield* Effect.runtime<Scope.Scope | OtelTracer.OtelTracer>()
372
+ const registry = new StoreRegistry({ runtime, defaultOptions: { unusedCacheTime } })
373
+
374
+ // Adapter that takes time to load (controlled by TestClock)
375
+ const baseAdapter = makeInMemoryAdapter()
376
+ const options = testStoreOptions({
377
+ adapter: ((args: any) =>
378
+ Effect.gen(function* () {
379
+ yield* Effect.sleep(loadDelay)
380
+ return yield* baseAdapter(args)
381
+ })) as any,
382
+ })
383
+
384
+ // Retain triggers loading (won't complete until clock advances past loadDelay)
385
+ const release = registry.retain(options)
386
+ yield* Effect.yieldNow()
387
+
388
+ // Release immediately — schedules disposal after unusedCacheTime
389
+ release()
390
+ yield* Effect.yieldNow()
391
+
392
+ // Advance past unusedCacheTime but NOT past loadDelay → disposal fires, interrupts loading
393
+ yield* TestClock.adjust(unusedCacheTime)
394
+
395
+ // Start a fresh load — since the first was aborted, this should be a new entry
396
+ const freshLoadFiber = yield* Effect.fork(registry.getOrLoad(options).pipe(Effect.scoped))
397
+ yield* Effect.yieldNow()
398
+
399
+ // Advance enough for the fresh load to complete
400
+ yield* TestClock.adjust(loadDelay)
401
+ const store = yield* Fiber.join(freshLoadFiber)
402
+
403
+ expect(store).toBeDefined()
404
+ }),
405
+ )
406
+
407
+ it.scoped('retain keeps store alive past unusedCacheTime', () =>
408
+ Effect.gen(function* () {
409
+ const unusedCacheTime = 50
410
+ const runtime = yield* Effect.runtime<Scope.Scope | OtelTracer.OtelTracer>()
411
+ const registry = new StoreRegistry({ runtime, defaultOptions: { unusedCacheTime } })
412
+ const options = testStoreOptions()
414
413
 
415
- it('applies default options from constructor', async () => {
416
- const storeRegistry = new StoreRegistry({
417
- defaultOptions: {
418
- unusedCacheTime: 100,
419
- },
420
- })
414
+ // Load the store
415
+ const store = yield* registry.getOrLoad(options).pipe(Effect.scoped)
421
416
 
422
- const options = testStoreOptions()
417
+ // Retain the store before disposal could fire
418
+ const release = registry.retain(options)
423
419
 
424
- const store = await storeRegistry.getOrLoadPromise(options)
420
+ yield* Effect.yieldNow()
425
421
 
426
- // Verify the store loads successfully
427
- expect(store).toBeDefined()
428
- expect(store[StoreInternalsSymbol].clientSession.debugInstanceId).toBeDefined()
422
+ // Advance past unusedCacheTime idle timer fires but refCount > 0, so no eviction
423
+ yield* TestClock.adjust(unusedCacheTime + 50)
429
424
 
430
- // Verify configured default unusedCacheTime is applied by checking disposal doesn't happen before it
431
- await sleep(50)
425
+ // Store should still be cached because retain keeps it alive
426
+ const cached = yield* registry.getOrLoad(options).pipe(Effect.scoped)
427
+ expect(cached).toBe(store)
432
428
 
433
- // Store should still be cached after 50ms (default is 100ms)
434
- expect(storeRegistry.getOrLoadPromise(options)).toBe(store)
429
+ release()
430
+ }),
431
+ )
435
432
 
436
- await store.shutdownPromise()
437
- })
433
+ it.scoped('manages multiple stores with different IDs independently', () =>
434
+ Effect.gen(function* () {
435
+ const unusedCacheTime = 50
436
+ const runtime = yield* Effect.runtime<Scope.Scope | OtelTracer.OtelTracer>()
437
+ const registry = new StoreRegistry({ runtime, defaultOptions: { unusedCacheTime } })
438
438
 
439
- it('prevents getOrLoadStore from returning a dying store', async () => {
440
- const unusedCacheTime = 25
441
- const storeRegistry = new StoreRegistry({ defaultOptions: { unusedCacheTime } })
442
- const options = testStoreOptions()
439
+ const options1 = testStoreOptions({ storeId: 'store-1' })
440
+ const options2 = testStoreOptions({ storeId: 'store-2' })
443
441
 
444
- // Load the store and wait for it to be ready
445
- const originalStore = await storeRegistry.getOrLoadPromise(options)
442
+ const store1 = yield* registry.getOrLoad(options1).pipe(Effect.scoped)
443
+ const store2 = yield* registry.getOrLoad(options2).pipe(Effect.scoped)
446
444
 
447
- // Verify store is cached
448
- expect(storeRegistry.getOrLoadPromise(options)).toBe(originalStore)
445
+ // Should be different store instances
446
+ expect(store1).not.toBe(store2)
449
447
 
450
- // Wait for disposal to trigger
451
- await sleep(unusedCacheTime + 50)
448
+ // Both should be cached independently
449
+ const cached1 = yield* registry.getOrLoad(options1).pipe(Effect.scoped)
450
+ const cached2 = yield* registry.getOrLoad(options2).pipe(Effect.scoped)
451
+ expect(cached1).toBe(store1)
452
+ expect(cached2).toBe(store2)
452
453
 
453
- // After disposal, the cache should be cleared
454
- // Calling getOrLoadStore should start a fresh load (return Promise)
455
- const storeOrPromise = storeRegistry.getOrLoadPromise(options)
454
+ yield* Effect.yieldNow()
455
+ yield* TestClock.adjust(unusedCacheTime)
456
456
 
457
- if (!(storeOrPromise instanceof Promise)) {
458
- expect.fail('getOrLoadStore returned dying store synchronously instead of starting fresh load')
459
- }
457
+ // Both stores should be disposed
458
+ const newStore1 = yield* registry.getOrLoad(options1).pipe(Effect.scoped)
459
+ const newStore2 = yield* registry.getOrLoad(options2).pipe(Effect.scoped)
460
+ expect(newStore1).not.toBe(store1)
461
+ expect(newStore2).not.toBe(store2)
462
+ }),
463
+ )
460
464
 
461
- const freshStore = await storeOrPromise
462
- // A fresh load was triggered because cache was cleared
463
- expect(freshStore).not.toBe(originalStore)
464
- await freshStore.shutdownPromise()
465
- })
465
+ it.scoped('applies default options from constructor', () =>
466
+ Effect.gen(function* () {
467
+ const runtime = yield* Effect.runtime<Scope.Scope | OtelTracer.OtelTracer>()
468
+ const registry = new StoreRegistry({ runtime, defaultOptions: { unusedCacheTime: 100 } })
469
+ const options = testStoreOptions()
466
470
 
467
- it('warms the cache so subsequent getOrLoadStore is synchronous after preload', async () => {
468
- const storeRegistry = new StoreRegistry()
469
- const options = testStoreOptions()
471
+ const store = yield* registry.getOrLoad(options).pipe(Effect.scoped)
470
472
 
471
- // Preload the store
472
- await storeRegistry.preload(options)
473
+ // Verify the store loads successfully
474
+ expect(store).toBeDefined()
475
+ expect(store[StoreInternalsSymbol].clientSession.debugInstanceId).toBeDefined()
473
476
 
474
- // Subsequent getOrLoadStore should return synchronously (not a Promise)
475
- const store = storeRegistry.getOrLoadPromise(options)
476
- expect(store).not.toBeInstanceOf(Promise)
477
+ yield* Effect.yieldNow()
477
478
 
478
- // TypeScript doesn't narrow the type, so we need to assert
479
- if (store instanceof Promise) {
480
- throw new Error('Expected store, got Promise')
481
- }
479
+ // After 50ms, store should still be cached (default is 100ms)
480
+ yield* TestClock.adjust(50)
482
481
 
483
- // Clean up
484
- await store.shutdownPromise()
485
- })
482
+ const cached = yield* registry.getOrLoad(options).pipe(Effect.scoped)
483
+ expect(cached).toBe(store)
484
+ }),
485
+ )
486
486
 
487
- it('schedules disposal after preload if no retainers are added', async () => {
488
- const unusedCacheTime = 50
489
- const storeRegistry = new StoreRegistry({ defaultOptions: { unusedCacheTime } })
490
- const options = testStoreOptions()
487
+ it.scoped('does not serve a disposed store from cache', () =>
488
+ Effect.gen(function* () {
489
+ const unusedCacheTime = 25
490
+ const runtime = yield* Effect.runtime<Scope.Scope | OtelTracer.OtelTracer>()
491
+ const registry = new StoreRegistry({ runtime, defaultOptions: { unusedCacheTime } })
492
+ const options = testStoreOptions()
491
493
 
492
- // Preload without retaining
493
- await storeRegistry.preload(options)
494
+ const originalStore = yield* registry.getOrLoad(options).pipe(Effect.scoped)
494
495
 
495
- // Get the store
496
- const store = storeRegistry.getOrLoadPromise(options)
497
- expect(store).not.toBeInstanceOf(Promise)
496
+ // Verify store is cached
497
+ const cached = yield* registry.getOrLoad(options).pipe(Effect.scoped)
498
+ expect(cached).toBe(originalStore)
498
499
 
499
- // Wait for disposal to trigger
500
- await sleep(unusedCacheTime + 50)
500
+ yield* Effect.yieldNow()
501
+ yield* TestClock.adjust(unusedCacheTime)
501
502
 
502
- // Store should be disposed since no retainers were added
503
- const nextStore = await storeRegistry.getOrLoadPromise(options)
504
- expect(nextStore).not.toBe(store)
503
+ // After disposal, calling getOrLoad should produce a fresh store
504
+ const freshStore = yield* registry.getOrLoad(options).pipe(Effect.scoped)
505
+ expect(freshStore).not.toBe(originalStore)
506
+ }),
507
+ )
505
508
 
506
- await nextStore.shutdownPromise()
509
+ it.scoped('schedules disposal after preload if no retainers are added', () =>
510
+ Effect.gen(function* () {
511
+ const unusedCacheTime = 50
512
+ const runtime = yield* Effect.runtime<Scope.Scope | OtelTracer.OtelTracer>()
513
+ const registry = new StoreRegistry({ runtime, defaultOptions: { unusedCacheTime } })
514
+ const options = testStoreOptions()
515
+
516
+ // Preload without retaining (load + immediate release)
517
+ const store = yield* registry.getOrLoad(options).pipe(Effect.scoped)
518
+
519
+ // Verify it's cached
520
+ const cached = yield* registry.getOrLoad(options).pipe(Effect.scoped)
521
+ expect(cached).toBe(store)
522
+
523
+ yield* Effect.yieldNow()
524
+ yield* TestClock.adjust(unusedCacheTime)
525
+
526
+ // Store should be disposed since no retainers were added
527
+ const nextStore = yield* registry.getOrLoad(options).pipe(Effect.scoped)
528
+ expect(nextStore).not.toBe(store)
529
+ }),
530
+ )
507
531
  })
508
532
  })
509
533