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.
Files changed (123) hide show
  1. package/CLAUDE.md +158 -30
  2. package/README.md +1 -1
  3. package/package.json +3 -1
  4. package/packages/cli/dist/bin.js +30911 -28444
  5. package/packages/cli/src/commands/metrics.ts +17 -11
  6. package/packages/cli/src/commands/serve.ts +10 -9
  7. package/packages/core/dist/index.js +30810 -28261
  8. package/packages/core/public/css/tina4.min.css +1 -1
  9. package/packages/core/src/ai.ts +7 -1
  10. package/packages/core/src/auth.ts +191 -39
  11. package/packages/core/src/background.ts +19 -19
  12. package/packages/core/src/cache.ts +492 -49
  13. package/packages/core/src/devAdmin.ts +79 -32
  14. package/packages/core/src/dispatchPipeline.ts +285 -0
  15. package/packages/core/src/dotenv.ts +185 -40
  16. package/packages/core/src/index.ts +6 -7
  17. package/packages/core/src/logger.ts +257 -36
  18. package/packages/core/src/mcp.ts +1 -1
  19. package/packages/core/src/messenger.ts +294 -106
  20. package/packages/core/src/metrics.ts +199 -961
  21. package/packages/core/src/middleware.ts +390 -123
  22. package/packages/core/src/queue.ts +188 -32
  23. package/packages/core/src/queueBackends/kafkaBackend.ts +1 -1
  24. package/packages/core/src/queueBackends/liteBackend.ts +13 -0
  25. package/packages/core/src/queueBackends/mongoBackend.ts +101 -9
  26. package/packages/core/src/queueBackends/rabbitmqBackend.ts +22 -4
  27. package/packages/core/src/rateLimiter.ts +10 -5
  28. package/packages/core/src/request.ts +34 -16
  29. package/packages/core/src/response.ts +46 -1
  30. package/packages/core/src/router.ts +29 -4
  31. package/packages/core/src/server.ts +886 -421
  32. package/packages/core/src/session.ts +244 -27
  33. package/packages/core/src/sessionHandlers/databaseHandler.ts +338 -48
  34. package/packages/core/src/sessionHandlers/memcachedHandler.ts +181 -0
  35. package/packages/core/src/sessionHandlers/mongoClient.ts +293 -208
  36. package/packages/core/src/sessionHandlers/mongoHandler.ts +88 -8
  37. package/packages/core/src/sessionHandlers/respClient.ts +16 -147
  38. package/packages/core/src/sessionHandlers/sqlClient.ts +290 -0
  39. package/packages/core/src/sessionHandlers/syncBridge.ts +190 -0
  40. package/packages/core/src/sessionHandlers/syncSocket.ts +236 -0
  41. package/packages/core/src/testClient.ts +18 -5
  42. package/packages/core/src/trustedProxy.ts +249 -0
  43. package/packages/core/src/types.ts +29 -5
  44. package/packages/core/src/websocket.ts +66 -0
  45. package/packages/orm/dist/index.js +22717 -20168
  46. package/packages/orm/src/adapters/firebird.ts +183 -56
  47. package/packages/orm/src/adapters/mongodb.ts +25 -4
  48. package/packages/orm/src/adapters/mssql.ts +114 -29
  49. package/packages/orm/src/adapters/mysql.ts +103 -40
  50. package/packages/orm/src/adapters/odbc.ts +44 -21
  51. package/packages/orm/src/adapters/postgres.ts +118 -26
  52. package/packages/orm/src/adapters/sqlDialect.ts +120 -0
  53. package/packages/orm/src/adapters/sqlite.ts +60 -24
  54. package/packages/orm/src/autoCrud.ts +12 -10
  55. package/packages/orm/src/baseModel.ts +135 -40
  56. package/packages/orm/src/cachedDatabase.ts +43 -19
  57. package/packages/orm/src/connectTimeout.ts +265 -0
  58. package/packages/orm/src/database.ts +241 -197
  59. package/packages/orm/src/databaseResult.ts +51 -28
  60. package/packages/orm/src/databaseUrl.ts +484 -0
  61. package/packages/orm/src/docstore.ts +386 -145
  62. package/packages/orm/src/index.ts +13 -6
  63. package/packages/orm/src/migration.ts +44 -11
  64. package/packages/orm/src/model.ts +4 -0
  65. package/packages/orm/src/queryBuilder.ts +47 -6
  66. package/packages/orm/src/sqlTranslator.ts +310 -4
  67. package/packages/orm/src/types.ts +21 -77
  68. package/packages/swagger/dist/index.js +78 -20
  69. package/packages/swagger/src/generator.ts +172 -29
  70. package/types/core/src/ai.d.ts +1 -1
  71. package/types/core/src/auth.d.ts +28 -5
  72. package/types/core/src/background.d.ts +3 -3
  73. package/types/core/src/cache.d.ts +15 -12
  74. package/types/core/src/dispatchPipeline.d.ts +117 -0
  75. package/types/core/src/dotenv.d.ts +38 -16
  76. package/types/core/src/index.d.ts +6 -9
  77. package/types/core/src/logger.d.ts +93 -16
  78. package/types/core/src/messenger.d.ts +47 -6
  79. package/types/core/src/metrics.d.ts +25 -61
  80. package/types/core/src/middleware.d.ts +134 -11
  81. package/types/core/src/queue.d.ts +54 -5
  82. package/types/core/src/queueBackends/kafkaBackend.d.ts +1 -1
  83. package/types/core/src/queueBackends/liteBackend.d.ts +9 -0
  84. package/types/core/src/queueBackends/mongoBackend.d.ts +24 -2
  85. package/types/core/src/queueBackends/rabbitmqBackend.d.ts +3 -3
  86. package/types/core/src/router.d.ts +14 -3
  87. package/types/core/src/server.d.ts +15 -4
  88. package/types/core/src/session.d.ts +87 -2
  89. package/types/core/src/sessionHandlers/databaseHandler.d.ts +60 -5
  90. package/types/core/src/sessionHandlers/memcachedHandler.d.ts +60 -0
  91. package/types/core/src/sessionHandlers/mongoClient.d.ts +16 -5
  92. package/types/core/src/sessionHandlers/mongoHandler.d.ts +51 -3
  93. package/types/core/src/sessionHandlers/respClient.d.ts +2 -2
  94. package/types/core/src/sessionHandlers/sqlClient.d.ts +39 -0
  95. package/types/core/src/sessionHandlers/syncBridge.d.ts +91 -0
  96. package/types/core/src/sessionHandlers/syncSocket.d.ts +49 -0
  97. package/types/core/src/trustedProxy.d.ts +44 -0
  98. package/types/core/src/types.d.ts +28 -5
  99. package/types/core/src/websocket.d.ts +26 -0
  100. package/types/orm/src/adapters/firebird.d.ts +55 -10
  101. package/types/orm/src/adapters/mongodb.d.ts +2 -2
  102. package/types/orm/src/adapters/mssql.d.ts +18 -11
  103. package/types/orm/src/adapters/mysql.d.ts +11 -10
  104. package/types/orm/src/adapters/odbc.d.ts +9 -12
  105. package/types/orm/src/adapters/postgres.d.ts +11 -10
  106. package/types/orm/src/adapters/sqlDialect.d.ts +71 -0
  107. package/types/orm/src/adapters/sqlite.d.ts +15 -3
  108. package/types/orm/src/baseModel.d.ts +45 -9
  109. package/types/orm/src/cachedDatabase.d.ts +18 -5
  110. package/types/orm/src/connectTimeout.d.ts +100 -0
  111. package/types/orm/src/database.d.ts +78 -28
  112. package/types/orm/src/databaseResult.d.ts +29 -15
  113. package/types/orm/src/databaseUrl.d.ts +125 -0
  114. package/types/orm/src/docstore.d.ts +102 -43
  115. package/types/orm/src/index.d.ts +6 -4
  116. package/types/orm/src/migration.d.ts +4 -3
  117. package/types/orm/src/queryBuilder.d.ts +23 -3
  118. package/types/orm/src/sqlTranslator.d.ts +126 -2
  119. package/types/orm/src/types.d.ts +21 -38
  120. package/packages/core/src/scss.ts +0 -623
  121. package/packages/core/src/sessionHandlers/redisHandler.ts +0 -219
  122. package/types/core/src/scss.d.ts +0 -19
  123. 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>, params?: unknown[]): DatabaseResult;
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
- tables(): string[];
103
+ getTables(): string[];
97
104
 
