tina4-nodejs 3.13.148 → 3.13.150
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/CLAUDE.md +4 -3
- package/package.json +1 -1
- package/packages/cli/dist/bin.js +10898 -9844
- package/packages/cli/src/commands/generate.ts +184 -25
- package/packages/core/dist/index.js +10864 -9810
- package/packages/core/public/bundled-component-provenance.json +2 -2
- package/packages/core/public/js/tina4-dev-admin-split.html +202 -0
- package/packages/core/public/js/tina4-dev-admin.min.js +147 -138
- package/packages/core/src/devAdmin.ts +105 -1
- package/packages/core/src/index.ts +3 -0
- package/packages/core/src/messenger.ts +57 -1
- package/packages/core/src/metrics.ts +5 -1
- package/packages/core/src/plan.ts +1 -1
- package/packages/core/src/router.ts +11 -1
- package/packages/core/src/server.ts +5 -2
- package/packages/core/templates/crud/form.twig +26 -0
- package/packages/core/templates/crud/modals.twig +31 -0
- package/packages/core/templates/crud/page.twig +401 -0
- package/packages/core/templates/crud/table.twig +69 -0
- package/packages/frond/dist/index.js +9 -1
- package/packages/frond/src/engine.ts +9 -0
- package/packages/frond/src/index.ts +1 -1
- package/packages/orm/dist/index.js +1283 -219
- package/packages/orm/src/adapters/mongodb.ts +209 -34
- package/packages/orm/src/autoCrud.ts +9 -0
- package/packages/orm/src/crud.ts +273 -0
- package/packages/orm/src/crudHelpers.ts +194 -0
- package/packages/orm/src/crudRender.ts +202 -0
- package/packages/orm/src/crudTable.ts +171 -0
- package/packages/orm/src/index.ts +7 -1
- package/packages/orm/src/migration.ts +167 -4
- package/packages/orm/src/query.ts +99 -40
- package/packages/orm/src/sqlTranslator.ts +8 -1
- package/packages/orm/src/types.ts +19 -0
- package/types/core/src/devAdmin.d.ts +23 -0
- package/types/core/src/messenger.d.ts +4 -0
- package/types/core/src/router.d.ts +0 -5
- package/types/frond/src/engine.d.ts +6 -0
- package/types/frond/src/index.d.ts +1 -1
- package/types/orm/src/crud.d.ts +82 -0
- package/types/orm/src/crudHelpers.d.ts +63 -0
- package/types/orm/src/crudRender.d.ts +38 -0
- package/types/orm/src/crudTable.d.ts +45 -0
- package/types/orm/src/index.d.ts +3 -1
- package/types/orm/src/migration.d.ts +0 -18
- package/types/orm/src/query.d.ts +0 -12
- package/types/orm/src/types.d.ts +19 -0
|
@@ -64,11 +64,114 @@ function isMatchAllWhere(where: string | null | undefined): boolean {
|
|
|
64
64
|
return where != null && MATCH_ALL_WHERE.test(where);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
// ── Non-backtracking SQL scanning helpers (ReDoS-safe) ──────────────
|
|
68
|
+
// The SQL dispatchers below parse with plain string operations (indexOf /
|
|
69
|
+
// slice / single linear scan) instead of regexes with adjacent variable-length
|
|
70
|
+
// groups (`\s+(.*?)\s+`), which CodeQL correctly flags as polynomial ReDoS.
|
|
71
|
+
// This mirrors the Ruby master's string-based SQL parsing. Behaviour is
|
|
72
|
+
// identical to the previous regexes on the ORM-generated SQL these see.
|
|
73
|
+
|
|
74
|
+
const QUOTE_CHARS = "\"'";
|
|
75
|
+
|
|
76
|
+
function isSqlWs(ch: string): boolean {
|
|
77
|
+
return ch === " " || ch === "\t" || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isWordChar(ch: string): boolean {
|
|
81
|
+
return (ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z") || (ch >= "0" && ch <= "9") || ch === "_";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** True when sql[start..end) is empty or all whitespace. */
|
|
85
|
+
function onlyWs(sql: string, start: number, end: number): boolean {
|
|
86
|
+
for (let i = start; i < end; i++) if (!isSqlWs(sql[i])) return false;
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Index of a whitespace-delimited keyword (matched case-insensitively against
|
|
92
|
+
* `upper`, the upper-cased sql) at or after `from`, or -1. Plain indexOf scan —
|
|
93
|
+
* no backtracking.
|
|
94
|
+
*/
|
|
95
|
+
function keywordAt(upper: string, keyword: string, from: number): number {
|
|
96
|
+
let at = upper.indexOf(keyword, from);
|
|
97
|
+
while (at >= 0) {
|
|
98
|
+
const end = at + keyword.length;
|
|
99
|
+
const okBefore = at === 0 || isSqlWs(upper[at - 1]);
|
|
100
|
+
const okAfter = end >= upper.length || isSqlWs(upper[end]);
|
|
101
|
+
if (okBefore && okAfter) return at;
|
|
102
|
+
at = upper.indexOf(keyword, at + 1);
|
|
103
|
+
}
|
|
104
|
+
return -1;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The lowest index among `positions` that is >= 0, else `fallback`. */
|
|
108
|
+
function earliest(positions: number[], fallback: number): number {
|
|
109
|
+
let min = fallback;
|
|
110
|
+
for (const p of positions) if (p >= 0 && p < min) min = p;
|
|
111
|
+
return min;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Read an optionally-quoted identifier (a \w+ run) at/after `from`; [name, end] or null. */
|
|
115
|
+
function readIdentifier(sql: string, from: number): [string, number] | null {
|
|
116
|
+
let i = from;
|
|
117
|
+
while (i < sql.length && isSqlWs(sql[i])) i++;
|
|
118
|
+
if (i < sql.length && QUOTE_CHARS.includes(sql[i])) i++;
|
|
119
|
+
const start = i;
|
|
120
|
+
while (i < sql.length && isWordChar(sql[i])) i++;
|
|
121
|
+
if (i === start) return null;
|
|
122
|
+
const name = sql.slice(start, i);
|
|
123
|
+
if (i < sql.length && QUOTE_CHARS.includes(sql[i])) i++;
|
|
124
|
+
return [name, i];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Index just past `keyword` (whitespace-delimited, case-insensitive) when it is
|
|
129
|
+
* the next token at/after `from` with only whitespace in between, else -1. Folds
|
|
130
|
+
* the recurring "find keyword + require all-whitespace gap" guard.
|
|
131
|
+
*/
|
|
132
|
+
function keywordGapEnd(upper: string, sql: string, keyword: string, from: number): number {
|
|
133
|
+
const at = keywordAt(upper, keyword, from);
|
|
134
|
+
if (at < 0 || !onlyWs(sql, from, at)) return -1;
|
|
135
|
+
return at + keyword.length;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Strip one leading and one trailing quote (matches /^["']|["']$/g). */
|
|
139
|
+
function stripQuotes(value: string): string {
|
|
140
|
+
let result = value;
|
|
141
|
+
if (result.length > 0 && (result[0] === '"' || result[0] === "'")) result = result.slice(1);
|
|
142
|
+
const last = result.length - 1;
|
|
143
|
+
if (last >= 0 && (result[last] === '"' || result[last] === "'")) result = result.slice(0, last);
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Split a WHERE clause on whitespace-delimited AND (case-insensitive), linearly.
|
|
149
|
+
* Each part is trimmed by the caller, so the surrounding whitespace that the old
|
|
150
|
+
* /\s+AND\s+/ delimiter consumed is dropped there too — identical result.
|
|
151
|
+
*/
|
|
152
|
+
function splitOnAnd(where: string): string[] {
|
|
153
|
+
const parts: string[] = [];
|
|
154
|
+
const lower = where.toLowerCase();
|
|
155
|
+
let segStart = 0;
|
|
156
|
+
let i = 0;
|
|
157
|
+
while (i < where.length) {
|
|
158
|
+
if (lower.startsWith("and", i) && i > 0 && isSqlWs(where[i - 1]) && i + 3 < where.length && isSqlWs(where[i + 3])) {
|
|
159
|
+
parts.push(where.slice(segStart, i));
|
|
160
|
+
i += 3;
|
|
161
|
+
segStart = i;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
i++;
|
|
165
|
+
}
|
|
166
|
+
parts.push(where.slice(segStart));
|
|
167
|
+
return parts;
|
|
168
|
+
}
|
|
169
|
+
|
|
67
170
|
/** Convert SQL WHERE clause tokens into a MongoDB filter object. */
|
|
68
171
|
function parseWhereClause(where: string, params: unknown[], paramOffset = 0): { filter: Record<string, unknown>; consumed: number } {
|
|
69
172
|
const filter: Record<string, unknown> = {};
|
|
70
|
-
// Simple key = ? / key = 'value' patterns separated by AND
|
|
71
|
-
const parts = where
|
|
173
|
+
// Simple key = ? / key = 'value' patterns separated by AND (linear split).
|
|
174
|
+
const parts = splitOnAnd(where);
|
|
72
175
|
let paramIndex = paramOffset;
|
|
73
176
|
|
|
74
177
|
for (const part of parts) {
|
|
@@ -191,27 +294,70 @@ function requireWriteFilter(filter: Record<string, unknown> | undefined, operati
|
|
|
191
294
|
}
|
|
192
295
|
}
|
|
193
296
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
if (!match) return null;
|
|
197
|
-
const [, cols, collection, whereClause, orderBy, limitStr, skipStr] = match;
|
|
297
|
+
/** Build a MongoDB projection document from a SELECT column list. */
|
|
298
|
+
function buildProjection(cols: string): Record<string, unknown> {
|
|
198
299
|
const projection: Record<string, unknown> = {};
|
|
199
|
-
if (cols
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}
|
|
300
|
+
if (!cols || cols.trim() === "*") return projection;
|
|
301
|
+
for (const col of cols.split(",")) {
|
|
302
|
+
const name = stripQuotes(col.trim());
|
|
303
|
+
if (name && name !== "*") projection[name] = 1;
|
|
204
304
|
}
|
|
205
|
-
|
|
305
|
+
return projection;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Remove a trailing whitespace-separated ASC/DESC (case-insensitive). */
|
|
309
|
+
function stripTrailingDirection(part: string): string {
|
|
310
|
+
const lower = part.toLowerCase();
|
|
311
|
+
let suffix = 0;
|
|
312
|
+
if (lower.endsWith("asc")) suffix = 3;
|
|
313
|
+
else if (lower.endsWith("desc")) suffix = 4;
|
|
314
|
+
else return part;
|
|
315
|
+
const beforeIdx = part.length - suffix - 1;
|
|
316
|
+
if (beforeIdx < 0 || !isSqlWs(part[beforeIdx])) return part;
|
|
317
|
+
let i = beforeIdx;
|
|
318
|
+
while (i >= 0 && isSqlWs(part[i])) i--;
|
|
319
|
+
return part.slice(0, i + 1);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Build a MongoDB sort document from an ORDER BY clause. */
|
|
323
|
+
function parseOrderBy(orderBy: string): Record<string, 1 | -1> {
|
|
206
324
|
const sort: Record<string, 1 | -1> = {};
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
sort[col] = desc ? -1 : 1;
|
|
213
|
-
}
|
|
325
|
+
for (const rawPart of orderBy.split(",")) {
|
|
326
|
+
const trimmed = rawPart.trim();
|
|
327
|
+
const desc = trimmed.toLowerCase().endsWith("desc");
|
|
328
|
+
const col = stripQuotes(stripTrailingDirection(trimmed)).trim();
|
|
329
|
+
sort[col] = desc ? -1 : 1;
|
|
214
330
|
}
|
|
331
|
+
return sort;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function parseSelect(sql: string, params: unknown[]): MongoOperation | null {
|
|
335
|
+
// Plain string scan — SELECT <cols> FROM <coll> [WHERE ..][ORDER BY ..][LIMIT n][OFFSET n].
|
|
336
|
+
const upper = sql.toUpperCase();
|
|
337
|
+
if (!(upper.startsWith("SELECT") && (sql.length === 6 || isSqlWs(sql[6])))) return null;
|
|
338
|
+
const fromAt = keywordAt(upper, "FROM", 6);
|
|
339
|
+
if (fromAt < 0) return null;
|
|
340
|
+
const cols = sql.slice(6, fromAt).trim();
|
|
341
|
+
const ident = readIdentifier(sql, fromAt + 4);
|
|
342
|
+
if (!ident) return null;
|
|
343
|
+
const [collection, afterColl] = ident;
|
|
344
|
+
|
|
345
|
+
const whereAt = keywordAt(upper, "WHERE", afterColl);
|
|
346
|
+
const orderAt = keywordAt(upper, "ORDER BY", afterColl);
|
|
347
|
+
const limitAt = keywordAt(upper, "LIMIT", afterColl);
|
|
348
|
+
const offsetAt = keywordAt(upper, "OFFSET", afterColl);
|
|
349
|
+
const end = sql.length;
|
|
350
|
+
|
|
351
|
+
const whereClause = whereAt < 0 ? undefined
|
|
352
|
+
: sql.slice(whereAt + 5, earliest([orderAt, limitAt, offsetAt], end)).trim();
|
|
353
|
+
const orderBy = orderAt < 0 ? undefined
|
|
354
|
+
: sql.slice(orderAt + 8, earliest([limitAt, offsetAt], end)).trim();
|
|
355
|
+
const limitStr = limitAt < 0 ? undefined
|
|
356
|
+
: sql.slice(limitAt + 5, earliest([offsetAt], end)).trim();
|
|
357
|
+
const skipStr = offsetAt < 0 ? undefined : sql.slice(offsetAt + 6).trim();
|
|
358
|
+
|
|
359
|
+
const projection = buildProjection(cols);
|
|
360
|
+
const filter = whereClause ? parseWhereClause(whereClause, params).filter : {};
|
|
215
361
|
return {
|
|
216
362
|
type: "find",
|
|
217
363
|
collection,
|
|
@@ -219,7 +365,7 @@ function parseSelect(sql: string, params: unknown[]): MongoOperation | null {
|
|
|
219
365
|
projection: Object.keys(projection).length > 0 ? projection : undefined,
|
|
220
366
|
limit: limitStr ? parseInt(limitStr, 10) : undefined,
|
|
221
367
|
skip: skipStr ? parseInt(skipStr, 10) : undefined,
|
|
222
|
-
sort: orderBy ?
|
|
368
|
+
sort: orderBy ? parseOrderBy(orderBy) : undefined,
|
|
223
369
|
};
|
|
224
370
|
}
|
|
225
371
|
|
|
@@ -258,21 +404,36 @@ function parseSetClause(setClause: string, params: unknown[]): { document: Recor
|
|
|
258
404
|
}
|
|
259
405
|
|
|
260
406
|
function parseUpdate(sql: string, params: unknown[]): MongoOperation | null {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
407
|
+
// Plain string scan — UPDATE <coll> SET <assignments> [WHERE ..].
|
|
408
|
+
const upper = sql.toUpperCase();
|
|
409
|
+
if (!(upper.startsWith("UPDATE") && sql.length > 6 && isSqlWs(sql[6]))) return null;
|
|
410
|
+
const ident = readIdentifier(sql, 6);
|
|
411
|
+
if (!ident) return null;
|
|
412
|
+
const [collection, afterColl] = ident;
|
|
413
|
+
const setAt = keywordAt(upper, "SET", afterColl);
|
|
414
|
+
if (setAt < 0 || !onlyWs(sql, afterColl, setAt)) return null;
|
|
415
|
+
const whereAt = keywordAt(upper, "WHERE", setAt + 3);
|
|
416
|
+
const setClause = sql.slice(setAt + 3, whereAt < 0 ? sql.length : whereAt).trim();
|
|
417
|
+
const whereClause = whereAt < 0 ? undefined : sql.slice(whereAt + 5).trim();
|
|
264
418
|
const set = parseSetClause(setClause, params);
|
|
265
|
-
const matchAll = isMatchAllWhere(whereClause
|
|
266
|
-
const filter = whereClause ? parseWhereClause(whereClause
|
|
419
|
+
const matchAll = isMatchAllWhere(whereClause);
|
|
420
|
+
const filter = whereClause ? parseWhereClause(whereClause, params, set.consumed).filter : {};
|
|
267
421
|
return { type: "updateMany", collection, filter, update: { $set: set.document }, matchAll };
|
|
268
422
|
}
|
|
269
423
|
|
|
270
424
|
function parseDelete(sql: string, params: unknown[]): MongoOperation | null {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
const
|
|
275
|
-
|
|
425
|
+
// Plain string scan — DELETE FROM <coll> [WHERE ..].
|
|
426
|
+
const upper = sql.toUpperCase();
|
|
427
|
+
if (!upper.startsWith("DELETE")) return null;
|
|
428
|
+
const fromAt = keywordAt(upper, "FROM", 6);
|
|
429
|
+
if (fromAt < 0 || !onlyWs(sql, 6, fromAt)) return null;
|
|
430
|
+
const ident = readIdentifier(sql, fromAt + 4);
|
|
431
|
+
if (!ident) return null;
|
|
432
|
+
const [collection, afterColl] = ident;
|
|
433
|
+
const whereAt = keywordAt(upper, "WHERE", afterColl);
|
|
434
|
+
const whereClause = whereAt < 0 ? undefined : sql.slice(whereAt + 5).trim();
|
|
435
|
+
const matchAll = isMatchAllWhere(whereClause);
|
|
436
|
+
const filter = whereClause ? parseWhereClause(whereClause, params).filter : {};
|
|
276
437
|
return { type: "deleteMany", collection, filter, matchAll };
|
|
277
438
|
}
|
|
278
439
|
|
|
@@ -282,10 +443,24 @@ function parseCreate(sql: string): MongoOperation | null {
|
|
|
282
443
|
}
|
|
283
444
|
|
|
284
445
|
function parseCount(sql: string, params: unknown[]): MongoOperation | null {
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
const
|
|
446
|
+
// Plain string scan — SELECT COUNT(*) AS <alias> FROM <coll> [WHERE ..].
|
|
447
|
+
const upper = sql.toUpperCase();
|
|
448
|
+
if (!upper.startsWith("SELECT")) return null;
|
|
449
|
+
const afterCount = keywordGapEnd(upper, sql, "COUNT(*)", 6);
|
|
450
|
+
if (afterCount < 0) return null;
|
|
451
|
+
const afterAs = keywordGapEnd(upper, sql, "AS", afterCount);
|
|
452
|
+
if (afterAs < 0) return null;
|
|
453
|
+
const aliasIdent = readIdentifier(sql, afterAs);
|
|
454
|
+
if (!aliasIdent) return null;
|
|
455
|
+
const [alias, afterAlias] = aliasIdent;
|
|
456
|
+
const afterFrom = keywordGapEnd(upper, sql, "FROM", afterAlias);
|
|
457
|
+
if (afterFrom < 0) return null;
|
|
458
|
+
const coll = readIdentifier(sql, afterFrom);
|
|
459
|
+
if (!coll) return null;
|
|
460
|
+
const [collection, afterColl] = coll;
|
|
461
|
+
const whereAt = keywordAt(upper, "WHERE", afterColl);
|
|
462
|
+
const whereClause = whereAt < 0 ? undefined : sql.slice(whereAt + 5).trim();
|
|
463
|
+
const filter = whereClause ? parseWhereClause(whereClause, params).filter : {};
|
|
289
464
|
return { type: "aggregate", collection, pipeline: [{ $match: filter }, { $count: alias }] };
|
|
290
465
|
}
|
|
291
466
|
|
|
@@ -217,6 +217,15 @@ export function generateCrudRoutes(models: DiscoveredModel[], options: AutoCrudO
|
|
|
217
217
|
let built: ReturnType<typeof buildQuery>;
|
|
218
218
|
try {
|
|
219
219
|
const qp = parseQueryString(req.query ?? {});
|
|
220
|
+
// ADR-0094: resolve the model's declared string/text columns for the
|
|
221
|
+
// ?search term (OR'd LIKE %term%). Resolved from the model, never the
|
|
222
|
+
// request, so only declared columns reach SQL (ADR-0069). A model with
|
|
223
|
+
// no string/text column yields an empty list and search matches nothing.
|
|
224
|
+
if ((qp.search ?? "").trim() !== "") {
|
|
225
|
+
qp.searchColumns = Object.entries(fields)
|
|
226
|
+
.filter(([, def]) => def.type === "string" || def.type === "text")
|
|
227
|
+
.map(([name]) => getDbCol(name));
|
|
228
|
+
}
|
|
220
229
|
built = buildQuery(tableName, qp, extraConditions, q, (key) => resolveFieldColumn(fields, getDbCol, key));
|
|
221
230
|
} catch (err) {
|
|
222
231
|
if (err instanceof UnknownFieldError) {
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (c) 2026 Code Infinity
|
|
3
|
+
SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Crud (ADR-0094) — a FRONTEND over AutoCrud.
|
|
11
|
+
*
|
|
12
|
+
* `Crud.toCrud(request, options)` renders a complete server-rendered admin UI
|
|
13
|
+
* (searchable, sortable, paginated table + create/edit/delete modals) for an ORM
|
|
14
|
+
* model. It owns NO backend routes: the entire REST backend (GET list, GET /{id},
|
|
15
|
+
* POST, PUT, DELETE, secure-by-default) is delegated to AutoCrud, and the UI's
|
|
16
|
+
* JavaScript talks to those routes over fetch(). The HTML comes from four
|
|
17
|
+
* app-overridable Frond templates under `crud/` (page, table, form, modals),
|
|
18
|
+
* resolved app-first-then-framework (the same convention the error pages use), so
|
|
19
|
+
* an app restyles the admin by dropping its own `src/templates/crud/<name>.twig`.
|
|
20
|
+
*
|
|
21
|
+
* Lives in @tina4/orm (next to AutoCrud, the ORM it drives) rather than in the
|
|
22
|
+
* eager @tina4/core barrel: Crud is an opt-in feature, not on every request
|
|
23
|
+
* path, so it must not be pulled into core's import graph (lazyFeatureLoading).
|
|
24
|
+
*
|
|
25
|
+
* Node returns `Promise<string>` (the DB adapters are async); the Python/PHP/Ruby
|
|
26
|
+
* entry points return a string. generateTable / generateForm stay synchronous.
|
|
27
|
+
*
|
|
28
|
+
* Usage:
|
|
29
|
+
* get("/admin/users", async (request, response) =>
|
|
30
|
+
* response.html(await Crud.toCrud(request, { model: User, title: "Users" }))
|
|
31
|
+
* ).secure();
|
|
32
|
+
*
|
|
33
|
+
* A custom `sql` only shapes the LISTING grid (a filter/join/projection); the
|
|
34
|
+
* model still drives columns, the primary key, and every write path, so a custom
|
|
35
|
+
* listing can never create an unauthenticated or divergent write route.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import type { Tina4Request } from "../../core/src/types.js";
|
|
39
|
+
import { defaultRouter } from "../../core/src/router.js";
|
|
40
|
+
import { generateCrudRoutes } from "./autoCrud.js";
|
|
41
|
+
import { getAdapter, adapterQuery, adapterFetch } from "./database.js";
|
|
42
|
+
import {
|
|
43
|
+
type CrudModel,
|
|
44
|
+
h,
|
|
45
|
+
columnOf,
|
|
46
|
+
primaryKeyProp,
|
|
47
|
+
searchableProps,
|
|
48
|
+
crudSortProp,
|
|
49
|
+
stripOrderAndLimit,
|
|
50
|
+
extractColumns,
|
|
51
|
+
} from "./crudHelpers.js";
|
|
52
|
+
import { jsConfig, pageControls, tableData } from "./crudTable.js";
|
|
53
|
+
import { renderCrudTemplate, renderModals, generateTable, generateForm } from "./crudRender.js";
|
|
54
|
+
|
|
55
|
+
export type { CrudModel } from "./crudHelpers.js";
|
|
56
|
+
export type { FormFieldDef } from "./crudRender.js";
|
|
57
|
+
export { generateTable, generateForm } from "./crudRender.js";
|
|
58
|
+
export { stripOrderAndLimit } from "./crudHelpers.js";
|
|
59
|
+
|
|
60
|
+
export interface ToCrudOptions {
|
|
61
|
+
/** REQUIRED — the ORM model class (a BaseModel subclass). */
|
|
62
|
+
model: CrudModel;
|
|
63
|
+
/** Optional listing query (inferred from the model when omitted); shapes only the displayed grid. */
|
|
64
|
+
sql?: string;
|
|
65
|
+
/** Page title (default "CRUD"). */
|
|
66
|
+
title?: string;
|
|
67
|
+
/** AutoCrud route prefix (default "/api"). */
|
|
68
|
+
prefix?: string;
|
|
69
|
+
/** Records per page (default 10). */
|
|
70
|
+
limit?: number;
|
|
71
|
+
/** When true, the AutoCrud write routes are OPEN (no auth). Default false (secure). */
|
|
72
|
+
public?: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** (prefix, table) pairs whose AutoCrud routes have already been registered. */
|
|
76
|
+
const registeredTables = new Set<string>();
|
|
77
|
+
|
|
78
|
+
/** Reset the registration cache (tests). */
|
|
79
|
+
export function _resetCrudRegistrations(): void {
|
|
80
|
+
registeredTables.clear();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Build the DiscoveredModel shape generateCrudRoutes expects from a model class. */
|
|
84
|
+
function discoveredFrom(model: CrudModel): any {
|
|
85
|
+
return {
|
|
86
|
+
filePath: `src/models/${model.name ?? model.tableName}.ts`,
|
|
87
|
+
modelClass: model,
|
|
88
|
+
definition: {
|
|
89
|
+
tableName: model.tableName,
|
|
90
|
+
className: model.name,
|
|
91
|
+
fields: model.fields,
|
|
92
|
+
fieldMapping: model.fieldMapping,
|
|
93
|
+
softDelete: (model as any).softDelete ?? false,
|
|
94
|
+
tableFilter: (model as any).tableFilter,
|
|
95
|
+
dbName: (model as any)._db,
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Delegate the ENTIRE backend to AutoCrud. Idempotent per (prefix, table):
|
|
102
|
+
* the first registration wins (so a scaffolded route that opted writes public
|
|
103
|
+
* is not later reset to secure), and addRoute replaces a route in place anyway.
|
|
104
|
+
*/
|
|
105
|
+
export async function registerBackend(
|
|
106
|
+
model: CrudModel,
|
|
107
|
+
options: { prefix?: string; public?: boolean } = {},
|
|
108
|
+
): Promise<void> {
|
|
109
|
+
const prefix = options.prefix ?? "/api";
|
|
110
|
+
const key = `${prefix}::${model.tableName}`;
|
|
111
|
+
if (registeredTables.has(key)) return;
|
|
112
|
+
|
|
113
|
+
const routes = generateCrudRoutes([discoveredFrom(model)], { public: options.public === true });
|
|
114
|
+
for (const route of routes) defaultRouter.addRoute(route);
|
|
115
|
+
registeredTables.add(key);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
interface FetchOpts { search: string; sort: string; sortDir: string; limit: number; offset: number; }
|
|
119
|
+
|
|
120
|
+
/** Fetch a page of records from the model (ADR-0069 safe search + sort). */
|
|
121
|
+
async function fetchModelData(model: CrudModel, opts: FetchOpts): Promise<[Array<Record<string, unknown>>, number]> {
|
|
122
|
+
const orderBy = `${opts.sort} ${opts.sortDir.toUpperCase()}`;
|
|
123
|
+
const search = opts.search.trim();
|
|
124
|
+
const searchable = search === "" ? [] : searchableProps(model);
|
|
125
|
+
|
|
126
|
+
let rows: any;
|
|
127
|
+
if (searchable.length === 0) {
|
|
128
|
+
rows = await model.all(opts.limit, opts.offset, undefined, orderBy);
|
|
129
|
+
} else {
|
|
130
|
+
const whereClause = searchable.map((prop) => `${columnOf(model, prop)} LIKE ?`).join(" OR ");
|
|
131
|
+
const params = searchable.map(() => `%${search}%`);
|
|
132
|
+
rows = await model.where(whereClause, params, opts.limit, opts.offset, undefined, orderBy);
|
|
133
|
+
}
|
|
134
|
+
const total = typeof rows.getTotalRecords === "function" ? rows.getTotalRecords() : rows.length;
|
|
135
|
+
const records = (rows as any[]).map((r) => (typeof r.toDict === "function" ? r.toDict() : r));
|
|
136
|
+
return [records, total];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Fetch a page of rows for a custom listing SQL (shapes the DISPLAY only). */
|
|
140
|
+
async function fetchSqlData(sql: string, opts: FetchOpts): Promise<[Array<Record<string, unknown>>, number]> {
|
|
141
|
+
let adapter: any;
|
|
142
|
+
try {
|
|
143
|
+
adapter = getAdapter();
|
|
144
|
+
} catch {
|
|
145
|
+
return [[], 0];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const base = stripOrderAndLimit(sql);
|
|
149
|
+
const dir = opts.sortDir.toUpperCase();
|
|
150
|
+
const search = opts.search.trim();
|
|
151
|
+
|
|
152
|
+
if (search === "") {
|
|
153
|
+
const countRow = await adapterQuery(adapter, `SELECT COUNT(*) as cnt FROM (${base}) AS _crud_cnt`, []);
|
|
154
|
+
const total = Number(countRow[0]?.cnt ?? 0);
|
|
155
|
+
const rows = await adapterFetch(adapter, `${base} ORDER BY ${opts.sort} ${dir}`, [], opts.limit, opts.offset);
|
|
156
|
+
return [rows as Array<Record<string, unknown>>, total];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const columns = extractColumns(sql);
|
|
160
|
+
const searchParts = columns.map((col) => `CAST(${col} AS TEXT) LIKE ?`);
|
|
161
|
+
const params = columns.map(() => `%${search}%`);
|
|
162
|
+
const countRow = await adapterQuery(
|
|
163
|
+
adapter,
|
|
164
|
+
`SELECT COUNT(*) as cnt FROM (${base}) AS _crud_cnt WHERE ${searchParts.join(" OR ")}`,
|
|
165
|
+
params,
|
|
166
|
+
);
|
|
167
|
+
const total = Number(countRow[0]?.cnt ?? 0);
|
|
168
|
+
const rows = await adapterFetch(
|
|
169
|
+
adapter,
|
|
170
|
+
`SELECT * FROM (${base}) AS _crud_sub WHERE ${searchParts.join(" OR ")} ORDER BY ${opts.sort} ${dir}`,
|
|
171
|
+
params,
|
|
172
|
+
opts.limit,
|
|
173
|
+
opts.offset,
|
|
174
|
+
);
|
|
175
|
+
return [rows as Array<Record<string, unknown>>, total];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
interface CrudRequest {
|
|
179
|
+
page: number; search: string; sortProp: string; sortColumn: string; sortDir: string;
|
|
180
|
+
offset: number; requestPath: string;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Parse pagination / search / safe-sort from the request query string. */
|
|
184
|
+
function parseCrudRequest(request: Tina4Request, model: CrudModel, pkProp: string, limit: number): CrudRequest {
|
|
185
|
+
const query = (request && (request as any).query) || {};
|
|
186
|
+
const page = Math.max(parseInt(String(query.page ?? "1"), 10) || 1, 1);
|
|
187
|
+
const search = String(query.search ?? "").trim();
|
|
188
|
+
const sortProp = crudSortProp(model, query.sort, pkProp); // header/state key (property)
|
|
189
|
+
const sortColumn = columnOf(model, sortProp); // DB column for ORDER BY
|
|
190
|
+
const sortDir = query.sort_dir === "desc" ? "desc" : "asc";
|
|
191
|
+
const requestPath = (request && typeof (request as any).path === "string") ? (request as any).path : "/";
|
|
192
|
+
return { page, search, sortProp, sortColumn, sortDir, offset: (page - 1) * limit, requestPath };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Clamp the configured records-per-page to a positive integer (default 10). */
|
|
196
|
+
function clampLimit(raw: number | undefined): number {
|
|
197
|
+
const limit = Number(raw ?? 10);
|
|
198
|
+
return !Number.isFinite(limit) || limit <= 0 ? 10 : limit;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Render the CRUD admin page for `model` and register its AutoCrud routes.
|
|
203
|
+
* Returns the rendered crud/page template.
|
|
204
|
+
*/
|
|
205
|
+
export async function toCrud(request: Tina4Request, options: ToCrudOptions): Promise<string> {
|
|
206
|
+
const model = options.model;
|
|
207
|
+
if (!model || !model.tableName || !model.fields) {
|
|
208
|
+
throw new Error("Crud.toCrud requires a model (an ORM class)");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const sql = options.sql;
|
|
212
|
+
const title = String(options.title ?? "CRUD");
|
|
213
|
+
const prefix = String(options.prefix ?? "/api");
|
|
214
|
+
const limit = clampLimit(options.limit);
|
|
215
|
+
|
|
216
|
+
const tableName = String(model.tableName);
|
|
217
|
+
const pkProp = primaryKeyProp(model);
|
|
218
|
+
const columns = Object.keys(model.fields);
|
|
219
|
+
|
|
220
|
+
// Backend: delegate 100% to AutoCrud (idempotent — register once).
|
|
221
|
+
await registerBackend(model, { prefix, public: options.public });
|
|
222
|
+
|
|
223
|
+
const req = parseCrudRequest(request, model, pkProp, limit);
|
|
224
|
+
const fetchOpts: FetchOpts = {
|
|
225
|
+
search: req.search, sort: req.sortColumn, sortDir: req.sortDir, limit, offset: req.offset,
|
|
226
|
+
};
|
|
227
|
+
const [records, total] = sql
|
|
228
|
+
? await fetchSqlData(sql, fetchOpts)
|
|
229
|
+
: await fetchModelData(model, fetchOpts);
|
|
230
|
+
|
|
231
|
+
const totalPages = total > 0 ? Math.ceil(total / limit) : 1;
|
|
232
|
+
const apiPath = `${prefix}/${tableName}`;
|
|
233
|
+
const editableColumns = columns.filter((c) => c !== pkProp);
|
|
234
|
+
|
|
235
|
+
const tableHtml = renderCrudTemplate(
|
|
236
|
+
"table",
|
|
237
|
+
tableData({
|
|
238
|
+
columns, records, pk: pkProp, tableName, editable: false, sortable: true,
|
|
239
|
+
inlineScript: false, requestPath: req.requestPath, search: req.search, sortCol: req.sortProp,
|
|
240
|
+
sortDir: req.sortDir, page: req.page, limit, tableId: null, model,
|
|
241
|
+
}),
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
return renderCrudTemplate("page", {
|
|
245
|
+
title: h(title),
|
|
246
|
+
search: h(req.search),
|
|
247
|
+
request_path: h(req.requestPath),
|
|
248
|
+
info_count: records.length,
|
|
249
|
+
info_total: total,
|
|
250
|
+
info_page: req.page,
|
|
251
|
+
info_total_pages: totalPages,
|
|
252
|
+
table_html: tableHtml,
|
|
253
|
+
modals_html: renderModals(editableColumns, pkProp),
|
|
254
|
+
show_pagination: totalPages > 1,
|
|
255
|
+
controls: pageControls(req.page, totalPages, req.requestPath, req.search, req.sortProp, req.sortDir, limit),
|
|
256
|
+
config_json: jsConfig({
|
|
257
|
+
apiPath, pk: pkProp, columns, editable: editableColumns, model, limit,
|
|
258
|
+
search: req.search, sortCol: req.sortProp, sortDir: req.sortDir, page: req.page,
|
|
259
|
+
}),
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The public Crud surface (ADR-0094). `CRUD` is an uppercase alias. */
|
|
264
|
+
export const Crud = {
|
|
265
|
+
toCrud,
|
|
266
|
+
registerBackend,
|
|
267
|
+
generateTable,
|
|
268
|
+
generateForm,
|
|
269
|
+
stripOrderAndLimit,
|
|
270
|
+
_resetCrudRegistrations,
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
export const CRUD = Crud;
|