tina4-nodejs 3.13.25 → 3.13.27

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.13.25)
1
+ # CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.13.27)
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.13.25 — The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
7
+ Tina4 for Node.js/TypeScript v3.13.27 — The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. 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
 
@@ -575,7 +575,10 @@ db.delete(table, filter?, params?): DatabaseWriteResult
575
575
  db.getLastId(): string | number | null
576
576
  db.getError(): string | null
577
577
 
578
- // Transactions — autoCommit defaults to OFF; set TINA4_AUTOCOMMIT=true to enable
578
+ // Transactions — autoCommit defaults to ON: a standalone write commits on its
579
+ // own connection (durable + visible across the pool); inside startTransaction()
580
+ // the per-statement commit is suppressed so the transaction stays atomic. Set
581
+ // TINA4_AUTOCOMMIT=false for strict manual-commit mode.
579
582
  db.startTransaction(): void
580
583
  db.commit(): void
581
584
  db.rollback(): void
@@ -1114,7 +1117,7 @@ When adding new features, add a corresponding `test/<feature>.test.ts` file.
1114
1117
  ## v3 Features Summary
1115
1118
 
1116
1119
  - **45 built-in features**, zero third-party dependencies
1117
- - **3,804 tests** passing across all modules
1120
+ - **3,752 tests** passing across all modules
1118
1121
  - **Race-safe `getNextId()`** with atomic sequence table (`tina4_sequences`) for SQLite/MySQL/MSSQL; PostgreSQL auto-creates sequences
1119
1122
  - **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)
1120
1123
  - **Production server auto-detect**: `npx tina4nodejs serve --production` auto-uses cluster mode
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
 
5
5
 
6
- "version": "3.13.25",
6
+ "version": "3.13.27",
7
7
 
8
8
  "type": "module",
9
9
  "description": "Tina4 for Node.js/TypeScript \u2014 54 built-in features, zero dependencies",
@@ -156,7 +156,10 @@ const FILTER_WITH_ARGS_RE = /^(\w+)\s*\(([\s\S]*)\)$/;
156
156
  const FILTER_COMPARISON_RE = /^(\w+)\s*(!=|==|>=|<=|>|<)\s*(.+)$/;
157
157
  const TITLE_WORD_RE = /\b\w/g;
158
158
  const STRIP_TAGS_RE = /<[^>]+>/g;
159
- const FORMAT_RE = /%[sd]/g;
159
+ // printf-style conversions: %%, plus %[flags][width][.precision]type for the
160
+ // common types. Matches PHP sprintf / Python % / Ruby format so the `format`
161
+ // filter renders e.g. `{{ '%.2f' | format(n) }}` as "3.14" across all engines.
162
+ const FORMAT_RE = /%%|%([-+ 0]*)(\d+)?(?:\.(\d+))?([sdifFeEgGxXob])/g;
160
163
  const LEADING_WS_RE = /^\s+/;
161
164
  const TRAILING_WS_RE = /\s+$/;
162
165
  const THOUSANDS_RE = /\B(?=(\d{3})+(?!\d))/g;
@@ -164,7 +167,7 @@ const THOUSANDS_RE = /\B(?=(\d{3})+(?!\d))/g;
164
167
  // ── Caches (module level) ─────────────────────────────────────
165
168
 
166
169
  /** Cache for parsed filter chains: expr string -> [variable, filters] */
167
- const filterChainCache = new Map<string, [string, [string, string[]][]]>();
170
+ const filterChainCache = new Map<string, [string, [string, unknown[]][]]>();
168
171
 
169
172
  /** Cache for parsed dotted/bracket paths: expr string -> [parts, fromBracket] */
170
173
  const pathParseCache = new Map<string, [string[], boolean[]]>();
@@ -896,7 +899,7 @@ function splitFilterNameAndPath(fname: string): [string, string] {
896
899
  return [fname, ""];
897
900
  }
898
901
 
