tina4-nodejs 3.12.1 → 3.12.3

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.11.0)
1
+ # CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.12.3)
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.10.95 — 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.12.3 — 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
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
 
5
5
 
6
- "version": "3.12.1",
6
+ "version": "3.12.3",
7
7
 
8
8
  "type": "module",
9
9
  "description": "Tina4 for Node.js/TypeScript — 54 built-in features, zero dependencies",
@@ -23,6 +23,66 @@ function requireFirebird(): any {
23
23
  }
24
24
  }
25
25
 
26
+ // Detects a Windows drive-letter prefix like "C:/" or "C:\". The leading-slash
27
+ // variant ("/C:/...") shows up after URL parsing strips one slash off
28
+ // "firebird://host:port/C:/...".
29
+ const WIN_DRIVE_RE = /^\/?[A-Za-z]:[/\\]/;
30
+
31
+ /**
32
+ * Turn a URL path component into a Firebird database identifier.
33
+ *
34
+ * Firebird is the awkward one — it needs either an absolute file path on the
35
+ * server, a Windows drive-letter path, or an alias name. The classic URI form
36
+ * uses a double-slash to keep the leading "/" of an absolute path through
37
+ * URL parsing:
38
+ *
39
+ * firebird://host:port//firebird/data/app.fdb -> /firebird/data/app.fdb
40
+ *
41
+ * But that double slash is unintuitive to anyone used to the way
42
+ * postgres / mysql / mssql encode the database name. We accept five
43
+ * equivalent forms and normalise all of them:
44
+ *
45
+ * - `//abs/path/db.fdb` -> `/abs/path/db.fdb` (classic double-slash)
46
+ * - `/abs/path/db.fdb` -> `/abs/path/db.fdb` (single-slash, what most people type)
47
+ * - `/C:/Data/db.fdb` -> `C:/Data/db.fdb` (Windows, leading URL slash dropped)
48
+ * - `/C%3A/Data/db.fdb` -> `C:/Data/db.fdb` (Windows with URL-encoded colon)
49
+ * - `/employee` -> `employee` (alias — single token)
50
+ *
51
+ * Aliases are detected as the leftover case: a single token with no
52
+ * slashes. Anything path-like is kept as a path.
53
+ */
54
+ export function normalizeFirebirdDbIdentifier(rawPath: string): string {
55
+ let decoded = decodeURIComponent(rawPath);
56
+
57
+ // Classic double-slash form: //abs/path -> /abs/path
58
+ if (decoded.startsWith("//")) {
59
+ decoded = decoded.slice(1);
60
+ }
61
+
62
+ // Windows drive-letter — drop the URL-introduced leading slash.
63
+ // /C:/Data/db.fdb -> C:/Data/db.fdb
64
+ if (WIN_DRIVE_RE.test(decoded)) {
65
+ if (decoded.startsWith("/")) {
66
+ decoded = decoded.slice(1);
67
+ }
68
+ return decoded;
69
+ }
70
+
71
+ // Look at the content after stripping the leading slash. If it's a single
72
+ // token with no separators, it's a Firebird alias — return WITHOUT the
73
+ // leading slash (the alias name itself is the identifier).
74
+ const body = decoded.startsWith("/") ? decoded.slice(1) : decoded;
75
+ if (body && !body.includes("/") && !body.includes("\\")) {
76
+ return body;
77
+ }
78
+
79
+ // Otherwise it's a file path. If it already has a leading slash, keep it.
80
+ // If it's a relative-looking path (slash-separated but no leading "/")
81
+ // promote it to absolute — Firebird needs absolute paths and we don't know
82
+ // the server's CWD anyway.
83
+ return decoded.startsWith("/") ? decoded : "/" + decoded;
84
+ }
85
+
26
86
  export interface FirebirdConfig {
27
87
  host?: string;
28
88
  port?: number;
@@ -69,6 +129,21 @@ export class FirebirdAdapter implements DatabaseAdapter {
69
129
  };
70
130
  }
71
131
 
132
+ // Firebird database identifier resolution — two layers:
133
+ //
134
+ // 1. `TINA4_DATABASE_FIREBIRD_PATH` env override wins if set. Useful for
135
+ // Windows users with raw backslash paths (no URL encoding required)
136
+ // and for ops setups that keep server URL and DB location in separate
137
+ // config layers.
138
+ // 2. Otherwise normalise whatever the URL or config supplied — accepts
139
+ // every sensible variant (single/double slash, drive letter, alias).
140
+ const envOverride = process.env.TINA4_DATABASE_FIREBIRD_PATH;
141
+ if (envOverride && envOverride.length > 0) {
142
+ fbConfig.database = envOverride;
143
+ } else if (typeof fbConfig.database === "string" && fbConfig.database.length > 0) {
144
+ fbConfig.database = normalizeFirebirdDbIdentifier(fbConfig.database);
145
+ }
146
+
72
147
  await new Promise<void>((resolve, reject) => {
73
148
  fb.attach(fbConfig, (err: Error | null, db: any) => {
74
149
  if (err) reject(err);
@@ -82,6 +157,8 @@ export class FirebirdAdapter implements DatabaseAdapter {
82
157
 
83
158
  private parseUrl(url: string): { host?: string; port?: number; user?: string; password?: string; database?: string } {
84
159
  // firebird://user:pass@host:port/path/to/db.fdb
160
+ // The path part after the host is normalised by normalizeFirebirdDbIdentifier()
161
+ // in connect(); here we just preserve it (with the leading "/" the regex strips).
85
162
  const match = url.match(/firebird:\/\/(?:([^:]+):([^@]+)@)?([^:/]+)(?::(\d+))?\/(.*)/);
86
163
  if (match) {
87
164
  return {
@@ -54,7 +54,7 @@ export { MysqlAdapter } from "./adapters/mysql.js";
54
54
  export type { MysqlConfig } from "./adapters/mysql.js";
55
55
  export { MssqlAdapter } from "./adapters/mssql.js";
56
56
  export type { MssqlConfig } from "./adapters/mssql.js";
57
- export { FirebirdAdapter } from "./adapters/firebird.js";
57
+ export { FirebirdAdapter, normalizeFirebirdDbIdentifier } from "./adapters/firebird.js";
58
58
  export type { FirebirdConfig } from "./adapters/firebird.js";
59
59
  export { MongodbAdapter } from "./adapters/mongodb.js";
60
60
  export type { MongoConfig } from "./adapters/mongodb.js";