tina4-nodejs 3.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tina4-nodejs",
3
- "version": "3.6.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"],
@@ -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
 
@@ -652,11 +697,18 @@ ${reset}
652
697
  return;
653
698
  }
654
699
 
655
- // Show landing page on "/" if no route matched and no index template exists
656
- if (pathname === "/" && (req.method ?? "GET") === "GET") {
657
- const hasIndexHtml = existsSync(resolve(templatesDir, "index.html"));
658
- const hasIndexTwig = existsSync(resolve(templatesDir, "index.twig"));
659
- 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 === "/") {
660
712
  const allRoutes = router.getRoutes().map((r) => ({
661
713
  method: r.method,
662
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