tina4-nodejs 3.13.116 → 3.13.117

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
@@ -13,13 +13,13 @@ Even if the skill text is not currently loaded, these are non-negotiable:
13
13
 
14
14
  The full discipline lives in `.claude/skills/tina4-maintainer/SKILL.md`; this block is the always-on floor.
15
15
 
16
- # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.116)
16
+ # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.117)
17
17
 
18
18
  > This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
19
19
 
20
20
  ## What This Project Is
21
21
 
22
- Tina4 for Node.js/TypeScript v3.13.116 - The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
22
+ Tina4 for Node.js/TypeScript v3.13.117 - The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
23
23
 
24
24
  The philosophy: zero ceremony, batteries included, file system as source of truth.
25
25
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tina4-nodejs",
3
- "version": "3.13.116",
3
+ "version": "3.13.117",
4
4
  "type": "module",
5
5
  "description": "Tina4 for Node.js/TypeScript - native TypeScript conventions and shared Tina4 contracts",
6
6
  "keywords": [
@@ -45430,23 +45430,203 @@ var FIELD_TYPE_MAP = {
45430
45430
  blob: { orm: '"string"', sql: "BLOB", defaultVal: "NULL" }
45431
45431
  };
45432
45432
  function ensureDir(dir) {
45433
+ if (__resolution.dryRun) return;
45433
45434
  if (!existsSync33(dir)) {
45434
45435
  mkdirSync23(dir, { recursive: true });
45435
45436
  }
45436
45437
  }
45437
45438
  function writeFileSafe(path8, content) {
45439
+ if (__resolution.dryRun) {
45440
+ return;
45441
+ }
45438
45442
  if (existsSync33(path8)) {
45439
- console.log(` File already exists: ${path8}`);
45443
+ if (!__resolution.jsonMode) console.log(` File already exists: ${path8}`);
45440
45444
  return;
45441
45445
  }
45442
45446
  writeFileSync20(path8, content, "utf-8");
45443
- console.log(` Created ${path8}`);
45447
+ __resolution.actionsTaken.push(`wrote ${path8}`);
45448
+ if (!__resolution.jsonMode) console.log(` Created ${path8}`);
45444
45449
  }
45445
45450
  function toSnake(name) {
45446
45451
  return name.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2").replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
45447
45452
  }
45453
+ var SQL_RESERVED_TABLE_NAMES = /* @__PURE__ */ new Set([
45454
+ "order",
45455
+ "group",
45456
+ "user",
45457
+ "table",
45458
+ "select",
45459
+ "from",
45460
+ "where",
45461
+ "index",
45462
+ "key",
45463
+ "values",
45464
+ "column",
45465
+ "constraint",
45466
+ "check",
45467
+ "default",
45468
+ "primary",
45469
+ "foreign",
45470
+ "references",
45471
+ "unique",
45472
+ "join",
45473
+ "union",
45474
+ "having",
45475
+ "limit",
45476
+ "offset",
45477
+ "desc",
45478
+ "asc",
45479
+ "case",
45480
+ "when",
45481
+ "then",
45482
+ "else",
45483
+ "end",
45484
+ "and",
45485
+ "or",
45486
+ "not",
45487
+ "null",
45488
+ "insert",
45489
+ "update",
45490
+ "delete",
45491
+ "create",
45492
+ "drop",
45493
+ "alter",
45494
+ "grant",
45495
+ "revoke",
45496
+ "commit",
45497
+ "rollback",
45498
+ "view",
45499
+ "trigger",
45500
+ "procedure",
45501
+ "function",
45502
+ "database",
45503
+ "schema",
45504
+ "session",
45505
+ "set",
45506
+ "into",
45507
+ "as",
45508
+ "on",
45509
+ "by",
45510
+ "inner",
45511
+ "outer",
45512
+ "left",
45513
+ "right",
45514
+ "full",
45515
+ "natural",
45516
+ "using",
45517
+ "with",
45518
+ "distinct",
45519
+ "between",
45520
+ "exists",
45521
+ "like",
45522
+ "in",
45523
+ "is",
45524
+ "all",
45525
+ "any",
45526
+ "cross",
45527
+ "add",
45528
+ "row",
45529
+ "rows",
45530
+ "range",
45531
+ "current",
45532
+ "to"
45533
+ ]);
45534
+ function pluralizeReserved(name) {
45535
+ if (name.endsWith("y") && !/[aeiouy]y$/i.test(name)) return name.slice(0, -1) + "ies";
45536
+ if (/(s|x|z|ch|sh)$/.test(name)) return name + "es";
45537
+ return name + "s";
45538
+ }
45448
45539
  function toTableName(name) {
45449
- return toSnake(name);
45540
+ const raw = toSnake(name);
45541
+ if (SQL_RESERVED_TABLE_NAMES.has(raw)) {
45542
+ const safe = pluralizeReserved(raw);
45543
+ recordTransformation({
45544
+ kind: "reserved_word_pluralize",
45545
+ from: raw,
45546
+ to: safe,
45547
+ reason: `SQL reserved word '${raw}' would break CREATE TABLE`,
45548
+ override: `--table ${raw} --quote (requires quoted-identifier mode, not yet implemented)`
45549
+ });
45550
+ return safe;
45551
+ }
45552
+ return raw;
45553
+ }
45554
+ var RESOLUTION_ENVELOPE_VERSION = "generate_v1";
45555
+ var __resolution = {
45556
+ target: "",
45557
+ input: { name: "", fields: null },
45558
+ body: { transformations: [] },
45559
+ actionsTaken: [],
45560
+ dryRun: false,
45561
+ jsonMode: false
45562
+ };
45563
+ function resetResolution(target, input, opts) {
45564
+ __resolution.target = target;
45565
+ __resolution.input = input;
45566
+ __resolution.body = { transformations: [] };
45567
+ __resolution.actionsTaken = [];
45568
+ __resolution.dryRun = opts.dryRun;
45569
+ __resolution.jsonMode = opts.jsonMode;
45570
+ }
45571
+ function recordTransformation(t) {
45572
+ __resolution.body.transformations.push(t);
45573
+ }
45574
+ function currentResolution() {
45575
+ return {
45576
+ command: "generate",
45577
+ target: __resolution.target,
45578
+ input: { ...__resolution.input },
45579
+ resolution: {
45580
+ ...__resolution.body,
45581
+ transformations: [...__resolution.body.transformations]
45582
+ },
45583
+ actions_taken: [...__resolution.actionsTaken],
45584
+ dry_run: __resolution.dryRun
45585
+ };
45586
+ }
45587
+ function setResolutionField(key, value) {
45588
+ __resolution.body[key] = value;
45589
+ }
45590
+ function pushRoute(routePattern) {
45591
+ if (!__resolution.body.routes) __resolution.body.routes = [];
45592
+ __resolution.body.routes.push(routePattern);
45593
+ }
45594
+ function pushTestPath(path8) {
45595
+ if (!__resolution.body.test_paths) __resolution.body.test_paths = [];
45596
+ __resolution.body.test_paths.push(path8);
45597
+ }
45598
+ function printResolution() {
45599
+ if (__resolution.jsonMode) {
45600
+ process.stdout.write(JSON.stringify(currentResolution(), null, 2) + "\n");
45601
+ return;
45602
+ }
45603
+ const b = __resolution.body;
45604
+ const lines = [];
45605
+ lines.push("");
45606
+ lines.push(`Generated ${__resolution.target} ${__resolution.input.name}`);
45607
+ if (b.class_name || b.file_path) {
45608
+ const where = b.file_path ? ` (in ${b.file_path})` : "";
45609
+ lines.push(` class ${b.class_name ?? __resolution.input.name}${where}`);
45610
+ }
45611
+ if (b.table_name) {
45612
+ const t = b.transformations.find((x) => x.kind === "reserved_word_pluralize");
45613
+ const note = t ? ` (auto-pluralized: '${t.from}' is a SQL reserved word)` : "";
45614
+ lines.push(` table ${b.table_name}${note}`);
45615
+ }
45616
+ if (b.routes && b.routes.length) {
45617
+ lines.push(` routes ${b.routes.join(", ")}`);
45618
+ }
45619
+ if (b.migration_path) {
45620
+ lines.push(` migration ${b.migration_path}`);
45621
+ }
45622
+ const reserved = b.transformations.find((t) => t.kind === "reserved_word_pluralize");
45623
+ if (reserved && reserved.from && reserved.override) {
45624
+ lines.push("");
45625
+ lines.push(` To keep the raw name '${reserved.from}' as the table:`);
45626
+ lines.push(` tina4nodejs generate ${__resolution.target} ${__resolution.input.name} ${reserved.override}`);
45627
+ }
45628
+ lines.push("");
45629
+ process.stderr.write(lines.join("\n"));
45450
45630
  }
45451
45631
  function toPlural(name) {
45452
45632
  const lower = name.toLowerCase();
@@ -45488,7 +45668,10 @@ function parseCliArgs(args) {
45488
45668
  "all",
45489
45669
  "clear",
45490
45670
  "public",
45491
- "no-migration"
45671
+ "no-migration",
45672
+ // Resolution transparency (Feature B, 3.13.117): both accept NO value.
45673
+ "json",
45674
+ "dry-run"
45492
45675
  ]);
45493
45676
  const flags = {};
45494
45677
  const positional = [];
@@ -45576,6 +45759,8 @@ async function generate2(what, name, extraArgs = []) {
45576
45759
  console.error(' Options: --fields "name:string,price:float" --model ModelName');
45577
45760
  console.error(" --public open a route's writes (default: secure)");
45578
45761
  console.error(' --every 5m | --cron "\u2026" service schedule');
45762
+ console.error(" --json emit machine-readable resolution envelope on stdout");
45763
+ console.error(" --dry-run report resolution without writing any files");
45579
45764
  process.exit(1);
45580
45765
  }
45581
45766
  const noNameGenerators = /* @__PURE__ */ new Set(["auth"]);
@@ -45584,6 +45769,9 @@ async function generate2(what, name, extraArgs = []) {
45584
45769
  process.exit(1);
45585
45770
  }
45586
45771
  const { flags } = parseCliArgs(extraArgs);
45772
+ const jsonMode = Boolean(flags.json);
45773
+ const dryRun = Boolean(flags["dry-run"]);
45774
+ resetResolution(what, { name, fields: flags.fields ?? null }, { dryRun, jsonMode });
45587
45775
  const spec = GENERATORS[what];
45588
45776
  if (spec) {
45589
45777
  spec.handler(name, flags);
@@ -45592,6 +45780,7 @@ async function generate2(what, name, extraArgs = []) {
45592
45780
  console.error(` Available: ${GENERATOR_LIST}`);
45593
45781
  process.exit(1);
45594
45782
  }
45783
+ printResolution();
45595
45784
  }
45596
45785
  function generateModel(name, flags, emitTest = true) {
45597
45786
  const fields = fieldsOrDefault(flags.fields || "");
@@ -45599,6 +45788,10 @@ function generateModel(name, flags, emitTest = true) {
45599
45788
  const dir = resolve28("src/models");
45600
45789
  ensureDir(dir);
45601
45790
  const path8 = join35(dir, `${name}.ts`);
45791
+ setResolutionField("class_name", name);
45792
+ setResolutionField("table_name", table2);
45793
+ setResolutionField("file_path", `src/models/${name}.ts`);
45794
+ pushTestPath(`tests/${table2}_model.test.ts`);
45602
45795
  const fieldLines = [
45603
45796
  ` id: { type: "integer" as const, primaryKey: true, autoIncrement: true },`
45604
45797
  ];
@@ -45636,6 +45829,11 @@ function generateRoute(name, flags, emitTest = true) {
45636
45829
  const idDir = join35(base, "[id]");
45637
45830
  ensureDir(base);
45638
45831
  ensureDir(idDir);
45832
+ pushRoute(`/api/${routePath}`);
45833
+ pushRoute(`/api/${routePath}/{id}`);
45834
+ if (__resolution.target === "route") {
45835
+ setResolutionField("file_path", `src/routes/api/${routePath}/get.ts`);
45836
+ }
45639
45837
  const table2 = model ? toTableName(model) : "";
45640
45838
  const modelImportBase = model ? `import ${model} from "../../../models/${model}.js";
45641
45839
  ` : "";
@@ -45876,13 +46074,21 @@ function generateMigration(name, flags, fieldsOverride, tableOverride, emitTest
45876
46074
  if (tableOverride) {
45877
46075
  table2 = tableOverride;
45878
46076
  } else {
45879
- table2 = name.replace(/^create_/, "").replace(/^add_/, "").replace(/^drop_/, "");
45880
- table2 = toSnake(table2);
46077
+ const raw = name.replace(/^create_/, "").replace(/^add_/, "").replace(/^drop_/, "");
46078
+ table2 = toTableName(raw);
46079
+ }
46080
+ if (__resolution.target === "migration") {
46081
+ setResolutionField("table_name", table2);
45881
46082
  }
45882
46083
  const fields = fieldsOverride || parseFields(flags.fields || "");
45883
46084
  const isCreate = name.startsWith("create_") || fieldsOverride !== void 0;
45884
46085
  const fileName = `${ts}_${name}.sql`;
45885
46086
  const path8 = join35(dir, fileName);
46087
+ setResolutionField("migration_path", `migrations/${fileName}`);
46088
+ if (__resolution.target === "migration") {
46089
+ setResolutionField("file_path", `migrations/${fileName}`);
46090
+ pushTestPath(`tests/${table2}_migration.test.ts`);
46091
+ }
45886
46092
  let upSql;
45887
46093
  let downSql;
45888
46094
  if (isCreate) {
@@ -45928,6 +46134,11 @@ function generateMiddleware(name, _flags) {
45928
46134
  const dir = resolve28("src/middleware");
45929
46135
  ensureDir(dir);
45930
46136
  const path8 = join35(dir, `${snake}.ts`);
46137
+ if (__resolution.target === "middleware") {
46138
+ setResolutionField("class_name", name);
46139
+ setResolutionField("file_path", `src/middleware/${snake}.ts`);
46140
+ pushTestPath(`tests/${snake}.test.ts`);
46141
+ }
45931
46142
  const content = `import type { Tina4Request, Tina4Response } from "tina4-nodejs";
45932
46143
 
45933
46144
  /**
@@ -46240,7 +46451,7 @@ export default async function (req: Tina4Request, res: Tina4Response) {
46240
46451
  return;
46241
46452
  }
46242
46453
 
46243
- const existing = await User.selectOne("SELECT * FROM user WHERE email = ?", [email]);
46454
+ const existing = await User.selectOne("SELECT * FROM users WHERE email = ?", [email]);
46244
46455
  if (existing) {
46245
46456
  res.json({ error: "Email already registered" }, 409);
46246
46457
  return;
@@ -46271,7 +46482,7 @@ export default async function (req: Tina4Request, res: Tina4Response) {
46271
46482
  return;
46272
46483
  }
46273
46484
 
46274
- const user = await User.selectOne("SELECT * FROM user WHERE email = ?", [email]);
46485
+ const user = await User.selectOne("SELECT * FROM users WHERE email = ?", [email]);
46275
46486
  if (!user || !checkPassword(password, user.toObject().password as string)) {
46276
46487
  res.json({ error: "Invalid credentials" }, 401);
46277
46488
  return;
@@ -47381,7 +47592,15 @@ function buildCommandManifest() {
47381
47592
  if (spec.args && spec.args.length) entry.args = [...spec.args];
47382
47593
  commands.push(entry);
47383
47594
  }
47384
- return { framework: "nodejs", version: readCliVersion(), commands };
47595
+ return {
47596
+ framework: "nodejs",
47597
+ version: readCliVersion(),
47598
+ commands,
47599
+ // Feature B (3.13.117): declare the resolution envelope this framework
47600
+ // emits for `generate <what> --json`. Consumers read this to know which
47601
+ // schema to parse — never hard-code the shape.
47602
+ resolution_contract: { version: "1", envelope: RESOLUTION_ENVELOPE_VERSION }
47603
+ };
47385
47604
  }
47386
47605
  function runCommands(args = []) {
47387
47606
  const manifest = buildCommandManifest();
@@ -6,7 +6,7 @@ import { migrateStatus } from "./commands/migrateStatus.js";
6
6
  import { migrateRollback } from "./commands/migrateRollback.js";
7
7
  import { listRoutes } from "./commands/routes.js";
8
8
  import { runTests } from "./commands/test.js";
9
- import { generate, GENERATORS } from "./commands/generate.js";
9
+ import { generate, GENERATORS, RESOLUTION_ENVELOPE_VERSION } from "./commands/generate.js";
10
10
  import { runSeeds } from "./commands/seed.js";
11
11
  import { queueCommand, QUEUE_SUBCOMMAND_NAMES } from "./commands/queue.js";
12
12
  import { buildImage } from "./commands/build.js";
@@ -85,10 +85,24 @@ export interface CommandManifestEntry {
85
85
  delegated?: boolean;
86
86
  }
87
87
 
88
+ /**
89
+ * A stable, machine-readable pointer to the `generate` resolution envelope
90
+ * this framework speaks. Consumers (the tina4 client, an AI agent, a
91
+ * downstream tool) MUST NOT hard-code an envelope shape — instead they read
92
+ * `resolution_contract.envelope` from this manifest and follow its version.
93
+ * `version` bumps on any breaking key rename or removal; `envelope` is the
94
+ * name of the schema (currently `generate_v1`).
95
+ */
96
+ export interface ResolutionContract {
97
+ version: string;
98
+ envelope: string;
99
+ }
100
+
88
101
  export interface CommandManifest {
89
102
  framework: string;
90
103
  version: string;
91
104
  commands: CommandManifestEntry[];
105
+ resolution_contract: ResolutionContract;
92
106
  }
93
107
 
94
108
  /**
@@ -120,7 +134,15 @@ export function buildCommandManifest(): CommandManifest {
120
134
  if (spec.args && spec.args.length) entry.args = [...spec.args];
121
135
  commands.push(entry);
122
136
  }
123
- return { framework: "nodejs", version: readCliVersion(), commands };
137
+ return {
138
+ framework: "nodejs",
139
+ version: readCliVersion(),
140
+ commands,
141
+ // Feature B (3.13.117): declare the resolution envelope this framework
142
+ // emits for `generate <what> --json`. Consumers read this to know which
143
+ // schema to parse — never hard-code the shape.
144
+ resolution_contract: { version: "1", envelope: RESOLUTION_ENVELOPE_VERSION },
145
+ };
124
146
  }
125
147
 
126
148
  /**
@@ -55,18 +55,25 @@ const FIELD_TYPE_MAP: Record<string, { orm: string; sql: string; defaultVal: str
55
55
  // ── Helpers ─────────────────────────────────────────────────────────
56
56
 
57
57
  function ensureDir(dir: string): void {
58
+ if (__resolution.dryRun) return; // dry-run creates NO directories
58
59
  if (!existsSync(dir)) {
59
60
  mkdirSync(dir, { recursive: true });
60
61
  }
61
62
  }
62
63
 
63
64
  function writeFileSafe(path: string, content: string): void {
65
+ if (__resolution.dryRun) {
66
+ // Dry-run: record what WOULD have been written, but touch no disk state
67
+ // and print no per-file line to stdout (that would leak into --json).
68
+ return;
69
+ }
64
70
  if (existsSync(path)) {
65
- console.log(` File already exists: ${path}`);
71
+ if (!__resolution.jsonMode) console.log(` File already exists: ${path}`);
66
72
  return;
67
73
  }
68
74
  writeFileSync(path, content, "utf-8");
69
- console.log(` Created ${path}`);
75
+ __resolution.actionsTaken.push(`wrote ${path}`);
76
+ if (!__resolution.jsonMode) console.log(` Created ${path}`);
70
77
  }
71
78
 
72
79
  export function toSnake(name: string): string {
@@ -76,8 +83,207 @@ export function toSnake(name: string): string {
76
83
  .toLowerCase();
77
84
  }
78
85
 
86
+ // Table names that collide with SQL reserved words. `CREATE TABLE order (...)`
87
+ // is a syntax error on every engine, and the ORM interpolates table names into
88
+ // SQL unquoted (and hands the raw name to driver insert/update/delete), so the
89
+ // safe fix is to never GENERATE one. The plural form is not reserved and reads
90
+ // naturally as a table name. Mirrors the Python master's SQL_RESERVED_TABLE_NAMES
91
+ // at tina4-python/tina4_python/cli/__init__.py.
92
+ export const SQL_RESERVED_TABLE_NAMES: ReadonlySet<string> = new Set([
93
+ "order", "group", "user", "table", "select", "from", "where", "index",
94
+ "key", "values", "column", "constraint", "check", "default", "primary",
95
+ "foreign", "references", "unique", "join", "union", "having", "limit",
96
+ "offset", "desc", "asc", "case", "when", "then", "else", "end", "and",
97
+ "or", "not", "null", "insert", "update", "delete", "create", "drop",
98
+ "alter", "grant", "revoke", "commit", "rollback", "view", "trigger",
99
+ "procedure", "function", "database", "schema", "session", "set", "into",
100
+ "as", "on", "by", "inner", "outer", "left", "right", "full", "natural",
101
+ "using", "with", "distinct", "between", "exists", "like", "in", "is",
102
+ "all", "any", "cross", "add", "row", "rows", "range", "current", "to",
103
+ ]);
104
+
105
+ /** Simple English plural, used to escape a reserved-word table name. */
106
+ export function pluralizeReserved(name: string): string {
107
+ if (name.endsWith("y") && !/[aeiouy]y$/i.test(name)) return name.slice(0, -1) + "ies";
108
+ if (/(s|x|z|ch|sh)$/.test(name)) return name + "es";
109
+ return name + "s";
110
+ }
111
+
112
+ /**
113
+ * Class name -> table name (singular by default), with a resolution side effect:
114
+ * a name that collides with a SQL reserved word is pluralised (Order -> orders)
115
+ * AND recorded on the current run's resolution as a `reserved_word_pluralize`
116
+ * transformation. Every generator routes through here so the model, migration,
117
+ * routes and tests all agree on the same table name.
118
+ */
79
119
  export function toTableName(name: string): string {
80
- return toSnake(name);
120
+ const raw = toSnake(name);
121
+ if (SQL_RESERVED_TABLE_NAMES.has(raw)) {
122
+ const safe = pluralizeReserved(raw);
123
+ recordTransformation({
124
+ kind: "reserved_word_pluralize",
125
+ from: raw,
126
+ to: safe,
127
+ reason: `SQL reserved word '${raw}' would break CREATE TABLE`,
128
+ override: `--table ${raw} --quote (requires quoted-identifier mode, not yet implemented)`,
129
+ });
130
+ return safe;
131
+ }
132
+ return raw;
133
+ }
134
+
135
+ // ── Resolution surface — the machine-readable envelope every generator ─
136
+ // populates so `--json` can print it and a human run can print the same
137
+ // facts to stderr. See the JSDoc on `printResolution` below for the envelope
138
+ // shape (kept stable under `resolution_contract` in `commands --json`).
139
+
140
+ /** One transformation the resolver made — visible to the caller so an AI
141
+ * agent (or human) knows exactly why the output differs from the input. */
142
+ export interface ResolutionTransformation {
143
+ kind: string;
144
+ from?: string;
145
+ to?: string;
146
+ reason?: string;
147
+ override?: string;
148
+ }
149
+
150
+ export interface ResolutionInput {
151
+ name: string;
152
+ fields: string | null;
153
+ }
154
+
155
+ export interface ResolutionBody {
156
+ class_name?: string;
157
+ table_name?: string;
158
+ file_path?: string;
159
+ migration_path?: string;
160
+ routes?: string[];
161
+ test_paths?: string[];
162
+ transformations: ResolutionTransformation[];
163
+ }
164
+
165
+ export interface ResolutionEnvelope {
166
+ command: "generate";
167
+ target: string;
168
+ input: ResolutionInput;
169
+ resolution: ResolutionBody;
170
+ actions_taken: string[];
171
+ dry_run: boolean;
172
+ }
173
+
174
+ /**
175
+ * A stable version tag on the JSON envelope. `commands --json` echoes this in
176
+ * `resolution_contract.envelope` so the tina4 client (or any consumer) can
177
+ * discover the exact contract this framework speaks. Bump when a breaking
178
+ * key rename / removal lands; keep unchanged when new OPTIONAL keys are added.
179
+ */
180
+ export const RESOLUTION_ENVELOPE_VERSION = "generate_v1";
181
+
182
+ /**
183
+ * Per-run mutable resolution state. Reset by `resetResolution()` on every
184
+ * top-level `generate()` call so a sub-generator (crud -> model + route +
185
+ * migration + form + view + test) contributes to ONE envelope, not many.
186
+ */
187
+ const __resolution: {
188
+ target: string;
189
+ input: ResolutionInput;
190
+ body: ResolutionBody;
191
+ actionsTaken: string[];
192
+ dryRun: boolean;
193
+ jsonMode: boolean;
194
+ } = {
195
+ target: "",
196
+ input: { name: "", fields: null },
197
+ body: { transformations: [] },
198
+ actionsTaken: [],
199
+ dryRun: false,
200
+ jsonMode: false,
201
+ };
202
+
203
+ function resetResolution(target: string, input: ResolutionInput, opts: { dryRun: boolean; jsonMode: boolean }): void {
204
+ __resolution.target = target;
205
+ __resolution.input = input;
206
+ __resolution.body = { transformations: [] };
207
+ __resolution.actionsTaken = [];
208
+ __resolution.dryRun = opts.dryRun;
209
+ __resolution.jsonMode = opts.jsonMode;
210
+ }
211
+
212
+ function recordTransformation(t: ResolutionTransformation): void {
213
+ __resolution.body.transformations.push(t);
214
+ }
215
+
216
+ /** Read-only snapshot of the current resolution — exported for tests that
217
+ * want to inspect it in-process (the CLI itself uses only the envelope). */
218
+ export function currentResolution(): ResolutionEnvelope {
219
+ return {
220
+ command: "generate",
221
+ target: __resolution.target,
222
+ input: { ...__resolution.input },
223
+ resolution: {
224
+ ...__resolution.body,
225
+ transformations: [...__resolution.body.transformations],
226
+ },
227
+ actions_taken: [...__resolution.actionsTaken],
228
+ dry_run: __resolution.dryRun,
229
+ };
230
+ }
231
+
232
+ function setResolutionField<K extends keyof ResolutionBody>(key: K, value: ResolutionBody[K]): void {
233
+ __resolution.body[key] = value;
234
+ }
235
+
236
+ function pushRoute(routePattern: string): void {
237
+ if (!__resolution.body.routes) __resolution.body.routes = [];
238
+ __resolution.body.routes.push(routePattern);
239
+ }
240
+
241
+ function pushTestPath(path: string): void {
242
+ if (!__resolution.body.test_paths) __resolution.body.test_paths = [];
243
+ __resolution.body.test_paths.push(path);
244
+ }
245
+
246
+ /**
247
+ * Emit the resolution — as JSON on STDOUT for `--json`, otherwise as a human
248
+ * block on STDERR (stderr so a caller piping stdout for other output isn't
249
+ * polluted). Called from `generate()` BEFORE the files are written on the
250
+ * human path so an operator sees WHY the tool made its choices before disk
251
+ * changes; the JSON path prints after collection so the envelope carries the
252
+ * completed `actions_taken`.
253
+ */
254
+ function printResolution(): void {
255
+ if (__resolution.jsonMode) {
256
+ process.stdout.write(JSON.stringify(currentResolution(), null, 2) + "\n");
257
+ return;
258
+ }
259
+ // Human block on STDERR, so `command | jq …` on stdout works cleanly.
260
+ const b = __resolution.body;
261
+ const lines: string[] = [];
262
+ lines.push("");
263
+ lines.push(`Generated ${__resolution.target} ${__resolution.input.name}`);
264
+ if (b.class_name || b.file_path) {
265
+ const where = b.file_path ? ` (in ${b.file_path})` : "";
266
+ lines.push(` class ${b.class_name ?? __resolution.input.name}${where}`);
267
+ }
268
+ if (b.table_name) {
269
+ const t = b.transformations.find((x) => x.kind === "reserved_word_pluralize");
270
+ const note = t ? ` (auto-pluralized: '${t.from}' is a SQL reserved word)` : "";
271
+ lines.push(` table ${b.table_name}${note}`);
272
+ }
273
+ if (b.routes && b.routes.length) {
274
+ lines.push(` routes ${b.routes.join(", ")}`);
275
+ }
276
+ if (b.migration_path) {
277
+ lines.push(` migration ${b.migration_path}`);
278
+ }
279
+ const reserved = b.transformations.find((t) => t.kind === "reserved_word_pluralize");
280
+ if (reserved && reserved.from && reserved.override) {
281
+ lines.push("");
282
+ lines.push(` To keep the raw name '${reserved.from}' as the table:`);
283
+ lines.push(` tina4nodejs generate ${__resolution.target} ${__resolution.input.name} ${reserved.override}`);
284
+ }
285
+ lines.push("");
286
+ process.stderr.write(lines.join("\n"));
81
287
  }
82
288
 
83
289
  function toPlural(name: string): string {
@@ -135,6 +341,8 @@ export function parseCliArgs(args: string[]): { flags: Record<string, string | b
135
341
  const booleanFlags = new Set([
136
342
  "no-browser", "no-reload", "production", "managed", "all", "clear",
137
343
  "public", "no-migration",
344
+ // Resolution transparency (Feature B, 3.13.117): both accept NO value.
345
+ "json", "dry-run",
138
346
  ]);
139
347
 
140
348
  const flags: Record<string, string | boolean> = {};
@@ -278,6 +486,8 @@ export async function generate(what: string, name: string, extraArgs: string[] =
278
486
  console.error(' Options: --fields "name:string,price:float" --model ModelName');
279
487
  console.error(" --public open a route's writes (default: secure)");
280
488
  console.error(' --every 5m | --cron "…" service schedule');
489
+ console.error(" --json emit machine-readable resolution envelope on stdout");
490
+ console.error(" --dry-run report resolution without writing any files");
281
491
  process.exit(1);
282
492
  }
283
493
 
@@ -290,6 +500,15 @@ export async function generate(what: string, name: string, extraArgs: string[] =
290
500
 
291
501
  const { flags } = parseCliArgs(extraArgs);
292
502
 
503
+ // Feature B (3.13.117): resolution transparency. `--json` emits a stable
504
+ // envelope on STDOUT (see `RESOLUTION_ENVELOPE_VERSION` / `commands --json`
505
+ // -> `resolution_contract`); the human path prints the same facts to STDERR.
506
+ // `--dry-run` short-circuits every file write so an agent can preview the
507
+ // resolution and then rerun without the flag to commit.
508
+ const jsonMode = Boolean(flags.json);
509
+ const dryRun = Boolean(flags["dry-run"]);
510
+ resetResolution(what, { name, fields: (flags.fields as string) ?? null }, { dryRun, jsonMode });
511
+
293
512
  // Dispatch from the single-source-of-truth GENERATORS registry (also feeds
294
513
  // `bin.ts` help + the `commands --json` manifest subcommands).
295
514
  const spec = GENERATORS[what];
@@ -300,6 +519,10 @@ export async function generate(what: string, name: string, extraArgs: string[] =
300
519
  console.error(` Available: ${GENERATOR_LIST}`);
301
520
  process.exit(1);
302
521
  }
522
+
523
+ // Emit the resolution AFTER dispatch so `actions_taken` reflects the real
524
+ // writes (or the empty list under `--dry-run`).
525
+ printResolution();
303
526
  }
304
527
 
305
528
  // ── Model ───────────────────────────────────────────────────────────
@@ -311,6 +534,18 @@ function generateModel(name: string, flags: Record<string, string | boolean>, em
311
534
  ensureDir(dir);
312
535
  const path = join(dir, `${name}.ts`);
313
536
 
537
+ // Populate the resolution — the top-level `generate()` prints this AFTER
538
+ // dispatch (as JSON on stdout for `--json`, or as a human block on stderr).
539
+ // Fields set here are relative paths (portable across cwd) — path.resolve
540
+ // above uses cwd, then we express the file path relative to it for the
541
+ // envelope, matching the Python master's `src/models/Order.ts` string.
542
+ setResolutionField("class_name", name);
543
+ setResolutionField("table_name", table);
544
+ setResolutionField("file_path", `src/models/${name}.ts`);
545
+ // Matches the real path emitted by emitModelTest() so the envelope never
546
+ // lies about where a generated test lands.
547
+ pushTestPath(`tests/${table}_model.test.ts`);
548
+
314
549
  // Build field definitions
315
550
  const fieldLines: string[] = [
316
551
  ` id: { type: "integer" as const, primaryKey: true, autoIncrement: true },`,
@@ -373,6 +608,15 @@ function generateRoute(name: string, flags: Record<string, string | boolean>, em
373
608
  ensureDir(base);
374
609
  ensureDir(idDir);
375
610
 
611
+ // Populate the resolution — routes AND file paths always safe to add; only
612
+ // set the primary file_path when THIS is the top-level target so a `generate
613
+ // model` running us as a sub-step doesn't overwrite the model's file_path.
614
+ pushRoute(`/api/${routePath}`);
615
+ pushRoute(`/api/${routePath}/{id}`);
616
+ if (__resolution.target === "route") {
617
+ setResolutionField("file_path", `src/routes/api/${routePath}/get.ts`);
618
+ }
619
+
376
620
  const table = model ? toTableName(model) : "";
377
621
  // Model import path is RELATIVE to the route file's directory. Files directly
378
622
  // under src/routes/api/<name>/ are 3 levels above src/models/; the [id]/ files
@@ -649,16 +893,30 @@ function generateMigration(
649
893
  const dir = resolve("migrations");
650
894
  ensureDir(dir);
651
895
 
652
- // Determine table name
896
+ // Determine table name. When called from `generateModel` (tableOverride set),
897
+ // the model already ran toTableName() and recorded any reserved-word
898
+ // pluralisation — reuse that resolved name so the two files agree. When
899
+ // called directly (`generate migration create_order`), strip the prefix and
900
+ // route through toTableName() so a reserved word is caught HERE too.
653
901
  let table: string;
654
902
  if (tableOverride) {
655
903
  table = tableOverride;
656
904
  } else {
657
- table = name
905
+ const raw = name
658
906
  .replace(/^create_/, "")
659
907
  .replace(/^add_/, "")
660
908
  .replace(/^drop_/, "");
661
- table = toSnake(table);
909
+ // toTableName also records a `reserved_word_pluralize` transformation
910
+ // on the resolution when the raw form collides with a SQL reserved word.
911
+ table = toTableName(raw);
912
+ }
913
+ // Only set table_name / migration_path when THIS is the top-level target.
914
+ // A migration produced by generateModel is a side effect of `generate model`,
915
+ // and the model already populated `table_name` / `file_path` for that
916
+ // resolution — overwriting them here would lie about what the caller asked
917
+ // for. `migration_path` is always safe to record either way.
918
+ if (__resolution.target === "migration") {
919
+ setResolutionField("table_name", table);
662
920
  }
663
921
 
664
922
  // Build SQL columns from fields
@@ -667,6 +925,15 @@ function generateMigration(
667
925
 
668
926
  const fileName = `${ts}_${name}.sql`;
669
927
  const path = join(dir, fileName);
928
+ // Record on the resolution — always safe (a `generate model` run overwrites
929
+ // this with each nested migration; the last write wins, which is the one
930
+ // the operator actually gets on disk).
931
+ setResolutionField("migration_path", `migrations/${fileName}`);
932
+ if (__resolution.target === "migration") {
933
+ setResolutionField("file_path", `migrations/${fileName}`);
934
+ // Matches emitMigrationTest's real write path.
935
+ pushTestPath(`tests/${table}_migration.test.ts`);
936
+ }
670
937
 
671
938
  let upSql: string;
672
939
  let downSql: string;
@@ -719,6 +986,14 @@ function generateMiddleware(name: string, _flags: Record<string, string | boolea
719
986
  ensureDir(dir);
720
987
  const path = join(dir, `${snake}.ts`);
721
988
 
989
+ // Populate the resolution for --json/--dry-run visibility.
990
+ if (__resolution.target === "middleware") {
991
+ setResolutionField("class_name", name);
992
+ setResolutionField("file_path", `src/middleware/${snake}.ts`);
993
+ // Matches emitMiddlewareTest's real write path.
994
+ pushTestPath(`tests/${snake}.test.ts`);
995
+ }
996
+
722
997
  const content = `import type { Tina4Request, Tina4Response } from "tina4-nodejs";
723
998
 
724
999
  /**
@@ -1084,7 +1359,7 @@ export default async function (req: Tina4Request, res: Tina4Response) {
1084
1359
  return;
1085
1360
  }
1086
1361
 
1087
- const existing = await User.selectOne("SELECT * FROM user WHERE email = ?", [email]);
1362
+ const existing = await User.selectOne("SELECT * FROM users WHERE email = ?", [email]);
1088
1363
  if (existing) {
1089
1364
  res.json({ error: "Email already registered" }, 409);
1090
1365
  return;
@@ -1117,7 +1392,7 @@ export default async function (req: Tina4Request, res: Tina4Response) {
1117
1392
  return;
1118
1393
  }
1119
1394
 
1120
- const user = await User.selectOne("SELECT * FROM user WHERE email = ?", [email]);
1395
+ const user = await User.selectOne("SELECT * FROM users WHERE email = ?", [email]);
1121
1396
  if (!user || !checkPassword(password, user.toObject().password as string)) {
1122
1397
  res.json({ error: "Invalid credentials" }, 401);
1123
1398
  return;
@@ -0,0 +1,20 @@
1
+ // src/_missing.ts
2
+ import { readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { dirname, join } from "node:path";
5
+ function realSubpaths() {
6
+ const here = dirname(fileURLToPath(import.meta.url));
7
+ const pkgPath = join(here, "..", "package.json");
8
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
9
+ const pkgName2 = pkg.name ?? "@tina4/core";
10
+ const exportsMap = pkg.exports ?? {};
11
+ const subpaths2 = [];
12
+ for (const key of Object.keys(exportsMap)) {
13
+ if (key === "." || key === "./*") continue;
14
+ subpaths2.push(key.startsWith("./") ? key.slice(2) : key);
15
+ }
16
+ return { pkgName: pkgName2, subpaths: subpaths2 };
17
+ }
18
+ var { pkgName, subpaths } = realSubpaths();
19
+ var message = `${pkgName}: no such subpath. Real subpaths: ${subpaths.join(", ")}. (Node's wildcard resolver can't see the original request, so this message lists every real subpath rather than pointing at the closest match \u2014 see ADR-0062.)`;
20
+ throw new Error(message);
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Last-resort finder for `@tina4/core/<subpath>` under Node's ESM resolver.
3
+ *
4
+ * The `exports` map registers `"./*": "./dist/_missing.js"` as the LAST entry.
5
+ * Any subpath that no earlier entry matched (a typo, a guess, an ORM name
6
+ * imported from the core entry, etc.) lands here at import time and throws a
7
+ * helpful Error that names every REAL subpath — parsed at throw time from the
8
+ * OWN package.json's `exports` map so the message can never drift from what's
9
+ * actually exported.
10
+ *
11
+ * NODE PARITY GAP (accepted, ADR-0062).
12
+ *
13
+ * Node's wildcard resolver invokes this file with the RESOLVED target path,
14
+ * not the ORIGINAL requested subpath — so we cannot know what the caller
15
+ * typed. Python / PHP / Ruby's finders receive the raw request and can point
16
+ * at the closest match ("did you mean `router`?"). Node's message is
17
+ * necessarily generic: it lists ALL real subpaths as a browsable set. For an
18
+ * AI-agent consumer (or a human agent), the browsable list is enough to make
19
+ * the correct next call; the asymmetry is called out here so nobody wonders
20
+ * why Node's message lacks the pointed "did you mean" line.
21
+ *
22
+ * The module SIDE-EFFECT throws — importing this file is enough to raise,
23
+ * whether the caller does a bare-`import` or a named-`import { X }`. That is
24
+ * what routes the wildcard's fallback through this file: Node evaluates the
25
+ * module body BEFORE resolving named bindings.
26
+ */
27
+ import { readFileSync } from "node:fs";
28
+ import { fileURLToPath } from "node:url";
29
+ import { dirname, join } from "node:path";
30
+
31
+ /**
32
+ * Read the OWN package.json (walking up from this file's URL) and return the
33
+ * real subpaths — everything in `exports` except `.` (the root) and the
34
+ * wildcard `./*` itself. Order matches the declaration in package.json, which
35
+ * is the order a maintainer curated for discoverability.
36
+ */
37
+ function realSubpaths(): { pkgName: string; subpaths: string[] } {
38
+ const here = dirname(fileURLToPath(import.meta.url));
39
+ // dist/ (or src/ under tsx) → package root is one level up
40
+ const pkgPath = join(here, "..", "package.json");
41
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as {
42
+ name?: string;
43
+ exports?: Record<string, unknown>;
44
+ };
45
+ const pkgName = pkg.name ?? "@tina4/core";
46
+ const exportsMap = pkg.exports ?? {};
47
+ const subpaths: string[] = [];
48
+ for (const key of Object.keys(exportsMap)) {
49
+ if (key === "." || key === "./*") continue;
50
+ // Strip the leading "./" so a caller sees "router" not "./router"
51
+ subpaths.push(key.startsWith("./") ? key.slice(2) : key);
52
+ }
53
+ return { pkgName, subpaths };
54
+ }
55
+
56
+ const { pkgName, subpaths } = realSubpaths();
57
+
58
+ // The message shape matches the Python / PHP / Ruby import-hint format so an
59
+ // AI-agent consumer that switches languages sees a recognisable string.
60
+ const message =
61
+ `${pkgName}: no such subpath. ` +
62
+ `Real subpaths: ${subpaths.join(", ")}. ` +
63
+ `(Node's wildcard resolver can't see the original request, so this message ` +
64
+ `lists every real subpath rather than pointing at the closest match — ` +
65
+ `see ADR-0062.)`;
66
+
67
+ throw new Error(message);
@@ -6,10 +6,23 @@ export interface CommandManifestEntry {
6
6
  /** True when the tina4 client implements this command, not the framework. */
7
7
  delegated?: boolean;
8
8
  }
9
+ /**
10
+ * A stable, machine-readable pointer to the `generate` resolution envelope
11
+ * this framework speaks. Consumers (the tina4 client, an AI agent, a
12
+ * downstream tool) MUST NOT hard-code an envelope shape — instead they read
13
+ * `resolution_contract.envelope` from this manifest and follow its version.
14
+ * `version` bumps on any breaking key rename or removal; `envelope` is the
15
+ * name of the schema (currently `generate_v1`).
16
+ */
17
+ export interface ResolutionContract {
18
+ version: string;
19
+ envelope: string;
20
+ }
9
21
  export interface CommandManifest {
10
22
  framework: string;
11
23
  version: string;
12
24
  commands: CommandManifestEntry[];
25
+ resolution_contract: ResolutionContract;
13
26
  }
14
27
  /**
15
28
  * Build the machine-readable manifest of the CLI's command surface.
@@ -1,5 +1,55 @@
1
1
  export declare function toSnake(name: string): string;
2
+ export declare const SQL_RESERVED_TABLE_NAMES: ReadonlySet<string>;
3
+ /** Simple English plural, used to escape a reserved-word table name. */
4
+ export declare function pluralizeReserved(name: string): string;
5
+ /**
6
+ * Class name -> table name (singular by default), with a resolution side effect:
7
+ * a name that collides with a SQL reserved word is pluralised (Order -> orders)
8
+ * AND recorded on the current run's resolution as a `reserved_word_pluralize`
9
+ * transformation. Every generator routes through here so the model, migration,
10
+ * routes and tests all agree on the same table name.
11
+ */
2
12
  export declare function toTableName(name: string): string;
13
+ /** One transformation the resolver made — visible to the caller so an AI
14
+ * agent (or human) knows exactly why the output differs from the input. */
15
+ export interface ResolutionTransformation {
16
+ kind: string;
17
+ from?: string;
18
+ to?: string;
19
+ reason?: string;
20
+ override?: string;
21
+ }
22
+ export interface ResolutionInput {
23
+ name: string;
24
+ fields: string | null;
25
+ }
26
+ export interface ResolutionBody {
27
+ class_name?: string;
28
+ table_name?: string;
29
+ file_path?: string;
30
+ migration_path?: string;
31
+ routes?: string[];
32
+ test_paths?: string[];
33
+ transformations: ResolutionTransformation[];
34
+ }
35
+ export interface ResolutionEnvelope {
36
+ command: "generate";
37
+ target: string;
38
+ input: ResolutionInput;
39
+ resolution: ResolutionBody;
40
+ actions_taken: string[];
41
+ dry_run: boolean;
42
+ }
43
+ /**
44
+ * A stable version tag on the JSON envelope. `commands --json` echoes this in
45
+ * `resolution_contract.envelope` so the tina4 client (or any consumer) can
46
+ * discover the exact contract this framework speaks. Bump when a breaking
47
+ * key rename / removal lands; keep unchanged when new OPTIONAL keys are added.
48
+ */
49
+ export declare const RESOLUTION_ENVELOPE_VERSION = "generate_v1";
50
+ /** Read-only snapshot of the current resolution — exported for tests that
51
+ * want to inspect it in-process (the CLI itself uses only the envelope). */
52
+ export declare function currentResolution(): ResolutionEnvelope;
3
53
  /** slug-of-anything → PascalCase (order-emails → OrderEmails). */
4
54
  export declare function toPascal(name: string): string;
5
55
  export declare function parseFields(fieldsStr: string): Array<[string, string]>;
@@ -0,0 +1 @@
1
+ export {};