tina4-nodejs 3.6.0 → 3.8.0
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.
|
|
3
|
+
"version": "3.8.0",
|
|
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"],
|
|
@@ -359,11 +359,26 @@ export class Router {
|
|
|
359
359
|
paramNames.push(name);
|
|
360
360
|
return "(.+)";
|
|
361
361
|
}
|
|
362
|
-
// Dynamic param: {id}
|
|
362
|
+
// Dynamic param: {id}, {id:int}, {id:float}, {id:path} (matching Python/Ruby)
|
|
363
363
|
if (segment.startsWith("{") && segment.endsWith("}")) {
|
|
364
|
-
const
|
|
364
|
+
const inner = segment.slice(1, -1);
|
|
365
|
+
const colonIdx = inner.indexOf(":");
|
|
366
|
+
const name = colonIdx >= 0 ? inner.slice(0, colonIdx) : inner;
|
|
367
|
+
const type = colonIdx >= 0 ? inner.slice(colonIdx + 1) : "string";
|
|
365
368
|
paramNames.push(name);
|
|
366
|
-
|
|
369
|
+
switch (type) {
|
|
370
|
+
case "int":
|
|
371
|
+
case "integer":
|
|
372
|
+
return "(\\d+)";
|
|
373
|
+
case "float":
|
|
374
|
+
case "number":
|
|
375
|
+
return "([\\d.]+)";
|
|
376
|
+
case "path":
|
|
377
|
+
case ".*":
|
|
378
|
+
return "(.+)";
|
|
379
|
+
default:
|
|
380
|
+
return "([^/]+)";
|
|
381
|
+
}
|
|
367
382
|
}
|
|
368
383
|
// Dynamic param: [id] (file-based routing internal syntax)
|
|
369
384
|
if (segment.startsWith("[") && segment.endsWith("]")) {
|
|
@@ -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
|
-
//
|
|
656
|
-
if (
|
|
657
|
-
const
|
|
658
|
-
|
|
659
|
-
|
|
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,
|
|
@@ -758,7 +758,9 @@ const BUILTIN_FILTERS: Record<string, FilterFn> = {
|
|
|
758
758
|
md5: (v) => createHash("md5").update(String(v)).digest("hex"),
|
|
759
759
|
sha256: (v) => createHash("sha256").update(String(v)).digest("hex"),
|
|
760
760
|
base64_encode: (v) => Buffer.isBuffer(v) ? v.toString("base64") : Buffer.from(String(v)).toString("base64"),
|
|
761
|
+
base64encode: (v) => Buffer.isBuffer(v) ? v.toString("base64") : Buffer.from(String(v)).toString("base64"),
|
|
761
762
|
base64_decode: (v) => Buffer.from(String(v), "base64").toString("utf-8"),
|
|
763
|
+
base64decode: (v) => Buffer.from(String(v), "base64").toString("utf-8"),
|
|
762
764
|
data_uri: (v) => {
|
|
763
765
|
if (v && typeof v === "object" && "content" in v) {
|
|
764
766
|
const ct = (v as any).type ?? "application/octet-stream";
|
|
@@ -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
|
|