tina4-nodejs 3.13.147 → 3.13.149

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.
Files changed (45) hide show
  1. package/CLAUDE.md +4 -3
  2. package/package.json +1 -1
  3. package/packages/cli/dist/bin.js +10901 -9855
  4. package/packages/cli/src/commands/generate.ts +184 -25
  5. package/packages/core/dist/index.js +10874 -9828
  6. package/packages/core/src/devAdmin.ts +72 -1
  7. package/packages/core/src/dispatchPipeline.ts +4 -12
  8. package/packages/core/src/index.ts +3 -0
  9. package/packages/core/src/messenger.ts +57 -1
  10. package/packages/core/src/router.ts +11 -1
  11. package/packages/core/src/server.ts +5 -2
  12. package/packages/core/src/session.ts +45 -3
  13. package/packages/core/templates/crud/form.twig +26 -0
  14. package/packages/core/templates/crud/modals.twig +31 -0
  15. package/packages/core/templates/crud/page.twig +401 -0
  16. package/packages/core/templates/crud/table.twig +69 -0
  17. package/packages/frond/dist/index.js +9 -1
  18. package/packages/frond/src/engine.ts +9 -0
  19. package/packages/frond/src/index.ts +1 -1
  20. package/packages/orm/dist/index.js +1283 -227
  21. package/packages/orm/src/adapters/mongodb.ts +209 -34
  22. package/packages/orm/src/autoCrud.ts +9 -0
  23. package/packages/orm/src/crud.ts +273 -0
  24. package/packages/orm/src/crudHelpers.ts +194 -0
  25. package/packages/orm/src/crudRender.ts +202 -0
  26. package/packages/orm/src/crudTable.ts +171 -0
  27. package/packages/orm/src/index.ts +7 -1
  28. package/packages/orm/src/migration.ts +167 -4
  29. package/packages/orm/src/query.ts +99 -40
  30. package/packages/orm/src/sqlTranslator.ts +8 -1
  31. package/packages/orm/src/types.ts +19 -0
  32. package/types/core/src/devAdmin.d.ts +23 -0
  33. package/types/core/src/messenger.d.ts +4 -0
  34. package/types/core/src/router.d.ts +0 -5
  35. package/types/core/src/session.d.ts +25 -0
  36. package/types/frond/src/engine.d.ts +6 -0
  37. package/types/frond/src/index.d.ts +1 -1
  38. package/types/orm/src/crud.d.ts +82 -0
  39. package/types/orm/src/crudHelpers.d.ts +63 -0
  40. package/types/orm/src/crudRender.d.ts +38 -0
  41. package/types/orm/src/crudTable.d.ts +45 -0
  42. package/types/orm/src/index.d.ts +3 -1
  43. package/types/orm/src/migration.d.ts +0 -18
  44. package/types/orm/src/query.d.ts +0 -12
  45. 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.split(/\s+AND\s+/i);
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
- function parseSelect(sql: string, params: unknown[]): MongoOperation | null {
195
- const match = sql.match(/^SELECT\s+(.*?)\s+FROM\s+["']?(\w+)["']?(?:\s+WHERE\s+(.*?))?(?:\s+ORDER\s+BY\s+(.*?))?(?:\s+LIMIT\s+(\d+))?(?:\s+OFFSET\s+(\d+))?$/is);
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 && cols.trim() !== "*") {
200
- for (const col of cols.split(",")) {
201
- const name = col.trim().replace(/^["']|["']$/g, "");
202
- if (name && name !== "*") projection[name] = 1;
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
- const filter = whereClause ? parseWhereClause(whereClause.trim(), params).filter : {};
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
- if (orderBy) {
208
- for (const part of orderBy.split(",")) {
209
- const trimmed = part.trim();
210
- const desc = /DESC$/i.test(trimmed);
211
- const col = trimmed.replace(/\s+(ASC|DESC)$/i, "").replace(/^["']|["']$/g, "").trim();
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 ? sort : undefined,
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
- const match = sql.match(/^UPDATE\s+["']?(\w+)["']?\s+SET\s+(.*?)(?:\s+WHERE\s+(.+))?$/is);
262
- if (!match) return null;
263
- const [, collection, setClause, whereClause] = match;
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?.trim());
266
- const filter = whereClause ? parseWhereClause(whereClause.trim(), params, set.consumed).filter : {};
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
- const match = sql.match(/^DELETE\s+FROM\s+["']?(\w+)["']?(?:\s+WHERE\s+(.+))?$/is);
272
- if (!match) return null;
273
- const [, collection, whereClause] = match;
274
- const matchAll = isMatchAllWhere(whereClause?.trim());
275
- const filter = whereClause ? parseWhereClause(whereClause.trim(), params).filter : {};
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
- const match = sql.match(/^SELECT\s+COUNT\(\*\)\s+AS\s+(\w+)\s+FROM\s+["']?(\w+)["']?(?:\s+WHERE\s+(.+))?$/is);
286
- if (!match) return null;
287
- const [, alias, collection, whereClause] = match;
288
- const filter = whereClause ? parseWhereClause(whereClause.trim(), params).filter : {};
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;