tina4-nodejs 3.13.38 → 3.13.40
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 +54 -5
- package/README.md +6 -6
- package/package.json +1 -1
- package/packages/core/src/api.ts +64 -1
- package/packages/core/src/auth.ts +4 -1
- package/packages/core/src/devAdmin.ts +91 -21
- package/packages/core/src/index.ts +9 -4
- package/packages/core/src/logger.ts +84 -27
- package/packages/core/src/mcp.ts +105 -12
- package/packages/core/src/metrics.ts +330 -70
- package/packages/core/src/middleware.ts +1 -1
- package/packages/core/src/queueBackends/kafkaBackend.ts +97 -0
- package/packages/core/src/router.ts +54 -6
- package/packages/core/src/server.ts +120 -22
- package/packages/core/src/sessionHandlers/mongoHandler.ts +2 -0
- package/packages/core/src/types.ts +21 -2
- package/packages/core/src/websocket.ts +419 -9
- package/packages/core/src/websocketConnection.ts +6 -0
- package/packages/orm/src/baseModel.ts +167 -22
- package/packages/orm/src/docstore.ts +819 -0
- package/packages/orm/src/index.ts +14 -0
- package/packages/orm/src/migration.ts +149 -22
- package/packages/orm/src/queryBuilder.ts +14 -2
- package/packages/orm/src/types.ts +7 -0
- package/packages/orm/src/validation.ts +14 -0
- package/packages/swagger/src/generator.ts +119 -16
- package/packages/swagger/src/ui.ts +10 -2
|
@@ -39,6 +39,10 @@ export {
|
|
|
39
39
|
createMigration,
|
|
40
40
|
status,
|
|
41
41
|
Migration,
|
|
42
|
+
splitStatements,
|
|
43
|
+
normalizeQuotes,
|
|
44
|
+
sortMigrationFiles,
|
|
45
|
+
shouldSkipCreateTable,
|
|
42
46
|
} from "./migration.js";
|
|
43
47
|
export type { MigrationResult, MigrationStatus } from "./migration.js";
|
|
44
48
|
export { AutoCrud, generateCrudRoutes } from "./autoCrud.js";
|
|
@@ -54,6 +58,16 @@ export { FakeData } from "./fakeData.js";
|
|
|
54
58
|
export { seedTable, seedOrm, seedModels } from "./seeder.js";
|
|
55
59
|
export type { SeedSummary, SeedOptions } from "./seeder.js";
|
|
56
60
|
|
|
61
|
+
// DocStore — pymongo-style document store with a zero-config SQLite (JSON1) fallback
|
|
62
|
+
export {
|
|
63
|
+
ObjectId, InvalidId, SqliteDatabase, SqliteCollection, Cursor,
|
|
64
|
+
getCollection, isServerless, resetDefaultStore,
|
|
65
|
+
encodeValue, decodeValue, compileFilter,
|
|
66
|
+
} from "./docstore.js";
|
|
67
|
+
export type {
|
|
68
|
+
InsertOneResult, InsertManyResult, UpdateResult, DeleteResult,
|
|
69
|
+
} from "./docstore.js";
|
|
70
|
+
|
|
57
71
|
// Database adapters
|
|
58
72
|
export { SQLiteAdapter } from "./adapters/sqlite.js";
|
|
59
73
|
export { PostgresAdapter } from "./adapters/postgres.js";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
|
+
import { Log } from "@tina4/core";
|
|
3
4
|
import type { FieldDefinition, DatabaseAdapter } from "./types.js";
|
|
4
5
|
import type { SQLiteAdapter } from "./adapters/sqlite.js";
|
|
5
6
|
import type { DiscoveredModel } from "./model.js";
|
|
@@ -76,6 +77,55 @@ async function shouldSkipForFirebird(
|
|
|
76
77
|
return null;
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Match CREATE TABLE <name> — name may be quoted ("x"), bracketed ([x] MSSQL),
|
|
82
|
+
* or bare. Captures the table name.
|
|
83
|
+
*/
|
|
84
|
+
const CREATE_TABLE_RE =
|
|
85
|
+
/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:"([^"]+)"|\[([^\]]+)\]|(\w+))/i;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Identify the database engine via the adapter's class name.
|
|
89
|
+
* (constructor.name is the engine discriminator used throughout this package.)
|
|
90
|
+
*/
|
|
91
|
+
function engineOf(db: DatabaseAdapter): "firebird" | "mssql" | "other" {
|
|
92
|
+
const name = db.constructor.name;
|
|
93
|
+
if (name === "FirebirdAdapter") return "firebird";
|
|
94
|
+
if (name === "MssqlAdapter") return "mssql";
|
|
95
|
+
return "other";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Make CREATE TABLE idempotent on engines lacking IF NOT EXISTS.
|
|
100
|
+
*
|
|
101
|
+
* Firebird and MSSQL do not support `CREATE TABLE IF NOT EXISTS`, so a raw
|
|
102
|
+
* CREATE in a re-run migration raises "object already exists". When the target
|
|
103
|
+
* table already exists on those engines, return a skip reason so the statement
|
|
104
|
+
* is skipped (mirrors the Firebird ALTER-TABLE-ADD idempotency guard).
|
|
105
|
+
* SQLite/MySQL/PostgreSQL support IF NOT EXISTS and are left to the engine.
|
|
106
|
+
* Only a genuine already-exists is skipped — every other error still raises.
|
|
107
|
+
*/
|
|
108
|
+
export async function shouldSkipCreateTable(
|
|
109
|
+
db: DatabaseAdapter,
|
|
110
|
+
stmt: string,
|
|
111
|
+
): Promise<string | null> {
|
|
112
|
+
const engine = engineOf(db);
|
|
113
|
+
if (engine !== "firebird" && engine !== "mssql") return null;
|
|
114
|
+
|
|
115
|
+
const m = stmt.match(CREATE_TABLE_RE);
|
|
116
|
+
if (!m) return null;
|
|
117
|
+
|
|
118
|
+
const table = m[1] ?? m[2] ?? m[3];
|
|
119
|
+
try {
|
|
120
|
+
if (await adapterTableExists(db, table)) {
|
|
121
|
+
return `Table ${table} already exists, skipping CREATE TABLE`;
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
79
129
|
/**
|
|
80
130
|
* Sync model definitions to the database (create tables, add columns).
|
|
81
131
|
*/
|
|
@@ -411,13 +461,44 @@ export interface MigrationStatus {
|
|
|
411
461
|
pending: string[];
|
|
412
462
|
}
|
|
413
463
|
|
|
464
|
+
/**
|
|
465
|
+
* Smart/curly quotes — editors, word processors, docs and chat apps silently
|
|
466
|
+
* convert a straight " to “ ” and a straight ' to ‘ ’ (plus primes ′ ″). Those
|
|
467
|
+
* characters are NOT valid SQL string/identifier delimiters, so a pasted-in
|
|
468
|
+
* migration fails to run ("syntax error near …"). Map them back to straight
|
|
469
|
+
* ASCII quotes. (Real string CONTENTS are unaffected by intent — we only swap
|
|
470
|
+
* the lookalike code points for their ASCII equivalents.)
|
|
471
|
+
*/
|
|
472
|
+
const SMART_QUOTES: Record<string, string> = {
|
|
473
|
+
// Double-quote lookalikes → straight " (U+0022)
|
|
474
|
+
"“": '"', "”": '"', "„": '"', "‟": '"', "″": '"',
|
|
475
|
+
// Single-quote / apostrophe lookalikes → straight ' (U+0027)
|
|
476
|
+
"‘": "'", "’": "'", "‚": "'", "‛": "'", "′": "'",
|
|
477
|
+
};
|
|
478
|
+
const SMART_QUOTE_RE = new RegExp(`[${Object.keys(SMART_QUOTES).join("")}]`, "g");
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Replace smart/curly quotes with straight ASCII quotes so migration SQL
|
|
482
|
+
* authored or pasted from an editor/doc actually runs (those code points are
|
|
483
|
+
* not valid SQL delimiters). Already-straight quotes and ordinary string
|
|
484
|
+
* content are returned byte-for-byte unchanged.
|
|
485
|
+
*/
|
|
486
|
+
export function normalizeQuotes(sql: string): string {
|
|
487
|
+
return sql.replace(SMART_QUOTE_RE, (ch) => SMART_QUOTES[ch]);
|
|
488
|
+
}
|
|
489
|
+
|
|
414
490
|
/**
|
|
415
491
|
* Split SQL text into individual statements on the given delimiter.
|
|
416
492
|
*
|
|
417
493
|
* Strips line comments (`-- ...`) and block comments, handles stored
|
|
418
494
|
* procedure blocks delimited by `$$` or `//`.
|
|
419
495
|
*/
|
|
420
|
-
function splitStatements(sql: string, delimiter = ";"): string[] {
|
|
496
|
+
export function splitStatements(sql: string, delimiter = ";"): string[] {
|
|
497
|
+
// Normalize smart/curly quotes to straight ASCII first, so SQL pasted from
|
|
498
|
+
// an editor/doc (which converts " → “ ” and ' → ‘ ’) actually runs. Mirrors
|
|
499
|
+
// Python's _split_statements applying _normalize_quotes as its first line.
|
|
500
|
+
sql = normalizeQuotes(sql);
|
|
501
|
+
|
|
421
502
|
// Extract blocks delimited by $$ or // first, replacing with placeholders
|
|
422
503
|
const blocks: string[] = [];
|
|
423
504
|
const saveBlock = (_match: string, _p1: string): string => {
|
|
@@ -426,7 +507,12 @@ function splitStatements(sql: string, delimiter = ";"): string[] {
|
|
|
426
507
|
};
|
|
427
508
|
|
|
428
509
|
let processed = sql.replace(/\$\$([\s\S]*?)\$\$/g, saveBlock);
|
|
429
|
-
|
|
510
|
+
// The `//` delimiters must NOT be preceded by a colon, so a URL scheme
|
|
511
|
+
// (`https://…`) or other `://` literal inside a migration is never captured
|
|
512
|
+
// as an opaque stored-proc block (it would otherwise swallow everything
|
|
513
|
+
// between two `//` occurrences and skip statement splitting/cleaning).
|
|
514
|
+
// Negative lookbehind `(?<!:)` mirrors Python's runner.
|
|
515
|
+
processed = processed.replace(/(?<!:)\/\/([\s\S]*?)(?<!:)\/\//g, saveBlock);
|
|
430
516
|
|
|
431
517
|
// Remove block comments (/* ... */)
|
|
432
518
|
const clean = processed.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
@@ -458,25 +544,43 @@ function splitStatements(sql: string, delimiter = ";"): string[] {
|
|
|
458
544
|
* - Sequential: 000001_name.sql, 000002_name.sql
|
|
459
545
|
* - Timestamp: 20240315120000_name.sql (YYYYMMDDHHMMSS)
|
|
460
546
|
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
547
|
+
* Numeric-aware: a file with a leading numeric/timestamp prefix sorts first by
|
|
548
|
+
* that number (so `9_*` applies before `10_*` — a plain lexical sort misorders
|
|
549
|
+
* unpadded prefixes because "10" < "9"). Files with NO numeric prefix sort
|
|
550
|
+
* AFTER the numbered ones, then lexically. Mirrors Python's `_migration_sort_key`.
|
|
463
551
|
*/
|
|
464
|
-
function sortMigrationFiles(files: string[]): string[] {
|
|
552
|
+
export function sortMigrationFiles(files: string[]): string[] {
|
|
553
|
+
// Returns a tuple-like key: [group, numeric, name].
|
|
554
|
+
// group 0 = has numeric prefix (sorts first); group 1 = no prefix (sorts after).
|
|
555
|
+
const key = (name: string): [number, bigint, string] => {
|
|
556
|
+
const m = name.match(/^(\d+)/);
|
|
557
|
+
return m ? [0, BigInt(m[1]), name] : [1, 0n, name];
|
|
558
|
+
};
|
|
465
559
|
return [...files].sort((a, b) => {
|
|
466
|
-
const
|
|
467
|
-
const
|
|
468
|
-
if (
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
if (aNum < bNum) return -1;
|
|
473
|
-
if (aNum > bNum) return 1;
|
|
474
|
-
return a.localeCompare(b);
|
|
475
|
-
}
|
|
476
|
-
return a.localeCompare(b);
|
|
560
|
+
const [ag, an, anm] = key(a);
|
|
561
|
+
const [bg, bn, bnm] = key(b);
|
|
562
|
+
if (ag !== bg) return ag - bg;
|
|
563
|
+
if (an < bn) return -1;
|
|
564
|
+
if (an > bn) return 1;
|
|
565
|
+
return anm.localeCompare(bnm);
|
|
477
566
|
});
|
|
478
567
|
}
|
|
479
568
|
|
|
569
|
+
/**
|
|
570
|
+
* Warn (once) about migration filenames without a recognized NNNNNN_/timestamp
|
|
571
|
+
* prefix — their ordering relative to numbered migrations is undefined, a silent
|
|
572
|
+
* out-of-order-apply footgun. Mirrors Python's runner.
|
|
573
|
+
*/
|
|
574
|
+
function warnUnprefixedMigrations(files: string[]): void {
|
|
575
|
+
const unprefixed = files.filter((f) => !/^\d+[_-]/.test(f));
|
|
576
|
+
if (unprefixed.length > 0) {
|
|
577
|
+
Log.warning(
|
|
578
|
+
"Migration file(s) without a numeric/timestamp prefix may apply out of order: " +
|
|
579
|
+
unprefixed.join(", "),
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
480
584
|
/**
|
|
481
585
|
* Run all pending SQL-file migrations.
|
|
482
586
|
*
|
|
@@ -533,7 +637,19 @@ export async function migrate(
|
|
|
533
637
|
batch INTEGER NOT NULL DEFAULT 1,
|
|
534
638
|
applied_at TEXT NOT NULL
|
|
535
639
|
)`);
|
|
640
|
+
} else if (engineOf(db) === "mssql") {
|
|
641
|
+
// MSSQL has no AUTOINCREMENT / IF NOT EXISTS — route the bootstrap
|
|
642
|
+
// through the adapter's engine-aware createTable (IDENTITY(1,1) etc.),
|
|
643
|
+
// exactly like ensureMigrationTable does. Emitting raw
|
|
644
|
+
// AUTOINCREMENT/IF NOT EXISTS here is invalid on SQL Server.
|
|
645
|
+
await adapterCreateTable(db, MIGRATION_TABLE, {
|
|
646
|
+
id: { type: "integer", primaryKey: true, autoIncrement: true },
|
|
647
|
+
name: { type: "string", required: true },
|
|
648
|
+
batch: { type: "integer", required: true },
|
|
649
|
+
applied_at: { type: "datetime", default: "now" },
|
|
650
|
+
});
|
|
536
651
|
} else {
|
|
652
|
+
// SQLite / MySQL — both support IF NOT EXISTS + AUTOINCREMENT/AUTO_INCREMENT.
|
|
537
653
|
await adapterExecute(db, `CREATE TABLE IF NOT EXISTS "${MIGRATION_TABLE}" (
|
|
538
654
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
539
655
|
name TEXT NOT NULL,
|
|
@@ -553,13 +669,17 @@ export async function migrate(
|
|
|
553
669
|
}
|
|
554
670
|
}
|
|
555
671
|
|
|
556
|
-
// Collect .sql files (exclude .down.sql), sorted
|
|
672
|
+
// Collect .sql files (exclude .down.sql), numeric-aware sorted (9_ before 10_).
|
|
557
673
|
const files = sortMigrationFiles(
|
|
558
674
|
readdirSync(dir).filter((f) => f.endsWith(".sql") && !f.endsWith(".down.sql")),
|
|
559
675
|
);
|
|
560
676
|
|
|
561
677
|
if (files.length === 0) return result;
|
|
562
678
|
|
|
679
|
+
// Warn about files without a recognized numeric/timestamp prefix — their
|
|
680
|
+
// ordering relative to numbered migrations is undefined.
|
|
681
|
+
warnUnprefixedMigrations(files);
|
|
682
|
+
|
|
563
683
|
// Determine the batch number for this run
|
|
564
684
|
let currentBatch = 1;
|
|
565
685
|
try {
|
|
@@ -621,10 +741,12 @@ export async function migrate(
|
|
|
621
741
|
await adapterStartTransaction(db);
|
|
622
742
|
|
|
623
743
|
for (const stmt of statements) {
|
|
624
|
-
//
|
|
625
|
-
// Pre-check the
|
|
626
|
-
//
|
|
627
|
-
|
|
744
|
+
// Idempotency on engines lacking IF NOT EXISTS: Firebird ALTER-TABLE-ADD,
|
|
745
|
+
// and CREATE TABLE on Firebird/MSSQL. Pre-check the catalogue so a genuine
|
|
746
|
+
// already-exists is skipped instead of raising — every other error still
|
|
747
|
+
// raises (rolls the file back).
|
|
748
|
+
const skipReason =
|
|
749
|
+
(await shouldSkipForFirebird(db, stmt)) ?? (await shouldSkipCreateTable(db, stmt));
|
|
628
750
|
if (skipReason) {
|
|
629
751
|
console.log(` Migration ${file}: ${skipReason}`);
|
|
630
752
|
continue;
|
|
@@ -671,7 +793,12 @@ export async function migrate(
|
|
|
671
793
|
const msg = err instanceof Error ? err.message : String(err);
|
|
672
794
|
console.error(` Migration failed: ${file} — ${msg}`);
|
|
673
795
|
result.failed.push(file);
|
|
674
|
-
//
|
|
796
|
+
// STOP at the first failure (parity with Python/PHP/Ruby). The file rolled
|
|
797
|
+
// back; later migrations must NOT be applied on top of a missing earlier
|
|
798
|
+
// one (a missing column / table would cascade silent corruption). Already-
|
|
799
|
+
// applied files stay applied — fix the bad file and re-run.
|
|
800
|
+
Log.error(`Migration stopped at first failure: ${file} — ${msg}`);
|
|
801
|
+
break;
|
|
675
802
|
}
|
|
676
803
|
}
|
|
677
804
|
|
|
@@ -428,8 +428,20 @@ export class QueryBuilder {
|
|
|
428
428
|
return [{ [field]: { [mongoOp]: val } }, paramIndex + 1];
|
|
429
429
|
}
|
|
430
430
|
|
|
431
|
-
//
|
|
432
|
-
|
|
431
|
+
// Canonical #5: no silent $where fallback. Previously an unparseable
|
|
432
|
+
// condition was wrapped as `{ $where: <raw condition string> }` — a raw-JS
|
|
433
|
+
// sink that is both injection-shaped (the WHERE string runs as JavaScript
|
|
434
|
+
// on the MongoDB server) and silently different semantics from the SQL the
|
|
435
|
+
// caller wrote. Fail loud instead: name the clause so the caller fixes it
|
|
436
|
+
// rather than shipping a surprise $where.
|
|
437
|
+
throw new Error(
|
|
438
|
+
`QueryBuilder.toMongo(): cannot translate WHERE clause to a MongoDB ` +
|
|
439
|
+
`filter: "${cond}". Supported forms: "<field> <op> ?" ` +
|
|
440
|
+
`(=, !=, <>, >, >=, <, <=), "<field> LIKE ?", ` +
|
|
441
|
+
`"<field> [NOT] IN (?)", "<field> IS [NOT] NULL". Rewrite the ` +
|
|
442
|
+
`condition in one of those forms (toMongo() will not silently emit a ` +
|
|
443
|
+
`raw $where JavaScript expression).`,
|
|
444
|
+
);
|
|
433
445
|
}
|
|
434
446
|
|
|
435
447
|
/**
|
|
@@ -20,6 +20,13 @@ export interface FieldDefinition {
|
|
|
20
20
|
export interface RelationshipDefinition {
|
|
21
21
|
model: string;
|
|
22
22
|
foreignKey: string;
|
|
23
|
+
/**
|
|
24
|
+
* The relationship accessor/include name on the OWNING model. For an
|
|
25
|
+
* FK-auto-wired has-many this is the declaring class name lowercased + "s"
|
|
26
|
+
* (Python master rule) or the `relatedName` override. Used by eager-load
|
|
27
|
+
* include resolution so an `include: ["posts"]` matches the wired relation.
|
|
28
|
+
*/
|
|
29
|
+
relatedName?: string;
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
export interface ModelDefinition {
|
|
@@ -86,6 +86,20 @@ export function validate(
|
|
|
86
86
|
errors.push({ field: name, message: "must be a valid date/time" });
|
|
87
87
|
}
|
|
88
88
|
break;
|
|
89
|
+
|
|
90
|
+
case "foreignKey": {
|
|
91
|
+
// Outlier D: previously there was no foreignKey case, so ANY value
|
|
92
|
+
// passed validation silently. A foreign key references another model's
|
|
93
|
+
// primary key — by default an auto-increment integer — so validate it
|
|
94
|
+
// as an integer (a numeric string like "12" is coerced and accepted).
|
|
95
|
+
// This catches the common bug of assigning a whole object / array /
|
|
96
|
+
// non-numeric string to an *_id column before it reaches the driver.
|
|
97
|
+
const fkNum = typeof value === "string" ? Number(value) : value;
|
|
98
|
+
if (typeof fkNum !== "number" || isNaN(fkNum) || !Number.isInteger(fkNum)) {
|
|
99
|
+
errors.push({ field: name, message: "must be a valid foreign key (integer)" });
|
|
100
|
+
}
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
89
103
|
}
|
|
90
104
|
}
|
|
91
105
|
|
|
@@ -12,10 +12,14 @@ interface OpenAPISpecInfo {
|
|
|
12
12
|
interface OpenAPISpec {
|
|
13
13
|
openapi: string;
|
|
14
14
|
info: OpenAPISpecInfo;
|
|
15
|
+
servers?: { url: string }[];
|
|
15
16
|
paths: Record<string, Record<string, unknown>>;
|
|
16
|
-
components?: { schemas?: Record<string, unknown> };
|
|
17
|
+
components?: { schemas?: Record<string, unknown>; securitySchemes?: Record<string, unknown> };
|
|
18
|
+
tags?: { name: string }[];
|
|
17
19
|
}
|
|
18
20
|
|
|
21
|
+
const WRITE_METHODS = new Set(["post", "put", "patch", "delete"]);
|
|
22
|
+
|
|
19
23
|
export function generate(
|
|
20
24
|
routes: RouteDefinition[],
|
|
21
25
|
models: ModelDefinition[] = []
|
|
@@ -26,12 +30,16 @@ export function generate(
|
|
|
26
30
|
description: process.env.TINA4_SWAGGER_DESCRIPTION ?? "Auto-generated API documentation",
|
|
27
31
|
};
|
|
28
32
|
|
|
29
|
-
// Optional contact email
|
|
30
|
-
//
|
|
33
|
+
// Optional contact — email, plus name/url (the interface declares them; they
|
|
34
|
+
// were never populated before). Matches the python SWAGGER_CONTACT_* convention.
|
|
31
35
|
const contactEmail = (process.env.TINA4_SWAGGER_CONTACT_EMAIL ?? "").trim();
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
36
|
+
const contactName = (process.env.TINA4_SWAGGER_CONTACT_TEAM ?? "").trim();
|
|
37
|
+
const contactUrl = (process.env.TINA4_SWAGGER_CONTACT_URL ?? "").trim();
|
|
38
|
+
const contact: { name?: string; url?: string; email?: string } = {};
|
|
39
|
+
if (contactName.length > 0) contact.name = contactName;
|
|
40
|
+
if (contactUrl.length > 0) contact.url = contactUrl;
|
|
41
|
+
if (contactEmail.length > 0) contact.email = contactEmail;
|
|
42
|
+
if (Object.keys(contact).length > 0) info.contact = contact;
|
|
35
43
|
|
|
36
44
|
// Optional license — accepts a plain SPDX identifier ("MIT", "Apache-2.0")
|
|
37
45
|
// or a "Name|URL" pair. Empty string disables license output entirely.
|
|
@@ -44,8 +52,16 @@ export function generate(
|
|
|
44
52
|
const spec: OpenAPISpec = {
|
|
45
53
|
openapi: "3.0.3",
|
|
46
54
|
info,
|
|
55
|
+
servers: resolveServers(),
|
|
47
56
|
paths: {},
|
|
48
|
-
components: {
|
|
57
|
+
components: {
|
|
58
|
+
schemas: {},
|
|
59
|
+
// bearerAuth was never defined before — secured routes were documented
|
|
60
|
+
// identically to public ones (audit P1). Define it once and reference it.
|
|
61
|
+
securitySchemes: {
|
|
62
|
+
bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "JWT" },
|
|
63
|
+
},
|
|
64
|
+
},
|
|
49
65
|
};
|
|
50
66
|
|
|
51
67
|
// Generate schemas from models
|
|
@@ -54,6 +70,9 @@ export function generate(
|
|
|
54
70
|
spec.components!.schemas![model.tableName] = schema;
|
|
55
71
|
}
|
|
56
72
|
|
|
73
|
+
const usedTags: string[] = [];
|
|
74
|
+
const seenIds = new Set<string>();
|
|
75
|
+
|
|
57
76
|
// Generate paths from routes
|
|
58
77
|
for (const route of routes) {
|
|
59
78
|
const openApiPath = patternToOpenAPI(route.pattern);
|
|
@@ -63,14 +82,23 @@ export function generate(
|
|
|
63
82
|
spec.paths[openApiPath] = {};
|
|
64
83
|
}
|
|
65
84
|
|
|
85
|
+
const tags = route.meta?.tags ?? inferTags(route.pattern);
|
|
86
|
+
for (const t of tags) {
|
|
87
|
+
if (!usedTags.includes(t)) usedTags.push(t);
|
|
88
|
+
}
|
|
89
|
+
|
|
66
90
|
const operation: Record<string, unknown> = {
|
|
91
|
+
operationId: uniqueOperationId(method, openApiPath, seenIds),
|
|
67
92
|
summary: route.meta?.summary ?? `${route.method} ${route.pattern}`,
|
|
68
|
-
tags
|
|
93
|
+
tags,
|
|
69
94
|
responses: route.meta?.responses ?? {
|
|
70
95
|
"200": { description: "Successful response" },
|
|
71
96
|
},
|
|
72
97
|
};
|
|
73
98
|
|
|
99
|
+
if (route.meta?.description) operation.description = route.meta.description;
|
|
100
|
+
if (route.meta?.deprecated) operation.deprecated = true;
|
|
101
|
+
|
|
74
102
|
// Add path parameters
|
|
75
103
|
const pathParams = extractPathParams(route.pattern);
|
|
76
104
|
if (pathParams.length > 0) {
|
|
@@ -99,13 +127,13 @@ export function generate(
|
|
|
99
127
|
if (method === "post" || method === "put") {
|
|
100
128
|
const modelName = inferModelFromPath(route.pattern);
|
|
101
129
|
if (modelName && models.some((m) => m.tableName === modelName)) {
|
|
130
|
+
const media: Record<string, unknown> = {
|
|
131
|
+
schema: { $ref: `#/components/schemas/${modelName}` },
|
|
132
|
+
};
|
|
133
|
+
if (route.meta?.example !== undefined) media.example = route.meta.example;
|
|
102
134
|
operation.requestBody = {
|
|
103
135
|
required: true,
|
|
104
|
-
content: {
|
|
105
|
-
"application/json": {
|
|
106
|
-
schema: { $ref: `#/components/schemas/${modelName}` },
|
|
107
|
-
},
|
|
108
|
-
},
|
|
136
|
+
content: { "application/json": media },
|
|
109
137
|
};
|
|
110
138
|
|
|
111
139
|
// Add response schema
|
|
@@ -115,15 +143,47 @@ export function generate(
|
|
|
115
143
|
: { "200": { description: "Updated", content: { "application/json": { schema: { $ref: `#/components/schemas/${modelName}` } } } } }),
|
|
116
144
|
"422": { description: "Validation failed" },
|
|
117
145
|
};
|
|
146
|
+
} else if (route.meta?.example !== undefined) {
|
|
147
|
+
// Non-model body with an explicit example.
|
|
148
|
+
operation.requestBody = {
|
|
149
|
+
content: { "application/json": { schema: inferSchema(route.meta.example), example: route.meta.example } },
|
|
150
|
+
};
|
|
118
151
|
}
|
|
119
152
|
}
|
|
120
153
|
|
|
154
|
+
// Security — a secured route emits operation.security + 401. Mirrors the
|
|
155
|
+
// router's enforcement (writes secure by default unless noAuth; GET secure
|
|
156
|
+
// only when marked). Before, no route ever got a security requirement.
|
|
157
|
+
if (routeRequiresAuth(route, method)) {
|
|
158
|
+
operation.security = [{ bearerAuth: [] }];
|
|
159
|
+
const responses = operation.responses as Record<string, unknown>;
|
|
160
|
+
if (!responses["401"]) responses["401"] = { description: "Unauthorized" };
|
|
161
|
+
}
|
|
162
|
+
|
|
121
163
|
spec.paths[openApiPath][method] = operation;
|
|
122
164
|
}
|
|
123
165
|
|
|
166
|
+
if (usedTags.length > 0) {
|
|
167
|
+
spec.tags = usedTags.map((name) => ({ name }));
|
|
168
|
+
}
|
|
169
|
+
|
|
124
170
|
return spec;
|
|
125
171
|
}
|
|
126
172
|
|
|
173
|
+
function routeRequiresAuth(route: RouteDefinition, method: string): boolean {
|
|
174
|
+
if (route.noAuth) return false;
|
|
175
|
+
if (WRITE_METHODS.has(method)) return true; // secure by default (router parity)
|
|
176
|
+
return route.secure === true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function resolveServers(): { url: string }[] {
|
|
180
|
+
const raw = (process.env.TINA4_SWAGGER_SERVERS ?? "").trim();
|
|
181
|
+
const urls = raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
182
|
+
if (urls.length > 0) return urls.map((url) => ({ url }));
|
|
183
|
+
const dev = (process.env.SWAGGER_DEV_URL ?? "").trim();
|
|
184
|
+
return dev.length > 0 ? [{ url: dev }] : [{ url: "/" }];
|
|
185
|
+
}
|
|
186
|
+
|
|
127
187
|
function modelToSchema(model: ModelDefinition): Record<string, unknown> {
|
|
128
188
|
const properties: Record<string, unknown> = {};
|
|
129
189
|
const required: string[] = [];
|
|
@@ -171,8 +231,16 @@ function fieldToSchemaProperty(def: FieldDefinition): Record<string, unknown> {
|
|
|
171
231
|
prop.type = "string";
|
|
172
232
|
prop.format = "date-time";
|
|
173
233
|
break;
|
|
234
|
+
case "foreignKey":
|
|
235
|
+
// A foreign-key column is an integer reference. Before, it had no case
|
|
236
|
+
// and produced an empty {} schema (audit P2).
|
|
237
|
+
prop.type = "integer";
|
|
238
|
+
break;
|
|
239
|
+
default:
|
|
240
|
+
prop.type = "string";
|
|
174
241
|
}
|
|
175
242
|
|
|
243
|
+
if (def.default !== undefined) prop.default = def.default;
|
|
176
244
|
if (def.primaryKey && def.autoIncrement) {
|
|
177
245
|
prop.readOnly = true;
|
|
178
246
|
}
|
|
@@ -180,6 +248,22 @@ function fieldToSchemaProperty(def: FieldDefinition): Record<string, unknown> {
|
|
|
180
248
|
return prop;
|
|
181
249
|
}
|
|
182
250
|
|
|
251
|
+
function inferSchema(value: unknown): Record<string, unknown> {
|
|
252
|
+
if (Array.isArray(value)) {
|
|
253
|
+
return { type: "array", items: value.length > 0 ? inferSchema(value[0]) : {} };
|
|
254
|
+
}
|
|
255
|
+
if (value !== null && typeof value === "object") {
|
|
256
|
+
const properties: Record<string, unknown> = {};
|
|
257
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
258
|
+
properties[k] = inferSchema(v);
|
|
259
|
+
}
|
|
260
|
+
return { type: "object", properties };
|
|
261
|
+
}
|
|
262
|
+
if (typeof value === "boolean") return { type: "boolean" };
|
|
263
|
+
if (typeof value === "number") return { type: Number.isInteger(value) ? "integer" : "number" };
|
|
264
|
+
return { type: "string" };
|
|
265
|
+
}
|
|
266
|
+
|
|
183
267
|
function patternToOpenAPI(pattern: string): string {
|
|
184
268
|
return pattern.replace(/\[\.\.\.(\w+)\]/g, "{$1}").replace(/\[(\w+)\]/g, "{$1}");
|
|
185
269
|
}
|
|
@@ -207,8 +291,27 @@ function inferTags(pattern: string): string[] {
|
|
|
207
291
|
function inferModelFromPath(pattern: string): string | null {
|
|
208
292
|
const parts = pattern.split("/").filter(Boolean);
|
|
209
293
|
const apiIndex = parts.indexOf("api");
|
|
210
|
-
if (apiIndex
|
|
211
|
-
|
|
212
|
-
|
|
294
|
+
if (apiIndex === -1 || !parts[apiIndex + 1]) return null;
|
|
295
|
+
const candidate = parts[apiIndex + 1];
|
|
296
|
+
// Only a SIMPLE resource binds a model: /api/<model> or /api/<model>/[id].
|
|
297
|
+
// A deeper nested path (/api/users/[id]/comments) must NOT attach the parent
|
|
298
|
+
// resource's body/schema to the sub-resource endpoint (audit P2).
|
|
299
|
+
const rest = parts.slice(apiIndex + 2);
|
|
300
|
+
if (rest.length === 0) return candidate;
|
|
301
|
+
if (rest.length === 1 && /^[[{]\.{0,3}\w+[\]}]$/.test(rest[0])) return candidate;
|
|
213
302
|
return null;
|
|
214
303
|
}
|
|
304
|
+
|
|
305
|
+
function uniqueOperationId(method: string, openApiPath: string, seen: Set<string>): string {
|
|
306
|
+
const base = (method + openApiPath.replace(/[/{}]/g, "_"))
|
|
307
|
+
.replace(/_+/g, "_")
|
|
308
|
+
.replace(/_$/, "");
|
|
309
|
+
let oid = base;
|
|
310
|
+
let n = 2;
|
|
311
|
+
while (seen.has(oid)) {
|
|
312
|
+
oid = `${base}_${n}`;
|
|
313
|
+
n += 1;
|
|
314
|
+
}
|
|
315
|
+
seen.add(oid);
|
|
316
|
+
return oid;
|
|
317
|
+
}
|
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
import type { Tina4Request, Tina4Response, RouteDefinition } from "@tina4/core";
|
|
2
2
|
|
|
3
|
+
// The UI assets load from a CDN by default (a documented architecture decision —
|
|
4
|
+
// we don't vendor ~1.4MB of swagger-ui-dist, to stay small). Air-gapped
|
|
5
|
+
// deployments point TINA4_SWAGGER_UI_CDN at a self-hosted mirror (a base URL
|
|
6
|
+
// serving swagger-ui.css + swagger-ui-bundle.js).
|
|
7
|
+
function swaggerUiCdn(): string {
|
|
8
|
+
return (process.env.TINA4_SWAGGER_UI_CDN ?? "https://unpkg.com/swagger-ui-dist@5").replace(/\/+$/, "");
|
|
9
|
+
}
|
|
10
|
+
|
|
3
11
|
const SWAGGER_UI_HTML = (specUrl: string) => `<!DOCTYPE html>
|
|
4
12
|
<html lang="en">
|
|
5
13
|
<head>
|
|
6
14
|
<meta charset="UTF-8">
|
|
7
15
|
<title>Tina4 API Documentation</title>
|
|
8
|
-
<link rel="stylesheet" href="
|
|
16
|
+
<link rel="stylesheet" href="${swaggerUiCdn()}/swagger-ui.css">
|
|
9
17
|
<style>
|
|
10
18
|
body { margin: 0; background: #fafafa; }
|
|
11
19
|
.topbar { display: none !important; }
|
|
@@ -13,7 +21,7 @@ const SWAGGER_UI_HTML = (specUrl: string) => `<!DOCTYPE html>
|
|
|
13
21
|
</head>
|
|
14
22
|
<body>
|
|
15
23
|
<div id="swagger-ui"></div>
|
|
16
|
-
<script src="
|
|
24
|
+
<script src="${swaggerUiCdn()}/swagger-ui-bundle.js"></script>
|
|
17
25
|
<script>
|
|
18
26
|
SwaggerUIBundle({
|
|
19
27
|
url: "${specUrl}",
|