tina4-nodejs 3.13.89 → 3.13.91

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.89)
1
+ # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.91)
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.89 - 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.91 - 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
 
@@ -1244,7 +1244,7 @@ When adding new features, add a corresponding `test/<feature>.test.ts` file.
1244
1244
  ## v3 Features Summary
1245
1245
 
1246
1246
  - **98 built-in features**, zero third-party dependencies
1247
- - **5,916 tests** passing across 189 files (build + typecheck green)
1247
+ - **5,932 tests** passing across 190 files (build + typecheck green)
1248
1248
  - **Race-safe `getNextId()`** with atomic sequence table (`tina4_sequences`) for SQLite/MySQL/MSSQL; PostgreSQL auto-creates sequences
1249
1249
  - **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)
1250
1250
  - **Production server auto-detect**: `npx tina4nodejs serve --production` auto-uses cluster mode
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tina4-nodejs",
3
- "version": "3.13.89",
3
+ "version": "3.13.91",
4
4
  "type": "module",
5
5
  "description": "Tina4 for Node.js/TypeScript - 54 built-in features, zero dependencies",
6
6
  "keywords": [
@@ -17938,7 +17938,7 @@ function extractFunctions(source, filePath, root = ".") {
17938
17938
  if (funcName !== null && !NON_FUNCTION_WORDS.has(funcName)) {
17939
17939
  const displayName = funcName === "constructor" && currentClass ? `${currentClass}.constructor` : currentClass !== null && !isTopLevelDecl ? `${currentClass}.${funcName}` : funcName;
17940
17940
  const funcBody = extractFunctionBody(lines, i);
17941
- const funcLoc = funcBody.split("\n").length;
17941
+ const funcLoc = Math.max(1, countLines(funcBody).loc);
17942
17942
  const complexity = cycloMaticComplexity(funcBody);
17943
17943
  const args = argsStr.split(",").map((a) => a.trim().split(":")[0].split("=")[0].replace("?", "").trim()).filter((a) => a && a !== "this");
17944
17944
  functions.push({
@@ -17954,6 +17954,24 @@ function extractFunctions(source, filePath, root = ".") {
17954
17954
  currentClass = null;
17955
17955
  }
17956
17956
  }
17957
+ return chargeNestedComplexityToTheNestedFunction(functions);
17958
+ }
17959
+ function chargeNestedComplexityToTheNestedFunction(functions) {
17960
+ if (functions.length < 2) return functions;
17961
+ const lastLine = (f) => f.line + Math.max(1, f.loc) - 1;
17962
+ const contains = (outer, inner) => inner.line > outer.line && lastLine(inner) <= lastLine(outer);
17963
+ const raw = functions.map((f) => f.complexity);
17964
+ functions.forEach((outer, i) => {
17965
+ let subtract = 0;
17966
+ functions.forEach((inner, j) => {
17967
+ if (i === j || !contains(outer, inner)) return;
17968
+ const nestedDeeper = functions.some(
17969
+ (mid, k) => k !== i && k !== j && contains(outer, mid) && contains(mid, inner)
17970
+ );
17971
+ if (!nestedDeeper) subtract += raw[j] - 1;
17972
+ });
17973
+ outer.complexity = Math.max(1, raw[i] - subtract);
17974
+ });
17957
17975
  return functions;
17958
17976
  }
17959
17977
  function extractFunctionBody(lines, startLine) {
@@ -38761,6 +38779,11 @@ function parseFields(fieldsStr) {
38761
38779
  }
38762
38780
  return result;
38763
38781
  }
38782
+ var DEFAULT_FIELDS = [["name", "string"]];
38783
+ function fieldsOrDefault(fieldsStr) {
38784
+ const parsed = parseFields(fieldsStr);
38785
+ return parsed.length > 0 ? parsed : DEFAULT_FIELDS.map(([f, t]) => [f, t]);
38786
+ }
38764
38787
  function parseCliArgs(args) {
38765
38788
  const booleanFlags = /* @__PURE__ */ new Set([
38766
38789
  "no-browser",
@@ -38876,7 +38899,7 @@ async function generate2(what, name, extraArgs = []) {
38876
38899
  }
38877
38900
  }
38878
38901
  function generateModel(name, flags, emitTest = true) {
38879
- const fields = parseFields(flags.fields || "");
38902
+ const fields = fieldsOrDefault(flags.fields || "");
38880
38903
  const table2 = toTableName(name);
38881
38904
  const dir = resolve29("src/models");
38882
38905
  ensureDir(dir);
@@ -38884,13 +38907,9 @@ function generateModel(name, flags, emitTest = true) {
38884
38907
  const fieldLines = [
38885
38908
  ` id: { type: "integer" as const, primaryKey: true, autoIncrement: true },`
38886
38909
  ];
38887
- if (fields.length > 0) {
38888
- for (const [fname, ftype] of fields) {
38889
- const info = FIELD_TYPE_MAP[ftype] || FIELD_TYPE_MAP.string;
38890
- fieldLines.push(` ${fname}: { type: ${info.orm} as const },`);
38891
- }
38892
- } else {
38893
- fieldLines.push(` name: { type: "string" as const },`);
38910
+ for (const [fname, ftype] of fields) {
38911
+ const info = FIELD_TYPE_MAP[ftype] || FIELD_TYPE_MAP.string;
38912
+ fieldLines.push(` ${fname}: { type: ${info.orm} as const },`);
38894
38913
  }
38895
38914
  fieldLines.push(` created_at: { type: "datetime" as const },`);
38896
38915
  const content = `import { BaseModel } from "tina4-nodejs/orm";
@@ -38904,7 +38923,7 @@ ${fieldLines.join("\n")}
38904
38923
  `;
38905
38924
  writeFileSafe(path8, content);
38906
38925
  if (!flags["no-migration"]) {
38907
- generateMigration(`create_${table2}`, flags, fields.length > 0 ? fields : void 0, table2, false);
38926
+ generateMigration(`create_${table2}`, flags, fields, table2, false);
38908
38927
  }
38909
38928
  if (emitTest) emitModelTest(name, table2, fields);
38910
38929
  }
@@ -38975,7 +38994,12 @@ ${extend(
38975
38994
  "validate / business rules before persist",
38976
38995
  `e.g. reject invalid input; ground: tina4_context("validate before create", "nodejs")`
38977
38996
  )} const item = new ${model}(req.body as Record<string, unknown>);
38978
- await item.save();
38997
+ // save() returns false on failure rather than throwing - check it, or a failed
38998
+ // write is reported to the client as a 201 carrying unsaved data.
38999
+ if ((await item.save()) === false) {
39000
+ res.json({ error: "Could not create ${singular}" }, 400);
39001
+ return;
39002
+ }
38979
39003
  res.json({ data: item.toObject() }, 201);
38980
39004
  }
38981
39005
  `
@@ -39055,7 +39079,12 @@ ${extend(
39055
39079
  "guard which fields / who may update",
39056
39080
  `e.g. enforce ownership; ground: tina4_context("authorize update", "nodejs")`
39057
39081
  )} Object.assign(item, req.body as Record<string, unknown>);
39058
- await item.save();
39082
+ // save() returns false on failure rather than throwing - check it, or a failed
39083
+ // write is reported to the client as a 200 carrying unsaved data.
39084
+ if ((await item.save()) === false) {
39085
+ res.json({ error: "Could not update ${singular}" }, 400);
39086
+ return;
39087
+ }
39059
39088
  res.json({ data: item.toObject() });
39060
39089
  }
39061
39090
  `
@@ -39362,7 +39391,7 @@ void test${titleName};
39362
39391
  writeFileSafe(path8, content);
39363
39392
  }
39364
39393
  function generateForm(name, flags) {
39365
- const fields = parseFields(flags.fields || "");
39394
+ const fields = fieldsOrDefault(flags.fields || "");
39366
39395
  const table2 = toTableName(name);
39367
39396
  const routeName = toPlural(table2);
39368
39397
  const inputTypes = {
@@ -39382,9 +39411,8 @@ function generateForm(name, flags) {
39382
39411
  const dir = resolve29("src/templates/forms");
39383
39412
  ensureDir(dir);
39384
39413
  const path8 = join33(dir, `${table2}.twig`);
39385
- const fieldEntries = fields.length > 0 ? fields : [["name", "string"]];
39386
39414
  let fieldHtml = "";
39387
- for (const [fname, ftype] of fieldEntries) {
39415
+ for (const [fname, ftype] of fields) {
39388
39416
  const itype = inputTypes[ftype] || "text";
39389
39417
  const label = fname.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
39390
39418
  const step = ["float", "numeric", "decimal"].includes(ftype) ? ' step="0.01"' : "";
@@ -39428,10 +39456,10 @@ function generateForm(name, flags) {
39428
39456
  writeFileSafe(path8, content);
39429
39457
  }
39430
39458
  function generateView(name, flags) {
39431
- const fields = parseFields(flags.fields || "");
39459
+ const fields = fieldsOrDefault(flags.fields || "");
39432
39460
  const table2 = toTableName(name);
39433
39461
  const routeName = toPlural(table2);
39434
- const cols = fields.length > 0 ? fields.map(([f]) => f) : ["name"];
39462
+ const cols = fields.map(([f]) => f);
39435
39463
  const dir = resolve29("src/templates/pages");
39436
39464
  ensureDir(dir);
39437
39465
  const listPath = join33(dir, `${routeName}.twig`);
@@ -39946,7 +39974,7 @@ function sampleLiteral(fieldType) {
39946
39974
  }
39947
39975
  }
39948
39976
  function emitModelTest(model, table2, fields) {
39949
- const flds = fields.length > 0 ? fields : [["name", "string"]];
39977
+ const flds = fields.length > 0 ? fields : DEFAULT_FIELDS.map(([f, t]) => [f, t]);
39950
39978
  const payload = flds.map(([f, t]) => `${f}: ${sampleLiteral(t)}`).join(", ");
39951
39979
  const stringField = flds.find(([, t]) => ["string", "str", "text"].includes((t || "string").toLowerCase()))?.[0];
39952
39980
  const valueAssert = stringField ? `
@@ -115,6 +115,21 @@ export function parseFields(fieldsStr: string): Array<[string, string]> {
115
115
  return result;
116
116
  }
117
117
 
118
+ // Called without --fields, the generators fall back to a single `name` string
119
+ // column. That default MUST be materialised here, in one place, and then flow
120
+ // into the model, the migration, the form, the view and the test alike. It used
121
+ // to live only inside the model template, so `generate model X` / `generate
122
+ // crud X` wrote a model declaring `name` while the migration - built from the
123
+ // parsed field list, which was empty - created only id + created_at. The first
124
+ // write then failed with "table x has no column named name".
125
+ export const DEFAULT_FIELDS: ReadonlyArray<[string, string]> = [["name", "string"]];
126
+
127
+ /** Parsed --fields, or the default single `name` column when none given. */
128
+ export function fieldsOrDefault(fieldsStr: string): Array<[string, string]> {
129
+ const parsed = parseFields(fieldsStr);
130
+ return parsed.length > 0 ? parsed : DEFAULT_FIELDS.map(([f, t]) => [f, t] as [string, string]);
131
+ }
132
+
118
133
  export function parseCliArgs(args: string[]): { flags: Record<string, string | boolean>; positional: string[] } {
119
134
  // Boolean-only flags that never take a value argument.
120
135
  const booleanFlags = new Set([
@@ -290,7 +305,7 @@ export async function generate(what: string, name: string, extraArgs: string[] =
290
305
  // ── Model ───────────────────────────────────────────────────────────
291
306
 
292
307
  function generateModel(name: string, flags: Record<string, string | boolean>, emitTest = true): void {
293
- const fields = parseFields((flags.fields as string) || "");
308
+ const fields = fieldsOrDefault((flags.fields as string) || "");
294
309
  const table = toTableName(name);
295
310
  const dir = resolve("src/models");
296
311
  ensureDir(dir);
@@ -300,13 +315,9 @@ function generateModel(name: string, flags: Record<string, string | boolean>, em
300
315
  const fieldLines: string[] = [
301
316
  ` id: { type: "integer" as const, primaryKey: true, autoIncrement: true },`,
302
317
  ];
303
- if (fields.length > 0) {
304
- for (const [fname, ftype] of fields) {
305
- const info = FIELD_TYPE_MAP[ftype] || FIELD_TYPE_MAP.string;
306
- fieldLines.push(` ${fname}: { type: ${info.orm} as const },`);
307
- }
308
- } else {
309
- fieldLines.push(` name: { type: "string" as const },`);
318
+ for (const [fname, ftype] of fields) {
319
+ const info = FIELD_TYPE_MAP[ftype] || FIELD_TYPE_MAP.string;
320
+ fieldLines.push(` ${fname}: { type: ${info.orm} as const },`);
310
321
  }
311
322
  fieldLines.push(` created_at: { type: "datetime" as const },`);
312
323
 
@@ -328,7 +339,11 @@ ${fieldLines.join("\n")}
328
339
  // (below) proves the schema through the real ORM, so the migration sub-call
329
340
  // does NOT also co-emit a migration test (emitTest=false).
330
341
  if (!flags["no-migration"]) {
331
- generateMigration(`create_${table}`, flags, fields.length > 0 ? fields : undefined, table, false);
342
+ // Always hand over the RESOLVED field list. Passing `undefined` when the
343
+ // parsed list was empty made the migration fall back to its own
344
+ // parseFields() - also empty - so the table got only id + created_at while
345
+ // the model above declared `name`, and the first write 500'd.
346
+ generateMigration(`create_${table}`, flags, fields, table, false);
332
347
  }
333
348
 
334
349
  // Co-emit a real SQLite roundtrip test next to the model. Composite
@@ -417,7 +432,12 @@ ${modelImportBase}${secureOptOut(isPublic)}export const meta = { summary: "Creat
417
432
  export default async function (req: Tina4Request, res: Tina4Response) {
418
433
  ${extend("validate / business rules before persist",
419
434
  `e.g. reject invalid input; ground: tina4_context("validate before create", "nodejs")`)} const item = new ${model}(req.body as Record<string, unknown>);
420
- await item.save();
435
+ // save() returns false on failure rather than throwing - check it, or a failed
436
+ // write is reported to the client as a 201 carrying unsaved data.
437
+ if ((await item.save()) === false) {
438
+ res.json({ error: "Could not create ${singular}" }, 400);
439
+ return;
440
+ }
421
441
  res.json({ data: item.toObject() }, 201);
422
442
  }
423
443
  `,
@@ -499,7 +519,12 @@ export default async function (req: Tina4Request, res: Tina4Response) {
499
519
  }
500
520
  ${extend("guard which fields / who may update",
501
521
  `e.g. enforce ownership; ground: tina4_context("authorize update", "nodejs")`)} Object.assign(item, req.body as Record<string, unknown>);
502
- await item.save();
522
+ // save() returns false on failure rather than throwing - check it, or a failed
523
+ // write is reported to the client as a 200 carrying unsaved data.
524
+ if ((await item.save()) === false) {
525
+ res.json({ error: "Could not update ${singular}" }, 400);
526
+ return;
527
+ }
503
528
  res.json({ data: item.toObject() });
504
529
  }
505
530
  `,
@@ -873,7 +898,7 @@ void test${titleName};
873
898
  // ── Form ────────────────────────────────────────────────────────────
874
899
 
875
900
  function generateForm(name: string, flags: Record<string, string | boolean>): void {
876
- const fields = parseFields((flags.fields as string) || "");
901
+ const fields = fieldsOrDefault((flags.fields as string) || "");
877
902
  const table = toTableName(name);
878
903
  const routeName = toPlural(table);
879
904
 
@@ -890,9 +915,8 @@ function generateForm(name: string, flags: Record<string, string | boolean>): vo
890
915
  const path = join(dir, `${table}.twig`);
891
916
 
892
917
  // Build form fields
893
- const fieldEntries = fields.length > 0 ? fields : [["name", "string"] as [string, string]];
894
918
  let fieldHtml = "";
895
- for (const [fname, ftype] of fieldEntries) {
919
+ for (const [fname, ftype] of fields) {
896
920
  const itype = inputTypes[ftype] || "text";
897
921
  const label = fname.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
898
922
  const step = ["float", "numeric", "decimal"].includes(ftype) ? ' step="0.01"' : "";
@@ -946,11 +970,11 @@ function generateForm(name: string, flags: Record<string, string | boolean>): vo
946
970
  // ── View ────────────────────────────────────────────────────────────
947
971
 
948
972
  function generateView(name: string, flags: Record<string, string | boolean>): void {
949
- const fields = parseFields((flags.fields as string) || "");
973
+ const fields = fieldsOrDefault((flags.fields as string) || "");
950
974
  const table = toTableName(name);
951
975
  const routeName = toPlural(table);
952
976
 
953
- const cols = fields.length > 0 ? fields.map(([f]) => f) : ["name"];
977
+ const cols = fields.map(([f]) => f);
954
978
 
955
979
  const dir = resolve("src/templates/pages");
956
980
  ensureDir(dir);
@@ -1591,7 +1615,9 @@ function sampleLiteral(fieldType: string): string {
1591
1615
 
1592
1616
  /** model → real SQLite roundtrip (create / read back / missing → null). */
1593
1617
  function emitModelTest(model: string, table: string, fields: Array<[string, string]>): void {
1594
- const flds = fields.length > 0 ? fields : [["name", "string"] as [string, string]];
1618
+ // Reuse the single DEFAULT_FIELDS constant rather than re-stating the literal,
1619
+ // so the co-emitted test can never describe a shape the model does not have.
1620
+ const flds = fields.length > 0 ? fields : DEFAULT_FIELDS.map(([f, t]) => [f, t] as [string, string]);
1595
1621
  const payload = flds.map(([f, t]) => `${f}: ${sampleLiteral(t)}`).join(", ");
1596
1622
  const stringField = flds.find(([, t]) => ["string", "str", "text"].includes((t || "string").toLowerCase()))?.[0];
1597
1623
  const valueAssert = stringField
@@ -17937,7 +17937,7 @@ function extractFunctions(source, filePath, root = ".") {
17937
17937
  if (funcName !== null && !NON_FUNCTION_WORDS.has(funcName)) {
17938
17938
  const displayName = funcName === "constructor" && currentClass ? `${currentClass}.constructor` : currentClass !== null && !isTopLevelDecl ? `${currentClass}.${funcName}` : funcName;
17939
17939
  const funcBody = extractFunctionBody(lines, i);
17940
- const funcLoc = funcBody.split("\n").length;
17940
+ const funcLoc = Math.max(1, countLines(funcBody).loc);
17941
17941
  const complexity = cycloMaticComplexity(funcBody);
17942
17942
  const args = argsStr.split(",").map((a) => a.trim().split(":")[0].split("=")[0].replace("?", "").trim()).filter((a) => a && a !== "this");
17943
17943
  functions.push({
@@ -17953,6 +17953,24 @@ function extractFunctions(source, filePath, root = ".") {
17953
17953
  currentClass = null;
17954
17954
  }
17955
17955
  }
17956
+ return chargeNestedComplexityToTheNestedFunction(functions);
17957
+ }
17958
+ function chargeNestedComplexityToTheNestedFunction(functions) {
17959
+ if (functions.length < 2) return functions;
17960
+ const lastLine = (f) => f.line + Math.max(1, f.loc) - 1;
17961
+ const contains = (outer, inner) => inner.line > outer.line && lastLine(inner) <= lastLine(outer);
17962
+ const raw = functions.map((f) => f.complexity);
17963
+ functions.forEach((outer, i) => {
17964
+ let subtract = 0;
17965
+ functions.forEach((inner, j) => {
17966
+ if (i === j || !contains(outer, inner)) return;
17967
+ const nestedDeeper = functions.some(
17968
+ (mid, k) => k !== i && k !== j && contains(outer, mid) && contains(mid, inner)
17969
+ );
17970
+ if (!nestedDeeper) subtract += raw[j] - 1;
17971
+ });
17972
+ outer.complexity = Math.max(1, raw[i] - subtract);
17973
+ });
17956
17974
  return functions;
17957
17975
  }
17958
17976
  function extractFunctionBody(lines, startLine) {