tina4-nodejs 3.0.0-rc.2

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.
Files changed (119) hide show
  1. package/BENCHMARK_REPORT.md +96 -0
  2. package/CARBONAH.md +140 -0
  3. package/CLAUDE.md +599 -0
  4. package/COMPARISON.md +194 -0
  5. package/README.md +595 -0
  6. package/package.json +59 -0
  7. package/packages/cli/src/bin.ts +110 -0
  8. package/packages/cli/src/commands/init.ts +194 -0
  9. package/packages/cli/src/commands/migrate.ts +96 -0
  10. package/packages/cli/src/commands/migrateCreate.ts +59 -0
  11. package/packages/cli/src/commands/routes.ts +61 -0
  12. package/packages/cli/src/commands/serve.ts +58 -0
  13. package/packages/cli/src/commands/test.ts +83 -0
  14. package/packages/core/gallery/auth/meta.json +1 -0
  15. package/packages/core/gallery/auth/src/routes/api/gallery/auth/login/post.ts +22 -0
  16. package/packages/core/gallery/auth/src/routes/api/gallery/auth/verify/get.ts +16 -0
  17. package/packages/core/gallery/auth/src/routes/gallery/auth/get.ts +97 -0
  18. package/packages/core/gallery/database/meta.json +1 -0
  19. package/packages/core/gallery/database/src/routes/api/gallery/db/notes/get.ts +13 -0
  20. package/packages/core/gallery/database/src/routes/api/gallery/db/notes/post.ts +17 -0
  21. package/packages/core/gallery/database/src/routes/api/gallery/db/tables/get.ts +23 -0
  22. package/packages/core/gallery/error-overlay/meta.json +1 -0
  23. package/packages/core/gallery/error-overlay/src/routes/api/gallery/crash/get.ts +17 -0
  24. package/packages/core/gallery/orm/meta.json +1 -0
  25. package/packages/core/gallery/orm/src/routes/api/gallery/products/get.ts +12 -0
  26. package/packages/core/gallery/orm/src/routes/api/gallery/products/post.ts +7 -0
  27. package/packages/core/gallery/queue/meta.json +1 -0
  28. package/packages/core/gallery/queue/src/routes/api/gallery/queue/produce/post.ts +16 -0
  29. package/packages/core/gallery/queue/src/routes/api/gallery/queue/status/get.ts +10 -0
  30. package/packages/core/gallery/rest-api/meta.json +1 -0
  31. package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/get.ts +6 -0
  32. package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/post.ts +7 -0
  33. package/packages/core/gallery/templates/meta.json +1 -0
  34. package/packages/core/gallery/templates/src/routes/gallery/page/get.ts +15 -0
  35. package/packages/core/gallery/templates/src/templates/gallery_page.twig +257 -0
  36. package/packages/core/public/css/tina4.css +2463 -0
  37. package/packages/core/public/css/tina4.min.css +1 -0
  38. package/packages/core/public/favicon.ico +0 -0
  39. package/packages/core/public/images/logo.svg +5 -0
  40. package/packages/core/public/images/tina4-logo-icon.webp +0 -0
  41. package/packages/core/public/js/frond.min.js +420 -0
  42. package/packages/core/public/js/tina4-dev-admin.min.js +327 -0
  43. package/packages/core/public/js/tina4.min.js +93 -0
  44. package/packages/core/public/swagger/index.html +90 -0
  45. package/packages/core/public/swagger/oauth2-redirect.html +63 -0
  46. package/packages/core/src/ai.ts +359 -0
  47. package/packages/core/src/api.ts +248 -0
  48. package/packages/core/src/auth.ts +287 -0
  49. package/packages/core/src/cache.ts +121 -0
  50. package/packages/core/src/constants.ts +48 -0
  51. package/packages/core/src/container.ts +90 -0
  52. package/packages/core/src/devAdmin.ts +2024 -0
  53. package/packages/core/src/devMailbox.ts +316 -0
  54. package/packages/core/src/dotenv.ts +172 -0
  55. package/packages/core/src/errorOverlay.test.ts +122 -0
  56. package/packages/core/src/errorOverlay.ts +278 -0
  57. package/packages/core/src/events.ts +112 -0
  58. package/packages/core/src/fakeData.ts +309 -0
  59. package/packages/core/src/graphql.ts +812 -0
  60. package/packages/core/src/health.ts +31 -0
  61. package/packages/core/src/htmlElement.ts +172 -0
  62. package/packages/core/src/i18n.ts +136 -0
  63. package/packages/core/src/index.ts +88 -0
  64. package/packages/core/src/logger.ts +226 -0
  65. package/packages/core/src/messenger.ts +822 -0
  66. package/packages/core/src/middleware.ts +138 -0
  67. package/packages/core/src/queue.ts +481 -0
  68. package/packages/core/src/queueBackends/kafkaBackend.ts +348 -0
  69. package/packages/core/src/queueBackends/rabbitmqBackend.ts +479 -0
  70. package/packages/core/src/rateLimiter.ts +107 -0
  71. package/packages/core/src/request.ts +189 -0
  72. package/packages/core/src/response.ts +146 -0
  73. package/packages/core/src/routeDiscovery.ts +87 -0
  74. package/packages/core/src/router.ts +398 -0
  75. package/packages/core/src/scss.ts +366 -0
  76. package/packages/core/src/server.ts +610 -0
  77. package/packages/core/src/service.ts +380 -0
  78. package/packages/core/src/session.ts +480 -0
  79. package/packages/core/src/sessionHandlers/mongoHandler.ts +286 -0
  80. package/packages/core/src/sessionHandlers/valkeyHandler.ts +184 -0
  81. package/packages/core/src/static.ts +58 -0
  82. package/packages/core/src/testing.ts +233 -0
  83. package/packages/core/src/types.ts +98 -0
  84. package/packages/core/src/watcher.ts +37 -0
  85. package/packages/core/src/websocket.ts +408 -0
  86. package/packages/core/src/wsdl.ts +546 -0
  87. package/packages/core/templates/errors/302.twig +14 -0
  88. package/packages/core/templates/errors/401.twig +9 -0
  89. package/packages/core/templates/errors/403.twig +29 -0
  90. package/packages/core/templates/errors/404.twig +29 -0
  91. package/packages/core/templates/errors/500.twig +38 -0
  92. package/packages/core/templates/errors/502.twig +9 -0
  93. package/packages/core/templates/errors/503.twig +12 -0
  94. package/packages/core/templates/errors/base.twig +37 -0
  95. package/packages/frond/src/engine.ts +1475 -0
  96. package/packages/frond/src/index.ts +2 -0
  97. package/packages/orm/src/adapters/firebird.ts +455 -0
  98. package/packages/orm/src/adapters/mssql.ts +440 -0
  99. package/packages/orm/src/adapters/mysql.ts +355 -0
  100. package/packages/orm/src/adapters/postgres.ts +362 -0
  101. package/packages/orm/src/adapters/sqlite.ts +270 -0
  102. package/packages/orm/src/autoCrud.ts +231 -0
  103. package/packages/orm/src/baseModel.ts +536 -0
  104. package/packages/orm/src/database.ts +321 -0
  105. package/packages/orm/src/fakeData.ts +118 -0
  106. package/packages/orm/src/index.ts +49 -0
  107. package/packages/orm/src/migration.ts +392 -0
  108. package/packages/orm/src/model.ts +56 -0
  109. package/packages/orm/src/query.ts +113 -0
  110. package/packages/orm/src/seeder.ts +120 -0
  111. package/packages/orm/src/sqlTranslation.ts +272 -0
  112. package/packages/orm/src/types.ts +110 -0
  113. package/packages/orm/src/validation.ts +93 -0
  114. package/packages/swagger/src/generator.ts +189 -0
  115. package/packages/swagger/src/index.ts +2 -0
  116. package/packages/swagger/src/ui.ts +48 -0
  117. package/skills/tina4-developer.skill +0 -0
  118. package/skills/tina4-js.skill +0 -0
  119. package/skills/tina4-maintainer.skill +0 -0
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Tina4 SQL Translation — Cross-engine SQL translator.
3
+ *
4
+ * Translates SQL dialect differences between engines so that application
5
+ * code can use a single SQL style and have it adapted at runtime.
6
+ *
7
+ * import { SQLTranslator } from "@tina4/orm";
8
+ *
9
+ * // Firebird: LIMIT/OFFSET → ROWS X TO Y
10
+ * SQLTranslator.limitToRows("SELECT * FROM users LIMIT 10 OFFSET 5");
11
+ * // → "SELECT * FROM users ROWS 6 TO 15"
12
+ *
13
+ * // MSSQL: LIMIT → TOP N
14
+ * SQLTranslator.limitToTop("SELECT * FROM users LIMIT 10");
15
+ * // → "SELECT TOP 10 * FROM users"
16
+ *
17
+ * Also includes a query cache with TTL support.
18
+ */
19
+
20
+ // ── SQL Translator ───────────────────────────────────────────
21
+
22
+ export class SQLTranslator {
23
+ /**
24
+ * Convert LIMIT/OFFSET to Firebird ROWS...TO syntax.
25
+ *
26
+ * LIMIT 10 OFFSET 5 → ROWS 6 TO 15
27
+ * LIMIT 10 → ROWS 1 TO 10
28
+ */
29
+ static limitToRows(sql: string): string {
30
+ // Match LIMIT n OFFSET m at end of statement
31
+ const limitOffset = /\bLIMIT\s+(\d+)\s+OFFSET\s+(\d+)\s*$/i;
32
+ let m = sql.match(limitOffset);
33
+ if (m) {
34
+ const limit = parseInt(m[1], 10);
35
+ const offset = parseInt(m[2], 10);
36
+ const start = offset + 1;
37
+ const end = offset + limit;
38
+ return sql.slice(0, m.index) + `ROWS ${start} TO ${end}`;
39
+ }
40
+
41
+ // Match LIMIT n at end of statement
42
+ const limitOnly = /\bLIMIT\s+(\d+)\s*$/i;
43
+ m = sql.match(limitOnly);
44
+ if (m) {
45
+ const limit = parseInt(m[1], 10);
46
+ return sql.slice(0, m.index) + `ROWS 1 TO ${limit}`;
47
+ }
48
+
49
+ return sql;
50
+ }
51
+
52
+ /**
53
+ * Convert LIMIT to MSSQL TOP syntax.
54
+ *
55
+ * SELECT ... LIMIT 10 → SELECT TOP 10 ...
56
+ * Does NOT convert if OFFSET is present (TOP doesn't support it).
57
+ */
58
+ static limitToTop(sql: string): string {
59
+ const limitOnly = /\bLIMIT\s+(\d+)\s*$/i;
60
+ const m = sql.match(limitOnly);
61
+ if (m && !/\bOFFSET\b/i.test(sql)) {
62
+ const limit = parseInt(m[1], 10);
63
+ const body = sql.slice(0, m.index).trim();
64
+ return body.replace(/^(SELECT)\b/i, `$1 TOP ${limit}`);
65
+ }
66
+ return sql;
67
+ }
68
+
69
+ /**
70
+ * Convert || concatenation to CONCAT() for MySQL/MSSQL.
71
+ *
72
+ * 'a' || 'b' || 'c' → CONCAT('a', 'b', 'c')
73
+ */
74
+ static concatPipesToFunc(sql: string): string {
75
+ if (!sql.includes("||")) return sql;
76
+ const parts = sql.split("||");
77
+ if (parts.length > 1) {
78
+ return "CONCAT(" + parts.map((p) => p.trim()).join(", ") + ")";
79
+ }
80
+ return sql;
81
+ }
82
+
83
+ /**
84
+ * Convert TRUE/FALSE to 1/0 for engines without boolean type (Firebird).
85
+ */
86
+ static booleanToInt(sql: string): string {
87
+ sql = sql.replace(/\bTRUE\b/gi, "1");
88
+ sql = sql.replace(/\bFALSE\b/gi, "0");
89
+ return sql;
90
+ }
91
+
92
+ /**
93
+ * Convert ILIKE to LOWER() LIKE LOWER() for engines without ILIKE.
94
+ */
95
+ static ilikeToLike(sql: string): string {
96
+ return sql.replace(
97
+ /(\S+)\s+ILIKE\s+(\S+)/gi,
98
+ (_match, col: string, val: string) => `LOWER(${col.trim()}) LIKE LOWER(${val.trim()})`,
99
+ );
100
+ }
101
+
102
+ /**
103
+ * Translate AUTOINCREMENT across engines in DDL.
104
+ */
105
+ static autoIncrementSyntax(sql: string, engine: string): string {
106
+ switch (engine) {
107
+ case "mysql":
108
+ return sql.replace(/AUTOINCREMENT/gi, "AUTO_INCREMENT");
109
+ case "postgresql":
110
+ return sql.replace(
111
+ /INTEGER\s+PRIMARY\s+KEY\s+AUTOINCREMENT/gi,
112
+ "SERIAL PRIMARY KEY",
113
+ );
114
+ case "mssql":
115
+ return sql.replace(/AUTOINCREMENT/gi, "IDENTITY(1,1)");
116
+ case "firebird":
117
+ return sql.replace(/\s*AUTOINCREMENT\b/gi, "");
118
+ default:
119
+ return sql;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Convert ? placeholders to engine-specific style.
125
+ *
126
+ * ? → %s (MySQL, PostgreSQL)
127
+ * ? → :1, :2, :3 (Oracle, Firebird)
128
+ */
129
+ static placeholderStyle(sql: string, style: string): string {
130
+ if (style === "%s") {
131
+ return sql.replace(/\?/g, "%s");
132
+ }
133
+ if (style.startsWith(":")) {
134
+ let count = 0;
135
+ return sql.replace(/\?/g, () => {
136
+ count++;
137
+ return `:${count}`;
138
+ });
139
+ }
140
+ return sql;
141
+ }
142
+
143
+ /**
144
+ * Detect and strip RETURNING clause from INSERT/UPDATE statements.
145
+ * Returns the cleaned SQL and the list of RETURNING columns.
146
+ *
147
+ * "INSERT INTO t (x) VALUES (1) RETURNING id, name"
148
+ * → { sql: "INSERT INTO t (x) VALUES (1)", columns: ["id", "name"] }
149
+ */
150
+ static parseReturning(sql: string): { sql: string; columns: string[] } {
151
+ const m = sql.match(/\bRETURNING\s+(.+)$/i);
152
+ if (!m) return { sql, columns: [] };
153
+ const columns = m[1].split(",").map((c) => c.trim());
154
+ return {
155
+ sql: sql.slice(0, m.index!).trim(),
156
+ columns,
157
+ };
158
+ }
159
+ }
160
+
161
+ // ── Query Cache ──────────────────────────────────────────────
162
+
163
+ interface CacheEntry<T> {
164
+ value: T;
165
+ expiresAt: number;
166
+ }
167
+
168
+ /**
169
+ * Simple in-memory query cache with TTL support.
170
+ */
171
+ export class QueryCache {
172
+ private store = new Map<string, CacheEntry<unknown>>();
173
+ private defaultTtl: number;
174
+ private maxSize: number;
175
+
176
+ constructor(options?: { defaultTtl?: number; maxSize?: number }) {
177
+ this.defaultTtl = options?.defaultTtl ?? 60;
178
+ this.maxSize = options?.maxSize ?? 1000;
179
+ }
180
+
181
+ /**
182
+ * Generate a cache key from a SQL query and params.
183
+ */
184
+ static queryKey(sql: string, params?: unknown[]): string {
185
+ const paramStr = params ? JSON.stringify(params) : "";
186
+ // Simple hash via string combination
187
+ return `query:${sql}:${paramStr}`;
188
+ }
189
+
190
+ /**
191
+ * Get a cached value. Returns undefined if expired or missing.
192
+ */
193
+ get<T>(key: string): T | undefined {
194
+ const entry = this.store.get(key) as CacheEntry<T> | undefined;
195
+ if (!entry) return undefined;
196
+ if (Date.now() > entry.expiresAt) {
197
+ this.store.delete(key);
198
+ return undefined;
199
+ }
200
+ return entry.value;
201
+ }
202
+
203
+ /**
204
+ * Set a cached value with optional TTL (seconds).
205
+ */
206
+ set<T>(key: string, value: T, ttl?: number): void {
207
+ // Evict oldest entry if at max size
208
+ if (this.store.size >= this.maxSize) {
209
+ const firstKey = this.store.keys().next().value;
210
+ if (firstKey !== undefined) this.store.delete(firstKey);
211
+ }
212
+
213
+ this.store.set(key, {
214
+ value,
215
+ expiresAt: Date.now() + (ttl ?? this.defaultTtl) * 1000,
216
+ });
217
+ }
218
+
219
+ /**
220
+ * Check if a key exists and is not expired.
221
+ */
222
+ has(key: string): boolean {
223
+ return this.get(key) !== undefined;
224
+ }
225
+
226
+ /**
227
+ * Delete a specific key.
228
+ */
229
+ delete(key: string): boolean {
230
+ return this.store.delete(key);
231
+ }
232
+
233
+ /**
234
+ * Remove all expired entries.
235
+ */
236
+ sweep(): number {
237
+ const now = Date.now();
238
+ let removed = 0;
239
+ for (const [key, entry] of this.store) {
240
+ if (now > entry.expiresAt) {
241
+ this.store.delete(key);
242
+ removed++;
243
+ }
244
+ }
245
+ return removed;
246
+ }
247
+
248
+ /**
249
+ * Clear all cached entries.
250
+ */
251
+ clear(): void {
252
+ this.store.clear();
253
+ }
254
+
255
+ /**
256
+ * Get the number of cached entries.
257
+ */
258
+ size(): number {
259
+ return this.store.size;
260
+ }
261
+
262
+ /**
263
+ * Get or set a value using a factory function.
264
+ */
265
+ remember<T>(key: string, ttl: number, factory: () => T): T {
266
+ const cached = this.get<T>(key);
267
+ if (cached !== undefined) return cached;
268
+ const value = factory();
269
+ this.set(key, value, ttl);
270
+ return value;
271
+ }
272
+ }
@@ -0,0 +1,110 @@
1
+ export type FieldType = "string" | "integer" | "number" | "numeric" | "boolean" | "datetime" | "text";
2
+
3
+ export interface FieldDefinition {
4
+ type: FieldType;
5
+ primaryKey?: boolean;
6
+ autoIncrement?: boolean;
7
+ required?: boolean;
8
+ default?: unknown;
9
+ minLength?: number;
10
+ maxLength?: number;
11
+ min?: number;
12
+ max?: number;
13
+ pattern?: string;
14
+ }
15
+
16
+ export interface RelationshipDefinition {
17
+ model: string;
18
+ foreignKey: string;
19
+ }
20
+
21
+ export interface ModelDefinition {
22
+ tableName: string;
23
+ fields: Record<string, FieldDefinition>;
24
+ softDelete?: boolean;
25
+ tableFilter?: string;
26
+ hasOne?: RelationshipDefinition[];
27
+ hasMany?: RelationshipDefinition[];
28
+ dbName?: string;
29
+ }
30
+
31
+ export interface ColumnInfo {
32
+ name: string;
33
+ type: string;
34
+ nullable?: boolean;
35
+ default?: unknown;
36
+ primaryKey?: boolean;
37
+ }
38
+
39
+ export interface DatabaseResult {
40
+ success: boolean;
41
+ rowsAffected: number;
42
+ lastInsertId?: number | bigint;
43
+ error?: string;
44
+ }
45
+
46
+ export interface DatabaseAdapter {
47
+ /** Execute a statement (INSERT, UPDATE, DELETE, DDL). */
48
+ execute(sql: string, params?: unknown[]): unknown;
49
+
50
+ /** Execute a single SQL statement with multiple parameter sets (batch). */
51
+ executeMany(sql: string, paramsList: unknown[][]): { totalAffected: number; lastInsertId?: number | bigint };
52
+
53
+ /** Query rows. */
54
+ query<T = Record<string, unknown>>(sql: string, params?: unknown[]): T[];
55
+
56
+ /** Fetch rows with optional pagination (limit/skip). */
57
+ fetch<T = Record<string, unknown>>(sql: string, params?: unknown[], limit?: number, skip?: number): T[];
58
+
59
+ /** Fetch a single row or null. */
60
+ fetchOne<T = Record<string, unknown>>(sql: string, params?: unknown[]): T | null;
61
+
62
+ /** Insert one or more rows into a table, returns result with lastInsertId. */
63
+ insert(table: string, data: Record<string, unknown> | Record<string, unknown>[]): DatabaseResult;
64
+
65
+ /** Update rows in a table matching filter, returns affected row count. */
66
+ update(table: string, data: Record<string, unknown>, filter: Record<string, unknown>): DatabaseResult;
67
+
68
+ /** Delete rows from a table matching filter (object, string WHERE, or array of objects). */
69
+ delete(table: string, filter: Record<string, unknown> | string | Record<string, unknown>[]): DatabaseResult;
70
+
71
+ /** Start a transaction. */
72
+ startTransaction(): void;
73
+
74
+ /** Commit the current transaction. */
75
+ commit(): void;
76
+
77
+ /** Rollback the current transaction. */
78
+ rollback(): void;
79
+
80
+ /** List all tables in the database. */
81
+ tables(): string[];
82
+
83
+ /** List columns with types for a table. */
84
+ columns(table: string): ColumnInfo[];
85
+
86
+ /** Get the last auto-increment id. */
87
+ lastInsertId(): number | bigint | null;
88
+
89
+ /** Close the connection. */
90
+ close(): void;
91
+
92
+ /** Check if a table exists. */
93
+ tableExists(name: string): boolean;
94
+
95
+ /** Create a table from field definitions. */
96
+ createTable(name: string, columns: Record<string, FieldDefinition>): void;
97
+
98
+ /** Get raw column info (legacy, used by migration). */
99
+ getTableColumns?(name: string): Array<{ name: string; type: string }>;
100
+
101
+ /** Add a column to an existing table (legacy, used by migration). */
102
+ addColumn?(table: string, colName: string, def: FieldDefinition): void;
103
+ }
104
+
105
+ export interface QueryOptions {
106
+ filter?: Record<string, unknown>;
107
+ sort?: string;
108
+ page?: number;
109
+ limit?: number;
110
+ }
@@ -0,0 +1,93 @@
1
+ import type { FieldDefinition } from "./types.js";
2
+
3
+ export interface ValidationError {
4
+ field: string;
5
+ message: string;
6
+ }
7
+
8
+ export function validate(
9
+ data: Record<string, unknown>,
10
+ fields: Record<string, FieldDefinition>,
11
+ isUpdate = false
12
+ ): ValidationError[] {
13
+ const errors: ValidationError[] = [];
14
+
15
+ // Pre-compile regex patterns outside the loop (E005)
16
+ const compiledPatterns = new Map<string, RegExp>();
17
+ for (const [name, def] of Object.entries(fields)) {
18
+ if (def.pattern !== undefined) {
19
+ compiledPatterns.set(name, new RegExp(def.pattern));
20
+ }
21
+ }
22
+
23
+ for (const [name, def] of Object.entries(fields)) {
24
+ // Skip primary key / autoIncrement fields on create
25
+ if (def.primaryKey && def.autoIncrement) continue;
26
+
27
+ const value = data[name];
28
+
29
+ // Required check (skip on update if field not provided)
30
+ if (def.required && !isUpdate && (value === undefined || value === null || value === "")) {
31
+ errors.push({ field: name, message: "is required" });
32
+ continue;
33
+ }
34
+
35
+ // Skip further validation if value not provided
36
+ if (value === undefined || value === null) continue;
37
+
38
+ // Type checks
39
+ switch (def.type) {
40
+ case "string":
41
+ case "text":
42
+ if (typeof value !== "string") {
43
+ errors.push({ field: name, message: "must be a string" });
44
+ } else {
45
+ if (def.minLength !== undefined && value.length < def.minLength) {
46
+ errors.push({ field: name, message: `must be at least ${def.minLength} characters` });
47
+ }
48
+ if (def.maxLength !== undefined && value.length > def.maxLength) {
49
+ errors.push({ field: name, message: `must be at most ${def.maxLength} characters` });
50
+ }
51
+ const regex = compiledPatterns.get(name);
52
+ if (regex && !regex.test(value)) {
53
+ errors.push({ field: name, message: `does not match required pattern` });
54
+ }
55
+ }
56
+ break;
57
+
58
+ case "integer":
59
+ case "number":
60
+ case "numeric": {
61
+ const num = typeof value === "string" ? Number(value) : value;
62
+ if (typeof num !== "number" || isNaN(num)) {
63
+ errors.push({ field: name, message: "must be a number" });
64
+ } else {
65
+ if (def.type === "integer" && !Number.isInteger(num)) {
66
+ errors.push({ field: name, message: "must be an integer" });
67
+ }
68
+ if (def.min !== undefined && num < def.min) {
69
+ errors.push({ field: name, message: `must be at least ${def.min}` });
70
+ }
71
+ if (def.max !== undefined && num > def.max) {
72
+ errors.push({ field: name, message: `must be at most ${def.max}` });
73
+ }
74
+ }
75
+ break;
76
+ }
77
+
78
+ case "boolean":
79
+ if (typeof value !== "boolean" && value !== 0 && value !== 1 && value !== "true" && value !== "false") {
80
+ errors.push({ field: name, message: "must be a boolean" });
81
+ }
82
+ break;
83
+
84
+ case "datetime":
85
+ if (typeof value === "string" && isNaN(Date.parse(value))) {
86
+ errors.push({ field: name, message: "must be a valid date/time" });
87
+ }
88
+ break;
89
+ }
90
+ }
91
+
92
+ return errors;
93
+ }
@@ -0,0 +1,189 @@
1
+ import type { RouteDefinition } from "@tina4/core";
2
+ import type { ModelDefinition, FieldDefinition } from "@tina4/orm";
3
+
4
+ interface OpenAPISpec {
5
+ openapi: string;
6
+ info: { title: string; version: string; description?: string };
7
+ paths: Record<string, Record<string, unknown>>;
8
+ components?: { schemas?: Record<string, unknown> };
9
+ }
10
+
11
+ export function generateOpenAPISpec(
12
+ routes: RouteDefinition[],
13
+ models: ModelDefinition[]
14
+ ): OpenAPISpec {
15
+ const spec: OpenAPISpec = {
16
+ openapi: "3.0.3",
17
+ info: {
18
+ title: "Tina4 API",
19
+ version: "0.0.1",
20
+ description: "Auto-generated API documentation",
21
+ },
22
+ paths: {},
23
+ components: { schemas: {} },
24
+ };
25
+
26
+ // Generate schemas from models
27
+ for (const model of models) {
28
+ const schema = modelToSchema(model);
29
+ spec.components!.schemas![model.tableName] = schema;
30
+ }
31
+
32
+ // Generate paths from routes
33
+ for (const route of routes) {
34
+ const openApiPath = patternToOpenAPI(route.pattern);
35
+ const method = route.method.toLowerCase();
36
+
37
+ if (!spec.paths[openApiPath]) {
38
+ spec.paths[openApiPath] = {};
39
+ }
40
+
41
+ const operation: Record<string, unknown> = {
42
+ summary: route.meta?.summary ?? `${route.method} ${route.pattern}`,
43
+ tags: route.meta?.tags ?? inferTags(route.pattern),
44
+ responses: route.meta?.responses ?? {
45
+ "200": { description: "Successful response" },
46
+ },
47
+ };
48
+
49
+ // Add path parameters
50
+ const pathParams = extractPathParams(route.pattern);
51
+ if (pathParams.length > 0) {
52
+ operation.parameters = pathParams.map((name) => ({
53
+ name,
54
+ in: "path",
55
+ required: true,
56
+ schema: { type: "string" },
57
+ }));
58
+ }
59
+
60
+ // Add query parameters for GET list endpoints
61
+ if (method === "get" && !route.pattern.includes("[id]") && !route.pattern.includes("[...")) {
62
+ const modelName = inferModelFromPath(route.pattern);
63
+ if (modelName && models.some((m) => m.tableName === modelName)) {
64
+ operation.parameters = [
65
+ ...(operation.parameters as unknown[] ?? []),
66
+ { name: "page", in: "query", schema: { type: "integer", default: 1 } },
67
+ { name: "limit", in: "query", schema: { type: "integer", default: 20 } },
68
+ { name: "sort", in: "query", schema: { type: "string" }, description: "Sort fields (prefix with - for descending)" },
69
+ ];
70
+ }
71
+ }
72
+
73
+ // Add request body for POST/PUT
74
+ if (method === "post" || method === "put") {
75
+ const modelName = inferModelFromPath(route.pattern);
76
+ if (modelName && models.some((m) => m.tableName === modelName)) {
77
+ operation.requestBody = {
78
+ required: true,
79
+ content: {
80
+ "application/json": {
81
+ schema: { $ref: `#/components/schemas/${modelName}` },
82
+ },
83
+ },
84
+ };
85
+
86
+ // Add response schema
87
+ operation.responses = {
88
+ ...(method === "post"
89
+ ? { "201": { description: "Created", content: { "application/json": { schema: { $ref: `#/components/schemas/${modelName}` } } } } }
90
+ : { "200": { description: "Updated", content: { "application/json": { schema: { $ref: `#/components/schemas/${modelName}` } } } } }),
91
+ "422": { description: "Validation failed" },
92
+ };
93
+ }
94
+ }
95
+
96
+ spec.paths[openApiPath][method] = operation;
97
+ }
98
+
99
+ return spec;
100
+ }
101
+
102
+ function modelToSchema(model: ModelDefinition): Record<string, unknown> {
103
+ const properties: Record<string, unknown> = {};
104
+ const required: string[] = [];
105
+
106
+ for (const [name, def] of Object.entries(model.fields)) {
107
+ properties[name] = fieldToSchemaProperty(def);
108
+ if (def.required && !def.primaryKey) {
109
+ required.push(name);
110
+ }
111
+ }
112
+
113
+ return {
114
+ type: "object",
115
+ properties,
116
+ ...(required.length > 0 ? { required } : {}),
117
+ };
118
+ }
119
+
120
+ function fieldToSchemaProperty(def: FieldDefinition): Record<string, unknown> {
121
+ const prop: Record<string, unknown> = {};
122
+
123
+ switch (def.type) {
124
+ case "string":
125
+ case "text":
126
+ prop.type = "string";
127
+ if (def.maxLength) prop.maxLength = def.maxLength;
128
+ if (def.minLength) prop.minLength = def.minLength;
129
+ if (def.pattern) prop.pattern = def.pattern;
130
+ break;
131
+ case "integer":
132
+ prop.type = "integer";
133
+ if (def.min !== undefined) prop.minimum = def.min;
134
+ if (def.max !== undefined) prop.maximum = def.max;
135
+ break;
136
+ case "number":
137
+ case "numeric":
138
+ prop.type = "number";
139
+ if (def.min !== undefined) prop.minimum = def.min;
140
+ if (def.max !== undefined) prop.maximum = def.max;
141
+ break;
142
+ case "boolean":
143
+ prop.type = "boolean";
144
+ break;
145
+ case "datetime":
146
+ prop.type = "string";
147
+ prop.format = "date-time";
148
+ break;
149
+ }
150
+
151
+ if (def.primaryKey && def.autoIncrement) {
152
+ prop.readOnly = true;
153
+ }
154
+
155
+ return prop;
156
+ }
157
+
158
+ function patternToOpenAPI(pattern: string): string {
159
+ return pattern.replace(/\[\.\.\.(\w+)\]/g, "{$1}").replace(/\[(\w+)\]/g, "{$1}");
160
+ }
161
+
162
+ function extractPathParams(pattern: string): string[] {
163
+ const params: string[] = [];
164
+ const regex = /\[(?:\.\.\.)?(\w+)\]/g;
165
+ let match;
166
+ while ((match = regex.exec(pattern)) !== null) {
167
+ params.push(match[1]);
168
+ }
169
+ return params;
170
+ }
171
+
172
+ function inferTags(pattern: string): string[] {
173
+ const parts = pattern.split("/").filter(Boolean);
174
+ // Use the first meaningful segment after /api/ as tag
175
+ const apiIndex = parts.indexOf("api");
176
+ if (apiIndex !== -1 && parts[apiIndex + 1]) {
177
+ return [parts[apiIndex + 1]];
178
+ }
179
+ return parts.length > 0 ? [parts[0]] : ["default"];
180
+ }
181
+
182
+ function inferModelFromPath(pattern: string): string | null {
183
+ const parts = pattern.split("/").filter(Boolean);
184
+ const apiIndex = parts.indexOf("api");
185
+ if (apiIndex !== -1 && parts[apiIndex + 1]) {
186
+ return parts[apiIndex + 1];
187
+ }
188
+ return null;
189
+ }
@@ -0,0 +1,2 @@
1
+ export { generateOpenAPISpec } from "./generator.js";
2
+ export { createSwaggerRoutes } from "./ui.js";