ascua-scaffold 0.2.0 → 0.4.0
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/README.md +40 -5
- package/generate.js +279 -43
- package/index.js +187 -88
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -54,10 +54,18 @@ y vuelve a generar el módulo de brasa con las consultas nuevas.
|
|
|
54
54
|
enum, `type="date"` para fechas, `field.email` para una columna que se
|
|
55
55
|
llame `email`, `<textarea>` para `description`, `body` o `notes`. Lo
|
|
56
56
|
nullable es opcional y vacío se guarda como `NULL`.
|
|
57
|
+
- **Relaciones**: una columna con clave foránea (`category_id integer
|
|
58
|
+
REFERENCES categories`) se elige de un `<select>` con las filas de la otra
|
|
59
|
+
tabla, por su nombre —la columna `name`, `title`, `email`… que tenga—, y la
|
|
60
|
+
lista muestra ese nombre en vez del número. Las opciones salen de una
|
|
61
|
+
consulta generada, `ProductCategoryIdOptions`, que puedes cambiar.
|
|
57
62
|
- **Formularios de HTML**, sin islas: funcionan sin una línea de JavaScript.
|
|
58
63
|
Un error de validación vuelve a la página con un 400, cada mensaje bajo su
|
|
59
|
-
campo y lo escrito en su sitio.
|
|
60
|
-
|
|
64
|
+
campo y lo escrito en su sitio.
|
|
65
|
+
- **Lo que la base rechaza vuelve al formulario** con un 409, no con un 500:
|
|
66
|
+
un `UNIQUE` repetido («Already taken.»), una clave foránea que no existe
|
|
67
|
+
(«Does not exist.») y borrar una fila que otras usan («Still used by other
|
|
68
|
+
rows.»), con el error bajo su columna. En Postgres y en MySQL.
|
|
61
69
|
- **La clave de la URL se valida** antes de llegar a la base: `/products/abc`
|
|
62
70
|
es un 404. Un `bigint` entra como texto, para no perder precisión.
|
|
63
71
|
|
|
@@ -80,14 +88,40 @@ un `<form>` de HTML, así que también funciona sin JavaScript.
|
|
|
80
88
|
(`ProductForm`) y se guardan todas en una sola transacción
|
|
81
89
|
(`transaction()` de brasa): si una falla, no se escribe ninguna, y el
|
|
82
90
|
error aparece en su celda.
|
|
83
|
-
-
|
|
84
|
-
fila.
|
|
91
|
+
- Lo que la base rechaza —un `UNIQUE` repetido, una clave foránea que no
|
|
92
|
+
existe— vuelve con un 409 y el error en esa fila.
|
|
93
|
+
|
|
94
|
+
## El admin
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
stil exec ascua-scaffold --admin
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Genera todas las tablas a la vez bajo `/admin`, como el admin de Django:
|
|
101
|
+
lista, alta, edición y grilla de cada una, un panel en `/admin` y una barra
|
|
102
|
+
con las tablas.
|
|
103
|
+
|
|
104
|
+
- **Pide usuario y contraseña** (HTTP Basic): `ADMIN_PASSWORD` y
|
|
105
|
+
`ADMIN_USER` («admin» por defecto). **Sin `ADMIN_PASSWORD`, `/admin` es un
|
|
106
|
+
404**: nunca queda abierto por olvido. Va en `src/routes/admin/_middleware.ts`,
|
|
107
|
+
para cambiarlo por el inicio de sesión del sitio (passkeys, una sesión)
|
|
108
|
+
cuando lo tenga.
|
|
109
|
+
- **Reutiliza lo que hay**: una tabla que ya tiene sus consultas y su
|
|
110
|
+
`src/resources/<tabla>.ts` —de un `ascua-scaffold <tabla>` anterior— los
|
|
111
|
+
conserva y solo recibe sus páginas del admin.
|
|
112
|
+
- **Lo que no puede generar lo salta y dice por qué**: vistas, claves
|
|
113
|
+
compuestas, columnas obligatorias que el formulario no sabe editar. El
|
|
114
|
+
panel las lista.
|
|
115
|
+
- **Se puede volver a correr**: tras una migración nueva, agrega las tablas
|
|
116
|
+
nuevas y deja como están las que ya tenían página. `--force` las reescribe
|
|
117
|
+
todas.
|
|
85
118
|
|
|
86
119
|
## Requisitos
|
|
87
120
|
|
|
88
121
|
- Un sitio con servidor: `ascua({ site: { mode: "server" } })` en
|
|
89
122
|
`vite.config.ts` (`create-ascua --template server`).
|
|
90
|
-
- `brasa.json` y las migraciones de brasa (`brasa init`).
|
|
123
|
+
- `brasa.json` y las migraciones de brasa (`brasa init`). Las relaciones
|
|
124
|
+
piden brasa-sql 0.2.0 o más nuevo, que describe las claves foráneas.
|
|
91
125
|
- Una tabla con clave primaria de una sola columna: entero, texto o uuid. Si
|
|
92
126
|
es `serial` o tiene `DEFAULT`, la pone la base; si no, se pide al crear y
|
|
93
127
|
no se cambia al editar.
|
|
@@ -97,6 +131,7 @@ un `<form>` de HTML, así que también funciona sin JavaScript.
|
|
|
97
131
|
|
|
98
132
|
```text
|
|
99
133
|
ascua-scaffold <tabla> [--config brasa.json] [--force]
|
|
134
|
+
ascua-scaffold --admin [--config brasa.json] [--force]
|
|
100
135
|
```
|
|
101
136
|
|
|
102
137
|
## Licencia
|
package/generate.js
CHANGED
|
@@ -71,7 +71,7 @@ export class ScaffoldError extends Error {}
|
|
|
71
71
|
* Qué columnas van en cada parte del recurso. Lanza `ScaffoldError` si la
|
|
72
72
|
* tabla no se puede generar.
|
|
73
73
|
*/
|
|
74
|
-
export function analizar(tabla, { mysql }) {
|
|
74
|
+
export function analizar(tabla, { mysql, tablas = [] }) {
|
|
75
75
|
if (tabla.view) throw new ScaffoldError(t(`\`${tabla.name}\` es una vista: no se puede editar`, `\`${tabla.name}\` is a view: it cannot be edited`));
|
|
76
76
|
if (tabla.primaryKey.length !== 1) {
|
|
77
77
|
throw new ScaffoldError(
|
|
@@ -115,9 +115,33 @@ export function analizar(tabla, { mysql }) {
|
|
|
115
115
|
if (alEditar.length === 0) throw new ScaffoldError(t(`\`${tabla.name}\` no tiene columnas que editar`, `\`${tabla.name}\` has no columns to edit`));
|
|
116
116
|
|
|
117
117
|
const visibles = tabla.columns.filter((c) => c !== clave && EDITABLES.has(c.kind) && !c.array).slice(0, 4);
|
|
118
|
-
|
|
118
|
+
|
|
119
|
+
// Las columnas que apuntan a otra tabla se eligen de una lista.
|
|
120
|
+
const relaciones = [];
|
|
121
|
+
for (const c of alCrear) {
|
|
122
|
+
const destino = c.references && tablas.find((x) => x.name === c.references.table);
|
|
123
|
+
if (!destino) continue;
|
|
124
|
+
relaciones.push({ columna: c, tabla: destino.name, valor: c.references.column, etiqueta: etiquetaDe(destino, c.references.column) });
|
|
125
|
+
}
|
|
126
|
+
return { clave, claveAutomatica, alCrear, alEditar, fuera, visibles, relaciones, mysql };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Las columnas que se prefieren para nombrar una fila de otra tabla. */
|
|
130
|
+
const ROTULOS = ["name", "title", "label", "full_name", "display_name", "username", "email", "code", "slug"];
|
|
131
|
+
|
|
132
|
+
/** Con qué columna se muestra una fila de `tabla` en una lista: su nombre, si tiene, o la clave. */
|
|
133
|
+
function etiquetaDe(tabla, valor) {
|
|
134
|
+
const texto = tabla.columns.filter((c) => c.kind === "text" && !c.array);
|
|
135
|
+
const preferida = ROTULOS.map((r) => texto.find((c) => c.name.toLowerCase() === r)).find(Boolean);
|
|
136
|
+
return (preferida ?? texto.find((c) => c.name !== valor) ?? { name: valor }).name;
|
|
119
137
|
}
|
|
120
138
|
|
|
139
|
+
/** La relación de una columna, si apunta a otra tabla. */
|
|
140
|
+
const relacionDe = (a, c) => a.relaciones.find((r) => r.columna === c);
|
|
141
|
+
|
|
142
|
+
/** El nombre de la consulta con las opciones de una columna: ProductCategoryIdOptions. */
|
|
143
|
+
export const consultaOpciones = (n, r) => `${n.uno}${pascal(r.columna.name)}Options`;
|
|
144
|
+
|
|
121
145
|
/** Los nombres de las consultas del recurso. */
|
|
122
146
|
export function nombres(tabla) {
|
|
123
147
|
const plural = pascal(tabla.name);
|
|
@@ -154,7 +178,14 @@ export function consultasSql(tabla, a, n) {
|
|
|
154
178
|
`-- name: ${n.get} :one\nSELECT * FROM ${tab} WHERE ${pk} = :${a.clave.name};\n\n` +
|
|
155
179
|
`${crear}\n\n` +
|
|
156
180
|
actualizar +
|
|
157
|
-
`-- name: ${n.delete} :execrows\nDELETE FROM ${tab} WHERE ${pk} = :${a.clave.name};\n`
|
|
181
|
+
`-- name: ${n.delete} :execrows\nDELETE FROM ${tab} WHERE ${pk} = :${a.clave.name};\n` +
|
|
182
|
+
a.relaciones
|
|
183
|
+
.map(
|
|
184
|
+
(r) =>
|
|
185
|
+
`\n-- name: ${consultaOpciones(n, r)} :many\n-- The choices for ${r.columna.name}, by ${r.etiqueta}.\n` +
|
|
186
|
+
`SELECT ${q(r.valor)} AS value, ${q(r.etiqueta)} AS label FROM ${q(r.tabla)}\nORDER BY ${q(r.etiqueta)}\nLIMIT 1000;\n`,
|
|
187
|
+
)
|
|
188
|
+
.join("")
|
|
158
189
|
);
|
|
159
190
|
}
|
|
160
191
|
|
|
@@ -183,14 +214,23 @@ function campo(c, valoresEnum) {
|
|
|
183
214
|
}
|
|
184
215
|
|
|
185
216
|
/** El control HTML de una columna, dentro del `view` de `…Fields`. */
|
|
186
|
-
function control(c, valoresEnum) {
|
|
217
|
+
function control(c, valoresEnum, relacion) {
|
|
187
218
|
const nombre = c.name;
|
|
188
219
|
const v = `values${acceso(nombre)}`;
|
|
189
220
|
const texto = `String(${v} ?? "")`;
|
|
190
221
|
// El esquema exige todo lo NOT NULL salvo casillas y texto con DEFAULT (que acepta vacío).
|
|
191
222
|
const req = !c.nullable && c.kind !== "boolean" && !(c.kind === "text" && c.hasDefault) ? " required" : "";
|
|
192
223
|
let html;
|
|
193
|
-
switch (c.kind) {
|
|
224
|
+
switch (relacion ? "relacion" : c.kind) {
|
|
225
|
+
case "relacion": {
|
|
226
|
+
const vacia = c.nullable ? `<option value=""></option>` : `<option value="" disabled prop:selected=\${!${v}}>Choose…</option>`;
|
|
227
|
+
html =
|
|
228
|
+
`<select name="${nombre}"${req}>${vacia}\n` +
|
|
229
|
+
` <For each=\${() => props.options${acceso(nombre)}} key=\${(o: Option) => o.value}\n` +
|
|
230
|
+
` render=\${(o: Option) => view\`<option value=\${o.value} prop:selected=\${${v} === o.value}>\${o.label}</option>\`}/>\n` +
|
|
231
|
+
` </select>`;
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
194
234
|
case "boolean":
|
|
195
235
|
html = `<input type="checkbox" name="${nombre}" prop:checked=\${${v} === "on"}>`;
|
|
196
236
|
break;
|
|
@@ -246,26 +286,42 @@ function aTexto(c, fila) {
|
|
|
246
286
|
}
|
|
247
287
|
|
|
248
288
|
/**
|
|
249
|
-
* `
|
|
250
|
-
* formulario con un 409 en vez de un 500.
|
|
251
|
-
*
|
|
289
|
+
* `rejected()` del recurso: lo que la base rechaza por una restricción vuelve
|
|
290
|
+
* al formulario con un 409 y el error bajo su columna, en vez de un 500. Un
|
|
291
|
+
* UNIQUE repetido, una clave foránea que no existe y borrar algo que otras
|
|
292
|
+
* filas usan. Postgres dice la columna en el `detail`; MySQL, en el mensaje
|
|
293
|
+
* de la clave foránea, y en un UNIQUE solo el índice: ahí el error es general.
|
|
252
294
|
*/
|
|
253
295
|
function duplicado(mysql) {
|
|
254
296
|
const deteccion = mysql
|
|
255
|
-
? `
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
297
|
+
? ` const code = e?.code;
|
|
298
|
+
// "… FOREIGN KEY (\`supplier_id\`) REFERENCES …"
|
|
299
|
+
const column = /FOREIGN KEY \\(\`([^\`]+)\`\\)/.exec(String(e?.message))?.[1] ?? "";
|
|
300
|
+
if (code === "ER_DUP_ENTRY") return reject("", "Already taken.");
|
|
301
|
+
if (code === "ER_NO_REFERENCED_ROW_2") return reject(column, "Does not exist.");
|
|
302
|
+
if (code === "ER_ROW_IS_REFERENCED_2") return reject("", "Still used by other rows.");
|
|
303
|
+
throw error;`
|
|
304
|
+
: ` // "Key (sku)=(H-1) already exists.", "Key (supplier_id)=(9) is not present in table …"
|
|
305
|
+
const named = /^Key \\(([^)]+)\\)=/.exec(String(e?.detail))?.[1];
|
|
306
|
+
const column = named && !named.includes(",") ? named : "";
|
|
307
|
+
if (e?.code === "23505") return reject(column, "Already taken.");
|
|
308
|
+
if (e?.code === "23503") {
|
|
309
|
+
// Deleting a row that others point to says "still referenced".
|
|
310
|
+
if (/still referenced/.test(String(e.detail))) return reject("", "Still used by other rows.");
|
|
311
|
+
return reject(column, "Does not exist.");
|
|
312
|
+
}
|
|
313
|
+
throw error;`;
|
|
261
314
|
return `/**
|
|
262
|
-
*
|
|
263
|
-
* the error under
|
|
315
|
+
* What the database rejected because of a constraint, as the same form again
|
|
316
|
+
* with the error under its field (409): a repeated UNIQUE value, a key of
|
|
317
|
+
* another table that does not exist, deleting a row others still use. Any
|
|
318
|
+
* other error is thrown again.
|
|
264
319
|
*/
|
|
265
|
-
export function
|
|
266
|
-
const e = error as { code?: unknown; detail?: unknown } | null;
|
|
320
|
+
export function rejected(error: unknown, formData: FormData) {
|
|
321
|
+
const e = error as { code?: unknown; detail?: unknown; message?: unknown } | null;
|
|
322
|
+
const reject = (field: string, message: string) =>
|
|
323
|
+
fail(409, { ok: false as const, errors: { [field]: message }, values: texts(formData) });
|
|
267
324
|
${deteccion}
|
|
268
|
-
return fail(409, { ok: false as const, errors: { [field]: "Already taken." }, values: texts(formData) });
|
|
269
325
|
}
|
|
270
326
|
|
|
271
327
|
/** The text fields of a form, to fill it in again. */
|
|
@@ -285,7 +341,30 @@ export function recurso(tabla, a, n, { db, enums }) {
|
|
|
285
341
|
const conClave = !a.claveAutomatica;
|
|
286
342
|
|
|
287
343
|
const esquema = a.alEditar.map((c) => ` ${propiedad(c.name)}: ${campo(c, valoresEnum(c))},`).join("\n");
|
|
288
|
-
const controles = (cols) => cols.map((c) => control(c, valoresEnum(c))).join("\n");
|
|
344
|
+
const controles = (cols) => cols.map((c) => control(c, valoresEnum(c), relacionDe(a, c))).join("\n");
|
|
345
|
+
const funcionesOpciones = a.relaciones.map((r) => camel(consultaOpciones(n, r)));
|
|
346
|
+
const opciones = a.relaciones.length
|
|
347
|
+
? `
|
|
348
|
+
/** The choices of the columns that point to other tables, as text. */
|
|
349
|
+
export async function ${camel(n.uno)}Options(db: Queryable): Promise<Options> {
|
|
350
|
+
const [${a.relaciones.map((r) => camel(r.columna.name)).join(", ")}] = await Promise.all([
|
|
351
|
+
${funcionesOpciones.map((f) => ` ${f}(db),`).join("\n")}
|
|
352
|
+
]);
|
|
353
|
+
const text = (rows: { value: unknown; label: unknown }[]) => rows.map((o) => ({ value: String(o.value), label: String(o.label ?? o.value) }));
|
|
354
|
+
return {
|
|
355
|
+
${a.relaciones.map((r) => ` ${propiedad(r.columna.name)}: text(${camel(r.columna.name)}),`).join("\n")}
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
`
|
|
359
|
+
: `
|
|
360
|
+
/** The choices of the columns that point to other tables: ${tabla.name} has none. */
|
|
361
|
+
export async function ${camel(n.uno)}Options(_db: Queryable): Promise<Options> {
|
|
362
|
+
return {};
|
|
363
|
+
}
|
|
364
|
+
`;
|
|
365
|
+
const tipoOpciones = a.relaciones.length
|
|
366
|
+
? `{ ${a.relaciones.map((r) => `${propiedad(r.columna.name)}: Option[]`).join("; ")} }`
|
|
367
|
+
: "Record<string, never>";
|
|
289
368
|
const aParams = a.alEditar
|
|
290
369
|
.map((c) => {
|
|
291
370
|
const v = `data${acceso(c.name)}`;
|
|
@@ -326,7 +405,7 @@ export const New${n.uno}Form = ${n.uno}Form;
|
|
|
326
405
|
import type { Infer } from "ascua";
|
|
327
406
|
import { fail, field, fields } from "vite-plugin-ascua/site";
|
|
328
407
|
|
|
329
|
-
import { ${[...importsEnum, `type ${n.get}Row`].join(", ")} } from "${db}";
|
|
408
|
+
import { ${[...importsEnum, ...funcionesOpciones, "type Queryable", `type ${n.get}Row`].join(", ")} } from "${db}";
|
|
330
409
|
|
|
331
410
|
const shape = {
|
|
332
411
|
${esquema}
|
|
@@ -338,6 +417,9 @@ ${nuevoEsquema}
|
|
|
338
417
|
export type ${n.uno}Data = Infer<typeof ${n.uno}Form>;
|
|
339
418
|
export type FormValues = Partial<Record<string, string | string[]>>;
|
|
340
419
|
export type FormErrors = Partial<Record<string, string>>;
|
|
420
|
+
/** One choice of a <select>: the value it sends and what it shows. */
|
|
421
|
+
export type Option = { value: string; label: string };
|
|
422
|
+
export type Options = ${tipoOpciones};
|
|
341
423
|
${fechas}
|
|
342
424
|
/** The form data as query parameters: empty optional fields become NULL. */
|
|
343
425
|
export function ${camel(n.uno)}Params(data: ${n.uno}Data) {
|
|
@@ -346,7 +428,7 @@ ${aParams}
|
|
|
346
428
|
};
|
|
347
429
|
}
|
|
348
430
|
|
|
349
|
-
${duplicado(a.mysql)}
|
|
431
|
+
${duplicado(a.mysql)}${opciones}
|
|
350
432
|
/** A row as the values of the form. */
|
|
351
433
|
export function ${camel(n.uno)}Values(row: ${n.get}Row): FormValues {
|
|
352
434
|
return {
|
|
@@ -355,7 +437,7 @@ ${aValores}
|
|
|
355
437
|
}
|
|
356
438
|
|
|
357
439
|
/** The fields of the form, with the error of each one under it. */
|
|
358
|
-
export function ${n.uno}Fields(props: { values: FormValues; errors: FormErrors${conClave ? "; withKey?: boolean" : ""} }) {
|
|
440
|
+
export function ${n.uno}Fields(props: { values: FormValues; errors: FormErrors; options: Options${conClave ? "; withKey?: boolean" : ""} }) {
|
|
359
441
|
const { values, errors } = props;
|
|
360
442
|
return view\`
|
|
361
443
|
<div class="fields">
|
|
@@ -393,16 +475,21 @@ export function rutas(tabla, a, n, { db, pool, resource, base }) {
|
|
|
393
475
|
const params = `${camel(n.uno)}Params`;
|
|
394
476
|
const values = `${camel(n.uno)}Values`;
|
|
395
477
|
const campos = `${n.uno}Fields`;
|
|
478
|
+
const opcionesFn = `${camel(n.uno)}Options`;
|
|
396
479
|
|
|
397
480
|
const cabeceras = [pk, ...a.visibles.map((c) => c.name)].map((c) => `<th>${etiqueta(c)}</th>`).join("");
|
|
398
|
-
|
|
481
|
+
// Una columna que apunta a otra tabla se lee por su nombre, no por su clave.
|
|
482
|
+
const conOpciones = a.visibles.some((c) => relacionDe(a, c));
|
|
483
|
+
const celdas = a.visibles
|
|
484
|
+
.map((c) => (relacionDe(a, c) ? `<td>\${label(data.options${acceso(c.name)}, r${acceso(c.name)})}</td>` : `<td>\${show(r${acceso(c.name)})}</td>`))
|
|
485
|
+
.join("");
|
|
399
486
|
|
|
400
487
|
const index = `// Generated by ascua-scaffold. Yours to edit: it is not generated again.
|
|
401
488
|
import type { LoadContext } from "vite-plugin-ascua/site";
|
|
402
489
|
|
|
403
490
|
import { ${camel(n.list)}, type ${n.list}Row } from "${db.index}";
|
|
404
491
|
import { db } from "${pool.index}";
|
|
405
|
-
|
|
492
|
+
${conOpciones ? `import { ${opcionesFn}, type Option } from "${resource.nested}";\n` : ""}
|
|
406
493
|
const PAGE = 50;
|
|
407
494
|
|
|
408
495
|
export const title = "${titulo}";
|
|
@@ -411,7 +498,7 @@ export async function load(_params: Record<string, string>, { url }: LoadContext
|
|
|
411
498
|
const page = Math.max(1, Math.floor(Number(url.searchParams.get("page"))) || 1);
|
|
412
499
|
// One more than a page, to know whether there is a next one.
|
|
413
500
|
const rows = await ${camel(n.list)}(await db(), { limit: PAGE + 1, offset: (page - 1) * PAGE });
|
|
414
|
-
return { page, rows: rows.slice(0, PAGE), more: rows.length > PAGE };
|
|
501
|
+
return { page, rows: rows.slice(0, PAGE), more: rows.length > PAGE${conOpciones ? `, options: await ${opcionesFn}(await db())` : ""} };
|
|
415
502
|
}
|
|
416
503
|
|
|
417
504
|
/** How a value of any column reads in a cell. */
|
|
@@ -421,6 +508,12 @@ function show(value: unknown): string {
|
|
|
421
508
|
if (typeof value === "boolean") return value ? "yes" : "no";
|
|
422
509
|
return String(value);
|
|
423
510
|
}
|
|
511
|
+
${conOpciones ? `
|
|
512
|
+
/** A key of another table, by its name. */
|
|
513
|
+
function label(options: Option[], value: unknown): string {
|
|
514
|
+
return options.find((o) => o.value === String(value))?.label ?? show(value);
|
|
515
|
+
}
|
|
516
|
+
` : ""}
|
|
424
517
|
|
|
425
518
|
export default function ${n.plural}({ data }: { data: Awaited<ReturnType<typeof load>> }) {
|
|
426
519
|
return view\`
|
|
@@ -449,7 +542,7 @@ export default function ${n.plural}({ data }: { data: Awaited<ReturnType<typeof
|
|
|
449
542
|
// Crear dentro de try: un UNIQUE repetido vuelve al formulario. La
|
|
450
543
|
// redirección va fuera, porque también se lanza.
|
|
451
544
|
const intento = (asignar, despues) =>
|
|
452
|
-
`${asignar ? `let ${asignar};\n ` : ""}try {\n ${asignar ? `${asignar} = ` : ""}await ${camel(n.create)}(await db(), ${datos});\n } catch (error) {\n return
|
|
545
|
+
`${asignar ? `let ${asignar};\n ` : ""}try {\n ${asignar ? `${asignar} = ` : ""}await ${camel(n.create)}(await db(), ${datos});\n } catch (error) {\n return rejected(error, formData);\n }\n throw redirect(\`${base}/\${${despues}}\`);`;
|
|
453
546
|
const despuesDeCrear = a.mysql
|
|
454
547
|
? a.claveAutomatica
|
|
455
548
|
? intento("id", "id")
|
|
@@ -461,10 +554,14 @@ import { fail, redirect, validate, type ActionContext, type FormResult } from "v
|
|
|
461
554
|
|
|
462
555
|
import { ${camel(n.create)} } from "${db.nested}";
|
|
463
556
|
import { db } from "${pool.nested}";
|
|
464
|
-
import { ${nuevaForma}, ${campos}, ${params},
|
|
557
|
+
import { ${nuevaForma}, ${campos}, ${params}, ${opcionesFn}, rejected } from "${resource.nested}";
|
|
465
558
|
|
|
466
559
|
export const title = "New ${uno}";
|
|
467
560
|
|
|
561
|
+
export async function load() {
|
|
562
|
+
return { options: await ${opcionesFn}(await db()) };
|
|
563
|
+
}
|
|
564
|
+
|
|
468
565
|
export const actions = {
|
|
469
566
|
async default({ formData }: ActionContext) {
|
|
470
567
|
const result = await validate(${nuevaForma}, formData);
|
|
@@ -473,13 +570,13 @@ export const actions = {
|
|
|
473
570
|
},
|
|
474
571
|
};
|
|
475
572
|
|
|
476
|
-
export default function New${n.uno}({ form }: { form?: FormResult<typeof ${nuevaForma}> }) {
|
|
573
|
+
export default function New${n.uno}({ data, form }: { data: Awaited<ReturnType<typeof load>>; form?: FormResult<typeof ${nuevaForma}> }) {
|
|
477
574
|
const failed = form?.ok === false ? form : undefined;
|
|
478
575
|
return view\`
|
|
479
576
|
<section>
|
|
480
577
|
<h1>New ${uno}</h1>
|
|
481
578
|
<form method="post">
|
|
482
|
-
<${campos} values=\${failed?.values ?? {}} errors=\${failed?.errors ?? {}}${a.claveAutomatica ? "" : " withKey=\${true}"}/>
|
|
579
|
+
<${campos} values=\${failed?.values ?? {}} errors=\${failed?.errors ?? {}} options=\${data.options}${a.claveAutomatica ? "" : " withKey=\${true}"}/>
|
|
483
580
|
<button>Create</button>
|
|
484
581
|
</form>
|
|
485
582
|
<p><a href="${base}">Back</a></p>
|
|
@@ -497,7 +594,7 @@ export default function New${n.uno}({ form }: { form?: FormResult<typeof ${nueva
|
|
|
497
594
|
try {
|
|
498
595
|
changed = await ${camel(n.update)}(await db(), { ...${params}(result.data), ${propiedad(pk)}: id });
|
|
499
596
|
} catch (error) {
|
|
500
|
-
return
|
|
597
|
+
return rejected(error, formData);
|
|
501
598
|
}
|
|
502
599
|
if (changed === 0) throw notFound();
|
|
503
600
|
throw redirect("${base}");
|
|
@@ -509,7 +606,7 @@ import { fail, notFound, redirect, validate, type ActionContext, type FormResult
|
|
|
509
606
|
|
|
510
607
|
import { ${[camel(n.get), a.alEditar.length ? camel(n.update) : null, camel(n.delete)].filter(Boolean).join(", ")} } from "${db.nested}";
|
|
511
608
|
import { db } from "${pool.nested}";
|
|
512
|
-
import { ${forma}, ${campos}, ${params}, ${values},
|
|
609
|
+
import { ${forma}, ${campos}, ${params}, ${values}, ${opcionesFn}, rejected } from "${resource.nested}";
|
|
513
610
|
|
|
514
611
|
type Params = { id: string };
|
|
515
612
|
|
|
@@ -524,23 +621,29 @@ export const title = ({ id }: Params) => \`${etiqueta(singular(tabla.name))} \${
|
|
|
524
621
|
export async function load({ id }: Params) {
|
|
525
622
|
const row = await ${camel(n.get)}(await db(), { ${propiedad(pk)}: key(id) });
|
|
526
623
|
if (!row) throw notFound();
|
|
527
|
-
return row;
|
|
624
|
+
return { row, options: await ${opcionesFn}(await db()) };
|
|
528
625
|
}
|
|
529
626
|
|
|
530
627
|
export const actions = {${actualizar}
|
|
531
|
-
async delete({ params }: ActionContext<Params>) {
|
|
532
|
-
|
|
628
|
+
async delete({ params, formData }: ActionContext<Params>) {
|
|
629
|
+
try {
|
|
630
|
+
await ${camel(n.delete)}(await db(), { ${propiedad(pk)}: key(params.id) });
|
|
631
|
+
} catch (error) {
|
|
632
|
+
return rejected(error, formData);
|
|
633
|
+
}
|
|
533
634
|
throw redirect("${base}");
|
|
534
635
|
},
|
|
535
636
|
};
|
|
536
637
|
|
|
537
638
|
export default function Edit${n.uno}({ data, form }: { data: Awaited<ReturnType<typeof load>>; form?: FormResult<typeof ${forma}> }) {
|
|
538
639
|
const failed = form?.ok === false ? form : undefined;
|
|
640
|
+
// A rejected delete sends no fields: the form keeps showing the row.
|
|
641
|
+
const shown = failed && Object.keys(failed.values).length > 0 ? failed.values : ${values}(data.row);
|
|
539
642
|
return view\`
|
|
540
643
|
<section>
|
|
541
|
-
<h1>${etiqueta(singular(tabla.name))} \${String(${fila("data")})}</h1>
|
|
644
|
+
<h1>${etiqueta(singular(tabla.name))} \${String(${fila("data.row")})}</h1>
|
|
542
645
|
${a.alEditar.length ? `<form method="post">
|
|
543
|
-
<${campos} values=\${
|
|
646
|
+
<${campos} values=\${shown} errors=\${failed?.errors ?? {}} options=\${data.options}/>
|
|
544
647
|
<button>Save</button>
|
|
545
648
|
</form>
|
|
546
649
|
` : ""}<form method="post" action="?/delete">
|
|
@@ -599,12 +702,19 @@ export function readGrid(formData: FormData, columns: readonly string[]): { rows
|
|
|
599
702
|
}
|
|
600
703
|
|
|
601
704
|
/** Una celda editable de la grilla, con su error debajo. */
|
|
602
|
-
function celda(c, valoresEnum) {
|
|
705
|
+
function celda(c, valoresEnum, relacion) {
|
|
603
706
|
const nombre = `\${\`c\${r.index}.${c.name}\`}`;
|
|
604
707
|
const v = `r.values${acceso(c.name)}`;
|
|
605
708
|
const texto = `String(${v} ?? "")`;
|
|
606
709
|
let html;
|
|
607
|
-
switch (c.kind) {
|
|
710
|
+
switch (relacion ? "relacion" : c.kind) {
|
|
711
|
+
case "relacion": {
|
|
712
|
+
const vacia = c.nullable ? `<option value=""></option>` : "";
|
|
713
|
+
html =
|
|
714
|
+
`<select name=${nombre}>${vacia}<For each=\${() => data.options${acceso(c.name)}} key=\${(o: Option) => o.value} ` +
|
|
715
|
+
`render=\${(o: Option) => view\`<option value=\${o.value} prop:selected=\${${v} === o.value}>\${o.label}</option>\`}/></select>`;
|
|
716
|
+
break;
|
|
717
|
+
}
|
|
608
718
|
case "boolean":
|
|
609
719
|
html = `<input type="checkbox" name=${nombre} prop:checked=\${${v} === "on"}>`;
|
|
610
720
|
break;
|
|
@@ -653,7 +763,8 @@ export function rejilla(tabla, a, n, { db, pool, resource, grid, base, enums })
|
|
|
653
763
|
const importsEnum = [...new Set(a.alEditar.filter((c) => c.kind === "enum" && enums.has(c.sqlType)).map((c) => `${camel(c.sqlType)}Values`))];
|
|
654
764
|
const columnas = a.alEditar.map((c) => JSON.stringify(c.name)).join(", ");
|
|
655
765
|
const cabeceras = [pk, ...a.alEditar.map((c) => c.name)].map((c) => `<th>${etiqueta(c)}</th>`).join("");
|
|
656
|
-
const celdas = a.alEditar.map((c) => celda(c, valoresEnumDe(c, enums))).join("\n ");
|
|
766
|
+
const celdas = a.alEditar.map((c) => celda(c, valoresEnumDe(c, enums), relacionDe(a, c))).join("\n ");
|
|
767
|
+
const opcionesFn = `${camel(n.uno)}Options`;
|
|
657
768
|
|
|
658
769
|
return `// Generated by ascua-scaffold. Yours to edit: it is not generated again.
|
|
659
770
|
//
|
|
@@ -665,7 +776,7 @@ import { fail, notFound, redirect, validate, type ActionContext, type LoadContex
|
|
|
665
776
|
import { ${[...importsEnum, camel(n.list), "transaction", camel(n.update)].join(", ")} } from "${db}";
|
|
666
777
|
import { db } from "${pool}";
|
|
667
778
|
import { readGrid, type FormErrors, type FormValues } from "${grid}";
|
|
668
|
-
import { ${forma}, ${params}, ${values},
|
|
779
|
+
import { ${forma}, ${params}, ${values}, ${opcionesFn}, rejected, type ${n.uno}Data, type Option } from "${resource}";
|
|
669
780
|
|
|
670
781
|
const PAGE = 50;
|
|
671
782
|
const COLUMNS = [${columnas}] as const;
|
|
@@ -685,6 +796,7 @@ export async function load(_params: Record<string, string>, { url }: LoadContext
|
|
|
685
796
|
page,
|
|
686
797
|
more: rows.length > PAGE,
|
|
687
798
|
rows: rows.slice(0, PAGE).map((row) => ({ key: String(row${acceso(pk)}), values: ${values}(row) })),
|
|
799
|
+
options: await ${opcionesFn}(await db()),
|
|
688
800
|
};
|
|
689
801
|
}
|
|
690
802
|
|
|
@@ -716,8 +828,8 @@ export const actions = {
|
|
|
716
828
|
}
|
|
717
829
|
});
|
|
718
830
|
} catch (error) {
|
|
719
|
-
// A repeated UNIQUE value: the error goes under that row's cell.
|
|
720
|
-
const taken =
|
|
831
|
+
// A repeated UNIQUE value or a missing key: the error goes under that row's cell.
|
|
832
|
+
const taken = rejected(error, new FormData());
|
|
721
833
|
return fail(409, { errors: { [current]: taken.data.errors }, values } satisfies Failed);
|
|
722
834
|
}
|
|
723
835
|
throw redirect(url.pathname + url.search);
|
|
@@ -765,6 +877,130 @@ export default function ${n.plural}Grid({ data, form }: { data: Awaited<ReturnTy
|
|
|
765
877
|
`;
|
|
766
878
|
}
|
|
767
879
|
|
|
880
|
+
/** `src/resources/admin.ts`: las tablas del admin. Se reescribe en cada `--admin`. */
|
|
881
|
+
export function datosAdmin(incluidas, saltadas) {
|
|
882
|
+
const filas = incluidas.map((x) => ` { name: ${JSON.stringify(x.name)}, label: ${JSON.stringify(etiqueta(x.name))}, path: ${JSON.stringify(x.path)} },`).join("\n");
|
|
883
|
+
// Los motivos vienen de la CLI, con \`nombres\` entre comillas invertidas: en la página, sin ellas.
|
|
884
|
+
const fuera = saltadas.map((x) => ` { name: ${JSON.stringify(x.name)}, reason: ${JSON.stringify(x.reason.replace(/\`/g, ""))} },`).join("\n");
|
|
885
|
+
return `// Written by \`ascua-scaffold --admin\` each time it runs: edits here are lost.
|
|
886
|
+
|
|
887
|
+
/** The tables the admin manages, in the order of the migrations. */
|
|
888
|
+
export const adminTables: readonly { name: string; label: string; path: string }[] = [
|
|
889
|
+
${filas}
|
|
890
|
+
];
|
|
891
|
+
|
|
892
|
+
/** The tables it left out, and why. */
|
|
893
|
+
export const skippedTables: readonly { name: string; reason: string }[] = [
|
|
894
|
+
${fuera}
|
|
895
|
+
];
|
|
896
|
+
`;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/** `src/routes/admin/_middleware.ts`: sin contraseña configurada, el admin no existe. */
|
|
900
|
+
export function guardiaAdmin() {
|
|
901
|
+
return `// Generated by ascua-scaffold. Yours to edit: it is not generated again.
|
|
902
|
+
import { notFound, type Middleware } from "vite-plugin-ascua/site";
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* The admin asks for a user and a password (HTTP Basic): ADMIN_USER, "admin"
|
|
906
|
+
* by default, and ADMIN_PASSWORD. Without ADMIN_PASSWORD the admin does not
|
|
907
|
+
* exist: every page under /admin is a 404. Serve it over HTTPS only, and swap
|
|
908
|
+
* this for the site's own sign-in (passkeys, a session) when it has one.
|
|
909
|
+
*/
|
|
910
|
+
const middleware: Middleware = ({ request, setHeaders }) => {
|
|
911
|
+
setHeaders({ "cache-control": "no-store", "x-robots-tag": "noindex" });
|
|
912
|
+
const password = process.env.ADMIN_PASSWORD;
|
|
913
|
+
if (!password) throw notFound();
|
|
914
|
+
const expected = \`Basic \${base64(\`\${process.env.ADMIN_USER || "admin"}:\${password}\`)}\`;
|
|
915
|
+
if (!same(request.headers.get("authorization") ?? "", expected)) {
|
|
916
|
+
return new Response("Sign in to the admin.", {
|
|
917
|
+
status: 401,
|
|
918
|
+
headers: { "www-authenticate": 'Basic realm="admin", charset="UTF-8"', "cache-control": "no-store" },
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
};
|
|
922
|
+
|
|
923
|
+
export default middleware;
|
|
924
|
+
|
|
925
|
+
/** UTF-8 to base64, the same on Node and on Workers. */
|
|
926
|
+
function base64(text: string): string {
|
|
927
|
+
return btoa(String.fromCharCode(...new TextEncoder().encode(text)));
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/** Compares without stopping at the first difference: the time it takes does not tell how much matched. */
|
|
931
|
+
function same(a: string, b: string): boolean {
|
|
932
|
+
let diff = a.length ^ b.length;
|
|
933
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
|
|
934
|
+
return diff === 0;
|
|
935
|
+
}
|
|
936
|
+
`;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/** `src/routes/admin/_layout.ts`: la barra con las tablas. */
|
|
940
|
+
export function marcoAdmin(datos) {
|
|
941
|
+
return `// Generated by ascua-scaffold. Yours to edit: it is not generated again.
|
|
942
|
+
import type { LayoutProps } from "vite-plugin-ascua/site";
|
|
943
|
+
|
|
944
|
+
import { adminTables } from "${datos}";
|
|
945
|
+
|
|
946
|
+
type AdminTable = (typeof adminTables)[number];
|
|
947
|
+
|
|
948
|
+
/** Every admin page: the tables on the side, the page on the right. */
|
|
949
|
+
export default function AdminLayout(props: LayoutProps) {
|
|
950
|
+
const frame = view\`
|
|
951
|
+
<div class="admin">
|
|
952
|
+
<nav>
|
|
953
|
+
<a href="/admin"><strong>Admin</strong></a>
|
|
954
|
+
<For each=\${() => adminTables} key=\${(t: AdminTable) => t.name}
|
|
955
|
+
render=\${(t: AdminTable) => view\`<a href=\${t.path} class:current=\${props.path.startsWith(t.path)}>\${t.label}</a>\`}/>
|
|
956
|
+
</nav>
|
|
957
|
+
<div class="page"></div>
|
|
958
|
+
<style>
|
|
959
|
+
.admin { display: grid; grid-template-columns: 12rem 1fr; gap: 1.5rem; }
|
|
960
|
+
.admin nav { display: grid; align-content: start; gap: .25rem; }
|
|
961
|
+
.admin nav .current { font-weight: bold; }
|
|
962
|
+
@media (max-width: 40rem) { .admin { grid-template-columns: 1fr; } }
|
|
963
|
+
</style>
|
|
964
|
+
</div>\`;
|
|
965
|
+
props.children(frame.querySelector(".page")!);
|
|
966
|
+
return frame;
|
|
967
|
+
}
|
|
968
|
+
`;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/** `src/routes/admin/index.ts`: el panel. */
|
|
972
|
+
export function panelAdmin(datos) {
|
|
973
|
+
return `// Generated by ascua-scaffold. Yours to edit: it is not generated again.
|
|
974
|
+
import { adminTables, skippedTables } from "${datos}";
|
|
975
|
+
|
|
976
|
+
export const title = "Admin";
|
|
977
|
+
|
|
978
|
+
type AdminTable = (typeof adminTables)[number];
|
|
979
|
+
type Skipped = (typeof skippedTables)[number];
|
|
980
|
+
|
|
981
|
+
export default function Admin() {
|
|
982
|
+
return view\`
|
|
983
|
+
<section>
|
|
984
|
+
<h1>Admin</h1>
|
|
985
|
+
<table>
|
|
986
|
+
<thead><tr><th>Table</th><th></th></tr></thead>
|
|
987
|
+
<tbody>
|
|
988
|
+
<For each=\${() => adminTables} key=\${(t: AdminTable) => t.name}
|
|
989
|
+
render=\${(t: AdminTable) => view\`<tr><td><a href=\${t.path}>\${t.label}</a></td><td><a href=\${\`\${t.path}/new\`}>New</a> · <a href=\${\`\${t.path}/grid\`}>Grid</a></td></tr>\`}/>
|
|
990
|
+
</tbody>
|
|
991
|
+
</table>
|
|
992
|
+
<Show when=\${() => skippedTables.length > 0}>
|
|
993
|
+
<h2>Left out</h2>
|
|
994
|
+
<ul>
|
|
995
|
+
<For each=\${() => skippedTables} key=\${(t: Skipped) => t.name}
|
|
996
|
+
render=\${(t: Skipped) => view\`<li><code>\${t.name}</code>: \${t.reason}</li>\`}/>
|
|
997
|
+
</ul>
|
|
998
|
+
</Show>
|
|
999
|
+
</section>\`;
|
|
1000
|
+
}
|
|
1001
|
+
`;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
768
1004
|
/** `src/pool.ts`: un pool para todo el servidor, con las migraciones aplicadas una vez. */
|
|
769
1005
|
export function piscina({ mysql, migrate, db }) {
|
|
770
1006
|
if (mysql) {
|
package/index.js
CHANGED
|
@@ -16,10 +16,11 @@ import { createRequire } from "node:module";
|
|
|
16
16
|
import { dirname, join, relative, resolve } from "node:path";
|
|
17
17
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
18
18
|
|
|
19
|
-
import { analizar, ayudaRejilla, consultasSql, nombres, piscina, recurso, rejilla, rutas, ScaffoldError, t } from "./generate.js";
|
|
19
|
+
import { analizar, ayudaRejilla, camel, consultaOpciones, consultasSql, datosAdmin, guardiaAdmin, marcoAdmin, nombres, panelAdmin, piscina, recurso, rejilla, rutas, ScaffoldError, t } from "./generate.js";
|
|
20
20
|
|
|
21
21
|
const AYUDA = t(
|
|
22
22
|
`Uso: ascua-scaffold <tabla> [opciones]
|
|
23
|
+
ascua-scaffold --admin [opciones]
|
|
23
24
|
|
|
24
25
|
Genera, a partir de una tabla de las migraciones de brasa:
|
|
25
26
|
db/queries/<tabla>.sql List, Get, Create, Update y Delete
|
|
@@ -33,9 +34,11 @@ Genera, a partir de una tabla de las migraciones de brasa:
|
|
|
33
34
|
|
|
34
35
|
Opciones:
|
|
35
36
|
--config <ruta> el brasa.json (por defecto, el del directorio actual)
|
|
37
|
+
--admin todas las tablas bajo /admin, con su panel; pide ADMIN_PASSWORD
|
|
36
38
|
--force reescribe los archivos que ya existan
|
|
37
39
|
-h, --help esta ayuda`,
|
|
38
40
|
`Usage: ascua-scaffold <table> [options]
|
|
41
|
+
ascua-scaffold --admin [options]
|
|
39
42
|
|
|
40
43
|
Generates, from a table in the brasa migrations:
|
|
41
44
|
db/queries/<table>.sql List, Get, Create, Update and Delete
|
|
@@ -49,6 +52,7 @@ Generates, from a table in the brasa migrations:
|
|
|
49
52
|
|
|
50
53
|
Options:
|
|
51
54
|
--config <path> the brasa.json (default: the one in the current directory)
|
|
55
|
+
--admin every table under /admin, with a dashboard; asks for ADMIN_PASSWORD
|
|
52
56
|
--force overwrite files that already exist
|
|
53
57
|
-h, --help this help`,
|
|
54
58
|
);
|
|
@@ -57,11 +61,12 @@ const POR_DEFECTO = { dialect: "postgres", migrations: "db/migrations", queries:
|
|
|
57
61
|
|
|
58
62
|
/** `["posts", "--force"]` → `{ table: "posts", force: true }`. */
|
|
59
63
|
export function leerArgumentos(argv) {
|
|
60
|
-
const salida = { table: undefined, config: "brasa.json", force: false, ayuda: false };
|
|
64
|
+
const salida = { table: undefined, config: "brasa.json", force: false, admin: false, ayuda: false };
|
|
61
65
|
for (let i = 0; i < argv.length; i++) {
|
|
62
66
|
const a = argv[i];
|
|
63
67
|
if (a === "-h" || a === "--help") salida.ayuda = true;
|
|
64
68
|
else if (a === "--force") salida.force = true;
|
|
69
|
+
else if (a === "--admin") salida.admin = true;
|
|
65
70
|
else if (a === "--config") salida.config = argv[++i];
|
|
66
71
|
else if (a.startsWith("--config=")) salida.config = a.slice("--config=".length);
|
|
67
72
|
else if (!a.startsWith("-") && salida.table === undefined) salida.table = a;
|
|
@@ -99,140 +104,234 @@ function importar(desde, hacia) {
|
|
|
99
104
|
return r;
|
|
100
105
|
}
|
|
101
106
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if (args.ayuda || !args.table) {
|
|
105
|
-
console.log(AYUDA);
|
|
106
|
-
return args.ayuda ? 0 : 1;
|
|
107
|
-
}
|
|
108
|
-
|
|
107
|
+
/** brasa.json, el esquema y el brasa del proyecto. */
|
|
108
|
+
async function proyecto(args) {
|
|
109
109
|
const rutaConfig = resolve(args.config);
|
|
110
110
|
const raiz = dirname(rutaConfig);
|
|
111
111
|
if (!existsSync(rutaConfig)) {
|
|
112
112
|
throw new ScaffoldError(t(`no existe ${rutaConfig}: crea uno con \`brasa init\``, `${rutaConfig} does not exist: create one with \`brasa init\``));
|
|
113
113
|
}
|
|
114
114
|
const config = { ...POR_DEFECTO, ...JSON.parse(readFileSync(rutaConfig, "utf8")) };
|
|
115
|
-
const mysql = config.dialect === "mysql";
|
|
116
|
-
const dirMigraciones = resolve(raiz, config.migrations);
|
|
117
|
-
const dirConsultas = resolve(raiz, config.queries);
|
|
118
|
-
const out = resolve(raiz, config.out);
|
|
119
|
-
|
|
120
115
|
const brasa = await cargarBrasa(raiz);
|
|
121
|
-
const
|
|
122
|
-
const
|
|
123
|
-
|
|
116
|
+
const dirConsultas = resolve(raiz, config.queries);
|
|
117
|
+
const salida = brasa.compile({
|
|
118
|
+
dialect: config.dialect,
|
|
119
|
+
migrate: config.migrate,
|
|
120
|
+
migrations: leerSql(resolve(raiz, config.migrations)),
|
|
121
|
+
queries: leerSql(dirConsultas),
|
|
122
|
+
});
|
|
124
123
|
if (salida.code === null) {
|
|
125
124
|
throw new ScaffoldError(t("el esquema o las consultas tienen errores: corre `brasa generate` para verlos", "the schema or the queries have errors: run `brasa generate` to see them"));
|
|
126
125
|
}
|
|
127
126
|
if (!salida.tables) {
|
|
128
127
|
throw new ScaffoldError(t("este brasa-sql no describe las tablas: actualízalo", "this brasa-sql does not describe tables: update it"));
|
|
129
128
|
}
|
|
129
|
+
return {
|
|
130
|
+
rutaConfig,
|
|
131
|
+
raiz,
|
|
132
|
+
config,
|
|
133
|
+
brasa,
|
|
134
|
+
salida,
|
|
135
|
+
dirConsultas,
|
|
136
|
+
mysql: config.dialect === "mysql",
|
|
137
|
+
out: resolve(raiz, config.out),
|
|
138
|
+
pool: join(raiz, "src", "pool.ts"),
|
|
139
|
+
ayuda: join(raiz, "src", "resources", "grid.ts"),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
130
142
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const a = analizar(tabla, { mysql });
|
|
143
|
+
/**
|
|
144
|
+
* Los archivos de una tabla y lo que llevan. Lanza `ScaffoldError` si no se
|
|
145
|
+
* puede generar. Con `reutilizar`, una tabla que ya tiene sus cinco consultas
|
|
146
|
+
* y su `resources/<tabla>.ts` los conserva y solo recibe rutas nuevas.
|
|
147
|
+
*/
|
|
148
|
+
function planTabla(p, tabla, { prefijo, force, reutilizar }) {
|
|
149
|
+
const a = analizar(tabla, { mysql: p.mysql, tablas: p.salida.tables });
|
|
140
150
|
const n = nombres(tabla);
|
|
141
|
-
// Con --force, las del archivo que se va a reescribir no cuentan.
|
|
142
|
-
const propio = join(dirConsultas, `${tabla.name}.sql`);
|
|
143
|
-
const ajenas = salida.queries.filter((x) => !(args.force && resolve(x.file) === propio));
|
|
144
|
-
const repetidas = [n.list, n.get, n.create, n.update, n.delete].filter((q) => ajenas.some((x) => x.name === q));
|
|
145
|
-
const enPropio = repetidas.filter((q) => salida.queries.some((x) => x.name === q && resolve(x.file) === propio));
|
|
146
|
-
if (repetidas.length && enPropio.length === repetidas.length) {
|
|
147
|
-
// Las del ejemplo de `brasa init`, por ejemplo: están en el archivo que se va a escribir.
|
|
148
|
-
const archivo = relative(process.cwd(), propio);
|
|
149
|
-
throw new ScaffoldError(
|
|
150
|
-
t(`${archivo} ya tiene ${repetidas.join(", ")}. Con --force se reemplaza ese archivo`, `${archivo} already has ${repetidas.join(", ")}. Use --force to replace that file`),
|
|
151
|
-
);
|
|
152
|
-
}
|
|
153
|
-
if (repetidas.length) {
|
|
154
|
-
throw new ScaffoldError(
|
|
155
|
-
t(`ya hay consultas con estos nombres: ${repetidas.join(", ")}. Quita esas o genera otra tabla`, `queries with these names already exist: ${repetidas.join(", ")}. Remove them or scaffold another table`),
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
151
|
const carpeta = tabla.name.replace(/_/g, "-").toLowerCase();
|
|
160
152
|
if (carpeta === "grid") {
|
|
161
153
|
throw new ScaffoldError(t("una tabla `grid` chocaría con src/resources/grid.ts, que comparten las grillas", "a `grid` table would clash with src/resources/grid.ts, which the grids share"));
|
|
162
154
|
}
|
|
163
155
|
// Los enums de Postgres tienen su `<x>Values` en el módulo; los de MySQL
|
|
164
156
|
// (`ENUM('a','b')` en la columna), no: sus valores van escritos.
|
|
165
|
-
const enums = new Set(mysql ? [] : tabla.columns.filter((c) => c.kind === "enum").map((c) => c.sqlType));
|
|
157
|
+
const enums = new Set(p.mysql ? [] : tabla.columns.filter((c) => c.kind === "enum").map((c) => c.sqlType));
|
|
166
158
|
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
ayuda: join(raiz, "src", "resources", "grid.ts"),
|
|
176
|
-
};
|
|
177
|
-
const existentes = Object.entries(archivos).filter(([k, f]) => k !== "pool" && k !== "ayuda" && existsSync(f));
|
|
178
|
-
if (existentes.length && !args.force) {
|
|
159
|
+
const sql = join(p.dirConsultas, `${tabla.name}.sql`);
|
|
160
|
+
const archivoRecurso = join(p.raiz, "src", "resources", `${carpeta}.ts`);
|
|
161
|
+
const consultas = [n.list, n.get, n.create, n.update, n.delete, ...a.relaciones.map((r) => consultaOpciones(n, r))];
|
|
162
|
+
const hay = consultas.filter((q) => p.salida.queries.some((x) => x.name === q));
|
|
163
|
+
// Un recurso de un ascua-scaffold anterior a las relaciones no trae `<x>Options`.
|
|
164
|
+
const conOpciones = existsSync(archivoRecurso) && readFileSync(archivoRecurso, "utf8").includes(`function ${camel(n.uno)}Options(`);
|
|
165
|
+
const reusa = reutilizar && !force && hay.length === consultas.length && conOpciones;
|
|
166
|
+
if (reutilizar && !force && hay.length === consultas.length && existsSync(archivoRecurso) && !conOpciones) {
|
|
179
167
|
throw new ScaffoldError(
|
|
180
|
-
t(
|
|
168
|
+
t(`${relative(p.raiz, archivoRecurso)} es de un ascua-scaffold anterior: regenéralo con \`ascua-scaffold ${tabla.name} --force\``, `${relative(p.raiz, archivoRecurso)} is from an older ascua-scaffold: regenerate it with \`ascua-scaffold ${tabla.name} --force\``),
|
|
181
169
|
);
|
|
182
170
|
}
|
|
183
171
|
|
|
172
|
+
if (!reusa) {
|
|
173
|
+
// Con --force, las del archivo que se va a reescribir no cuentan.
|
|
174
|
+
const ajenas = p.salida.queries.filter((x) => !(force && resolve(x.file) === sql));
|
|
175
|
+
const repetidas = consultas.filter((q) => ajenas.some((x) => x.name === q));
|
|
176
|
+
const enPropio = repetidas.filter((q) => p.salida.queries.some((x) => x.name === q && resolve(x.file) === sql));
|
|
177
|
+
if (repetidas.length && enPropio.length === repetidas.length) {
|
|
178
|
+
// Las del ejemplo de `brasa init`, por ejemplo: están en el archivo que se va a escribir.
|
|
179
|
+
const archivo = relative(process.cwd(), sql);
|
|
180
|
+
throw new ScaffoldError(t(`${archivo} ya tiene ${repetidas.join(", ")}. Con --force se reemplaza ese archivo`, `${archivo} already has ${repetidas.join(", ")}. Use --force to replace that file`));
|
|
181
|
+
}
|
|
182
|
+
if (repetidas.length) {
|
|
183
|
+
throw new ScaffoldError(t(`ya hay consultas con estos nombres: ${repetidas.join(", ")}. Quita esas o genera otra tabla`, `queries with these names already exist: ${repetidas.join(", ")}. Remove them or scaffold another table`));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const dirRutas = join(p.raiz, "src", "routes", ...(prefijo ? [prefijo] : []), carpeta);
|
|
188
|
+
const base = `${prefijo ? `/${prefijo}` : ""}/${carpeta}`;
|
|
189
|
+
const archivos = {
|
|
190
|
+
sql,
|
|
191
|
+
recurso: archivoRecurso,
|
|
192
|
+
index: join(dirRutas, "index.ts"),
|
|
193
|
+
nuevo: join(dirRutas, "new.ts"),
|
|
194
|
+
editar: join(dirRutas, "[id].ts"),
|
|
195
|
+
rejilla: join(dirRutas, "grid.ts"),
|
|
196
|
+
};
|
|
197
|
+
if (reusa) {
|
|
198
|
+
delete archivos.sql;
|
|
199
|
+
delete archivos.recurso;
|
|
200
|
+
}
|
|
201
|
+
const existentes = Object.values(archivos).filter((f) => existsSync(f));
|
|
202
|
+
if (existentes.length && !force) {
|
|
203
|
+
const lista = existentes.map((f) => relative(p.raiz, f)).join(", ");
|
|
204
|
+
throw new ScaffoldError(t(`ya existen: ${lista}. Usa --force para reescribirlos`, `already exist: ${lista}. Use --force to overwrite them`));
|
|
205
|
+
}
|
|
206
|
+
|
|
184
207
|
const r = rutas(tabla, a, n, {
|
|
185
|
-
db: { index: importar(archivos.index, out), nested: importar(archivos.nuevo, out) },
|
|
186
|
-
pool: { index: importar(archivos.index,
|
|
187
|
-
resource: { nested: importar(archivos.nuevo,
|
|
188
|
-
base
|
|
208
|
+
db: { index: importar(archivos.index, p.out), nested: importar(archivos.nuevo, p.out) },
|
|
209
|
+
pool: { index: importar(archivos.index, p.pool), nested: importar(archivos.nuevo, p.pool) },
|
|
210
|
+
resource: { nested: importar(archivos.nuevo, archivoRecurso) },
|
|
211
|
+
base,
|
|
189
212
|
});
|
|
190
213
|
const contenidos = {
|
|
191
|
-
sql: consultasSql(tabla, a, n),
|
|
192
|
-
recurso: recurso(tabla, a, n, { db: importar(archivos.recurso, out), enums }),
|
|
193
214
|
index: r.index,
|
|
194
215
|
nuevo: r.nuevo,
|
|
195
216
|
editar: r.editar,
|
|
196
217
|
rejilla: rejilla(tabla, a, n, {
|
|
197
|
-
db: importar(archivos.rejilla, out),
|
|
198
|
-
pool: importar(archivos.rejilla,
|
|
199
|
-
resource: importar(archivos.rejilla,
|
|
200
|
-
grid: importar(archivos.rejilla,
|
|
201
|
-
base
|
|
218
|
+
db: importar(archivos.rejilla, p.out),
|
|
219
|
+
pool: importar(archivos.rejilla, p.pool),
|
|
220
|
+
resource: importar(archivos.rejilla, archivoRecurso),
|
|
221
|
+
grid: importar(archivos.rejilla, p.ayuda),
|
|
222
|
+
base,
|
|
202
223
|
enums,
|
|
203
224
|
}),
|
|
204
225
|
};
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
writeFileSync(archivos[k], contenido);
|
|
209
|
-
escritos.push(archivos[k]);
|
|
210
|
-
}
|
|
211
|
-
if (!existsSync(archivos.ayuda)) {
|
|
212
|
-
writeFileSync(archivos.ayuda, ayudaRejilla());
|
|
213
|
-
escritos.push(archivos.ayuda);
|
|
226
|
+
if (!reusa) {
|
|
227
|
+
contenidos.sql = consultasSql(tabla, a, n);
|
|
228
|
+
contenidos.recurso = recurso(tabla, a, n, { db: importar(archivoRecurso, p.out), enums });
|
|
214
229
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
230
|
+
return { tabla: tabla.name, base, archivos, contenidos, reusa };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Escribe los planes y lo común (el pool, el ayudante de la grilla); devuelve lo escrito. */
|
|
234
|
+
function escribir(p, planes, extra = {}) {
|
|
235
|
+
const escritos = [];
|
|
236
|
+
const poner = (archivo, contenido) => {
|
|
237
|
+
mkdirSync(dirname(archivo), { recursive: true });
|
|
238
|
+
writeFileSync(archivo, contenido);
|
|
239
|
+
escritos.push(archivo);
|
|
240
|
+
};
|
|
241
|
+
for (const plan of planes) {
|
|
242
|
+
for (const [k, contenido] of Object.entries(plan.contenidos)) poner(plan.archivos[k], contenido);
|
|
218
243
|
}
|
|
244
|
+
for (const [archivo, contenido] of Object.entries(extra)) poner(archivo, contenido);
|
|
245
|
+
if (!existsSync(p.ayuda)) poner(p.ayuda, ayudaRejilla());
|
|
246
|
+
if (!existsSync(p.pool)) poner(p.pool, piscina({ mysql: p.mysql, migrate: p.config.migrate, db: importar(p.pool, p.out) }));
|
|
247
|
+
return escritos;
|
|
248
|
+
}
|
|
219
249
|
|
|
220
|
-
|
|
221
|
-
|
|
250
|
+
/** El módulo de brasa con las consultas nuevas, y lo que falta en el proyecto. */
|
|
251
|
+
function terminar(p, escritos, listo) {
|
|
252
|
+
const generado = spawnSync(process.execPath, [p.brasa.bin, "generate", "--config", p.rutaConfig], { cwd: p.raiz, stdio: "inherit" });
|
|
222
253
|
if (generado.status !== 0) {
|
|
223
254
|
throw new ScaffoldError(t("brasa no pudo generar el módulo con las consultas nuevas (arriba, el error)", "brasa could not generate the module with the new queries (the error is above)"));
|
|
224
255
|
}
|
|
225
|
-
|
|
226
256
|
for (const f of escritos) console.log(` ${relative(process.cwd(), f)}`);
|
|
227
|
-
const paquete = existsSync(join(raiz, "package.json")) ? JSON.parse(readFileSync(join(raiz, "package.json"), "utf8")) : {};
|
|
257
|
+
const paquete = existsSync(join(p.raiz, "package.json")) ? JSON.parse(readFileSync(join(p.raiz, "package.json"), "utf8")) : {};
|
|
228
258
|
const deps = { ...paquete.dependencies, ...paquete.devDependencies };
|
|
229
|
-
const faltan = (mysql ? ["mysql2"] : ["pg", "@types/pg"]).concat("@types/node").filter((d) => !deps[d]);
|
|
259
|
+
const faltan = (p.mysql ? ["mysql2"] : ["pg", "@types/pg"]).concat("@types/node").filter((d) => !deps[d]);
|
|
230
260
|
console.log("");
|
|
231
|
-
console.log(
|
|
261
|
+
console.log(listo);
|
|
232
262
|
if (faltan.length) console.log(t(`Falta instalar: stil add ${faltan.join(" ")}`, `Still to install: npm i ${faltan.join(" ")}`));
|
|
233
|
-
if (!/mode:\s*["']server["']/.test(leer(join(raiz, "vite.config.ts")))) {
|
|
263
|
+
if (!/mode:\s*["']server["']/.test(leer(join(p.raiz, "vite.config.ts")))) {
|
|
234
264
|
console.log(t('Las acciones necesitan servidor: ascua({ site: { mode: "server" } }) en vite.config.ts', 'Actions need a server: ascua({ site: { mode: "server" } }) in vite.config.ts'));
|
|
235
265
|
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** `--admin`: todas las tablas bajo /admin, con su panel y su contraseña. */
|
|
269
|
+
function admin(p, args) {
|
|
270
|
+
const planes = [];
|
|
271
|
+
const saltadas = [];
|
|
272
|
+
const conservadas = [];
|
|
273
|
+
for (const tabla of p.salida.tables) {
|
|
274
|
+
// Una tabla que ya tiene su página en el admin se queda como está.
|
|
275
|
+
const carpeta = tabla.name.replace(/_/g, "-").toLowerCase();
|
|
276
|
+
if (!args.force && existsSync(join(p.raiz, "src", "routes", "admin", carpeta, "index.ts"))) {
|
|
277
|
+
conservadas.push({ name: tabla.name, path: `/admin/${carpeta}` });
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
planes.push(planTabla(p, tabla, { prefijo: "admin", force: args.force, reutilizar: true }));
|
|
282
|
+
} catch (error) {
|
|
283
|
+
if (!(error instanceof ScaffoldError)) throw error;
|
|
284
|
+
saltadas.push({ name: tabla.name, reason: error.message });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (planes.length === 0 && conservadas.length === 0) {
|
|
288
|
+
const motivos = saltadas.map((x) => `\n ${x.name}: ${x.reason}`).join("");
|
|
289
|
+
throw new ScaffoldError(t(`ninguna tabla se pudo generar:${motivos}`, `no table could be scaffolded:${motivos}`));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const dir = join(p.raiz, "src", "routes", "admin");
|
|
293
|
+
const datos = join(p.raiz, "src", "resources", "admin.ts");
|
|
294
|
+
// En el orden de las migraciones, las de antes y las nuevas.
|
|
295
|
+
const orden = p.salida.tables.map((x) => x.name);
|
|
296
|
+
const incluidas = [...conservadas, ...planes.map((x) => ({ name: x.tabla, path: x.base }))].sort((a, b) => orden.indexOf(a.name) - orden.indexOf(b.name));
|
|
297
|
+
const extra = { [datos]: datosAdmin(incluidas, saltadas) };
|
|
298
|
+
const propios = {
|
|
299
|
+
[join(dir, "_middleware.ts")]: guardiaAdmin(),
|
|
300
|
+
[join(dir, "_layout.ts")]: marcoAdmin(importar(join(dir, "_layout.ts"), datos)),
|
|
301
|
+
[join(dir, "index.ts")]: panelAdmin(importar(join(dir, "index.ts"), datos)),
|
|
302
|
+
};
|
|
303
|
+
// El panel, la barra y la contraseña son tuyos: se escriben si faltan.
|
|
304
|
+
for (const [archivo, contenido] of Object.entries(propios)) {
|
|
305
|
+
if (args.force || !existsSync(archivo)) extra[archivo] = contenido;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const escritos = escribir(p, planes, extra);
|
|
309
|
+
const cuantas = incluidas.length === 1 ? t("1 tabla", "1 table") : t(`${incluidas.length} tablas`, `${incluidas.length} tables`);
|
|
310
|
+
terminar(p, escritos, t(`Listo: /admin con ${cuantas} (${planes.length} nuevas).`, `Done: /admin with ${cuantas} (${planes.length} new).`));
|
|
311
|
+
for (const plan of planes.filter((x) => x.reusa)) {
|
|
312
|
+
console.log(t(` ${plan.tabla}: usa las consultas y el formulario que ya tenía`, ` ${plan.tabla}: uses the queries and form it already had`));
|
|
313
|
+
}
|
|
314
|
+
for (const x of saltadas) console.log(t(` fuera: ${x.name}: ${x.reason}`, ` left out: ${x.name}: ${x.reason}`));
|
|
315
|
+
console.log(t("Pide usuario y contraseña: define ADMIN_PASSWORD (y ADMIN_USER, «admin» por defecto). Sin ella, /admin es un 404.", "It asks for a user and password: set ADMIN_PASSWORD (and ADMIN_USER, \"admin\" by default). Without it, /admin is a 404."));
|
|
316
|
+
return 0;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
320
|
+
const args = leerArgumentos(argv);
|
|
321
|
+
if (args.ayuda || (!args.table && !args.admin)) {
|
|
322
|
+
console.log(AYUDA);
|
|
323
|
+
return args.ayuda ? 0 : 1;
|
|
324
|
+
}
|
|
325
|
+
const p = await proyecto(args);
|
|
326
|
+
if (args.admin) return admin(p, args);
|
|
327
|
+
|
|
328
|
+
const tabla = p.salida.tables.find((x) => x.name === args.table) ?? p.salida.tables.find((x) => x.name.toLowerCase() === args.table.toLowerCase());
|
|
329
|
+
if (!tabla) {
|
|
330
|
+
const hay = p.salida.tables.filter((x) => !x.view).map((x) => x.name);
|
|
331
|
+
throw new ScaffoldError(t(`no hay una tabla \`${args.table}\` en las migraciones. Hay: ${hay.join(", ") || "ninguna"}`, `there is no \`${args.table}\` table in the migrations. There are: ${hay.join(", ") || "none"}`));
|
|
332
|
+
}
|
|
333
|
+
const plan = planTabla(p, tabla, { prefijo: "", force: args.force, reutilizar: false });
|
|
334
|
+
terminar(p, escribir(p, [plan]), t(`Listo: ${plan.base} en el sitio.`, `Done: ${plan.base} on the site.`));
|
|
236
335
|
return 0;
|
|
237
336
|
}
|
|
238
337
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ascua-scaffold",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "De una tabla de brasa a un recurso de Ascua: consultas, formulario validado y rutas para listar, crear, editar y borrar.",
|
|
5
5
|
"keywords": ["ascua", "scaffold", "crud", "brasa", "sql", "forms"],
|
|
6
6
|
"homepage": "https://ascua.gitweave.run",
|