ascua-scaffold 0.0.0-stage → 0.1.1

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/LICENSE-APACHE ADDED
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
package/LICENSE-MIT ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Los autores de Ascua
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,87 @@
1
- # Temporary Holding Version
1
+ # ascua-scaffold
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ De una tabla a un recurso completo de [Ascua](https://ascua.gitweave.run):
4
+ lista paginada, alta, edición y baja, con el formulario validado en el
5
+ servidor. Un comando, como en Rails o Django, pero con SQL de verdad: lee el
6
+ esquema de tus migraciones con [brasa](https://www.npmjs.com/package/brasa-sql).
7
+
8
+ ```sh
9
+ stil add -D ascua-scaffold brasa-sql # o npm i -D ascua-scaffold brasa-sql
10
+ stil add pg # o mysql2, con "dialect": "mysql"
11
+ stil exec ascua-scaffold products # o npx ascua-scaffold products
12
+ ```
13
+
14
+ Con esta migración:
15
+
16
+ ```sql
17
+ CREATE TYPE category AS ENUM ('tools', 'books', 'garden');
18
+
19
+ CREATE TABLE products (
20
+ id serial PRIMARY KEY,
21
+ sku text NOT NULL UNIQUE,
22
+ name text NOT NULL,
23
+ description text,
24
+ price numeric(10, 2) NOT NULL,
25
+ active boolean NOT NULL DEFAULT true,
26
+ category category NOT NULL DEFAULT 'tools',
27
+ released_on date,
28
+ created_at timestamptz NOT NULL DEFAULT now()
29
+ );
30
+ ```
31
+
32
+ escribe:
33
+
34
+ ```text
35
+ db/queries/products.sql ListProducts, GetProduct, CreateProduct, UpdateProduct, DeleteProduct
36
+ src/resources/products.ts el esquema del formulario, sus campos y las conversiones
37
+ src/routes/products/index.ts /products, de 50 en 50
38
+ src/routes/products/new.ts /products/new
39
+ src/routes/products/[id].ts /products/:id, editar y borrar (?/delete)
40
+ src/pool.ts el pool, con las migraciones aplicadas una vez (si no existe)
41
+ ```
42
+
43
+ y vuelve a generar el módulo de brasa con las consultas nuevas.
44
+
45
+ ## Qué genera
46
+
47
+ - **Consultas en SQL**, en un archivo tuyo: cámbialas como quieras (un
48
+ `WHERE`, otro orden, un `JOIN`) y `brasa generate` revisa que sigan
49
+ valiendo. Llevan parámetros `:nombre`, que sirven en Postgres y en MySQL.
50
+ - **Un formulario por columna**, según su tipo: `field.number` para números,
51
+ `field.checkbox` para booleanos, un `<select>` con `field.choice` para un
52
+ enum, `type="date"` para fechas, `field.email` para una columna que se
53
+ llame `email`, `<textarea>` para `description`, `body` o `notes`. Lo
54
+ nullable es opcional y vacío se guarda como `NULL`.
55
+ - **Formularios de HTML**, sin islas: funcionan sin una línea de JavaScript.
56
+ Un error de validación vuelve a la página con un 400, cada mensaje bajo su
57
+ campo y lo escrito en su sitio. Un `UNIQUE` repetido, con un 409 y el error
58
+ bajo esa columna, no con un 500.
59
+ - **La clave de la URL se valida** antes de llegar a la base: `/products/abc`
60
+ es un 404. Un `bigint` entra como texto, para no perder precisión.
61
+
62
+ Lo que el formulario no sabe editar (JSON, binarios, arreglos) y las columnas
63
+ `created_at` o `updated_at` con `DEFAULT` quedan fuera. Si una columna así es
64
+ obligatoria y no tiene `DEFAULT`, el comando lo dice y no genera nada.
65
+
66
+ Lo generado es tuyo: no se vuelve a generar. Con `--force` se reescribe
67
+ encima.
68
+
69
+ ## Requisitos
70
+
71
+ - Un sitio con servidor: `ascua({ site: { mode: "server" } })` en
72
+ `vite.config.ts` (`create-ascua --template server`).
73
+ - `brasa.json` y las migraciones de brasa (`brasa init`).
74
+ - Una tabla con clave primaria de una sola columna: entero, texto o uuid. Si
75
+ es `serial` o tiene `DEFAULT`, la pone la base; si no, se pide al crear y
76
+ no se cambia al editar.
77
+ - La conexión sale de `DATABASE_URL`.
78
+
79
+ ## Opciones
80
+
81
+ ```text
82
+ ascua-scaffold <tabla> [--config brasa.json] [--force]
83
+ ```
84
+
85
+ ## Licencia
86
+
87
+ MIT o Apache-2.0, a elección.
package/generate.js ADDED
@@ -0,0 +1,619 @@
1
+ /**
2
+ * De una tabla del esquema de brasa a los archivos de un recurso: consultas,
3
+ * esquema del formulario y rutas. Sin E/S: recibe la tabla y devuelve
4
+ * `{ ruta: contenido }`, para poder probarlo sin disco ni base.
5
+ */
6
+
7
+ const español = /^es/i.test(process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG || "");
8
+ export const t = (es, en) => (español ? es : en);
9
+
10
+ /** Lo que el formulario sabe editar; el resto (json, binarios, arreglos…) se deja fuera. */
11
+ const EDITABLES = new Set(["integer", "decimal", "number", "boolean", "text", "uuid", "date", "datetime", "time", "enum"]);
12
+
13
+ /** Columnas de auditoría que la base llena sola: con DEFAULT, no van al formulario. */
14
+ const AUTOMATICAS = new Set(["created_at", "updated_at", "inserted_at", "createdAt", "updatedAt"]);
15
+
16
+ /** Columnas de texto largo: `<textarea>` en vez de `<input>`. */
17
+ const LARGAS = /^(body|content|description|bio|notes?|text|summary|comment)$/i;
18
+
19
+ /** Palabras reservadas que no pueden ir sin comillas como nombre de columna o tabla. */
20
+ const RESERVADAS = new Set(
21
+ "all and any as asc both case check column constraint create default desc distinct do else end false for foreign from grant group having in into is key limit not null offset on or order primary references select table then to true union unique user using when where with".split(
22
+ " ",
23
+ ),
24
+ );
25
+
26
+ /** `list_posts` → `["list", "posts"]`. */
27
+ function palabras(n) {
28
+ return n
29
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
30
+ .split(/[^A-Za-z0-9]+/)
31
+ .filter(Boolean)
32
+ .map((p) => p.toLowerCase());
33
+ }
34
+
35
+ export const pascal = (n) => palabras(n).map((p) => p[0].toUpperCase() + p.slice(1)).join("");
36
+ export const camel = (n) => {
37
+ const p = pascal(n);
38
+ return p[0].toLowerCase() + p.slice(1);
39
+ };
40
+
41
+ /** Singular en inglés, lo justo para nombres de tablas: posts → post, categories → category. */
42
+ export function singular(n) {
43
+ if (/ies$/i.test(n)) return n.slice(0, -3) + "y";
44
+ if (/(ss|us|is)$/i.test(n)) return n;
45
+ if (/(x|ch|sh|ss)es$/i.test(n)) return n.slice(0, -2);
46
+ if (/s$/i.test(n)) return n.slice(0, -1);
47
+ return n;
48
+ }
49
+
50
+ /** `published_at` → `Published at`. */
51
+ function etiqueta(n) {
52
+ const p = palabras(n).join(" ");
53
+ return p[0].toUpperCase() + p.slice(1);
54
+ }
55
+
56
+ /** El nombre en SQL, con comillas solo si hace falta. */
57
+ function sqlNombre(n, mysql) {
58
+ const simple = mysql ? /^[A-Za-z_][A-Za-z0-9_]*$/ : /^[a-z_][a-z0-9_]*$/;
59
+ if (simple.test(n) && !RESERVADAS.has(n.toLowerCase())) return n;
60
+ return mysql ? "`" + n.replace(/`/g, "``") + "`" : '"' + n.replace(/"/g, '""') + '"';
61
+ }
62
+
63
+ const esIdentificador = (n) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(n);
64
+ const propiedad = (n) => (esIdentificador(n) ? n : JSON.stringify(n));
65
+ const acceso = (n) => (esIdentificador(n) ? `.${n}` : `[${JSON.stringify(n)}]`);
66
+
67
+ /** Error con un mensaje para quien usa la CLI, no una traza. */
68
+ export class ScaffoldError extends Error {}
69
+
70
+ /**
71
+ * Qué columnas van en cada parte del recurso. Lanza `ScaffoldError` si la
72
+ * tabla no se puede generar.
73
+ */
74
+ export function analizar(tabla, { mysql }) {
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
+ if (tabla.primaryKey.length !== 1) {
77
+ throw new ScaffoldError(
78
+ tabla.primaryKey.length === 0
79
+ ? t(`\`${tabla.name}\` no tiene clave primaria`, `\`${tabla.name}\` has no primary key`)
80
+ : t(`\`${tabla.name}\` tiene una clave primaria compuesta; por ahora solo se genera con una columna`, `\`${tabla.name}\` has a composite primary key; only single-column keys are supported for now`),
81
+ );
82
+ }
83
+ const clave = tabla.columns.find((c) => c.name === tabla.primaryKey[0]);
84
+ if (!["integer", "text", "uuid"].includes(clave.kind) || clave.array) {
85
+ throw new ScaffoldError(t(`la clave primaria \`${clave.name}\` es ${clave.sqlType}: se admite entero, texto o uuid`, `primary key \`${clave.name}\` is ${clave.sqlType}: integer, text or uuid are supported`));
86
+ }
87
+ const claveAutomatica = clave.hasDefault;
88
+
89
+ const editables = [];
90
+ const fuera = [];
91
+ for (const c of tabla.columns) {
92
+ if (c.generated) continue;
93
+ if (c === clave && claveAutomatica) continue;
94
+ if (c.hasDefault && AUTOMATICAS.has(c.name)) continue;
95
+ if (!EDITABLES.has(c.kind) || c.array) {
96
+ if (!c.nullable && !c.hasDefault) {
97
+ throw new ScaffoldError(
98
+ t(
99
+ `la columna \`${c.name}\` (${c.sqlType}) es obligatoria y el formulario no sabe editarla; dale un DEFAULT o hazla nullable`,
100
+ `column \`${c.name}\` (${c.sqlType}) is required and the form cannot edit it; give it a DEFAULT or make it nullable`,
101
+ ),
102
+ );
103
+ }
104
+ fuera.push(c);
105
+ continue;
106
+ }
107
+ if (!esIdentificador(c.name)) {
108
+ throw new ScaffoldError(t(`la columna \`${c.name}\` no sirve como nombre de parámetro`, `column \`${c.name}\` cannot be used as a parameter name`));
109
+ }
110
+ editables.push(c);
111
+ }
112
+ // Sin clave automática, la clave se escribe al crear, pero no se cambia al editar.
113
+ const alCrear = editables;
114
+ const alEditar = editables.filter((c) => c !== clave);
115
+ if (alEditar.length === 0) throw new ScaffoldError(t(`\`${tabla.name}\` no tiene columnas que editar`, `\`${tabla.name}\` has no columns to edit`));
116
+
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 };
119
+ }
120
+
121
+ /** Los nombres de las consultas del recurso. */
122
+ export function nombres(tabla) {
123
+ const plural = pascal(tabla.name);
124
+ const uno = pascal(singular(tabla.name));
125
+ return {
126
+ plural,
127
+ uno,
128
+ list: `List${plural}`,
129
+ get: `Get${uno}`,
130
+ create: `Create${uno}`,
131
+ update: `Update${uno}`,
132
+ delete: `Delete${uno}`,
133
+ };
134
+ }
135
+
136
+ /** El SQL de las cinco consultas. Los parámetros `:nombre` valen en Postgres y en MySQL. */
137
+ export function consultasSql(tabla, a, n) {
138
+ const q = (x) => sqlNombre(x, a.mysql);
139
+ const tab = q(tabla.name);
140
+ const pk = q(a.clave.name);
141
+ const columnas = a.alCrear.map((c) => q(c.name)).join(", ");
142
+ const valores = a.alCrear.map((c) => `:${c.name}`).join(", ");
143
+ const sets = a.alEditar.map((c) => `${q(c.name)} = :${c.name}`).join(",\n ");
144
+ const crear =
145
+ a.mysql
146
+ ? `-- name: ${n.create} ${a.claveAutomatica ? ":execlastid" : ":exec"}\nINSERT INTO ${tab} (${columnas})\nVALUES (${valores});`
147
+ : `-- name: ${n.create} :one\nINSERT INTO ${tab} (${columnas})\nVALUES (${valores})\nRETURNING ${pk};`;
148
+ const actualizar = a.alEditar.length
149
+ ? `-- name: ${n.update} :execrows\nUPDATE ${tab}\nSET ${sets}\nWHERE ${pk} = :${a.clave.name};\n\n`
150
+ : "";
151
+ return (
152
+ `-- Generated by ascua-scaffold. Yours to edit: it is not generated again.\n\n` +
153
+ `-- name: ${n.list} :many\nSELECT * FROM ${tab}\nORDER BY ${pk} DESC\nLIMIT :limit OFFSET :offset;\n\n` +
154
+ `-- name: ${n.get} :one\nSELECT * FROM ${tab} WHERE ${pk} = :${a.clave.name};\n\n` +
155
+ `${crear}\n\n` +
156
+ actualizar +
157
+ `-- name: ${n.delete} :execrows\nDELETE FROM ${tab} WHERE ${pk} = :${a.clave.name};\n`
158
+ );
159
+ }
160
+
161
+ /** El esquema de `field` para una columna. */
162
+ function campo(c, valoresEnum) {
163
+ const opcional = (s) => (c.nullable ? `field.optional(${s})` : s);
164
+ switch (c.kind) {
165
+ case "integer":
166
+ return opcional("field.number({ integer: true })");
167
+ case "decimal":
168
+ case "number":
169
+ return opcional("field.number()");
170
+ case "boolean":
171
+ // Una casilla sin marcar es `false`, nunca nula.
172
+ return "field.checkbox()";
173
+ case "enum":
174
+ return opcional(`field.choice(${valoresEnum})`);
175
+ case "text":
176
+ if (/(^|_)e-?mail$/i.test(c.name)) return opcional("field.email()");
177
+ // Obligatorio solo si la base lo exige y no tiene DEFAULT.
178
+ return c.nullable ? "field.optional(field.text())" : c.hasDefault ? "field.text({ min: 0 })" : "field.text()";
179
+ default:
180
+ // uuid, fechas y horas llegan como texto; la base las valida.
181
+ return opcional("field.text()");
182
+ }
183
+ }
184
+
185
+ /** El control HTML de una columna, dentro del `view` de `…Fields`. */
186
+ function control(c, valoresEnum) {
187
+ const nombre = c.name;
188
+ const v = `values${acceso(nombre)}`;
189
+ const texto = `String(${v} ?? "")`;
190
+ // El esquema exige todo lo NOT NULL salvo casillas y texto con DEFAULT (que acepta vacío).
191
+ const req = !c.nullable && c.kind !== "boolean" && !(c.kind === "text" && c.hasDefault) ? " required" : "";
192
+ let html;
193
+ switch (c.kind) {
194
+ case "boolean":
195
+ html = `<input type="checkbox" name="${nombre}" prop:checked=\${${v} === "on"}>`;
196
+ break;
197
+ case "enum": {
198
+ const vacia = c.nullable ? `<option value=""></option>` : "";
199
+ html =
200
+ `<select name="${nombre}"${req}>${vacia}\n` +
201
+ ` <For each=\${() => ${valoresEnum}} key=\${(v: string) => v}\n` +
202
+ ` render=\${(v: string) => view\`<option prop:selected=\${${v} === v}>\${v}</option>\`}/>\n` +
203
+ ` </select>`;
204
+ break;
205
+ }
206
+ case "integer":
207
+ html = `<input type="number" name="${nombre}" step="1" prop:value=\${${texto}}${req}>`;
208
+ break;
209
+ case "decimal":
210
+ case "number":
211
+ html = `<input type="number" name="${nombre}" step="any" prop:value=\${${texto}}${req}>`;
212
+ break;
213
+ case "date":
214
+ html = `<input type="date" name="${nombre}" prop:value=\${${texto}}${req}>`;
215
+ break;
216
+ case "datetime":
217
+ html = `<input type="datetime-local" name="${nombre}" prop:value=\${${texto}}${req}>`;
218
+ break;
219
+ case "time":
220
+ html = `<input type="time" name="${nombre}" prop:value=\${${texto}}${req}>`;
221
+ break;
222
+ default:
223
+ if (LARGAS.test(nombre)) html = `<textarea name="${nombre}"${req}>\${${texto}}</textarea>`;
224
+ else if (/(^|_)e-?mail$/i.test(nombre)) html = `<input type="email" name="${nombre}" prop:value=\${${texto}}${req}>`;
225
+ else html = `<input name="${nombre}" prop:value=\${${texto}}${req}>`;
226
+ }
227
+ return (
228
+ ` <label>${etiqueta(nombre)}\n ${html}\n </label>\n` +
229
+ ` <p class="error">\${errors${acceso(nombre)} ?? ""}</p>`
230
+ );
231
+ }
232
+
233
+ /** Una fila de la base a los textos del formulario. */
234
+ function aTexto(c, fila) {
235
+ const v = `${fila}${acceso(c.name)}`;
236
+ switch (c.kind) {
237
+ case "boolean":
238
+ return `${v} ? "on" : ""`;
239
+ case "date":
240
+ return `${v} == null ? "" : toDate(${v})`;
241
+ case "datetime":
242
+ return `${v} == null ? "" : toDateTime(${v})`;
243
+ default:
244
+ return `${v} == null ? "" : String(${v})`;
245
+ }
246
+ }
247
+
248
+ /**
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.
252
+ */
253
+ function duplicado(mysql) {
254
+ 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 : "";`;
261
+ 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.
264
+ */
265
+ export function duplicate(error: unknown, formData: FormData) {
266
+ const e = error as { code?: unknown; detail?: unknown } | null;
267
+ ${deteccion}
268
+ return fail(409, { ok: false as const, errors: { [field]: "Already taken." }, values: texts(formData) });
269
+ }
270
+
271
+ /** The text fields of a form, to fill it in again. */
272
+ function texts(formData: FormData): FormValues {
273
+ const values: Record<string, string> = {};
274
+ for (const [name, value] of formData) if (typeof value === "string") values[name] = value;
275
+ return values;
276
+ }
277
+ `;
278
+ }
279
+
280
+ /** `src/resources/<tabla>.ts`: los esquemas, los campos y las conversiones. */
281
+ export function recurso(tabla, a, n, { db, enums }) {
282
+ const valoresEnum = (c) => (c.values ? (enums.has(c.sqlType) ? `${camel(c.sqlType)}Values` : JSON.stringify(c.values)) : "[]");
283
+ const importsEnum = [...new Set(a.alCrear.filter((c) => c.kind === "enum" && enums.has(c.sqlType)).map((c) => `${camel(c.sqlType)}Values`))];
284
+ const usaFechas = a.alEditar.some((c) => c.kind === "date" || c.kind === "datetime");
285
+ const conClave = !a.claveAutomatica;
286
+
287
+ 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");
289
+ const aParams = a.alEditar
290
+ .map((c) => {
291
+ const v = `data${acceso(c.name)}`;
292
+ return ` ${propiedad(c.name)}: ${c.nullable && c.kind !== "boolean" ? `${v} ?? null` : v},`;
293
+ })
294
+ .join("\n");
295
+ const aValores = a.alEditar.map((c) => ` ${propiedad(c.name)}: ${aTexto(c, "row")},`).join("\n");
296
+
297
+ const fechas = usaFechas
298
+ ? `
299
+ const pad = (n: number) => String(n).padStart(2, "0");
300
+ /** A \`Date\` as the value of \`<input type="date">\`. */
301
+ const toDate = (d: Date) => \`\${d.getFullYear()}-\${pad(d.getMonth() + 1)}-\${pad(d.getDate())}\`;
302
+ /** A \`Date\` as the value of \`<input type="datetime-local">\`, in local time. */
303
+ const toDateTime = (d: Date) => \`\${toDate(d)}T\${pad(d.getHours())}:\${pad(d.getMinutes())}\`;
304
+ `
305
+ : "";
306
+
307
+ const nuevoEsquema = conClave
308
+ ? `
309
+ /** The new ${singular(tabla.name)} form: the same fields plus the key, which is not edited later. */
310
+ export const New${n.uno}Form = fields({
311
+ ${propiedad(a.clave.name)}: ${campo(a.clave, "[]")},
312
+ ...shape,
313
+ });
314
+ `
315
+ : `
316
+ /** Creating asks for the same fields: the key comes from the database. */
317
+ export const New${n.uno}Form = ${n.uno}Form;
318
+ `;
319
+
320
+ const cuerpo = conClave
321
+ ? `<Show when=\${() => props.withKey === true}>\n${controles([a.clave]).replace(/^/gm, " ")}\n </Show>\n${controles(a.alEditar)}`
322
+ : controles(a.alEditar);
323
+
324
+ return `// Generated by ascua-scaffold from the \`${tabla.name}\` table. Yours to edit:
325
+ // it is not generated again.
326
+ import type { Infer } from "ascua";
327
+ import { fail, field, fields } from "vite-plugin-ascua/site";
328
+
329
+ import { ${[...importsEnum, `type ${n.get}Row`].join(", ")} } from "${db}";
330
+
331
+ const shape = {
332
+ ${esquema}
333
+ };
334
+
335
+ /** What the ${singular(tabla.name)} form sends, validated on the server. */
336
+ export const ${n.uno}Form = fields(shape);
337
+ ${nuevoEsquema}
338
+ export type ${n.uno}Data = Infer<typeof ${n.uno}Form>;
339
+ export type FormValues = Partial<Record<string, string | string[]>>;
340
+ export type FormErrors = Partial<Record<string, string>>;
341
+ ${fechas}
342
+ /** The form data as query parameters: empty optional fields become NULL. */
343
+ export function ${camel(n.uno)}Params(data: ${n.uno}Data) {
344
+ return {
345
+ ${aParams}
346
+ };
347
+ }
348
+
349
+ ${duplicado(a.mysql)}
350
+ /** A row as the values of the form. */
351
+ export function ${camel(n.uno)}Values(row: ${n.get}Row): FormValues {
352
+ return {
353
+ ${aValores}
354
+ };
355
+ }
356
+
357
+ /** 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" : ""} }) {
359
+ const { values, errors } = props;
360
+ return view\`
361
+ <div class="fields">
362
+ ${cuerpo}
363
+ <p class="error">\${errors[""] ?? ""}</p>
364
+ <style>
365
+ .fields { display: grid; gap: .25rem; max-width: 32rem; }
366
+ .fields label { display: grid; gap: .25rem; }
367
+ .error { color: crimson; margin: 0; min-height: 1.25em; }
368
+ </style>
369
+ </div>\`;
370
+ }
371
+ `;
372
+ }
373
+
374
+ /** Cómo se convierte el texto de la URL en el tipo de la clave. */
375
+ function claveDeUrl(a) {
376
+ if (a.clave.kind === "integer") {
377
+ // `int8` entra como texto para no perder precisión; los demás, como número.
378
+ const ancho = a.clave.sqlType === "int8" && !a.mysql;
379
+ return { guarda: `/^\\d+$/.test(id)`, valor: ancho ? "id" : "Number(id)" };
380
+ }
381
+ if (a.clave.kind === "uuid") return { guarda: `/^[0-9a-f-]{36}$/i.test(id)`, valor: "id" };
382
+ return { guarda: "id !== \"\"", valor: "id" };
383
+ }
384
+
385
+ /** Las tres rutas del recurso. */
386
+ export function rutas(tabla, a, n, { db, pool, resource, base }) {
387
+ const { guarda, valor } = claveDeUrl(a);
388
+ const pk = a.clave.name;
389
+ const fila = (r) => `${r}${acceso(pk)}`;
390
+ const titulo = etiqueta(tabla.name);
391
+ const uno = etiqueta(singular(tabla.name)).toLowerCase();
392
+ const forma = `${n.uno}Form`;
393
+ const params = `${camel(n.uno)}Params`;
394
+ const values = `${camel(n.uno)}Values`;
395
+ const campos = `${n.uno}Fields`;
396
+
397
+ 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("");
399
+
400
+ const index = `// Generated by ascua-scaffold. Yours to edit: it is not generated again.
401
+ import type { LoadContext } from "vite-plugin-ascua/site";
402
+
403
+ import { ${camel(n.list)}, type ${n.list}Row } from "${db.index}";
404
+ import { db } from "${pool.index}";
405
+
406
+ const PAGE = 50;
407
+
408
+ export const title = "${titulo}";
409
+
410
+ export async function load(_params: Record<string, string>, { url }: LoadContext) {
411
+ const page = Math.max(1, Math.floor(Number(url.searchParams.get("page"))) || 1);
412
+ // One more than a page, to know whether there is a next one.
413
+ 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 };
415
+ }
416
+
417
+ /** How a value of any column reads in a cell. */
418
+ function show(value: unknown): string {
419
+ if (value == null) return "";
420
+ if (value instanceof Date) return value.toLocaleString();
421
+ if (typeof value === "boolean") return value ? "yes" : "no";
422
+ return String(value);
423
+ }
424
+
425
+ export default function ${n.plural}({ data }: { data: Awaited<ReturnType<typeof load>> }) {
426
+ return view\`
427
+ <section>
428
+ <h1>${titulo}</h1>
429
+ <p><a href="${base}/new">New ${uno}</a></p>
430
+ <table>
431
+ <thead><tr>${cabeceras}</tr></thead>
432
+ <tbody>
433
+ <For each=\${() => data.rows} key=\${(r: ${n.list}Row) => String(${fila("r")})}
434
+ render=\${(r: ${n.list}Row) => view\`<tr><td><a href=\${\`${base}/\${${fila("r")}}\`}>\${String(${fila("r")})}</a></td>${celdas}</tr>\`}/>
435
+ </tbody>
436
+ </table>
437
+ <nav>
438
+ <Show when=\${() => data.page > 1}><a href=\${\`?page=\${data.page - 1}\`}>Previous</a></Show>
439
+ <Show when=\${() => data.more}><a href=\${\`?page=\${data.page + 1}\`}>Next</a></Show>
440
+ </nav>
441
+ </section>\`;
442
+ }
443
+ `;
444
+
445
+ const nuevaForma = `New${n.uno}Form`;
446
+ const datos = a.claveAutomatica
447
+ ? `${params}(result.data)`
448
+ : `{ ...${params}(result.data), ${propiedad(pk)}: result.data${acceso(pk)} }`;
449
+ // Crear dentro de try: un UNIQUE repetido vuelve al formulario. La
450
+ // redirección va fuera, porque también se lanza.
451
+ 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}}\`);`;
453
+ const despuesDeCrear = a.mysql
454
+ ? a.claveAutomatica
455
+ ? intento("id", "id")
456
+ : intento(null, `result.data${acceso(pk)}`)
457
+ : intento("row", fila("row!"));
458
+
459
+ const nuevo = `// Generated by ascua-scaffold. Yours to edit: it is not generated again.
460
+ import { fail, redirect, validate, type ActionContext, type FormResult } from "vite-plugin-ascua/site";
461
+
462
+ import { ${camel(n.create)} } from "${db.nested}";
463
+ import { db } from "${pool.nested}";
464
+ import { ${nuevaForma}, ${campos}, ${params}, duplicate } from "${resource.nested}";
465
+
466
+ export const title = "New ${uno}";
467
+
468
+ export const actions = {
469
+ async default({ formData }: ActionContext) {
470
+ const result = await validate(${nuevaForma}, formData);
471
+ if (!result.ok) return fail(400, result);
472
+ ${despuesDeCrear}
473
+ },
474
+ };
475
+
476
+ export default function New${n.uno}({ form }: { form?: FormResult<typeof ${nuevaForma}> }) {
477
+ const failed = form?.ok === false ? form : undefined;
478
+ return view\`
479
+ <section>
480
+ <h1>New ${uno}</h1>
481
+ <form method="post">
482
+ <${campos} values=\${failed?.values ?? {}} errors=\${failed?.errors ?? {}}${a.claveAutomatica ? "" : " withKey=\${true}"}/>
483
+ <button>Create</button>
484
+ </form>
485
+ <p><a href="${base}">Back</a></p>
486
+ </section>\`;
487
+ }
488
+ `;
489
+
490
+ const actualizar = a.alEditar.length
491
+ ? `
492
+ async default({ params, formData }: ActionContext<Params>) {
493
+ const id = key(params.id);
494
+ const result = await validate(${forma}, formData);
495
+ if (!result.ok) return fail(400, result);
496
+ let changed;
497
+ try {
498
+ changed = await ${camel(n.update)}(await db(), { ...${params}(result.data), ${propiedad(pk)}: id });
499
+ } catch (error) {
500
+ return duplicate(error, formData);
501
+ }
502
+ if (changed === 0) throw notFound();
503
+ throw redirect("${base}");
504
+ },`
505
+ : "";
506
+
507
+ const editar = `// Generated by ascua-scaffold. Yours to edit: it is not generated again.
508
+ import { fail, notFound, redirect, validate, type ActionContext, type FormResult } from "vite-plugin-ascua/site";
509
+
510
+ import { ${[camel(n.get), a.alEditar.length ? camel(n.update) : null, camel(n.delete)].filter(Boolean).join(", ")} } from "${db.nested}";
511
+ import { db } from "${pool.nested}";
512
+ import { ${forma}, ${campos}, ${params}, ${values}, duplicate } from "${resource.nested}";
513
+
514
+ type Params = { id: string };
515
+
516
+ /** The key from the URL, or a 404 if it cannot be one. */
517
+ function key(id: string) {
518
+ if (!(${guarda})) throw notFound();
519
+ return ${valor};
520
+ }
521
+
522
+ export const title = ({ id }: Params) => \`${etiqueta(singular(tabla.name))} \${id}\`;
523
+
524
+ export async function load({ id }: Params) {
525
+ const row = await ${camel(n.get)}(await db(), { ${propiedad(pk)}: key(id) });
526
+ if (!row) throw notFound();
527
+ return row;
528
+ }
529
+
530
+ export const actions = {${actualizar}
531
+ async delete({ params }: ActionContext<Params>) {
532
+ await ${camel(n.delete)}(await db(), { ${propiedad(pk)}: key(params.id) });
533
+ throw redirect("${base}");
534
+ },
535
+ };
536
+
537
+ export default function Edit${n.uno}({ data, form }: { data: Awaited<ReturnType<typeof load>>; form?: FormResult<typeof ${forma}> }) {
538
+ const failed = form?.ok === false ? form : undefined;
539
+ return view\`
540
+ <section>
541
+ <h1>${etiqueta(singular(tabla.name))} \${String(${fila("data")})}</h1>
542
+ ${a.alEditar.length ? `<form method="post">
543
+ <${campos} values=\${failed?.values ?? ${values}(data)} errors=\${failed?.errors ?? {}}/>
544
+ <button>Save</button>
545
+ </form>
546
+ ` : ""}<form method="post" action="?/delete">
547
+ <button>Delete</button>
548
+ </form>
549
+ <p><a href="${base}">Back</a></p>
550
+ </section>\`;
551
+ }
552
+ `;
553
+
554
+ return { index, nuevo, editar };
555
+ }
556
+
557
+ /** `src/pool.ts`: un pool para todo el servidor, con las migraciones aplicadas una vez. */
558
+ export function piscina({ mysql, migrate, db }) {
559
+ if (mysql) {
560
+ return `// Generated by ascua-scaffold. Yours to edit: it is not generated again.
561
+ import mysql from "mysql2/promise";
562
+ ${migrate ? `\nimport { migrate } from "${db}";\n` : ""}
563
+ /** One pool for the whole server, from DATABASE_URL. */
564
+ export const pool = mysql.createPool(process.env.DATABASE_URL ?? "mysql://root@127.0.0.1/app");
565
+ ${
566
+ migrate
567
+ ? `
568
+ let ready: Promise<typeof pool> | undefined;
569
+
570
+ /** The pool, once the pending migrations are applied. A failure is retried on the next request. */
571
+ export function db(): Promise<typeof pool> {
572
+ ready ??= migrate(pool).then(
573
+ () => pool,
574
+ (error) => {
575
+ ready = undefined;
576
+ throw error;
577
+ },
578
+ );
579
+ return ready;
580
+ }
581
+ `
582
+ : `
583
+ /** The pool. Migrations are applied by hand. */
584
+ export async function db(): Promise<typeof pool> {
585
+ return pool;
586
+ }
587
+ `
588
+ }`;
589
+ }
590
+ return `// Generated by ascua-scaffold. Yours to edit: it is not generated again.
591
+ import pg from "pg";
592
+ ${migrate ? `\nimport { migrate } from "${db}";\n` : ""}
593
+ /** One pool for the whole server: DATABASE_URL, or the PG* variables. */
594
+ export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
595
+ ${
596
+ migrate
597
+ ? `
598
+ let ready: Promise<pg.Pool> | undefined;
599
+
600
+ /** The pool, once the pending migrations are applied. A failure is retried on the next request. */
601
+ export function db(): Promise<pg.Pool> {
602
+ ready ??= migrate(pool).then(
603
+ () => pool,
604
+ (error) => {
605
+ ready = undefined;
606
+ throw error;
607
+ },
608
+ );
609
+ return ready;
610
+ }
611
+ `
612
+ : `
613
+ /** The pool. Migrations are applied by hand. */
614
+ export async function db(): Promise<pg.Pool> {
615
+ return pool;
616
+ }
617
+ `
618
+ }`;
619
+ }
package/index.js ADDED
@@ -0,0 +1,233 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ascua-scaffold: de una tabla a un recurso con lista, alta, edición y baja.
4
+ *
5
+ * stil exec ascua-scaffold posts
6
+ * npx ascua-scaffold posts
7
+ *
8
+ * Lee el esquema con el `brasa-sql` del proyecto (las migraciones de
9
+ * `brasa.json`), escribe las consultas, vuelve a generar el módulo de brasa y
10
+ * escribe el esquema del formulario y las rutas. Sin dependencias propias.
11
+ */
12
+
13
+ import { spawnSync } from "node:child_process";
14
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
15
+ import { createRequire } from "node:module";
16
+ import { dirname, join, relative, resolve } from "node:path";
17
+ import { fileURLToPath, pathToFileURL } from "node:url";
18
+
19
+ import { analizar, consultasSql, nombres, piscina, recurso, rutas, ScaffoldError, t } from "./generate.js";
20
+
21
+ const AYUDA = t(
22
+ `Uso: ascua-scaffold <tabla> [opciones]
23
+
24
+ Genera, a partir de una tabla de las migraciones de brasa:
25
+ db/queries/<tabla>.sql List, Get, Create, Update y Delete
26
+ src/resources/<tabla>.ts el esquema del formulario y sus campos
27
+ src/routes/<tabla>/index.ts la lista, paginada
28
+ src/routes/<tabla>/new.ts el alta
29
+ src/routes/<tabla>/[id].ts la edición y la baja
30
+ src/pool.ts el pool de la base, si no existe
31
+
32
+ Opciones:
33
+ --config <ruta> el brasa.json (por defecto, el del directorio actual)
34
+ --force reescribe los archivos que ya existan
35
+ -h, --help esta ayuda`,
36
+ `Usage: ascua-scaffold <table> [options]
37
+
38
+ Generates, from a table in the brasa migrations:
39
+ db/queries/<table>.sql List, Get, Create, Update and Delete
40
+ src/resources/<table>.ts the form schema and its fields
41
+ src/routes/<table>/index.ts the list, paginated
42
+ src/routes/<table>/new.ts creating
43
+ src/routes/<table>/[id].ts editing and deleting
44
+ src/pool.ts the database pool, if missing
45
+
46
+ Options:
47
+ --config <path> the brasa.json (default: the one in the current directory)
48
+ --force overwrite files that already exist
49
+ -h, --help this help`,
50
+ );
51
+
52
+ const POR_DEFECTO = { dialect: "postgres", migrations: "db/migrations", queries: "db/queries", out: "src/db.ts", migrate: true };
53
+
54
+ /** `["posts", "--force"]` → `{ table: "posts", force: true }`. */
55
+ export function leerArgumentos(argv) {
56
+ const salida = { table: undefined, config: "brasa.json", force: false, ayuda: false };
57
+ for (let i = 0; i < argv.length; i++) {
58
+ const a = argv[i];
59
+ if (a === "-h" || a === "--help") salida.ayuda = true;
60
+ else if (a === "--force") salida.force = true;
61
+ else if (a === "--config") salida.config = argv[++i];
62
+ else if (a.startsWith("--config=")) salida.config = a.slice("--config=".length);
63
+ else if (!a.startsWith("-") && salida.table === undefined) salida.table = a;
64
+ else throw new ScaffoldError(t(`opción desconocida: ${a}`, `unknown option: ${a}`));
65
+ }
66
+ return salida;
67
+ }
68
+
69
+ /** El `brasa-sql` del proyecto: el mismo que genera su módulo. */
70
+ async function cargarBrasa(raiz) {
71
+ const require = createRequire(join(raiz, "package.json"));
72
+ let entrada;
73
+ try {
74
+ entrada = require.resolve("brasa-sql");
75
+ } catch {
76
+ throw new ScaffoldError(t("falta brasa-sql en el proyecto: stil add -D brasa-sql", "brasa-sql is missing from the project: npm i -D brasa-sql"));
77
+ }
78
+ const brasa = await import(pathToFileURL(entrada).href);
79
+ const bin = join(dirname(entrada), "bin", "brasa.js");
80
+ return { compile: brasa.compile, bin };
81
+ }
82
+
83
+ function leerSql(dir) {
84
+ if (!existsSync(dir)) return [];
85
+ return readdirSync(dir)
86
+ .filter((n) => n.endsWith(".sql"))
87
+ .sort()
88
+ .map((n) => ({ name: relative(process.cwd(), join(dir, n)), content: readFileSync(join(dir, n), "utf8") }));
89
+ }
90
+
91
+ /** Una importación relativa de TypeScript con extensión `.js`, como pide `nodenext`. */
92
+ function importar(desde, hacia) {
93
+ let r = relative(dirname(desde), hacia).replace(/\\/g, "/").replace(/\.ts$/, ".js");
94
+ if (!r.startsWith(".")) r = `./${r}`;
95
+ return r;
96
+ }
97
+
98
+ export async function main(argv = process.argv.slice(2)) {
99
+ const args = leerArgumentos(argv);
100
+ if (args.ayuda || !args.table) {
101
+ console.log(AYUDA);
102
+ return args.ayuda ? 0 : 1;
103
+ }
104
+
105
+ const rutaConfig = resolve(args.config);
106
+ const raiz = dirname(rutaConfig);
107
+ if (!existsSync(rutaConfig)) {
108
+ throw new ScaffoldError(t(`no existe ${rutaConfig}: crea uno con \`brasa init\``, `${rutaConfig} does not exist: create one with \`brasa init\``));
109
+ }
110
+ const config = { ...POR_DEFECTO, ...JSON.parse(readFileSync(rutaConfig, "utf8")) };
111
+ const mysql = config.dialect === "mysql";
112
+ const dirMigraciones = resolve(raiz, config.migrations);
113
+ const dirConsultas = resolve(raiz, config.queries);
114
+ const out = resolve(raiz, config.out);
115
+
116
+ const brasa = await cargarBrasa(raiz);
117
+ const migrations = leerSql(dirMigraciones);
118
+ const queries = leerSql(dirConsultas);
119
+ const salida = brasa.compile({ dialect: config.dialect, migrate: config.migrate, migrations, queries });
120
+ if (salida.code === null) {
121
+ 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"));
122
+ }
123
+ if (!salida.tables) {
124
+ throw new ScaffoldError(t("este brasa-sql no describe las tablas: actualízalo", "this brasa-sql does not describe tables: update it"));
125
+ }
126
+
127
+ const tabla = salida.tables.find((x) => x.name === args.table) ?? salida.tables.find((x) => x.name.toLowerCase() === args.table.toLowerCase());
128
+ if (!tabla) {
129
+ const hay = salida.tables.filter((x) => !x.view).map((x) => x.name);
130
+ throw new ScaffoldError(
131
+ 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"}`),
132
+ );
133
+ }
134
+
135
+ const a = analizar(tabla, { mysql });
136
+ const n = nombres(tabla);
137
+ // Con --force, las del archivo que se va a reescribir no cuentan.
138
+ const propio = join(dirConsultas, `${tabla.name}.sql`);
139
+ const ajenas = salida.queries.filter((x) => !(args.force && resolve(x.file) === propio));
140
+ const repetidas = [n.list, n.get, n.create, n.update, n.delete].filter((q) => ajenas.some((x) => x.name === q));
141
+ const enPropio = repetidas.filter((q) => salida.queries.some((x) => x.name === q && resolve(x.file) === propio));
142
+ if (repetidas.length && enPropio.length === repetidas.length) {
143
+ // Las del ejemplo de `brasa init`, por ejemplo: están en el archivo que se va a escribir.
144
+ const archivo = relative(process.cwd(), propio);
145
+ throw new ScaffoldError(
146
+ t(`${archivo} ya tiene ${repetidas.join(", ")}. Con --force se reemplaza ese archivo`, `${archivo} already has ${repetidas.join(", ")}. Use --force to replace that file`),
147
+ );
148
+ }
149
+ if (repetidas.length) {
150
+ throw new ScaffoldError(
151
+ 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`),
152
+ );
153
+ }
154
+
155
+ const carpeta = tabla.name.replace(/_/g, "-").toLowerCase();
156
+ // Los enums de Postgres tienen su `<x>Values` en el módulo; los de MySQL
157
+ // (`ENUM('a','b')` en la columna), no: sus valores van escritos.
158
+ const enums = new Set(mysql ? [] : tabla.columns.filter((c) => c.kind === "enum").map((c) => c.sqlType));
159
+
160
+ const archivos = {
161
+ sql: join(dirConsultas, `${tabla.name}.sql`),
162
+ recurso: join(raiz, "src", "resources", `${carpeta}.ts`),
163
+ index: join(raiz, "src", "routes", carpeta, "index.ts"),
164
+ nuevo: join(raiz, "src", "routes", carpeta, "new.ts"),
165
+ editar: join(raiz, "src", "routes", carpeta, "[id].ts"),
166
+ pool: join(raiz, "src", "pool.ts"),
167
+ };
168
+ const existentes = Object.entries(archivos).filter(([k, f]) => k !== "pool" && existsSync(f));
169
+ if (existentes.length && !args.force) {
170
+ throw new ScaffoldError(
171
+ 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`),
172
+ );
173
+ }
174
+
175
+ const r = rutas(tabla, a, n, {
176
+ db: { index: importar(archivos.index, out), nested: importar(archivos.nuevo, out) },
177
+ pool: { index: importar(archivos.index, archivos.pool), nested: importar(archivos.nuevo, archivos.pool) },
178
+ resource: { nested: importar(archivos.nuevo, archivos.recurso) },
179
+ base: `/${carpeta}`,
180
+ });
181
+ const contenidos = {
182
+ sql: consultasSql(tabla, a, n),
183
+ recurso: recurso(tabla, a, n, { db: importar(archivos.recurso, out), enums }),
184
+ index: r.index,
185
+ nuevo: r.nuevo,
186
+ editar: r.editar,
187
+ };
188
+ const escritos = [];
189
+ for (const [k, contenido] of Object.entries(contenidos)) {
190
+ mkdirSync(dirname(archivos[k]), { recursive: true });
191
+ writeFileSync(archivos[k], contenido);
192
+ escritos.push(archivos[k]);
193
+ }
194
+ if (!existsSync(archivos.pool)) {
195
+ writeFileSync(archivos.pool, piscina({ mysql, migrate: config.migrate, db: importar(archivos.pool, out) }));
196
+ escritos.push(archivos.pool);
197
+ }
198
+
199
+ // El módulo de brasa, con las consultas nuevas.
200
+ const generado = spawnSync(process.execPath, [brasa.bin, "generate", "--config", rutaConfig], { cwd: raiz, stdio: "inherit" });
201
+ if (generado.status !== 0) {
202
+ 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)"));
203
+ }
204
+
205
+ for (const f of escritos) console.log(` ${relative(process.cwd(), f)}`);
206
+ const paquete = existsSync(join(raiz, "package.json")) ? JSON.parse(readFileSync(join(raiz, "package.json"), "utf8")) : {};
207
+ const deps = { ...paquete.dependencies, ...paquete.devDependencies };
208
+ const faltan = (mysql ? ["mysql2"] : ["pg", "@types/pg"]).concat("@types/node").filter((d) => !deps[d]);
209
+ console.log("");
210
+ console.log(t(`Listo: /${carpeta} en el sitio.`, `Done: /${carpeta} on the site.`));
211
+ if (faltan.length) console.log(t(`Falta instalar: stil add ${faltan.join(" ")}`, `Still to install: npm i ${faltan.join(" ")}`));
212
+ if (!/mode:\s*["']server["']/.test(leer(join(raiz, "vite.config.ts")))) {
213
+ 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'));
214
+ }
215
+ return 0;
216
+ }
217
+
218
+ function leer(f) {
219
+ return existsSync(f) ? readFileSync(f, "utf8") : "";
220
+ }
221
+
222
+ if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
223
+ main().then(
224
+ (codigo) => process.exit(codigo),
225
+ (error) => {
226
+ if (error instanceof ScaffoldError) {
227
+ console.error(`ascua-scaffold: ${error.message}`);
228
+ process.exit(1);
229
+ }
230
+ throw error;
231
+ },
232
+ );
233
+ }
package/package.json CHANGED
@@ -1,6 +1,25 @@
1
1
  {
2
2
  "name": "ascua-scaffold",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "De una tabla de brasa a un recurso de Ascua: consultas, formulario validado y rutas para listar, crear, editar y borrar.",
5
+ "keywords": ["ascua", "scaffold", "crud", "brasa", "sql", "forms"],
6
+ "homepage": "https://ascua.gitweave.run",
7
+ "license": "MIT OR Apache-2.0",
8
+ "type": "module",
9
+ "bin": {
10
+ "ascua-scaffold": "index.js"
11
+ },
12
+ "files": ["index.js", "generate.js", "README.md", "LICENSE-MIT", "LICENSE-APACHE"],
13
+ "engines": {
14
+ "node": ">=20"
15
+ },
16
+ "scripts": {
17
+ "test": "vitest run"
18
+ },
19
+ "devDependencies": {
20
+ "vitest": "^5.0.3"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ }
25
+ }