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.
@@ -1,19 +1,38 @@
1
1
  /**
2
- * CLI command: generate — Rich scaffolding for models, routes, migrations,
3
- * middleware, tests, forms, views, CRUD stacks, and auth.
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, readdirSync, writeFileSync } from "node:fs";
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(["no-browser", "no-reload", "production", "managed", "all", "clear"]);
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(" Generators: model, route, crud, migration, middleware, test, form, view, auth");
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
- switch (what) {
154
- case "model":
155
- generateModel(name, flags);
156
- break;
157
- case "route":
158
- generateRoute(name, flags);
159
- break;
160
- case "crud":
161
- generateCrud(name, flags);
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>): void {
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
- const content = `import { BaseModel } from "tina4-nodejs";
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>): void {
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 modelImport = model
241
- ? `import ${model} from "../../../models/${model}.js";\n`
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
- // GET list
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
- ${modelImport}
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?.page as string) || 1;
254
- const limit = parseInt(req.query?.limit as string) || 20;
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 results = await ${model}.select("SELECT * FROM ${toTableName(model)} LIMIT ? OFFSET ?", [limit, offset]);
257
- res.json({ data: results, page, limit });
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
- res.json({ data: [] });
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
- ${modelImport}
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
- const item = new ${model}(req.body);
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.toJSON() }, 201);
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
- res.json({ data: req.body }, 201);
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
- ${modelImport}
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 ${toTableName(model)} WHERE id = ?", [id]);
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
- const { id } = req.params;
332
- res.json({ data: { id } });
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
- ${modelImport}
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 ${toTableName(model)} WHERE id = ?", [id]);
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
- const updated = new ${model}({ ...item, ...req.body, id });
354
- await updated.save();
355
- res.json({ data: updated.toJSON() });
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
- const { id } = req.params;
368
- res.json({ data: { ...req.body, id } });
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
- ${modelImport}
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 ${toTableName(model)} WHERE id = ?", [id]);
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
- const record = new ${model}(item);
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
- const { id } = req.params;
404
- res.json({ message: "deleted", id });
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
- generateModel(name, flags);
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
- generateRoute(routeName, { ...flags, model: name });
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, { ...flags, model: name });
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, flags: Record<string, string | boolean>): void {
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, assertEqual } from "tina4-nodejs";
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, assertEqual } from "tina4-nodejs";
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(flags: Record<string, string | boolean>): void {
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
- generateModel("User", { fields: "email:string,password:string,role:string" });
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
- // Create user (password should be hashed in production)
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.toJSON().id }, 201);
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
- res.json({ message: "Logged in", email: (user as any).email });
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 auth = req.headers["authorization"];
866
- if (!auth) {
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
- generateTest("auth", { model: "User" });
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
+ }