tina4-nodejs 3.13.115 → 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.115)
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.115 - 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.115",
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": [
@@ -38499,6 +38499,7 @@ var registry, watchedFiles, Tina4Service, ServiceRunner;
38499
38499
  var init_service = __esm({
38500
38500
  "../core/src/service.ts"() {
38501
38501
  "use strict";
38502
+ init_logger();
38502
38503
  registry = /* @__PURE__ */ new Map();
38503
38504
  watchedFiles = /* @__PURE__ */ new Set();
38504
38505
  Tina4Service = class {
@@ -38642,6 +38643,17 @@ var init_service = __esm({
38642
38643
  static stop(name) {
38643
38644
  const targets = name ? [registry.get(name)].filter(Boolean) : Array.from(registry.values());
38644
38645
  for (const svc of targets) {
38646
+ const instance = svc.instance;
38647
+ if (instance && typeof instance.stop === "function") {
38648
+ try {
38649
+ instance.stop();
38650
+ } catch (err) {
38651
+ Log.error("Error stopping service instance", {
38652
+ name: svc.name,
38653
+ error: err instanceof Error ? err.message : String(err)
38654
+ });
38655
+ }
38656
+ }
38645
38657
  svc.context.running = false;
38646
38658
  if (svc.timerId) {
38647
38659
  clearInterval(svc.timerId);
@@ -45418,23 +45430,203 @@ var FIELD_TYPE_MAP = {
45418
45430
  blob: { orm: '"string"', sql: "BLOB", defaultVal: "NULL" }
45419
45431
  };
45420
45432
  function ensureDir(dir) {
45433
+ if (__resolution.dryRun) return;
45421
45434
  if (!existsSync33(dir)) {
45422
45435
  mkdirSync23(dir, { recursive: true });
45423
45436
  }
45424
45437
  }
45425
45438
  function writeFileSafe(path8, content) {
45439
+ if (__resolution.dryRun) {
45440
+ return;
45441
+ }
45426
45442
  if (existsSync33(path8)) {
45427
- console.log(` File already exists: ${path8}`);
45443
+ if (!__resolution.jsonMode) console.log(` File already exists: ${path8}`);
45428
45444
  return;
45429
45445
  }
45430
45446
  writeFileSync20(path8, content, "utf-8");
45431
- console.log(` Created ${path8}`);
45447
+ __resolution.actionsTaken.push(`wrote ${path8}`);
45448
+ if (!__resolution.jsonMode) console.log(` Created ${path8}`);
45432
45449
  }
45433
45450
  function toSnake(name) {
45434
45451
  return name.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2").replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
45435
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
+ }
45436
45539
  function toTableName(name) {
45437
- 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"));
45438
45630
  }
45439
45631
  function toPlural(name) {
45440
45632
  const lower = name.toLowerCase();
@@ -45476,7 +45668,10 @@ function parseCliArgs(args) {
45476
45668
  "all",
45477
45669
  "clear",
45478
45670
  "public",
45479
- "no-migration"
45671
+ "no-migration",
45672
+ // Resolution transparency (Feature B, 3.13.117): both accept NO value.
45673
+ "json",
45674
+ "dry-run"
45480
45675
  ]);
45481
45676
  const flags = {};
45482
45677
  const positional = [];
@@ -45564,6 +45759,8 @@ async function generate2(what, name, extraArgs = []) {
45564
45759
  console.error(' Options: --fields "name:string,price:float" --model ModelName');
45565
45760
  console.error(" --public open a route's writes (default: secure)");
45566
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");
45567
45764
  process.exit(1);
45568
45765
  }
45569
45766
  const noNameGenerators = /* @__PURE__ */ new Set(["auth"]);
@@ -45572,6 +45769,9 @@ async function generate2(what, name, extraArgs = []) {
45572
45769
  process.exit(1);
45573
45770
  }
45574
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 });
45575
45775
  const spec = GENERATORS[what];
45576
45776
  if (spec) {
45577
45777
  spec.handler(name, flags);
@@ -45580,6 +45780,7 @@ async function generate2(what, name, extraArgs = []) {
45580
45780
  console.error(` Available: ${GENERATOR_LIST}`);
45581
45781
  process.exit(1);
45582
45782
  }
45783
+ printResolution();
45583
45784
  }
45584
45785
  function generateModel(name, flags, emitTest = true) {
45585
45786
  const fields = fieldsOrDefault(flags.fields || "");
@@ -45587,6 +45788,10 @@ function generateModel(name, flags, emitTest = true) {
45587
45788
  const dir = resolve28("src/models");
45588
45789
  ensureDir(dir);
45589
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`);
45590
45795
  const fieldLines = [
45591
45796
  ` id: { type: "integer" as const, primaryKey: true, autoIncrement: true },`
45592
45797
  ];
@@ -45624,6 +45829,11 @@ function generateRoute(name, flags, emitTest = true) {
45624
45829
  const idDir = join35(base, "[id]");
45625
45830
  ensureDir(base);
45626
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
+ }
45627
45837
  const table2 = model ? toTableName(model) : "";
45628
45838
  const modelImportBase = model ? `import ${model} from "../../../models/${model}.js";
45629
45839
  ` : "";
@@ -45864,13 +46074,21 @@ function generateMigration(name, flags, fieldsOverride, tableOverride, emitTest
45864
46074
  if (tableOverride) {
45865
46075
  table2 = tableOverride;
45866
46076
  } else {
45867
- table2 = name.replace(/^create_/, "").replace(/^add_/, "").replace(/^drop_/, "");
45868
- 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);
45869
46082
  }
45870
46083
  const fields = fieldsOverride || parseFields(flags.fields || "");
45871
46084
  const isCreate = name.startsWith("create_") || fieldsOverride !== void 0;
45872
46085
  const fileName = `${ts}_${name}.sql`;
45873
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
+ }
45874
46092
  let upSql;
45875
46093
  let downSql;
45876
46094
  if (isCreate) {
@@ -45916,6 +46134,11 @@ function generateMiddleware(name, _flags) {
45916
46134
  const dir = resolve28("src/middleware");
45917
46135
  ensureDir(dir);
45918
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
+ }
45919
46142
  const content = `import type { Tina4Request, Tina4Response } from "tina4-nodejs";
45920
46143
 
45921
46144
  /**
@@ -46228,7 +46451,7 @@ export default async function (req: Tina4Request, res: Tina4Response) {
46228
46451
  return;
46229
46452
  }
46230
46453
 
46231
- const existing = await User.selectOne("SELECT * FROM user WHERE email = ?", [email]);
46454
+ const existing = await User.selectOne("SELECT * FROM users WHERE email = ?", [email]);
46232
46455
  if (existing) {
46233
46456
  res.json({ error: "Email already registered" }, 409);
46234
46457
  return;
@@ -46259,7 +46482,7 @@ export default async function (req: Tina4Request, res: Tina4Response) {
46259
46482
  return;
46260
46483
  }
46261
46484
 
46262
- const user = await User.selectOne("SELECT * FROM user WHERE email = ?", [email]);
46485
+ const user = await User.selectOne("SELECT * FROM users WHERE email = ?", [email]);
46263
46486
  if (!user || !checkPassword(password, user.toObject().password as string)) {
46264
46487
  res.json({ error: "Invalid credentials" }, 401);
46265
46488
  return;
@@ -47369,7 +47592,15 @@ function buildCommandManifest() {
47369
47592
  if (spec.args && spec.args.length) entry.args = [...spec.args];
47370
47593
  commands.push(entry);
47371
47594
  }
47372
- 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
+ };
47373
47604
  }
47374
47605
  function runCommands(args = []) {
47375
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);
@@ -38478,6 +38478,7 @@ var registry, watchedFiles, Tina4Service, ServiceRunner;
38478
38478
  var init_service = __esm({
38479
38479
  "src/service.ts"() {
38480
38480
  "use strict";
38481
+ init_logger();
38481
38482
  registry = /* @__PURE__ */ new Map();
38482
38483
  watchedFiles = /* @__PURE__ */ new Set();
38483
38484
  Tina4Service = class {
@@ -38621,6 +38622,17 @@ var init_service = __esm({
38621
38622
  static stop(name) {
38622
38623
  const targets = name ? [registry.get(name)].filter(Boolean) : Array.from(registry.values());
38623
38624
  for (const svc of targets) {
38625
+ const instance = svc.instance;
38626
+ if (instance && typeof instance.stop === "function") {
38627
+ try {
38628
+ instance.stop();
38629
+ } catch (err) {
38630
+ Log.error("Error stopping service instance", {
38631
+ name: svc.name,
38632
+ error: err instanceof Error ? err.message : String(err)
38633
+ });
38634
+ }
38635
+ }
38624
38636
  svc.context.running = false;
38625
38637
  if (svc.timerId) {
38626
38638
  clearInterval(svc.timerId);
@@ -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);
@@ -5,6 +5,7 @@
5
5
  import { readdirSync, statSync, watchFile, unwatchFile } from "node:fs";
6
6
  import { join, extname } from "node:path";
7
7
  import { pathToFileURL } from "node:url";
8
+ import { Log } from "./logger.js";
8
9
 
9
10
  // ─── Types ───────────────────────────────────────────────────────────────────
10
11
 
@@ -40,6 +41,7 @@ interface RegisteredService {
40
41
  context: ServiceContext;
41
42
  timerId: ReturnType<typeof setInterval> | null;
42
43
  retries: number;
44
+ instance?: Tina4Service;
43
45
  }
44
46
 
45
47
  const registry = new Map<string, RegisteredService>();
@@ -266,11 +268,10 @@ export class ServiceRunner {
266
268
  ): void {
267
269
  const merged: ServiceOptions = { daemon: true, ...options };
268
270
  this.register(name, service.asHandler(), merged);
269
- // Stash the instance on the registry entry so future stop() calls
270
- // can route to service.stop().
271
+ // Stash the instance so stop() can route to service.stop().
271
272
  const entry = registry.get(name);
272
273
  if (entry) {
273
- (entry as unknown as Record<string, unknown>).instance = service;
274
+ entry.instance = service;
274
275
  }
275
276
  }
276
277
 
@@ -362,6 +363,17 @@ export class ServiceRunner {
362
363
  : Array.from(registry.values());
363
364
 
364
365
  for (const svc of targets) {
366
+ const instance = svc.instance;
367
+ if (instance && typeof instance.stop === "function") {
368
+ try {
369
+ instance.stop();
370
+ } catch (err) {
371
+ Log.error("Error stopping service instance", {
372
+ name: svc.name,
373
+ error: err instanceof Error ? err.message : String(err),
374
+ });
375
+ }
376
+ }
365
377
  svc.context.running = false;
366
378
  if (svc.timerId) {
367
379
  clearInterval(svc.timerId);
@@ -27305,6 +27305,7 @@ var registry, watchedFiles, Tina4Service, ServiceRunner;
27305
27305
  var init_service = __esm({
27306
27306
  "../core/src/service.ts"() {
27307
27307
  "use strict";
27308
+ init_logger();
27308
27309
  registry = /* @__PURE__ */ new Map();
27309
27310
  watchedFiles = /* @__PURE__ */ new Set();
27310
27311
  Tina4Service = class {
@@ -27448,6 +27449,17 @@ var init_service = __esm({
27448
27449
  static stop(name) {
27449
27450
  const targets = name ? [registry.get(name)].filter(Boolean) : Array.from(registry.values());
27450
27451
  for (const svc of targets) {
27452
+ const instance = svc.instance;
27453
+ if (instance && typeof instance.stop === "function") {
27454
+ try {
27455
+ instance.stop();
27456
+ } catch (err) {
27457
+ Log.error("Error stopping service instance", {
27458
+ name: svc.name,
27459
+ error: err instanceof Error ? err.message : String(err)
27460
+ });
27461
+ }
27462
+ }
27451
27463
  svc.context.running = false;
27452
27464
  if (svc.timerId) {
27453
27465
  clearInterval(svc.timerId);
@@ -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 {};