tina4-nodejs 3.10.18 → 3.10.20

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.10.18)
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.18 — a convention-over-configuration structural paradigm. **Not a framework.** The developer writes TypeScript; Tina4 is invisible infrastructure.
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.18",
3
+ "version": "3.10.20",
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 (/^-?\d+(\.\d+)?$/.test(expr)) {
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
- const parts: string[] = [];
141
- const fromBracket: boolean[] = [];
142
- {
143
- let current = "";
144
- let depth = 0;
145
- let inQuote: string | null = null;
146
- for (let i = 0; i < expr.length; i++) {
147
- const ch = expr[i];
148
- if (inQuote) {
149
- current += ch;
150
- if (ch === inQuote) inQuote = null;
151
- continue;
152
- }
153
- if (ch === '"' || ch === "'") { inQuote = ch; current += ch; continue; }
154
- if (ch === '(') { depth++; current += ch; continue; }
155
- if (ch === ')') { depth--; current += ch; continue; }
156
- if (ch === '.' && depth === 0) {
157
- if (current) { parts.push(current); fromBracket.push(false); }
158
- current = "";
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 += ch;
208
+ if (current) { parts.push(current); fromBracket.push(false); }
173
209
  }
174
- if (current) { parts.push(current); fromBracket.push(false); }
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(/^(\w+)\s*\(([\s\S]*)?\)$/);
220
+ const methodMatch = part.match(METHOD_CALL_RE);
185
221
  if (methodMatch) {
186
222
  const methodName = methodMatch[1];
187
223
  const rawArgs = methodMatch[2] || "";
@@ -308,6 +344,23 @@ function evalExpr(expr: string, context: Record<string, unknown>): unknown {
308
344
  }
309
345
  }
310
346
 
347
+ // Parenthesized sub-expression: (expr) — strip parens and evaluate inner
348
+ if (expr.length >= 2 && expr[0] === "(" && expr.endsWith(")")) {
349
+ let depth = 0;
350
+ let matched = true;
351
+ for (let pi = 0; pi < expr.length; pi++) {
352
+ if (expr[pi] === "(") depth++;
353
+ else if (expr[pi] === ")") depth--;
354
+ if (depth === 0 && pi < expr.length - 1) {
355
+ matched = false;
356
+ break;
357
+ }
358
+ }
359
+ if (matched) {
360
+ return evalExpr(expr.slice(1, -1), context);
361
+ }
362
+ }
363
+
311
364
  // Ternary: condition ? true_val : false_val
312
365
  // Match carefully to handle nested ternaries
313
366
  const ternaryIdx = findTernary(expr);
@@ -323,11 +376,17 @@ function evalExpr(expr: string, context: Record<string, unknown>): unknown {
323
376
  }
324
377
  }
325
378
 
326
- // Jinja2-style inline if: value if condition else other_value
327
- const inlineIfMatch = expr.match(/^(.+?)\s+if\s+(.+?)\s+else\s+(.+)$/);
328
- if (inlineIfMatch) {
329
- const cond = evalExpr(inlineIfMatch[2], context);
330
- return cond ? evalExpr(inlineIfMatch[1], context) : evalExpr(inlineIfMatch[3], context);
379
+ // Jinja2-style inline if: value if condition else other_value — quote-aware
380
+ const ifIdx = findOutsideQuotes(expr, " if ");
381
+ if (ifIdx >= 0) {
382
+ const elseIdx = findOutsideQuotes(expr, " else ");
383
+ if (elseIdx >= 0 && elseIdx > ifIdx) {
384
+ const valuePart = expr.slice(0, ifIdx).trim();
385
+ const condPart = expr.slice(ifIdx + 4, elseIdx).trim();
386
+ const elsePart = expr.slice(elseIdx + 6).trim();
387
+ const cond = evalExpr(condPart, context);
388
+ return cond ? evalExpr(valuePart, context) : evalExpr(elsePart, context);
389
+ }
331
390
  }
332
391
 
333
392
  // Null coalescing: value ?? "default"
@@ -361,7 +420,7 @@ function evalExpr(expr: string, context: Record<string, unknown>): unknown {
361
420
  }
362
421
 
363
422
  // Function call: name("arg1", "arg2") — supports dotted names like user.t("key")
364
- const fnMatch = expr.match(/^([\w.]+)\s*\(([\s\S]*)?\)$/);
423
+ const fnMatch = expr.match(FN_CALL_RE);
365
424
  if (fnMatch) {
366
425
  const fnName = fnMatch[1];
367
426
  const rawArgs = fnMatch[2] || "";
@@ -460,53 +519,58 @@ function splitOnTilde(expr: string): string[] {
460
519
  return parts;
461
520
  }
462
521
 
463
- function evalComparison(expr: string, context: Record<string, unknown>): boolean {
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;
464
528
  expr = expr.trim();
465
529
 
466
530
  // Handle 'not' prefix
467
531
  if (expr.startsWith("not ")) {
468
- return !evalComparison(expr.slice(4), context);
532
+ return !evalComparison(expr.slice(4), context, evalFn);
469
533
  }
470
534
 
471
535
  // 'or' (lowest precedence)
472
536
  const orParts = splitOnKeyword(expr, " or ");
473
537
  if (orParts.length > 1) {
474
- return orParts.some(p => evalComparison(p, context));
538
+ return orParts.some(p => evalComparison(p, context, evalFn));
475
539
  }
476
540
 
477
541
  // 'and'
478
542
  const andParts = splitOnKeyword(expr, " and ");
479
543
  if (andParts.length > 1) {
480
- return andParts.every(p => evalComparison(p, context));
544
+ return andParts.every(p => evalComparison(p, context, evalFn));
481
545
  }
482
546
 
483
547
  // 'is not' test
484
- let m = expr.match(/^(.+?)\s+is\s+not\s+(\w+)(.*)$/);
548
+ let m = expr.match(IS_NOT_RE);
485
549
  if (m) {
486
- return !evalTest(m[1].trim(), m[2], m[3].trim(), context);
550
+ return !evalTest(m[1].trim(), m[2], m[3].trim(), context, evalFn);
487
551
  }
488
552
 
489
553
  // 'is' test
490
- m = expr.match(/^(.+?)\s+is\s+(\w+)(.*)$/);
554
+ m = expr.match(IS_RE);
491
555
  if (m) {
492
- return evalTest(m[1].trim(), m[2], m[3].trim(), context);
556
+ return evalTest(m[1].trim(), m[2], m[3].trim(), context, evalFn);
493
557
  }
494
558
 
495
559
  // 'not in'
496
- m = expr.match(/^(.+?)\s+not\s+in\s+(.+)$/);
560
+ m = expr.match(NOT_IN_RE);
497
561
  if (m) {
498
- const val = evalExpr(m[1].trim(), context);
499
- const collection = evalExpr(m[2].trim(), context);
562
+ const val = ev(m[1].trim(), context);
563
+ const collection = ev(m[2].trim(), context);
500
564
  if (Array.isArray(collection)) return !collection.includes(val);
501
565
  if (typeof collection === "string") return !collection.includes(val as string);
502
566
  return true;
503
567
  }
504
568
 
505
569
  // 'in'
506
- m = expr.match(/^(.+?)\s+in\s+(.+)$/);
570
+ m = expr.match(IN_RE);
507
571
  if (m) {
508
- const val = evalExpr(m[1].trim(), context);
509
- const collection = evalExpr(m[2].trim(), context);
572
+ const val = ev(m[1].trim(), context);
573
+ const collection = ev(m[2].trim(), context);
510
574
  if (Array.isArray(collection)) return collection.includes(val);
511
575
  if (typeof collection === "string") return collection.includes(val as string);
512
576
  return false;
@@ -527,8 +591,8 @@ function evalComparison(expr: string, context: Record<string, unknown>): boolean
527
591
  if (opIdx !== -1) {
528
592
  const left = expr.slice(0, opIdx).trim();
529
593
  const right = expr.slice(opIdx + op.length).trim();
530
- const l = evalExpr(left, context);
531
- const r = evalExpr(right, context);
594
+ const l = ev(left, context);
595
+ const r = ev(right, context);
532
596
  try {
533
597
  return fn(l, r);
534
598
  } catch {
@@ -538,7 +602,7 @@ function evalComparison(expr: string, context: Record<string, unknown>): boolean
538
602
  }
539
603
 
540
604
  // Fall through to simple eval
541
- const val = evalExpr(expr, context);
605
+ const val = ev(expr, context);
542
606
  return val !== null && val !== undefined && val !== false && val !== 0 && val !== "";
543
607
  }
544
608
 
@@ -584,8 +648,10 @@ function evalTest(
584
648
  testName: string,
585
649
  args: string,
586
650
  context: Record<string, unknown>,
651
+ evalFn?: (expr: string, context: Record<string, unknown>) => unknown,
587
652
  ): boolean {
588
- const val = evalExpr(valueExpr, context);
653
+ const ev = evalFn ?? evalExpr;
654
+ const val = ev(valueExpr, context);
589
655
 
590
656
  // Check custom tests first
591
657
  const customTests = (context as { __frond_tests__?: Record<string, TestFn> }).__frond_tests__;
@@ -608,7 +674,7 @@ function evalTest(
608
674
 
609
675
  // 'divisible by(n)'
610
676
  if (testName === "divisible") {
611
- const dm = args.match(/\s*by\s*\(\s*(\d+)\s*\)/);
677
+ const dm = args.match(DIVISIBLE_BY_RE);
612
678
  if (dm) {
613
679
  const n = parseInt(dm[1], 10);
614
680
  return typeof val === "number" && Number.isInteger(val) && val % n === 0;
@@ -626,6 +692,10 @@ function evalTest(
626
692
  // ── Filters ────────────────────────────────────────────────────
627
693
 
628
694
  function parseFilterChain(expr: string): [string, [string, string[]][]] {
695
+ // Check cache first
696
+ const cached = filterChainCache.get(expr);
697
+ if (cached) return cached;
698
+
629
699
  // Split on | but not inside strings or parentheses
630
700
  const parts: string[] = [];
631
701
  let current = "";
@@ -660,7 +730,7 @@ function parseFilterChain(expr: string): [string, [string, string[]][]] {
660
730
 
661
731
  for (let i = 1; i < parts.length; i++) {
662
732
  const f = parts[i].trim();
663
- const fm = f.match(/^(\w+)\s*\(([\s\S]*)\)$/);
733
+ const fm = f.match(FILTER_WITH_ARGS_RE);
664
734
  if (fm) {
665
735
  const name = fm[1];
666
736
  const rawArgs = fm[2].trim();
@@ -671,7 +741,9 @@ function parseFilterChain(expr: string): [string, [string, string[]][]] {
671
741
  }
672
742
  }
673
743
 
674
- return [variable, filters];
744
+ const result: [string, [string, string[]][]] = [variable, filters];
745
+ filterChainCache.set(expr, result);
746
+ return result;
675
747
  }
676
748
 
677
749
  function parseArgs(raw: string): string[] {
@@ -804,7 +876,7 @@ function numberFormat(value: unknown, decimals: number): string {
804
876
  const num = parseFloat(String(value));
805
877
  const fixed = num.toFixed(decimals);
806
878
  const [intPart, decPart] = fixed.split(".");
807
- const formatted = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
879
+ const formatted = intPart.replace(THOUSANDS_RE, ",");
808
880
  return decPart ? `${formatted}.${decPart}` : formatted;
809
881
  }
810
882
 
@@ -812,10 +884,10 @@ const BUILTIN_FILTERS: Record<string, FilterFn> = {
812
884
  upper: (v) => String(v).toUpperCase(),
813
885
  lower: (v) => String(v).toLowerCase(),
814
886
  capitalize: (v) => { const s = String(v); return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase(); },
815
- title: (v) => String(v).replace(/\b\w/g, c => c.toUpperCase()),
887
+ title: (v) => String(v).replace(TITLE_WORD_RE, c => c.toUpperCase()),
816
888
  trim: (v) => String(v).trim(),
817
- ltrim: (v) => String(v).replace(/^\s+/, ""),
818
- rtrim: (v) => String(v).replace(/\s+$/, ""),
889
+ ltrim: (v) => String(v).replace(LEADING_WS_RE, ""),
890
+ rtrim: (v) => String(v).replace(TRAILING_WS_RE, ""),
819
891
  length: (v) => {
820
892
  if (Array.isArray(v)) return v.length;
821
893
  if (typeof v === "string") return v.length;
@@ -843,7 +915,7 @@ const BUILTIN_FILTERS: Record<string, FilterFn> = {
843
915
  safe: (v) => v,
844
916
  escape: (v) => htmlEscape(String(v)),
845
917
  e: (v) => htmlEscape(String(v)),
846
- striptags: (v) => String(v).replace(/<[^>]+>/g, ""),
918
+ striptags: (v) => String(v).replace(STRIP_TAGS_RE, ""),
847
919
  nl2br: (v) => String(v).replace(/\n/g, "<br>\n"),
848
920
  abs: (v) => typeof v === "number" ? Math.abs(v) : v,
849
921
  round: (v, decimals) => {
@@ -934,7 +1006,7 @@ const BUILTIN_FILTERS: Record<string, FilterFn> = {
934
1006
  let s = String(v);
935
1007
  // Simple %s / %d replacement like Python's % operator
936
1008
  let idx = 0;
937
- s = s.replace(/%[sd]/g, () => {
1009
+ s = s.replace(FORMAT_RE, () => {
938
1010
  const val = idx < args.length ? String(args[idx]) : "";
939
1011
  idx++;
940
1012
  return val;
@@ -1236,20 +1308,20 @@ export class Frond {
1236
1308
  } else if (ttype === "VAR") {
1237
1309
  const [content, stripB, stripA] = stripTag(raw);
1238
1310
  if (stripB && output.length > 0) {
1239
- output[output.length - 1] = output[output.length - 1].replace(/\s+$/, "");
1311
+ output[output.length - 1] = output[output.length - 1].replace(TRAILING_WS_RE, "");
1240
1312
  }
1241
1313
 
1242
1314
  const result = this.evalVar(content, context);
1243
1315
  output.push(result !== null && result !== undefined ? String(result) : "");
1244
1316
 
1245
1317
  if (stripA && i + 1 < tokens.length && tokens[i + 1][0] === "TEXT") {
1246
- tokens[i + 1] = ["TEXT", tokens[i + 1][1].replace(/^\s+/, "")];
1318
+ tokens[i + 1] = ["TEXT", tokens[i + 1][1].replace(LEADING_WS_RE, "")];
1247
1319
  }
1248
1320
  i++;
1249
1321
  } else if (ttype === "BLOCK") {
1250
1322
  const [content, stripB, stripA] = stripTag(raw);
1251
1323
  if (stripB && output.length > 0) {
1252
- output[output.length - 1] = output[output.length - 1].replace(/\s+$/, "");
1324
+ output[output.length - 1] = output[output.length - 1].replace(TRAILING_WS_RE, "");
1253
1325
  }
1254
1326
 
1255
1327
  const parts = content.split(/\s+/);
@@ -1257,7 +1329,7 @@ export class Frond {
1257
1329
 
1258
1330
  // Apply stripA before handlers consume body tokens
1259
1331
  if (stripA && i + 1 < tokens.length && tokens[i + 1][0] === "TEXT") {
1260
- tokens[i + 1] = ["TEXT", tokens[i + 1][1].replace(/^\s+/, "")];
1332
+ tokens[i + 1] = ["TEXT", tokens[i + 1][1].replace(LEADING_WS_RE, "")];
1261
1333
  }
1262
1334
 
1263
1335
  if (tag === "if") {
@@ -1319,7 +1391,7 @@ export class Frond {
1319
1391
  }
1320
1392
 
1321
1393
  if (stripA && i < tokens.length && tokens[i][0] === "TEXT") {
1322
- tokens[i] = ["TEXT", tokens[i][1].replace(/^\s+/, "")];
1394
+ tokens[i] = ["TEXT", tokens[i][1].replace(LEADING_WS_RE, "")];
1323
1395
  }
1324
1396
  } else {
1325
1397
  i++;
@@ -1378,7 +1450,7 @@ export class Frond {
1378
1450
  // The filter name may include a trailing comparison operator,
1379
1451
  // e.g. "length != 1". Extract the real filter name and the
1380
1452
  // comparison suffix, apply the filter, then evaluate the comparison.
1381
- const m = fname.match(/^(\w+)\s*(!=|==|>=|<=|>|<)\s*(.+)$/);
1453
+ const m = fname.match(FILTER_COMPARISON_RE);
1382
1454
  if (m) {
1383
1455
  const realFilter = m[1];
1384
1456
  const op = m[2];
@@ -1435,6 +1507,40 @@ export class Frond {
1435
1507
  }
1436
1508
  }
1437
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
+
1438
1544
  const fn = this.filters[fname];
1439
1545
  if (fn) {
1440
1546
  value = fn(value, ...args);
@@ -1480,25 +1586,25 @@ export class Frond {
1480
1586
  } else if (tag === "endif" && depth === 0) {
1481
1587
  // Strip trailing whitespace from last body token if endif has strip_before
1482
1588
  if (tagStripB && currentTokens.length > 0 && currentTokens[currentTokens.length - 1][0] === "TEXT") {
1483
- currentTokens[currentTokens.length - 1] = ["TEXT", currentTokens[currentTokens.length - 1][1].replace(/\s+$/, "")];
1589
+ currentTokens[currentTokens.length - 1] = ["TEXT", currentTokens[currentTokens.length - 1][1].replace(TRAILING_WS_RE, "")];
1484
1590
  }
1485
1591
  branches.push([currentCond, currentTokens]);
1486
1592
  // Apply stripA on token after endif
1487
1593
  if (tagStripA && i + 1 < tokens.length && tokens[i + 1][0] === "TEXT") {
1488
- tokens[i + 1] = ["TEXT", tokens[i + 1][1].replace(/^\s+/, "")];
1594
+ tokens[i + 1] = ["TEXT", tokens[i + 1][1].replace(LEADING_WS_RE, "")];
1489
1595
  }
1490
1596
  i++;
1491
1597
  break;
1492
1598
  } else if ((tag === "elseif" || tag === "elif") && depth === 0) {
1493
1599
  if (tagStripB && currentTokens.length > 0 && currentTokens[currentTokens.length - 1][0] === "TEXT") {
1494
- currentTokens[currentTokens.length - 1] = ["TEXT", currentTokens[currentTokens.length - 1][1].replace(/\s+$/, "")];
1600
+ currentTokens[currentTokens.length - 1] = ["TEXT", currentTokens[currentTokens.length - 1][1].replace(TRAILING_WS_RE, "")];
1495
1601
  }
1496
1602
  branches.push([currentCond, currentTokens]);
1497
1603
  currentCond = tagContent.slice(tag.length).trim();
1498
1604
  currentTokens = [];
1499
1605
  } else if (tag === "else" && depth === 0) {
1500
1606
  if (tagStripB && currentTokens.length > 0 && currentTokens[currentTokens.length - 1][0] === "TEXT") {
1501
- currentTokens[currentTokens.length - 1] = ["TEXT", currentTokens[currentTokens.length - 1][1].replace(/\s+$/, "")];
1607
+ currentTokens[currentTokens.length - 1] = ["TEXT", currentTokens[currentTokens.length - 1][1].replace(TRAILING_WS_RE, "")];
1502
1608
  }
1503
1609
  branches.push([currentCond, currentTokens]);
1504
1610
  currentCond = null; // else branch
@@ -1514,7 +1620,7 @@ export class Frond {
1514
1620
 
1515
1621
  // Evaluate branches
1516
1622
  for (const [cond, branchTokens] of branches) {
1517
- if (cond === null || evalComparison(cond, context)) {
1623
+ if (cond === null || evalComparison(cond, context, this.evalVarRaw.bind(this))) {
1518
1624
  return [this.renderTokens([...branchTokens], context), i];
1519
1625
  }
1520
1626
  }
@@ -1590,38 +1696,70 @@ export class Frond {
1590
1696
  : Array.isArray(iterable) ? iterable : [];
1591
1697
  const total = items.length;
1592
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
+
1593
1712
  for (let idx = 0; idx < total; idx++) {
1594
1713
  const item = items[idx];
1595
- const loopCtx: Record<string, unknown> = { ...context };
1596
- loopCtx.loop = {
1597
- index: idx + 1,
1598
- index0: idx,
1599
- first: idx === 0,
1600
- last: idx === total - 1,
1601
- length: total,
1602
- revindex: total - idx,
1603
- revindex0: total - idx - 1,
1604
- even: (idx + 1) % 2 === 0,
1605
- odd: (idx + 1) % 2 !== 0,
1606
- };
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 };
1607
1727
 
1608
1728
  if (isDict) {
1609
1729
  const [key, value] = item as [string, unknown];
1610
- if (var2) {
1611
- loopCtx[var1] = key;
1612
- loopCtx[var2] = value;
1613
- } else {
1614
- loopCtx[var1] = key;
1615
- }
1730
+ locals[var1] = key;
1731
+ if (var2) locals[var2] = value;
1616
1732
  } else {
1617
1733
  if (var2) {
1618
- loopCtx[var1] = idx;
1619
- loopCtx[var2] = item;
1734
+ locals[var1] = idx;
1735
+ locals[var2] = item;
1620
1736
  } else {
1621
- loopCtx[var1] = item;
1737
+ locals[var1] = item;
1622
1738
  }
1623
1739
  }
1624
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
+
1625
1763
  output.push(this.renderTokens([...bodyTokens], loopCtx));
1626
1764
  }
1627
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() on the standard sequence, falls through to MAX+1.
408
- * - SQLite/MySQL/MSSQL: uses MAX(pk) + 1.
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 MAX
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
- // No sequencefall through to MAX
520
+ // Sequence doesn't exist try to auto-create it
439
521
  }
440
- }
441
522
 
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;
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