98
105
  /** List columns with types for a table. */
99
- columns(table: string): ColumnInfo[];
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
- export interface PaginatedResult<T = Record<string, unknown>> {
121
- data: T[];
122
- page: number;
123
- perPage: number;
124
- total: number;
125
- totalPages: number;
126
- hasNext: boolean;
127
- hasPrev: boolean;
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: process.env.TINA4_SWAGGER_VERSION ?? "0.0.1",
77
- description: process.env.TINA4_SWAGGER_DESCRIPTION ?? "Auto-generated API documentation"
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 schema = modelToSchema(model);
112
- spec.components.schemas[model.tableName] = schema;
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: { type: "string" }
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
- if (modelName && models.some((m) => m.tableName === modelName)) {
170
- const media = {
171
- schema: { $ref: `#/components/schemas/${modelName}` }
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
- operation.responses = {
179
- ...method === "post" ? { "201": { description: "Created", content: { "application/json": { schema: { $ref: `#/components/schemas/${modelName}` } } } } } : { "200": { description: "Updated", content: { "application/json": { schema: { $ref: `#/components/schemas/${modelName}` } } } } },
180
- "422": { description: "Validation failed" }
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.replace(/\[\.\.\.(\w+)\]/g, "{$1}").replace(/\[(\w+)\]/g, "{$1}");
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 regex = /\[(?:\.\.\.)?(\w+)\]/g;
347
- let match;
348
- while ((match = regex.exec(pattern)) !== null) {
349
- params.push(match[1]);
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 + openApiPath.replace(/[/{}]/g, "_")).replace(/_+/g, "_").replace(/_$/, "");
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: process.env.TINA4_SWAGGER_VERSION ?? "0.0.1",
144
- description: process.env.TINA4_SWAGGER_DESCRIPTION ?? "Auto-generated API documentation",
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 schema = modelToSchema(model);
194
- spec.components!.schemas![model.tableName] = schema;
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: { type: "string" },
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
- if (modelName && models.some((m) => m.tableName === modelName)) {
267
- const media: Record<string, unknown> = {
268
- schema: { $ref: `#/components/schemas/${modelName}` },
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
- // Add response schema
277
- operation.responses = {
278
- ...(method === "post"
279
- ? { "201": { description: "Created", content: { "application/json": { schema: { $ref: `#/components/schemas/${modelName}` } } } } }
280
- : { "200": { description: "Updated", content: { "application/json": { schema: { $ref: `#/components/schemas/${modelName}` } } } } }),
281
- "422": { description: "Validation failed" },
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.replace(/\[\.\.\.(\w+)\]/g, "{$1}").replace(/\[(\w+)\]/g, "{$1}");
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
- function extractPathParams(pattern: string): string[] {
487
- const params: string[] = [];
488
- const regex = /\[(?:\.\.\.)?(\w+)\]/g;
489
- let match;
490
- while ((match = regex.exec(pattern)) !== null) {
491
- params.push(match[1]);
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 + openApiPath.replace(/[/{}]/g, "_"))
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)) {
@@ -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 };
@@ -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 (naming the supported set and the env var) when asked for an algorithm
31
- * we cannot sign a silent downgrade to HS256 is the whole bug in python#106.
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 / RS256 are supported and anything else throws.
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 - "HS256" or "RS256" (default "HS256")
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 automatically on SIGTERM/SIGINT;
29
- * also called from the server's `close()` so a manual server shutdown stops
30
- * the timer wheel along with HTTP listeners.
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). */