tina4-nodejs 3.13.69 → 3.13.71

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.69)
1
+ # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.71)
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.69 - 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.71 - 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
 
@@ -1238,7 +1238,7 @@ When adding new features, add a corresponding `test/<feature>.test.ts` file.
1238
1238
  ## v3 Features Summary
1239
1239
 
1240
1240
  - **45 built-in features**, zero third-party dependencies
1241
- - **5,332 tests** passing across all modules
1241
+ - **5,379 tests** passing across 154 files (build + typecheck green; 9 PostgreSQL/Valkey service-gated failures)
1242
1242
  - **Race-safe `getNextId()`** with atomic sequence table (`tina4_sequences`) for SQLite/MySQL/MSSQL; PostgreSQL auto-creates sequences
1243
1243
  - **Frond template engine optimizations**: pre-compiled regexes, lazy loop context (copy-on-write), filter chain caching, path split caching, inline common filters (11-15% speedup)
1244
1244
  - **Production server auto-detect**: `npx tina4nodejs serve --production` auto-uses cluster mode
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tina4-nodejs",
3
- "version": "3.13.69",
3
+ "version": "3.13.71",
4
4
  "type": "module",
5
5
  "description": "Tina4 for Node.js/TypeScript - 54 built-in features, zero dependencies",
6
6
  "keywords": [
@@ -60,6 +60,13 @@
60
60
  "engines": {
61
61
  "node": ">=22.0.0"
62
62
  },
63
+ "optionalDependencies": {
64
+ "@aws-sdk/client-s3": "^3.600.0",
65
+ "@aws-sdk/s3-request-presigner": "^3.600.0",
66
+ "mongodb": "^6.0.0",
67
+ "pg": "^8.20.0",
68
+ "redis": "^4.7.0"
69
+ },
63
70
  "devDependencies": {
64
71
  "@types/node": "^22.10.0",
65
72
  "esbuild": "^0.24.0",
@@ -4,7 +4,7 @@
4
4
  * Simple menu-driven installer for AI tool context files.
5
5
  * The user picks which tools they use, we install the appropriate files.
6
6
  *
7
- * import { showMenu, installSelected } from "@tina4/core";
7
+ * import { showMenu, installSelected } from "tina4-nodejs";
8
8
  */
9
9
  import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
10
10
  import { homedir } from "node:os";
@@ -693,7 +693,7 @@ Set \`TINA4_DATABASE_URL\` in \`.env\` (e.g. \`postgres://localhost:5432/mydb\`)
693
693
 
694
694
  ## Auth
695
695
 
696
- JWT auth built in. \`import { createToken, validateToken, hashPassword, checkPassword } from "@tina4/core"\`.
696
+ JWT auth built in. \`import { createToken, validateToken, hashPassword, checkPassword } from "tina4-nodejs"\`.
697
697
  GET routes are public. POST/PUT/PATCH/DELETE require auth by default.
698
698
  `;
699
699
  }
@@ -862,7 +862,7 @@ Set \`TINA4_DATABASE_URL\` in \`.env\` (e.g. \`sqlite:///path/to/db.sqlite\`, \`
862
862
 
863
863
  ## Auth
864
864
 
865
- JWT auth built in. \`import { createToken, validateToken, hashPassword, checkPassword } from "@tina4/core"\`.
865
+ JWT auth built in. \`import { createToken, validateToken, hashPassword, checkPassword } from "tina4-nodejs"\`.
866
866
  GET routes are public. POST/PUT/PATCH/DELETE require auth by default.
867
867
 
868
868
  ## Testing
@@ -873,7 +873,7 @@ npx tina4nodejs test # Run all tests
873
873
 
874
874
  Add test files in \`test/\` directory. Use built-in inline testing:
875
875
  \`\`\`typescript
876
- import { tests, assertEqual, runAll } from "@tina4/core";
876
+ import { tests, assertEqual, runAll } from "tina4-nodejs";
877
877
  \`\`\`
878
878
 
879
879
  ## Important
@@ -1017,7 +1017,7 @@ class DatabaseBackend implements CacheBackend {
1017
1017
  process.env.TINA4_AUTO_CACHING = "false";
1018
1018
  process.env.TINA4_DB_CACHE = "false";
1019
1019
  try {
1020
- const mod: any = await import("@tina4/orm").catch(() => null);
1020
+ const mod: any = await import("../../orm/src/index.js").catch(() => null);
1021
1021
  if (!mod || !mod.initDatabase) { this.available = false; return; }
1022
1022
  const db = await mod.initDatabase({ url: this.url });
1023
1023
  await db.execute(
@@ -518,7 +518,7 @@ export class DevAdmin {
518
518
 
519
519
  // Auto-discover ORM models from BaseModel registry
520
520
  try {
521
- const orm = await import("@tina4/orm");
521
+ const orm = await import("../../orm/src/index.js");
522
522
  const registry = (orm as any).BaseModel?._modelRegistry as Record<string, any> | undefined;
523
523
  if (registry) {
524
524
  for (const modelClass of Object.values(registry)) {
@@ -746,7 +746,7 @@ function handleStatus(router: Router): RouteHandler {
746
746
  const mailboxCounts = DevMailboxStore.count();
747
747
  let dbTableCount = 0;
748
748
  try {
749
- const { getAdapter } = await import("@tina4/orm");
749
+ const { getAdapter } = await import("../../orm/src/index.js");
750
750
  const db = getAdapter();
751
751
  dbTableCount = db.tables().length;
752
752
  } catch { /* no database connected */ }
@@ -852,7 +852,7 @@ const handleSystem: RouteHandler = async (_req, res) => {
852
852
  let dbTableCount: number | undefined;
853
853
  let dbConnected = false;
854
854
  try {
855
- const { getAdapter } = await import("@tina4/orm");
855
+ const { getAdapter } = await import("../../orm/src/index.js");
856
856
  const db = getAdapter();
857
857
  dbTableCount = db.tables().length;
858
858
  dbConnected = true;
@@ -1067,7 +1067,7 @@ const handleTable: RouteHandler = (req, res) => {
1067
1067
 
1068
1068
  const handleTables: RouteHandler = async (_req, res) => {
1069
1069
  try {
1070
- const { getAdapter } = await import("@tina4/orm");
1070
+ const { getAdapter } = await import("../../orm/src/index.js");
1071
1071
  const db = getAdapter();
1072
1072
  const tables = db.tables();
1073
1073
  res.json({ tables });
@@ -1093,7 +1093,7 @@ const handleSeed: RouteHandler = async (req, res) => {
1093
1093
  return;
1094
1094
  }
1095
1095
  try {
1096
- const orm = await import("@tina4/orm");
1096
+ const orm = await import("../../orm/src/index.js");
1097
1097
  const db = orm.getAdapter();
1098
1098
  const { seedTable } = orm;
1099
1099
  // A shared FakeData seeds the RNG so a `seed` makes the run reproducible.
@@ -1144,7 +1144,7 @@ const handleQuery: RouteHandler = async (req, res) => {
1144
1144
  }
1145
1145
 
1146
1146
  try {
1147
- const { getAdapter } = await import("@tina4/orm");
1147
+ const { getAdapter } = await import("../../orm/src/index.js");
1148
1148
  const db = getAdapter();
1149
1149
 
1150
1150
  // Split multiple statements on semicolons
@@ -1519,7 +1519,7 @@ const handleConnectionsTest: RouteHandler = async (req, res) => {
1519
1519
  }
1520
1520
  try {
1521
1521
  // Try to use the ORM's initDatabase if available
1522
- const { initDatabase } = await import("@tina4/orm").catch(() => ({ initDatabase: null }));
1522
+ const { initDatabase } = await import("../../orm/src/index.js").catch(() => ({ initDatabase: null }));
1523
1523
  if (!initDatabase) {
1524
1524
  res.json({ success: false, error: "Database module (@tina4/orm) not available" });
1525
1525
  return;
@@ -39,11 +39,13 @@ const req = createRequire(import.meta.url);
39
39
  * MCP client hits /__dev/mcp from a from-source dev server.
40
40
  */
41
41
  function reqSibling(pkg: "orm" | "swagger" | "frond"): Record<string, unknown> {
42
- try {
43
- return req(`@tina4/${pkg}`) as Record<string, unknown>;
44
- } catch {
45
- return req(`../../${pkg}/src/index.ts`) as Record<string, unknown>;
46
- }
42
+ // Resolve the sibling package from its in-repo/installed SOURCE path. A bare
43
+ // `@tina4/${pkg}` specifier does NOT resolve in a consumer install — the root
44
+ // `tina4-nodejs` package is the only thing on disk, there is no `@tina4/*` in
45
+ // node_modules (that was the #32 break) so go straight to the relative src.
46
+ // createRequire + tsx resolves the .ts here (synchronous dev-tool path); each
47
+ // caller already wraps this in try/catch and degrades gracefully on failure.
48
+ return req(`../../${pkg}/src/index.ts`) as Record<string, unknown>;
47
49
  }
48
50
 
49
51
  // ── Types ─────────────────────────────────────────────────────
@@ -1187,13 +1189,43 @@ export function registerDevTools(server: McpServer): void {
1187
1189
 
1188
1190
  server.registerTool(
1189
1191
  "route_test",
1190
- (args) => {
1191
- // Simplified: return info about what would be tested
1192
- return {
1193
- info: "Route testing requires the test client",
1194
- method: args.method,
1195
- path: args.path,
1196
- };
1192
+ async (args) => {
1193
+ // Dispatch the route through the REAL in-process TestClient (parity with
1194
+ // the Python master's route_test, which drives its TestClient). Previously
1195
+ // an echo stub that returned {info,method,path} and never dispatched.
1196
+ // The TestClient builds a mock request, matches the route on the active
1197
+ // router, runs it through the real auth gate, and executes the handler —
1198
+ // exactly like a live request, no socket. Returns {status, body, contentType}.
1199
+ try {
1200
+ const { TestClient } = req("./testClient.js") as typeof import("./testClient.js");
1201
+ const { defaultRouter } = req("./router.js") as typeof import("./router.js");
1202
+ // Same router accessor route_list uses: prefer the running server's
1203
+ // router, fall back to the default global router.
1204
+ const router = (globalThis as any).__tina4_router ?? defaultRouter;
1205
+ const client = new TestClient(router);
1206
+ const method = String(args.method ?? "GET").toUpperCase();
1207
+ const routePath = String(args.path ?? "");
1208
+ const bodyStr = args.body ? String(args.body) : undefined;
1209
+ let headers: Record<string, string> = {};
1210
+ try {
1211
+ headers = args.headers ? JSON.parse(String(args.headers)) : {};
1212
+ } catch {
1213
+ headers = {};
1214
+ }
1215
+ const options = { body: bodyStr, headers };
1216
+ let resp;
1217
+ switch (method) {
1218
+ case "GET": resp = await client.get(routePath, options); break;
1219
+ case "POST": resp = await client.post(routePath, options); break;
1220
+ case "PUT": resp = await client.put(routePath, options); break;
1221
+ case "PATCH": resp = await client.patch(routePath, options); break;
1222
+ case "DELETE": resp = await client.delete(routePath, options); break;
1223
+ default: return { error: `Unsupported method: ${method}` };
1224
+ }
1225
+ return { status: resp.status, body: resp.text(), contentType: resp.contentType };
1226
+ } catch (e) {
1227
+ return { error: (e as Error).message };
1228
+ }
1197
1229
  },
1198
1230
  "Call a route and return the response",
1199
1231
  schemaFromParams([
@@ -1379,18 +1411,28 @@ export function registerDevTools(server: McpServer): void {
1379
1411
  "migration_status",
1380
1412
  async (_args) => {
1381
1413
  try {
1382
- const db = (globalThis as any).__tina4_db;
1383
- if (!db) return { error: "No database connection" };
1384
1414
  // Use the real migration API (parity with the Python master, whose MCP
1385
- // migration_status calls MigrationRunner(db).status()). Previously a stub.
1386
- // Load orm via dynamic ESM import (the same mechanism server.ts uses for
1387
- // autoMigrateOnStartup) — reqSibling()'s createRequire fails here because
1388
- // @tina4/orm imports @tina4/core (no CJS "exports" main).
1415
+ // migration_status calls status(db)). Load orm via dynamic ESM import
1416
+ // (the same mechanism server.ts uses for autoMigrateOnStartup) —
1417
+ // reqSibling()'s createRequire fails here because @tina4/orm imports
1418
+ // @tina4/core (no CJS "exports" main).
1389
1419
  const orm = await import("../../orm/src/index.js");
1390
1420
  if (typeof orm.status !== "function") {
1391
1421
  return { error: "Migration API not available (install @tina4/orm)" };
1392
1422
  }
1393
- const st = await orm.status(db, { migrationsDir: path.join(projectRoot, "migrations") });
1423
+ // Pass the RAW adapter, NOT globalThis.__tina4_db. status()/migrate()
1424
+ // read applied-state via adapterQuery -> adapter.query(); the Database
1425
+ // WRAPPER has no .query/.queryAsync (only fetch/execute/tableExists), so
1426
+ // passing it made adapterQuery throw, got swallowed, and every migration
1427
+ // was reported "pending". getAdapter() is the same accessor the server
1428
+ // boot (server.ts) and the CLI (commands/migrate.ts) pass — and it works.
1429
+ let adapter;
1430
+ try {
1431
+ adapter = orm.getAdapter();
1432
+ } catch {
1433
+ return { error: "No database connection" };
1434
+ }
1435
+ const st = await orm.status(adapter, { migrationsDir: path.join(projectRoot, "migrations") });
1394
1436
  return { completed: st.completed, pending: st.pending };
1395
1437
  } catch (e) {
1396
1438
  return { error: (e as Error).message };
@@ -1422,17 +1464,26 @@ export function registerDevTools(server: McpServer): void {
1422
1464
  "migration_run",
1423
1465
  async (_args) => {
1424
1466
  try {
1425
- const db = (globalThis as any).__tina4_db;
1426
- if (!db) return { error: "No database connection" };
1427
1467
  // Run pending migrations for real (parity with the Python master's MCP
1428
- // migration_run -> Migration(db).migrate()). Previously a stub. Load orm
1429
- // via dynamic ESM import (server.ts's mechanism); reqSibling's createRequire
1430
- // fails because @tina4/orm imports @tina4/core.
1468
+ // migration_run -> migrate(db)). Load orm via dynamic ESM import
1469
+ // (server.ts's mechanism); reqSibling's createRequire fails because
1470
+ // @tina4/orm imports @tina4/core.
1431
1471
  const orm = await import("../../orm/src/index.js");
1432
1472
  if (typeof orm.migrate !== "function") {
1433
1473
  return { error: "Migration API not available (install @tina4/orm)" };
1434
1474
  }
1435
- const result = await orm.migrate(db, { migrationsDir: path.join(projectRoot, "migrations") });
1475
+ // Pass the RAW adapter, NOT globalThis.__tina4_db (see migration_status
1476
+ // above). Passing the wrapper made migrate() unable to read applied-state
1477
+ // (adapterQuery threw + was swallowed), so it re-applied every migration
1478
+ // on every call — non-idempotent. With the raw adapter the second run
1479
+ // reads passed=1 rows and skips them (idempotent).
1480
+ let adapter;
1481
+ try {
1482
+ adapter = orm.getAdapter();
1483
+ } catch {
1484
+ return { error: "No database connection" };
1485
+ }
1486
+ const result = await orm.migrate(adapter, { migrationsDir: path.join(projectRoot, "migrations") });
1436
1487
  return { applied: result.applied, skipped: result.skipped, failed: result.failed };
1437
1488
  } catch (e) {
1438
1489
  return { error: (e as Error).message };
@@ -1585,12 +1636,44 @@ export function registerDevTools(server: McpServer): void {
1585
1636
  "seed_table",
1586
1637
  async (args) => {
1587
1638
  try {
1588
- const { seedTable } = reqSibling("orm") as { seedTable?: (db: unknown, table: string, count: number) => number | Promise<number> };
1589
- const db = (globalThis as any).__tina4_db;
1590
- if (!db) return { error: "No database connection" };
1639
+ // Load orm via dynamic ESM import (NOT reqSibling/createRequire): the
1640
+ // CJS require cache is a SEPARATE module instance from the ESM one that
1641
+ // initDatabase() populated, so its getAdapter() sees a null adapter
1642
+ // ("No database connection"). The migration tools already use await
1643
+ // import for exactly this reason — share the live adapter.
1644
+ const orm = await import("../../orm/src/index.js") as {
1645
+ seedTable?: (db: unknown, table: string, count: number, fieldMap?: Record<string, () => unknown>) => Promise<{ seeded: number; failed: number }>;
1646
+ autoFieldMap?: (db: unknown, table: string) => Promise<Record<string, () => unknown>>;
1647
+ getAdapter?: () => unknown;
1648
+ };
1649
+ if (typeof orm.seedTable !== "function" || typeof orm.autoFieldMap !== "function") {
1650
+ return { error: "Seeder API not available (install @tina4/orm)" };
1651
+ }
1652
+ // RAW adapter, NOT globalThis.__tina4_db: autoFieldMap introspects via
1653
+ // adapter.columns() and seedTable inserts via adapter.execute() — the
1654
+ // Database WRAPPER has neither (its introspection method is getColumns).
1655
+ // getAdapter() is the same accessor migration_* use above.
1656
+ let adapter;
1657
+ try {
1658
+ adapter = orm.getAdapter?.();
1659
+ } catch {
1660
+ return { error: "No database connection" };
1661
+ }
1662
+ if (!adapter) return { error: "No database connection" };
1591
1663
  const count = (args.count as number) || 10;
1592
- const inserted = (await seedTable?.(db, args.table as string, count)) ?? 0;
1593
- return { table: args.table, inserted };
1664
+ // Build a column->generator map from the table's real columns (parity
1665
+ // with Python's seed_table, which calls auto_field_map). seedTable with
1666
+ // NO field map is a deliberate no-op (0 rows) — this is how a caller opts
1667
+ // into automatic generation, so the tool must supply one or nothing
1668
+ // inserts (the exact bug this fixes).
1669
+ const fieldMap = await orm.autoFieldMap(adapter, args.table as string);
1670
+ if (!fieldMap || Object.keys(fieldMap).length === 0) {
1671
+ return { error: `Table '${args.table}' not found or has no seedable columns` };
1672
+ }
1673
+ const summary = await orm.seedTable(adapter, args.table as string, count, fieldMap);
1674
+ // Return an INTEGER inserted count (Python shape {table, inserted}); the
1675
+ // seeder returns a SeedSummary, so surface .seeded (+ .failed for context).
1676
+ return { table: args.table, inserted: summary.seeded, failed: summary.failed };
1594
1677
  } catch (e) {
1595
1678
  return { error: (e as Error).message };
1596
1679
  }
@@ -52,7 +52,7 @@ export async function getFrond(): Promise<InstanceType<any>> {
52
52
  const dir = _defaultTemplatesDir ?? nodePath.resolve(process.cwd(), "src/templates");
53
53
  let engine = _frondCache.get(dir);
54
54
  if (!engine) {
55
- const { Frond } = await import("@tina4/frond");
55
+ const { Frond } = await import("../../frond/src/engine.js");
56
56
  engine = new Frond(dir);
57
57
  _frondCache.set(dir, engine);
58
58
  }
@@ -68,7 +68,7 @@ export async function getFrameworkFrond(): Promise<InstanceType<any> | null> {
68
68
  const frameworkDir = nodePath.resolve(nodePath.dirname(import.meta.url.replace("file://", "")), "..", "templates");
69
69
  if (!_frameworkFrond && fs.existsSync(frameworkDir)) {
70
70
  try {
71
- const { Frond } = await import("@tina4/frond");
71
+ const { Frond } = await import("../../frond/src/engine.js");
72
72
  _frameworkFrond = new Frond(frameworkDir);
73
73
  } catch { return null; }
74
74
  }
@@ -332,7 +332,7 @@ export function createResponse(res: ServerResponse): Tina4Response {
332
332
  templateDir?: string,
333
333
  ): Promise<Tina4Response> {
334
334
  try {
335
- const { Frond } = await import("@tina4/frond");
335
+ const { Frond } = await import("../../frond/src/engine.js");
336
336
  const dir = templateDir ?? _defaultTemplatesDir ?? nodePath.resolve(process.cwd(), "src/templates");
337
337
  let engine = _frondCache.get(dir);
338
338
  if (!engine) {
@@ -56,6 +56,12 @@ const WIN_DRIVE_RE = /^\/?[A-Za-z]:[/\\]/;
56
56
  * slashes. Anything path-like is kept as a path.
57
57
  */
58
58
  export function normalizeFirebirdDbIdentifier(rawPath: string): string {
59
+ // php #160: a `?charset=` (or any query) tacked onto a connection URL must
60
+ // NOT leak into the Firebird database identifier — a path/alias never
61
+ // legitimately contains `?`. The charset itself is resolved separately by
62
+ // resolveFirebirdCharset(). Strip the query before decoding/normalising.
63
+ const queryIndex = rawPath.indexOf("?");
64
+ if (queryIndex >= 0) rawPath = rawPath.slice(0, queryIndex);
59
65
  let decoded = decodeURIComponent(rawPath);
60
66
 
61
67
  // Classic double-slash form: //abs/path -> /abs/path
@@ -87,6 +93,36 @@ export function normalizeFirebirdDbIdentifier(rawPath: string): string {
87
93
  return decoded.startsWith("/") ? decoded : "/" + decoded;
88
94
  }
89
95
 
96
+ /**
97
+ * Resolve the Firebird connection charset (php #160 / parity with the Python
98
+ * master's `_resolve_firebird_charset`).
99
+ *
100
+ * The adapter used to pass NO charset, deferring to the driver's implicit
101
+ * default, which double-encodes UTF-8 bytes stored under a legacy `NONE`
102
+ * database. This resolves the charset from, in precedence order:
103
+ *
104
+ * 1. the connection URL query — `firebird://host:port/path?charset=NONE`
105
+ * 2. an explicit `charset` on the FirebirdConfig object passed to the adapter
106
+ * 3. the `TINA4_DATABASE_CHARSET` environment variable
107
+ * 4. the `UTF8` default
108
+ *
109
+ * Pure config resolution over its inputs (URL string, explicit charset, env) —
110
+ * it opens NO connection, so it is unit-testable without a live server.
111
+ */
112
+ export function resolveFirebirdCharset(connectionString: string, explicitCharset?: string): string {
113
+ let urlCharset: string | undefined;
114
+ const queryIndex = (connectionString ?? "").indexOf("?");
115
+ if (queryIndex >= 0) {
116
+ urlCharset = new URLSearchParams(connectionString.slice(queryIndex + 1)).get("charset") ?? undefined;
117
+ }
118
+ return (
119
+ urlCharset ||
120
+ explicitCharset ||
121
+ process.env.TINA4_DATABASE_CHARSET ||
122
+ "UTF8"
123
+ );
124
+ }
125
+
90
126
  export interface FirebirdConfig {
91
127
  host?: string;
92
128
  port?: number;
@@ -95,6 +131,8 @@ export interface FirebirdConfig {
95
131
  database?: string;
96
132
  role?: string;
97
133
  pageSize?: number;
134
+ /** Connection charset. Overridden by a `?charset=` URL query; see resolveFirebirdCharset. */
135
+ charset?: string;
98
136
  }
99
137
 
100
138
  export class FirebirdAdapter implements DatabaseAdapter {
@@ -120,6 +158,9 @@ export class FirebirdAdapter implements DatabaseAdapter {
120
158
  password: parsed.password ?? "masterkey",
121
159
  role: undefined,
122
160
  pageSize: 4096,
161
+ // php #160: honour ?charset= in the URL and TINA4_DATABASE_CHARSET so a
162
+ // legacy NONE database isn't force-connected under a mismatched charset.
163
+ charset: resolveFirebirdCharset(this.config),
123
164
  };
124
165
  } else {
125
166
  fbConfig = {
@@ -130,6 +171,8 @@ export class FirebirdAdapter implements DatabaseAdapter {
130
171
  password: this.config.password ?? "masterkey",
131
172
  role: this.config.role,
132
173
  pageSize: this.config.pageSize ?? 4096,
174
+ // php #160: explicit config.charset wins over env, else UTF8 default.
175
+ charset: resolveFirebirdCharset("", this.config.charset),
133
176
  };
134
177
  }
135
178
 
@@ -160,9 +203,10 @@ export class FirebirdAdapter implements DatabaseAdapter {
160
203
  }
161
204
 
162
205
  private parseUrl(url: string): { host?: string; port?: number; user?: string; password?: string; database?: string } {
163
- // firebird://user:pass@host:port/path/to/db.fdb
206
+ // firebird://user:pass@host:port/path/to/db.fdb[?charset=...]
164
207
  // The path part after the host is normalised by normalizeFirebirdDbIdentifier()
165
- // in connect(); here we just preserve it (with the leading "/" the regex strips).
208
+ // in connect() (which also strips any `?charset=` query see php #160); here
209
+ // we just preserve it (with the leading "/" the regex strips).
166
210
  const match = url.match(/firebird:\/\/(?:([^:]+):([^@]+)@)?([^:/]+)(?::(\d+))?\/(.*)/);
167
211
  if (match) {
168
212
  return {
@@ -1,4 +1,4 @@
1
- import type { RouteDefinition, Tina4Request, Tina4Response } from "@tina4/core";
1
+ import type { RouteDefinition, Tina4Request, Tina4Response } from "../../core/src/index.js";
2
2
  import type { DiscoveredModel } from "./model.js";
3
3
  import { getAdapter, adapterQuery, adapterExecute } from "./database.js";
4
4
  import { buildQuery, parseQueryString } from "./query.js";
@@ -8,7 +8,7 @@ import { validate as validateFields } from "./validation.js";
8
8
  import { QueryBuilder } from "./queryBuilder.js";
9
9
  import { SQLiteAdapter } from "./adapters/sqlite.js";
10
10
  import { QueryCache } from "./sqlTranslation.js";
11
- import { Log } from "@tina4/core";
11
+ import { Log } from "../../core/src/index.js";
12
12
  import type { DatabaseAdapter, FieldDefinition, RelationshipDefinition } from "./types.js";
13
13
 
14
14
  /**
@@ -701,23 +701,50 @@ export class BaseModel {
701
701
  await adapterExecute(db, `UPDATE "${ModelClass.tableName}" SET ${setClause} WHERE "${pkCol}" = ?`, values);
702
702
  } else {
703
703
  // Insert
704
+ //
705
+ // #165: an INSERT must OMIT a column the caller never assigned so a
706
+ // `NOT NULL DEFAULT <x>` column gets its DB default instead of an
707
+ // explicit NULL that violates the constraint. In TS an unset column is
708
+ // `undefined` (the constructor only seeds a field that declares a
709
+ // non-null ORM default — everything else stays `undefined`), so the
710
+ // `this[name] !== undefined` filter already drops unset columns; a
711
+ // value the caller set to `null` IS included and written as an explicit
712
+ // NULL (parity with Python's _assigned_fields split — JS distinguishes
713
+ // undefined-unset from null-explicit natively). A resolved non-null ORM
714
+ // default is still written (no regression).
704
715
  const insertFields = Object.entries(ModelClass.fields).filter(
705
716
  ([name, def]) => !(def.primaryKey && def.autoIncrement) && this[name] !== undefined,
706
717
  );
707
718
 
708
- const columns = insertFields.map(([k]) => `"${ModelClass.getDbColumn(k)}"`).join(", ");
709
- const placeholders = insertFields.map(() => "?").join(", ");
710
- const values = insertFields.map(([k, def]) => toDbFieldValue(def, this[k]));
711
-
712
719
  // For auto-increment PKs on engines that need it (PostgreSQL),
713
720
  // RETURNING the PK column lets us read the engine-assigned id back.
714
721
  // SQLite ignores the RETURNING clause harmlessly (it supports it
715
722
  // since 3.35) and we still prefer its lastInsertRowid; for other
716
723
  // engines extractLastInsertId() reads rows[0].id.
717
724
  const wantReturning = pkField?.autoIncrement && db.constructor.name !== "SQLiteAdapter";
718
- const insertSql =
719
- `INSERT INTO "${ModelClass.tableName}" (${columns}) VALUES (${placeholders})` +
720
- (wantReturning ? ` RETURNING "${pkCol}"` : "");
725
+ const returningClause = wantReturning ? ` RETURNING "${pkCol}"` : "";
726
+
727
+ let insertSql: string;
728
+ let values: unknown[];
729
+ if (insertFields.length === 0) {
730
+ // #165: every insertable column was left unset — let the DB apply ALL
731
+ // its column defaults rather than emitting an empty column list
732
+ // (`() VALUES ()` is invalid on SQLite/PostgreSQL/Firebird/MSSQL, which
733
+ // spell the all-defaults insert `DEFAULT VALUES`; only MySQL uses the
734
+ // empty-parens form). Mirrors the Python master's engine split.
735
+ insertSql =
736
+ (db.constructor.name === "MysqlAdapter"
737
+ ? `INSERT INTO "${ModelClass.tableName}" () VALUES ()`
738
+ : `INSERT INTO "${ModelClass.tableName}" DEFAULT VALUES`) + returningClause;
739
+ values = [];
740
+ } else {
741
+ const columns = insertFields.map(([k]) => `"${ModelClass.getDbColumn(k)}"`).join(", ");
742
+ const placeholders = insertFields.map(() => "?").join(", ");
743
+ values = insertFields.map(([k, def]) => toDbFieldValue(def, this[k]));
744
+ insertSql =
745
+ `INSERT INTO "${ModelClass.tableName}" (${columns}) VALUES (${placeholders})` +
746
+ returningClause;
747
+ }
721
748
 
722
749
  const result = await adapterExecute(db, insertSql, values);
723
750
 
@@ -34,7 +34,7 @@
34
34
 
35
35
  import { QueryCache } from "./sqlTranslation.js";
36
36
  import type { DatabaseAdapter, DatabaseResult, ColumnInfo, FieldDefinition } from "./types.js";
37
- import type { CacheBackend } from "@tina4/core";
37
+ import type { CacheBackend } from "../../core/src/index.js";
38
38
 
39
39
  function isTruthy(val: string | undefined): boolean {
40
40
  return ["true", "1", "yes", "on"].includes((val ?? "").trim().toLowerCase());
@@ -148,7 +148,7 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
148
148
  try {
149
149
  // Dynamic import keeps @tina4/orm free of an import-time cycle with
150
150
  // @tina4/core (whose `database` backend dynamically imports @tina4/orm).
151
- const core: any = await import("@tina4/core");
151
+ const core: any = await import("../../core/src/index.js");
152
152
  const b: CacheBackend = await core.createBackend({
153
153
  backend: this.backendName,
154
154
  cacheUrl: process.env.TINA4_DB_CACHE_URL,
@@ -57,7 +57,7 @@ export { SQLTranslator, QueryCache } from "./sqlTranslation.js";
57
57
  export { CachedDatabaseAdapter } from "./cachedDatabase.js";
58
58
  export type { CachedAdapterOptions } from "./cachedDatabase.js";
59
59
  export { FakeData } from "./fakeData.js";
60
- export { seedTable, seedOrm, seedModels } from "./seeder.js";
60
+ export { seedTable, seedOrm, seedModels, autoFieldMap } from "./seeder.js";
61
61
  export type { SeedSummary, SeedOptions } from "./seeder.js";
62
62
 
63
63
  // DocStore — pymongo-style document store with a zero-config SQLite (JSON1) fallback
@@ -78,7 +78,7 @@ export { MysqlAdapter } from "./adapters/mysql.js";
78
78
  export type { MysqlConfig } from "./adapters/mysql.js";
79
79
  export { MssqlAdapter } from "./adapters/mssql.js";
80
80
  export type { MssqlConfig } from "./adapters/mssql.js";
81
- export { FirebirdAdapter, normalizeFirebirdDbIdentifier } from "./adapters/firebird.js";
81
+ export { FirebirdAdapter, normalizeFirebirdDbIdentifier, resolveFirebirdCharset } from "./adapters/firebird.js";
82
82
  export type { FirebirdConfig } from "./adapters/firebird.js";
83
83
  export { MongodbAdapter } from "./adapters/mongodb.js";
84
84
  export type { MongoConfig } from "./adapters/mongodb.js";
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
- import { Log } from "@tina4/core";
3
+ import { Log } from "../../core/src/index.js";
4
4
  import type { FieldDefinition, DatabaseAdapter } from "./types.js";
5
5
  import type { SQLiteAdapter } from "./adapters/sqlite.js";
6
6
  import type { DiscoveredModel } from "./model.js";
@@ -1075,8 +1075,16 @@ export async function status(
1075
1075
  for (const row of rows) {
1076
1076
  if (row.migration_name) appliedNames.add(row.migration_name);
1077
1077
  }
1078
- } catch {
1079
- // No valid tracking treat all as pending
1078
+ } catch (err) {
1079
+ // The tracking table exists (checked above) but reading applied-state
1080
+ // failed — do NOT silently report every migration as pending. That
1081
+ // masked a real bug: passing the Database WRAPPER (no .query) instead of a
1082
+ // raw adapter made adapterQuery throw here, so status() reported all-pending
1083
+ // and the MCP migration_run re-applied everything each call. Log loud so a
1084
+ // wrong-arg / bad adapter can never again hide as "nothing applied yet".
1085
+ // (Python master #57 "don't swallow" lesson.) Applied-state stays empty for
1086
+ // this call, but the cause is now visible instead of masked.
1087
+ Log.error(`Migration status: could not read applied migrations from "${MIGRATION_TABLE}" — ${(err as Error).message}`);
1080
1088
  }
1081
1089
 
1082
1090
  for (const file of files) {
@@ -1191,7 +1199,7 @@ export async function createClassMigration(
1191
1199
  const content =
1192
1200
  `// Migration: ${description}\n` +
1193
1201
  `// Created: ${now.toISOString()}\n\n` +
1194
- `import type { DatabaseAdapter } from "@tina4/orm";\n\n` +
1202
+ `import type { DatabaseAdapter } from "tina4-nodejs/orm";\n\n` +
1195
1203
  `export class ${className} {\n` +
1196
1204
  ` async up(db: DatabaseAdapter): Promise<void> {\n` +
1197
1205
  ` // db.execute("CREATE TABLE ...");\n` +
@@ -10,7 +10,7 @@ import {
10
10
  type WebSocketConnection,
11
11
  type Tina4Request,
12
12
  type Tina4Response,
13
- } from "@tina4/core";
13
+ } from "../../../core/src/index.js";
14
14
  import Workspace from "./models/workspace.js";
15
15
  import Channel from "./models/channel.js";
16
16
  import ChannelMember from "./models/channelMember.js";
@@ -2,7 +2,7 @@ import { randomBytes } from "node:crypto";
2
2
  import { mkdirSync, readFileSync, writeFileSync, unlinkSync, statSync } from "node:fs";
3
3
  import { resolve, sep } from "node:path";
4
4
  import { createRequire } from "node:module";
5
- import { Log } from "@tina4/core";
5
+ import { Log } from "../../../core/src/index.js";
6
6
 
7
7
  // ESM has no global `require`; create one so the OPTIONAL aws-sdk can be loaded
8
8
  // synchronously in S3Storage's constructor. A missing driver throws here and
@@ -14,9 +14,9 @@
14
14
  // real parent PKs, and warns on clear type mismatches.
15
15
 
16
16
  import { FakeData } from "./fakeData.js";
17
- import { adapterExecute, adapterFetch } from "./database.js";
18
- import { Log } from "@tina4/core";
19
- import type { DatabaseAdapter, FieldDefinition } from "./types.js";
17
+ import { adapterExecute, adapterFetch, adapterColumns } from "./database.js";
18
+ import { Log } from "../../core/src/index.js";
19
+ import type { DatabaseAdapter, FieldDefinition, FieldType } from "./types.js";
20
20
 
21
21
  /**
22
22
  * Result of a seed run — `{ seeded, failed, errors }`.
@@ -75,6 +75,70 @@ async function clearTable(db: DatabaseAdapter, tableName: string): Promise<void>
75
75
  }
76
76
  }
77
77
 
78
+ /**
79
+ * Map a raw SQL column type string to a Tina4 FieldType so FakeData.forField()
80
+ * can pick a generator. Substring match (case-insensitive) covers the engine
81
+ * spellings — INTEGER/INT, REAL/FLOAT/DOUBLE/NUMERIC/DECIMAL, BOOL, DATE/TIME,
82
+ * TEXT/CLOB. Anything else is a plain string.
83
+ */
84
+ function sqlTypeToFieldType(sqlType: string): FieldType {
85
+ const t = (sqlType || "").toUpperCase();
86
+ if (t.includes("INT")) return "integer";
87
+ if (t.includes("BOOL")) return "boolean";
88
+ if (t.includes("REAL") || t.includes("FLOA") || t.includes("DOUB") || t.includes("NUM") || t.includes("DEC")) return "number";
89
+ if (t.includes("DATE") || t.includes("TIME")) return "datetime";
90
+ if (t.includes("TEXT") || t.includes("CLOB")) return "text";
91
+ return "string";
92
+ }
93
+
94
+ /**
95
+ * Introspect `table`'s columns and build a column->generator field map for
96
+ * {@link seedTable}, skipping the auto-increment / `id` primary key (the engine
97
+ * assigns it). Mirrors the Python master's `auto_field_map`
98
+ * (tina4_python/seeder/__init__.py): the shared "seed a table I did not
99
+ * hand-write generators for" helper that both the dev-admin seed endpoint and
100
+ * the MCP `seed_table` dev tool use — `seedTable` itself stays explicit
101
+ * (no map = no rows), and this is how a caller opts into automatic generation.
102
+ *
103
+ * Reuses `FakeData.forField()` (column-name + type heuristics) so the generated
104
+ * data matches every other Tina4 seeding path. Returns an empty map when the
105
+ * table has no seedable columns, so `seedTable` then seeds nothing rather than
106
+ * crashing.
107
+ *
108
+ * @param db - A DatabaseAdapter instance (pass `getAdapter()`, NOT the Database
109
+ * wrapper — the wrapper has no `columns()`).
110
+ * @param table - The table to introspect.
111
+ * @param fake - Optional shared FakeData (pass one seeded via `new FakeData(n)`
112
+ * for reproducible output).
113
+ * @returns `{ column -> () => value }`, ready to hand to `seedTable`.
114
+ */
115
+ export async function autoFieldMap(
116
+ db: DatabaseAdapter,
117
+ table: string,
118
+ fake: FakeData = new FakeData(),
119
+ ): Promise<Record<string, () => unknown>> {
120
+ const columns = await adapterColumns(db, table);
121
+ const fieldMap: Record<string, () => unknown> = {};
122
+ for (const col of columns) {
123
+ const name = col.name;
124
+ const sqlType = String(col.type ?? "").toUpperCase();
125
+ // Skip the auto-increment surrogate key so the INSERT omits it and the
126
+ // engine assigns it. Mirrors Python: primary key AND (AUTO/SERIAL/IDENTITY
127
+ // in the type OR the column is literally `id` — SQLite reports just
128
+ // "INTEGER" for an AUTOINCREMENT rowid alias, so the name catch is needed).
129
+ if (col.primaryKey === true &&
130
+ (sqlType.includes("AUTO") || sqlType.includes("SERIAL") ||
131
+ sqlType.includes("IDENTITY") || name.toLowerCase() === "id")) {
132
+ continue;
133
+ }
134
+ const fieldType = sqlTypeToFieldType(sqlType);
135
+ // Bind the type + name per column; forField applies the name heuristics
136
+ // (email/phone/name/...) before falling back to the type.
137
+ fieldMap[name] = () => fake.forField({ type: fieldType }, name);
138
+ }
139
+ return fieldMap;
140
+ }
141
+
78
142
  /**
79
143
  * Seed a database table with fake data using raw SQL inserts.
80
144
  *
@@ -1,5 +1,5 @@
1
- import type { RouteDefinition } from "@tina4/core";
2
- import type { ModelDefinition, FieldDefinition } from "@tina4/orm";
1
+ import type { RouteDefinition } from "../../core/src/index.js";
2
+ import type { ModelDefinition, FieldDefinition } from "../../orm/src/index.js";
3
3
 
4
4
  interface OpenAPISpecInfo {
5
5
  title: string;
@@ -1,4 +1,4 @@
1
- import type { Tina4Request, Tina4Response, RouteDefinition } from "@tina4/core";
1
+ import type { Tina4Request, Tina4Response, RouteDefinition } from "../../core/src/index.js";
2
2
 
3
3
  // The UI assets load from a CDN by default (a documented architecture decision —
4
4
  // we don't vendor ~1.4MB of swagger-ui-dist, to stay small). Air-gapped