tina4-nodejs 3.13.63 → 3.13.66
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +3 -3
- package/package.json +1 -1
- package/packages/cli/src/bin.ts +338 -152
- package/packages/cli/src/commands/build.ts +110 -0
- package/packages/cli/src/commands/generate.ts +1154 -134
- package/packages/cli/src/commands/queue.ts +325 -0
- package/packages/core/public/js/frond.js +19 -8
- package/packages/core/public/js/frond.min.js +1 -1
- package/packages/core/src/routeDiscovery.ts +10 -2
- package/packages/core/src/types.ts +9 -1
- package/packages/orm/src/baseModel.ts +4 -1
|
@@ -1,19 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CLI command: generate — Rich scaffolding
|
|
3
|
-
*
|
|
2
|
+
* CLI command: generate — Rich, scaffolding-first code generation.
|
|
3
|
+
*
|
|
4
|
+
* Two shapes of generator:
|
|
5
|
+
* • CRUD-shaped (model, crud, migration, middleware, form, view, test, auth):
|
|
6
|
+
* emit WORKING code — the boilerplate IS the feature. A working extension
|
|
7
|
+
* point is flagged with a light `EXTEND:` marker (no throw).
|
|
8
|
+
* • LOGIC-shaped (custom route body, service, queue, validator, seeder,
|
|
9
|
+
* websocket, listener): emit the real WIRING (imports + registration +
|
|
10
|
+
* signature) plus a single greppable `AI-FILL` fill-spec that ends in
|
|
11
|
+
* `throw new Error("… not implemented")`, so an unfilled scaffold fails LOUD.
|
|
12
|
+
*
|
|
13
|
+
* Secure by default: the router gates every scaffolded WRITE (POST/PUT/DELETE)
|
|
14
|
+
* behind a Bearer token automatically; reads (GET) are public automatically.
|
|
15
|
+
* `--public` re-adds the opt-out (`export const secure = false;`) on the WRITE
|
|
16
|
+
* method files only — mirroring the AutoCrud `public` opt-in.
|
|
4
17
|
*
|
|
5
18
|
* Usage:
|
|
6
19
|
* tina4nodejs generate model Product --fields "name:string,price:float"
|
|
7
|
-
* tina4nodejs generate route products --model Product
|
|
8
|
-
* tina4nodejs generate crud Product --fields "name:string,price:float"
|
|
20
|
+
* tina4nodejs generate route products --model Product [--public]
|
|
21
|
+
* tina4nodejs generate crud Product --fields "name:string,price:float" [--public]
|
|
9
22
|
* tina4nodejs generate migration create_product
|
|
10
23
|
* tina4nodejs generate middleware Auth
|
|
11
24
|
* tina4nodejs generate test products --model Product
|
|
12
25
|
* tina4nodejs generate form Product --fields "name:string,price:float"
|
|
13
26
|
* tina4nodejs generate view Product --fields "name:string,price:float"
|
|
14
27
|
* tina4nodejs generate auth
|
|
28
|
+
* tina4nodejs generate service Cleanup --every 5m | --cron "0 3 * * *"
|
|
29
|
+
* tina4nodejs generate queue order-emails
|
|
30
|
+
* tina4nodejs generate validator CreateUser
|
|
31
|
+
* tina4nodejs generate seeder Product
|
|
32
|
+
* tina4nodejs generate websocket chat
|
|
33
|
+
* tina4nodejs generate listener user.created
|
|
15
34
|
*/
|
|
16
|
-
import { existsSync, mkdirSync,
|
|
35
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
17
36
|
import { join, resolve } from "node:path";
|
|
18
37
|
|
|
19
38
|
// ── Field type mapping ──────────────────────────────────────────────
|
|
@@ -72,6 +91,15 @@ function toCamel(name: string): string {
|
|
|
72
91
|
return name.charAt(0).toLowerCase() + name.slice(1);
|
|
73
92
|
}
|
|
74
93
|
|
|
94
|
+
/** slug-of-anything → PascalCase (order-emails → OrderEmails). */
|
|
95
|
+
export function toPascal(name: string): string {
|
|
96
|
+
return name
|
|
97
|
+
.split(/[^0-9a-zA-Z]+/)
|
|
98
|
+
.filter(Boolean)
|
|
99
|
+
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
|
|
100
|
+
.join("");
|
|
101
|
+
}
|
|
102
|
+
|
|
75
103
|
export function parseFields(fieldsStr: string): Array<[string, string]> {
|
|
76
104
|
if (!fieldsStr || !fieldsStr.trim()) return [];
|
|
77
105
|
const result: Array<[string, string]> = [];
|
|
@@ -79,7 +107,7 @@ export function parseFields(fieldsStr: string): Array<[string, string]> {
|
|
|
79
107
|
const trimmed = part.trim();
|
|
80
108
|
if (trimmed.includes(":")) {
|
|
81
109
|
const [fname, ftype] = trimmed.split(":", 2);
|
|
82
|
-
result.push([fname.trim(), ftype.trim().toLowerCase()]);
|
|
110
|
+
if (fname.trim()) result.push([fname.trim(), ftype.trim().toLowerCase()]);
|
|
83
111
|
} else if (trimmed) {
|
|
84
112
|
result.push([trimmed, "string"]);
|
|
85
113
|
}
|
|
@@ -88,8 +116,11 @@ export function parseFields(fieldsStr: string): Array<[string, string]> {
|
|
|
88
116
|
}
|
|
89
117
|
|
|
90
118
|
export function parseCliArgs(args: string[]): { flags: Record<string, string | boolean>; positional: string[] } {
|
|
91
|
-
// Boolean-only flags that never take a value argument
|
|
92
|
-
const booleanFlags = new Set([
|
|
119
|
+
// Boolean-only flags that never take a value argument.
|
|
120
|
+
const booleanFlags = new Set([
|
|
121
|
+
"no-browser", "no-reload", "production", "managed", "all", "clear",
|
|
122
|
+
"public", "no-migration",
|
|
123
|
+
]);
|
|
93
124
|
|
|
94
125
|
const flags: Record<string, string | boolean> = {};
|
|
95
126
|
const positional: string[] = [];
|
|
@@ -115,6 +146,59 @@ export function parseCliArgs(args: string[]): { flags: Record<string, string | b
|
|
|
115
146
|
return { flags, positional };
|
|
116
147
|
}
|
|
117
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Parse a `--every` duration ("5m", "30s", "2h", "1d", or bare seconds) → seconds.
|
|
151
|
+
* Falls back to 60s on an empty/unparseable value so a scaffold always has a
|
|
152
|
+
* valid ServiceRunner interval.
|
|
153
|
+
*/
|
|
154
|
+
export function parseEvery(every: string | boolean | undefined): number {
|
|
155
|
+
if (!every || every === true) return 60;
|
|
156
|
+
const s = String(every).trim().toLowerCase();
|
|
157
|
+
const units: Record<string, number> = { s: 1, m: 60, h: 3600, d: 86400 };
|
|
158
|
+
const unit = s.slice(-1);
|
|
159
|
+
if (unit in units) {
|
|
160
|
+
const n = parseFloat(s.slice(0, -1));
|
|
161
|
+
return Number.isFinite(n) ? Math.max(1, Math.round(n * units[unit])) : 60;
|
|
162
|
+
}
|
|
163
|
+
const n = parseFloat(s);
|
|
164
|
+
return Number.isFinite(n) ? Math.max(1, Math.round(n)) : 60;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The canonical AI-FILL placeholder for a LOGIC-shaped stub — a tight, grounded
|
|
169
|
+
* fill-spec (not a vague `// TODO`) so a coding agent (or dev) completes it
|
|
170
|
+
* correctly. `throw new Error(...)` makes an unfilled scaffold fail LOUD; the
|
|
171
|
+
* greppable `AI-FILL` banner lets a human/agent jump to every gap. `use` names
|
|
172
|
+
* only REAL tina4-nodejs symbols (verified in source).
|
|
173
|
+
*/
|
|
174
|
+
export function aiFill(
|
|
175
|
+
fn: string,
|
|
176
|
+
spec: { intent: string; given?: string; use: string; ret?: string; ground: string; raise: string },
|
|
177
|
+
indent = " ",
|
|
178
|
+
): string {
|
|
179
|
+
const rule = (label: string) => "─".repeat(Math.max(4, 46 - label.length));
|
|
180
|
+
const lines = [`${indent}// ─── AI-FILL: ${fn} ${rule(fn)}`];
|
|
181
|
+
lines.push(`${indent}// Intent: ${spec.intent}`);
|
|
182
|
+
if (spec.given) lines.push(`${indent}// Given: ${spec.given}`);
|
|
183
|
+
lines.push(`${indent}// Use: ${spec.use}`);
|
|
184
|
+
if (spec.ret) lines.push(`${indent}// Return: ${spec.ret}`);
|
|
185
|
+
lines.push(`${indent}// Ground: ${spec.ground}`);
|
|
186
|
+
lines.push(`${indent}throw new Error(${JSON.stringify(spec.raise)}); // remove when implemented`);
|
|
187
|
+
lines.push(`${indent}// ${"─".repeat(52)}`);
|
|
188
|
+
return lines.join("\n") + "\n";
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The lighter EXTEND marker for CRUD-shaped WORKING code — no throw (the
|
|
193
|
+
* boilerplate IS the feature); just a greppable hint at the natural extension
|
|
194
|
+
* point (custom validation / business rules / authorization).
|
|
195
|
+
*/
|
|
196
|
+
export function extend(note: string, hint = "", indent = " "): string {
|
|
197
|
+
let out = `${indent}// ─── EXTEND: ${note} ${"─".repeat(Math.max(4, 46 - note.length))}\n`;
|
|
198
|
+
if (hint) out += `${indent}// ${hint}\n`;
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
|
|
118
202
|
function timestamp(): string {
|
|
119
203
|
const now = new Date();
|
|
120
204
|
return (
|
|
@@ -131,13 +215,54 @@ function isoNow(): string {
|
|
|
131
215
|
return new Date().toISOString().replace("T", " ").replace(/\.\d+Z$/, "");
|
|
132
216
|
}
|
|
133
217
|
|
|
218
|
+
// ── Generator registry — the single source of truth ─────────────────
|
|
219
|
+
//
|
|
220
|
+
// One entry per generator drives `generate` dispatch (below), the human help
|
|
221
|
+
// (`bin.ts` Generators section), AND the `commands --json` manifest's
|
|
222
|
+
// `generate.subcommands`. Add a generator in ONE place and it appears in
|
|
223
|
+
// dispatch, help, and discovery with no second list to keep in sync.
|
|
224
|
+
//
|
|
225
|
+
// Every handler is normalised to `(name, flags) => void`; `auth` takes no name
|
|
226
|
+
// so it drops it. Mirrors the Python master's GENERATORS registry
|
|
227
|
+
// (tina4_python/cli/__init__.py).
|
|
228
|
+
|
|
229
|
+
export interface GeneratorSpec {
|
|
230
|
+
handler: (name: string, flags: Record<string, string | boolean>) => void;
|
|
231
|
+
/** Arg/flag hint shown in `tina4nodejs help` (human only). */
|
|
232
|
+
usage: string;
|
|
233
|
+
summary: string;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export const GENERATORS: Record<string, GeneratorSpec> = {
|
|
237
|
+
model: { handler: generateModel, usage: '<Name> [--fields "name:string,price:float"]', summary: "ORM model + matching migration" },
|
|
238
|
+
route: { handler: generateRoute, usage: "<name> [--model Name] [--public]", summary: "CRUD route file, secure by default (--public opens writes)" },
|
|
239
|
+
crud: { handler: generateCrud, usage: '<Name> [--fields "..."] [--public]', summary: "Model + migration + routes + form + view + test" },
|
|
240
|
+
migration: { handler: (n, f) => generateMigration(n, f), usage: "<description>", summary: "Timestamped migration file (UP/DOWN)" },
|
|
241
|
+
middleware: { handler: generateMiddleware, usage: "<Name>", summary: "Middleware with before/after hooks" },
|
|
242
|
+
test: { handler: generateTest, usage: "<name> [--model Name]", summary: "Test file" },
|
|
243
|
+
form: { handler: generateForm, usage: '<Name> [--fields "..."]', summary: "Form template with inputs matching model fields" },
|
|
244
|
+
view: { handler: generateView, usage: '<Name> [--fields "..."]', summary: "List + detail view templates" },
|
|
245
|
+
auth: { handler: (_n, f) => generateAuth(f), usage: "", summary: "Login/register routes (public) + User model + templates" },
|
|
246
|
+
service: { handler: generateService, usage: '<Name> [--every 5m | --cron "..."]', summary: "Scheduled ServiceRunner task (src/services/)" },
|
|
247
|
+
queue: { handler: generateQueue, usage: "<topic>", summary: "Producer + consumer daemon worker (src/services/)" },
|
|
248
|
+
validator: { handler: generateValidator, usage: "<Name>", summary: "Request-body Validator (src/validators/)" },
|
|
249
|
+
seeder: { handler: generateSeeder, usage: "<Model>", summary: "FakeData + seedOrm seeder (src/seeds/)" },
|
|
250
|
+
websocket: { handler: generateWebsocket, usage: "<path>", summary: "websocket() handler (src/routes/)" },
|
|
251
|
+
listener: { handler: generateListener, usage: "<event>", summary: "Events.on(event) listener (src/listeners/)" },
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
/** Comma-separated generator names for usage/error output — derived, never a hand-kept list. */
|
|
255
|
+
const GENERATOR_LIST = Object.keys(GENERATORS).join(", ");
|
|
256
|
+
|
|
134
257
|
// ── Main entry point ────────────────────────────────────────────────
|
|
135
258
|
|
|
136
259
|
export async function generate(what: string, name: string, extraArgs: string[] = []): Promise<void> {
|
|
137
260
|
if (!what) {
|
|
138
261
|
console.error(" Usage: tina4nodejs generate <what> <name> [options]");
|
|
139
|
-
console.error(
|
|
262
|
+
console.error(` Generators: ${GENERATOR_LIST}`);
|
|
140
263
|
console.error(' Options: --fields "name:string,price:float" --model ModelName');
|
|
264
|
+
console.error(" --public open a route's writes (default: secure)");
|
|
265
|
+
console.error(' --every 5m | --cron "…" service schedule');
|
|
141
266
|
process.exit(1);
|
|
142
267
|
}
|
|
143
268
|
|
|
@@ -150,44 +275,21 @@ export async function generate(what: string, name: string, extraArgs: string[] =
|
|
|
150
275
|
|
|
151
276
|
const { flags } = parseCliArgs(extraArgs);
|
|
152
277
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
break;
|
|
163
|
-
case "migration":
|
|
164
|
-
generateMigration(name, flags);
|
|
165
|
-
break;
|
|
166
|
-
case "middleware":
|
|
167
|
-
generateMiddleware(name, flags);
|
|
168
|
-
break;
|
|
169
|
-
case "test":
|
|
170
|
-
generateTest(name, flags);
|
|
171
|
-
break;
|
|
172
|
-
case "form":
|
|
173
|
-
generateForm(name, flags);
|
|
174
|
-
break;
|
|
175
|
-
case "view":
|
|
176
|
-
generateView(name, flags);
|
|
177
|
-
break;
|
|
178
|
-
case "auth":
|
|
179
|
-
generateAuth(flags);
|
|
180
|
-
break;
|
|
181
|
-
default:
|
|
182
|
-
console.error(` Unknown generator: ${what}`);
|
|
183
|
-
console.error(" Available: model, route, crud, migration, middleware, test, form, view, auth");
|
|
184
|
-
process.exit(1);
|
|
278
|
+
// Dispatch from the single-source-of-truth GENERATORS registry (also feeds
|
|
279
|
+
// `bin.ts` help + the `commands --json` manifest subcommands).
|
|
280
|
+
const spec = GENERATORS[what];
|
|
281
|
+
if (spec) {
|
|
282
|
+
spec.handler(name, flags);
|
|
283
|
+
} else {
|
|
284
|
+
console.error(` Unknown generator: ${what}`);
|
|
285
|
+
console.error(` Available: ${GENERATOR_LIST}`);
|
|
286
|
+
process.exit(1);
|
|
185
287
|
}
|
|
186
288
|
}
|
|
187
289
|
|
|
188
290
|
// ── Model ───────────────────────────────────────────────────────────
|
|
189
291
|
|
|
190
|
-
function generateModel(name: string, flags: Record<string, string | boolean
|
|
292
|
+
function generateModel(name: string, flags: Record<string, string | boolean>, emitTest = true): void {
|
|
191
293
|
const fields = parseFields((flags.fields as string) || "");
|
|
192
294
|
const table = toTableName(name);
|
|
193
295
|
const dir = resolve("src/models");
|
|
@@ -208,7 +310,9 @@ function generateModel(name: string, flags: Record<string, string | boolean>): v
|
|
|
208
310
|
}
|
|
209
311
|
fieldLines.push(` created_at: { type: "datetime" as const },`);
|
|
210
312
|
|
|
211
|
-
|
|
313
|
+
// BaseModel is exported from tina4-nodejs/orm (NOT the core "tina4-nodejs"
|
|
314
|
+
// entry) — importing it from the core entry yields `undefined` at runtime.
|
|
315
|
+
const content = `import { BaseModel } from "tina4-nodejs/orm";
|
|
212
316
|
|
|
213
317
|
export default class ${name} extends BaseModel {
|
|
214
318
|
static tableName = "${table}";
|
|
@@ -220,41 +324,65 @@ ${fieldLines.join("\n")}
|
|
|
220
324
|
|
|
221
325
|
writeFileSafe(path, content);
|
|
222
326
|
|
|
223
|
-
// Generate matching migration unless --no-migration
|
|
327
|
+
// Generate matching migration unless --no-migration. The model's own test
|
|
328
|
+
// (below) proves the schema through the real ORM, so the migration sub-call
|
|
329
|
+
// does NOT also co-emit a migration test (emitTest=false).
|
|
224
330
|
if (!flags["no-migration"]) {
|
|
225
|
-
generateMigration(`create_${table}`, flags, fields.length > 0 ? fields : undefined, table);
|
|
331
|
+
generateMigration(`create_${table}`, flags, fields.length > 0 ? fields : undefined, table, false);
|
|
226
332
|
}
|
|
333
|
+
|
|
334
|
+
// Co-emit a real SQLite roundtrip test next to the model. Composite
|
|
335
|
+
// generators (crud/auth) pass emitTest=false and emit their own broader test.
|
|
336
|
+
if (emitTest) emitModelTest(name, table, fields);
|
|
227
337
|
}
|
|
228
338
|
|
|
229
339
|
// ── Route ───────────────────────────────────────────────────────────
|
|
340
|
+
//
|
|
341
|
+
// Secure by default. The router marks POST/PUT/DELETE `secure: true` unless the
|
|
342
|
+
// route def opts out; route discovery reads a module's `export const secure`
|
|
343
|
+
// into that def. So a scaffolded write is Bearer-gated with NOTHING emitted;
|
|
344
|
+
// `--public` emits `export const secure = false;` on the write files only.
|
|
345
|
+
|
|
346
|
+
/** The opt-out line for a WRITE method file when --public is set (else ""). */
|
|
347
|
+
function secureOptOut(isPublic: boolean): string {
|
|
348
|
+
return isPublic ? `export const secure = false;\n\n` : "";
|
|
349
|
+
}
|
|
230
350
|
|
|
231
|
-
function generateRoute(name: string, flags: Record<string, string | boolean
|
|
351
|
+
function generateRoute(name: string, flags: Record<string, string | boolean>, emitTest = true): void {
|
|
232
352
|
const routePath = name.replace(/^\//, "");
|
|
233
353
|
const singular = routePath.endsWith("s") ? routePath.slice(0, -1) : routePath;
|
|
234
354
|
const model = flags.model as string | undefined;
|
|
355
|
+
const isPublic = Boolean(flags.public);
|
|
235
356
|
const base = resolve("src/routes/api", routePath);
|
|
236
357
|
const idDir = join(base, "[id]");
|
|
237
358
|
ensureDir(base);
|
|
238
359
|
ensureDir(idDir);
|
|
239
360
|
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
361
|
+
const table = model ? toTableName(model) : "";
|
|
362
|
+
// Model import path is RELATIVE to the route file's directory. Files directly
|
|
363
|
+
// under src/routes/api/<name>/ are 3 levels above src/models/; the [id]/ files
|
|
364
|
+
// are one deeper (4 levels).
|
|
365
|
+
const modelImportBase = model ? `import ${model} from "../../../models/${model}.js";\n` : "";
|
|
366
|
+
const modelImportId = model ? `import ${model} from "../../../../models/${model}.js";\n` : "";
|
|
243
367
|
|
|
244
|
-
|
|
368
|
+
const writeDoc = isPublic
|
|
369
|
+
? "Public (--public): no token required."
|
|
370
|
+
: "Secure by default: requires a Bearer token (use --public to open).";
|
|
371
|
+
|
|
372
|
+
// ── GET list (public) ──────────────────────────────────────────────
|
|
245
373
|
if (model) {
|
|
246
374
|
writeFileSafe(
|
|
247
375
|
join(base, "get.ts"),
|
|
248
376
|
`import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
249
|
-
${
|
|
377
|
+
${modelImportBase}
|
|
250
378
|
export const meta = { summary: "List all ${routePath}", tags: ["${routePath}"] };
|
|
251
379
|
|
|
252
380
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
253
|
-
const page = parseInt(req.query
|
|
254
|
-
const limit = parseInt(req.query
|
|
381
|
+
const page = parseInt(req.query.page as string) || 1;
|
|
382
|
+
const limit = parseInt(req.query.limit as string) || 20;
|
|
255
383
|
const offset = (page - 1) * limit;
|
|
256
|
-
const
|
|
257
|
-
res.json({ data:
|
|
384
|
+
const rows = await ${model}.select("SELECT * FROM ${table} LIMIT ? OFFSET ?", [limit, offset]);
|
|
385
|
+
res.json({ data: rows.map((r) => r.toObject()), page, limit });
|
|
258
386
|
}
|
|
259
387
|
`,
|
|
260
388
|
);
|
|
@@ -266,24 +394,31 @@ export default async function (req: Tina4Request, res: Tina4Response) {
|
|
|
266
394
|
export const meta = { summary: "List all ${routePath}", tags: ["${routePath}"] };
|
|
267
395
|
|
|
268
396
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
269
|
-
|
|
270
|
-
}
|
|
397
|
+
${aiFill(`list_${routePath}`, {
|
|
398
|
+
intent: `return the ${routePath} collection (add pagination if it grows)`,
|
|
399
|
+
given: "req.query -> filters/paging",
|
|
400
|
+
use: `Model.select("SELECT … LIMIT ? OFFSET ?", [limit, offset]) then r.toObject()`,
|
|
401
|
+
ret: "res.json({ data: rows })",
|
|
402
|
+
ground: `tina4_context("list ORM records with pagination", "nodejs") · skill tina4-developer-nodejs`,
|
|
403
|
+
raise: `${routePath} list not implemented`,
|
|
404
|
+
})}}
|
|
271
405
|
`,
|
|
272
406
|
);
|
|
273
407
|
}
|
|
274
408
|
|
|
275
|
-
// POST create
|
|
409
|
+
// ── POST create (secure by default; --public opens it) ─────────────
|
|
276
410
|
if (model) {
|
|
277
411
|
writeFileSafe(
|
|
278
412
|
join(base, "post.ts"),
|
|
279
413
|
`import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
280
|
-
${
|
|
281
|
-
export const meta = { summary: "Create a new ${singular}", tags: ["${routePath}"] };
|
|
414
|
+
${modelImportBase}${secureOptOut(isPublic)}export const meta = { summary: "Create a new ${singular}", tags: ["${routePath}"] };
|
|
282
415
|
|
|
416
|
+
// ${writeDoc}
|
|
283
417
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
284
|
-
|
|
418
|
+
${extend("validate / business rules before persist",
|
|
419
|
+
`e.g. reject invalid input; ground: tina4_context("validate before create", "nodejs")`)} const item = new ${model}(req.body as Record<string, unknown>);
|
|
285
420
|
await item.save();
|
|
286
|
-
res.json({ data: item.
|
|
421
|
+
res.json({ data: item.toObject() }, 201);
|
|
287
422
|
}
|
|
288
423
|
`,
|
|
289
424
|
);
|
|
@@ -292,31 +427,38 @@ export default async function (req: Tina4Request, res: Tina4Response) {
|
|
|
292
427
|
join(base, "post.ts"),
|
|
293
428
|
`import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
294
429
|
|
|
295
|
-
export const meta = { summary: "Create a new ${singular}", tags: ["${routePath}"] };
|
|
430
|
+
${secureOptOut(isPublic)}export const meta = { summary: "Create a new ${singular}", tags: ["${routePath}"] };
|
|
296
431
|
|
|
432
|
+
// ${writeDoc}
|
|
297
433
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
298
|
-
|
|
299
|
-
}
|
|
434
|
+
${aiFill(`create_${singular}`, {
|
|
435
|
+
intent: `validate the body and persist a new ${singular}`,
|
|
436
|
+
given: "req.body -> the posted fields",
|
|
437
|
+
use: "new Model(req.body).save() then item.toObject() (import your model)",
|
|
438
|
+
ret: "res.json({ data: item }, 201)",
|
|
439
|
+
ground: `tina4_context("create ORM record and return 201", "nodejs") · skill tina4-developer-nodejs`,
|
|
440
|
+
raise: `create ${singular} not implemented`,
|
|
441
|
+
})}}
|
|
300
442
|
`,
|
|
301
443
|
);
|
|
302
444
|
}
|
|
303
445
|
|
|
304
|
-
// GET by id
|
|
446
|
+
// ── GET by id (public) ─────────────────────────────────────────────
|
|
305
447
|
if (model) {
|
|
306
448
|
writeFileSafe(
|
|
307
449
|
join(idDir, "get.ts"),
|
|
308
450
|
`import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
309
|
-
${
|
|
451
|
+
${modelImportId}
|
|
310
452
|
export const meta = { summary: "Get a ${singular} by ID", tags: ["${routePath}"] };
|
|
311
453
|
|
|
312
454
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
313
455
|
const { id } = req.params;
|
|
314
|
-
const item = await ${model}.selectOne("SELECT * FROM ${
|
|
456
|
+
const item = await ${model}.selectOne("SELECT * FROM ${table} WHERE id = ?", [id]);
|
|
315
457
|
if (!item) {
|
|
316
458
|
res.json({ error: "Not found" }, 404);
|
|
317
459
|
return;
|
|
318
460
|
}
|
|
319
|
-
res.json({ data: item });
|
|
461
|
+
res.json({ data: item.toObject() });
|
|
320
462
|
}
|
|
321
463
|
`,
|
|
322
464
|
);
|
|
@@ -328,31 +470,37 @@ export default async function (req: Tina4Request, res: Tina4Response) {
|
|
|
328
470
|
export const meta = { summary: "Get a ${singular} by ID", tags: ["${routePath}"] };
|
|
329
471
|
|
|
330
472
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
473
|
+
${aiFill(`get_${singular}`, {
|
|
474
|
+
intent: `fetch one ${singular} by id`,
|
|
475
|
+
given: "req.params.id -> the record id",
|
|
476
|
+
use: `Model.selectOne("SELECT … WHERE id = ?", [req.params.id])`,
|
|
477
|
+
ret: "res.json({ data: item }) or res.json({ error: 'Not found' }, 404)",
|
|
478
|
+
ground: `tina4_context("find ORM record by id", "nodejs") · skill tina4-developer-nodejs`,
|
|
479
|
+
raise: `get ${singular} not implemented`,
|
|
480
|
+
})}}
|
|
334
481
|
`,
|
|
335
482
|
);
|
|
336
483
|
}
|
|
337
484
|
|
|
338
|
-
// PUT by id
|
|
485
|
+
// ── PUT by id (secure by default; --public opens it) ───────────────
|
|
339
486
|
if (model) {
|
|
340
487
|
writeFileSafe(
|
|
341
488
|
join(idDir, "put.ts"),
|
|
342
489
|
`import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
343
|
-
${
|
|
344
|
-
export const meta = { summary: "Update a ${singular} by ID", tags: ["${routePath}"] };
|
|
490
|
+
${modelImportId}${secureOptOut(isPublic)}export const meta = { summary: "Update a ${singular} by ID", tags: ["${routePath}"] };
|
|
345
491
|
|
|
492
|
+
// ${writeDoc}
|
|
346
493
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
347
494
|
const { id } = req.params;
|
|
348
|
-
const item = await ${model}.selectOne("SELECT * FROM ${
|
|
495
|
+
const item = await ${model}.selectOne("SELECT * FROM ${table} WHERE id = ?", [id]);
|
|
349
496
|
if (!item) {
|
|
350
497
|
res.json({ error: "Not found" }, 404);
|
|
351
498
|
return;
|
|
352
499
|
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
500
|
+
${extend("guard which fields / who may update",
|
|
501
|
+
`e.g. enforce ownership; ground: tina4_context("authorize update", "nodejs")`)} Object.assign(item, req.body as Record<string, unknown>);
|
|
502
|
+
await item.save();
|
|
503
|
+
res.json({ data: item.toObject() });
|
|
356
504
|
}
|
|
357
505
|
`,
|
|
358
506
|
);
|
|
@@ -361,33 +509,38 @@ export default async function (req: Tina4Request, res: Tina4Response) {
|
|
|
361
509
|
join(idDir, "put.ts"),
|
|
362
510
|
`import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
363
511
|
|
|
364
|
-
export const meta = { summary: "Update a ${singular} by ID", tags: ["${routePath}"] };
|
|
512
|
+
${secureOptOut(isPublic)}export const meta = { summary: "Update a ${singular} by ID", tags: ["${routePath}"] };
|
|
365
513
|
|
|
514
|
+
// ${writeDoc}
|
|
366
515
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
516
|
+
${aiFill(`update_${singular}`, {
|
|
517
|
+
intent: `load, mutate and save an existing ${singular}`,
|
|
518
|
+
given: "req.params.id -> id; req.body -> changed fields",
|
|
519
|
+
use: "Model.selectOne(…) then Object.assign(item, req.body) then item.save()",
|
|
520
|
+
ret: "res.json({ data: item }) or 404",
|
|
521
|
+
ground: `tina4_context("update ORM record", "nodejs") · skill tina4-developer-nodejs`,
|
|
522
|
+
raise: `update ${singular} not implemented`,
|
|
523
|
+
})}}
|
|
370
524
|
`,
|
|
371
525
|
);
|
|
372
526
|
}
|
|
373
527
|
|
|
374
|
-
// DELETE by id
|
|
528
|
+
// ── DELETE by id (secure by default; --public opens it) ────────────
|
|
375
529
|
if (model) {
|
|
376
530
|
writeFileSafe(
|
|
377
531
|
join(idDir, "delete.ts"),
|
|
378
532
|
`import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
379
|
-
${
|
|
380
|
-
export const meta = { summary: "Delete a ${singular} by ID", tags: ["${routePath}"] };
|
|
533
|
+
${modelImportId}${secureOptOut(isPublic)}export const meta = { summary: "Delete a ${singular} by ID", tags: ["${routePath}"] };
|
|
381
534
|
|
|
535
|
+
// ${writeDoc}
|
|
382
536
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
383
537
|
const { id } = req.params;
|
|
384
|
-
const item = await ${model}.selectOne("SELECT * FROM ${
|
|
538
|
+
const item = await ${model}.selectOne("SELECT * FROM ${table} WHERE id = ?", [id]);
|
|
385
539
|
if (!item) {
|
|
386
540
|
res.json({ error: "Not found" }, 404);
|
|
387
541
|
return;
|
|
388
542
|
}
|
|
389
|
-
|
|
390
|
-
await record.delete();
|
|
543
|
+
await item.delete();
|
|
391
544
|
res.json({ message: "deleted", id });
|
|
392
545
|
}
|
|
393
546
|
`,
|
|
@@ -397,15 +550,33 @@ export default async function (req: Tina4Request, res: Tina4Response) {
|
|
|
397
550
|
join(idDir, "delete.ts"),
|
|
398
551
|
`import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
399
552
|
|
|
400
|
-
export const meta = { summary: "Delete a ${singular} by ID", tags: ["${routePath}"] };
|
|
553
|
+
${secureOptOut(isPublic)}export const meta = { summary: "Delete a ${singular} by ID", tags: ["${routePath}"] };
|
|
401
554
|
|
|
555
|
+
// ${writeDoc}
|
|
402
556
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
557
|
+
${aiFill(`delete_${singular}`, {
|
|
558
|
+
intent: `delete a ${singular} by id`,
|
|
559
|
+
given: "req.params.id -> id",
|
|
560
|
+
use: "Model.selectOne(…) then item.delete()",
|
|
561
|
+
ret: "res.json({ message: 'deleted', id }) or 404",
|
|
562
|
+
ground: `tina4_context("delete ORM record", "nodejs") · skill tina4-developer-nodejs`,
|
|
563
|
+
raise: `delete ${singular} not implemented`,
|
|
564
|
+
})}}
|
|
406
565
|
`,
|
|
407
566
|
);
|
|
408
567
|
}
|
|
568
|
+
|
|
569
|
+
// Co-emit a real test. A --model route is working code → the secure-by-default
|
|
570
|
+
// gate test (reads public, writes gated, or open under --public). A no-model
|
|
571
|
+
// route's handlers are loud AI-FILL stubs → the Router-registration + live-stub
|
|
572
|
+
// test. Composite generators (crud) pass emitTest=false and emit their own.
|
|
573
|
+
if (emitTest) {
|
|
574
|
+
if (model) {
|
|
575
|
+
generateTest(routePath, { model, "secure-writes": true, public: isPublic });
|
|
576
|
+
} else {
|
|
577
|
+
emitRouteStubTest(routePath);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
409
580
|
}
|
|
410
581
|
|
|
411
582
|
// ── CRUD ────────────────────────────────────────────────────────────
|
|
@@ -413,14 +584,18 @@ export default async function (req: Tina4Request, res: Tina4Response) {
|
|
|
413
584
|
function generateCrud(name: string, flags: Record<string, string | boolean>): void {
|
|
414
585
|
const table = toTableName(name);
|
|
415
586
|
const routeName = toPlural(table);
|
|
587
|
+
const isPublic = Boolean(flags.public);
|
|
416
588
|
|
|
417
589
|
console.log(`\n Generating CRUD for ${name}...\n`);
|
|
418
590
|
|
|
419
|
-
// 1. Model + migration
|
|
420
|
-
|
|
591
|
+
// 1. Model + migration (its own model test is suppressed — the gate test
|
|
592
|
+
// below is CRUD's single, broader co-emitted test).
|
|
593
|
+
generateModel(name, flags, false);
|
|
421
594
|
|
|
422
|
-
// 2. Routes with model
|
|
423
|
-
|
|
595
|
+
// 2. Routes with model — secure by default; thread --public through so
|
|
596
|
+
// `generate crud X --public` opens the writes (mirrors AutoCrud public).
|
|
597
|
+
// Route test suppressed (the gate test below covers the routes).
|
|
598
|
+
generateRoute(routeName, { ...flags, model: name }, false);
|
|
424
599
|
|
|
425
600
|
// 3. Form
|
|
426
601
|
generateForm(name, flags);
|
|
@@ -428,8 +603,8 @@ function generateCrud(name: string, flags: Record<string, string | boolean>): vo
|
|
|
428
603
|
// 4. View (list + detail)
|
|
429
604
|
generateView(name, flags);
|
|
430
605
|
|
|
431
|
-
// 5. Test
|
|
432
|
-
generateTest(routeName, {
|
|
606
|
+
// 5. Test — real secure-by-default boot-gate (reads public, writes gated).
|
|
607
|
+
generateTest(routeName, { model: name, "secure-writes": true, public: isPublic });
|
|
433
608
|
|
|
434
609
|
console.log(`\n CRUD generation complete for ${name}.`);
|
|
435
610
|
console.log(" Run: tina4nodejs migrate");
|
|
@@ -443,6 +618,7 @@ function generateMigration(
|
|
|
443
618
|
flags: Record<string, string | boolean>,
|
|
444
619
|
fieldsOverride?: Array<[string, string]>,
|
|
445
620
|
tableOverride?: string,
|
|
621
|
+
emitTest = true,
|
|
446
622
|
): void {
|
|
447
623
|
const ts = timestamp();
|
|
448
624
|
const dir = resolve("migrations");
|
|
@@ -503,13 +679,17 @@ function generateMigration(
|
|
|
503
679
|
`${downSql}\n`;
|
|
504
680
|
|
|
505
681
|
writeFileSafe(downPath, downContent);
|
|
682
|
+
|
|
683
|
+
// Co-emit a real up/down migration test — only for CREATE migrations (a
|
|
684
|
+
// placeholder ALTER migration has no asserted schema yet). Suppressed when a
|
|
685
|
+
// model generates its matching migration (the model's own test covers schema).
|
|
686
|
+
if (emitTest && isCreate) emitMigrationTest(name, table);
|
|
506
687
|
}
|
|
507
688
|
|
|
508
689
|
// ── Middleware ───────────────────────────────────────────────────────
|
|
509
690
|
|
|
510
|
-
function generateMiddleware(name: string,
|
|
691
|
+
function generateMiddleware(name: string, _flags: Record<string, string | boolean>): void {
|
|
511
692
|
const snake = toSnake(name);
|
|
512
|
-
const camel = toCamel(name);
|
|
513
693
|
const dir = resolve("src/middleware");
|
|
514
694
|
ensureDir(dir);
|
|
515
695
|
const path = join(dir, `${snake}.ts`);
|
|
@@ -547,6 +727,7 @@ export async function after${name}(
|
|
|
547
727
|
`;
|
|
548
728
|
|
|
549
729
|
writeFileSafe(path, content);
|
|
730
|
+
emitMiddlewareTest(name, snake);
|
|
550
731
|
}
|
|
551
732
|
|
|
552
733
|
// ── Test ────────────────────────────────────────────────────────────
|
|
@@ -560,10 +741,70 @@ function generateTest(name: string, flags: Record<string, string | boolean>): vo
|
|
|
560
741
|
ensureDir(dir);
|
|
561
742
|
const path = join(dir, `${snake}.test.ts`);
|
|
562
743
|
|
|
744
|
+
// Secure-by-default CRUD gate test (emitted by `generate crud`): a REAL
|
|
745
|
+
// boot-gate through TestClient over the generated file-based routes and a
|
|
746
|
+
// real SQLite DB — reads public, writes gated (or open under --public). No
|
|
747
|
+
// mocks: real Router, real auth gate, real DB. Grounded on
|
|
748
|
+
// test/testClientAuth.test.ts + test/autoCrud.test.ts.
|
|
749
|
+
if (model && flags["secure-writes"]) {
|
|
750
|
+
const isPublic = Boolean(flags.public);
|
|
751
|
+
const posture = isPublic ? "open (--public)" : "gated";
|
|
752
|
+
const writeCase = isPublic
|
|
753
|
+
? ` // --public opened the write: an anonymous POST creates -> 201.
|
|
754
|
+
assert("anonymous POST is public -> 201",
|
|
755
|
+
(await client.post("/api/${snake}", { json: { name: "test" } })).status === 201);`
|
|
756
|
+
: ` // Secure by default: a tokenless POST is rejected with 401.
|
|
757
|
+
assert("anonymous POST is gated -> 401",
|
|
758
|
+
(await client.post("/api/${snake}", { json: { name: "test" } })).status === 401);
|
|
759
|
+
// A valid Bearer token passes the gate and creates -> 201.
|
|
760
|
+
const token = getToken({ userId: 1 });
|
|
761
|
+
assert("authenticated POST creates -> 201",
|
|
762
|
+
(await client.post("/api/${snake}", { json: { name: "test" }, headers: { authorization: \`Bearer \${token}\` } })).status === 201);`;
|
|
763
|
+
|
|
764
|
+
const content = `/**
|
|
765
|
+
* ${name} CRUD — reads public, writes ${posture} (secure by default).
|
|
766
|
+
*
|
|
767
|
+
* Real end-to-end via TestClient: no mocks — real Router, real auth gate, real
|
|
768
|
+
* JWT, real SQLite DB + table. Run with: npx tsx tests/${snake}.test.ts
|
|
769
|
+
*/
|
|
770
|
+
import { dirname, resolve } from "node:path";
|
|
771
|
+
import { fileURLToPath } from "node:url";
|
|
772
|
+
import { Router, TestClient, getToken, discoverRoutes } from "tina4-nodejs";
|
|
773
|
+
import { initDatabase } from "tina4-nodejs/orm";
|
|
774
|
+
import ${model} from "../src/models/${model}.js";
|
|
775
|
+
|
|
776
|
+
process.env.TINA4_SECRET = process.env.TINA4_SECRET ?? "test-secret";
|
|
777
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
778
|
+
|
|
779
|
+
let pass = 0;
|
|
780
|
+
let fail = 0;
|
|
781
|
+
function assert(label: string, ok: boolean): void {
|
|
782
|
+
if (ok) { pass++; console.log(\` PASS \${label}\`); }
|
|
783
|
+
else { fail++; console.log(\` FAIL \${label}\`); }
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
await initDatabase({ url: "sqlite:///data/test_${snake}.db" });
|
|
787
|
+
await ${model}.createTable();
|
|
788
|
+
|
|
789
|
+
const router = new Router();
|
|
790
|
+
for (const def of await discoverRoutes(resolve(here, "../src/routes"))) router.addRoute(def);
|
|
791
|
+
const client = new TestClient(router);
|
|
792
|
+
|
|
793
|
+
// Reads are public.
|
|
794
|
+
assert("GET list is public -> 200", (await client.get("/api/${snake}")).status === 200);
|
|
795
|
+
${writeCase}
|
|
796
|
+
|
|
797
|
+
console.log(\`\\nResults: \${pass} passed, \${fail} failed\`);
|
|
798
|
+
process.exit(fail > 0 ? 1 : 0);
|
|
799
|
+
`;
|
|
800
|
+
writeFileSafe(path, content);
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
|
|
563
804
|
let content: string;
|
|
564
805
|
|
|
565
806
|
if (model) {
|
|
566
|
-
content = `import { tests, assertTrue
|
|
807
|
+
content = `import { tests, assertTrue } from "tina4-nodejs";
|
|
567
808
|
|
|
568
809
|
/**
|
|
569
810
|
* Tests for ${name} CRUD operations.
|
|
@@ -603,10 +844,12 @@ const delete${model} = tests(
|
|
|
603
844
|
// TODO: implement delete test
|
|
604
845
|
return true;
|
|
605
846
|
});
|
|
847
|
+
|
|
848
|
+
void [list${model}s, get${model}, create${model}, update${model}, delete${model}];
|
|
606
849
|
`;
|
|
607
850
|
} else {
|
|
608
851
|
const titleName = name.charAt(0).toUpperCase() + name.slice(1);
|
|
609
|
-
content = `import { tests, assertTrue
|
|
852
|
+
content = `import { tests, assertTrue } from "tina4-nodejs";
|
|
610
853
|
|
|
611
854
|
/**
|
|
612
855
|
* Tests for ${name}.
|
|
@@ -618,10 +861,13 @@ const test${titleName} = tests(
|
|
|
618
861
|
// TODO: implement test
|
|
619
862
|
return true;
|
|
620
863
|
});
|
|
864
|
+
|
|
865
|
+
void test${titleName};
|
|
621
866
|
`;
|
|
622
867
|
}
|
|
623
868
|
|
|
624
869
|
writeFileSafe(path, content);
|
|
870
|
+
void singular;
|
|
625
871
|
}
|
|
626
872
|
|
|
627
873
|
// ── Form ────────────────────────────────────────────────────────────
|
|
@@ -774,15 +1020,19 @@ function generateView(name: string, flags: Record<string, string | boolean>): vo
|
|
|
774
1020
|
writeFileSafe(detailPath, detailContent);
|
|
775
1021
|
}
|
|
776
1022
|
|
|
777
|
-
// ── Auth
|
|
1023
|
+
// ── Auth (login/register stay PUBLIC) ───────────────────────────────
|
|
778
1024
|
|
|
779
|
-
function generateAuth(
|
|
1025
|
+
function generateAuth(_flags: Record<string, string | boolean>): void {
|
|
780
1026
|
console.log("\n Generating authentication scaffolding...\n");
|
|
781
1027
|
|
|
782
|
-
// 1. User model + migration
|
|
783
|
-
|
|
1028
|
+
// 1. User model + migration (model test suppressed — the auth test below is
|
|
1029
|
+
// the composite, broader co-emitted test).
|
|
1030
|
+
generateModel("User", { fields: "email:string,password:string,role:string" }, false);
|
|
784
1031
|
|
|
785
|
-
// 2. Auth routes (file-based)
|
|
1032
|
+
// 2. Auth routes (file-based). register + login are genuinely public — the
|
|
1033
|
+
// user has no token yet — so they opt out of the write gate with
|
|
1034
|
+
// `export const secure = false;`. `me` is a GET (public route) that
|
|
1035
|
+
// authenticates the Bearer itself.
|
|
786
1036
|
const registerDir = resolve("src/routes/api/auth/register");
|
|
787
1037
|
const loginDir = resolve("src/routes/api/auth/login");
|
|
788
1038
|
const meDir = resolve("src/routes/api/auth/me");
|
|
@@ -790,47 +1040,53 @@ function generateAuth(flags: Record<string, string | boolean>): void {
|
|
|
790
1040
|
ensureDir(loginDir);
|
|
791
1041
|
ensureDir(meDir);
|
|
792
1042
|
|
|
793
|
-
// POST /api/auth/register
|
|
1043
|
+
// POST /api/auth/register (public)
|
|
794
1044
|
writeFileSafe(
|
|
795
1045
|
join(registerDir, "post.ts"),
|
|
796
1046
|
`import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
1047
|
+
import { hashPassword } from "tina4-nodejs";
|
|
797
1048
|
import User from "../../../../models/User.js";
|
|
798
1049
|
|
|
1050
|
+
// Public: registration mints an account for a user who has no token yet.
|
|
1051
|
+
export const secure = false;
|
|
1052
|
+
|
|
799
1053
|
export const meta = { summary: "Register a new user", tags: ["auth"] };
|
|
800
1054
|
|
|
801
1055
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
802
|
-
const { email, password } = req.body
|
|
1056
|
+
const { email, password } = (req.body ?? {}) as { email?: string; password?: string };
|
|
803
1057
|
|
|
804
1058
|
if (!email || !password) {
|
|
805
1059
|
res.json({ error: "Email and password required" }, 400);
|
|
806
1060
|
return;
|
|
807
1061
|
}
|
|
808
1062
|
|
|
809
|
-
// Check if user exists
|
|
810
1063
|
const existing = await User.selectOne("SELECT * FROM user WHERE email = ?", [email]);
|
|
811
1064
|
if (existing) {
|
|
812
1065
|
res.json({ error: "Email already registered" }, 409);
|
|
813
1066
|
return;
|
|
814
1067
|
}
|
|
815
1068
|
|
|
816
|
-
|
|
817
|
-
const user = new User({ email, password, role: "user" });
|
|
1069
|
+
const user = new User({ email, password: hashPassword(password), role: "user" });
|
|
818
1070
|
await user.save();
|
|
819
|
-
res.json({ message: "Registered", id: user.
|
|
1071
|
+
res.json({ message: "Registered", id: user.toObject().id }, 201);
|
|
820
1072
|
}
|
|
821
1073
|
`,
|
|
822
1074
|
);
|
|
823
1075
|
|
|
824
|
-
// POST /api/auth/login
|
|
1076
|
+
// POST /api/auth/login (public)
|
|
825
1077
|
writeFileSafe(
|
|
826
1078
|
join(loginDir, "post.ts"),
|
|
827
1079
|
`import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
1080
|
+
import { checkPassword, getToken } from "tina4-nodejs";
|
|
828
1081
|
import User from "../../../../models/User.js";
|
|
829
1082
|
|
|
1083
|
+
// Public: login authenticates by password and mints the token.
|
|
1084
|
+
export const secure = false;
|
|
1085
|
+
|
|
830
1086
|
export const meta = { summary: "Login and receive JWT token", tags: ["auth"] };
|
|
831
1087
|
|
|
832
1088
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
833
|
-
const { email, password } = req.body
|
|
1089
|
+
const { email, password } = (req.body ?? {}) as { email?: string; password?: string };
|
|
834
1090
|
|
|
835
1091
|
if (!email || !password) {
|
|
836
1092
|
res.json({ error: "Email and password required" }, 400);
|
|
@@ -838,38 +1094,33 @@ export default async function (req: Tina4Request, res: Tina4Response) {
|
|
|
838
1094
|
}
|
|
839
1095
|
|
|
840
1096
|
const user = await User.selectOne("SELECT * FROM user WHERE email = ?", [email]);
|
|
841
|
-
if (!user) {
|
|
842
|
-
res.json({ error: "Invalid credentials" }, 401);
|
|
843
|
-
return;
|
|
844
|
-
}
|
|
845
|
-
|
|
846
|
-
// In production, compare hashed passwords
|
|
847
|
-
if ((user as any).password !== password) {
|
|
1097
|
+
if (!user || !checkPassword(password, user.toObject().password as string)) {
|
|
848
1098
|
res.json({ error: "Invalid credentials" }, 401);
|
|
849
1099
|
return;
|
|
850
1100
|
}
|
|
851
1101
|
|
|
852
|
-
|
|
1102
|
+
const data = user.toObject();
|
|
1103
|
+
const token = getToken({ userId: data.id, email: data.email, role: data.role });
|
|
1104
|
+
res.json({ token });
|
|
853
1105
|
}
|
|
854
1106
|
`,
|
|
855
1107
|
);
|
|
856
1108
|
|
|
857
|
-
// GET /api/auth/me
|
|
1109
|
+
// GET /api/auth/me (public route; authenticates the Bearer itself)
|
|
858
1110
|
writeFileSafe(
|
|
859
1111
|
join(meDir, "get.ts"),
|
|
860
1112
|
`import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
1113
|
+
import { authenticateRequest } from "tina4-nodejs";
|
|
861
1114
|
|
|
862
1115
|
export const meta = { summary: "Get current authenticated user", tags: ["auth"] };
|
|
863
1116
|
|
|
864
1117
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
865
|
-
const
|
|
866
|
-
if (!
|
|
1118
|
+
const payload = authenticateRequest(req.headers as Record<string, string | string[] | undefined>);
|
|
1119
|
+
if (!payload) {
|
|
867
1120
|
res.json({ error: "Unauthorized" }, 401);
|
|
868
1121
|
return;
|
|
869
1122
|
}
|
|
870
|
-
|
|
871
|
-
// In production, decode JWT and look up user
|
|
872
|
-
res.json({ message: "Authenticated user profile" });
|
|
1123
|
+
res.json({ user: payload });
|
|
873
1124
|
}
|
|
874
1125
|
`,
|
|
875
1126
|
);
|
|
@@ -929,12 +1180,781 @@ export default async function (req: Tina4Request, res: Tina4Response) {
|
|
|
929
1180
|
`,
|
|
930
1181
|
);
|
|
931
1182
|
|
|
932
|
-
// 5. Auth test
|
|
933
|
-
|
|
1183
|
+
// 5. Auth test — real register / login / me end-to-end (no mocks).
|
|
1184
|
+
emitAuthTest();
|
|
934
1185
|
|
|
935
1186
|
console.log("\n Authentication scaffolding complete.");
|
|
936
1187
|
console.log(" Run: tina4nodejs migrate");
|
|
937
|
-
console.log(" POST /api/auth/register — create account");
|
|
938
|
-
console.log(" POST /api/auth/login — get JWT token");
|
|
1188
|
+
console.log(" POST /api/auth/register — create account (public)");
|
|
1189
|
+
console.log(" POST /api/auth/login — get JWT token (public)");
|
|
939
1190
|
console.log(" GET /api/auth/me — get profile (requires token)");
|
|
940
1191
|
}
|
|
1192
|
+
|
|
1193
|
+
// ── Service (scheduled background task — ServiceRunner) ──────────────
|
|
1194
|
+
//
|
|
1195
|
+
// Grounded on packages/core/src/service.ts: ServiceRunner.discover() imports
|
|
1196
|
+
// each src/services/*.ts and reads `mod.default ?? mod` for
|
|
1197
|
+
// { name, handler, interval?/timing?/daemon? }. handler receives a
|
|
1198
|
+
// ServiceContext ({ running, lastRun, name }).
|
|
1199
|
+
|
|
1200
|
+
function generateService(name: string, flags: Record<string, string | boolean>): void {
|
|
1201
|
+
const snake = toSnake(name);
|
|
1202
|
+
const camel = toCamel(toPascal(name)) || snake;
|
|
1203
|
+
const cron = flags.cron;
|
|
1204
|
+
const dir = resolve("src/services");
|
|
1205
|
+
ensureDir(dir);
|
|
1206
|
+
const path = join(dir, `${snake}.ts`);
|
|
1207
|
+
|
|
1208
|
+
let scheduleField: string;
|
|
1209
|
+
let note: string;
|
|
1210
|
+
if (cron && cron !== true) {
|
|
1211
|
+
scheduleField = ` timing: ${JSON.stringify(String(cron))},`;
|
|
1212
|
+
note = `cron '${cron}'`;
|
|
1213
|
+
} else {
|
|
1214
|
+
const seconds = parseEvery(flags.every);
|
|
1215
|
+
scheduleField = ` interval: ${seconds},`;
|
|
1216
|
+
note = `every ${seconds}s`;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
const body = aiFill(`${camel}Task`, {
|
|
1220
|
+
intent: "do the scheduled work for this service",
|
|
1221
|
+
given: "context -> ServiceContext (.name, .running, .lastRun)",
|
|
1222
|
+
use: "your ORM / Api / Messenger code (re-run on schedule)",
|
|
1223
|
+
ground: `tina4_context("background service scheduled task", "nodejs") · skill tina4-developer-nodejs`,
|
|
1224
|
+
raise: `service ${snake} not implemented`,
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
const content = `import type { ServiceContext } from "tina4-nodejs";
|
|
1228
|
+
|
|
1229
|
+
/**
|
|
1230
|
+
* ${name} background service — runs ${note} via ServiceRunner.
|
|
1231
|
+
*
|
|
1232
|
+
* Wire a runner once (e.g. in app.ts) to actually run it — \`tina4nodejs serve\`
|
|
1233
|
+
* does NOT auto-start services:
|
|
1234
|
+
*
|
|
1235
|
+
* import { ServiceRunner } from "tina4-nodejs";
|
|
1236
|
+
* await ServiceRunner.discover("src/services"); // registers this default export
|
|
1237
|
+
* ServiceRunner.start();
|
|
1238
|
+
*/
|
|
1239
|
+
|
|
1240
|
+
export async function ${camel}Task(context: ServiceContext): Promise<void> {
|
|
1241
|
+
${body}}
|
|
1242
|
+
|
|
1243
|
+
// Discovered by ServiceRunner.discover("src/services") — it reads name/handler
|
|
1244
|
+
// (+ interval or timing) off this default export.
|
|
1245
|
+
export default {
|
|
1246
|
+
name: "${snake}",
|
|
1247
|
+
handler: ${camel}Task,
|
|
1248
|
+
${scheduleField}
|
|
1249
|
+
};
|
|
1250
|
+
`;
|
|
1251
|
+
|
|
1252
|
+
writeFileSafe(path, content);
|
|
1253
|
+
emitServiceTest(name, snake, camel);
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// ── Queue (producer + consumer worker) ──────────────────────────────
|
|
1257
|
+
//
|
|
1258
|
+
// Grounded on packages/core/src/queue.ts + job.ts: new Queue({ topic }),
|
|
1259
|
+
// queue.produce(topic, payload) -> id, `for await (const job of
|
|
1260
|
+
// queue.consume(topic))` yields QueueJob ({ payload, complete(), fail() }).
|
|
1261
|
+
// The consumer is wired as a ServiceRunner daemon (it owns its own loop).
|
|
1262
|
+
|
|
1263
|
+
function generateQueue(name: string, _flags: Record<string, string | boolean>): void {
|
|
1264
|
+
const topic = name.replace(/^\//, "");
|
|
1265
|
+
const slug = toSnake(topic.replace(/[^0-9a-zA-Z]+/g, "_")).replace(/^_+|_+$/g, "") || "topic";
|
|
1266
|
+
const pascal = toPascal(topic) || "Topic";
|
|
1267
|
+
const dir = resolve("src/services"); // consumer runs as a daemon service
|
|
1268
|
+
ensureDir(dir);
|
|
1269
|
+
const path = join(dir, `${slug}_consumer.ts`);
|
|
1270
|
+
|
|
1271
|
+
const body = aiFill(`handle${pascal}`, {
|
|
1272
|
+
intent: `process ONE ${topic} job payload`,
|
|
1273
|
+
given: "payload -> the produced job data (job.payload)",
|
|
1274
|
+
use: "your ORM / Messenger code; return to ack (job.complete), throw to nack (job.fail)",
|
|
1275
|
+
ground: `tina4_context("process a queue job", "nodejs") · skill tina4-developer-nodejs`,
|
|
1276
|
+
raise: `queue ${topic} handler not implemented`,
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
const content = `import { Queue } from "tina4-nodejs";
|
|
1280
|
+
import type { ServiceContext } from "tina4-nodejs";
|
|
1281
|
+
|
|
1282
|
+
/**
|
|
1283
|
+
* ${topic} queue — producer + consumer worker.
|
|
1284
|
+
*
|
|
1285
|
+
* Produce from anywhere: publish${pascal}({ ... })
|
|
1286
|
+
* The consumer is a long-running worker wired as a ServiceRunner daemon:
|
|
1287
|
+
* await ServiceRunner.discover("src/services"); ServiceRunner.start();
|
|
1288
|
+
*/
|
|
1289
|
+
|
|
1290
|
+
/** Enqueue a ${topic} job for the worker below to process. Returns the job id. */
|
|
1291
|
+
export function publish${pascal}(payload: Record<string, unknown>): string {
|
|
1292
|
+
return new Queue({ topic: "${topic}" }).produce("${topic}", payload);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/** Process ONE ${topic} job payload. */
|
|
1296
|
+
export async function handle${pascal}(payload: unknown): Promise<void> {
|
|
1297
|
+
${body}}
|
|
1298
|
+
|
|
1299
|
+
/** Long-running ${topic} worker — consume() yields jobs; ack/nack each. */
|
|
1300
|
+
export async function consume${pascal}(_context?: ServiceContext): Promise<void> {
|
|
1301
|
+
const queue = new Queue({ topic: "${topic}" });
|
|
1302
|
+
for await (const job of queue.consume("${topic}")) {
|
|
1303
|
+
const one = Array.isArray(job) ? job[0] : job;
|
|
1304
|
+
try {
|
|
1305
|
+
await handle${pascal}(one.payload);
|
|
1306
|
+
one.complete(); // ack — remove from the queue
|
|
1307
|
+
} catch (err) {
|
|
1308
|
+
one.fail(String(err)); // nack — retry / dead-letter
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
// Discovered by ServiceRunner.discover("src/services"); daemon:true because
|
|
1314
|
+
// consume${pascal} owns its own loop. The topic + per-job handle keys let
|
|
1315
|
+
// \`tina4nodejs queue work ${topic}\` drive this consumer directly (own the poll
|
|
1316
|
+
// loop / bounded --once drain) without wiring a ServiceRunner.
|
|
1317
|
+
export default {
|
|
1318
|
+
name: "${topic}-consumer",
|
|
1319
|
+
topic: "${topic}",
|
|
1320
|
+
handler: consume${pascal},
|
|
1321
|
+
handle: handle${pascal},
|
|
1322
|
+
daemon: true,
|
|
1323
|
+
};
|
|
1324
|
+
`;
|
|
1325
|
+
|
|
1326
|
+
writeFileSafe(path, content);
|
|
1327
|
+
emitQueueTest(topic, slug, pascal);
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// ── Validator (request-body Validator) ──────────────────────────────
|
|
1331
|
+
//
|
|
1332
|
+
// Grounded on packages/core/src/validator.ts: new Validator(data) is chainable
|
|
1333
|
+
// (.required/.email/.minLength/.integer/.inList) and exposes .isValid()/.errors().
|
|
1334
|
+
|
|
1335
|
+
function generateValidator(name: string, _flags: Record<string, string | boolean>): void {
|
|
1336
|
+
const dir = resolve("src/validators");
|
|
1337
|
+
ensureDir(dir);
|
|
1338
|
+
const path = join(dir, `${toSnake(name)}.ts`);
|
|
1339
|
+
|
|
1340
|
+
// Working-default: ship a real starter rule (required "name") so the
|
|
1341
|
+
// co-emitted valid/invalid test is GREEN on generation — a scaffolded test
|
|
1342
|
+
// that fails on creation is bad DX. Extend the rules for your own payload.
|
|
1343
|
+
const rules = extend(
|
|
1344
|
+
"add / adjust the validation rules for this payload",
|
|
1345
|
+
`e.g. .email("email").minLength("name", 2).integer("age"); ground: tina4_context("validate request body with Validator", "nodejs")`,
|
|
1346
|
+
);
|
|
1347
|
+
|
|
1348
|
+
const content = `import { Validator } from "tina4-nodejs";
|
|
1349
|
+
|
|
1350
|
+
/**
|
|
1351
|
+
* Validate a ${name} payload. Returns a Validator (chainable rules).
|
|
1352
|
+
*
|
|
1353
|
+
* Usage in a route:
|
|
1354
|
+
* const v = validate${toPascal(name)}(req.body as Record<string, unknown>);
|
|
1355
|
+
* if (!v.isValid()) return res.json({ error: v.errors()[0]?.message }, 400);
|
|
1356
|
+
*/
|
|
1357
|
+
export function validate${toPascal(name)}(data: Record<string, unknown>): Validator {
|
|
1358
|
+
const validator = new Validator(data);
|
|
1359
|
+
${rules} validator.required("name"); // starter rule (matches the model's default field)
|
|
1360
|
+
return validator;
|
|
1361
|
+
}
|
|
1362
|
+
`;
|
|
1363
|
+
|
|
1364
|
+
writeFileSafe(path, content);
|
|
1365
|
+
emitValidatorTest(name, toSnake(name), toPascal(name));
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
// ── Seeder (FakeData + seedOrm) ─────────────────────────────────────
|
|
1369
|
+
//
|
|
1370
|
+
// Grounded on packages/orm/src/seeder.ts (seedOrm(model, count, overrides) —
|
|
1371
|
+
// overrides may be static values OR (fake) => value callables) and
|
|
1372
|
+
// packages/cli/src/commands/seed.ts (runs each src/seeds/*.ts as a script).
|
|
1373
|
+
|
|
1374
|
+
function generateSeeder(name: string, _flags: Record<string, string | boolean>): void {
|
|
1375
|
+
const table = toTableName(name);
|
|
1376
|
+
const dir = resolve("src/seeds");
|
|
1377
|
+
ensureDir(dir);
|
|
1378
|
+
const path = join(dir, `${table}_seeder.ts`);
|
|
1379
|
+
|
|
1380
|
+
// Working-default: return {} so seedOrm auto-fills every field by type/name
|
|
1381
|
+
// and the co-emitted rows-created test is GREEN on generation. Override the
|
|
1382
|
+
// fields that need a specific shape below.
|
|
1383
|
+
const overrides = extend(
|
|
1384
|
+
"override fields that need a specific shape (seedOrm auto-fills the rest)",
|
|
1385
|
+
`e.g. return { email: (f) => f.email(), status: "active" }; ground: tina4_context("seed ORM model with FakeData", "nodejs")`,
|
|
1386
|
+
);
|
|
1387
|
+
|
|
1388
|
+
const content = `import { pathToFileURL } from "node:url";
|
|
1389
|
+
import { FakeData, seedOrm, initDatabase } from "tina4-nodejs/orm";
|
|
1390
|
+
import ${name} from "../models/${name}.js";
|
|
1391
|
+
|
|
1392
|
+
/**
|
|
1393
|
+
* Seeder for ${name} — run with: tina4nodejs seed
|
|
1394
|
+
*
|
|
1395
|
+
* seedOrm auto-fills every field by type/name; override the ones that need a
|
|
1396
|
+
* specific shape below. Each callable receives a FakeData instance.
|
|
1397
|
+
*/
|
|
1398
|
+
export function fieldOverrides(fake: FakeData): Record<string, unknown> {
|
|
1399
|
+
${overrides} void fake; // available for overrides above
|
|
1400
|
+
return {};
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
/** Seed rows. Invoked when this file is run directly by \`tina4nodejs seed\`. */
|
|
1404
|
+
export async function run(): Promise<void> {
|
|
1405
|
+
await initDatabase({ url: process.env.TINA4_DATABASE_URL ?? "sqlite:///data/app.db" });
|
|
1406
|
+
const summary = await seedOrm(${name} as never, 20, fieldOverrides(new FakeData()));
|
|
1407
|
+
console.log(\`Seeded \${summary.seeded} ${name} row(s), \${summary.failed} failed\`);
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// Only seed when executed as a script (\`tina4nodejs seed\` runs it via tsx) —
|
|
1411
|
+
// importing this module (e.g. in a test) must NOT trigger seeding.
|
|
1412
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
1413
|
+
await run();
|
|
1414
|
+
}
|
|
1415
|
+
`;
|
|
1416
|
+
|
|
1417
|
+
writeFileSafe(path, content);
|
|
1418
|
+
emitSeederTest(name, table);
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// ── WebSocket route ─────────────────────────────────────────────────
|
|
1422
|
+
//
|
|
1423
|
+
// Grounded on packages/core/src/router.ts (websocket(path, handler).secure())
|
|
1424
|
+
// + types.ts (handler is (connection, event, data)) + websocketConnection.ts
|
|
1425
|
+
// (.send/.sendJson/.broadcast/.close/.params). Node has NO file-based WS
|
|
1426
|
+
// auto-discovery — the module must be imported to register (see the doc block).
|
|
1427
|
+
|
|
1428
|
+
function generateWebsocket(name: string, _flags: Record<string, string | boolean>): void {
|
|
1429
|
+
const raw = name.trim();
|
|
1430
|
+
const wsPath = raw.startsWith("/") ? raw : "/ws/" + raw.replace(/^\/+/, "");
|
|
1431
|
+
let slug = toSnake(raw.replace(/^\/+|\/+$/g, "").replace(/[^0-9a-zA-Z]+/g, "_")).replace(/^_+|_+$/g, "") || "ws";
|
|
1432
|
+
const base = slug.startsWith("ws_") ? slug.slice(3) : slug;
|
|
1433
|
+
const handlerName = `${toCamel(toPascal(base))}Ws`;
|
|
1434
|
+
const dir = resolve("src/routes");
|
|
1435
|
+
ensureDir(dir);
|
|
1436
|
+
const path = join(dir, `ws_${base}.ts`);
|
|
1437
|
+
|
|
1438
|
+
const body = aiFill(handlerName, {
|
|
1439
|
+
intent: `handle an inbound "message" frame on ${wsPath}`,
|
|
1440
|
+
given: "data -> the message payload (string); connection -> WebSocketConnection",
|
|
1441
|
+
use: "connection.broadcast(data) or connection.sendJson({ ... })",
|
|
1442
|
+
ground: `tina4_context("websocket broadcast message", "nodejs") · skill tina4-developer-nodejs`,
|
|
1443
|
+
raise: `websocket ${wsPath} not implemented`,
|
|
1444
|
+
});
|
|
1445
|
+
|
|
1446
|
+
const content = `import { websocket } from "tina4-nodejs";
|
|
1447
|
+
import type { WebSocketConnection } from "tina4-nodejs";
|
|
1448
|
+
|
|
1449
|
+
/**
|
|
1450
|
+
* ${wsPath} WebSocket route.
|
|
1451
|
+
*
|
|
1452
|
+
* Registered on import by websocket(). Node has NO file-based WS
|
|
1453
|
+
* auto-discovery, so IMPORT this module once from app.ts to activate it (add
|
|
1454
|
+
* \`.secure()\` to require a JWT on the upgrade):
|
|
1455
|
+
*
|
|
1456
|
+
* import "./src/routes/ws_${base}.js";
|
|
1457
|
+
*
|
|
1458
|
+
* The server invokes the handler as (connection, event, data) for each event:
|
|
1459
|
+
* "open" (connect), "message" (inbound frame), "close" (disconnect).
|
|
1460
|
+
*/
|
|
1461
|
+
export async function ${handlerName}(
|
|
1462
|
+
connection: WebSocketConnection,
|
|
1463
|
+
event: "open" | "message" | "close",
|
|
1464
|
+
data: string,
|
|
1465
|
+
): Promise<void> {
|
|
1466
|
+
if (event === "open") {
|
|
1467
|
+
connection.sendJson({ type: "welcome" });
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
if (event === "close") {
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
// event === "message"
|
|
1474
|
+
${body}}
|
|
1475
|
+
|
|
1476
|
+
websocket("${wsPath}", ${handlerName});
|
|
1477
|
+
`;
|
|
1478
|
+
|
|
1479
|
+
writeFileSafe(path, content);
|
|
1480
|
+
emitWebsocketTest(wsPath, base, handlerName);
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
// ── Event listener ──────────────────────────────────────────────────
|
|
1484
|
+
//
|
|
1485
|
+
// Grounded on packages/core/src/events.ts: Events.on(event, cb) registers,
|
|
1486
|
+
// Events.emit(event, ...args) fires, Events.listeners(event) reads them. Node
|
|
1487
|
+
// has NO src/listeners/ auto-discovery — import the module to register.
|
|
1488
|
+
|
|
1489
|
+
function generateListener(name: string, _flags: Record<string, string | boolean>): void {
|
|
1490
|
+
const event = name.trim();
|
|
1491
|
+
const slug = toSnake(event.replace(/[^0-9a-zA-Z]+/g, "_")).replace(/^_+|_+$/g, "") || "event";
|
|
1492
|
+
const handlerName = `on${toPascal(slug)}`;
|
|
1493
|
+
const dir = resolve("src/listeners");
|
|
1494
|
+
ensureDir(dir);
|
|
1495
|
+
const path = join(dir, `${slug}.ts`);
|
|
1496
|
+
|
|
1497
|
+
const body = aiFill(handlerName, {
|
|
1498
|
+
intent: `react to the '${event}' event`,
|
|
1499
|
+
given: `args -> whatever Events.emit("${event}", ...args) passed`,
|
|
1500
|
+
use: "your app code — Messenger().send(...), an ORM write, or Events.emit(...) a follow-up",
|
|
1501
|
+
ground: `tina4_context("event listener reaction", "nodejs") · skill tina4-developer-nodejs`,
|
|
1502
|
+
raise: `listener ${event} not implemented`,
|
|
1503
|
+
});
|
|
1504
|
+
|
|
1505
|
+
const content = `import { Events } from "tina4-nodejs";
|
|
1506
|
+
|
|
1507
|
+
/**
|
|
1508
|
+
* Listener for the '${event}' event.
|
|
1509
|
+
*
|
|
1510
|
+
* Registered on import by Events.on(). Node has NO src/listeners/
|
|
1511
|
+
* auto-discovery, so IMPORT this module once from app.ts to activate it:
|
|
1512
|
+
*
|
|
1513
|
+
* import "./src/listeners/${slug}.js";
|
|
1514
|
+
*
|
|
1515
|
+
* Fires when something calls Events.emit("${event}", ...args).
|
|
1516
|
+
*/
|
|
1517
|
+
export function ${handlerName}(...args: unknown[]): void {
|
|
1518
|
+
${body}}
|
|
1519
|
+
|
|
1520
|
+
Events.on("${event}", ${handlerName});
|
|
1521
|
+
`;
|
|
1522
|
+
|
|
1523
|
+
writeFileSafe(path, content);
|
|
1524
|
+
emitListenerTest(event, slug);
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
1528
|
+
// Co-emitted test builders — every code-producing generator emits a REAL,
|
|
1529
|
+
// green, no-mock `*.test.ts` next to its scaffold (self-describing CLI epic,
|
|
1530
|
+
// Phase 4). Mirrors the Python master's `_write_test` + `_emit_*_test` builders
|
|
1531
|
+
// (tina4_python/cli/__init__.py): ONE shared writer + ONE focused builder per
|
|
1532
|
+
// generator (not copy-pasted blobs). Each exercises a DIFFERENT real subsystem
|
|
1533
|
+
// (real SQLite / TestClient / Router / MiddlewareChain / ServiceRunner / Queue /
|
|
1534
|
+
// event bus). CRUD-shaped scaffolds (working code) get behavioural tests;
|
|
1535
|
+
// logic-shaped scaffolds (loud AI-FILL stubs) get real wiring tests + a lock-in
|
|
1536
|
+
// that the placeholder fails loud. form/view are template-only → exempt (no
|
|
1537
|
+
// test); `test` itself is exempt (it IS the test generator).
|
|
1538
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
1539
|
+
|
|
1540
|
+
/**
|
|
1541
|
+
* The SINGLE place a co-emitted test is written — `tests/<name>.test.ts`
|
|
1542
|
+
* (Node's `*.test.ts` convention, discovered by `tsx test/run-all.ts`). Shares
|
|
1543
|
+
* writeFileSafe's overwrite-refusal + the same "Created …" line every generator
|
|
1544
|
+
* prints. Generalized from generateTest so builders share one rule.
|
|
1545
|
+
*/
|
|
1546
|
+
function writeTest(testName: string, content: string): void {
|
|
1547
|
+
const dir = resolve("tests");
|
|
1548
|
+
ensureDir(dir);
|
|
1549
|
+
writeFileSafe(join(dir, `${testName}.test.ts`), content);
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
/**
|
|
1553
|
+
* Assemble a standalone-tsx test: doc header + shared pass/fail tally + body +
|
|
1554
|
+
* summary line + `process.exit`. This is the project's test convention (each
|
|
1555
|
+
* file self-tallies and exits), so `tsx test/run-all.ts` — and the co-emit
|
|
1556
|
+
* meta-test — pick the emitted file up and read its "Results: N passed, M
|
|
1557
|
+
* failed" line. Grounded on the existing secure-gate CRUD test format.
|
|
1558
|
+
*/
|
|
1559
|
+
function standaloneTest(doc: string, body: string): string {
|
|
1560
|
+
return `${doc}
|
|
1561
|
+
|
|
1562
|
+
let pass = 0;
|
|
1563
|
+
let fail = 0;
|
|
1564
|
+
function assert(label: string, ok: boolean): void {
|
|
1565
|
+
if (ok) { pass++; console.log(\` PASS \${label}\`); }
|
|
1566
|
+
else { fail++; console.log(\` FAIL \${label}\`); }
|
|
1567
|
+
}
|
|
1568
|
+
async function assertThrows(label: string, fn: () => unknown | Promise<unknown>): Promise<void> {
|
|
1569
|
+
try { await fn(); assert(label, false); }
|
|
1570
|
+
catch { assert(label, true); }
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
${body}
|
|
1574
|
+
|
|
1575
|
+
console.log(\`\\nResults: \${pass} passed, \${fail} failed\`);
|
|
1576
|
+
process.exit(fail > 0 ? 1 : 0);
|
|
1577
|
+
`;
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
/** A source-text literal that is a valid value for a scaffolded field type. */
|
|
1581
|
+
function sampleLiteral(fieldType: string): string {
|
|
1582
|
+
switch ((fieldType || "string").toLowerCase()) {
|
|
1583
|
+
case "int": case "integer": return "1";
|
|
1584
|
+
case "float": case "number": case "numeric": case "decimal": return "1.5";
|
|
1585
|
+
case "bool": case "boolean": return "true";
|
|
1586
|
+
case "datetime": return '"2020-01-01 00:00:00"';
|
|
1587
|
+
case "blob": return '"x"';
|
|
1588
|
+
default: return '"sample"';
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
/** model → real SQLite roundtrip (create / read back / missing → null). */
|
|
1593
|
+
function emitModelTest(model: string, table: string, fields: Array<[string, string]>): void {
|
|
1594
|
+
const flds = fields.length > 0 ? fields : [["name", "string"] as [string, string]];
|
|
1595
|
+
const payload = flds.map(([f, t]) => `${f}: ${sampleLiteral(t)}`).join(", ");
|
|
1596
|
+
const stringField = flds.find(([, t]) => ["string", "str", "text"].includes((t || "string").toLowerCase()))?.[0];
|
|
1597
|
+
const valueAssert = stringField
|
|
1598
|
+
? `\nassert("string field round-trips", fetched !== null && (fetched.toObject() as Record<string, unknown>).${stringField} === "sample");`
|
|
1599
|
+
: "";
|
|
1600
|
+
const doc = `/**
|
|
1601
|
+
* Real ORM roundtrip for ${model} — no mocks, real SQLite.
|
|
1602
|
+
*
|
|
1603
|
+
* Generated with src/models/${model}.ts by \`tina4nodejs generate model
|
|
1604
|
+
* ${model}\`. The model scaffold is working code, so this passes on generation:
|
|
1605
|
+
* binds a real in-memory SQLite DB, creates the table, saves a row, reads it
|
|
1606
|
+
* back. Run with: npx tsx tests/${table}_model.test.ts
|
|
1607
|
+
*/
|
|
1608
|
+
import ${model} from "../src/models/${model}.js";
|
|
1609
|
+
import { initDatabase } from "tina4-nodejs/orm";`;
|
|
1610
|
+
const body = `await initDatabase({ url: "sqlite:///:memory:" });
|
|
1611
|
+
await ${model}.createTable();
|
|
1612
|
+
|
|
1613
|
+
const row = new ${model}({ ${payload} });
|
|
1614
|
+
const saved = await row.save();
|
|
1615
|
+
assert("create() persists and returns the row", saved !== false && Boolean(row.toObject().id));
|
|
1616
|
+
|
|
1617
|
+
const id = row.toObject().id;
|
|
1618
|
+
const fetched = await ${model}.selectOne("SELECT * FROM ${table} WHERE id = ?", [id]);
|
|
1619
|
+
assert("row reads back by id", fetched !== null);
|
|
1620
|
+
assert("read-back id matches", fetched !== null && (fetched.toObject() as Record<string, unknown>).id === id);${valueAssert}
|
|
1621
|
+
|
|
1622
|
+
const missing = await ${model}.selectOne("SELECT * FROM ${table} WHERE id = ?", [999999]);
|
|
1623
|
+
assert("find missing returns null", missing === null);`;
|
|
1624
|
+
writeTest(`${table}_model`, standaloneTest(doc, body));
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
/** route (no --model) → real Router registration + the list handler is a live
|
|
1628
|
+
* loud stub. (route --model reuses the secure-gate generateTest instead.) */
|
|
1629
|
+
function emitRouteStubTest(route: string): void {
|
|
1630
|
+
const doc = `/**
|
|
1631
|
+
* Routing test for ${route} — no mocks, real Router + route discovery.
|
|
1632
|
+
*
|
|
1633
|
+
* Generated with src/routes/api/${route}/ by \`tina4nodejs generate route
|
|
1634
|
+
* ${route}\` (no --model). The handlers are AI-FILL stubs that throw until you
|
|
1635
|
+
* implement them, so this tests what IS live on generation: all five routes
|
|
1636
|
+
* register on the REAL Router, and the list handler fails loud until filled.
|
|
1637
|
+
* Run with: npx tsx tests/${route}.test.ts
|
|
1638
|
+
*/
|
|
1639
|
+
import { dirname, resolve } from "node:path";
|
|
1640
|
+
import { fileURLToPath } from "node:url";
|
|
1641
|
+
import { Router, discoverRoutes } from "tina4-nodejs";
|
|
1642
|
+
import listHandler from "../src/routes/api/${route}/get.js";`;
|
|
1643
|
+
const body = `const here = dirname(fileURLToPath(import.meta.url));
|
|
1644
|
+
const router = new Router();
|
|
1645
|
+
const defs = await discoverRoutes(resolve(here, "../src/routes"));
|
|
1646
|
+
for (const def of defs) router.addRoute(def);
|
|
1647
|
+
|
|
1648
|
+
const sigs = defs.map((d) => \`\${d.method} \${d.pattern}\`);
|
|
1649
|
+
for (const sig of ["GET /api/${route}", "POST /api/${route}", "GET /api/${route}/{id}", "PUT /api/${route}/{id}", "DELETE /api/${route}/{id}"]) {
|
|
1650
|
+
assert(\`route registered: \${sig}\`, sigs.includes(sig));
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
// The scaffolded list handler is a loud AI-FILL stub — it throws until filled.
|
|
1654
|
+
await assertThrows("list handler is a live stub (throws until filled)",
|
|
1655
|
+
() => listHandler({} as never, {} as never));`;
|
|
1656
|
+
writeTest(route, standaloneTest(doc, body));
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
/** middleware → the scaffolded before/after driven through the REAL
|
|
1660
|
+
* MiddlewareChain with a real Request/Response (no mock req/res). */
|
|
1661
|
+
function emitMiddlewareTest(name: string, snake: string): void {
|
|
1662
|
+
const doc = `/**
|
|
1663
|
+
* Real dispatch test for the ${name} middleware — no mocks.
|
|
1664
|
+
*
|
|
1665
|
+
* Generated with src/middleware/${snake}.ts by \`tina4nodejs generate middleware
|
|
1666
|
+
* ${name}\`. Drives the scaffolded before/after functions through the REAL
|
|
1667
|
+
* MiddlewareChain with a real Tina4Request/Response (built from real node http
|
|
1668
|
+
* objects) — the same continuation dispatch the live server runs.
|
|
1669
|
+
* Run with: npx tsx tests/${snake}.test.ts
|
|
1670
|
+
*/
|
|
1671
|
+
import { IncomingMessage, ServerResponse } from "node:http";
|
|
1672
|
+
import { Socket } from "node:net";
|
|
1673
|
+
import { MiddlewareChain, createRequest, createResponse } from "tina4-nodejs";
|
|
1674
|
+
import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
1675
|
+
import { before${name}, after${name} } from "../src/middleware/${snake}.js";`;
|
|
1676
|
+
const body = `function realPair(headers: Record<string, string>): { req: Tina4Request; res: Tina4Response; raw: ServerResponse } {
|
|
1677
|
+
const socket = new Socket();
|
|
1678
|
+
const rawReq = new IncomingMessage(socket);
|
|
1679
|
+
rawReq.method = "GET";
|
|
1680
|
+
rawReq.url = "/";
|
|
1681
|
+
rawReq.headers = { ...headers, host: "localhost" };
|
|
1682
|
+
rawReq.push(null);
|
|
1683
|
+
const rawRes = new ServerResponse(rawReq);
|
|
1684
|
+
rawRes.write = (() => true) as typeof rawRes.write;
|
|
1685
|
+
rawRes.end = (function (this: ServerResponse) { return this; }) as typeof rawRes.end;
|
|
1686
|
+
return { req: createRequest(rawReq), res: createResponse(rawRes), raw: rawRes };
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
// before(): blocks an unauthenticated request (401, does not call next()).
|
|
1690
|
+
{
|
|
1691
|
+
const chain = new MiddlewareChain();
|
|
1692
|
+
chain.use(before${name});
|
|
1693
|
+
let reached = false;
|
|
1694
|
+
chain.use(async (_r, _s, next) => { reached = true; next(); });
|
|
1695
|
+
const { req, res, raw } = realPair({});
|
|
1696
|
+
await chain.run(req, res);
|
|
1697
|
+
assert("before blocks unauthenticated (401, chain short-circuits)", raw.statusCode === 401 && reached === false);
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
// before(): lets an authenticated request through to the next middleware.
|
|
1701
|
+
{
|
|
1702
|
+
const chain = new MiddlewareChain();
|
|
1703
|
+
chain.use(before${name});
|
|
1704
|
+
let reached = false;
|
|
1705
|
+
chain.use(async (_r, _s, next) => { reached = true; next(); });
|
|
1706
|
+
const { req, res } = realPair({ authorization: "Bearer test" });
|
|
1707
|
+
await chain.run(req, res);
|
|
1708
|
+
assert("before passes an authenticated request through", reached === true);
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
// after(): always continues the chain.
|
|
1712
|
+
{
|
|
1713
|
+
const chain = new MiddlewareChain();
|
|
1714
|
+
chain.use(after${name});
|
|
1715
|
+
let reached = false;
|
|
1716
|
+
chain.use(async (_r, _s, next) => { reached = true; next(); });
|
|
1717
|
+
const { req, res } = realPair({});
|
|
1718
|
+
await chain.run(req, res);
|
|
1719
|
+
assert("after runs and continues the chain", reached === true);
|
|
1720
|
+
}`;
|
|
1721
|
+
writeTest(snake, standaloneTest(doc, body));
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
/** service → registrable on a REAL ServiceRunner + descriptor shape + loud stub. */
|
|
1725
|
+
function emitServiceTest(name: string, snake: string, camel: string): void {
|
|
1726
|
+
const doc = `/**
|
|
1727
|
+
* Real ServiceRunner test for the ${name} service — no mocks.
|
|
1728
|
+
*
|
|
1729
|
+
* Generated with src/services/${snake}.ts by \`tina4nodejs generate service
|
|
1730
|
+
* ${name}\`. Registers the scaffold on a REAL ServiceRunner and confirms the
|
|
1731
|
+
* descriptor; the task body is an AI-FILL stub that throws until filled.
|
|
1732
|
+
* Run with: npx tsx tests/${snake}.test.ts
|
|
1733
|
+
*/
|
|
1734
|
+
import { ServiceRunner } from "tina4-nodejs";
|
|
1735
|
+
import service, { ${camel}Task } from "../src/services/${snake}.js";`;
|
|
1736
|
+
const body = `assert("descriptor has a name + callable handler",
|
|
1737
|
+
service.name === "${snake}" && typeof service.handler === "function");
|
|
1738
|
+
|
|
1739
|
+
// Register on a REAL ServiceRunner and confirm it is listed.
|
|
1740
|
+
ServiceRunner.register(service.name, service.handler, { interval: (service as { interval?: number }).interval });
|
|
1741
|
+
assert("registers on a real ServiceRunner", ServiceRunner.list().some((s) => s.name === "${snake}"));
|
|
1742
|
+
ServiceRunner.remove("${snake}");
|
|
1743
|
+
|
|
1744
|
+
// The scaffolded task body is an AI-FILL stub — it throws until filled.
|
|
1745
|
+
await assertThrows("task is a live stub (throws until filled)", () => ${camel}Task({} as never));`;
|
|
1746
|
+
writeTest(snake, standaloneTest(doc, body));
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
/** queue → push a REAL job onto the real file-backed Queue + daemon wiring. */
|
|
1750
|
+
function emitQueueTest(topic: string, slug: string, pascal: string): void {
|
|
1751
|
+
const doc = `/**
|
|
1752
|
+
* Real file-backed Queue test for the ${topic} worker — no mocks.
|
|
1753
|
+
*
|
|
1754
|
+
* Generated with src/services/${slug}_consumer.ts by \`tina4nodejs generate
|
|
1755
|
+
* queue ${topic}\`. Pushes a REAL job onto the real file-backed Queue and
|
|
1756
|
+
* asserts it is enqueued, and that the consumer is wired as a daemon. The
|
|
1757
|
+
* per-job handle is an AI-FILL stub that throws until filled.
|
|
1758
|
+
* Run with: npx tsx tests/${slug}.test.ts
|
|
1759
|
+
*/
|
|
1760
|
+
import { Queue } from "tina4-nodejs";
|
|
1761
|
+
import worker, { publish${pascal}, handle${pascal} } from "../src/services/${slug}_consumer.js";`;
|
|
1762
|
+
const body = `const jobId = publish${pascal}({ hello: "world" });
|
|
1763
|
+
assert("publish enqueues a real job (returns an id)", typeof jobId === "string" && jobId.length > 0);
|
|
1764
|
+
assert("the job is really on the queue", new Queue({ topic: "${topic}" }).size() >= 1);
|
|
1765
|
+
|
|
1766
|
+
assert("consumer default export is a daemon", worker.daemon === true);
|
|
1767
|
+
assert("consumer handler is wired", typeof worker.handler === "function");
|
|
1768
|
+
|
|
1769
|
+
// The per-job handler is an AI-FILL stub — it throws until filled.
|
|
1770
|
+
await assertThrows("handle is a live stub (throws until filled)", () => handle${pascal}({}));`;
|
|
1771
|
+
writeTest(slug, standaloneTest(doc, body));
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
/** validator → run the scaffold against valid + invalid real input
|
|
1775
|
+
* (working-default: ships a starter required("name") rule). */
|
|
1776
|
+
function emitValidatorTest(name: string, snake: string, pascal: string): void {
|
|
1777
|
+
const doc = `/**
|
|
1778
|
+
* Real validation test for validate${pascal} — no mocks.
|
|
1779
|
+
*
|
|
1780
|
+
* Generated with src/validators/${snake}.ts by \`tina4nodejs generate validator
|
|
1781
|
+
* ${name}\`. The scaffold ships a starter rule (required "name"), so this passes
|
|
1782
|
+
* on generation — adjust the rules for your payload and update these cases.
|
|
1783
|
+
* Run with: npx tsx tests/${snake}.test.ts
|
|
1784
|
+
*/
|
|
1785
|
+
import { validate${pascal} } from "../src/validators/${snake}.js";`;
|
|
1786
|
+
const body = `assert("valid input passes", validate${pascal}({ name: "Ada" }).isValid());
|
|
1787
|
+
|
|
1788
|
+
const bad = validate${pascal}({});
|
|
1789
|
+
assert("invalid input fails", bad.isValid() === false);
|
|
1790
|
+
assert("invalid input reports errors", bad.errors().length > 0);`;
|
|
1791
|
+
writeTest(snake, standaloneTest(doc, body));
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
/** seeder → run the scaffolded seeder against real SQLite, assert rows created
|
|
1795
|
+
* (working-default: fieldOverrides returns {}, seedOrm auto-fills). */
|
|
1796
|
+
function emitSeederTest(model: string, table: string): void {
|
|
1797
|
+
const doc = `/**
|
|
1798
|
+
* Real seeding test for the ${model} seeder — no mocks, real SQLite.
|
|
1799
|
+
*
|
|
1800
|
+
* Generated with src/seeds/${table}_seeder.ts by \`tina4nodejs generate seeder
|
|
1801
|
+
* ${model}\`. Binds a real SQLite DB, creates the table, runs the scaffolded
|
|
1802
|
+
* seeder (auto-fills every field via FakeData) and asserts rows were created.
|
|
1803
|
+
* Run with: npx tsx tests/${table}_seeder.test.ts
|
|
1804
|
+
*/
|
|
1805
|
+
import { initDatabase, FakeData } from "tina4-nodejs/orm";
|
|
1806
|
+
import ${model} from "../src/models/${model}.js";
|
|
1807
|
+
import { fieldOverrides, run } from "../src/seeds/${table}_seeder.js";`;
|
|
1808
|
+
const body = `process.env.TINA4_DATABASE_URL = "sqlite:///test_${table}_seeder.db";
|
|
1809
|
+
await initDatabase({ url: process.env.TINA4_DATABASE_URL });
|
|
1810
|
+
await ${model}.createTable();
|
|
1811
|
+
|
|
1812
|
+
assert("fieldOverrides returns an object", typeof fieldOverrides(new FakeData()) === "object");
|
|
1813
|
+
|
|
1814
|
+
await run(); // run() re-binds the same DB URL and seeds via seedOrm
|
|
1815
|
+
const rows = await ${model}.all();
|
|
1816
|
+
assert("run() seeds real rows", rows.length >= 1);`;
|
|
1817
|
+
writeTest(`${table}_seeder`, standaloneTest(doc, body));
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
/** websocket → real Router registration + drive the real handler (socket-free
|
|
1821
|
+
* "close" event) directly; the "message" branch is a loud stub. */
|
|
1822
|
+
function emitWebsocketTest(wsPath: string, base: string, handler: string): void {
|
|
1823
|
+
const doc = `/**
|
|
1824
|
+
* Real handler test for the ${wsPath} WebSocket route — no mocks.
|
|
1825
|
+
*
|
|
1826
|
+
* Generated with src/routes/ws_${base}.ts by \`tina4nodejs generate websocket
|
|
1827
|
+
* ...\`. Confirms the handler registers on the REAL Router (importing runs the
|
|
1828
|
+
* module-level websocket() call) and drives the real handler for the "close"
|
|
1829
|
+
* event (no socket needed). The "message" branch is an AI-FILL stub that throws
|
|
1830
|
+
* until filled. Run with: npx tsx tests/ws_${base}.test.ts
|
|
1831
|
+
*/
|
|
1832
|
+
import { Router } from "tina4-nodejs";
|
|
1833
|
+
import { ${handler} } from "../src/routes/ws_${base}.js"; // importing registers via websocket()`;
|
|
1834
|
+
const body = `assert("handler registered on the real router",
|
|
1835
|
+
Router.getWebSocketRoutes().some((r) => r.pattern === "${wsPath}"));
|
|
1836
|
+
|
|
1837
|
+
// The "close" branch returns cleanly without a live connection.
|
|
1838
|
+
const closed = await ${handler}(null as never, "close", "");
|
|
1839
|
+
assert("close event handled cleanly", closed === undefined);
|
|
1840
|
+
|
|
1841
|
+
// The "message" branch is an AI-FILL stub — it throws until filled.
|
|
1842
|
+
await assertThrows("message branch is a live stub (throws until filled)",
|
|
1843
|
+
() => ${handler}(null as never, "message", "hi"));`;
|
|
1844
|
+
writeTest(`ws_${base}`, standaloneTest(doc, body));
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
/** listener → emit the REAL event on the real bus, assert the listener ran. */
|
|
1848
|
+
function emitListenerTest(event: string, slug: string): void {
|
|
1849
|
+
const doc = `/**
|
|
1850
|
+
* Real event-bus test for the '${event}' listener — no mocks.
|
|
1851
|
+
*
|
|
1852
|
+
* Generated with src/listeners/${slug}.ts by \`tina4nodejs generate listener
|
|
1853
|
+
* ${event}\`. Confirms the listener binds on the REAL event bus (importing runs
|
|
1854
|
+
* the module-level Events.on) and that emitting the event reaches it. The
|
|
1855
|
+
* reaction body is an AI-FILL stub, so a strict emit re-raises here (proving it
|
|
1856
|
+
* ran). Run with: npx tsx tests/${slug}.test.ts
|
|
1857
|
+
*/
|
|
1858
|
+
import { Events } from "tina4-nodejs";
|
|
1859
|
+
import "../src/listeners/${slug}.js"; // importing registers the listener via Events.on()`;
|
|
1860
|
+
const body = `assert("listener registered on the real event bus", Events.listeners("${event}").length >= 1);
|
|
1861
|
+
|
|
1862
|
+
// strict emit re-raises the stub error, proving the listener actually ran.
|
|
1863
|
+
await assertThrows("emitting the event reaches the (stub) listener",
|
|
1864
|
+
() => Events.emit("${event}", { strict: true }, { id: 1 }));`;
|
|
1865
|
+
writeTest(slug, standaloneTest(doc, body));
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
/** auth → real register / login / me end-to-end via the real TestClient. This
|
|
1869
|
+
* proves the token→200 path on /api/auth/me (the scaffold passes headers to
|
|
1870
|
+
* authenticateRequest, not the request — no always-401 bug). */
|
|
1871
|
+
function emitAuthTest(): void {
|
|
1872
|
+
const doc = `/**
|
|
1873
|
+
* Real auth test — register / login / me via the real TestClient.
|
|
1874
|
+
*
|
|
1875
|
+
* Generated with the auth scaffold by \`tina4nodejs generate auth\`. No mocks:
|
|
1876
|
+
* real Router + route discovery, real Auth (PBKDF2 + JWT), real SQLite. register
|
|
1877
|
+
* + login are public; the token from login authenticates GET /api/auth/me.
|
|
1878
|
+
* Run with: npx tsx tests/auth.test.ts
|
|
1879
|
+
*/
|
|
1880
|
+
import { dirname, resolve } from "node:path";
|
|
1881
|
+
import { fileURLToPath } from "node:url";
|
|
1882
|
+
import { Router, TestClient, discoverRoutes } from "tina4-nodejs";
|
|
1883
|
+
import { initDatabase } from "tina4-nodejs/orm";
|
|
1884
|
+
import User from "../src/models/User.js";
|
|
1885
|
+
|
|
1886
|
+
process.env.TINA4_SECRET = process.env.TINA4_SECRET ?? "test-secret";
|
|
1887
|
+
delete process.env.TINA4_API_KEY;
|
|
1888
|
+
const here = dirname(fileURLToPath(import.meta.url));`;
|
|
1889
|
+
const body = `await initDatabase({ url: "sqlite:///test_auth.db" });
|
|
1890
|
+
await User.createTable();
|
|
1891
|
+
for (const existing of await User.all()) await existing.delete(); // start from an empty table
|
|
1892
|
+
|
|
1893
|
+
const router = new Router();
|
|
1894
|
+
for (const def of await discoverRoutes(resolve(here, "../src/routes"))) router.addRoute(def);
|
|
1895
|
+
const client = new TestClient(router);
|
|
1896
|
+
|
|
1897
|
+
const registered = await client.post("/api/auth/register", { json: { email: "a@b.c", password: "secret12" } });
|
|
1898
|
+
assert("register a new user -> 201", registered.status === 201);
|
|
1899
|
+
|
|
1900
|
+
const duplicate = await client.post("/api/auth/register", { json: { email: "a@b.c", password: "secret12" } });
|
|
1901
|
+
assert("duplicate register -> 409", duplicate.status === 409);
|
|
1902
|
+
|
|
1903
|
+
const login = await client.post("/api/auth/login", { json: { email: "a@b.c", password: "secret12" } });
|
|
1904
|
+
assert("login -> 200", login.status === 200);
|
|
1905
|
+
const token = (login.json() as { token?: string }).token;
|
|
1906
|
+
assert("login returns a token", typeof token === "string" && token.length > 0);
|
|
1907
|
+
|
|
1908
|
+
const me = await client.get("/api/auth/me", { headers: { authorization: \`Bearer \${token}\` } });
|
|
1909
|
+
assert("authenticated GET /api/auth/me -> 200 (token accepted)", me.status === 200);
|
|
1910
|
+
assert("me returns the authenticated user's email",
|
|
1911
|
+
((me.json() as { user?: { email?: string } }).user?.email) === "a@b.c");
|
|
1912
|
+
|
|
1913
|
+
const anon = await client.get("/api/auth/me");
|
|
1914
|
+
assert("anonymous GET /api/auth/me -> 401", anon.status === 401);
|
|
1915
|
+
|
|
1916
|
+
const bad = await client.post("/api/auth/login", { json: { email: "a@b.c", password: "WRONG" } });
|
|
1917
|
+
assert("wrong password -> 401", bad.status === 401);`;
|
|
1918
|
+
writeTest("auth", standaloneTest(doc, body));
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
/** migration (create_*) → apply UP then DOWN against real SQLite, assert the
|
|
1922
|
+
* table appears then disappears. Only for CREATE migrations. */
|
|
1923
|
+
function emitMigrationTest(migrationName: string, table: string): void {
|
|
1924
|
+
const doc = `/**
|
|
1925
|
+
* Real migration test for ${migrationName} — no mocks, real SQLite.
|
|
1926
|
+
*
|
|
1927
|
+
* Generated with the migration by \`tina4nodejs generate migration
|
|
1928
|
+
* ${migrationName}\`. Applies the generated UP SQL against a fresh real
|
|
1929
|
+
* in-memory SQLite database and asserts the table exists, then applies the DOWN
|
|
1930
|
+
* SQL and asserts it is gone — the raw SQL the migration runner executes.
|
|
1931
|
+
* Run with: npx tsx tests/${table}_migration.test.ts
|
|
1932
|
+
*/
|
|
1933
|
+
import { dirname, join, resolve } from "node:path";
|
|
1934
|
+
import { fileURLToPath } from "node:url";
|
|
1935
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
1936
|
+
import { SQLiteAdapter } from "tina4-nodejs/orm";`;
|
|
1937
|
+
const body = `const here = dirname(fileURLToPath(import.meta.url));
|
|
1938
|
+
const migrationsDir = resolve(here, "../migrations");
|
|
1939
|
+
|
|
1940
|
+
const upFile = readdirSync(migrationsDir).find((f) => f.endsWith("_${migrationName}.sql") && !f.endsWith(".down.sql"));
|
|
1941
|
+
assert("generated UP migration file exists", Boolean(upFile));
|
|
1942
|
+
const downFile = upFile!.replace(/\\.sql$/, ".down.sql");
|
|
1943
|
+
|
|
1944
|
+
function statements(sql: string): string[] {
|
|
1945
|
+
const noComments = sql.split("\\n").filter((l) => !l.trim().startsWith("--")).join("\\n");
|
|
1946
|
+
return noComments.split(";").map((s) => s.trim()).filter(Boolean);
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
const upText = readFileSync(join(migrationsDir, upFile!), "utf-8");
|
|
1950
|
+
const upSql = upText.split("-- UP")[1].split("-- DOWN")[0];
|
|
1951
|
+
const downSql = readFileSync(join(migrationsDir, downFile), "utf-8");
|
|
1952
|
+
|
|
1953
|
+
const db = new SQLiteAdapter(":memory:");
|
|
1954
|
+
for (const stmt of statements(upSql)) db.execute(stmt);
|
|
1955
|
+
assert("UP creates the ${table} table", db.tableExists("${table}"));
|
|
1956
|
+
|
|
1957
|
+
for (const stmt of statements(downSql)) db.execute(stmt);
|
|
1958
|
+
assert("DOWN drops the ${table} table", db.tableExists("${table}") === false);`;
|
|
1959
|
+
writeTest(`${table}_migration`, standaloneTest(doc, body));
|
|
1960
|
+
}
|