tina4-nodejs 3.13.116 → 3.13.118
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 +2 -2
- package/package.json +1 -1
- package/packages/cli/dist/bin.js +228 -9
- package/packages/cli/src/bin.ts +24 -2
- package/packages/cli/src/commands/generate.ts +283 -8
- package/packages/core/dist/_missing.js +20 -0
- package/packages/core/src/_missing.ts +67 -0
- package/types/cli/src/bin.d.ts +13 -0
- package/types/cli/src/commands/generate.d.ts +50 -0
- package/types/core/src/_missing.d.ts +1 -0
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.
|
|
16
|
+
# CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.118)
|
|
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.
|
|
22
|
+
Tina4 for Node.js/TypeScript v3.13.118 - 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
package/packages/cli/dist/bin.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
45880
|
-
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
|
|
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
|
|
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 {
|
|
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();
|
package/packages/cli/src/bin.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
905
|
+
const raw = name
|
|
658
906
|
.replace(/^create_/, "")
|
|
659
907
|
.replace(/^add_/, "")
|
|
660
908
|
.replace(/^drop_/, "");
|
|
661
|
-
|
|
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
|
|
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
|
|
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);
|
package/types/cli/src/bin.d.ts
CHANGED
|
@@ -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 {};
|