tina4-nodejs 3.13.0 → 3.13.2
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 -2
- package/package.json +2 -2
- package/packages/core/src/ai.ts +4 -4
- package/packages/core/src/api.ts +65 -3
- package/packages/core/src/graphql.ts +99 -1
- package/packages/core/src/index.ts +1 -1
- package/packages/core/src/scss.ts +60 -20
- package/packages/core/src/service.ts +87 -0
- package/packages/core/src/sessionHandlers/databaseHandler.ts +2 -2
- package/packages/orm/src/baseModel.ts +3 -3
- package/packages/orm/src/database.ts +52 -4
package/CLAUDE.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
# CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.13.
|
|
1
|
+
# CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.13.2)
|
|
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.
|
|
7
|
+
Tina4 for Node.js/TypeScript v3.13.2 — 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.
|
|
6
|
+
"version": "3.13.2",
|
|
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": ">=
|
|
63
|
+
"node": ">=22.0.0"
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {},
|
|
66
66
|
"devDependencies": {
|
package/packages/core/src/ai.ts
CHANGED
|
@@ -424,7 +424,7 @@ npx tina4nodejs routes # List routes
|
|
|
424
424
|
## Database
|
|
425
425
|
|
|
426
426
|
Default: SQLite via \`node:sqlite\`. Adapters for PostgreSQL, MySQL, MSSQL, Firebird.
|
|
427
|
-
Set \`
|
|
427
|
+
Set \`TINA4_DATABASE_URL\` in \`.env\` (e.g. \`postgres://localhost:5432/mydb\`).
|
|
428
428
|
|
|
429
429
|
## Auth
|
|
430
430
|
|
|
@@ -485,7 +485,7 @@ npx tina4nodejs routes # List routes
|
|
|
485
485
|
## Database
|
|
486
486
|
|
|
487
487
|
Default: SQLite via \`node:sqlite\`. Adapters for PostgreSQL, MySQL, MSSQL, Firebird.
|
|
488
|
-
Set \`
|
|
488
|
+
Set \`TINA4_DATABASE_URL\` in \`.env\`.
|
|
489
489
|
`;
|
|
490
490
|
}
|
|
491
491
|
|
|
@@ -593,7 +593,7 @@ npx tina4nodejs routes # List routes
|
|
|
593
593
|
## Database
|
|
594
594
|
|
|
595
595
|
Default: SQLite via \`node:sqlite\`. Adapters for PostgreSQL, MySQL, MSSQL, Firebird.
|
|
596
|
-
Set \`
|
|
596
|
+
Set \`TINA4_DATABASE_URL\` in \`.env\` (e.g. \`sqlite:///path/to/db.sqlite\`, \`postgres://localhost:5432/mydb\`).
|
|
597
597
|
|
|
598
598
|
## Auth
|
|
599
599
|
|
|
@@ -693,7 +693,7 @@ npx tina4nodejs routes # List routes
|
|
|
693
693
|
## Database
|
|
694
694
|
|
|
695
695
|
Default: SQLite via \`node:sqlite\`. Adapters for PostgreSQL, MySQL, MSSQL, Firebird.
|
|
696
|
-
Set \`
|
|
696
|
+
Set \`TINA4_DATABASE_URL\` in \`.env\`.
|
|
697
697
|
|
|
698
698
|
## Auth
|
|
699
699
|
|
package/packages/core/src/api.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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";
|
|
@@ -251,30 +251,70 @@ function resolveIncludes(
|
|
|
251
251
|
// ── Math Evaluation ──────────────────────────────────────────────
|
|
252
252
|
|
|
253
253
|
function evalMath(scss: string): string {
|
|
254
|
-
|
|
254
|
+
// Mixed-unit arithmetic is left verbatim — that is exactly what CSS calc()
|
|
255
|
+
// is for, and folding it silently produces invalid output (see tina4-nodejs#1).
|
|
256
|
+
// Math inside calc(...) is preserved untouched on the same principle: the
|
|
257
|
+
// author asked the browser to compute it.
|
|
258
|
+
//
|
|
259
|
+
// Rules for folding:
|
|
260
|
+
// * Both operands unitless → fold
|
|
261
|
+
// * Same unit on both operands → fold, keep unit
|
|
262
|
+
// * One operand unitless for * or / → fold, keep the other unit
|
|
263
|
+
// * Anything else (mixed units on +/-, etc) → leave verbatim
|
|
264
|
+
|
|
265
|
+
// Step 1 — mask calc(...) ranges so the math regex cannot eat into them.
|
|
266
|
+
const placeholders: string[] = [];
|
|
267
|
+
const masked = scss.replace(/calc\([^()]*\)/g, (m) => {
|
|
268
|
+
placeholders.push(m);
|
|
269
|
+
return `\x00CALC${placeholders.length - 1}\x00`;
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// Step 2 — run the math fold on what remains.
|
|
273
|
+
const folded = masked.replace(
|
|
255
274
|
/([\d.]+)([a-z%]*)\s*([+\-*/])\s*([\d.]+)([a-z%]*)/g,
|
|
256
|
-
(
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
return _m;
|
|
275
|
+
(full, n1: string, u1: string, op: string, n2: string, u2: string) => {
|
|
276
|
+
const num1 = parseFloat(n1);
|
|
277
|
+
const num2 = parseFloat(n2);
|
|
278
|
+
if (Number.isNaN(num1) || Number.isNaN(num2)) return full;
|
|
279
|
+
|
|
280
|
+
const unit1 = u1 || "";
|
|
281
|
+
const unit2 = u2 || "";
|
|
282
|
+
|
|
283
|
+
// Decide result unit; bail if units are incompatible.
|
|
284
|
+
let unit: string;
|
|
285
|
+
if (unit1 === unit2) {
|
|
286
|
+
unit = unit1;
|
|
287
|
+
} else if ((op === "*" || op === "/") && unit1 === "") {
|
|
288
|
+
unit = unit2;
|
|
289
|
+
} else if ((op === "*" || op === "/") && unit2 === "") {
|
|
290
|
+
unit = unit1;
|
|
291
|
+
} else {
|
|
292
|
+
return full;
|
|
275
293
|
}
|
|
294
|
+
|
|
295
|
+
let result: number;
|
|
296
|
+
switch (op) {
|
|
297
|
+
case "+": result = num1 + num2; break;
|
|
298
|
+
case "-": result = num1 - num2; break;
|
|
299
|
+
case "*": result = num1 * num2; break;
|
|
300
|
+
case "/":
|
|
301
|
+
if (num2 === 0) return full;
|
|
302
|
+
result = num1 / num2;
|
|
303
|
+
break;
|
|
304
|
+
default: return full;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (result === Math.floor(result)) {
|
|
308
|
+
return `${Math.floor(result)}${unit}`;
|
|
309
|
+
}
|
|
310
|
+
return `${result.toFixed(2)}${unit}`;
|
|
276
311
|
}
|
|
277
312
|
);
|
|
313
|
+
|
|
314
|
+
// Step 3 — restore the calc() ranges verbatim.
|
|
315
|
+
return folded.replace(/\x00CALC(\d+)\x00/g, (_m, idx: string) => {
|
|
316
|
+
return placeholders[parseInt(idx, 10)];
|
|
317
|
+
});
|
|
278
318
|
}
|
|
279
319
|
|
|
280
320
|
// ── Nesting Flattener ────────────────────────────────────────────
|
|
@@ -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? }.
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Stores sessions in a `tina4_session` table with JSON data and expiry.
|
|
6
6
|
*
|
|
7
7
|
* Configure via environment variables:
|
|
8
|
-
*
|
|
8
|
+
* TINA4_DATABASE_URL (default: "sqlite:///data/tina4_sessions.db")
|
|
9
9
|
*
|
|
10
10
|
* The handler dynamically imports `better-sqlite3` and throws a clear
|
|
11
11
|
* error if the package is not installed.
|
|
@@ -20,7 +20,7 @@ interface SessionData {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export interface DatabaseSessionConfig {
|
|
23
|
-
/** SQLite database file path (default: extracted from
|
|
23
|
+
/** SQLite database file path (default: extracted from TINA4_DATABASE_URL or "data/tina4_sessions.db") */
|
|
24
24
|
dbPath?: string;
|
|
25
25
|
}
|
|
26
26
|
|
|
@@ -206,7 +206,7 @@ export class BaseModel {
|
|
|
206
206
|
|
|
207
207
|
/**
|
|
208
208
|
* Get the database adapter for this model.
|
|
209
|
-
* If no adapter is registered, attempts auto-discovery from
|
|
209
|
+
* If no adapter is registered, attempts auto-discovery from TINA4_DATABASE_URL.
|
|
210
210
|
* SQLite URLs are initialised synchronously. Other engines require initDatabase()
|
|
211
211
|
* to be called before first use.
|
|
212
212
|
*/
|
|
@@ -229,12 +229,12 @@ export class BaseModel {
|
|
|
229
229
|
return adapter;
|
|
230
230
|
}
|
|
231
231
|
throw new Error(
|
|
232
|
-
`
|
|
232
|
+
`TINA4_DATABASE_URL is set to a non-SQLite engine ("${parsed.type}"). ` +
|
|
233
233
|
`Call await initDatabase() at startup before using ORM models.`,
|
|
234
234
|
);
|
|
235
235
|
}
|
|
236
236
|
throw new Error(
|
|
237
|
-
"No database adapter configured. Call initDatabase() or set
|
|
237
|
+
"No database adapter configured. Call initDatabase() or set TINA4_DATABASE_URL in .env.",
|
|
238
238
|
);
|
|
239
239
|
}
|
|
240
240
|
}
|
|
@@ -60,7 +60,7 @@ export interface DatabaseConfig {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
|
-
* Parsed result from a
|
|
63
|
+
* Parsed result from a TINA4_DATABASE_URL connection string.
|
|
64
64
|
*/
|
|
65
65
|
export interface ParsedDatabaseUrl {
|
|
66
66
|
type: "sqlite" | "postgres" | "mysql" | "mssql" | "firebird" | "mongodb" | "odbc";
|
|
@@ -75,7 +75,7 @@ export interface ParsedDatabaseUrl {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
/**
|
|
78
|
-
* Parse a
|
|
78
|
+
* Parse a TINA4_DATABASE_URL connection string into its components.
|
|
79
79
|
*
|
|
80
80
|
* Supported formats:
|
|
81
81
|
* sqlite:///path/to/db.sqlite
|
|
@@ -325,10 +325,10 @@ export class Database {
|
|
|
325
325
|
|
|
326
326
|
/**
|
|
327
327
|
* Create a Database from an environment variable.
|
|
328
|
-
* @param envKey - Name of the env var holding the connection URL. Defaults to "
|
|
328
|
+
* @param envKey - Name of the env var holding the connection URL. Defaults to "TINA4_DATABASE_URL".
|
|
329
329
|
* @param pool - Number of pooled connections (0 = single, N>0 = round-robin)
|
|
330
330
|
*/
|
|
331
|
-
static async fromEnv(envKey = "
|
|
331
|
+
static async fromEnv(envKey = "TINA4_DATABASE_URL", pool: number = 0): Promise<Database> {
|
|
332
332
|
const url = process.env[envKey];
|
|
333
333
|
if (!url) {
|
|
334
334
|
throw new Error(`Environment variable "${envKey}" is not set.`);
|
|
@@ -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;
|