tina4-nodejs 3.13.148 → 3.13.150

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CLAUDE.md +4 -3
  2. package/package.json +1 -1
  3. package/packages/cli/dist/bin.js +10898 -9844
  4. package/packages/cli/src/commands/generate.ts +184 -25
  5. package/packages/core/dist/index.js +10864 -9810
  6. package/packages/core/public/bundled-component-provenance.json +2 -2
  7. package/packages/core/public/js/tina4-dev-admin-split.html +202 -0
  8. package/packages/core/public/js/tina4-dev-admin.min.js +147 -138
  9. package/packages/core/src/devAdmin.ts +105 -1
  10. package/packages/core/src/index.ts +3 -0
  11. package/packages/core/src/messenger.ts +57 -1
  12. package/packages/core/src/metrics.ts +5 -1
  13. package/packages/core/src/plan.ts +1 -1
  14. package/packages/core/src/router.ts +11 -1
  15. package/packages/core/src/server.ts +5 -2
  16. package/packages/core/templates/crud/form.twig +26 -0
  17. package/packages/core/templates/crud/modals.twig +31 -0
  18. package/packages/core/templates/crud/page.twig +401 -0
  19. package/packages/core/templates/crud/table.twig +69 -0
  20. package/packages/frond/dist/index.js +9 -1
  21. package/packages/frond/src/engine.ts +9 -0
  22. package/packages/frond/src/index.ts +1 -1
  23. package/packages/orm/dist/index.js +1283 -219
  24. package/packages/orm/src/adapters/mongodb.ts +209 -34
  25. package/packages/orm/src/autoCrud.ts +9 -0
  26. package/packages/orm/src/crud.ts +273 -0
  27. package/packages/orm/src/crudHelpers.ts +194 -0
  28. package/packages/orm/src/crudRender.ts +202 -0
  29. package/packages/orm/src/crudTable.ts +171 -0
  30. package/packages/orm/src/index.ts +7 -1
  31. package/packages/orm/src/migration.ts +167 -4
  32. package/packages/orm/src/query.ts +99 -40
  33. package/packages/orm/src/sqlTranslator.ts +8 -1
  34. package/packages/orm/src/types.ts +19 -0
  35. package/types/core/src/devAdmin.d.ts +23 -0
  36. package/types/core/src/messenger.d.ts +4 -0
  37. package/types/core/src/router.d.ts +0 -5
  38. package/types/frond/src/engine.d.ts +6 -0
  39. package/types/frond/src/index.d.ts +1 -1
  40. package/types/orm/src/crud.d.ts +82 -0
  41. package/types/orm/src/crudHelpers.d.ts +63 -0
  42. package/types/orm/src/crudRender.d.ts +38 -0
  43. package/types/orm/src/crudTable.d.ts +45 -0
  44. package/types/orm/src/index.d.ts +3 -1
  45. package/types/orm/src/migration.d.ts +0 -18
  46. package/types/orm/src/query.d.ts +0 -12
  47. package/types/orm/src/types.d.ts +19 -0
@@ -40,8 +40,9 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
40
40
  * tina4nodejs generate websocket chat
41
41
  * tina4nodejs generate listener user.created
42
42
  */
43
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
44
- import { join, relative, resolve, sep } from "node:path";
43
+ import { existsSync, mkdirSync, writeFileSync, readdirSync, copyFileSync } from "node:fs";
44
+ import { join, relative, resolve, sep, dirname } from "node:path";
45
+ import { fileURLToPath } from "node:url";
45
46
  import { parseFlags } from "../util.js";
46
47
 
47
48
  // ── Field type mapping ──────────────────────────────────────────────
