@drzl/cli 4.34.0 → 4.36.0
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/dist/cli.cjs +278 -5
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +278 -5
- package/dist/cli.js.map +1 -1
- package/dist/config.d.cts +2 -2
- package/dist/config.d.ts +2 -2
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -2150,6 +2150,240 @@ async function resolveSchemaSource(cfg, cwd = process.cwd()) {
|
|
|
2150
2150
|
};
|
|
2151
2151
|
}
|
|
2152
2152
|
|
|
2153
|
+
// src/constraint-drift.ts
|
|
2154
|
+
import { tableConstraints as tableConstraints2 } from "@drzl/validation-core";
|
|
2155
|
+
import { Chalk as Chalk4 } from "chalk";
|
|
2156
|
+
var PLAIN3 = new Chalk4({ level: 0 });
|
|
2157
|
+
var PLAIN_TEXT_TYPES = /* @__PURE__ */ new Set([
|
|
2158
|
+
"text",
|
|
2159
|
+
"varchar",
|
|
2160
|
+
"character varying",
|
|
2161
|
+
"char",
|
|
2162
|
+
"character",
|
|
2163
|
+
"bpchar",
|
|
2164
|
+
"citext",
|
|
2165
|
+
"tinytext",
|
|
2166
|
+
"mediumtext",
|
|
2167
|
+
"longtext",
|
|
2168
|
+
"nvarchar",
|
|
2169
|
+
"nchar"
|
|
2170
|
+
]);
|
|
2171
|
+
function typeStem(sqlType) {
|
|
2172
|
+
return sqlType.replace(/\(.*$/, "").replace(/\[\]$/, "").trim().toLowerCase();
|
|
2173
|
+
}
|
|
2174
|
+
function isSchemaOnlyEnum(column) {
|
|
2175
|
+
if (!column.enumValues || column.enumValues.length === 0) return false;
|
|
2176
|
+
if (!column.sqlType) return false;
|
|
2177
|
+
return PLAIN_TEXT_TYPES.has(typeStem(column.sqlType));
|
|
2178
|
+
}
|
|
2179
|
+
function sqlLiterals(values) {
|
|
2180
|
+
return values.map((v) => `'${v.replace(/'/g, "''")}'`).join(", ");
|
|
2181
|
+
}
|
|
2182
|
+
function derivedName(table, column) {
|
|
2183
|
+
return `${table}_${column}_check`;
|
|
2184
|
+
}
|
|
2185
|
+
var ADD_CONSTRAINT_DIALECTS = /* @__PURE__ */ new Set([
|
|
2186
|
+
"postgres",
|
|
2187
|
+
"cockroach",
|
|
2188
|
+
"gel",
|
|
2189
|
+
"mysql",
|
|
2190
|
+
"singlestore",
|
|
2191
|
+
"mssql"
|
|
2192
|
+
]);
|
|
2193
|
+
function fixFor(dialect, table, column, values) {
|
|
2194
|
+
if (!ADD_CONSTRAINT_DIALECTS.has(dialect)) return void 0;
|
|
2195
|
+
return `ALTER TABLE ${table} ADD CONSTRAINT ${derivedName(table, column)} CHECK (${column} IN (${sqlLiterals(values)}));`;
|
|
2196
|
+
}
|
|
2197
|
+
function noFixReason(dialect) {
|
|
2198
|
+
if (dialect === "sqlite") {
|
|
2199
|
+
return "SQLite cannot add a CHECK to an existing column: ALTER TABLE ... ADD CONSTRAINT is a syntax error there. Closing this means rebuilding the table, which is the twelve-step procedure SQLite documents: create the new table with the constraint, copy the rows, drop the old one, rename.";
|
|
2200
|
+
}
|
|
2201
|
+
return `no statement is emitted for the "${dialect}" dialect, which this report cannot name a fix for`;
|
|
2202
|
+
}
|
|
2203
|
+
function buildConstraintDriftReport(analysis, schemaPath) {
|
|
2204
|
+
const entries = [];
|
|
2205
|
+
for (const table of analysis.tables) {
|
|
2206
|
+
for (const c of tableConstraints2(table).constraints) {
|
|
2207
|
+
if (c.enforced) {
|
|
2208
|
+
for (const part of c.unenforced ?? []) {
|
|
2209
|
+
entries.push({
|
|
2210
|
+
side: "database-only",
|
|
2211
|
+
table: table.name,
|
|
2212
|
+
columns: [...c.columns],
|
|
2213
|
+
rule: part.part,
|
|
2214
|
+
reason: part.reason,
|
|
2215
|
+
...c.name ? { constraint: c.name } : {}
|
|
2216
|
+
});
|
|
2217
|
+
}
|
|
2218
|
+
continue;
|
|
2219
|
+
}
|
|
2220
|
+
entries.push({
|
|
2221
|
+
side: "database-only",
|
|
2222
|
+
table: table.name,
|
|
2223
|
+
columns: [...c.columns],
|
|
2224
|
+
rule: c.rule,
|
|
2225
|
+
reason: reasonFor(c.kind, c.unenforced),
|
|
2226
|
+
...c.name ? { constraint: c.name } : {}
|
|
2227
|
+
});
|
|
2228
|
+
}
|
|
2229
|
+
for (const column of table.columns) {
|
|
2230
|
+
if (!isSchemaOnlyEnum(column)) continue;
|
|
2231
|
+
const values = column.enumValues;
|
|
2232
|
+
entries.push({
|
|
2233
|
+
side: "schema-only",
|
|
2234
|
+
table: table.name,
|
|
2235
|
+
columns: [column.name],
|
|
2236
|
+
rule: `${column.name} IN (${sqlLiterals(values)})`,
|
|
2237
|
+
reason: `the column is declared ${column.sqlType}, which holds any text; the set exists only in the generated schemas`,
|
|
2238
|
+
...(() => {
|
|
2239
|
+
const fix = fixFor(analysis.dialect, table.name, column.name, values);
|
|
2240
|
+
return fix ? { fix } : { noFix: noFixReason(analysis.dialect) };
|
|
2241
|
+
})()
|
|
2242
|
+
});
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
const databaseOnly = entries.filter((e) => e.side === "database-only").length;
|
|
2246
|
+
const schemaOnly = entries.filter((e) => e.side === "schema-only").length;
|
|
2247
|
+
return {
|
|
2248
|
+
schema: schemaPath,
|
|
2249
|
+
dialect: analysis.dialect,
|
|
2250
|
+
ok: entries.length === 0,
|
|
2251
|
+
counts: { tables: analysis.tables.length, databaseOnly, schemaOnly },
|
|
2252
|
+
entries
|
|
2253
|
+
};
|
|
2254
|
+
}
|
|
2255
|
+
function reasonFor(kind, unenforced) {
|
|
2256
|
+
if (unenforced && unenforced.length) return unenforced.map((u) => u.reason).join("; ");
|
|
2257
|
+
switch (kind) {
|
|
2258
|
+
case "primaryKey":
|
|
2259
|
+
case "unique":
|
|
2260
|
+
return "whether a value is already taken is a fact about the table, not about one row";
|
|
2261
|
+
case "foreignKey":
|
|
2262
|
+
return "whether the referenced row exists is a question only the database can answer";
|
|
2263
|
+
default:
|
|
2264
|
+
return "no generated schema states it";
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
function wrap3(text, indent, first = indent, width = 96) {
|
|
2268
|
+
const words = text.split(/\s+/).filter(Boolean);
|
|
2269
|
+
const lines = [];
|
|
2270
|
+
let line = first;
|
|
2271
|
+
let started = false;
|
|
2272
|
+
for (const w of words) {
|
|
2273
|
+
if (started && line.length + 1 + w.length > width) {
|
|
2274
|
+
lines.push(line);
|
|
2275
|
+
line = indent + w;
|
|
2276
|
+
} else {
|
|
2277
|
+
line = started ? `${line} ${w}` : line + w;
|
|
2278
|
+
started = true;
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
if (started) lines.push(line);
|
|
2282
|
+
return lines.join("\n");
|
|
2283
|
+
}
|
|
2284
|
+
function renderConstraintDriftReport(report, style = PLAIN3) {
|
|
2285
|
+
const chalk = style;
|
|
2286
|
+
const out = [];
|
|
2287
|
+
const plural = (n, one) => `${n} ${one}${n === 1 ? "" : "s"}`;
|
|
2288
|
+
out.push(chalk.bold(`DRZL constraint drift ${report.schema}`));
|
|
2289
|
+
out.push(chalk.dim(`${report.dialect}, ${plural(report.counts.tables, "table")}`));
|
|
2290
|
+
out.push("");
|
|
2291
|
+
if (report.ok) {
|
|
2292
|
+
out.push(chalk.green("No drift."));
|
|
2293
|
+
out.push(chalk.dim(" Every constraint the database declares is one the generated schemas state."));
|
|
2294
|
+
out.push(chalk.dim(" Every set the generated schemas restrict is one the database enforces."));
|
|
2295
|
+
return out.join("\n");
|
|
2296
|
+
}
|
|
2297
|
+
const schemaOnly = report.entries.filter((e) => e.side === "schema-only");
|
|
2298
|
+
if (schemaOnly.length) {
|
|
2299
|
+
out.push(chalk.yellow("Your schemas enforce this and the database does not"));
|
|
2300
|
+
out.push(
|
|
2301
|
+
chalk.dim(
|
|
2302
|
+
wrap3(
|
|
2303
|
+
"Any other client writes past these. A migration, a psql session or an admin tool is not running your validators.",
|
|
2304
|
+
" "
|
|
2305
|
+
)
|
|
2306
|
+
)
|
|
2307
|
+
);
|
|
2308
|
+
out.push("");
|
|
2309
|
+
for (const e of schemaOnly) {
|
|
2310
|
+
out.push(` ${chalk.dim("-")} ${chalk.bold(e.table)}.${e.columns.join(", ")}`);
|
|
2311
|
+
out.push(wrap3(e.rule, " "));
|
|
2312
|
+
out.push(chalk.dim(wrap3(e.reason, " ")));
|
|
2313
|
+
if (e.fix) {
|
|
2314
|
+
out.push(chalk.dim(" Close it with:"));
|
|
2315
|
+
out.push(` ${e.fix}`);
|
|
2316
|
+
} else if (e.noFix) {
|
|
2317
|
+
out.push(chalk.dim(wrap3(e.noFix, " ", " Not one statement here: ")));
|
|
2318
|
+
}
|
|
2319
|
+
out.push("");
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
const databaseOnly = report.entries.filter((e) => e.side === "database-only");
|
|
2323
|
+
if (databaseOnly.length) {
|
|
2324
|
+
out.push(chalk.cyan("The database enforces this and your schemas do not"));
|
|
2325
|
+
out.push(
|
|
2326
|
+
chalk.dim(
|
|
2327
|
+
wrap3(
|
|
2328
|
+
"Mostly not a defect: a key, a unique index and a foreign key are facts about the table rather than about one row, so no per-row validator can see them. Listed so nobody reads the generated schemas as the whole story.",
|
|
2329
|
+
" "
|
|
2330
|
+
)
|
|
2331
|
+
)
|
|
2332
|
+
);
|
|
2333
|
+
out.push("");
|
|
2334
|
+
for (const e of databaseOnly) {
|
|
2335
|
+
const name = e.constraint ? ` ${chalk.dim(`(${e.constraint})`)}` : "";
|
|
2336
|
+
out.push(` ${chalk.dim("-")} ${chalk.bold(e.table)}${name}`);
|
|
2337
|
+
out.push(wrap3(e.rule, " "));
|
|
2338
|
+
out.push(chalk.dim(wrap3(e.reason, " ")));
|
|
2339
|
+
out.push("");
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
out.push(
|
|
2343
|
+
chalk.dim(
|
|
2344
|
+
`${plural(report.counts.schemaOnly, "gap")} your schemas alone enforce, ${plural(report.counts.databaseOnly, "constraint")} only the database does.`
|
|
2345
|
+
)
|
|
2346
|
+
);
|
|
2347
|
+
return out.join("\n");
|
|
2348
|
+
}
|
|
2349
|
+
function renderConstraintDriftSql(report) {
|
|
2350
|
+
const gaps = report.entries.filter((e) => e.side === "schema-only");
|
|
2351
|
+
if (!gaps.length) return "";
|
|
2352
|
+
const out = [];
|
|
2353
|
+
const runnable = gaps.filter((g) => g.fix);
|
|
2354
|
+
const unrunnable = gaps.filter((g) => !g.fix);
|
|
2355
|
+
out.push(`-- Generated by drzl doctor --constraints --sql`);
|
|
2356
|
+
out.push(`-- ${report.schema}, ${report.dialect}`);
|
|
2357
|
+
out.push(
|
|
2358
|
+
`-- ${runnable.length} constraint(s) your schemas enforce and the database does not.`
|
|
2359
|
+
);
|
|
2360
|
+
if (unrunnable.length) {
|
|
2361
|
+
out.push(
|
|
2362
|
+
`-- ${unrunnable.length} more cannot be closed by one statement on this dialect; see below.`
|
|
2363
|
+
);
|
|
2364
|
+
}
|
|
2365
|
+
out.push("");
|
|
2366
|
+
const byPlace = (a, b) => a.table.localeCompare(b.table) || a.columns.join().localeCompare(b.columns.join());
|
|
2367
|
+
for (const g of [...runnable].sort(byPlace)) {
|
|
2368
|
+
out.push(`-- ${g.table}.${g.columns.join(", ")}: ${g.reason}`);
|
|
2369
|
+
out.push(g.fix);
|
|
2370
|
+
out.push("");
|
|
2371
|
+
}
|
|
2372
|
+
if (unrunnable.length) {
|
|
2373
|
+
out.push("-- The rest, which this dialect cannot do in one statement:");
|
|
2374
|
+
for (const g of [...unrunnable].sort(byPlace)) {
|
|
2375
|
+
out.push(`--`);
|
|
2376
|
+
out.push(`-- ${g.table}.${g.columns.join(", ")}`);
|
|
2377
|
+
out.push(`-- ${g.rule}`);
|
|
2378
|
+
for (const line of (g.noFix ?? "").match(/.{1,88}(\s|$)/g) ?? []) {
|
|
2379
|
+
out.push(`-- ${line.trim()}`);
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
out.push("");
|
|
2383
|
+
}
|
|
2384
|
+
return out.join("\n");
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2153
2387
|
// src/drift.ts
|
|
2154
2388
|
import { promises as fs2 } from "fs";
|
|
2155
2389
|
import path2 from "path";
|
|
@@ -3072,7 +3306,10 @@ withOutputFlags(
|
|
|
3072
3306
|
}
|
|
3073
3307
|
});
|
|
3074
3308
|
withOutputFlags(
|
|
3075
|
-
program.command("doctor").description("Report what DRZL cannot type or enforce in your schema, and why").argument("[schema]", "path to drizzle schema (TS); defaults to the schema in drzl.config").option("-c, --config <path>", "path to drzl.config, read when no schema argument is given").option("--strict", "exit 2 when anything is reported", false)
|
|
3309
|
+
program.command("doctor").description("Report what DRZL cannot type or enforce in your schema, and why").argument("[schema]", "path to drizzle schema (TS); defaults to the schema in drzl.config").option("-c, --config <path>", "path to drzl.config, read when no schema argument is given").option("--strict", "exit 2 when anything is reported", false).option(
|
|
3310
|
+
"--constraints",
|
|
3311
|
+
"report what each side enforces that the other does not, instead of the usual findings"
|
|
3312
|
+
).option("--sql", "with --constraints, emit the statements alone, for redirecting to a migration")
|
|
3076
3313
|
).action(async (schema, opts) => {
|
|
3077
3314
|
const out = outputFor(opts);
|
|
3078
3315
|
{
|
|
@@ -3094,10 +3331,46 @@ withOutputFlags(
|
|
|
3094
3331
|
includeRelations: true,
|
|
3095
3332
|
validateConstraints: true
|
|
3096
3333
|
});
|
|
3097
|
-
const
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3334
|
+
const schemaLabel = Array.isArray(target) ? target.join(", ") : target;
|
|
3335
|
+
if (opts.sql && !opts.constraints) {
|
|
3336
|
+
const msg = "--sql reports the constraint drift as statements, so it needs --constraints.";
|
|
3337
|
+
if (opts.json) out.jsonData(jsonFailure("doctor", "DRZL_CLI_DOCTOR", msg));
|
|
3338
|
+
else out.error("Doctor failed (DRZL_CLI_DOCTOR):", msg);
|
|
3339
|
+
process.exit(EXIT_FAILED);
|
|
3340
|
+
return;
|
|
3341
|
+
}
|
|
3342
|
+
if (opts.constraints) {
|
|
3343
|
+
const preflight = buildDoctorReport(analysis, schemaLabel);
|
|
3344
|
+
const fatal = preflight.findings.filter((f) => f.level === "error");
|
|
3345
|
+
if (fatal.length) {
|
|
3346
|
+
if (opts.json)
|
|
3347
|
+
out.data(
|
|
3348
|
+
JSON.stringify(
|
|
3349
|
+
{ command: "doctor", exitCode: EXIT_FAILED, ...preflight },
|
|
3350
|
+
null,
|
|
3351
|
+
2
|
|
3352
|
+
)
|
|
3353
|
+
);
|
|
3354
|
+
else out.data(renderDoctorReport(preflight, out.outStyle));
|
|
3355
|
+
process.exit(EXIT_FAILED);
|
|
3356
|
+
return;
|
|
3357
|
+
}
|
|
3358
|
+
const drift = buildConstraintDriftReport(analysis, schemaLabel);
|
|
3359
|
+
const driftCode = opts.strict && drift.counts.schemaOnly ? EXIT_FINDINGS : EXIT_OK;
|
|
3360
|
+
if (opts.sql) {
|
|
3361
|
+
out.data(renderConstraintDriftSql(drift));
|
|
3362
|
+
process.exit(driftCode);
|
|
3363
|
+
return;
|
|
3364
|
+
}
|
|
3365
|
+
if (opts.json)
|
|
3366
|
+
out.data(
|
|
3367
|
+
JSON.stringify({ command: "doctor", exitCode: driftCode, ...drift }, null, 2)
|
|
3368
|
+
);
|
|
3369
|
+
else out.data(renderConstraintDriftReport(drift, out.outStyle));
|
|
3370
|
+
process.exit(driftCode);
|
|
3371
|
+
return;
|
|
3372
|
+
}
|
|
3373
|
+
const report = buildDoctorReport(analysis, schemaLabel);
|
|
3101
3374
|
const unreadable = report.findings.some((f) => f.level === "error");
|
|
3102
3375
|
const code = unreadable ? EXIT_FAILED : opts.strict && report.findings.length ? EXIT_FINDINGS : EXIT_OK;
|
|
3103
3376
|
if (opts.json)
|