tina4-nodejs 3.13.41 → 3.13.43
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
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.43)
|
|
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
|
|
package/package.json
CHANGED
|
@@ -81,9 +81,17 @@ export interface ProcessOptions {
|
|
|
81
81
|
maxJobs?: number;
|
|
82
82
|
maxRetries?: number;
|
|
83
83
|
batchSize?: number;
|
|
84
|
+
/**
|
|
85
|
+
* Override the queue's topic for this drain (parity with Python's
|
|
86
|
+
* process(handler, topic=...)). When set, process() retargets the queue so
|
|
87
|
+
* pop() reads the requested topic instead of the construction-time one.
|
|
88
|
+
*/
|
|
89
|
+
topic?: string;
|
|
84
90
|
}
|
|
85
91
|
|
|
86
92
|
export interface ConsumeOptions {
|
|
93
|
+
/** Topic to consume (defaults to the constructor topic). */
|
|
94
|
+
topic?: string;
|
|
87
95
|
batchSize?: number;
|
|
88
96
|
pollInterval?: number;
|
|
89
97
|
iterations?: number;
|
|
@@ -95,6 +103,18 @@ export interface QueueBackendInterface {
|
|
|
95
103
|
pop(queue: string): QueueJob | null;
|
|
96
104
|
size(queue: string): number;
|
|
97
105
|
clear(queue: string): void;
|
|
106
|
+
// Optional full lifecycle. Reservation-based backends (MongoDB) implement
|
|
107
|
+
// these so complete()/fail() ack the ACTIVE store — without complete(), a
|
|
108
|
+
// reserved Mongo job is re-delivered after the visibility window. Backends
|
|
109
|
+
// that auto-ack on pop (RabbitMQ no-ack) or delegate to the broker (Kafka
|
|
110
|
+
// offsets) omit them, and the Queue keeps its prior behaviour for them.
|
|
111
|
+
complete?(queue: string, id: string): void;
|
|
112
|
+
fail?(queue: string, id: string, error: string, maxRetries: number, retryBackoff: number): void;
|
|
113
|
+
retry?(queue: string, id: string, delaySeconds?: number): void;
|
|
114
|
+
deadLetters?(queue: string, maxRetries?: number): QueueJob[];
|
|
115
|
+
failed?(queue: string, maxRetries?: number): QueueJob[];
|
|
116
|
+
retryFailed?(queue: string, maxRetries?: number): number;
|
|
117
|
+
purge?(queue: string, status?: string): number;
|
|
98
118
|
}
|
|
99
119
|
|
|
100
120
|
// ── Queue ────────────────────────────────────────────────────
|
|
@@ -152,6 +172,22 @@ export class Queue {
|
|
|
152
172
|
}
|
|
153
173
|
}
|
|
154
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Point this queue at ``topic`` in place.
|
|
177
|
+
*
|
|
178
|
+
* produce()/consume()/process() call this so a topic argument actually
|
|
179
|
+
* changes which topic is read or written. Without it the argument was
|
|
180
|
+
* accepted but ignored on the read path — pop() always used the
|
|
181
|
+
* construction-time topic, so consume("other") silently drained the wrong
|
|
182
|
+
* queue. The lite + external backends are topic-per-call (every push/pop/size
|
|
183
|
+
* takes the queue name), so changing this.topic retargets all of them; the
|
|
184
|
+
* job lifecycle (complete()/fail()/retry()) routes by the job's own .topic, so
|
|
185
|
+
* it is unaffected. Mirrors Python's Queue._retarget().
|
|
186
|
+
*/
|
|
187
|
+
private retarget(topic: string): void {
|
|
188
|
+
this.topic = topic;
|
|
189
|
+
}
|
|
190
|
+
|
|
155
191
|
// ── Unified API (topic-aware) ────────────────────────────────
|
|
156
192
|
|
|
157
193
|
/**
|
|
@@ -197,6 +233,12 @@ export class Queue {
|
|
|
197
233
|
handler: (job: QueueJob | QueueJob[]) => Promise<void> | void,
|
|
198
234
|
options?: ProcessOptions,
|
|
199
235
|
): void {
|
|
236
|
+
// Honour an explicit topic: retarget so pop() drains the requested topic
|
|
237
|
+
// (parity with Python's process(handler, topic=...)). Without this the
|
|
238
|
+
// argument was accepted but ignored on the read path.
|
|
239
|
+
if (options?.topic !== undefined) {
|
|
240
|
+
this.retarget(options.topic);
|
|
241
|
+
}
|
|
200
242
|
const queue = this.topic;
|
|
201
243
|
const opts = options;
|
|
202
244
|
|
|
@@ -275,6 +317,9 @@ export class Queue {
|
|
|
275
317
|
* auto-retry lifecycle; dead-lettered jobs are returned by deadLetters().
|
|
276
318
|
*/
|
|
277
319
|
failed(): QueueJob[] {
|
|
320
|
+
if (this.externalBackend?.failed) {
|
|
321
|
+
return this.externalBackend.failed(this.topic, this._maxRetries);
|
|
322
|
+
}
|
|
278
323
|
return this.liteBackend.failed(this.topic, this._maxRetries);
|
|
279
324
|
}
|
|
280
325
|
|
|
@@ -288,6 +333,10 @@ export class Queue {
|
|
|
288
333
|
retry(jobId?: string, delaySeconds?: number): boolean {
|
|
289
334
|
if (jobId) {
|
|
290
335
|
// Retry a specific job by ID
|
|
336
|
+
if (this.externalBackend?.retry) {
|
|
337
|
+
this.externalBackend.retry(this.topic, jobId, delaySeconds);
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
291
340
|
return this.liteBackend.retry(this.topic, jobId, delaySeconds);
|
|
292
341
|
}
|
|
293
342
|
// Retry all dead-letter jobs
|
|
@@ -295,8 +344,12 @@ export class Queue {
|
|
|
295
344
|
if (deadJobs.length === 0) return false;
|
|
296
345
|
let retried = false;
|
|
297
346
|
for (const job of deadJobs) {
|
|
298
|
-
|
|
299
|
-
|
|
347
|
+
if (this.externalBackend?.retry) {
|
|
348
|
+
this.externalBackend.retry(this.topic, job.id, delaySeconds);
|
|
349
|
+
retried = true;
|
|
350
|
+
} else if (this.liteBackend.retry(this.topic, job.id, delaySeconds)) {
|
|
351
|
+
retried = true;
|
|
352
|
+
}
|
|
300
353
|
}
|
|
301
354
|
return retried;
|
|
302
355
|
}
|
|
@@ -305,6 +358,9 @@ export class Queue {
|
|
|
305
358
|
* Get dead letter jobs — failed jobs that exceeded max retries.
|
|
306
359
|
*/
|
|
307
360
|
deadLetters(maxRetries?: number): QueueJob[] {
|
|
361
|
+
if (this.externalBackend?.deadLetters) {
|
|
362
|
+
return this.externalBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries);
|
|
363
|
+
}
|
|
308
364
|
return this.liteBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries);
|
|
309
365
|
}
|
|
310
366
|
|
|
@@ -312,6 +368,9 @@ export class Queue {
|
|
|
312
368
|
* Delete messages by status (e.g. "completed", "failed", "dead").
|
|
313
369
|
*/
|
|
314
370
|
purge(status: string, maxRetries?: number): number {
|
|
371
|
+
if (this.externalBackend?.purge) {
|
|
372
|
+
return this.externalBackend.purge(this.topic, status);
|
|
373
|
+
}
|
|
315
374
|
return this.liteBackend.purge(this.topic, status, maxRetries ?? this._maxRetries);
|
|
316
375
|
}
|
|
317
376
|
|
|
@@ -319,17 +378,27 @@ export class Queue {
|
|
|
319
378
|
* Re-queue failed jobs that haven't exceeded max retries back to pending.
|
|
320
379
|
*/
|
|
321
380
|
retryFailed(maxRetries?: number): number {
|
|
381
|
+
if (this.externalBackend?.retryFailed) {
|
|
382
|
+
return this.externalBackend.retryFailed(this.topic, maxRetries ?? this._maxRetries);
|
|
383
|
+
}
|
|
322
384
|
return this.liteBackend.retryFailed(this.topic, maxRetries ?? this._maxRetries);
|
|
323
385
|
}
|
|
324
386
|
|
|
325
387
|
/**
|
|
326
388
|
* Produce a message onto a topic. Convenience wrapper around push().
|
|
389
|
+
*
|
|
390
|
+
* Retargets to the requested topic (restoring the prior one afterwards) so it
|
|
391
|
+
* shares the same retarget path consume()/process() use — keeping produce and
|
|
392
|
+
* consume symmetric on the same topic argument.
|
|
327
393
|
*/
|
|
328
394
|
produce(topic: string, payload: unknown, priority: number = 0, delay: number = 0): string {
|
|
329
|
-
|
|
330
|
-
|
|
395
|
+
const previous = this.topic;
|
|
396
|
+
this.retarget(topic);
|
|
397
|
+
try {
|
|
398
|
+
return this.push(payload, delay, priority);
|
|
399
|
+
} finally {
|
|
400
|
+
this.retarget(previous);
|
|
331
401
|
}
|
|
332
|
-
return this.liteBackend.push(topic, payload, delay, priority);
|
|
333
402
|
}
|
|
334
403
|
|
|
335
404
|
/**
|
|
@@ -368,7 +437,9 @@ export class Queue {
|
|
|
368
437
|
|
|
369
438
|
if (topicOrOptions !== null && typeof topicOrOptions === "object") {
|
|
370
439
|
const opts = topicOrOptions as ConsumeOptions;
|
|
371
|
-
|
|
440
|
+
// Honour opts.topic (parity with the string-arg form) — previously the
|
|
441
|
+
// options-object form ignored it and always drained the constructor topic.
|
|
442
|
+
q = opts.topic ?? this.topic;
|
|
372
443
|
resolvedId = opts.id;
|
|
373
444
|
resolvedPollInterval = opts.pollInterval ?? 1000;
|
|
374
445
|
resolvedIterations = opts.iterations ?? 0;
|
|
@@ -381,6 +452,12 @@ export class Queue {
|
|
|
381
452
|
resolvedBatchSize = batchSize;
|
|
382
453
|
}
|
|
383
454
|
|
|
455
|
+
// Honour the topic argument: point the queue (and the backend pop()/
|
|
456
|
+
// popById() route through) at it. Previously the resolved topic was
|
|
457
|
+
// computed but never used — pop()/popById() read this.topic, so
|
|
458
|
+
// consume("other") silently drained the construction-time topic.
|
|
459
|
+
this.retarget(q);
|
|
460
|
+
|
|
384
461
|
if (resolvedId !== undefined) {
|
|
385
462
|
const raw = this.popById(resolvedId);
|
|
386
463
|
if (raw) yield createJob(raw as any, this);
|
|
@@ -453,6 +530,10 @@ export class Queue {
|
|
|
453
530
|
* after retryBackoff seconds) or dead-letter (attempts >= maxRetries).
|
|
454
531
|
*/
|
|
455
532
|
_failJob(queue: string, job: QueueJob, error: string, maxRetries: number): void {
|
|
533
|
+
if (this.externalBackend?.fail) {
|
|
534
|
+
this.externalBackend.fail(queue, job.id, error, maxRetries, this._retryBackoff);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
456
537
|
this.liteBackend.failJob(queue, job, error, maxRetries, this._retryBackoff);
|
|
457
538
|
}
|
|
458
539
|
|
|
@@ -460,15 +541,26 @@ export class Queue {
|
|
|
460
541
|
* Re-queue a job back to the main queue directory with incremented attempts.
|
|
461
542
|
*/
|
|
462
543
|
_retryJob(queue: string, job: QueueJob, delaySeconds?: number): void {
|
|
544
|
+
if (this.externalBackend?.retry) {
|
|
545
|
+
this.externalBackend.retry(queue, job.id, delaySeconds);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
463
548
|
this.liteBackend.retryJob(queue, job, delaySeconds);
|
|
464
549
|
}
|
|
465
550
|
|
|
466
551
|
/**
|
|
467
|
-
* Acknowledge a completed job — drop its reservation
|
|
468
|
-
*
|
|
469
|
-
*
|
|
552
|
+
* Acknowledge a completed job — drop its reservation so the visibility reclaim
|
|
553
|
+
* never re-delivers it. Routes to the active backend: a reservation-based
|
|
554
|
+
* external backend (MongoDB) acks there (without this its reserved doc would
|
|
555
|
+
* be re-delivered after the visibility window); RabbitMQ (no-ack on get) and
|
|
556
|
+
* Kafka (offset-based) expose no complete(), so the lite path is used and is a
|
|
557
|
+
* harmless no-op for them since they already acked/own redelivery.
|
|
470
558
|
*/
|
|
471
559
|
_completeJob(queue: string, job: QueueJob): void {
|
|
560
|
+
if (this.externalBackend?.complete) {
|
|
561
|
+
this.externalBackend.complete(queue, job.id);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
472
564
|
this.liteBackend.completeJob(queue, job);
|
|
473
565
|
}
|
|
474
566
|
}
|
|
@@ -229,12 +229,20 @@ export class MongoBackend implements QueueBackend {
|
|
|
229
229
|
if (result && result.value) {
|
|
230
230
|
// findOneAndUpdate returns { value: doc } in older drivers
|
|
231
231
|
const doc = result.value;
|
|
232
|
+
// Carry the framework topic on the job so complete()/fail()
|
|
233
|
+
// route the ack/requeue back to THIS topic's docs (the Mongo
|
|
234
|
+
// internal queue field is dropped from the returned shape).
|
|
235
|
+
doc.topic = queueName;
|
|
232
236
|
delete doc._id;
|
|
233
237
|
delete doc.queue;
|
|
234
238
|
process.stdout.write(JSON.stringify(doc));
|
|
235
239
|
} else if (result && result._id) {
|
|
236
240
|
// Some driver versions return the doc directly
|
|
237
241
|
const doc = { ...result };
|
|
242
|
+
// Carry the framework topic on the job so complete()/fail()
|
|
243
|
+
// route the ack/requeue back to THIS topic's docs (the Mongo
|
|
244
|
+
// internal queue field is dropped from the returned shape).
|
|
245
|
+
doc.topic = queueName;
|
|
238
246
|
delete doc._id;
|
|
239
247
|
delete doc.queue;
|
|
240
248
|
process.stdout.write(JSON.stringify(doc));
|
|
@@ -253,6 +261,95 @@ export class MongoBackend implements QueueBackend {
|
|
|
253
261
|
await col.deleteMany({ queue: queueName });
|
|
254
262
|
process.stdout.write("__CLEARED__");
|
|
255
263
|
}
|
|
264
|
+
else if (operation === "complete") {
|
|
265
|
+
// Ack a finished job so the reclaim never re-delivers it. data = job id.
|
|
266
|
+
// (The pop reserved this doc; without this it stays reserved and is
|
|
267
|
+
// re-delivered after the visibility window — the redelivery bug.)
|
|
268
|
+
await col.updateOne(
|
|
269
|
+
{ queue: queueName, id: data },
|
|
270
|
+
{ $set: { status: "completed", completedAt: new Date().toISOString(), reservedAt: null } },
|
|
271
|
+
);
|
|
272
|
+
process.stdout.write("__OK__");
|
|
273
|
+
}
|
|
274
|
+
else if (operation === "fail") {
|
|
275
|
+
// Requeue while retries remain (reset availableAt -> visible again),
|
|
276
|
+
// else dead-letter. Atomic decision in Mongo. data = JSON
|
|
277
|
+
// { id, error, maxRetries, retryBackoff }.
|
|
278
|
+
const info = JSON.parse(data);
|
|
279
|
+
const now = new Date().toISOString();
|
|
280
|
+
const doc = await col.findOne({ queue: queueName, id: info.id });
|
|
281
|
+
if (doc) {
|
|
282
|
+
const attempts = (doc.attempts || 0) + 1;
|
|
283
|
+
if (attempts >= info.maxRetries) {
|
|
284
|
+
await col.insertOne({
|
|
285
|
+
...doc, _id: undefined, attempts,
|
|
286
|
+
status: "dead", queue: queueName + ".dead_letter", error: info.error,
|
|
287
|
+
});
|
|
288
|
+
await col.deleteOne({ _id: doc._id, queue: queueName });
|
|
289
|
+
} else {
|
|
290
|
+
const avail = info.retryBackoff > 0
|
|
291
|
+
? new Date(Date.now() + info.retryBackoff * 1000).toISOString()
|
|
292
|
+
: now;
|
|
293
|
+
await col.updateOne(
|
|
294
|
+
{ _id: doc._id, queue: queueName },
|
|
295
|
+
{ $set: { status: "pending", availableAt: avail, reservedAt: null, error: info.error },
|
|
296
|
+
$inc: { attempts: 1 } },
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
process.stdout.write("__OK__");
|
|
301
|
+
}
|
|
302
|
+
else if (operation === "retry") {
|
|
303
|
+
// Explicit manual re-queue (always re-enqueues). data = JSON
|
|
304
|
+
// { id, delaySeconds }.
|
|
305
|
+
const info = JSON.parse(data);
|
|
306
|
+
const avail = info.delaySeconds > 0
|
|
307
|
+
? new Date(Date.now() + info.delaySeconds * 1000).toISOString()
|
|
308
|
+
: new Date().toISOString();
|
|
309
|
+
await col.updateOne(
|
|
310
|
+
{ queue: queueName, id: info.id },
|
|
311
|
+
{ $set: { status: "pending", availableAt: avail, reservedAt: null }, $inc: { attempts: 1 } },
|
|
312
|
+
);
|
|
313
|
+
process.stdout.write("__OK__");
|
|
314
|
+
}
|
|
315
|
+
else if (operation === "deadLetters") {
|
|
316
|
+
const docs = await col.find({ queue: queueName + ".dead_letter" }).toArray();
|
|
317
|
+
const out = docs.map((d) => { delete d._id; delete d.queue; return d; });
|
|
318
|
+
process.stdout.write(JSON.stringify(out));
|
|
319
|
+
}
|
|
320
|
+
else if (operation === "failed") {
|
|
321
|
+
const docs = await col
|
|
322
|
+
.find({ queue: queueName, status: "failed", attempts: { $lt: maxRetries } })
|
|
323
|
+
.toArray();
|
|
324
|
+
const out = docs.map((d) => { delete d._id; delete d.queue; return d; });
|
|
325
|
+
process.stdout.write(JSON.stringify(out));
|
|
326
|
+
}
|
|
327
|
+
else if (operation === "retryFailed") {
|
|
328
|
+
// Revive dead-lettered jobs under the (possibly raised) limit back to
|
|
329
|
+
// the main queue as pending. data = the max-retries limit.
|
|
330
|
+
const mr = data ? Number(data) : maxRetries;
|
|
331
|
+
const now = new Date().toISOString();
|
|
332
|
+
let revived = 0;
|
|
333
|
+
while (true) {
|
|
334
|
+
const doc = await col.findOneAndUpdate(
|
|
335
|
+
{ queue: queueName + ".dead_letter", attempts: { $lt: mr } },
|
|
336
|
+
{ $set: { status: "pending", availableAt: now, reservedAt: null, queue: queueName, error: null } },
|
|
337
|
+
{ returnDocument: "after" },
|
|
338
|
+
);
|
|
339
|
+
const updated = doc && doc.value ? doc.value : (doc && doc._id ? doc : null);
|
|
340
|
+
if (!updated) break;
|
|
341
|
+
revived++;
|
|
342
|
+
}
|
|
343
|
+
process.stdout.write(String(revived));
|
|
344
|
+
}
|
|
345
|
+
else if (operation === "purge") {
|
|
346
|
+
// Delete docs by status (default: all for the topic). data = JSON { status }.
|
|
347
|
+
const info = data ? JSON.parse(data) : {};
|
|
348
|
+
const filter = { queue: queueName };
|
|
349
|
+
if (info.status) filter.status = info.status;
|
|
350
|
+
const res = await col.deleteMany(filter);
|
|
351
|
+
process.stdout.write(String(res.deletedCount || 0));
|
|
352
|
+
}
|
|
256
353
|
} catch (err) {
|
|
257
354
|
process.stderr.write(err.message || String(err));
|
|
258
355
|
process.exit(1);
|
|
@@ -328,4 +425,50 @@ export class MongoBackend implements QueueBackend {
|
|
|
328
425
|
clear(queue: string): void {
|
|
329
426
|
this.execSync("clear", queue);
|
|
330
427
|
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Acknowledge a completed job — drop its reservation so the reclaim never
|
|
431
|
+
* re-delivers it. Without this a Mongo-popped job stayed reserved and was
|
|
432
|
+
* re-delivered after the visibility window (the redelivery bug).
|
|
433
|
+
*/
|
|
434
|
+
complete(queue: string, id: string): void {
|
|
435
|
+
this.execSync("complete", queue, id);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Record a failed attempt: requeue (reset availableAt, ++attempts) while
|
|
440
|
+
* retries remain, else dead-letter. Mirrors the file/lite backend.
|
|
441
|
+
*/
|
|
442
|
+
fail(queue: string, id: string, error: string, maxRetries: number, retryBackoff: number = 0): void {
|
|
443
|
+
this.execSync("fail", queue, JSON.stringify({ id, error, maxRetries, retryBackoff }));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Explicit manual re-queue (always re-enqueues regardless of the retry limit). */
|
|
447
|
+
retry(queue: string, id: string, delaySeconds: number = 0): void {
|
|
448
|
+
this.execSync("retry", queue, JSON.stringify({ id, delaySeconds }));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Jobs that exceeded max retries (the `<queue>.dead_letter` collection topic). */
|
|
452
|
+
deadLetters(queue: string, maxRetries?: number): QueueJob[] {
|
|
453
|
+
const out = this.execSync("deadLetters", queue, String(maxRetries ?? this.maxRetries));
|
|
454
|
+
try { return JSON.parse(out) as QueueJob[]; } catch { return []; }
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Jobs that failed but are still eligible for retry (status=failed, attempts < max). */
|
|
458
|
+
failed(queue: string, maxRetries?: number): QueueJob[] {
|
|
459
|
+
const out = this.execSync("failed", queue, String(maxRetries ?? this.maxRetries));
|
|
460
|
+
try { return JSON.parse(out) as QueueJob[]; } catch { return []; }
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Revive dead-lettered jobs under the (possibly raised) limit. Returns count revived. */
|
|
464
|
+
retryFailed(queue: string, maxRetries?: number): number {
|
|
465
|
+
const out = this.execSync("retryFailed", queue, String(maxRetries ?? this.maxRetries));
|
|
466
|
+
return parseInt(out, 10) || 0;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Remove jobs by status (default: every doc for the topic). Returns count removed. */
|
|
470
|
+
purge(queue: string, status?: string): number {
|
|
471
|
+
const out = this.execSync("purge", queue, JSON.stringify({ status: status ?? "" }));
|
|
472
|
+
return parseInt(out, 10) || 0;
|
|
473
|
+
}
|
|
331
474
|
}
|
|
@@ -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";
|