@livestore/livestore 0.4.0-dev.21 → 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 (216) 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 +130 -2
  12. package/dist/effect/LiveStore.d.ts.map +1 -1
  13. package/dist/effect/LiveStore.js +185 -6
  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/effect/mod.d.ts +1 -1
  20. package/dist/effect/mod.d.ts.map +1 -1
  21. package/dist/effect/mod.js +3 -1
  22. package/dist/effect/mod.js.map +1 -1
  23. package/dist/live-queries/base-class.d.ts +3 -3
  24. package/dist/live-queries/base-class.d.ts.map +1 -1
  25. package/dist/live-queries/base-class.js +2 -2
  26. package/dist/live-queries/base-class.js.map +1 -1
  27. package/dist/live-queries/client-document-get-query.d.ts +1 -1
  28. package/dist/live-queries/client-document-get-query.d.ts.map +1 -1
  29. package/dist/live-queries/client-document-get-query.js +1 -1
  30. package/dist/live-queries/client-document-get-query.js.map +1 -1
  31. package/dist/live-queries/computed.d.ts.map +1 -1
  32. package/dist/live-queries/computed.js +2 -2
  33. package/dist/live-queries/computed.js.map +1 -1
  34. package/dist/live-queries/db-query.js +14 -14
  35. package/dist/live-queries/db-query.js.map +1 -1
  36. package/dist/live-queries/db-query.test.js +2 -2
  37. package/dist/live-queries/db-query.test.js.map +1 -1
  38. package/dist/live-queries/signal.test.js +2 -2
  39. package/dist/live-queries/signal.test.js.map +1 -1
  40. package/dist/mod.d.ts +2 -1
  41. package/dist/mod.d.ts.map +1 -1
  42. package/dist/mod.js +1 -0
  43. package/dist/mod.js.map +1 -1
  44. package/dist/reactive.d.ts +9 -9
  45. package/dist/reactive.d.ts.map +1 -1
  46. package/dist/reactive.js +9 -26
  47. package/dist/reactive.js.map +1 -1
  48. package/dist/reactive.test.js +2 -2
  49. package/dist/reactive.test.js.map +1 -1
  50. package/dist/store/StoreRegistry.d.ts +215 -0
  51. package/dist/store/StoreRegistry.d.ts.map +1 -0
  52. package/dist/store/StoreRegistry.js +267 -0
  53. package/dist/store/StoreRegistry.js.map +1 -0
  54. package/dist/store/StoreRegistry.test.d.ts +2 -0
  55. package/dist/store/StoreRegistry.test.d.ts.map +1 -0
  56. package/dist/store/StoreRegistry.test.js +381 -0
  57. package/dist/store/StoreRegistry.test.js.map +1 -0
  58. package/dist/store/create-store.d.ts +56 -6
  59. package/dist/store/create-store.d.ts.map +1 -1
  60. package/dist/store/create-store.js +32 -7
  61. package/dist/store/create-store.js.map +1 -1
  62. package/dist/store/devtools.d.ts +1 -1
  63. package/dist/store/devtools.d.ts.map +1 -1
  64. package/dist/store/devtools.js +16 -3
  65. package/dist/store/devtools.js.map +1 -1
  66. package/dist/store/store-eventstream.test.js +2 -2
  67. package/dist/store/store-eventstream.test.js.map +1 -1
  68. package/dist/store/store-types.d.ts +59 -9
  69. package/dist/store/store-types.d.ts.map +1 -1
  70. package/dist/store/store-types.js.map +1 -1
  71. package/dist/store/store-types.test.js +1 -1
  72. package/dist/store/store-types.test.js.map +1 -1
  73. package/dist/store/store.d.ts +102 -6
  74. package/dist/store/store.d.ts.map +1 -1
  75. package/dist/store/store.js +148 -47
  76. package/dist/store/store.js.map +1 -1
  77. package/dist/utils/dev.js.map +1 -1
  78. package/dist/utils/stack-info.js +2 -2
  79. package/dist/utils/stack-info.js.map +1 -1
  80. package/dist/utils/tests/fixture.d.ts +1 -1
  81. package/dist/utils/tests/fixture.d.ts.map +1 -1
  82. package/dist/utils/tests/fixture.js.map +1 -1
  83. package/dist/utils/tests/otel.d.ts.map +1 -1
  84. package/dist/utils/tests/otel.js +5 -5
  85. package/dist/utils/tests/otel.js.map +1 -1
  86. package/package.json +59 -18
  87. package/src/QueryCache.ts +1 -1
  88. package/src/SqliteDbWrapper.test.ts +4 -2
  89. package/src/SqliteDbWrapper.ts +12 -11
  90. package/src/ambient.d.ts +0 -7
  91. package/src/effect/LiveStore.test.ts +61 -0
  92. package/src/effect/LiveStore.ts +381 -8
  93. package/src/effect/mod.ts +13 -1
  94. package/src/live-queries/__snapshots__/db-query.test.ts.snap +336 -231
  95. package/src/live-queries/base-class.ts +7 -6
  96. package/src/live-queries/client-document-get-query.ts +4 -2
  97. package/src/live-queries/computed.ts +3 -2
  98. package/src/live-queries/db-query.test.ts +3 -2
  99. package/src/live-queries/db-query.ts +15 -15
  100. package/src/live-queries/signal.test.ts +3 -2
  101. package/src/mod.ts +2 -0
  102. package/src/reactive.test.ts +3 -2
  103. package/src/reactive.ts +22 -23
  104. package/src/store/StoreRegistry.test.ts +540 -0
  105. package/src/store/StoreRegistry.ts +418 -0
  106. package/src/store/create-store.ts +76 -15
  107. package/src/store/devtools.ts +20 -6
  108. package/src/store/store-eventstream.test.ts +4 -2
  109. package/src/store/store-types.test.ts +3 -1
  110. package/src/store/store-types.ts +64 -13
  111. package/src/store/store.ts +197 -60
  112. package/src/utils/dev.ts +2 -2
  113. package/src/utils/stack-info.ts +2 -2
  114. package/src/utils/tests/fixture.ts +2 -1
  115. package/src/utils/tests/otel.ts +8 -7
  116. package/docs/api/index.md +0 -3
  117. package/docs/building-with-livestore/complex-ui-state/index.md +0 -5
  118. package/docs/building-with-livestore/crud/index.md +0 -5
  119. package/docs/building-with-livestore/data-modeling/index.md +0 -1
  120. package/docs/building-with-livestore/debugging/index.md +0 -17
  121. package/docs/building-with-livestore/devtools/index.md +0 -79
  122. package/docs/building-with-livestore/events/index.md +0 -355
  123. package/docs/building-with-livestore/examples/ai-agent/index.md +0 -5
  124. package/docs/building-with-livestore/examples/index.md +0 -30
  125. package/docs/building-with-livestore/examples/todo-workspaces/index.md +0 -891
  126. package/docs/building-with-livestore/examples/turnbased-game/index.md +0 -7
  127. package/docs/building-with-livestore/opentelemetry/index.md +0 -208
  128. package/docs/building-with-livestore/production-checklist/index.md +0 -5
  129. package/docs/building-with-livestore/reactivity-system/index.md +0 -202
  130. package/docs/building-with-livestore/rules-for-ai-agents/index.md +0 -9
  131. package/docs/building-with-livestore/state/materializers/index.md +0 -300
  132. package/docs/building-with-livestore/state/sql-queries/index.md +0 -72
  133. package/docs/building-with-livestore/state/sqlite/index.md +0 -45
  134. package/docs/building-with-livestore/state/sqlite-schema/index.md +0 -306
  135. package/docs/building-with-livestore/state/sqlite-schema-effect/index.md +0 -300
  136. package/docs/building-with-livestore/store/index.md +0 -281
  137. package/docs/building-with-livestore/syncing/index.md +0 -136
  138. package/docs/building-with-livestore/tools/cli/index.md +0 -177
  139. package/docs/building-with-livestore/tools/mcp/index.md +0 -187
  140. package/docs/examples/cloudflare-adapter/index.md +0 -44
  141. package/docs/examples/expo-adapter/index.md +0 -44
  142. package/docs/examples/index.md +0 -55
  143. package/docs/examples/node-adapter/index.md +0 -44
  144. package/docs/examples/web-adapter/index.md +0 -52
  145. package/docs/framework-integrations/custom-elements/index.md +0 -142
  146. package/docs/framework-integrations/react-integration/index.md +0 -918
  147. package/docs/framework-integrations/solid-integration/index.md +0 -293
  148. package/docs/framework-integrations/svelte-integration/index.md +0 -42
  149. package/docs/framework-integrations/vue-integration/index.md +0 -294
  150. package/docs/getting-started/expo/index.md +0 -736
  151. package/docs/getting-started/node/index.md +0 -115
  152. package/docs/getting-started/react-web/index.md +0 -573
  153. package/docs/getting-started/solid/index.md +0 -3
  154. package/docs/getting-started/vue/index.md +0 -471
  155. package/docs/index.md +0 -209
  156. package/docs/llms.txt +0 -147
  157. package/docs/misc/CODE_OF_CONDUCT/index.md +0 -133
  158. package/docs/misc/FAQ/index.md +0 -37
  159. package/docs/misc/community/index.md +0 -88
  160. package/docs/misc/credits/index.md +0 -14
  161. package/docs/misc/design-partners/index.md +0 -13
  162. package/docs/misc/package-management/index.md +0 -21
  163. package/docs/misc/performance/index.md +0 -25
  164. package/docs/misc/resources/index.md +0 -46
  165. package/docs/misc/state-of-the-project/index.md +0 -37
  166. package/docs/misc/troubleshooting/index.md +0 -82
  167. package/docs/overview/concepts/index.md +0 -78
  168. package/docs/overview/how-livestore-works/index.md +0 -56
  169. package/docs/overview/introduction/index.md +0 -5
  170. package/docs/overview/technology-comparison/index.md +0 -40
  171. package/docs/overview/when-livestore/index.md +0 -81
  172. package/docs/overview/why-livestore/index.md +0 -5
  173. package/docs/patterns/ai/index.md +0 -15
  174. package/docs/patterns/anonymous-user-transition/index.md +0 -10
  175. package/docs/patterns/app-evolution/index.md +0 -72
  176. package/docs/patterns/auth/index.md +0 -226
  177. package/docs/patterns/effect/index.md +0 -1495
  178. package/docs/patterns/encryption/index.md +0 -6
  179. package/docs/patterns/external-data/index.md +0 -5
  180. package/docs/patterns/file-management/index.md +0 -11
  181. package/docs/patterns/file-structure/index.md +0 -14
  182. package/docs/patterns/list-ordering/index.md +0 -369
  183. package/docs/patterns/offline/index.md +0 -32
  184. package/docs/patterns/orm/index.md +0 -18
  185. package/docs/patterns/presence/index.md +0 -11
  186. package/docs/patterns/rich-text-editing/index.md +0 -11
  187. package/docs/patterns/server-side-clients/index.md +0 -97
  188. package/docs/patterns/side-effects/index.md +0 -11
  189. package/docs/patterns/state-machines/index.md +0 -11
  190. package/docs/patterns/storybook/index.md +0 -192
  191. package/docs/patterns/undo-redo/index.md +0 -9
  192. package/docs/patterns/version-control/index.md +0 -8
  193. package/docs/platform-adapters/cloudflare-durable-object-adapter/index.md +0 -453
  194. package/docs/platform-adapters/electron-adapter/index.md +0 -15
  195. package/docs/platform-adapters/expo-adapter/index.md +0 -245
  196. package/docs/platform-adapters/node-adapter/index.md +0 -160
  197. package/docs/platform-adapters/tauri-adapter/index.md +0 -15
  198. package/docs/platform-adapters/web-adapter/index.md +0 -218
  199. package/docs/sustainable-open-source/contributing/docs/index.md +0 -94
  200. package/docs/sustainable-open-source/contributing/info/index.md +0 -63
  201. package/docs/sustainable-open-source/contributing/monorepo/index.md +0 -195
  202. package/docs/sustainable-open-source/sponsoring/index.md +0 -104
  203. package/docs/sync-providers/cloudflare/index.md +0 -773
  204. package/docs/sync-providers/custom/index.md +0 -65
  205. package/docs/sync-providers/electricsql/index.md +0 -159
  206. package/docs/sync-providers/s2/index.md +0 -230
  207. package/docs/tutorial/0-welcome/index.md +0 -48
  208. package/docs/tutorial/1-setup-starter-project/index.md +0 -105
  209. package/docs/tutorial/2-deploy-to-cloudflare/index.md +0 -195
  210. package/docs/tutorial/3-read-and-write-todos-via-livestore/index.md +0 -511
  211. package/docs/tutorial/4-sync-data-via-cloudflare/index.md +0 -210
  212. package/docs/tutorial/5-expand-business-logic/index.md +0 -174
  213. package/docs/tutorial/6-persist-ui-state/index.md +0 -453
  214. package/docs/tutorial/7-next-steps/index.md +0 -22
  215. package/docs/understanding-livestore/design-decisions/index.md +0 -33
  216. package/docs/understanding-livestore/event-sourcing/index.md +0 -40
