tina4-nodejs 3.13.94 → 3.13.96
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 +158 -30
- package/README.md +1 -1
- package/package.json +3 -1
- package/packages/cli/dist/bin.js +30911 -28444
- package/packages/cli/src/commands/metrics.ts +17 -11
- package/packages/cli/src/commands/serve.ts +10 -9
- package/packages/core/dist/index.js +30810 -28261
- package/packages/core/public/css/tina4.min.css +1 -1
- package/packages/core/src/ai.ts +7 -1
- package/packages/core/src/auth.ts +191 -39
- package/packages/core/src/background.ts +19 -19
- package/packages/core/src/cache.ts +492 -49
- package/packages/core/src/devAdmin.ts +79 -32
- package/packages/core/src/dispatchPipeline.ts +285 -0
- package/packages/core/src/dotenv.ts +185 -40
- package/packages/core/src/index.ts +6 -7
- package/packages/core/src/logger.ts +257 -36
- package/packages/core/src/mcp.ts +1 -1
- package/packages/core/src/messenger.ts +294 -106
- package/packages/core/src/metrics.ts +199 -961
- package/packages/core/src/middleware.ts +390 -123
- package/packages/core/src/queue.ts +188 -32
- package/packages/core/src/queueBackends/kafkaBackend.ts +1 -1
- package/packages/core/src/queueBackends/liteBackend.ts +13 -0
- package/packages/core/src/queueBackends/mongoBackend.ts +101 -9
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +22 -4
- package/packages/core/src/rateLimiter.ts +10 -5
- package/packages/core/src/request.ts +34 -16
- package/packages/core/src/response.ts +46 -1
- package/packages/core/src/router.ts +29 -4
- package/packages/core/src/server.ts +886 -421
- package/packages/core/src/session.ts +244 -27
- package/packages/core/src/sessionHandlers/databaseHandler.ts +338 -48
- package/packages/core/src/sessionHandlers/memcachedHandler.ts +181 -0
- package/packages/core/src/sessionHandlers/mongoClient.ts +293 -208
- package/packages/core/src/sessionHandlers/mongoHandler.ts +88 -8
- package/packages/core/src/sessionHandlers/respClient.ts +16 -147
- package/packages/core/src/sessionHandlers/sqlClient.ts +290 -0
- package/packages/core/src/sessionHandlers/syncBridge.ts +190 -0
- package/packages/core/src/sessionHandlers/syncSocket.ts +236 -0
- package/packages/core/src/testClient.ts +18 -5
- package/packages/core/src/trustedProxy.ts +249 -0
- package/packages/core/src/types.ts +29 -5
- package/packages/core/src/websocket.ts +66 -0
- package/packages/orm/dist/index.js +22717 -20168
- package/packages/orm/src/adapters/firebird.ts +183 -56
- package/packages/orm/src/adapters/mongodb.ts +25 -4
- package/packages/orm/src/adapters/mssql.ts +114 -29
- package/packages/orm/src/adapters/mysql.ts +103 -40
- package/packages/orm/src/adapters/odbc.ts +44 -21
- package/packages/orm/src/adapters/postgres.ts +118 -26
- package/packages/orm/src/adapters/sqlDialect.ts +120 -0
- package/packages/orm/src/adapters/sqlite.ts +60 -24
- package/packages/orm/src/autoCrud.ts +12 -10
- package/packages/orm/src/baseModel.ts +135 -40
- package/packages/orm/src/cachedDatabase.ts +43 -19
- package/packages/orm/src/connectTimeout.ts +265 -0
- package/packages/orm/src/database.ts +241 -197
- package/packages/orm/src/databaseResult.ts +51 -28
- package/packages/orm/src/databaseUrl.ts +484 -0
- package/packages/orm/src/docstore.ts +386 -145
- package/packages/orm/src/index.ts +13 -6
- package/packages/orm/src/migration.ts +44 -11
- package/packages/orm/src/model.ts +4 -0
- package/packages/orm/src/queryBuilder.ts +47 -6
- package/packages/orm/src/sqlTranslator.ts +310 -4
- package/packages/orm/src/types.ts +21 -77
- package/packages/swagger/dist/index.js +78 -20
- package/packages/swagger/src/generator.ts +172 -29
- package/types/core/src/ai.d.ts +1 -1
- package/types/core/src/auth.d.ts +28 -5
- package/types/core/src/background.d.ts +3 -3
- package/types/core/src/cache.d.ts +15 -12
- package/types/core/src/dispatchPipeline.d.ts +117 -0
- package/types/core/src/dotenv.d.ts +38 -16
- package/types/core/src/index.d.ts +6 -9
- package/types/core/src/logger.d.ts +93 -16
- package/types/core/src/messenger.d.ts +47 -6
- package/types/core/src/metrics.d.ts +25 -61
- package/types/core/src/middleware.d.ts +134 -11
- package/types/core/src/queue.d.ts +54 -5
- package/types/core/src/queueBackends/kafkaBackend.d.ts +1 -1
- package/types/core/src/queueBackends/liteBackend.d.ts +9 -0
- package/types/core/src/queueBackends/mongoBackend.d.ts +24 -2
- package/types/core/src/queueBackends/rabbitmqBackend.d.ts +3 -3
- package/types/core/src/router.d.ts +14 -3
- package/types/core/src/server.d.ts +15 -4
- package/types/core/src/session.d.ts +87 -2
- package/types/core/src/sessionHandlers/databaseHandler.d.ts +60 -5
- package/types/core/src/sessionHandlers/memcachedHandler.d.ts +60 -0
- package/types/core/src/sessionHandlers/mongoClient.d.ts +16 -5
- package/types/core/src/sessionHandlers/mongoHandler.d.ts +51 -3
- package/types/core/src/sessionHandlers/respClient.d.ts +2 -2
- package/types/core/src/sessionHandlers/sqlClient.d.ts +39 -0
- package/types/core/src/sessionHandlers/syncBridge.d.ts +91 -0
- package/types/core/src/sessionHandlers/syncSocket.d.ts +49 -0
- package/types/core/src/trustedProxy.d.ts +44 -0
- package/types/core/src/types.d.ts +28 -5
- package/types/core/src/websocket.d.ts +26 -0
- package/types/orm/src/adapters/firebird.d.ts +55 -10
- package/types/orm/src/adapters/mongodb.d.ts +2 -2
- package/types/orm/src/adapters/mssql.d.ts +18 -11
- package/types/orm/src/adapters/mysql.d.ts +11 -10
- package/types/orm/src/adapters/odbc.d.ts +9 -12
- package/types/orm/src/adapters/postgres.d.ts +11 -10
- package/types/orm/src/adapters/sqlDialect.d.ts +71 -0
- package/types/orm/src/adapters/sqlite.d.ts +15 -3
- package/types/orm/src/baseModel.d.ts +45 -9
- package/types/orm/src/cachedDatabase.d.ts +18 -5
- package/types/orm/src/connectTimeout.d.ts +100 -0
- package/types/orm/src/database.d.ts +78 -28
- package/types/orm/src/databaseResult.d.ts +29 -15
- package/types/orm/src/databaseUrl.d.ts +125 -0
- package/types/orm/src/docstore.d.ts +102 -43
- package/types/orm/src/index.d.ts +6 -4
- package/types/orm/src/migration.d.ts +4 -3
- package/types/orm/src/queryBuilder.d.ts +23 -3
- package/types/orm/src/sqlTranslator.d.ts +126 -2
- package/types/orm/src/types.d.ts +21 -38
- package/packages/core/src/scss.ts +0 -623
- package/packages/core/src/sessionHandlers/redisHandler.ts +0 -219
- package/types/core/src/scss.d.ts +0 -19
- package/types/core/src/sessionHandlers/redisHandler.d.ts +0 -60
|
@@ -31,6 +31,13 @@ export interface RelationshipDefinition {
|
|
|
31
31
|
|
|
32
32
|
export interface ModelDefinition {
|
|
33
33
|
tableName: string;
|
|
34
|
+
/**
|
|
35
|
+
* The model CLASS name (e.g. `Item` for tableName `items`), carried from
|
|
36
|
+
* `ModelClass.name` at discovery. Swagger keys `components.schemas` by this —
|
|
37
|
+
* the type name a generated client wants — falling back to a singular
|
|
38
|
+
* PascalCase derivation of tableName when a raw definition carries none.
|
|
39
|
+
*/
|
|
40
|
+
className?: string;
|
|
34
41
|
fields: Record<string, FieldDefinition>;
|
|
35
42
|
fieldMapping?: Record<string, string>;
|
|
36
43
|
softDelete?: boolean;
|
|
@@ -77,8 +84,8 @@ export interface DatabaseAdapter {
|
|
|
77
84
|
/** Insert one or more rows into a table, returns result with lastId. */
|
|
78
85
|
insert(table: string, data: Record<string, unknown> | Record<string, unknown>[]): DatabaseResult;
|
|
79
86
|
|
|
80
|
-
/** Update rows in a table matching filter, returns affected row count. */
|
|
81
|
-
update(table: string, data: Record<string, unknown>, filter: Record<string, unknown
|
|
87
|
+
/** Update rows in a table matching filter (object or string WHERE), returns affected row count. */
|
|
88
|
+
update(table: string, data: Record<string, unknown>, filter: Record<string, unknown> | string, params?: unknown[]): DatabaseResult;
|
|
82
89
|
|
|
83
90
|
/** Delete rows from a table matching filter (object, string WHERE, or array of objects). */
|
|
84
91
|
delete(table: string, filter: Record<string, unknown> | string | Record<string, unknown>[], params?: unknown[]): DatabaseResult;
|
|
@@ -93,10 +100,10 @@ export interface DatabaseAdapter {
|
|
|
93
100
|
rollback(): void;
|
|
94
101
|
|
|
95
102
|
/** List all tables in the database. */
|
|
96
|
-
|
|
103
|
+
getTables(): string[];
|
|
97
104
|
|
|
98
105
|
/** List columns with types for a table. */
|
|
99
|
-
|
|
106
|
+
getColumns(table: string): ColumnInfo[];
|
|
100
107
|
|
|
101
108
|
/** Get the last inserted id (auto-increment integer, or a UUID/string PK). */
|
|
102
109
|
lastInsertId(): number | bigint | string | null;
|
|
@@ -115,80 +122,17 @@ export interface DatabaseAdapter {
|
|
|
115
122
|
|
|
116
123
|
/** Add a column to an existing table (legacy, used by migration). */
|
|
117
124
|
addColumn?(table: string, colName: string, def: FieldDefinition): void;
|
|
118
|
-
}
|
|
119
125
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Wraps an array of fetched rows with convenience methods.
|
|
132
|
-
*
|
|
133
|
-
* Mirrors Python's `DatabaseResult` and Ruby's `Tina4::DatabaseResult`.
|
|
134
|
-
*/
|
|
135
|
-
export class FetchResult<T = Record<string, unknown>> {
|
|
136
|
-
readonly records: T[];
|
|
137
|
-
readonly count: number;
|
|
138
|
-
readonly sql: string;
|
|
139
|
-
|
|
140
|
-
constructor(records: T[], sql = "") {
|
|
141
|
-
this.records = records;
|
|
142
|
-
this.count = records.length;
|
|
143
|
-
this.sql = sql;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** Paginate the in-memory result set. */
|
|
147
|
-
toPaginate(page = 1, perPage = 20): PaginatedResult<T> {
|
|
148
|
-
const total = this.count;
|
|
149
|
-
const totalPages = Math.max(1, Math.ceil(total / perPage));
|
|
150
|
-
const offset = (page - 1) * perPage;
|
|
151
|
-
const data = this.records.slice(offset, offset + perPage);
|
|
152
|
-
return {
|
|
153
|
-
data,
|
|
154
|
-
page,
|
|
155
|
-
perPage,
|
|
156
|
-
total,
|
|
157
|
-
totalPages,
|
|
158
|
-
hasNext: page < totalPages,
|
|
159
|
-
hasPrev: page > 1,
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/** Return the first record or null. */
|
|
164
|
-
first(): T | null {
|
|
165
|
-
return this.records[0] ?? null;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/** Return the last record or null. */
|
|
169
|
-
last(): T | null {
|
|
170
|
-
return this.records[this.records.length - 1] ?? null;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/** Check if result is empty. */
|
|
174
|
-
isEmpty(): boolean {
|
|
175
|
-
return this.records.length === 0;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/** Convert to plain array. */
|
|
179
|
-
toArray(): T[] {
|
|
180
|
-
return [...this.records];
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/** Convert to JSON string. */
|
|
184
|
-
toJSON(): string {
|
|
185
|
-
return JSON.stringify(this.records);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/** Iterate over records. */
|
|
189
|
-
[Symbol.iterator](): Iterator<T> {
|
|
190
|
-
return this.records[Symbol.iterator]();
|
|
191
|
-
}
|
|
126
|
+
/**
|
|
127
|
+
* Stable identity of the DATABASE this adapter is connected to, as
|
|
128
|
+
* `engine://host:port/database` with NO credentials - set by whoever built
|
|
129
|
+
* the adapter from a URL or config.
|
|
130
|
+
*
|
|
131
|
+
* The query cache folds this into every key. Without it two databases sharing
|
|
132
|
+
* one cache backend cross-serve each other's rows, because identical SQL text
|
|
133
|
+
* across tenants is the common case.
|
|
134
|
+
*/
|
|
135
|
+
cacheIdentity?: string;
|
|
192
136
|
}
|
|
193
137
|
|
|
194
138
|
export interface QueryOptions {
|
|
@@ -73,8 +73,12 @@ function sanitizeSecurity(reqs, schemes) {
|
|
|
73
73
|
function generate(routes, models = []) {
|
|
74
74
|
const info = {
|
|
75
75
|
title: process.env.TINA4_SWAGGER_TITLE ?? "Tina4 API",
|
|
76
|
-
version
|
|
77
|
-
|
|
76
|
+
// The app's version, defaulting to 1.0.0 — NOT the framework's (Node shipped
|
|
77
|
+
// 0.0.1). description defaults to the empty string, not a canned sentence.
|
|
78
|
+
// Both are the settled cross-framework defaults (parity with the Python
|
|
79
|
+
// master); TINA4_SWAGGER_VERSION / _DESCRIPTION still override.
|
|
80
|
+
version: process.env.TINA4_SWAGGER_VERSION ?? "1.0.0",
|
|
81
|
+
description: process.env.TINA4_SWAGGER_DESCRIPTION ?? ""
|
|
78
82
|
};
|
|
79
83
|
const contactEmail = (process.env.TINA4_SWAGGER_CONTACT_EMAIL ?? "").trim();
|
|
80
84
|
const contactName = (process.env.TINA4_SWAGGER_CONTACT_TEAM ?? "").trim();
|
|
@@ -107,9 +111,11 @@ function generate(routes, models = []) {
|
|
|
107
111
|
const includePrefixes = csv(process.env.TINA4_SWAGGER_INCLUDE);
|
|
108
112
|
const excludePrefixes = csv(process.env.TINA4_SWAGGER_EXCLUDE);
|
|
109
113
|
const refSchemas = /* @__PURE__ */ new Set();
|
|
114
|
+
const tableToSchema = /* @__PURE__ */ new Map();
|
|
110
115
|
for (const model of models) {
|
|
111
|
-
const
|
|
112
|
-
|
|
116
|
+
const schemaKey = schemaNameForModel(model);
|
|
117
|
+
tableToSchema.set(model.tableName, schemaKey);
|
|
118
|
+
spec.components.schemas[schemaKey] = modelToSchema(model);
|
|
113
119
|
}
|
|
114
120
|
const usedTags = [];
|
|
115
121
|
const seenIds = /* @__PURE__ */ new Set();
|
|
@@ -136,11 +142,11 @@ function generate(routes, models = []) {
|
|
|
136
142
|
if (route.meta?.deprecated) operation.deprecated = true;
|
|
137
143
|
const pathParams = extractPathParams(route.pattern);
|
|
138
144
|
if (pathParams.length > 0) {
|
|
139
|
-
operation.parameters = pathParams.map((name) => ({
|
|
145
|
+
operation.parameters = pathParams.map(({ name, schema }) => ({
|
|
140
146
|
name,
|
|
141
147
|
in: "path",
|
|
142
148
|
required: true,
|
|
143
|
-
schema
|
|
149
|
+
schema
|
|
144
150
|
}));
|
|
145
151
|
}
|
|
146
152
|
if (method === "get" && !route.pattern.includes("[id]") && !route.pattern.includes("[...")) {
|
|
@@ -166,19 +172,20 @@ function generate(routes, models = []) {
|
|
|
166
172
|
};
|
|
167
173
|
} else if (method === "post" || method === "put") {
|
|
168
174
|
const modelName = inferModelFromPath(route.pattern);
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
};
|
|
175
|
+
const schemaKey = modelName ? tableToSchema.get(modelName) : void 0;
|
|
176
|
+
if (schemaKey) {
|
|
177
|
+
const sref = `#/components/schemas/${schemaKey}`;
|
|
178
|
+
const media = { schema: { $ref: sref } };
|
|
173
179
|
if (route.meta?.example !== void 0) media.example = route.meta.example;
|
|
174
180
|
operation.requestBody = {
|
|
175
181
|
required: true,
|
|
176
182
|
content: { "application/json": media }
|
|
177
183
|
};
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
184
|
+
if (route.meta?.responses === void 0) {
|
|
185
|
+
operation.responses = {
|
|
186
|
+
"200": { description: "Successful response", content: { "application/json": { schema: { $ref: sref } } } }
|
|
187
|
+
};
|
|
188
|
+
}
|
|
182
189
|
} else if (route.meta?.example !== void 0) {
|
|
183
190
|
operation.requestBody = {
|
|
184
191
|
content: { "application/json": { schema: inferSchema(route.meta.example), example: route.meta.example } }
|
|
@@ -265,6 +272,21 @@ function resolveServers() {
|
|
|
265
272
|
const dev = (process.env.SWAGGER_DEV_URL ?? "").trim();
|
|
266
273
|
return dev.length > 0 ? [{ url: dev }] : [{ url: "/" }];
|
|
267
274
|
}
|
|
275
|
+
function schemaNameForModel(model) {
|
|
276
|
+
const explicit = model.className?.trim();
|
|
277
|
+
if (explicit) return explicit;
|
|
278
|
+
return deriveClassName(model.tableName);
|
|
279
|
+
}
|
|
280
|
+
function deriveClassName(tableName) {
|
|
281
|
+
return singularize(tableName).split(/[_\s-]+/).filter(Boolean).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join("") || tableName;
|
|
282
|
+
}
|
|
283
|
+
function singularize(word) {
|
|
284
|
+
if (/ies$/i.test(word) && word.length > 3) return word.slice(0, -3) + "y";
|
|
285
|
+
if (/(ses|xes|zes|ches|shes)$/i.test(word)) return word.slice(0, -2);
|
|
286
|
+
if (/ss$/i.test(word)) return word;
|
|
287
|
+
if (/s$/i.test(word) && word.length > 1) return word.slice(0, -1);
|
|
288
|
+
return word;
|
|
289
|
+
}
|
|
268
290
|
function modelToSchema(model) {
|
|
269
291
|
const properties = {};
|
|
270
292
|
const required = [];
|
|
@@ -338,15 +360,47 @@ function inferSchema(value) {
|
|
|
338
360
|
if (typeof value === "number") return { type: Number.isInteger(value) ? "integer" : "number" };
|
|
339
361
|
return { type: "string" };
|
|
340
362
|
}
|
|
363
|
+
var PARAM_TYPE_SCHEMA = {
|
|
364
|
+
int: { type: "integer" },
|
|
365
|
+
integer: { type: "integer" },
|
|
366
|
+
float: { type: "number" },
|
|
367
|
+
number: { type: "number" },
|
|
368
|
+
uuid: { type: "string", format: "uuid" },
|
|
369
|
+
slug: { type: "string", pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$" },
|
|
370
|
+
alpha: { type: "string", pattern: "^[A-Za-z]+$" },
|
|
371
|
+
alnum: { type: "string", pattern: "^[A-Za-z0-9]+$" },
|
|
372
|
+
path: { type: "string" },
|
|
373
|
+
string: { type: "string" }
|
|
374
|
+
};
|
|
375
|
+
function segmentParam(segment) {
|
|
376
|
+
if (segment.startsWith("{") && segment.endsWith("}")) {
|
|
377
|
+
const inner = segment.slice(1, -1);
|
|
378
|
+
if (inner.startsWith("...")) return { name: inner.slice(3), type: "string" };
|
|
379
|
+
const colon = inner.indexOf(":");
|
|
380
|
+
if (colon >= 0) return { name: inner.slice(0, colon), type: inner.slice(colon + 1) };
|
|
381
|
+
return { name: inner, type: "string" };
|
|
382
|
+
}
|
|
383
|
+
if (segment.startsWith("[") && segment.endsWith("]")) {
|
|
384
|
+
const inner = segment.slice(1, -1);
|
|
385
|
+
return { name: inner.startsWith("...") ? inner.slice(3) : inner, type: "string" };
|
|
386
|
+
}
|
|
387
|
+
if (segment.startsWith(":") && segment.length > 1) {
|
|
388
|
+
return { name: segment.slice(1), type: "string" };
|
|
389
|
+
}
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
341
392
|
function patternToOpenAPI(pattern) {
|
|
342
|
-
return pattern.
|
|
393
|
+
return pattern.split("/").map((segment) => {
|
|
394
|
+
const p = segmentParam(segment);
|
|
395
|
+
return p ? `{${p.name}}` : segment;
|
|
396
|
+
}).join("/");
|
|
343
397
|
}
|
|
344
398
|
function extractPathParams(pattern) {
|
|
345
399
|
const params = [];
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
params.push(
|
|
400
|
+
for (const segment of pattern.split("/")) {
|
|
401
|
+
const p = segmentParam(segment);
|
|
402
|
+
if (!p) continue;
|
|
403
|
+
params.push({ name: p.name, schema: { ...PARAM_TYPE_SCHEMA[p.type] ?? { type: "string" } } });
|
|
350
404
|
}
|
|
351
405
|
return params;
|
|
352
406
|
}
|
|
@@ -368,8 +422,12 @@ function inferModelFromPath(pattern) {
|
|
|
368
422
|
if (rest.length === 1 && /^[[{]\.{0,3}\w+[\]}]$/.test(rest[0])) return candidate;
|
|
369
423
|
return null;
|
|
370
424
|
}
|
|
425
|
+
function operationIdBase(method, openApiPath) {
|
|
426
|
+
const clean = openApiPath.replace(/^\/+|\/+$/g, "").replace(/\//g, "_").replace(/\.\.\./g, "").replace(/[{}]/g, "").replace(/\*/g, "wildcard");
|
|
427
|
+
return clean ? `${method}_${clean}` : method;
|
|
428
|
+
}
|
|
371
429
|
function uniqueOperationId(method, openApiPath, seen) {
|
|
372
|
-
const base = (method
|
|
430
|
+
const base = operationIdBase(method, openApiPath);
|
|
373
431
|
let oid = base;
|
|
374
432
|
let n = 2;
|
|
375
433
|
while (seen.has(oid)) {
|
|
@@ -140,8 +140,12 @@ export function generate(
|
|
|
140
140
|
): OpenAPISpec {
|
|
141
141
|
const info: OpenAPISpecInfo = {
|
|
142
142
|
title: process.env.TINA4_SWAGGER_TITLE ?? "Tina4 API",
|
|
143
|
-
version
|
|
144
|
-
|
|
143
|
+
// The app's version, defaulting to 1.0.0 — NOT the framework's (Node shipped
|
|
144
|
+
// 0.0.1). description defaults to the empty string, not a canned sentence.
|
|
145
|
+
// Both are the settled cross-framework defaults (parity with the Python
|
|
146
|
+
// master); TINA4_SWAGGER_VERSION / _DESCRIPTION still override.
|
|
147
|
+
version: process.env.TINA4_SWAGGER_VERSION ?? "1.0.0",
|
|
148
|
+
description: process.env.TINA4_SWAGGER_DESCRIPTION ?? "",
|
|
145
149
|
};
|
|
146
150
|
|
|
147
151
|
// Optional contact — email, plus name/url (the interface declares them; they
|
|
@@ -188,10 +192,15 @@ export function generate(
|
|
|
188
192
|
// Reusable custom schemas referenced by routes via meta.requestSchema/responseSchemas.
|
|
189
193
|
const refSchemas = new Set<string>();
|
|
190
194
|
|
|
191
|
-
// Generate schemas from models
|
|
195
|
+
// Generate schemas from models, keyed by the model CLASS name ('Item'), which
|
|
196
|
+
// is the type name a generated client wants — never the tableName ('items').
|
|
197
|
+
// `tableToSchema` maps a path-inferred tableName back to that schema key so a
|
|
198
|
+
// POST/PUT request body $ref resolves to the same 'Item' entry.
|
|
199
|
+
const tableToSchema = new Map<string, string>();
|
|
192
200
|
for (const model of models) {
|
|
193
|
-
const
|
|
194
|
-
|
|
201
|
+
const schemaKey = schemaNameForModel(model);
|
|
202
|
+
tableToSchema.set(model.tableName, schemaKey);
|
|
203
|
+
spec.components!.schemas![schemaKey] = modelToSchema(model);
|
|
195
204
|
}
|
|
196
205
|
|
|
197
206
|
const usedTags: string[] = [];
|
|
@@ -224,14 +233,18 @@ export function generate(
|
|
|
224
233
|
if (route.meta?.description) operation.description = route.meta.description;
|
|
225
234
|
if (route.meta?.deprecated) operation.deprecated = true;
|
|
226
235
|
|
|
227
|
-
// Add path parameters
|
|
236
|
+
// Add path parameters. Each typed token maps to its JSON Schema fragment
|
|
237
|
+
// (int -> integer, uuid -> string+format, slug -> string+pattern, ...) via
|
|
238
|
+
// PARAM_TYPE_SCHEMA below, mirroring the Python master's _PARAM_TYPE_SCHEMA
|
|
239
|
+
// and the router's accepted token set. An unknown token degrades to string
|
|
240
|
+
// rather than dropping the parameter, and the token never reaches the key.
|
|
228
241
|
const pathParams = extractPathParams(route.pattern);
|
|
229
242
|
if (pathParams.length > 0) {
|
|
230
|
-
operation.parameters = pathParams.map((name) => ({
|
|
243
|
+
operation.parameters = pathParams.map(({ name, schema }) => ({
|
|
231
244
|
name,
|
|
232
245
|
in: "path",
|
|
233
246
|
required: true,
|
|
234
|
-
schema
|
|
247
|
+
schema,
|
|
235
248
|
}));
|
|
236
249
|
}
|
|
237
250
|
|
|
@@ -263,23 +276,27 @@ export function generate(
|
|
|
263
276
|
};
|
|
264
277
|
} else if (method === "post" || method === "put") {
|
|
265
278
|
const modelName = inferModelFromPath(route.pattern);
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
};
|
|
279
|
+
const schemaKey = modelName ? tableToSchema.get(modelName) : undefined;
|
|
280
|
+
if (schemaKey) {
|
|
281
|
+
const sref = `#/components/schemas/${schemaKey}`;
|
|
282
|
+
const media: Record<string, unknown> = { schema: { $ref: sref } };
|
|
270
283
|
if (route.meta?.example !== undefined) media.example = route.meta.example;
|
|
271
284
|
operation.requestBody = {
|
|
272
285
|
required: true,
|
|
273
286
|
content: { "application/json": media },
|
|
274
287
|
};
|
|
275
288
|
|
|
276
|
-
//
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
289
|
+
// Response documents 200 with the resource schema — parity with the
|
|
290
|
+
// Python master, which emits ONLY 200 for a model write. The old code
|
|
291
|
+
// stamped an unconditional 422 and a 201 the generator has no way to know
|
|
292
|
+
// a given route returns; both were fiction on a path-inferred write. A
|
|
293
|
+
// route that genuinely answers another code declares it via
|
|
294
|
+
// meta.responses, which is honoured above and never clobbered here.
|
|
295
|
+
if (route.meta?.responses === undefined) {
|
|
296
|
+
operation.responses = {
|
|
297
|
+
"200": { description: "Successful response", content: { "application/json": { schema: { $ref: sref } } } },
|
|
298
|
+
};
|
|
299
|
+
}
|
|
283
300
|
} else if (route.meta?.example !== undefined) {
|
|
284
301
|
// Non-model body with an explicit example.
|
|
285
302
|
operation.requestBody = {
|
|
@@ -394,6 +411,36 @@ function resolveServers(): { url: string }[] {
|
|
|
394
411
|
return dev.length > 0 ? [{ url: dev }] : [{ url: "/" }];
|
|
395
412
|
}
|
|
396
413
|
|
|
414
|
+
/**
|
|
415
|
+
* The `components.schemas` key for a model: its CLASS name when carried from
|
|
416
|
+
* discovery (`ModelClass.name`, e.g. `Item`), else a singular PascalCase
|
|
417
|
+
* derivation of the tableName so a raw `{tableName, fields}` still yields a
|
|
418
|
+
* client-friendly type name ('items' -> 'Item').
|
|
419
|
+
*/
|
|
420
|
+
function schemaNameForModel(model: ModelDefinition): string {
|
|
421
|
+
const explicit = model.className?.trim();
|
|
422
|
+
if (explicit) return explicit;
|
|
423
|
+
return deriveClassName(model.tableName);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** 'items' -> 'Item', 'blog_posts' -> 'BlogPost', 'categories' -> 'Category'. */
|
|
427
|
+
function deriveClassName(tableName: string): string {
|
|
428
|
+
return singularize(tableName)
|
|
429
|
+
.split(/[_\s-]+/)
|
|
430
|
+
.filter(Boolean)
|
|
431
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
432
|
+
.join("") || tableName;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Best-effort English singularisation for the common plural-table convention. */
|
|
436
|
+
function singularize(word: string): string {
|
|
437
|
+
if (/ies$/i.test(word) && word.length > 3) return word.slice(0, -3) + "y";
|
|
438
|
+
if (/(ses|xes|zes|ches|shes)$/i.test(word)) return word.slice(0, -2);
|
|
439
|
+
if (/ss$/i.test(word)) return word; // "class"/"address" stay as-is
|
|
440
|
+
if (/s$/i.test(word) && word.length > 1) return word.slice(0, -1);
|
|
441
|
+
return word;
|
|
442
|
+
}
|
|
443
|
+
|
|
397
444
|
function modelToSchema(model: ModelDefinition): Record<string, unknown> {
|
|
398
445
|
const properties: Record<string, unknown> = {};
|
|
399
446
|
const required: string[] = [];
|
|
@@ -479,16 +526,93 @@ function inferSchema(value: unknown): Record<string, unknown> {
|
|
|
479
526
|
return { type: "string" };
|
|
480
527
|
}
|
|
481
528
|
|
|
529
|
+
/**
|
|
530
|
+
* Path-param type token -> JSON Schema fragment. Mirrors the Python master's
|
|
531
|
+
* `_PARAM_TYPE_SCHEMA` (tina4_python/swagger/__init__.py) and the router's
|
|
532
|
+
* `PARAM_TYPE_PATTERNS`, so the documented contract matches EXACTLY what the
|
|
533
|
+
* router accepts. `int`/`integer` -> integer, `float`/`number` -> number,
|
|
534
|
+
* `uuid` -> string+format, `slug`/`alpha`/`alnum` -> string+pattern, bare/`path`
|
|
535
|
+
* -> string. A token NOT in this table degrades to `{type:"string"}` (see
|
|
536
|
+
* `extractPathParams`) rather than dropping the parameter.
|
|
537
|
+
*/
|
|
538
|
+
const PARAM_TYPE_SCHEMA: Record<string, Record<string, unknown>> = {
|
|
539
|
+
int: { type: "integer" },
|
|
540
|
+
integer: { type: "integer" },
|
|
541
|
+
float: { type: "number" },
|
|
542
|
+
number: { type: "number" },
|
|
543
|
+
uuid: { type: "string", format: "uuid" },
|
|
544
|
+
slug: { type: "string", pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$" },
|
|
545
|
+
alpha: { type: "string", pattern: "^[A-Za-z]+$" },
|
|
546
|
+
alnum: { type: "string", pattern: "^[A-Za-z0-9]+$" },
|
|
547
|
+
path: { type: "string" },
|
|
548
|
+
string: { type: "string" },
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* The (name, type) of a single dynamic path segment, or null for a static one.
|
|
553
|
+
*
|
|
554
|
+
* Accepts EVERY shape a route pattern can carry: the brace form `{id}` /
|
|
555
|
+
* `{id:int}` / `{...slug}` (the runtime/programmatic spelling), the file-system
|
|
556
|
+
* form `[id]` / `[...slug]` (route discovery converts these to `{...}` before a
|
|
557
|
+
* route is registered, but `generate()` is public and a caller may hand either),
|
|
558
|
+
* and the Express form `:id`. A catch-all (`...`) carries no type token, so it
|
|
559
|
+
* is always a string.
|
|
560
|
+
*/
|
|
561
|
+
function segmentParam(segment: string): { name: string; type: string } | null {
|
|
562
|
+
if (segment.startsWith("{") && segment.endsWith("}")) {
|
|
563
|
+
const inner = segment.slice(1, -1);
|
|
564
|
+
if (inner.startsWith("...")) return { name: inner.slice(3), type: "string" };
|
|
565
|
+
const colon = inner.indexOf(":");
|
|
566
|
+
if (colon >= 0) return { name: inner.slice(0, colon), type: inner.slice(colon + 1) };
|
|
567
|
+
return { name: inner, type: "string" };
|
|
568
|
+
}
|
|
569
|
+
if (segment.startsWith("[") && segment.endsWith("]")) {
|
|
570
|
+
const inner = segment.slice(1, -1);
|
|
571
|
+
return { name: inner.startsWith("...") ? inner.slice(3) : inner, type: "string" };
|
|
572
|
+
}
|
|
573
|
+
if (segment.startsWith(":") && segment.length > 1) {
|
|
574
|
+
return { name: segment.slice(1), type: "string" };
|
|
575
|
+
}
|
|
576
|
+
return null;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* The OpenAPI path key for a route pattern: every dynamic segment collapses to a
|
|
581
|
+
* bare `{name}`, so the type token NEVER leaks into the key (PHP shipped
|
|
582
|
+
* `/api/typed/{id:int}` as the key — invalid, and a mismatch with the declared
|
|
583
|
+
* parameter). File-system `[id]`/`[...slug]`, runtime `{id:int}`/`{...slug}` and
|
|
584
|
+
* Express `:id` all normalise to `{name}`.
|
|
585
|
+
*/
|
|
482
586
|
function patternToOpenAPI(pattern: string): string {
|
|
483
|
-
return pattern
|
|
587
|
+
return pattern
|
|
588
|
+
.split("/")
|
|
589
|
+
.map((segment) => {
|
|
590
|
+
const p = segmentParam(segment);
|
|
591
|
+
return p ? `{${p.name}}` : segment;
|
|
592
|
+
})
|
|
593
|
+
.join("/");
|
|
484
594
|
}
|
|
485
595
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
596
|
+
/**
|
|
597
|
+
* Every dynamic path parameter of a route pattern as a `{name, schema}` pair.
|
|
598
|
+
*
|
|
599
|
+
* This once matched only `[id]` - the FILE-SYSTEM spelling - while route
|
|
600
|
+
* discovery had already converted `[id]` to `{id}`, so at runtime the regex ran
|
|
601
|
+
* against `{id}` looking for `[id]` and returned nothing: every operation
|
|
602
|
+
* shipped with no `in: path` parameter, an invalid document (measured against
|
|
603
|
+
* openapi-spec-validator), unconditionally, because the framework registers
|
|
604
|
+
* `/__frond/live/{name}` itself. Beyond that first fix, a TYPED token like
|
|
605
|
+
* `{id:int}` was still dropped (the name-only regex did not match past the
|
|
606
|
+
* colon) and its type leaked into the path key. This segment-based walk resolves
|
|
607
|
+
* both spellings AND the type token, mapping it through PARAM_TYPE_SCHEMA so the
|
|
608
|
+
* documented type matches the route the router compiled.
|
|
609
|
+
*/
|
|
610
|
+
function extractPathParams(pattern: string): Array<{ name: string; schema: Record<string, unknown> }> {
|
|
611
|
+
const params: Array<{ name: string; schema: Record<string, unknown> }> = [];
|
|
612
|
+
for (const segment of pattern.split("/")) {
|
|
613
|
+
const p = segmentParam(segment);
|
|
614
|
+
if (!p) continue;
|
|
615
|
+
params.push({ name: p.name, schema: { ...(PARAM_TYPE_SCHEMA[p.type] ?? { type: "string" }) } });
|
|
492
616
|
}
|
|
493
617
|
return params;
|
|
494
618
|
}
|
|
@@ -517,10 +641,29 @@ function inferModelFromPath(pattern: string): string | null {
|
|
|
517
641
|
return null;
|
|
518
642
|
}
|
|
519
643
|
|
|
644
|
+
/**
|
|
645
|
+
* operationId base from method + path, mirroring the Python master's
|
|
646
|
+
* `_operation_id`: strip leading/trailing slashes, turn internal `/` into `_`,
|
|
647
|
+
* drop braces, catch-all `...` and a bare splat `*` -> `wildcard`.
|
|
648
|
+
*
|
|
649
|
+
* Underscores are deliberately NOT collapsed. Collapsing made `/__health` and
|
|
650
|
+
* `/health` both reduce to `get_health`, so one got a `_2` suffix and WHICH one
|
|
651
|
+
* depended on registration order — a generated client's method name flipping
|
|
652
|
+
* between builds. Preserving them yields `get___health` vs `get_health`, two
|
|
653
|
+
* distinct ids straight from the two distinct paths.
|
|
654
|
+
*/
|
|
655
|
+
function operationIdBase(method: string, openApiPath: string): string {
|
|
656
|
+
const clean = openApiPath
|
|
657
|
+
.replace(/^\/+|\/+$/g, "") // strip leading/trailing slashes (Python .strip("/"))
|
|
658
|
+
.replace(/\//g, "_") // internal slash -> underscore
|
|
659
|
+
.replace(/\.\.\./g, "") // catch-all '...' inside braces -> nothing ({...slug} -> slug)
|
|
660
|
+
.replace(/[{}]/g, "") // drop braces
|
|
661
|
+
.replace(/\*/g, "wildcard"); // bare splat -> wildcard
|
|
662
|
+
return clean ? `${method}_${clean}` : method;
|
|
663
|
+
}
|
|
664
|
+
|
|
520
665
|
function uniqueOperationId(method: string, openApiPath: string, seen: Set<string>): string {
|
|
521
|
-
const base = (method
|
|
522
|
-
.replace(/_+/g, "_")
|
|
523
|
-
.replace(/_$/, "");
|
|
666
|
+
const base = operationIdBase(method, openApiPath);
|
|
524
667
|
let oid = base;
|
|
525
668
|
let n = 2;
|
|
526
669
|
while (seen.has(oid)) {
|
package/types/core/src/ai.d.ts
CHANGED
|
@@ -61,4 +61,4 @@ export declare function writeOrMerge(contextPath: string, contextFile: string, f
|
|
|
61
61
|
* Generate the Tina4 context document for a specific AI tool.
|
|
62
62
|
*/
|
|
63
63
|
export declare function generateContext(toolName?: string): string;
|
|
64
|
-
export { AiTool as AiToolType };
|
|
64
|
+
export type { AiTool as AiToolType };
|
package/types/core/src/auth.d.ts
CHANGED
|
@@ -16,6 +16,18 @@ import type { Middleware } from "./types.js";
|
|
|
16
16
|
* @returns The newly-generated secret, or null when nothing was generated.
|
|
17
17
|
*/
|
|
18
18
|
export declare function ensureDevSecret(cwd?: string): string | null;
|
|
19
|
+
/**
|
|
20
|
+
* Can this runtime actually sign and verify `algorithm` right now?
|
|
21
|
+
*
|
|
22
|
+
* The cross-framework capability check — same question, same answer shape, in
|
|
23
|
+
* all four frameworks. HMAC answers `true` everywhere. RS256 answers `true`
|
|
24
|
+
* only where the runtime ships asymmetric crypto natively (node:crypto here,
|
|
25
|
+
* core ext-openssl in PHP, the stdlib openssl gem in Ruby) and `false` in
|
|
26
|
+
* tina4-python. An algorithm Tina4 does not know at all answers `false`.
|
|
27
|
+
*/
|
|
28
|
+
export declare function algorithmAvailable(algorithm: string): boolean;
|
|
29
|
+
/** Every algorithm this runtime can sign and verify right now, in advertised order. */
|
|
30
|
+
export declare function availableAlgorithms(): string[];
|
|
19
31
|
/**
|
|
20
32
|
* Seconds of clock skew tolerated on the "nbf" (not-before) claim.
|
|
21
33
|
*
|
|
@@ -27,8 +39,12 @@ export declare const JWT_LEEWAY_SECONDS = 60;
|
|
|
27
39
|
/**
|
|
28
40
|
* Pick the JWT algorithm: explicit argument, else TINA4_JWT_ALGORITHM, else HS256.
|
|
29
41
|
*
|
|
30
|
-
* Throws
|
|
31
|
-
*
|
|
42
|
+
* Throws when asked for an algorithm Tina4 does not know (naming the known set,
|
|
43
|
+
* what is available here, and the env var), and throws again — with the runtime's
|
|
44
|
+
* own reason and a remedy — when it knows the algorithm but this build cannot
|
|
45
|
+
* provide it. A silent downgrade to HS256 is the whole bug in python#106, and a
|
|
46
|
+
* silent downgrade from RS256 would be worse: it would quietly turn asymmetric
|
|
47
|
+
* verification into a shared secret.
|
|
32
48
|
*
|
|
33
49
|
* @param algorithm - Explicit algorithm; wins over the environment when given.
|
|
34
50
|
*/
|
|
@@ -37,8 +53,11 @@ export declare function resolveAlgorithm(algorithm?: string): string;
|
|
|
37
53
|
* Create a signed JWT token.
|
|
38
54
|
*
|
|
39
55
|
* Secret is always read from `process.env.TINA4_SECRET`.
|
|
40
|
-
* Algorithm is read from `process.env.TINA4_JWT_ALGORITHM` (default "HS256")
|
|
41
|
-
* HS256 / HS384 / HS512
|
|
56
|
+
* Algorithm is read from `process.env.TINA4_JWT_ALGORITHM` (default "HS256").
|
|
57
|
+
* HS256 / HS384 / HS512 is the cross-framework standard; RS256 is an opt-in
|
|
58
|
+
* extra that Node provides from builtin node:crypto (pass the PEM private key
|
|
59
|
+
* as the secret). An unknown algorithm, or one this runtime cannot provide,
|
|
60
|
+
* throws — see `resolveAlgorithm`.
|
|
42
61
|
*
|
|
43
62
|
* The header's `alg` is always the algorithm that actually signed the token.
|
|
44
63
|
*
|
|
@@ -117,7 +136,8 @@ export declare function refreshToken(token: string, expiresIn?: number): string
|
|
|
117
136
|
*
|
|
118
137
|
* @param headers - Object with header keys (e.g. `{ authorization: "Bearer ..." }`)
|
|
119
138
|
* @param secret - HMAC secret or PEM public key
|
|
120
|
-
* @param algorithm -
|
|
139
|
+
* @param algorithm - Omit it to honour TINA4_JWT_ALGORITHM (then HS256). HS256 /
|
|
140
|
+
* HS384 / HS512 everywhere; RS256 where the runtime provides it (it does here).
|
|
121
141
|
* @returns Decoded payload, or null if missing/invalid
|
|
122
142
|
*/
|
|
123
143
|
export declare function authenticateRequest(headers: Record<string, string | string[] | undefined>, secret?: string, algorithm?: string): Record<string, unknown> | null;
|
|
@@ -144,6 +164,9 @@ export declare function validateApiKey(provided: string, expected?: string): boo
|
|
|
144
164
|
export declare class Auth {
|
|
145
165
|
static getToken: typeof getToken;
|
|
146
166
|
static validToken: typeof validToken;
|
|
167
|
+
static resolveAlgorithm: typeof resolveAlgorithm;
|
|
168
|
+
static algorithmAvailable: typeof algorithmAvailable;
|
|
169
|
+
static availableAlgorithms: typeof availableAlgorithms;
|
|
147
170
|
static getPayload: typeof getPayload;
|
|
148
171
|
static hashPassword: typeof hashPassword;
|
|
149
172
|
static checkPassword: typeof checkPassword;
|
|
@@ -25,9 +25,9 @@ export declare function background(callback: () => unknown | Promise<unknown>, i
|
|
|
25
25
|
stop: () => void;
|
|
26
26
|
};
|
|
27
27
|
/**
|
|
28
|
-
* Clear every registered background task. Called
|
|
29
|
-
*
|
|
30
|
-
* the timer wheel along with
|
|
28
|
+
* Clear every registered background task. Called by the server's graceful
|
|
29
|
+
* shutdown (its first step on SIGTERM/SIGINT) and by its `close()`, so both a
|
|
30
|
+
* signal and a manual shutdown stop the timer wheel along with the listeners.
|
|
31
31
|
*/
|
|
32
32
|
export declare function stopAllBackgroundTasks(): void;
|
|
33
33
|
/** Number of currently-registered background tasks (test helper). */
|