@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 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` — publish and subscribe to per-table change events (`TableChangeEvent`).
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` — wraps a Kysely instance so writes announce the tables they touched;
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: string, type: ChangeType, meta?: TableChangeMeta) => void;
16
- on: (tables: string[], listener: (event: TableChangeEvent) => void) => () => void;
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: (table: string, changeType: ChangeType, meta?: {
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: ReactiveDbDeps<unknown>["emitChange"], table: string, changeType: ChangeType, executor: () => Promise<T>): Promise<T>;
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
- tables: string[];
86
- queryKey: string;
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: string[], callback: (event: TableChangeEvent) => void) => () => void;
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 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) {
145
+ function wrapBuilder(builder, table, changeType, notify) {
108
146
  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);
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
- if (result && typeof result.execute === "function" && result !== target) return wrapBuilder(result, table, changeType, onExecuted);
115
- return result;
156
+ return hasExecute(result) && result !== target ? wrapBuilder(result, table, changeType, notify) : result;
116
157
  };
117
158
  } });
118
159
  }
119
- function createMutationProxy(db, onExecuted) {
160
+ function interceptMutations(db, notify) {
120
161
  return new Proxy(db, { get(target, prop) {
121
- const value = target[prop];
122
- if (prop === "insertInto" || prop === "updateTable" || prop === "deleteFrom") return (...args) => {
162
+ const value = read(target, prop);
163
+ if (isMutationMethod(prop) && isFunction(value)) return (...args) => {
123
164
  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);
165
+ return wrapBuilder(value.apply(target, args), table, MUTATION_CHANGE_TYPE[prop], notify);
127
166
  };
128
- if (typeof value === "function") return value.bind(target);
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[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);
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 touchedTables) emitChange(table, "bulk");
188
+ for (const [table, changeTypes] of touched) for (const changeType of changeTypes) emitChange(table, changeType);
141
189
  return result;
142
190
  };
143
- if (typeof value === "function") return (...args) => {
191
+ return (...args) => {
144
192
  const result = value.apply(target, args);
145
- if (result && typeof result.execute === "function") return wrapTransactionBuilder(result, emitChange);
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
- function emitMutationEvent(table, changeType, result) {
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[prop];
161
- if (prop === "insertInto" || prop === "updateTable" || prop === "deleteFrom") return (...args) => {
212
+ const value = read(db, prop);
213
+ if (isMutationMethod(prop) && isFunction(value)) return (...args) => {
162
214
  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);
215
+ return wrapBuilder(value.apply(db, args), table, MUTATION_CHANGE_TYPE[prop], notify);
169
216
  };
170
- if (typeof value === "function") return value.bind(db);
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.2.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": "^26.1.1",
34
+ "@types/node": "^24.12.2",
35
35
  "bumpp": "^11.1.0",
36
36
  "kysely": "^0.29.5",
37
- "typescript": "^7.0.2",
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
  },