tina4-nodejs 3.13.12 → 3.13.16
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/CLAUDE.md +42 -36
- package/package.json +1 -1
- package/packages/cli/src/commands/migrate.ts +7 -5
- package/packages/cli/src/commands/migrateRollback.ts +3 -3
- package/packages/core/src/logger.ts +21 -8
- package/packages/core/src/middleware.ts +24 -3
- package/packages/core/src/server.ts +1 -1
- package/packages/orm/src/adapters/mssql.ts +12 -4
- package/packages/orm/src/adapters/mysql.ts +13 -4
- package/packages/orm/src/adapters/postgres.ts +18 -8
- package/packages/orm/src/adapters/sqlite.ts +22 -2
- package/packages/orm/src/autoCrud.ts +32 -16
- package/packages/orm/src/baseModel.ts +170 -187
- package/packages/orm/src/database.ts +189 -70
- package/packages/orm/src/databaseResult.ts +24 -0
- package/packages/orm/src/index.ts +6 -0
- package/packages/orm/src/migration.ts +128 -75
- package/packages/orm/src/queryBuilder.ts +12 -9
- package/packages/orm/src/seeder.ts +2 -1
- package/packages/orm/src/sqlTranslation.ts +16 -0
package/CLAUDE.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
# CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.13.
|
|
1
|
+
# CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.13.16)
|
|
2
2
|
|
|
3
3
|
> This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
|
|
4
4
|
|
|
5
5
|
## What This Project Is
|
|
6
6
|
|
|
7
|
-
Tina4 for Node.js/TypeScript v3.13.
|
|
7
|
+
Tina4 for Node.js/TypeScript v3.13.16 — The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
|
|
8
8
|
|
|
9
9
|
The philosophy: zero ceremony, batteries included, file system as source of truth.
|
|
10
10
|
|
|
@@ -53,7 +53,7 @@ tina4-nodejs/
|
|
|
53
53
|
seeder.ts # Database seeding (seedTable, seedOrm)
|
|
54
54
|
sqlTranslation.ts # Cross-engine SQL translator + query cache
|
|
55
55
|
swagger/ # OpenAPI spec generator, Swagger UI
|
|
56
|
-
|
|
56
|
+
frond/ # Zero-dependency Twig-compatible template engine
|
|
57
57
|
test/
|
|
58
58
|
run-all.ts # Test runner — executes all 43 test files
|
|
59
59
|
integration.ts # Full integration test
|
|
@@ -70,7 +70,7 @@ This is an **npm workspaces monorepo**. All packages are in `packages/*`.
|
|
|
70
70
|
- **Runtime:** Node.js 20+ (ESM only, `"type": "module"` everywhere)
|
|
71
71
|
- **HTTP:** Native `node:http` — no Express, no Fastify
|
|
72
72
|
- **Database:** SQLite via `node:sqlite` (default), with adapters for Postgres, MySQL, MSSQL/SQL Server, and Firebird
|
|
73
|
-
- **Templates:**
|
|
73
|
+
- **Templates:** Frond — built-in zero-dependency Twig-compatible engine (`@tina4/frond`)
|
|
74
74
|
- **Dev tooling:** `tsx` for runtime TS execution, `esbuild` for builds
|
|
75
75
|
- **Testing:** 43 test files via `tsx test/run-all.ts`
|
|
76
76
|
|
|
@@ -110,10 +110,10 @@ The HTTP foundation. Handles request/response lifecycle, route matching, middlew
|
|
|
110
110
|
- `static.ts` — Serves files from `public/` with MIME type detection
|
|
111
111
|
- `types.ts` — All shared type definitions (`Tina4Request`, `Tina4Response`, `RouteHandler`, etc.)
|
|
112
112
|
- `events.ts` — Observer-pattern event system (`Events.on`, `emit`, `once`, `off`, `clear`)
|
|
113
|
-
- `ai.ts` — AI coding tool
|
|
113
|
+
- `ai.ts` — AI coding tool context installer (`AI_TOOLS`, `isInstalled`, `showMenu`, `installSelected`, `installAll`, `generateContext`)
|
|
114
114
|
- `errorOverlay.ts` — Rich debug error page for dev mode (`renderErrorOverlay`, `renderProductionError`, `isDebugMode`)
|
|
115
115
|
- `htmlElement.ts` — Programmatic HTML builder (`HtmlElement`, `htmlElement`, `addHtmlHelpers`)
|
|
116
|
-
- `testing.ts` — Inline testing framework (`tests`, `assertEqual`, `
|
|
116
|
+
- `testing.ts` — Inline testing framework (`tests`, `assertEqual`, `assertRaises`, `runAll`)
|
|
117
117
|
- `fakeData.ts` — Core fake data generator (names, emails, addresses, UUIDs, etc.)
|
|
118
118
|
- `constants.ts` — HTTP status codes (`HTTP_OK`, `HTTP_NOT_FOUND`, etc.) and content types (`APPLICATION_JSON`, `TEXT_HTML`, etc.)
|
|
119
119
|
- `devAdmin.ts` — Dev toolbar (fixed bottom bar injected into HTML pages) and admin dashboard at `/_dev/`
|
|
@@ -152,7 +152,7 @@ Database layer with auto-CRUD generation, seeding, fake data, and SQL translatio
|
|
|
152
152
|
- `fakeData.ts` — ORM-aware fake data extending core (adds `forField()` with column-name heuristics)
|
|
153
153
|
- `seeder.ts` — Database seeding (`seedTable` for raw SQL, `seedOrm` for model-based)
|
|
154
154
|
- `sqlTranslation.ts` — Cross-engine SQL translator (`SQLTranslator`) and TTL query cache (`QueryCache`)
|
|
155
|
-
- **Instance methods:** `save(): this|
|
|
155
|
+
- **Instance methods:** `save(): this|false` (fluent, false on failure), `delete()`, `forceDelete()`, `restore()`, `load(sql, params?, include?): boolean`, `validate(): string[]`, `toDict(include?)`, `toAssoc(include?)`, `toObject()`, `toArray(): unknown[]`, `toList()`, `toJson(include?)`, `hasOne(class, fk)`, `hasMany(class, fk, limit?, offset?)`, `belongsTo(class, fk)`
|
|
156
156
|
- **Static methods:** `find(id, include?)`, `findById(id, include?)`, `findOrFail(id)`, `create(data)`, `all(where?, params?, include?)`, `select(sql, params?)`, `selectOne(sql, params?, include?)`, `where(conditions, params?, limit?, offset?, include?)`, `count(conditions?, params?)`, `withTrashed(conditions?, params?, limit?, offset?)`, `scope(name, filterSql, params?)` (registers reusable method), `createTable()`, `query()`, `_processForeignKeys()`, `_applyFkRegistry()`
|
|
157
157
|
- **Foreign key auto-wire:** Declare a field with `type: "foreignKey"` and `references: "ModelName"` to auto-wire both `belongsTo` on the declaring model and `hasMany` on the referenced model. Optional `relatedName` overrides the has-many key. Models must be registered via `BaseModel.registerModel(name, class)` for name-based resolution. Example: `user_id: { type: "foreignKey", references: "User" }` → `post.belongsTo(User, "user_id")` and `user.hasMany(Post, "user_id")` both resolve without extra wiring.
|
|
158
158
|
- QueryBuilder supports `toMongo()` for generating MongoDB query documents from the same fluent API
|
|
@@ -240,7 +240,7 @@ req.cookies: Record<string, string> // parsed from Cookie header
|
|
|
240
240
|
req.contentType: string // from content-type header
|
|
241
241
|
req.query: Record<string, string> // query string params
|
|
242
242
|
response.xml(content, status?): Tina4Response
|
|
243
|
-
response.stream(
|
|
243
|
+
response.stream(source: AsyncIterable<string | Buffer>, contentType?: string): Promise<Tina4Response> // SSE/streaming
|
|
244
244
|
```
|
|
245
245
|
|
|
246
246
|
### Queue
|
|
@@ -258,12 +258,11 @@ Auto-generates OpenAPI 3.0 docs.
|
|
|
258
258
|
- `generator.ts` — Produces OpenAPI spec from route table + model definitions
|
|
259
259
|
- `ui.ts` — Serves Swagger UI HTML (CDN-based) at `/swagger` and spec at `/swagger/openapi.json`
|
|
260
260
|
|
|
261
|
-
### @tina4/
|
|
262
|
-
|
|
261
|
+
### @tina4/frond (`packages/frond/`)
|
|
262
|
+
Built-in zero-dependency Twig-compatible template engine (the only template engine; there is no `twig` npm dependency).
|
|
263
263
|
|
|
264
264
|
**Key files:**
|
|
265
|
-
- `engine.ts` —
|
|
266
|
-
- `middleware.ts` — Adds `res.render(template, data)` to response objects
|
|
265
|
+
- `engine.ts` — The `Frond` class: `render(path, data)`, `renderString(template, data)`, filters/globals/tests, sandbox mode
|
|
267
266
|
|
|
268
267
|
### tina4 CLI (`packages/cli/`)
|
|
269
268
|
Developer-facing CLI commands.
|
|
@@ -305,26 +304,29 @@ Events.clear();
|
|
|
305
304
|
|
|
306
305
|
## Module: AI (`packages/core/src/ai.ts`)
|
|
307
306
|
|
|
308
|
-
|
|
307
|
+
Installs Tina4 context files for AI coding tools (Claude Code, Cursor, Copilot, Windsurf, Aider, Cline, Codex). `AI_TOOLS` is the ordered list of known tools; the installer writes a marker-bracketed Tina4 skill block into each tool's context file, preserving existing content.
|
|
309
308
|
|
|
310
309
|
```typescript
|
|
311
|
-
import {
|
|
310
|
+
import { AI_TOOLS, isInstalled, showMenu, installSelected, installAll, generateContext } from "@tina4/core";
|
|
312
311
|
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
-
// → [{ name: "claude-code", description: "Claude Code (Anthropic CLI)",
|
|
316
|
-
// configFile: "CLAUDE.md", status: "detected" }, ...]
|
|
312
|
+
// The known tools (name, description, contextFile, configDir)
|
|
313
|
+
AI_TOOLS; // → [{ name: "claude-code", description: "Claude Code", contextFile: "CLAUDE.md", configDir: ".claude" }, ...]
|
|
317
314
|
|
|
318
|
-
//
|
|
319
|
-
|
|
320
|
-
// → ["CLAUDE.md", ".cursorules"]
|
|
315
|
+
// Check whether a tool's context file already exists in a project directory
|
|
316
|
+
isInstalled(".", AI_TOOLS[0]); // → boolean
|
|
321
317
|
|
|
322
|
-
//
|
|
323
|
-
|
|
324
|
-
installAllAiContext(".", true); // force overwrite
|
|
318
|
+
// Show the interactive numbered menu and read the user's selection (returns a Promise)
|
|
319
|
+
const selection = await showMenu(".");
|
|
325
320
|
|
|
326
|
-
//
|
|
327
|
-
|
|
321
|
+
// Install context files for a selection ("1,2,3" or "all") — returns created/updated paths
|
|
322
|
+
const created = installSelected(".", selection);
|
|
323
|
+
// → ["CLAUDE.md", ".cursorules", ...]
|
|
324
|
+
|
|
325
|
+
// Install for ALL known tools, non-interactive
|
|
326
|
+
installAll(".");
|
|
327
|
+
|
|
328
|
+
// Generate the context document string for a specific tool (defaults to "claude-code")
|
|
329
|
+
const doc = generateContext("cursor");
|
|
328
330
|
```
|
|
329
331
|
|
|
330
332
|
## Module: Error Overlay (`packages/core/src/errorOverlay.ts`)
|
|
@@ -384,16 +386,16 @@ Void tags (`br`, `hr`, `img`, `input`, `meta`, etc.) render without closing tags
|
|
|
384
386
|
|
|
385
387
|
## Module: Inline Testing (`packages/core/src/testing.ts`)
|
|
386
388
|
|
|
387
|
-
Attach test assertions directly to functions. Tests are registered globally and run with `
|
|
389
|
+
Attach test assertions directly to functions. Tests are registered globally and run with `runAll()`. No external test runner needed.
|
|
388
390
|
|
|
389
391
|
```typescript
|
|
390
|
-
import { tests, assertEqual,
|
|
392
|
+
import { tests, assertEqual, assertRaises, assertTrue, assertFalse, runAll, reset } from "@tina4/core";
|
|
391
393
|
|
|
392
394
|
// Decorate a function with inline tests
|
|
393
395
|
const add = tests(
|
|
394
396
|
assertEqual([5, 3], 8), // add(5, 3) === 8
|
|
395
397
|
assertEqual([0, 0], 0), // add(0, 0) === 0
|
|
396
|
-
|
|
398
|
+
assertRaises(Error, [null]), // add(null) throws Error
|
|
397
399
|
)(function add(a: number, b: number | null = null): number {
|
|
398
400
|
if (b === null) throw new Error("b required");
|
|
399
401
|
return a + b;
|
|
@@ -403,7 +405,7 @@ const add = tests(
|
|
|
403
405
|
add(2, 3); // 5
|
|
404
406
|
|
|
405
407
|
// Run all registered tests
|
|
406
|
-
const results =
|
|
408
|
+
const results = runAll({ quiet: false, failfast: false });
|
|
407
409
|
// → { passed: 3, failed: 0, errors: 0, details: [...] }
|
|
408
410
|
|
|
409
411
|
// Additional assertion types
|
|
@@ -411,7 +413,7 @@ assertTrue([someArgs]); // result is truthy
|
|
|
411
413
|
assertFalse([someArgs]); // result is falsy
|
|
412
414
|
|
|
413
415
|
// Reset registry between test runs
|
|
414
|
-
|
|
416
|
+
reset();
|
|
415
417
|
```
|
|
416
418
|
|
|
417
419
|
## Module: Seeder / FakeData (`packages/orm/src/seeder.ts`, `packages/orm/src/fakeData.ts`)
|
|
@@ -600,7 +602,7 @@ db.pool
|
|
|
600
602
|
Active-Record base class. Models live in `src/models/` and are auto-discovered. Use `static fields` (not decorators) — same convention across all four frameworks.
|
|
601
603
|
|
|
602
604
|
```typescript
|
|
603
|
-
import { BaseModel,
|
|
605
|
+
import { BaseModel, initDatabase, setAdapter } from "@tina4/orm";
|
|
604
606
|
|
|
605
607
|
export default class User extends BaseModel {
|
|
606
608
|
static tableName = "users";
|
|
@@ -614,7 +616,7 @@ export default class User extends BaseModel {
|
|
|
614
616
|
|
|
615
617
|
// Instance methods (chainable where it makes sense)
|
|
616
618
|
const user = new User({ email: "alice@example.com" });
|
|
617
|
-
user.save(); // returns this on success,
|
|
619
|
+
user.save(); // returns this on success, false on failure
|
|
618
620
|
user.delete(); // soft-delete if enabled, otherwise hard
|
|
619
621
|
user.forceDelete(); // bypasses soft-delete
|
|
620
622
|
user.restore(); // clears soft-delete marker
|
|
@@ -643,7 +645,11 @@ User.createTable();
|
|
|
643
645
|
User.query(): QueryBuilder;
|
|
644
646
|
BaseModel.registerModel(name, class); // for foreignKey name resolution
|
|
645
647
|
|
|
646
|
-
|
|
648
|
+
// Models bind to the active adapter, not a Database wrapper. initDatabase() sets it
|
|
649
|
+
// automatically; setAdapter() lets you bind one explicitly. Models read it via getAdapter().
|
|
650
|
+
await initDatabase({ url: "sqlite:///app.db" }); // sets the active adapter for all models
|
|
651
|
+
// or, with an adapter you constructed yourself:
|
|
652
|
+
setAdapter(adapter);
|
|
647
653
|
```
|
|
648
654
|
|
|
649
655
|
**Soft delete:** set `static softDelete = true`. Adds an `is_deleted` INTEGER column (0/1). `delete()` flips the flag, `forceDelete()` removes the row, `restore()` clears it.
|
|
@@ -726,7 +732,7 @@ frond.sandbox(["upper"], ["if"], ["x"]); // allowed: filters, tags, vars
|
|
|
726
732
|
frond.unsandbox();
|
|
727
733
|
```
|
|
728
734
|
|
|
729
|
-
- **
|
|
735
|
+
- **Safe output** — Frond's built-in `raw`/`safe`-style filters (and the `{% autoescape %}` controls) mark output as already-escaped so it bypasses auto-escaping. The internal `SafeString` wrapper backing this is not exported from `@tina4/frond` (only `Frond`, `FilterFn`, `TestFn` are public).
|
|
730
736
|
- **Fragment caching** — `{% cache "key" 300 %}...{% endcache %}` caches block output for TTL seconds.
|
|
731
737
|
- **Raw blocks** — `{% raw %}...{% endraw %}` outputs literal template syntax.
|
|
732
738
|
- **Pre-compiled regexes** + token caching (cleared on file mtime change in dev mode) for ~2.8x render improvement over the naive path.
|
|
@@ -1056,7 +1062,7 @@ When adding new features, add a corresponding `test/<feature>.test.ts` file.
|
|
|
1056
1062
|
## v3 Features Summary
|
|
1057
1063
|
|
|
1058
1064
|
- **45 built-in features**, zero third-party dependencies
|
|
1059
|
-
- **
|
|
1065
|
+
- **3,644 tests** passing across all modules
|
|
1060
1066
|
- **Race-safe `getNextId()`** with atomic sequence table (`tina4_sequences`) for SQLite/MySQL/MSSQL; PostgreSQL auto-creates sequences
|
|
1061
1067
|
- **Frond template engine optimizations**: pre-compiled regexes, lazy loop context (copy-on-write), filter chain caching, path split caching, inline common filters (11-15% speedup)
|
|
1062
1068
|
- **Production server auto-detect**: `npx tina4nodejs serve --production` auto-uses cluster mode
|
package/package.json
CHANGED
|
@@ -32,6 +32,7 @@ export async function runMigrations(migrationDir?: string): Promise<void> {
|
|
|
32
32
|
let recordMigration: typeof import("../../../orm/src/index.js").recordMigration;
|
|
33
33
|
let getNextBatch: typeof import("../../../orm/src/index.js").getNextBatch;
|
|
34
34
|
let getAdapter: typeof import("../../../orm/src/index.js").getAdapter;
|
|
35
|
+
let adapterExecute: typeof import("../../../orm/src/index.js").adapterExecute;
|
|
35
36
|
|
|
36
37
|
try {
|
|
37
38
|
const orm = await import("../../../orm/src/index.js");
|
|
@@ -41,6 +42,7 @@ export async function runMigrations(migrationDir?: string): Promise<void> {
|
|
|
41
42
|
recordMigration = orm.recordMigration;
|
|
42
43
|
getNextBatch = orm.getNextBatch;
|
|
43
44
|
getAdapter = orm.getAdapter;
|
|
45
|
+
adapterExecute = orm.adapterExecute;
|
|
44
46
|
} catch {
|
|
45
47
|
console.error(" Error: @tina4/orm is required to run migrations.");
|
|
46
48
|
process.exit(1);
|
|
@@ -57,7 +59,7 @@ export async function runMigrations(migrationDir?: string): Promise<void> {
|
|
|
57
59
|
process.exit(1);
|
|
58
60
|
}
|
|
59
61
|
|
|
60
|
-
ensureMigrationTable();
|
|
62
|
+
await ensureMigrationTable();
|
|
61
63
|
|
|
62
64
|
// Collect .sql files, excluding .down.sql, sorted by numeric prefix
|
|
63
65
|
const files = readdirSync(dir)
|
|
@@ -79,13 +81,13 @@ export async function runMigrations(migrationDir?: string): Promise<void> {
|
|
|
79
81
|
return;
|
|
80
82
|
}
|
|
81
83
|
|
|
82
|
-
const batch = getNextBatch();
|
|
84
|
+
const batch = await getNextBatch();
|
|
83
85
|
let applied = 0;
|
|
84
86
|
|
|
85
87
|
for (const file of files) {
|
|
86
88
|
const name = file.replace(/\.sql$/, "");
|
|
87
89
|
|
|
88
|
-
if (isMigrationApplied(name)) {
|
|
90
|
+
if (await isMigrationApplied(name)) {
|
|
89
91
|
continue;
|
|
90
92
|
}
|
|
91
93
|
|
|
@@ -100,7 +102,7 @@ export async function runMigrations(migrationDir?: string): Promise<void> {
|
|
|
100
102
|
|
|
101
103
|
for (const stmt of statements) {
|
|
102
104
|
try {
|
|
103
|
-
adapter
|
|
105
|
+
await adapterExecute(adapter, stmt);
|
|
104
106
|
} catch (err) {
|
|
105
107
|
const msg = err instanceof Error ? err.message : String(err);
|
|
106
108
|
console.error(` Error in ${file}: ${msg}`);
|
|
@@ -108,7 +110,7 @@ export async function runMigrations(migrationDir?: string): Promise<void> {
|
|
|
108
110
|
}
|
|
109
111
|
}
|
|
110
112
|
|
|
111
|
-
recordMigration(name, batch);
|
|
113
|
+
await recordMigration(name, batch);
|
|
112
114
|
applied++;
|
|
113
115
|
}
|
|
114
116
|
|
|
@@ -44,9 +44,9 @@ export async function migrateRollback(migrationDir?: string): Promise<void> {
|
|
|
44
44
|
process.exit(1);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
ensureMigrationTable();
|
|
47
|
+
await ensureMigrationTable();
|
|
48
48
|
|
|
49
|
-
const lastBatch = getLastBatchMigrations();
|
|
49
|
+
const lastBatch = await getLastBatchMigrations();
|
|
50
50
|
if (lastBatch.length === 0) {
|
|
51
51
|
console.log(" Nothing to rollback — no migrations have been applied.");
|
|
52
52
|
return;
|
|
@@ -54,7 +54,7 @@ export async function migrateRollback(migrationDir?: string): Promise<void> {
|
|
|
54
54
|
|
|
55
55
|
console.log(` Rolling back batch ${lastBatch[0].batch} (${lastBatch.length} migration(s))...`);
|
|
56
56
|
|
|
57
|
-
const rolledBack = rollbackFn(dir);
|
|
57
|
+
const rolledBack = await rollbackFn(dir);
|
|
58
58
|
|
|
59
59
|
if (rolledBack.length === 0) {
|
|
60
60
|
console.log(" Nothing was rolled back.");
|
|
@@ -190,7 +190,9 @@ export class Log {
|
|
|
190
190
|
rotateKeep = isNaN(n) || n < 1 ? DEFAULT_ROTATE_KEEP : n;
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
-
|
|
193
|
+
// v3.13.14: default level INFO (was DEBUG) — parity with Python/PHP/Ruby;
|
|
194
|
+
// surfaces request/startup/warn/error without debug noise in deploys.
|
|
195
|
+
const levelEnv = (process.env.TINA4_LOG_LEVEL ?? "INFO").toUpperCase();
|
|
194
196
|
const minLevel = LEVEL_PRIORITY[levelEnv as LogLevel] ?? 0;
|
|
195
197
|
|
|
196
198
|
const fmt = (process.env.TINA4_LOG_FORMAT ?? "text").trim().toLowerCase();
|
|
@@ -385,16 +387,27 @@ export class Log {
|
|
|
385
387
|
const dataPart = data !== undefined ? ` ${JSON.stringify(data)}` : "";
|
|
386
388
|
const humanLine = `${entry.timestamp} [${paddedLevel}]${reqPart}${fnPart} ${message}${dataPart}`;
|
|
387
389
|
|
|
388
|
-
// Build the file-format line
|
|
389
|
-
|
|
390
|
+
// Build the file-format line. v3.13.14: production always emits JSON
|
|
391
|
+
// (parity with Python/Ruby) so log aggregators get structured lines;
|
|
392
|
+
// TINA4_LOG_FORMAT=json forces it in dev too.
|
|
393
|
+
const fileLine =
|
|
394
|
+
cfg.format === "json" || Log.isProduction() ? JSON.stringify(entry) : humanLine;
|
|
390
395
|
|
|
391
396
|
const shouldLog = (LEVEL_PRIORITY[level] ?? 0) >= cfg.minLevel;
|
|
392
397
|
|
|
393
|
-
// Console output.
|
|
394
|
-
//
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
+
// Console output. v3.13.14: stdout is NOT suppressed in production —
|
|
399
|
+
// containers read PID 1 stdout (docker logs / k8s) and the old
|
|
400
|
+
// `!isProduction()` gate meant deployed apps logged nothing. In
|
|
401
|
+
// production we print the clean structured line (JSON, no ANSI) so it
|
|
402
|
+
// stays parseable; in dev we keep the coloured human-readable line.
|
|
403
|
+
// TINA4_LOG_OUTPUT="file" still opts out of stdout entirely.
|
|
404
|
+
if (shouldLog && cfg.output !== "file") {
|
|
405
|
+
if (Log.isProduction()) {
|
|
406
|
+
console.log(fileLine);
|
|
407
|
+
} else {
|
|
408
|
+
const color = COLORS[level];
|
|
409
|
+
console.log(`${color}${humanLine}${RESET}`);
|
|
410
|
+
}
|
|
398
411
|
}
|
|
399
412
|
|
|
400
413
|
// File output: always teed for dev (legacy behaviour), and either always
|
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import type { Tina4Request, Tina4Response, Middleware } from "./types.js";
|
|
2
2
|
import { validToken, getPayload } from "./auth.js";
|
|
3
|
+
import { Log } from "./logger.js";
|
|
4
|
+
import { isTruthy } from "./dotenv.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Whether to emit a per-request log line (v3.13.14). TINA4_LOG_REQUESTS is
|
|
8
|
+
* the explicit control (true/false); when unset, request logging follows
|
|
9
|
+
* dev mode (on under TINA4_DEBUG, off in production). Same contract across
|
|
10
|
+
* all four frameworks.
|
|
11
|
+
*/
|
|
12
|
+
function requestLoggingEnabled(): boolean {
|
|
13
|
+
const val = process.env.TINA4_LOG_REQUESTS;
|
|
14
|
+
if (val !== undefined && val !== "") return isTruthy(val);
|
|
15
|
+
return isTruthy(process.env.TINA4_DEBUG);
|
|
16
|
+
}
|
|
3
17
|
|
|
4
18
|
export class MiddlewareChain {
|
|
5
19
|
private middlewares: Middleware[] = [];
|
|
@@ -588,18 +602,25 @@ export class CsrfMiddleware {
|
|
|
588
602
|
}
|
|
589
603
|
}
|
|
590
604
|
|
|
591
|
-
// Built-in request logger middleware
|
|
605
|
+
// Built-in request logger middleware.
|
|
606
|
+
//
|
|
607
|
+
// v3.13.14: routes through the Tina4 Log (was a bare console.log) so the
|
|
608
|
+
// line gets the same timestamp/level treatment as every other log — human
|
|
609
|
+
// in dev, structured JSON in production — and is gated by
|
|
610
|
+
// requestLoggingEnabled() (on by default in dev, opt-in in prod via
|
|
611
|
+
// TINA4_LOG_REQUESTS). Line format matches Python/PHP/Ruby:
|
|
612
|
+
// METHOD /path -> STATUS (Nms)
|
|
592
613
|
export function requestLogger(): Middleware {
|
|
593
614
|
return (req, res, next) => {
|
|
594
615
|
const start = Date.now();
|
|
595
616
|
|
|
596
617
|
res.raw.on("finish", () => {
|
|
618
|
+
if (!requestLoggingEnabled()) return;
|
|
597
619
|
const duration = Date.now() - start;
|
|
598
620
|
const status = res.raw.statusCode;
|
|
599
621
|
const method = req.method ?? "?";
|
|
600
622
|
const url = req.url ?? "/";
|
|
601
|
-
|
|
602
|
-
console.log(` ${color}${status}\x1b[0m ${method} ${url} \x1b[90m${duration}ms\x1b[0m`);
|
|
623
|
+
Log.info(`${method} ${url} -> ${status} (${duration}ms)`);
|
|
603
624
|
});
|
|
604
625
|
|
|
605
626
|
next();
|
|
@@ -832,7 +832,7 @@ ${reset}
|
|
|
832
832
|
}
|
|
833
833
|
if (models.length > 0) {
|
|
834
834
|
console.log(`\n Models discovered:`);
|
|
835
|
-
orm.syncModels(models);
|
|
835
|
+
await orm.syncModels(models);
|
|
836
836
|
for (const { definition } of models) {
|
|
837
837
|
console.log(` \x1b[35m${definition.tableName}\x1b[0m (${Object.keys(definition.fields).length} fields)`);
|
|
838
838
|
}
|
|
@@ -344,14 +344,18 @@ export class MssqlAdapter implements DatabaseAdapter {
|
|
|
344
344
|
}
|
|
345
345
|
|
|
346
346
|
async columnsAsync(table: string): Promise<ColumnInfo[]> {
|
|
347
|
+
// v3.13.14 (#48): honour a schema-qualified name ("dbo.widget"); a bare
|
|
348
|
+
// name matches in any schema (NULL guard skips the schema filter).
|
|
349
|
+
const [schema, tbl] = SQLTranslator.splitSchema(table);
|
|
347
350
|
const rows = await this.queryAsync<{
|
|
348
351
|
COLUMN_NAME: string;
|
|
349
352
|
DATA_TYPE: string;
|
|
350
353
|
IS_NULLABLE: string;
|
|
351
354
|
COLUMN_DEFAULT: string | null;
|
|
352
355
|
}>(
|
|
353
|
-
"SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT FROM INFORMATION_SCHEMA.COLUMNS
|
|
354
|
-
|
|
356
|
+
"SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT FROM INFORMATION_SCHEMA.COLUMNS " +
|
|
357
|
+
"WHERE TABLE_NAME = ? AND (? IS NULL OR TABLE_SCHEMA = ?)",
|
|
358
|
+
[tbl, schema, schema],
|
|
355
359
|
);
|
|
356
360
|
return rows.map((r) => ({
|
|
357
361
|
name: r.COLUMN_NAME,
|
|
@@ -378,9 +382,13 @@ export class MssqlAdapter implements DatabaseAdapter {
|
|
|
378
382
|
}
|
|
379
383
|
|
|
380
384
|
async tableExistsAsync(name: string): Promise<boolean> {
|
|
385
|
+
// v3.13.14 (#48): honour a schema-qualified name ("dbo.widget"); a bare
|
|
386
|
+
// name matches in any schema (NULL guard skips the schema filter).
|
|
387
|
+
const [schema, tbl] = SQLTranslator.splitSchema(name);
|
|
381
388
|
const rows = await this.queryAsync<{ cnt: number }>(
|
|
382
|
-
"SELECT COUNT(*) AS cnt FROM INFORMATION_SCHEMA.TABLES
|
|
383
|
-
|
|
389
|
+
"SELECT COUNT(*) AS cnt FROM INFORMATION_SCHEMA.TABLES " +
|
|
390
|
+
"WHERE TABLE_NAME = ? AND (? IS NULL OR TABLE_SCHEMA = ?)",
|
|
391
|
+
[tbl, schema, schema],
|
|
384
392
|
);
|
|
385
393
|
return (rows[0]?.cnt ?? 0) > 0;
|
|
386
394
|
}
|
|
@@ -262,13 +262,17 @@ export class MysqlAdapter implements DatabaseAdapter {
|
|
|
262
262
|
}
|
|
263
263
|
|
|
264
264
|
async columnsAsync(table: string): Promise<ColumnInfo[]> {
|
|
265
|
+
// v3.13.14 (#48): a qualified name ("db.table") must back-quote each part
|
|
266
|
+
// separately, otherwise the dot is read as part of one identifier.
|
|
267
|
+
const [schema, tbl] = SQLTranslator.splitSchema(table);
|
|
268
|
+
const target = schema ? `\`${schema}\`.\`${tbl}\`` : `\`${tbl}\``;
|
|
265
269
|
const rows = await this.queryAsync<{
|
|
266
270
|
Field: string;
|
|
267
271
|
Type: string;
|
|
268
272
|
Null: string;
|
|
269
273
|
Default: string | null;
|
|
270
274
|
Key: string;
|
|
271
|
-
}>(`DESCRIBE
|
|
275
|
+
}>(`DESCRIBE ${target}`);
|
|
272
276
|
return rows.map((r) => ({
|
|
273
277
|
name: r.Field,
|
|
274
278
|
type: r.Type,
|
|
@@ -294,9 +298,14 @@ export class MysqlAdapter implements DatabaseAdapter {
|
|
|
294
298
|
}
|
|
295
299
|
|
|
296
300
|
async tableExistsAsync(name: string): Promise<boolean> {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
301
|
+
// v3.13.14 (#48): MySQL's "schema" is the database. A qualified name
|
|
302
|
+
// ("otherdb.table") checks that catalog; a bare name defaults to the
|
|
303
|
+
// connection's current database via DATABASE().
|
|
304
|
+
const [schema, tbl] = SQLTranslator.splitSchema(name);
|
|
305
|
+
const rows = await this.queryAsync<Record<string, unknown>>(
|
|
306
|
+
"SELECT 1 FROM information_schema.tables " +
|
|
307
|
+
"WHERE table_schema = COALESCE(?, DATABASE()) AND table_name = ?",
|
|
308
|
+
[schema, tbl],
|
|
300
309
|
);
|
|
301
310
|
return rows.length > 0;
|
|
302
311
|
}
|
|
@@ -249,10 +249,16 @@ export class PostgresAdapter implements DatabaseAdapter {
|
|
|
249
249
|
}
|
|
250
250
|
|
|
251
251
|
async tablesAsync(): Promise<string[]> {
|
|
252
|
-
|
|
253
|
-
|
|
252
|
+
// v3.13.14 (#48): list every user schema; public tables stay bare, others
|
|
253
|
+
// are returned schema-qualified.
|
|
254
|
+
const rows = await this.queryAsync<{ schemaname: string; tablename: string }>(
|
|
255
|
+
"SELECT schemaname, tablename FROM pg_tables " +
|
|
256
|
+
"WHERE schemaname NOT IN ('pg_catalog', 'information_schema') " +
|
|
257
|
+
"ORDER BY schemaname, tablename",
|
|
258
|
+
);
|
|
259
|
+
return rows.map((r) =>
|
|
260
|
+
r.schemaname === "public" ? r.tablename : `${r.schemaname}.${r.tablename}`,
|
|
254
261
|
);
|
|
255
|
-
return rows.map((r) => r.tablename);
|
|
256
262
|
}
|
|
257
263
|
|
|
258
264
|
columns(table: string): ColumnInfo[] {
|
|
@@ -260,14 +266,16 @@ export class PostgresAdapter implements DatabaseAdapter {
|
|
|
260
266
|
}
|
|
261
267
|
|
|
262
268
|
async columnsAsync(table: string): Promise<ColumnInfo[]> {
|
|
269
|
+
// v3.13.14 (#48): honour a schema-qualified name; default to public.
|
|
270
|
+
const [schema, tbl] = SQLTranslator.splitSchema(table);
|
|
263
271
|
const rows = await this.queryAsync<{
|
|
264
272
|
column_name: string;
|
|
265
273
|
data_type: string;
|
|
266
274
|
is_nullable: string;
|
|
267
275
|
column_default: string | null;
|
|
268
276
|
}>(
|
|
269
|
-
"SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_name = $1",
|
|
270
|
-
[
|
|
277
|
+
"SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_name = $1 AND table_schema = $2",
|
|
278
|
+
[tbl, schema ?? "public"],
|
|
271
279
|
);
|
|
272
280
|
return rows.map((r) => ({
|
|
273
281
|
name: r.column_name,
|
|
@@ -294,11 +302,13 @@ export class PostgresAdapter implements DatabaseAdapter {
|
|
|
294
302
|
}
|
|
295
303
|
|
|
296
304
|
async tableExistsAsync(name: string): Promise<boolean> {
|
|
297
|
-
|
|
298
|
-
|
|
305
|
+
// v3.13.14 (#48): to_regclass resolves a (possibly schema-qualified)
|
|
306
|
+
// relation name and search_path like a FROM clause; null if absent.
|
|
307
|
+
const row = await this.fetchOneAsync<{ oid: string | null }>(
|
|
308
|
+
"SELECT to_regclass($1) AS oid",
|
|
299
309
|
[name],
|
|
300
310
|
);
|
|
301
|
-
return row?.
|
|
311
|
+
return (row?.oid ?? null) !== null;
|
|
302
312
|
}
|
|
303
313
|
|
|
304
314
|
createTable(name: string, columns: Record<string, FieldDefinition>): void {
|
|
@@ -2,6 +2,12 @@ import { DatabaseSync } from "node:sqlite";
|
|
|
2
2
|
import { mkdirSync } from "node:fs";
|
|
3
3
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
4
|
import type { DatabaseAdapter, DatabaseResult, ColumnInfo, FieldDefinition } from "../types.js";
|
|
5
|
+
import { SQLTranslator } from "../sqlTranslation.js";
|
|
6
|
+
|
|
7
|
+
/** A safe-to-interpolate SQL identifier (no quoting/escaping needed). */
|
|
8
|
+
function isIdentifier(str: string): boolean {
|
|
9
|
+
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(str);
|
|
10
|
+
}
|
|
5
11
|
|
|
6
12
|
/**
|
|
7
13
|
* Resolve a SQLite path argument against the project root (cwd).
|
|
@@ -209,7 +215,14 @@ export class SQLiteAdapter implements DatabaseAdapter {
|
|
|
209
215
|
}
|
|
210
216
|
|
|
211
217
|
columns(table: string): ColumnInfo[] {
|
|
212
|
-
|
|
218
|
+
// v3.13.14 (#48): a SQLite "schema" is an ATTACH alias ("extra.widget").
|
|
219
|
+
// PRAGMA accepts a schema prefix when both parts are plain identifiers.
|
|
220
|
+
const [schema, tbl] = SQLTranslator.splitSchema(table);
|
|
221
|
+
const pragma =
|
|
222
|
+
schema && isIdentifier(schema) && isIdentifier(tbl)
|
|
223
|
+
? `PRAGMA ${schema}.table_info("${tbl}")`
|
|
224
|
+
: `PRAGMA table_info("${table}")`;
|
|
225
|
+
const rows = this.db.prepare(pragma).all() as Array<{
|
|
213
226
|
name: string; type: string; notnull: number; dflt_value: unknown; pk: number;
|
|
214
227
|
}>;
|
|
215
228
|
return rows.map((r) => ({
|
|
@@ -221,7 +234,14 @@ export class SQLiteAdapter implements DatabaseAdapter {
|
|
|
221
234
|
close(): void { this.db.close(); }
|
|
222
235
|
|
|
223
236
|
tableExists(name: string): boolean {
|
|
224
|
-
|
|
237
|
+
// v3.13.14 (#48): a SQLite "schema" is an ATTACH alias ("extra.widget").
|
|
238
|
+
// Query that database's own sqlite_master when the prefix is a plain
|
|
239
|
+
// identifier; otherwise treat the whole string as a bare table name.
|
|
240
|
+
const [schema, tbl] = SQLTranslator.splitSchema(name);
|
|
241
|
+
const master = schema && isIdentifier(schema) ? `${schema}.sqlite_master` : "sqlite_master";
|
|
242
|
+
const result = this.db
|
|
243
|
+
.prepare(`SELECT name FROM ${master} WHERE type='table' AND name=?`)
|
|
244
|
+
.get(tbl);
|
|
225
245
|
return !!result;
|
|
226
246
|
}
|
|
227
247
|
|