tina4-nodejs 3.10.13 → 3.10.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +2 -2
- package/package.json +1 -1
- package/packages/frond/src/engine.ts +36 -2
- package/packages/orm/src/database.ts +58 -1
package/CLAUDE.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
# CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.10.
|
|
1
|
+
# CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.10.15)
|
|
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.
|
|
7
|
+
Tina4 for Node.js/TypeScript v3.10.15 — a convention-over-configuration structural paradigm. **Not a framework.** The developer writes TypeScript; Tina4 is invisible infrastructure.
|
|
8
8
|
|
|
9
9
|
The philosophy: zero ceremony, batteries included, file system as source of truth.
|
|
10
10
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tina4-nodejs",
|
|
3
|
-
"version": "3.10.
|
|
3
|
+
"version": "3.10.15",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "This is not a framework. Tina4 for Node.js/TypeScript — zero deps, 38 built-in features.",
|
|
6
6
|
"keywords": ["tina4", "framework", "web", "api", "orm", "graphql", "websocket", "typescript"],
|
|
@@ -666,14 +666,45 @@ function parseFilterChain(expr: string): [string, [string, string[]][]] {
|
|
|
666
666
|
return [variable, filters];
|
|
667
667
|
}
|
|
668
668
|
|
|
669
|
+
function processEscapes(s: string): string {
|
|
670
|
+
let out = "";
|
|
671
|
+
for (let i = 0; i < s.length; i++) {
|
|
672
|
+
if (s[i] === "\\" && i + 1 < s.length) {
|
|
673
|
+
const nxt = s[i + 1];
|
|
674
|
+
switch (nxt) {
|
|
675
|
+
case "n": out += "\n"; i++; break;
|
|
676
|
+
case "t": out += "\t"; i++; break;
|
|
677
|
+
case "\\": out += "\\"; i++; break;
|
|
678
|
+
case "'": out += "'"; i++; break;
|
|
679
|
+
case '"': out += '"'; i++; break;
|
|
680
|
+
default: out += "\\"; break;
|
|
681
|
+
}
|
|
682
|
+
} else {
|
|
683
|
+
out += s[i];
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
return out;
|
|
687
|
+
}
|
|
688
|
+
|
|
669
689
|
function parseArgs(raw: string): string[] {
|
|
670
690
|
const args: string[] = [];
|
|
671
691
|
let current = "";
|
|
672
692
|
let inQuote: string | null = null;
|
|
673
693
|
let wasQuoted = false;
|
|
674
694
|
let depth = 0;
|
|
695
|
+
let escaped = false;
|
|
675
696
|
|
|
676
697
|
for (const ch of raw) {
|
|
698
|
+
if (escaped) {
|
|
699
|
+
current += ch;
|
|
700
|
+
escaped = false;
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
if (ch === "\\" && inQuote) {
|
|
704
|
+
current += ch;
|
|
705
|
+
escaped = true;
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
677
708
|
if (inQuote) {
|
|
678
709
|
if (ch === inQuote) {
|
|
679
710
|
inQuote = null;
|
|
@@ -692,7 +723,7 @@ function parseArgs(raw: string): string[] {
|
|
|
692
723
|
if (ch === "(") { depth++; current += ch; continue; }
|
|
693
724
|
if (ch === ")") { depth--; current += ch; continue; }
|
|
694
725
|
if (ch === "," && depth === 0) {
|
|
695
|
-
args.push(wasQuoted ? current : current.trim());
|
|
726
|
+
args.push(wasQuoted ? processEscapes(current) : current.trim());
|
|
696
727
|
current = "";
|
|
697
728
|
wasQuoted = false;
|
|
698
729
|
continue;
|
|
@@ -700,7 +731,7 @@ function parseArgs(raw: string): string[] {
|
|
|
700
731
|
current += ch;
|
|
701
732
|
}
|
|
702
733
|
|
|
703
|
-
const final = wasQuoted ? current : current.trim();
|
|
734
|
+
const final = wasQuoted ? processEscapes(current) : current.trim();
|
|
704
735
|
if (final !== "" || wasQuoted) {
|
|
705
736
|
args.push(final);
|
|
706
737
|
}
|
|
@@ -936,6 +967,9 @@ const BUILTIN_FILTERS: Record<string, FilterFn> = {
|
|
|
936
967
|
dump: (v) => JSON.stringify(v),
|
|
937
968
|
formToken: (v?: unknown) => _generateFormToken(v != null ? String(v) : ""),
|
|
938
969
|
form_token: (v?: unknown) => _generateFormToken(v != null ? String(v) : ""),
|
|
970
|
+
to_json: (v) => JSON.stringify(v).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026"),
|
|
971
|
+
tojson: (v) => JSON.stringify(v).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026"),
|
|
972
|
+
js_escape: (v) => String(v).replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r"),
|
|
939
973
|
};
|
|
940
974
|
|
|
941
975
|
// ── Form Token ────────────────────────────────────────────────
|
|
@@ -208,6 +208,9 @@ export class Database {
|
|
|
208
208
|
/** Whether to automatically commit after each write operation */
|
|
209
209
|
private autoCommit: boolean = process.env.TINA4_AUTOCOMMIT === "true";
|
|
210
210
|
|
|
211
|
+
/** Database engine type (sqlite, postgres, mysql, mssql, firebird) */
|
|
212
|
+
private dbType: string = "sqlite";
|
|
213
|
+
|
|
211
214
|
/**
|
|
212
215
|
* Create a Database wrapping an existing adapter.
|
|
213
216
|
* For creating a Database from a URL, use the async static factories:
|
|
@@ -227,6 +230,8 @@ export class Database {
|
|
|
227
230
|
* @param pool - Number of pooled connections (0 = single, N>0 = round-robin)
|
|
228
231
|
*/
|
|
229
232
|
static async create(url: string, username?: string, password?: string, pool: number = 0): Promise<Database> {
|
|
233
|
+
const parsed = parseDatabaseUrl(url, username, password);
|
|
234
|
+
|
|
230
235
|
if (pool > 0) {
|
|
231
236
|
// Pooled mode — create all adapters eagerly
|
|
232
237
|
const adapters: DatabaseAdapter[] = [];
|
|
@@ -243,13 +248,16 @@ export class Database {
|
|
|
243
248
|
db.poolIndex = 0;
|
|
244
249
|
db.adapter = null; // Don't use single-adapter path
|
|
245
250
|
db.adapterFactory = () => createAdapterFromUrl(url, username, password);
|
|
251
|
+
db.dbType = parsed.type;
|
|
246
252
|
return db;
|
|
247
253
|
}
|
|
248
254
|
|
|
249
255
|
// Single-connection mode — current behavior
|
|
250
256
|
const adapter = await createAdapterFromUrl(url, username, password);
|
|
251
257
|
setAdapter(adapter);
|
|
252
|
-
|
|
258
|
+
const db = new Database(adapter);
|
|
259
|
+
db.dbType = parsed.type;
|
|
260
|
+
return db;
|
|
253
261
|
}
|
|
254
262
|
|
|
255
263
|
/**
|
|
@@ -391,6 +399,55 @@ export class Database {
|
|
|
391
399
|
if (id === null) return 0;
|
|
392
400
|
return typeof id === "bigint" ? id.toString() : id;
|
|
393
401
|
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Pre-generate the next available primary key ID using engine-aware strategies.
|
|
405
|
+
*
|
|
406
|
+
* - Firebird: auto-creates a generator if missing, then increments via GEN_ID.
|
|
407
|
+
* - PostgreSQL: tries nextval() on the standard sequence, falls through to MAX+1.
|
|
408
|
+
* - SQLite/MySQL/MSSQL: uses MAX(pk) + 1.
|
|
409
|
+
* - Returns 1 if the table is empty or does not exist.
|
|
410
|
+
*/
|
|
411
|
+
getNextId(table: string, pkColumn = "id", generatorName?: string): number {
|
|
412
|
+
const adapter = this.getNextAdapter();
|
|
413
|
+
|
|
414
|
+
// Firebird — use generators
|
|
415
|
+
if (this.dbType === "firebird") {
|
|
416
|
+
const genName = generatorName ?? `GEN_${table.toUpperCase()}_ID`;
|
|
417
|
+
|
|
418
|
+
// Auto-create the generator if it does not exist
|
|
419
|
+
try {
|
|
420
|
+
adapter.execute(`CREATE GENERATOR ${genName}`);
|
|
421
|
+
} catch {
|
|
422
|
+
// Generator already exists — ignore
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const row = adapter.fetchOne<Record<string, unknown>>(`SELECT GEN_ID(${genName}, 1) AS NEXT_ID FROM RDB$DATABASE`);
|
|
426
|
+
return Number(row?.NEXT_ID ?? row?.next_id ?? 1);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// PostgreSQL — try sequence first, fall through to MAX
|
|
430
|
+
if (this.dbType === "postgres") {
|
|
431
|
+
const seqName = `${table.toLowerCase()}_${pkColumn.toLowerCase()}_seq`;
|
|
432
|
+
try {
|
|
433
|
+
const row = adapter.fetchOne<Record<string, unknown>>(`SELECT nextval('${seqName}') AS next_id`);
|
|
434
|
+
if (row?.next_id != null) {
|
|
435
|
+
return Number(row.next_id);
|
|
436
|
+
}
|
|
437
|
+
} catch {
|
|
438
|
+
// No sequence — fall through to MAX
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// SQLite / MySQL / MSSQL / PostgreSQL fallback — MAX + 1
|
|
443
|
+
try {
|
|
444
|
+
const row = adapter.fetchOne<Record<string, unknown>>(`SELECT MAX(${pkColumn}) + 1 AS next_id FROM ${table}`);
|
|
445
|
+
const nextId = row?.next_id;
|
|
446
|
+
return nextId != null ? Number(nextId) : 1;
|
|
447
|
+
} catch {
|
|
448
|
+
return 1;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
394
451
|
}
|
|
395
452
|
|
|
396
453
|
/**
|