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
|
|
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 {
|
|
412
|
+
import { getToken, validToken } from "tina4-nodejs";
|
|
413
413
|
|
|
414
|
-
const token =
|
|
415
|
-
const payload =
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
//
|
|
635
|
-
if (
|
|
636
|
-
const
|
|
637
|
-
|
|
638
|
-
|
|
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
|
|