@navette-js/database 1.0.0-beta.3 → 1.0.0-beta.5

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/lib/index.d.ts CHANGED
@@ -7,6 +7,8 @@ export type ListQuery = {
7
7
  /** Global search: every word must appear in at least one of the searchable columns. */
8
8
  search?: string | null;
9
9
  searchColumns?: string[];
10
+ /** `false`: the search term is matched as a whole, instead of word by word. */
11
+ splitSearchTerms?: boolean;
10
12
  /** Per-column search: all conditions must match. */
11
13
  columnSearches?: Record<string, string>;
12
14
  /** Soft-deleted records: excluded by default, included, or only those. */
@@ -16,10 +18,14 @@ export type ListQuery = {
16
18
  direction: SortDirection;
17
19
  } | null;
18
20
  /** Table grouping: this sort is applied before `sort`. */
21
+ /** Rows are first ordered by their group: by `column`, or by `modify` (a custom order). */
19
22
  groupSort?: {
20
23
  column: string;
21
24
  direction: SortDirection;
25
+ modify?: (query: any, direction: SortDirection) => void;
22
26
  } | null;
27
+ /** Only these records (keys), e.g. the rows of the displayed page. */
28
+ keys?: string[];
23
29
  /** Extra server-side conditions (list tab, custom filter); never built from unvalidated browser input. */
24
30
  modifiers?: ((query: any) => void)[];
25
31
  page: number;
@@ -57,6 +63,8 @@ export interface DataAdapter<T = any> {
57
63
  count(): Promise<number>;
58
64
  paginate(query: ListQuery): Promise<Page<T>>;
59
65
  find(key: string | number): Promise<T | null>;
66
+ /** Like `find()`, soft-deleted records included (tables that show deleted records). Optional. */
67
+ findWithTrashed?(key: string | number): Promise<T | null>;
60
68
  create(data: Record<string, unknown>): Promise<T>;
61
69
  update(key: string | number, data: Record<string, unknown>): Promise<T | null>;
62
70
  delete(key: string | number): Promise<boolean>;
@@ -70,7 +78,17 @@ export interface DataAdapter<T = any> {
70
78
  deleteMany(keys: (string | number)[]): Promise<number>;
71
79
  /** True if another record already has this value (`unique` rule). */
72
80
  exists(column: string, value: unknown, exceptKey?: string | number | null): Promise<boolean>;
81
+ /** Runs `fn` in a database transaction (`panel.databaseTransactions()`); without it, `fn` runs as is. */
82
+ transaction?<T>(fn: () => Promise<T>): Promise<T>;
73
83
  }
84
+ /** The transaction open on this connection in the current async context (`transaction()`), or `null`. */
85
+ export declare function currentTransaction(knex?: Knex): Knex.Transaction | null;
86
+ /**
87
+ * Runs `fn` in a database transaction: committed when it resolves, rolled back when it throws. Every query made in the
88
+ * meantime through the connection (`createConnection()`, `sqlite()`: Objection models and `knex(table)` alike) joins it.
89
+ * Called inside an open transaction, `fn` simply joins it.
90
+ */
91
+ export declare function transaction<T>(knex: Knex | (() => Promise<T>), fn?: () => Promise<T>): Promise<T>;
74
92
  export declare function createConnection(config: Knex.Config): Knex;
75
93
  /** SQLite connection (better-sqlite3 driver), for development and demos. */
76
94
  export declare function sqlite(filename: string): Knex;
@@ -94,6 +112,10 @@ export declare class ObjectionAdapter<M extends Model> implements DataAdapter<M>
94
112
  * (the request's tenant; `null` means no filter). Set by the panel (`panel.tenant()`).
95
113
  */
96
114
  scopeToTenant(column: string, key: () => string | number | null): this;
115
+ /** A query of the model, in the open transaction if any. */
116
+ private query;
117
+ /** Runs `fn` in a transaction of the model's connection. */
118
+ transaction<T>(fn: () => Promise<T>): Promise<T>;
97
119
  private base;
98
120
  private now;
99
121
  private get keyColumn();
@@ -137,6 +159,7 @@ export declare class ObjectionAdapter<M extends Model> implements DataAdapter<M>
137
159
  private filtered;
138
160
  private finishPagination;
139
161
  find(key: string | number): Promise<M | null>;
162
+ findWithTrashed(key: string | number): Promise<M | null>;
140
163
  create(data: Record<string, unknown>): Promise<M>;
141
164
  update(key: string | number, data: Record<string, unknown>): Promise<M | null>;
142
165
  /** With soft deletes, the record is timestamped instead of removed. */
package/lib/index.js CHANGED
@@ -1,9 +1,48 @@
1
1
  // @navette-js/database — data access contract and its Knex + Objection adapter.
2
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
3
  import knexFactory from 'knex';
3
4
  import { Model } from 'objection';
4
5
  export { Model };
6
+ /** Transactions open in the current async context, by connection. */
7
+ const openTransactions = new AsyncLocalStorage();
8
+ const RAW = Symbol('navette.rawKnex');
9
+ const rawOf = (knex) => knex[RAW] ?? knex;
10
+ /** The transaction open on this connection in the current async context (`transaction()`), or `null`. */
11
+ export function currentTransaction(knex = Model.knex()) {
12
+ return openTransactions.getStore()?.get(rawOf(knex)) ?? null;
13
+ }
14
+ /**
15
+ * Runs `fn` in a database transaction: committed when it resolves, rolled back when it throws. Every query made in the
16
+ * meantime through the connection (`createConnection()`, `sqlite()`: Objection models and `knex(table)` alike) joins it.
17
+ * Called inside an open transaction, `fn` simply joins it.
18
+ */
19
+ export async function transaction(knex, fn) {
20
+ if (typeof fn !== 'function') {
21
+ fn = knex;
22
+ knex = Model.knex();
23
+ }
24
+ const raw = rawOf(knex);
25
+ const open = openTransactions.getStore();
26
+ if (open?.has(raw))
27
+ return fn();
28
+ return raw.transaction((trx) => openTransactions.run(new Map(open ?? []).set(raw, trx), fn));
29
+ }
30
+ /** The connection, routed to the transaction open in the current async context (`transaction()`), if any. */
31
+ function transactionAware(knex) {
32
+ const target = () => openTransactions.getStore()?.get(knex) ?? knex;
33
+ return new Proxy(knex, {
34
+ apply: (_target, thisArg, args) => Reflect.apply(target(), thisArg, args),
35
+ get: (_target, property) => {
36
+ if (property === RAW)
37
+ return knex;
38
+ const current = target();
39
+ const value = Reflect.get(current, property);
40
+ return typeof value === 'function' && current !== knex && property !== 'constructor' ? value.bind(current) : value;
41
+ },
42
+ });
43
+ }
5
44
  export function createConnection(config) {
6
- const knex = knexFactory(config);
45
+ const knex = transactionAware(knexFactory(config));
7
46
  Model.knex(knex);
8
47
  return knex;
9
48
  }
@@ -33,8 +72,12 @@ export class ObjectionAdapter {
33
72
  * (the request's tenant; `null` means no filter). Set by the panel (`panel.tenant()`).
34
73
  */
35
74
  scopeToTenant(column, key) { this.tenantScope = { column, key }; return this; }
75
+ /** A query of the model, in the open transaction if any. */
76
+ query() { return this.model.query(currentTransaction(this.model.knex()) ?? undefined); }
77
+ /** Runs `fn` in a transaction of the model's connection. */
78
+ transaction(fn) { return transaction(this.model.knex(), fn); }
36
79
  base(trashed = 'without') {
37
- const q = this.model.query();
80
+ const q = this.query();
38
81
  const tenantKey = this.tenantScope?.key() ?? null;
39
82
  if (tenantKey !== null)
40
83
  q.where(`${this.model.tableName}.${this.tenantScope.column}`, tenantKey);
@@ -127,8 +170,8 @@ export class ObjectionAdapter {
127
170
  if (!keys.length)
128
171
  return 0;
129
172
  if (this.softDeletes)
130
- return this.model.query().whereIn(this.keyColumn, keys).whereNull(this.softDeletes).patch({ [this.softDeletes]: this.now() });
131
- return this.model.query().delete().whereIn(this.keyColumn, keys);
173
+ return this.query().whereIn(this.keyColumn, keys).whereNull(this.softDeletes).patch({ [this.softDeletes]: this.now() });
174
+ return this.query().delete().whereIn(this.keyColumn, keys);
132
175
  }
133
176
  async paginate(query) {
134
177
  return this.finishPagination(this.filtered(query), query);
@@ -136,9 +179,12 @@ export class ObjectionAdapter {
136
179
  /** Base query with modifiers, global search and per-column searches applied. */
137
180
  filtered(query) {
138
181
  const q = this.base(query.trashed ?? 'without');
182
+ if (query.keys)
183
+ q.whereIn(`${this.model.tableName}.${this.keyColumn}`, query.keys.map(String).slice(0, 10000));
139
184
  for (const modifier of query.modifiers ?? [])
140
185
  modifier(q);
141
- const words = (query.search ?? '').trim().split(/\s+/).filter(Boolean);
186
+ const term = (query.search ?? '').trim();
187
+ const words = query.splitSearchTerms === false ? (term ? [term] : []) : term.split(/\s+/).filter(Boolean);
142
188
  const columns = query.searchColumns ?? [];
143
189
  if (words.length && columns.length) {
144
190
  // Each word must match at least one column, case-insensitively; LIKE wildcards in the input are escaped.
@@ -157,7 +203,9 @@ export class ObjectionAdapter {
157
203
  }
158
204
  async finishPagination(q, query) {
159
205
  const total = await q.clone().resultSize();
160
- if (query.groupSort)
206
+ if (query.groupSort?.modify)
207
+ query.groupSort.modify(q, query.groupSort.direction);
208
+ else if (query.groupSort)
161
209
  q.orderBy(query.groupSort.column, query.groupSort.direction);
162
210
  if (query.sort)
163
211
  q.orderBy(query.sort.column, query.sort.direction);
@@ -169,9 +217,10 @@ export class ObjectionAdapter {
169
217
  return { records, total, page, perPage, lastPage, from: records.length ? (page - 1) * perPage + 1 : null, to: records.length ? (page - 1) * perPage + records.length : null };
170
218
  }
171
219
  async find(key) { return (await this.base(this.findsTrashed ? 'with' : 'without').findById(key)) ?? null; }
220
+ async findWithTrashed(key) { return (await this.base('with').findById(key)) ?? null; }
172
221
  async create(data) {
173
222
  const tenantKey = this.tenantScope?.key() ?? null;
174
- return (await this.model.query().insertAndFetch((tenantKey !== null ? { ...data, [this.tenantScope.column]: tenantKey } : data)));
223
+ return (await this.query().insertAndFetch((tenantKey !== null ? { ...data, [this.tenantScope.column]: tenantKey } : data)));
175
224
  }
176
225
  async update(key, data) {
177
226
  // Records outside the current tenant cannot be updated, and the tenant column never changes.
@@ -180,24 +229,24 @@ export class ObjectionAdapter {
180
229
  return null;
181
230
  data = Object.fromEntries(Object.entries(data).filter(([column]) => column !== this.tenantScope.column));
182
231
  }
183
- return (await this.model.query().patchAndFetchById(key, data)) ?? null;
232
+ return (await this.query().patchAndFetchById(key, data)) ?? null;
184
233
  }
185
234
  /** With soft deletes, the record is timestamped instead of removed. */
186
235
  async delete(key) {
187
236
  if (this.softDeletes)
188
- return (await this.model.query().findById(key).whereNull(this.softDeletes).patch({ [this.softDeletes]: this.now() })) > 0;
189
- return (await this.model.query().deleteById(key)) > 0;
237
+ return (await this.query().findById(key).whereNull(this.softDeletes).patch({ [this.softDeletes]: this.now() })) > 0;
238
+ return (await this.query().deleteById(key)) > 0;
190
239
  }
191
240
  supportsSoftDeletes() { return this.softDeletes !== null; }
192
241
  isTrashed(record) { return this.softDeletes !== null && record?.[this.softDeletes] != null; }
193
242
  async restore(key) {
194
243
  if (!this.softDeletes)
195
244
  return false;
196
- return (await this.model.query().findById(key).whereNotNull(this.softDeletes).patch({ [this.softDeletes]: null })) > 0;
245
+ return (await this.query().findById(key).whereNotNull(this.softDeletes).patch({ [this.softDeletes]: null })) > 0;
197
246
  }
198
- async forceDelete(key) { return (await this.model.query().deleteById(key)) > 0; }
247
+ async forceDelete(key) { return (await this.query().deleteById(key)) > 0; }
199
248
  async exists(column, value, exceptKey = null) {
200
- const q = this.model.query().where(column, value);
249
+ const q = this.query().where(column, value);
201
250
  if (exceptKey !== null)
202
251
  q.whereNot(this.keyColumn, exceptKey);
203
252
  return (await q.resultSize()) > 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@navette-js/database",
3
- "version": "1.0.0-beta.3",
3
+ "version": "1.0.0-beta.5",
4
4
  "description": "Data adapters (Objection/Knex) for Navette panels.",
5
5
  "license": "MIT",
6
6
  "type": "module",