@cavulsqa/reactive-db 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 ADDED
@@ -0,0 +1,24 @@
1
+ # @cavulsqa/reactive-db
2
+
3
+ Framework-agnostic primitives for keeping a UI in step with a local SQLite database.
4
+
5
+ - `createChangeBus` — publish and subscribe to per-table change events (`TableChangeEvent`).
6
+ - `createResultCache` — bounded, keyed result cache with staleness handling. A standalone
7
+ primitive: `@cavulsqa/reactive-vue` does not use it, so nothing shares results between call
8
+ sites unless you build that on top.
9
+ - `createVisibilityGate` — suppresses refetches while a view is hidden.
10
+ - `createReactiveDb` — wraps a Kysely instance so writes announce the tables they touched;
11
+ `executeWithEvent` does the same for a single query.
12
+ - `createQueryMetrics` — query duration, error, and cache-hit counters.
13
+ - `ReactiveQueryOptions`, `calcRetryDelay`, `noopMetrics` — the option contract and retry policy a
14
+ framework binding builds on.
15
+
16
+ **No framework dependency.** There is no `vue` import; `isVisible?: { value: boolean }` is a
17
+ structural stand-in for a ref, so any framework (or none) can drive it. Vue bindings live in
18
+ [`@cavulsqa/reactive-vue`](../reactive-vue).
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ vp install @cavulsqa/reactive-db
24
+ ```
@@ -0,0 +1,121 @@
1
+ import { Kysely } from "kysely";
2
+ //#region src/events.d.ts
3
+ type ChangeType = "insert" | "update" | "delete" | "bulk";
4
+ interface TableChangeMeta {
5
+ affectedRows?: number;
6
+ affectedIds?: (string | number)[];
7
+ transactionId?: string;
8
+ timestamp?: number;
9
+ }
10
+ interface TableChangeEvent extends TableChangeMeta {
11
+ table: string;
12
+ type: ChangeType;
13
+ }
14
+ interface ChangeBus {
15
+ emit: (table: string, type: ChangeType, meta?: TableChangeMeta) => void;
16
+ on: (tables: string[], listener: (event: TableChangeEvent) => void) => () => void;
17
+ }
18
+ declare function createChangeBus(): ChangeBus;
19
+ //#endregion
20
+ //#region src/resultCache.d.ts
21
+ interface ResultCache {
22
+ get(key: string): unknown;
23
+ set(key: string, value: unknown, tables: string[]): void;
24
+ invalidateTable(table: string): void;
25
+ clear(): void;
26
+ }
27
+ declare function createResultCache(maxEntries: number): ResultCache;
28
+ //#endregion
29
+ //#region src/visibilityGate.d.ts
30
+ type ChangeDecision = "refetch" | "defer";
31
+ type ShowDecision = "refetch" | "idle";
32
+ interface VisibilityGate {
33
+ recordChange(isVisible: boolean): ChangeDecision;
34
+ recordVisible(): ShowDecision;
35
+ }
36
+ declare function createVisibilityGate(): VisibilityGate;
37
+ //#endregion
38
+ //#region src/mutationProxy.d.ts
39
+ interface ReactiveDbDeps<DB> {
40
+ getDb: () => Kysely<DB>;
41
+ emitChange: (table: string, changeType: ChangeType, meta?: {
42
+ affectedRows?: number;
43
+ }) => void;
44
+ }
45
+ declare function affectedRowsOf(result: unknown): number | null;
46
+ declare function createReactiveDb<DB>(deps: ReactiveDbDeps<DB>): Kysely<DB>;
47
+ declare function executeWithEvent<T>(emitChange: ReactiveDbDeps<unknown>["emitChange"], table: string, changeType: ChangeType, executor: () => Promise<T>): Promise<T>;
48
+ //#endregion
49
+ //#region src/queryMetrics.d.ts
50
+ interface QueryMetric {
51
+ count: number;
52
+ totalTime: number;
53
+ avgTime: number;
54
+ lastTime: number;
55
+ lastSeen: number;
56
+ }
57
+ interface QueryMetricsState {
58
+ queries: Record<string, QueryMetric>;
59
+ errors: Record<string, number>;
60
+ cacheHits: number;
61
+ refetchesByTable: Record<string, number>;
62
+ activeListeners: number;
63
+ startTime: number;
64
+ isDevToolsOpen: boolean;
65
+ }
66
+ interface QueryMetricsRecorder {
67
+ recordQuery(queryKey: string, durationMs: number): void;
68
+ recordError(queryKey: string): void;
69
+ recordCacheHit(): void;
70
+ recordRefetch(table: string): void;
71
+ incrementListeners(): void;
72
+ decrementListeners(): void;
73
+ reset(): void;
74
+ getState(): QueryMetricsState;
75
+ toggleDevTools(isOpen?: boolean): void;
76
+ }
77
+ interface CreateQueryMetricsOptions {
78
+ maxEntries?: number;
79
+ wrapState?: (state: QueryMetricsState) => QueryMetricsState;
80
+ }
81
+ declare function createQueryMetrics(options?: CreateQueryMetricsOptions): QueryMetricsRecorder;
82
+ //#endregion
83
+ //#region src/useReactiveQuery.d.ts
84
+ interface ReactiveQueryOptions<T = unknown> {
85
+ tables: string[];
86
+ queryKey: string;
87
+ debounce?: number;
88
+ debug?: boolean;
89
+ refetchOn?: TableChangeEvent["type"][];
90
+ shouldRefetch?: (event: TableChangeEvent) => boolean;
91
+ cacheTime?: number;
92
+ staleWhileRevalidate?: boolean;
93
+ enabled?: boolean;
94
+ /**
95
+ * Run the query once on mount. Defaults to true. Set false when a caller-owned
96
+ * watcher already drives the initial load, so the mount fetch would only add a
97
+ * redundant run. Unlike `enabled: false`, the table-change subscription stays.
98
+ */
99
+ fetchOnMount?: boolean;
100
+ onSuccess?: (data: T) => void;
101
+ onError?: (error: Error) => void;
102
+ cancelOnUnmount?: boolean;
103
+ retry?: number | false;
104
+ retryDelay?: number | ((attempt: number) => number);
105
+ isVisible?: {
106
+ value: boolean;
107
+ };
108
+ }
109
+ interface QueryMetrics {
110
+ recordQuery(queryKey: string, durationMs: number): void;
111
+ recordError(queryKey: string): void;
112
+ recordCacheHit(): void;
113
+ recordRefetch(table: string): void;
114
+ incrementListeners(): void;
115
+ decrementListeners(): void;
116
+ }
117
+ type OnTableChangeFn = (tables: string[], callback: (event: TableChangeEvent) => void) => () => void;
118
+ declare function calcRetryDelay(attempt: number, retryDelay?: number | ((attempt: number) => number)): number;
119
+ declare const noopMetrics: QueryMetrics;
120
+ //#endregion
121
+ export { ChangeBus, ChangeDecision, ChangeType, CreateQueryMetricsOptions, type OnTableChangeFn, QueryMetric, type QueryMetrics, QueryMetricsRecorder, QueryMetricsState, ReactiveDbDeps, type ReactiveQueryOptions, ResultCache, ShowDecision, TableChangeEvent, TableChangeMeta, VisibilityGate, affectedRowsOf, calcRetryDelay, createChangeBus, createQueryMetrics, createReactiveDb, createResultCache, createVisibilityGate, executeWithEvent, noopMetrics };
package/dist/index.mjs ADDED
@@ -0,0 +1,274 @@
1
+ //#region src/events.ts
2
+ function createChangeBus() {
3
+ const listeners = /* @__PURE__ */ new Set();
4
+ return {
5
+ emit(table, type, meta) {
6
+ const event = {
7
+ table,
8
+ type,
9
+ ...meta
10
+ };
11
+ for (const listener of listeners) if (listener.tables.has("*") || listener.tables.has(table)) listener.fn(event);
12
+ },
13
+ on(tables, listener) {
14
+ const entry = {
15
+ tables: new Set(tables),
16
+ fn: listener
17
+ };
18
+ listeners.add(entry);
19
+ return () => {
20
+ listeners.delete(entry);
21
+ };
22
+ }
23
+ };
24
+ }
25
+ //#endregion
26
+ //#region src/resultCache.ts
27
+ function createResultCache(maxEntries) {
28
+ const entries = /* @__PURE__ */ new Map();
29
+ return {
30
+ get(key) {
31
+ const entry = entries.get(key);
32
+ if (entry === void 0) return void 0;
33
+ entries.delete(key);
34
+ entries.set(key, entry);
35
+ return entry.value;
36
+ },
37
+ set(key, value, tables) {
38
+ if (entries.has(key)) entries.delete(key);
39
+ entries.set(key, {
40
+ value,
41
+ tables
42
+ });
43
+ while (entries.size > maxEntries) {
44
+ const oldest = entries.keys().next().value;
45
+ entries.delete(oldest);
46
+ }
47
+ },
48
+ invalidateTable(table) {
49
+ for (const [key, entry] of entries) if (entry.tables.includes(table)) entries.delete(key);
50
+ },
51
+ clear() {
52
+ entries.clear();
53
+ }
54
+ };
55
+ }
56
+ //#endregion
57
+ //#region src/visibilityGate.ts
58
+ function createVisibilityGate() {
59
+ let deferred = false;
60
+ return {
61
+ recordChange(isVisible) {
62
+ if (isVisible) return "refetch";
63
+ deferred = true;
64
+ return "defer";
65
+ },
66
+ recordVisible() {
67
+ if (!deferred) return "idle";
68
+ deferred = false;
69
+ return "refetch";
70
+ }
71
+ };
72
+ }
73
+ //#endregion
74
+ //#region src/mutationProxy.ts
75
+ const MUTATION_CHANGE_TYPE = {
76
+ insertInto: "insert",
77
+ updateTable: "update",
78
+ deleteFrom: "delete"
79
+ };
80
+ const ROW_COUNT_KEYS = [
81
+ "numUpdatedRows",
82
+ "numDeletedRows",
83
+ "numInsertedOrUpdatedRows",
84
+ "numAffectedRows"
85
+ ];
86
+ function affectedRowsOf(result) {
87
+ const rows = Array.isArray(result) ? result : result == null ? [] : [result];
88
+ let counted = null;
89
+ for (const row of rows) {
90
+ if (!row || typeof row !== "object") continue;
91
+ for (const key of ROW_COUNT_KEYS) {
92
+ const value = row[key];
93
+ if (value === void 0 || value === null) continue;
94
+ counted = (counted ?? 0) + Number(value);
95
+ break;
96
+ }
97
+ }
98
+ return counted ?? (rows.length > 0 ? rows.length : null);
99
+ }
100
+ function wrapExecutor(executor, table, changeType, onExecuted) {
101
+ return async () => {
102
+ const result = await executor();
103
+ onExecuted(table, changeType, result);
104
+ return result;
105
+ };
106
+ }
107
+ function wrapBuilder(builder, table, changeType, onExecuted) {
108
+ return new Proxy(builder, { get(target, prop) {
109
+ const value = target[prop];
110
+ if (typeof value !== "function") return value;
111
+ if (prop === "execute" || prop === "executeTakeFirst" || prop === "executeTakeFirstOrThrow") return wrapExecutor(value.bind(target), table, changeType, onExecuted);
112
+ return (...args) => {
113
+ const result = value.apply(target, args);
114
+ if (result && typeof result.execute === "function" && result !== target) return wrapBuilder(result, table, changeType, onExecuted);
115
+ return result;
116
+ };
117
+ } });
118
+ }
119
+ function createMutationProxy(db, onExecuted) {
120
+ return new Proxy(db, { get(target, prop) {
121
+ const value = target[prop];
122
+ if (prop === "insertInto" || prop === "updateTable" || prop === "deleteFrom") return (...args) => {
123
+ const table = args[0];
124
+ const builder = value.apply(target, args);
125
+ const changeType = MUTATION_CHANGE_TYPE[prop];
126
+ return wrapBuilder(builder, table, changeType, onExecuted);
127
+ };
128
+ if (typeof value === "function") return value.bind(target);
129
+ return value;
130
+ } });
131
+ }
132
+ function wrapTransactionBuilder(txBuilder, emitChange) {
133
+ return new Proxy(txBuilder, { get(target, prop) {
134
+ const value = target[prop];
135
+ if (prop === "execute" && typeof value === "function") return async (callback) => {
136
+ const touchedTables = /* @__PURE__ */ new Set();
137
+ const result = await value.call(target, (trx) => callback(createMutationProxy(trx, (table) => {
138
+ touchedTables.add(table);
139
+ })));
140
+ for (const table of touchedTables) emitChange(table, "bulk");
141
+ return result;
142
+ };
143
+ if (typeof value === "function") return (...args) => {
144
+ const result = value.apply(target, args);
145
+ if (result && typeof result.execute === "function") return wrapTransactionBuilder(result, emitChange);
146
+ return result;
147
+ };
148
+ return value;
149
+ } });
150
+ }
151
+ function createReactiveDb(deps) {
152
+ const { getDb, emitChange } = deps;
153
+ function emitMutationEvent(table, changeType, result) {
154
+ const affectedRows = affectedRowsOf(result);
155
+ if (affectedRows === 0) return;
156
+ emitChange(table, changeType, affectedRows === null ? void 0 : { affectedRows });
157
+ }
158
+ return new Proxy({}, { get(_target, prop) {
159
+ const db = getDb();
160
+ const value = db[prop];
161
+ if (prop === "insertInto" || prop === "updateTable" || prop === "deleteFrom") return (...args) => {
162
+ const table = args[0];
163
+ const builder = value.apply(db, args);
164
+ const changeType = MUTATION_CHANGE_TYPE[prop];
165
+ return wrapBuilder(builder, table, changeType, emitMutationEvent);
166
+ };
167
+ if (prop === "transaction") return () => {
168
+ return wrapTransactionBuilder(value.apply(db, []), emitChange);
169
+ };
170
+ if (typeof value === "function") return value.bind(db);
171
+ return value;
172
+ } });
173
+ }
174
+ async function executeWithEvent(emitChange, table, changeType, executor) {
175
+ const result = await executor();
176
+ emitChange(table, changeType);
177
+ return result;
178
+ }
179
+ //#endregion
180
+ //#region src/queryMetrics.ts
181
+ const DEFAULT_MAX_ENTRIES = 200;
182
+ function createQueryMetrics(options = {}) {
183
+ const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
184
+ const state = (options.wrapState ?? ((s) => s))({
185
+ queries: {},
186
+ errors: {},
187
+ cacheHits: 0,
188
+ refetchesByTable: {},
189
+ activeListeners: 0,
190
+ startTime: Date.now(),
191
+ isDevToolsOpen: false
192
+ });
193
+ function pruneQueries() {
194
+ const entries = Object.entries(state.queries);
195
+ if (entries.length <= maxEntries) return;
196
+ const expired = entries.sort(([, a], [, b]) => a.lastSeen - b.lastSeen).slice(0, entries.length - maxEntries);
197
+ for (const [key] of expired) {
198
+ delete state.queries[key];
199
+ delete state.errors[key];
200
+ }
201
+ }
202
+ function pruneRecord(record) {
203
+ const keys = Object.keys(record);
204
+ if (keys.length <= maxEntries) return;
205
+ for (const key of keys.slice(0, keys.length - maxEntries)) delete record[key];
206
+ }
207
+ return {
208
+ recordQuery(queryKey, durationMs) {
209
+ const now = Date.now();
210
+ if (!state.queries[queryKey]) state.queries[queryKey] = {
211
+ count: 0,
212
+ totalTime: 0,
213
+ avgTime: 0,
214
+ lastTime: 0,
215
+ lastSeen: now
216
+ };
217
+ const metric = state.queries[queryKey];
218
+ metric.count++;
219
+ metric.totalTime += durationMs;
220
+ metric.avgTime = metric.totalTime / metric.count;
221
+ metric.lastTime = durationMs;
222
+ metric.lastSeen = now;
223
+ pruneQueries();
224
+ },
225
+ recordError(queryKey) {
226
+ state.errors[queryKey] = (state.errors[queryKey] ?? 0) + 1;
227
+ pruneRecord(state.errors);
228
+ },
229
+ recordCacheHit() {
230
+ state.cacheHits++;
231
+ },
232
+ recordRefetch(table) {
233
+ state.refetchesByTable[table] = (state.refetchesByTable[table] ?? 0) + 1;
234
+ pruneRecord(state.refetchesByTable);
235
+ },
236
+ incrementListeners() {
237
+ state.activeListeners++;
238
+ },
239
+ decrementListeners() {
240
+ if (state.activeListeners > 0) state.activeListeners--;
241
+ },
242
+ reset() {
243
+ state.queries = {};
244
+ state.errors = {};
245
+ state.cacheHits = 0;
246
+ state.refetchesByTable = {};
247
+ state.startTime = Date.now();
248
+ },
249
+ getState() {
250
+ return state;
251
+ },
252
+ toggleDevTools(isOpen) {
253
+ state.isDevToolsOpen = isOpen ?? !state.isDevToolsOpen;
254
+ }
255
+ };
256
+ }
257
+ //#endregion
258
+ //#region src/useReactiveQuery.ts
259
+ const DEFAULT_RETRY_BASE = 1e3;
260
+ function calcRetryDelay(attempt, retryDelay) {
261
+ if (typeof retryDelay === "function") return retryDelay(attempt);
262
+ if (typeof retryDelay === "number") return retryDelay;
263
+ return DEFAULT_RETRY_BASE * Math.pow(2, attempt);
264
+ }
265
+ const noopMetrics = {
266
+ recordQuery() {},
267
+ recordError() {},
268
+ recordCacheHit() {},
269
+ recordRefetch() {},
270
+ incrementListeners() {},
271
+ decrementListeners() {}
272
+ };
273
+ //#endregion
274
+ export { affectedRowsOf, calcRetryDelay, createChangeBus, createQueryMetrics, createReactiveDb, createResultCache, createVisibilityGate, executeWithEvent, noopMetrics };
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@cavulsqa/reactive-db",
3
+ "version": "0.1.0",
4
+ "description": "Framework-agnostic reactive query primitives: table-change bus, result cache, visibility gate, mutation proxy, query metrics.",
5
+ "keywords": [
6
+ "cache",
7
+ "offline-first",
8
+ "query",
9
+ "reactive",
10
+ "sqlite"
11
+ ],
12
+ "homepage": "https://github.com/aybinv7/cavulsqa/tree/main/packages/reactive-db#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/aybinv7/cavulsqa/issues"
15
+ },
16
+ "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/aybinv7/cavulsqa.git",
20
+ "directory": "packages/reactive-db"
21
+ },
22
+ "files": [
23
+ "dist"
24
+ ],
25
+ "type": "module",
26
+ "exports": {
27
+ ".": "./dist/index.mjs",
28
+ "./package.json": "./package.json"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "scripts": {
34
+ "build": "vp pack",
35
+ "dev": "vp pack --watch",
36
+ "test": "vp test",
37
+ "check": "vp check",
38
+ "prepublishOnly": "vp run build"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^26.1.1",
42
+ "bumpp": "^11.1.0",
43
+ "kysely": "^0.28.9",
44
+ "typescript": "^7.0.2",
45
+ "vite": "catalog:",
46
+ "vite-plus": "catalog:"
47
+ },
48
+ "peerDependencies": {
49
+ "kysely": ">=0.28"
50
+ },
51
+ "peerDependenciesMeta": {
52
+ "kysely": {
53
+ "optional": true
54
+ }
55
+ }
56
+ }