@drzl/cli 4.35.0 → 4.37.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/{chunk-S5JWTWWU.js → chunk-EQ4OSVMJ.js} +68 -4
- package/dist/chunk-EQ4OSVMJ.js.map +1 -0
- package/dist/cli.cjs +498 -31
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +436 -29
- package/dist/cli.js.map +1 -1
- package/dist/config.cjs +69 -3
- package/dist/config.cjs.map +1 -1
- package/dist/config.d.cts +49 -5
- package/dist/config.d.ts +49 -5
- package/dist/config.js +5 -1
- package/dist/drzl.config.schema.json +23 -1
- package/package.json +13 -11
- package/dist/chunk-S5JWTWWU.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
fastCheckOutDir,
|
|
17
17
|
fastifyOutDir,
|
|
18
18
|
filterTables,
|
|
19
|
+
formsOutDir,
|
|
19
20
|
graphqlOutDir,
|
|
20
21
|
h3OutDir,
|
|
21
22
|
hasNamedSchemas,
|
|
@@ -28,13 +29,14 @@ import {
|
|
|
28
29
|
nearestKey,
|
|
29
30
|
nestjsOutDir,
|
|
30
31
|
nextOutDir,
|
|
32
|
+
openApiFetchOutDir,
|
|
31
33
|
seedOutDir,
|
|
32
34
|
tableAliases,
|
|
33
35
|
tableFilterWarnings,
|
|
34
36
|
tanstackStartOutDir,
|
|
35
37
|
trpcOutDir,
|
|
36
38
|
tsRestOutDir
|
|
37
|
-
} from "./chunk-
|
|
39
|
+
} from "./chunk-EQ4OSVMJ.js";
|
|
38
40
|
|
|
39
41
|
// src/cli.ts
|
|
40
42
|
import { qualifiedTableName as qualifiedTableName2, SchemaAnalyzer as SchemaAnalyzer2 } from "@drzl/analyzer";
|
|
@@ -414,20 +416,81 @@ function tsRestOptions(g, cfg) {
|
|
|
414
416
|
};
|
|
415
417
|
}
|
|
416
418
|
|
|
417
|
-
// src/
|
|
419
|
+
// src/openapi-fetch-options.ts
|
|
418
420
|
var VALIDATOR_DEFAULT_DIRS3 = {
|
|
421
|
+
zod: "src/validators/zod",
|
|
422
|
+
valibot: "src/validators/valibot",
|
|
423
|
+
arktype: "src/validators/arktype"
|
|
424
|
+
};
|
|
425
|
+
function projectRelative3(p) {
|
|
426
|
+
return p.startsWith("./") ? p.slice(2) : p;
|
|
427
|
+
}
|
|
428
|
+
function openApiFetchOptions(g, cfg) {
|
|
429
|
+
const configured = g.validation?.library ?? "zod";
|
|
430
|
+
const library = configured === "typebox" ? "zod" : configured;
|
|
431
|
+
const siblings = cfg.generators.filter((s) => s.kind === library);
|
|
432
|
+
const derived = siblings.length === 1 ? projectRelative3(siblings[0].path ?? VALIDATOR_DEFAULT_DIRS3[library]) : void 0;
|
|
433
|
+
return {
|
|
434
|
+
outputDir: openApiFetchOutDir(g, cfg),
|
|
435
|
+
clientName: g.clientName,
|
|
436
|
+
document: g.document,
|
|
437
|
+
outputHeader: g.outputHeader,
|
|
438
|
+
format: g.format,
|
|
439
|
+
importExtension: g.importExtension,
|
|
440
|
+
validation: {
|
|
441
|
+
...g.validation,
|
|
442
|
+
library,
|
|
443
|
+
useShared: true,
|
|
444
|
+
importPath: g.validation?.importPath ?? derived
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// src/forms-options.ts
|
|
450
|
+
var VALIDATOR_DEFAULT_DIRS4 = {
|
|
451
|
+
zod: "src/validators/zod",
|
|
452
|
+
valibot: "src/validators/valibot",
|
|
453
|
+
arktype: "src/validators/arktype",
|
|
454
|
+
typebox: "src/validators/typebox",
|
|
455
|
+
effect: "src/validators/effect"
|
|
456
|
+
};
|
|
457
|
+
function projectRelative4(p) {
|
|
458
|
+
return p.startsWith("./") ? p.slice(2) : p;
|
|
459
|
+
}
|
|
460
|
+
function formsOptions(g, cfg) {
|
|
461
|
+
const library = g.validation?.library ?? "zod";
|
|
462
|
+
const siblings = cfg.generators.filter((s) => s.kind === library);
|
|
463
|
+
const derived = siblings.length === 1 ? projectRelative4(siblings[0].path ?? VALIDATOR_DEFAULT_DIRS4[library]) : void 0;
|
|
464
|
+
return {
|
|
465
|
+
outputDir: formsOutDir(g, cfg),
|
|
466
|
+
target: g.target,
|
|
467
|
+
modes: g.modes,
|
|
468
|
+
outputHeader: g.outputHeader,
|
|
469
|
+
format: g.format,
|
|
470
|
+
importExtension: g.importExtension,
|
|
471
|
+
validation: {
|
|
472
|
+
...g.validation,
|
|
473
|
+
library,
|
|
474
|
+
useShared: true,
|
|
475
|
+
importPath: g.validation?.importPath ?? derived
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// src/elysia-options.ts
|
|
481
|
+
var VALIDATOR_DEFAULT_DIRS5 = {
|
|
419
482
|
zod: "src/validators/zod",
|
|
420
483
|
valibot: "src/validators/valibot",
|
|
421
484
|
arktype: "src/validators/arktype",
|
|
422
485
|
typebox: "src/validators/typebox"
|
|
423
486
|
};
|
|
424
|
-
function
|
|
487
|
+
function projectRelative5(p) {
|
|
425
488
|
return p.startsWith("./") ? p.slice(2) : p;
|
|
426
489
|
}
|
|
427
490
|
function elysiaOptions(g, cfg) {
|
|
428
491
|
const library = g.validation?.library ?? "zod";
|
|
429
492
|
const siblings = cfg.generators.filter((s) => s.kind === library);
|
|
430
|
-
const derived = siblings.length === 1 ?
|
|
493
|
+
const derived = siblings.length === 1 ? projectRelative5(siblings[0].path ?? VALIDATOR_DEFAULT_DIRS5[library]) : void 0;
|
|
431
494
|
return {
|
|
432
495
|
outputDir: elysiaOutDir(g, cfg),
|
|
433
496
|
appName: g.appName,
|
|
@@ -469,19 +532,19 @@ function fastCheckOptions(g, cfg) {
|
|
|
469
532
|
}
|
|
470
533
|
|
|
471
534
|
// src/h3-options.ts
|
|
472
|
-
var
|
|
535
|
+
var VALIDATOR_DEFAULT_DIRS6 = {
|
|
473
536
|
zod: "src/validators/zod",
|
|
474
537
|
valibot: "src/validators/valibot",
|
|
475
538
|
arktype: "src/validators/arktype"
|
|
476
539
|
};
|
|
477
|
-
function
|
|
540
|
+
function projectRelative6(p) {
|
|
478
541
|
return p.startsWith("./") ? p.slice(2) : p;
|
|
479
542
|
}
|
|
480
543
|
function h3Options(g, cfg) {
|
|
481
544
|
const configured = g.validation?.library ?? "zod";
|
|
482
545
|
const library = configured === "typebox" ? "zod" : configured;
|
|
483
546
|
const siblings = cfg.generators.filter((s) => s.kind === library);
|
|
484
|
-
const derived = siblings.length === 1 ?
|
|
547
|
+
const derived = siblings.length === 1 ? projectRelative6(siblings[0].path ?? VALIDATOR_DEFAULT_DIRS6[library]) : void 0;
|
|
485
548
|
return {
|
|
486
549
|
outputDir: h3OutDir(g, cfg),
|
|
487
550
|
h3: g.h3,
|
|
@@ -515,19 +578,19 @@ function mcpOptions(g, cfg) {
|
|
|
515
578
|
}
|
|
516
579
|
|
|
517
580
|
// src/next-options.ts
|
|
518
|
-
var
|
|
581
|
+
var VALIDATOR_DEFAULT_DIRS7 = {
|
|
519
582
|
zod: "src/validators/zod",
|
|
520
583
|
valibot: "src/validators/valibot",
|
|
521
584
|
arktype: "src/validators/arktype"
|
|
522
585
|
};
|
|
523
|
-
function
|
|
586
|
+
function projectRelative7(p) {
|
|
524
587
|
return p.startsWith("./") ? p.slice(2) : p;
|
|
525
588
|
}
|
|
526
589
|
function nextOptions(g, cfg) {
|
|
527
590
|
const configured = g.validation?.library ?? "zod";
|
|
528
591
|
const library = configured === "typebox" ? "zod" : configured;
|
|
529
592
|
const siblings = cfg.generators.filter((s) => s.kind === library);
|
|
530
|
-
const derived = siblings.length === 1 ?
|
|
593
|
+
const derived = siblings.length === 1 ? projectRelative7(siblings[0].path ?? VALIDATOR_DEFAULT_DIRS7[library]) : void 0;
|
|
531
594
|
return {
|
|
532
595
|
outputDir: nextOutDir(g, cfg),
|
|
533
596
|
naming: g.naming,
|
|
@@ -544,19 +607,19 @@ function nextOptions(g, cfg) {
|
|
|
544
607
|
}
|
|
545
608
|
|
|
546
609
|
// src/tanstack-start-options.ts
|
|
547
|
-
var
|
|
610
|
+
var VALIDATOR_DEFAULT_DIRS8 = {
|
|
548
611
|
zod: "src/validators/zod",
|
|
549
612
|
valibot: "src/validators/valibot",
|
|
550
613
|
arktype: "src/validators/arktype"
|
|
551
614
|
};
|
|
552
|
-
function
|
|
615
|
+
function projectRelative8(p) {
|
|
553
616
|
return p.startsWith("./") ? p.slice(2) : p;
|
|
554
617
|
}
|
|
555
618
|
function tanstackStartOptions(g, cfg) {
|
|
556
619
|
const configured = g.validation?.library ?? "zod";
|
|
557
620
|
const library = configured === "typebox" ? "zod" : configured;
|
|
558
621
|
const siblings = cfg.generators.filter((s) => s.kind === library);
|
|
559
|
-
const derived = siblings.length === 1 ?
|
|
622
|
+
const derived = siblings.length === 1 ? projectRelative8(siblings[0].path ?? VALIDATOR_DEFAULT_DIRS8[library]) : void 0;
|
|
560
623
|
return {
|
|
561
624
|
outputDir: tanstackStartOutDir(g, cfg),
|
|
562
625
|
naming: g.naming,
|
|
@@ -640,7 +703,7 @@ function trpcOptions(g, cfg, servicesDir) {
|
|
|
640
703
|
}
|
|
641
704
|
|
|
642
705
|
// src/generator-registry.ts
|
|
643
|
-
var
|
|
706
|
+
var VALIDATOR_DEFAULT_DIRS9 = {
|
|
644
707
|
zod: "src/validators/zod",
|
|
645
708
|
valibot: "src/validators/valibot",
|
|
646
709
|
arktype: "src/validators/arktype",
|
|
@@ -673,10 +736,14 @@ var GENERATORS = [
|
|
|
673
736
|
//
|
|
674
737
|
// The side effect was invisible and lasted longer than the reason: tsup externalises
|
|
675
738
|
// `dependencies` and `peerDependencies` and bundles everything else, so those eight travelled
|
|
676
|
-
// inside `dist` while the
|
|
677
|
-
// registry now and
|
|
678
|
-
// package that can genuinely be absent, and `loadGenerator` tell absence apart from failure
|
|
679
|
-
//
|
|
739
|
+
// inside `dist` while the rest were resolved from `node_modules`. Every generator package is on
|
|
740
|
+
// the registry now and every one is a `dependencies` entry, which is what makes each of them a
|
|
741
|
+
// package that can genuinely be absent, and `loadGenerator` tell absence apart from failure for
|
|
742
|
+
// every kind rather than for some of them.
|
|
743
|
+
//
|
|
744
|
+
// Counted nowhere on purpose. This paragraph twice carried a number that the next batch of
|
|
745
|
+
// generators falsified, and `packages/cli/test/generator-registry.spec.ts` asserts the property
|
|
746
|
+
// against the manifest, which is where a quantity belongs.
|
|
680
747
|
specifier: "@drzl/generator-trpc",
|
|
681
748
|
load: () => import("@drzl/generator-trpc"),
|
|
682
749
|
construct: (m, analysis) => new m.TRPCGenerator(analysis),
|
|
@@ -728,9 +795,10 @@ var GENERATORS = [
|
|
|
728
795
|
// This one and the three below spent one release each in `optionalDependencies`, because a
|
|
729
796
|
// package that has never existed cannot publish through npm's trusted-publisher OIDC flow and
|
|
730
797
|
// naming it as a hard dependency in the release that introduces it breaks `npm i @drzl/cli`
|
|
731
|
-
// for everyone until the first publish lands.
|
|
732
|
-
//
|
|
733
|
-
//
|
|
798
|
+
// for everyone until the first publish lands. The six generators added after them went the
|
|
799
|
+
// same way and were promoted the same way once their first versions were published by hand.
|
|
800
|
+
// Nothing here is optional any more. `scripts/verify/stages/33-registry-deps.sh` gates both
|
|
801
|
+
// halves of that rule and is what reported each promotion was due.
|
|
734
802
|
specifier: "@drzl/generator-mcp",
|
|
735
803
|
load: () => import("@drzl/generator-mcp"),
|
|
736
804
|
construct: (m, analysis) => new m.MCPGenerator(analysis),
|
|
@@ -785,6 +853,22 @@ var GENERATORS = [
|
|
|
785
853
|
outputDir: (g, cfg) => tsRestOutDir(g, cfg),
|
|
786
854
|
options: (g, cfg) => tsRestOptions(g, cfg)
|
|
787
855
|
},
|
|
856
|
+
{
|
|
857
|
+
kind: "openapi-fetch",
|
|
858
|
+
specifier: "@drzl/generator-openapi-fetch",
|
|
859
|
+
load: () => import("@drzl/generator-openapi-fetch"),
|
|
860
|
+
construct: (m, analysis) => new m.OpenApiFetchGenerator(analysis),
|
|
861
|
+
outputDir: (g, cfg) => openApiFetchOutDir(g, cfg),
|
|
862
|
+
options: (g, cfg) => openApiFetchOptions(g, cfg)
|
|
863
|
+
},
|
|
864
|
+
{
|
|
865
|
+
kind: "forms",
|
|
866
|
+
specifier: "@drzl/generator-forms",
|
|
867
|
+
load: () => import("@drzl/generator-forms"),
|
|
868
|
+
construct: (m, analysis) => new m.FormsGenerator(analysis),
|
|
869
|
+
outputDir: (g, cfg) => formsOutDir(g, cfg),
|
|
870
|
+
options: (g, cfg) => formsOptions(g, cfg)
|
|
871
|
+
},
|
|
788
872
|
{
|
|
789
873
|
kind: "elysia",
|
|
790
874
|
specifier: "@drzl/generator-elysia",
|
|
@@ -822,7 +906,7 @@ var GENERATORS = [
|
|
|
822
906
|
specifier: "@drzl/generator-zod",
|
|
823
907
|
load: () => import("@drzl/generator-zod"),
|
|
824
908
|
construct: (m, analysis) => new m.ZodGenerator(analysis),
|
|
825
|
-
outputDir: (g) => g.path ??
|
|
909
|
+
outputDir: (g) => g.path ?? VALIDATOR_DEFAULT_DIRS9.zod,
|
|
826
910
|
// `meta` is zod-only; see `GeneratorCapabilities.meta` for why it is not passed to the other
|
|
827
911
|
// four rather than being passed and ignored.
|
|
828
912
|
options: (g, cfg, ctx) => validationOptions(g, cfg, ctx.outDir, {
|
|
@@ -836,7 +920,7 @@ var GENERATORS = [
|
|
|
836
920
|
specifier: "@drzl/generator-valibot",
|
|
837
921
|
load: () => import("@drzl/generator-valibot"),
|
|
838
922
|
construct: (m, analysis) => new m.ValibotGenerator(analysis),
|
|
839
|
-
outputDir: (g) => g.path ??
|
|
923
|
+
outputDir: (g) => g.path ?? VALIDATOR_DEFAULT_DIRS9.valibot,
|
|
840
924
|
options: (g, cfg, ctx) => validationOptions(g, cfg, ctx.outDir, { schemaTypes: true, constraints: true })
|
|
841
925
|
},
|
|
842
926
|
{
|
|
@@ -844,7 +928,7 @@ var GENERATORS = [
|
|
|
844
928
|
specifier: "@drzl/generator-arktype",
|
|
845
929
|
load: () => import("@drzl/generator-arktype"),
|
|
846
930
|
construct: (m, analysis) => new m.ArkTypeGenerator(analysis),
|
|
847
|
-
outputDir: (g) => g.path ??
|
|
931
|
+
outputDir: (g) => g.path ?? VALIDATOR_DEFAULT_DIRS9.arktype,
|
|
848
932
|
options: (g, cfg, ctx) => validationOptions(g, cfg, ctx.outDir, { schemaTypes: false })
|
|
849
933
|
},
|
|
850
934
|
{
|
|
@@ -852,7 +936,7 @@ var GENERATORS = [
|
|
|
852
936
|
specifier: "@drzl/generator-typebox",
|
|
853
937
|
load: () => import("@drzl/generator-typebox"),
|
|
854
938
|
construct: (m, analysis) => new m.TypeBoxGenerator(analysis),
|
|
855
|
-
outputDir: (g) => g.path ??
|
|
939
|
+
outputDir: (g) => g.path ?? VALIDATOR_DEFAULT_DIRS9.typebox,
|
|
856
940
|
options: (g, cfg, ctx) => validationOptions(g, cfg, ctx.outDir, { schemaTypes: true, standardSchema: true })
|
|
857
941
|
},
|
|
858
942
|
{
|
|
@@ -860,7 +944,7 @@ var GENERATORS = [
|
|
|
860
944
|
specifier: "@drzl/generator-effect",
|
|
861
945
|
load: () => import("@drzl/generator-effect"),
|
|
862
946
|
construct: (m, analysis) => new m.EffectGenerator(analysis),
|
|
863
|
-
outputDir: (g) => g.path ??
|
|
947
|
+
outputDir: (g) => g.path ?? VALIDATOR_DEFAULT_DIRS9.effect,
|
|
864
948
|
options: (g, cfg, ctx) => validationOptions(g, cfg, ctx.outDir, { schemaTypes: true })
|
|
865
949
|
},
|
|
866
950
|
{
|
|
@@ -868,7 +952,7 @@ var GENERATORS = [
|
|
|
868
952
|
specifier: "@drzl/generator-json-schema",
|
|
869
953
|
load: () => import("@drzl/generator-json-schema"),
|
|
870
954
|
construct: (m, analysis) => new m.JsonSchemaGenerator(analysis),
|
|
871
|
-
outputDir: (g) => g.path ??
|
|
955
|
+
outputDir: (g) => g.path ?? VALIDATOR_DEFAULT_DIRS9["json-schema"],
|
|
872
956
|
options: (g, cfg, ctx) => jsonSchemaOptions(g, cfg, ctx.outDir)
|
|
873
957
|
}
|
|
874
958
|
];
|
|
@@ -2182,6 +2266,24 @@ function sqlLiterals(values) {
|
|
|
2182
2266
|
function derivedName(table, column) {
|
|
2183
2267
|
return `${table}_${column}_check`;
|
|
2184
2268
|
}
|
|
2269
|
+
var ADD_CONSTRAINT_DIALECTS = /* @__PURE__ */ new Set([
|
|
2270
|
+
"postgres",
|
|
2271
|
+
"cockroach",
|
|
2272
|
+
"gel",
|
|
2273
|
+
"mysql",
|
|
2274
|
+
"singlestore",
|
|
2275
|
+
"mssql"
|
|
2276
|
+
]);
|
|
2277
|
+
function fixFor(dialect, table, column, values) {
|
|
2278
|
+
if (!ADD_CONSTRAINT_DIALECTS.has(dialect)) return void 0;
|
|
2279
|
+
return `ALTER TABLE ${table} ADD CONSTRAINT ${derivedName(table, column)} CHECK (${column} IN (${sqlLiterals(values)}));`;
|
|
2280
|
+
}
|
|
2281
|
+
function noFixReason(dialect) {
|
|
2282
|
+
if (dialect === "sqlite") {
|
|
2283
|
+
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.";
|
|
2284
|
+
}
|
|
2285
|
+
return `no statement is emitted for the "${dialect}" dialect, which this report cannot name a fix for`;
|
|
2286
|
+
}
|
|
2185
2287
|
function buildConstraintDriftReport(analysis, schemaPath) {
|
|
2186
2288
|
const entries = [];
|
|
2187
2289
|
for (const table of analysis.tables) {
|
|
@@ -2217,7 +2319,10 @@ function buildConstraintDriftReport(analysis, schemaPath) {
|
|
|
2217
2319
|
columns: [column.name],
|
|
2218
2320
|
rule: `${column.name} IN (${sqlLiterals(values)})`,
|
|
2219
2321
|
reason: `the column is declared ${column.sqlType}, which holds any text; the set exists only in the generated schemas`,
|
|
2220
|
-
|
|
2322
|
+
...(() => {
|
|
2323
|
+
const fix = fixFor(analysis.dialect, table.name, column.name, values);
|
|
2324
|
+
return fix ? { fix } : { noFix: noFixReason(analysis.dialect) };
|
|
2325
|
+
})()
|
|
2221
2326
|
});
|
|
2222
2327
|
}
|
|
2223
2328
|
}
|
|
@@ -2292,6 +2397,8 @@ function renderConstraintDriftReport(report, style = PLAIN3) {
|
|
|
2292
2397
|
if (e.fix) {
|
|
2293
2398
|
out.push(chalk.dim(" Close it with:"));
|
|
2294
2399
|
out.push(` ${e.fix}`);
|
|
2400
|
+
} else if (e.noFix) {
|
|
2401
|
+
out.push(chalk.dim(wrap3(e.noFix, " ", " Not one statement here: ")));
|
|
2295
2402
|
}
|
|
2296
2403
|
out.push("");
|
|
2297
2404
|
}
|
|
@@ -2323,6 +2430,271 @@ function renderConstraintDriftReport(report, style = PLAIN3) {
|
|
|
2323
2430
|
);
|
|
2324
2431
|
return out.join("\n");
|
|
2325
2432
|
}
|
|
2433
|
+
function renderConstraintDriftSql(report) {
|
|
2434
|
+
const gaps = report.entries.filter((e) => e.side === "schema-only");
|
|
2435
|
+
if (!gaps.length) return "";
|
|
2436
|
+
const out = [];
|
|
2437
|
+
const runnable = gaps.filter((g) => g.fix);
|
|
2438
|
+
const unrunnable = gaps.filter((g) => !g.fix);
|
|
2439
|
+
out.push(`-- Generated by drzl doctor --constraints --sql`);
|
|
2440
|
+
out.push(`-- ${report.schema}, ${report.dialect}`);
|
|
2441
|
+
out.push(
|
|
2442
|
+
`-- ${runnable.length} constraint(s) your schemas enforce and the database does not.`
|
|
2443
|
+
);
|
|
2444
|
+
if (unrunnable.length) {
|
|
2445
|
+
out.push(
|
|
2446
|
+
`-- ${unrunnable.length} more cannot be closed by one statement on this dialect; see below.`
|
|
2447
|
+
);
|
|
2448
|
+
}
|
|
2449
|
+
out.push("");
|
|
2450
|
+
const byPlace = (a, b) => a.table.localeCompare(b.table) || a.columns.join().localeCompare(b.columns.join());
|
|
2451
|
+
for (const g of [...runnable].sort(byPlace)) {
|
|
2452
|
+
out.push(`-- ${g.table}.${g.columns.join(", ")}: ${g.reason}`);
|
|
2453
|
+
out.push(g.fix);
|
|
2454
|
+
out.push("");
|
|
2455
|
+
}
|
|
2456
|
+
if (unrunnable.length) {
|
|
2457
|
+
out.push("-- The rest, which this dialect cannot do in one statement:");
|
|
2458
|
+
for (const g of [...unrunnable].sort(byPlace)) {
|
|
2459
|
+
out.push(`--`);
|
|
2460
|
+
out.push(`-- ${g.table}.${g.columns.join(", ")}`);
|
|
2461
|
+
out.push(`-- ${g.rule}`);
|
|
2462
|
+
for (const line of (g.noFix ?? "").match(/.{1,88}(\s|$)/g) ?? []) {
|
|
2463
|
+
out.push(`-- ${line.trim()}`);
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
out.push("");
|
|
2467
|
+
}
|
|
2468
|
+
return out.join("\n");
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
// src/policy-report.ts
|
|
2472
|
+
import { Chalk as Chalk5 } from "chalk";
|
|
2473
|
+
var PLAIN4 = new Chalk5({ level: 0 });
|
|
2474
|
+
var COMMANDS = ["select", "insert", "update", "delete"];
|
|
2475
|
+
var DENIAL = {
|
|
2476
|
+
select: "every read returns zero rows",
|
|
2477
|
+
insert: "every insert is refused",
|
|
2478
|
+
update: "every update is refused",
|
|
2479
|
+
delete: "every delete is refused"
|
|
2480
|
+
};
|
|
2481
|
+
function appliesTo(policy, command) {
|
|
2482
|
+
const forCmd = (policy.for ?? "all").toLowerCase();
|
|
2483
|
+
return forCmd === "all" || forCmd === command;
|
|
2484
|
+
}
|
|
2485
|
+
function grants(policy, command) {
|
|
2486
|
+
if (!appliesTo(policy, command)) return false;
|
|
2487
|
+
if ((policy.as ?? "permissive").toLowerCase() === "restrictive") return false;
|
|
2488
|
+
const hasUsing = !!policy.using;
|
|
2489
|
+
const hasCheck = !!policy.withCheck;
|
|
2490
|
+
const forCmd = (policy.for ?? "all").toLowerCase();
|
|
2491
|
+
if (forCmd === "all") return hasUsing || hasCheck;
|
|
2492
|
+
switch (command) {
|
|
2493
|
+
case "select":
|
|
2494
|
+
case "delete":
|
|
2495
|
+
return hasUsing;
|
|
2496
|
+
case "insert":
|
|
2497
|
+
return hasCheck;
|
|
2498
|
+
case "update":
|
|
2499
|
+
return hasCheck || hasUsing;
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
function hasAnyExpression(policy) {
|
|
2503
|
+
return !!policy.using || !!policy.withCheck;
|
|
2504
|
+
}
|
|
2505
|
+
function readTable(table) {
|
|
2506
|
+
if (typeof table.rlsEnabled !== "boolean") return void 0;
|
|
2507
|
+
const policies = table.policies ?? [];
|
|
2508
|
+
const declaredRls = table.rlsEnabled;
|
|
2509
|
+
const effective = declaredRls || policies.length > 0;
|
|
2510
|
+
if (!effective) return void 0;
|
|
2511
|
+
const grantMap = {};
|
|
2512
|
+
for (const c of COMMANDS) grantMap[c] = policies.some((p) => grants(p, c));
|
|
2513
|
+
return { table: table.name, declaredRls, effective, policies, grants: grantMap };
|
|
2514
|
+
}
|
|
2515
|
+
function buildPolicyReport(analysis, schemaPath) {
|
|
2516
|
+
const tables = [];
|
|
2517
|
+
const findings = [];
|
|
2518
|
+
for (const table of analysis.tables) {
|
|
2519
|
+
const read = readTable(table);
|
|
2520
|
+
if (!read) continue;
|
|
2521
|
+
tables.push(read);
|
|
2522
|
+
if (COMMANDS.every((c) => !read.grants[c])) {
|
|
2523
|
+
findings.push({
|
|
2524
|
+
kind: "denied",
|
|
2525
|
+
table: read.table,
|
|
2526
|
+
detail: "row-level security is on and no permissive policy grants anything, so every read returns zero rows and every write is refused, for every role but the table's owner and any role with BYPASSRLS",
|
|
2527
|
+
// Deliberately not "give it a USING": a policy written `FOR INSERT` consults only its
|
|
2528
|
+
// WITH CHECK, so that advice would be wrong for exactly the declaration this report exists
|
|
2529
|
+
// to catch. The per-policy findings below name the right expression for each one.
|
|
2530
|
+
fix: read.policies.length ? `no policy here grants a command: ${read.policies.map((p) => `"${p.name}"`).join(", ")} ${read.policies.length === 1 ? "needs" : "need"} a USING expression, or a WITH CHECK where the command is INSERT` : "declare a policy, or drop the row-level security on this table"
|
|
2531
|
+
});
|
|
2532
|
+
} else {
|
|
2533
|
+
for (const command of COMMANDS) {
|
|
2534
|
+
if (read.grants[command]) continue;
|
|
2535
|
+
const named = read.policies.filter((p) => appliesTo(p, command));
|
|
2536
|
+
findings.push({
|
|
2537
|
+
kind: "denied",
|
|
2538
|
+
table: read.table,
|
|
2539
|
+
command,
|
|
2540
|
+
detail: `row-level security is on and no permissive policy grants ${command.toUpperCase()}, so ${DENIAL[command]} for every role but the table's owner and any role with BYPASSRLS`,
|
|
2541
|
+
fix: named.length ? `${named.length === 1 ? "the policy" : "the policies"} ${named.map((p) => `"${p.name}"`).join(", ")} name${named.length === 1 ? "s" : ""} ${command.toUpperCase()} but grant${named.length === 1 ? "s" : ""} nothing; give ${command === "insert" ? "it a WITH CHECK" : "it a USING"} expression` : `declare a policy for ${command.toUpperCase()}, or drop the row-level security on this table`
|
|
2542
|
+
});
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2545
|
+
for (const policy of read.policies) {
|
|
2546
|
+
if (hasAnyExpression(policy)) continue;
|
|
2547
|
+
findings.push({
|
|
2548
|
+
kind: "grants-nothing",
|
|
2549
|
+
table: read.table,
|
|
2550
|
+
policy: policy.name,
|
|
2551
|
+
detail: "the policy carries neither a USING nor a WITH CHECK expression, so it permits nothing and only the policies beside it decide what this table allows",
|
|
2552
|
+
fix: (policy.for ?? "all").toLowerCase() === "insert" ? "give it a WITH CHECK expression, which is the only one INSERT consults" : "give it a USING expression, and a WITH CHECK too where writes need a different rule"
|
|
2553
|
+
});
|
|
2554
|
+
}
|
|
2555
|
+
}
|
|
2556
|
+
const policies = tables.reduce((n, t) => n + t.policies.length, 0);
|
|
2557
|
+
return {
|
|
2558
|
+
schema: schemaPath,
|
|
2559
|
+
dialect: analysis.dialect,
|
|
2560
|
+
ok: findings.length === 0,
|
|
2561
|
+
counts: {
|
|
2562
|
+
tables: analysis.tables.length,
|
|
2563
|
+
withRls: tables.length,
|
|
2564
|
+
policies,
|
|
2565
|
+
findings: findings.length
|
|
2566
|
+
},
|
|
2567
|
+
tables,
|
|
2568
|
+
findings,
|
|
2569
|
+
ignoredByGeneratedCode: tables.map((t) => t.table)
|
|
2570
|
+
};
|
|
2571
|
+
}
|
|
2572
|
+
function wrap4(text, indent, first = indent, width = 96) {
|
|
2573
|
+
const words = text.split(/\s+/).filter(Boolean);
|
|
2574
|
+
const lines = [];
|
|
2575
|
+
let line = first;
|
|
2576
|
+
let started = false;
|
|
2577
|
+
for (const w of words) {
|
|
2578
|
+
if (started && line.length + 1 + w.length > width) {
|
|
2579
|
+
lines.push(line);
|
|
2580
|
+
line = indent + w;
|
|
2581
|
+
} else {
|
|
2582
|
+
line = started ? `${line} ${w}` : line + w;
|
|
2583
|
+
started = true;
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
if (started) lines.push(line);
|
|
2587
|
+
return lines.join("\n");
|
|
2588
|
+
}
|
|
2589
|
+
function policyLine(policy) {
|
|
2590
|
+
const bits = [(policy.for ?? "all").toUpperCase()];
|
|
2591
|
+
if ((policy.as ?? "").toLowerCase() === "restrictive") bits.push("restrictive");
|
|
2592
|
+
bits.push(policy.to?.length ? `to ${policy.to.join(", ")}` : "to public");
|
|
2593
|
+
const carries = [policy.using ? "USING" : void 0, policy.withCheck ? "WITH CHECK" : void 0].filter(Boolean).join(" + ");
|
|
2594
|
+
bits.push(carries || "no expression");
|
|
2595
|
+
if (policy.linked) bits.push("linked");
|
|
2596
|
+
return bits.join(", ");
|
|
2597
|
+
}
|
|
2598
|
+
function renderPolicyReport(report, style = PLAIN4) {
|
|
2599
|
+
const chalk = style;
|
|
2600
|
+
const out = [];
|
|
2601
|
+
const plural = (n, one) => `${n} ${one}${n === 1 ? "" : "s"}`;
|
|
2602
|
+
out.push(chalk.bold(`DRZL row-level security ${report.schema}`));
|
|
2603
|
+
out.push(
|
|
2604
|
+
chalk.dim(
|
|
2605
|
+
`${report.dialect}, ${plural(report.counts.withRls, "table")} under RLS, ${plural(report.counts.policies, "policy").replace("policys", "policies")}`
|
|
2606
|
+
)
|
|
2607
|
+
);
|
|
2608
|
+
out.push("");
|
|
2609
|
+
if (!report.counts.withRls) {
|
|
2610
|
+
out.push(chalk.green("No table in this schema uses row-level security."));
|
|
2611
|
+
if (report.dialect !== "postgres" && report.dialect !== "cockroach") {
|
|
2612
|
+
out.push(chalk.dim(` ${report.dialect} has no row-level security to declare.`));
|
|
2613
|
+
} else {
|
|
2614
|
+
out.push(chalk.dim(" No table calls .enableRLS() and none declares a pgPolicy."));
|
|
2615
|
+
}
|
|
2616
|
+
return out.join("\n");
|
|
2617
|
+
}
|
|
2618
|
+
const denied = report.findings.filter((f) => f.kind === "denied");
|
|
2619
|
+
if (denied.length) {
|
|
2620
|
+
out.push(chalk.red("These tables refuse the operation your generated code performs"));
|
|
2621
|
+
out.push(
|
|
2622
|
+
chalk.dim(
|
|
2623
|
+
wrap4(
|
|
2624
|
+
"A table under row-level security permits only what a policy grants. The generated service still compiles and its return type still promises rows.",
|
|
2625
|
+
" "
|
|
2626
|
+
)
|
|
2627
|
+
)
|
|
2628
|
+
);
|
|
2629
|
+
out.push("");
|
|
2630
|
+
for (const f of denied) {
|
|
2631
|
+
out.push(` ${chalk.dim("-")} ${chalk.bold(f.table)} ${chalk.dim(f.command ?? "everything")}`);
|
|
2632
|
+
out.push(wrap4(f.detail, " "));
|
|
2633
|
+
if (f.fix) out.push(chalk.dim(wrap4(f.fix, " ", " Close it: ")));
|
|
2634
|
+
out.push("");
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
2637
|
+
const dead = report.findings.filter((f) => f.kind === "grants-nothing");
|
|
2638
|
+
if (dead.length) {
|
|
2639
|
+
out.push(chalk.yellow("These policies grant nothing"));
|
|
2640
|
+
out.push(
|
|
2641
|
+
chalk.dim(
|
|
2642
|
+
wrap4(
|
|
2643
|
+
"A policy with neither expression is not a permissive default. Measured against Postgres 18.3: a lone FOR INSERT policy carrying no WITH CHECK refused every insert.",
|
|
2644
|
+
" "
|
|
2645
|
+
)
|
|
2646
|
+
)
|
|
2647
|
+
);
|
|
2648
|
+
out.push("");
|
|
2649
|
+
for (const f of dead) {
|
|
2650
|
+
out.push(` ${chalk.dim("-")} ${chalk.bold(f.table)}.${f.policy}`);
|
|
2651
|
+
out.push(wrap4(f.detail, " "));
|
|
2652
|
+
if (f.fix) out.push(chalk.dim(wrap4(f.fix, " ", " Close it: ")));
|
|
2653
|
+
out.push("");
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
out.push(chalk.cyan("The policies each table carries"));
|
|
2657
|
+
out.push(
|
|
2658
|
+
chalk.dim(
|
|
2659
|
+
wrap4(
|
|
2660
|
+
"Whether these apply to the role your application connects as is the one question this report cannot answer for you.",
|
|
2661
|
+
" "
|
|
2662
|
+
)
|
|
2663
|
+
)
|
|
2664
|
+
);
|
|
2665
|
+
out.push("");
|
|
2666
|
+
for (const t of report.tables) {
|
|
2667
|
+
const flag = t.declaredRls ? "enableRLS()" : "RLS on, implied by its policies";
|
|
2668
|
+
out.push(` ${chalk.dim("-")} ${chalk.bold(t.table)} ${chalk.dim(`(${flag})`)}`);
|
|
2669
|
+
if (!t.policies.length) {
|
|
2670
|
+
out.push(chalk.dim(" no policies"));
|
|
2671
|
+
}
|
|
2672
|
+
for (const p of t.policies) {
|
|
2673
|
+
out.push(` ${p.name}: ${chalk.dim(policyLine(p))}`);
|
|
2674
|
+
}
|
|
2675
|
+
const permitted = COMMANDS.filter((c) => t.grants[c]);
|
|
2676
|
+
out.push(
|
|
2677
|
+
chalk.dim(` permits: ${permitted.length ? permitted.join(", ") : "nothing"}`)
|
|
2678
|
+
);
|
|
2679
|
+
out.push("");
|
|
2680
|
+
}
|
|
2681
|
+
out.push(chalk.yellow("What DRZL generates does not know about any of this"));
|
|
2682
|
+
out.push(
|
|
2683
|
+
chalk.dim(
|
|
2684
|
+
wrap4(
|
|
2685
|
+
`No generator emits policy awareness, so a generated read path describes rows the caller may not be allowed to see, and a reader of the emitted types will believe otherwise. That is true of ${plural(report.ignoredByGeneratedCode.length, "table")} here, and it is a fact about DRZL rather than a defect in your schema.`,
|
|
2686
|
+
" "
|
|
2687
|
+
)
|
|
2688
|
+
)
|
|
2689
|
+
);
|
|
2690
|
+
out.push("");
|
|
2691
|
+
out.push(
|
|
2692
|
+
chalk.dim(
|
|
2693
|
+
`${plural(report.counts.findings, "finding")} across ${plural(report.counts.withRls, "table")} under row-level security.`
|
|
2694
|
+
)
|
|
2695
|
+
);
|
|
2696
|
+
return out.join("\n");
|
|
2697
|
+
}
|
|
2326
2698
|
|
|
2327
2699
|
// src/drift.ts
|
|
2328
2700
|
import { promises as fs2 } from "fs";
|
|
@@ -3249,6 +3621,9 @@ withOutputFlags(
|
|
|
3249
3621
|
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(
|
|
3250
3622
|
"--constraints",
|
|
3251
3623
|
"report what each side enforces that the other does not, instead of the usual findings"
|
|
3624
|
+
).option("--sql", "with --constraints, emit the statements alone, for redirecting to a migration").option(
|
|
3625
|
+
"--policies",
|
|
3626
|
+
"report the row-level security policies each table carries, and what they refuse"
|
|
3252
3627
|
)
|
|
3253
3628
|
).action(async (schema, opts) => {
|
|
3254
3629
|
const out = outputFor(opts);
|
|
@@ -3272,7 +3647,21 @@ withOutputFlags(
|
|
|
3272
3647
|
validateConstraints: true
|
|
3273
3648
|
});
|
|
3274
3649
|
const schemaLabel = Array.isArray(target) ? target.join(", ") : target;
|
|
3275
|
-
if (opts.constraints) {
|
|
3650
|
+
if (opts.sql && !opts.constraints) {
|
|
3651
|
+
const msg = "--sql reports the constraint drift as statements, so it needs --constraints.";
|
|
3652
|
+
if (opts.json) out.jsonData(jsonFailure("doctor", "DRZL_CLI_DOCTOR", msg));
|
|
3653
|
+
else out.error("Doctor failed (DRZL_CLI_DOCTOR):", msg);
|
|
3654
|
+
process.exit(EXIT_FAILED);
|
|
3655
|
+
return;
|
|
3656
|
+
}
|
|
3657
|
+
if (opts.constraints && opts.policies) {
|
|
3658
|
+
const msg = "--constraints and --policies are separate reports. Run one, then the other.";
|
|
3659
|
+
if (opts.json) out.jsonData(jsonFailure("doctor", "DRZL_CLI_DOCTOR", msg));
|
|
3660
|
+
else out.error("Doctor failed (DRZL_CLI_DOCTOR):", msg);
|
|
3661
|
+
process.exit(EXIT_FAILED);
|
|
3662
|
+
return;
|
|
3663
|
+
}
|
|
3664
|
+
if (opts.constraints || opts.policies) {
|
|
3276
3665
|
const preflight = buildDoctorReport(analysis, schemaLabel);
|
|
3277
3666
|
const fatal = preflight.findings.filter((f) => f.level === "error");
|
|
3278
3667
|
if (fatal.length) {
|
|
@@ -3288,8 +3677,15 @@ withOutputFlags(
|
|
|
3288
3677
|
process.exit(EXIT_FAILED);
|
|
3289
3678
|
return;
|
|
3290
3679
|
}
|
|
3680
|
+
}
|
|
3681
|
+
if (opts.constraints) {
|
|
3291
3682
|
const drift = buildConstraintDriftReport(analysis, schemaLabel);
|
|
3292
3683
|
const driftCode = opts.strict && drift.counts.schemaOnly ? EXIT_FINDINGS : EXIT_OK;
|
|
3684
|
+
if (opts.sql) {
|
|
3685
|
+
out.data(renderConstraintDriftSql(drift));
|
|
3686
|
+
process.exit(driftCode);
|
|
3687
|
+
return;
|
|
3688
|
+
}
|
|
3293
3689
|
if (opts.json)
|
|
3294
3690
|
out.data(
|
|
3295
3691
|
JSON.stringify({ command: "doctor", exitCode: driftCode, ...drift }, null, 2)
|
|
@@ -3298,6 +3694,17 @@ withOutputFlags(
|
|
|
3298
3694
|
process.exit(driftCode);
|
|
3299
3695
|
return;
|
|
3300
3696
|
}
|
|
3697
|
+
if (opts.policies) {
|
|
3698
|
+
const policies = buildPolicyReport(analysis, schemaLabel);
|
|
3699
|
+
const policyCode = opts.strict && policies.counts.findings ? EXIT_FINDINGS : EXIT_OK;
|
|
3700
|
+
if (opts.json)
|
|
3701
|
+
out.data(
|
|
3702
|
+
JSON.stringify({ command: "doctor", exitCode: policyCode, ...policies }, null, 2)
|
|
3703
|
+
);
|
|
3704
|
+
else out.data(renderPolicyReport(policies, out.outStyle));
|
|
3705
|
+
process.exit(policyCode);
|
|
3706
|
+
return;
|
|
3707
|
+
}
|
|
3301
3708
|
const report = buildDoctorReport(analysis, schemaLabel);
|
|
3302
3709
|
const unreadable = report.findings.some((f) => f.level === "error");
|
|
3303
3710
|
const code = unreadable ? EXIT_FAILED : opts.strict && report.findings.length ? EXIT_FINDINGS : EXIT_OK;
|