@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 +23 -0
- package/lib/index.js +62 -13
- package/package.json +1 -1
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.
|
|
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.
|
|
131
|
-
return this.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
189
|
-
return (await this.
|
|
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.
|
|
245
|
+
return (await this.query().findById(key).whereNotNull(this.softDeletes).patch({ [this.softDeletes]: null })) > 0;
|
|
197
246
|
}
|
|
198
|
-
async forceDelete(key) { return (await this.
|
|
247
|
+
async forceDelete(key) { return (await this.query().deleteById(key)) > 0; }
|
|
199
248
|
async exists(column, value, exceptKey = null) {
|
|
200
|
-
const q = this.
|
|
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;
|