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.
Files changed (4) hide show
  1. package/README.md +40 -5
  2. package/generate.js +279 -43
  3. package/index.js +187 -88
  4. 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. Un `UNIQUE` repetido, con un 409 y el error
60
- bajo esa columna, no con un 500.
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
- - Un `UNIQUE` repetido vuelve con un 409 y el error bajo la celda de esa
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
- return { clave, claveAutomatica, alCrear, alEditar, fuera, visibles, mysql };
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
- * `duplicate()` del recurso: un UNIQUE o una clave repetidos vuelven al
250
- * formulario con un 409 en vez de un 500. Postgres dice la columna en el
251
- * `detail`; MySQL da el nombre del índice, así que ahí el error es general.
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
- ? ` if (e?.code !== "ER_DUP_ENTRY") throw error;
256
- const field = "";`
257
- : ` if (e?.code !== "23505") throw error;
258
- // "Key (sku)=(H-1) already exists."
259
- const column = /^Key \\(([^)]+)\\)=/.exec(String(e.detail))?.[1];
260
- const field = column && !column.includes(",") ? column : "";`;
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
- * A duplicate of a UNIQUE column or of the key, as the same form again with
263
- * the error under that field (409). Any other error is thrown again.
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 duplicate(error: unknown, formData: FormData) {
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
- const celdas = a.visibles.map((c) => `<td>\${show(r${acceso(c.name)})}</td>`).join("");
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 duplicate(error, formData);\n }\n throw redirect(\`${base}/\${${despues}}\`);`;
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}, duplicate } from "${resource.nested}";
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 duplicate(error, formData);
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}, duplicate } from "${resource.nested}";
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
- await ${camel(n.delete)}(await db(), { ${propiedad(pk)}: key(params.id) });
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=\${failed?.values ?? ${values}(data)} errors=\${failed?.errors ?? {}}/>
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}, duplicate, type ${n.uno}Data } from "${resource}";
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 = duplicate(error, new FormData());
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
- export async function main(argv = process.argv.slice(2)) {
103
- const args = leerArgumentos(argv);
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 migrations = leerSql(dirMigraciones);
122
- const queries = leerSql(dirConsultas);
123
- const salida = brasa.compile({ dialect: config.dialect, migrate: config.migrate, migrations, queries });
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
- const tabla = salida.tables.find((x) => x.name === args.table) ?? salida.tables.find((x) => x.name.toLowerCase() === args.table.toLowerCase());
132
- if (!tabla) {
133
- const hay = salida.tables.filter((x) => !x.view).map((x) => x.name);
134
- throw new ScaffoldError(
135
- 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"}`),
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 archivos = {
168
- sql: join(dirConsultas, `${tabla.name}.sql`),
169
- recurso: join(raiz, "src", "resources", `${carpeta}.ts`),
170
- index: join(raiz, "src", "routes", carpeta, "index.ts"),
171
- nuevo: join(raiz, "src", "routes", carpeta, "new.ts"),
172
- editar: join(raiz, "src", "routes", carpeta, "[id].ts"),
173
- rejilla: join(raiz, "src", "routes", carpeta, "grid.ts"),
174
- pool: join(raiz, "src", "pool.ts"),
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(`ya existen: ${existentes.map(([, f]) => relative(raiz, f)).join(", ")}. Usa --force para reescribirlos`, `already exist: ${existentes.map(([, f]) => relative(raiz, f)).join(", ")}. Use --force to overwrite them`),
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, archivos.pool), nested: importar(archivos.nuevo, archivos.pool) },
187
- resource: { nested: importar(archivos.nuevo, archivos.recurso) },
188
- base: `/${carpeta}`,
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, archivos.pool),
199
- resource: importar(archivos.rejilla, archivos.recurso),
200
- grid: importar(archivos.rejilla, archivos.ayuda),
201
- base: `/${carpeta}`,
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
- const escritos = [];
206
- for (const [k, contenido] of Object.entries(contenidos)) {
207
- mkdirSync(dirname(archivos[k]), { recursive: true });
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
- if (!existsSync(archivos.pool)) {
216
- writeFileSync(archivos.pool, piscina({ mysql, migrate: config.migrate, db: importar(archivos.pool, out) }));
217
- escritos.push(archivos.pool);
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
- // El módulo de brasa, con las consultas nuevas.
221
- const generado = spawnSync(process.execPath, [brasa.bin, "generate", "--config", rutaConfig], { cwd: raiz, stdio: "inherit" });
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(t(`Listo: /${carpeta} en el sitio.`, `Done: /${carpeta} on the site.`));
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.2.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",