tina4-nodejs 3.13.95 → 3.13.97
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 +3 -4
- package/package.json +2 -1
- package/packages/cli/dist/bin.js +708 -1012
- package/packages/core/dist/index.js +588 -893
- package/packages/core/public/css/tina4.min.css +1 -1
- package/packages/core/src/index.ts +1 -3
- package/packages/core/src/messenger.ts +288 -96
- package/packages/core/src/queueBackends/kafkaBackend.ts +23 -2
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +29 -17
- package/packages/core/src/request.ts +28 -7
- package/packages/core/src/server.ts +135 -7
- package/packages/core/src/session.ts +8 -1
- package/packages/orm/dist/index.js +639 -944
- package/packages/orm/src/autoCrud.ts +12 -10
- package/packages/orm/src/database.ts +62 -58
- package/packages/orm/src/databaseResult.ts +44 -73
- package/packages/orm/src/index.ts +0 -3
- package/packages/orm/src/migration.ts +26 -8
- package/packages/orm/src/model.ts +4 -0
- package/packages/orm/src/queryBuilder.ts +12 -5
- package/packages/orm/src/types.ts +7 -74
- package/packages/swagger/dist/index.js +78 -20
- package/packages/swagger/src/generator.ts +172 -29
- package/types/core/src/index.d.ts +1 -3
- package/types/core/src/messenger.d.ts +45 -4
- package/types/core/src/queueBackends/kafkaBackend.d.ts +1 -0
- package/types/core/src/queueBackends/rabbitmqBackend.d.ts +2 -1
- package/types/core/src/server.d.ts +0 -4
- package/types/core/src/session.d.ts +7 -0
- package/types/orm/src/database.d.ts +34 -30
- package/types/orm/src/databaseResult.d.ts +26 -36
- package/types/orm/src/index.d.ts +1 -2
- package/types/orm/src/migration.d.ts +4 -3
- package/types/orm/src/types.d.ts +7 -34
- package/packages/core/src/scss.ts +0 -623
- package/types/core/src/scss.d.ts +0 -19
|
@@ -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)) {
|
|
@@ -23,8 +23,6 @@ export { Session, FileSessionHandler, RedisSessionHandler, buildSessionCookie, i
|
|
|
23
23
|
export type { SessionConfig, SessionHandler } from "./session.js";
|
|
24
24
|
export { I18n } from "./i18n.js";
|
|
25
25
|
export { FakeData } from "./fakeData.js";
|
|
26
|
-
export { ScssCompiler } from "./scss.js";
|
|
27
|
-
export type { ScssConfig } from "./scss.js";
|
|
28
26
|
export { Queue } from "./queue.js";
|
|
29
27
|
export type { QueueConfig, QueueJob, ProcessOptions } from "./queue.js";
|
|
30
28
|
export { createJob } from "./job.js";
|
|
@@ -57,7 +55,7 @@ export { HtmlElement, htmlElement, addHtmlHelpers, Raw, SafeString } from "./htm
|
|
|
57
55
|
export { renderErrorOverlay, renderProductionError, isDebugMode } from "./errorOverlay.js";
|
|
58
56
|
export { AI_TOOLS, isInstalled, showMenu, installSelected, installAll, generateContext } from "./ai.js";
|
|
59
57
|
export type { AiTool } from "./ai.js";
|
|
60
|
-
export type { ImapMessage, ImapFullMessage } from "./messenger.js";
|
|
58
|
+
export type { ImapMessage, ImapFullMessage, ImapAttachment } from "./messenger.js";
|
|
61
59
|
export { LiteBackend } from "./queueBackends/liteBackend.js";
|
|
62
60
|
export { RabbitMQBackend, parseAmqpUrl } from "./queueBackends/rabbitmqBackend.js";
|
|
63
61
|
export type { RabbitMQConfig } from "./queueBackends/rabbitmqBackend.js";
|
|
@@ -2,7 +2,13 @@ import { DevMailbox } from "./devMailbox.js";
|
|
|
2
2
|
export interface SendResult {
|
|
3
3
|
success: boolean;
|
|
4
4
|
message: string;
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* The real Message-ID on success, `null` on failure — but ALWAYS present, so a
|
|
7
|
+
* caller reading `result.id` gets one shape from both branches (G6). It used to
|
|
8
|
+
* be omitted on the failure path, handing back `undefined` there and a string on
|
|
9
|
+
* success.
|
|
10
|
+
*/
|
|
11
|
+
id: string | null;
|
|
6
12
|
}
|
|
7
13
|
/**
|
|
8
14
|
* Raised when an IMAP read fails to connect, authenticate, or speak the
|
|
@@ -62,15 +68,33 @@ export interface ImapMessage {
|
|
|
62
68
|
snippet: string;
|
|
63
69
|
seen: boolean;
|
|
64
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* An attachment from a read() message. `content` is the RAW DECODED BYTES of the
|
|
73
|
+
* part (transfer-decoded from base64 / quoted-printable), the SAME convention as
|
|
74
|
+
* req.files[x].content — raw bytes, not base64 — so an attachment is downloadable
|
|
75
|
+
* as-is; `size` is that decoded byte length. Parity with Python's read()
|
|
76
|
+
* attachment dict {filename, content_type, size, content}, in Node's idiomatic
|
|
77
|
+
* camelCase (ADR-0008 / G5). #69 folded the bytes in HERE — there is no separate
|
|
78
|
+
* carrier (Python retired its attachments_data in 3.13.96).
|
|
79
|
+
*/
|
|
80
|
+
export interface ImapAttachment {
|
|
81
|
+
filename: string;
|
|
82
|
+
contentType: string;
|
|
83
|
+
size: number;
|
|
84
|
+
content: Buffer;
|
|
85
|
+
}
|
|
65
86
|
export interface ImapFullMessage {
|
|
66
87
|
uid: string;
|
|
67
88
|
subject: string;
|
|
68
89
|
from: string;
|
|
69
90
|
to: string;
|
|
70
91
|
cc: string;
|
|
92
|
+
/** ISO-8601, parsed from the Date header (parity with Python's _iso_date). */
|
|
71
93
|
date: string;
|
|
72
94
|
bodyText: string;
|
|
73
95
|
bodyHtml: string;
|
|
96
|
+
/** Attachments, each carrying its decoded bytes. Empty when the message has none (G5 / #69). */
|
|
97
|
+
attachments: ImapAttachment[];
|
|
74
98
|
headers: Record<string, string>;
|
|
75
99
|
}
|
|
76
100
|
export declare class Messenger {
|
|
@@ -116,6 +140,13 @@ export declare class Messenger {
|
|
|
116
140
|
/** The local mailbox, created on first capture and reused after. */
|
|
117
141
|
private getDevMailbox;
|
|
118
142
|
send(to: string | string[], subject: string, body: string, html?: boolean, text?: string, cc?: string | string[], bcc?: string | string[], replyTo?: string, attachments?: string[], headers?: Record<string, string>): Promise<SendResult>;
|
|
143
|
+
/**
|
|
144
|
+
* Render a Frond template STRING and send it as an HTML email (G7, parity with
|
|
145
|
+
* Python's send_template). Extra send() options (cc, bcc, replyTo, attachments,
|
|
146
|
+
* headers) pass through. If the Frond package cannot be loaded the raw template
|
|
147
|
+
* is sent verbatim (matches Python's ImportError fallback) rather than failing.
|
|
148
|
+
*/
|
|
149
|
+
sendTemplate(to: string | string[], subject: string, template: string, data?: Record<string, unknown>, cc?: string | string[], bcc?: string | string[], replyTo?: string, attachments?: string[], headers?: Record<string, string>): Promise<SendResult>;
|
|
119
150
|
/**
|
|
120
151
|
* Test the SMTP connection without sending an email.
|
|
121
152
|
*/
|
|
@@ -138,7 +169,7 @@ export declare class Messenger {
|
|
|
138
169
|
*/
|
|
139
170
|
inbox(folder?: string, limit?: number, offset?: number): Promise<ImapMessage[]>;
|
|
140
171
|
/**
|
|
141
|
-
* Read a single message by
|
|
172
|
+
* Read a single message by its IMAP UID.
|
|
142
173
|
*/
|
|
143
174
|
read(uid: string, folder?: string): Promise<ImapFullMessage | null>;
|
|
144
175
|
/**
|
|
@@ -146,13 +177,23 @@ export declare class Messenger {
|
|
|
146
177
|
*/
|
|
147
178
|
search(folder?: string, subject?: string, sender?: string, since?: string, before?: string, unseenOnly?: boolean, limit?: number): Promise<ImapMessage[]>;
|
|
148
179
|
/**
|
|
149
|
-
* Delete a message by UID.
|
|
180
|
+
* Delete a message by UID (mark \Deleted, then EXPUNGE).
|
|
181
|
+
*
|
|
182
|
+
* `delete` is the one cross-framework name (python/php/ruby/node all spell it
|
|
183
|
+
* `delete`). `deleteMessage` remains as a DEPRECATED alias for one release.
|
|
150
184
|
*/
|
|
185
|
+
delete(uid: string, folder?: string): Promise<void>;
|
|
186
|
+
/** @deprecated Use {@link delete} — kept as an alias for one release (G7). */
|
|
151
187
|
deleteMessage(uid: string, folder?: string): Promise<void>;
|
|
152
188
|
/**
|
|
153
|
-
* Mark a message as read.
|
|
189
|
+
* Mark a message as read (+FLAGS \Seen).
|
|
154
190
|
*/
|
|
155
191
|
markRead(uid: string, folder?: string): Promise<void>;
|
|
192
|
+
/**
|
|
193
|
+
* Mark a message as unread (-FLAGS \Seen) — the inverse of markRead (G7,
|
|
194
|
+
* parity with Python's mark_unread).
|
|
195
|
+
*/
|
|
196
|
+
markUnread(uid: string, folder?: string): Promise<void>;
|
|
156
197
|
/**
|
|
157
198
|
* Count unseen messages in a folder.
|
|
158
199
|
*/
|
|
@@ -51,5 +51,6 @@ export declare class RabbitMQBackend implements QueueBackend {
|
|
|
51
51
|
push(queue: string, payload: unknown, _delay?: number): string;
|
|
52
52
|
pop(queue: string): QueueJob | null;
|
|
53
53
|
size(queue: string): number;
|
|
54
|
-
clear(
|
|
54
|
+
clear(_queue: string): void;
|
|
55
|
+
purge(_queue: string, _status?: string): number;
|
|
55
56
|
}
|
|
@@ -120,10 +120,6 @@ export declare function resetTemplateCache(): void;
|
|
|
120
120
|
* The whole feature can be turned off with `TINA4_TEMPLATE_ROUTING=off`.
|
|
121
121
|
*/
|
|
122
122
|
export declare function resolveTemplate(pathname: string, templatesDir: string): string | null;
|
|
123
|
-
/**
|
|
124
|
-
* Start the Tina4 HTTP server.
|
|
125
|
-
* Thin wrapper around startServer() for cross-framework parity with PHP and Ruby.
|
|
126
|
-
*/
|
|
127
123
|
export declare function start(config?: Tina4Config): Promise<{
|
|
128
124
|
close: () => void;
|
|
129
125
|
router: Router;
|
|
@@ -233,6 +233,13 @@ export declare class Session {
|
|
|
233
233
|
*
|
|
234
234
|
* session.flash("message", "Saved!") // set
|
|
235
235
|
* session.flash("message") // get + auto-remove → "Saved!"
|
|
236
|
+
* session.flash("message", null) // get + auto-remove (null is a GET sentinel)
|
|
237
|
+
*
|
|
238
|
+
* `null` — NOT just `undefined` — is the GET sentinel, so `flash(key, null)`
|
|
239
|
+
* READS and clears rather than STORING null. This matches the Python master
|
|
240
|
+
* (`if value is not None`), PHP (`if ($value !== null)`) and Ruby
|
|
241
|
+
* (`if value.nil?`): passing the language's "no value" literal means GET. A
|
|
242
|
+
* caller wanting to persist an explicit null should store it with `set()`.
|
|
236
243
|
*/
|
|
237
244
|
flash(key: string, value?: unknown): unknown;
|
|
238
245
|
/**
|