@@ -0,0 +1,540 @@
1
+ import { describe, expect, it } from '@effect/vitest'
2
+
3
+ import { makeInMemoryAdapter } from '@livestore/adapter-web'
4
+ import { OtelLiveDummy, UnknownError } from '@livestore/common'
5
+ import { Effect, Fiber, type OtelTracer, type Scope, TestClock } from '@livestore/utils/effect'
6
+
7
+ import { schema } from '../utils/tests/fixture.ts'
8
+ import { StoreInternalsSymbol } from './store-types.ts'
9
+ import { type RegistryStoreOptions, StoreRegistry, storeOptions } from './StoreRegistry.ts'
10
+
11
+ describe('StoreRegistry', () => {
12
+ it('returns a promise when the store is loading', async () => {
13
+ const storeRegistry = new StoreRegistry()
14
+ const result = storeRegistry.getOrLoadPromise(testStoreOptions())
15
+
16
+ expect(result).toBeInstanceOf(Promise)
17
+
18
+ // Clean up
19
+ const store = await result
20
+ await store.shutdownPromise()
21
+ })
22
+
23
+ it('returns cached store synchronously after first load resolves', async () => {
24
+ const storeRegistry = new StoreRegistry()
25
+
26
+ const initial = storeRegistry.getOrLoadPromise(testStoreOptions())
27
+ expect(initial).toBeInstanceOf(Promise)
28
+
29
+ const store = await initial
30
+
31
+ const cached = storeRegistry.getOrLoadPromise(testStoreOptions())
32
+ expect(cached).toBe(store)
33
+ expect(cached).not.toBeInstanceOf(Promise)
34
+
35
+ // Clean up
36
+ await store.shutdownPromise()
37
+ })
38
+
39
+ it('reuses the same promise for concurrent getOrLoadPromise calls while loading', async () => {
40
+ const storeRegistry = new StoreRegistry()
41
+ const options = testStoreOptions()
42
+
43
+ const first = storeRegistry.getOrLoadPromise(options)
44
+ const second = storeRegistry.getOrLoadPromise(options)
45
+
46
+ // Both should be the same promise
47
+ expect(first).toBe(second)
48
+ expect(first).toBeInstanceOf(Promise)
49
+
50
+ const store = await first
51
+
52
+ // Both promises should resolve to the same store
53
+ expect(await second).toBe(store)
54
+
55
+ // Clean up
56
+ await store.shutdownPromise()
57
+ })
58
+
59
+ it('throws synchronously and rethrows on subsequent calls for sync failures', () => {
60
+ const storeRegistry = new StoreRegistry()
61
+
62
+ const badOptions = testStoreOptions({
63
+ // @ts-expect-error - intentionally passing invalid adapter to trigger error
64
+ adapter: null,
65
+ })
66
+
67
+ // First call throws synchronously
68
+ expect(() => storeRegistry.getOrLoadPromise(badOptions)).toThrow()
69
+
70
+ // Subsequent call should also throw synchronously (cached error)
71
+ expect(() => storeRegistry.getOrLoadPromise(badOptions)).toThrow()
72
+ })
73
+
74
+ it('caches and rethrows rejection on subsequent calls for async failures', async () => {
75
+ const storeRegistry = new StoreRegistry()
76
+
77
+ // Create an adapter that fails asynchronously (after yielding to the event loop)
78
+ const failingAdapter = () =>
79
+ Effect.gen(function* () {
80
+ yield* Effect.sleep(0) // Force async execution
81
+ return yield* UnknownError.make({ cause: new Error('Async failure') })
82
+ })
83
+ const badOptions = testStoreOptions({
84
+ adapter: failingAdapter,
85
+ })
86
+
87
+ // First call returns a promise that rejects
88
+ await expect(storeRegistry.getOrLoadPromise(badOptions)).rejects.toThrow()
89
+
90
+ // Subsequent call should throw the cached error synchronously (RcMap caches failures)
91
+ expect(() => storeRegistry.getOrLoadPromise(badOptions)).toThrow()
92
+ })
93
+
94
+ it('throws the same error instance on multiple calls after failure', async () => {
95
+ const storeRegistry = new StoreRegistry()
96
+
97
+ // Create an adapter that fails asynchronously
98
+ const failingAdapter = () =>
99
+ Effect.gen(function* () {
100
+ yield* Effect.sleep(0) // Force async execution
101
+ return yield* UnknownError.make({ cause: new Error('Async failure') })
102
+ })
103
+
104
+ const badOptions = testStoreOptions({
105
+ adapter: failingAdapter,
106
+ })
107
+
108
+ // Wait for the first failure
109
+ await expect(storeRegistry.getOrLoadPromise(badOptions)).rejects.toThrow()
110
+
111
+ // Capture the errors from subsequent calls
112
+ let error1: unknown
113
+ let error2: unknown
114
+
115
+ try {
116
+ storeRegistry.getOrLoadPromise(badOptions)
117
+ } catch (err) {
118
+ error1 = err
119
+ }
120
+
121
+ try {
122
+ storeRegistry.getOrLoadPromise(badOptions)
123
+ } catch (err) {
124
+ error2 = err
125
+ }
126
+
127
+ // Both should be the exact same error instance (cached)
128
+ expect(error1).toBeDefined()
129
+ expect(error1).toBe(error2)
130
+ })
131
+
132
+ it('preload does not throw', async () => {
133
+ const storeRegistry = new StoreRegistry()
134
+
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
+ })
140
+
141
+ // preload should not throw
142
+ await expect(storeRegistry.preload(badOptions)).resolves.toBeUndefined()
143
+
144
+ // But subsequent getOrLoadStore should throw the cached error
145
+ expect(() => storeRegistry.getOrLoadPromise(badOptions)).toThrow()
146
+ })
147
+
148
+ it('warms the cache so subsequent getOrLoadStore is synchronous after preload', async () => {
149
+ const storeRegistry = new StoreRegistry()
150
+ const options = testStoreOptions()
151
+
152
+ // Preload the store
153
+ await storeRegistry.preload(options)
154
+
155
+ // Subsequent getOrLoadStore should return synchronously (not a Promise)
156
+ const store = storeRegistry.getOrLoadPromise(options)
157
+ expect(store).not.toBeInstanceOf(Promise)
158
+
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
+ }
163
+
164
+ // Clean up
165
+ await store.shutdownPromise()
166
+ })
167
+
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()
175
+
176
+ const store = yield* registry.getOrLoad(options).pipe(Effect.scoped)
177
+
178
+ // Store should still be in cache
179
+ const cached = yield* registry.getOrLoad(options).pipe(Effect.scoped)
180
+ expect(cached).toBe(store)
181
+
182
+ // Let the idle timer fiber register its sleep with TestClock
183
+ yield* Effect.yieldNow()
184
+
185
+ // Advance time past unusedCacheTime → idle timer fires → entry evicted
186
+ yield* TestClock.adjust(unusedCacheTime)
187
+
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
+ )
194
+
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()
200
+
201
+ const store = yield* registry.getOrLoad(options).pipe(Effect.scoped)
202
+
203
+ // Advance a large amount of time — no idle timer was started for Infinity
204
+ yield* TestClock.adjust(100_000)
205
+
206
+ // Store should still be cached
207
+ const cached = yield* registry.getOrLoad(options).pipe(Effect.scoped)
208
+ expect(cached).toBe(store)
209
+ }),
210
+ )
211
+
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()
218
+
219
+ // Load without retaining — disposal is scheduled when scope closes
220
+ const store = yield* registry.getOrLoad(options).pipe(Effect.scoped)
221
+
222
+ yield* Effect.yieldNow()
223
+ yield* TestClock.adjust(unusedCacheTime)
224
+
225
+ // Store should be disposed
226
+ const nextStore = yield* registry.getOrLoad(options).pipe(Effect.scoped)
227
+ expect(nextStore).not.toBe(store)
228
+ }),
229
+ )
230
+
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
235
+
236
+ const unusedCacheTimeOverride = 25
237
+ const options = testStoreOptions({ unusedCacheTime: unusedCacheTimeOverride })
238
+
239
+ const store = yield* registry.getOrLoad(options).pipe(Effect.scoped)
240
+
241
+ yield* Effect.yieldNow()
242
+ yield* TestClock.adjust(unusedCacheTimeOverride)
243
+
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
+ )
249
+
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 } })
254
+
255
+ const shortOptions = testStoreOptions({ storeId: 'short-lived', unusedCacheTime: 25 })
256
+ const longOptions = testStoreOptions({ storeId: 'long-lived', unusedCacheTime: 10_000 })
257
+
258
+ const shortStore = yield* registry.getOrLoad(shortOptions).pipe(Effect.scoped)
259
+ const longStore = yield* registry.getOrLoad(longOptions).pipe(Effect.scoped)
260
+
261
+ yield* Effect.yieldNow()
262
+
263
+ // Advance past short store's unusedCacheTime only
264
+ yield* TestClock.adjust(25)
265
+
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)
269
+
270
+ const cachedLongStore = yield* registry.getOrLoad(longOptions).pipe(Effect.scoped)
271
+ expect(cachedLongStore).toBe(longStore)
272
+ }),
273
+ )
274
+
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 })
281
+
282
+ const options = testStoreOptions({ unusedCacheTime: 10 })
283
+ const release = registry.retain(options)
284
+
285
+ const store = yield* registry.getOrLoad(options).pipe(Effect.scoped)
286
+
287
+ // Call with longer unusedCacheTime
288
+ yield* registry.getOrLoad(testStoreOptions({ unusedCacheTime: 100 })).pipe(Effect.scoped)
289
+
290
+ release()
291
+ yield* Effect.yieldNow()
292
+
293
+ // After 99ms, store should still be alive (100ms unusedCacheTime used)
294
+ yield* TestClock.adjust(99)
295
+
296
+ const cached = yield* registry.getOrLoad(options).pipe(Effect.scoped)
297
+ expect(cached).toBe(store)
298
+
299
+ // After 1 more ms, store should be disposed
300
+ yield* TestClock.adjust(1)
301
+
302
+ const nextStore = yield* registry.getOrLoad(options).pipe(Effect.scoped)
303
+ expect(nextStore).not.toBe(store)
304
+ }),
305
+ )
306
+
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()
313
+
314
+ const store = yield* registry.getOrLoad(options).pipe(Effect.scoped)
315
+
316
+ // Rapidly retain and release multiple times
317
+ for (let i = 0; i < 10; i++) {
318
+ const release = registry.retain(options)
319
+ release()
320
+ }
321
+
322
+ yield* Effect.yieldNow()
323
+ yield* TestClock.adjust(unusedCacheTime)
324
+
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
+ )
330
+
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()
337
+
338
+ const store = yield* registry.getOrLoad(options).pipe(Effect.scoped)
339
+
340
+ yield* Effect.yieldNow()
341
+
342
+ // Advance almost to disposal threshold
343
+ yield* TestClock.adjust(unusedCacheTime - 5)
344
+
345
+ // Add a new retain before disposal triggers
346
+ const release = registry.retain(options)
347
+
348
+ // Complete the original unusedCacheTime
349
+ yield* TestClock.adjust(5)
350
+
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)
354
+
355
+ // Release retain — new idle timer starts
356
+ release()
357
+ yield* Effect.yieldNow()
358
+
359
+ yield* TestClock.adjust(unusedCacheTime)
360
+
361
+ // Now it should be disposed
362
+ const nextStore = yield* registry.getOrLoad(options).pipe(Effect.scoped)
363
+ expect(nextStore).not.toBe(store)
364
+ }),
365
+ )
366
+
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()
413
+
414
+ // Load the store
415
+ const store = yield* registry.getOrLoad(options).pipe(Effect.scoped)
416
+
417
+ // Retain the store before disposal could fire
418
+ const release = registry.retain(options)
419
+
420
+ yield* Effect.yieldNow()
421
+
422
+ // Advance past unusedCacheTime — idle timer fires but refCount > 0, so no eviction
423
+ yield* TestClock.adjust(unusedCacheTime + 50)
424
+
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)
428
+
429
+ release()
430
+ }),
431
+ )
432
+
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
+
439
+ const options1 = testStoreOptions({ storeId: 'store-1' })
440
+ const options2 = testStoreOptions({ storeId: 'store-2' })
441
+
442
+ const store1 = yield* registry.getOrLoad(options1).pipe(Effect.scoped)
443
+ const store2 = yield* registry.getOrLoad(options2).pipe(Effect.scoped)
444
+
445
+ // Should be different store instances
446
+ expect(store1).not.toBe(store2)
447
+
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)
453
+
454
+ yield* Effect.yieldNow()
455
+ yield* TestClock.adjust(unusedCacheTime)
456
+
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
+ )
464
+
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()
470
+
471
+ const store = yield* registry.getOrLoad(options).pipe(Effect.scoped)
472
+
473
+ // Verify the store loads successfully
474
+ expect(store).toBeDefined()
475
+ expect(store[StoreInternalsSymbol].clientSession.debugInstanceId).toBeDefined()
476
+
477
+ yield* Effect.yieldNow()
478
+
479
+ // After 50ms, store should still be cached (default is 100ms)
480
+ yield* TestClock.adjust(50)
481
+
482
+ const cached = yield* registry.getOrLoad(options).pipe(Effect.scoped)
483
+ expect(cached).toBe(store)
484
+ }),
485
+ )
486
+
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()
493
+
494
+ const originalStore = yield* registry.getOrLoad(options).pipe(Effect.scoped)
495
+
496
+ // Verify store is cached
497
+ const cached = yield* registry.getOrLoad(options).pipe(Effect.scoped)
498
+ expect(cached).toBe(originalStore)
499
+
500
+ yield* Effect.yieldNow()
501
+ yield* TestClock.adjust(unusedCacheTime)
502
+
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
+ )
508
+
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
+ )
531
+ })
532
+ })
533
+
534
+ const testStoreOptions = (overrides: Partial<RegistryStoreOptions<typeof schema>> = {}) =>
535
+ storeOptions({
536
+ storeId: 'test-store',
537
+ schema,
538
+ adapter: makeInMemoryAdapter(),
539
+ ...overrides,
540
+ })