tina4-nodejs 3.12.2 → 3.12.4
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 +408 -2
- package/package.json +1 -1
- package/packages/core/src/dotenv.ts +9 -2
- package/packages/core/src/graphql.ts +23 -0
- package/packages/core/src/health.ts +71 -13
- package/packages/core/src/index.ts +6 -6
- package/packages/core/src/logger.ts +179 -59
- package/packages/core/src/mcp.ts +38 -0
- package/packages/core/src/messenger.ts +25 -1
- package/packages/core/src/router.ts +39 -1
- package/packages/core/src/server.ts +42 -12
- package/packages/core/src/session.ts +43 -3
- package/packages/frond/src/engine.ts +37 -9
- package/packages/orm/src/database.ts +21 -0
- package/packages/orm/src/index.ts +1 -1
- package/packages/swagger/src/generator.ts +31 -6
- package/packages/swagger/src/index.ts +1 -1
- package/packages/swagger/src/ui.ts +17 -0
package/CLAUDE.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
# CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.12.
|
|
1
|
+
# CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.12.4)
|
|
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.
|
|
7
|
+
Tina4 for Node.js/TypeScript v3.12.4 — 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
|
|
|
@@ -510,6 +510,383 @@ cache.clear(); // remove everything
|
|
|
510
510
|
const rows = cache.remember(key, 60, () => db.execute(sql, params));
|
|
511
511
|
```
|
|
512
512
|
|
|
513
|
+
## Module: Router (`packages/core/src/router.ts`)
|
|
514
|
+
|
|
515
|
+
Programmatic route registration. The convention is file-based discovery in `src/routes/`, but a `Router` class and module-level `get`/`post`/etc. helpers are also exported for libraries, plugins, and tests.
|
|
516
|
+
|
|
517
|
+
```typescript
|
|
518
|
+
import { Router, defaultRouter, get, post, put, patch, del, any } from "@tina4/core";
|
|
519
|
+
import type { Tina4Request, Tina4Response } from "@tina4/core";
|
|
520
|
+
|
|
521
|
+
// Module-level helpers register on the default global router
|
|
522
|
+
get("/api/users", async (req, res) => res.json([]));
|
|
523
|
+
post("/api/users", async (req, res) => res.json({ ok: true }));
|
|
524
|
+
put("/api/users/{id}", handler);
|
|
525
|
+
patch("/api/users/{id}", handler);
|
|
526
|
+
del("/api/users/{id}", handler); // "del" — "delete" is a reserved word
|
|
527
|
+
any("/api/webhook", handler); // matches all HTTP methods
|
|
528
|
+
|
|
529
|
+
// Wildcard routes: catch-all segment
|
|
530
|
+
get("/api/files/{...path}", async (req, res) => {
|
|
531
|
+
const path = req.params["path"]; // "a/b/c.txt"
|
|
532
|
+
return res.send(path);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
// Fluent route refs — chain auth, cache, middleware
|
|
536
|
+
get("/api/data", handler).secure().cache(60);
|
|
537
|
+
|
|
538
|
+
// Dedicated Router instance (e.g. for sub-apps or testing)
|
|
539
|
+
const r = new Router();
|
|
540
|
+
r.get("/ping", async (_req, res) => res.json({ pong: true }));
|
|
541
|
+
r.group("/api/v1", (g) => {
|
|
542
|
+
g.get("/users", listUsers);
|
|
543
|
+
g.post("/users", createUser);
|
|
544
|
+
});
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
**Path patterns:** `{id}` for dynamic params, `{...slug}` for catch-all. Read params via `req.params["id"]`.
|
|
548
|
+
|
|
549
|
+
## Module: Database (`packages/orm/src/database.ts`)
|
|
550
|
+
|
|
551
|
+
Full Database API. The same instance covers all five drivers (sqlite, postgres, mysql, mssql, firebird) — pick the driver via `DATABASE_URL` or pass a `DatabaseConfig` to `initDatabase()`.
|
|
552
|
+
|
|
553
|
+
```typescript
|
|
554
|
+
import { initDatabase, Database, DatabaseResult } from "@tina4/orm";
|
|
555
|
+
|
|
556
|
+
const db = await initDatabase({ url: "sqlite:///app.db" });
|
|
557
|
+
// Connection pooling: pass `pool: 4` for round-robin connections.
|
|
558
|
+
|
|
559
|
+
// Reads — synchronous (node:sqlite is sync; other adapters are wrapped)
|
|
560
|
+
db.fetch(sql, params?, limit?, offset?): DatabaseResult // .records, .count, .limit, .offset
|
|
561
|
+
db.fetchOne<T>(sql, params?): T | null
|
|
562
|
+
|
|
563
|
+
// Writes — return boolean for simple writes, result for RETURNING / CALL / EXEC / SELECT
|
|
564
|
+
db.execute(sql, params?): boolean | unknown
|
|
565
|
+
db.executeMany(sql, paramSets): unknown[] // wrapped in a transaction
|
|
566
|
+
db.insert(table, data): DatabaseWriteResult
|
|
567
|
+
db.update(table, data, filter?, params?): DatabaseWriteResult
|
|
568
|
+
db.delete(table, filter?, params?): DatabaseWriteResult
|
|
569
|
+
|
|
570
|
+
// Last-write metadata
|
|
571
|
+
db.getLastId(): string | number | null
|
|
572
|
+
db.getError(): string | null
|
|
573
|
+
|
|
574
|
+
// Transactions — autoCommit defaults to ON unless TINA4_DB_AUTOCOMMIT=false
|
|
575
|
+
db.startTransaction(): void
|
|
576
|
+
db.commit(): void
|
|
577
|
+
db.rollback(): void
|
|
578
|
+
|
|
579
|
+
// Schema introspection
|
|
580
|
+
db.tableExists(name): boolean
|
|
581
|
+
db.getTables(): string[]
|
|
582
|
+
db.getColumns(table): { name, type, nullable?, default?, primaryKey? }[]
|
|
583
|
+
|
|
584
|
+
// Race-safe sequence — uses tina4_sequences for SQLite/MySQL/MSSQL,
|
|
585
|
+
// auto-creates Postgres sequences, and uses native Firebird generators.
|
|
586
|
+
db.getNextId(table, pkColumn?, generatorName?): number
|
|
587
|
+
|
|
588
|
+
// Query cache (TINA4_DB_CACHE=true)
|
|
589
|
+
db.cacheStats(): { enabled, size, ttl }
|
|
590
|
+
db.cacheClear(): void
|
|
591
|
+
|
|
592
|
+
// Connection pool access (null when pooling disabled)
|
|
593
|
+
db.pool
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
**`tina4_sequences` table** — Auto-created by `getNextId()` on first use for SQLite, MySQL, and MSSQL. Stores the current sequence value per table. Do not modify this table manually.
|
|
597
|
+
|
|
598
|
+
## Module: ORM (`packages/orm/src/baseModel.ts`)
|
|
599
|
+
|
|
600
|
+
Active-Record base class. Models live in `src/models/` and are auto-discovered. Use `static fields` (not decorators) — same convention across all four frameworks.
|
|
601
|
+
|
|
602
|
+
```typescript
|
|
603
|
+
import { BaseModel, ormBind } from "@tina4/orm";
|
|
604
|
+
|
|
605
|
+
export default class User extends BaseModel {
|
|
606
|
+
static tableName = "users";
|
|
607
|
+
static fields = {
|
|
608
|
+
id: { type: "integer" as const, primaryKey: true, autoIncrement: true },
|
|
609
|
+
email: { type: "string" as const, required: true, maxLength: 255 },
|
|
610
|
+
author_id: { type: "foreignKey" as const, references: "Author" }, // auto-wires belongsTo + hasMany
|
|
611
|
+
};
|
|
612
|
+
static softDelete = true; // optional — toggles is_deleted column
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Instance methods (chainable where it makes sense)
|
|
616
|
+
const user = new User({ email: "alice@example.com" });
|
|
617
|
+
user.save(); // returns this on success, null on failure
|
|
618
|
+
user.delete(); // soft-delete if enabled, otherwise hard
|
|
619
|
+
user.forceDelete(); // bypasses soft-delete
|
|
620
|
+
user.restore(); // clears soft-delete marker
|
|
621
|
+
user.load(sql, params?, include?): boolean
|
|
622
|
+
user.validate(): string[]; // empty = valid
|
|
623
|
+
user.toDict(include?); user.toAssoc(include?); user.toObject();
|
|
624
|
+
user.toArray(): unknown[]; user.toList();
|
|
625
|
+
user.toJson(include?): string;
|
|
626
|
+
user.hasOne(RelatedClass, fk?);
|
|
627
|
+
user.hasMany(RelatedClass, fk?, limit?, offset?);
|
|
628
|
+
user.belongsTo(RelatedClass, fk?);
|
|
629
|
+
|
|
630
|
+
// Static methods — also callable as `new User().all()`
|
|
631
|
+
User.find(id, include?);
|
|
632
|
+
User.findById(id, include?);
|
|
633
|
+
User.findOrFail(id); // throws if missing
|
|
634
|
+
User.create(data); // construct + save
|
|
635
|
+
User.all(where?, params?, include?);
|
|
636
|
+
User.select(sql, params?);
|
|
637
|
+
User.selectOne(sql, params?, include?);
|
|
638
|
+
User.where(conditions, params?, limit?, offset?, include?);
|
|
639
|
+
User.count(conditions?, params?);
|
|
640
|
+
User.withTrashed(conditions?, params?, limit?, offset?);
|
|
641
|
+
User.scope(name, filterSql, params?); // registers a reusable named method
|
|
642
|
+
User.createTable();
|
|
643
|
+
User.query(): QueryBuilder;
|
|
644
|
+
BaseModel.registerModel(name, class); // for foreignKey name resolution
|
|
645
|
+
|
|
646
|
+
ormBind(db); // bind a Database instance for all models in the registry
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
**Soft delete:** set `static softDelete = true`. Adds an `is_deleted` INTEGER column (0/1). `delete()` flips the flag, `forceDelete()` removes the row, `restore()` clears it.
|
|
650
|
+
|
|
651
|
+
## Module: QueryBuilder (`packages/orm/src/queryBuilder.ts`)
|
|
652
|
+
|
|
653
|
+
Fluent builder for JOINs, aggregates, and GROUP BY. Prefer over raw `db.fetch()` for any query more involved than a single table read.
|
|
654
|
+
|
|
655
|
+
```typescript
|
|
656
|
+
import { QueryBuilder } from "@tina4/orm";
|
|
657
|
+
|
|
658
|
+
// Standalone
|
|
659
|
+
const orders = QueryBuilder.fromTable("orders o")
|
|
660
|
+
.select("o.*", "c.name as customer_name")
|
|
661
|
+
.join("customers c", "o.customer_id = c.id")
|
|
662
|
+
.where("o.status = ?", ["pending"])
|
|
663
|
+
.orderBy("o.created_at DESC")
|
|
664
|
+
.limit(20)
|
|
665
|
+
.get(); // → row[]
|
|
666
|
+
|
|
667
|
+
// LEFT JOIN
|
|
668
|
+
QueryBuilder.fromTable("products p")
|
|
669
|
+
.leftJoin("categories c", "p.category_id = c.id")
|
|
670
|
+
.get();
|
|
671
|
+
|
|
672
|
+
// Aggregates with HAVING
|
|
673
|
+
const top = QueryBuilder.fromTable("orders")
|
|
674
|
+
.select("customer_id", "SUM(total) as total")
|
|
675
|
+
.groupBy("customer_id")
|
|
676
|
+
.having("SUM(total) > ?", [1000])
|
|
677
|
+
.first(); // → single row | null
|
|
678
|
+
|
|
679
|
+
// From an ORM model
|
|
680
|
+
const adults = User.query().where("age > ?", [18]).orderBy("name").get();
|
|
681
|
+
|
|
682
|
+
// Methods: fromTable, select, where, orWhere, join, leftJoin, groupBy, having,
|
|
683
|
+
// orderBy, limit, get, first, count, exists, toSql, toMongo
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
**NoSQL bridge:** `toMongo()` returns `{ filter, projection, sort, limit, skip }` — the same fluent state expressed as a MongoDB query document.
|
|
687
|
+
|
|
688
|
+
## Module: Migration (`packages/orm/src/migration.ts`)
|
|
689
|
+
|
|
690
|
+
SQL-file based migrations under `migrations/`. The framework runs pending migrations on startup; the helpers here are for programmatic control (CLI, scripts, tests).
|
|
691
|
+
|
|
692
|
+
```typescript
|
|
693
|
+
import {
|
|
694
|
+
migrate, rollback, status, createMigration, syncModels,
|
|
695
|
+
ensureMigrationTable, isMigrationApplied, recordMigration,
|
|
696
|
+
} from "@tina4/orm";
|
|
697
|
+
|
|
698
|
+
await migrate(db); // run all pending migrations
|
|
699
|
+
await rollback(db, 1); // roll back last N batches (default 1)
|
|
700
|
+
await status(db); // pending vs applied
|
|
701
|
+
await createMigration("add users table"); // scaffolds migrations/<ts>_add_users_table.sql
|
|
702
|
+
syncModels(discoveredModels); // auto-create tables / add columns from `static fields`
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
Migration tracking lives in `tina4_migration` (id, name, batch, applied_at). Schema sync runs alongside SQL migrations on boot.
|
|
706
|
+
|
|
707
|
+
## Module: Frond (`packages/frond/src/engine.ts`)
|
|
708
|
+
|
|
709
|
+
Zero-dependency Twig-compatible template engine. Replaces the older `Template`. Supports variables, filters, `if`/`for`/`set`, `extends`/`block`, `include`, `macro`, comments, whitespace control, tests, fragment caching, and sandbox mode.
|
|
710
|
+
|
|
711
|
+
```typescript
|
|
712
|
+
import { Frond } from "@tina4/frond";
|
|
713
|
+
|
|
714
|
+
const frond = new Frond("src/templates");
|
|
715
|
+
|
|
716
|
+
frond.render("page.twig", { user, posts }); // file template
|
|
717
|
+
frond.renderString("Hello {{ name }}", { name: "Al" });
|
|
718
|
+
|
|
719
|
+
// Customise
|
|
720
|
+
frond.addFilter("upper", (v) => String(v).toUpperCase());
|
|
721
|
+
frond.addGlobal("siteName", "Tina4");
|
|
722
|
+
frond.addTest("even", (v) => Number(v) % 2 === 0);
|
|
723
|
+
|
|
724
|
+
// Sandbox — restrict capabilities for user-supplied templates
|
|
725
|
+
frond.sandbox(["upper"], ["if"], ["x"]); // allowed: filters, tags, vars
|
|
726
|
+
frond.unsandbox();
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
- **SafeString** — filters can return `new SafeString(value)` to bypass auto-escaping.
|
|
730
|
+
- **Fragment caching** — `{% cache "key" 300 %}...{% endcache %}` caches block output for TTL seconds.
|
|
731
|
+
- **Raw blocks** — `{% raw %}...{% endraw %}` outputs literal template syntax.
|
|
732
|
+
- **Pre-compiled regexes** + token caching (cleared on file mtime change in dev mode) for ~2.8x render improvement over the naive path.
|
|
733
|
+
|
|
734
|
+
## Module: Api (`packages/core/src/api.ts`)
|
|
735
|
+
|
|
736
|
+
Zero-dep HTTP client over `node:http` / `node:https`. Used by integrations, queue producers, health checks, and tests.
|
|
737
|
+
|
|
738
|
+
```typescript
|
|
739
|
+
import { Api } from "@tina4/core";
|
|
740
|
+
|
|
741
|
+
const api = new Api("https://api.example.com", "" /* authHeader */, 30 /* timeoutSeconds */);
|
|
742
|
+
|
|
743
|
+
api.addHeaders({ "X-Trace-Id": "abc" });
|
|
744
|
+
api.setBearerToken(token);
|
|
745
|
+
api.setBasicAuth(user, pass);
|
|
746
|
+
api.setIgnoreSsl(true); // dev / self-signed certs only
|
|
747
|
+
|
|
748
|
+
const r = await api.get("/users", { active: "1" });
|
|
749
|
+
await api.post("/users", { name: "Alice" });
|
|
750
|
+
await api.put("/users/1", { name: "Alice" });
|
|
751
|
+
await api.patch("/users/1",{ active: false });
|
|
752
|
+
await api.delete("/users/1");
|
|
753
|
+
await api.sendRequest("OPTIONS", "/users");
|
|
754
|
+
|
|
755
|
+
// Result shape (all methods return the same):
|
|
756
|
+
// { http_code: 200, body: <parsed JSON or string>, headers: {...}, error: null }
|
|
757
|
+
```
|
|
758
|
+
|
|
759
|
+
`error` is non-null on transport failure or timeout; `http_code` is `null` if the request never reached the server.
|
|
760
|
+
|
|
761
|
+
## Module: Queue (`packages/core/src/queue.ts`)
|
|
762
|
+
|
|
763
|
+
Pluggable job queue (file/RabbitMQ/Kafka/MongoDB backends). The same fluent API works against any backend — pick via env vars.
|
|
764
|
+
|
|
765
|
+
```typescript
|
|
766
|
+
import { Queue } from "@tina4/core";
|
|
767
|
+
|
|
768
|
+
const queue = new Queue("emails", 3 /* maxRetries */);
|
|
769
|
+
|
|
770
|
+
const id = queue.push({ to: "a@b.c", body: "hi" }, 0 /* delaySec */, 0 /* priority */);
|
|
771
|
+
const job = queue.pop();
|
|
772
|
+
queue.size("pending");
|
|
773
|
+
queue.purge("completed");
|
|
774
|
+
queue.retryFailed();
|
|
775
|
+
queue.deadLetters();
|
|
776
|
+
queue.produce("notifications", payload, 0, 0);
|
|
777
|
+
|
|
778
|
+
// Job methods
|
|
779
|
+
job?.complete();
|
|
780
|
+
job?.fail("smtp timeout");
|
|
781
|
+
job?.reject("permanent");
|
|
782
|
+
job?.retry(60);
|
|
783
|
+
|
|
784
|
+
// Long-running consumer — async generator
|
|
785
|
+
for await (const job of queue.consume("emails")) {
|
|
786
|
+
try {
|
|
787
|
+
await sendEmail(job.payload);
|
|
788
|
+
job.complete();
|
|
789
|
+
} catch (err) {
|
|
790
|
+
job.fail(String(err));
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
// pollInterval=0 for single-pass drain (tests).
|
|
794
|
+
```
|
|
795
|
+
|
|
796
|
+
## Module: Background Tasks (`packages/core/src/background.ts`)
|
|
797
|
+
|
|
798
|
+
Periodic callbacks that run alongside the HTTP server. Use this instead of bare `setInterval` so timers integrate with the server lifecycle and clear on graceful shutdown.
|
|
799
|
+
|
|
800
|
+
```typescript
|
|
801
|
+
import { background, stopAllBackgroundTasks, backgroundTaskCount } from "@tina4/core";
|
|
802
|
+
|
|
803
|
+
// Run every 2 seconds
|
|
804
|
+
const task = background(() => processQueue(), 2);
|
|
805
|
+
|
|
806
|
+
// Async callbacks are fine — rejections are caught and logged.
|
|
807
|
+
background(async () => {
|
|
808
|
+
const r = await api.get("/health");
|
|
809
|
+
if (r.error) Log.warn("health check failed");
|
|
810
|
+
}, 30);
|
|
811
|
+
|
|
812
|
+
task.stop(); // stop just this one
|
|
813
|
+
stopAllBackgroundTasks(); // stop everything (also runs on SIGTERM/SIGINT)
|
|
814
|
+
backgroundTaskCount(); // test helper
|
|
815
|
+
```
|
|
816
|
+
|
|
817
|
+
**Never use bare `setInterval` for periodic work in a Tina4 app.** `background()` catches errors, integrates with shutdown signals, calls `timer.unref()` so it doesn't block process exit, and matches Python's `background()` API exactly.
|
|
818
|
+
|
|
819
|
+
## Module: DI Container (`packages/core/src/container.ts`)
|
|
820
|
+
|
|
821
|
+
Lightweight dependency injection. Transient factories build a fresh instance every `get()`; singletons memoise the first build. Node.js is single-threaded, so no locking is needed.
|
|
822
|
+
|
|
823
|
+
```typescript
|
|
824
|
+
import { Container, container } from "@tina4/core";
|
|
825
|
+
|
|
826
|
+
// Use the default global container, or construct your own
|
|
827
|
+
container.register("mailer", () => new MailService()); // transient
|
|
828
|
+
container.singleton("db", () => initDatabase({ url })); // singleton
|
|
829
|
+
|
|
830
|
+
const mailer = container.get<MailService>("mailer"); // new each call
|
|
831
|
+
const db = container.get<Database>("db"); // same each call
|
|
832
|
+
|
|
833
|
+
container.has("db"); // true
|
|
834
|
+
container.has("missing"); // false
|
|
835
|
+
container.reset(); // clear all registrations + cached instances
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
## Module: Response Cache (`packages/core/src/cache.ts`)
|
|
839
|
+
|
|
840
|
+
Multi-backend cache. Used as middleware to cache GET responses, or directly via `cacheGet`/`cacheSet` for arbitrary key/value caching. Backends: memory (default), redis/valkey, file.
|
|
841
|
+
|
|
842
|
+
```typescript
|
|
843
|
+
import {
|
|
844
|
+
responseCache, cacheGet, cacheSet, cacheDelete, cacheClear, cacheStats,
|
|
845
|
+
} from "@tina4/core";
|
|
846
|
+
|
|
847
|
+
// Middleware on a route
|
|
848
|
+
get("/api/products", listProducts).middleware(responseCache({ ttl: 60 }));
|
|
849
|
+
|
|
850
|
+
// Direct key/value usage (same shape across all four frameworks)
|
|
851
|
+
cacheSet("user:1", { name: "Alice" }, 120);
|
|
852
|
+
const u = cacheGet("user:1");
|
|
853
|
+
cacheDelete("user:1");
|
|
854
|
+
cacheClear();
|
|
855
|
+
|
|
856
|
+
cacheStats(); // { hits, misses, size, backend }
|
|
857
|
+
```
|
|
858
|
+
|
|
859
|
+
Environment:
|
|
860
|
+
- `TINA4_CACHE_BACKEND` — `memory` | `redis` | `file` (default: `memory`)
|
|
861
|
+
- `TINA4_CACHE_URL` — `redis://localhost:6379` (redis backend only)
|
|
862
|
+
- `TINA4_CACHE_TTL` — default TTL seconds (default: `0` = disabled)
|
|
863
|
+
- `TINA4_CACHE_MAX_ENTRIES` — max entries (default: `1000`)
|
|
864
|
+
|
|
865
|
+
## Firebird-Specific Rules
|
|
866
|
+
|
|
867
|
+
When using Firebird as the database engine:
|
|
868
|
+
|
|
869
|
+
- **No `IF NOT EXISTS`** for `ALTER TABLE ADD` — the migration runner detects already-present columns via `RDB$RELATION_FIELDS` and skips silently.
|
|
870
|
+
- **No `AUTOINCREMENT`** — use generators. `db.getNextId(table, pkColumn?, generatorName?)` creates and uses generators (default name: `GEN_<TABLE>_ID`).
|
|
871
|
+
- **Pagination** — `SQLTranslator.limitToRows()` rewrites `LIMIT n OFFSET m` to Firebird's `ROWS m+1 TO m+n` syntax automatically.
|
|
872
|
+
- **No `TEXT` type** — use `VARCHAR(n)` or `BLOB SUB_TYPE TEXT`. The migration tracker schema (`tina4_migration`) uses `VARCHAR(500)` for the name column on Firebird.
|
|
873
|
+
- **No `REAL`/`FLOAT`** — use `DOUBLE PRECISION`.
|
|
874
|
+
- **BLOB handling** — `db.fetch()` and `db.fetchOne()` auto-convert memoryview/Buffer BLOB columns to `Buffer` (raw bytes, not base64).
|
|
875
|
+
- **No triggers, no foreign keys** in migrations on Firebird-targeted projects — relationships are wired in the ORM layer instead.
|
|
876
|
+
|
|
877
|
+
## How DevReload works
|
|
878
|
+
|
|
879
|
+
The `tina4` Rust CLI is the sole file watcher for the Tina4 stack — there is no framework-side watcher. The flow:
|
|
880
|
+
|
|
881
|
+
1. Rust CLI (`npx tina4nodejs serve`) watches `src/`, `migrations/`, `.env`. Noise is filtered (Access/Metadata events, `node_modules`, `.git`, `dist`, `logs`, `.log`/`.db*`/`.swp` files) and a real mtime check defeats overlayfs spurious events.
|
|
882
|
+
2. On a real change, the CLI POSTs `/__dev/api/reload` to the running server.
|
|
883
|
+
3. The framework bumps its in-memory reload counter and (a) broadcasts `{type: 'reload'}` over WebSocket at `/__dev_reload`, and (b) exposes the counter at `GET /__dev/api/mtime` for the polling fallback.
|
|
884
|
+
4. The browser's dev toolbar JS listens on the WS (primary) and polls `/__dev/api/mtime` every 3s (fallback). On a change it reloads the page, or swaps the stylesheet if the change was CSS.
|
|
885
|
+
|
|
886
|
+
No configuration needed — set `TINA4_DEBUG=true` to enable. If you're running without the Rust CLI (e.g. Docker), there is no automatic reload; the production path is unaffected.
|
|
887
|
+
|
|
888
|
+
**AI dual-port mode:** when `TINA4_DEBUG=true` and `TINA4_NO_AI_PORT` is unset, the main port suppresses reload/toolbar injection (so AI tools never trigger a refresh) and a second server on `port+1000` provides the normal hot-reload experience for browser testing.
|
|
889
|
+
|
|
513
890
|
## Conventions You Must Follow
|
|
514
891
|
|
|
515
892
|
### Route Files
|
|
@@ -717,3 +1094,32 @@ Always read and follow the instructions in .claude/skills/tina4-developer/SKILL.
|
|
|
717
1094
|
|
|
718
1095
|
## Tina4-js Frontend Skill
|
|
719
1096
|
Always read and follow the instructions in .claude/skills/tina4-js/SKILL.md when working with tina4-js frontend code. Read its referenced files in .claude/skills/tina4-js/references/ as needed.
|
|
1097
|
+
|
|
1098
|
+
## First Principle: Documentation Matches Code Reality
|
|
1099
|
+
|
|
1100
|
+
**This rule overrides everything else in this file.**
|
|
1101
|
+
|
|
1102
|
+
Every command, env var, method, class, or feature mentioned in any
|
|
1103
|
+
documentation file (`*.md` in this repo, or any tina4-book chapter,
|
|
1104
|
+
or `tina4-documentation/docs/`) MUST exist in code. No exceptions.
|
|
1105
|
+
No "we'll build it later" entries. No Laravel/Rails-style commands
|
|
1106
|
+
that look right but don't exist. No env vars that the framework
|
|
1107
|
+
doesn't actually read.
|
|
1108
|
+
|
|
1109
|
+
When you add a doc reference, add the implementation in the same PR.
|
|
1110
|
+
When you remove a feature, remove every doc reference in the same PR.
|
|
1111
|
+
When you find drift, fix it both ways: build the real thing OR delete
|
|
1112
|
+
the doc.
|
|
1113
|
+
|
|
1114
|
+
The `tina4-documentation/scripts/audit-truth.py` script is the source
|
|
1115
|
+
of truth. It runs as a CI gate (`audit-truth.yml`) on every PR — the
|
|
1116
|
+
build fails on CLI drift. Run it locally before pushing if you've
|
|
1117
|
+
touched docs:
|
|
1118
|
+
|
|
1119
|
+
```bash
|
|
1120
|
+
cd /path/to/tina4-documentation
|
|
1121
|
+
python3 scripts/audit-truth.py --strict
|
|
1122
|
+
```
|
|
1123
|
+
|
|
1124
|
+
If you're unsure whether something exists, run `tina4 <command> --help`
|
|
1125
|
+
or grep the framework source. Don't guess.
|
package/package.json
CHANGED
|
@@ -76,11 +76,18 @@ function parseEnvContent(content: string): Record<string, string> {
|
|
|
76
76
|
* Load environment variables from a .env file into process.env.
|
|
77
77
|
* Does not override existing process.env values unless they are undefined.
|
|
78
78
|
*
|
|
79
|
-
*
|
|
79
|
+
* Resolution order for the env file path:
|
|
80
|
+
* 1. Explicit `path` argument
|
|
81
|
+
* 2. `TINA4_ENV_FILE` env var (if set and non-empty)
|
|
82
|
+
* 3. `.env` in the current working directory
|
|
83
|
+
*
|
|
84
|
+
* @param path - Path to the .env file. Optional override.
|
|
80
85
|
* @returns The parsed key-value pairs, or an empty object if the file doesn't exist.
|
|
81
86
|
*/
|
|
82
87
|
export function loadEnv(path?: string): Record<string, string> {
|
|
83
|
-
const
|
|
88
|
+
const fromEnv = (process.env.TINA4_ENV_FILE ?? "").trim();
|
|
89
|
+
const target = path ?? (fromEnv.length > 0 ? fromEnv : ".env");
|
|
90
|
+
const envPath = resolve(target);
|
|
84
91
|
|
|
85
92
|
if (!existsSync(envPath)) {
|
|
86
93
|
return {};
|
|
@@ -374,6 +374,29 @@ interface QueryConfig {
|
|
|
374
374
|
resolver: ResolverFn;
|
|
375
375
|
}
|
|
376
376
|
|
|
377
|
+
// ── Env-driven config ────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* URL the GraphQL handler should be mounted at.
|
|
381
|
+
* `TINA4_GRAPHQL_ENDPOINT` overrides the default `/graphql`.
|
|
382
|
+
*/
|
|
383
|
+
export function graphqlEndpoint(): string {
|
|
384
|
+
const raw = (process.env.TINA4_GRAPHQL_ENDPOINT ?? "").trim();
|
|
385
|
+
if (raw.length === 0) return "/graphql";
|
|
386
|
+
return raw.startsWith("/") ? raw : `/${raw}`;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Whether to auto-generate the schema from registered ORM models.
|
|
391
|
+
* `TINA4_GRAPHQL_AUTO_SCHEMA=true` (default) lets the dev server build a
|
|
392
|
+
* usable schema with no manual wiring; set to `false` to require explicit
|
|
393
|
+
* `addType` / `addQuery` calls.
|
|
394
|
+
*/
|
|
395
|
+
export function graphqlAutoSchemaEnabled(): boolean {
|
|
396
|
+
const raw = (process.env.TINA4_GRAPHQL_AUTO_SCHEMA ?? "true").trim().toLowerCase();
|
|
397
|
+
return ["true", "1", "yes", "on"].includes(raw);
|
|
398
|
+
}
|
|
399
|
+
|
|
377
400
|
// ── GraphQL Engine ───────────────────────────────────────────
|
|
378
401
|
|
|
379
402
|
export class GraphQL {
|
|
@@ -1,27 +1,46 @@
|
|
|
1
|
-
import type { RouteDefinition } from "./types.js";
|
|
1
|
+
import type { RouteDefinition, RouteHandler } from "./types.js";
|
|
2
2
|
|
|
3
3
|
/** Server start time, set when health route is created */
|
|
4
4
|
let startTime: number;
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* Resolve the health route path. Priority:
|
|
8
|
+
* 1. `TINA4_HEALTH_PATH` env var
|
|
9
|
+
* 2. Default `/__health` (matches Python parity — under-prefix avoids
|
|
10
|
+
* colliding with app routes named /health)
|
|
11
|
+
*/
|
|
12
|
+
export function healthPath(): string {
|
|
13
|
+
const raw = (process.env.TINA4_HEALTH_PATH ?? "").trim();
|
|
14
|
+
if (raw.length === 0) return "/__health";
|
|
15
|
+
return raw.startsWith("/") ? raw : `/${raw}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function buildHandler(version: string): RouteHandler {
|
|
19
|
+
return (_req, res) => {
|
|
20
|
+
const uptimeSeconds = (Date.now() - startTime) / 1000;
|
|
21
|
+
res.json({
|
|
22
|
+
status: "ok",
|
|
23
|
+
version,
|
|
24
|
+
uptime: Math.round(uptimeSeconds * 100) / 100,
|
|
25
|
+
framework: "tina4-nodejs",
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Create the primary health route definition.
|
|
32
|
+
*
|
|
33
|
+
* Tests use this directly. Server bootstrap goes through createHealthRoutes()
|
|
34
|
+
* to also register the legacy `/health` alias when TINA4_HEALTH_PATH points
|
|
35
|
+
* elsewhere — matching Python behaviour so existing probes don't break.
|
|
9
36
|
*/
|
|
10
37
|
export function createHealthRoute(version: string = "3.0.0"): RouteDefinition {
|
|
11
38
|
startTime = Date.now();
|
|
12
39
|
|
|
13
40
|
return {
|
|
14
41
|
method: "GET",
|
|
15
|
-
pattern:
|
|
16
|
-
handler: (
|
|
17
|
-
const uptimeSeconds = (Date.now() - startTime) / 1000;
|
|
18
|
-
res.json({
|
|
19
|
-
status: "ok",
|
|
20
|
-
version,
|
|
21
|
-
uptime: Math.round(uptimeSeconds * 100) / 100,
|
|
22
|
-
framework: "tina4-nodejs",
|
|
23
|
-
});
|
|
24
|
-
},
|
|
42
|
+
pattern: healthPath(),
|
|
43
|
+
handler: buildHandler(version),
|
|
25
44
|
meta: {
|
|
26
45
|
summary: "Health check",
|
|
27
46
|
description: "Returns server health status, version, and uptime.",
|
|
@@ -29,3 +48,42 @@ export function createHealthRoute(version: string = "3.0.0"): RouteDefinition {
|
|
|
29
48
|
},
|
|
30
49
|
};
|
|
31
50
|
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Create one or two health routes — the env-defined path always, plus a
|
|
54
|
+
* legacy `/health` alias when the env path differs. Mirrors
|
|
55
|
+
* tina4-python's two-line registration in `core/server.py`.
|
|
56
|
+
*/
|
|
57
|
+
export function createHealthRoutes(version: string = "3.0.0"): RouteDefinition[] {
|
|
58
|
+
startTime = Date.now();
|
|
59
|
+
const routes: RouteDefinition[] = [];
|
|
60
|
+
const path = healthPath();
|
|
61
|
+
|
|
62
|
+
routes.push({
|
|
63
|
+
method: "GET",
|
|
64
|
+
pattern: path,
|
|
65
|
+
handler: buildHandler(version),
|
|
66
|
+
meta: {
|
|
67
|
+
summary: "Health check",
|
|
68
|
+
description: "Returns server health status, version, and uptime.",
|
|
69
|
+
tags: ["System"],
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Always register /health for backwards compatibility with existing probes
|
|
74
|
+
// (Kubernetes liveness/readiness, load balancers, monitoring scripts).
|
|
75
|
+
if (path !== "/health") {
|
|
76
|
+
routes.push({
|
|
77
|
+
method: "GET",
|
|
78
|
+
pattern: "/health",
|
|
79
|
+
handler: buildHandler(version),
|
|
80
|
+
meta: {
|
|
81
|
+
summary: "Health check (legacy alias)",
|
|
82
|
+
description: "Backwards-compatible alias for the configured health path.",
|
|
83
|
+
tags: ["System"],
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return routes;
|
|
89
|
+
}
|
|
@@ -12,9 +12,9 @@ export type {
|
|
|
12
12
|
WebSocketRouteDefinition,
|
|
13
13
|
} from "./types.js";
|
|
14
14
|
|
|
15
|
-
export { startServer, resolvePortAndHost, handle, start, stop, httpReason, resolveTemplate, resetTemplateCache, templateAutoRoutingEnabled } from "./server.js";
|
|
15
|
+
export { startServer, resolvePortAndHost, handle, start, stop, httpReason, resolveTemplate, resetTemplateCache, templateAutoRoutingEnabled, isBannerSuppressed } from "./server.js";
|
|
16
16
|
export { background, stopAllBackgroundTasks, backgroundTaskCount } from "./background.js";
|
|
17
|
-
export { Router, RouteGroup, RouteRef, defaultRouter, runRouteMiddlewares } from "./router.js";
|
|
17
|
+
export { Router, RouteGroup, RouteRef, defaultRouter, runRouteMiddlewares, isTrailingSlashRedirectEnabled } from "./router.js";
|
|
18
18
|
export { get, post, put, patch, del, any, websocket, del as delete } from "./router.js";
|
|
19
19
|
export type { RouteInfo } from "./router.js";
|
|
20
20
|
export { discoverRoutes } from "./routeDiscovery.js";
|
|
@@ -25,7 +25,7 @@ export { createResponse, errorResponse, setDefaultTemplatesDir, getFrond, setFro
|
|
|
25
25
|
export { tryServeStatic } from "./static.js";
|
|
26
26
|
export { loadEnv, getEnv, requireEnv, hasEnv, allEnv, resetEnv, isTruthy } from "./dotenv.js";
|
|
27
27
|
export { Log } from "./logger.js";
|
|
28
|
-
export { createHealthRoute } from "./health.js";
|
|
28
|
+
export { createHealthRoute, createHealthRoutes, healthPath } from "./health.js";
|
|
29
29
|
export { rateLimiter } from "./rateLimiter.js";
|
|
30
30
|
export type { RateLimiterConfig } from "./rateLimiter.js";
|
|
31
31
|
export {
|
|
@@ -45,7 +45,7 @@ export {
|
|
|
45
45
|
refreshToken, authenticateRequest, validateApiKey,
|
|
46
46
|
Auth,
|
|
47
47
|
} from "./auth.js";
|
|
48
|
-
export { Session, FileSessionHandler, RedisSessionHandler } from "./session.js";
|
|
48
|
+
export { Session, FileSessionHandler, RedisSessionHandler, buildSessionCookie } from "./session.js";
|
|
49
49
|
export type { SessionConfig, SessionHandler } from "./session.js";
|
|
50
50
|
export { I18n } from "./i18n.js";
|
|
51
51
|
export { FakeData } from "./fakeData.js";
|
|
@@ -55,7 +55,7 @@ export { Queue } from "./queue.js";
|
|
|
55
55
|
export type { QueueConfig, QueueJob, ProcessOptions } from "./queue.js";
|
|
56
56
|
export { createJob } from "./job.js";
|
|
57
57
|
export type { JobData, JobQueueBridge } from "./job.js";
|
|
58
|
-
export { GraphQL, ParseError } from "./graphql.js";
|
|
58
|
+
export { GraphQL, ParseError, graphqlEndpoint, graphqlAutoSchemaEnabled } from "./graphql.js";
|
|
59
59
|
export type { GraphQLField, ResolverFn, GraphQLResult } from "./graphql.js";
|
|
60
60
|
export {
|
|
61
61
|
WebSocketServer,
|
|
@@ -108,7 +108,7 @@ export type { WebSocketBackplane } from "./websocketBackplane.js";
|
|
|
108
108
|
export {
|
|
109
109
|
McpServer, mcpTool, mcpResource, registerDevTools,
|
|
110
110
|
encodeResponse, encodeError, encodeNotification, decodeRequest,
|
|
111
|
-
schemaFromParams, isLocalhost,
|
|
111
|
+
schemaFromParams, isLocalhost, mcpEnabled, mcpPort,
|
|
112
112
|
PARSE_ERROR, INVALID_REQUEST, METHOD_NOT_FOUND, INVALID_PARAMS, INTERNAL_ERROR,
|
|
113
113
|
} from "./mcp.js";
|
|
114
114
|
export type { JsonRpcMessage, McpToolDefinition, McpResourceDefinition, JsonSchema, McpToolParam } from "./mcp.js";
|