dexie-reactive 0.0.1
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.
- package/LICENSE +21 -0
- package/README.md +463 -0
- package/dist/index.d.mts +24 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.mjs +252 -0
- package/package.json +99 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Konstantin Kroner
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
# dexie-reactive
|
|
2
|
+
|
|
3
|
+
Shared, SSR-safe Dexie live query state for Vue 3 and Nuxt 3.
|
|
4
|
+
|
|
5
|
+
`dexie-reactive` wraps Dexie `liveQuery` with small Vue composables. One
|
|
6
|
+
component owns the real Dexie subscription as a producer. Other components can
|
|
7
|
+
subscribe to the same reactive state by key without creating another Dexie
|
|
8
|
+
subscription.
|
|
9
|
+
|
|
10
|
+
## Why dexie-reactive?
|
|
11
|
+
|
|
12
|
+
Use `dexie-reactive` when multiple Vue components need to react to the same
|
|
13
|
+
Dexie query result without each component creating its own IndexedDB
|
|
14
|
+
subscription.
|
|
15
|
+
|
|
16
|
+
It provides:
|
|
17
|
+
|
|
18
|
+
- one producer-owned Dexie `liveQuery` per key
|
|
19
|
+
- shared Vue reactive state for all consumers
|
|
20
|
+
- duplicate producer protection
|
|
21
|
+
- SSR-safe client-only subscription behavior
|
|
22
|
+
- same-origin tab-to-tab updates through Dexie live query propagation
|
|
23
|
+
- persistent IndexedDB-backed state for query results without keeping the whole
|
|
24
|
+
database in memory
|
|
25
|
+
- stale result protection during restarts and component cleanup
|
|
26
|
+
- explicit loading and error state
|
|
27
|
+
- focused unit and browser integration test coverage
|
|
28
|
+
|
|
29
|
+
This keeps IndexedDB reactivity predictable in Vue and Nuxt apps while leaving
|
|
30
|
+
Dexie query construction fully under application control.
|
|
31
|
+
|
|
32
|
+
## Decision Guide
|
|
33
|
+
|
|
34
|
+
| Use this package when | Avoid it when |
|
|
35
|
+
| ----------------------------------------------------------------------------- | ------------------------------------------------------------- |
|
|
36
|
+
| multiple components share the same IndexedDB query result | each component owns completely independent queries |
|
|
37
|
+
| you want explicit producer and consumer ownership | you want implicit global query caching |
|
|
38
|
+
| you need Nuxt-safe client-only IndexedDB behavior | you need server-side IndexedDB execution |
|
|
39
|
+
| you want Dexie queries to stay application-owned | you want a query builder or ORM abstraction |
|
|
40
|
+
| duplicate shared subscription ownership should fail immediately | duplicate subscriptions are acceptable in your design |
|
|
41
|
+
| you want IndexedDB-backed state that behaves like persistent shared app state | you need a general-purpose Pinia replacement for all UI state |
|
|
42
|
+
| you need same-origin tab-to-tab updates from Dexie writes | you need cross-device or backend synchronization |
|
|
43
|
+
|
|
44
|
+
## What It Does Not Do
|
|
45
|
+
|
|
46
|
+
- It does not replace Dexie.
|
|
47
|
+
- It does not replace Pinia for transient UI state.
|
|
48
|
+
- It does not build or parse queries.
|
|
49
|
+
- It does not sync data to a backend.
|
|
50
|
+
- It does not sync data across devices, users, or origins.
|
|
51
|
+
- It does not provide persistence beyond IndexedDB.
|
|
52
|
+
- It does not create server-side data fetching.
|
|
53
|
+
- It does not hide duplicate ownership mistakes.
|
|
54
|
+
|
|
55
|
+
## When To Use It
|
|
56
|
+
|
|
57
|
+
Use this package when a Vue or Nuxt app needs IndexedDB data that updates
|
|
58
|
+
reactively after Dexie writes.
|
|
59
|
+
|
|
60
|
+
- Use `useLiveQuery` in the component or composable that owns the query.
|
|
61
|
+
- Use `useLiveQuerySubscription` in components that only need to display the
|
|
62
|
+
already shared state.
|
|
63
|
+
- Use an explicit `key` when multiple components should share one subscription.
|
|
64
|
+
- Omit the key for local, non-shared live queries.
|
|
65
|
+
|
|
66
|
+
## Installation
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
npm install dexie-reactive dexie vue
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`dexie` and `vue` are peer dependencies. Nuxt users can install the package in a
|
|
73
|
+
Nuxt 3 project and import the composables directly from `dexie-reactive`.
|
|
74
|
+
|
|
75
|
+
## Quick Start
|
|
76
|
+
|
|
77
|
+
Define your Dexie database once:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
// db.ts
|
|
81
|
+
import Dexie, { type EntityTable } from 'dexie'
|
|
82
|
+
|
|
83
|
+
export interface Friend {
|
|
84
|
+
id?: number
|
|
85
|
+
name: string
|
|
86
|
+
age: number
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface AppDatabase extends Dexie {
|
|
90
|
+
friends: EntityTable<Friend, 'id'>
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const db = new Dexie('app-db') as AppDatabase
|
|
94
|
+
|
|
95
|
+
db.version(1).stores({
|
|
96
|
+
friends: '++id,name,age',
|
|
97
|
+
})
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Create a producer composable:
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
// useOlderFriends.ts
|
|
104
|
+
import { useLiveQuery } from 'dexie-reactive'
|
|
105
|
+
import { db } from './db'
|
|
106
|
+
|
|
107
|
+
export function useOlderFriends() {
|
|
108
|
+
return useLiveQuery(() => db.friends.where('age').above(75).toArray(), {
|
|
109
|
+
key: 'older-friends',
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Use it in a producer component:
|
|
115
|
+
|
|
116
|
+
```vue
|
|
117
|
+
<script setup lang="ts">
|
|
118
|
+
import { useOlderFriends } from './useOlderFriends'
|
|
119
|
+
|
|
120
|
+
const { data, loading, hasError } = useOlderFriends()
|
|
121
|
+
</script>
|
|
122
|
+
|
|
123
|
+
<template>
|
|
124
|
+
<p v-if="loading">Loading...</p>
|
|
125
|
+
<p v-else-if="hasError">Could not load friends.</p>
|
|
126
|
+
<ul v-else>
|
|
127
|
+
<li v-for="friend in data" :key="friend.id">
|
|
128
|
+
{{ friend.name }}
|
|
129
|
+
</li>
|
|
130
|
+
</ul>
|
|
131
|
+
</template>
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Use the same state from a consumer component:
|
|
135
|
+
|
|
136
|
+
```vue
|
|
137
|
+
<script setup lang="ts">
|
|
138
|
+
import { useLiveQuerySubscription } from 'dexie-reactive'
|
|
139
|
+
import type { Friend } from './db'
|
|
140
|
+
|
|
141
|
+
const { data, loading, hasError } =
|
|
142
|
+
useLiveQuerySubscription<Friend>('older-friends')
|
|
143
|
+
</script>
|
|
144
|
+
|
|
145
|
+
<template>
|
|
146
|
+
<p v-if="loading">Loading...</p>
|
|
147
|
+
<p v-else-if="hasError">Could not load friends.</p>
|
|
148
|
+
<ul v-else>
|
|
149
|
+
<li v-for="friend in data" :key="friend.id">
|
|
150
|
+
{{ friend.name }}
|
|
151
|
+
</li>
|
|
152
|
+
</ul>
|
|
153
|
+
</template>
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Core Model
|
|
157
|
+
|
|
158
|
+
`useLiveQuery` is the producer. It owns the real Dexie live query subscription.
|
|
159
|
+
|
|
160
|
+
`useLiveQuerySubscription` is the consumer. It attaches to shared state by key
|
|
161
|
+
and never creates a Dexie subscription.
|
|
162
|
+
|
|
163
|
+
The consumer receives shared reactive state by key. It does not get a cloned
|
|
164
|
+
snapshot and it does not create a second Dexie subscription.
|
|
165
|
+
|
|
166
|
+
## Public API
|
|
167
|
+
|
|
168
|
+
The package exports only the stable composables and public types:
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
import {
|
|
172
|
+
useLiveQuery,
|
|
173
|
+
useLiveQuerySubscription,
|
|
174
|
+
type LiveQueryState,
|
|
175
|
+
type UseLiveQueryOptions,
|
|
176
|
+
} from 'dexie-reactive'
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### `useLiveQuery(queryFn, options?)`
|
|
180
|
+
|
|
181
|
+
Creates and owns a Dexie live query subscription.
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
function useLiveQuery<T>(
|
|
185
|
+
queryFn?:
|
|
186
|
+
| (() => T[] | Promise<T[]>)
|
|
187
|
+
| Ref<(() => T[] | Promise<T[]>) | null | undefined>
|
|
188
|
+
| null,
|
|
189
|
+
options?: {
|
|
190
|
+
key?: string
|
|
191
|
+
},
|
|
192
|
+
): LiveQueryState<T>
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
- `queryFn` is a function that returns an array or a promise of an array.
|
|
196
|
+
- `queryFn` may be passed directly or as a reactive function reference.
|
|
197
|
+
- `options.key` is optional. If omitted, a UUID key is generated and returned.
|
|
198
|
+
- Only one producer may exist for a key.
|
|
199
|
+
- The live query is created only in the browser.
|
|
200
|
+
|
|
201
|
+
### `useLiveQuerySubscription(key)`
|
|
202
|
+
|
|
203
|
+
Consumes existing shared state by key.
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
function useLiveQuerySubscription<T>(key: string): LiveQueryState<T>
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
- Receives only a key.
|
|
210
|
+
- Never receives a query function.
|
|
211
|
+
- Never creates a Dexie live query subscription.
|
|
212
|
+
- Returns the same reactive refs as the producer once the producer exists.
|
|
213
|
+
|
|
214
|
+
### Returned State
|
|
215
|
+
|
|
216
|
+
Both composables return:
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
interface LiveQueryState<T> {
|
|
220
|
+
key: string
|
|
221
|
+
data: Ref<T[]>
|
|
222
|
+
loading: Ref<boolean>
|
|
223
|
+
hasError: Ref<boolean>
|
|
224
|
+
error?: Ref<unknown | undefined>
|
|
225
|
+
stop: () => void
|
|
226
|
+
restart: () => void
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
`error` is available only in development mode. Production consumers should rely
|
|
231
|
+
on `hasError`.
|
|
232
|
+
|
|
233
|
+
## Generated Key Usage
|
|
234
|
+
|
|
235
|
+
If no key is provided, `useLiveQuery` generates a UUID key and returns it.
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
const friends = useLiveQuery(() => db.friends.toArray())
|
|
239
|
+
|
|
240
|
+
console.log(friends.key)
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Generated keys are useful for local, non-shared subscriptions. Use an explicit
|
|
244
|
+
key when another component needs to subscribe to the same state.
|
|
245
|
+
|
|
246
|
+
## Error Handling
|
|
247
|
+
|
|
248
|
+
Errors are caught internally and do not throw to components.
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
const friends = useLiveQuery(() => db.friends.toArray(), {
|
|
252
|
+
key: 'friends',
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
if (friends.hasError.value) {
|
|
256
|
+
// Render fallback UI or trigger app-level reporting.
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
On failure:
|
|
261
|
+
|
|
262
|
+
- `hasError.value` becomes `true`
|
|
263
|
+
- `loading.value` becomes `false`
|
|
264
|
+
- `data.value` remains an array
|
|
265
|
+
- the original error is exposed only through `error` in development mode
|
|
266
|
+
|
|
267
|
+
## Query Function Behavior
|
|
268
|
+
|
|
269
|
+
The package passes your function to Dexie `liveQuery`; it does not parse, build,
|
|
270
|
+
transform, or interpret Dexie queries.
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
const query = () => db.friends.where('age').above(75).toArray()
|
|
274
|
+
|
|
275
|
+
const friends = useLiveQuery(query, { key: 'older-friends' })
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Reactive values used inside the query function are not tracked by
|
|
279
|
+
`dexie-reactive`. If external dependencies change, recreate the query function
|
|
280
|
+
reference.
|
|
281
|
+
|
|
282
|
+
```ts
|
|
283
|
+
import { computed } from 'vue'
|
|
284
|
+
|
|
285
|
+
const minimumAge = ref(75)
|
|
286
|
+
const query = computed(
|
|
287
|
+
() => () => db.friends.where('age').above(minimumAge.value).toArray(),
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
const friends = useLiveQuery(query, { key: 'filtered-friends' })
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
When the query function reference changes, the composable stops the current
|
|
294
|
+
subscription, resets state to `data = []`, `loading = true`, `hasError = false`,
|
|
295
|
+
and starts a new subscription.
|
|
296
|
+
|
|
297
|
+
## SSR And Nuxt
|
|
298
|
+
|
|
299
|
+
The same package build works in plain Vue and Nuxt.
|
|
300
|
+
|
|
301
|
+
During SSR:
|
|
302
|
+
|
|
303
|
+
- no Dexie live query subscription is created
|
|
304
|
+
- shared state is scoped per runtime environment
|
|
305
|
+
- browser state is not shared with SSR
|
|
306
|
+
- SSR request state is isolated and cannot leak across requests
|
|
307
|
+
|
|
308
|
+
Use the composables in Nuxt components or composables, but expect live Dexie
|
|
309
|
+
updates only on the client because IndexedDB is a browser API.
|
|
310
|
+
|
|
311
|
+
```vue
|
|
312
|
+
<script setup lang="ts">
|
|
313
|
+
import { useLiveQuery } from 'dexie-reactive'
|
|
314
|
+
|
|
315
|
+
const friends = useLiveQuery(() => db.friends.toArray(), {
|
|
316
|
+
key: 'friends',
|
|
317
|
+
})
|
|
318
|
+
</script>
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
## Lifecycle Controls
|
|
322
|
+
|
|
323
|
+
`stop()` unsubscribes from Dexie, sets `loading` to `false`, and keeps current
|
|
324
|
+
data.
|
|
325
|
+
|
|
326
|
+
`restart()` performs a full reset and starts a new subscription using the latest
|
|
327
|
+
query function.
|
|
328
|
+
|
|
329
|
+
For shared keys, these controls affect all consumers because they point to the
|
|
330
|
+
producer-owned state.
|
|
331
|
+
|
|
332
|
+
## Flow Diagrams
|
|
333
|
+
|
|
334
|
+
### Producer Flow
|
|
335
|
+
|
|
336
|
+
```mermaid
|
|
337
|
+
flowchart TD
|
|
338
|
+
A["useLiveQuery(queryFn, options)"] --> B["Resolve or generate key"]
|
|
339
|
+
B --> C{"Key already exists?"}
|
|
340
|
+
C -->|Yes| D["Throw duplicate producer error"]
|
|
341
|
+
C -->|No| E["Create shared reactive state"]
|
|
342
|
+
E --> F["Store state in subscription map"]
|
|
343
|
+
F --> G["Emit registration message"]
|
|
344
|
+
G --> H{"Browser runtime?"}
|
|
345
|
+
H -->|No| I["Do not create Dexie subscription"]
|
|
346
|
+
H -->|Yes| J["Start Dexie liveQuery"]
|
|
347
|
+
J --> K["Apply latest result to shared refs"]
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
### Consumer Flow
|
|
351
|
+
|
|
352
|
+
```mermaid
|
|
353
|
+
flowchart TD
|
|
354
|
+
A["useLiveQuerySubscription(key)"] --> B{"Key exists in subscription map?"}
|
|
355
|
+
B -->|Yes| C["Return existing shared reactive state"]
|
|
356
|
+
B -->|No| D["Create waiting reactive state"]
|
|
357
|
+
D --> E["Register waiting consumer by key"]
|
|
358
|
+
E --> F["Return waiting state"]
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
### Waiting Consumer Flow
|
|
362
|
+
|
|
363
|
+
```mermaid
|
|
364
|
+
flowchart TD
|
|
365
|
+
A["Consumer subscribes before producer"] --> B["Waiting consumer is stored by key"]
|
|
366
|
+
B --> C["Producer later registers same key"]
|
|
367
|
+
C --> D["Registration message is emitted synchronously"]
|
|
368
|
+
D --> E["Waiting consumer attaches to producer refs"]
|
|
369
|
+
E --> F["Waiting entry is removed"]
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
### Duplicate Producer Error Flow
|
|
373
|
+
|
|
374
|
+
```mermaid
|
|
375
|
+
flowchart TD
|
|
376
|
+
A["First useLiveQuery registers key"] --> B["subscriptionMap has key"]
|
|
377
|
+
B --> C["Second useLiveQuery uses same key"]
|
|
378
|
+
C --> D["No second Dexie subscription is created"]
|
|
379
|
+
D --> E["Error is thrown immediately"]
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
## Limitations
|
|
383
|
+
|
|
384
|
+
- Dexie queries must return arrays.
|
|
385
|
+
- Consumers cannot create subscriptions.
|
|
386
|
+
- Keys are identifiers for shared state, not a security boundary.
|
|
387
|
+
- Live queries run only in the browser.
|
|
388
|
+
- The package uses vanilla Dexie `liveQuery` semantics through the composables.
|
|
389
|
+
- It does not use framework-specific Dexie bindings such as React hooks.
|
|
390
|
+
|
|
391
|
+
## Anti-Patterns
|
|
392
|
+
|
|
393
|
+
Do not create duplicate producers for the same key:
|
|
394
|
+
|
|
395
|
+
```ts
|
|
396
|
+
useLiveQuery(() => db.friends.toArray(), { key: 'friends' })
|
|
397
|
+
useLiveQuery(() => db.friends.toArray(), { key: 'friends' }) // throws
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
Do not pass query functions to consumers:
|
|
401
|
+
|
|
402
|
+
```ts
|
|
403
|
+
useLiveQuerySubscription('friends') // correct
|
|
404
|
+
useLiveQuerySubscription(() => db.friends.toArray()) // incorrect
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
Do not rely on reactive values inside a stable query function reference:
|
|
408
|
+
|
|
409
|
+
```ts
|
|
410
|
+
const minimumAge = ref(75)
|
|
411
|
+
|
|
412
|
+
useLiveQuery(() => db.friends.where('age').above(minimumAge.value).toArray(), {
|
|
413
|
+
key: 'friends',
|
|
414
|
+
})
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
Recreate the query function when dependencies change instead.
|
|
418
|
+
|
|
419
|
+
## Demo And Browser Integration Test
|
|
420
|
+
|
|
421
|
+
The browser test app doubles as a small local demo:
|
|
422
|
+
|
|
423
|
+
```sh
|
|
424
|
+
npm run test:browser:server
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
Open the printed local URL and inspect IndexedDB under that same origin. The demo
|
|
428
|
+
database is named `dexie-reactive-browser` and uses a `friends` object store.
|
|
429
|
+
|
|
430
|
+
## Testing Strategy
|
|
431
|
+
|
|
432
|
+
The unit test suite focuses on the shared live query contract:
|
|
433
|
+
|
|
434
|
+
- public API exports and returned reactive state shape
|
|
435
|
+
- browser singleton and SSR-isolated subscription scopes
|
|
436
|
+
- producer lifecycle for start, stop, restart, unsubscribe, and cleanup
|
|
437
|
+
- duplicate producer rejection without creating a second Dexie subscription
|
|
438
|
+
- consumer coordination for producer-first and waiting-consumer flows
|
|
439
|
+
- shared reactive state references instead of cloned consumer state
|
|
440
|
+
- stale result protection across stop, restart, scope disposal, and rapid query changes
|
|
441
|
+
- missing, invalid, and changing query function handling
|
|
442
|
+
- error, loading, and development-only error exposure behavior
|
|
443
|
+
- generated UUID key uniqueness
|
|
444
|
+
- Dexie `liveQuery` usage through the provided query callback
|
|
445
|
+
|
|
446
|
+
The browser integration suite mounts a minimal Vue app in Chromium with a real
|
|
447
|
+
Dexie IndexedDB database. It verifies producer and consumer components sharing
|
|
448
|
+
one key, database updates propagating to all mounted components, consumer
|
|
449
|
+
unmount/remount behavior, and duplicate producer errors in the browser runtime.
|
|
450
|
+
|
|
451
|
+
## Scripts
|
|
452
|
+
|
|
453
|
+
- `npm run lint` checks the code with ESLint.
|
|
454
|
+
- `npm run format:check` verifies Prettier formatting.
|
|
455
|
+
- `npm run typecheck` runs TypeScript without emitting files.
|
|
456
|
+
- `npm run test` runs Vitest tests from `tests/*`.
|
|
457
|
+
- `npm run test:browser` runs Playwright browser integration tests.
|
|
458
|
+
- `npm run build` builds the package with unbuild.
|
|
459
|
+
- `npm run check` runs linting, formatting, type checking, tests, and build.
|
|
460
|
+
|
|
461
|
+
## Git Hooks
|
|
462
|
+
|
|
463
|
+
Husky runs staged linting before commits and commitlint for commit messages.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Ref } from 'vue';
|
|
2
|
+
|
|
3
|
+
type MaybePromise<T> = T | Promise<T>;
|
|
4
|
+
type LiveQueryQueryFunction<T> = () => MaybePromise<T[]>;
|
|
5
|
+
interface UseLiveQueryOptions {
|
|
6
|
+
key?: string;
|
|
7
|
+
}
|
|
8
|
+
interface LiveQueryState<T> {
|
|
9
|
+
key: string;
|
|
10
|
+
data: Ref<T[]>;
|
|
11
|
+
loading: Ref<boolean>;
|
|
12
|
+
hasError: Ref<boolean>;
|
|
13
|
+
error?: Ref<unknown | undefined>;
|
|
14
|
+
stop: () => void;
|
|
15
|
+
restart: () => void;
|
|
16
|
+
}
|
|
17
|
+
type LiveQueryQuerySource<T> = LiveQueryQueryFunction<T> | Ref<LiveQueryQueryFunction<T> | null | undefined> | null | undefined;
|
|
18
|
+
|
|
19
|
+
declare function useLiveQuery<T>(queryFn?: LiveQueryQuerySource<T>, options?: UseLiveQueryOptions): LiveQueryState<T>;
|
|
20
|
+
|
|
21
|
+
declare function useLiveQuerySubscription<T>(key: string): LiveQueryState<T>;
|
|
22
|
+
|
|
23
|
+
export { useLiveQuery, useLiveQuerySubscription };
|
|
24
|
+
export type { LiveQueryQueryFunction, LiveQueryQuerySource, LiveQueryState, MaybePromise, UseLiveQueryOptions };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Ref } from 'vue';
|
|
2
|
+
|
|
3
|
+
type MaybePromise<T> = T | Promise<T>;
|
|
4
|
+
type LiveQueryQueryFunction<T> = () => MaybePromise<T[]>;
|
|
5
|
+
interface UseLiveQueryOptions {
|
|
6
|
+
key?: string;
|
|
7
|
+
}
|
|
8
|
+
interface LiveQueryState<T> {
|
|
9
|
+
key: string;
|
|
10
|
+
data: Ref<T[]>;
|
|
11
|
+
loading: Ref<boolean>;
|
|
12
|
+
hasError: Ref<boolean>;
|
|
13
|
+
error?: Ref<unknown | undefined>;
|
|
14
|
+
stop: () => void;
|
|
15
|
+
restart: () => void;
|
|
16
|
+
}
|
|
17
|
+
type LiveQueryQuerySource<T> = LiveQueryQueryFunction<T> | Ref<LiveQueryQueryFunction<T> | null | undefined> | null | undefined;
|
|
18
|
+
|
|
19
|
+
declare function useLiveQuery<T>(queryFn?: LiveQueryQuerySource<T>, options?: UseLiveQueryOptions): LiveQueryState<T>;
|
|
20
|
+
|
|
21
|
+
declare function useLiveQuerySubscription<T>(key: string): LiveQueryState<T>;
|
|
22
|
+
|
|
23
|
+
export { useLiveQuery, useLiveQuerySubscription };
|
|
24
|
+
export type { LiveQueryQueryFunction, LiveQueryQuerySource, LiveQueryState, MaybePromise, UseLiveQueryOptions };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { liveQuery } from 'dexie';
|
|
2
|
+
import { shallowReactive, ref, shallowRef, onScopeDispose, isRef, watch } from 'vue';
|
|
3
|
+
|
|
4
|
+
function createLiveQueryState(key) {
|
|
5
|
+
const state = shallowReactive({
|
|
6
|
+
key,
|
|
7
|
+
data: shallowRef([]),
|
|
8
|
+
loading: ref(false),
|
|
9
|
+
hasError: ref(false),
|
|
10
|
+
stop: () => {
|
|
11
|
+
},
|
|
12
|
+
restart: () => {
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
if (isDevelopmentEnvironment()) {
|
|
16
|
+
state.error = ref(void 0);
|
|
17
|
+
}
|
|
18
|
+
return state;
|
|
19
|
+
}
|
|
20
|
+
function isDevelopmentEnvironment() {
|
|
21
|
+
const runtime = globalThis;
|
|
22
|
+
if (!runtime.process) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
return runtime.process.env?.NODE_ENV === "development";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let browserSubscriptionScope;
|
|
29
|
+
function createSubscriptionScope() {
|
|
30
|
+
return {
|
|
31
|
+
subscriptionMap: /* @__PURE__ */ new Map(),
|
|
32
|
+
waitingConsumers: /* @__PURE__ */ new Map()
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function resolveSubscriptionScope() {
|
|
36
|
+
if (typeof window === "undefined") {
|
|
37
|
+
return createSubscriptionScope();
|
|
38
|
+
}
|
|
39
|
+
browserSubscriptionScope ??= createSubscriptionScope();
|
|
40
|
+
return browserSubscriptionScope;
|
|
41
|
+
}
|
|
42
|
+
function registerLiveQueryProducer(scope, state, configureEntry) {
|
|
43
|
+
if (scope.subscriptionMap.has(state.key)) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`Duplicate live query producer for key "${state.key}". Only one useLiveQuery producer may own a key; useLiveQuerySubscription(key) to consume existing shared state.`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
const entry = Object.assign(state, {
|
|
49
|
+
producer: {
|
|
50
|
+
active: true,
|
|
51
|
+
generation: 0
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
configureEntry?.(entry);
|
|
55
|
+
scope.subscriptionMap.set(
|
|
56
|
+
entry.key,
|
|
57
|
+
entry
|
|
58
|
+
);
|
|
59
|
+
emitSubscriptionRegistered(scope, entry);
|
|
60
|
+
return entry;
|
|
61
|
+
}
|
|
62
|
+
function unregisterLiveQueryProducer(scope, entry) {
|
|
63
|
+
const currentEntry = scope.subscriptionMap.get(entry.key);
|
|
64
|
+
if (currentEntry !== entry) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
entry.producer.active = false;
|
|
68
|
+
scope.subscriptionMap.delete(entry.key);
|
|
69
|
+
}
|
|
70
|
+
function resolveLiveQuerySubscription(scope, key) {
|
|
71
|
+
const entry = scope.subscriptionMap.get(key);
|
|
72
|
+
if (entry) {
|
|
73
|
+
return entry;
|
|
74
|
+
}
|
|
75
|
+
const state = createLiveQueryState(key);
|
|
76
|
+
state.loading.value = true;
|
|
77
|
+
const waitingConsumer = {
|
|
78
|
+
attach: (registeredEntry) => {
|
|
79
|
+
attachToSharedState(state, registeredEntry);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
addWaitingConsumer(scope, key, waitingConsumer);
|
|
83
|
+
onScopeDispose(() => {
|
|
84
|
+
removeWaitingConsumer(scope, key, waitingConsumer);
|
|
85
|
+
}, true);
|
|
86
|
+
return state;
|
|
87
|
+
}
|
|
88
|
+
function addWaitingConsumer(scope, key, waitingConsumer) {
|
|
89
|
+
const consumers = scope.waitingConsumers.get(key) ?? /* @__PURE__ */ new Set();
|
|
90
|
+
consumers.add(waitingConsumer);
|
|
91
|
+
scope.waitingConsumers.set(key, consumers);
|
|
92
|
+
}
|
|
93
|
+
function removeWaitingConsumer(scope, key, waitingConsumer) {
|
|
94
|
+
const consumers = scope.waitingConsumers.get(key);
|
|
95
|
+
if (!consumers) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
consumers.delete(waitingConsumer);
|
|
99
|
+
if (consumers.size === 0) {
|
|
100
|
+
scope.waitingConsumers.delete(key);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function emitSubscriptionRegistered(scope, entry) {
|
|
104
|
+
const waitingConsumers = scope.waitingConsumers.get(entry.key);
|
|
105
|
+
if (!waitingConsumers) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
for (const waitingConsumer of waitingConsumers) {
|
|
109
|
+
waitingConsumer.attach(entry);
|
|
110
|
+
}
|
|
111
|
+
scope.waitingConsumers.delete(entry.key);
|
|
112
|
+
}
|
|
113
|
+
function attachToSharedState(state, entry) {
|
|
114
|
+
state.data = entry.data;
|
|
115
|
+
state.loading = entry.loading;
|
|
116
|
+
state.hasError = entry.hasError;
|
|
117
|
+
state.error = entry.error;
|
|
118
|
+
state.stop = entry.stop;
|
|
119
|
+
state.restart = entry.restart;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function useLiveQuery(queryFn, options = {}) {
|
|
123
|
+
const scope = resolveSubscriptionScope();
|
|
124
|
+
const state = createLiveQueryState(options.key ?? crypto.randomUUID());
|
|
125
|
+
let subscription;
|
|
126
|
+
let latestQueryFn = resolveQueryFunction(queryFn);
|
|
127
|
+
const resetState = () => {
|
|
128
|
+
entry.data.value = [];
|
|
129
|
+
entry.loading.value = true;
|
|
130
|
+
entry.hasError.value = false;
|
|
131
|
+
clearError(entry);
|
|
132
|
+
};
|
|
133
|
+
const stopSubscription = () => {
|
|
134
|
+
subscription?.unsubscribe();
|
|
135
|
+
subscription = void 0;
|
|
136
|
+
incrementGeneration(entry);
|
|
137
|
+
entry.loading.value = false;
|
|
138
|
+
};
|
|
139
|
+
const startSubscription = () => {
|
|
140
|
+
stopSubscription();
|
|
141
|
+
const query = latestQueryFn;
|
|
142
|
+
if (!query) {
|
|
143
|
+
applyInactiveDefaults(entry);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (typeof query !== "function") {
|
|
147
|
+
applyInactiveErrorDefaults(entry);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
resetState();
|
|
151
|
+
const generation = incrementGeneration(entry);
|
|
152
|
+
if (typeof window === "undefined") {
|
|
153
|
+
entry.loading.value = false;
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
const observable = liveQuery(
|
|
158
|
+
() => query()
|
|
159
|
+
);
|
|
160
|
+
subscription = observable.subscribe({
|
|
161
|
+
next: (result) => {
|
|
162
|
+
if (!isLatestGeneration(entry, generation)) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
applyResultDefaults(entry, result);
|
|
166
|
+
},
|
|
167
|
+
error: (error) => {
|
|
168
|
+
if (!isLatestGeneration(entry, generation)) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
applyErrorDefaults(entry, error);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (!isLatestGeneration(entry, generation)) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
applyErrorDefaults(entry, error);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
const restartSubscription = () => {
|
|
182
|
+
latestQueryFn = resolveQueryFunction(queryFn);
|
|
183
|
+
startSubscription();
|
|
184
|
+
};
|
|
185
|
+
const entry = registerLiveQueryProducer(scope, state, (registeredEntry) => {
|
|
186
|
+
registeredEntry.stop = stopSubscription;
|
|
187
|
+
registeredEntry.restart = restartSubscription;
|
|
188
|
+
});
|
|
189
|
+
if (isRef(queryFn)) {
|
|
190
|
+
watch(
|
|
191
|
+
queryFn,
|
|
192
|
+
(nextQueryFn) => {
|
|
193
|
+
latestQueryFn = nextQueryFn;
|
|
194
|
+
startSubscription();
|
|
195
|
+
},
|
|
196
|
+
{ flush: "sync" }
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
startSubscription();
|
|
200
|
+
onScopeDispose(() => {
|
|
201
|
+
stopSubscription();
|
|
202
|
+
unregisterLiveQueryProducer(scope, entry);
|
|
203
|
+
}, true);
|
|
204
|
+
return entry;
|
|
205
|
+
}
|
|
206
|
+
function resolveQueryFunction(queryFn) {
|
|
207
|
+
return isRef(queryFn) ? queryFn.value : queryFn;
|
|
208
|
+
}
|
|
209
|
+
function incrementGeneration(entry) {
|
|
210
|
+
entry.producer.generation += 1;
|
|
211
|
+
return entry.producer.generation;
|
|
212
|
+
}
|
|
213
|
+
function isLatestGeneration(entry, generation) {
|
|
214
|
+
return entry.producer.active && entry.producer.generation === generation;
|
|
215
|
+
}
|
|
216
|
+
function clearError(entry) {
|
|
217
|
+
if (entry.error) {
|
|
218
|
+
entry.error.value = void 0;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function applyInactiveDefaults(entry) {
|
|
222
|
+
entry.data.value = [];
|
|
223
|
+
entry.loading.value = false;
|
|
224
|
+
entry.hasError.value = false;
|
|
225
|
+
clearError(entry);
|
|
226
|
+
}
|
|
227
|
+
function applyInactiveErrorDefaults(entry) {
|
|
228
|
+
applyInactiveDefaults(entry);
|
|
229
|
+
entry.hasError.value = true;
|
|
230
|
+
}
|
|
231
|
+
function applyErrorDefaults(entry, error) {
|
|
232
|
+
entry.loading.value = false;
|
|
233
|
+
entry.hasError.value = true;
|
|
234
|
+
setError(entry, error);
|
|
235
|
+
}
|
|
236
|
+
function applyResultDefaults(entry, result) {
|
|
237
|
+
entry.data.value = Array.isArray(result) ? result : [];
|
|
238
|
+
entry.loading.value = false;
|
|
239
|
+
entry.hasError.value = false;
|
|
240
|
+
clearError(entry);
|
|
241
|
+
}
|
|
242
|
+
function setError(entry, error) {
|
|
243
|
+
if (entry.error) {
|
|
244
|
+
entry.error.value = error ?? void 0;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function useLiveQuerySubscription(key) {
|
|
249
|
+
return resolveLiveQuerySubscription(resolveSubscriptionScope(), key);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export { useLiveQuery, useLiveQuerySubscription };
|
package/package.json
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dexie-reactive",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Shared, SSR-safe Dexie live query state for Vue 3 and Nuxt 3.",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"keywords": [
|
|
9
|
+
"dexie",
|
|
10
|
+
"indexeddb",
|
|
11
|
+
"livequery",
|
|
12
|
+
"vue",
|
|
13
|
+
"vue3",
|
|
14
|
+
"nuxt",
|
|
15
|
+
"nuxt3",
|
|
16
|
+
"composable",
|
|
17
|
+
"reactive",
|
|
18
|
+
"shared-state",
|
|
19
|
+
"offline-first"
|
|
20
|
+
],
|
|
21
|
+
"homepage": "https://github.com/Nessiahs/dexie-reactive#readme",
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/Nessiahs/dexie-reactive/issues"
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/Nessiahs/dexie-reactive.git"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"author": "Konstantin Kroner",
|
|
31
|
+
"packageManager": "npm@11.6.2",
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
|
34
|
+
},
|
|
35
|
+
"main": "./dist/index.mjs",
|
|
36
|
+
"module": "./dist/index.mjs",
|
|
37
|
+
"types": "./dist/index.d.ts",
|
|
38
|
+
"exports": {
|
|
39
|
+
".": {
|
|
40
|
+
"types": "./dist/index.d.ts",
|
|
41
|
+
"import": "./dist/index.mjs"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"sideEffects": false,
|
|
45
|
+
"files": [
|
|
46
|
+
"dist"
|
|
47
|
+
],
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "unbuild",
|
|
50
|
+
"check": "npm run lint && npm run format:check && npm run typecheck && npm run test:coverage && npm run build",
|
|
51
|
+
"lint": "eslint .",
|
|
52
|
+
"format": "prettier . --write",
|
|
53
|
+
"format:check": "prettier . --check",
|
|
54
|
+
"typecheck": "tsc --noEmit",
|
|
55
|
+
"commitlint": "commitlint --edit",
|
|
56
|
+
"stagedlint": "lint-staged",
|
|
57
|
+
"lint:staged": "lint-staged",
|
|
58
|
+
"test:browser": "playwright test",
|
|
59
|
+
"test:browser:server": "vite --config tests/browser/vite.config.mjs --host 127.0.0.1 --port 4173",
|
|
60
|
+
"prepare": "node .husky/install.mjs",
|
|
61
|
+
"prepublishOnly": "npm run build",
|
|
62
|
+
"test": "vitest run",
|
|
63
|
+
"test:coverage": "vitest run --coverage",
|
|
64
|
+
"test:watch": "vitest"
|
|
65
|
+
},
|
|
66
|
+
"peerDependencies": {
|
|
67
|
+
"dexie": "^4.4.2",
|
|
68
|
+
"vue": "^3.4.0 || ^3.5.0"
|
|
69
|
+
},
|
|
70
|
+
"devDependencies": {
|
|
71
|
+
"@commitlint/cli": "^20.5.3",
|
|
72
|
+
"@commitlint/config-conventional": "^20.5.3",
|
|
73
|
+
"@emnapi/core": "^1.10.0",
|
|
74
|
+
"@emnapi/runtime": "^1.10.0",
|
|
75
|
+
"@eslint/js": "^10.0.1",
|
|
76
|
+
"@playwright/test": "^1.59.1",
|
|
77
|
+
"@vitest/coverage-v8": "^4.1.5",
|
|
78
|
+
"dexie": "^4.4.2",
|
|
79
|
+
"esbuild": "^0.28.0",
|
|
80
|
+
"eslint": "^10.3.0",
|
|
81
|
+
"eslint-config-prettier": "^10.1.8",
|
|
82
|
+
"husky": "^9.1.7",
|
|
83
|
+
"lint-staged": "^16.4.0",
|
|
84
|
+
"prettier": "^3.8.3",
|
|
85
|
+
"typescript": "^5.9.3",
|
|
86
|
+
"typescript-eslint": "^8.59.1",
|
|
87
|
+
"unbuild": "^3.6.1",
|
|
88
|
+
"vite": "^8.0.10",
|
|
89
|
+
"vitest": "^4.1.5",
|
|
90
|
+
"vue": "^3.5.33"
|
|
91
|
+
},
|
|
92
|
+
"lint-staged": {
|
|
93
|
+
"*.{ts,tsx,js,mjs,cjs}": [
|
|
94
|
+
"eslint --fix",
|
|
95
|
+
"prettier --write"
|
|
96
|
+
],
|
|
97
|
+
"*.{json,md}": "prettier --write"
|
|
98
|
+
}
|
|
99
|
+
}
|