@cavulsqa/reactive-vue 0.1.0 → 0.3.0
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/README.md +27 -11
- package/dist/framework7.d.mts +2 -4
- package/dist/framework7.mjs +2 -2
- package/dist/index.d.mts +37 -33
- package/dist/index.mjs +76 -43
- package/package.json +16 -17
package/README.md
CHANGED
|
@@ -7,8 +7,6 @@ copy between apps.
|
|
|
7
7
|
`useStaticQuery`, bound to Vue's lifecycle: fetch on mount, refetch on table change (debounced),
|
|
8
8
|
in-flight deduplication by query key, retry with backoff, stale-while-revalidate, a per-instance
|
|
9
9
|
cache window, cancel on unmount, and a deferred first read via a reactive `enabled`.
|
|
10
|
-
- `uniqueQueryKey(prefix)` — a key that is never shared, so a query is never deduplicated against
|
|
11
|
-
another.
|
|
12
10
|
- An injectable `logger`, so failures reach your error service rather than only the console.
|
|
13
11
|
- `createVueQueryMetrics()` — the recorder with its state in `reactive()`, plus a `useQueryMetrics`
|
|
14
12
|
composable of derived counters for a dev-tools panel.
|
|
@@ -51,7 +49,7 @@ Then, at a call site:
|
|
|
51
49
|
```ts
|
|
52
50
|
const { data, loading, error, refetch } = useReactiveQuery(
|
|
53
51
|
() => rdb.selectFrom("sale_order").selectAll().execute(),
|
|
54
|
-
{ tables: ["sale_order"], queryKey:
|
|
52
|
+
{ tables: ["sale_order"], queryKey: ["sale_order:list"] },
|
|
55
53
|
);
|
|
56
54
|
```
|
|
57
55
|
|
|
@@ -66,15 +64,33 @@ createReactiveQuery({
|
|
|
66
64
|
});
|
|
67
65
|
```
|
|
68
66
|
|
|
69
|
-
## queryKey is an identity,
|
|
67
|
+
## queryKey is an identity, built from arguments
|
|
70
68
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
69
|
+
A key is an array, and two mounted queries whose keys hash alike await one request and share its
|
|
70
|
+
result. That is what you want — the same list rendered twice costs one query — and it is only safe
|
|
71
|
+
because the key carries the arguments:
|
|
74
72
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
73
|
+
```ts
|
|
74
|
+
queryKey: ["sale_order", id]; // two detail pages, two identities
|
|
75
|
+
queryKey: ["sale_order:list"]; // one list, shared wherever it appears
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Refs inside a key are unwrapped and tracked, so the key can follow a filter. When it moves, the
|
|
79
|
+
query re-runs through the same debounce a table change uses — no `refetch()` on every keystroke:
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
const term = ref("");
|
|
83
|
+
useReactiveQuery(() => search(term.value), {
|
|
84
|
+
tables: ["sale_order"],
|
|
85
|
+
queryKey: ["sale_order:search", term],
|
|
86
|
+
debounce: 200,
|
|
87
|
+
});
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
A key may hold anything JSON can represent; object field order does not matter. A function or a
|
|
91
|
+
symbol throws rather than hashing to the same string as every other one. As a backstop, mounting
|
|
92
|
+
the same key against different `tables` logs a warning; pass `warnOnKeyConflict: false` to silence
|
|
93
|
+
it.
|
|
78
94
|
|
|
79
95
|
## What this does not do
|
|
80
96
|
|
|
@@ -96,7 +112,7 @@ competes for the one native database thread while the first screen loads:
|
|
|
96
112
|
```ts
|
|
97
113
|
useReactiveQuery(load, {
|
|
98
114
|
tables: ["partner"],
|
|
99
|
-
queryKey:
|
|
115
|
+
queryKey: ["partner:list"],
|
|
100
116
|
enabled: isTabActive, // a ref - activation starts the read and the subscription together
|
|
101
117
|
});
|
|
102
118
|
```
|
package/dist/framework7.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Ref } from "vue";
|
|
1
|
+
import { ComputedRef, Ref } from "vue";
|
|
2
2
|
//#region src/framework7.d.ts
|
|
3
3
|
/**
|
|
4
4
|
* Framework7 emits these on the `.page` element. An app on another router passes its own.
|
|
@@ -17,8 +17,6 @@ declare function providePageVisibility(options?: ProvidePageVisibilityOptions):
|
|
|
17
17
|
* The provider's own component reads its ref directly: `provide` is not visible to the component
|
|
18
18
|
* that called it.
|
|
19
19
|
*/
|
|
20
|
-
declare function usePageVisibility():
|
|
21
|
-
value: boolean;
|
|
22
|
-
};
|
|
20
|
+
declare function usePageVisibility(): Ref<boolean> | ComputedRef<boolean>;
|
|
23
21
|
//#endregion
|
|
24
22
|
export { FRAMEWORK7_PAGE_EVENTS, PageVisibilityEvents, ProvidePageVisibilityOptions, providePageVisibility, usePageVisibility };
|
package/dist/framework7.mjs
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { getCurrentInstance, inject, onBeforeUnmount, onMounted, provide, ref } from "vue";
|
|
1
|
+
import { computed, getCurrentInstance, inject, onBeforeUnmount, onMounted, provide, ref } from "vue";
|
|
2
2
|
//#region src/framework7.ts
|
|
3
3
|
const FRAMEWORK7_PAGE_EVENTS = {
|
|
4
4
|
show: ["page:beforein", "page:tabshow"],
|
|
5
5
|
hide: ["page:beforeout", "page:tabhide"]
|
|
6
6
|
};
|
|
7
7
|
const PAGE_VISIBILITY_KEY = Symbol("pageVisibility");
|
|
8
|
-
const ALWAYS_VISIBLE =
|
|
8
|
+
const ALWAYS_VISIBLE = computed(() => true);
|
|
9
9
|
const ownVisibility = /* @__PURE__ */ new WeakMap();
|
|
10
10
|
function resolvePageEl(node, pageSelector) {
|
|
11
11
|
let current = node;
|
package/dist/index.d.mts
CHANGED
|
@@ -1,18 +1,28 @@
|
|
|
1
|
-
import { ComputedRef, Ref } from "vue";
|
|
2
|
-
import { OnTableChangeFn, QueryMetric, QueryMetrics, QueryMetricsRecorder, ReactiveQueryOptions } from "@cavulsqa/reactive-db";
|
|
1
|
+
import { ComputedRef, MaybeRefOrGetter, Ref } from "vue";
|
|
2
|
+
import { OnTableChangeFn, QueryKey, QueryMetric, QueryMetrics, QueryMetricsRecorder, ReactiveQueryOptions, TableName } from "@cavulsqa/reactive-db";
|
|
3
3
|
//#region src/reactiveQuery.d.ts
|
|
4
4
|
interface ReactiveQueryLogger {
|
|
5
5
|
debug: (message: string, ...details: unknown[]) => void;
|
|
6
6
|
warn: (message: string, ...details: unknown[]) => void;
|
|
7
7
|
error: (message: string, ...details: unknown[]) => void;
|
|
8
8
|
}
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
9
|
+
type VueReactiveQueryOptions<T, DB = Record<string, unknown>> = Omit<ReactiveQueryOptions<T, DB>, "enabled" | "queryKey" | "isVisible"> & {
|
|
10
|
+
/**
|
|
11
|
+
* `enabled` may be a ref, so a screen can defer its first read until it is actually shown.
|
|
12
|
+
* Framework7 mounts every tab at startup; without this, a tab the user has not opened still
|
|
13
|
+
* competes for the one database thread while the first screen is loading.
|
|
14
|
+
*/
|
|
15
15
|
enabled?: boolean | Ref<boolean> | ComputedRef<boolean>;
|
|
16
|
+
/**
|
|
17
|
+
* Identity, from the values the query reads: `["order", id]`, not `"order"`.
|
|
18
|
+
*
|
|
19
|
+
* Refs inside the key are unwrapped and tracked, so `["search", term]` re-keys the query as the
|
|
20
|
+
* term moves: the key follows the filter and brings the debounced refetch with it, instead of a
|
|
21
|
+
* manual `refetch()` on every keystroke.
|
|
22
|
+
*/
|
|
23
|
+
queryKey: MaybeRefOrGetter<QueryKey>;
|
|
24
|
+
/** A real ref, because a plain object cannot be watched and a deferred refetch would never run. */
|
|
25
|
+
isVisible?: Ref<boolean> | ComputedRef<boolean>;
|
|
16
26
|
};
|
|
17
27
|
interface ReactiveQuery<T> {
|
|
18
28
|
data: Ref<T | null>;
|
|
@@ -28,9 +38,9 @@ interface ReactiveQuery<T> {
|
|
|
28
38
|
invalidate: () => Promise<void>;
|
|
29
39
|
cancel: () => void;
|
|
30
40
|
}
|
|
31
|
-
interface CreateReactiveQueryDeps {
|
|
41
|
+
interface CreateReactiveQueryDeps<DB = Record<string, unknown>> {
|
|
32
42
|
/** Subscribe to table changes. Wire this to the same change bus the writes emit on. */
|
|
33
|
-
onTableChange: OnTableChangeFn
|
|
43
|
+
onTableChange: OnTableChangeFn<DB>;
|
|
34
44
|
/** Defaults to a no-op recorder. Pass `createVueQueryMetrics().recorder` to collect timings. */
|
|
35
45
|
metrics?: QueryMetrics;
|
|
36
46
|
/**
|
|
@@ -39,13 +49,10 @@ interface CreateReactiveQueryDeps {
|
|
|
39
49
|
* router that mounts every tab up front, pass an adapter - `usePageVisibility` from
|
|
40
50
|
* `@cavulsqa/reactive-vue/framework7` is one.
|
|
41
51
|
*/
|
|
42
|
-
useVisibility?: () =>
|
|
43
|
-
value: boolean;
|
|
44
|
-
};
|
|
52
|
+
useVisibility?: () => Ref<boolean> | ComputedRef<boolean>;
|
|
45
53
|
/**
|
|
46
|
-
* Warn when two mounted queries share a
|
|
47
|
-
*
|
|
48
|
-
* production.
|
|
54
|
+
* Warn when two mounted queries share a key but watch different tables - they are different
|
|
55
|
+
* queries, so the second is handed the first's rows. On unless turned off.
|
|
49
56
|
*/
|
|
50
57
|
warnOnKeyConflict?: boolean;
|
|
51
58
|
/**
|
|
@@ -54,29 +61,26 @@ interface CreateReactiveQueryDeps {
|
|
|
54
61
|
*/
|
|
55
62
|
logger?: ReactiveQueryLogger;
|
|
56
63
|
}
|
|
57
|
-
interface ReactiveQueryComposables {
|
|
58
|
-
useReactiveQuery: <T>(queryFn: () => Promise<T>, options: VueReactiveQueryOptions<T>) => ReactiveQuery<T>;
|
|
59
|
-
useStructuralQuery: <T>(queryFn: () => Promise<T>, tables:
|
|
60
|
-
useStaticQuery: <T>(queryFn: () => Promise<T>, tables:
|
|
64
|
+
interface ReactiveQueryComposables<DB = Record<string, unknown>> {
|
|
65
|
+
useReactiveQuery: <T>(queryFn: () => Promise<T>, options: VueReactiveQueryOptions<T, DB>) => ReactiveQuery<T>;
|
|
66
|
+
useStructuralQuery: <T>(queryFn: () => Promise<T>, tables: TableName<DB>[], options: Omit<VueReactiveQueryOptions<T, DB>, "tables" | "refetchOn">) => ReactiveQuery<T>;
|
|
67
|
+
useStaticQuery: <T>(queryFn: () => Promise<T>, tables: TableName<DB>[], options: Omit<VueReactiveQueryOptions<T, DB>, "tables" | "enabled">) => ReactiveQuery<T>;
|
|
61
68
|
}
|
|
62
69
|
/**
|
|
63
70
|
* Binds the reactive-db primitives to Vue's lifecycle once, and returns the composables an app
|
|
64
71
|
* calls everywhere. The change bus and the metrics recorder are app-owned singletons, so they are
|
|
65
72
|
* injected here rather than created per query.
|
|
66
|
-
*/
|
|
67
|
-
declare function createReactiveQuery(deps: CreateReactiveQueryDeps): ReactiveQueryComposables;
|
|
68
|
-
//#endregion
|
|
69
|
-
//#region src/queryKeys.d.ts
|
|
70
|
-
/**
|
|
71
|
-
* A key unique to this call, so the query is never deduplicated against another and its
|
|
72
|
-
* `cacheTime` window is its own.
|
|
73
73
|
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
74
|
+
* Pass the schema - `createReactiveQuery<Database>({...})` - and every `tables` entry is checked
|
|
75
|
+
* against it. A misspelt table name is the one mistake this library cannot report at run time: the
|
|
76
|
+
* query subscribes to nothing and simply never refetches.
|
|
77
|
+
*
|
|
78
|
+
* `deps` is `NoInfer` because `keyof DB` is not an inference site TypeScript can invert: handed an
|
|
79
|
+
* already-typed `onTableChange`, it solved DB as a union of one object per table, and every
|
|
80
|
+
* `tables` entry in the app then failed against `never`. Now the schema comes from the type
|
|
81
|
+
* argument or not at all.
|
|
78
82
|
*/
|
|
79
|
-
declare function
|
|
83
|
+
declare function createReactiveQuery<DB = Record<string, unknown>>(deps: NoInfer<CreateReactiveQueryDeps<DB>>): ReactiveQueryComposables<DB>;
|
|
80
84
|
//#endregion
|
|
81
85
|
//#region src/queryMetrics.d.ts
|
|
82
86
|
interface VueQueryMetricsOptions {
|
|
@@ -114,4 +118,4 @@ interface VueQueryMetrics {
|
|
|
114
118
|
*/
|
|
115
119
|
declare function createVueQueryMetrics(options?: VueQueryMetricsOptions): VueQueryMetrics;
|
|
116
120
|
//#endregion
|
|
117
|
-
export { CreateReactiveQueryDeps, QueryMetricsView, ReactiveQuery, ReactiveQueryComposables, ReactiveQueryLogger, VueQueryMetrics, VueQueryMetricsOptions, VueReactiveQueryOptions, createReactiveQuery, createVueQueryMetrics
|
|
121
|
+
export { CreateReactiveQueryDeps, QueryMetricsView, ReactiveQuery, ReactiveQueryComposables, ReactiveQueryLogger, VueQueryMetrics, VueQueryMetricsOptions, VueReactiveQueryOptions, createReactiveQuery, createVueQueryMetrics };
|
package/dist/index.mjs
CHANGED
|
@@ -1,8 +1,32 @@
|
|
|
1
|
-
import { computed, isRef, onMounted, onUnmounted, reactive, ref, watch } from "vue";
|
|
2
|
-
import { calcRetryDelay, createQueryMetrics, createVisibilityGate, noopMetrics } from "@cavulsqa/reactive-db";
|
|
1
|
+
import { computed, isRef, onMounted, onUnmounted, reactive, ref, toValue, watch } from "vue";
|
|
2
|
+
import { calcRetryDelay, createQueryMetrics, createVisibilityGate, hashQueryKey, noopMetrics } from "@cavulsqa/reactive-db";
|
|
3
|
+
//#region src/queryKey.ts
|
|
4
|
+
/**
|
|
5
|
+
* Reads a key, unwrapping any refs inside it.
|
|
6
|
+
*
|
|
7
|
+
* `toValue` alone resolves the container, not its contents, so `["search", term]` hashed the ref
|
|
8
|
+
* object itself: the hash never moved when the term did, and reading `_value` rather than `.value`
|
|
9
|
+
* tracked nothing either - the key looked reactive and was not. Unwrapping here means the read
|
|
10
|
+
* happens inside the caller's `computed`, which is what makes the dependency real.
|
|
11
|
+
*/
|
|
12
|
+
function resolveQueryKey(key) {
|
|
13
|
+
return toValue(key).map(unwrapRefs);
|
|
14
|
+
}
|
|
15
|
+
function unwrapRefs(value) {
|
|
16
|
+
if (isRef(value)) return unwrapRefs(value.value);
|
|
17
|
+
if (Array.isArray(value)) return value.map(unwrapRefs);
|
|
18
|
+
if (isPlainObject(value)) return Object.fromEntries(Object.entries(value).map(([field, inner]) => [field, unwrapRefs(inner)]));
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
function isPlainObject(value) {
|
|
22
|
+
if (value === null || typeof value !== "object") return false;
|
|
23
|
+
const prototype = Object.getPrototypeOf(value);
|
|
24
|
+
return prototype === Object.prototype || prototype === null;
|
|
25
|
+
}
|
|
26
|
+
//#endregion
|
|
3
27
|
//#region src/reactiveQuery.ts
|
|
4
28
|
const DEFAULT_DEBOUNCE = 100;
|
|
5
|
-
const ALWAYS_VISIBLE =
|
|
29
|
+
const ALWAYS_VISIBLE = computed(() => true);
|
|
6
30
|
const consoleLogger = {
|
|
7
31
|
debug: (message, ...details) => console.log(message, ...details),
|
|
8
32
|
warn: (message, ...details) => console.warn(message, ...details),
|
|
@@ -12,6 +36,15 @@ const consoleLogger = {
|
|
|
12
36
|
* Binds the reactive-db primitives to Vue's lifecycle once, and returns the composables an app
|
|
13
37
|
* calls everywhere. The change bus and the metrics recorder are app-owned singletons, so they are
|
|
14
38
|
* injected here rather than created per query.
|
|
39
|
+
*
|
|
40
|
+
* Pass the schema - `createReactiveQuery<Database>({...})` - and every `tables` entry is checked
|
|
41
|
+
* against it. A misspelt table name is the one mistake this library cannot report at run time: the
|
|
42
|
+
* query subscribes to nothing and simply never refetches.
|
|
43
|
+
*
|
|
44
|
+
* `deps` is `NoInfer` because `keyof DB` is not an inference site TypeScript can invert: handed an
|
|
45
|
+
* already-typed `onTableChange`, it solved DB as a union of one object per table, and every
|
|
46
|
+
* `tables` entry in the app then failed against `never`. Now the schema comes from the type
|
|
47
|
+
* argument or not at all.
|
|
15
48
|
*/
|
|
16
49
|
function createReactiveQuery(deps) {
|
|
17
50
|
const metrics = deps.metrics ?? noopMetrics;
|
|
@@ -20,24 +53,24 @@ function createReactiveQuery(deps) {
|
|
|
20
53
|
const logger = deps.logger ?? consoleLogger;
|
|
21
54
|
const inFlightQueries = /* @__PURE__ */ new Map();
|
|
22
55
|
const watchedTablesByKey = /* @__PURE__ */ new Map();
|
|
23
|
-
function claimKey(
|
|
56
|
+
function claimKey(key, tables) {
|
|
24
57
|
const signature = [...tables].sort().join(",");
|
|
25
|
-
const existing = watchedTablesByKey.get(
|
|
58
|
+
const existing = watchedTablesByKey.get(key);
|
|
26
59
|
if (!existing) {
|
|
27
|
-
watchedTablesByKey.set(
|
|
60
|
+
watchedTablesByKey.set(key, {
|
|
28
61
|
tables: signature,
|
|
29
62
|
holders: 1
|
|
30
63
|
});
|
|
31
64
|
return;
|
|
32
65
|
}
|
|
33
|
-
if (warnOnKeyConflict && existing.tables !== signature) logger.warn(`[useReactiveQuery]
|
|
66
|
+
if (warnOnKeyConflict && existing.tables !== signature) logger.warn(`[useReactiveQuery] the key ${key} is mounted twice watching different tables ("${existing.tables}" and "${signature}"). These are different queries sharing an identity, so one will receive the other's result. Add what distinguishes them to the key.`);
|
|
34
67
|
existing.holders += 1;
|
|
35
68
|
}
|
|
36
|
-
function releaseKey(
|
|
37
|
-
const existing = watchedTablesByKey.get(
|
|
69
|
+
function releaseKey(key) {
|
|
70
|
+
const existing = watchedTablesByKey.get(key);
|
|
38
71
|
if (!existing) return;
|
|
39
72
|
existing.holders -= 1;
|
|
40
|
-
if (existing.holders <= 0) watchedTablesByKey.delete(
|
|
73
|
+
if (existing.holders <= 0) watchedTablesByKey.delete(key);
|
|
41
74
|
}
|
|
42
75
|
function useReactiveQuery(queryFn, options) {
|
|
43
76
|
const data = ref(null);
|
|
@@ -55,7 +88,7 @@ function createReactiveQuery(deps) {
|
|
|
55
88
|
const debounceMs = options.debounce ?? DEFAULT_DEBOUNCE;
|
|
56
89
|
const enabledRef = isRef(options.enabled) ? options.enabled : ref(options.enabled !== false);
|
|
57
90
|
const fetchOnMount = options.fetchOnMount !== false;
|
|
58
|
-
const
|
|
91
|
+
const keyHash = computed(() => hashQueryKey(resolveQueryKey(options.queryKey)));
|
|
59
92
|
const cancelOnUnmount = options.cancelOnUnmount !== false;
|
|
60
93
|
const maxRetries = options.retry === false ? 0 : options.retry ?? 0;
|
|
61
94
|
const cacheAge = computed(() => cacheClock.value - lastFetchTime.value);
|
|
@@ -94,10 +127,11 @@ function createReactiveQuery(deps) {
|
|
|
94
127
|
});
|
|
95
128
|
}
|
|
96
129
|
async function executeQueryWithRetry(showLoading = true, attempt = 0, force = false) {
|
|
97
|
-
|
|
98
|
-
|
|
130
|
+
const key = keyHash.value;
|
|
131
|
+
if (!force && inFlightQueries.has(key)) {
|
|
132
|
+
if (options.debug) logger.debug(`[useReactiveQuery] Deduping query: ${key}`);
|
|
99
133
|
try {
|
|
100
|
-
data.value = await inFlightQueries.get(
|
|
134
|
+
data.value = await inFlightQueries.get(key);
|
|
101
135
|
metrics.recordCacheHit();
|
|
102
136
|
} catch {}
|
|
103
137
|
return;
|
|
@@ -110,7 +144,7 @@ function createReactiveQuery(deps) {
|
|
|
110
144
|
retryCount.value = attempt;
|
|
111
145
|
const startTime = performance.now();
|
|
112
146
|
const queryPromise = queryFn();
|
|
113
|
-
inFlightQueries.set(
|
|
147
|
+
inFlightQueries.set(key, queryPromise);
|
|
114
148
|
try {
|
|
115
149
|
const result = await queryPromise;
|
|
116
150
|
if (requestId !== activeRequestId) return;
|
|
@@ -119,7 +153,7 @@ function createReactiveQuery(deps) {
|
|
|
119
153
|
isStale.value = false;
|
|
120
154
|
retryCount.value = 0;
|
|
121
155
|
const duration = performance.now() - startTime;
|
|
122
|
-
metrics.recordQuery(
|
|
156
|
+
metrics.recordQuery(key, duration);
|
|
123
157
|
if (options.debug) logger.debug(`[useReactiveQuery] Query executed (${duration.toFixed(1)}ms):`, result);
|
|
124
158
|
options.onSuccess?.(result);
|
|
125
159
|
} catch (err) {
|
|
@@ -127,7 +161,7 @@ function createReactiveQuery(deps) {
|
|
|
127
161
|
const errorObj = err instanceof Error ? err : new Error(String(err));
|
|
128
162
|
if (attempt < maxRetries) {
|
|
129
163
|
const delay = calcRetryDelay(attempt, options.retryDelay);
|
|
130
|
-
inFlightQueries.delete(
|
|
164
|
+
inFlightQueries.delete(key);
|
|
131
165
|
if (options.debug) logger.debug(`[useReactiveQuery] Retry ${attempt + 1}/${maxRetries} in ${delay}ms`);
|
|
132
166
|
await waitForRetry(delay);
|
|
133
167
|
if (disposed || requestId !== activeRequestId) return;
|
|
@@ -136,14 +170,14 @@ function createReactiveQuery(deps) {
|
|
|
136
170
|
error.value = errorObj;
|
|
137
171
|
isStale.value = false;
|
|
138
172
|
logger.error("[useReactiveQuery] Query failed:", err);
|
|
139
|
-
metrics.recordError(
|
|
173
|
+
metrics.recordError(key);
|
|
140
174
|
options.onError?.(errorObj);
|
|
141
175
|
} finally {
|
|
142
|
-
loading.value = false;
|
|
143
|
-
if (inFlightQueries.get(
|
|
176
|
+
if (requestId === activeRequestId) loading.value = false;
|
|
177
|
+
if (inFlightQueries.get(key) === queryPromise) inFlightQueries.delete(key);
|
|
144
178
|
}
|
|
145
179
|
}
|
|
146
|
-
function scheduledRefetch() {
|
|
180
|
+
function scheduledRefetch(changedTable) {
|
|
147
181
|
if (isCacheValidNow()) {
|
|
148
182
|
if (options.debug) logger.debug("[useReactiveQuery] Cache valid, skipping refetch");
|
|
149
183
|
metrics.recordCacheHit();
|
|
@@ -151,7 +185,7 @@ function createReactiveQuery(deps) {
|
|
|
151
185
|
}
|
|
152
186
|
if (debounceTimer) clearTimeout(debounceTimer);
|
|
153
187
|
debounceTimer = setTimeout(() => {
|
|
154
|
-
metrics.recordRefetch(
|
|
188
|
+
if (changedTable) metrics.recordRefetch(changedTable);
|
|
155
189
|
executeQueryWithRetry();
|
|
156
190
|
debounceTimer = null;
|
|
157
191
|
}, debounceMs);
|
|
@@ -183,30 +217,45 @@ function createReactiveQuery(deps) {
|
|
|
183
217
|
const visibility = options.isVisible ?? resolveVisibility();
|
|
184
218
|
let unsubscribe = null;
|
|
185
219
|
let stopVisibilityWatch = null;
|
|
220
|
+
let stopKeyWatch = null;
|
|
221
|
+
let claimedKey = null;
|
|
186
222
|
let active = false;
|
|
187
223
|
function activate() {
|
|
188
224
|
if (active) return;
|
|
189
225
|
active = true;
|
|
190
|
-
|
|
226
|
+
claimedKey = keyHash.value;
|
|
227
|
+
claimKey(claimedKey, options.tables);
|
|
191
228
|
if (fetchOnMount) executeQueryWithRetry();
|
|
192
229
|
unsubscribe = deps.onTableChange(options.tables, (event) => {
|
|
193
230
|
if (options.debug) logger.debug("[useReactiveQuery] Table changed:", event);
|
|
194
231
|
if (!shouldTriggerRefetch(event)) return;
|
|
195
|
-
if (gate.recordChange(visibility.value) === "refetch") scheduledRefetch();
|
|
232
|
+
if (gate.recordChange(visibility.value) === "refetch") scheduledRefetch(event.table);
|
|
196
233
|
});
|
|
197
|
-
|
|
234
|
+
stopVisibilityWatch = watch(visibility, (visible) => {
|
|
198
235
|
if (visible && gate.recordVisible() === "refetch") scheduledRefetch();
|
|
199
236
|
});
|
|
237
|
+
stopKeyWatch = watch(keyHash, (next, previous) => {
|
|
238
|
+
releaseKey(previous);
|
|
239
|
+
claimKey(next, options.tables);
|
|
240
|
+
claimedKey = next;
|
|
241
|
+
lastFetchTime.value = 0;
|
|
242
|
+
scheduledRefetch();
|
|
243
|
+
});
|
|
200
244
|
metrics.incrementListeners();
|
|
201
245
|
}
|
|
202
246
|
function deactivate() {
|
|
203
247
|
if (!active) return;
|
|
204
248
|
active = false;
|
|
205
|
-
|
|
249
|
+
if (claimedKey !== null) {
|
|
250
|
+
releaseKey(claimedKey);
|
|
251
|
+
claimedKey = null;
|
|
252
|
+
}
|
|
206
253
|
unsubscribe?.();
|
|
207
254
|
unsubscribe = null;
|
|
208
255
|
stopVisibilityWatch?.();
|
|
209
256
|
stopVisibilityWatch = null;
|
|
257
|
+
stopKeyWatch?.();
|
|
258
|
+
stopKeyWatch = null;
|
|
210
259
|
metrics.decrementListeners();
|
|
211
260
|
}
|
|
212
261
|
let stopEnabledWatch = null;
|
|
@@ -253,22 +302,6 @@ function createReactiveQuery(deps) {
|
|
|
253
302
|
};
|
|
254
303
|
}
|
|
255
304
|
//#endregion
|
|
256
|
-
//#region src/queryKeys.ts
|
|
257
|
-
let instanceCounter = 0;
|
|
258
|
-
/**
|
|
259
|
-
* A key unique to this call, so the query is never deduplicated against another and its
|
|
260
|
-
* `cacheTime` window is its own.
|
|
261
|
-
*
|
|
262
|
-
* A `queryKey` is an *identity*: two mounted queries sharing one await a single request and share
|
|
263
|
-
* the result. That is what you want for the same list rendered twice, and wrong for two different
|
|
264
|
-
* queries that happen to be named alike - the second would be handed the first's rows. Reach for a
|
|
265
|
-
* stable literal only when sharing is the intent; otherwise call this.
|
|
266
|
-
*/
|
|
267
|
-
function uniqueQueryKey(prefix) {
|
|
268
|
-
instanceCounter += 1;
|
|
269
|
-
return `${prefix}#${instanceCounter}`;
|
|
270
|
-
}
|
|
271
|
-
//#endregion
|
|
272
305
|
//#region src/queryMetrics.ts
|
|
273
306
|
const SLOWEST_QUERY_COUNT = 5;
|
|
274
307
|
/**
|
|
@@ -321,4 +354,4 @@ function createVueQueryMetrics(options = {}) {
|
|
|
321
354
|
};
|
|
322
355
|
}
|
|
323
356
|
//#endregion
|
|
324
|
-
export { createReactiveQuery, createVueQueryMetrics
|
|
357
|
+
export { createReactiveQuery, createVueQueryMetrics };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cavulsqa/reactive-vue",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Vue bindings for @cavulsqa/reactive-db: a reactive query composable, Framework7 page visibility, and a reactive metrics view.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"framework7",
|
|
@@ -32,25 +32,24 @@
|
|
|
32
32
|
"publishConfig": {
|
|
33
33
|
"access": "public"
|
|
34
34
|
},
|
|
35
|
-
"scripts": {
|
|
36
|
-
"build": "vp pack",
|
|
37
|
-
"dev": "vp pack --watch",
|
|
38
|
-
"test": "vp test",
|
|
39
|
-
"check": "vp check",
|
|
40
|
-
"prepublishOnly": "vp run build"
|
|
41
|
-
},
|
|
42
35
|
"devDependencies": {
|
|
43
|
-
"@cavulsqa/reactive-db": "
|
|
44
|
-
"@types/node": "^
|
|
36
|
+
"@cavulsqa/reactive-db": "0.3.0",
|
|
37
|
+
"@types/node": "^24.12.2",
|
|
45
38
|
"bumpp": "^11.1.0",
|
|
46
|
-
"happy-dom": "
|
|
47
|
-
"typescript": "^
|
|
48
|
-
"vite": "
|
|
49
|
-
"vite-plus": "
|
|
50
|
-
"vue": "
|
|
39
|
+
"happy-dom": "^20.11.6",
|
|
40
|
+
"typescript": "^5.9.3",
|
|
41
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@0.3.0",
|
|
42
|
+
"vite-plus": "0.3.0",
|
|
43
|
+
"vue": "^3.5.41"
|
|
51
44
|
},
|
|
52
45
|
"peerDependencies": {
|
|
53
|
-
"@cavulsqa/reactive-db": "^0.
|
|
46
|
+
"@cavulsqa/reactive-db": "^0.3.0",
|
|
54
47
|
"vue": ">=3.5"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "vp pack",
|
|
51
|
+
"dev": "vp pack --watch",
|
|
52
|
+
"test": "vp test",
|
|
53
|
+
"check": "vp check"
|
|
55
54
|
}
|
|
56
|
-
}
|
|
55
|
+
}
|