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 +8 -2
- package/package.json +1 -1
- package/packages/frond/src/engine.ts +237 -99
- 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.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 (
|
|
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] || "";
|
|
@@ -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
|
|
328
|
-
if (
|
|
329
|
-
const
|
|
330
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
560
|
+
m = expr.match(NOT_IN_RE);
|
|
497
561
|
if (m) {
|
|
498
|
-
const val =
|
|
499
|
-
const collection =
|
|
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(
|
|
570
|
+
m = expr.match(IN_RE);
|
|
507
571
|
if (m) {
|
|
508
|
-
const val =
|
|
509
|
-
const collection =
|
|
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 =
|
|
531
|
-
const r =
|
|
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 =
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
818
|
-
rtrim: (v) => String(v).replace(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
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
|
-
|
|
1611
|
-
|
|
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
|
-
|
|
1619
|
-
|
|
1734
|
+
locals[var1] = idx;
|
|
1735
|
+
locals[var2] = item;
|
|
1620
1736
|
} else {
|
|
1621
|
-
|
|
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()
|
|
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
|
|