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
|
@@ -6,8 +6,10 @@ License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
|
6
6
|
file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
10
10
|
import { join, resolve } from "node:path";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
11
13
|
import { Log } from "../../core/src/index.js";
|
|
12
14
|
import type { FieldDefinition, DatabaseAdapter } from "./types.js";
|
|
13
15
|
import type { SQLiteAdapter } from "./adapters/sqlite.js";
|
|
@@ -1016,6 +1018,144 @@ function warnUnprefixedMigrations(files: string[]): void {
|
|
|
1016
1018
|
* @param adapter - A DatabaseAdapter instance (or omit to use the global adapter).
|
|
1017
1019
|
* @param options - Optional configuration.
|
|
1018
1020
|
*/
|
|
1021
|
+
// ── Cross-process migration lock (issue #277) ──────────────────────────────
|
|
1022
|
+
const MIGRATION_LOCK_NAME = "tina4_migration_lock";
|
|
1023
|
+
// A stable signed-64-bit key for PostgreSQL's pg_advisory_lock, derived from the
|
|
1024
|
+
// name so it cannot collide with an application's own advisory-lock keys. It is a
|
|
1025
|
+
// constant (not user input), so inlining it in the SQL is injection-safe.
|
|
1026
|
+
const PG_ADVISORY_KEY = BigInt.asIntN(
|
|
1027
|
+
64,
|
|
1028
|
+
BigInt("0x" + createHash("sha256").update(MIGRATION_LOCK_NAME).digest("hex").slice(0, 16)),
|
|
1029
|
+
).toString();
|
|
1030
|
+
|
|
1031
|
+
type MigrationLock = { kind: "postgres" | "mysql" | "mssql" | "file" | "none"; resource?: string };
|
|
1032
|
+
|
|
1033
|
+
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* Take the run-wide migration lock, blocking until it is held. PostgreSQL/MySQL/
|
|
1037
|
+
* MSSQL use a native session-scoped advisory lock; SQLite, Firebird and anything
|
|
1038
|
+
* else fall back to an atomic exclusive-create file lock on a sidecar in the
|
|
1039
|
+
* system temp dir (crash-safe via a stale-PID check). A backend that cannot
|
|
1040
|
+
* lock degrades to the file lock, and finally to running unlocked.
|
|
1041
|
+
*/
|
|
1042
|
+
async function acquireMigrationLock(db: DatabaseAdapter, dir: string): Promise<MigrationLock> {
|
|
1043
|
+
let engine = "";
|
|
1044
|
+
try {
|
|
1045
|
+
engine = (db.getDatabaseType() || "").toLowerCase();
|
|
1046
|
+
} catch {
|
|
1047
|
+
engine = "";
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
try {
|
|
1051
|
+
if (engine.startsWith("postgres")) {
|
|
1052
|
+
await adapterQuery(db, `SELECT pg_advisory_lock(${PG_ADVISORY_KEY}) AS locked`);
|
|
1053
|
+
return { kind: "postgres" };
|
|
1054
|
+
}
|
|
1055
|
+
if (engine.startsWith("mysql")) {
|
|
1056
|
+
// -1 = wait indefinitely; GET_LOCK is connection-scoped.
|
|
1057
|
+
await adapterQuery(db, `SELECT GET_LOCK('${MIGRATION_LOCK_NAME}', -1) AS locked`);
|
|
1058
|
+
return { kind: "mysql" };
|
|
1059
|
+
}
|
|
1060
|
+
if (engine === "mssql" || engine === "sqlserver") {
|
|
1061
|
+
await adapterExecute(
|
|
1062
|
+
db,
|
|
1063
|
+
`DECLARE @res INT; EXEC @res = sp_getapplock @Resource = '${MIGRATION_LOCK_NAME}', ` +
|
|
1064
|
+
`@LockMode = 'Exclusive', @LockOwner = 'Session', @LockTimeout = -1`,
|
|
1065
|
+
);
|
|
1066
|
+
return { kind: "mssql" };
|
|
1067
|
+
}
|
|
1068
|
+
} catch (err) {
|
|
1069
|
+
Log.debug(`DB migration lock unavailable (${engine}): ${err instanceof Error ? err.message : err}; using a file lock`);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
return acquireFileLock(dir);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* Atomic exclusive-file cross-process lock (Node core has no flock). An
|
|
1077
|
+
* O_CREAT|O_EXCL ("wx") write IS the lock: exactly one process creates the file.
|
|
1078
|
+
* It records the holder's PID so a waiter can clear a lock left by a CRASHED
|
|
1079
|
+
* holder — but ONLY when it reads a valid PID that is confirmed dead. An unread-
|
|
1080
|
+
* able, empty or missing PID is treated as NOT stale (the holder created the file
|
|
1081
|
+
* microseconds before writing its PID; stealing on that window double-applied the
|
|
1082
|
+
* migration — the exact 2-rows/1-tracker-row signature of issue #277). Returns
|
|
1083
|
+
* {kind:"none"} rather than blocking boot forever if it cannot be taken.
|
|
1084
|
+
*/
|
|
1085
|
+
async function acquireFileLock(dir: string): Promise<MigrationLock> {
|
|
1086
|
+
// The lock file lives in the system temp dir, NOT the migrations folder — a
|
|
1087
|
+
// runtime lock is not a migration, and a dotfile left in the tracked
|
|
1088
|
+
// migrations/ dir gets committed by accident and blocks a plain rmdir. The
|
|
1089
|
+
// name is hashed from the ABSOLUTE migrations path so every worker of the same
|
|
1090
|
+
// app lands on the same file (issue #277, parity with PHP fileLockPath()).
|
|
1091
|
+
const lockFile = join(tmpdir(), `tina4-migration-${createHash("sha256").update(resolve(dir)).digest("hex")}.lock`);
|
|
1092
|
+
const deadline = Date.now() + 60_000;
|
|
1093
|
+
for (;;) {
|
|
1094
|
+
try {
|
|
1095
|
+
writeFileSync(lockFile, String(process.pid), { flag: "wx" }); // atomic create-exclusive
|
|
1096
|
+
return { kind: "file", resource: lockFile };
|
|
1097
|
+
} catch (err) {
|
|
1098
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
1099
|
+
Log.debug(`file migration lock unavailable: ${err instanceof Error ? err.message : err}; running unlocked`);
|
|
1100
|
+
return { kind: "none" };
|
|
1101
|
+
}
|
|
1102
|
+
if (isStaleLock(lockFile)) {
|
|
1103
|
+
try {
|
|
1104
|
+
rmSync(lockFile, { force: true });
|
|
1105
|
+
} catch {
|
|
1106
|
+
/* another process may have cleared it first */
|
|
1107
|
+
}
|
|
1108
|
+
continue;
|
|
1109
|
+
}
|
|
1110
|
+
if (Date.now() > deadline) {
|
|
1111
|
+
Log.debug("file migration lock wait timed out; running unlocked");
|
|
1112
|
+
return { kind: "none" };
|
|
1113
|
+
}
|
|
1114
|
+
await delay(50);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/**
|
|
1120
|
+
* Stale ONLY when the lock file records a VALID pid that is confirmed dead. An
|
|
1121
|
+
* empty/unparseable pid (holder mid-write) or a vanished file (holder released)
|
|
1122
|
+
* is NOT stale — treating either as stale lets a waiter steal a live lock.
|
|
1123
|
+
*/
|
|
1124
|
+
function isStaleLock(lockFile: string): boolean {
|
|
1125
|
+
let raw: string;
|
|
1126
|
+
try {
|
|
1127
|
+
raw = readFileSync(lockFile, "utf-8").trim();
|
|
1128
|
+
} catch {
|
|
1129
|
+
return false; // ENOENT: the lock was released — retry acquisition, don't steal
|
|
1130
|
+
}
|
|
1131
|
+
const pid = parseInt(raw, 10);
|
|
1132
|
+
if (!raw || !Number.isFinite(pid) || pid <= 0) {
|
|
1133
|
+
return false; // holder has not written its pid yet — give it the benefit of the doubt
|
|
1134
|
+
}
|
|
1135
|
+
try {
|
|
1136
|
+
process.kill(pid, 0); // throws ESRCH if the process is gone
|
|
1137
|
+
return false;
|
|
1138
|
+
} catch (err) {
|
|
1139
|
+
return (err as NodeJS.ErrnoException).code === "ESRCH";
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
async function releaseMigrationLock(db: DatabaseAdapter, lock: MigrationLock): Promise<void> {
|
|
1144
|
+
try {
|
|
1145
|
+
if (lock.kind === "postgres") {
|
|
1146
|
+
await adapterQuery(db, `SELECT pg_advisory_unlock(${PG_ADVISORY_KEY}) AS released`);
|
|
1147
|
+
} else if (lock.kind === "mysql") {
|
|
1148
|
+
await adapterQuery(db, `SELECT RELEASE_LOCK('${MIGRATION_LOCK_NAME}') AS released`);
|
|
1149
|
+
} else if (lock.kind === "mssql") {
|
|
1150
|
+
await adapterExecute(db, `EXEC sp_releaseapplock @Resource = '${MIGRATION_LOCK_NAME}', @LockOwner = 'Session'`);
|
|
1151
|
+
} else if (lock.kind === "file" && lock.resource) {
|
|
1152
|
+
rmSync(lock.resource, { recursive: true, force: true });
|
|
1153
|
+
}
|
|
1154
|
+
} catch (err) {
|
|
1155
|
+
Log.debug(`migration lock release failed (${lock.kind}): ${err instanceof Error ? err.message : err}`);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1019
1159
|
export async function migrate(
|
|
1020
1160
|
adapter?: DatabaseAdapter,
|
|
1021
1161
|
options?: { migrationsDir?: string; delimiter?: string },
|
|
@@ -1024,11 +1164,34 @@ export async function migrate(
|
|
|
1024
1164
|
const dir = resolve(options?.migrationsDir ?? "migrations");
|
|
1025
1165
|
const delimiter = options?.delimiter ?? ";";
|
|
1026
1166
|
|
|
1027
|
-
const result: MigrationResult = { applied: [], skipped: [], failed: [] };
|
|
1028
|
-
|
|
1029
1167
|
if (!existsSync(dir)) {
|
|
1030
|
-
return
|
|
1168
|
+
return { applied: [], skipped: [], failed: [] };
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// Serialize concurrent startup migrations across processes (#277): the hook
|
|
1172
|
+
// runs once per process on a long-lived server and on every request under
|
|
1173
|
+
// php -S-style servers, so several workers/instances could reach a fresh
|
|
1174
|
+
// database at once and apply the same migration more than once (a data
|
|
1175
|
+
// migration then inserted its rows twice). The winner migrates while the rest
|
|
1176
|
+
// block, then re-read the applied set and find nothing pending. Every backend
|
|
1177
|
+
// auto-releases the lock when the holder's session/process dies.
|
|
1178
|
+
const lock = await acquireMigrationLock(db, dir);
|
|
1179
|
+
try {
|
|
1180
|
+
return await runPendingMigrations(db, dir, delimiter);
|
|
1181
|
+
} finally {
|
|
1182
|
+
await releaseMigrationLock(db, lock);
|
|
1031
1183
|
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* Apply every pending migration, in order. Caller holds the run-wide lock.
|
|
1188
|
+
*/
|
|
1189
|
+
async function runPendingMigrations(
|
|
1190
|
+
db: DatabaseAdapter,
|
|
1191
|
+
dir: string,
|
|
1192
|
+
delimiter: string,
|
|
1193
|
+
): Promise<MigrationResult> {
|
|
1194
|
+
const result: MigrationResult = { applied: [], skipped: [], failed: [] };
|
|
1032
1195
|
|
|
1033
1196
|
// Ensure the canonical tracking table exists (creates it, or upgrades an
|
|
1034
1197
|
// older <= 3.13.54 `name`/`applied_at` table in place). Shared with
|
|
@@ -93,6 +93,62 @@ const filterValueError = (field: string): InvalidQueryParameterError =>
|
|
|
93
93
|
* not resolve throws UnknownFieldError before any SQL exists. With no
|
|
94
94
|
* resolver, no filter or sort key resolves (ADR-0069).
|
|
95
95
|
*/
|
|
96
|
+
/**
|
|
97
|
+
* ADR-0094: ?search=term filters the list - a LIKE %term% OR'd across the
|
|
98
|
+
* model's declared string/text columns (already resolved + passed in by the
|
|
99
|
+
* caller), added to the WHERE before limit/offset so the COUNT reflects the
|
|
100
|
+
* filtered set. The columns come from the model, never the request, so the
|
|
101
|
+
* identifiers are safe; the term itself is bound. A model with no string/text
|
|
102
|
+
* column simply matches nothing (the handler passes an empty list).
|
|
103
|
+
*/
|
|
104
|
+
function applySearch(
|
|
105
|
+
options: QueryOptions,
|
|
106
|
+
conditions: string[],
|
|
107
|
+
params: unknown[],
|
|
108
|
+
quote: (name: string) => string,
|
|
109
|
+
): void {
|
|
110
|
+
const searchTerm = (options.search ?? "").trim();
|
|
111
|
+
if (searchTerm === "" || !options.searchColumns || options.searchColumns.length === 0) return;
|
|
112
|
+
const likeParts = options.searchColumns.map((col) => `${quote(col)} LIKE ?`);
|
|
113
|
+
conditions.push(`(${likeParts.join(" OR ")})`);
|
|
114
|
+
for (const _ of options.searchColumns) params.push(`%${searchTerm}%`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Build the ORDER BY clause from ?sort. Comma-separated parts, empty parts
|
|
119
|
+
* skipped, a leading "-" = DESC. ORDER BY is built only from resolved columns
|
|
120
|
+
* and the words ASC / DESC. ADR-0094: a single bare ?sort=column honours
|
|
121
|
+
* ?sort_dir=asc|desc (the CRUD grid's spelling); a comma list or a leading "-"
|
|
122
|
+
* keeps its own inline direction and ignores sort_dir.
|
|
123
|
+
*/
|
|
124
|
+
/** One resolved "col ASC|DESC" ORDER BY term, or null for an empty part. */
|
|
125
|
+
function orderPart(
|
|
126
|
+
rawPart: string,
|
|
127
|
+
bareDir: string | null,
|
|
128
|
+
column: (kind: "filter" | "sort", key: string) => string,
|
|
129
|
+
): string | null {
|
|
130
|
+
const trimmed = rawPart.trim();
|
|
131
|
+
if (trimmed === "") return null;
|
|
132
|
+
const descending = trimmed.startsWith("-");
|
|
133
|
+
const col = column("sort", descending ? trimmed.slice(1) : trimmed);
|
|
134
|
+
return `${col} ${bareDir ?? (descending ? "DESC" : "ASC")}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function buildOrderClause(
|
|
138
|
+
options: QueryOptions,
|
|
139
|
+
column: (kind: "filter" | "sort", key: string) => string,
|
|
140
|
+
): string {
|
|
141
|
+
if (!options.sort) return "";
|
|
142
|
+
const rawParts = options.sort.split(",");
|
|
143
|
+
const first = rawParts[0].trim();
|
|
144
|
+
const singleBare = rawParts.length === 1 && first !== "" && !first.startsWith("-");
|
|
145
|
+
const bareDir = singleBare && String(options.sortDir).toLowerCase() === "desc" ? "DESC" : null;
|
|
146
|
+
const parts = rawParts
|
|
147
|
+
.map((part) => orderPart(part, bareDir, column))
|
|
148
|
+
.filter((term): term is string => term !== null);
|
|
149
|
+
return parts.length > 0 ? `ORDER BY ${parts.join(", ")}` : "";
|
|
150
|
+
}
|
|
151
|
+
|
|
96
152
|
export function buildQuery(
|
|
97
153
|
tableName: string,
|
|
98
154
|
options: QueryOptions,
|
|
@@ -142,22 +198,10 @@ export function buildQuery(
|
|
|
142
198
|
}
|
|
143
199
|
}
|
|
144
200
|
|
|
145
|
-
|
|
201
|
+
applySearch(options, conditions, params, quote);
|
|
146
202
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
let orderClause = "";
|
|
150
|
-
if (options.sort) {
|
|
151
|
-
const parts: string[] = [];
|
|
152
|
-
for (const part of options.sort.split(",")) {
|
|
153
|
-
const trimmed = part.trim();
|
|
154
|
-
if (trimmed === "") continue;
|
|
155
|
-
const descending = trimmed.startsWith("-");
|
|
156
|
-
const col = column("sort", descending ? trimmed.slice(1) : trimmed);
|
|
157
|
-
parts.push(`${col} ${descending ? "DESC" : "ASC"}`);
|
|
158
|
-
}
|
|
159
|
-
if (parts.length > 0) orderClause = `ORDER BY ${parts.join(", ")}`;
|
|
160
|
-
}
|
|
203
|
+
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
204
|
+
const orderClause = buildOrderClause(options, column);
|
|
161
205
|
|
|
162
206
|
// Pagination — PAGE-DEC-01: clamp page >= 1 BEFORE deriving offset, so
|
|
163
207
|
// offset=(page-1)*limit can never go negative (a page=0/negative request used
|
|
@@ -188,43 +232,58 @@ export function buildQuery(
|
|
|
188
232
|
};
|
|
189
233
|
}
|
|
190
234
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
235
|
+
/**
|
|
236
|
+
* Parse filter params: filter[name]=John or filter[age][gt]=25. ANY key
|
|
237
|
+
* inside the brackets is captured - buildQuery rejects one that is not a
|
|
238
|
+
* declared field rather than it being silently ignored here (ADR-0069).
|
|
239
|
+
* A second bracket must be a known operator; a list (filter[name][]), a map
|
|
240
|
+
* (filter[name][x]) or a nested key is a wrong-shaped value and throws
|
|
241
|
+
* InvalidQueryParameterError. `sort[...]` is a list/map where a single
|
|
242
|
+
* comma-separated string belongs, and throws the same way.
|
|
243
|
+
* A null-prototype map, so a "__proto__" key is stored (and then rejected)
|
|
244
|
+
* instead of silently re-pointing the object's prototype.
|
|
245
|
+
*/
|
|
246
|
+
function parseFilterParams(query: Record<string, string>): Record<string, unknown> {
|
|
203
247
|
const filter: Record<string, unknown> = Object.create(null);
|
|
204
248
|
for (const [key, value] of Object.entries(query)) {
|
|
205
249
|
if (key.startsWith("sort[")) {
|
|
206
250
|
throw new InvalidQueryParameterError("Query parameter 'sort' must be a single comma-separated string");
|
|
207
251
|
}
|
|
208
252
|
if (!key.startsWith("filter[")) continue;
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
253
|
+
addFilterKey(filter, key, value);
|
|
254
|
+
}
|
|
255
|
+
return filter;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Store one filter[field] / filter[field][op] param into the filter map. */
|
|
259
|
+
function addFilterKey(filter: Record<string, unknown>, key: string, value: string): void {
|
|
260
|
+
const filterMatch = key.match(/^filter\[([^\]]*)\](.*)$/s);
|
|
261
|
+
if (!filterMatch) throw new UnknownFieldError("filter", key.slice("filter[".length));
|
|
262
|
+
const [, field, rest] = filterMatch;
|
|
263
|
+
if (rest === "") {
|
|
264
|
+
filter[field] = value;
|
|
265
|
+
return;
|
|
222
266
|
}
|
|
267
|
+
const operator = rest.match(/^\[([^[\]]*)\]$/)?.[1];
|
|
268
|
+
if (operator === undefined || !Object.hasOwn(operatorMap, operator)) throw filterValueError(field);
|
|
269
|
+
if (!filter[field] || typeof filter[field] !== "object") {
|
|
270
|
+
filter[field] = {};
|
|
271
|
+
}
|
|
272
|
+
(filter[field] as Record<string, string>)[operator] = value;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function parseQueryString(query: Record<string, string>): QueryOptions {
|
|
276
|
+
const options: QueryOptions = {};
|
|
277
|
+
|
|
278
|
+
const filter = parseFilterParams(query);
|
|
223
279
|
if (Object.keys(filter).length > 0) {
|
|
224
280
|
options.filter = filter;
|
|
225
281
|
}
|
|
226
282
|
|
|
227
283
|
if (query.sort) options.sort = query.sort;
|
|
284
|
+
// ADR-0094: the CRUD grid's search + single-column sort direction.
|
|
285
|
+
if (query.search !== undefined) options.search = query.search;
|
|
286
|
+
if (query.sort_dir !== undefined) options.sortDir = query.sort_dir;
|
|
228
287
|
if (query.page) options.page = parseInt(query.page, 10);
|
|
229
288
|
if (query.limit) options.limit = parseInt(query.limit, 10);
|
|
230
289
|
// Allow ?offset= as an alternative to ?page= (offset-based pagination)
|
|
@@ -802,7 +802,14 @@ export class SQLTranslator {
|
|
|
802
802
|
if (limit === undefined || limit === null || limit <= 0) return sql;
|
|
803
803
|
if (SQLTranslator.hasTrailingLimit(sql)) return sql;
|
|
804
804
|
|
|
805
|
-
|
|
805
|
+
// Strip trailing whitespace/semicolons with a linear end-scan rather than a
|
|
806
|
+
// `/[\s;]+$/` replace, which backtracks superlinearly on a long run of
|
|
807
|
+
// whitespace/semicolons followed by a non-matching tail (ReDoS-safe). Plain
|
|
808
|
+
// character comparison — no regex.
|
|
809
|
+
const trailing = " \t\n\r\f\v;";
|
|
810
|
+
let end = sql.length;
|
|
811
|
+
while (end > 0 && trailing.includes(sql[end - 1])) end--;
|
|
812
|
+
const trimmed = sql.slice(0, end);
|
|
806
813
|
const suffix = offset !== undefined && offset > 0
|
|
807
814
|
? `LIMIT ${limit} OFFSET ${offset}`
|
|
808
815
|
: `LIMIT ${limit}`;
|
|
@@ -235,4 +235,23 @@ export interface QueryOptions {
|
|
|
235
235
|
sort?: string;
|
|
236
236
|
page?: number;
|
|
237
237
|
limit?: number;
|
|
238
|
+
/**
|
|
239
|
+
* ADR-0094: full-text search term. LIKE %term% is OR'd across `searchColumns`
|
|
240
|
+
* and added to the WHERE before limit/offset, so the total reflects the
|
|
241
|
+
* filtered set. The CRUD admin grid (and any client) drives this via ?search=.
|
|
242
|
+
*/
|
|
243
|
+
search?: string;
|
|
244
|
+
/**
|
|
245
|
+
* DB columns the `search` term matches against (the model's declared
|
|
246
|
+
* string/text columns, already resolved to their mapped column names). The
|
|
247
|
+
* caller resolves these from the model; buildQuery only quotes and binds them,
|
|
248
|
+
* so nothing but a declared column can reach SQL (ADR-0069).
|
|
249
|
+
*/
|
|
250
|
+
searchColumns?: string[];
|
|
251
|
+
/**
|
|
252
|
+
* ADR-0094: direction for a single bare ?sort=column (the CRUD grid's
|
|
253
|
+
* spelling). "desc" sorts descending; anything else ascending. Ignored for a
|
|
254
|
+
* Mongo-style "-field,field" sort, which carries its own inline direction.
|
|
255
|
+
*/
|
|
256
|
+
sortDir?: string;
|
|
238
257
|
}
|
|
@@ -35,6 +35,29 @@ export declare function devMutationDenial(req: Tina4Request): {
|
|
|
35
35
|
* governs it instead.
|
|
36
36
|
*/
|
|
37
37
|
export declare function devHostAllowed(hostHeader: string | undefined | null): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* True when raw peer `ip` falls inside `entry` (a bare IP or a CIDR). IPv4 and
|
|
40
|
+
* IPv6 via node:net's BlockList; an IPv4-mapped IPv6 peer (::ffff:a.b.c.d) is
|
|
41
|
+
* matched as IPv4. `ip` is the real socket peer, `entry` is operator config -
|
|
42
|
+
* never a forwarded header.
|
|
43
|
+
*/
|
|
44
|
+
export declare function ipInCidr(ip: string, entry: string): boolean;
|
|
45
|
+
/**
|
|
46
|
+
* True when the RAW socket peer may reach /__dev: loopback always, plus any
|
|
47
|
+
* IP/CIDR in TINA4_DEV_ALLOWED_PEERS (comma-separated, opt-in, default none).
|
|
48
|
+
* Reads the real socket peer ONLY, never a forwarded header, so it cannot be
|
|
49
|
+
* spoofed by X-Forwarded-For. The documented way to reach the dev dashboard from
|
|
50
|
+
* a Docker dev box, where requests arrive from the container-network gateway
|
|
51
|
+
* rather than loopback (issue #279).
|
|
52
|
+
*/
|
|
53
|
+
export declare function devPeerAllowed(remoteIp: string | undefined | null): boolean;
|
|
54
|
+
/**
|
|
55
|
+
* True when the toolbar may be injected for this viewer - the Host allow-list
|
|
56
|
+
* and the raw-peer gate both pass. A viewer the /__dev gate would refuse gets NO
|
|
57
|
+
* toolbar markup, so its stylesheet and script are never requested only to 403
|
|
58
|
+
* (issue #279).
|
|
59
|
+
*/
|
|
60
|
+
export declare function devToolbarAllowed(req: Tina4Request): boolean;
|
|
38
61
|
/**
|
|
39
62
|
* Return `{status, error}` to REFUSE any /__dev request (read or write), or
|
|
40
63
|
* `null` to allow (ADR-0082): Host allow-list, then same-origin, then the
|
|
@@ -58,6 +58,8 @@ interface MessengerOptions {
|
|
|
58
58
|
imapPass?: string;
|
|
59
59
|
/** IMAP transport security: "tls" (default), "starttls", or "none". */
|
|
60
60
|
imapEncryption?: string;
|
|
61
|
+
/** SMTP send timeout in whole SECONDS, at least 1 (default 30; env TINA4_MAIL_TIMEOUT). */
|
|
62
|
+
timeout?: number;
|
|
61
63
|
}
|
|
62
64
|
export interface ImapMessage {
|
|
63
65
|
uid: string;
|
|
@@ -114,6 +116,8 @@ export declare class Messenger {
|
|
|
114
116
|
private imapUser;
|
|
115
117
|
private imapPass;
|
|
116
118
|
private imapEncryption;
|
|
119
|
+
/** Resolved SMTP send timeout, in milliseconds (see the constructor). */
|
|
120
|
+
readonly timeoutMs: number;
|
|
117
121
|
constructor(options?: MessengerOptions);
|
|
118
122
|
/**
|
|
119
123
|
* How the SMTP connection is secured (ADR-0071 section 1). Port 465 is the
|
|
@@ -352,11 +352,6 @@ export declare function resolveStringMiddleware(spec: string): Promise<Middlewar
|
|
|
352
352
|
* was inert. Python and PHP both ran per-route class hooks already.
|
|
353
353
|
*/
|
|
354
354
|
export declare function runRouteMiddlewares(middlewares: MiddlewareSpec[], req: Tina4Request, res: Tina4Response): Promise<boolean>;
|
|
355
|
-
/**
|
|
356
|
-
* Default global router instance.
|
|
357
|
-
* Top-level get(), post(), etc. register routes here.
|
|
358
|
-
* The server merges these routes on startup.
|
|
359
|
-
*/
|
|
360
355
|
export declare const defaultRouter: Router;
|
|
361
356
|
/**
|
|
362
357
|
* Top-level route registration functions — mirrors Python's decorator pattern.
|
|
@@ -49,6 +49,12 @@ export declare const TEMPLATE_CACHE_MAX = 256;
|
|
|
49
49
|
export declare const MEMO_CACHE_MAX = 1024;
|
|
50
50
|
/** Cached expression dispatcher branch; exported only for cache-bound verification. */
|
|
51
51
|
export declare const expressionFormCache: Map<string, number>;
|
|
52
|
+
/**
|
|
53
|
+
* The framework's ONE canonical HTML escaper (the five-character table `& < > " '`,
|
|
54
|
+
* byte-identical across all four Tina4 frameworks). Exported so a component such as
|
|
55
|
+
* Crud.toCrud never carries its own copy of the escape table (ADR-0094).
|
|
56
|
+
*/
|
|
57
|
+
export declare function escapeHtml(value: unknown): string;
|
|
52
58
|
/**
|
|
53
59
|
* Set the session ID used by formToken() / form_token() for CSRF session binding.
|
|
54
60
|
*/
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { Frond } from "./engine.js";
|
|
1
|
+
export { Frond, escapeHtml, setCspNonceProvider } from "./engine.js";
|
|
2
2
|
export type { FilterFn, TestFn, LiveProvider, LiveRequest, LiveResponse, LiveBroadcaster } from "./engine.js";
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crud (ADR-0094) — a FRONTEND over AutoCrud.
|
|
3
|
+
*
|
|
4
|
+
* `Crud.toCrud(request, options)` renders a complete server-rendered admin UI
|
|
5
|
+
* (searchable, sortable, paginated table + create/edit/delete modals) for an ORM
|
|
6
|
+
* model. It owns NO backend routes: the entire REST backend (GET list, GET /{id},
|
|
7
|
+
* POST, PUT, DELETE, secure-by-default) is delegated to AutoCrud, and the UI's
|
|
8
|
+
* JavaScript talks to those routes over fetch(). The HTML comes from four
|
|
9
|
+
* app-overridable Frond templates under `crud/` (page, table, form, modals),
|
|
10
|
+
* resolved app-first-then-framework (the same convention the error pages use), so
|
|
11
|
+
* an app restyles the admin by dropping its own `src/templates/crud/<name>.twig`.
|
|
12
|
+
*
|
|
13
|
+
* Lives in @tina4/orm (next to AutoCrud, the ORM it drives) rather than in the
|
|
14
|
+
* eager @tina4/core barrel: Crud is an opt-in feature, not on every request
|
|
15
|
+
* path, so it must not be pulled into core's import graph (lazyFeatureLoading).
|
|
16
|
+
*
|
|
17
|
+
* Node returns `Promise<string>` (the DB adapters are async); the Python/PHP/Ruby
|
|
18
|
+
* entry points return a string. generateTable / generateForm stay synchronous.
|
|
19
|
+
*
|
|
20
|
+
* Usage:
|
|
21
|
+
* get("/admin/users", async (request, response) =>
|
|
22
|
+
* response.html(await Crud.toCrud(request, { model: User, title: "Users" }))
|
|
23
|
+
* ).secure();
|
|
24
|
+
*
|
|
25
|
+
* A custom `sql` only shapes the LISTING grid (a filter/join/projection); the
|
|
26
|
+
* model still drives columns, the primary key, and every write path, so a custom
|
|
27
|
+
* listing can never create an unauthenticated or divergent write route.
|
|
28
|
+
*/
|
|
29
|
+
import type { Tina4Request } from "../../core/src/types.js";
|
|
30
|
+
import { type CrudModel, stripOrderAndLimit } from "./crudHelpers.js";
|
|
31
|
+
import { generateTable, generateForm } from "./crudRender.js";
|
|
32
|
+
export type { CrudModel } from "./crudHelpers.js";
|
|
33
|
+
export type { FormFieldDef } from "./crudRender.js";
|
|
34
|
+
export { generateTable, generateForm } from "./crudRender.js";
|
|
35
|
+
export { stripOrderAndLimit } from "./crudHelpers.js";
|
|
36
|
+
export interface ToCrudOptions {
|
|
37
|
+
/** REQUIRED — the ORM model class (a BaseModel subclass). */
|
|
38
|
+
model: CrudModel;
|
|
39
|
+
/** Optional listing query (inferred from the model when omitted); shapes only the displayed grid. */
|
|
40
|
+
sql?: string;
|
|
41
|
+
/** Page title (default "CRUD"). */
|
|
42
|
+
title?: string;
|
|
43
|
+
/** AutoCrud route prefix (default "/api"). */
|
|
44
|
+
prefix?: string;
|
|
45
|
+
/** Records per page (default 10). */
|
|
46
|
+
limit?: number;
|
|
47
|
+
/** When true, the AutoCrud write routes are OPEN (no auth). Default false (secure). */
|
|
48
|
+
public?: boolean;
|
|
49
|
+
}
|
|
50
|
+
/** Reset the registration cache (tests). */
|
|
51
|
+
export declare function _resetCrudRegistrations(): void;
|
|
52
|
+
/**
|
|
53
|
+
* Delegate the ENTIRE backend to AutoCrud. Idempotent per (prefix, table):
|
|
54
|
+
* the first registration wins (so a scaffolded route that opted writes public
|
|
55
|
+
* is not later reset to secure), and addRoute replaces a route in place anyway.
|
|
56
|
+
*/
|
|
57
|
+
export declare function registerBackend(model: CrudModel, options?: {
|
|
58
|
+
prefix?: string;
|
|
59
|
+
public?: boolean;
|
|
60
|
+
}): Promise<void>;
|
|
61
|
+
/**
|
|
62
|
+
* Render the CRUD admin page for `model` and register its AutoCrud routes.
|
|
63
|
+
* Returns the rendered crud/page template.
|
|
64
|
+
*/
|
|
65
|
+
export declare function toCrud(request: Tina4Request, options: ToCrudOptions): Promise<string>;
|
|
66
|
+
/** The public Crud surface (ADR-0094). `CRUD` is an uppercase alias. */
|
|
67
|
+
export declare const Crud: {
|
|
68
|
+
toCrud: typeof toCrud;
|
|
69
|
+
registerBackend: typeof registerBackend;
|
|
70
|
+
generateTable: typeof generateTable;
|
|
71
|
+
generateForm: typeof generateForm;
|
|
72
|
+
stripOrderAndLimit: typeof stripOrderAndLimit;
|
|
73
|
+
_resetCrudRegistrations: typeof _resetCrudRegistrations;
|
|
74
|
+
};
|
|
75
|
+
export declare const CRUD: {
|
|
76
|
+
toCrud: typeof toCrud;
|
|
77
|
+
registerBackend: typeof registerBackend;
|
|
78
|
+
generateTable: typeof generateTable;
|
|
79
|
+
generateForm: typeof generateForm;
|
|
80
|
+
stripOrderAndLimit: typeof stripOrderAndLimit;
|
|
81
|
+
_resetCrudRegistrations: typeof _resetCrudRegistrations;
|
|
82
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crud (ADR-0094) — the pure helpers behind the HTML admin page.
|
|
3
|
+
*
|
|
4
|
+
* No I/O, no templates, no DB: column/label/alignment derivation, safe-sort
|
|
5
|
+
* resolution, the pre-joined table-data hash, the pagination controls, the
|
|
6
|
+
* injected JS config, and the two linear SQL-listing parsers. Kept in their own
|
|
7
|
+
* module so `crud.ts` stays a thin orchestrator and no single file carries the
|
|
8
|
+
* whole feature (the second-pass split by concern).
|
|
9
|
+
*/
|
|
10
|
+
import { escapeHtml } from "../../frond/src/engine.js";
|
|
11
|
+
/** A BaseModel-like class: the statics Crud reads + the query statics it calls. */
|
|
12
|
+
export interface CrudModel {
|
|
13
|
+
tableName: string;
|
|
14
|
+
fields: Record<string, {
|
|
15
|
+
type?: string;
|
|
16
|
+
primaryKey?: boolean;
|
|
17
|
+
[k: string]: unknown;
|
|
18
|
+
}>;
|
|
19
|
+
fieldMapping?: Record<string, string>;
|
|
20
|
+
softDelete?: boolean;
|
|
21
|
+
tableFilter?: string;
|
|
22
|
+
name?: string;
|
|
23
|
+
all(limit?: number, offset?: number, include?: string[], orderBy?: string): Promise<any>;
|
|
24
|
+
where(conditions: string, params?: unknown[], limit?: number, offset?: number, include?: string[], orderBy?: string): Promise<any>;
|
|
25
|
+
count(conditions?: string, params?: unknown[]): Promise<number>;
|
|
26
|
+
}
|
|
27
|
+
/** Escape alias used throughout the CRUD HTML assembly. */
|
|
28
|
+
export declare const h: typeof escapeHtml;
|
|
29
|
+
/** The DB column a property name maps to (fieldMapping or the property itself). */
|
|
30
|
+
export declare function columnOf(model: CrudModel, prop: string): string;
|
|
31
|
+
/** The primary-key property name (the field flagged primaryKey, else "id"). */
|
|
32
|
+
export declare function primaryKeyProp(model: CrudModel): string;
|
|
33
|
+
/** The model's declared string/text property names. */
|
|
34
|
+
export declare function searchableProps(model: CrudModel): string[];
|
|
35
|
+
/** Column alignment from the field type: numeric right, everything else left. */
|
|
36
|
+
export declare function columnAlignment(model: CrudModel | null, col: string): string;
|
|
37
|
+
/** Pretty label from a column name: "user_name" => "User Name". */
|
|
38
|
+
export declare function prettyLabel(col: string): string;
|
|
39
|
+
/**
|
|
40
|
+
* ADR-0069 safe sort: resolve the requested sort to a declared field PROPERTY
|
|
41
|
+
* (the grid's headers + client state are keyed by property), else the primary
|
|
42
|
+
* key. A request may name the field by its property or its mapped column; the
|
|
43
|
+
* DB column for the ORDER BY is derived with columnOf(). A rendered page ignores
|
|
44
|
+
* a bad sort rather than erroring.
|
|
45
|
+
*/
|
|
46
|
+
export declare function crudSortProp(model: CrudModel, requested: string | undefined, pkProp: string): string;
|
|
47
|
+
/** Read one column value from a record hash (keyed by property name). */
|
|
48
|
+
export declare function cellValue(record: Record<string, unknown>, col: string): unknown;
|
|
49
|
+
/** Build the pre-joined <td> run for one row (every value escaped + aligned). */
|
|
50
|
+
export declare function buildCells(columns: string[], record: Record<string, unknown>, editable: boolean, aligns: string[]): string;
|
|
51
|
+
/**
|
|
52
|
+
* Drop a trailing ORDER BY / LIMIT clause from each line of a custom listing SQL.
|
|
53
|
+
* Pure string scanning — no backtracking regex.
|
|
54
|
+
*/
|
|
55
|
+
export declare function stripOrderAndLimit(sql: string): string;
|
|
56
|
+
/**
|
|
57
|
+
* The column names a custom listing SQL selects (for the search CAST LIKE).
|
|
58
|
+
* Linear string scan: locate the first SELECT / FROM keywords (whitespace
|
|
59
|
+
* bounded, case-insensitive) and split the projection on commas — no
|
|
60
|
+
* backtracking regex over caller-supplied SQL (ReDoS-safe), mirroring the
|
|
61
|
+
* Ruby master's plain-string SQL parsing.
|
|
62
|
+
*/
|
|
63
|
+
export declare function extractColumns(sql: string): string[];
|