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 +2 -2
- package/package.json +1 -1
- package/packages/cli/dist/bin.js +240 -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/dist/index.js +12 -0
- package/packages/core/src/_missing.ts +67 -0
- package/packages/core/src/service.ts +15 -3
- package/packages/orm/dist/index.js +12 -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.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.
|
|
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
package/packages/cli/dist/bin.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
45868
|
-
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
|
|
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
|
|
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 {
|
|
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();
|
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);
|
|
@@ -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
|
|
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
|
-
|
|
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);
|
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 {};
|