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 CHANGED
@@ -1,10 +1,10 @@
1
- # CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.13.12)
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.12 — The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
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
- twig/ # Optional Twig template engine
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:** Twig via `twig` npm package (optional)
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 detection and context scaffolding (`detectAi`, `installAiContext`, `aiStatusReport`)
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`, `assertThrows`, `runAllTests`)
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|null` (fluent, null 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)`
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(generator, contentType?: string, status?: number): void // SSE/streaming
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/twig (`packages/twig/`)
262
- Optional server-side template rendering.
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` — Wraps the `twig` npm package, `renderTemplate(path, data)`
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
- Detects AI coding tools (Claude Code, Cursor, Copilot, Windsurf, Aider, Cline, Codex) by checking for their config files/directories. Can scaffold a universal Tina4 context document into each tool's expected location.
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 { detectAi, installAiContext, aiStatusReport } from "@tina4/core";
310
+ import { AI_TOOLS, isInstalled, showMenu, installSelected, installAll, generateContext } from "@tina4/core";
312
311
 
313
- // Detect which AI tools are present in a project directory
314
- const tools = detectAi(".");
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
- // Install context files for all detected tools (creates CLAUDE.md, .cursorules, etc.)
319
- const created = installAiContext(".", { force: false });
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
- // Install for ALL known tools, not just detected ones
323
- import { installAllAiContext } from "@tina4/core";
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
- // Print a human-readable status report
327
- console.log(aiStatusReport("."));
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 `runAllTests()`. No external test runner needed.
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, assertThrows, assertTrue, assertFalse, runAllTests, resetTests } from "@tina4/core";
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
- assertThrows(Error, [null]), // add(null) throws Error
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 = runAllTests({ quiet: false, failfast: false });
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
- resetTests();
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, ormBind } from "@tina4/orm";
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, null on failure
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
- ormBind(db); // bind a Database instance for all models in the registry
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
- - **SafeString** — filters can return `new SafeString(value)` to bypass auto-escaping.
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
- - **1,812 tests** passing across all modules
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
@@ -3,7 +3,7 @@
3
3
 
4
4
 
5
5
 
6
- "version": "3.13.12",
6
+ "version": "3.13.16",
7
7
 
8
8
  "type": "module",
9
9
  "description": "Tina4 for Node.js/TypeScript \u2014 54 built-in features, zero dependencies",
@@ -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.execute(stmt);
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
- const levelEnv = (process.env.TINA4_LOG_LEVEL ?? "DEBUG").toUpperCase();
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 based on TINA4_LOG_FORMAT
389
- const fileLine = cfg.format === "json" ? JSON.stringify(entry) : humanLine;
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. TINA4_LOG_OUTPUT="file" disables stdout entirely;
394
- // anything else (stdout, both) prints to console in dev, suppresses in prod.
395
- if (shouldLog && cfg.output !== "file" && !Log.isProduction()) {
396
- const color = COLORS[level];
397
- console.log(`${color}${humanLine}${RESET}`);
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 (function form — kept for backwards compat)
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
- const color = status >= 400 ? "\x1b[31m" : status >= 300 ? "\x1b[33m" : "\x1b[32m";
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 WHERE TABLE_NAME = ?",
354
- [table],
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 WHERE TABLE_NAME = ?",
383
- [name],
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 \`${table}\``);
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
- const rows = await this.queryAsync<Record<string, string>>(
298
- `SHOW TABLES LIKE ?`,
299
- [name],
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
- const rows = await this.queryAsync<{ tablename: string }>(
253
- "SELECT tablename FROM pg_tables WHERE schemaname = 'public'",
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
- [table],
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
- const row = await this.fetchOneAsync<{ exists: boolean }>(
298
- "SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1) AS exists",
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?.exists ?? false;
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
- const rows = this.db.prepare(`PRAGMA table_info("${table}")`).all() as Array<{
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
- const result = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
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