@navette-js/database 0.0.0-stage → 1.0.0-beta.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NavetteJS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,9 @@
1
- # Temporary Holding Version
1
+ # @navette-js/database
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Data adapters (Objection/Knex) for Navette panels.
4
+
5
+ Part of [Navette](https://github.com/NavetteJS/navette), admin panels for Node.js. Start a project with `npm create navette@latest`, or install the framework with `npm install @navette-js/panels`.
6
+
7
+ **Beta:** the API may still change between beta releases.
8
+
9
+ License: MIT. Third-party notices: see `THIRD-PARTY-NOTICES.md` where present.
package/lib/index.d.ts ADDED
@@ -0,0 +1,149 @@
1
+ import { type Knex } from 'knex';
2
+ import { Model, type ModelClass, type QueryBuilder } from 'objection';
3
+ export { Model };
4
+ export type { Knex };
5
+ export type SortDirection = 'asc' | 'desc';
6
+ export type ListQuery = {
7
+ /** Global search: every word must appear in at least one of the searchable columns. */
8
+ search?: string | null;
9
+ searchColumns?: string[];
10
+ /** Per-column search: all conditions must match. */
11
+ columnSearches?: Record<string, string>;
12
+ /** Soft-deleted records: excluded by default, included, or only those. */
13
+ trashed?: 'without' | 'with' | 'only';
14
+ sort?: {
15
+ column: string;
16
+ direction: SortDirection;
17
+ } | null;
18
+ /** Table grouping: this sort is applied before `sort`. */
19
+ groupSort?: {
20
+ column: string;
21
+ direction: SortDirection;
22
+ } | null;
23
+ /** Extra server-side conditions (list tab, custom filter); never built from unvalidated browser input. */
24
+ modifiers?: ((query: any) => void)[];
25
+ page: number;
26
+ perPage: number;
27
+ };
28
+ export type Page<T> = {
29
+ records: T[];
30
+ total: number;
31
+ page: number;
32
+ perPage: number;
33
+ lastPage: number;
34
+ from: number | null;
35
+ to: number | null;
36
+ };
37
+ /** What the framework needs to list, read and write records, independently of the ORM. */
38
+ export interface DataAdapter<T = any> {
39
+ getKey(record: T): string;
40
+ /** Filters keys received from the browser: only those within the adapter's scope are returned. */
41
+ keysInScope(keys: (string | number)[]): Promise<string[]>;
42
+ sum(query: Omit<ListQuery, 'page' | 'perPage'>, columns: string[]): Promise<Record<string, number>>;
43
+ /** Aggregates over the records of a list query: count, and column sums, averages, minimums and maximums. */
44
+ aggregate(query: Omit<ListQuery, 'page' | 'perPage'>, columns?: {
45
+ sum?: string[];
46
+ avg?: string[];
47
+ min?: string[];
48
+ max?: string[];
49
+ }): Promise<{
50
+ count: number;
51
+ sum: Record<string, number>;
52
+ avg: Record<string, number>;
53
+ min: Record<string, unknown>;
54
+ max: Record<string, unknown>;
55
+ }>;
56
+ getLabel(): string;
57
+ count(): Promise<number>;
58
+ paginate(query: ListQuery): Promise<Page<T>>;
59
+ find(key: string | number): Promise<T | null>;
60
+ create(data: Record<string, unknown>): Promise<T>;
61
+ update(key: string | number, data: Record<string, unknown>): Promise<T | null>;
62
+ delete(key: string | number): Promise<boolean>;
63
+ /** Soft deletes: support, record state, restore, permanent delete. */
64
+ supportsSoftDeletes(): boolean;
65
+ isTrashed(record: T): boolean;
66
+ restore(key: string | number): Promise<boolean>;
67
+ forceDelete(key: string | number): Promise<boolean>;
68
+ /** Keys of every record matching the query (unpaginated), for "select all". */
69
+ listKeys(query: Omit<ListQuery, 'page' | 'perPage'>): Promise<string[]>;
70
+ deleteMany(keys: (string | number)[]): Promise<number>;
71
+ /** True if another record already has this value (`unique` rule). */
72
+ exists(column: string, value: unknown, exceptKey?: string | number | null): Promise<boolean>;
73
+ }
74
+ export declare function createConnection(config: Knex.Config): Knex;
75
+ /** SQLite connection (better-sqlite3 driver), for development and demos. */
76
+ export declare function sqlite(filename: string): Knex;
77
+ export declare class ObjectionAdapter<M extends Model> implements DataAdapter<M> {
78
+ private model;
79
+ private modify;
80
+ private softDeletes;
81
+ private findsTrashed;
82
+ /**
83
+ * `modify`: base query constraints applied to every read. `softDeletes`: deletion timestamp column.
84
+ * `findsTrashed`: `find()` also returns soft-deleted records.
85
+ */
86
+ constructor(model: ModelClass<M>, options?: {
87
+ modify?: (query: QueryBuilder<M, M[]>) => void;
88
+ softDeletes?: string;
89
+ findsTrashed?: boolean;
90
+ });
91
+ private tenantScope;
92
+ /**
93
+ * Scopes the adapter to a tenant: every read filters on `column` and every create fills it, with the key returned by `key()`
94
+ * (the request's tenant; `null` means no filter). Set by the panel (`panel.tenant()`).
95
+ */
96
+ scopeToTenant(column: string, key: () => string | number | null): this;
97
+ private base;
98
+ private now;
99
+ private get keyColumn();
100
+ getKey(record: M): string;
101
+ getLabel(): string;
102
+ count(): Promise<number>;
103
+ listKeys(query: Omit<ListQuery, 'page' | 'perPage'>): Promise<string[]>;
104
+ /**
105
+ * Keeps the browser-supplied keys that fall within the adapter's scope (`modify` and tenant; trashed records included),
106
+ * so a forged selection cannot target a record the table does not show.
107
+ */
108
+ keysInScope(keys: (string | number)[]): Promise<string[]>;
109
+ /** Count and column aggregates over every record of the query (for summary rows). */
110
+ aggregate(query: Omit<ListQuery, 'page' | 'perPage'>, columns?: {
111
+ sum?: string[];
112
+ avg?: string[];
113
+ min?: string[];
114
+ max?: string[];
115
+ }): Promise<{
116
+ count: number;
117
+ sum: Record<string, number>;
118
+ avg: Record<string, number>;
119
+ min: Record<string, unknown>;
120
+ max: Record<string, unknown>;
121
+ }>;
122
+ /**
123
+ * Monthly trend over the filtered query (count, or average of `average`): one value per month from `start` to `end`,
124
+ * 0 for months without records. The date SQL targets SQLite; dates are UTC, formatted `Y-m-d H:i:s`.
125
+ */
126
+ trendPerMonth(query: Omit<ListQuery, 'page' | 'perPage'>, options: {
127
+ start: Date;
128
+ end: Date;
129
+ dateColumn?: string;
130
+ average?: string;
131
+ modify?: (q: any) => void;
132
+ }): Promise<number[]>;
133
+ sum(query: Omit<ListQuery, 'page' | 'perPage'>, columns: string[]): Promise<Record<string, number>>;
134
+ deleteMany(keys: (string | number)[]): Promise<number>;
135
+ paginate(query: ListQuery): Promise<Page<M>>;
136
+ /** Base query with modifiers, global search and per-column searches applied. */
137
+ private filtered;
138
+ private finishPagination;
139
+ find(key: string | number): Promise<M | null>;
140
+ create(data: Record<string, unknown>): Promise<M>;
141
+ update(key: string | number, data: Record<string, unknown>): Promise<M | null>;
142
+ /** With soft deletes, the record is timestamped instead of removed. */
143
+ delete(key: string | number): Promise<boolean>;
144
+ supportsSoftDeletes(): boolean;
145
+ isTrashed(record: M): boolean;
146
+ restore(key: string | number): Promise<boolean>;
147
+ forceDelete(key: string | number): Promise<boolean>;
148
+ exists(column: string, value: unknown, exceptKey?: string | number | null): Promise<boolean>;
149
+ }
package/lib/index.js ADDED
@@ -0,0 +1,205 @@
1
+ // @navette-js/database — data access contract and its Knex + Objection adapter.
2
+ import knexFactory from 'knex';
3
+ import { Model } from 'objection';
4
+ export { Model };
5
+ export function createConnection(config) {
6
+ const knex = knexFactory(config);
7
+ Model.knex(knex);
8
+ return knex;
9
+ }
10
+ /** SQLite connection (better-sqlite3 driver), for development and demos. */
11
+ export function sqlite(filename) {
12
+ return createConnection({ client: 'better-sqlite3', connection: { filename }, useNullAsDefault: true });
13
+ }
14
+ const escapeLike = (s) => s.replace(/[\\%_]/g, (c) => '\\' + c);
15
+ export class ObjectionAdapter {
16
+ model;
17
+ modify;
18
+ softDeletes;
19
+ findsTrashed;
20
+ /**
21
+ * `modify`: base query constraints applied to every read. `softDeletes`: deletion timestamp column.
22
+ * `findsTrashed`: `find()` also returns soft-deleted records.
23
+ */
24
+ constructor(model, options = {}) {
25
+ this.model = model;
26
+ this.modify = options.modify ?? null;
27
+ this.softDeletes = options.softDeletes ?? null;
28
+ this.findsTrashed = options.findsTrashed === true;
29
+ }
30
+ tenantScope = null;
31
+ /**
32
+ * Scopes the adapter to a tenant: every read filters on `column` and every create fills it, with the key returned by `key()`
33
+ * (the request's tenant; `null` means no filter). Set by the panel (`panel.tenant()`).
34
+ */
35
+ scopeToTenant(column, key) { this.tenantScope = { column, key }; return this; }
36
+ base(trashed = 'without') {
37
+ const q = this.model.query();
38
+ const tenantKey = this.tenantScope?.key() ?? null;
39
+ if (tenantKey !== null)
40
+ q.where(`${this.model.tableName}.${this.tenantScope.column}`, tenantKey);
41
+ if (this.modify)
42
+ this.modify(q);
43
+ if (this.softDeletes) {
44
+ if (trashed === 'without')
45
+ q.whereNull(this.softDeletes);
46
+ else if (trashed === 'only')
47
+ q.whereNotNull(this.softDeletes);
48
+ }
49
+ return q;
50
+ }
51
+ now() { return new Date().toISOString().slice(0, 19).replace('T', ' '); }
52
+ get keyColumn() { return String(this.model.idColumn); }
53
+ getKey(record) { return String(record[this.keyColumn]); }
54
+ getLabel() { return this.model.tableName; }
55
+ async count() { return this.base().resultSize(); }
56
+ async listKeys(query) {
57
+ const rows = await this.filtered(query).select(this.keyColumn);
58
+ return rows.map((r) => this.getKey(r));
59
+ }
60
+ /**
61
+ * Keeps the browser-supplied keys that fall within the adapter's scope (`modify` and tenant; trashed records included),
62
+ * so a forged selection cannot target a record the table does not show.
63
+ */
64
+ async keysInScope(keys) {
65
+ if (!keys.length)
66
+ return [];
67
+ const rows = await this.base('with').whereIn(`${this.model.tableName}.${this.keyColumn}`, keys.map(String).slice(0, 10000));
68
+ return rows.map((r) => this.getKey(r));
69
+ }
70
+ /** Count and column aggregates over every record of the query (for summary rows). */
71
+ async aggregate(query, columns = {}) {
72
+ const q = this.model.knex().from(this.filtered(query).toKnexQuery().as('t')).count({ __count: '*' });
73
+ for (const column of columns.sum ?? [])
74
+ q.sum({ [`sum_${column}`]: `t.${column}` });
75
+ for (const column of columns.avg ?? [])
76
+ q.avg({ [`avg_${column}`]: `t.${column}` });
77
+ for (const column of columns.min ?? [])
78
+ q.min({ [`min_${column}`]: `t.${column}` });
79
+ for (const column of columns.max ?? [])
80
+ q.max({ [`max_${column}`]: `t.${column}` });
81
+ const row = ((await q.first()) ?? {});
82
+ return {
83
+ count: Number(row.__count ?? 0),
84
+ sum: Object.fromEntries((columns.sum ?? []).map((column) => [column, Number(row[`sum_${column}`] ?? 0)])),
85
+ avg: Object.fromEntries((columns.avg ?? []).map((column) => [column, Number(row[`avg_${column}`] ?? 0)])),
86
+ min: Object.fromEntries((columns.min ?? []).map((column) => [column, row[`min_${column}`] ?? null])),
87
+ max: Object.fromEntries((columns.max ?? []).map((column) => [column, row[`max_${column}`] ?? null])),
88
+ };
89
+ }
90
+ /**
91
+ * Monthly trend over the filtered query (count, or average of `average`): one value per month from `start` to `end`,
92
+ * 0 for months without records. The date SQL targets SQLite; dates are UTC, formatted `Y-m-d H:i:s`.
93
+ */
94
+ async trendPerMonth(query, options) {
95
+ const pad = (n) => String(n).padStart(2, '0');
96
+ const stamp = (d) => `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
97
+ const column = options.dateColumn ?? 'created_at';
98
+ const q = this.model.knex().from(this.filtered(query).toKnexQuery().as('t')).select(this.model.knex().raw(`strftime('%Y-%m', t.??) as month`, [column])).whereBetween(`t.${column}`, [stamp(options.start), stamp(options.end)]).groupBy('month');
99
+ if (options.average)
100
+ q.avg({ aggregate: `t.${options.average}` });
101
+ else
102
+ q.count({ aggregate: '*' });
103
+ options.modify?.(q);
104
+ const rows = new Map((await q).map((row) => [row.month, Number(row.aggregate ?? 0)]));
105
+ const values = [];
106
+ // One step per calendar month from `start`, up to and including `end`.
107
+ for (let i = 0;; i++) {
108
+ const month = new Date(Date.UTC(options.start.getUTCFullYear(), options.start.getUTCMonth() + i, 1));
109
+ if (month.getTime() > options.end.getTime())
110
+ break;
111
+ values.push(rows.get(`${month.getUTCFullYear()}-${pad(month.getUTCMonth() + 1)}`) ?? 0);
112
+ }
113
+ return values;
114
+ }
115
+ async sum(query, columns) {
116
+ if (!columns.length)
117
+ return {};
118
+ // The filtered query is used as a subquery so its computed (aliased) columns remain usable by the search.
119
+ const q = this.model.knex().from(this.filtered(query).toKnexQuery().as('t'));
120
+ for (const column of columns)
121
+ q.sum({ [column]: `t.${column}` });
122
+ const row = ((await q.first()) ?? {});
123
+ return Object.fromEntries(columns.map((column) => [column, Number(row[column] ?? 0)]));
124
+ }
125
+ async deleteMany(keys) {
126
+ keys = await this.keysInScope(keys);
127
+ if (!keys.length)
128
+ return 0;
129
+ 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);
132
+ }
133
+ async paginate(query) {
134
+ return this.finishPagination(this.filtered(query), query);
135
+ }
136
+ /** Base query with modifiers, global search and per-column searches applied. */
137
+ filtered(query) {
138
+ const q = this.base(query.trashed ?? 'without');
139
+ for (const modifier of query.modifiers ?? [])
140
+ modifier(q);
141
+ const words = (query.search ?? '').trim().split(/\s+/).filter(Boolean);
142
+ const columns = query.searchColumns ?? [];
143
+ if (words.length && columns.length) {
144
+ // Each word must match at least one column, case-insensitively; LIKE wildcards in the input are escaped.
145
+ for (const word of words) {
146
+ q.where((sub) => {
147
+ for (const column of columns)
148
+ sub.orWhereRaw("lower(??) like ? escape '\\'", [column, `%${escapeLike(word.toLowerCase())}%`]);
149
+ });
150
+ }
151
+ }
152
+ for (const [column, value] of Object.entries(query.columnSearches ?? {})) {
153
+ for (const word of String(value ?? '').trim().split(/\s+/).filter(Boolean))
154
+ q.whereRaw("lower(??) like ? escape '\\'", [column, `%${escapeLike(word.toLowerCase())}%`]);
155
+ }
156
+ return q;
157
+ }
158
+ async finishPagination(q, query) {
159
+ const total = await q.clone().resultSize();
160
+ if (query.groupSort)
161
+ q.orderBy(query.groupSort.column, query.groupSort.direction);
162
+ if (query.sort)
163
+ q.orderBy(query.sort.column, query.sort.direction);
164
+ q.orderBy(this.keyColumn, 'asc');
165
+ const perPage = Math.max(1, query.perPage);
166
+ const lastPage = Math.max(1, Math.ceil(total / perPage));
167
+ const page = Math.min(Math.max(1, query.page), lastPage);
168
+ const records = await q.offset((page - 1) * perPage).limit(perPage);
169
+ return { records, total, page, perPage, lastPage, from: records.length ? (page - 1) * perPage + 1 : null, to: records.length ? (page - 1) * perPage + records.length : null };
170
+ }
171
+ async find(key) { return (await this.base(this.findsTrashed ? 'with' : 'without').findById(key)) ?? null; }
172
+ async create(data) {
173
+ const tenantKey = this.tenantScope?.key() ?? null;
174
+ return (await this.model.query().insertAndFetch((tenantKey !== null ? { ...data, [this.tenantScope.column]: tenantKey } : data)));
175
+ }
176
+ async update(key, data) {
177
+ // Records outside the current tenant cannot be updated, and the tenant column never changes.
178
+ if (this.tenantScope && this.tenantScope.key() !== null) {
179
+ if (!(await this.base('with').findById(key)))
180
+ return null;
181
+ data = Object.fromEntries(Object.entries(data).filter(([column]) => column !== this.tenantScope.column));
182
+ }
183
+ return (await this.model.query().patchAndFetchById(key, data)) ?? null;
184
+ }
185
+ /** With soft deletes, the record is timestamped instead of removed. */
186
+ async delete(key) {
187
+ 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;
190
+ }
191
+ supportsSoftDeletes() { return this.softDeletes !== null; }
192
+ isTrashed(record) { return this.softDeletes !== null && record?.[this.softDeletes] != null; }
193
+ async restore(key) {
194
+ if (!this.softDeletes)
195
+ return false;
196
+ return (await this.model.query().findById(key).whereNotNull(this.softDeletes).patch({ [this.softDeletes]: null })) > 0;
197
+ }
198
+ async forceDelete(key) { return (await this.model.query().deleteById(key)) > 0; }
199
+ async exists(column, value, exceptKey = null) {
200
+ const q = this.model.query().where(column, value);
201
+ if (exceptKey !== null)
202
+ q.whereNot(this.keyColumn, exceptKey);
203
+ return (await q.resultSize()) > 0;
204
+ }
205
+ }
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
2
  "name": "@navette-js/database",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0-beta.2",
4
+ "description": "Data adapters (Objection/Knex) for Navette panels.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./lib/index.d.ts",
10
+ "default": "./lib/index.js"
11
+ }
12
+ },
13
+ "types": "./lib/index.d.ts",
14
+ "files": [
15
+ "lib",
16
+ "LICENSE",
17
+ "README.md"
18
+ ],
19
+ "engines": {
20
+ "node": ">=24"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/NavetteJS/navette.git",
25
+ "directory": "packages/database"
26
+ },
27
+ "homepage": "https://github.com/NavetteJS/navette#readme",
28
+ "bugs": {
29
+ "url": "https://github.com/NavetteJS/navette/issues"
30
+ },
31
+ "keywords": [
32
+ "navette",
33
+ "admin",
34
+ "panel",
35
+ "livewire",
36
+ "express",
37
+ "node"
38
+ ],
39
+ "dependencies": {
40
+ "knex": "^3.1.0",
41
+ "objection": "^3.1.5",
42
+ "better-sqlite3": "^12.0.0"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public",
46
+ "tag": "beta"
47
+ }
48
+ }