tina4-nodejs 3.13.11 → 3.13.14

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.11)
1
+ # CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.13.14)
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.11 — 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.14 — 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
 
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
 
5
5
 
6
- "version": "3.13.11",
6
+ "version": "3.13.14",
7
7
 
8
8
  "type": "module",
9
9
  "description": "Tina4 for Node.js/TypeScript \u2014 54 built-in features, zero dependencies",
@@ -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();
@@ -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
 
@@ -2,6 +2,26 @@ import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import type { DatabaseAdapter, DatabaseResult as DatabaseWriteResult } from "./types.js";
3
3
  import { DatabaseResult } from "./databaseResult.js";
4
4
 
5
+ /**
6
+ * v3.13.12 — strip trailing `;` and whitespace from user-supplied SQL
7
+ * before the framework wraps it with COUNT(*) subqueries or appends
8
+ * LIMIT/OFFSET clauses. Without this, `"SELECT * FROM t;"` becomes
9
+ * `"SELECT * FROM t; LIMIT 100 OFFSET 0"` — a syntax error on every
10
+ * engine. Internal semicolons (in string literals, between meaningful
11
+ * statements) are left alone; drivers reject those if the engine
12
+ * doesn't support multi-statement.
13
+ *
14
+ * Exported so adapters and external tooling can compose it.
15
+ */
16
+ export function stripTrailingSemicolons(sql: string): string {
17
+ if (!sql) return sql;
18
+ let stripped = sql.replace(/\s+$/, "");
19
+ while (stripped.endsWith(";")) {
20
+ stripped = stripped.slice(0, -1).replace(/\s+$/, "");
21
+ }
22
+ return stripped;
23
+ }
24
+
5
25
  let activeAdapter: DatabaseAdapter | null = null;
6
26
  const namedAdapters: Map<string, DatabaseAdapter> = new Map();
7
27
 
@@ -404,6 +424,10 @@ export class Database {
404
424
 
405
425
  /** Query rows with optional pagination. Returns a DatabaseResult wrapper. */
406
426
  fetch(sql: string, params?: unknown[], limit?: number, offset?: number): DatabaseResult {
427
+ // v3.13.12: strip trailing `;` before the adapter wraps with COUNT(*)
428
+ // or appends LIMIT/OFFSET. Without this, `"SELECT * FROM t;"` becomes
429
+ // `"SELECT * FROM t; LIMIT 100 OFFSET 0"` — a syntax error.
430
+ sql = stripTrailingSemicolons(sql);
407
431
  const adapter = this.getNextAdapter();
408
432
  const rows = adapter.fetch<Record<string, unknown>>(sql, params, limit, offset);
409
433
  return new DatabaseResult(rows, undefined, undefined, limit, offset, adapter, sql);
@@ -411,6 +435,7 @@ export class Database {
411
435
 
412
436
  /** Fetch a single row or null. */
413
437
  fetchOne<T = Record<string, unknown>>(sql: string, params?: unknown[]): T | null {
438
+ sql = stripTrailingSemicolons(sql);
414
439
  return this.getNextAdapter().fetchOne<T>(sql, params);
415
440
  }
416
441
 
@@ -14,7 +14,7 @@ export { FetchResult } from "./types.js";
14
14
 
15
15
  export { DatabaseResult } from "./databaseResult.js";
16
16
  export type { ColumnInfoResult } from "./databaseResult.js";
17
- export { Database, initDatabase, getAdapter, setAdapter, closeDatabase, parseDatabaseUrl, setNamedAdapter, getNamedAdapter, resolveDbPool } from "./database.js";
17
+ export { Database, initDatabase, getAdapter, setAdapter, closeDatabase, parseDatabaseUrl, setNamedAdapter, getNamedAdapter, resolveDbPool, stripTrailingSemicolons } from "./database.js";
18
18
  export type { DatabaseConfig, ParsedDatabaseUrl } from "./database.js";
19
19
  export { discoverModels } from "./model.js";
20
20
  export type { DiscoveredModel } from "./model.js";
@@ -156,6 +156,22 @@ export class SQLTranslator {
156
156
  columns,
157
157
  };
158
158
  }
159
+
160
+ /**
161
+ * v3.13.14 (#48): split a possibly-qualified table name into [schema, table].
162
+ *
163
+ * A model whose table name is qualified — PostgreSQL "gift_cards.gift_card",
164
+ * MSSQL "dbo.widget", MySQL "otherdb.table", SQLite "attached.table" — lives
165
+ * in that schema/catalog, not the default. Adapters use this so tableExists /
166
+ * getColumns query the right namespace instead of matching the whole dotted
167
+ * string as one flat name. Returns [null, name] for a bare name. Splits on the
168
+ * first dot. Firebird has no schemas, so its adapter ignores this.
169
+ */
170
+ static splitSchema(name: string): [string | null, string] {
171
+ const idx = name.indexOf(".");
172
+ if (idx === -1) return [null, name];
173
+ return [name.slice(0, idx), name.slice(idx + 1)];
174
+ }
159
175
  }
160
176
 
161
177
  // ── Query Cache ──────────────────────────────────────────────