@cavulsqa/reactive-db 0.2.0 → 1.0.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 +20 -3
- package/dist/index.d.mts +43 -13
- package/dist/index.mjs +91 -44
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -2,17 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
Framework-agnostic primitives for keeping a UI in step with a local SQLite database.
|
|
4
4
|
|
|
5
|
-
- `createChangeBus
|
|
5
|
+
- `createChangeBus<DB>` — publish and subscribe to per-table change events (`TableChangeEvent`).
|
|
6
|
+
Pass the schema and table names are checked against it (`TableName<DB>`); subscribe to
|
|
7
|
+
`ALL_TABLES` for everything.
|
|
8
|
+
- `hashQueryKey` — a stable string for a `QueryKey` (an array). Identity comes from the arguments,
|
|
9
|
+
not from a name the caller invents, so two call sites only share a request when they are asking
|
|
10
|
+
the same question. A function or symbol in a key throws rather than hashing alike.
|
|
6
11
|
- `createResultCache` — bounded, keyed result cache with staleness handling. A standalone
|
|
7
12
|
primitive: `@cavulsqa/reactive-vue` does not use it, so nothing shares results between call
|
|
8
13
|
sites unless you build that on top.
|
|
9
14
|
- `createVisibilityGate` — suppresses refetches while a view is hidden.
|
|
10
|
-
- `createReactiveDb
|
|
11
|
-
`executeWithEvent` does the same for a single query.
|
|
15
|
+
- `createReactiveDb<DB>` — wraps a Kysely instance so writes announce the tables they touched;
|
|
16
|
+
`executeWithEvent` does the same for a single query. A transaction reports **what it actually
|
|
17
|
+
did** per table, not a blanket `"bulk"` — otherwise a query filtering on `refetchOn` ignores
|
|
18
|
+
precisely the batched writes that matter.
|
|
12
19
|
- `createQueryMetrics` — query duration, error, and cache-hit counters.
|
|
13
20
|
- `ReactiveQueryOptions`, `calcRetryDelay`, `noopMetrics` — the option contract and retry policy a
|
|
14
21
|
framework binding builds on.
|
|
15
22
|
|
|
23
|
+
## Table names are the one argument that fails silently
|
|
24
|
+
|
|
25
|
+
Misspell one in `tables` and the query subscribes to a table nobody writes to: no error, no warning,
|
|
26
|
+
a screen that is stale forever. So pass the schema:
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
const bus = createChangeBus<Database>();
|
|
30
|
+
bus.emit("sale_ordr", "insert"); // ← a type error, with a "did you mean" suggestion
|
|
31
|
+
```
|
|
32
|
+
|
|
16
33
|
`kysely` is an **optional** peer: it is imported for types only. Marking it optional keeps npm
|
|
17
34
|
from auto-installing a kysely major that `@cavulsqa/mobile-db` cannot use - which otherwise
|
|
18
35
|
resolves the whole tree onto an older mobile-db.
|
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { Kysely } from "kysely";
|
|
2
2
|
//#region src/events.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* A table in the schema, when one is known.
|
|
5
|
+
*
|
|
6
|
+
* Defaults to `string` so a caller can skip the parameter, but passing the schema is the point: a
|
|
7
|
+
* table name is the one argument in this library that fails silently. Misspell it in `tables` and
|
|
8
|
+
* the query simply never refetches - no error, no warning, a screen that is stale forever.
|
|
9
|
+
*/
|
|
10
|
+
type TableName<DB = Record<string, unknown>> = keyof DB & string;
|
|
11
|
+
/** Every table, for a listener that wants the whole bus rather than named tables. */
|
|
12
|
+
declare const ALL_TABLES = "*";
|
|
3
13
|
type ChangeType = "insert" | "update" | "delete" | "bulk";
|
|
4
14
|
interface TableChangeMeta {
|
|
5
15
|
affectedRows?: number;
|
|
@@ -11,11 +21,22 @@ interface TableChangeEvent extends TableChangeMeta {
|
|
|
11
21
|
table: string;
|
|
12
22
|
type: ChangeType;
|
|
13
23
|
}
|
|
14
|
-
interface ChangeBus {
|
|
15
|
-
emit: (table:
|
|
16
|
-
on: (tables:
|
|
24
|
+
interface ChangeBus<DB = Record<string, unknown>> {
|
|
25
|
+
emit: (table: TableName<DB>, type: ChangeType, meta?: TableChangeMeta) => void;
|
|
26
|
+
on: (tables: (TableName<DB> | typeof ALL_TABLES)[], listener: (event: TableChangeEvent) => void) => () => void;
|
|
17
27
|
}
|
|
18
|
-
declare function createChangeBus(): ChangeBus
|
|
28
|
+
declare function createChangeBus<DB = Record<string, unknown>>(): ChangeBus<DB>;
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region src/queryKey.d.ts
|
|
31
|
+
type QueryKey = readonly unknown[];
|
|
32
|
+
/**
|
|
33
|
+
* A stable string for a key array.
|
|
34
|
+
*
|
|
35
|
+
* The identity has to come from the arguments, not from a name the caller invents: two mounted
|
|
36
|
+
* queries sharing an identity await one request and share its result, so `"order-detail"` used by
|
|
37
|
+
* two detail pages showed one page the other page's order. `["order-detail", id]` cannot.
|
|
38
|
+
*/
|
|
39
|
+
declare function hashQueryKey(key: QueryKey): string;
|
|
19
40
|
//#endregion
|
|
20
41
|
//#region src/resultCache.d.ts
|
|
21
42
|
interface ResultCache {
|
|
@@ -36,15 +57,22 @@ interface VisibilityGate {
|
|
|
36
57
|
declare function createVisibilityGate(): VisibilityGate;
|
|
37
58
|
//#endregion
|
|
38
59
|
//#region src/mutationProxy.d.ts
|
|
60
|
+
type EmitChangeFn<DB> = (table: TableName<DB>, changeType: ChangeType, meta?: {
|
|
61
|
+
affectedRows?: number;
|
|
62
|
+
}) => void;
|
|
39
63
|
interface ReactiveDbDeps<DB> {
|
|
40
64
|
getDb: () => Kysely<DB>;
|
|
41
|
-
emitChange:
|
|
42
|
-
affectedRows?: number;
|
|
43
|
-
}) => void;
|
|
65
|
+
emitChange: EmitChangeFn<DB>;
|
|
44
66
|
}
|
|
45
67
|
declare function affectedRowsOf(result: unknown): number | null;
|
|
68
|
+
/**
|
|
69
|
+
* A Kysely that announces the tables it wrote to.
|
|
70
|
+
*
|
|
71
|
+
* The database is resolved per access rather than captured, so this can be created at module scope
|
|
72
|
+
* before anything has opened it.
|
|
73
|
+
*/
|
|
46
74
|
declare function createReactiveDb<DB>(deps: ReactiveDbDeps<DB>): Kysely<DB>;
|
|
47
|
-
declare function executeWithEvent<T>(emitChange:
|
|
75
|
+
declare function executeWithEvent<T, DB>(emitChange: EmitChangeFn<DB>, table: TableName<DB>, changeType: ChangeType, executor: () => Promise<T>): Promise<T>;
|
|
48
76
|
//#endregion
|
|
49
77
|
//#region src/queryMetrics.d.ts
|
|
50
78
|
interface QueryMetric {
|
|
@@ -81,9 +109,11 @@ interface CreateQueryMetricsOptions {
|
|
|
81
109
|
declare function createQueryMetrics(options?: CreateQueryMetricsOptions): QueryMetricsRecorder;
|
|
82
110
|
//#endregion
|
|
83
111
|
//#region src/useReactiveQuery.d.ts
|
|
84
|
-
interface ReactiveQueryOptions<T = unknown
|
|
85
|
-
|
|
86
|
-
|
|
112
|
+
interface ReactiveQueryOptions<T = unknown, DB = Record<string, unknown>> {
|
|
113
|
+
/** Every table the query function reads. Under-list one and the screen goes stale in silence. */
|
|
114
|
+
tables: TableName<DB>[];
|
|
115
|
+
/** Identity, taken from the arguments the query reads. See `hashQueryKey`. */
|
|
116
|
+
queryKey: QueryKey;
|
|
87
117
|
debounce?: number;
|
|
88
118
|
debug?: boolean;
|
|
89
119
|
refetchOn?: TableChangeEvent["type"][];
|
|
@@ -114,8 +144,8 @@ interface QueryMetrics {
|
|
|
114
144
|
incrementListeners(): void;
|
|
115
145
|
decrementListeners(): void;
|
|
116
146
|
}
|
|
117
|
-
type OnTableChangeFn = (tables:
|
|
147
|
+
type OnTableChangeFn<DB = Record<string, unknown>> = (tables: (TableName<DB> | typeof ALL_TABLES)[], callback: (event: TableChangeEvent) => void) => () => void;
|
|
118
148
|
declare function calcRetryDelay(attempt: number, retryDelay?: number | ((attempt: number) => number)): number;
|
|
119
149
|
declare const noopMetrics: QueryMetrics;
|
|
120
150
|
//#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 };
|
|
151
|
+
export { ALL_TABLES, ChangeBus, ChangeDecision, ChangeType, CreateQueryMetricsOptions, EmitChangeFn, type OnTableChangeFn, QueryKey, QueryMetric, type QueryMetrics, QueryMetricsRecorder, QueryMetricsState, ReactiveDbDeps, type ReactiveQueryOptions, ResultCache, ShowDecision, TableChangeEvent, TableChangeMeta, TableName, VisibilityGate, affectedRowsOf, calcRetryDelay, createChangeBus, createQueryMetrics, createReactiveDb, createResultCache, createVisibilityGate, executeWithEvent, hashQueryKey, noopMetrics };
|
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
//#region src/events.ts
|
|
2
|
+
/** Every table, for a listener that wants the whole bus rather than named tables. */
|
|
3
|
+
const ALL_TABLES = "*";
|
|
2
4
|
function createChangeBus() {
|
|
3
5
|
const listeners = /* @__PURE__ */ new Set();
|
|
4
6
|
return {
|
|
@@ -23,6 +25,31 @@ function createChangeBus() {
|
|
|
23
25
|
};
|
|
24
26
|
}
|
|
25
27
|
//#endregion
|
|
28
|
+
//#region src/queryKey.ts
|
|
29
|
+
/**
|
|
30
|
+
* A stable string for a key array.
|
|
31
|
+
*
|
|
32
|
+
* The identity has to come from the arguments, not from a name the caller invents: two mounted
|
|
33
|
+
* queries sharing an identity await one request and share its result, so `"order-detail"` used by
|
|
34
|
+
* two detail pages showed one page the other page's order. `["order-detail", id]` cannot.
|
|
35
|
+
*/
|
|
36
|
+
function hashQueryKey(key) {
|
|
37
|
+
return JSON.stringify(key, (_field, value) => {
|
|
38
|
+
if (typeof value === "function" || typeof value === "symbol") throw new Error(`[reactive-db] a query key cannot contain a ${typeof value}: it has no stable serialisation, so two different queries would hash alike and share each other's results. Key on the values the query reads instead.`);
|
|
39
|
+
return isPlainObject(value) ? sortFields(value) : value;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
function isPlainObject(value) {
|
|
43
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
44
|
+
const prototype = Object.getPrototypeOf(value);
|
|
45
|
+
return prototype === Object.prototype || prototype === null;
|
|
46
|
+
}
|
|
47
|
+
function sortFields(value) {
|
|
48
|
+
const sorted = {};
|
|
49
|
+
for (const field of Object.keys(value).sort()) sorted[field] = value[field];
|
|
50
|
+
return sorted;
|
|
51
|
+
}
|
|
52
|
+
//#endregion
|
|
26
53
|
//#region src/resultCache.ts
|
|
27
54
|
function createResultCache(maxEntries) {
|
|
28
55
|
const entries = /* @__PURE__ */ new Map();
|
|
@@ -77,12 +104,30 @@ const MUTATION_CHANGE_TYPE = {
|
|
|
77
104
|
updateTable: "update",
|
|
78
105
|
deleteFrom: "delete"
|
|
79
106
|
};
|
|
107
|
+
const EXECUTORS = /* @__PURE__ */ new Set([
|
|
108
|
+
"execute",
|
|
109
|
+
"executeTakeFirst",
|
|
110
|
+
"executeTakeFirstOrThrow"
|
|
111
|
+
]);
|
|
80
112
|
const ROW_COUNT_KEYS = [
|
|
81
113
|
"numUpdatedRows",
|
|
82
114
|
"numDeletedRows",
|
|
83
115
|
"numInsertedOrUpdatedRows",
|
|
84
116
|
"numAffectedRows"
|
|
85
117
|
];
|
|
118
|
+
function isMutationMethod(prop) {
|
|
119
|
+
return typeof prop === "string" && prop in MUTATION_CHANGE_TYPE;
|
|
120
|
+
}
|
|
121
|
+
/** Kysely's builders are structurally huge and reached dynamically, so reads are narrowed by hand. */
|
|
122
|
+
function read(target, prop) {
|
|
123
|
+
return Reflect.get(target, prop);
|
|
124
|
+
}
|
|
125
|
+
function isFunction(value) {
|
|
126
|
+
return typeof value === "function";
|
|
127
|
+
}
|
|
128
|
+
function hasExecute(value) {
|
|
129
|
+
return typeof value === "object" && value !== null && isFunction(read(value, "execute"));
|
|
130
|
+
}
|
|
86
131
|
function affectedRowsOf(result) {
|
|
87
132
|
const rows = Array.isArray(result) ? result : result == null ? [] : [result];
|
|
88
133
|
let counted = null;
|
|
@@ -97,78 +142,80 @@ function affectedRowsOf(result) {
|
|
|
97
142
|
}
|
|
98
143
|
return counted ?? (rows.length > 0 ? rows.length : null);
|
|
99
144
|
}
|
|
100
|
-
function
|
|
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) {
|
|
145
|
+
function wrapBuilder(builder, table, changeType, notify) {
|
|
108
146
|
return new Proxy(builder, { get(target, prop) {
|
|
109
|
-
const value = target
|
|
110
|
-
if (
|
|
111
|
-
if (
|
|
147
|
+
const value = read(target, prop);
|
|
148
|
+
if (!isFunction(value)) return value;
|
|
149
|
+
if (typeof prop === "string" && EXECUTORS.has(prop)) return async (...args) => {
|
|
150
|
+
const result = await value.apply(target, args);
|
|
151
|
+
notify(table, changeType, result);
|
|
152
|
+
return result;
|
|
153
|
+
};
|
|
112
154
|
return (...args) => {
|
|
113
155
|
const result = value.apply(target, args);
|
|
114
|
-
|
|
115
|
-
return result;
|
|
156
|
+
return hasExecute(result) && result !== target ? wrapBuilder(result, table, changeType, notify) : result;
|
|
116
157
|
};
|
|
117
158
|
} });
|
|
118
159
|
}
|
|
119
|
-
function
|
|
160
|
+
function interceptMutations(db, notify) {
|
|
120
161
|
return new Proxy(db, { get(target, prop) {
|
|
121
|
-
const value = target
|
|
122
|
-
if (prop
|
|
162
|
+
const value = read(target, prop);
|
|
163
|
+
if (isMutationMethod(prop) && isFunction(value)) return (...args) => {
|
|
123
164
|
const table = args[0];
|
|
124
|
-
|
|
125
|
-
const changeType = MUTATION_CHANGE_TYPE[prop];
|
|
126
|
-
return wrapBuilder(builder, table, changeType, onExecuted);
|
|
165
|
+
return wrapBuilder(value.apply(target, args), table, MUTATION_CHANGE_TYPE[prop], notify);
|
|
127
166
|
};
|
|
128
|
-
|
|
129
|
-
return value;
|
|
167
|
+
return isFunction(value) ? value.bind(target) : value;
|
|
130
168
|
} });
|
|
131
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* A transaction reports what it actually did, per table.
|
|
172
|
+
*
|
|
173
|
+
* It used to report `"bulk"` for everything, which any query filtering on `refetchOn` then dropped
|
|
174
|
+
* on the floor - and since batched writes are the ones that run in transactions, a screen watching
|
|
175
|
+
* for inserts missed precisely the writes that mattered.
|
|
176
|
+
*/
|
|
132
177
|
function wrapTransactionBuilder(txBuilder, emitChange) {
|
|
133
178
|
return new Proxy(txBuilder, { get(target, prop) {
|
|
134
|
-
const value = target
|
|
135
|
-
if (
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
|
|
179
|
+
const value = read(target, prop);
|
|
180
|
+
if (!isFunction(value)) return value;
|
|
181
|
+
if (prop === "execute") return async (callback) => {
|
|
182
|
+
const touched = /* @__PURE__ */ new Map();
|
|
183
|
+
const result = await value.call(target, (trx) => callback(interceptMutations(trx, (table, changeType) => {
|
|
184
|
+
const seen = touched.get(table) ?? /* @__PURE__ */ new Set();
|
|
185
|
+
seen.add(changeType);
|
|
186
|
+
touched.set(table, seen);
|
|
139
187
|
})));
|
|
140
|
-
for (const table of
|
|
188
|
+
for (const [table, changeTypes] of touched) for (const changeType of changeTypes) emitChange(table, changeType);
|
|
141
189
|
return result;
|
|
142
190
|
};
|
|
143
|
-
|
|
191
|
+
return (...args) => {
|
|
144
192
|
const result = value.apply(target, args);
|
|
145
|
-
|
|
146
|
-
return result;
|
|
193
|
+
return hasExecute(result) ? wrapTransactionBuilder(result, emitChange) : result;
|
|
147
194
|
};
|
|
148
|
-
return value;
|
|
149
195
|
} });
|
|
150
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* A Kysely that announces the tables it wrote to.
|
|
199
|
+
*
|
|
200
|
+
* The database is resolved per access rather than captured, so this can be created at module scope
|
|
201
|
+
* before anything has opened it.
|
|
202
|
+
*/
|
|
151
203
|
function createReactiveDb(deps) {
|
|
152
204
|
const { getDb, emitChange } = deps;
|
|
153
|
-
|
|
205
|
+
const notify = (table, changeType, result) => {
|
|
154
206
|
const affectedRows = affectedRowsOf(result);
|
|
155
207
|
if (affectedRows === 0) return;
|
|
156
208
|
emitChange(table, changeType, affectedRows === null ? void 0 : { affectedRows });
|
|
157
|
-
}
|
|
209
|
+
};
|
|
158
210
|
return new Proxy({}, { get(_target, prop) {
|
|
159
211
|
const db = getDb();
|
|
160
|
-
const value = db
|
|
161
|
-
if (prop
|
|
212
|
+
const value = read(db, prop);
|
|
213
|
+
if (isMutationMethod(prop) && isFunction(value)) return (...args) => {
|
|
162
214
|
const table = args[0];
|
|
163
|
-
|
|
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);
|
|
215
|
+
return wrapBuilder(value.apply(db, args), table, MUTATION_CHANGE_TYPE[prop], notify);
|
|
169
216
|
};
|
|
170
|
-
if (
|
|
171
|
-
return value;
|
|
217
|
+
if (prop === "transaction" && isFunction(value)) return () => wrapTransactionBuilder(value.apply(db, []), emitChange);
|
|
218
|
+
return isFunction(value) ? value.bind(db) : value;
|
|
172
219
|
} });
|
|
173
220
|
}
|
|
174
221
|
async function executeWithEvent(emitChange, table, changeType, executor) {
|
|
@@ -271,4 +318,4 @@ const noopMetrics = {
|
|
|
271
318
|
decrementListeners() {}
|
|
272
319
|
};
|
|
273
320
|
//#endregion
|
|
274
|
-
export { affectedRowsOf, calcRetryDelay, createChangeBus, createQueryMetrics, createReactiveDb, createResultCache, createVisibilityGate, executeWithEvent, noopMetrics };
|
|
321
|
+
export { ALL_TABLES, affectedRowsOf, calcRetryDelay, createChangeBus, createQueryMetrics, createReactiveDb, createResultCache, createVisibilityGate, executeWithEvent, hashQueryKey, noopMetrics };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cavulsqa/reactive-db",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Framework-agnostic reactive query primitives: table-change bus, result cache, visibility gate, mutation proxy, query metrics.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cache",
|
|
@@ -31,10 +31,10 @@
|
|
|
31
31
|
"access": "public"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
|
-
"@types/node": "^
|
|
34
|
+
"@types/node": "^24.12.2",
|
|
35
35
|
"bumpp": "^11.1.0",
|
|
36
36
|
"kysely": "^0.29.5",
|
|
37
|
-
"typescript": "^
|
|
37
|
+
"typescript": "^5.9.3",
|
|
38
38
|
"vite": "npm:@voidzero-dev/vite-plus-core@0.3.0",
|
|
39
39
|
"vite-plus": "0.3.0"
|
|
40
40
|
},
|