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 CHANGED
@@ -1,4 +1,4 @@
1
- # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.42)
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
@@ -3,7 +3,7 @@
3
3
 
4
4
 
5
5
 
6
- "version": "3.13.42",
6
+ "version": "3.13.44",
7
7
 
8
8
  "type": "module",
9
9
  "description": "Tina4 for Node.js/TypeScript \u2014 54 built-in features, zero dependencies",
@@ -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.fetchAll("SELECT * FROM products"));
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.fetchAll("SELECT * FROM reviews WHERE product_id = ?", [product.id]));
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
- value = config.resolver(null, args, ctx);
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);
@@ -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
- return { info: "Migration status not yet implemented for Node.js" };
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
- return { info: "Migration run not yet implemented for Node.js" };
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
- const ok = this.liteBackend.retry(this.topic, job.id, delaySeconds);
299
- if (ok) retried = true;
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
- if (this.externalBackend) {
330
- return this.externalBackend.push(topic, payload, delay);
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
- q = this.topic;
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 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).
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
  }