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.
Files changed (36) hide show
  1. package/CLAUDE.md +3 -4
  2. package/package.json +2 -1
  3. package/packages/cli/dist/bin.js +708 -1012
  4. package/packages/core/dist/index.js +588 -893
  5. package/packages/core/public/css/tina4.min.css +1 -1
  6. package/packages/core/src/index.ts +1 -3
  7. package/packages/core/src/messenger.ts +288 -96
  8. package/packages/core/src/queueBackends/kafkaBackend.ts +23 -2
  9. package/packages/core/src/queueBackends/rabbitmqBackend.ts +29 -17
  10. package/packages/core/src/request.ts +28 -7
  11. package/packages/core/src/server.ts +135 -7
  12. package/packages/core/src/session.ts +8 -1
  13. package/packages/orm/dist/index.js +639 -944
  14. package/packages/orm/src/autoCrud.ts +12 -10
  15. package/packages/orm/src/database.ts +62 -58
  16. package/packages/orm/src/databaseResult.ts +44 -73
  17. package/packages/orm/src/index.ts +0 -3
  18. package/packages/orm/src/migration.ts +26 -8
  19. package/packages/orm/src/model.ts +4 -0
  20. package/packages/orm/src/queryBuilder.ts +12 -5
  21. package/packages/orm/src/types.ts +7 -74
  22. package/packages/swagger/dist/index.js +78 -20
  23. package/packages/swagger/src/generator.ts +172 -29
  24. package/types/core/src/index.d.ts +1 -3
  25. package/types/core/src/messenger.d.ts +45 -4
  26. package/types/core/src/queueBackends/kafkaBackend.d.ts +1 -0
  27. package/types/core/src/queueBackends/rabbitmqBackend.d.ts +2 -1
  28. package/types/core/src/server.d.ts +0 -4
  29. package/types/core/src/session.d.ts +7 -0
  30. package/types/orm/src/database.d.ts +34 -30
  31. package/types/orm/src/databaseResult.d.ts +26 -36
  32. package/types/orm/src/index.d.ts +1 -2
  33. package/types/orm/src/migration.d.ts +4 -3
  34. package/types/orm/src/types.d.ts +7 -34
  35. package/packages/core/src/scss.ts +0 -623
  36. 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: 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)) {
@@ -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
- id?: string;
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 sequence number or UID.
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
  */
@@ -114,4 +114,5 @@ export declare class KafkaBackend implements QueueBackend {
114
114
  pop(queue: string): QueueJob | null;
115
115
  size(_queue: string): number;
116
116
  clear(_queue: string): void;
117
+ purge(_queue: string, _status?: string): number;
117
118
  }
@@ -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(queue: string): void;
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
  /**