@@ -652,7 +653,7 @@ export interface GeneratorSpec {
652
653
  export const GENERATORS: Record<string, GeneratorSpec> = {
653
654
  model: { handler: generateModel, usage: '<Name> [--fields "name:string,price:float"] [--table-name <name>]', summary: "ORM model + matching migration" },
654
655
  route: { handler: generateRoute, usage: "<name> [--model Name] [--public]", summary: "CRUD route file, secure by default (--public opens writes)" },
655
- crud: { handler: generateCrud, usage: '<Name> [--fields "..."] [--public]', summary: "Model + migration + routes + form + view + test" },
656
+ crud: { handler: generateCrud, usage: '<Name> [--fields "..."] [--public] [--no-templates]', summary: "Model + migration + AutoCrud-backed admin page (toCrud) + editable crud/ templates + gate test" },
656
657
  migration: { handler: (n, f) => generateMigration(n, f, undefined, undefined, !f["no-test"]), usage: "<description>", summary: "Timestamped migration file (UP/DOWN)" },
657
658
  middleware: { handler: generateMiddleware, usage: "<Name>", summary: "Middleware with before/after hooks" },
658
659
  test: { handler: generateTest, usage: "<name> [--model Name]", summary: "Test file" },
@@ -695,8 +696,9 @@ const NEXT_STEPS: Record<string, (c: NextContext) => string[]> = {
695
696
  ],
696
697
  crud: ({ name, table }) => [
697
698
  `Apply the migration: npx tina4nodejs migrate`,
698
- `Serve and try: npx tina4nodejs serve -> visit /swagger`,
699
- `Run the gate test: npx tsx tests/${pluralizeReserved(toSnake(name))}.test.ts`,
699
+ `Serve and try: npx tina4nodejs serve -> visit /admin/${table} (or /swagger)`,
700
+ `Run the gate test: npx tsx tests/${table}.test.ts`,
701
+ `Restyle the UI: edit src/templates/crud/*.twig`,
700
702
  `Change fields: edit src/models/${name}.ts then re-run generate crud`,
701
703
  ],
702
704
  migration: () => [
@@ -1224,40 +1226,197 @@ ${aiFill(`delete_${singular}`, {
1224
1226
  // ── CRUD ────────────────────────────────────────────────────────────
1225
1227
 
1226
1228
  function generateCrud(name: string, flags: Record<string, string | boolean>): void {
1227
- // Composite: quiet here (announce=false); the generateModel sub-call below is
1228
- // the one that announces, so the reserved-word note prints exactly once.
1229
+ // ADR-0094: `generate crud` scaffolds an admin PAGE rendered by Crud.toCrud,
1230
+ // whose REST backend is delegated entirely to AutoCrud (no hand-written
1231
+ // list/detail/write routes). The generated page route registers AutoCrud for
1232
+ // the model (secure-by-default; --public opens writes) and renders toCrud; the
1233
+ // overridable crud/*.twig templates are copied into the app so the developer
1234
+ // can edit them immediately.
1235
+ //
1236
+ // The table name (singular, e.g. "product") is the canonical path: the model
1237
+ // declares tableName "<table>", AutoCrud serves /api/<table>, and the gate
1238
+ // test + page route all key off it.
1229
1239
  const table = resolveTable(name, flags, { announce: false });
1230
- const routeName = pluralizeReserved(toSnake(name));
1231
1240
  const isPublic = Boolean(flags.public);
1232
1241
 
1233
- // Human-only banners; suppressed under --json to keep stdout parseable
1234
- // (a console.log during --json produced invalid JSON). writeFileSafe already
1235
- // gates its own "Created …" lines the same way.
1242
+ // Human-only banners; suppressed under --json to keep stdout parseable.
1236
1243
  if (!__resolution.jsonMode) console.log(`\n Generating CRUD for ${name}...\n`);
1237
1244
 
1238
1245
  // 1. Model + migration (its own model test is suppressed — the gate test
1239
1246
  // below is CRUD's single, broader co-emitted test).
1240
1247
  generateModel(name, flags, false);
1241
1248
 
1242
- // 2. Routes with model — secure by default; thread --public through so
1243
- // `generate crud X --public` opens the writes (mirrors AutoCrud public).
1244
- // Route test suppressed (the gate test below covers the routes).
1245
- generateRoute(routeName, { ...flags, model: name }, false);
1246
-
1247
- // 3. Form
1248
- generateForm(name, flags);
1249
+ // 2. Admin page route: registers AutoCrud (secure-by-default; --public opens
1250
+ // writes) and renders the toCrud admin UI at /admin/<table>.
1251
+ generateCrudAdminRoute(name, table, isPublic);
1249
1252
 
1250
- // 4. View (list + detail)
1251
- generateView(name, flags);
1253
+ // 3. Copy the overridable crud/*.twig templates into the app (unless
1254
+ // --no-templates) so the developer owns an editable copy straight away.
1255
+ if (!flags["no-templates"]) copyCrudTemplates();
1252
1256
 
1253
- // 5. Test — real secure-by-default boot-gate (reads public, writes gated).
1254
- generateTest(routeName, { model: name, "secure-writes": true, public: isPublic });
1257
+ // 4. Test — secure-by-default gate test (behavioural, real TestClient).
1258
+ generateCrudGateTest(name, table, isPublic);
1255
1259
 
1256
1260
  if (!__resolution.jsonMode) {
1257
1261
  console.log(`\n CRUD generation complete for ${name}.`);
1258
1262
  console.log(" Run: tina4nodejs migrate");
1259
- console.log(" Visit: /swagger to see the API docs");
1263
+ console.log(` Visit: /admin/${table} for the admin UI, or /swagger for the API docs`);
1264
+ }
1265
+ }
1266
+
1267
+ /**
1268
+ * Write the admin page route (src/routes/admin/<table>/get.ts). It registers the
1269
+ * AutoCrud REST backend for the model at load (idempotent) and renders the
1270
+ * toCrud admin page — ADR-0094: toCrud owns no routes of its own, so the backend
1271
+ * is AutoCrud and only the GET page lives here. Secure-by-default; `isPublic`
1272
+ * opens the page AND the writes.
1273
+ */
1274
+ function generateCrudAdminRoute(model: string, table: string, isPublic: boolean): void {
1275
+ const dir = resolve("src/routes/admin", table);
1276
+ ensureDir(dir);
1277
+
1278
+ pushRoute(`/admin/${table}`);
1279
+ if (__resolution.target === "crud") {
1280
+ setResolutionField("file_path", `src/routes/admin/${table}/get.ts`);
1260
1281
  }
1282
+
1283
+ const secureExport = isPublic ? "" : "export const secure = true;\n\n";
1284
+ const publicOpt = isPublic ? ", public: true" : "";
1285
+ const writeDoc = isPublic
1286
+ ? "--public: the admin page and the writes are OPEN (no token)."
1287
+ : "Secure by default: the admin page AND the writes require a valid Bearer token (pass --public to open them).";
1288
+ const pageDoc = isPublic
1289
+ ? ""
1290
+ : `\n// NOTE: the admin page is secured (export const secure = true). A browser needs a valid\n// token/session to open it; wire your login (Auth / Session) or run\n// \`generate crud ${model} --public\` for an open page. The AutoCrud READ API\n// (GET /api/${table}, GET /api/${table}/{id}) stays public by AutoCrud's default.`;
1291
+
1292
+ const content = `import ${model} from "../../../models/${model}.js";
1293
+ import { Crud } from "tina4-nodejs/orm";
1294
+ import type { Tina4Request, Tina4Response } from "tina4-nodejs";
1295
+
1296
+ // ${model} admin — one server-rendered CRUD page (searchable, sortable,
1297
+ // paginated table + create/edit/delete modals). The REST backend (GET list,
1298
+ // GET/{id}, POST, PUT, DELETE) is AutoCrud; this file owns only the GET admin
1299
+ // page. ${writeDoc}${pageDoc}
1300
+ //
1301
+ // Restyle the UI by editing src/templates/crud/*.twig (copied into this app).
1302
+ // Register the AutoCrud backend for the model at load (idempotent).
1303
+ await Crud.registerBackend(${model}, { public: ${isPublic} });
1304
+
1305
+ ${secureExport}export const meta = { summary: "${model} admin", tags: ["admin"] };
1306
+
1307
+ export default async function (req: Tina4Request, res: Tina4Response) {
1308
+ return res.html(await Crud.toCrud(req, { model: ${model}, title: "${model} Admin"${publicOpt} }));
1309
+ }
1310
+ `;
1311
+ writeFileSafe(join(dir, "get.ts"), content);
1312
+ }
1313
+
1314
+ /** The framework's shipped crud/ templates directory (monorepo + published). */
1315
+ function frameworkCrudTemplatesDir(): string | null {
1316
+ const here = dirname(fileURLToPath(import.meta.url));
1317
+ const candidates = [
1318
+ resolve(here, "../../../core/templates/crud"), // monorepo: packages/cli/src/commands -> packages/core/templates/crud
1319
+ resolve(here, "../../core/templates/crud"), // built layout fallback
1320
+ resolve(here, "../templates/crud"), // flattened publish fallback
1321
+ ];
1322
+ return candidates.find((c) => existsSync(c)) ?? null;
1323
+ }
1324
+
1325
+ /**
1326
+ * Copy the framework's overridable crud/*.twig templates into the app's
1327
+ * src/templates/crud/ so the developer can edit them in place (an app copy wins
1328
+ * over the framework's via toCrud's app-first resolution). Existing files are
1329
+ * left untouched.
1330
+ */
1331
+ function copyCrudTemplates(): void {
1332
+ const sourceDir = frameworkCrudTemplatesDir();
1333
+ if (!sourceDir) {
1334
+ if (!__resolution.jsonMode) console.log(" Skipped crud templates (framework templates not found)");
1335
+ return;
1336
+ }
1337
+ const targetDir = resolve("src/templates/crud");
1338
+ ensureDir(targetDir);
1339
+ for (const file of readdirSync(sourceDir).filter((f) => f.endsWith(".twig")).sort()) {
1340
+ const dest = join(targetDir, file);
1341
+ if (existsSync(dest)) {
1342
+ if (!__resolution.jsonMode) console.log(` File already exists: ${dest}`);
1343
+ continue;
1344
+ }
1345
+ if (__resolution.dryRun) continue;
1346
+ copyFileSync(join(sourceDir, file), dest);
1347
+ __resolution.actionsTaken.push(`wrote ${dest}`);
1348
+ if (!__resolution.jsonMode) console.log(` Created ${dest}`);
1349
+ }
1350
+ }
1351
+
1352
+ /**
1353
+ * Emit the CRUD gate test (tests/<table>.test.ts): a real boot-gate through the
1354
+ * TestClient over the generated admin route + AutoCrud backend. No mocks — real
1355
+ * Router, real auth gate, real JWT, real route discovery. It proves the
1356
+ * secure-by-default POSTURE (the point of a "gate" test): which routes register,
1357
+ * and which are gated vs open. The posture checks are adapter-independent (the
1358
+ * auth gate runs before the handler), so they hold regardless of the DB; the
1359
+ * full DB-backed CRUD behaviour is covered by the framework's own crud tests.
1360
+ */
1361
+ function generateCrudGateTest(model: string, table: string, isPublic: boolean): void {
1362
+ const dir = resolve("tests");
1363
+ ensureDir(dir);
1364
+ const posture = isPublic ? "open (--public)" : "gated";
1365
+ const writeCase = isPublic
1366
+ ? ` // --public opened the write: no 401 gate (the handler, not the gate, answers).
1367
+ assert("anonymous POST is NOT gated (--public)",
1368
+ (await client.post("/api/${table}", { json: { name: "test" } })).status !== 401);`
1369
+ : ` // Secure by default: a tokenless POST is rejected by the gate with 401.
1370
+ assert("anonymous POST is gated -> 401",
1371
+ (await client.post("/api/${table}", { json: { name: "test" } })).status === 401);
1372
+ // A valid Bearer token passes the gate (the gate no longer answers 401).
1373
+ const token = getToken({ userId: 1 });
1374
+ assert("authenticated POST passes the gate (not 401)",
1375
+ (await client.post("/api/${table}", { json: { name: "test" }, headers: { authorization: \`Bearer \${token}\` } })).status !== 401);`;
1376
+ const pageCase = isPublic
1377
+ ? ` assert("admin page is NOT gated (--public)", (await client.get("/admin/${table}")).status !== 401);`
1378
+ : ` assert("admin page is secured -> 401 without a token", (await client.get("/admin/${table}")).status === 401);`;
1379
+
1380
+ const content = `/**
1381
+ * ${model} CRUD admin (ADR-0094) — reads public, writes ${posture}, page ${isPublic ? "public" : "secured"}.
1382
+ *
1383
+ * Real end-to-end via TestClient: no mocks — real Router, real auth gate, real
1384
+ * JWT, real route discovery. The admin route registers the AutoCrud backend at
1385
+ * load; discoverRoutes imports it, so the /api/${table} REST routes exist here.
1386
+ * This gate test proves the SECURE-BY-DEFAULT posture (what registers, what is
1387
+ * gated vs open) — adapter-independent, since the auth gate runs before the
1388
+ * handler. Run with: npx tsx tests/${table}.test.ts
1389
+ */
1390
+ import { dirname, resolve } from "node:path";
1391
+ import { fileURLToPath } from "node:url";
1392
+ import { defaultRouter, TestClient, getToken, discoverRoutes } from "tina4-nodejs";
1393
+
1394
+ process.env.TINA4_SECRET = process.env.TINA4_SECRET ?? "tina4-test-secret-0123456789abcdef0123456789";
1395
+ const here = dirname(fileURLToPath(import.meta.url));
1396
+
1397
+ let pass = 0;
1398
+ let fail = 0;
1399
+ function assert(label: string, ok: boolean): void {
1400
+ if (ok) { pass++; console.log(\` PASS \${label}\`); }
1401
+ else { fail++; console.log(\` FAIL \${label}\`); }
1402
+ }
1403
+
1404
+ // Discovering the routes imports src/routes/admin/${table}/get.ts, whose
1405
+ // top-level Crud.registerBackend() adds the AutoCrud routes to defaultRouter.
1406
+ for (const def of await discoverRoutes(resolve(here, "../src/routes"))) defaultRouter.addRoute(def);
1407
+ const patterns = defaultRouter.getRoutes().map((r) => \`\${r.method} \${r.pattern}\`);
1408
+ const client = new TestClient();
1409
+
1410
+ assert("AutoCrud GET list route registered", patterns.includes("GET /api/${table}"));
1411
+ assert("AutoCrud POST route registered", patterns.includes("POST /api/${table}"));
1412
+ assert("admin page route registered", patterns.includes("GET /admin/${table}"));
1413
+ ${pageCase}
1414
+ ${writeCase}
1415
+
1416
+ console.log(\`\\nResults: \${pass} passed, \${fail} failed\`);
1417
+ process.exit(fail > 0 ? 1 : 0);
1418
+ `;
1419
+ writeFileSafe(join(dir, `${table}.test.ts`), content);
1261
1420
  }
1262
1421
 
1263
1422
  // ── Migration ───────────────────────────────────────────────────────
@@ -1474,7 +1633,7 @@ import { Router, TestClient, getToken, discoverRoutes } from "tina4-nodejs";
1474
1633
  import { initDatabase } from "tina4-nodejs/orm";
1475
1634
  import ${model} from "../src/models/${model}.js";
1476
1635
 
1477
- process.env.TINA4_SECRET = process.env.TINA4_SECRET ?? "test-secret";
1636
+ process.env.TINA4_SECRET = process.env.TINA4_SECRET ?? "tina4-test-secret-0123456789abcdef0123456789";
1478
1637
  const here = dirname(fileURLToPath(import.meta.url));
1479
1638
 
1480
1639
  let pass = 0;
@@ -2615,7 +2774,7 @@ import { Router, TestClient, discoverRoutes } from "tina4-nodejs";
2615
2774
  import { initDatabase } from "tina4-nodejs/orm";
2616
2775
  import User from "../src/models/User.js";
2617
2776
 
2618
- process.env.TINA4_SECRET = process.env.TINA4_SECRET ?? "test-secret";
2777
+ process.env.TINA4_SECRET = process.env.TINA4_SECRET ?? "tina4-test-secret-0123456789abcdef0123456789";
2619
2778
  delete process.env.TINA4_API_KEY;
2620
2779
  const here = dirname(fileURLToPath(import.meta.url));`;
2621
2780
  const body = `await initDatabase({ url: "sqlite:///test_auth.db" });