tina4-nodejs 3.13.0 → 3.13.1

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,10 +1,10 @@
1
- # CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.13.0)
1
+ # CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.13.1)
2
2
 
3
3
  > This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
4
4
 
5
5
  ## What This Project Is
6
6
 
7
- Tina4 for Node.js/TypeScript v3.13.0 — The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
7
+ Tina4 for Node.js/TypeScript v3.13.1 — The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
8
8
 
9
9
  The philosophy: zero ceremony, batteries included, file system as source of truth.
10
10
 
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
 
5
5
 
6
- "version": "3.13.0",
6
+ "version": "3.13.1",
7
7
 
8
8
  "type": "module",
9
9
  "description": "Tina4 for Node.js/TypeScript \u2014 54 built-in features, zero dependencies",
@@ -60,7 +60,7 @@
60
60
  "test": "tsx test/run-all.ts"
61
61
  },
62
62
  "engines": {
63
- "node": ">=20.0.0"
63
+ "node": ">=22.0.0"
64
64
  },
65
65
  "dependencies": {},
66
66
  "devDependencies": {
@@ -18,6 +18,23 @@ export interface ApiResult {
18
18
  error: string | null;
19
19
  }
20
20
 
21
+ /**
22
+ * Constructor options for {@link Api}. Used as the second argument to
23
+ * `new Api(url, { ... })` — cross-framework parity with Python
24
+ * `Api(bearer_token=, ...)` kwargs added in 3.13.x.
25
+ */
26
+ export interface ApiOptions {
27
+ authHeader?: string;
28
+ timeout?: number;
29
+ ignoreSsl?: boolean;
30
+ /** Positive form of ignoreSsl — `verifySsl: false` disables verification. */
31
+ verifySsl?: boolean;
32
+ bearerToken?: string;
33
+ username?: string;
34
+ password?: string;
35
+ headers?: Record<string, string>;
36
+ }
37
+
21
38
  export class Api {
22
39
  private baseUrl: string;
23
40
  private headers: Record<string, string>;
@@ -25,11 +42,56 @@ export class Api {
25
42
  private authHeader: string;
26
43
  private ignoreSsl: boolean;
27
44
 
28
- constructor(baseUrl: string = "", authHeader: string = "", timeout: number = 30) {
45
+ /**
46
+ * Construct an Api client.
47
+ *
48
+ * Two construction styles supported:
49
+ *
50
+ * // Legacy positional form
51
+ * new Api("https://api.example.com", "Bearer token", 30);
52
+ *
53
+ * // 3.13.1: ergonomic options bag (recommended) — cross-framework
54
+ * // parity with Python tina4_python.api.Api kwargs.
55
+ * new Api("https://api.example.com", { bearerToken: "sk-abc" });
56
+ * new Api("https://api.example.com", { username: "u", password: "p" });
57
+ * new Api("https://api.example.com", { headers: { "X-Tenant": "acme" } });
58
+ * new Api("https://self-signed.local", { verifySsl: false });
59
+ *
60
+ * Bearer wins over basic-auth when both passed. `verifySsl: false` is
61
+ * the positive form of `ignoreSsl: true`; `ignoreSsl` wins when both
62
+ * supplied for backward compatibility.
63
+ */
64
+ constructor(
65
+ baseUrl: string = "",
66
+ authHeaderOrOptions: string | ApiOptions = "",
67
+ timeout: number = 30
68
+ ) {
29
69
  this.baseUrl = baseUrl.replace(/\/+$/, "");
30
- this.authHeader = authHeader;
31
- this.timeout = timeout;
32
70
  this.headers = {};
71
+
72
+ // Options-bag form — second arg is an object literal
73
+ if (typeof authHeaderOrOptions === "object" && authHeaderOrOptions !== null) {
74
+ const opts = authHeaderOrOptions;
75
+ this.authHeader = opts.authHeader ?? "";
76
+ this.timeout = opts.timeout ?? timeout;
77
+ this.ignoreSsl = (opts.ignoreSsl ?? false) || (opts.verifySsl === false);
78
+
79
+ // Bearer wins over basic-auth when both are passed
80
+ if (opts.bearerToken != null) {
81
+ this.setBearerToken(opts.bearerToken);
82
+ } else if (opts.username != null && opts.password != null) {
83
+ this.setBasicAuth(opts.username, opts.password);
84
+ }
85
+
86
+ if (opts.headers) {
87
+ this.addHeaders(opts.headers);
88
+ }
89
+ return;
90
+ }
91
+
92
+ // Legacy positional form
93
+ this.authHeader = authHeaderOrOptions;
94
+ this.timeout = timeout;
33
95
  this.ignoreSsl = false;
34
96
  }
35
97
 
@@ -403,8 +403,106 @@ export class GraphQL {
403
403
  private types: Map<string, Record<string, GraphQLField>> = new Map();
404
404
  private queries: Map<string, QueryConfig> = new Map();
405
405
  private mutations: Map<string, QueryConfig> = new Map();
406
+ /** Object-type field resolvers indexed by `[typeName][fieldName]`. */
407
+ private fieldResolvers: Map<string, Map<string, ResolverFn>> = new Map();
408
+
409
+ // ── Class-level resolver registry — 3.13.1 ──────────────────────────
410
+ //
411
+ // Resolvers registered via `GraphQL.resolve("Type", "field", fn)`
412
+ // accumulate here BEFORE any GraphQL instance exists. When `new GraphQL()`
413
+ // runs, the instance drains the registry into its schema. Cross-framework
414
+ // parity with Python @GraphQL.resolve, PHP GraphQL::resolve, Ruby
415
+ // Tina4::GraphQL.resolve.
416
+ private static classResolvers = new Map<string, Map<string, ResolverFn>>();
417
+ private static defaultInstance: GraphQL | null = null;
406
418
 
407
- constructor() {}
419
+ /**
420
+ * Decorator-style resolver registration.
421
+ *
422
+ * GraphQL.resolve("Query", "products", async (root, args) =>
423
+ * db.fetchAll("SELECT * FROM products"));
424
+ *
425
+ * GraphQL.resolve("Mutation", "createProduct", async (root, args) => {
426
+ * const p = new Product(args.input);
427
+ * await p.save();
428
+ * return p.toDict();
429
+ * });
430
+ *
431
+ * GraphQL.resolve("Product", "reviews", async (product, args) =>
432
+ * db.fetchAll("SELECT * FROM reviews WHERE product_id = ?", [product.id]));
433
+ *
434
+ * Resolvers registered before any GraphQL instance exists accumulate
435
+ * in the class-level registry. `new GraphQL()` drains them into its
436
+ * schema. Resolvers registered after `setDefault(gql)` wire into the
437
+ * live schema immediately.
438
+ */
439
+ static resolve(typeName: string, fieldName: string, resolver: ResolverFn): void {
440
+ let typeMap = GraphQL.classResolvers.get(typeName);
441
+ if (!typeMap) {
442
+ typeMap = new Map();
443
+ GraphQL.classResolvers.set(typeName, typeMap);
444
+ }
445
+ typeMap.set(fieldName, resolver);
446
+
447
+ if (GraphQL.defaultInstance) {
448
+ GraphQL.defaultInstance.attachResolver(typeName, fieldName, resolver);
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Designate `instance` as the default singleton. Post-startup
454
+ * `GraphQL.resolve()` calls wire into this instance's live schema.
455
+ */
456
+ static setDefault(instance: GraphQL): void {
457
+ GraphQL.defaultInstance = instance;
458
+ }
459
+
460
+ /** Test-only — clear the class-level registry. */
461
+ static _clearClassResolvers(): void {
462
+ GraphQL.classResolvers.clear();
463
+ GraphQL.defaultInstance = null;
464
+ }
465
+
466
+ constructor() {
467
+ // Drain any resolvers registered via the class-level GraphQL.resolve()
468
+ // BEFORE this instance was constructed.
469
+ for (const [typeName, fields] of GraphQL.classResolvers.entries()) {
470
+ for (const [fieldName, resolver] of fields.entries()) {
471
+ this.attachResolver(typeName, fieldName, resolver);
472
+ }
473
+ }
474
+ }
475
+
476
+ /** Wire a single resolver into the live schema. */
477
+ private attachResolver(typeName: string, fieldName: string, resolver: ResolverFn): void {
478
+ if (typeName === "Query") {
479
+ const existing = this.queries.get(fieldName) ?? { args: {}, returnType: "String", resolver };
480
+ existing.resolver = resolver;
481
+ this.queries.set(fieldName, existing);
482
+ return;
483
+ }
484
+ if (typeName === "Mutation") {
485
+ const existing = this.mutations.get(fieldName) ?? { args: {}, returnType: "String", resolver };
486
+ existing.resolver = resolver;
487
+ this.mutations.set(fieldName, existing);
488
+ return;
489
+ }
490
+ // Object-type field resolver
491
+ let typeMap = this.fieldResolvers.get(typeName);
492
+ if (!typeMap) {
493
+ typeMap = new Map();
494
+ this.fieldResolvers.set(typeName, typeMap);
495
+ }
496
+ typeMap.set(fieldName, resolver);
497
+ }
498
+
499
+ /**
500
+ * Get the field resolver registered for an object type, if any.
501
+ * Used by the executor during nested field resolution.
502
+ */
503
+ getFieldResolver(typeName: string, fieldName: string): ResolverFn | undefined {
504
+ return this.fieldResolvers.get(typeName)?.get(fieldName);
505
+ }
408
506
 
409
507
  /**
410
508
  * Return schema metadata for debugging.
@@ -64,7 +64,7 @@ export {
64
64
  CLOSE_NORMAL, CLOSE_PROTOCOL_ERROR,
65
65
  } from "./websocket.js";
66
66
  export type { WebSocketClient } from "./websocket.js";
67
- export { ServiceRunner, matchCronField, matchesCron } from "./service.js";
67
+ export { ServiceRunner, Tina4Service, matchCronField, matchesCron } from "./service.js";
68
68
  export type { ServiceOptions, ServiceContext, ServiceHandler, ServiceInfo } from "./service.js";
69
69
  export { responseCache, clearCache, cacheStats, cacheGet, cacheSet, cacheDelete, cacheClear, cacheBackendStats, _resetBackend } from "./cache.js";
70
70
  export type { ResponseCacheConfig } from "./cache.js";
@@ -161,6 +161,64 @@ function startDaemonService(svc: RegisteredService): void {
161
161
  executeHandler(svc);
162
162
  }
163
163
 
164
+ // ─── Tina4Service base class (3.13.1) ───────────────────────────────────────
165
+ //
166
+ // Class-based background service pattern. Cross-framework parity with
167
+ // Python tina4_python.service (when shipped), PHP Tina4\Service, and Ruby
168
+ // Tina4::Service. The documentation has long taught:
169
+ //
170
+ // class EmailQueueWorker extends Tina4Service {
171
+ // async run() {
172
+ // while (!this.shouldStop()) {
173
+ // // process work
174
+ // }
175
+ // }
176
+ // }
177
+ //
178
+ // ServiceRunner.registerService("emails", new EmailQueueWorker());
179
+ // await ServiceRunner.start();
180
+ //
181
+ // Subclasses MUST override `run()`. Optionally override `stop()` for
182
+ // custom shutdown; always call `super.stop()` so the internal flag
183
+ // gets set — `shouldStop()` reads from it.
184
+
185
+ export abstract class Tina4Service {
186
+ private _running = true;
187
+
188
+ /** Main work loop — subclasses MUST override. */
189
+ abstract run(): Promise<void> | void;
190
+
191
+ /**
192
+ * Signal this service to stop. The next `shouldStop()` check returns true.
193
+ * Override for custom shutdown behaviour but always call `super.stop()`.
194
+ */
195
+ stop(): void {
196
+ this._running = false;
197
+ }
198
+
199
+ /**
200
+ * Returns true once `stop()` has been called. Use inside `run()` loops
201
+ * as the exit condition:
202
+ *
203
+ * async run() {
204
+ * while (!this.shouldStop()) { ... }
205
+ * }
206
+ */
207
+ shouldStop(): boolean {
208
+ return !this._running;
209
+ }
210
+
211
+ /**
212
+ * Return a callable that ServiceRunner can register. Used by
213
+ * ServiceRunner.registerService under the hood.
214
+ */
215
+ asHandler(): ServiceHandler {
216
+ return async () => {
217
+ await this.run();
218
+ };
219
+ }
220
+ }
221
+
164
222
  // ─── ServiceRunner ───────────────────────────────────────────────────────────
165
223
 
166
224
  export class ServiceRunner {
@@ -187,6 +245,35 @@ export class ServiceRunner {
187
245
  });
188
246
  }
189
247
 
248
+ /**
249
+ * Register a class-based service (subclass of {@link Tina4Service}) by name.
250
+ *
251
+ * Wraps the service's `run()` method as the runner's handler. Defaults
252
+ * to `daemon: true` because Tina4Service subclasses manage their own
253
+ * loop inside `run()`. Override via `options`.
254
+ *
255
+ * class EmailWorker extends Tina4Service { async run() { ... } }
256
+ * ServiceRunner.registerService("emails", new EmailWorker());
257
+ * await ServiceRunner.start();
258
+ *
259
+ * Cross-framework parity with PHP `ServiceRunner::registerService` and
260
+ * Ruby `Tina4::ServiceRunner.register_service`.
261
+ */
262
+ static registerService(
263
+ name: string,
264
+ service: Tina4Service,
265
+ options: ServiceOptions = {},
266
+ ): void {
267
+ const merged: ServiceOptions = { daemon: true, ...options };
268
+ this.register(name, service.asHandler(), merged);
269
+ // Stash the instance on the registry entry so future stop() calls
270
+ // can route to service.stop().
271
+ const entry = registry.get(name);
272
+ if (entry) {
273
+ (entry as unknown as Record<string, unknown>).instance = service;
274
+ }
275
+ }
276
+
190
277
  /**
191
278
  * Discover services from a directory. Each file should export
192
279
  * { name, handler, timing?, interval?, daemon?, maxRetries? }.
@@ -414,6 +414,23 @@ export class Database {
414
414
  return this.getNextAdapter().fetchOne<T>(sql, params);
415
415
  }
416
416
 
417
+ /**
418
+ * Fetch rows and return the records array directly.
419
+ *
420
+ * Symmetric with `fetchOne`. For the common case where you just want
421
+ * the rows and don't need the `DatabaseResult` metadata, this is one
422
+ * less attribute access than `fetch(...).records`.
423
+ *
424
+ * const rows = db.fetchAll("SELECT * FROM users WHERE active = ?", [true]);
425
+ * for (const row of rows) console.log(row.name);
426
+ *
427
+ * Returns `[]` (not `null`) when no rows match. Cross-framework parity
428
+ * with Python `db.fetch_all()`, PHP `$db->fetchAll()`, and Ruby `db.fetch_all`.
429
+ */
430
+ fetchAll<T = Record<string, unknown>>(sql: string, params?: unknown[], limit?: number, offset?: number): T[] {
431
+ return this.fetch(sql, params, limit, offset).records as T[];
432
+ }
433
+
417
434
  /**
418
435
  * Execute a write statement. Returns true/false for simple writes.
419
436
  * If SQL contains RETURNING, CALL, EXEC, or SELECT, returns the result set.
@@ -845,6 +862,37 @@ export function resolveDbPool(): number {
845
862
  return isNaN(n) || n < 0 ? 0 : n;
846
863
  }
847
864
 
865
+ /**
866
+ * Open a database connection — convention name matching SQLAlchemy
867
+ * `engine.connect()` and the cross-framework Database.get_connection()
868
+ * surface shipped in 3.13.x.
869
+ *
870
+ * Equivalent to `initDatabase({ url })` but with an opinionated, simpler
871
+ * signature: pass a URL string directly, or omit for env-based defaults
872
+ * (falls back to in-memory SQLite when nothing resolves).
873
+ *
874
+ * const db = await Database.getConnection(); // from env
875
+ * const db = await Database.getConnection("sqlite://./app.db"); // explicit URL
876
+ * const db = await Database.getConnection("postgres://localhost/x", { username: "u", password: "p" });
877
+ *
878
+ * Cross-framework parity with Python `Database.get_connection()`, PHP
879
+ * `\Tina4\Database::getConnection()`, and Ruby `Tina4::Database.get_connection`.
880
+ */
881
+ // eslint-disable-next-line @typescript-eslint/no-namespace
882
+ export namespace Database {
883
+ export async function getConnection(
884
+ url?: string,
885
+ opts: { username?: string; password?: string } = {}
886
+ ): Promise<Database> {
887
+ const resolvedUrl = url ?? process.env.TINA4_DATABASE_URL ?? "sqlite::memory:";
888
+ return initDatabase({
889
+ url: resolvedUrl,
890
+ username: opts.username,
891
+ password: opts.password,
892
+ });
893
+ }
894
+ }
895
+
848
896
  export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
849
897
  // Resolve credentials: config.user > config.username > env TINA4_DATABASE_USERNAME
850
898
  const resolvedUser = config?.user ?? config?.username ?? process.env.TINA4_DATABASE_USERNAME;