tina4-nodejs 3.5.0 → 3.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -371,7 +371,7 @@ export default class User {
371
371
  };
372
372
  }
373
373
 
374
- // Auto-generates: GET/POST /api/users, GET/PUT/DELETE /api/users/:id
374
+ // Auto-generates: GET/POST /api/users, GET/PUT/DELETE /api/users/{id}
375
375
  // With filtering, sorting, pagination, and validation built in
376
376
  ```
377
377
 
@@ -409,10 +409,10 @@ get("/protected", async (request, response) => {
409
409
  ### JWT Authentication
410
410
 
411
411
  ```typescript
412
- import { createToken, validateToken } from "tina4-nodejs";
412
+ import { getToken, validToken } from "tina4-nodejs";
413
413
 
414
- const token = createToken({userId: 42}, "your-secret");
415
- const payload = validateToken(token, "your-secret");
414
+ const token = getToken({userId: 42}, "your-secret");
415
+ const payload = validToken(token, "your-secret");
416
416
  ```
417
417
 
418
418
  POST/PUT/PATCH/DELETE routes require `Authorization: Bearer <token>` by default. Use `noauth()` to make public, `secured()` to protect GET routes.
@@ -643,7 +643,7 @@ DATABASE_USERNAME=admin # Separate credentials for networked databa
643
643
  DATABASE_PASSWORD=secret
644
644
  TINA4_DEBUG=true # Enable dev toolbar, error overlay
645
645
  TINA4_LOG_LEVEL=ALL # ALL, DEBUG, INFO, WARNING, ERROR
646
- TINA4_LANGUAGE=en # en, fr, af, zh, ja, es
646
+ TINA4_LOCALE=en # en, fr, af, zh, ja, es
647
647
  TINA4_SESSION_HANDLER=SessionFileHandler
648
648
  SWAGGER_TITLE=My API
649
649
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tina4-nodejs",
3
- "version": "3.5.0",
3
+ "version": "3.7.1",
4
4
  "type": "module",
5
5
  "description": "This is not a framework. Tina4 for Node.js/TypeScript — zero deps, 38 built-in features.",
6
6
  "keywords": ["tina4", "framework", "web", "api", "orm", "graphql", "websocket", "typescript"],
@@ -10,11 +10,12 @@ export async function serveProject(options: ServeOptions): Promise<void> {
10
10
  const cwd = process.cwd();
11
11
 
12
12
  const routesDir = resolve(cwd, "src/routes");
13
+ const ormDir = resolve(cwd, "src/orm");
13
14
  const modelsDir = resolve(cwd, "src/models");
14
15
  const templatesDir = resolve(cwd, "src/templates");
15
16
  const staticDir = resolve(cwd, "public");
16
17
 
17
- if (!existsSync(routesDir) && !existsSync(modelsDir)) {
18
+ if (!existsSync(routesDir) && !existsSync(modelsDir) && !existsSync(ormDir)) {
18
19
  console.error(" Error: Not a Tina4 project. Run this from a project created with 'tina4 init'.");
19
20
  process.exit(1);
20
21
  }
@@ -31,7 +32,8 @@ export async function serveProject(options: ServeOptions): Promise<void> {
31
32
  });
32
33
 
33
34
  // Watch for file changes and reload routes
34
- const watcher = watchForChanges([routesDir, modelsDir, templatesDir], async () => {
35
+ const watchDirs = [routesDir, ormDir, modelsDir, templatesDir].filter((d) => existsSync(d));
36
+ const watcher = watchForChanges(watchDirs, async () => {
35
37
  try {
36
38
  const { discoverRoutes } = await import("@tina4/core");
37
39
  const routes = await discoverRoutes(routesDir);
@@ -1,6 +1,6 @@
1
1
  import { createServer } from "node:http";
2
2
  import { resolve, dirname, join, relative } from "node:path";
3
- import { existsSync, readdirSync, statSync } from "node:fs";
3
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
4
4
  import { isatty } from "node:tty";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import cluster from "node:cluster";
@@ -166,6 +166,51 @@ function getGalleryDeployedState(): Record<string, boolean> {
166
166
  return state;
167
167
  }
168
168
 
169
+ /** Template cache: url_path -> template_file. Null until first production lookup. */
170
+ let templateCache: Map<string, string> | null = null;
171
+
172
+ /**
173
+ * Resolve a URL path to a template file in src/templates/.
174
+ * Dev mode: checks filesystem every time for live changes.
175
+ * Production: uses a cached lookup built once at startup.
176
+ */
177
+ function resolveTemplate(pathname: string, templatesDir: string): string | null {
178
+ const cleanPath = pathname.replace(/^\//, "") || "index";
179
+ const isDev = (process.env.TINA4_DEBUG ?? "false").toLowerCase() === "true";
180
+
181
+ if (isDev) {
182
+ for (const ext of [".twig", ".html"]) {
183
+ const candidate = cleanPath + ext;
184
+ if (existsSync(resolve(templatesDir, candidate))) {
185
+ return candidate;
186
+ }
187
+ }
188
+ return null;
189
+ }
190
+
191
+ // Production: cached lookup
192
+ if (!templateCache) {
193
+ templateCache = new Map();
194
+ if (existsSync(templatesDir)) {
195
+ const scan = (dir: string, prefix: string) => {
196
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
197
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
198
+ if (entry.isDirectory()) {
199
+ scan(resolve(dir, entry.name), rel);
200
+ } else if (entry.name.endsWith(".twig") || entry.name.endsWith(".html")) {
201
+ const urlPath = rel.replace(/\.(twig|html)$/, "");
202
+ if (!templateCache!.has(urlPath)) {
203
+ templateCache!.set(urlPath, rel);
204
+ }
205
+ }
206
+ }
207
+ };
208
+ scan(templatesDir, "");
209
+ }
210
+ }
211
+ return templateCache.get(cleanPath) ?? null;
212
+ }
213
+
169
214
  function renderLandingPage(routes: Array<{ method: string; pattern: string; flags?: string[] }>, port: number = 7148): string {
170
215
  const version = TINA4_VERSION;
171
216
 
@@ -383,6 +428,7 @@ ${reset}
383
428
  const base = config?.basePath ? resolve(config.basePath) : process.cwd();
384
429
  const routesDir = resolve(base, config?.routesDir ?? "src/routes");
385
430
  const modelsDir = resolve(base, config?.modelsDir ?? "src/models");
431
+ const ormDir = resolve(base, "src/orm");
386
432
  const staticDir = resolve(base, config?.staticDir ?? "public");
387
433
  const templatesDir = resolve(base, config?.templatesDir ?? "src/templates");
388
434
 
@@ -429,8 +475,10 @@ ${reset}
429
475
  console.log(`\n No routes directory found at ${routesDir}`);
430
476
  }
431
477
 
432
- // Initialize ORM if models directory exists
433
- if (existsSync(modelsDir)) {
478
+ // Initialize ORM if models directory exists (check src/orm/ first, then src/models/)
479
+ const hasOrmDir = existsSync(ormDir);
480
+ const hasModelsDir = existsSync(modelsDir);
481
+ if (hasOrmDir || hasModelsDir) {
434
482
  try {
435
483
  const orm = await import("@tina4/orm");
436
484
  const dbConfig = config?.database ?? {};
@@ -439,7 +487,18 @@ ${reset}
439
487
  path: dbConfig.path ?? "./data/tina4.db",
440
488
  });
441
489
 
442
- const models = await orm.discoverModels(modelsDir);
490
+ // Discover from src/orm/ (primary) and src/models/ (fallback), merge results
491
+ let models = hasOrmDir ? await orm.discoverModels(ormDir) : [];
492
+ if (hasModelsDir) {
493
+ const extraModels = await orm.discoverModels(modelsDir);
494
+ // Only add models not already discovered (src/orm/ takes priority)
495
+ const existingTables = new Set(models.map((m: any) => m.definition.tableName));
496
+ for (const m of extraModels) {
497
+ if (!existingTables.has(m.definition.tableName)) {
498
+ models.push(m);
499
+ }
500
+ }
501
+ }
443
502
  if (models.length > 0) {
444
503
  console.log(`\n Models discovered:`);
445
504
  orm.syncModels(models);
@@ -476,9 +535,16 @@ ${reset}
476
535
  let modelDefs: Array<{ tableName: string; fields: Record<string, unknown> }> = [];
477
536
  try {
478
537
  const orm = await import("@tina4/orm");
479
- if (existsSync(modelsDir)) {
480
- const models = await orm.discoverModels(modelsDir);
481
- modelDefs = models.map((m) => m.definition);
538
+ const allModelDirs = [ormDir, modelsDir].filter((d) => existsSync(d));
539
+ const seenTables = new Set<string>();
540
+ for (const dir of allModelDirs) {
541
+ const discovered = await orm.discoverModels(dir);
542
+ for (const m of discovered) {
543
+ if (!seenTables.has(m.definition.tableName)) {
544
+ modelDefs.push(m.definition);
545
+ seenTables.add(m.definition.tableName);
546
+ }
547
+ }
482
548
  }
483
549
  } catch {
484
550
  // ORM not available, swagger will work without model schemas
@@ -631,11 +697,18 @@ ${reset}
631
697
  return;
632
698
  }
633
699
 
634
- // Show landing page on "/" if no route matched and no index template exists
635
- if (pathname === "/" && (req.method ?? "GET") === "GET") {
636
- const hasIndexHtml = existsSync(resolve(templatesDir, "index.html"));
637
- const hasIndexTwig = existsSync(resolve(templatesDir, "index.twig"));
638
- if (!hasIndexHtml && !hasIndexTwig) {
700
+ // Try serving a template file (e.g. /hello -> src/templates/hello.twig or hello.html)
701
+ if ((req.method ?? "GET") === "GET") {
702
+ const tplFile = resolveTemplate(pathname, templatesDir);
703
+ if (tplFile) {
704
+ const html = readFileSync(resolve(templatesDir, tplFile), "utf-8");
705
+ res.raw.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
706
+ res.raw.end(html);
707
+ return;
708
+ }
709
+
710
+ // Show landing page for "/" when no template exists
711
+ if (pathname === "/") {
639
712
  const allRoutes = router.getRoutes().map((r) => ({
640
713
  method: r.method,
641
714
  pattern: r.pattern,
@@ -5,6 +5,72 @@ import type { SQLiteAdapter } from "./adapters/sqlite.js";
5
5
  import type { DiscoveredModel } from "./model.js";
6
6
  import { getAdapter } from "./database.js";
7
7
 
8
+ // ---------------------------------------------------------------------------
9
+ // Firebird ALTER TABLE ADD idempotency check
10
+ // ---------------------------------------------------------------------------
11
+ // Firebird does not support IF NOT EXISTS for ALTER TABLE ADD. When a migration
12
+ // adds a column that already exists, Firebird throws an error and blocks the
13
+ // entire migration. These helpers detect ALTER TABLE ... ADD statements and
14
+ // query RDB$RELATION_FIELDS to see if the column is already present. If so,
15
+ // the statement is silently skipped rather than executed.
16
+
17
+ /**
18
+ * Regex to match ALTER TABLE <table> ADD <column> ...
19
+ * Captures table name and column name (quoted or unquoted).
20
+ */
21
+ const ALTER_ADD_RE =
22
+ /^\s*ALTER\s+TABLE\s+(?:"([^"]+)"|(\S+))\s+ADD\s+(?:"([^"]+)"|(\S+))/i;
23
+
24
+ /**
25
+ * Check if the adapter is a Firebird adapter (duck-type check).
26
+ * We look for the `queryAsync` method and `translateSql` which are
27
+ * unique to the Firebird adapter.
28
+ */
29
+ function isFirebirdAdapter(db: DatabaseAdapter): boolean {
30
+ return (
31
+ typeof (db as any).queryAsync === "function" &&
32
+ typeof (db as any).translateSql === "function"
33
+ );
34
+ }
35
+
36
+ /**
37
+ * Check if a column already exists in a Firebird table.
38
+ */
39
+ async function firebirdColumnExists(
40
+ db: DatabaseAdapter,
41
+ table: string,
42
+ column: string,
43
+ ): Promise<boolean> {
44
+ const rows = await (db as any).queryAsync<Record<string, unknown>>(
45
+ "SELECT 1 FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME = ? AND TRIM(RDB$FIELD_NAME) = ?",
46
+ [table.toUpperCase(), column.toUpperCase()],
47
+ );
48
+ return rows.length > 0;
49
+ }
50
+
51
+ /**
52
+ * If stmt is an ALTER TABLE ... ADD on Firebird and the column already exists,
53
+ * returns a skip reason string. Returns null if the statement should execute normally.
54
+ */
55
+ async function shouldSkipForFirebird(
56
+ db: DatabaseAdapter,
57
+ stmt: string,
58
+ ): Promise<string | null> {
59
+ if (!isFirebirdAdapter(db)) return null;
60
+
61
+ const m = stmt.match(ALTER_ADD_RE);
62
+ if (!m) return null;
63
+
64
+ const table = m[1] ?? m[2];
65
+ const column = m[3] ?? m[4];
66
+
67
+ if (await firebirdColumnExists(db, table, column)) {
68
+ return `Column ${column} already exists in ${table}, skipping`;
69
+ }
70
+
71
+ return null;
72
+ }
73
+
8
74
  /**
9
75
  * Sync model definitions to the database (create tables, add columns).
10
76
  */
@@ -458,6 +524,14 @@ export async function migrate(
458
524
  db.startTransaction();
459
525
 
460
526
  for (const stmt of statements) {
527
+ // Firebird lacks IF NOT EXISTS for ALTER TABLE ADD.
528
+ // Pre-check the system catalogue so duplicate columns are
529
+ // silently skipped instead of raising an error.
530
+ const skipReason = await shouldSkipForFirebird(db, stmt);
531
+ if (skipReason) {
532
+ console.log(` Migration ${file}: ${skipReason}`);
533
+ continue;
534
+ }
461
535
  db.execute(stmt);
462
536
  }
463
537