tina4-nodejs 3.13.40 → 3.13.42
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 +11 -2
- package/package.json +1 -1
- package/packages/core/src/job.ts +5 -1
- package/packages/core/src/queue.ts +50 -4
- package/packages/core/src/queueBackends/kafkaBackend.ts +7 -1
- package/packages/core/src/queueBackends/liteBackend.ts +188 -14
- package/packages/core/src/queueBackends/mongoBackend.ts +102 -8
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +7 -1
- package/packages/core/src/types.ts +22 -0
- package/packages/swagger/src/generator.ts +223 -13
- package/packages/swagger/src/index.ts +1 -1
package/CLAUDE.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.
|
|
1
|
+
# CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.42)
|
|
2
2
|
|
|
3
3
|
> This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
|
|
4
4
|
|
|
@@ -300,6 +300,15 @@ Auto-generates OpenAPI 3.0.3 docs.
|
|
|
300
300
|
- `TINA4_SWAGGER_UI_CDN` - base URL for the Swagger UI assets (`swagger-ui.css` + `swagger-ui-bundle.js`). Defaults to the public CDN (`https://unpkg.com/swagger-ui-dist@5`); point it at a self-hosted mirror for air-gapped deployments.
|
|
301
301
|
- Info block: `TINA4_SWAGGER_TITLE`, `TINA4_SWAGGER_VERSION`, `TINA4_SWAGGER_DESCRIPTION`, `TINA4_SWAGGER_CONTACT_EMAIL`, `TINA4_SWAGGER_CONTACT_TEAM`, `TINA4_SWAGGER_CONTACT_URL`, `TINA4_SWAGGER_LICENSE`.
|
|
302
302
|
|
|
303
|
+
**Configurability (v3.13.42):**
|
|
304
|
+
- `TINA4_SWAGGER_OPENAPI` - OpenAPI version (default `3.0.3`); `3.1`/`3.1.0` emits `3.1.0`.
|
|
305
|
+
- `TINA4_SWAGGER_BEARER_FORMAT` - `bearerFormat` on the built-in `bearerAuth` scheme (default `JWT`; use `opaque` for `sk_live_` keys).
|
|
306
|
+
- `TINA4_SWAGGER_API_KEY_NAME` / `TINA4_SWAGGER_API_KEY_IN` - when the name is set, emit an `apiKeyAuth` scheme; `_IN` is `header` (default) / `query` / `cookie`.
|
|
307
|
+
- `TINA4_SWAGGER_DEFAULT_SCHEME` - scheme a secured route uses when its `meta` declares no `security` (default `bearerAuth`).
|
|
308
|
+
- `TINA4_SWAGGER_INCLUDE` / `TINA4_SWAGGER_EXCLUDE` - comma-separated path-prefix allow-list / deny-list (`/swagger` + `/__dev` always excluded).
|
|
309
|
+
|
|
310
|
+
**Per-route security + reusable schemas (v3.13.42).** A route's `meta` may carry `security` (a scheme name, a `{name: [scopes]}` map, a list of maps for OR, or the string `"public"` to force `security: []`), a sibling `scopes` array, and `requestSchema` / `responseSchemas` referencing schemas registered with `addSchema(name, schema)`. Register arbitrary schemes (including `oauth2` with scopes) via `addSecurityScheme(name, definition)`; `resetRegistry()` clears both. All three are exported from `@tina4/swagger`. Scopes are kept spec-valid: only `oauth2`/`openIdConnect` carry them, `http`/`apiKey` get `[]`.
|
|
311
|
+
|
|
303
312
|
### @tina4/frond (`packages/frond/`)
|
|
304
313
|
Built-in zero-dependency Twig-compatible template engine (the only template engine; there is no `twig` npm dependency).
|
|
305
314
|
|
|
@@ -1209,7 +1218,7 @@ When adding new features, add a corresponding `test/<feature>.test.ts` file.
|
|
|
1209
1218
|
- **Database**: 5 engines (SQLite, PostgreSQL, MySQL, MSSQL, Firebird), DB query caching — request-scoped auto cache **off by default — opt-in via `TINA4_AUTO_CACHING=true`** (TTL `TINA4_AUTO_CACHING_TTL=5`s) which dedupes identical `db.fetch()`/ORM reads within a request and flushes on writes (always in-process); default OFF because a request-scoped cache defaulting on is a read-after-write footgun (cached pre-write `SELECT MAX(id)` → duplicate PKs); persistent cross-request cache opt-in via `TINA4_DB_CACHE=true` (TTL `TINA4_DB_CACHE_TTL=30`s) routed through the unified async backend set via `TINA4_DB_CACHE_BACKEND` (memory/file/redis/valkey/memcached/mongodb/database) + `TINA4_DB_CACHE_URL`, so instances share one cache with global write-invalidation (full parity with Python/PHP/Ruby). `db.cacheStats()` reports `mode` (request/persistent/off) + `backend`
|
|
1210
1219
|
- **Cache**: unified backend set — `memory` (default), `file`, `redis`, `valkey`, `memcached`, `mongodb`, `database` — via `TINA4_CACHE_BACKEND` (+ `TINA4_CACHE_URL`/credentials); file-backend fallback if a backend is unreachable. The KV API, the `responseCache` middleware, and the persistent DB query cache all route through this async backend set (native async clients, no child processes) — a network backend distributes them cross-instance, full parity with Python/PHP/Ruby; `memory` (default) keeps them in-process. `await` the async API (`cacheGet`/`cacheSet`/`clearCache`)
|
|
1211
1220
|
- **Sessions**: file backend (default). `TINA4_SESSION_SAMESITE` env var (default: Lax)
|
|
1212
|
-
- **Queue**: file/RabbitMQ/Kafka/MongoDB backends, configured via env vars
|
|
1221
|
+
- **Queue**: file/RabbitMQ/Kafka/MongoDB backends, configured via env vars. **Reservation/visibility timeout** (file + MongoDB): a popped job is reserved for `TINA4_QUEUE_VISIBILITY_TIMEOUT` seconds (default 300; `visibilityTimeout` Queue option; `<= 0` disables) — if the consumer dies before `complete()`/`fail()`, the next `pop()` reclaims it (incrementing `attempts`, dead-lettering past `maxRetries`), so a crashed/evicted consumer never strands a job. RabbitMQ/Kafka delegate redelivery to the broker.
|
|
1213
1222
|
- **Cache**: memory/Redis/file backends
|
|
1214
1223
|
- **Messenger**: .env driven SMTP/IMAP
|
|
1215
1224
|
- **ORM relationships**: `hasMany`, `hasOne`, `belongsTo` with eager loading (`include`)
|
package/package.json
CHANGED
package/packages/core/src/job.ts
CHANGED
|
@@ -40,6 +40,7 @@ export type QueueJob = JobData & JobLifecycle;
|
|
|
40
40
|
export interface JobQueueBridge {
|
|
41
41
|
_failJob(topic: string, job: QueueJob, reason: string, maxRetries: number): void;
|
|
42
42
|
_retryJob(topic: string, job: QueueJob, delaySeconds?: number): void;
|
|
43
|
+
_completeJob(topic: string, job: QueueJob): void;
|
|
43
44
|
getMaxRetries(): number;
|
|
44
45
|
}
|
|
45
46
|
|
|
@@ -48,8 +49,11 @@ export function createJob(data: JobData, queue: JobQueueBridge): QueueJob {
|
|
|
48
49
|
const job: QueueJob = {
|
|
49
50
|
...data,
|
|
50
51
|
complete() {
|
|
51
|
-
// Terminal — the
|
|
52
|
+
// Terminal — the pending file was claimed on pop and a reservation record
|
|
53
|
+
// written; complete() drops the reservation so a dead-consumer reclaim
|
|
54
|
+
// never re-delivers an already-acked job. The job is done.
|
|
52
55
|
job.status = "completed";
|
|
56
|
+
queue._completeJob(job.topic, job);
|
|
53
57
|
},
|
|
54
58
|
fail(reason = "") {
|
|
55
59
|
// Record a failed attempt. `attempts` is incremented exactly once, inside
|
|
@@ -51,6 +51,29 @@ export interface QueueConfig {
|
|
|
51
51
|
* straight away. Parity with Python's retry_backoff.
|
|
52
52
|
*/
|
|
53
53
|
retryBackoff?: number;
|
|
54
|
+
/**
|
|
55
|
+
* Reservation/visibility timeout (seconds). A popped job is reserved for this
|
|
56
|
+
* long; if the consumer dies before complete()/fail() (crash, OOM, k8s
|
|
57
|
+
* eviction) the next pop() reclaims it — incrementing attempts and
|
|
58
|
+
* re-enqueuing, or dead-lettering past maxRetries (at-least-once delivery).
|
|
59
|
+
* Falls back to TINA4_QUEUE_VISIBILITY_TIMEOUT, else 300 (5 min). <= 0
|
|
60
|
+
* disables the reclaim (a reservation then lasts until the consumer acks —
|
|
61
|
+
* the old at-most-once behaviour). File + MongoDB backends only;
|
|
62
|
+
* RabbitMQ/Kafka delegate visibility to the broker. Parity with Python's
|
|
63
|
+
* visibility_timeout.
|
|
64
|
+
*/
|
|
65
|
+
visibilityTimeout?: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Reservation/visibility timeout in seconds, from env (default 300 = 5 min).
|
|
70
|
+
* Mirrors Python's _default_visibility_timeout().
|
|
71
|
+
*/
|
|
72
|
+
function defaultVisibilityTimeout(): number {
|
|
73
|
+
const raw = process.env.TINA4_QUEUE_VISIBILITY_TIMEOUT;
|
|
74
|
+
if (raw === undefined || raw === "") return 300;
|
|
75
|
+
const parsed = Number(raw);
|
|
76
|
+
return Number.isFinite(parsed) ? parsed : 300;
|
|
54
77
|
}
|
|
55
78
|
|
|
56
79
|
export interface ProcessOptions {
|
|
@@ -82,6 +105,7 @@ export class Queue {
|
|
|
82
105
|
private topic: string;
|
|
83
106
|
private _maxRetries: number;
|
|
84
107
|
private _retryBackoff: number;
|
|
108
|
+
private _visibilityTimeout: number;
|
|
85
109
|
private externalBackend: QueueBackendInterface | null = null;
|
|
86
110
|
private liteBackend!: LiteBackend;
|
|
87
111
|
|
|
@@ -112,15 +136,19 @@ export class Queue {
|
|
|
112
136
|
this.topic = resolvedConfig.topic ?? "default";
|
|
113
137
|
this._maxRetries = resolvedConfig.maxRetries ?? 3;
|
|
114
138
|
this._retryBackoff = resolvedConfig.retryBackoff ?? 0;
|
|
115
|
-
this.
|
|
139
|
+
this._visibilityTimeout = resolvedConfig.visibilityTimeout ?? defaultVisibilityTimeout();
|
|
140
|
+
this.liteBackend = new LiteBackend(this.basePath, this._visibilityTimeout);
|
|
116
141
|
|
|
117
142
|
// Initialize external backends
|
|
118
143
|
if (this.backendName === "rabbitmq") {
|
|
119
|
-
|
|
144
|
+
// Broker manages visibility/redelivery (unacked messages requeue on
|
|
145
|
+
// channel close) — the framework timeout is accepted but not used.
|
|
146
|
+
this.externalBackend = new RabbitMQBackend({ visibilityTimeout: this._visibilityTimeout });
|
|
120
147
|
} else if (this.backendName === "kafka") {
|
|
121
|
-
|
|
148
|
+
// Consumer-group offsets manage redelivery — framework timeout N/A.
|
|
149
|
+
this.externalBackend = new KafkaBackend({ visibilityTimeout: this._visibilityTimeout });
|
|
122
150
|
} else if (this.backendName === "mongodb" || this.backendName === "mongo") {
|
|
123
|
-
this.externalBackend = new MongoBackend();
|
|
151
|
+
this.externalBackend = new MongoBackend({ visibilityTimeout: this._visibilityTimeout });
|
|
124
152
|
}
|
|
125
153
|
}
|
|
126
154
|
|
|
@@ -410,6 +438,15 @@ export class Queue {
|
|
|
410
438
|
return this._retryBackoff;
|
|
411
439
|
}
|
|
412
440
|
|
|
441
|
+
/**
|
|
442
|
+
* Resolved reservation/visibility timeout (seconds). <= 0 means the reclaim
|
|
443
|
+
* is disabled. File + MongoDB backends honour it; RabbitMQ/Kafka delegate to
|
|
444
|
+
* the broker.
|
|
445
|
+
*/
|
|
446
|
+
getVisibilityTimeout(): number {
|
|
447
|
+
return this._visibilityTimeout;
|
|
448
|
+
}
|
|
449
|
+
|
|
413
450
|
/**
|
|
414
451
|
* Record a failed attempt for a job. The backend increments `attempts`
|
|
415
452
|
* exactly once and decides whether to re-enqueue (attempts < maxRetries,
|
|
@@ -425,4 +462,13 @@ export class Queue {
|
|
|
425
462
|
_retryJob(queue: string, job: QueueJob, delaySeconds?: number): void {
|
|
426
463
|
this.liteBackend.retryJob(queue, job, delaySeconds);
|
|
427
464
|
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Acknowledge a completed job — drop its reservation record so the visibility
|
|
468
|
+
* reclaim never re-delivers it. No-op for external backends (they ack their
|
|
469
|
+
* own way; lite-backend reservations are file records).
|
|
470
|
+
*/
|
|
471
|
+
_completeJob(queue: string, job: QueueJob): void {
|
|
472
|
+
this.liteBackend.completeJob(queue, job);
|
|
473
|
+
}
|
|
428
474
|
}
|
|
@@ -27,6 +27,12 @@ import type { QueueJob } from "../queue.js";
|
|
|
27
27
|
export interface KafkaConfig {
|
|
28
28
|
brokers?: string;
|
|
29
29
|
groupId?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Accepted for API parity with the file/MongoDB backends and IGNORED —
|
|
32
|
+
* consumer-group offsets own redelivery, so the framework-level visibility
|
|
33
|
+
* timeout does not apply here.
|
|
34
|
+
*/
|
|
35
|
+
visibilityTimeout?: number;
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
/**
|
|
@@ -134,7 +140,7 @@ export class KafkaBackend implements QueueBackend {
|
|
|
134
140
|
/**
|
|
135
141
|
* Resolved connection config — exposed for testing/introspection.
|
|
136
142
|
*/
|
|
137
|
-
getConfig(): Required<KafkaConfig
|
|
143
|
+
getConfig(): Required<Omit<KafkaConfig, "visibilityTimeout">> {
|
|
138
144
|
return { brokers: this.brokers, groupId: this.groupId };
|
|
139
145
|
}
|
|
140
146
|
|
|
@@ -24,9 +24,19 @@ import { createJob, type JobQueueBridge } from "../job.js";
|
|
|
24
24
|
export class LiteBackend {
|
|
25
25
|
private basePath: string;
|
|
26
26
|
private seq: number = 0;
|
|
27
|
+
/**
|
|
28
|
+
* Reservation/visibility timeout (seconds). A popped job is held in reserved/
|
|
29
|
+
* with availableAt = now + visibilityTimeout. If the consumer dies before
|
|
30
|
+
* complete()/fail() (crash, OOM, k8s eviction) the next pop() reclaims it once
|
|
31
|
+
* the window expires — incrementing attempts and re-enqueuing, or
|
|
32
|
+
* dead-lettering past maxRetries. <= 0 disables the reclaim (a reservation
|
|
33
|
+
* then lasts until the consumer acks — the old at-most-once behaviour).
|
|
34
|
+
*/
|
|
35
|
+
private visibilityTimeout: number;
|
|
27
36
|
|
|
28
|
-
constructor(basePath: string = "data/queue") {
|
|
37
|
+
constructor(basePath: string = "data/queue", visibilityTimeout: number = 300) {
|
|
29
38
|
this.basePath = basePath;
|
|
39
|
+
this.visibilityTimeout = visibilityTimeout;
|
|
30
40
|
}
|
|
31
41
|
|
|
32
42
|
private ensureDir(queue: string): string {
|
|
@@ -41,6 +51,24 @@ export class LiteBackend {
|
|
|
41
51
|
return dir;
|
|
42
52
|
}
|
|
43
53
|
|
|
54
|
+
private ensureReservedDir(queue: string): string {
|
|
55
|
+
const dir = join(this.basePath, queue, "reserved");
|
|
56
|
+
mkdirSync(dir, { recursive: true });
|
|
57
|
+
return dir;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private reservedPath(queue: string, jobId: string): string {
|
|
61
|
+
return join(this.ensureReservedDir(queue), `${jobId}.queue-data`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private nowIso(): string {
|
|
65
|
+
return new Date().toISOString();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private futureIso(seconds: number): string {
|
|
69
|
+
return new Date(Date.now() + seconds * 1000).toISOString();
|
|
70
|
+
}
|
|
71
|
+
|
|
44
72
|
private nextPrefix(): string {
|
|
45
73
|
this.seq++;
|
|
46
74
|
return `${Date.now()}-${String(this.seq).padStart(6, "0")}`;
|
|
@@ -111,22 +139,112 @@ export class LiteBackend {
|
|
|
111
139
|
return candidates;
|
|
112
140
|
}
|
|
113
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Persist a reservation record so a dead consumer's job is reclaimable.
|
|
144
|
+
*
|
|
145
|
+
* Stores reservedAt + availableAt = now + visibilityTimeout. The next pop()
|
|
146
|
+
* reclaims this job once availableAt has passed (see reclaimExpired).
|
|
147
|
+
* complete()/fail()/retry() delete the record.
|
|
148
|
+
*/
|
|
149
|
+
private writeReserved(queue: string, job: any): void {
|
|
150
|
+
const now = this.nowIso();
|
|
151
|
+
const vt = this.visibilityTimeout || 0;
|
|
152
|
+
const record = {
|
|
153
|
+
id: job.id,
|
|
154
|
+
payload: job.payload,
|
|
155
|
+
status: "reserved" as const,
|
|
156
|
+
priority: job.priority ?? 0,
|
|
157
|
+
attempts: job.attempts ?? 0,
|
|
158
|
+
error: job.error,
|
|
159
|
+
reservedAt: now,
|
|
160
|
+
availableAt: vt > 0 ? this.futureIso(vt) : now,
|
|
161
|
+
createdAt: job.createdAt ?? now,
|
|
162
|
+
topic: job.topic ?? queue,
|
|
163
|
+
};
|
|
164
|
+
writeFileSync(this.reservedPath(queue, record.id), JSON.stringify(record, null, 2));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Return expired reservations to the queue (at-least-once delivery).
|
|
169
|
+
*
|
|
170
|
+
* A reserved job whose availableAt <= now means its consumer never
|
|
171
|
+
* acknowledged in time (crash / OOM / pod eviction). Atomically claim it
|
|
172
|
+
* (delete the reservation file), increment attempts, and either re-enqueue it
|
|
173
|
+
* (so the next pop picks it up) or dead-letter it once it has hit maxRetries.
|
|
174
|
+
* Disabled when visibilityTimeout <= 0.
|
|
175
|
+
*/
|
|
176
|
+
private reclaimExpired(queue: string, maxRetries: number, now: string): void {
|
|
177
|
+
if (!this.visibilityTimeout || this.visibilityTimeout <= 0) return;
|
|
178
|
+
const reservedDir = this.ensureReservedDir(queue);
|
|
179
|
+
|
|
180
|
+
let filenames: string[];
|
|
181
|
+
try {
|
|
182
|
+
filenames = readdirSync(reservedDir).filter(f => f.endsWith(".queue-data"));
|
|
183
|
+
} catch {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
for (const filename of filenames) {
|
|
188
|
+
const filePath = join(reservedDir, filename);
|
|
189
|
+
let record: any;
|
|
190
|
+
try {
|
|
191
|
+
record = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
192
|
+
} catch {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (record.availableAt && record.availableAt > now) continue; // still valid
|
|
196
|
+
// Atomically claim the expired reservation by deleting its file.
|
|
197
|
+
try {
|
|
198
|
+
unlinkSync(filePath);
|
|
199
|
+
} catch {
|
|
200
|
+
continue; // another worker reclaimed it first
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const attempts = (record.attempts ?? 0) + 1;
|
|
204
|
+
const error = "reservation timed out — consumer did not acknowledge within the visibility timeout";
|
|
205
|
+
const job: QueueJob = {
|
|
206
|
+
id: record.id,
|
|
207
|
+
payload: record.payload,
|
|
208
|
+
status: "reserved",
|
|
209
|
+
createdAt: record.createdAt ?? now,
|
|
210
|
+
attempts,
|
|
211
|
+
delayUntil: null,
|
|
212
|
+
priority: record.priority ?? 0,
|
|
213
|
+
topic: record.topic ?? queue,
|
|
214
|
+
error,
|
|
215
|
+
} as QueueJob;
|
|
216
|
+
|
|
217
|
+
if (attempts >= maxRetries) {
|
|
218
|
+
this.deadLetter(queue, job, error);
|
|
219
|
+
} else {
|
|
220
|
+
this.requeue(queue, job, 0, error);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
114
225
|
pop(queue: string, bridge: JobQueueBridge): QueueJob | null {
|
|
115
226
|
const dir = this.ensureDir(queue);
|
|
116
|
-
|
|
227
|
+
// First return any reservations whose consumer died mid-flight.
|
|
228
|
+
this.reclaimExpired(queue, bridge.getMaxRetries(), this.nowIso());
|
|
229
|
+
const now = this.nowIso();
|
|
117
230
|
|
|
118
231
|
for (const [filename, job] of this.availableCandidates(queue, now)) {
|
|
119
232
|
const filePath = join(dir, filename);
|
|
120
|
-
|
|
233
|
+
job.topic = queue;
|
|
234
|
+
job.priority = job.priority ?? 0;
|
|
235
|
+
// Write the reservation BEFORE claiming the pending file, so a crash
|
|
236
|
+
// between claim and reserve can never strand the job. Only the worker
|
|
237
|
+
// that wins the unlink owns — and returns — it.
|
|
238
|
+
this.writeReserved(queue, job);
|
|
121
239
|
try {
|
|
122
240
|
unlinkSync(filePath);
|
|
123
241
|
} catch {
|
|
124
|
-
|
|
242
|
+
// Already consumed by another worker — drop the speculative reservation.
|
|
243
|
+
try { unlinkSync(this.reservedPath(queue, job.id)); } catch { /* ignore */ }
|
|
244
|
+
continue;
|
|
125
245
|
}
|
|
126
246
|
|
|
127
247
|
job.status = "reserved";
|
|
128
|
-
job.topic = queue;
|
|
129
|
-
job.priority = job.priority ?? 0;
|
|
130
248
|
return createJob(job as any, bridge);
|
|
131
249
|
}
|
|
132
250
|
|
|
@@ -135,34 +253,61 @@ export class LiteBackend {
|
|
|
135
253
|
|
|
136
254
|
popBatch(queue: string, bridge: JobQueueBridge, count: number): QueueJob[] {
|
|
137
255
|
const dir = this.ensureDir(queue);
|
|
138
|
-
|
|
256
|
+
this.reclaimExpired(queue, bridge.getMaxRetries(), this.nowIso());
|
|
257
|
+
const now = this.nowIso();
|
|
139
258
|
const results: QueueJob[] = [];
|
|
140
259
|
|
|
141
260
|
for (const [filename, job] of this.availableCandidates(queue, now)) {
|
|
142
261
|
if (results.length >= count) break;
|
|
143
262
|
const filePath = join(dir, filename);
|
|
263
|
+
job.topic = queue;
|
|
264
|
+
job.priority = job.priority ?? 0;
|
|
265
|
+
this.writeReserved(queue, job);
|
|
144
266
|
try {
|
|
145
267
|
unlinkSync(filePath);
|
|
146
268
|
} catch {
|
|
269
|
+
try { unlinkSync(this.reservedPath(queue, job.id)); } catch { /* ignore */ }
|
|
147
270
|
continue; // Already consumed by another worker
|
|
148
271
|
}
|
|
149
272
|
|
|
150
273
|
job.status = "reserved";
|
|
151
|
-
job.topic = queue;
|
|
152
|
-
job.priority = job.priority ?? 0;
|
|
153
274
|
results.push(createJob(job as any, bridge));
|
|
154
275
|
}
|
|
155
276
|
|
|
156
277
|
return results;
|
|
157
278
|
}
|
|
158
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Delete a job's reservation record (best-effort).
|
|
282
|
+
*/
|
|
283
|
+
private clearReservation(queue: string, jobId: string): void {
|
|
284
|
+
try {
|
|
285
|
+
unlinkSync(this.reservedPath(queue, jobId));
|
|
286
|
+
} catch {
|
|
287
|
+
// no reservation record — nothing to clear
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Acknowledge a completed job — drop its reservation record so the visibility
|
|
293
|
+
* reclaim never re-delivers an already-acked job.
|
|
294
|
+
*/
|
|
295
|
+
completeJob(queue: string, job: QueueJob): void {
|
|
296
|
+
this.clearReservation(queue, job.id);
|
|
297
|
+
}
|
|
298
|
+
|
|
159
299
|
// Status aliases that live in the failed/ directory (dead-lettered jobs)
|
|
160
300
|
// rather than as pending files in the queue directory.
|
|
161
301
|
private static readonly DEAD_STATES = ["failed", "dead", "dead_letter"];
|
|
162
302
|
|
|
163
303
|
size(queue: string, status: string = "pending"): number {
|
|
164
304
|
const isDead = LiteBackend.DEAD_STATES.includes(status);
|
|
165
|
-
const
|
|
305
|
+
const isReserved = status === "reserved";
|
|
306
|
+
const scanDir = isDead
|
|
307
|
+
? this.ensureFailedDir(queue)
|
|
308
|
+
: isReserved
|
|
309
|
+
? this.ensureReservedDir(queue)
|
|
310
|
+
: this.ensureDir(queue);
|
|
166
311
|
|
|
167
312
|
let files: string[];
|
|
168
313
|
try {
|
|
@@ -171,9 +316,9 @@ export class LiteBackend {
|
|
|
171
316
|
return 0;
|
|
172
317
|
}
|
|
173
318
|
|
|
174
|
-
if (isDead) {
|
|
175
|
-
// Every file in failed/
|
|
176
|
-
// the exact stored status string.
|
|
319
|
+
if (isDead || isReserved) {
|
|
320
|
+
// Every file in failed/ (or reserved/) matches the requested status;
|
|
321
|
+
// count them all regardless of the exact stored status string.
|
|
177
322
|
return files.length;
|
|
178
323
|
}
|
|
179
324
|
|
|
@@ -215,6 +360,20 @@ export class LiteBackend {
|
|
|
215
360
|
} catch {
|
|
216
361
|
// ignore
|
|
217
362
|
}
|
|
363
|
+
|
|
364
|
+
// Also clear reservation records.
|
|
365
|
+
const reservedDir = join(dir, "reserved");
|
|
366
|
+
try {
|
|
367
|
+
if (existsSync(reservedDir)) {
|
|
368
|
+
const files = readdirSync(reservedDir).filter(f => f.endsWith(".queue-data"));
|
|
369
|
+
for (const file of files) {
|
|
370
|
+
unlinkSync(join(reservedDir, file));
|
|
371
|
+
count++;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
} catch {
|
|
375
|
+
// ignore
|
|
376
|
+
}
|
|
218
377
|
return count;
|
|
219
378
|
}
|
|
220
379
|
|
|
@@ -416,7 +575,18 @@ export class LiteBackend {
|
|
|
416
575
|
|
|
417
576
|
if (job.status !== "pending") continue;
|
|
418
577
|
if (job.id === id) {
|
|
419
|
-
|
|
578
|
+
job.topic = queue;
|
|
579
|
+
job.priority = job.priority ?? 0;
|
|
580
|
+
// Reserve (so a dead consumer's job is reclaimable) then claim the
|
|
581
|
+
// pending file — mirrors pop().
|
|
582
|
+
this.writeReserved(queue, job);
|
|
583
|
+
try {
|
|
584
|
+
unlinkSync(filePath);
|
|
585
|
+
} catch {
|
|
586
|
+
try { unlinkSync(this.reservedPath(queue, job.id)); } catch { /* ignore */ }
|
|
587
|
+
continue; // already consumed
|
|
588
|
+
}
|
|
589
|
+
job.status = "reserved";
|
|
420
590
|
return job;
|
|
421
591
|
}
|
|
422
592
|
}
|
|
@@ -479,6 +649,8 @@ export class LiteBackend {
|
|
|
479
649
|
* (attempts >= maxRetries) it is moved to the dead-letter store.
|
|
480
650
|
*/
|
|
481
651
|
failJob(queue: string, job: QueueJob, error: string, maxRetries: number, retryBackoff: number = 0): void {
|
|
652
|
+
// Clear the reservation — the consumer acknowledged (with a failure).
|
|
653
|
+
this.clearReservation(queue, job.id);
|
|
482
654
|
job.attempts = (job.attempts || 0) + 1;
|
|
483
655
|
job.error = error;
|
|
484
656
|
if (job.attempts < maxRetries) {
|
|
@@ -495,6 +667,8 @@ export class LiteBackend {
|
|
|
495
667
|
* distinct from the automatic failJob() path.
|
|
496
668
|
*/
|
|
497
669
|
retryJob(queue: string, job: QueueJob, delaySeconds?: number): void {
|
|
670
|
+
// Clear the reservation — the consumer acknowledged (with an explicit retry).
|
|
671
|
+
this.clearReservation(queue, job.id);
|
|
498
672
|
job.attempts = (job.attempts || 0) + 1;
|
|
499
673
|
job.error = undefined;
|
|
500
674
|
this.requeue(queue, job, delaySeconds ?? 0, undefined);
|
|
@@ -32,6 +32,15 @@ export interface MongoConfig {
|
|
|
32
32
|
password?: string;
|
|
33
33
|
database?: string;
|
|
34
34
|
collection?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Reservation/visibility timeout (seconds). A dequeued message is held
|
|
37
|
+
* reserved with availableAt = now + timeout; reclaim returns it once that
|
|
38
|
+
* passes (consumer died mid-flight, before complete()/fail()). <= 0 disables
|
|
39
|
+
* the reclaim. Falls back to TINA4_QUEUE_VISIBILITY_TIMEOUT, else 300.
|
|
40
|
+
*/
|
|
41
|
+
visibilityTimeout?: number;
|
|
42
|
+
/** Max attempts before the reclaim dead-letters a job instead of re-delivering. */
|
|
43
|
+
maxRetries?: number;
|
|
35
44
|
}
|
|
36
45
|
|
|
37
46
|
export interface QueueBackend {
|
|
@@ -58,6 +67,8 @@ export class MongoBackend implements QueueBackend {
|
|
|
58
67
|
private password: string;
|
|
59
68
|
private database: string;
|
|
60
69
|
private collection: string;
|
|
70
|
+
private visibilityTimeout: number;
|
|
71
|
+
private maxRetries: number;
|
|
61
72
|
|
|
62
73
|
constructor(config?: MongoConfig) {
|
|
63
74
|
this.host = config?.host ?? process.env.TINA4_MONGO_HOST ?? "localhost";
|
|
@@ -68,6 +79,16 @@ export class MongoBackend implements QueueBackend {
|
|
|
68
79
|
this.database = config?.database ?? process.env.TINA4_MONGO_DB ?? "tina4";
|
|
69
80
|
this.collection = config?.collection ?? process.env.TINA4_MONGO_COLLECTION ?? "tina4_queue";
|
|
70
81
|
|
|
82
|
+
// Reservation/visibility timeout (seconds): config wins, else env, else 300.
|
|
83
|
+
if (config?.visibilityTimeout !== undefined) {
|
|
84
|
+
this.visibilityTimeout = config.visibilityTimeout;
|
|
85
|
+
} else {
|
|
86
|
+
const raw = process.env.TINA4_QUEUE_VISIBILITY_TIMEOUT;
|
|
87
|
+
const parsed = raw === undefined || raw === "" ? 300 : Number(raw);
|
|
88
|
+
this.visibilityTimeout = Number.isFinite(parsed) ? parsed : 300;
|
|
89
|
+
}
|
|
90
|
+
this.maxRetries = config?.maxRetries ?? 3;
|
|
91
|
+
|
|
71
92
|
// Connection URI precedence: explicit config.uri > TINA4_MONGO_URI
|
|
72
93
|
// > TINA4_QUEUE_URL > a URI built from the host/port/auth field vars.
|
|
73
94
|
const explicitUri = config?.uri ?? process.env.TINA4_MONGO_URI ?? process.env.TINA4_QUEUE_URL;
|
|
@@ -84,15 +105,34 @@ export class MongoBackend implements QueueBackend {
|
|
|
84
105
|
/**
|
|
85
106
|
* Resolved connection config — exposed for testing/introspection.
|
|
86
107
|
*/
|
|
87
|
-
getConfig(): { uri: string; database: string; collection: string } {
|
|
88
|
-
return {
|
|
108
|
+
getConfig(): { uri: string; database: string; collection: string; visibilityTimeout: number } {
|
|
109
|
+
return {
|
|
110
|
+
uri: this.uri,
|
|
111
|
+
database: this.database,
|
|
112
|
+
collection: this.collection,
|
|
113
|
+
visibilityTimeout: this.visibilityTimeout,
|
|
114
|
+
};
|
|
89
115
|
}
|
|
90
116
|
|
|
91
117
|
/**
|
|
92
|
-
*
|
|
118
|
+
* Resolved reservation/visibility timeout (seconds). <= 0 disables the
|
|
119
|
+
* reclaim. Exposed for testing/introspection.
|
|
93
120
|
*/
|
|
94
|
-
|
|
95
|
-
|
|
121
|
+
getVisibilityTimeout(): number {
|
|
122
|
+
return this.visibilityTimeout;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Build the Node script that performs one MongoDB queue operation in a child
|
|
127
|
+
* process. Exposed (not private) so tests can assert the visibility-timeout
|
|
128
|
+
* behaviour without a live MongoDB — the script's pop branch advances
|
|
129
|
+
* availableAt = now + visibilityTimeout and stamps reservedAt (the core fix),
|
|
130
|
+
* and the reclaim branch flips an expired { status: reserved } back to
|
|
131
|
+
* pending with attempts incremented (dead-lettering past maxRetries),
|
|
132
|
+
* disabled when visibilityTimeout <= 0.
|
|
133
|
+
*/
|
|
134
|
+
buildScript(operation: string, queue: string, data?: string): string {
|
|
135
|
+
return `
|
|
96
136
|
async function main() {
|
|
97
137
|
let mongodb;
|
|
98
138
|
try {
|
|
@@ -109,6 +149,8 @@ export class MongoBackend implements QueueBackend {
|
|
|
109
149
|
const operation = ${JSON.stringify(operation)};
|
|
110
150
|
const queueName = ${JSON.stringify(queue)};
|
|
111
151
|
const data = ${JSON.stringify(data ?? "")};
|
|
152
|
+
const visibilityTimeout = ${JSON.stringify(this.visibilityTimeout)};
|
|
153
|
+
const maxRetries = ${JSON.stringify(this.maxRetries)};
|
|
112
154
|
|
|
113
155
|
const client = new MongoClient(uri, {
|
|
114
156
|
connectTimeoutMS: 5000,
|
|
@@ -121,7 +163,7 @@ export class MongoBackend implements QueueBackend {
|
|
|
121
163
|
const col = db.collection(collName);
|
|
122
164
|
|
|
123
165
|
// Ensure indexes on first use
|
|
124
|
-
await col.createIndex({ queue: 1, status: 1,
|
|
166
|
+
await col.createIndex({ queue: 1, status: 1, availableAt: 1 });
|
|
125
167
|
await col.createIndex({ queue: 1, createdAt: 1 });
|
|
126
168
|
|
|
127
169
|
if (operation === "push") {
|
|
@@ -129,19 +171,59 @@ export class MongoBackend implements QueueBackend {
|
|
|
129
171
|
await col.insertOne({ ...job, queue: queueName });
|
|
130
172
|
process.stdout.write("__PUSHED__");
|
|
131
173
|
}
|
|
174
|
+
else if (operation === "reclaim") {
|
|
175
|
+
// Return reservations whose visibility window expired (at-least-once
|
|
176
|
+
// delivery). A doc left { status: reserved, availableAt <= now } had
|
|
177
|
+
// its consumer die before acknowledging — flip it back to pending
|
|
178
|
+
// with attempts incremented, or dead-letter once attempts hit the
|
|
179
|
+
// limit. Disabled when visibilityTimeout <= 0.
|
|
180
|
+
let reclaimed = 0;
|
|
181
|
+
if (visibilityTimeout > 0) {
|
|
182
|
+
while (true) {
|
|
183
|
+
const now = new Date().toISOString();
|
|
184
|
+
const doc = await col.findOneAndUpdate(
|
|
185
|
+
{ queue: queueName, status: "reserved", availableAt: { $lte: now } },
|
|
186
|
+
{ $set: { status: "pending", availableAt: now, reservedAt: null }, $inc: { attempts: 1 } },
|
|
187
|
+
{ sort: { availableAt: 1 }, returnDocument: "after" },
|
|
188
|
+
);
|
|
189
|
+
const updated = doc && doc.value ? doc.value : (doc && doc._id ? doc : null);
|
|
190
|
+
if (!updated) break;
|
|
191
|
+
reclaimed++;
|
|
192
|
+
if ((updated.attempts || 0) >= maxRetries) {
|
|
193
|
+
await col.insertOne({
|
|
194
|
+
...updated,
|
|
195
|
+
_id: undefined,
|
|
196
|
+
status: "dead",
|
|
197
|
+
queue: queueName + ".dead_letter",
|
|
198
|
+
error: "reservation timed out — consumer did not acknowledge within the visibility timeout",
|
|
199
|
+
});
|
|
200
|
+
await col.deleteOne({ _id: updated._id, queue: queueName });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
process.stdout.write(String(reclaimed));
|
|
205
|
+
}
|
|
132
206
|
else if (operation === "pop") {
|
|
133
207
|
const now = new Date().toISOString();
|
|
208
|
+
// The claim advances availableAt = now + visibilityTimeout and
|
|
209
|
+
// stamps reservedAt so reclaim can return the job if the consumer
|
|
210
|
+
// dies before complete()/fail() — this is the fix for the "reserved
|
|
211
|
+
// forever" bug (previously availableAt was left unchanged).
|
|
212
|
+
const future = new Date(Date.now() + visibilityTimeout * 1000).toISOString();
|
|
134
213
|
const result = await col.findOneAndUpdate(
|
|
135
214
|
{
|
|
136
215
|
queue: queueName,
|
|
137
216
|
status: "pending",
|
|
138
217
|
$or: [
|
|
218
|
+
{ availableAt: null },
|
|
219
|
+
{ availableAt: { $exists: false } },
|
|
220
|
+
{ availableAt: { $lte: now } },
|
|
139
221
|
{ delayUntil: null },
|
|
140
222
|
{ delayUntil: { $lte: now } },
|
|
141
223
|
],
|
|
142
224
|
},
|
|
143
|
-
{ $set: { status: "reserved" } },
|
|
144
|
-
{ sort: { createdAt: 1 }, returnDocument: "before" },
|
|
225
|
+
{ $set: { status: "reserved", reservedAt: now, availableAt: future } },
|
|
226
|
+
{ sort: { priority: -1, createdAt: 1 }, returnDocument: "before" },
|
|
145
227
|
);
|
|
146
228
|
|
|
147
229
|
if (result && result.value) {
|
|
@@ -181,6 +263,13 @@ export class MongoBackend implements QueueBackend {
|
|
|
181
263
|
|
|
182
264
|
main();
|
|
183
265
|
`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Execute a MongoDB operation synchronously via a child process.
|
|
270
|
+
*/
|
|
271
|
+
private execSync(operation: string, queue: string, data?: string): string {
|
|
272
|
+
const script = this.buildScript(operation, queue, data);
|
|
184
273
|
|
|
185
274
|
try {
|
|
186
275
|
const result = execFileSync(process.execPath, ["-e", script], {
|
|
@@ -215,6 +304,11 @@ export class MongoBackend implements QueueBackend {
|
|
|
215
304
|
}
|
|
216
305
|
|
|
217
306
|
pop(queue: string): QueueJob | null {
|
|
307
|
+
// Reclaim any reservations whose consumer died before acking, then take the
|
|
308
|
+
// next available message (at-least-once delivery). Disabled at timeout <= 0.
|
|
309
|
+
if (this.visibilityTimeout > 0) {
|
|
310
|
+
this.execSync("reclaim", queue);
|
|
311
|
+
}
|
|
218
312
|
const result = this.execSync("pop", queue);
|
|
219
313
|
if (!result || result === "__EMPTY__") return null;
|
|
220
314
|
|
|
@@ -28,6 +28,12 @@ export interface RabbitMQConfig {
|
|
|
28
28
|
username?: string;
|
|
29
29
|
password?: string;
|
|
30
30
|
vhost?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Accepted for API parity with the file/MongoDB backends and IGNORED — the
|
|
33
|
+
* broker owns redelivery (unacked messages requeue on channel close), so the
|
|
34
|
+
* framework-level visibility timeout does not apply here.
|
|
35
|
+
*/
|
|
36
|
+
visibilityTimeout?: number;
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
/**
|
|
@@ -200,7 +206,7 @@ export class RabbitMQBackend implements QueueBackend {
|
|
|
200
206
|
/**
|
|
201
207
|
* Resolved connection config — exposed for testing/introspection.
|
|
202
208
|
*/
|
|
203
|
-
getConfig(): Required<RabbitMQConfig
|
|
209
|
+
getConfig(): Required<Omit<RabbitMQConfig, "visibilityTimeout">> {
|
|
204
210
|
return {
|
|
205
211
|
host: this.host,
|
|
206
212
|
port: this.port,
|
|
@@ -138,6 +138,28 @@ export interface RouteMeta {
|
|
|
138
138
|
example?: unknown;
|
|
139
139
|
/** Marks the operation deprecated in the spec. */
|
|
140
140
|
deprecated?: boolean;
|
|
141
|
+
/**
|
|
142
|
+
* Per-route security requirement (v3.13.42). Overrides the default scheme.
|
|
143
|
+
* Accepted forms (normalized by the generator into a security-requirement list):
|
|
144
|
+
* "bearerAuth" -> [{ bearerAuth: [] }]
|
|
145
|
+
* "public" | "none" | [] -> [] (explicitly no auth)
|
|
146
|
+
* { apiKeyAuth: [] } -> [{ apiKeyAuth: [] }] (AND within one map)
|
|
147
|
+
* [{ oauth2: ["read"] }, { bearerAuth: [] }] -> verbatim (OR across maps)
|
|
148
|
+
*/
|
|
149
|
+
security?: string | string[] | Record<string, string[]> | Array<Record<string, string[]>>;
|
|
150
|
+
/** Scopes for a single named scheme passed as `security: "oauth2"` + `scopes: [...]`. */
|
|
151
|
+
scopes?: string[];
|
|
152
|
+
/**
|
|
153
|
+
* Reference a registered component schema as the request body (v3.13.42):
|
|
154
|
+
* requestSchema: "CreateUser" OR { name: "CreateUser", contentType: "application/json" }
|
|
155
|
+
* Emits `$ref: #/components/schemas/CreateUser` and lands the schema in components.schemas.
|
|
156
|
+
*/
|
|
157
|
+
requestSchema?: string | { name: string; contentType?: string };
|
|
158
|
+
/**
|
|
159
|
+
* Reference registered component schemas as response bodies, keyed by status (v3.13.42):
|
|
160
|
+
* responseSchemas: { 200: "User", 201: { name: "User", isList: true } }
|
|
161
|
+
*/
|
|
162
|
+
responseSchemas?: Record<string, string | { name: string; isList?: boolean }>;
|
|
141
163
|
}
|
|
142
164
|
|
|
143
165
|
export interface Tina4Config {
|
|
@@ -20,6 +20,120 @@ interface OpenAPISpec {
|
|
|
20
20
|
|
|
21
21
|
const WRITE_METHODS = new Set(["post", "put", "patch", "delete"]);
|
|
22
22
|
|
|
23
|
+
// ── Configuration registries (v3.13.42) ───────────────────────────
|
|
24
|
+
// Process-wide registries for security schemes and reusable component schemas
|
|
25
|
+
// declared programmatically (addSecurityScheme / addSchema). Kept module-level so
|
|
26
|
+
// app bootstrap can register before any generate() call; resetRegistry() clears
|
|
27
|
+
// them (tests). Parity with Python's Swagger.add_security_scheme/add_schema/reset_registry.
|
|
28
|
+
const registeredSchemes: Record<string, Record<string, unknown>> = {};
|
|
29
|
+
const registeredSchemas: Record<string, Record<string, unknown>> = {};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Register a named OpenAPI security scheme (e.g. an oauth2 scheme with scopes,
|
|
33
|
+
* or a custom apiKey). Call at app bootstrap, before generate(). A registered
|
|
34
|
+
* scheme may override the built-in bearerAuth.
|
|
35
|
+
*/
|
|
36
|
+
export function addSecurityScheme(name: string, definition: Record<string, unknown>): void {
|
|
37
|
+
registeredSchemes[name] = definition;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Register a reusable component schema, referenceable via meta.requestSchema /
|
|
42
|
+
* meta.responseSchemas or a raw $ref.
|
|
43
|
+
*/
|
|
44
|
+
export function addSchema(name: string, schema: Record<string, unknown>): void {
|
|
45
|
+
registeredSchemas[name] = schema;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Clear the security-scheme and schema registries (test helper). */
|
|
49
|
+
export function resetRegistry(): void {
|
|
50
|
+
for (const k of Object.keys(registeredSchemes)) delete registeredSchemes[k];
|
|
51
|
+
for (const k of Object.keys(registeredSchemas)) delete registeredSchemas[k];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Resolve TINA4_SWAGGER_OPENAPI to a concrete version. Default 3.0.3; "3.1"/"3.1.0" -> "3.1.0". */
|
|
55
|
+
function resolveOpenApiVersion(): string {
|
|
56
|
+
const v = (process.env.TINA4_SWAGGER_OPENAPI ?? "").trim();
|
|
57
|
+
if (!v) return "3.0.3";
|
|
58
|
+
if (v === "3.1" || v === "3.1.0") return "3.1.0";
|
|
59
|
+
if (v === "3.0" || v === "3.0.3") return "3.0.3";
|
|
60
|
+
return v; // honour an explicit full version verbatim
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Comma-separated env value -> clean list. */
|
|
64
|
+
function csv(val: string | undefined): string[] {
|
|
65
|
+
return (val ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Resolve components.securitySchemes from defaults + env + registry. */
|
|
69
|
+
function resolveSecuritySchemes(): Record<string, Record<string, unknown>> {
|
|
70
|
+
const bearerFormat = process.env.TINA4_SWAGGER_BEARER_FORMAT ?? "JWT";
|
|
71
|
+
const schemes: Record<string, Record<string, unknown>> = {
|
|
72
|
+
bearerAuth: { type: "http", scheme: "bearer", bearerFormat },
|
|
73
|
+
};
|
|
74
|
+
const apiKeyName = (process.env.TINA4_SWAGGER_API_KEY_NAME ?? "").trim();
|
|
75
|
+
if (apiKeyName.length > 0) {
|
|
76
|
+
const rawIn = process.env.TINA4_SWAGGER_API_KEY_IN ?? "header";
|
|
77
|
+
const apiKeyIn = ["header", "query", "cookie"].includes(rawIn) ? rawIn : "header";
|
|
78
|
+
schemes.apiKeyAuth = { type: "apiKey", name: apiKeyName, in: apiKeyIn };
|
|
79
|
+
}
|
|
80
|
+
// Registered schemes win (let an app override bearerAuth or add oauth2).
|
|
81
|
+
for (const [name, def] of Object.entries(registeredSchemes)) {
|
|
82
|
+
schemes[name] = def;
|
|
83
|
+
}
|
|
84
|
+
return schemes;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Normalize a meta.security value (+ optional scopes) into an OpenAPI
|
|
89
|
+
* security-requirement list. Mirrors Python's _normalize_security.
|
|
90
|
+
*/
|
|
91
|
+
function normalizeSecurity(
|
|
92
|
+
value: NonNullable<unknown> | undefined,
|
|
93
|
+
scopes: string[] | undefined
|
|
94
|
+
): Array<Record<string, string[]>> {
|
|
95
|
+
if ((value === "public" || value === "none" || value === undefined || value === null) && (!scopes || scopes.length === 0)) {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
if (typeof value === "string") {
|
|
99
|
+
return [{ [value]: [...(scopes ?? [])] }];
|
|
100
|
+
}
|
|
101
|
+
if (Array.isArray(value)) {
|
|
102
|
+
if (value.length === 0) return [];
|
|
103
|
+
return value.map((req) => normalizeRequirementMap(req as Record<string, string[]>));
|
|
104
|
+
}
|
|
105
|
+
if (value !== null && typeof value === "object") {
|
|
106
|
+
return [normalizeRequirementMap(value as Record<string, string[]>)];
|
|
107
|
+
}
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function normalizeRequirementMap(req: Record<string, string[]>): Record<string, string[]> {
|
|
112
|
+
const out: Record<string, string[]> = {};
|
|
113
|
+
for (const [k, v] of Object.entries(req)) out[k] = [...(v ?? [])];
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Keep a security-requirement list spec-valid: scopes are allowed only on
|
|
119
|
+
* oauth2/openIdConnect schemes; everything else gets an empty array (OpenAPI
|
|
120
|
+
* requires it). Mirrors Python's _sanitize_security.
|
|
121
|
+
*/
|
|
122
|
+
function sanitizeSecurity(
|
|
123
|
+
reqs: Array<Record<string, string[]>>,
|
|
124
|
+
schemes: Record<string, Record<string, unknown>>
|
|
125
|
+
): Array<Record<string, string[]>> {
|
|
126
|
+
const scopeOk = new Set(["oauth2", "openIdConnect"]);
|
|
127
|
+
return reqs.map((req) => {
|
|
128
|
+
const clean: Record<string, string[]> = {};
|
|
129
|
+
for (const [name, scopes] of Object.entries(req)) {
|
|
130
|
+
const stype = (schemes[name] as Record<string, unknown> | undefined)?.type;
|
|
131
|
+
clean[name] = scopeOk.has(stype as string) ? [...scopes] : [];
|
|
132
|
+
}
|
|
133
|
+
return clean;
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
23
137
|
export function generate(
|
|
24
138
|
routes: RouteDefinition[],
|
|
25
139
|
models: ModelDefinition[] = []
|
|
@@ -49,21 +163,31 @@ export function generate(
|
|
|
49
163
|
info.license = url ? { name, url } : { name };
|
|
50
164
|
}
|
|
51
165
|
|
|
166
|
+
const schemes = resolveSecuritySchemes();
|
|
52
167
|
const spec: OpenAPISpec = {
|
|
53
|
-
openapi:
|
|
168
|
+
openapi: resolveOpenApiVersion(),
|
|
54
169
|
info,
|
|
55
170
|
servers: resolveServers(),
|
|
56
171
|
paths: {},
|
|
57
172
|
components: {
|
|
58
173
|
schemas: {},
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
},
|
|
174
|
+
// Configurable security schemes (v3.13.42): bearerFormat via env, optional
|
|
175
|
+
// apiKey scheme, plus any programmatically-registered schemes (which may
|
|
176
|
+
// override bearerAuth — e.g. an oauth2 scheme with scopes).
|
|
177
|
+
securitySchemes: schemes,
|
|
64
178
|
},
|
|
65
179
|
};
|
|
66
180
|
|
|
181
|
+
// Default scheme secured routes use when no explicit meta.security is set.
|
|
182
|
+
const defaultScheme = process.env.TINA4_SWAGGER_DEFAULT_SCHEME ?? "bearerAuth";
|
|
183
|
+
|
|
184
|
+
// Path filters (comma-separated raw-path prefixes).
|
|
185
|
+
const includePrefixes = csv(process.env.TINA4_SWAGGER_INCLUDE);
|
|
186
|
+
const excludePrefixes = csv(process.env.TINA4_SWAGGER_EXCLUDE);
|
|
187
|
+
|
|
188
|
+
// Reusable custom schemas referenced by routes via meta.requestSchema/responseSchemas.
|
|
189
|
+
const refSchemas = new Set<string>();
|
|
190
|
+
|
|
67
191
|
// Generate schemas from models
|
|
68
192
|
for (const model of models) {
|
|
69
193
|
const schema = modelToSchema(model);
|
|
@@ -75,6 +199,7 @@ export function generate(
|
|
|
75
199
|
|
|
76
200
|
// Generate paths from routes
|
|
77
201
|
for (const route of routes) {
|
|
202
|
+
if (!isIncludedPath(route.pattern, includePrefixes, excludePrefixes)) continue;
|
|
78
203
|
const openApiPath = patternToOpenAPI(route.pattern);
|
|
79
204
|
const method = route.method.toLowerCase();
|
|
80
205
|
|
|
@@ -123,8 +248,20 @@ export function generate(
|
|
|
123
248
|
}
|
|
124
249
|
}
|
|
125
250
|
|
|
126
|
-
//
|
|
127
|
-
|
|
251
|
+
// Request body — a registered custom schema $ref (meta.requestSchema) wins,
|
|
252
|
+
// else the inferred-from-model body (POST/PUT to a resource), else an
|
|
253
|
+
// example-only body.
|
|
254
|
+
const reqSchemaRef = parseRequestSchema(route.meta?.requestSchema);
|
|
255
|
+
if (reqSchemaRef && (method === "post" || method === "put" || method === "patch")) {
|
|
256
|
+
refSchemas.add(reqSchemaRef.name);
|
|
257
|
+
const media: Record<string, unknown> = {
|
|
258
|
+
schema: { $ref: `#/components/schemas/${reqSchemaRef.name}` },
|
|
259
|
+
};
|
|
260
|
+
if (route.meta?.example !== undefined) media.example = route.meta.example;
|
|
261
|
+
operation.requestBody = {
|
|
262
|
+
content: { [reqSchemaRef.contentType]: media },
|
|
263
|
+
};
|
|
264
|
+
} else if (method === "post" || method === "put") {
|
|
128
265
|
const modelName = inferModelFromPath(route.pattern);
|
|
129
266
|
if (modelName && models.some((m) => m.tableName === modelName)) {
|
|
130
267
|
const media: Record<string, unknown> = {
|
|
@@ -151,11 +288,35 @@ export function generate(
|
|
|
151
288
|
}
|
|
152
289
|
}
|
|
153
290
|
|
|
154
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
291
|
+
// Registered response schemas ($ref) — explicit and authoritative, keyed by status.
|
|
292
|
+
const respSchemas = parseResponseSchemas(route.meta?.responseSchemas);
|
|
293
|
+
if (respSchemas.length > 0) {
|
|
294
|
+
const responses = operation.responses as Record<string, unknown>;
|
|
295
|
+
for (const { status, name, isList } of respSchemas) {
|
|
296
|
+
refSchemas.add(name);
|
|
297
|
+
const sref = `#/components/schemas/${name}`;
|
|
298
|
+
const schema = isList ? { type: "array", items: { $ref: sref } } : { $ref: sref };
|
|
299
|
+
responses[status] = {
|
|
300
|
+
description: status.startsWith("2") ? "Successful response" : "Response",
|
|
301
|
+
content: { "application/json": { schema } },
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Security (v3.13.42) — explicit meta.security wins (empty list = explicitly
|
|
307
|
+
// public); otherwise a secured route gets the default scheme. Scopes are kept
|
|
308
|
+
// valid (only oauth2/openIdConnect carry them).
|
|
309
|
+
const hasExplicitSecurity =
|
|
310
|
+
route.meta?.security !== undefined || (route.meta?.scopes !== undefined && route.meta.scopes.length > 0);
|
|
311
|
+
if (hasExplicitSecurity) {
|
|
312
|
+
const normalized = normalizeSecurity(route.meta?.security, route.meta?.scopes);
|
|
313
|
+
operation.security = normalized.length > 0 ? sanitizeSecurity(normalized, schemes) : [];
|
|
314
|
+
if (normalized.length > 0) {
|
|
315
|
+
const responses = operation.responses as Record<string, unknown>;
|
|
316
|
+
if (!responses["401"]) responses["401"] = { description: "Unauthorized" };
|
|
317
|
+
}
|
|
318
|
+
} else if (routeRequiresAuth(route, method)) {
|
|
319
|
+
operation.security = sanitizeSecurity([{ [defaultScheme]: [] }], schemes);
|
|
159
320
|
const responses = operation.responses as Record<string, unknown>;
|
|
160
321
|
if (!responses["401"]) responses["401"] = { description: "Unauthorized" };
|
|
161
322
|
}
|
|
@@ -163,6 +324,16 @@ export function generate(
|
|
|
163
324
|
spec.paths[openApiPath][method] = operation;
|
|
164
325
|
}
|
|
165
326
|
|
|
327
|
+
// Registered component schemas referenced via meta.requestSchema/responseSchemas.
|
|
328
|
+
if (refSchemas.size > 0) {
|
|
329
|
+
const schemas = spec.components!.schemas!;
|
|
330
|
+
for (const name of refSchemas) {
|
|
331
|
+
if (name in registeredSchemas && !(name in schemas)) {
|
|
332
|
+
schemas[name] = registeredSchemas[name];
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
166
337
|
if (usedTags.length > 0) {
|
|
167
338
|
spec.tags = usedTags.map((name) => ({ name }));
|
|
168
339
|
}
|
|
@@ -176,6 +347,45 @@ function routeRequiresAuth(route: RouteDefinition, method: string): boolean {
|
|
|
176
347
|
return route.secure === true;
|
|
177
348
|
}
|
|
178
349
|
|
|
350
|
+
/**
|
|
351
|
+
* Path-filter a raw route pattern. Framework internals (/swagger, /__dev) are
|
|
352
|
+
* ALWAYS excluded; then TINA4_SWAGGER_INCLUDE (allow-list) / _EXCLUDE apply.
|
|
353
|
+
* Mirrors Python's _included.
|
|
354
|
+
*/
|
|
355
|
+
function isIncludedPath(rawPath: string, include: string[], exclude: string[]): boolean {
|
|
356
|
+
for (const internal of ["/swagger", "/__dev"]) {
|
|
357
|
+
if (rawPath === internal || rawPath.startsWith(internal + "/")) return false;
|
|
358
|
+
}
|
|
359
|
+
if (include.length > 0 && !include.some((p) => rawPath === p || rawPath.startsWith(p))) {
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
if (exclude.some((p) => rawPath === p || rawPath.startsWith(p))) return false;
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function parseRequestSchema(
|
|
367
|
+
spec: string | { name: string; contentType?: string } | undefined
|
|
368
|
+
): { name: string; contentType: string } | null {
|
|
369
|
+
if (spec === undefined) return null;
|
|
370
|
+
if (typeof spec === "string") return { name: spec, contentType: "application/json" };
|
|
371
|
+
return { name: spec.name, contentType: spec.contentType ?? "application/json" };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function parseResponseSchemas(
|
|
375
|
+
spec: Record<string, string | { name: string; isList?: boolean }> | undefined
|
|
376
|
+
): Array<{ status: string; name: string; isList: boolean }> {
|
|
377
|
+
if (!spec) return [];
|
|
378
|
+
const out: Array<{ status: string; name: string; isList: boolean }> = [];
|
|
379
|
+
for (const [status, value] of Object.entries(spec)) {
|
|
380
|
+
if (typeof value === "string") {
|
|
381
|
+
out.push({ status, name: value, isList: false });
|
|
382
|
+
} else {
|
|
383
|
+
out.push({ status, name: value.name, isList: value.isList === true });
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return out;
|
|
387
|
+
}
|
|
388
|
+
|
|
179
389
|
function resolveServers(): { url: string }[] {
|
|
180
390
|
const raw = (process.env.TINA4_SWAGGER_SERVERS ?? "").trim();
|
|
181
391
|
const urls = raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { generate } from "./generator.js";
|
|
1
|
+
export { generate, addSecurityScheme, addSchema, resetRegistry } from "./generator.js";
|
|
2
2
|
export { createSwaggerRoutes, swaggerEnabled } from "./ui.js";
|