tina4-nodejs 3.13.42 → 3.13.44
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 +2 -1
- package/package.json +1 -1
- package/packages/core/src/graphql.ts +23 -14
- package/packages/core/src/mcp.ts +21 -2
- package/packages/core/src/queue.ts +101 -9
- package/packages/core/src/queueBackends/kafkaBackend.ts +303 -167
- package/packages/core/src/queueBackends/mongoBackend.ts +143 -0
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +97 -31
- package/packages/core/src/server.ts +12 -5
- package/packages/core/src/session.ts +11 -95
- package/packages/core/src/sessionHandlers/mongoClient.ts +238 -0
- package/packages/core/src/sessionHandlers/mongoHandler.ts +25 -204
- package/packages/core/src/sessionHandlers/redisHandler.ts +69 -114
- package/packages/core/src/sessionHandlers/respClient.ts +171 -0
- package/packages/core/src/sessionHandlers/valkeyHandler.ts +11 -95
- package/packages/orm/src/adapters/firebird.ts +20 -2
- package/packages/orm/src/adapters/mssql.ts +24 -2
- package/packages/orm/src/adapters/mysql.ts +20 -2
- package/packages/orm/src/adapters/postgres.ts +40 -12
- package/packages/orm/src/adapters/sqlite.ts +16 -2
- package/packages/orm/src/autoCrud.ts +13 -0
- package/packages/orm/src/baseModel.ts +3 -1
- package/packages/orm/src/cachedDatabase.ts +1 -1
- package/packages/orm/src/database.ts +42 -11
- package/packages/orm/src/index.ts +1 -1
- package/packages/orm/src/migration.ts +124 -45
- package/packages/orm/src/types.ts +5 -3
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.44)
|
|
2
2
|
|
|
3
3
|
> This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
|
|
4
4
|
|
|
@@ -1239,6 +1239,7 @@ When adding new features, add a corresponding `test/<feature>.test.ts` file.
|
|
|
1239
1239
|
- **Don't break the test files** — run `npm test` before committing
|
|
1240
1240
|
- **Don't add unnecessary dependencies** — minimal footprint is a core principle
|
|
1241
1241
|
- **Parity across all frameworks** — Every new feature, fix, or optimization must be implemented with equivalent logic AND tests in all 4 Tina4 frameworks (Python, PHP, Ruby, Node.js). Never ship to one without shipping to all.
|
|
1242
|
+
- **NO mock testing. Mocks are not acceptable in any circumstances.** A test double (mock, stub, fake, spy, monkeypatch, script-introspection assertion, or any in-test object standing in for a real collaborator) may never substitute for a real dependency, under any justification. There is no "supplement" exception and no "hard to reproduce" exception. Any test that touches a dependency (a DB engine, MongoDB, Redis/Valkey/Memcached, RabbitMQ/Kafka, an HTTP/SMTP service, the filesystem, a socket) must exercise the REAL service; if a failure mode is hard to trigger, reproduce it for real, never simulate it. "Verified"/"green" requires a real run; a passing mock test is not verification. CI provisions the services; use them and add any that is missing. The only tests that need no live dependency are pure functions with no dependency and no double; that is not a mock test. (The MongoDB queue re-delivered every completed job for two releases because its queue tests were mock/script-introspection-based and never ran against a real Mongo.)
|
|
1242
1243
|
- **Don't use `url.parse()`** — use the WHATWG `URL` constructor instead (deprecated in Node 20+)
|
|
1243
1244
|
|
|
1244
1245
|
## Tina4 Maintainer Skill
|
package/package.json
CHANGED
|
@@ -510,8 +510,13 @@ export class GraphQL {
|
|
|
510
510
|
/**
|
|
511
511
|
* Decorator-style resolver registration.
|
|
512
512
|
*
|
|
513
|
+
* Resolvers may be synchronous OR async (return a Promise) — `execute()`
|
|
514
|
+
* awaits every resolver, so an `async` resolver's value is resolved before
|
|
515
|
+
* the field is serialized. Return a value directly, or `await` your data
|
|
516
|
+
* (e.g. an async DB driver) and the executor will await it for you.
|
|
517
|
+
*
|
|
513
518
|
* GraphQL.resolve("Query", "products", async (root, args) =>
|
|
514
|
-
* db.
|
|
519
|
+
* (await db.fetch("SELECT * FROM products")).records);
|
|
515
520
|
*
|
|
516
521
|
* GraphQL.resolve("Mutation", "createProduct", async (root, args) => {
|
|
517
522
|
* const p = new Product(args.input);
|
|
@@ -520,7 +525,7 @@ export class GraphQL {
|
|
|
520
525
|
* });
|
|
521
526
|
*
|
|
522
527
|
* GraphQL.resolve("Product", "reviews", async (product, args) =>
|
|
523
|
-
* db.
|
|
528
|
+
* (await db.fetch("SELECT * FROM reviews WHERE product_id = ?", [product.id])).records);
|
|
524
529
|
*
|
|
525
530
|
* Resolvers registered before any GraphQL instance exists accumulate
|
|
526
531
|
* in the class-level registry. `new GraphQL()` drains them into its
|
|
@@ -647,7 +652,7 @@ export class GraphQL {
|
|
|
647
652
|
/**
|
|
648
653
|
* Execute a GraphQL query string.
|
|
649
654
|
*/
|
|
650
|
-
execute(query: string, variables?: Record<string, unknown>, context?: Record<string, unknown>): GraphQLResult {
|
|
655
|
+
async execute(query: string, variables?: Record<string, unknown>, context?: Record<string, unknown>): Promise<GraphQLResult> {
|
|
651
656
|
const vars = variables ?? {};
|
|
652
657
|
const ctx = context ?? {};
|
|
653
658
|
const errors: Array<{ message: string; path?: string[] }> = [];
|
|
@@ -690,7 +695,7 @@ export class GraphQL {
|
|
|
690
695
|
|
|
691
696
|
const data: Record<string, unknown> = {};
|
|
692
697
|
// Top-level selections start at depth 1.
|
|
693
|
-
const errs = this.resolveSelectionsInto(op.selections, resolvers, null, vars, ctx, fragments, data, 1);
|
|
698
|
+
const errs = await this.resolveSelectionsInto(op.selections, resolvers, null, vars, ctx, fragments, data, 1);
|
|
694
699
|
errors.push(...errs);
|
|
695
700
|
|
|
696
701
|
const result: GraphQLResult = { data };
|
|
@@ -710,7 +715,7 @@ export class GraphQL {
|
|
|
710
715
|
* instead of recursing until the interpreter stack overflows. Top-level
|
|
711
716
|
* starts at depth 1; `maxDepth <= 0` disables the guard.
|
|
712
717
|
*/
|
|
713
|
-
private resolveSelectionsInto(
|
|
718
|
+
private async resolveSelectionsInto(
|
|
714
719
|
selections: ParsedSelection[],
|
|
715
720
|
resolvers: Map<string, QueryConfig>,
|
|
716
721
|
parent: unknown,
|
|
@@ -719,7 +724,7 @@ export class GraphQL {
|
|
|
719
724
|
fragments: Map<string, ParsedFragment>,
|
|
720
725
|
target: Record<string, unknown>,
|
|
721
726
|
depth: number,
|
|
722
|
-
): Array<{ message: string; path?: string[] }
|
|
727
|
+
): Promise<Array<{ message: string; path?: string[] }>> {
|
|
723
728
|
const errors: Array<{ message: string; path?: string[] }> = [];
|
|
724
729
|
|
|
725
730
|
if (this.maxDepth > 0 && depth > this.maxDepth) {
|
|
@@ -736,7 +741,7 @@ export class GraphQL {
|
|
|
736
741
|
errors.push({ message: `Fragment not found: ${sel.name}` });
|
|
737
742
|
continue;
|
|
738
743
|
}
|
|
739
|
-
const errs = this.resolveSelectionsInto(
|
|
744
|
+
const errs = await this.resolveSelectionsInto(
|
|
740
745
|
frag.selections, resolvers, parent, variables, context, fragments, target, depth + 1,
|
|
741
746
|
);
|
|
742
747
|
errors.push(...errs);
|
|
@@ -744,14 +749,14 @@ export class GraphQL {
|
|
|
744
749
|
}
|
|
745
750
|
|
|
746
751
|
if (sel.kind === "inline_fragment") {
|
|
747
|
-
const errs = this.resolveSelectionsInto(
|
|
752
|
+
const errs = await this.resolveSelectionsInto(
|
|
748
753
|
sel.selections, resolvers, parent, variables, context, fragments, target, depth + 1,
|
|
749
754
|
);
|
|
750
755
|
errors.push(...errs);
|
|
751
756
|
continue;
|
|
752
757
|
}
|
|
753
758
|
|
|
754
|
-
const [value, errs] = this.resolveField(sel, resolvers, parent, variables, context, fragments, depth);
|
|
759
|
+
const [value, errs] = await this.resolveField(sel, resolvers, parent, variables, context, fragments, depth);
|
|
755
760
|
errors.push(...errs);
|
|
756
761
|
const key = sel.alias ?? sel.name;
|
|
757
762
|
target[key] = value;
|
|
@@ -1010,7 +1015,7 @@ export class GraphQL {
|
|
|
1010
1015
|
return `(${parts.join(", ")})`;
|
|
1011
1016
|
}
|
|
1012
1017
|
|
|
1013
|
-
private resolveField(
|
|
1018
|
+
private async resolveField(
|
|
1014
1019
|
sel: ParsedField,
|
|
1015
1020
|
resolvers: Map<string, QueryConfig>,
|
|
1016
1021
|
parent: unknown,
|
|
@@ -1018,7 +1023,7 @@ export class GraphQL {
|
|
|
1018
1023
|
context: Record<string, unknown> = {},
|
|
1019
1024
|
fragments: Map<string, ParsedFragment> = new Map(),
|
|
1020
1025
|
depth: number = 1,
|
|
1021
|
-
): [unknown, Array<{ message: string; path?: string[] }>] {
|
|
1026
|
+
): Promise<[unknown, Array<{ message: string; path?: string[] }>]> {
|
|
1022
1027
|
const errors: Array<{ message: string; path?: string[] }> = [];
|
|
1023
1028
|
const name = sel.name;
|
|
1024
1029
|
const args = this.resolveArgs(sel.args, variables);
|
|
@@ -1046,7 +1051,11 @@ export class GraphQL {
|
|
|
1046
1051
|
// Inject sub-selections into context for DataLoader/eager-loading
|
|
1047
1052
|
const ctx = { ...context, __selections: sel.selections ?? [] };
|
|
1048
1053
|
try {
|
|
1049
|
-
|
|
1054
|
+
// Resolvers may be sync or async. Awaiting a plain value is a no-op;
|
|
1055
|
+
// awaiting a Promise (async resolver) resolves it before serialization.
|
|
1056
|
+
// A rejected Promise throws here, into the same catch below, so async
|
|
1057
|
+
// resolver errors are masked/detailed exactly like sync ones.
|
|
1058
|
+
value = await config.resolver(null, args, ctx);
|
|
1050
1059
|
} catch (e: unknown) {
|
|
1051
1060
|
// Log the real cause; only surface the detail to the client in debug
|
|
1052
1061
|
// mode — a resolver exception can carry internal state (DB errors,
|
|
@@ -1067,7 +1076,7 @@ export class GraphQL {
|
|
|
1067
1076
|
const result: Record<string, unknown>[] = [];
|
|
1068
1077
|
for (const item of value) {
|
|
1069
1078
|
const obj: Record<string, unknown> = {};
|
|
1070
|
-
const errs = this.resolveSelectionsInto(
|
|
1079
|
+
const errs = await this.resolveSelectionsInto(
|
|
1071
1080
|
sel.selections, new Map(), item, variables, context, fragments, obj, depth + 1,
|
|
1072
1081
|
);
|
|
1073
1082
|
errors.push(...errs);
|
|
@@ -1078,7 +1087,7 @@ export class GraphQL {
|
|
|
1078
1087
|
|
|
1079
1088
|
if (value !== null && value !== undefined) {
|
|
1080
1089
|
const obj: Record<string, unknown> = {};
|
|
1081
|
-
const errs = this.resolveSelectionsInto(
|
|
1090
|
+
const errs = await this.resolveSelectionsInto(
|
|
1082
1091
|
sel.selections, new Map(), value, variables, context, fragments, obj, depth + 1,
|
|
1083
1092
|
);
|
|
1084
1093
|
errors.push(...errs);
|
package/packages/core/src/mcp.ts
CHANGED
|
@@ -1231,7 +1231,17 @@ export function registerDevTools(server: McpServer): void {
|
|
|
1231
1231
|
try {
|
|
1232
1232
|
const db = (globalThis as any).__tina4_db;
|
|
1233
1233
|
if (!db) return { error: "No database connection" };
|
|
1234
|
-
|
|
1234
|
+
// Use the real migration API (parity with the Python master, whose MCP
|
|
1235
|
+
// migration_status calls MigrationRunner(db).status()). Previously a stub.
|
|
1236
|
+
// Load orm via dynamic ESM import (the same mechanism server.ts uses for
|
|
1237
|
+
// autoMigrateOnStartup) — reqSibling()'s createRequire fails here because
|
|
1238
|
+
// @tina4/orm imports @tina4/core (no CJS "exports" main).
|
|
1239
|
+
const orm = await import("../../orm/src/index.js");
|
|
1240
|
+
if (typeof orm.status !== "function") {
|
|
1241
|
+
return { error: "Migration API not available (install @tina4/orm)" };
|
|
1242
|
+
}
|
|
1243
|
+
const st = await orm.status(db, { migrationsDir: path.join(projectRoot, "migrations") });
|
|
1244
|
+
return { completed: st.completed, pending: st.pending };
|
|
1235
1245
|
} catch (e) {
|
|
1236
1246
|
return { error: (e as Error).message };
|
|
1237
1247
|
}
|
|
@@ -1264,7 +1274,16 @@ export function registerDevTools(server: McpServer): void {
|
|
|
1264
1274
|
try {
|
|
1265
1275
|
const db = (globalThis as any).__tina4_db;
|
|
1266
1276
|
if (!db) return { error: "No database connection" };
|
|
1267
|
-
|
|
1277
|
+
// Run pending migrations for real (parity with the Python master's MCP
|
|
1278
|
+
// migration_run -> Migration(db).migrate()). Previously a stub. Load orm
|
|
1279
|
+
// via dynamic ESM import (server.ts's mechanism); reqSibling's createRequire
|
|
1280
|
+
// fails because @tina4/orm imports @tina4/core.
|
|
1281
|
+
const orm = await import("../../orm/src/index.js");
|
|
1282
|
+
if (typeof orm.migrate !== "function") {
|
|
1283
|
+
return { error: "Migration API not available (install @tina4/orm)" };
|
|
1284
|
+
}
|
|
1285
|
+
const result = await orm.migrate(db, { migrationsDir: path.join(projectRoot, "migrations") });
|
|
1286
|
+
return { applied: result.applied, skipped: result.skipped, failed: result.failed };
|
|
1268
1287
|
} catch (e) {
|
|
1269
1288
|
return { error: (e as Error).message };
|
|
1270
1289
|
}
|
|
@@ -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
|
}
|