899
- function parseFilterChain(expr: string): [string, [string, string[]][]] {
902
+ function parseFilterChain(expr: string): [string, [string, unknown[]][]] {
900
903
  // Check cache first
901
904
  const cached = filterChainCache.get(expr);
902
905
  if (cached) return cached;
@@ -931,7 +934,7 @@ function parseFilterChain(expr: string): [string, [string, string[]][]] {
931
934
  if (current) parts.push(current);
932
935
 
933
936
  const variable = parts[0].trim();
934
- const filters: [string, string[]][] = [];
937
+ const filters: [string, unknown[]][] = [];
935
938
 
936
939
  for (let i = 1; i < parts.length; i++) {
937
940
  const f = parts[i].trim();
@@ -946,18 +949,48 @@ function parseFilterChain(expr: string): [string, [string, string[]][]] {
946
949
  }
947
950
  }
948
951
 
949
- const result: [string, [string, string[]][]] = [variable, filters];
952
+ const result: [string, [string, unknown[]][]] = [variable, filters];
950
953
  filterChainCache.set(expr, result);
951
954
  return result;
952
955
  }
953
956
 
954
- function parseArgs(raw: string): string[] {
955
- const args: string[] = [];
957
+ /**
958
+ * An UNQUOTED bareword filter argument — a variable reference (or dotted/bracket
959
+ * path), not a literal. Resolved against the render context at apply-time so
960
+ * `{{ '%.2f' | format(price) }}` binds `price` to its value. Quoted literals
961
+ * (`default('fb')`) stay plain strings and are never resolved.
962
+ */
963
+ class VarRef {
964
+ constructor(public name: string) {}
965
+ }
966
+
967
+ /** Coerce an unquoted arg token to a typed value, or a VarRef if it's a name. */
968
+ function coerceArg(t: string): unknown {
969
+ if (/^-?\d+$/.test(t)) return parseInt(t, 10);
970
+ if (/^-?\d*\.\d+$/.test(t)) return parseFloat(t);
971
+ if (t === "true") return true;
972
+ if (t === "false") return false;
973
+ if (t === "null" || t === "none" || t === "nil") return null;
974
+ if ((t.startsWith("{") && t.endsWith("}")) || (t.startsWith("[") && t.endsWith("]"))) {
975
+ try { return JSON.parse(t); } catch { /* not JSON — fall through */ }
976
+ }
977
+ return new VarRef(t); // bareword / path → resolve at apply-time
978
+ }
979
+
980
+ function parseArgs(raw: string): unknown[] {
981
+ const args: unknown[] = [];
956
982
  let current = "";
957
983
  let inQuote: string | null = null;
958
984
  let wasQuoted = false;
959
985
  let depth = 0;
960
986
 
987
+ const flush = (): void => {
988
+ if (wasQuoted) args.push(current);
989
+ else { const t = current.trim(); if (t !== "") args.push(coerceArg(t)); }
990
+ current = "";
991
+ wasQuoted = false;
992
+ };
993
+
961
994
  for (const ch of raw) {
962
995
  if (inQuote) {
963
996
  if (ch === inQuote) {
@@ -976,19 +1009,10 @@ function parseArgs(raw: string): string[] {
976
1009
  }
977
1010
  if (ch === "(") { depth++; current += ch; continue; }
978
1011
  if (ch === ")") { depth--; current += ch; continue; }
979
- if (ch === "," && depth === 0) {
980
- args.push(wasQuoted ? current : current.trim());
981
- current = "";
982
- wasQuoted = false;
983
- continue;
984
- }
1012
+ if (ch === "," && depth === 0) { flush(); continue; }
985
1013
  current += ch;
986
1014
  }
987
-
988
- const final = wasQuoted ? current : current.trim();
989
- if (final !== "" || wasQuoted) {
990
- args.push(final);
991
- }
1015
+ flush();
992
1016
 
993
1017
  return args;
994
1018
  }
@@ -1134,7 +1158,7 @@ const BUILTIN_FILTERS: Record<string, FilterFn> = {
1134
1158
  escape: (v) => htmlEscape(String(v)),
1135
1159
  e: (v) => htmlEscape(String(v)),
1136
1160
  striptags: (v) => String(v).replace(STRIP_TAGS_RE, ""),
1137
- nl2br: (v) => String(v).replace(/\n/g, "<br>\n"),
1161
+ nl2br: (v) => new SafeString(htmlEscape(String(v)).replace(/\n/g, "<br />\n")),
1138
1162
  abs: (v) => typeof v === "number" ? Math.abs(v) : v,
1139
1163
  round: (v, decimals) => {
1140
1164
  const d = decimals !== undefined ? parseInt(String(decimals), 10) : 0;
@@ -1221,15 +1245,38 @@ const BUILTIN_FILTERS: Record<string, FilterFn> = {
1221
1245
  },
1222
1246
  url_encode: (v) => encodeURIComponent(String(v)),
1223
1247
  format: (v, ...args) => {
1224
- let s = String(v);
1225
- // Simple %s / %d replacement like Python's % operator
1226
1248
  let idx = 0;
1227
- s = s.replace(FORMAT_RE, () => {
1228
- const val = idx < args.length ? String(args[idx]) : "";
1229
- idx++;
1230
- return val;
1249
+ return String(v).replace(FORMAT_RE, (m, flags, width, prec, type) => {
1250
+ if (m === "%%") return "%";
1251
+ const arg = args[idx++];
1252
+ const p = prec !== undefined ? parseInt(String(prec), 10) : undefined;
1253
+ let out: string;
1254
+ switch (type) {
1255
+ case "s": out = String(arg ?? ""); break;
1256
+ case "d": case "i": out = String(Math.trunc(Number(arg) || 0)); break;
1257
+ case "f": case "F": out = Number(arg).toFixed(p !== undefined ? p : 6); break;
1258
+ case "e": case "E": {
1259
+ out = Number(arg).toExponential(p !== undefined ? p : 6);
1260
+ if (type === "E") out = out.toUpperCase();
1261
+ break;
1262
+ }
1263
+ case "g": case "G": out = String(Number(arg)); break;
1264
+ case "x": out = Math.trunc(Number(arg) || 0).toString(16); break;
1265
+ case "X": out = Math.trunc(Number(arg) || 0).toString(16).toUpperCase(); break;
1266
+ case "o": out = Math.trunc(Number(arg) || 0).toString(8); break;
1267
+ case "b": out = Math.trunc(Number(arg) || 0).toString(2); break;
1268
+ default: out = String(arg ?? "");
1269
+ }
1270
+ if (width) {
1271
+ const w = parseInt(String(width), 10);
1272
+ if (out.length < w) {
1273
+ const f = String(flags || "");
1274
+ if (f.includes("-")) out = out.padEnd(w, " ");
1275
+ else out = out.padStart(w, f.includes("0") ? "0" : " ");
1276
+ }
1277
+ }
1278
+ return out;
1231
1279
  });
1232
- return s;
1233
1280
  },
1234
1281
  dump: (v) => JSON.stringify(v),
1235
1282
  formToken: (v?: unknown) => _generateFormToken(v != null ? String(v) : ""),
@@ -1868,7 +1915,8 @@ export class Frond {
1868
1915
  private evalVarRaw(expr: string, context: Record<string, unknown>): unknown {
1869
1916
  const [varName, filters] = parseFilterChain(expr);
1870
1917
  let value = evalExpr(varName, context);
1871
- for (const [fname, args] of filters) {
1918
+ for (const [fname, rawArgs] of filters) {
1919
+ const args = rawArgs.map((a) => (a instanceof VarRef ? evalExpr(a.name, context) : a));
1872
1920
  if (fname === "raw" || fname === "safe") continue;
1873
1921
 
1874
1922
  // Filter + property-access chain: `first.groupSummary` — apply
@@ -1944,7 +1992,8 @@ export class Frond {
1944
1992
  let value = evalExpr(varName, context);
1945
1993
 
1946
1994
  let isSafe = false;
1947
- for (const [fname, args] of filters) {
1995
+ for (const [fname, rawArgs] of filters) {
1996
+ const args = rawArgs.map((a) => (a instanceof VarRef ? evalExpr(a.name, context) : a));
1948
1997
  if (fname === "raw" || fname === "safe") {
1949
1998
  isSafe = true;
1950
1999
  continue;
@@ -2020,7 +2069,7 @@ export class Frond {
2020
2069
  // In production this emits an empty SafeString (no leaked state).
2021
2070
  value = renderDump(value);
2022
2071
  continue;
2023
- case "nl2br": value = String(value).replace(/\n/g, "<br>\n"); continue;
2072
+ case "nl2br": value = new SafeString(htmlEscape(String(value)).replace(/\n/g, "<br />\n")); continue;
2024
2073
  case "unique": value = Array.isArray(value) ? [...new Set(value)] : value; continue;
2025
2074
  case "sort": value = Array.isArray(value) ? [...value].sort() : value; continue;
2026
2075
  case "reverse": value = Array.isArray(value) ? [...value].reverse() : String(value).split("").reverse().join(""); continue;
@@ -458,8 +458,17 @@ export class Database {
458
458
  /** Factory for creating new adapters (used by pool) */
459
459
  private adapterFactory: (() => Promise<DatabaseAdapter>) | null = null;
460
460
 
461
- /** Whether to automatically commit after each write operation */
462
- private autoCommit: boolean = process.env.TINA4_AUTOCOMMIT === "true";
461
+ /**
462
+ * Whether a standalone write auto-commits. ON by default — a write made
463
+ * outside an explicit transaction commits on its own connection before
464
+ * returning (so it's durable and visible across pooled connections). Inside
465
+ * startTransaction()/commit()/rollback() the per-statement commit is
466
+ * suppressed, so explicit transactions stay atomic. Set TINA4_AUTOCOMMIT=false
467
+ * for strict manual-commit mode.
468
+ */
469
+ private autoCommit: boolean = ["true", "1", "yes"].includes(
470
+ (process.env.TINA4_AUTOCOMMIT ?? "true").toLowerCase(),
471
+ );
463
472
  private lastError: string | null = null;
464
473
 
465
474
  /** Database engine type (sqlite, postgres, mysql, mssql, firebird) */
@@ -675,7 +684,7 @@ export class Database {
675
684
  try {
676
685
  const adapter = this.getNextAdapter();
677
686
  const result = await adapterExecute(adapter, sql, params);
678
- if (this.autoCommit) {
687
+ if (this.autoCommit && !this.inExplicitTransaction()) {
679
688
  try { await adapterCommit(adapter); } catch { /* no active transaction */ }
680
689
  }
681
690
  this.lastError = null;
@@ -697,7 +706,7 @@ export class Database {
697
706
  const result = (adapter as any).insertAsync
698
707
  ? await (adapter as any).insertAsync(table, data)
699
708
  : adapter.insert(table, data);
700
- if (this.autoCommit) {
709
+ if (this.autoCommit && !this.inExplicitTransaction()) {
701
710
  try { await adapterCommit(adapter); } catch { /* no active transaction */ }
702
711
  }
703
712
  return result;
@@ -709,7 +718,7 @@ export class Database {
709
718
  const result = (adapter as any).updateAsync
710
719
  ? await (adapter as any).updateAsync(table, data, filter ?? {}, params)
711
720
  : adapter.update(table, data, filter ?? {}, params);
712
- if (this.autoCommit) {
721
+ if (this.autoCommit && !this.inExplicitTransaction()) {
713
722
  try { await adapterCommit(adapter); } catch { /* no active transaction */ }
714
723
  }
715
724
  return result;
@@ -721,7 +730,7 @@ export class Database {
721
730
  const result = (adapter as any).deleteAsync
722
731
  ? await (adapter as any).deleteAsync(table, filter ?? {}, params)
723
732
  : adapter.delete(table, filter ?? {}, params);
724
- if (this.autoCommit) {
733
+ if (this.autoCommit && !this.inExplicitTransaction()) {
725
734
  try { await adapterCommit(adapter); } catch { /* no active transaction */ }
726
735
  }
727
736
  return result;
@@ -741,6 +750,16 @@ export class Database {
741
750
  }
742
751
  }
743
752
 
753
+ /**
754
+ * True while an explicit transaction is active on the current async context.
755
+ * startTransaction() pins an adapter into txStore; commit()/rollback() clear
756
+ * it. Standalone writes only auto-commit when this is false, so per-statement
757
+ * commits never break the atomicity of an explicit transaction.
758
+ */
759
+ private inExplicitTransaction(): boolean {
760
+ return !!this.txStore.getStore()?.adapter;
761
+ }
762
+
744
763
  /**
745
764
  * Start a transaction. Pins the adapter to the current async context for
746
765
  * the whole transaction so executes and the final commit/rollback all run