tina4-nodejs 3.10.19 → 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.19)
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.19 — 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.19",
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] || "";
@@ -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(/^([\w.]+)\s*\(([\s\S]*)?\)$/);
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(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;
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(/^(.+?)\s+is\s+not\s+(\w+)(.*)$/);
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(/^(.+?)\s+is\s+(\w+)(.*)$/);
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(/^(.+?)\s+not\s+in\s+(.+)$/);
560
+ m = expr.match(NOT_IN_RE);
520
561
  if (m) {
521
- const val = evalExpr(m[1].trim(), context);
522
- const collection = evalExpr(m[2].trim(), context);
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(/^(.+?)\s+in\s+(.+)$/);
570
+ m = expr.match(IN_RE);
530
571
  if (m) {
531
- const val = evalExpr(m[1].trim(), context);
532
- const collection = evalExpr(m[2].trim(), context);
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 = evalExpr(left, context);
554
- const r = evalExpr(right, context);
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 = evalExpr(expr, context);
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 val = evalExpr(valueExpr, context);
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(/\s*by\s*\(\s*(\d+)\s*\)/);
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(/^(\w+)\s*\(([\s\S]*)\)$/);
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
- return [variable, filters];
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(/\B(?=(\d{3})+(?!\d))/g, ",");
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(/\b\w/g, c => c.toUpperCase()),
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(/^\s+/, ""),
841
- rtrim: (v) => String(v).replace(/\s+$/, ""),
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(/<[^>]+>/g, ""),
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(/%[sd]/g, () => {
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(/\s+$/, "");
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(/^\s+/, "")];
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(/\s+$/, "");
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(/^\s+/, "")];
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(/^\s+/, "")];
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(/^(\w+)\s*(!=|==|>=|<=|>|<)\s*(.+)$/);
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(/\s+$/, "")];
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(/^\s+/, "")];
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(/\s+$/, "")];
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(/\s+$/, "")];
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
- const loopCtx: Record<string, unknown> = { ...context };
1619
- loopCtx.loop = {
1620
- index: idx + 1,
1621
- index0: idx,
1622
- first: idx === 0,
1623
- last: idx === total - 1,
1624
- length: total,
1625
- revindex: total - idx,
1626
- revindex0: total - idx - 1,
1627
- even: (idx + 1) % 2 === 0,
1628
- odd: (idx + 1) % 2 !== 0,
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
- if (var2) {
1634
- loopCtx[var1] = key;
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
- loopCtx[var1] = idx;
1642
- loopCtx[var2] = item;
1734
+ locals[var1] = idx;
1735
+ locals[var2] = item;
1643
1736
  } else {
1644
- loopCtx[var1] = item;
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() 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