tina4-nodejs 3.10.19 → 3.10.21
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 +8 -2
- package/package.json +1 -1
- package/packages/frond/src/engine.ts +209 -94
- package/packages/orm/src/database.ts +108 -15
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.21)
|
|
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.21 — 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
|
|
|
@@ -154,6 +154,9 @@ Database layer with auto-CRUD generation, seeding, fake data, and SQL translatio
|
|
|
154
154
|
- `seeder.ts` — Database seeding (`seedTable` for raw SQL, `seedOrm` for model-based)
|
|
155
155
|
- `sqlTranslation.ts` — Cross-engine SQL translator (`SQLTranslator`) and TTL query cache (`QueryCache`)
|
|
156
156
|
- QueryBuilder supports `toMongo()` for generating MongoDB query documents from the same fluent API
|
|
157
|
+
- `getNextId(table: string, pkColumn?: string, generatorName?: string): Promise<number>` — Race-safe ID generation using atomic sequence table (`tina4_sequences`). SQLite/MySQL/MSSQL use `tina4_sequences` with atomic UPDATE+SELECT. PostgreSQL auto-creates sequences if missing. Firebird uses existing generators (unchanged).
|
|
158
|
+
|
|
159
|
+
**`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.
|
|
157
160
|
|
|
158
161
|
### @tina4/swagger (`packages/swagger/`)
|
|
159
162
|
Auto-generates OpenAPI 3.0 docs.
|
|
@@ -584,6 +587,8 @@ When adding new features, add a corresponding `test/<feature>.test.ts` file.
|
|
|
584
587
|
|
|
585
588
|
- **38 built-in features**, zero third-party dependencies
|
|
586
589
|
- **1,812 tests** passing across all modules
|
|
590
|
+
- **Race-safe `getNextId()`** with atomic sequence table (`tina4_sequences`) for SQLite/MySQL/MSSQL; PostgreSQL auto-creates sequences
|
|
591
|
+
- **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)
|
|
587
592
|
- **Production server auto-detect**: `npx tina4nodejs serve --production` auto-uses cluster mode
|
|
588
593
|
- **`npx tina4nodejs generate`**: model, route, migration, middleware scaffolding
|
|
589
594
|
- **Database**: 5 engines (SQLite, PostgreSQL, MySQL, MSSQL, Firebird), query caching (`TINA4_DB_CACHE=true`)
|
|
@@ -607,6 +612,7 @@ When adding new features, add a corresponding `test/<feature>.test.ts` file.
|
|
|
607
612
|
- **Don't bundle `swagger-ui-dist`** — we load Swagger UI from CDN to stay under 8MB
|
|
608
613
|
- **Don't break the test files** — run `npm test` before committing
|
|
609
614
|
- **Don't add unnecessary dependencies** — minimal footprint is a core principle
|
|
615
|
+
- **Parity across all frameworks** — Every new feature, fix, or optimization must be implemented with equivalent logic AND tests in all 4 Tina4 frameworks (Python, PHP, Ruby, Node.js). Never ship to one without shipping to all.
|
|
610
616
|
- **Don't use `url.parse()`** — use the WHATWG `URL` constructor instead (deprecated in Node 20+)
|
|
611
617
|
|
|
612
618
|
## Tina4 Maintainer Skill
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tina4-nodejs",
|
|
3
|
-
"version": "3.10.
|
|
3
|
+
"version": "3.10.21",
|
|
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"],
|
|
@@ -22,6 +22,33 @@ class SafeString {
|
|
|
22
22
|
type TokenType = "TEXT" | "VAR" | "BLOCK" | "COMMENT";
|
|
23
23
|
type Token = [TokenType, string];
|
|
24
24
|
|
|
25
|
+
// ── Pre-compiled Regexes (module level) ────────────────────────
|
|
26
|
+
|
|
27
|
+
const NUMERIC_RE = /^-?\d+(\.\d+)?$/;
|
|
28
|
+
const METHOD_CALL_RE = /^(\w+)\s*\(([\s\S]*)?\)$/;
|
|
29
|
+
const FN_CALL_RE = /^([\w.]+)\s*\(([\s\S]*)?\)$/;
|
|
30
|
+
const IS_NOT_RE = /^(.+?)\s+is\s+not\s+(\w+)(.*)$/;
|
|
31
|
+
const IS_RE = /^(.+?)\s+is\s+(\w+)(.*)$/;
|
|
32
|
+
const NOT_IN_RE = /^(.+?)\s+not\s+in\s+(.+)$/;
|
|
33
|
+
const IN_RE = /^(.+?)\s+in\s+(.+)$/;
|
|
34
|
+
const DIVISIBLE_BY_RE = /\s*by\s*\(\s*(\d+)\s*\)/;
|
|
35
|
+
const FILTER_WITH_ARGS_RE = /^(\w+)\s*\(([\s\S]*)\)$/;
|
|
36
|
+
const FILTER_COMPARISON_RE = /^(\w+)\s*(!=|==|>=|<=|>|<)\s*(.+)$/;
|
|
37
|
+
const TITLE_WORD_RE = /\b\w/g;
|
|
38
|
+
const STRIP_TAGS_RE = /<[^>]+>/g;
|
|
39
|
+
const FORMAT_RE = /%[sd]/g;
|
|
40
|
+
const LEADING_WS_RE = /^\s+/;
|
|
41
|
+
const TRAILING_WS_RE = /\s+$/;
|
|
42
|
+
const THOUSANDS_RE = /\B(?=(\d{3})+(?!\d))/g;
|
|
43
|
+
|
|
44
|
+
// ── Caches (module level) ─────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
/** Cache for parsed filter chains: expr string -> [variable, filters] */
|
|
47
|
+
const filterChainCache = new Map<string, [string, [string, string[]][]]>();
|
|
48
|
+
|
|
49
|
+
/** Cache for parsed dotted/bracket paths: expr string -> [parts, fromBracket] */
|
|
50
|
+
const pathParseCache = new Map<string, [string[], boolean[]]>();
|
|
51
|
+
|
|
25
52
|
// ── Lexer ──────────────────────────────────────────────────────
|
|
26
53
|
|
|
27
54
|
const TOKEN_RE = /(\{%-?\s*[\s\S]*?\s*-?%\})|(\{\{-?\s*[\s\S]*?\s*-?\}\})|(\{#[\s\S]*?#\})/g;
|
|
@@ -118,7 +145,7 @@ function resolveVar(expr: string, context: Record<string, unknown>): unknown {
|
|
|
118
145
|
}
|
|
119
146
|
|
|
120
147
|
// Numeric literal
|
|
121
|
-
if (
|
|
148
|
+
if (NUMERIC_RE.test(expr)) {
|
|
122
149
|
return expr.includes(".") ? parseFloat(expr) : parseInt(expr, 10);
|
|
123
150
|
}
|
|
124
151
|
|
|
@@ -137,41 +164,50 @@ function resolveVar(expr: string, context: Record<string, unknown>): unknown {
|
|
|
137
164
|
|
|
138
165
|
// Dotted path with bracket access — split on . and [...] but not . inside parentheses
|
|
139
166
|
// Track which parts came from bracket access (need variable resolution)
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
continue;
|
|
160
|
-
}
|
|
161
|
-
if (ch === '[' && depth === 0) {
|
|
162
|
-
if (current) { parts.push(current); fromBracket.push(false); }
|
|
163
|
-
current = "";
|
|
164
|
-
const end = expr.indexOf(']', i + 1);
|
|
165
|
-
if (end !== -1) {
|
|
166
|
-
parts.push(expr.slice(i + 1, end));
|
|
167
|
-
fromBracket.push(true);
|
|
168
|
-
i = end;
|
|
167
|
+
let parts: string[];
|
|
168
|
+
let fromBracket: boolean[];
|
|
169
|
+
|
|
170
|
+
const cachedPath = pathParseCache.get(expr);
|
|
171
|
+
if (cachedPath) {
|
|
172
|
+
[parts, fromBracket] = cachedPath;
|
|
173
|
+
} else {
|
|
174
|
+
parts = [];
|
|
175
|
+
fromBracket = [];
|
|
176
|
+
{
|
|
177
|
+
let current = "";
|
|
178
|
+
let depth = 0;
|
|
179
|
+
let inQuote: string | null = null;
|
|
180
|
+
for (let i = 0; i < expr.length; i++) {
|
|
181
|
+
const ch = expr[i];
|
|
182
|
+
if (inQuote) {
|
|
183
|
+
current += ch;
|
|
184
|
+
if (ch === inQuote) inQuote = null;
|
|
185
|
+
continue;
|
|
169
186
|
}
|
|
170
|
-
continue;
|
|
187
|
+
if (ch === '"' || ch === "'") { inQuote = ch; current += ch; continue; }
|
|
188
|
+
if (ch === '(') { depth++; current += ch; continue; }
|
|
189
|
+
if (ch === ')') { depth--; current += ch; continue; }
|
|
190
|
+
if (ch === '.' && depth === 0) {
|
|
191
|
+
if (current) { parts.push(current); fromBracket.push(false); }
|
|
192
|
+
current = "";
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (ch === '[' && depth === 0) {
|
|
196
|
+
if (current) { parts.push(current); fromBracket.push(false); }
|
|
197
|
+
current = "";
|
|
198
|
+
const end = expr.indexOf(']', i + 1);
|
|
199
|
+
if (end !== -1) {
|
|
200
|
+
parts.push(expr.slice(i + 1, end));
|
|
201
|
+
fromBracket.push(true);
|
|
202
|
+
i = end;
|
|
203
|
+
}
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
current += ch;
|
|
171
207
|
}
|
|
172
|
-
current
|
|
208
|
+
if (current) { parts.push(current); fromBracket.push(false); }
|
|
173
209
|
}
|
|
174
|
-
|
|
210
|
+
pathParseCache.set(expr, [parts, fromBracket]);
|
|
175
211
|
}
|
|
176
212
|
|
|
177
213
|
let value: unknown = context;
|
|
@@ -181,7 +217,7 @@ function resolveVar(expr: string, context: Record<string, unknown>): unknown {
|
|
|
181
217
|
if (value === null || value === undefined) return null;
|
|
182
218
|
|
|
183
219
|
// Check for method call: name(args)
|
|
184
|
-
const methodMatch = part.match(
|
|
220
|
+
const methodMatch = part.match(METHOD_CALL_RE);
|
|
185
221
|
if (methodMatch) {
|
|
186
222
|
const methodName = methodMatch[1];
|
|
187
223
|
const rawArgs = methodMatch[2] || "";
|
|
@@ -384,7 +420,7 @@ function evalExpr(expr: string, context: Record<string, unknown>): unknown {
|
|
|
384
420
|
}
|
|
385
421
|
|
|
386
422
|
// Function call: name("arg1", "arg2") — supports dotted names like user.t("key")
|
|
387
|
-
const fnMatch = expr.match(
|
|
423
|
+
const fnMatch = expr.match(FN_CALL_RE);
|
|
388
424
|
if (fnMatch) {
|
|
389
425
|
const fnName = fnMatch[1];
|
|
390
426
|
const rawArgs = fnMatch[2] || "";
|
|
@@ -483,53 +519,58 @@ function splitOnTilde(expr: string): string[] {
|
|
|
483
519
|
return parts;
|
|
484
520
|
}
|
|
485
521
|
|
|
486
|
-
function evalComparison(
|
|
522
|
+
function evalComparison(
|
|
523
|
+
expr: string,
|
|
524
|
+
context: Record<string, unknown>,
|
|
525
|
+
evalFn?: (expr: string, context: Record<string, unknown>) => unknown,
|
|
526
|
+
): boolean {
|
|
527
|
+
const ev = evalFn ?? evalExpr;
|
|
487
528
|
expr = expr.trim();
|
|
488
529
|
|
|
489
530
|
// Handle 'not' prefix
|
|
490
531
|
if (expr.startsWith("not ")) {
|
|
491
|
-
return !evalComparison(expr.slice(4), context);
|
|
532
|
+
return !evalComparison(expr.slice(4), context, evalFn);
|
|
492
533
|
}
|
|
493
534
|
|
|
494
535
|
// 'or' (lowest precedence)
|
|
495
536
|
const orParts = splitOnKeyword(expr, " or ");
|
|
496
537
|
if (orParts.length > 1) {
|
|
497
|
-
return orParts.some(p => evalComparison(p, context));
|
|
538
|
+
return orParts.some(p => evalComparison(p, context, evalFn));
|
|
498
539
|
}
|
|
499
540
|
|
|
500
541
|
// 'and'
|
|
501
542
|
const andParts = splitOnKeyword(expr, " and ");
|
|
502
543
|
if (andParts.length > 1) {
|
|
503
|
-
return andParts.every(p => evalComparison(p, context));
|
|
544
|
+
return andParts.every(p => evalComparison(p, context, evalFn));
|
|
504
545
|
}
|
|
505
546
|
|
|
506
547
|
// 'is not' test
|
|
507
|
-
let m = expr.match(
|
|
548
|
+
let m = expr.match(IS_NOT_RE);
|
|
508
549
|
if (m) {
|
|
509
|
-
return !evalTest(m[1].trim(), m[2], m[3].trim(), context);
|
|
550
|
+
return !evalTest(m[1].trim(), m[2], m[3].trim(), context, evalFn);
|
|
510
551
|
}
|
|
511
552
|
|
|
512
553
|
// 'is' test
|
|
513
|
-
m = expr.match(
|
|
554
|
+
m = expr.match(IS_RE);
|
|
514
555
|
if (m) {
|
|
515
|
-
return evalTest(m[1].trim(), m[2], m[3].trim(), context);
|
|
556
|
+
return evalTest(m[1].trim(), m[2], m[3].trim(), context, evalFn);
|
|
516
557
|
}
|
|
517
558
|
|
|
518
559
|
// 'not in'
|
|
519
|
-
m = expr.match(
|
|
560
|
+
m = expr.match(NOT_IN_RE);
|
|
520
561
|
if (m) {
|
|
521
|
-
const val =
|
|
522
|
-
const collection =
|
|
562
|
+
const val = ev(m[1].trim(), context);
|
|
563
|
+
const collection = ev(m[2].trim(), context);
|
|
523
564
|
if (Array.isArray(collection)) return !collection.includes(val);
|
|
524
565
|
if (typeof collection === "string") return !collection.includes(val as string);
|
|
525
566
|
return true;
|
|
526
567
|
}
|
|
527
568
|
|
|
528
569
|
// 'in'
|
|
529
|
-
m = expr.match(
|
|
570
|
+
m = expr.match(IN_RE);
|
|
530
571
|
if (m) {
|
|
531
|
-
const val =
|
|
532
|
-
const collection =
|
|
572
|
+
const val = ev(m[1].trim(), context);
|
|
573
|
+
const collection = ev(m[2].trim(), context);
|
|
533
574
|
if (Array.isArray(collection)) return collection.includes(val);
|
|
534
575
|
if (typeof collection === "string") return collection.includes(val as string);
|
|
535
576
|
return false;
|
|
@@ -550,8 +591,8 @@ function evalComparison(expr: string, context: Record<string, unknown>): boolean
|
|
|
550
591
|
if (opIdx !== -1) {
|
|
551
592
|
const left = expr.slice(0, opIdx).trim();
|
|
552
593
|
const right = expr.slice(opIdx + op.length).trim();
|
|
553
|
-
const l =
|
|
554
|
-
const r =
|
|
594
|
+
const l = ev(left, context);
|
|
595
|
+
const r = ev(right, context);
|
|
555
596
|
try {
|
|
556
597
|
return fn(l, r);
|
|
557
598
|
} catch {
|
|
@@ -561,7 +602,7 @@ function evalComparison(expr: string, context: Record<string, unknown>): boolean
|
|
|
561
602
|
}
|
|
562
603
|
|
|
563
604
|
// Fall through to simple eval
|
|
564
|
-
const val =
|
|
605
|
+
const val = ev(expr, context);
|
|
565
606
|
return val !== null && val !== undefined && val !== false && val !== 0 && val !== "";
|
|
566
607
|
}
|
|
567
608
|
|
|
@@ -607,8 +648,10 @@ function evalTest(
|
|
|
607
648
|
testName: string,
|
|
608
649
|
args: string,
|
|
609
650
|
context: Record<string, unknown>,
|
|
651
|
+
evalFn?: (expr: string, context: Record<string, unknown>) => unknown,
|
|
610
652
|
): boolean {
|
|
611
|
-
const
|
|
653
|
+
const ev = evalFn ?? evalExpr;
|
|
654
|
+
const val = ev(valueExpr, context);
|
|
612
655
|
|
|
613
656
|
// Check custom tests first
|
|
614
657
|
const customTests = (context as { __frond_tests__?: Record<string, TestFn> }).__frond_tests__;
|
|
@@ -631,7 +674,7 @@ function evalTest(
|
|
|
631
674
|
|
|
632
675
|
// 'divisible by(n)'
|
|
633
676
|
if (testName === "divisible") {
|
|
634
|
-
const dm = args.match(
|
|
677
|
+
const dm = args.match(DIVISIBLE_BY_RE);
|
|
635
678
|
if (dm) {
|
|
636
679
|
const n = parseInt(dm[1], 10);
|
|
637
680
|
return typeof val === "number" && Number.isInteger(val) && val % n === 0;
|
|
@@ -649,6 +692,10 @@ function evalTest(
|
|
|
649
692
|
// ── Filters ────────────────────────────────────────────────────
|
|
650
693
|
|
|
651
694
|
function parseFilterChain(expr: string): [string, [string, string[]][]] {
|
|
695
|
+
// Check cache first
|
|
696
|
+
const cached = filterChainCache.get(expr);
|
|
697
|
+
if (cached) return cached;
|
|
698
|
+
|
|
652
699
|
// Split on | but not inside strings or parentheses
|
|
653
700
|
const parts: string[] = [];
|
|
654
701
|
let current = "";
|
|
@@ -683,7 +730,7 @@ function parseFilterChain(expr: string): [string, [string, string[]][]] {
|
|
|
683
730
|
|
|
684
731
|
for (let i = 1; i < parts.length; i++) {
|
|
685
732
|
const f = parts[i].trim();
|
|
686
|
-
const fm = f.match(
|
|
733
|
+
const fm = f.match(FILTER_WITH_ARGS_RE);
|
|
687
734
|
if (fm) {
|
|
688
735
|
const name = fm[1];
|
|
689
736
|
const rawArgs = fm[2].trim();
|
|
@@ -694,7 +741,9 @@ function parseFilterChain(expr: string): [string, [string, string[]][]] {
|
|
|
694
741
|
}
|
|
695
742
|
}
|
|
696
743
|
|
|
697
|
-
|
|
744
|
+
const result: [string, [string, string[]][]] = [variable, filters];
|
|
745
|
+
filterChainCache.set(expr, result);
|
|
746
|
+
return result;
|
|
698
747
|
}
|
|
699
748
|
|
|
700
749
|
function parseArgs(raw: string): string[] {
|
|
@@ -827,7 +876,7 @@ function numberFormat(value: unknown, decimals: number): string {
|
|
|
827
876
|
const num = parseFloat(String(value));
|
|
828
877
|
const fixed = num.toFixed(decimals);
|
|
829
878
|
const [intPart, decPart] = fixed.split(".");
|
|
830
|
-
const formatted = intPart.replace(
|
|
879
|
+
const formatted = intPart.replace(THOUSANDS_RE, ",");
|
|
831
880
|
return decPart ? `${formatted}.${decPart}` : formatted;
|
|
832
881
|
}
|
|
833
882
|
|
|
@@ -835,10 +884,10 @@ const BUILTIN_FILTERS: Record<string, FilterFn> = {
|
|
|
835
884
|
upper: (v) => String(v).toUpperCase(),
|
|
836
885
|
lower: (v) => String(v).toLowerCase(),
|
|
837
886
|
capitalize: (v) => { const s = String(v); return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase(); },
|
|
838
|
-
title: (v) => String(v).replace(
|
|
887
|
+
title: (v) => String(v).replace(TITLE_WORD_RE, c => c.toUpperCase()),
|
|
839
888
|
trim: (v) => String(v).trim(),
|
|
840
|
-
ltrim: (v) => String(v).replace(
|
|
841
|
-
rtrim: (v) => String(v).replace(
|
|
889
|
+
ltrim: (v) => String(v).replace(LEADING_WS_RE, ""),
|
|
890
|
+
rtrim: (v) => String(v).replace(TRAILING_WS_RE, ""),
|
|
842
891
|
length: (v) => {
|
|
843
892
|
if (Array.isArray(v)) return v.length;
|
|
844
893
|
if (typeof v === "string") return v.length;
|
|
@@ -866,7 +915,7 @@ const BUILTIN_FILTERS: Record<string, FilterFn> = {
|
|
|
866
915
|
safe: (v) => v,
|
|
867
916
|
escape: (v) => htmlEscape(String(v)),
|
|
868
917
|
e: (v) => htmlEscape(String(v)),
|
|
869
|
-
striptags: (v) => String(v).replace(
|
|
918
|
+
striptags: (v) => String(v).replace(STRIP_TAGS_RE, ""),
|
|
870
919
|
nl2br: (v) => String(v).replace(/\n/g, "<br>\n"),
|
|
871
920
|
abs: (v) => typeof v === "number" ? Math.abs(v) : v,
|
|
872
921
|
round: (v, decimals) => {
|
|
@@ -957,7 +1006,7 @@ const BUILTIN_FILTERS: Record<string, FilterFn> = {
|
|
|
957
1006
|
let s = String(v);
|
|
958
1007
|
// Simple %s / %d replacement like Python's % operator
|
|
959
1008
|
let idx = 0;
|
|
960
|
-
s = s.replace(
|
|
1009
|
+
s = s.replace(FORMAT_RE, () => {
|
|
961
1010
|
const val = idx < args.length ? String(args[idx]) : "";
|
|
962
1011
|
idx++;
|
|
963
1012
|
return val;
|
|
@@ -1259,20 +1308,20 @@ export class Frond {
|
|
|
1259
1308
|
} else if (ttype === "VAR") {
|
|
1260
1309
|
const [content, stripB, stripA] = stripTag(raw);
|
|
1261
1310
|
if (stripB && output.length > 0) {
|
|
1262
|
-
output[output.length - 1] = output[output.length - 1].replace(
|
|
1311
|
+
output[output.length - 1] = output[output.length - 1].replace(TRAILING_WS_RE, "");
|
|
1263
1312
|
}
|
|
1264
1313
|
|
|
1265
1314
|
const result = this.evalVar(content, context);
|
|
1266
1315
|
output.push(result !== null && result !== undefined ? String(result) : "");
|
|
1267
1316
|
|
|
1268
1317
|
if (stripA && i + 1 < tokens.length && tokens[i + 1][0] === "TEXT") {
|
|
1269
|
-
tokens[i + 1] = ["TEXT", tokens[i + 1][1].replace(
|
|
1318
|
+
tokens[i + 1] = ["TEXT", tokens[i + 1][1].replace(LEADING_WS_RE, "")];
|
|
1270
1319
|
}
|
|
1271
1320
|
i++;
|
|
1272
1321
|
} else if (ttype === "BLOCK") {
|
|
1273
1322
|
const [content, stripB, stripA] = stripTag(raw);
|
|
1274
1323
|
if (stripB && output.length > 0) {
|
|
1275
|
-
output[output.length - 1] = output[output.length - 1].replace(
|
|
1324
|
+
output[output.length - 1] = output[output.length - 1].replace(TRAILING_WS_RE, "");
|
|
1276
1325
|
}
|
|
1277
1326
|
|
|
1278
1327
|
const parts = content.split(/\s+/);
|
|
@@ -1280,7 +1329,7 @@ export class Frond {
|
|
|
1280
1329
|
|
|
1281
1330
|
// Apply stripA before handlers consume body tokens
|
|
1282
1331
|
if (stripA && i + 1 < tokens.length && tokens[i + 1][0] === "TEXT") {
|
|
1283
|
-
tokens[i + 1] = ["TEXT", tokens[i + 1][1].replace(
|
|
1332
|
+
tokens[i + 1] = ["TEXT", tokens[i + 1][1].replace(LEADING_WS_RE, "")];
|
|
1284
1333
|
}
|
|
1285
1334
|
|
|
1286
1335
|
if (tag === "if") {
|
|
@@ -1342,7 +1391,7 @@ export class Frond {
|
|
|
1342
1391
|
}
|
|
1343
1392
|
|
|
1344
1393
|
if (stripA && i < tokens.length && tokens[i][0] === "TEXT") {
|
|
1345
|
-
tokens[i] = ["TEXT", tokens[i][1].replace(
|
|
1394
|
+
tokens[i] = ["TEXT", tokens[i][1].replace(LEADING_WS_RE, "")];
|
|
1346
1395
|
}
|
|
1347
1396
|
} else {
|
|
1348
1397
|
i++;
|
|
@@ -1401,7 +1450,7 @@ export class Frond {
|
|
|
1401
1450
|
// The filter name may include a trailing comparison operator,
|
|
1402
1451
|
// e.g. "length != 1". Extract the real filter name and the
|
|
1403
1452
|
// comparison suffix, apply the filter, then evaluate the comparison.
|
|
1404
|
-
const m = fname.match(
|
|
1453
|
+
const m = fname.match(FILTER_COMPARISON_RE);
|
|
1405
1454
|
if (m) {
|
|
1406
1455
|
const realFilter = m[1];
|
|
1407
1456
|
const op = m[2];
|
|
@@ -1458,6 +1507,40 @@ export class Frond {
|
|
|
1458
1507
|
}
|
|
1459
1508
|
}
|
|
1460
1509
|
|
|
1510
|
+
// Inline fast-path for common no-arg filters — avoids generic dispatch
|
|
1511
|
+
if (args.length === 0) {
|
|
1512
|
+
switch (fname) {
|
|
1513
|
+
case "upper": value = String(value).toUpperCase(); continue;
|
|
1514
|
+
case "lower": value = String(value).toLowerCase(); continue;
|
|
1515
|
+
case "trim": value = String(value).trim(); continue;
|
|
1516
|
+
case "length":
|
|
1517
|
+
if (Array.isArray(value)) { value = value.length; }
|
|
1518
|
+
else if (typeof value === "string") { value = value.length; }
|
|
1519
|
+
else if (typeof value === "object" && value !== null) { value = Object.keys(value).length; }
|
|
1520
|
+
else { value = 0; }
|
|
1521
|
+
continue;
|
|
1522
|
+
case "capitalize": { const s = String(value); value = s.charAt(0).toUpperCase() + s.slice(1).toLowerCase(); continue; }
|
|
1523
|
+
case "title": value = String(value).replace(TITLE_WORD_RE, c => c.toUpperCase()); continue;
|
|
1524
|
+
case "string": value = String(value); continue;
|
|
1525
|
+
case "int": value = value ? parseInt(String(value), 10) || 0 : 0; continue;
|
|
1526
|
+
case "float": value = value ? parseFloat(String(value)) || 0.0 : 0.0; continue;
|
|
1527
|
+
case "abs": value = typeof value === "number" ? Math.abs(value) : value; continue;
|
|
1528
|
+
case "striptags": value = String(value).replace(STRIP_TAGS_RE, ""); continue;
|
|
1529
|
+
case "first": value = Array.isArray(value) ? value[0] ?? null : null; continue;
|
|
1530
|
+
case "last": value = Array.isArray(value) ? value[value.length - 1] ?? null : null; continue;
|
|
1531
|
+
case "keys": value = (typeof value === "object" && value !== null && !Array.isArray(value)) ? Object.keys(value) : []; continue;
|
|
1532
|
+
case "values": value = (typeof value === "object" && value !== null && !Array.isArray(value)) ? Object.values(value) : []; continue;
|
|
1533
|
+
case "json_encode": value = JSON.stringify(value); continue;
|
|
1534
|
+
case "dump": value = JSON.stringify(value); continue;
|
|
1535
|
+
case "nl2br": value = String(value).replace(/\n/g, "<br>\n"); continue;
|
|
1536
|
+
case "unique": value = Array.isArray(value) ? [...new Set(value)] : value; continue;
|
|
1537
|
+
case "sort": value = Array.isArray(value) ? [...value].sort() : value; continue;
|
|
1538
|
+
case "reverse": value = Array.isArray(value) ? [...value].reverse() : String(value).split("").reverse().join(""); continue;
|
|
1539
|
+
case "filter": value = Array.isArray(value) ? value.filter(Boolean) : value; continue;
|
|
1540
|
+
// Not a fast-path filter — fall through to generic dispatch
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1461
1544
|
const fn = this.filters[fname];
|
|
1462
1545
|
if (fn) {
|
|
1463
1546
|
value = fn(value, ...args);
|
|
@@ -1503,25 +1586,25 @@ export class Frond {
|
|
|
1503
1586
|
} else if (tag === "endif" && depth === 0) {
|
|
1504
1587
|
// Strip trailing whitespace from last body token if endif has strip_before
|
|
1505
1588
|
if (tagStripB && currentTokens.length > 0 && currentTokens[currentTokens.length - 1][0] === "TEXT") {
|
|
1506
|
-
currentTokens[currentTokens.length - 1] = ["TEXT", currentTokens[currentTokens.length - 1][1].replace(
|
|
1589
|
+
currentTokens[currentTokens.length - 1] = ["TEXT", currentTokens[currentTokens.length - 1][1].replace(TRAILING_WS_RE, "")];
|
|
1507
1590
|
}
|
|
1508
1591
|
branches.push([currentCond, currentTokens]);
|
|
1509
1592
|
// Apply stripA on token after endif
|
|
1510
1593
|
if (tagStripA && i + 1 < tokens.length && tokens[i + 1][0] === "TEXT") {
|
|
1511
|
-
tokens[i + 1] = ["TEXT", tokens[i + 1][1].replace(
|
|
1594
|
+
tokens[i + 1] = ["TEXT", tokens[i + 1][1].replace(LEADING_WS_RE, "")];
|
|
1512
1595
|
}
|
|
1513
1596
|
i++;
|
|
1514
1597
|
break;
|
|
1515
1598
|
} else if ((tag === "elseif" || tag === "elif") && depth === 0) {
|
|
1516
1599
|
if (tagStripB && currentTokens.length > 0 && currentTokens[currentTokens.length - 1][0] === "TEXT") {
|
|
1517
|
-
currentTokens[currentTokens.length - 1] = ["TEXT", currentTokens[currentTokens.length - 1][1].replace(
|
|
1600
|
+
currentTokens[currentTokens.length - 1] = ["TEXT", currentTokens[currentTokens.length - 1][1].replace(TRAILING_WS_RE, "")];
|
|
1518
1601
|
}
|
|
1519
1602
|
branches.push([currentCond, currentTokens]);
|
|
1520
1603
|
currentCond = tagContent.slice(tag.length).trim();
|
|
1521
1604
|
currentTokens = [];
|
|
1522
1605
|
} else if (tag === "else" && depth === 0) {
|
|
1523
1606
|
if (tagStripB && currentTokens.length > 0 && currentTokens[currentTokens.length - 1][0] === "TEXT") {
|
|
1524
|
-
currentTokens[currentTokens.length - 1] = ["TEXT", currentTokens[currentTokens.length - 1][1].replace(
|
|
1607
|
+
currentTokens[currentTokens.length - 1] = ["TEXT", currentTokens[currentTokens.length - 1][1].replace(TRAILING_WS_RE, "")];
|
|
1525
1608
|
}
|
|
1526
1609
|
branches.push([currentCond, currentTokens]);
|
|
1527
1610
|
currentCond = null; // else branch
|
|
@@ -1537,7 +1620,7 @@ export class Frond {
|
|
|
1537
1620
|
|
|
1538
1621
|
// Evaluate branches
|
|
1539
1622
|
for (const [cond, branchTokens] of branches) {
|
|
1540
|
-
if (cond === null || evalComparison(cond, context)) {
|
|
1623
|
+
if (cond === null || evalComparison(cond, context, this.evalVarRaw.bind(this))) {
|
|
1541
1624
|
return [this.renderTokens([...branchTokens], context), i];
|
|
1542
1625
|
}
|
|
1543
1626
|
}
|
|
@@ -1613,38 +1696,70 @@ export class Frond {
|
|
|
1613
1696
|
: Array.isArray(iterable) ? iterable : [];
|
|
1614
1697
|
const total = items.length;
|
|
1615
1698
|
|
|
1699
|
+
// Reusable loop object — mutated each iteration to avoid allocation
|
|
1700
|
+
const loopObj = {
|
|
1701
|
+
index: 0,
|
|
1702
|
+
index0: 0,
|
|
1703
|
+
first: false,
|
|
1704
|
+
last: false,
|
|
1705
|
+
length: total,
|
|
1706
|
+
revindex: 0,
|
|
1707
|
+
revindex0: 0,
|
|
1708
|
+
even: false,
|
|
1709
|
+
odd: false,
|
|
1710
|
+
};
|
|
1711
|
+
|
|
1616
1712
|
for (let idx = 0; idx < total; idx++) {
|
|
1617
1713
|
const item = items[idx];
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1714
|
+
|
|
1715
|
+
// Update loop object in-place
|
|
1716
|
+
loopObj.index = idx + 1;
|
|
1717
|
+
loopObj.index0 = idx;
|
|
1718
|
+
loopObj.first = idx === 0;
|
|
1719
|
+
loopObj.last = idx === total - 1;
|
|
1720
|
+
loopObj.revindex = total - idx;
|
|
1721
|
+
loopObj.revindex0 = total - idx - 1;
|
|
1722
|
+
loopObj.even = (idx + 1) % 2 === 0;
|
|
1723
|
+
loopObj.odd = (idx + 1) % 2 !== 0;
|
|
1724
|
+
|
|
1725
|
+
// Lazy overlay context: reads from local overrides first, then parent
|
|
1726
|
+
const locals: Record<string, unknown> = { loop: loopObj };
|
|
1630
1727
|
|
|
1631
1728
|
if (isDict) {
|
|
1632
1729
|
const [key, value] = item as [string, unknown];
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
loopCtx[var2] = value;
|
|
1636
|
-
} else {
|
|
1637
|
-
loopCtx[var1] = key;
|
|
1638
|
-
}
|
|
1730
|
+
locals[var1] = key;
|
|
1731
|
+
if (var2) locals[var2] = value;
|
|
1639
1732
|
} else {
|
|
1640
1733
|
if (var2) {
|
|
1641
|
-
|
|
1642
|
-
|
|
1734
|
+
locals[var1] = idx;
|
|
1735
|
+
locals[var2] = item;
|
|
1643
1736
|
} else {
|
|
1644
|
-
|
|
1737
|
+
locals[var1] = item;
|
|
1645
1738
|
}
|
|
1646
1739
|
}
|
|
1647
1740
|
|
|
1741
|
+
const loopCtx = new Proxy(locals, {
|
|
1742
|
+
get(target, prop: string) {
|
|
1743
|
+
if (prop in target) return target[prop];
|
|
1744
|
+
return (context as Record<string, unknown>)[prop];
|
|
1745
|
+
},
|
|
1746
|
+
set(target, prop: string, value) {
|
|
1747
|
+
target[prop] = value;
|
|
1748
|
+
return true;
|
|
1749
|
+
},
|
|
1750
|
+
has(target, prop: string) {
|
|
1751
|
+
return prop in target || prop in context;
|
|
1752
|
+
},
|
|
1753
|
+
ownKeys() {
|
|
1754
|
+
return [...new Set([...Object.keys(locals), ...Object.keys(context)])];
|
|
1755
|
+
},
|
|
1756
|
+
getOwnPropertyDescriptor(target, prop: string) {
|
|
1757
|
+
if (prop in target) return { configurable: true, enumerable: true, value: target[prop] };
|
|
1758
|
+
if (prop in context) return { configurable: true, enumerable: true, value: (context as Record<string, unknown>)[prop] };
|
|
1759
|
+
return undefined;
|
|
1760
|
+
},
|
|
1761
|
+
}) as Record<string, unknown>;
|
|
1762
|
+
|
|
1648
1763
|
output.push(this.renderTokens([...bodyTokens], loopCtx));
|
|
1649
1764
|
}
|
|
1650
1765
|
|
|
@@ -400,18 +400,100 @@ export class Database {
|
|
|
400
400
|
return typeof id === "bigint" ? id.toString() : id;
|
|
401
401
|
}
|
|
402
402
|
|
|
403
|
+
/**
|
|
404
|
+
* Create the tina4_sequences table if it doesn't exist.
|
|
405
|
+
* Used by sequenceNext() for race-safe ID generation on
|
|
406
|
+
* SQLite, MySQL, MSSQL, and as a PostgreSQL fallback.
|
|
407
|
+
*/
|
|
408
|
+
private ensureSequenceTable(): void {
|
|
409
|
+
const adapter = this.getNextAdapter();
|
|
410
|
+
|
|
411
|
+
if (!adapter.tableExists("tina4_sequences")) {
|
|
412
|
+
if (this.dbType === "mssql") {
|
|
413
|
+
adapter.execute(
|
|
414
|
+
"CREATE TABLE tina4_sequences (" +
|
|
415
|
+
"seq_name VARCHAR(200) NOT NULL PRIMARY KEY, " +
|
|
416
|
+
"current_value INTEGER NOT NULL DEFAULT 0)"
|
|
417
|
+
);
|
|
418
|
+
} else {
|
|
419
|
+
adapter.execute(
|
|
420
|
+
"CREATE TABLE IF NOT EXISTS tina4_sequences (" +
|
|
421
|
+
"seq_name VARCHAR(200) NOT NULL PRIMARY KEY, " +
|
|
422
|
+
"current_value INTEGER NOT NULL DEFAULT 0)"
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
try { adapter.commit(); } catch { /* no active transaction */ }
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Atomically increment and return the next value from the sequence table.
|
|
431
|
+
*
|
|
432
|
+
* If the sequence row doesn't exist yet, seeds it from MAX(pkColumn)
|
|
433
|
+
* of the given table (or 0 if the table is empty/missing).
|
|
434
|
+
*/
|
|
435
|
+
private sequenceNext(seqName: string, table?: string, pkColumn = "id"): number {
|
|
436
|
+
this.ensureSequenceTable();
|
|
437
|
+
const adapter = this.getNextAdapter();
|
|
438
|
+
|
|
439
|
+
// Check if the sequence row exists
|
|
440
|
+
const existing = adapter.fetchOne<Record<string, unknown>>(
|
|
441
|
+
"SELECT current_value FROM tina4_sequences WHERE seq_name = ?",
|
|
442
|
+
[seqName]
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
if (existing == null) {
|
|
446
|
+
// Seed from current MAX
|
|
447
|
+
let seedValue = 0;
|
|
448
|
+
if (table) {
|
|
449
|
+
try {
|
|
450
|
+
const maxRow = adapter.fetchOne<Record<string, unknown>>(
|
|
451
|
+
`SELECT MAX(${pkColumn}) AS max_id FROM ${table}`
|
|
452
|
+
);
|
|
453
|
+
if (maxRow?.max_id != null) {
|
|
454
|
+
seedValue = Number(maxRow.max_id);
|
|
455
|
+
}
|
|
456
|
+
} catch {
|
|
457
|
+
// Table doesn't exist — start at 0
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
adapter.execute(
|
|
462
|
+
"INSERT INTO tina4_sequences (seq_name, current_value) VALUES (?, ?)",
|
|
463
|
+
[seqName, seedValue]
|
|
464
|
+
);
|
|
465
|
+
try { adapter.commit(); } catch { /* no active transaction */ }
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Atomic increment
|
|
469
|
+
adapter.execute(
|
|
470
|
+
"UPDATE tina4_sequences SET current_value = current_value + 1 WHERE seq_name = ?",
|
|
471
|
+
[seqName]
|
|
472
|
+
);
|
|
473
|
+
try { adapter.commit(); } catch { /* no active transaction */ }
|
|
474
|
+
|
|
475
|
+
// Read the new value
|
|
476
|
+
const row = adapter.fetchOne<Record<string, unknown>>(
|
|
477
|
+
"SELECT current_value FROM tina4_sequences WHERE seq_name = ?",
|
|
478
|
+
[seqName]
|
|
479
|
+
);
|
|
480
|
+
return row?.current_value != null ? Number(row.current_value) : 1;
|
|
481
|
+
}
|
|
482
|
+
|
|
403
483
|
/**
|
|
404
484
|
* Pre-generate the next available primary key ID using engine-aware strategies.
|
|
405
485
|
*
|
|
406
|
-
* - Firebird: auto-creates a generator if missing, then increments via GEN_ID.
|
|
407
|
-
* - PostgreSQL: tries nextval()
|
|
408
|
-
*
|
|
486
|
+
* - Firebird: auto-creates a generator if missing, then increments via GEN_ID (atomic).
|
|
487
|
+
* - PostgreSQL: tries nextval() first; if sequence missing, auto-creates it
|
|
488
|
+
* seeded from MAX; falls through to sequence table on failure.
|
|
489
|
+
* - SQLite/MySQL/MSSQL: uses tina4_sequences table with atomic UPDATE + SELECT
|
|
490
|
+
* (race-safe, replaces old MAX+1).
|
|
409
491
|
* - Returns 1 if the table is empty or does not exist.
|
|
410
492
|
*/
|
|
411
493
|
getNextId(table: string, pkColumn = "id", generatorName?: string): number {
|
|
412
494
|
const adapter = this.getNextAdapter();
|
|
413
495
|
|
|
414
|
-
// Firebird — use generators
|
|
496
|
+
// Firebird — use generators (atomic)
|
|
415
497
|
if (this.dbType === "firebird") {
|
|
416
498
|
const genName = generatorName ?? `GEN_${table.toUpperCase()}_ID`;
|
|
417
499
|
|
|
@@ -426,27 +508,38 @@ export class Database {
|
|
|
426
508
|
return Number(row?.NEXT_ID ?? row?.next_id ?? 1);
|
|
427
509
|
}
|
|
428
510
|
|
|
429
|
-
// PostgreSQL — try sequence first, fall through to
|
|
511
|
+
// PostgreSQL — try sequence first, auto-create if missing, fall through to sequence table
|
|
430
512
|
if (this.dbType === "postgres") {
|
|
431
|
-
const seqName = `${table.toLowerCase()}_${pkColumn.toLowerCase()}_seq`;
|
|
513
|
+
const seqName = generatorName ?? `${table.toLowerCase()}_${pkColumn.toLowerCase()}_seq`;
|
|
432
514
|
try {
|
|
433
515
|
const row = adapter.fetchOne<Record<string, unknown>>(`SELECT nextval('${seqName}') AS next_id`);
|
|
434
516
|
if (row?.next_id != null) {
|
|
435
517
|
return Number(row.next_id);
|
|
436
518
|
}
|
|
437
519
|
} catch {
|
|
438
|
-
//
|
|
520
|
+
// Sequence doesn't exist — try to auto-create it
|
|
439
521
|
}
|
|
440
|
-
}
|
|
441
522
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
523
|
+
// Auto-create sequence seeded from MAX
|
|
524
|
+
try {
|
|
525
|
+
const maxRow = adapter.fetchOne<Record<string, unknown>>(
|
|
526
|
+
`SELECT COALESCE(MAX(${pkColumn}), 0) AS max_id FROM ${table}`
|
|
527
|
+
);
|
|
528
|
+
const start = maxRow?.max_id != null ? Number(maxRow.max_id) + 1 : 1;
|
|
529
|
+
adapter.execute(`CREATE SEQUENCE ${seqName} START WITH ${start}`);
|
|
530
|
+
try { adapter.commit(); } catch { /* no active transaction */ }
|
|
531
|
+
const row = adapter.fetchOne<Record<string, unknown>>(`SELECT nextval('${seqName}') AS next_id`);
|
|
532
|
+
if (row?.next_id != null) {
|
|
533
|
+
return Number(row.next_id);
|
|
534
|
+
}
|
|
535
|
+
} catch {
|
|
536
|
+
// Fall through to sequence table
|
|
537
|
+
}
|
|
449
538
|
}
|
|
539
|
+
|
|
540
|
+
// SQLite / MySQL / MSSQL / PostgreSQL fallback — atomic sequence table
|
|
541
|
+
const seqKey = generatorName ?? `${table}.${pkColumn}`;
|
|
542
|
+
return this.sequenceNext(seqKey, table, pkColumn);
|
|
450
543
|
}
|
|
451
544
|
}
|
|
452
545
|
|