@cavulsqa/reactive-vue 0.1.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 +108 -0
- package/dist/framework7.d.mts +24 -0
- package/dist/framework7.mjs +62 -0
- package/dist/index.d.mts +117 -0
- package/dist/index.mjs +324 -0
- package/package.json +56 -0
package/README.md
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# @cavulsqa/reactive-vue
|
|
2
|
+
|
|
3
|
+
Vue bindings for [`@cavulsqa/reactive-db`](../reactive-db). This is the layer you would otherwise
|
|
4
|
+
copy between apps.
|
|
5
|
+
|
|
6
|
+
- `createReactiveQuery(deps)` — returns `useReactiveQuery`, `useStructuralQuery`, and
|
|
7
|
+
`useStaticQuery`, bound to Vue's lifecycle: fetch on mount, refetch on table change (debounced),
|
|
8
|
+
in-flight deduplication by query key, retry with backoff, stale-while-revalidate, a per-instance
|
|
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
|
+
- An injectable `logger`, so failures reach your error service rather than only the console.
|
|
13
|
+
- `createVueQueryMetrics()` — the recorder with its state in `reactive()`, plus a `useQueryMetrics`
|
|
14
|
+
composable of derived counters for a dev-tools panel.
|
|
15
|
+
- `@cavulsqa/reactive-vue/framework7` — `providePageVisibility()` / `usePageVisibility()`, which
|
|
16
|
+
track whether the surrounding page is on screen so a background tab stops refetching. Framework7
|
|
17
|
+
page events and `.page` by default; both are options, so another router can reuse it.
|
|
18
|
+
|
|
19
|
+
Nothing in the base entry knows about Framework7. The visibility adapter is a separate subpath you
|
|
20
|
+
opt into.
|
|
21
|
+
|
|
22
|
+
## Why a factory
|
|
23
|
+
|
|
24
|
+
The change bus and the metrics recorder are app-owned singletons — writes have to emit on the same
|
|
25
|
+
bus the queries listen to. Rather than have the package own a global, you pass them in once:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { createChangeBus, createReactiveDb } from "@cavulsqa/reactive-db";
|
|
29
|
+
import { createReactiveQuery, createVueQueryMetrics } from "@cavulsqa/reactive-vue";
|
|
30
|
+
import { usePageVisibility } from "@cavulsqa/reactive-vue/framework7";
|
|
31
|
+
|
|
32
|
+
const bus = createChangeBus();
|
|
33
|
+
const metrics = createVueQueryMetrics();
|
|
34
|
+
|
|
35
|
+
export const rdb = createReactiveDb<Database>({
|
|
36
|
+
getDb: () => dbService.getDb(),
|
|
37
|
+
emitChange: bus.emit,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export const { useReactiveQuery, useStructuralQuery, useStaticQuery } = createReactiveQuery({
|
|
41
|
+
onTableChange: bus.on,
|
|
42
|
+
metrics: metrics.recorder,
|
|
43
|
+
useVisibility: usePageVisibility,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
export const { useQueryMetrics } = metrics;
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Then, at a call site:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
const { data, loading, error, refetch } = useReactiveQuery(
|
|
53
|
+
() => rdb.selectFrom("sale_order").selectAll().execute(),
|
|
54
|
+
{ tables: ["sale_order"], queryKey: uniqueQueryKey("sale_order:list") },
|
|
55
|
+
);
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`metrics` defaults to a no-op recorder. `useVisibility` defaults to always-visible — without an
|
|
59
|
+
adapter, a screen the user cannot see still refetches. `logger` defaults to the console; pass your
|
|
60
|
+
app's error service and a failed query lands where every other failure does:
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
createReactiveQuery({
|
|
64
|
+
onTableChange: bus.on,
|
|
65
|
+
logger: { debug: noop, warn: reportWarning, error: reportError },
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## queryKey is an identity, not a label
|
|
70
|
+
|
|
71
|
+
Two mounted queries sharing a `queryKey` await a single request and share its result. That is
|
|
72
|
+
correct for the same list rendered twice, and wrong for two different queries that happen to be
|
|
73
|
+
named alike — the second is handed the first's rows.
|
|
74
|
+
|
|
75
|
+
So: `uniqueQueryKey("sale_order:list")` unless sharing is the point, in which case use a stable
|
|
76
|
+
literal. As a backstop, mounting the same key against different `tables` logs a warning; pass
|
|
77
|
+
`warnOnKeyConflict: false` to silence it.
|
|
78
|
+
|
|
79
|
+
## What this does not do
|
|
80
|
+
|
|
81
|
+
`cacheTime` is a **per-instance** revalidation window: it suppresses a refetch that a table change
|
|
82
|
+
would otherwise trigger, on that one query. It is not a shared cache — two screens running the same
|
|
83
|
+
query each hold their own result, and neither reads the other's.
|
|
84
|
+
|
|
85
|
+
`@cavulsqa/reactive-db` exports `createResultCache`, which is a real bounded keyed cache, but this
|
|
86
|
+
composable does not use it. Wiring it in means invalidating cached entries by table on every change
|
|
87
|
+
event, which is most of what a query-cache library does; if you need that, reach for
|
|
88
|
+
[TanStack Query](https://tanstack.com/query) and drive `invalidateQueries` from `bus.on` instead of
|
|
89
|
+
growing this one.
|
|
90
|
+
|
|
91
|
+
## Deferring a tab's first read
|
|
92
|
+
|
|
93
|
+
Framework7 mounts every tab at startup. Without `enabled`, a tab the user has not opened still
|
|
94
|
+
competes for the one native database thread while the first screen loads:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
useReactiveQuery(load, {
|
|
98
|
+
tables: ["partner"],
|
|
99
|
+
queryKey: uniqueQueryKey("partner:list"),
|
|
100
|
+
enabled: isTabActive, // a ref - activation starts the read and the subscription together
|
|
101
|
+
});
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Install
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
vp install @cavulsqa/reactive-vue @cavulsqa/reactive-db vue
|
|
108
|
+
```
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Ref } from "vue";
|
|
2
|
+
//#region src/framework7.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Framework7 emits these on the `.page` element. An app on another router passes its own.
|
|
5
|
+
*/
|
|
6
|
+
interface PageVisibilityEvents {
|
|
7
|
+
show: readonly string[];
|
|
8
|
+
hide: readonly string[];
|
|
9
|
+
}
|
|
10
|
+
declare const FRAMEWORK7_PAGE_EVENTS: PageVisibilityEvents;
|
|
11
|
+
interface ProvidePageVisibilityOptions {
|
|
12
|
+
events?: PageVisibilityEvents;
|
|
13
|
+
pageSelector?: string;
|
|
14
|
+
}
|
|
15
|
+
declare function providePageVisibility(options?: ProvidePageVisibilityOptions): Ref<boolean>;
|
|
16
|
+
/**
|
|
17
|
+
* The provider's own component reads its ref directly: `provide` is not visible to the component
|
|
18
|
+
* that called it.
|
|
19
|
+
*/
|
|
20
|
+
declare function usePageVisibility(): {
|
|
21
|
+
value: boolean;
|
|
22
|
+
};
|
|
23
|
+
//#endregion
|
|
24
|
+
export { FRAMEWORK7_PAGE_EVENTS, PageVisibilityEvents, ProvidePageVisibilityOptions, providePageVisibility, usePageVisibility };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { getCurrentInstance, inject, onBeforeUnmount, onMounted, provide, ref } from "vue";
|
|
2
|
+
//#region src/framework7.ts
|
|
3
|
+
const FRAMEWORK7_PAGE_EVENTS = {
|
|
4
|
+
show: ["page:beforein", "page:tabshow"],
|
|
5
|
+
hide: ["page:beforeout", "page:tabhide"]
|
|
6
|
+
};
|
|
7
|
+
const PAGE_VISIBILITY_KEY = Symbol("pageVisibility");
|
|
8
|
+
const ALWAYS_VISIBLE = { value: true };
|
|
9
|
+
const ownVisibility = /* @__PURE__ */ new WeakMap();
|
|
10
|
+
function resolvePageEl(node, pageSelector) {
|
|
11
|
+
let current = node;
|
|
12
|
+
while (current) {
|
|
13
|
+
if (current instanceof HTMLElement) return current.matches(pageSelector) ? current : current.closest(pageSelector);
|
|
14
|
+
current = current.nextSibling;
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* A page inside an inactive tab starts hidden. Everything else starts visible, including a page
|
|
20
|
+
* whose surrounding view is not a tab at all.
|
|
21
|
+
*/
|
|
22
|
+
function initialVisibility(pageEl) {
|
|
23
|
+
const view = pageEl.closest(".view");
|
|
24
|
+
if (!view?.classList.contains("tab")) return true;
|
|
25
|
+
return view.classList.contains("tab-active");
|
|
26
|
+
}
|
|
27
|
+
function providePageVisibility(options = {}) {
|
|
28
|
+
const events = options.events ?? FRAMEWORK7_PAGE_EVENTS;
|
|
29
|
+
const pageSelector = options.pageSelector ?? ".page";
|
|
30
|
+
const isVisible = ref(true);
|
|
31
|
+
const instance = getCurrentInstance();
|
|
32
|
+
let pageEl = null;
|
|
33
|
+
const show = () => isVisible.value = true;
|
|
34
|
+
const hide = () => isVisible.value = false;
|
|
35
|
+
provide(PAGE_VISIBILITY_KEY, isVisible);
|
|
36
|
+
if (instance) ownVisibility.set(instance, isVisible);
|
|
37
|
+
onMounted(() => {
|
|
38
|
+
pageEl = resolvePageEl(instance?.vnode.el ?? instance?.proxy?.$el, pageSelector);
|
|
39
|
+
if (!pageEl) return;
|
|
40
|
+
isVisible.value = initialVisibility(pageEl);
|
|
41
|
+
for (const event of events.show) pageEl.addEventListener(event, show);
|
|
42
|
+
for (const event of events.hide) pageEl.addEventListener(event, hide);
|
|
43
|
+
});
|
|
44
|
+
onBeforeUnmount(() => {
|
|
45
|
+
if (!pageEl) return;
|
|
46
|
+
for (const event of events.show) pageEl.removeEventListener(event, show);
|
|
47
|
+
for (const event of events.hide) pageEl.removeEventListener(event, hide);
|
|
48
|
+
pageEl = null;
|
|
49
|
+
});
|
|
50
|
+
return isVisible;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The provider's own component reads its ref directly: `provide` is not visible to the component
|
|
54
|
+
* that called it.
|
|
55
|
+
*/
|
|
56
|
+
function usePageVisibility() {
|
|
57
|
+
const instance = getCurrentInstance();
|
|
58
|
+
if (!instance) return ALWAYS_VISIBLE;
|
|
59
|
+
return ownVisibility.get(instance) ?? inject(PAGE_VISIBILITY_KEY, ALWAYS_VISIBLE);
|
|
60
|
+
}
|
|
61
|
+
//#endregion
|
|
62
|
+
export { FRAMEWORK7_PAGE_EVENTS, providePageVisibility, usePageVisibility };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { ComputedRef, Ref } from "vue";
|
|
2
|
+
import { OnTableChangeFn, QueryMetric, QueryMetrics, QueryMetricsRecorder, ReactiveQueryOptions } from "@cavulsqa/reactive-db";
|
|
3
|
+
//#region src/reactiveQuery.d.ts
|
|
4
|
+
interface ReactiveQueryLogger {
|
|
5
|
+
debug: (message: string, ...details: unknown[]) => void;
|
|
6
|
+
warn: (message: string, ...details: unknown[]) => void;
|
|
7
|
+
error: (message: string, ...details: unknown[]) => void;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* `enabled` may be a ref, so a screen can defer its first read until it is actually shown.
|
|
11
|
+
* Framework7 mounts every tab at startup; without this, a tab the user has not opened still
|
|
12
|
+
* competes for the one native database thread while the first screen is loading.
|
|
13
|
+
*/
|
|
14
|
+
type VueReactiveQueryOptions<T> = Omit<ReactiveQueryOptions<T>, "enabled"> & {
|
|
15
|
+
enabled?: boolean | Ref<boolean> | ComputedRef<boolean>;
|
|
16
|
+
};
|
|
17
|
+
interface ReactiveQuery<T> {
|
|
18
|
+
data: Ref<T | null>;
|
|
19
|
+
loading: Ref<boolean>;
|
|
20
|
+
error: Ref<Error | null>;
|
|
21
|
+
isStale: Ref<boolean>;
|
|
22
|
+
isCacheValid: ComputedRef<boolean>;
|
|
23
|
+
cacheAge: ComputedRef<number>;
|
|
24
|
+
retryCount: Ref<number>;
|
|
25
|
+
refetch: (options?: {
|
|
26
|
+
force?: boolean;
|
|
27
|
+
}) => Promise<void>;
|
|
28
|
+
invalidate: () => Promise<void>;
|
|
29
|
+
cancel: () => void;
|
|
30
|
+
}
|
|
31
|
+
interface CreateReactiveQueryDeps {
|
|
32
|
+
/** Subscribe to table changes. Wire this to the same change bus the writes emit on. */
|
|
33
|
+
onTableChange: OnTableChangeFn;
|
|
34
|
+
/** Defaults to a no-op recorder. Pass `createVueQueryMetrics().recorder` to collect timings. */
|
|
35
|
+
metrics?: QueryMetrics;
|
|
36
|
+
/**
|
|
37
|
+
* Resolves the visibility of the surrounding page when a call site does not pass `isVisible`.
|
|
38
|
+
* Defaults to always-visible, which means a screen the user cannot see still refetches. On a
|
|
39
|
+
* router that mounts every tab up front, pass an adapter - `usePageVisibility` from
|
|
40
|
+
* `@cavulsqa/reactive-vue/framework7` is one.
|
|
41
|
+
*/
|
|
42
|
+
useVisibility?: () => {
|
|
43
|
+
value: boolean;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Warn when two mounted queries share a `queryKey` but watch different tables - they are
|
|
47
|
+
* different queries, so the second is handed the first's rows. Defaults to on outside
|
|
48
|
+
* production.
|
|
49
|
+
*/
|
|
50
|
+
warnOnKeyConflict?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Where a failed query and the `debug` traces go. Defaults to the console; pass the app's error
|
|
53
|
+
* service so a failure reaches the same place as every other one.
|
|
54
|
+
*/
|
|
55
|
+
logger?: ReactiveQueryLogger;
|
|
56
|
+
}
|
|
57
|
+
interface ReactiveQueryComposables {
|
|
58
|
+
useReactiveQuery: <T>(queryFn: () => Promise<T>, options: VueReactiveQueryOptions<T>) => ReactiveQuery<T>;
|
|
59
|
+
useStructuralQuery: <T>(queryFn: () => Promise<T>, tables: string[], options: Omit<VueReactiveQueryOptions<T>, "tables" | "refetchOn">) => ReactiveQuery<T>;
|
|
60
|
+
useStaticQuery: <T>(queryFn: () => Promise<T>, tables: string[], options: Omit<VueReactiveQueryOptions<T>, "tables" | "enabled">) => ReactiveQuery<T>;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Binds the reactive-db primitives to Vue's lifecycle once, and returns the composables an app
|
|
64
|
+
* calls everywhere. The change bus and the metrics recorder are app-owned singletons, so they are
|
|
65
|
+
* 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
|
+
*
|
|
74
|
+
* A `queryKey` is an *identity*: two mounted queries sharing one await a single request and share
|
|
75
|
+
* the result. That is what you want for the same list rendered twice, and wrong for two different
|
|
76
|
+
* queries that happen to be named alike - the second would be handed the first's rows. Reach for a
|
|
77
|
+
* stable literal only when sharing is the intent; otherwise call this.
|
|
78
|
+
*/
|
|
79
|
+
declare function uniqueQueryKey(prefix: string): string;
|
|
80
|
+
//#endregion
|
|
81
|
+
//#region src/queryMetrics.d.ts
|
|
82
|
+
interface VueQueryMetricsOptions {
|
|
83
|
+
maxEntries?: number;
|
|
84
|
+
/**
|
|
85
|
+
* Name of a `window` property to expose `snapshot()` and `reset()` on, for a driven session
|
|
86
|
+
* (a device debugger) to read timings without opening dev tools. Omit outside development.
|
|
87
|
+
*/
|
|
88
|
+
exposeOnWindowAs?: string;
|
|
89
|
+
}
|
|
90
|
+
interface QueryMetricsView {
|
|
91
|
+
totalQueries: ComputedRef<number>;
|
|
92
|
+
avgQueryTime: ComputedRef<number>;
|
|
93
|
+
cacheHitRate: ComputedRef<number>;
|
|
94
|
+
refetchesByTable: ComputedRef<Record<string, number>>;
|
|
95
|
+
queriesByKey: ComputedRef<Record<string, QueryMetric>>;
|
|
96
|
+
slowestQueries: ComputedRef<Array<QueryMetric & {
|
|
97
|
+
key: string;
|
|
98
|
+
}>>;
|
|
99
|
+
errors: ComputedRef<Record<string, number>>;
|
|
100
|
+
activeListeners: ComputedRef<number>;
|
|
101
|
+
uptime: ComputedRef<number>;
|
|
102
|
+
isDevToolsOpen: ComputedRef<boolean>;
|
|
103
|
+
reset: () => void;
|
|
104
|
+
toggleDevTools: (isOpen?: boolean) => void;
|
|
105
|
+
}
|
|
106
|
+
interface VueQueryMetrics {
|
|
107
|
+
recorder: QueryMetricsRecorder;
|
|
108
|
+
useQueryMetrics: () => QueryMetricsView;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Wraps the recorder's state in `reactive()` so a dev-tools panel re-renders as queries run, and
|
|
112
|
+
* derives the aggregate views from it. One recorder per app: pass `recorder` to
|
|
113
|
+
* `createReactiveQuery` so both sides count the same queries.
|
|
114
|
+
*/
|
|
115
|
+
declare function createVueQueryMetrics(options?: VueQueryMetricsOptions): VueQueryMetrics;
|
|
116
|
+
//#endregion
|
|
117
|
+
export { CreateReactiveQueryDeps, QueryMetricsView, ReactiveQuery, ReactiveQueryComposables, ReactiveQueryLogger, VueQueryMetrics, VueQueryMetricsOptions, VueReactiveQueryOptions, createReactiveQuery, createVueQueryMetrics, uniqueQueryKey };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { computed, isRef, onMounted, onUnmounted, reactive, ref, watch } from "vue";
|
|
2
|
+
import { calcRetryDelay, createQueryMetrics, createVisibilityGate, noopMetrics } from "@cavulsqa/reactive-db";
|
|
3
|
+
//#region src/reactiveQuery.ts
|
|
4
|
+
const DEFAULT_DEBOUNCE = 100;
|
|
5
|
+
const ALWAYS_VISIBLE = { value: true };
|
|
6
|
+
const consoleLogger = {
|
|
7
|
+
debug: (message, ...details) => console.log(message, ...details),
|
|
8
|
+
warn: (message, ...details) => console.warn(message, ...details),
|
|
9
|
+
error: (message, ...details) => console.error(message, ...details)
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Binds the reactive-db primitives to Vue's lifecycle once, and returns the composables an app
|
|
13
|
+
* calls everywhere. The change bus and the metrics recorder are app-owned singletons, so they are
|
|
14
|
+
* injected here rather than created per query.
|
|
15
|
+
*/
|
|
16
|
+
function createReactiveQuery(deps) {
|
|
17
|
+
const metrics = deps.metrics ?? noopMetrics;
|
|
18
|
+
const resolveVisibility = deps.useVisibility ?? (() => ALWAYS_VISIBLE);
|
|
19
|
+
const warnOnKeyConflict = deps.warnOnKeyConflict ?? true;
|
|
20
|
+
const logger = deps.logger ?? consoleLogger;
|
|
21
|
+
const inFlightQueries = /* @__PURE__ */ new Map();
|
|
22
|
+
const watchedTablesByKey = /* @__PURE__ */ new Map();
|
|
23
|
+
function claimKey(queryKey, tables) {
|
|
24
|
+
const signature = [...tables].sort().join(",");
|
|
25
|
+
const existing = watchedTablesByKey.get(queryKey);
|
|
26
|
+
if (!existing) {
|
|
27
|
+
watchedTablesByKey.set(queryKey, {
|
|
28
|
+
tables: signature,
|
|
29
|
+
holders: 1
|
|
30
|
+
});
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (warnOnKeyConflict && existing.tables !== signature) logger.warn(`[useReactiveQuery] queryKey "${queryKey}" 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. Use uniqueQueryKey() for a key that is never shared.`);
|
|
34
|
+
existing.holders += 1;
|
|
35
|
+
}
|
|
36
|
+
function releaseKey(queryKey) {
|
|
37
|
+
const existing = watchedTablesByKey.get(queryKey);
|
|
38
|
+
if (!existing) return;
|
|
39
|
+
existing.holders -= 1;
|
|
40
|
+
if (existing.holders <= 0) watchedTablesByKey.delete(queryKey);
|
|
41
|
+
}
|
|
42
|
+
function useReactiveQuery(queryFn, options) {
|
|
43
|
+
const data = ref(null);
|
|
44
|
+
const loading = ref(false);
|
|
45
|
+
const error = ref(null);
|
|
46
|
+
const isStale = ref(false);
|
|
47
|
+
const lastFetchTime = ref(0);
|
|
48
|
+
const cacheClock = ref(Date.now());
|
|
49
|
+
const retryCount = ref(0);
|
|
50
|
+
let debounceTimer = null;
|
|
51
|
+
let retryTimer = null;
|
|
52
|
+
let abandonRetry = null;
|
|
53
|
+
let disposed = false;
|
|
54
|
+
let activeRequestId = 0;
|
|
55
|
+
const debounceMs = options.debounce ?? DEFAULT_DEBOUNCE;
|
|
56
|
+
const enabledRef = isRef(options.enabled) ? options.enabled : ref(options.enabled !== false);
|
|
57
|
+
const fetchOnMount = options.fetchOnMount !== false;
|
|
58
|
+
const queryKey = options.queryKey;
|
|
59
|
+
const cancelOnUnmount = options.cancelOnUnmount !== false;
|
|
60
|
+
const maxRetries = options.retry === false ? 0 : options.retry ?? 0;
|
|
61
|
+
const cacheAge = computed(() => cacheClock.value - lastFetchTime.value);
|
|
62
|
+
const isCacheValid = computed(() => {
|
|
63
|
+
if (!options.cacheTime || !lastFetchTime.value) return false;
|
|
64
|
+
return cacheClock.value - lastFetchTime.value < options.cacheTime;
|
|
65
|
+
});
|
|
66
|
+
function cancel() {
|
|
67
|
+
activeRequestId++;
|
|
68
|
+
}
|
|
69
|
+
function syncCacheClock() {
|
|
70
|
+
cacheClock.value = Date.now();
|
|
71
|
+
}
|
|
72
|
+
function isCacheValidNow() {
|
|
73
|
+
syncCacheClock();
|
|
74
|
+
return isCacheValid.value;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Resolves when the backoff elapses, or immediately when the query is torn down. Waiting on a
|
|
78
|
+
* bare `setTimeout` meant an unmount mid-backoff still woke up and ran `queryFn` again -
|
|
79
|
+
* against the one native database thread this composable exists to protect.
|
|
80
|
+
*/
|
|
81
|
+
function waitForRetry(delay) {
|
|
82
|
+
return new Promise((resolve) => {
|
|
83
|
+
abandonRetry = () => {
|
|
84
|
+
if (retryTimer) clearTimeout(retryTimer);
|
|
85
|
+
retryTimer = null;
|
|
86
|
+
abandonRetry = null;
|
|
87
|
+
resolve();
|
|
88
|
+
};
|
|
89
|
+
retryTimer = setTimeout(() => {
|
|
90
|
+
retryTimer = null;
|
|
91
|
+
abandonRetry = null;
|
|
92
|
+
resolve();
|
|
93
|
+
}, delay);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
async function executeQueryWithRetry(showLoading = true, attempt = 0, force = false) {
|
|
97
|
+
if (!force && inFlightQueries.has(queryKey)) {
|
|
98
|
+
if (options.debug) logger.debug(`[useReactiveQuery] Deduping query: ${queryKey}`);
|
|
99
|
+
try {
|
|
100
|
+
data.value = await inFlightQueries.get(queryKey);
|
|
101
|
+
metrics.recordCacheHit();
|
|
102
|
+
} catch {}
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
cancel();
|
|
106
|
+
const requestId = activeRequestId;
|
|
107
|
+
if (options.staleWhileRevalidate && data.value !== null) isStale.value = true;
|
|
108
|
+
else loading.value = showLoading;
|
|
109
|
+
error.value = null;
|
|
110
|
+
retryCount.value = attempt;
|
|
111
|
+
const startTime = performance.now();
|
|
112
|
+
const queryPromise = queryFn();
|
|
113
|
+
inFlightQueries.set(queryKey, queryPromise);
|
|
114
|
+
try {
|
|
115
|
+
const result = await queryPromise;
|
|
116
|
+
if (requestId !== activeRequestId) return;
|
|
117
|
+
data.value = result;
|
|
118
|
+
lastFetchTime.value = Date.now();
|
|
119
|
+
isStale.value = false;
|
|
120
|
+
retryCount.value = 0;
|
|
121
|
+
const duration = performance.now() - startTime;
|
|
122
|
+
metrics.recordQuery(queryKey, duration);
|
|
123
|
+
if (options.debug) logger.debug(`[useReactiveQuery] Query executed (${duration.toFixed(1)}ms):`, result);
|
|
124
|
+
options.onSuccess?.(result);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
if (requestId !== activeRequestId) return;
|
|
127
|
+
const errorObj = err instanceof Error ? err : new Error(String(err));
|
|
128
|
+
if (attempt < maxRetries) {
|
|
129
|
+
const delay = calcRetryDelay(attempt, options.retryDelay);
|
|
130
|
+
inFlightQueries.delete(queryKey);
|
|
131
|
+
if (options.debug) logger.debug(`[useReactiveQuery] Retry ${attempt + 1}/${maxRetries} in ${delay}ms`);
|
|
132
|
+
await waitForRetry(delay);
|
|
133
|
+
if (disposed || requestId !== activeRequestId) return;
|
|
134
|
+
return executeQueryWithRetry(showLoading, attempt + 1, force);
|
|
135
|
+
}
|
|
136
|
+
error.value = errorObj;
|
|
137
|
+
isStale.value = false;
|
|
138
|
+
logger.error("[useReactiveQuery] Query failed:", err);
|
|
139
|
+
metrics.recordError(queryKey);
|
|
140
|
+
options.onError?.(errorObj);
|
|
141
|
+
} finally {
|
|
142
|
+
loading.value = false;
|
|
143
|
+
if (inFlightQueries.get(queryKey) === queryPromise) inFlightQueries.delete(queryKey);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function scheduledRefetch() {
|
|
147
|
+
if (isCacheValidNow()) {
|
|
148
|
+
if (options.debug) logger.debug("[useReactiveQuery] Cache valid, skipping refetch");
|
|
149
|
+
metrics.recordCacheHit();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
153
|
+
debounceTimer = setTimeout(() => {
|
|
154
|
+
metrics.recordRefetch(options.tables[0] ?? "unknown");
|
|
155
|
+
executeQueryWithRetry();
|
|
156
|
+
debounceTimer = null;
|
|
157
|
+
}, debounceMs);
|
|
158
|
+
}
|
|
159
|
+
async function refetch(refetchOptions) {
|
|
160
|
+
if (debounceTimer) {
|
|
161
|
+
clearTimeout(debounceTimer);
|
|
162
|
+
debounceTimer = null;
|
|
163
|
+
}
|
|
164
|
+
await executeQueryWithRetry(true, 0, refetchOptions?.force);
|
|
165
|
+
}
|
|
166
|
+
function invalidate() {
|
|
167
|
+
lastFetchTime.value = 0;
|
|
168
|
+
syncCacheClock();
|
|
169
|
+
return refetch();
|
|
170
|
+
}
|
|
171
|
+
function shouldTriggerRefetch(event) {
|
|
172
|
+
if (options.refetchOn && !options.refetchOn.includes(event.type)) {
|
|
173
|
+
if (options.debug) logger.debug(`[useReactiveQuery] Skipping ${event.type} (not in refetchOn)`);
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
if (options.shouldRefetch && !options.shouldRefetch(event)) {
|
|
177
|
+
if (options.debug) logger.debug("[useReactiveQuery] Skipping (shouldRefetch returned false)");
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
const gate = createVisibilityGate();
|
|
183
|
+
const visibility = options.isVisible ?? resolveVisibility();
|
|
184
|
+
let unsubscribe = null;
|
|
185
|
+
let stopVisibilityWatch = null;
|
|
186
|
+
let active = false;
|
|
187
|
+
function activate() {
|
|
188
|
+
if (active) return;
|
|
189
|
+
active = true;
|
|
190
|
+
claimKey(queryKey, options.tables);
|
|
191
|
+
if (fetchOnMount) executeQueryWithRetry();
|
|
192
|
+
unsubscribe = deps.onTableChange(options.tables, (event) => {
|
|
193
|
+
if (options.debug) logger.debug("[useReactiveQuery] Table changed:", event);
|
|
194
|
+
if (!shouldTriggerRefetch(event)) return;
|
|
195
|
+
if (gate.recordChange(visibility.value) === "refetch") scheduledRefetch();
|
|
196
|
+
});
|
|
197
|
+
if (isRef(visibility)) stopVisibilityWatch = watch(visibility, (visible) => {
|
|
198
|
+
if (visible && gate.recordVisible() === "refetch") scheduledRefetch();
|
|
199
|
+
});
|
|
200
|
+
metrics.incrementListeners();
|
|
201
|
+
}
|
|
202
|
+
function deactivate() {
|
|
203
|
+
if (!active) return;
|
|
204
|
+
active = false;
|
|
205
|
+
releaseKey(queryKey);
|
|
206
|
+
unsubscribe?.();
|
|
207
|
+
unsubscribe = null;
|
|
208
|
+
stopVisibilityWatch?.();
|
|
209
|
+
stopVisibilityWatch = null;
|
|
210
|
+
metrics.decrementListeners();
|
|
211
|
+
}
|
|
212
|
+
let stopEnabledWatch = null;
|
|
213
|
+
onMounted(() => {
|
|
214
|
+
if (enabledRef.value) activate();
|
|
215
|
+
stopEnabledWatch = watch(enabledRef, (isEnabled) => {
|
|
216
|
+
if (isEnabled) activate();
|
|
217
|
+
else deactivate();
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
onUnmounted(() => {
|
|
221
|
+
disposed = true;
|
|
222
|
+
stopEnabledWatch?.();
|
|
223
|
+
deactivate();
|
|
224
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
225
|
+
abandonRetry?.();
|
|
226
|
+
if (cancelOnUnmount) cancel();
|
|
227
|
+
});
|
|
228
|
+
return {
|
|
229
|
+
data,
|
|
230
|
+
loading,
|
|
231
|
+
error,
|
|
232
|
+
isStale,
|
|
233
|
+
isCacheValid,
|
|
234
|
+
cacheAge,
|
|
235
|
+
retryCount,
|
|
236
|
+
refetch,
|
|
237
|
+
invalidate,
|
|
238
|
+
cancel
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
useReactiveQuery,
|
|
243
|
+
useStructuralQuery: (queryFn, tables, options) => useReactiveQuery(queryFn, {
|
|
244
|
+
tables,
|
|
245
|
+
refetchOn: ["insert", "delete"],
|
|
246
|
+
...options
|
|
247
|
+
}),
|
|
248
|
+
useStaticQuery: (queryFn, tables, options) => useReactiveQuery(queryFn, {
|
|
249
|
+
tables,
|
|
250
|
+
enabled: false,
|
|
251
|
+
...options
|
|
252
|
+
})
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
//#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
|
+
//#region src/queryMetrics.ts
|
|
273
|
+
const SLOWEST_QUERY_COUNT = 5;
|
|
274
|
+
/**
|
|
275
|
+
* Wraps the recorder's state in `reactive()` so a dev-tools panel re-renders as queries run, and
|
|
276
|
+
* derives the aggregate views from it. One recorder per app: pass `recorder` to
|
|
277
|
+
* `createReactiveQuery` so both sides count the same queries.
|
|
278
|
+
*/
|
|
279
|
+
function createVueQueryMetrics(options = {}) {
|
|
280
|
+
const recorder = createQueryMetrics({
|
|
281
|
+
maxEntries: options.maxEntries,
|
|
282
|
+
wrapState: reactive
|
|
283
|
+
});
|
|
284
|
+
const state = recorder.getState();
|
|
285
|
+
if (options.exposeOnWindowAs && typeof window !== "undefined") window[options.exposeOnWindowAs] = {
|
|
286
|
+
snapshot: () => JSON.parse(JSON.stringify(recorder.getState())),
|
|
287
|
+
reset: () => recorder.reset()
|
|
288
|
+
};
|
|
289
|
+
function useQueryMetrics() {
|
|
290
|
+
const totalQueries = computed(() => Object.values(state.queries).reduce((sum, metric) => sum + metric.count, 0));
|
|
291
|
+
return {
|
|
292
|
+
totalQueries,
|
|
293
|
+
avgQueryTime: computed(() => {
|
|
294
|
+
const metrics = Object.values(state.queries);
|
|
295
|
+
if (metrics.length === 0) return 0;
|
|
296
|
+
const totalTime = metrics.reduce((sum, metric) => sum + metric.totalTime, 0);
|
|
297
|
+
const totalCount = metrics.reduce((sum, metric) => sum + metric.count, 0);
|
|
298
|
+
return totalCount > 0 ? totalTime / totalCount : 0;
|
|
299
|
+
}),
|
|
300
|
+
cacheHitRate: computed(() => {
|
|
301
|
+
const total = totalQueries.value + state.cacheHits;
|
|
302
|
+
return total > 0 ? state.cacheHits / total * 100 : 0;
|
|
303
|
+
}),
|
|
304
|
+
refetchesByTable: computed(() => ({ ...state.refetchesByTable })),
|
|
305
|
+
queriesByKey: computed(() => ({ ...state.queries })),
|
|
306
|
+
slowestQueries: computed(() => Object.entries(state.queries).map(([key, metric]) => ({
|
|
307
|
+
key,
|
|
308
|
+
...metric
|
|
309
|
+
})).sort((a, b) => b.avgTime - a.avgTime).slice(0, SLOWEST_QUERY_COUNT)),
|
|
310
|
+
errors: computed(() => ({ ...state.errors })),
|
|
311
|
+
activeListeners: computed(() => state.activeListeners),
|
|
312
|
+
uptime: computed(() => Date.now() - state.startTime),
|
|
313
|
+
isDevToolsOpen: computed(() => state.isDevToolsOpen),
|
|
314
|
+
reset: () => recorder.reset(),
|
|
315
|
+
toggleDevTools: (isOpen) => recorder.toggleDevTools(isOpen)
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
recorder,
|
|
320
|
+
useQueryMetrics
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
//#endregion
|
|
324
|
+
export { createReactiveQuery, createVueQueryMetrics, uniqueQueryKey };
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cavulsqa/reactive-vue",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Vue bindings for @cavulsqa/reactive-db: a reactive query composable, Framework7 page visibility, and a reactive metrics view.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"framework7",
|
|
7
|
+
"offline-first",
|
|
8
|
+
"query",
|
|
9
|
+
"reactive",
|
|
10
|
+
"sqlite",
|
|
11
|
+
"vue"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/aybinv7/cavulsqa/tree/main/packages/reactive-vue#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/aybinv7/cavulsqa/issues"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/aybinv7/cavulsqa.git",
|
|
21
|
+
"directory": "packages/reactive-vue"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist"
|
|
25
|
+
],
|
|
26
|
+
"type": "module",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": "./dist/index.mjs",
|
|
29
|
+
"./framework7": "./dist/framework7.mjs",
|
|
30
|
+
"./package.json": "./package.json"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
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
|
+
"devDependencies": {
|
|
43
|
+
"@cavulsqa/reactive-db": "workspace:*",
|
|
44
|
+
"@types/node": "^26.1.1",
|
|
45
|
+
"bumpp": "^11.1.0",
|
|
46
|
+
"happy-dom": "catalog:",
|
|
47
|
+
"typescript": "^7.0.2",
|
|
48
|
+
"vite": "catalog:",
|
|
49
|
+
"vite-plus": "catalog:",
|
|
50
|
+
"vue": "catalog:"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"@cavulsqa/reactive-db": "^0.1.0",
|
|
54
|
+
"vue": ">=3.5"
|
|
55
|
+
}
|
|
56
|
+
}
|