From 9416647b404b5c04b554142361904277e4ab2c5d Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Wed, 21 Oct 2026 02:40:31 -0400 Subject: [PATCH 1/6] feat(docs): replace logo with the key-column symbol Adds a dark-mode logo and a tile favicon, swaps the touch icon, and regenerates the social card. --- docs/.vitepress/config.mts | 4 ++-- docs/.vitepress/og-source.html | 2 +- docs/public/image/favicon.svg | 9 +++++++++ docs/public/image/logo-dark.svg | 9 +++++++++ docs/public/image/logo.png | Bin 46953 -> 2178 bytes docs/public/image/logo.svg | 13 ++++++++----- docs/public/image/og.png | Bin 60183 -> 59157 bytes 7 files changed, 29 insertions(+), 8 deletions(-) create mode 100644 docs/public/image/favicon.svg create mode 100644 docs/public/image/logo-dark.svg diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index f5ec6b7f..8b94275b 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -41,7 +41,7 @@ export default withMermaid( // The `title`/`description` config fields cover and <meta name>; // everything social has to be spelled out here. head: [ - ['link', { rel: 'icon', href: '/image/logo.svg', type: 'image/svg+xml' }], + ['link', { rel: 'icon', href: '/image/favicon.svg', type: 'image/svg+xml' }], ['link', { rel: 'apple-touch-icon', href: '/image/logo.png' }], ['link', { rel: 'canonical', href: `${SITE_URL}/` }], ['meta', { name: 'theme-color', content: '#E05742' }], @@ -83,7 +83,7 @@ gtag('config', 'G-Y69K95866J');`], }, themeConfig: { // https://vitepress.dev/reference/default-theme-config - logo: '/image/logo.svg', + logo: { light: '/image/logo.svg', dark: '/image/logo-dark.svg' }, siteTitle: 'noorm', // No Home entry: the logo already links to `/`. Dev docs are not a // top-bar concern either — they live in the Reference group of the diff --git a/docs/.vitepress/og-source.html b/docs/.vitepress/og-source.html index 43b370e7..10f43c54 100644 --- a/docs/.vitepress/og-source.html +++ b/docs/.vitepress/og-source.html @@ -116,7 +116,7 @@ <div> <div class="mark"> - <img src="../public/image/logo.svg" alt=""> + <img src="../public/image/logo-dark.svg" alt=""> <span class="wordmark">noorm</span> </div> <h1>Write SQL. Skip the ORM.</h1> diff --git a/docs/public/image/favicon.svg b/docs/public/image/favicon.svg new file mode 100644 index 00000000..58318f9e --- /dev/null +++ b/docs/public/image/favicon.svg @@ -0,0 +1,9 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256"><title>noorm + + + + + + + + \ No newline at end of file diff --git a/docs/public/image/logo-dark.svg b/docs/public/image/logo-dark.svg new file mode 100644 index 00000000..759fc78c --- /dev/null +++ b/docs/public/image/logo-dark.svg @@ -0,0 +1,9 @@ + + noorm symbol, reversed + + + + + + + diff --git a/docs/public/image/logo.png b/docs/public/image/logo.png index 3d069b3c3856ac71d47eccff0988490de0a35ae7..d69bb090cbc90f62b55cddc2ca768ebd30764f40 100644 GIT binary patch literal 2178 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 diff --git a/docs/public/image/logo.svg b/docs/public/image/logo.svg index aa844b1f..d36d0fa8 100644 --- a/docs/public/image/logo.svg +++ b/docs/public/image/logo.svg @@ -1,6 +1,9 @@ - - - - - + + noorm symbol + + + + + + diff --git a/docs/public/image/og.png b/docs/public/image/og.png index 7715c434f1c7df75a70dcd252e41525770003fac..1dfaa91ca1b84baac1abf1b6b2fd05fb71a76cb4 100644 GIT binary patch literal 59157 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

@3h+x(i6LDCfThgR~yOpc(c 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

}i;H9lD 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 {{jhy`KTs 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 literal 60183 zcmeFYRa9KT)-_5XK!OB9a3?@;cZc9^jazVs;L=zEgg^rc?%Fg>kLycWqYgMf^=Ui)ttEtFhq7kDZARu7M%SmY-_;b0s=OIyp#mMJN;k@^^@ilG3v=e%k^^bSRx_|ZvQgy81^;$E0&r#@>?m=dbPLM zFVxk(Im`jeDy`?#6n2+^WzB32j;SAPUE}g0n@@pkW#2X|mKK3-t>+mzBpmrdNbu*t zw+Hahe;NpERFp6OHUBWY4EfJ7VTk4NLEr!MKT}G+`q%uW`Rdh+e=Tpc7gYc0RsDat zI5Y_RUuO^$8~g89At0RKB7XeWK*;sL$v`m83ZN6U;h?x0ThD-1a1w)Qg{>=SjKp!rOj@VIZ2YB? zh`CeoN^jTv!W<(9%Xa%f-bSP_a_DXx3XkWRYjyTM+ZvaXXjIYOd8G-hKeC}`1In=W z{F�#*6djGhUrb?>v#PD~g6tFb)@97KJfhgHZF0Uvg?fRxHD|kj5@Bk8ELKuCfB8 zZ0gb%{5>xzoM`R0GHdj<)>Q`7a+@i@1HPiwkSLHkK9JXnH7_WiB~BI#7d92ox&}CI2eYJvj}%RtBmwLyZ#Qne=A=p&G0FWtFgplbzGx;Ub4>9$osvP>wI?NVQM|6 zR{aCJ@2c1~#OcDl{KnIj0JSK>=IT#^^?;X2Qi-7I5y7m;sN`AVL32k7m1=E_YjD$wlVr$5|Dah(*O=x9XdSYq-t6PMd+*;Lk*A0jW>P)L#ogDxe#6Vgj1 zjBpn4-`?~xVC~n=z`StR9yyTJL19~DAgY}9|1Eg);H;+Dm$A3Fl(gd5LbIG1%N&Ur zkUIZ+p-;mP-Cx%2i9aF6fYKoOk&)oZ?da?`p-hTN{uMjVcv4Hoo3I>^r~Gp6-s!AZ z{px~$>Hv%I`Sm)M@dqxCk?+x>;3nQ2rncjz3c3vjpm~Ib6c71Q{J=Hh*gx4 zBmE7maOla_T|wOiw!!lDbxLJdePFj>K}wR5k2t-}o+Vw~V9fqVkLNkqahhfHe4~Wm zAj&->x4rWs9X#_J(qB|9p@ea1yPae6GZStVIV@CAViYNZZy<4kVG^}n0*2#?_uWs* zFXG}U8iIDDCCUqYF1t8?a=_rn&2IF{O)+vZrLj*=0jr_Fv5OIvA;aA)_jQ72!%v0V z@_>k())QtrkMDiFpaNgv$^sU7-Z+zMNw0{-&KFchWrfKKE9T||`xGmO`DM^|Y3Uxf z>XDhzyvVcFK(>te^WQ6~z1>%g89MM-n!Rt zCi|MN&&)U)|M&tkkIBTb@s}0NGy`?qFXR+I>=eH!7>Mbd0^>dkU^n=8Pd-I@s0GzM zwknPgsyAwJ%F>1N9=Acs{kSVJLqqADLqTiSFN~l~wV46EPJSF(e6J39ai=u1IoIpV z4~#BOy5bs@j!3-E^&UVs82L~8ANo}^MYn&PJ*M#9|2X!5`V}{2_*iH_{@96s{(Ysq zc!n17;nDA>GHr>N)y+t{ggf<;$BsUsCg$2h?YN}U)Ow8cc8JMyQ}-m_Q1ju2aZ>L1 z^=w57iFB|kSQA>p-@klzynD}-f9+Ypbv(Iv_$NVO1+1{G1z!yX>d62Z{?zaDZP!LP zwMlh0e<(eix2=u<0}Z##DvM%KjHg9!7W0^lkQO~W7#)9gJMsE93hFf=>yS%>oELqb z-a0?C+$RZ(Wpu=zH?^rV zm1_FQua?Ii>&ZSp)X42+y(dQI$Wk~xk??M_Ps^20_;mQ(yKy`R=3X~Q&JwELGtGt7 zMTWkM_83=sfpKObK4Ftp$H|lXJ-T$lq>=TSUhNX-uts$d>%V3tZF)yO$6pRsGEEpR z`s>ZXH)&MyIJ9<$_(S!SKM&M*KNRvB!G!g9C8Ri!5v;mVhNtsmhc+r0I&O7#uC|un z;`v;zfS%Gzrdim69R$kX&KBt3d9%}sOj#7%HPEjEFmtz=kq z>V0Gl<%P7y5B$uFjPV(=KZidv<0w*+ZLMwJ8&u{2$~P|_Rb^_(7Fy(cf7{#*^eSR; zGCIKr!@ar&zM_rf&OGPazY+^H%%^f)QaCntR+CyARK0mBjSb(=@-*a_oKYM}JQCdL zCpSx|n!Zp~sA!x`<;b?p1^=SS{(_Xzgeax_>3~I}3-Cz3EB?yi&**&5?w={^wao4K zD69sG4B81~0OXtzJ|L6y+?|QL3yoBdn3$@u#3nu3hBy;>P%2Aj(_=iv;a+r1)odok z<$X~v_k~X;bYx=|81eQIssjx4jFyv|PHgG^F_}_(o{gK+RWBQ;AM@0f1PfxQjT*EB z^5QK_%|7Yf!lOL3R{oZ#IZOTXM}E2D26{fZz}+mw{QjF{SF(yey?~?IxI1oW+r_TM zL~vvTi4+AE78@H?NH)7{Mo@!|#EmBr&W=8-_pk<<*_qoPdXF+5r*7Ca zRU7!Y>Na#jT^y=!;LIS%_e&emM@@4PPjVHwADtE*j*^PI&qDXtYl#0fr;-eo`?;$j zUB~^o!ZiU_KWO6Rd6ev#k=k(Sw5d$czeLtV6mT=|)?hF9dHaacFXv{mvQEN9-@GoO z^?Rr^?GLbq1%-5bcuGubZEa#3)JRiRsIhXlQv~^(qBLX4WG^u=I@C$j!(^|U_%1Rw z)n~5CXkKM0Ag7(^;rIFkJ@Q!dm&c5gm++u7>K0xdUdX6K6srt93KYy=A`wX)7pqHe zWb?9;mPx?uIAVA8qPt_7pL-br-7R~)geVeudFhG{8ea72v+4Nu?s~^O@@Q|S5^kb1 zP{hbel(0LC5+hsxtD>ilb&$r2#tW3kI45r2NhqT0^`T&{jv`y5-10};)#dYA&Q$R* zpC9-gbkO9zWssDNpatEE^D}0{4P~mjP7FQ$_yo(q%-960e;f41 zSV2bl#-;55tk2__54y>M0|>Ymz>Fdp+?%h{zSz8^bhO>HQ(J|`=*1A@yB*4Ls_)n2CIR6wMT6GqeOUYHl3Zd5J zbUn+Gf{Oa4Fnjsj733+2c^Lqi%wgQfOC9k)gyW?T7XdN3pt7`~jn)^F^-s5HWbiP; z=*6nWDzz`fr7~@~?#Jnhb_KF(hx-}Gou1Z@#s~JhCv;PT*3j^P0U4FQXpNCfsIJhk zY9?K&Z-dz1_JM2qO`&exwHG8xMxfxNp1UiQq2lcsrJa(_Gt&>VLItb{mpRIVH|Ey1 z%$K_8=92k)OR!8YkznKhY61N$^n%_zk*w7#(K{PL)xje>tN|4ada>_Rx@jym;++bh z_NES$4V*7R(LQ{;bJtN?TK=&8k$Qif8JydCI!Ge&Ft;xeMTUpFbtV!qX8O^T%19~d zg5FE8!Q_)TQW)IZ8a2~ORT2V?AxIK?&3F3be@VIrX&_QZXZ2#1r?I*|vp&GZ?$DZotc@zYRL%1!!mg2y zSY~!`;Awt+R^!e2{;QWA@flx>e<+ZIESZu@y~u%tw|^gQRn664TmIo%%VBQe3DvK< zS;6)f$@qxrF?8u!nLb*iXX>)+waFoFXgA#N&j*T7l=V0T#}`{rN`!;+A`O9x?;8f_f+_jhewGekzS6oQ zzjUt=BU>7t)IElh>CDMM4w`dCs8n~UKy)5C!Z`qwowM`86Y(AV9f`7PE7^ zvt)DTRgBiCI%9yqphXjH%94Ex2E48tHyB;CkZURGz*OJ@*I!tnuA$^Q{rH`x5!b%4 z=TB8Kz_T;nTV~Ww!#21Rx;5%#?ohDO8gB2a5-@^qtchCtNeYOo*;+N#3gv)R!*LLr=y@cXonk0f z!c4I%7#q<=S5QE%>j*h7UIvXSi5HBcy#4M;(s3!R zcsm*ecco_8R^`Dsco@onOP~etkLtnFppIE<(HFxt333SY9(wAMYGS6TF4=EyA2`@w&GaB; z%0Tz_Y=A;~GPG8Nd;2ba-!R57I1;r`L(;Y}6B}t6s8#J6Q;8oVYu2w@_zp{y$Yt%U zjuid@_xPDhx4NaP+tFAY(;+W4AyY_-XVqjeBM{~2*$SZU0vV1|4^PodBs1O1!vi`o z`;-|V5fmwBVfx@_#G2@ikIqHJilqo3_c$wz47qVJ2h7~|fE#a0l9$7jUsp~%k4Nzp z8NUyGBM-y;*zMoMCj!-=SkExQ;KE}ul;~l8B%AB=(hci&rxj}ZJUIhQw%ea+lsKkj zgOMa1wt60PqvtybxgH8+$Yk~~63Y~gU)SGvtOuc7NkpY5svcjyXQieR=~-G{92#!U zs|GB(7%BM~X9$*WoIZ z!GIq))VHr1@&cuc1Q%0=CU4|%A1*X;^jOP{Xyah-?e58$xNj)=>cOdir_=|_Wp84g z+y?FtxN9>`@+6-RwUHfXGud)ETwO?FVjE{s+$$QJDrrJxt4SNw_dlDi@^+LdYN$h^ zRb)}ZByW~WeOeeJICV{IHV8amb!3eo?L;3H`7WpLbrHR$6SHJgQ=R9fBa%_sR4qMc zaA(kJzR#Yjb{c|1;8>!=u2oz(UU|MtuO=+04msv%E7&-18&Th5qbn=O0coKI>fG^~ zYgTTT2?5U%(@%8$j1$t$VTI>~$p_QZW2LADBya}HI#KC$Wf6p=aPKXsn@qUZ1+nUJ z)%`p&D_&MngvS%2IWAcxk_~qph7N;f8kK7%mvU)v3UC>(K+-waJ=r&ZQDUr|<~t7` z!HO`p{UbY|9r#Z{45&t&()(>zes#sK{>h)N3XYG4^ak}uy=12}A}9T@3lYx9UrWOs z{e$enq8X_bxCE9XMJm%D(3f|-*KH4)+lD#e##C#9E6~jftxmj5Vms$6p*=-di)P}u zz=Jz8Z$G=?`+L)~6>M3jH4 zO&moP=_8qukw5i2$HN(32?Z)vBlpXTOAHM3b60v!i+?8}Fs_>?sYjq-Di0d3+o<9A zE8Ue}#1Q@K3+neK#I{p+`ngI(9`K*j)gruJ~j6*E;u&?*n1}) zvot1R{Qr&sLVpl_{FdZ>-~KOEmd)5aKx}Jk%hBAqfc56z8CvZf7yr3f`2S?e*vG!B zxdl@Aw?rt%WX9lKz1OHs4v&e+`cb#R^12VEKIZZtS0XMF5~Q}X?YzZ5J14iyc`<}# z+#H$6xV3v!c~sXGTjwz6bJByiPx$WvLB6kyWH!^-J-;7BRZdje*qZ0#|LZ6y`$-k= z{ONeOt#tWkO3F}>zG37=v(jbprAJLoHO?6bzJpys5empkJUjOGb0oWhuh@JAF(gNb zvUJja>KLkN^xJ%ee0g*KF#WC3Hk3BJp-bMU3wGNzm!aSBd%(qJ&#{D!F+(mR)#9QEO-U)_S8scN0$78prJKiqUq6@{q`!o zSg|ee63G2ieCXc?f0cx&Zd#jPIB_jR@b!q2fN7Vt-3QLAAEvxwXU}k1P=(UGllPnS zb{59)!Q+-?Onx?U?$fQ%T|jYIl8-YRdcH7;KR@&1;sCSn0exVRX{+`DZ;mWpLj02l z*X;!eLzs9*FM(kjWB3i!ADgnMk z%+t)9BiXx4-LGbbhdF3VD*}6$%{mk9xH~GdQvMQ>1&Qp&PlL7kB3kIjW8+syGucm5V5mdoFT^XKv#M~IfIYx({% zEJ^`uYubLro5k$ke#v;#-Pq>ozQ1YpArhra zB3XNG+w3?0m1m>0tU*(7RbN@gl5j{&`Nsn~%XRs&TE?c*uJVesip-O2@V4IkL`g-$ z3+iaXfm$3PfEMLt`sT5+w~>xPQ5970XPo@4c}>5G&P~?pleSE5k~cI#Bs5wdFH5nx zq}p)ka>dEE=dZC5j;SaC7K^I~? zXB+#k7Em{of#L3+Tq&9xS$}M)XQ8E?NnQjgf?Vopq8cDc&)K5wZShtGmQ_SWvwP9e z+!;(KX{3`_5atV~^(?<9+;zOQ{0Yvi>Y@iHeYTi5ztXEeq zXL@!Eig~%(Z)VePv4O&*U6O8`Z~de?_Pg%{FMhyS4nv${TarxI#lux~@Vn&2cCw6b@iApaT~E7E7?@uM76Dh3#5n0q>KHQCC#E+s zaXyZ0+sc}8{jNk;R8SnaPfD~LjC4=773D=UHVX-pcDA0V2hp?g&+C{Df%1>eDmofB+x92}ok(Ev$k<&n{m zZ3>1G{j14%fX{e^W)9asd}3v$Wdm>MZ~+{S`L(s4GtuqtKKM^(D;cO*q#_1}55!S3 zv4j+I>S0Y4nQaYJX(Wqe8Lg+%zIK~mJYQ&kGvV>7o}796it&!s(~bLaU(e3LIObrY ziFjYx$-;spkm1_g9O)=CqbZV(E09DJ4B-V-zP%Ti^v(Xi`I~%`E3%@EceD+ULU#^s4szWiq!gKyFWzUq z0i;V?tdvB>uo^+sKJT|wHu&A~)uzvO)E585pXi+ySH%6|jHNtx&K||WHM!!ef@RMhMf6K}tFw3i*zF$2?M8MzMom#QDGOJywa@j#V6mJOT{27@z{bL^vmqJM78&WpiW;bw?mgW2I4<*i2z21QokS!m%=A2;q`V^@Ay@&k7 z7S4+cL+Pf9iQ6c@xzMeQzKasa5XETe>R-J{DLCku7}Kj_lUh;jn_$e62s=bw7+)@c zHVJ_HKQJA0Ge+v9Es#{PwXVr@cqOf|N?D004ypDtDD*MKG=HZ(bcl!jLn8r_;sqhCtxtbm zED7MVGA62=l^-~ih^606_lfLp2XbF}5E|>oSeo^BLv3vx!{x+?Og1sH z;7rfBUocHnWgzPvPKIt*d>RolZB|&Pv10FAuYC)#u5uatNVICkEix7gzMJcejq{lF zEh!c(BjmNyIMmEM`O;Q7zGbkNE`C{J6v^T02EDxu{po{^7N4*%UHjNP}`h;HZ1h_msJvd{C`EDphj{W?i*XY`kQYmn< z45sPg7(jD^LMaM(RiiVn%X!SKKffIbYTR{E$n2<@2O)pO#L`sg+J6^O8c< zmNsETsNc8^C(fUeIpZGO)ga-CGFgthyIg)c*M%6K9Va@YnZg+^&}HePqAsr~N&56R zH28%P=lq;B3r^9STev30d!G0`1amCfid z!b8KuzDQaCiD*GmYb&|tbG&s*|4fEh3MHvG^oD~Wl(wQl+R{f_9bj&n%~B)v^06uo z9KKO6(HLmY5B+6(e_kD7qj{q%Q63x~;{@40-m!T9;5BC>zIy${4F2O8dnWzX#0q~| zt~WtnuOg0VTVAifG2bp+Y-W8DQ3g-LE#kw#23Kg+9~-S=J~_7JSZWbbuCjvonC~H% z9-nXidbtxayO1--6*nnY;^eX4cN~S@aK~0eThxe^S$%OGND~(o+bm*4FqGkGc5TA7 z5;)P_9Sc)s)HeH6rQubS+$yc$YN|db%<535ZrWcuPCnZ?u2xMd-Q^qnHYIch$E`tF zAk@<(W)*sTYs*#&U?yyOWMpZSb&-(J+YLUMgQaQ>)iQF- zo$*+GXLd-?1Zg3BU2P7?maBpxIz9t%@2pV5cSFyW1)Mx%H^NOq>-5(nvYt?A5j0Y| zl&(;_U1UUT2fLSknVXn=z>#3Mf12wNOxv}0@hcyMe?1ZW6O=YDQP36h>J(h&fJ_F? zBDMkKUX=d`0M(iYpuz?*#)~NsyCp4BA$dk`~8`H z9@4vTv%&j~J~X7nJfxLcbEbs@AXk>1nLt;p7ghr(QSzj!#Mb1LZukb%P^pG)3gXOF z1nFB+zYbNkQDYda#bxa>ZZ&SEw^JC}z8EGl?UX)l1E@Vc?Gm}y5NY}kzw=Ni=1bND z#2WQpdd&4iJlQ35#c9Pe9*;2^JF5kxM4ZMUTKuasym|A&G~sAD5C_hN9!A~<``Y$J zHmn(g^ZTz)C#alT#GD_R+^)z4wRGu4n#%!d{gdz3cnCYey8!R~O`k3W#e;;Z+#7{v z(I$E{1q~KTW}NFj3)u-h`QqYFg-|fGg!bpoacb-ct4{mvty{!^PVJ|awnPZkc97bd zTLKzDY3MbLHBreKrhS6tTRiJ$Mk}JJkN?D-plfXGekx-@vx%$6H4;b1;t!ET?~MSL zqyxcprUTOsi#5+7lZUjlmmesL3BPEIrmBSYpuNQ5`mP{0-7lULqvT|3OS@5oi=Fu0 zE!bhuiu!H29?2fSVnpig+XQPsOu7zpKW0SFOSIw!opLA>@ofF$*m^_}=atPX&LC z4+Y5;ap#gX{gr`;GQ5%ys8I}wh18h23k*%wn5Li?GeSmyQxJzO2i#?&B~{*0XUwd| zLWvu_$aEan_cS%Ng^Ot~9~lAg0VPBU7biHWC;qBz0pPHKlI} zh35czb`s%IM62%>h;}&^^Vw;y4ixq`kE5Qng{gk$;l+Ok0xDaK52Lj77Lv>+8|oE3?P&m_Wvkxa9h#psW0EHm?FQ(87JoH zN)!>SOX2sF;|ioe?s`Ki5*G*c<7pNI`{gUkT>klHN<+@%|HHdh!!z9DZvRtW2dmu( zKK}LIAp4@M5nlsN^QUCum200K}Y;3K-0w8O-qx3-Y^Z3{ESCi>A*Zx-S6Fn`Li75ss*@xoe+`ZaE0}^?oJ~U6wOZm~?3>7t*DaL~UAR}XV7QN?^IP6l*Hk-TTr#@aUp#yrk3@_n z^QVO;9GZ&q`Oo|4Qzfb&CK4FwH5va$y9~j`W~}p=SuI5Ts3#~WxFm>zwY$TyZL;S3rD7^ZQdf;DMZ&swSSVhi?iA ze2yRZ2ET5Qjzx}jRdfhhQmE-{VGfwH%?Z#nF_OEp)8?VCx zaP5Fr0gLl8K6{*BaEdbor--c=MKcF>>q85Hs{(7A`;R4Y(XsR|pT z)e0{mGfb#Jop-Je4j2RPWz_qqFqw~B(O;qRl^1;HmHyE}ob>y%=BdvVW_!O5C4gI~ z06u5{k#B0Q4|JUWDyD-h>GMB4-(nV5*K`$8qjA~hFd!2pGzA-f zA4)U~{n20T%6`hOlNnOYJ9&6y%1Z9lv-O&->oevnh;KyUyD<4d&O*o^OPZOm;#?)4 z!AOq(yj0N7G?6gX%}oYXjosb;zWRJ?i_+)ow&mX|X)S-k(!9voFSF{8bLkP(sT#-3 zD2zP5{Y(c9SUD4nER8SsbsFdmTG{6<^b8d{h$fu*m<;z=$>V`W79Eid?J+csR#W_*GebK5H~83{e&WnHwKPtOKfG&ca8PM&1MU41(S3ecUiziAqnaZpi^w=^c$EZSp;Qq8MaN1+8q^*5%Z zlVs@tG8J*V(=R?M&No(<^Azsc!Y#>s_$Rll$O!&AiP|!zeUMp)xsEVu&Q5d6x#fgI zL#6Fn&&in>3dpVvmnNd)J=~zZpa{{fiW7G&Nz<_p*z9&J$|ESF`fC9>TJf+QO~$fF zZ)9l~)|mgoW%{ti3ay1FA?C8YbX0r-0tzuV+cVTGEZ}}{OPC5CK0ZfTQf0j&CANvq zW`)AE5k3B^fY-`6VUY(I^!dK?pkSkKK6#N><3awzM+f zm#Fx!7OGB>qfa}n>90Ynp%Fz6{-1duZ@Cd`pdN6_dDq(q zdi3>-XJouBgBRdtk$4@>y3uglUiP8(YjPke*XYSuTFuAbkB&|@BfZ@X7G~)r5^GeK zRb-!9v5II@_EQ;~82q}1V`?_qrS#)1d+a*O9RIef-|%g+LxXcre@T5@b!AanPG+sR zS*MXC`ftmnrLzbHIQ;>0qGt?U(2H`9&EQ{OF=tU|fsF6!(!#qSuFFk7qEUI zo#I0Hb@CzQsNiNbz+;35ybcYF+?s#s|fLMr14!n|gdv7uAo&n5nab+E4CyRfQUa^{WAA=hc1oinO&-_(L*_I{YFi_ zW8%nq$?xvTY5EdzSL@~_BJ2`nV5W&+QMvE&$@lBxaBl3dj3s1~AdvV#j>>Ru@B7yX z(UgRq`2>ICI|DUlT^BAY8bO0T*?3|q268@neJZu02Tet(U_3Zx8T_E1<5!&T%}N0` zLEo&#QyS96gffDsD5{OadIsfyle5fpOZ#lD!Z9<(kFkY#iLYAQ@Y;55 z?P7v3Uk?a6C2n63mP9OOocPMy?miKupqF(rM4{+;#hqhEPZpDU3Gv6;hJhjQCkQUx9qVO&=BwmqMo7Uuvn?8UlB|5 z9~q^)3By!gKD?Bd0y>_9HEg5glL&Ko$h3R;RbhP}H^Cw~FbhH!F?g`;w7l|raayg8 znXeZB$K+LVpSj;3jt&fN2v2EyMGmv3`Uo=qTDJLwG(d@J?&#vAMw#Mhenh)T=9}pv z!deHgRbz2(X)kmQj_iCTpTb;l;YmLd%D4wMsBrbyT1P_0v)B2)%>17S;RpTuN6kri z=9ie(94gHfrD{Gv)4)!hdV@QJU97DjXur|N<7YJQy1DJfr(%2Of_Sh6_>yr<;<9i>P9HQJshA>H$E*zpfiZMO7&_M6p;Clo$7y7!Jah;FZKgXrpjQ*;o zx;kobaEDE^4B)0g^d*~{jS2MhHz^9=HoXb3amb8BB;RR%Q$2I5RE&t(`bVRK6ubpe zOY7;y=!&Z233fT}k}pi(V*aO#Ig?dLUBfUvDFIegRt7FADglE_N`5yRD=5%Cg#zx@s`awC)G)WK@-u``yzB@Lc5JN8 z9c}F$EiEnK75u-K6{lWBypD_2WI&NqQE34_k5$AG#A<4!%zqrS1qFRjPP@6|y}G)z z{d2f$WoPzY>szh_8hTcktf(*X)yD4mDDcOzh+l^|&(7{{&1PgwpkHm+^Zs%}w)y~0z z#voGvdgqltKK>~;^TW|&Pu;PwI5jasGmbFkD@Vr?Rf_oz$j0yzYTK;~Ef~$qD=AoL zhJld^+kKBqkeV8#-etlR_{xc_pjy}1*f=Z`903BQ5C^5|DZ6=i9*mWk|0q2S4o=lf z_^dDHCdMn&yRnnV)$5h}% zWH+lwA^|nq3Q1Yjz*?Kv=Z)FZUz{=)#*D{clJ@w06Jon?{;$wnkjrobToG-i{y+Y^Uj7(PF`7AMOjfrSyf&I6s0Om;TlDpMp#pD z+Wr20F6s}Tq_L1MkBx!Xn}#ydRa*i6cZ;+re>{37Si$jt@aS;+#Zg7uK2OK5gm^WM zcs33e1@%gw2ZG*NvvQ`Z+tJr+1&u>#XG&LKIX2jKxLSACBi_~zJMC-S*$O-AhYQtt z14-ysC$H|C}fPb~<$IQFp^S!rqVvf%EV zfv%Pg6+Ic=`g`O`$V;%KJ~jo+iP;BwdU=>-qp=`8UOO2z?Nnh+MWaA69j_CoCZNq) zkNy}4G?zQ`s|Tx&Akj3v3>&rBI_SMBXgfprhAo3Jr7>)I85q}nI9q-;+lo1f);C%H zmL5aWrLMhbdTnX<2a-nf~*zJ7@TuTX$~sDm-dn|5OoJfx04*;}NF z#?9^-umw@?!1qaJflpODVr@Ttmd45^Q#V0cj+AN<`l&iB2F=GOePwMXrFPpE3?faQ zhIhXaqYvLQ?#|Xul?Ljlrag8=8RTF9YCkWdzU#(>o@0Rp?(Xj58R6^aH#F<{UZAj_Eq?TZ%2GI;Gy$vO^zyWp zoo}dj(5l{ch=npzRSpe3uy4}w;v7z1x=Wf&?)#&1a-QZ_TJ5J|?W@Ze74Uek?S)-C z+-uA`7~;A|QZ9{^Cp~nhwG&BZo*r)b6upjx`o$YYL^F9^9$w{JabI6QIuE4s(T$+_ zpCo^|L)ac{?Ca=o*u6$?axZRgj?2tk&K(iOu-Ur5oe`UvWApmdc4Q<}ck3{wDjS_; zX%`gMq*(PqbSgd0K|JdS0|5}#W+ZcdhVHcF{|jQVPl>m;Um|cpU@0XTtY5PMh{c=y zIsyDMi-o_3NJf@{r`THDVDocxbFgMVG2|=7CpcV7l!N>qSmOVTArNzrZSNgSNekmsphu+=e zR~4RVIydnZyG!DlYVPCF+U}-W#7X;Ra^`^wFUxl;-xjT}><1c-O+I7K1dT#t>L8&+ zNwHK(`@Vtd*!NCj=|@HjTs&pU`@g!-#k6;+v=i~uo*pc3ezqz(=Fr4o?Al^0ZXC*hSoECx4Ri`84C*tNDpH8efA5OK;^4Zff+ciBFg zZEq#**%otWa=(Lsw-7Prg560-@$!-oYqC)ADn~@vw*Q5wrhy*`pYGGddlUqKskvi~ z#cA`rb4z6<_w!{K8wTe7kvPA(Iao~R>CayAtx`TH&U`?;bc$UM=`N>@J_x7TiPPtX zpP7ecX}({>R=$5Lqv^AjEr%eMs~FzhEq8XOxpwWDX-G-^C4v9NjgNvIv1Xj-p@^Bi zBcysZuKY6!ZmOUUsb-U?_w(WN6@{{jcB}Veuj!*;OYYokcQmQ{&1{OIpW_UnfCEC( zL=1|@;bgw`Of=m{A-k>i;cr{iy?E!ghuLYFO)XuX+%HM@>3W)Nx2t^lrMU$j{X*+) zH&zT_TckVBHjl6gb_#~t+G$6982d-mpe%1PY&1CQB;bz;LKzz)0x4mLORKTl>RcFjt`ug<1M+yLNP%=d9dbj}DTiZ_*!?F}4 z6$*@e#?z<+^=q|VI8cELw^>Told&-=k873|W*UW6;1*~Y2p0Yt+}H)$!XV{f;A6{L zB@(1k%FLc0l^CPZcw6BT?jCxE=Xq6&*&>|EYg;@X*Zh2RBS?M zSLFv^S{iN4StJlFZQLxs8B$MoVT?0GP=vTChHXP$ORmyVO4?&A3jP+_Bj`$u~hr-RpfuJ61tcW0W= zuU|cnt(Wuc*O3YY)K*sm_E@p7W?ZOIRDA7mdoEFs()er_1W)=eg)k84rL3VNS_jiA zF9yRW2%hKbA5YiWVVq-{lvr8OqRj*sQF#`vdVD z9S!UiFSSW>yL-Z&BX2CbvLa>FrN0*{*`>o!uA~Fi5jJngv0c*>HC0v1q=VYVGaX8_ z4id2kSFN)>6jk=eHOhV0uS8Jxg0<6Uh@X6J=Xv&P0UpOg3)efGW;QEq+ap_?*<>G)l$4krNE#DkHgdbzxV2Pl{WBZ<_~^AFF8(T;P})q) zUudOMD%2!B4lH% zAbs>)ZT+@PuAo?JH)rS~zH_jXy_atO)EcFvtOQ5ixbd%@`e27A-7Kq>5g35>+1~Om zNvMy}f`aoxLh}OrvqA#18n|QxYzIyR?~<@7vIxmYD2VU|CWZNQtXaF@NtPT+V%&S@ z!-&~mAeVxex8`;-r& z8hGQXl4a^_-z=)0$;gZP`aE4hj~?324>T>2 zkz?M+QVl4V`)@lNSbh}WxpDPuPC767u)Fm4i23sc$~|Xd@CDy_tW-N*mn>YTq{hd@ zNGJ8Ec6c5MC>{EW`H3X0NA6$PuWfu%1tNph{ehlOdrh0Pw6u#9<7Hk+sm21Y|6B(7 zc!z>iKfitzoZx|qsqH^m86B;nHjp0yEn4O8DgYF^wWkyL13k1Kex@%rw@T6}EPo6i z<&}65MFPC#wHvLEsf+TMd%(XiFE_gsbGKZUJ;wSodT(}_l~#AKsH!tvtb1@=H2?iW z1$vK{m4$`2$1%9aG@izk-{$S8#bo&lq&xTZ@k+%uKLana*}xs#q`rVj$r}3TiHSpv zSx+Y0=NL^E18$((ff|Gs)-c(=%uB_gcU9Wj(&$Ptf#bTl)hsRMLNTlk0C4+X5iIaM zP1olTB%K)>AGJ#i`6Sey{h3a8-`UwsUo>Q;{Z0LTJMi}0SuC&*^7&KMhZ=0`fSa~D zSyP8kvow7>(Q7~Douhos2S35~oIe*u>mjKWC^ zL?jF5ve5w{S@1d(^Ej$h$oX2hOw5-KT-_{s%3q|sKD%HLy*)6nnSIoyE(IHEEhMANpEo*N6WcZ<D8liikL@y;!bHP< z{=@+_pRS-3k3R~R;Oyd6%k%d!ZZAFp)~*7pcfQZBf6r0u*F};E{2b6we4*_BBP)?B z{8ddJ=b~`HonN;s2+kLYwA3OWkE(r|c+1xKKhy?h9X-5$1&clGa73VjJP{5@lZq)4 zNwfO5Ag10PiTTCCM5H73@4dQbpAuk*qHWHaWihfhKHJN}wOSFBe{Mj42y zcN_@6URW8Mm9Eq?;Su*)+7zrb!^*xQIs($wzd`sa5 zW;)t$$^>7kFHC=t^>R*r?sHGbmEb4@sFRz+Y0tUnT~@a>-MeigJaE5+L=KvN(E8dt z)r^)-hy|CA*iSg5bTn`yYd`B+gX~$V!E$RYZuO%>f!8~|S+Lc?A?01~4GoQ{%vHdb zb&cYbz#;9lyUU}yu4XI&{QL}`4j={X2MBvjmw5Fltj~FKoQ+PEu**MOKu|~Rmb&Q~ zHo){vC(L;rrHS{kbX<^ovQ`dD-F&M4^|~zS(&KfL^A9a&byPdsRME{6@I5a^;g`J8 zowrE*iG#g^PnXvok&UhQwzNI9yz@q3Sfwpp4tmyX#;dxq<`mvqmwRL3qWnD_kN588 z2tgz-LzJA1pGun2iI;+l-i**EY_AS8oKr(!Cg$(f8Jjp=4Ug8=v{W?LdpWtWwSx=V zoyV-X>;qGlh(CIK#k3EssG9gCsD9ikvUSWOrdP-#;H|~~#w$lD6dD1XED=g?#Nx5v zqXwEElV{Xh5cEy$drcZ(M0or;=_?>qdHi0WK{!|yH-2P8V$K;z0-ny3D zaA)f1s@TFDqt76d*0d3hW+;-yTD)qmInUyp5{G}ptIJCrmPh@T|A)P|{EF%i+rC8v z6$Dg3T2!Q@q`L*AyGv{GR7=e9mK&<5jQ^3szy1;y(`UhEgL0DzIrrk(A_y#)jkgz*I=ycT&v6 z#1?e56X{3qTD_|uDsyI@5_mk@H^DcbiD=X|j$zclYh@J|Ptc?&I&0n+`CIEct>%G#ASZAU;V^3Hb=tt!|Cwm#)c-Zf-@(U5$`M~^Vf}7+`_N>tq~Ll zSUGYPtpPbj#eVZUM=a=%BWEb){Gd0-tl;>uq6u}<60NA??g;KepPe{iEX%XJYO@9f zvh^R{kqZlf-sr!>mgs~)QcH#&<@nM*WcAq2Mx^kjBy0Q01;(R`UtBOk3m^YlG6-YX z%(+kLWoVS@ba|ShcsL3RP!E-Wd8IWaUyLF-YHFJ-5Egc&cZ<^=*TjAq7o0tetgYAc z>gLete4`{_=({*|67m?)x8J5xY1AA^u#{X%ZMlp#M!%n|2Tr%Hhv=2Mb=k;!K}DzI z_IffF?w>!uPU#YU%^Crv!oBvA1grV^P;#lGr;8^@r8odPgv|YFlbJV(f{?&bDH`P- zURf^qrRJvgt2?fYl8W2jF{QiVFyl56(UW4>>E>=6O<~wd0@Lrx0ycYpzvhv=#5SW@x>WxrWkla^gK}r#qHpsX@Horb>m&B(0<12g ze4zI$8?17GT67H&R^OzKUVHIWz!`Nu+!@8j+E4rU^5Nm>g{=X6y$PO-&(vuoeuAu5xzJ$he1m-;4BL4wAd+apC)yantFmIyeFRxsOTYC@& z)h=ZbUi*b&X|XsV_uc{Q)7wn0jCq1{cZ}=YtWkh}8}|-qt}t%F{KNT+OpX43(lGw# z>%ZK)!q4dRy1I73VCs=ffrzmXKpXYo5(6fPHH;wkY93t>cHZ2y%J(|D%p{XeDESwJ zEpaSlSFlBb1dY~1(&<=N18Tz66g=gw+4?+DaSbM>OQUNb%EHzHnS36T@Rov{eE4!b zQh!F=z}#ak3srdCruM!7A7oF>Q{$tHh6uy)jgJl16iBLWU)_&!sMFS-KBvh{Z%b@* zjc|}M7z~%})1`e*s(r9DaYMUE?jmbgvo-Ozz1WB2%k-1`ojkAgg^DDMn&U|e8|^D0 z{y(1$lTYzzjJP*feiN$vYMjLxh>L*{Z%d0DYWI8#xcZA~0RW0B1MKK^w5a{ATMidw zps*IClEv&WcmaM_nIe}|=4?6#zMEVqw?)$~VbQBx62^WDgX(Yp>sG9WgU-aQ8SxP-YcXR*K&xP(4Z9ovjRBn1?=&9kF~O*wfPklt5&_ z(PcH;w9{hi-PAs20ZuLAVdRR+t-9BTkECfntR*bcF}KoT@AHxt#xJMl_U?WagR}CL zp`@(-MbUU8APzd)Iu_bR6^)}6kveL`C3vPdzV|I34VwHKVtwT}DWfLHQRFa{BkW^% z?1}fM@_yl}h{&w&q_}Q;jp9l1wQ4-Sxc|}7@kAcF2=hg=F=^0pyV?BDn?Y_jayC_L zi|@-mH3guB$pP2CUH{>!b`A`in?vdOo`s~k=MWmIlDstu~ViP>3B)W$$4Fo zoxMH3GfFO{mf_);ByWA)p}4OMQ7xcZw?ag9Ch;D33*no7Ug6^9siRc&J-VdAzVw*I z?(uUQJA!N7=9lDh+)Pgi!BzjO1!PFaClrY5ewh(@x{n{59TvJ26nJ_825ps%goN+p z!8`#qa1PIdI!URq_sSd#$T(ERBi|#{O&YyVw@*`^KTp{H>9RWUa}MVr`fze6XZLNs zlkr0>y|OZBCs}!xM{$|^>B*s_Z{XbuuZYhT&^G-_<3f{zOaF{?b)9bv+V#^^R zM?Wuj`~`GZ4~pAYMe_jzqv8yAeQi z;(YAwqhC^1Eb#9Y$cCD~0eK^AVk~k&D>kCj^{1eOIfgzj_a2x*yuAq-w63FzmstE% zJ?nAa8lt;973bcw2O-v_&wpFv*(wwAB72DnnJ6^HdVtTDm9c>AVMv|qQ)7IA4W5eK zsl|uspm?h>k|0MD85D0ERG+WY$@peDC1J>hnn=t&&{7O%6`0^5AZ#! zxt+|U8vQLLSz}1rra~#0sU`s^6ruqu19=4{tl738yrNyq#j1$?zMy2)x`dM!hmhOm z>X5K`V=QBqpukJa@ZRXAf%-Q(#@54j&^`+1b z?RGsaoRwu9@z$Yy#pLSifDL*Y>j~9XKRfWF(Mc^JDjd8`vP1ReG#eWzNlxu)A_YG&M%v%PxW$X=~9*`DudIN@k5#frhd@N zk=F%~ySo!x2_YIanIesWT$ejrl|fwkb4y~m=IEV=A*0y}Dp*&Xl(HuZXJT~ojm3jo z9=0ZJ{vNwASmu!s*S&@=x8dFfPt_^@30|I-2O&+rws{7=r&|KPVY_9QQjF|jV*Ue) zjYgErq)*w@;X;7bbhhkdj;H2Vk~_4(#=P*0TR^#@X};-0Q0Z48uk-ubK>pOSctpv{6^dYAl-}CUK^4N=|FvpqJe_iv#?1h$}$q*2j!2NvA z%`ous<^(cu`$nE^vX$9%(P(FfZf7Y(aiN;57 zmTzG#i8m+h=1Enx-fMcODwqPLwZGSXZX8QY84vFkr0AHa3eaq!;G;6@NcFw>zi#<- z0md;nc{cNKVcl%n7HckZn*M&Mi#k?_RdLuam^`<8jp9^7C@65NVmpC<^2uxKo6(8N z{{2n@{c7Y2j{tkiiQ}PCN+H~HKN)rC)YJ2pIj)4))Pwlcq@=h=TP7BoZQOh~dV98; zjC<$W|;IU2Y=|oN8G_RDym?Mb4?z%iG%*6RUnf)rR@L ziq^`?9#xTH4xY>_27YyG+9{fPR-Oyr1#f-!*8Pg{4UAHUQ$<#mW4xp}gwCaxtJFtF z%sH1unkR}Y10ZG{$xvltI#J9U8ATK6{z-96tGDa^LLQO!sA-Gm{z}2SQ8i#p5NuHu zb+pvB_rk<=U3>T;s=x{sYe}&Ih2~D2M1H_A;C^_++8<&(ttX%KG$vAKgi6M8E#0VD zmXX`-*pf+w1Xi8aFGu=3AxTYCOvCIalINefvD~ENI^xdo3ELa+xB!%flS>IXL_6^q*u+dp6PSwvQSQ@0x+2g#> z*5`W4&Yn4#?-a}@D9VPnF59biwSyt28du#WN!Wf z;-++AL8ULnumxO9FiHJMKLz3Z-fuuzFn@0wR;(3@;A}d>j5an3V{`YGe9p=bQ+U0G zs>Cy|Nu8HEa@Gt}FrZDZscdi`8AVI4Q`@Kc0YDm=t!&L{&e3@}$&~_#d2lmRs!6wn za>ZKt(Vs<(CMhJ7kVFbkw8f%^mWuWhNH@EpFiiO%ScZe*+A} zPo4<%Bm+lRR?LLjc#r<}Y*2F^gH+|>N#TWQ_pPrHb(DN2q_4VD^)*aH5l1xut zc=5HR|LPZZovOrs6p``atzgRT>Vqk2aakjG)J^C3M+((O({?x8y?Oc$UMFX*x-ii; zdQms&<4ivQ1px=>rNaTPq}-f~5oe_%_#?^vzh4!t-?jQ~_bSl;DsM2@&E1^86aDK- zDdj8ohWq2RcscQ>v-5LfJ~W(t$8b*MWB){5S^Zn_-GeS>74RnNxBZv2AX55(fGXLs z2edpPdil=D1YtDL*Npu8(@DE1na1RmV9dq#tYJN`1)SUFZ)A$*onvx#*ZYi9Bb8*m z^ooPjba#SB3EGHsjukIJ2R%Ey0DIb__PHHhb6(^63yaKfr%x;((J6^x}M1^e$|bxiLtl|sE{C6(EDcM zS29LMWaDgEDaNqdE#ZZXAwK`G^`(1#SUM2Up^Q|CD~y=6T&pbl{Y{d%gYv${#jsM} z%)(uoE88z3@2F_WKDkt*jEhMZZ)JhB=5;A4gML`J`uI3+60tIA-`4?LnX!AFb-U9p z^@Dx7p{r5^kD#C)>+{p2?;i(5cqS7v6FU*cOufB{m{qNltcU_C)%yC{stCuI3}t*1 z8-s_*UXIgcP8rEg#?5vM|02gi04FVgii&|>?%m{wFy~`C>%6fiulv5dPnyQbGcdRu z%t@}q`d#ICCfgy48S&l@XMk*ou9w6`hSScM;>{f_KjgG$Bz*0F@TVMCJYIRGxj@ML zmdTCP#;(-G(drCTFE}Y}8y51G5hstjV3T%#zm<$vfF~1ud`B5wS6{yJMWcX9=3vG8 zfQ{ghs;bHo#N(}LU0wa%QbVK>4ZpY|~MzNgcy4l|US_32Bmp348d;5!4Ah!;?y?=h`tOclpOxttTWcD3sUt^H=mdIe z%VUG(C3*U`Wpz!pkI&_qOC1Cc*fC&iY6SfQFIv8BY->;j(j9$U03trj z(5253-WpBoMTn=Ry;Dg|{P~;pl~su47JHgM8?&JPHbhsa;v@!5Fgkw$Qhk?@{Kt#? zG22L%SnKi(PSWbd&=56M&-H zIiDkdojiH#7!O+@Fd zor_u(nzCFmtcFFIEUKSIp^Xy4R5*Eo2rFP-r|g^ zWmw|#Hd>q=;dr@5OC;I+i&j?IG#DQ!TS8M$3iavxEEVVI6M7_#ei|EdqxG13xzO9- zu?}^#_tNup{ATlzt2DZ4zf;}z>|iWnj209xC3JOA6QgTYc3p35vYFt`6``7TmnOymaC@Sd9tdou z$0|vuXc7aa+=%(FruFGY24w0K6pf;X7*_5WF=73EnwJG870$u!C{oo%cDo-+1vM?| zo`v$4$8+_5jayEW;m@>Go>S5F6(4*y8wI3*Ij`sI0^jNkygPpzAu z0+qPNjT&ZK#S50iThLK6{q4+v6T}0WU47)m-I3-DiSo4Pu4MMDJN>U`{}7`9WUO;X~(9>X(rV^ z+sEz9`t!k;b)HHE^*-3A#9ILA$t5-Mo;r=Xz2Zz-$nNSr-JwO;NN{her{- ziIY}4_(@uR3!>sDeq)JczgAD1Vnnv~AClx;>`cWe_e*$wRR3QsKyBaH9PXC_6*!1A zknlZA)SMQSteB}a9y`hKly~>6@$h!1lqfv>-LWX7#zedD?h7#s(dOy}orJK*+*>?6 z{IlJcFP{}%vZyJK3Q@Qx0n`>yC)QSrXaI~#-$>>7=CJu-A%V{KXLj}#>iCoiqU(+K zIY5FXC_J4~$^hwBZSd#=Wwh4eW6=g8qxw}*PHlf@%weul*Ou9b4g(05l0A;hSc8M~T% z-#cp^^m2{3A6a9wPxi6n}dJ(EDXrr1lRAg0sKo?}% zT9qy-m{@(VkgYBCkxQqbvI`iI6X}Wudj_ZVD;Ha~N6lLsgvb5Z-~YZ_z>AhM#}H>} z&^{ekAH^I-*^-F_?m5zBA}MnZ0JXXg@H$Z4n!~J%<WBtpXg zC1B7Ej}T;D)##Vn)oBj79p(zExJ*`F$^C6Z{p1ltfrZOr2U2UrE9DmVJ$Hl4^oUcMNUl98Js1p* z38nE5aAi$SkGcA}{(a4RT!5P!P^?}nRcpRuT$RW^&aRC+u^A}SkbR67LQ9wf*D24c zQqLk({4z zofrLTQ1kCF6ZhU#uucepbD}1ahJ>$6kKGr%q21E9|OpGP( zCp~Tup?;Cc?}4mP0A(58c#VV{E1IVMnD{LPhXz@4GG@{43&$M(SoQWE&jnq8pme~< zPD`PMb}$JanZMf?@2o9^!G?9L7b(aut)|Fgv}rX{uaO;~qBoPS=$9om1GAc4k&(0# zW|hP_7~;<&RQDKxrA+-{Y5!>#9jbH93WMQ;ND8j$q%=xUVtv60u_Ns?8EC3I1H3NK>ySxY_*U%Y?=dCNWo>kB{a=)g=}|;qJfTdX4e<1>%A9ENhD0E zf`2dF=Vu0Xje3hxqoczpDDnh~>nSD=gWD)@UVq`Clx0D}9>z}a*0E2TrULwq+%AzB z48mMPHSIN1_$w+Z;zxw-PJL&m3@5DK#FSR{P*Tfe69BX$H-~jZqH5%%5)Mq~awL2r zG>eV5yd{I0Ux_k{ii!#{iwd$ar&L)J-feEKZ`wS*i&MT+Q6Fuoa8*>qJ6KqobF-dV z?l{|RcO#q@8Xj%_EX6Cy$ykuTz9$nvLWeg9fTD9gY zxFs>MUEkFcbI@haE19zM{?}1P(L?XsB0eWM%vBueRx52bKdAd>2fTW*)K^#in!O<0 zgKox~^MfT$9{wosaRbuPwz9s_UNm>Gpwr^8b=D++j7HJUnftoG$j%A1-L5cMdHidy z4OOVB&tSPqFRAGSR#OD1T*I?j6;8&+#zy$nDg%RMFhJh&WDeh1U9UebXh|tScOlwZ zS{`ur03eiIY$fQySN{t-u|IBA`mD_dV&9`G^c+$O({DNM`RW33uEW2_9t>gcs*M9%KQZ;J zC~f)v=h_*kX1v?)^1iqsZ^Z2cfG9x2^w=YA`!vFbi;MN5X=0<}2LUwa`mUc+3Dl~8 z7;j^Xg7eyY_CI3Al{fICIRG=#Cs*a?828V!)?6(o$h9}$tQ_ogmof@+-L>cFaeFC# z;)UaY+dWvJW{j^`h0p)h)(2zI%UUfi)trEGd!>)$)E1FrmP?JKfH;mi=?(2zJ%Zmt zguxD4f?=WX8;369fn*l25Ud~4ENi}>KIWOrk;$0i5lGftem`hqSl$)OxLY74=4MfyjZ&^hqzjsCG2JE6|U%}G|y`8VK25D>RZVgI)5fsh*!+`-SAiR_E zkFU1r!G910*9ZV2+oBu?7OCr<%6|hSk_6=XzLQg{wMYX|K0a|#!ePWBtmJl~hw=WE z_0d{L1t=kDshM>w2TX7AMHPhj%!e zclVn(Cib1#$1juZ0Sa^B`q? zc74&*VjzP%AOyM!`D?d-!WHT*F5)HVuyeXJZyDgd|NHp2t)>Z|?H}||`Ombtp4!63 z#xUH@Z{IqB5)u7ULaIn%EOv8c%@*LK@quql1~J?N2s;YLJRkPb&uR4|lRvbklreEt zDy$}GiHR=)dhs$g!ZM8Hm;b4=qe-BVQIR4cFg{(-OY}ct-fkUqBZ6fE5dr%S9qZHl z0VLT!sk7CPJ($B^V~yH0VZp#DnP6MqNPEALPd(@4MV}5X!_G!QFyMmbgh)W)@wG`p=I7&R4Mz!}2!t;Gtdv0fB~njX7Z3Z+CuYqK^I^6%iRJ z$io9#-v8X~1bVy_5*hXcy#M zd?&rBuZJ_PIgJ}{W&V0KU8}|YCucE)p8@wjqF^Dc->q=g!^X5G09i|=&Fs3(po;Ul zaR{gnRQvhy&DwmE?F!6o&0i@>5*Ifdn#<{P76LA&u9r;@Nb*o|bI(vR+)gTv zGCC@b?f{7l%gQJ(NC+11D_SZFJYPHk38X@0iEAI6AY@9Xiu5~FahEMwxm z_cP>6jnY`{@v~=|v_WBvZrds!tr3qNK_5%9wX0REnb75->9v#v`Z^OuavLJ{_JJv# zOy7Ak==`);GH6AsxdwpKZ_8Ve>^CTDoxS=R3ZQDn5I1GNJaFcG*DdLAd#&H(lc`h+ zxaMnBK#eawW6>rhJs6YG1)N_pmFv#$ou;6GTc5>h5JHERZV%O|gX=USObDOe`=Frp{T*(ie8CsnFkme>ZMQQV<7aV(RvQdyysK-t2Q60LnE&wenfFz*SkHl^^PLCcbO^eLV z^EArkY2aB2y6(y03W7s>a7rEe$@O3%$!YQ~qz8|8CNEAFsz{aG4B%x_Q%7J%Q@ zno>f~3(`55FCPRTa?wT-Jyz<%o5^d{_EgaPFQ;bwa7wpkOZ4la;i5!O>}igZN+oHP zv*hV1U?uPddZCxP_V4JdK#N@ppSNT|u1Z5Lkeg^~>ckiI_4T*kjP-Ynq#m;n;7xwV z$GiWvSK&A4Cex4t09`%)l9St<3JCBN8h}x`60#4^KocSwMRu--d8AshS(Ea9Yw9lo z2d#su9w)BfSR89@CatEvZD^Vbl==wxA;U0Nd7F#0Q+>*N%lB~d-he7DQ!?WRti_w> z^GGHsH{FhvU^$v$&nme}j%OKIUR8uDvO=vBAyNGC@cgNR{)x@0laN}Vk#x9Q8Yo(? zL*7aF)ao%0mxK&O#Ztgt2Kagf)?QBYZh^e2zQ@PMGY;N<0%=)j4U8kFJXnPbgVfKN zo>z!oycF^V{7(4-&}!z@fadzmu`P03F#`HIB7#-&w$ol$B?dUOUT~Ndd**XR7ckYg z<^pg35@opi_xvKw`wRWNh@5_qYz6u;C+^O@o&4}{q=Yc2BDCsZ zM)Q8_-B0b*GXNrD=NL|#D}s80)zl6~*={ZPDw%-B6ep`zGW*UcT3#=4Ai%b_L@}LM z6ip|>eT8k+Rn^oZ&9_|ZQ%TjNc(Jx3G*%kj7=Pgy_P<&{Sjro#&`@~b(f0|X?@@L` zuW|1!yn3Y*0#VkfOf!Xzc#Wr&PwVdT`)^!lmAz=M_PL#_5ocBGg8xH|i><(7bKs@W zQ2!vq<1OHfr;7gJOLcy5Hd-e9CXR3}f%(qmAi;KJfnFxBQ+;p>a@3y!OtLCO1w;X7 zAxz9GrPF{(v6OK<8%aH%zzDuttb;vtu#6(oy{3R*2~*18m6hidr=a#V)B&zSY?j!l z4vAdw;mBpwV4EMPGyeNd_S}lyZV4J z2lmf$jkH(o#&s|Ts}h~Z`+GoAKT=Qf2vw!=VtkSXz1CAA?_O4qp`~;Ar67 zmto{I(aPi14U`!NmK10S!$hO_l15PD^8q$u8o`arZR&;l z{{H^(yAxf0TNd!J2G(UMpC0LJ>#h10#%10vZZ$-vB*NY3u>Hd;A|g|SBM5utm}El) zl^6(!^26YscRu&u>t^-f<9AZj{hw?9Am2A8ta1F{?*cgmh0APf5TJsvGFgEPG&RR4 zB2yrB%b4_38zz1Z@J={kGPa{*lR6CXGwu&7UuA@$yrE)0G^OgD3l!;7S~UbE)ZhYc z?>A?+mIszC)ur)g1@x7>%`Kauz(dj+o_>xu>4F~v@mUO`1NMj-JVsYu7a)O>>*Cdm zL_P)!0e3u;+Eq*OsbvLFV)uBhI+tXb`H~*AxMN3_xwT(F>Km?cP3?HqGR}o{Ye3E& zq{ha(x`Q3u?=?P4%Sp7z_x=6rQz~ByjcHGOP*8nR>0=AH1({GU9ecBz2_;TOAUTR+ z@;a)3%mK#j2OJrFEz|IAKDCI2&z4BS%A~4bexH%R4FWqUS4|*FWk4pk7eM!pi4g}MJlF1X9eO%9gZN7+ zGu08S^bOXIriXnmjW$Eyt0t2gX1?`s*(@P^$3k!&ap~OKY1%eQT50I-x9{-c2MUfcFB{$!^5SI>dFS5g~F zx606qK@NjQ0otAT010y$tvYZX0aj?^n*mf}#=x-g$ythVZY%t7HI#n%>gFN-Ta680 z;P|||u{FjBNW%g=cb1B>q8kUmwJcmd66Q!ntbRhc%2j!>#_l(hmy=c)S!lW9dI zd-#(*VRyTr9Qh?$`j5~>*}f54VQ&GZDm^$cH^CUtuiImL>982@-AU8odcnq2lSi$P zRYWKQ^JqC8`EQLj)J~wc;x6_^+S#TTXkTkd20{# z4*{IP2ov$WD7dFniuu3(4008 zUFUl@O#$fL6K6VspRC>$9ifo=cE3c4*9bVNtzLyMVN6248V%6Ck~l2vX3|36BxV zfw|1&B?$xmTLVO4`vCst56|O!M@p|=Jt$RYA5wLys5~b+n*gE#$9Z?v_JKvHzX-(4 z5Tg6vzgG+lZ2TOsvRQ4`z81?hB`BqQMPZ>8aHj8c?(LjEa#A56R2^2~;jPo2-~rXw zo6zZJeR(RST}s!72%$_%r=+2#{MX9OEGQ@{n(|-LhS$BesQU> zzdZcvFH)}zFz6Tmlfq%f>iK_DwC?>s-t_zbn!(Y@UI0Wk`p-3@QN!~ji7(pe%6@2TuCf`?7Wc~hEVR`vHqWZOIM2zZ3HATzI?(xo{iN(+=0K|F&&y@-cz~vHjV2jlH zy1cpnPz9#taA;y|Jl`|>XK=A+eqwU$52q+IvoIreGbYgexCNj?h3g(y% za^%f`t!m&I0G>-;YTckw7iwlSTe-px2J`UCTzB=CnwG*s;}QXm`!}fT!yxBYe<0d0 zUN3p`R5IJ*)2E+tN!aa9bIJ}LZtiQl-g!^!t|=H$)<0i%H^-qH(7j_xqa7yHq$w;N zF!k6NKo26Lpis}g71pjsjEI)Zt@qKZxO=!Iw!wl9M@G#*V6QP@LlusAB!@GccCPR$ zGWmg}q@OuMO3LmQ8U zt2%HB^h53G!50NBNAqjp!oqW=?Nn{<`!Dc!lFDK{hx3t%B*h(y`rZfP{>y(2FXS85 zYh0qK)(YA4E%Ou6~P)^(=qaGcq`MznxT}Lma|V^wc3eC44YZv0&EjJ-p^UuP7iW z1Gz5cKFm>t${JrJanCdB3+OOTysqiqB69L{pVgtMoQJvk7xyb zt$R;bO@YytPISK3^k-J3?wy3`NT`W=5*7VrFG?DSr$|hkY0>2ck1l(;Z+7aoy+7Ab zg`Y0#;W+a7o(vULwi8FVl*qF3yt`=T&tQt8nJQ4GAtbCPb}|COMbu7eZy`jpc0W&k zNLCtf@$m)|tN=)J&hthJWpEDSwvImSWL^Z|3Z| zu@~yVRbXLF@BGPkVR6jZ#Dtf3gNKjD_}I$XFf|obTdT;J?dOEow6pU@ zts=o4|TYFZ>--B63j8r2i&4##2_F?P8{3Z|ubASUIA?K)U=zsmj>Kp{gA?yF zb|Cn9@9D+$2)M62ojI82;%;B^k6F%p@1IJdm1SoikaD0hCT1tf5$aSYt;49p|QR2!rJ&@t(<@`k|iAs?2|7Am%I&V*ZpPkDFFf#5p+iN-zE$w z^mH$_oJ+lPVTgc8D3I(<7s;|+2(mM8=d2t8 zjX2JQEVg+OyqJC+uRW0+t}u47E)-C*uow^8M+V-$HodamkksTpZWu!`q8euc=p~7e zHP&2xX#!`Sg92WkKgTWHKZBXW{9@xnbV&0BLQyjhF1(SwW@`Ck?7;c@QW#?PN10a4 zzbPWtXM!mj`eScv4Z9q7A}gz_+;N*af}5QGGuX_oC-!u9o?ZgnVYBGnCWEonhRct+ zMJOj@dya)$=?U3&No8;d`&BcIJo%w{?LYJ7C2;mXhx;jgI1~-s?60TLVx$;K z+xg_+fmtem3m(RSvt8h+a`VFLJNx_Xv5DdyS6`bf4{IY+grh&yG&WI-qsu#-cyG4R@_hhS z(V+V1?oyvbe%_-bm1diOfdE;Hs?vUYSg716YzBT1z{a$Q~1_o8&>xHO+ zd>sy)0e3VsB&ziQ=kNYfkV(@ZjC~e8>P$O$a*^mqud1j%YE?}y0=b=mMn1WBL=L>Y zr_LS&kwng($zrBvq$4{p(OJU~*E?s&HEfl5pS%0J&G@`t$?GK0Telrs&7?%m@Yoz= z2c~8{@Dc&~xMkvGdbJE{k=5ezlm|Fy!K$z{k1s~m{JxM^?v$ouDZ0(>^xpZT6&d1y(WnJ{xwcE zx0e`(;hns+T5OdQZU*M2#St9Hzqy=z?Y{OrzI0$?XLY6({Oo$jxt2FXR1m|MLYjJNa}00_&tsl9JW}OZ?YQ|)ccMQ2_iw~#emJuMx`7C zB5{c(YiL6vDKwOoJeQ$rwfF>g`1coWg?l2iBUhi|LqG7$YMAFVeO?QD>2SOB8vXhV zJXV~`6E8UK7rRQ|1_%!Qbi7=0|MTd?_e}pMOv{ z%0=wIEvpLg5E9)~eNIQrOY_TXPv>uP)B0>9fbiwUBH; z?jH#*7T*&zy>nAUf4$rkuj!uYy8$}4S`Pf0hbOEoib!nn8hv2l z=r{xS*xTKZ_fnunJD&q{xwSvFJflTmyRDvGRM%d^}X319g55!ekf1hVPy&-azi=YW3rwQjaqVqLChsF4*Qo}w>j z2?>}@X!kJ}JL?-OO6N&F%rPb%pag{SKY$YW3?@7w)N__RT0Z8bbIO{MRz=c^e~0ie z!})LESmx3Z1$rQHrrpgg;w%i<37h*p7_F5B#9S10$6flN2*E%vea3QaN&-GwU&5A* zGUQiq4A0{~Y#?z4f|U3_FDx?-PI0G`x^G;hKEqa2um@86X)CGjm??$Uf>{CSvP;i4 zGyu`?*pcs&oqe@CZLs!ngiSG%Y9P(U_E;P>Yq1J7X-?{!S&#UR4?w$}Df@TBc$+G% zsnaa0s^K9uYIpb=X>5Mi(w5G)Jf^?!Z!oySLdgr@ZCXS`y&aKt>gX69SoXz|FX!z0 z0m>TvQr-D(gw^QRgr1)9JY{FI?7arq@CTfi3?|X4SI@s`)>@Okt#SAPTG;WL2htU7 zZe8wU)ZOHw10Jv_PFhM&b{R5yekVFWW|hH%T$aFFw+VZDv}F%SP!pj|l5`55)3;qo z_uZ8!>Ko0DTk~^s|4wwZ+D>1PO?-X z9+c2W_F~YUEH{T&fI9F(>;wfRd>p~r+Zj`!cGhkKbceQ(>zC@8u#^FLaIn$MGa;zdPn@5~x-;E5n zlT6(-sxIt@)R9g*FXr%i@L-Gs_Vw(vwBBip>)5)F*BiIF$O9=)yD!_6Fe-FmiEq5Q1DN?EN4=;_@73y%rNf6Dh<-@6v+xQc+ua| z7B;~HwE$a_j42#4?`YOA1Js?$A4+!Z<;jg5B#o91~}%5 z#0~!E-$V5rF~Smq?=c4vYhgnvf)8?;p6pE|4Kpl02A^SajAu9$zy3#uy5R9+J`K<$ zawLLv%3r8bjk#w@-nQvwZszj>8f{+WrfNZTi*m_S8GeYpJ(b`DPd6cKp#=;VQFuV7 z=XR{97US(?-2eCy7EE2k{eJ(`9!CS|#r|x>Gw{V>$}MZQcWMWd2o;1sC@dH@{5=m` zweV=TbRjfet;SOV*z%-?Dz23U1w!3SzPm|Y2T}*rlX^NWHJLAnsjA|7E`3x{%zMk+ zC^_Dqw9j2mndOI)DKQjyRp@fMlHiJe?@La?z<2T&hz43g6w)^?4*SGV^6t$&s!`qcF zGCY<*!77)Km9RE36A7#J2%mCP7yw3XWiO#T`@LJ)j%5dnR zM(N-+H#QFZ9m>zKor~8RRafm~%rDDsPN69f*0Hq+(lc&$d?zXiMBiwAP?Fr*<5>KV zk+S1t?EQb(d&{V(;6~wWzYJ^X46fOG|L*&~?klLGS(QvD)zx3@EbYHd2Eu*3pQhw+ zKMCp_XlYGGW%@gDhHv}4nclNNEw5PhRHfx462>u2HC=ClD4>>>xcQoZy%oTB8Av)G zM}kQD5oeAE`&6s_8dAhi_n%r?700Kq*$gTeSayrQ6N<=dtmvt2Iq9?>Y0_#8C^c`l z_qL#`Ga8@IM79th*eb|8H=cUU_=2ZfvwiE`k%GEf8~+Y;T@23yQZoH16sA<)Hkz?r zVgfv$!4?fWJ$5p+mAF5&<(3FlbddMrStcx|51N{^SW8BQL~d&(B}cezZBuzs zYZ96zCfLXnWduQs|GlV&GFON4aq_HrjK9`l!<;v2(K}X6qQR$_!^d!}kPd0g?7X#W+k$R@rJFbz*VB#m&9 zg-du5bnHJ^*38O$>Pm=WOajUJX|tKK_>`N56bb3d?(RX z(iD|z@WK&L*jb@x=w|MK&DmXziIKi=n z95ghrwRH3<-KpMJekJi^8o8yp(5TCW!cC;kB-#IUyf3vZ*7HXV<$A4CrfQ)!Pxg0y zJfSf%K1O*;TH%$uElp-&27JHetb{b8y`6R7ZMvRIS<;;Uvei_v9*Y+^+CDS5DcOW@ z0Xv%6?&YFv=g+1llW-0P1KtZ`^tD998cuW#lq7Lsv8B0aSFg9fXe7Ti-}$YRMABGP z^qUJq4w1U{sMNvQ7MZEqH{8Na@RGwzmy+rn4kd|pR?t!6IQM#F5qz1g~#XT z{7QXO~BV8HHVY^8xL((=d#^S z9M&{TxLY7~`1QZr+K+G3(Q6{6CLhvfH5t;V20ErS8rs`jT#i@WuM5_XvO1h;JxMz= z!e54U`h4i4Oc&p11-$$mXMJ91pRwJ?(xSO9X$s1&zo6F4a5FJ!+WpqB>`4`+flL}= z(}$2^ywtPwZTrReqLG4yH=#2ZcXQ64fCSA^lH2|tFGH9;GQQBh-CYY6xnQ7ZXis4f zl3p7d3sPJSps}xb?TIV$_=iEw0zxoSy=!(>YrFF|)dgo(#mC8{x6p5}M?8gp?= z9onrg{MIl;bMsW%a*@l_b^@+zz~JyfdHQa1tIW8{y0zj7!MTOkCB4$@Fy}1^8gh_( zDP(i~v-s|KW`U;9Be7)~fPDwEQcIYZEk;jBhc0MDfkCA9eO4_|{_6`ilAm($_F14x zswe;A0OBihL??4;;i9t$z&68DHSyiu&f4zJ53EwUKYBfDo%R+tmOl^tM2F;YEm%4L zC1BA8&WP}}q9!_s%fp=(Ng zeZAa@+79y55Quo`FL@uwIk(Bt(M*KXu~oJH2vc-ytj5o4(Kp9OQHO?U;hBK@~?C`&`P zJ&GO)I-_a2%LWj=lCLdW8aDXew{KW)q(^~<_$B52FCDb6iC75`*+6=y#m?ArW$~kL z!$@iq1E096@%*iCuwAXkl|2{0s9`+!lbmQva_pNx$1_pq_2Y5lfLzPhUNDO_MMZ^p z@(lK;)Ii2mWVCWnVPWA>jZ)WS*JfYocj2nSS2=OG-(7|FZ65pF@W{)eE%re=!j8CmKv6Q(<6WnwHL*V2%d1z-C90d~h%KRO?^! z5^K?$-CM&bsMT~oP(N!EczQ;8h07!lB!Kx~3rBN4wSyFii@xU1>>-1Jy+4-$_;g-g zX-rn_urT25#3C{$IhR7B+GzH5WyvU`CW);LETssU&nE@WTZNz)Z$0*G85Mnvik<)$ z=E+EgatTlNn?viRU!-$KO9&RG&nmsw~I$ZeL=lq5e^3#4-wIp3F>Xa8L|U2Bsfv#?&R+ z>T-S5Y!Ix(9ty`XhPXi*M2(qM4o{98M+ZmLKq%F4yrOyah)k6;W^V!c{MOsKI_>g45j?erW9goR?(?IH1P)g5YmKZ$3k@je8F3lc zQSpC|6_SpN<=@T|;_O^@VY9n7(UqujUBQOF=DV?N4r%9h&ApG63k1dx1g?!_)JG4X z8G;djvZJLm{qvF>)XYpO2xFc|F=Y@TqgI|uz;fRQd&7~V0 z49TWMhhhUs3Fr}*t(er@H(86Y0>&VB4p99_u`ZLXM>Z)dE9>WtrVMK}m*hWfZDnFT ztz&^^@8!=oIt6Hs7$UNIf++Bcx#K zMaryylxYX_mO~Wo(8Avzme7UQbl4Z!Y=CCR@8*bl+co`e`vvMoK7e3P zYen|l>Y&k6wvuJSE(O{7O(B3sGYWY6CZz)1uiMO*I11Sa#A=kC3;wk_d||j}gPf1y ziKj016b~zHhSyGO^H14F@?1VG6piY=n4CQTMwDk*UlsrfG3|y!cN)d#>l6$VQC4@WF8ken!I`-a-+@8 ziO*4uUxeYBQEV2GH>GzzwQFAhIMz$jD!XMf=469pv_x!3-&(zSZW|2;x8wpVU`p;( z5zPl}D}((3UGYD|HEhdH4s2eCWC%-(6dLe40B&h{c_kViN81kF+*-zFR)6gXM~&Q* zwPgg&k{2qVoyxC7ejC>&|BXNEKc?C6y<3wl(`3|;yAm}LyjO6^=HoXx1*xd0SS?~{ zNyqkp5AAM88cXG&-4rWkCTH|fC*w{>d(pB6O_qdXxNvf>P;%DU*3NLsO;&W!*7wd) zCp9fNk{TCD-Os0RRQnBi41n<5>5ptCk4m=@tUJ+vvYRZA@7Dr!v=ZHYxxb__%un$g z>{rc$MCbA=g{8G1Ms=H;o3lm4A_GCuRmbi*fOW&o`N@Wa55`&T3u@FDpmXl#1x703 zjU|!FD)JyKrD#M=3Dfora5Q>t${AZ|zeR3+AgLG6iOb2K^|?K-(xFlET-g)wuhbT_ ziTW)Q_OG4gQ)5`FP7w#8F*&Z{NpIbB<>j~bF-()=Ssl;w;nai3P>w;Y|KTR?H!$nU zNEm=a$eG7xqR2S`kKEp$DkG!}{#HLXy7nhIzW-!O>#>$dPbM3dLPM`_+9H$&*bpJ!BrUgQB=EnCRG)h?D zlK8Ht`fezK zA85%h?_&clf01I1`hn}(*_^yQiiodP6?EoSxUeg)+WMY(#Rb87n&I{kL)&Qg@4%9i zY)RqhcJJGRDg7o+;~2chQ=m5hAO+w!o7%|~A?_s?3uF_*x%wCV4#&7pYZ`KqopDCC zl%&Y?-L*!XmTa7-B8E8foTb*5jy;Y+A&x_`71e+CLd*hy$J82I|Wy+z;(ULYMW=b3L`#s00TX}W+=)x zg+rdjMz?5=g@Yvxq=04`vl|)aE-2tF-@S+7Hm;|h2G&_{vAJ5Fg(>Kv)<-QR6_w<~ ze7d!`$sVYwy0f0g6zjK9d(ZWoF<|4OL2<5&Yq>k`%Y7qlu42-Z7(BA*V?4z;kXTJa zpMzY;3HAX@rv(Mwj$he^-a0P5we9yl7X5~7S0;xEfY)bqMpJmi=ytlgah0BXynNYi zvNd137u@w5@Jm`-U)NNqQh$K2z(qzuF+^g$xF$1`V*|umw3x`Tqug&nL|p<|Imd9(p+|IQ3tivAH%Nrdf0IqO2>ikvs2-$@eA! zL87F#Es`LVhOfl=!2ob{1AHa`rJtVG6B3y|+nS0{PF8bKMIOu6&!V5-DI-J;WMwz{ zxW0e;ou*94rEw3`*hDVnb<)lTrRu@G< z*5Fb*U}{f|S9Nzc@%Ts&KlsnBUXeCg6i7r}&i<18qioNXt1ip^I8j|95)zn);KjjI z9zbto1w4-=T_PZSDlu+PrE!%piXGF(28@G`Q^v{Tmu}nTIzki zd3@WaCb zb8dtx@w`RNfV%tS2@AtF4KP$UrNhKn_;H+r2N^Njet03W=JF$YIPsGIm2hh3idBpB z%j4tY$+hELGDXqw{qzGJOJ|DLC#>$^6H8;4zRpEU`LXvdcsCm z1p?FuEle42Fimb3w3WOUTSH%me)&SFN5@8Xrc3!68*P9yt1iyxK0sOyScRAyx>J{x zyu*KLFX=FII-l7Cs&l_C_I2kPlcM;+Dg*GOGAGvyoxf-8XT`3k#1?DTi-dkA@}UuO zP6HLIXboHfAn~d6>IP{EEr~kqV2CB zLXh2A_dxA(q|4Rz-jLMw=FfM>(+y-pPW5Z~0zRS`2U~9Imx~i`-g{N_{FmY`tP#<8EwXlo@ZC$`h2aMZ034E=H1vCx1&rvBj-Vo)}J0?Zk! zZ@JhLT2J&rIpr9eijU})k3_rB98+sL@jOu8%Uu0;;o>}igxkG&ok>nH_Gk+?y-pBw zGDn7sD{b~yON>NIEXi(k+!#{yE-NT+*0sI2Ycj^yF(M>lR8V`I>U7=L2tCmdKA-VB zfJ5VAcqZ21XOit$r;6XR&5`#04%?+!8%Qz>ada8nf<-*XIzc!sJb_ox*BZ%dKp z!Sd3~&plu=(J8j-F@$!o zigKrlG38(m+g=@E2c9YT27Gq}4^ZL3`U6-?_sD;CnVyg+O3w_{I{xZ2a68{{jqh1$ zD6D^XxC3IzYe^#DYggx$Qx4bqs&}?APC%?nt`)M)wIhJ3wwby8+4U+fkS%vB+NY#= z{&7r0gwHdbf+@K*KzrFH5HubV25^}+6WmbdWBKzzx4 zzbf`nty;8Z^=jLj*5yO5E9-qxrvn(-eo8UVqrZOnM(e&Aqsx&NJDbmQ@(JoS4$lv} zUC+JDwT!e=c*SY2@@aI3ZeLfrK7w#lM5}VV1&X z{iXAi@N~Z)0Ts@sj4=HLwJ^i=3tQUs&{h(DUS44SS9uG}+^(yap8n-yJJmx`<$)jM zPx(piDrC4Xq^-8PHU))+fJOQHc}nTgvcow}JJsY`qLg3swfro5_M{whnFrGRk2mp$ltJCibAlJh8x z*X7?1CikmPPHyF9cezvE!eg|{gu_j7!blNt;CTa6VZ}?Lf+Qm&y&PT7M_7HsWzci; zFFbHDV_8|4h+Y1-^z_x}3J(iZv4KIz_r}8D3@J-@6?^admDk*Sfy*?uGL_ZUX$kkn z{&Exhi=ECVQSJik(}xUM10b3FxfnSvTdB(Oa&CTp1qGF?lmP=n%J^?ivM>q}L%XN+ z0NcQ#{-p-F!Ux|WgA9N&xM^)?<$wU`0qG%svviTQ@p<+6QB_qWG1*}KEOI>uZXT}l zbNBMHcM~Hc!C|4kWW@QY$FzO~bQMgR52|8fgy2vhT3R(lmjCGJ_VT+tK)9T7rxNx~ zm#L$iVAc+Q6oa>sAhYq*(2z}xYA#c2$y0SJGY^@}x_AJnkooP zP3tk{WNLm7>&VfmD&vJhO0vFhUIokTqh15!(tc6cdbXu}ULKLc*x;7&@C2@SDr8(X z1=BV9>>Q_eyhA}&Hh_GTAWBmKS)E$+pPv3~@_*=&Ys9NeOgRiW+1fmqT%yYKqMNM~ zwu$|lqX89ctjl}%Xt72qTCvY4`Ae9zANm@U?gOjCXIP{6#&t+~eJ*zwhss6HDJ@cy z0VD7B>!{9~iqDuvoMIOiI;>pfS5l9$&N`AGFoh%Px_;LAaABzllsriLM@D#$nc)7z zNynpT`^Fl}qmlOY!!PI`Fq9@%F4c#7%7DPl4&o#@iU+i$>$l>h91CyRr86k7!U5R> zK+Wf~v9Yn(ST!#AoS>Eq0!F}n2`|8U0pmt=FIBwdmE?cf`z>XJ|M^Mu|5X(5Pgn5o z&;MVEl?riRb=%sz0i97@X2mH$f>gl0{(o}KvE07=X*p;Ezv`xajZ3VI zk6IndrgxAHIf zw(=oxv2*E|IPZ4bcOYvPT9gmQENx83Yo*xgT(-8^_wOxbFfCm@@?&7g0z?y7Ukreh z(=WpRrSg8&-3HD~h$-+FSfhSlehrO{RaRGWzZ(n>R_*6xUc7xTLczwvGZGzL5mI2^ z&H%)@+84ZKf2(BnnNZv7N$B9vP*rIekP_6})s-&L%MkzR^JjBA2Wee^JPAPUuN;B+ z13(v4Ugin_^)(GawYBkxPzwzOVLy_Py3?~$zzr|t*>iyGKg=`gOI(We8B83R$u3CWKQ#2mQ(W9Lt8u zV!oc2ZJtv?&;CR2#jU%juDq*qoPuorNp|E2+cgFSW`E`=V9aZ9`d z!Z-tGH?Dw&E|lDZh=jQB&Xx>g0oJk^0bDu8n#b1{i~}4OJOxX@aoNh`ntxvqCm!~K zcToC=X|?e-Mv})DiW5C>H>g04s-;po8vwA*`>sCAcy=L>J*<-7;ZGoO7uMLxVba9H z(XszZ7wmUikNMQ;{sFk%E2^u<$HwE!51E_`5uZzvjc3kxr^9UTmT|+p6Y}zotY}ah zBZI*&$0R4!+Nw)00BWS~6{cr5$*9{ZLv#ksgx79(^8_4gr9ICWEq&wFj6xA{{pMrq_3GttW&6)3`)F{AEI|CB`0XcxO70SYY%9y5b+6O64FGQq!nu~Zs?*GJ z-wIk-Pbpqw3s#Cj=pdeJ-`-$jdqpx#QMC^LwVzB)ZpA(?sLAjS$SE-eIIsM+n=EGp z6@<_y%n za2eY3(&@Sw-DvF{Z|#`>Wn2OVy;6k|5S!(ZPu*n_gi#}-Gh^*Icz%@+K3bU_B|@A^ z5a;uPLF@o=Co4HeY9r zFaR9*}NsVfXGnP|bn#wz}55OH>-NopZ->9+l1 zG`*I_c&cebPV#e*S7I2(JtX8=E)obrd5K=!I!nA@#wUD922`N2Mpr}Ds5sE!q4V!u ztN5Go$H*75os0m=C3%#ia~7IcF@h{jYa8&rxLqNAIkX(P8x{dlo2=4yRhSj?LN8Aj z!tiPQhOk+PWi0;A;xm;GeKLnoQjr2HsX8w@UUBj6k-3<#$EE~jC3@VtyW6{y!ntIA zuDjG{2VoC@3F3O8X(c>aG{>pGx!)fzD{^=kIHKhm0}6KSdqn&if1<5=lTmkoes;C4 zD4BY$C?{uQdG);rzeBb144&Gz2>x||X{Jmrkcz){Fmc$t@(xXHXVn`8Xi5N!93VZ> z16DgE0gN7y#Z59{&Y`uV5z?Sm{_`jjA`*?)>`m&8Cox9Rk(kpAXy~)&KRTy}%InXm zBp8Lqk56!I9t@vUI8N=3*0K?G<(1}_jH&;d)$huchtL*gpe_YL7hVQ=g)_XNbFQEn793Nz_btxvqI9k-G45I12icO#?uA zK#t*+)ulE7C^1@`1~f1=C$F@PzTHn8Bu0vH>X&k`u*5WCZZoX3!u1;#rI2$#6giHm%Rb@2o@R5iBOt?E4#!R!maPJrvK%r zI=oO|H>oFP6*q5VZb`a3<319&Jv?%q)&>|jBOTL7cp}N@4}i(d&q=IT>P471ocVEF z;%?u$qS|43<_ia;7eG4($IWm@Vq$6yNxZpZ`tgZEMPauZH==xvYknf~T1F-&KC0@$ ze*x|WjrbEQ-mlkJNLKOCMwW0{T_DO$^#iBo?UW6^yplW_S50+H%%eSiB3oo*el;$h zD2>Yspa7ioWz5P%h(p`eK;dN=q9B1IJLdUyh)=30zj4}}-x?)7-{m3MF|Mt#jY?+?TwX7v#UXy*CjTjQ8iJRLQvYO!Fz*vg zWVPiY+jQJ#DxVNR4fkeCiTUa@e>7NP@SK70<6Mu{ad?Pz;=^it#%q=RAL5bZH!_LN zJ4LNF>2j%ne06ySfuNdXQ)P%0D)=vdaN_vb$&AQ$e_>LFckm}S_44HVn>yggHN$!7 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 L8$L!qdhzz(MqOee From 054b6fe97fef241793393f99ac4a93f529ce4169 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 03:16:22 +0000 Subject: [PATCH 2/6] Version Packages --- .changeset/prefetch-run-gate.md | 6 ------ examples/llm-memory-db-mssql/CHANGELOG.md | 7 +++++++ examples/llm-memory-db-mssql/package.json | 2 +- examples/llm-memory-db-pg/CHANGELOG.md | 7 +++++++ examples/llm-memory-db-pg/package.json | 2 +- examples/todo-db/CHANGELOG.md | 7 +++++++ examples/todo-db/package.json | 2 +- packages/cli/CHANGELOG.md | 6 ++++++ packages/cli/package.json | 2 +- packages/sdk/CHANGELOG.md | 6 ++++++ packages/sdk/package.json | 2 +- 11 files changed, 38 insertions(+), 11 deletions(-) delete mode 100644 .changeset/prefetch-run-gate.md diff --git a/.changeset/prefetch-run-gate.md b/.changeset/prefetch-run-gate.md deleted file mode 100644 index 95c2c523..00000000 --- a/.changeset/prefetch-run-gate.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@noormdev/cli': patch -'@noormdev/sdk': patch ---- - -Decide which files to skip with one or two tracking queries per batch instead of one or two per file. Skipped files no longer write execution rows, and a build where nothing changed writes no operation record. The SDK's `run.file(path, { preview: true })` now renders without executing. The TUI's re-run confirmation now finds earlier runs. diff --git a/examples/llm-memory-db-mssql/CHANGELOG.md b/examples/llm-memory-db-mssql/CHANGELOG.md index 71485e30..03a41957 100644 --- a/examples/llm-memory-db-mssql/CHANGELOG.md +++ b/examples/llm-memory-db-mssql/CHANGELOG.md @@ -1,5 +1,12 @@ # @noormdev/example-llm-memory-db-mssql +## 0.0.11 + +### Patch Changes + +- Updated dependencies [0fcc257] + - @noormdev/sdk@1.5.1 + ## 0.0.10 ### Patch Changes diff --git a/examples/llm-memory-db-mssql/package.json b/examples/llm-memory-db-mssql/package.json index 97f73eec..fa936653 100644 --- a/examples/llm-memory-db-mssql/package.json +++ b/examples/llm-memory-db-mssql/package.json @@ -1,6 +1,6 @@ { "name": "@noormdev/example-llm-memory-db-mssql", - "version": "0.0.10", + "version": "0.0.11", "private": true, "type": "module", "description": "LLM-memory schema + SDK example exercising NoORM against MSSQL with TVPs", diff --git a/examples/llm-memory-db-pg/CHANGELOG.md b/examples/llm-memory-db-pg/CHANGELOG.md index fdf51886..9b026644 100644 --- a/examples/llm-memory-db-pg/CHANGELOG.md +++ b/examples/llm-memory-db-pg/CHANGELOG.md @@ -1,5 +1,12 @@ # @noormdev/example-llm-memory-db-pg +## 0.0.11 + +### Patch Changes + +- Updated dependencies [0fcc257] + - @noormdev/sdk@1.5.1 + ## 0.0.10 ### Patch Changes diff --git a/examples/llm-memory-db-pg/package.json b/examples/llm-memory-db-pg/package.json index ee4afa3a..64c37e13 100644 --- a/examples/llm-memory-db-pg/package.json +++ b/examples/llm-memory-db-pg/package.json @@ -1,6 +1,6 @@ { "name": "@noormdev/example-llm-memory-db-pg", - "version": "0.0.10", + "version": "0.0.11", "private": true, "type": "module", "description": "Full-surface noorm example: LLM memory + task tracker schema, SDK, and tests against PostgreSQL", diff --git a/examples/todo-db/CHANGELOG.md b/examples/todo-db/CHANGELOG.md index 9899ae75..f2bf738f 100644 --- a/examples/todo-db/CHANGELOG.md +++ b/examples/todo-db/CHANGELOG.md @@ -1,5 +1,12 @@ # @noormdev/example-todo-db +## 0.0.11 + +### Patch Changes + +- Updated dependencies [0fcc257] + - @noormdev/sdk@1.5.1 + ## 0.0.10 ### Patch Changes diff --git a/examples/todo-db/package.json b/examples/todo-db/package.json index f963b3ac..02835bc2 100644 --- a/examples/todo-db/package.json +++ b/examples/todo-db/package.json @@ -1,6 +1,6 @@ { "name": "@noormdev/example-todo-db", - "version": "0.0.10", + "version": "0.0.11", "private": true, "type": "module", "description": "SDK integration tests exercising the todo-db example schema", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 5a4de9e0..244943ef 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,11 @@ # @noormdev/cli +## 1.5.1 + +### Patch Changes + +- 0fcc257: Decide which files to skip with one or two tracking queries per batch instead of one or two per file. Skipped files no longer write execution rows, and a build where nothing changed writes no operation record. The SDK's `run.file(path, { preview: true })` now renders without executing. The TUI's re-run confirmation now finds earlier runs. + ## 1.5.0 ### Minor Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index bd7f791e..922fbfb9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@noormdev/cli", - "version": "1.5.0", + "version": "1.5.1", "description": "Database schema & changeset manager CLI", "type": "module", "bin": { diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index 589a5db7..0e2d300a 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -1,5 +1,11 @@ # @noormdev/sdk +## 1.5.1 + +### Patch Changes + +- 0fcc257: Decide which files to skip with one or two tracking queries per batch instead of one or two per file. Skipped files no longer write execution rows, and a build where nothing changed writes no operation record. The SDK's `run.file(path, { preview: true })` now renders without executing. The TUI's re-run confirmation now finds earlier runs. + ## 1.5.0 ### Minor Changes diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 3c9c1e98..1de9afbc 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@noormdev/sdk", - "version": "1.5.0", + "version": "1.5.1", "description": "Database schema & changeset manager SDK", "type": "module", "main": "./dist/index.js", From 4f5d6a98ab96b2e3f47c1e8674112b042d7cd886 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 19 Oct 2026 02:05:02 -0400 Subject: [PATCH 3/6] feat(init): scaffold db/sql, db/changes and agent files DEFAULT_PATH_CONFIG stays ./sql and ./changes: a settings.yml with no `paths` key must keep resolving to them. Only init writes the db/ paths, and re-init keeps the paths an existing settings.yml already has. --- .changeset/init-db-layout-agent-files.md | 6 + README.md | 2 +- docs/cli/init.md | 8 +- docs/cli/run.md | 16 +- docs/dev/change.md | 2 +- docs/dev/settings.md | 3 + docs/getting-started/concepts.md | 34 +-- docs/getting-started/first-build.md | 76 +++-- docs/getting-started/installation.md | 11 +- docs/guide/automation/non-interactive.md | 2 +- docs/guide/changes/forward-revert.md | 2 +- docs/guide/changes/history.md | 4 +- docs/guide/changes/overview.md | 14 +- docs/guide/database/create.md | 2 +- docs/guide/sql-files/execution.md | 20 +- docs/guide/sql-files/organization.md | 34 +-- docs/guide/sql-files/templates.md | 14 +- docs/headless.md | 48 ++-- docs/index.md | 8 +- docs/reference/sdk.md | 10 +- skills/noorm/SKILL.md | 40 ++- skills/noorm/references/cli.md | 36 +-- skills/noorm/references/config.md | 22 +- skills/noorm/references/schema-files.md | 266 ++++++++++++++++++ skills/noorm/references/sdk.md | 8 +- skills/noorm/references/templates.md | 20 +- src/core/change/parser.ts | 12 +- src/core/project-init.ts | 94 ++++++- src/core/settings/defaults.ts | 25 ++ src/tui/screens/init/InitScreen.tsx | 23 +- src/tui/screens/init/ProjectSetup.tsx | 35 ++- tests/cli/screens/init/init-flow.test.tsx | 1 + tests/cli/screens/init/init-screen.test.tsx | 1 + tests/cli/screens/init/project-setup.test.tsx | 17 +- tests/core/project-init.test.ts | 204 +++++++++++++- 35 files changed, 892 insertions(+), 228 deletions(-) create mode 100644 .changeset/init-db-layout-agent-files.md create mode 100644 skills/noorm/references/schema-files.md diff --git a/.changeset/init-db-layout-agent-files.md b/.changeset/init-db-layout-agent-files.md new file mode 100644 index 00000000..c65d28c8 --- /dev/null +++ b/.changeset/init-db-layout-agent-files.md @@ -0,0 +1,6 @@ +--- +'@noormdev/cli': minor +'@noormdev/sdk': minor +--- + +`noorm init` now creates `db/sql/` and `db/changes/` instead of `sql/` and `changes/`, and writes `paths.sql: ./db/sql` and `paths.changes: ./db/changes` into `settings.yml`. It also writes `db/AGENTS.md`, which points coding agents at the `/noorm` skill and leaves a marker for your own steering, and `db/CLAUDE.md`, which imports it. Neither file is overwritten on re-init. Existing projects keep their layout: re-running `init`, including `--force`, keeps the `paths` already in `settings.yml`, and a `settings.yml` with no `paths` key still resolves to `./sql` and `./changes`. The `noorm` skill now states what `sql/` and `changes/` are for, which edits belong in each, how to reference `sql/` files from a change with a `.txt` manifest, and when to use a `.sql.tmpl`. diff --git a/README.md b/README.md index bcdddafe..72c0640d 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ The install script is [fully transparent and open source](https://github.com/noo ## Quick start ```bash -# Bootstrap a project — creates your identity and the sql/ and changes/ layout +# Bootstrap a project — creates your identity and the db/sql/ and db/changes/ layout noorm init # Add a database config diff --git a/docs/cli/init.md b/docs/cli/init.md index d2099464..25c1c787 100644 --- a/docs/cli/init.md +++ b/docs/cli/init.md @@ -2,7 +2,13 @@ Bootstrap a new noorm project interactively. Creates identity (if not present), -project structure (`sql/`, `changes/`, `.noorm/`), `settings.yml`, and `state.enc`. +project structure (`db/sql/`, `db/changes/`, `.noorm/`), `settings.yml`, and `state.enc`. + +It also writes `db/AGENTS.md` and `db/CLAUDE.md`. `AGENTS.md` points coding agents at the +`/noorm` skill and holds a marker for your own steering; `CLAUDE.md` imports it with +`@AGENTS.md`. Both are written only when absent, so re-running with `--force` keeps your edits. + +A project that already has a `settings.yml` keeps its `paths`. `--force` resets the rest of the file, but a project laid out at `sql/` and `changes/` stays there, and no `db/` directory is created for it. ## Flags diff --git a/docs/cli/run.md b/docs/cli/run.md index 3e32b952..398cdfdb 100644 --- a/docs/cli/run.md +++ b/docs/cli/run.md @@ -49,7 +49,7 @@ When a file errors, the human-readable output includes the SQL error beneath the file's status line so you don't have to scroll the log looking for the cause: - sql/02_tables/Memory.sql (failed) + db/sql/02_tables/Memory.sql (failed) error: relation "memory" does not exist Build completed status=partial filesRun=12 filesSkipped=0 filesFailed=1 durationMs=84 @@ -66,7 +66,7 @@ populated for every failed file: "status": "partial", "files": [ { - "filepath": "sql/02_tables/Memory.sql", + "filepath": "db/sql/02_tables/Memory.sql", "status": "failed", "error": "relation \"memory\" does not exist", "durationMs": 4.2, @@ -85,13 +85,13 @@ populated for every failed file: A file is skipped when its checksum matches a previous successful execution. The reason is shown inline: - sql/seeds/Sentinels.sql.tmpl (skipped: unchanged) + db/sql/seeds/Sentinels.sql.tmpl (skipped: unchanged) In JSON form, `skipReason` is set to `'unchanged'` (or `'already-run'` for the change-level checks): { - "filepath": "sql/seeds/Sentinels.sql.tmpl", + "filepath": "db/sql/seeds/Sentinels.sql.tmpl", "status": "skipped", "skipReason": "unchanged", "checksum": "..." @@ -125,10 +125,10 @@ and known limitations. noorm run build --force noorm run build --json - noorm run file sql/init.sql + noorm run file db/sql/init.sql noorm run file seeds/test-data.sql.tmpl --dry-run - noorm run dir sql/02_views/ - noorm run files --paths sql/01_tables/users.sql,sql/01_tables/orders.sql + noorm run dir db/sql/02_views/ + noorm run files --paths db/sql/01_tables/users.sql,db/sql/01_tables/orders.sql - noorm run exec "sql/**/*.sql" + noorm run exec "db/sql/**/*.sql" diff --git a/docs/dev/change.md b/docs/dev/change.md index 41ff3279..cca682e5 100644 --- a/docs/dev/change.md +++ b/docs/dev/change.md @@ -20,7 +20,7 @@ noorm's change system solves this with versioned folders containing forward and A change is a folder with a specific structure: ``` -changes/ +db/changes/ └── 2025-01-15-add-email-verification/ ├── change/ # Forward changes (required) │ ├── 001_create-tokens-table.sql diff --git a/docs/dev/settings.md b/docs/dev/settings.md index 4257f9ca..eb40938f 100644 --- a/docs/dev/settings.md +++ b/docs/dev/settings.md @@ -167,6 +167,9 @@ Defaults: | `sql` | `./sql` | | `changes` | `./changes` | +These apply only when `settings.yml` has no `paths` key. `noorm init` writes `./db/sql` and +`./db/changes` into the file explicitly, so a new project never relies on them. + ```typescript const paths = settings.getPaths() // { sql: './db/sql', changes: './db/changes' } diff --git a/docs/getting-started/concepts.md b/docs/getting-started/concepts.md index 8f43ec77..8f129202 100644 --- a/docs/getting-started/concepts.md +++ b/docs/getting-started/concepts.md @@ -9,7 +9,7 @@ This page explains how noorm thinks about your database. Understanding these con Your SQL files define what your schema **is**. They represent the ideal, current state of your database structure. ``` -sql/ +db/sql/ ├── 01_tables/ │ ├── 001_users.sql # CREATE TABLE users... │ └── 002_posts.sql # CREATE TABLE posts... @@ -29,17 +29,17 @@ Everything runs in alphanumeric order. Directories, files within directories, ch This matters because dependencies have order. Tables must exist before views reference them. Functions must exist before triggers call them. Without numeric prefixes, you get alphabetical order: ``` -sql/functions/ ← runs first (f < t < v) -sql/tables/ ← runs second -sql/views/ ← runs third +db/sql/functions/ ← runs first (f < t < v) +db/sql/tables/ ← runs second +db/sql/views/ ← runs third ``` That's broken—views can't reference tables that don't exist yet. Use numeric prefixes: ``` -sql/01_tables/ ← runs first -sql/02_views/ ← runs second -sql/03_functions/ ← runs third (functions can reference tables/views) +db/sql/01_tables/ ← runs first +db/sql/02_views/ ← runs second +db/sql/03_functions/ ← runs third (functions can reference tables/views) ``` The same rule applies to files within directories: @@ -68,7 +68,7 @@ One rule, everywhere. Look at the filesystem and you know the order. Changes document what you did to evolve an existing database. They answer: who changed what, when, and why. ``` -changes/ +db/changes/ └── 2024-01-15-add-user-roles/ ├── change/ │ └── 001_add_role_column.sql @@ -86,7 +86,7 @@ Sometimes you need more than static SQL. Templates let you generate SQL from str Add `.tmpl` to any SQL file to make it a template: ``` -sql/ +db/sql/ ├── tables/ │ └── users.sql # Static SQL └── security/ @@ -99,7 +99,7 @@ Templates can read adjacent data files. Place a `rls.yml` next to `rls.sql.tmpl` Here's a real example: PostgreSQL Row-Level Security. Without templates, you'd write dozens of similar `CREATE POLICY` statements. With templates, you define policies in YAML: ```yaml -# sql/security/rls.yml +# db/sql/security/rls.yml policies: users: - name: users_own_data @@ -129,7 +129,7 @@ policies: Then generate all the SQL: ```sql --- sql/security/rls.sql.tmpl +-- db/sql/security/rls.sql.tmpl {% for (const [table, policies] of Object.entries($.rls.policies)) { %} ALTER TABLE {%~ table %} ENABLE ROW LEVEL SECURITY; @@ -149,7 +149,7 @@ This generates correct, complete SQL for every table. Add a new policy? Edit the You can also define **helper functions** in `$helpers.ts` files. Helpers are inherited up the directory tree—define utilities at the root, specialize them in subdirectories: ```typescript -// sql/$helpers.ts +// db/sql/$helpers.ts export function grantAll(role: string, tables: string[]) { return tables .map(t => `GRANT SELECT, INSERT, UPDATE, DELETE ON ${t} TO ${role};`) @@ -158,7 +158,7 @@ export function grantAll(role: string, tables: string[]) { ``` ```sql --- sql/security/grants.sql.tmpl +-- db/sql/security/grants.sql.tmpl {%~ $.grantAll('app_user', ['users', 'posts', 'comments']) %} ``` @@ -240,7 +240,7 @@ Secrets are sensitive values that get inserted **into** your database via SQL te noorm stores secrets encrypted in your local state. When you run SQL templates, secrets are available but never written to disk in plain text. ```sql --- sql/config/aws.sql.tmpl +-- db/sql/config/aws.sql.tmpl INSERT INTO app_config (key, value) VALUES ('aws_access_key', '{%~ $.secrets.AWS_ACCESS_KEY %}'), ('aws_secret_key', '{%~ $.secrets.AWS_SECRET_KEY %}'); @@ -287,8 +287,8 @@ Settings live in `.noorm/settings.yml` and **should be committed**. They define ```yaml # .noorm/settings.yml paths: - sql: ./sql - changes: ./changes + sql: ./db/sql + changes: ./db/changes build: include: @@ -340,7 +340,7 @@ Connection values go under `defaults`. Required secrets go under `secrets`, and | Aspect | SQL Files | Changes | |--------|-----------|---------| | Purpose | Define ideal schema | Evolve existing databases | -| Location | `sql/` | `changes/` | +| Location | `db/sql/` | `db/changes/` | | When to run | Fresh database, tests | Existing database | | Rollback | No | Yes, with revert scripts | | Order | Alphanumeric by full path | By date prefix | diff --git a/docs/getting-started/first-build.md b/docs/getting-started/first-build.md index e882cb9c..b14b8546 100644 --- a/docs/getting-started/first-build.md +++ b/docs/getting-started/first-build.md @@ -39,8 +39,11 @@ noorm-tutorial/ │ ├── settings.yml # Project settings (commit this) │ └── state/ │ └── state.enc # Encrypted state (don't commit) -├── sql/ # Your SQL files go here -└── changes/ # Versioned changes go here +└── db/ + ├── sql/ # Your SQL files go here + ├── changes/ # Versioned changes go here + ├── AGENTS.md # Steering for AI coding agents working in db/ + └── CLAUDE.md # Imports AGENTS.md for Claude Code ``` Configs are added separately, from the TUI. That's the next step. @@ -67,7 +70,7 @@ Press `c` for config, then `a` to add a new config. Fill out the form: - **Agent Role (MCP/CLI access)**: leave at `viewer` - **Test Database**: leave unchecked -Submit the form to save your config. SQL and changes paths are not part of a config. They live in `.noorm/settings.yml` under `paths`, and default to `./sql` and `./changes`. +Submit the form to save your config. SQL and changes paths are not part of a config. They live in `.noorm/settings.yml` under `paths`, which `noorm init` sets to `./db/sql` and `./db/changes`. ## Step 3: Create the Database @@ -103,8 +106,8 @@ Open `.noorm/settings.yml` and define which folders to include: ```yaml paths: - sql: ./sql - changes: ./changes + sql: ./db/sql + changes: ./db/changes build: include: @@ -117,17 +120,17 @@ The `include` array filters which folders are included in builds. Folder names a Files and directories run in **alphanumeric order**. Without numeric prefixes: ``` -sql/functions/ ← runs first (alphabetically) -sql/tables/ ← runs second -sql/views/ ← runs third +db/sql/functions/ ← runs first (alphabetically) +db/sql/tables/ ← runs second +db/sql/views/ ← runs third ``` That's wrong—tables should exist before views reference them. Use numeric prefixes: ``` -sql/01_tables/ ← runs first -sql/02_views/ ← runs second -sql/03_functions/ ← runs third +db/sql/01_tables/ ← runs first +db/sql/02_views/ ← runs second +db/sql/03_functions/ ← runs third ``` This rule applies everywhere: directories, files within directories, change scripts. One mental model for everything. @@ -139,13 +142,13 @@ This rule applies everywhere: directories, files within directories, change scri Create the users table: ```bash -mkdir -p sql/01_tables +mkdir -p db/sql/01_tables ``` -Create `sql/01_tables/001_users.sql`: +Create `db/sql/01_tables/001_users.sql`: ```sql -CREATE TABLE IF NOT EXISTS users ( +CREATE TABLE users ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT UNIQUE, @@ -164,7 +167,7 @@ Output: ``` • build:start Starting schema build (1 files) -• file:after Executed sql/01_tables/001_users.sql (12ms) +• file:after Executed db/sql/01_tables/001_users.sql (12ms) • build:complete Build complete: 1 run, 0 skipped (18ms) • log Build completed status=success filesRun=1 filesSkipped=0 filesFailed=0 durationMs=18 ``` @@ -178,10 +181,10 @@ Now you want to expand the schema: add a `bio` column to users, create a posts t First, **update your SQL files** to reflect the new ideal state. -Edit `sql/01_tables/001_users.sql`: +Edit `db/sql/01_tables/001_users.sql`: ```sql -CREATE TABLE IF NOT EXISTS users ( +CREATE TABLE users ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT UNIQUE, @@ -190,10 +193,10 @@ CREATE TABLE IF NOT EXISTS users ( ); ``` -Create `sql/01_tables/002_posts.sql`: +Create `db/sql/01_tables/002_posts.sql`: ```sql -CREATE TABLE IF NOT EXISTS posts ( +CREATE TABLE posts ( id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id), title TEXT NOT NULL, @@ -202,10 +205,10 @@ CREATE TABLE IF NOT EXISTS posts ( ); ``` -Create `sql/02_views/001_recent_posts.sql`: +Create `db/sql/02_views/001_recent_posts.sql`: ```bash -mkdir -p sql/02_views +mkdir -p db/sql/02_views ``` ```sql @@ -251,22 +254,35 @@ Created folder structure with template files. Press `e` to open the change folder in your editor, or navigate to it manually. Add these files: -`changes/2024-01-15-add-posts-and-bio/change/001_add_bio.sql`: +`db/changes/2024-01-15-add-posts-and-bio/change/001_add_bio.sql`: ```sql ALTER TABLE users ADD COLUMN IF NOT EXISTS bio TEXT; ``` -Changes should be idempotent—safe to run on any database state. PostgreSQL's `IF NOT EXISTS` ensures this won't error if the column already exists. +Changes should be idempotent—safe to run on any database state. PostgreSQL's `IF NOT EXISTS` ensures this won't error if the column already exists. A freshly built database records no change history, so its first `noorm change ff` runs every change again. + +`db/changes/2024-01-15-add-posts-and-bio/change/002_create_posts.sql`: + +```sql +CREATE TABLE IF NOT EXISTS posts ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id), + title TEXT NOT NULL, + body TEXT, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +The change carries its own copy of the `CREATE TABLE`. A manifest runs the current content of the file it names, so it should only name objects that are safe to run repeatedly. -`changes/2024-01-15-add-posts-and-bio/change/002_schema.txt`: +`db/changes/2024-01-15-add-posts-and-bio/change/003_views.txt`: ``` -01_tables/002_posts.sql 02_views/001_recent_posts.sql ``` -The `.txt` file references your SQL files. When the change runs, it executes those files from your `sql/` directory. +The `.txt` file references a SQL file instead of copying it. When the change runs, it executes that file from your `db/sql/` directory, relative to `paths.sql`. The view is `CREATE OR REPLACE`, so running it again is harmless. ## Step 9: Apply the Change @@ -284,7 +300,7 @@ Output: • log 2024-01-15-add-posts-and-bio (success) ``` -Three files, because the `002_schema.txt` manifest expands to the two SQL files it names. Per-file lines are `change:file` events, which sit at debug level. Raise `logging.level` to `verbose` in `.noorm/settings.yml` to see them. +Three files: the two `.sql` files, and the one view file that the `003_views.txt` manifest names. Per-file lines are `change:file` events, which sit at debug level. Raise `logging.level` to `verbose` in `.noorm/settings.yml` to see them. Your dev database now matches your SQL files. @@ -309,9 +325,9 @@ Output: ``` • build:start Starting schema build (3 files) -• file:after Executed sql/01_tables/001_users.sql (14ms) -• file:after Executed sql/01_tables/002_posts.sql (11ms) -• file:after Executed sql/02_views/001_recent_posts.sql (7ms) +• file:after Executed db/sql/01_tables/001_users.sql (14ms) +• file:after Executed db/sql/01_tables/002_posts.sql (11ms) +• file:after Executed db/sql/02_views/001_recent_posts.sql (7ms) • build:complete Build complete: 3 run, 0 skipped (32ms) • log Build completed status=success filesRun=3 filesSkipped=0 filesFailed=0 durationMs=32 ``` diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 054f0077..d2bf3929 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -151,10 +151,13 @@ your-project/ │ ├── settings.yml # Project settings (commit this) │ └── state/ │ └── state.enc # Encrypted state (don't commit) -├── sql/ # Your SQL files -│ └── .gitkeep -└── changes/ # Versioned changes - └── .gitkeep +└── db/ + ├── sql/ # Your SQL files + │ └── .gitkeep + ├── changes/ # Versioned changes + │ └── .gitkeep + ├── AGENTS.md # Steering for AI coding agents working in db/ + └── CLAUDE.md # Imports AGENTS.md for Claude Code ``` `noorm init` is strictly interactive and exits with an error on a non-TTY unless you pass `--yes` and already have an identity at `~/.noorm/identity.{key,pub,json}`. diff --git a/docs/guide/automation/non-interactive.md b/docs/guide/automation/non-interactive.md index 9d5b0743..209b750c 100644 --- a/docs/guide/automation/non-interactive.md +++ b/docs/guide/automation/non-interactive.md @@ -93,7 +93,7 @@ with no prompts: --name "$CI_USER" \ --email "$CI_EMAIL" - # 2. Project scaffold (sql/, changes/, settings.yml, state.enc). + # 2. Project scaffold (db/sql/, db/changes/, settings.yml, state.enc). cd "$PROJECT_ROOT" noorm init --yes diff --git a/docs/guide/changes/forward-revert.md b/docs/guide/changes/forward-revert.md index e10837ed..8c46640a 100644 --- a/docs/guide/changes/forward-revert.md +++ b/docs/guide/changes/forward-revert.md @@ -39,7 +39,7 @@ Fast-forward is the workhorse for deployments. It finds every outstanding change } ``` -Each entry in `changes` also carries a `files` array with one record per SQL file: relative path, checksum, status, duration, and the error message when the file failed. If the `changes/` directory is missing, the payload gains a `warnings` array saying so, because an absent directory otherwise looks identical to an up-to-date database. +Each entry in `changes` also carries a `files` array with one record per SQL file: relative path, checksum, status, duration, and the error message when the file failed. If the `db/changes/` directory is missing, the payload gains a `warnings` array saying so, because an absent directory otherwise looks identical to an up-to-date database. ### Apply a Specific Change diff --git a/docs/guide/changes/history.md b/docs/guide/changes/history.md index e4f6b6b9..aba25435 100644 --- a/docs/guide/changes/history.md +++ b/docs/guide/changes/history.md @@ -84,7 +84,7 @@ Press Enter on any record to see file-level execution details: | | | +------------------------------------------------------------+| | | File Details || -| | Path: changes/2025-01-15-.../change/001_create-tokens... || +| | Path: db/changes/2025-01-15-.../change/001_create-tok... || | | Checksum: 8f4a2b3c9d1e... || | +------------------------------------------------------------+| | | @@ -97,7 +97,7 @@ For failed files, the detail panel shows the error message: ``` | +------------------------------------------------------------+| | | File Details || -| | Path: changes/2025-01-15-.../change/003_add-index.sql || +| | Path: db/changes/2025-01-15-.../change/003_add-index.sql || | | Checksum: 3b7c8d2a4f5e... || | | Error: relation "users" does not exist || | +------------------------------------------------------------+| diff --git a/docs/guide/changes/overview.md b/docs/guide/changes/overview.md index 1da4fbd2..f30c1277 100644 --- a/docs/guide/changes/overview.md +++ b/docs/guide/changes/overview.md @@ -31,9 +31,9 @@ Changes are your changelog. They document *how* to get an existing database to m The distinction is simple: **can you rebuild from scratch, or do you need to evolve what's there?** -**Fresh database?** Run `noorm run build`. This executes your `sql/` folder—your source of truth. Tables, views, functions, seed data. Everything needed to create a working schema from nothing. +**Fresh database?** Run `noorm run build`. This executes your `db/sql/` folder—your source of truth. Tables, views, functions, seed data. Everything needed to create a working schema from nothing. -**Existing database with data you can't lose?** Run `noorm change ff`. This applies pending changes from your `changes/` folder. Each change runs once, gets recorded in the database, and won't run again unless reverted. +**Existing database with data you can't lose?** Run `noorm change ff`. This applies pending changes from your `db/changes/` folder. Each change runs once, gets recorded in the database, and won't run again unless reverted. Changes exist because production databases can't be rebuilt. You can't drop a table with millions of rows and recreate it. You need to `ALTER TABLE` in place, transform data carefully, add indexes without blocking writes. Changes give you: @@ -47,7 +47,7 @@ Changes exist because production databases can't be rebuilt. You can't drop a ta Each change lives in its own folder with a specific structure: ``` -changes/ +db/changes/ └── 2025-01-15-add-email-verification/ ├── change/ # Forward scripts (required) │ ├── 001_create-tokens-table.sql @@ -101,7 +101,7 @@ Or from the TUI: run `noorm ui`, press `g` from home to enter the changes screen and enter a name like `add-email-verification`. Either path scaffolds the same layout: ``` -changes/2025-01-15-add-email-verification/ +db/changes/2025-01-15-add-email-verification/ ├── change/ │ └── 001_add-email-verification.sql ├── revert/ @@ -203,7 +203,7 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'active'; Manifest files participate in ordering. Here, `001_rebuild-views.txt` runs first (recreating views that might depend on the old schema), then `002_add-status-column.sql` adds the column. You could also flip this—add the column first with `001_`, then rebuild views with `002_` so they pick up the new column. -This keeps your `sql/` folder as the single source of truth. Views and functions are defined once, referenced wherever needed. +This keeps your `db/sql/` folder as the single source of truth. Views and functions are defined once, referenced wherever needed. ## Checking Change Status @@ -266,7 +266,7 @@ When you omit the change name on a TTY, several subcommands open an interactive `run`, `revert`, `rewind`, and `history-detail` query the database and filter to the relevant subset: outstanding changes for `run`, successfully applied ones for `revert` and `rewind`, anything with a history record for `history-detail`. `edit` and `rm` work offline -and list every folder in `changes/`. Non-TTY callers must supply the name. +and list every folder in `db/changes/`. Non-TTY callers must supply the name. ## Common Workflows @@ -341,7 +341,7 @@ To render a single template file to stdout without involving a change at all, us `noorm run preview`: ```bash -noorm run preview sql/views/user_summary.sql.tmpl +noorm run preview db/sql/views/user_summary.sql.tmpl ``` diff --git a/docs/guide/database/create.md b/docs/guide/database/create.md index 3b693d6f..a59091d0 100644 --- a/docs/guide/database/create.md +++ b/docs/guide/database/create.md @@ -19,7 +19,7 @@ expects a `--name` flag. End-to-end, from a clean repo to a usable database: - # 1. Scaffold the project (settings.yml, .noorm/, sql/, changes/). + # 1. Scaffold the project (settings.yml, .noorm/, db/sql/, db/changes/). noorm init --yes # 2. Add one config per environment, non-interactively. diff --git a/docs/guide/sql-files/execution.md b/docs/guide/sql-files/execution.md index 4f7fad81..b89567ff 100644 --- a/docs/guide/sql-files/execution.md +++ b/docs/guide/sql-files/execution.md @@ -29,7 +29,7 @@ noorm run build This executes all SQL files in your schema directory (defined in settings). Files are processed alphabetically by path, which means you can control execution order with prefixes: ``` -sql/ +db/sql/ ├── 00_extensions/ │ └── 001_extensions.sql # Runs first ├── 01_tables/ @@ -43,9 +43,9 @@ Output shows what ran and what was skipped. Every line carries a timestamp and a ``` Starting schema build (3 files) -Executed sql/01_tables/001_users.sql (14ms) -Skipped sql/01_tables/002_posts.sql (unchanged) -Executed sql/02_views/001_active_users.sql (9ms) +Executed db/sql/01_tables/001_users.sql (14ms) +Skipped db/sql/01_tables/002_posts.sql (unchanged) +Executed db/sql/02_views/001_active_users.sql (9ms) Build complete: 2 run, 1 skipped (412ms) ``` @@ -57,7 +57,7 @@ Add `--json` to get the same events as newline-delimited JSON on stderr and the Execute a single SQL file: ```bash -noorm run file sql/01_tables/001_users.sql +noorm run file db/sql/01_tables/001_users.sql ``` Useful for testing a specific file or re-running one file after a quick fix. The same change detection applies—if the file hasn't changed since its last successful run, it won't execute unless you use `--force`. @@ -68,7 +68,7 @@ Useful for testing a specific file or re-running one file after a quick fix. The Execute all files in a specific directory: ```bash -noorm run dir sql/02_views +noorm run dir db/sql/02_views ``` Processes all `.sql` and `.sql.tmpl` files in that directory (and subdirectories), alphabetically. This is helpful when you want to rebuild just views or just a subset of your schema. @@ -103,8 +103,8 @@ This renders all SQL files (including templates) and writes them to a local `tmp ``` Source: Dry run output: -sql/02_views/001_my_view.sql → tmp/sql/02_views/001_my_view.sql -sql/03_seeds/001_users.sql.tmpl → tmp/sql/03_seeds/001_users.sql +db/sql/02_views/001_my_view.sql → tmp/db/sql/02_views/001_my_view.sql +db/sql/03_seeds/001_users.sql.tmpl → tmp/db/sql/03_seeds/001_users.sql ``` Templates are fully rendered with your current config context. The `.tmpl` extension is stripped from output files. Nothing is written to the database and nothing is tracked. @@ -160,7 +160,7 @@ Files execute **alphabetically by full path**. This is deterministic and predict Common patterns: ``` -sql/ +db/sql/ ├── 00_extensions/ │ └── 001_uuid.sql # Extensions first ├── 01_types/ @@ -194,7 +194,7 @@ SQL Server has a particular rule: `CREATE PROCEDURE`, `CREATE FUNCTION`, `CREATE ```sql --- sql/09_procedures/checkout.sql +-- db/sql/09_procedures/checkout.sql CREATE PROCEDURE checkout_begin @CustomerId INT AS diff --git a/docs/guide/sql-files/organization.md b/docs/guide/sql-files/organization.md index c09aa5cc..916d142f 100644 --- a/docs/guide/sql-files/organization.md +++ b/docs/guide/sql-files/organization.md @@ -9,7 +9,7 @@ How you structure your SQL files determines how they execute. noorm processes fi Start with this layout: ``` -sql/ +db/sql/ ├── 01_tables/ │ ├── 001_users.sql │ ├── 002_posts.sql @@ -97,7 +97,7 @@ noorm walks your schema directory recursively, collects every `.sql` and `.sql.t Without numeric folder prefixes, alphabetical order causes problems: ``` -sql/ +db/sql/ ├── functions/ # f comes before t—runs first! │ └── 001_helpers.sql ├── tables/ # t comes after f—runs second @@ -110,7 +110,7 @@ sql/ This is wrong—functions often depend on tables. Use numeric prefixes on folders: ``` -sql/ +db/sql/ ├── 01_tables/ # runs first │ ├── 001_users.sql │ └── 002_posts.sql @@ -121,10 +121,10 @@ sql/ ``` Execution order: -1. `sql/01_tables/001_users.sql` -2. `sql/01_tables/002_posts.sql` -3. `sql/02_views/001_summary.sql` -4. `sql/03_functions/001_helpers.sql` +1. `db/sql/01_tables/001_users.sql` +2. `db/sql/01_tables/002_posts.sql` +3. `db/sql/02_views/001_summary.sql` +4. `db/sql/03_functions/001_helpers.sql` ## Using Settings for Build Order @@ -142,10 +142,10 @@ build: - archive # Never runs ``` -Folder names are relative to your `paths.sql` directory, not the project root. With the default `paths.sql: ./sql`, an entry of `01_tables` means `./sql/01_tables` — so you do **not** repeat the `sql/` prefix. +Folder names are relative to your `paths.sql` directory, not the project root. With the `paths.sql: ./db/sql` that `noorm init` writes, an entry of `01_tables` means `./db/sql/01_tables` — so you do **not** repeat the `sql/` prefix. ::: warning A wrong include path still exits 0 -`sql/01_tables` resolves to `./sql/sql/01_tables` and matches nothing. `noorm run build` names the offending entries in a warning: +`sql/01_tables` resolves to `./db/sql/sql/01_tables` and matches nothing. `noorm run build` names the offending entries in a warning: ``` Ignored 1 build.include entry that matched no files: sql/01_tables @@ -162,7 +162,7 @@ A leading `./` or a trailing `/` matches nothing for the same reason. The `include` array is a **filter**—it controls which folders are included, not their order. Execution order is always alphanumeric. Use numeric prefixes to control the sequence: ``` -sql/ +db/sql/ ├── 01_tables/ ← runs first ├── 02_views/ ← runs second ├── 03_functions/ ← runs third @@ -215,7 +215,7 @@ There are two common approaches to organizing SQL files. Group files by what they are: ``` -sql/ +db/sql/ ├── 01_tables/ ├── 02_views/ ├── 03_functions/ @@ -237,7 +237,7 @@ sql/ If you're building a [typed SDK](/getting-started/building-your-sdk), organize SQL to mirror your domain classes: ``` -sql/ +db/sql/ ├── 01_core/ │ ├── tables/ # Base entities (users, accounts) │ └── functions/ # Shared utilities @@ -253,7 +253,7 @@ sql/ └── defaults.sql # Reference data ``` -This aligns your database layer with your application layer. The SDK's `auth` domain class maps to `sql/02_auth/`, making it easy to find related code. +This aligns your database layer with your application layer. The SDK's `auth` domain class maps to `db/sql/02_auth/`, making it easy to find related code. **Pros:** - SQL and SDK stay in sync @@ -270,7 +270,7 @@ This aligns your database layer with your application layer. The SDK's `auth` do Start simple and add domains as needed: ``` -sql/ +db/sql/ ├── 01_tables/ # All tables (simple start) ├── 02_views/ ├── 03_functions/ @@ -280,7 +280,7 @@ sql/ When a domain grows complex, extract it: ``` -sql/ +db/sql/ ├── 01_core/ # Moved tables, views, functions ├── 02_billing/ # Extracted billing domain ├── 03_functions/ # Shared functions @@ -296,7 +296,7 @@ sql/ Use rules to include environment-specific folders: ``` -sql/ +db/sql/ ├── 01_tables/ ├── 02_views/ ├── 03_seeds-dev/ # Development data @@ -328,7 +328,7 @@ rules: Keep old files without running them: ``` -sql/ +db/sql/ ├── 01_tables/ ├── 02_views/ └── archive/ # Old files, excluded from builds diff --git a/docs/guide/sql-files/templates.md b/docs/guide/sql-files/templates.md index ed2a8e68..2676d1ee 100644 --- a/docs/guide/sql-files/templates.md +++ b/docs/guide/sql-files/templates.md @@ -128,7 +128,7 @@ INSERT INTO config (data) VALUES ('{%~ $.escape($.json(settings)) %}'); Includes another SQL file. The path is relative to the current template: ```sql --- changes/2025-01-15-setup/change/001_setup.sql.tmpl +-- db/changes/2025-01-15-setup/change/001_setup.sql.tmpl -- Load shared functions {%~ await $.include('../lib/uuid_function.sql') %} @@ -145,7 +145,7 @@ An included `.sql` file is inserted verbatim. An included `.sql.tmpl` file is re Data files in the same directory as the template are automatically loaded into `$`: ``` -sql/users/ +db/sql/users/ ├── 001_seed.sql.tmpl # Template ├── users.json # -> $.users ├── roles.yml # -> $.roles @@ -242,7 +242,7 @@ INSERT INTO users (email, name, role) VALUES `.dt` and `.dtz` files exported from [Data Transfer](/guide/database/transfer) can be used directly as seed data. The loader reads the embedded schema, maps positional values to column names, and exposes the data as an array of row objects—just like CSV. ``` -sql/users/ +db/sql/users/ ├── 001_seed.sql.tmpl # Template └── users.dt # -> $.users (exported from another database) ``` @@ -263,7 +263,7 @@ This lets you export data from one environment and use it as seed data in templa Helper files named `$helpers.ts` (or `$helpers.js`) are automatically loaded and inherited up the directory tree: ``` -sql/ +db/sql/ ├── $helpers.ts # Project-wide helpers ├── users/ │ ├── $helpers.ts # Overrides/extends parent @@ -281,7 +281,7 @@ Child helpers override parent helpers with the same name. This lets you define p Helper files can be written in TypeScript (`.ts`) or JavaScript (`.js`, `.mjs`). TypeScript helpers require Node >= 22.13, which supports type stripping natively. On older Node versions, use `.js` instead. ```typescript -// sql/$helpers.ts +// db/sql/$helpers.ts export function padId(id: number, length = 6): string { return String(id).padStart(length, '0') @@ -463,7 +463,7 @@ GRANT EXECUTE ON {%~ proc %} TO {%~ role %}; Large table schemas are easier to review when split into logical groups. Use `$.include()` to compose them into a single change: ``` -changes/2025-01-15-full-setup/ +db/changes/2025-01-15-full-setup/ ├── change/ │ └── 001_schema.sql.tmpl └── lib/ @@ -473,7 +473,7 @@ changes/2025-01-15-full-setup/ ``` ```sql --- changes/2025-01-15-full-setup/change/001_schema.sql.tmpl +-- db/changes/2025-01-15-full-setup/change/001_schema.sql.tmpl -- Core tables (users, roles, user_roles) {%~ await $.include('../lib/core_tables.sql') %} diff --git a/docs/headless.md b/docs/headless.md index 7d7dbd2d..bec2684e 100644 --- a/docs/headless.md +++ b/docs/headless.md @@ -127,8 +127,8 @@ NOORM_CI_CONFIG_NAME # Default config name for `ci init` (override: --na **Paths:** ```bash -NOORM_PATHS_SQL # Schema directory (default: ./sql) -NOORM_PATHS_CHANGES # Changes directory (default: ./changes) +NOORM_PATHS_SQL # Schema directory (default: ./sql when settings.yml has no paths) +NOORM_PATHS_CHANGES # Changes directory (default: ./changes when settings.yml has no paths) ``` **Behavior:** @@ -209,7 +209,7 @@ Raw SQL (`noorm sql`) is gated by what the statement actually does, not by a fla #### `init` -Bootstrap a new noorm project interactively. Creates identity (if needed), project structure (`sql/`, `changes/`, `.noorm/`), `settings.yml`, and encrypted `state.enc`. +Bootstrap a new noorm project interactively. Creates identity (if needed), project structure (`db/sql/`, `db/changes/`, `db/AGENTS.md`, `db/CLAUDE.md`, `.noorm/`), `settings.yml`, and encrypted `state.enc`. ```bash noorm init # Interactive, requires TTY @@ -685,7 +685,7 @@ noorm run build --dry-run # Preview without executing "success": true, "status": "success", "files": [ - {"filepath": "sql/01_tables/users.sql", "status": "success", "checksum": "…", "durationMs": 12} + {"filepath": "db/sql/01_tables/users.sql", "status": "success", "checksum": "…", "durationMs": 12} ], "filesRun": 2, "filesSkipped": 1, @@ -702,9 +702,9 @@ noorm run build --dry-run # Preview without executing Execute a single SQL or `.sql.tmpl` file. ```bash -noorm run file sql/01_tables/001_users.sql +noorm run file db/sql/01_tables/001_users.sql noorm run file seed.sql.tmpl -noorm run file sql/init.sql --json +noorm run file db/sql/init.sql --json ``` **JSON output:** @@ -724,7 +724,7 @@ noorm run file sql/init.sql --json Execute all SQL files in a directory in alphabetical order. ```bash -noorm run dir sql/01_tables/ +noorm run dir db/sql/01_tables/ noorm run dir seeds/ noorm run dir migrations/ --json ``` @@ -737,8 +737,8 @@ A directory that does not exist, or that holds no SQL files, exits `2` — a dir Batch-execute SQL files matching a glob pattern or directory. More flexible than `run dir`. ```bash -noorm run exec sql/ -noorm run exec "sql/**/*.sql" +noorm run exec db/sql/ +noorm run exec "db/sql/**/*.sql" noorm run exec "seeds/*.sql" --force noorm run exec migrations/ --dry-run ``` @@ -752,7 +752,7 @@ Execute multiple specific SQL files in order. ```bash noorm run files --paths seed.sql,fixtures.sql -noorm run files --paths sql/001_tables.sql,sql/002_indexes.sql --json +noorm run files --paths db/sql/001_tables.sql,db/sql/002_indexes.sql --json ``` @@ -761,15 +761,15 @@ noorm run files --paths sql/001_tables.sql,sql/002_indexes.sql --json Inspect the template context for a `.sql.tmpl` file without executing. Shows data files, helpers, config, secrets, and built-in functions. ```bash -noorm run inspect sql/users/001_create.sql.tmpl -noorm run inspect sql/core/Crons.sql.tmpl --json +noorm run inspect db/sql/users/001_create.sql.tmpl +noorm run inspect db/sql/core/Crons.sql.tmpl --json ``` **JSON output:** ```json { "success": true, - "filepath": "sql/users/001_create.sql.tmpl", + "filepath": "db/sql/users/001_create.sql.tmpl", "context": { "dataFiles": [{"key": "roles", "type": "Array [3]"}], "helpers": [{"key": "padId", "type": "Function"}], @@ -788,17 +788,17 @@ noorm run inspect sql/core/Crons.sql.tmpl --json Render a `.sql.tmpl` file and output the resulting SQL. Does **not** execute against the database. ```bash -noorm run preview sql/schema.sql.tmpl -noorm run preview sql/schema.sql.tmpl > rendered.sql -noorm run preview sql/seed.sql.tmpl --json -noorm run preview sql/migrations/002.sql.tmpl --config staging +noorm run preview db/sql/schema.sql.tmpl +noorm run preview db/sql/schema.sql.tmpl > rendered.sql +noorm run preview db/sql/seed.sql.tmpl --json +noorm run preview db/sql/migrations/002.sql.tmpl --config staging ``` **JSON output:** ```json { "success": true, - "filepath": "sql/schema.sql.tmpl", + "filepath": "db/sql/schema.sql.tmpl", "sql": "CREATE TABLE ...", "durationMs": 12 } @@ -818,8 +818,8 @@ Bare `noorm change` renders citty's help output and does not connect to the data | `change run` | pending + reverted + stale (not orphaned) | | `change revert` | success | | `change rewind` | success | -| `change edit` | every directory under `changes/` (filesystem) | -| `change rm` | every directory under `changes/` (filesystem) | +| `change edit` | every directory under `db/changes/` (filesystem) | +| `change rm` | every directory under `db/changes/` (filesystem) | | `change history-detail` | anything with an execution record (non-pending) | Non-TTY callers (CI, scripts) must pass the name or the command errors out. @@ -873,7 +873,7 @@ noorm change ff --force } ``` -`--dry-run` adds `"dryRun": true` to the payload. A missing `changes/` directory is a warning, not a failure: it lands in `warnings` rather than making the batch fail. +`--dry-run` adds `"dryRun": true` to the payload. A missing `db/changes/` directory is a warning, not a failure: it lands in `warnings` rather than making the batch fail. #### `change next` @@ -946,7 +946,7 @@ noorm change add add-users-table noorm change add "notification queue" --json ``` -Creates `changes/-/` with `change/` and `revert/` directories, each +Creates `db/changes/-/` with `change/` and `revert/` directories, each holding a `001_.sql` stub (a single comment naming what belongs there — "Add the SQL statements that apply this change" / "...undo this change"), plus `changelog.md`. The stub comments alone don't count as content: running the change before editing them fails with an @@ -1804,7 +1804,7 @@ list results live under a key named for the resource: { "success": true, "changes": [ … ], "pending": 0 } { "success": true, "status": "success", "filesRun": 3, "filesSkipped": 0, "filesFailed": 0 } { "success": false, "status": "partial", "filesRun": 1, "filesSkipped": 0, "filesFailed": 2 } -{ "success": false, "error": "Template not found: sql/nope.sql.tmpl" } +{ "success": false, "error": "Template not found: db/sql/nope.sql.tmpl" } ``` Three guarantees hold across every command: @@ -1866,5 +1866,5 @@ fi ```bash # Render template and pipe to external tool -noorm run preview sql/hotfix.sql.tmpl | psql -h localhost -d myapp +noorm run preview db/sql/hotfix.sql.tmpl | psql -h localhost -d myapp ``` diff --git a/docs/index.md b/docs/index.md index cdbf5ede..3e96d6cc 100644 --- a/docs/index.md +++ b/docs/index.md @@ -95,8 +95,8 @@ curl -fsSL https://raw.githubusercontent.com/noormdev/noorm/master/install.sh | Write a SQL file, then build it: ```bash -mkdir -p sql/01_tables -echo "CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT);" > sql/01_tables/001_users.sql +mkdir -p db/sql/01_tables +echo "CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT);" > db/sql/01_tables/001_users.sql noorm run build ``` @@ -108,8 +108,8 @@ noorm run build When the schema evolves, update the SQL file **and** add a change. The file keeps describing what the schema is; the change tells existing databases how to catch up: ```bash -# Edit sql/01_tables/001_users.sql to add an email column -# Add changes/2024-01-add-email/forward.sql +# Edit db/sql/01_tables/001_users.sql to add an email column +# Add db/changes/2024-01-add-email/forward.sql noorm change ff # fast-forward: apply pending changes ``` diff --git a/docs/reference/sdk.md b/docs/reference/sdk.md index e7e012ba..584302b5 100644 --- a/docs/reference/sdk.md +++ b/docs/reference/sdk.md @@ -648,7 +648,7 @@ if (result.status !== 'success') { Discover SQL files in a directory without executing. Works offline — no connection required. Defaults to the project's configured SQL path. ```typescript -const files = await ctx.noorm.run.discover('sql/'); +const files = await ctx.noorm.run.discover('db/sql/'); console.log(`Found ${files.length} SQL files`); ``` @@ -660,7 +660,7 @@ console.log(`Found ${files.length} SQL files`); Render SQL files (including templates) without executing. Useful for reviewing what would run before committing. ```typescript -const results = await ctx.noorm.run.preview(['sql/001.sql', 'sql/002.sql']); +const results = await ctx.noorm.run.preview(['db/sql/001.sql', 'db/sql/002.sql']); for (const r of results) { console.log(`${r.filepath}:\n${r.renderedSql}`); } @@ -750,7 +750,7 @@ Full rebuild: teardown + build. await ctx.noorm.db.reset(); ``` -**Returns:** `Promise`. Unlike `teardown()` and `truncate()`, `reset()` takes no options and deliberately ignores `settings.teardown.preserveTables`: it rebuilds the whole schema from `sql/`, so any preserved table would collide with the build's `CREATE TABLE` and abort the rebuild. +**Returns:** `Promise`. Unlike `teardown()` and `truncate()`, `reset()` takes no options and deliberately ignores `settings.teardown.preserveTables`: it rebuilds the whole schema from `db/sql/`, so any preserved table would collide with the build's `CREATE TABLE` and abort the rebuild. **Implementation notes.** Objects are dropped in `FK → Procedures → Functions → Views → Tables → Types` order. Procs/funcs/views go before tables because MSSQL schema-bound objects (`WITH SCHEMABINDING`) hold dependency locks on the tables they reference — dropping the table first fails with `Cannot DROP TABLE ... because it is being referenced by object ...`. Types drop last because TVPs may still be referenced by procs/funcs earlier in the chain. See [dev/teardown.md](../dev/teardown.md#drop-order) for the full reasoning. @@ -1098,7 +1098,7 @@ This is the one lock method behind the access policy. Breaking someone else's lo Render a template file without executing. ```typescript -const result = await ctx.noorm.templates.render('sql/001_users.sql.tmpl'); +const result = await ctx.noorm.templates.render('db/sql/001_users.sql.tmpl'); console.log(result.sql); ``` @@ -1457,7 +1457,7 @@ if (!result.ok) { Compute SHA-256 checksum for a file. ```typescript -const checksum = await ctx.noorm.utils.checksum('sql/001_users.sql'); +const checksum = await ctx.noorm.utils.checksum('db/sql/001_users.sql'); ``` diff --git a/skills/noorm/SKILL.md b/skills/noorm/SKILL.md index 4c527245..cd2f5ac2 100644 --- a/skills/noorm/SKILL.md +++ b/skills/noorm/SKILL.md @@ -1,6 +1,6 @@ --- name: noorm -description: Guide for building applications and tests with the NoORM database SDK (@noormdev/sdk) and composing CLI headless commands for CI/CD pipelines. Use this skill whenever the user imports @noormdev/sdk, calls createContext(), writes Kysely queries in a noorm-managed project, writes database integration tests, sets up CI/CD with noorm commands, manages database schemas or migrations, runs SQL files through noorm, transfers data between databases, manages vault secrets, or discusses noorm in any development context. Also trigger when you see noorm environment variables (NOORM_*), .sql.tmpl template files, or change/revert directory structures. Trigger for any database task in a noorm-managed project — the SDK conventions (SDK methods throw named errors, `attempt()` wraps deliberately, no try-catch, test safety guards, Kysely patterns) and CLI flags are non-obvious and critical to get right. +description: Guide for building applications and tests with the NoORM database SDK (@noormdev/sdk) and composing CLI headless commands for CI/CD pipelines. Use this skill whenever the user imports @noormdev/sdk, calls createContext(), writes Kysely queries in a noorm-managed project, writes database integration tests, sets up CI/CD with noorm commands, manages database schemas or migrations, runs SQL files through noorm, transfers data between databases, manages vault secrets, or discusses noorm in any development context. Also trigger when you see noorm environment variables (NOORM_*), .sql.tmpl template files, .txt change manifests, a `.noorm/` directory, `db/sql` or `db/changes` folders, or change/revert directory structures, and whenever the task is to add, alter, or drop a table, column, view, function, or seed data in a noorm project. Trigger for any database task in a noorm-managed project — the SDK conventions (SDK methods throw named errors, `attempt()` wraps deliberately, no try-catch, test safety guards, Kysely patterns) and CLI flags are non-obvious and critical to get right. --- # NoORM @@ -23,9 +23,34 @@ Database schema and change manager with two interfaces: | Managing noorm locks (tool-level coordination, not DB engine locks), vault secrets, or data transfers via CLI | `references/cli.md` | | Configuring `.noorm/settings.yml` or defining stages | `references/config.md` | | Setting up a new noorm project or understanding config structure | `references/config.md` | -| Writing `.sql.tmpl` template files | `references/templates.md` | +| Adding or changing a table, column, view, function, or seed data | `references/schema-files.md` | +| Deciding whether SQL goes in `sql/` or `changes/`, writing a change, or writing a `.txt` manifest | `references/schema-files.md` | +| Writing `.sql.tmpl` template files | `references/templates.md` (and **Templates** in `references/schema-files.md` for when to use one) | | Combining SDK code with CLI operations | Read SDK + CLI references | +## Project Layout + + +`noorm init` creates this: + + + db/ + ├── sql/ what the schema IS. `noorm run build` creates a database from nothing with these files + ├── changes/ how an EXISTING database reaches that state. `noorm change ff` applies them + ├── AGENTS.md the user's own steering for this directory. Read it first + └── CLAUDE.md imports AGENTS.md + + +The real locations are `paths.sql` and `paths.changes` in `.noorm/settings.yml`. Older projects use `./sql` and `./changes`. Read the settings before creating a file, and never add a second `sql/` or `changes/` beside the configured ones. + +Four rules that every schema edit follows (details and examples in `references/schema-files.md`): + +1. **`sql/` holds the current state and nothing else.** `CREATE` statements and seeds. Each column and constraint is declared once, inline in its `CREATE TABLE`. No `ALTER`, `DROP`, backfill, `IF NOT EXISTS` guard, or history comment. +2. **`changes/` holds every step that moves an existing database to that state.** `ALTER`, `DROP`, backfills, data fixes, written to be safe to run twice (a built database's first `ff` replays every change), each with a `revert/`. A change that has been applied is never edited; add a new one. +3. **Every edit to `sql/` ships with its change.** Edit `sql/` first, then write the change that brings an already-built database to the same state. Existing databases move with `noorm change ff`, never `noorm run build`. +4. **Reuse, do not copy.** A change re-creates a view, function, procedure, or trigger by listing its `sql/` file in a `.txt` manifest (paths relative to `paths.sql`, one per line). Repeated or data-driven SQL (seed rows, grants, per-table triggers, per-environment values, secrets) is a `.sql.tmpl` plus a data file. + + ## Shared Conventions These apply to both SDK and CLI usage. @@ -80,3 +105,14 @@ These are the patterns LLMs get wrong most often with noorm: | `Procs = { name: ArgsType }` | `Procs = { name: [ArgsType, ReturnType] }` | Tuple format enables return type inference | | Assume every `ctx.noorm.*` call returns a tuple | SDK methods throw named errors — wrap with `attempt()` only when you'll do something with the error | Unhandled throws stop the caller immediately; that's the contract | | Use raw Kysely without noorm | Use `ctx.kysely` from a noorm Context | Misses config resolution, checksums, change tracking | +| `ALTER TABLE`, `DROP`, or a backfill in a `sql/` file | Put it in `changes//change/` and write the end state in `sql/` | `sql/` builds an empty database. History belongs to changes | +| `IF NOT EXISTS` on a `sql/` `CREATE TABLE` to make it re-runnable | Remove the guard. Guards go in changes | A changed file re-runs against the existing table and the guard makes the edit silently do nothing | +| Edit `sql/` and stop | Add the change that brings an existing database to the same state | A built database never receives the edit | +| Run `noorm run build` on a database that already has data | `noorm change ff` | Build only re-runs changed files, and a changed `CREATE TABLE` cannot alter an existing table | +| Copy a view or function into `change/001_*.sql` | List its `sql/` file in a `.txt` manifest | The copy drifts from `sql/`. Manifest paths are relative to `paths.sql` | +| Reference a `CREATE TABLE` file from a manifest | Copy the `CREATE TABLE IF NOT EXISTS` into the change | A manifest runs the file's current content, so a pending or replayed change executes today's `CREATE TABLE` | +| Edit a change that was already applied | Add a new change | Databases that already ran it keep the old body and the rest run the new one | +| Hand-write a list of `INSERT`s or `GRANT`s | `.sql.tmpl` with a `roles.yml`-style data file beside it | One data file, one loop, `$.quote()` on every value | +| Put a secret or a per-environment value in plain `.sql` | `.sql.tmpl` with `$.secrets` / `$.config` | Plain files are identical in every environment | +| Create `sql/` at the repo root when `paths.sql` is `./db/sql` | Read `.noorm/settings.yml` first | noorm never reads the stray folder, so its files never run | +| `build.include: [sql/01_tables]` | `build.include: [01_tables]` | Entries are relative to `paths.sql`. A wrong entry matches nothing and still exits 0 | diff --git a/skills/noorm/references/cli.md b/skills/noorm/references/cli.md index 8e7456a7..80717157 100644 --- a/skills/noorm/references/cli.md +++ b/skills/noorm/references/cli.md @@ -62,8 +62,8 @@ When all three identity vars are set at process startup, every `noorm` command i ### Paths ```bash -NOORM_PATHS_SQL # Schema directory (default: ./sql) -NOORM_PATHS_CHANGES # Changes directory (default: ./changes) +NOORM_PATHS_SQL # Schema directory (default: ./sql when settings.yml has no paths) +NOORM_PATHS_CHANGES # Changes directory (default: ./changes when settings.yml has no paths) ``` ### Behavior @@ -83,7 +83,7 @@ NOORM_DEBUG # Enable debug logging ### init -Bootstrap a new noorm project interactively. Creates identity (if needed), project structure (`sql/`, `changes/`, `.noorm/`), `settings.yml`, and encrypted `state.enc`. +Bootstrap a new noorm project interactively. Creates identity (if needed), project structure (`db/sql/`, `db/changes/`, `.noorm/`), `settings.yml`, and encrypted `state.enc`. ```bash noorm init # Interactive — requires TTY @@ -310,9 +310,9 @@ noorm run build --dry-run # Preview without executing Execute a single SQL or `.sql.tmpl` file. ```bash -noorm run file sql/01_tables/001_users.sql +noorm run file db/sql/01_tables/001_users.sql noorm run file seed.sql.tmpl -noorm run file sql/init.sql --json +noorm run file db/sql/init.sql --json ``` **JSON:** `{ "filepath": "seed.sql", "status": "success", "durationMs": 45 }` @@ -324,7 +324,7 @@ Status is `"success"` or `"skipped"` (unchanged checksum). Execute all SQL files in a directory in alphabetical order. ```bash -noorm run dir sql/01_tables/ +noorm run dir db/sql/01_tables/ noorm run dir seeds/ ``` @@ -333,15 +333,15 @@ noorm run dir seeds/ Show the template context for a `.sql.tmpl` file without rendering it. Lists available data files, helpers, built-in functions, config keys, and secret counts. ```bash -noorm run inspect sql/users/001_create.sql.tmpl -noorm run inspect sql/core/Crons.sql.tmpl --json +noorm run inspect db/sql/users/001_create.sql.tmpl +noorm run inspect db/sql/core/Crons.sql.tmpl --json ``` **JSON:** ```json { - "filepath": "sql/users/001_create.sql.tmpl", + "filepath": "db/sql/users/001_create.sql.tmpl", "context": { "dataFiles": [{ "key": "roles", "type": "Array [3]" }], "helpers": [{ "key": "padId", "type": "Function" }], @@ -359,10 +359,10 @@ noorm run inspect sql/core/Crons.sql.tmpl --json Render a `.sql.tmpl` file and output the resulting SQL. Does **not** execute against the database. ```bash -noorm run preview sql/schema.sql.tmpl # Raw SQL to stdout -noorm run preview sql/schema.sql.tmpl > rendered.sql # Pipe to file -noorm run preview sql/seed.sql.tmpl --json # JSON envelope -noorm run preview sql/migrations/002.sql.tmpl --config staging +noorm run preview db/sql/schema.sql.tmpl # Raw SQL to stdout +noorm run preview db/sql/schema.sql.tmpl > rendered.sql # Pipe to file +noorm run preview db/sql/seed.sql.tmpl --json # JSON envelope +noorm run preview db/sql/migrations/002.sql.tmpl --config staging ``` **JSON:** `{ "filepath": "...", "sql": "CREATE TABLE ...", "durationMs": 12 }` @@ -379,9 +379,9 @@ Pickers filter their list to whatever the command can act on: | Command | TTY picker lists | |---------|------------------| -| `change edit` | every folder in `changes/` | +| `change edit` | every folder in `db/changes/` | | `change add` | — (prompts for free-form description via `p.text`) | -| `change rm` | every folder in `changes/` | +| `change rm` | every folder in `db/changes/` | | `change run` | pending / reverted changes | | `change revert` | successfully applied changes | | `change rewind` | successfully applied changes | @@ -490,7 +490,7 @@ noorm change add add-users-table noorm change add create-audit-log --json ``` -**JSON:** `{ "name": "2024-04-17-add-users-table", "path": "/project/changes/2024-04-17-add-users-table" }` +**JSON:** `{ "name": "2024-04-17-add-users-table", "path": "/project/db/changes/2024-04-17-add-users-table" }` ### change edit @@ -982,13 +982,13 @@ fi ### Render template and pipe to external tool ```bash -noorm run preview sql/hotfix.sql.tmpl | psql -h localhost -d myapp +noorm run preview db/sql/hotfix.sql.tmpl | psql -h localhost -d myapp ``` ### Check template context before rendering ```bash -files=$(noorm run inspect sql/seed.sql.tmpl --json | jq '.context.dataFiles | length') +files=$(noorm run inspect db/sql/seed.sql.tmpl --json | jq '.context.dataFiles | length') echo "Template has $files data files available" ``` diff --git a/skills/noorm/references/config.md b/skills/noorm/references/config.md index 54d37343..35c02813 100644 --- a/skills/noorm/references/config.md +++ b/skills/noorm/references/config.md @@ -10,7 +10,7 @@ Project settings live in `.noorm/settings.yml`. Configs (database connections) a build: # Folders to include. Paths are relative to `paths.sql`, NOT the project - # root — with `paths.sql: ./sql`, `tables` means `./sql/tables`. + # root — with `paths.sql: ./db/sql`, `tables` means `./db/sql/tables`. # Empty list means all files under `paths.sql`. include: - tables @@ -26,8 +26,8 @@ build: # Override default directory locations (relative to project root). paths: - sql: ./sql # Schema SQL files (default: ./sql) - changes: ./changes # Change directories (default: ./changes) + sql: ./db/sql # Schema SQL files (`noorm init` writes this; ./sql when the key is absent) + changes: ./db/changes # Change directories (`noorm init` writes this; ./changes when the key is absent) # ── Rules ──────────────────────────────────────────────────── # Conditional include/exclude based on the active config's properties. @@ -128,16 +128,16 @@ secrets: This is the single most common misconfiguration, and it fails silently. -Entries in `build.include`, `build.exclude`, and `rules[].include` / `rules[].exclude` are matched against each file's path **relative to `paths.sql`** — never the project root. With the default `paths.sql: ./sql`: +Entries in `build.include`, `build.exclude`, and `rules[].include` / `rules[].exclude` are matched against each file's path **relative to `paths.sql`** — never the project root. With the `paths.sql: ./db/sql` that `noorm init` writes: ```yaml paths: - sql: ./sql + sql: ./db/sql build: include: - - 01_tables # correct -> ./sql/01_tables - - sql/01_tables # WRONG -> ./sql/sql/01_tables, matches nothing + - 01_tables # correct -> ./db/sql/01_tables + - sql/01_tables # WRONG -> ./db/sql/sql/01_tables, matches nothing ``` A non-matching entry is not an error. The build reports `"status": "success"` with `"filesRun": 0` and exits `0`, so a misconfigured `include` looks exactly like a successful build — the schema is simply never created, and the failure surfaces later as a missing table. @@ -155,8 +155,8 @@ Use bare, unprefixed segment names. Nested paths (`06_seeds/cron`) are supported | Field | Type | Default | Description | |---|---|---|---| -| `sql` | `string` | `./sql` | Schema SQL directory | -| `changes` | `string` | `./changes` | Change (migration) directory | +| `sql` | `string` | `./sql` | Schema SQL directory. `noorm init` writes `./db/sql` | +| `changes` | `string` | `./changes` | Change directory. `noorm init` writes `./db/changes` | ### `rules[]` @@ -275,8 +275,8 @@ For a simple project with just dev and test: ```yaml paths: - sql: ./sql - changes: ./changes + sql: ./db/sql + changes: ./db/changes stages: dev: diff --git a/skills/noorm/references/schema-files.md b/skills/noorm/references/schema-files.md new file mode 100644 index 00000000..6dd060a0 --- /dev/null +++ b/skills/noorm/references/schema-files.md @@ -0,0 +1,266 @@ +# Schema Files and Changes + + +Where SQL goes in a noorm project and why. Read this before adding a table, altering a column, writing seed data, or creating a change. + + +## Table of Contents + +1. [Layout](#layout) +2. [What each directory is for](#what-each-directory-is-for) +3. [Which directory gets this edit](#which-directory-gets-this-edit) +4. [Anatomy of a change](#anatomy-of-a-change) +5. [Manifests](#manifests) +6. [Templates](#templates) +7. [Workflow for a schema edit](#workflow-for-a-schema-edit) +8. [Worked example](#worked-example) +9. [Checks before you finish](#checks-before-you-finish) + + +--- + + +## Layout + + + project/ + ├── .noorm/ + │ └── settings.yml # paths.sql, paths.changes, build.include/exclude + └── db/ + ├── sql/ # what the schema IS: the current state + ├── changes/ # how an EXISTING database gets to that state + ├── AGENTS.md # the user's steering for this directory; read it first + └── CLAUDE.md # imports AGENTS.md + + +`noorm init` writes `paths.sql: ./db/sql` and `paths.changes: ./db/changes` into `.noorm/settings.yml`. Projects created before that use `./sql` and `./changes`, and a settings file with no `paths` key falls back to those. + +Read `.noorm/settings.yml` before creating or moving a file. Never create a second `sql/` or `changes/` beside the configured ones: noorm does not read them, so their files never run. + + +--- + + +## What each directory is for + + +| | `sql/` | `changes/` | +|---|---|---| +| Describes | What the schema is now | How a database that already exists gets there | +| Run by | `noorm run build` | `noorm change ff`, `change run`, `change revert` | +| Target | An empty database | A database in the state before the change | +| Executes | Every file whose rendered checksum changed, in sorted path order | Each change once, recorded in the database | +| Keeps history | No | Yes, that is its job | + + +### Rules for `sql/` + +- Only statements that create the current state: `CREATE TABLE`, `CREATE OR REPLACE` views, functions, procedures and triggers (`CREATE OR ALTER` on mssql), `CREATE INDEX`, seed `INSERT`s, grants. +- Declare each column and constraint once, inline in its `CREATE TABLE`. Adding a column means editing that statement. +- Never put in `sql/`: + - `ALTER` of a table this directory creates + - `DROP`, or a file that exists only to retire an object + - `UPDATE` or backfills of existing rows + - `IF NOT EXISTS` / `DROP IF EXISTS` guards added to survive a second run + - comments about history ("added in v2") + +A table whose definition is spread over a `CREATE` and later `ALTER`s cannot be read without replaying them. A guard is worse: when the `CREATE TABLE` file changes, `build` re-runs it, and `CREATE TABLE IF NOT EXISTS` against the existing table does nothing, so the edit silently never lands. + +`sql/` is for fresh databases: CI, tests, a new developer's machine. Do not run `noorm run build` against a database that already holds data. A changed `CREATE TABLE` file re-executes against the existing table, where it either errors or no-ops and never alters anything. Existing databases move with `noorm change ff`. + + +### Rules for `changes/` + +- Everything that turns an existing database into the current state: `ALTER`, `DROP`, backfills, data fixes, type changes, index and constraint additions on live tables. +- Make every statement safe to run twice (`ADD COLUMN IF NOT EXISTS`, `DROP ... IF EXISTS`, `CREATE ... IF NOT EXISTS`). `noorm run build` records no change history, so the first `change ff` on a freshly built database replays every change in the folder, and a statement that is not safe to run twice fails there. +- Write a `revert/` for each change. +- Never edit a change that has been applied anywhere. Databases that already ran it keep the old body and the rest run the new one, so environments drift apart. Add a new change instead. `change ff` skips applied changes; `noorm change run ` re-executes an edited one on the one database you point it at. + + +--- + + +## Which directory gets this edit + + +Every edit to `sql/` that changes what a built database contains ships with a change that brings an already-built database to the same state. Edit `sql/` first (the target), then write the change. + + +| Edit | `sql/` | `changes//change/` | +|---|---|---| +| New table | `CREATE TABLE` | The same `CREATE TABLE IF NOT EXISTS`, inline. Do not reference the table's file from a manifest | +| New column | Add it to that table's `CREATE TABLE` | `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`, plus a backfill if rows need a value | +| Drop or rename a column | Remove or rename it in the `CREATE TABLE` | `ALTER TABLE ... DROP / RENAME COLUMN` | +| New or edited view, function, procedure, trigger | Edit its `CREATE OR REPLACE` file | A `.txt` manifest naming that file | +| New index | `CREATE INDEX` | `CREATE INDEX IF NOT EXISTS` | +| New seed row | Add it to the seed file or its data file | An idempotent `INSERT` for the rows existing databases lack | +| Bad data in existing rows | Nothing | `UPDATE` | +| Remove an object | Delete its file | `DROP ... IF EXISTS` | + + +--- + + +## Anatomy of a change + + + db/changes/2026-10-19-add-user-bio/ + ├── change/ + │ ├── 001_add-bio-column.sql + │ └── 002_rebuild-views.txt + ├── revert/ + │ └── 001_drop-bio-column.sql + └── changelog.md + + +- Scaffold with `noorm change add `. It creates the dated folder under `paths.changes` with comment-only stubs. Replace the comments with SQL. +- `change/` and `revert/` hold `.sql`, `.sql.tmpl` and `.txt` files. They run in filename order, so keep the `NNN_` prefix zero-padded to three digits. +- `revert/` also runs in forward order (`001`, then `002`). Number the revert files in the order the undo has to happen. +- `changelog.md` says why the change exists. The diff already shows what it does. +- A change whose files contain only comments or placeholders is rejected. + + +--- + + +## Manifests + + +A `.txt` file in `change/` or `revert/` runs existing `sql/` files in place instead of copying their SQL into the change. + + + # db/changes/2026-10-19-widen-name/change/003_rebuild-views.txt + views/active_users.sql + functions/get_user_stats.sql + + +- One path per line, relative to `paths.sql` (`views/active_users.sql`, not `db/sql/views/active_users.sql`). Blank lines and lines starting with `#` are ignored. +- Lines run in file order. The manifest sorts among the other change files by filename, so `001_rebuild.txt` runs before `002_alter.sql`. +- A missing file fails `noorm change run ` with a `ManifestReferenceError`. `noorm change ff` and `change list` skip a change they cannot parse, still exit 0, and the change is absent from `change list`. A manifest with no path lines is treated the same way. +- A manifest runs the referenced file's current content, not its content when the change was written. A change still pending on a lagging database, or replayed after a build, executes today's definition. +- Because of that, reference only objects that are safe to run repeatedly: `CREATE OR REPLACE` views, functions, procedures, triggers. Never reference a `CREATE TABLE` file: replayed, it hits a table that already exists. +- A path listed more than once across a change's manifests runs once, at its first position. +- A manifest in `revert/` rebuilds the current `sql/` definition. It cannot restore an older definition of a view or function; write that older `CREATE OR REPLACE` inline in the revert file. + + +Use a manifest whenever a change has to re-create an object that `sql/` already defines. Copying the DDL into the change leaves two definitions that drift apart. + + +A table change that a dependent view blocks: + + + change/ + ├── 001_drop-dependent-views.sql # DROP VIEW IF EXISTS active_users; + ├── 002_widen-name-column.sql # ALTER TABLE users ALTER COLUMN name TYPE VARCHAR(200); + └── 003_rebuild-views.txt # views/active_users.sql + + +--- + + +## Templates + + +A `.sql.tmpl` file is rendered by Eta before it runs, with any `.json`, `.yml`, `.csv` or `.dt` file in the same directory loaded onto `$`. Syntax, helpers and context are in `references/templates.md`. + +Reach for a template when any of these holds: + +| Situation | Template shape | +|---|---| +| Rows come from data: lookups, enums, roles, feature flags, cron schedules | Keep the data in `roles.yml` beside the template and loop over `$.roles` | +| Three or more statements differ only by a name or value: grants, audit triggers, per-table indexes | Loop over a list in a data file, or call a function from `$helpers.ts` | +| A value differs by environment: stage, test flag, host | Branch on `$.config.stage` / `$.config.isTest`, or read `$.env` | +| A value is secret | Read `$.secrets.KEY`. Never write a secret into a plain `.sql` file | +| The same fragment appears in several files | `$.include()` it | + +Use plain `.sql` when none applies. A template with no variables is noise. + +- Quote every interpolated value with `$.quote()`. Data files are not trusted input. +- Templates work in `sql/` and in `change/` and `revert/`. In a change folder a template reads the data files beside it, and those `.json`, `.yml` and `.csv` files are not run as change files. +- In `sql/` the checksum covers the rendered output, so editing a data file re-runs the template on the next build. `$.now()` and `$.uuid()` change the output every build, so a file that calls them runs every build. A change's data files sit outside the change's checksum. +- Check the output with `noorm run preview ` before running it. + + +A hand-written list like this: + + + INSERT INTO role (name) VALUES ('admin'); + INSERT INTO role (name) VALUES ('editor'); + INSERT INTO role (name) VALUES ('viewer'); + + +becomes data plus a template: + + + # db/sql/10_seeds/roles.yml + - admin + - editor + - viewer + + -- db/sql/10_seeds/001_roles.sql.tmpl + -- {% for (const name of $.roles) { %} + INSERT INTO role (name) VALUES ({%~ $.quote(name) %}); + -- {% } %} + + +--- + + +## Workflow for a schema edit + + +1. Read `.noorm/settings.yml` for `paths.sql` and `paths.changes`. Read `db/AGENTS.md` for the user's own rules. +2. Edit the target state in `sql/`. Columns and constraints go inline in `CREATE TABLE`. +3. `noorm change add ` to scaffold the change folder. +4. Write `change/NNN_*.sql`: the `ALTER`s, backfills and guards that move an existing database to the new `sql/` state. +5. Re-create dependent views, functions, procedures and triggers with a `.txt` manifest, not a copy. +6. Write `revert/NNN_*.sql` that undoes the change, numbered in the order the undo runs. +7. Fill in `changelog.md`. +8. Verify (see below), then commit `sql/` and the change together. + + +--- + + +## Worked example + + +Add a `bio` column to `users`. + + + -- db/sql/01_tables/001_users.sql + CREATE TABLE users ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + bio TEXT NOT NULL DEFAULT '' + ); + + + -- db/changes/2026-10-19-add-user-bio/change/001_add-bio-column.sql + ALTER TABLE users ADD COLUMN IF NOT EXISTS bio TEXT NOT NULL DEFAULT ''; + + + -- db/changes/2026-10-19-add-user-bio/revert/001_drop-bio-column.sql + ALTER TABLE users DROP COLUMN IF EXISTS bio; + + + # db/changes/2026-10-19-add-user-bio/changelog.md + Profiles need a short bio. Default is empty so existing rows stay valid. + + +The `ALTER` is in the change and the column is declared once, inline, in `sql/`. The reverse placements are the usual mistake: the `ALTER` in `sql/`, or the column only in the change. + + +--- + + +## Checks before you finish + + +- A fresh database built from `sql/` and a database built from the previous commit then moved with `noorm change ff` end up with the same schema. This is the test of the pairing rule. +- `noorm run build --dry-run` and `noorm change ff --dry-run` render every file, templates and manifests included, to `tmp/` and execute nothing. `change ff --dry-run` still reads the change tracking tables to decide what is pending. +- `sql/` contains no `ALTER`, `DROP`, backfill or guard that you added for this edit. +- No `CREATE OR REPLACE` object was copied into a change when a manifest would do. +- Seed lists, grants and repeated statements went through a template. +- No file landed outside the configured `paths`. +- The change appears in `noorm change list`. A broken manifest makes `ff` skip the change and still exit 0. diff --git a/skills/noorm/references/sdk.md b/skills/noorm/references/sdk.md index faf416c9..ee0d06cb 100644 --- a/skills/noorm/references/sdk.md +++ b/skills/noorm/references/sdk.md @@ -309,10 +309,10 @@ const history = await ctx.noorm.changes.history(20); // Execution log ```typescript // Discovery -const files = await ctx.noorm.run.discover('sql/'); +const files = await ctx.noorm.run.discover('db/sql/'); // Preview (render templates without executing) -const previews = await ctx.noorm.run.preview(['sql/001.sql', 'sql/002.sql']); +const previews = await ctx.noorm.run.preview(['db/sql/001.sql', 'db/sql/002.sql']); // Execution await ctx.noorm.run.file('seeds/test-data.sql'); @@ -404,7 +404,7 @@ const apiKey = ctx.noorm.secrets.get('API_KEY'); // Returns undefined if not set Render `.sql.tmpl` files without executing — useful for previewing generated SQL. ```typescript -const result = await ctx.noorm.templates.render('sql/001_users.sql.tmpl'); +const result = await ctx.noorm.templates.render('db/sql/001_users.sql.tmpl'); // result: { name, content, error?, durationMs? } ``` @@ -449,7 +449,7 @@ const result = await ctx.noorm.dt.importFile('./exports/users.dtz', { ### utils ```typescript -const checksum = await ctx.noorm.utils.checksum('sql/001.sql'); +const checksum = await ctx.noorm.utils.checksum('db/sql/001.sql'); const { ok, error } = await ctx.noorm.utils.testConnection(); ``` diff --git a/skills/noorm/references/templates.md b/skills/noorm/references/templates.md index a944ab5d..65f0acd2 100644 --- a/skills/noorm/references/templates.md +++ b/skills/noorm/references/templates.md @@ -1,6 +1,6 @@ # NoORM Templates Reference -Writing `.sql.tmpl` files — syntax, context API, secrets, environment variables, data files, and helpers. +Writing `.sql.tmpl` files — syntax, context API, secrets, environment variables, data files, and helpers. For when a template is the right choice over plain SQL, and where it goes (`sql/` or a change), see **Templates** in `schema-files.md`. ## Table of Contents @@ -184,7 +184,7 @@ Include another SQL file. Path is relative to the current template. **Async — If the included file is `.sql.tmpl`, it is recursively rendered with the same context. ```sql --- changes/2025-01-15-setup/change/001_schema.sql.tmpl +-- db/changes/2025-01-15-setup/change/001_schema.sql.tmpl {%~ await $.include('../lib/core_tables.sql') %} {%~ await $.include('../lib/audit_triggers.sql.tmpl') %} ``` @@ -198,7 +198,7 @@ Path traversal outside the project root is blocked. Any supported file in the same directory as the template is automatically loaded onto `$`: ``` -sql/users/ +db/sql/users/ ├── 001_seed.sql.tmpl # Template ├── users.json # → $.users ├── roles.yml # → $.roles @@ -236,7 +236,7 @@ Filenames are converted to camelCase: Files named `$helpers.ts` (or `.js`, `.mjs`) are auto-discovered by walking UP the directory tree to the project root. Child helpers override parent helpers with the same name. ``` -sql/ +db/sql/ ├── $helpers.ts # Project-wide helpers ├── users/ │ ├── $helpers.ts # Overrides parent for this subtree @@ -248,7 +248,7 @@ sql/ ### Writing Helpers ```typescript -// sql/$helpers.ts +// db/sql/$helpers.ts export function padId(id: number, length = 6): string { return String(id).padStart(length, '0') @@ -347,7 +347,7 @@ GRANT EXECUTE ON {%~ proc %} TO {%~ role %}; ### Composing from Fragments ```sql --- changes/2025-01-15-setup/change/001_schema.sql.tmpl +-- db/changes/2025-01-15-setup/change/001_schema.sql.tmpl {%~ await $.include('../lib/core_tables.sql') %} {%~ await $.include('../lib/content_tables.sql') %} {%~ await $.include('../lib/seed_defaults.sql') %} @@ -373,14 +373,14 @@ Before executing, preview or inspect templates: **CLI:** ```bash -noorm run preview sql/schema.sql.tmpl # Rendered SQL to stdout -noorm run preview sql/schema.sql.tmpl > rendered.sql -noorm run inspect sql/seed.sql.tmpl # Show available context +noorm run preview db/sql/schema.sql.tmpl # Rendered SQL to stdout +noorm run preview db/sql/schema.sql.tmpl > rendered.sql +noorm run inspect db/sql/seed.sql.tmpl # Show available context ``` **SDK:** ```typescript -const result = await ctx.noorm.templates.render('sql/001_users.sql.tmpl'); +const result = await ctx.noorm.templates.render('db/sql/001_users.sql.tmpl'); // result: { name, content, error?, durationMs? } ``` diff --git a/src/core/change/parser.ts b/src/core/change/parser.ts index 8a45b014..77633fd3 100644 --- a/src/core/change/parser.ts +++ b/src/core/change/parser.ts @@ -234,15 +234,15 @@ export async function discoverChanges( * * @example * ```typescript - * // Manifest contains: - * // sql/tables/users.sql - * // sql/views/active_users.sql + * // Lines are relative to the schema directory, not the project root: + * // tables/users.sql + * // views/active_users.sql * * const paths = await resolveManifest( - * '/project/changes/.../change/001_refs.txt', - * '/project/sql' + * '/project/db/changes/.../change/001_refs.txt', + * '/project/db/sql' * ) - * // => ['/project/sql/tables/users.sql', '/project/sql/views/active_users.sql'] + * // => ['/project/db/sql/tables/users.sql', '/project/db/sql/views/active_users.sql'] * ``` */ export async function resolveManifest(manifestPath: string, sqlDir: string): Promise { diff --git a/src/core/project-init.ts b/src/core/project-init.ts index 1a2ce8a1..2a97bdd9 100644 --- a/src/core/project-init.ts +++ b/src/core/project-init.ts @@ -2,7 +2,8 @@ * Project initialization — shared between TUI `InitScreen` and CLI `noorm init`. * * Creates the on-disk structure for a new noorm project: - * - `sql/` and `changes/` directories with .gitkeep + * - `db/sql/` and `db/changes/` directories with .gitkeep + * - `db/AGENTS.md` and `db/CLAUDE.md` agent steering, when absent * - `.noorm/`, `.noorm/state/`, `.noorm/.gitignore` * - `settings.yml` via SettingsManager * - `state.enc` via StateManager @@ -19,7 +20,7 @@ import { readFileSync, appendFileSync, } from 'node:fs'; -import { join } from 'node:path'; +import { join, posix } from 'node:path'; import { attempt } from '@logosdx/utils'; @@ -30,6 +31,15 @@ import { loadIdentityMetadata, } from './identity/index.js'; import { SettingsManager } from './settings/manager.js'; +import { + DEFAULT_PATH_CONFIG, + INIT_CHANGES_PATH, + INIT_DB_DIR, + INIT_PATH_CONFIG, + INIT_SQL_PATH, + isInitLayout, +} from './settings/defaults.js'; +import type { PathConfig } from './settings/types.js'; import { StateManager, getStateManager } from './state/index.js'; import { observer } from './observer.js'; @@ -41,6 +51,11 @@ const NOORM_IGNORE_ENTRY = '.noorm/state/'; const NOORM_GITIGNORE_BLOCK = `\n# noorm\n${NOORM_IGNORE_ENTRY}\n`; +const AGENTS_TEMPLATE = 'Use the `/noorm` skill for managing files here.\n\n\n'; + +// Claude Code skips AGENTS.md when a CLAUDE.md is present, so CLAUDE.md imports it. +const CLAUDE_TEMPLATE = '@AGENTS.md\n'; + export interface ProjectInitIdentityInfo { name: string; email: string; @@ -59,6 +74,69 @@ export interface ProjectInitResult { createdFiles: string[]; } +/** + * Paths a project's settings.yml carries after init. A project that already has + * a settings.yml keeps what it resolves to, so `--force` never repoints it at + * a layout it does not have; only a new project gets the `db/` layout. + * + * @example + * const paths = await resolveInitPaths('/project'); // INIT_PATH_CONFIG when new + */ +export async function resolveInitPaths(projectRoot: string): Promise { + + const manager = new SettingsManager(projectRoot); + + if (!(await manager.exists())) return INIT_PATH_CONFIG; + + const [settings] = await attempt(() => manager.load()); + + return settings?.paths ?? DEFAULT_PATH_CONFIG; + +} + +/** + * Creates `db/sql/`, `db/changes/`, and the agent steering files beside them, + * for projects on the init layout. The steering files hold the user's edits, + * so they are written only when absent. + * + * @example + * scaffoldDbLayout('/project', INIT_PATH_CONFIG); // ['db/sql/.gitkeep', ..., 'db/CLAUDE.md'] + */ +export function scaffoldDbLayout(projectRoot: string, paths: PathConfig): string[] { + + if (!isInitLayout(paths)) return []; + + const created: string[] = []; + const agentFiles = { + 'AGENTS.md': AGENTS_TEMPLATE, + 'CLAUDE.md': CLAUDE_TEMPLATE, + }; + + for (const dir of [INIT_SQL_PATH, INIT_CHANGES_PATH]) { + + const relativeDir = posix.normalize(dir); + + mkdirSync(join(projectRoot, relativeDir), { recursive: true }); + writeFileSync(join(projectRoot, relativeDir, '.gitkeep'), '', { flag: 'a' }); + created.push(`${relativeDir}/.gitkeep`); + + } + + for (const [name, body] of Object.entries(agentFiles)) { + + const filePath = join(projectRoot, INIT_DB_DIR, name); + + if (existsSync(filePath)) continue; + + writeFileSync(filePath, body); + created.push(`${INIT_DB_DIR}/${name}`); + + } + + return created; + +} + /** * Bootstraps a noorm project: directories, settings, state, and (optionally) identity. * When `identityInfo` is null, a global identity must already exist. @@ -69,21 +147,14 @@ export async function performProjectInit( const { projectRoot, force, identityInfo } = opts; - const sqlPath = join(projectRoot, 'sql'); - const changesPath = join(projectRoot, 'changes'); const noormPath = join(projectRoot, '.noorm'); const statePath = join(noormPath, 'state'); const gitignorePath = join(projectRoot, '.gitignore'); const created: string[] = []; + const paths = await resolveInitPaths(projectRoot); - mkdirSync(sqlPath, { recursive: true }); - writeFileSync(join(sqlPath, '.gitkeep'), '', { flag: 'a' }); - created.push('sql/.gitkeep'); - - mkdirSync(changesPath, { recursive: true }); - writeFileSync(join(changesPath, '.gitkeep'), '', { flag: 'a' }); - created.push('changes/.gitkeep'); + created.push(...scaffoldDbLayout(projectRoot, paths)); mkdirSync(noormPath, { recursive: true }); mkdirSync(statePath, { recursive: true }); @@ -134,6 +205,7 @@ export async function performProjectInit( const settingsManager = new SettingsManager(projectRoot); await settingsManager.init(force); + await settingsManager.setPaths(paths); created.push('.noorm/settings.yml'); const stateManager = new StateManager(projectRoot, { privateKey }); diff --git a/src/core/settings/defaults.ts b/src/core/settings/defaults.ts index 26ab36cb..c6de0467 100644 --- a/src/core/settings/defaults.ts +++ b/src/core/settings/defaults.ts @@ -27,6 +27,31 @@ export const DEFAULT_PATH_CONFIG: PathConfig = { changes: './changes', }; +export const INIT_DB_DIR = 'db'; + +export const INIT_SQL_PATH = `./${INIT_DB_DIR}/sql`; + +export const INIT_CHANGES_PATH = `./${INIT_DB_DIR}/changes`; + +// Not DEFAULT_PATH_CONFIG: moving that repoints every project whose settings.yml has no `paths`. +export const INIT_PATH_CONFIG: PathConfig = { + sql: INIT_SQL_PATH, + changes: INIT_CHANGES_PATH, +}; + +/** + * Whether `paths` is the layout `noorm init` scaffolds, as opposed to a + * project that already chose its own. + * + * @example + * if (isInitLayout(paths)) scaffoldDbLayout(root, paths); + */ +export function isInitLayout(paths: PathConfig): boolean { + + return paths.sql === INIT_SQL_PATH && paths.changes === INIT_CHANGES_PATH; + +} + /** * Default strict mode configuration. * diff --git a/src/tui/screens/init/InitScreen.tsx b/src/tui/screens/init/InitScreen.tsx index 2454fc1a..baea3a86 100644 --- a/src/tui/screens/init/InitScreen.tsx +++ b/src/tui/screens/init/InitScreen.tsx @@ -8,7 +8,7 @@ * 4. Optionally add first config * * Creates: - * - sql/ and changes/ directories + * - db/sql/, db/changes/, db/AGENTS.md, db/CLAUDE.md (new projects only) * - .noorm/settings.yml * - .noorm/state/state.enc * - Keypair in ~/.noorm/ @@ -39,6 +39,9 @@ import { loadIdentityMetadata, } from '../../../core/identity/index.js'; import { SettingsManager } from '../../../core/settings/manager.js'; +import { INIT_PATH_CONFIG } from '../../../core/settings/defaults.js'; +import type { PathConfig } from '../../../core/settings/types.js'; +import { resolveInitPaths, scaffoldDbLayout } from '../../../core/project-init.js'; import { StateManager, getStateManager } from '../../../core/state/index.js'; import { observer } from '../../../core/observer.js'; @@ -102,6 +105,9 @@ export function InitScreen({ params }: ScreenProps): ReactElement { // Project root const projectRoot = process.cwd(); + // Paths the project will carry: its own when settings.yml exists, else the db/ layout + const [initPaths, setInitPaths] = useState(INIT_PATH_CONFIG); + // Check existing state on mount useEffect(() => { @@ -111,6 +117,8 @@ export function InitScreen({ params }: ScreenProps): ReactElement { const noormDir = join(projectRoot, '.noorm'); const alreadyInitialized = existsSync(noormDir); + setInitPaths(await resolveInitPaths(projectRoot)); + // Check if identity key files exist const keysExist = await hasKeyFiles(); setHasExistingKeys(keysExist); @@ -222,16 +230,10 @@ export function InitScreen({ params }: ScreenProps): ReactElement { // Step 1: Create directories updateItem(0, { status: 'running' }); - const sqlPath = join(projectRoot, 'sql'); - const changesPath = join(projectRoot, 'changes'); const noormPath = join(projectRoot, '.noorm'); const statePath = join(noormPath, 'state'); - mkdirSync(sqlPath, { recursive: true }); - writeFileSync(join(sqlPath, '.gitkeep'), '', { flag: 'a' }); - - mkdirSync(changesPath, { recursive: true }); - writeFileSync(join(changesPath, '.gitkeep'), '', { flag: 'a' }); + scaffoldDbLayout(projectRoot, initPaths); mkdirSync(noormPath, { recursive: true }); mkdirSync(statePath, { recursive: true }); @@ -284,6 +286,7 @@ export function InitScreen({ params }: ScreenProps): ReactElement { const settingsManager = new SettingsManager(projectRoot); await settingsManager.init(force); + await settingsManager.setPaths(initPaths); updateItem(settingsIndex, { status: 'success' }); @@ -371,7 +374,7 @@ export function InitScreen({ params }: ScreenProps): ReactElement { setStep('complete'); - }, [projectRoot, identityValues, hasExistingKeys, hasExistingMetadata, force, refresh]); + }, [projectRoot, identityValues, hasExistingKeys, hasExistingMetadata, force, refresh, initPaths]); // Handle project setup - add config const handleAddConfig = useCallback(async () => { @@ -467,6 +470,8 @@ export function InitScreen({ params }: ScreenProps): ReactElement { return ( Will create: - - • - {sqlPath}/.gitkeep - - - • - {changesPath}/.gitkeep - + {scaffoldsDb && ( + <> + + • + {sqlPath}/.gitkeep + + + • + {changesPath}/.gitkeep + + + • + ./{INIT_DB_DIR}/AGENTS.md + + + • + ./{INIT_DB_DIR}/CLAUDE.md + + + )} • .noorm/settings.yml diff --git a/tests/cli/screens/init/init-flow.test.tsx b/tests/cli/screens/init/init-flow.test.tsx index 5313e1d2..3f82e839 100644 --- a/tests/cli/screens/init/init-flow.test.tsx +++ b/tests/cli/screens/init/init-flow.test.tsx @@ -145,6 +145,7 @@ mock.module('../../../../src/core/settings/manager.js', () => { SettingsManager: class MockSettingsManager { init = vi.fn(() => Promise.resolve()); + setPaths = vi.fn(() => Promise.resolve()); load = vi.fn(() => Promise.resolve()); exists = vi.fn(() => Promise.resolve(false)); isLoaded = true; diff --git a/tests/cli/screens/init/init-screen.test.tsx b/tests/cli/screens/init/init-screen.test.tsx index 310859e8..3df5803e 100644 --- a/tests/cli/screens/init/init-screen.test.tsx +++ b/tests/cli/screens/init/init-screen.test.tsx @@ -101,6 +101,7 @@ mock.module('../../../../src/core/state/manager.js', () => ({ mock.module('../../../../src/core/settings/manager.js', () => ({ SettingsManager: vi.fn().mockImplementation(() => ({ init: vi.fn(() => Promise.resolve()), + setPaths: vi.fn(() => Promise.resolve()), load: vi.fn(() => Promise.resolve()), exists: vi.fn(() => Promise.resolve(false)), isLoaded: true, diff --git a/tests/cli/screens/init/project-setup.test.tsx b/tests/cli/screens/init/project-setup.test.tsx index 8cda610f..3afe58bd 100644 --- a/tests/cli/screens/init/project-setup.test.tsx +++ b/tests/cli/screens/init/project-setup.test.tsx @@ -53,7 +53,7 @@ describe('cli: screens/init/ProjectSetup', () => { , ); - expect(lastFrame()).toContain('./sql'); + expect(lastFrame()).toContain('./db/sql'); }); @@ -65,7 +65,20 @@ describe('cli: screens/init/ProjectSetup', () => { , ); - expect(lastFrame()).toContain('./changes'); + expect(lastFrame()).toContain('./db/changes'); + + }); + + it('should list the agent steering files it will create', () => { + + const { lastFrame } = render( + + {}} onSkipConfig={() => {}} onCancel={() => {}} /> + , + ); + + expect(lastFrame()).toContain('./db/AGENTS.md'); + expect(lastFrame()).toContain('./db/CLAUDE.md'); }); diff --git a/tests/core/project-init.test.ts b/tests/core/project-init.test.ts index 4d4462a1..11bed804 100644 --- a/tests/core/project-init.test.ts +++ b/tests/core/project-init.test.ts @@ -1,9 +1,14 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; -import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir, homedir } from 'node:os'; import { join } from 'node:path'; -import { performProjectInit } from '../../src/core/project-init.js'; +import { performProjectInit, resolveInitPaths, scaffoldDbLayout } from '../../src/core/project-init.js'; +import { DEFAULT_PATH_CONFIG, INIT_PATH_CONFIG } from '../../src/core/settings/defaults.js'; +import { SettingsManager } from '../../src/core/settings/manager.js'; +import { resolveChangesDir, resolveSqlDir } from '../../src/tui/utils/paths.js'; + +const hasGlobalIdentity = existsSync(join(homedir(), '.noorm', 'identity.key')); describe('core: performProjectInit', () => { @@ -33,8 +38,10 @@ describe('core: performProjectInit', () => { }); expect(result.success).toBe(true); - expect(existsSync(join(tmpDir, 'sql', '.gitkeep'))).toBe(true); - expect(existsSync(join(tmpDir, 'changes', '.gitkeep'))).toBe(true); + expect(existsSync(join(tmpDir, 'db', 'sql', '.gitkeep'))).toBe(true); + expect(existsSync(join(tmpDir, 'db', 'changes', '.gitkeep'))).toBe(true); + expect(existsSync(join(tmpDir, 'sql'))).toBe(false); + expect(existsSync(join(tmpDir, 'changes'))).toBe(false); expect(existsSync(join(tmpDir, '.noorm', 'settings.yml'))).toBe(true); expect(existsSync(join(tmpDir, '.noorm', '.gitignore'))).toBe(true); expect(existsSync(join(tmpDir, '.noorm', 'state'))).toBe(true); @@ -120,4 +127,193 @@ describe('core: performProjectInit', () => { }); + // Init makes the directories and writes `paths` separately. If they disagree, + // `build` reads a folder init never made and succeeds on zero files. + it.skipIf(!hasGlobalIdentity)('should write settings paths that resolve to the directories it created', async () => { + + await performProjectInit({ + projectRoot: tmpDir, + force: false, + identityInfo: null, + }); + + const manager = new SettingsManager(tmpDir); + const settings = await manager.load(); + + expect(settings.paths?.sql).toBe('./db/sql'); + expect(settings.paths?.changes).toBe('./db/changes'); + expect(resolveSqlDir(tmpDir, settings)).toBe(join(tmpDir, 'db', 'sql')); + expect(resolveChangesDir(tmpDir, settings)).toBe(join(tmpDir, 'db', 'changes')); + expect(existsSync(resolveSqlDir(tmpDir, settings))).toBe(true); + expect(existsSync(resolveChangesDir(tmpDir, settings))).toBe(true); + + }); + + it.skipIf(!hasGlobalIdentity)('should not overwrite agent steering files on re-init', async () => { + + await performProjectInit({ + projectRoot: tmpDir, + force: false, + identityInfo: null, + }); + + const agentsPath = join(tmpDir, 'db', 'AGENTS.md'); + const claudePath = join(tmpDir, 'db', 'CLAUDE.md'); + + writeFileSync(agentsPath, 'Always use PascalCase.\n'); + writeFileSync(claudePath, '@AGENTS.md\n\nUse plan mode.\n'); + + const second = await performProjectInit({ + projectRoot: tmpDir, + force: true, + identityInfo: null, + }); + + expect(readFileSync(agentsPath, 'utf-8')).toBe('Always use PascalCase.\n'); + expect(readFileSync(claudePath, 'utf-8')).toBe('@AGENTS.md\n\nUse plan mode.\n'); + expect(second.createdFiles).not.toContain('db/AGENTS.md'); + expect(second.createdFiles).not.toContain('db/CLAUDE.md'); + + }); + + // --force resets settings.yml; a project laid out at ./sql must not be repointed at an empty db/. + it.skipIf(!hasGlobalIdentity)('should keep a legacy project layout on --force', async () => { + + mkdirSync(join(tmpDir, '.noorm'), { recursive: true }); + writeFileSync(join(tmpDir, '.noorm', 'settings.yml'), 'paths:\n sql: ./sql\n changes: ./changes\n'); + mkdirSync(join(tmpDir, 'sql'), { recursive: true }); + mkdirSync(join(tmpDir, 'changes'), { recursive: true }); + + const result = await performProjectInit({ + projectRoot: tmpDir, + force: true, + identityInfo: null, + }); + + const settings = await new SettingsManager(tmpDir).load(); + + expect(settings.paths).toEqual({ sql: './sql', changes: './changes' }); + expect(existsSync(join(tmpDir, 'db'))).toBe(false); + expect(result.createdFiles.some((f) => f.startsWith('db/'))).toBe(false); + + }); + +}); + +describe('core: resolveInitPaths', () => { + + let tmpDir: string; + + beforeEach(() => { + + tmpDir = mkdtempSync(join(tmpdir(), 'noorm-init-paths-')); + mkdirSync(join(tmpDir, '.noorm'), { recursive: true }); + + }); + + afterEach(() => { + + rmSync(tmpDir, { recursive: true, force: true }); + + }); + + it('should give a project with no settings.yml the db/ layout', async () => { + + expect(await resolveInitPaths(tmpDir)).toEqual(INIT_PATH_CONFIG); + + }); + + it('should keep the paths an existing settings.yml carries', async () => { + + writeFileSync(join(tmpDir, '.noorm', 'settings.yml'), 'paths:\n sql: ./schema\n changes: ./deltas\n'); + + expect(await resolveInitPaths(tmpDir)).toEqual({ sql: './schema', changes: './deltas' }); + + }); + + // A settings.yml without `paths` resolves to ./sql today; init must not silently move it. + it('should resolve a settings.yml with no paths key to the legacy default', async () => { + + writeFileSync(join(tmpDir, '.noorm', 'settings.yml'), 'build:\n include: []\n'); + + expect(await resolveInitPaths(tmpDir)).toEqual(DEFAULT_PATH_CONFIG); + + }); + + it('should resolve an unreadable settings.yml to the legacy default', async () => { + + writeFileSync(join(tmpDir, '.noorm', 'settings.yml'), 'paths: [unclosed\n'); + + expect(await resolveInitPaths(tmpDir)).toEqual(DEFAULT_PATH_CONFIG); + + }); + +}); + +describe('core: scaffoldDbLayout', () => { + + let tmpDir: string; + + beforeEach(() => { + + tmpDir = mkdtempSync(join(tmpdir(), 'noorm-scaffold-')); + + }); + + afterEach(() => { + + rmSync(tmpDir, { recursive: true, force: true }); + + }); + + it('should nest sql/ and changes/ under db/', () => { + + const created = scaffoldDbLayout(tmpDir, INIT_PATH_CONFIG); + + expect(created).toEqual([ + 'db/sql/.gitkeep', + 'db/changes/.gitkeep', + 'db/AGENTS.md', + 'db/CLAUDE.md', + ]); + expect(existsSync(join(tmpDir, 'db', 'sql'))).toBe(true); + expect(existsSync(join(tmpDir, 'db', 'changes'))).toBe(true); + + }); + + it('should template AGENTS.md for the user and import it from CLAUDE.md', () => { + + scaffoldDbLayout(tmpDir, INIT_PATH_CONFIG); + + const agents = readFileSync(join(tmpDir, 'db', 'AGENTS.md'), 'utf-8'); + const claude = readFileSync(join(tmpDir, 'db', 'CLAUDE.md'), 'utf-8'); + + expect(agents).toContain('Use the `/noorm` skill for managing files here.'); + expect(agents).toContain(''); + expect(claude.split('\n')).toContain('@AGENTS.md'); + + }); + + it('should create nothing for a project on its own layout', () => { + + const created = scaffoldDbLayout(tmpDir, DEFAULT_PATH_CONFIG); + + expect(created).toEqual([]); + expect(existsSync(join(tmpDir, 'db'))).toBe(false); + + }); + + it('should leave an existing CLAUDE.md untouched and report only what it created', () => { + + mkdirSync(join(tmpDir, 'db'), { recursive: true }); + writeFileSync(join(tmpDir, 'db', 'CLAUDE.md'), 'mine\n'); + + const created = scaffoldDbLayout(tmpDir, INIT_PATH_CONFIG); + + expect(readFileSync(join(tmpDir, 'db', 'CLAUDE.md'), 'utf-8')).toBe('mine\n'); + expect(created).toContain('db/AGENTS.md'); + expect(created).not.toContain('db/CLAUDE.md'); + + }); + }); From 35c73a4d72fd668b13de2db08660ef9c84251730 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Tue, 20 Oct 2026 07:22:00 -0400 Subject: [PATCH 4/6] feat!: db/ default paths, change fixes, skill prompt Defaults and `noorm init` now agree on ./db/sql and ./db/changes; overwriting a settings file keeps the paths it already has. Also: an unreadable change fails `change ff` instead of vanishing, SQLite change files run every statement, and `noorm update` offers the skill. BREAKING CHANGE: a settings.yml with no `paths` now resolves to ./db/sql and ./db/changes. Add paths: { sql: ./sql, changes: ./changes } to keep the old layout. --- .changeset/change-multi-statement.md | 6 + .changeset/change-parse-failures.md | 6 + .changeset/init-db-layout-agent-files.md | 4 +- .changeset/update-skill-prompt.md | 6 + docs/cli/init.md | 2 +- docs/dev/ci.md | 4 +- docs/dev/headless.md | 6 +- docs/dev/runner.md | 40 ++-- docs/dev/sdk.md | 8 +- docs/dev/settings.md | 10 +- docs/dev/template.md | 20 +- docs/getting-started/installation.md | 5 +- docs/guide/changes/forward-revert.md | 2 +- docs/guide/changes/overview.md | 2 + docs/guide/database/teardown.md | 6 +- docs/guide/environments/secrets.md | 2 +- docs/guide/environments/vault.md | 2 +- docs/guide/sql-files/templates.md | 4 +- docs/headless.md | 15 +- docs/index.md | 2 +- skills/noorm/SKILL.md | 4 +- skills/noorm/references/cli.md | 4 +- skills/noorm/references/config.md | 8 +- skills/noorm/references/schema-files.md | 27 ++- skills/noorm/references/templates.md | 7 +- src/cli/change/add.ts | 3 +- src/cli/change/edit.ts | 3 +- src/cli/change/list.ts | 2 +- src/cli/change/rm.ts | 3 +- src/cli/dev/test-helpers.ts | 8 +- src/cli/run/dir.ts | 2 +- src/cli/run/exec.ts | 8 +- src/cli/run/file.ts | 2 +- src/cli/run/files.ts | 2 +- src/cli/run/inspect.ts | 8 +- src/cli/run/preview.ts | 12 +- src/cli/settings/edit.ts | 5 +- src/cli/update.ts | 55 ++++++ src/core/change/executor.ts | 15 +- src/core/change/manager.ts | 80 ++++++-- src/core/change/parser.ts | 92 ++++++++- src/core/change/types.ts | 4 + src/core/project-init.ts | 51 ++--- src/core/runner/mssql-batches.ts | 7 +- src/core/runner/runner.ts | 2 +- src/core/settings/defaults.ts | 38 ++-- src/core/settings/manager.ts | 10 + src/core/update/skill.ts | 54 ++++++ src/rpc/commands/run.ts | 2 +- src/sdk/namespaces/changes.ts | 5 +- src/sdk/namespaces/run.ts | 11 +- src/tui/screens/change/ChangeEditScreen.tsx | 6 +- src/tui/screens/change/ChangeFFScreen.tsx | 4 +- src/tui/screens/change/ChangeListScreen.tsx | 4 +- src/tui/screens/change/ChangeNextScreen.tsx | 4 +- src/tui/screens/home.tsx | 12 +- src/tui/screens/init/InitScreen.tsx | 14 +- src/tui/screens/init/ProjectSetup.tsx | 12 +- src/tui/screens/run/RunBuildScreen.tsx | 3 +- src/tui/screens/run/RunDirScreen.tsx | 3 +- src/tui/screens/run/RunExecScreen.tsx | 3 +- src/tui/screens/run/RunFileScreen.tsx | 5 +- src/tui/screens/run/RunInspectScreen.tsx | 5 +- src/tui/screens/run/RunListScreen.tsx | 3 +- .../screens/settings/SettingsBuildScreen.tsx | 6 +- .../screens/settings/SettingsListScreen.tsx | 3 +- .../screens/settings/SettingsPathsScreen.tsx | 15 +- src/tui/utils/change-loader.ts | 28 ++- src/tui/utils/paths.ts | 13 +- tests/cli/run/_setup.ts | 2 +- .../screens/change/change-dry-run.test.tsx | 4 +- tests/cli/utils/change-context.test.ts | 4 +- tests/cli/utils/change-loader.test.ts | 83 +++++++++ tests/core/change/manager.test.ts | 175 ++++++++++++++++++ tests/core/project-init.test.ts | 63 +------ tests/core/settings/manager.test.ts | 77 +++++++- tests/core/update/skill.test.ts | 119 ++++++++++++ .../integration/change/mssql-batches.test.ts | 174 +++++++++++++++++ tests/integration/cli/setup.ts | 2 +- tests/sdk/run-build-filtering.test.ts | 16 +- 80 files changed, 1225 insertions(+), 323 deletions(-) create mode 100644 .changeset/change-multi-statement.md create mode 100644 .changeset/change-parse-failures.md create mode 100644 .changeset/update-skill-prompt.md create mode 100644 src/core/update/skill.ts create mode 100644 tests/cli/utils/change-loader.test.ts create mode 100644 tests/core/update/skill.test.ts create mode 100644 tests/integration/change/mssql-batches.test.ts diff --git a/.changeset/change-multi-statement.md b/.changeset/change-multi-statement.md new file mode 100644 index 00000000..341abd80 --- /dev/null +++ b/.changeset/change-multi-statement.md @@ -0,0 +1,6 @@ +--- +'@noormdev/cli': patch +'@noormdev/sdk': patch +--- + +A change file with more than one statement now runs every statement on SQLite, and `GO` batch separators in change files are honored on SQL Server, as they already were for `sql/` files. Before, SQLite ran only the first statement of a change file and reported success. diff --git a/.changeset/change-parse-failures.md b/.changeset/change-parse-failures.md new file mode 100644 index 00000000..0d070d9d --- /dev/null +++ b/.changeset/change-parse-failures.md @@ -0,0 +1,6 @@ +--- +'@noormdev/cli': patch +'@noormdev/sdk': patch +--- + +`noorm change ff` and `change next` now fail at the position of a change folder that cannot be parsed when they would run it (never run, `reverted`, or `stale`), for example a `.txt` manifest that names a file no longer in `sql/`. Before, the folder was skipped, later changes ran without it, and the command exited 0. `change list` shows such a change as `failed` with the parse error, the TUI change screens and home count include it, `changes.pending()` returns it, and `change run ` reports the parse error instead of "Change not found". An applied change keeps its status, and the batch adds a warning instead of failing. A folder with neither `change/` nor `revert/` is not a change and is ignored. diff --git a/.changeset/init-db-layout-agent-files.md b/.changeset/init-db-layout-agent-files.md index c65d28c8..923b574c 100644 --- a/.changeset/init-db-layout-agent-files.md +++ b/.changeset/init-db-layout-agent-files.md @@ -3,4 +3,6 @@ '@noormdev/sdk': minor --- -`noorm init` now creates `db/sql/` and `db/changes/` instead of `sql/` and `changes/`, and writes `paths.sql: ./db/sql` and `paths.changes: ./db/changes` into `settings.yml`. It also writes `db/AGENTS.md`, which points coding agents at the `/noorm` skill and leaves a marker for your own steering, and `db/CLAUDE.md`, which imports it. Neither file is overwritten on re-init. Existing projects keep their layout: re-running `init`, including `--force`, keeps the `paths` already in `settings.yml`, and a `settings.yml` with no `paths` key still resolves to `./sql` and `./changes`. The `noorm` skill now states what `sql/` and `changes/` are for, which edits belong in each, how to reference `sql/` files from a change with a `.txt` manifest, and when to use a `.sql.tmpl`. +**Breaking:** the default for `paths.sql` and `paths.changes` is now `./db/sql` and `./db/changes`. A `settings.yml` with no `paths` key resolves there, so a project laid out at `./sql` and `./changes` must add `paths: { sql: ./sql, changes: ./changes }`. A `settings.yml` written by `noorm init` already carries `paths` and is unaffected. + +`noorm init` now creates `db/sql/` and `db/changes/` instead of `sql/` and `changes/`, and writes `db/AGENTS.md`, which points coding agents at the `/noorm` skill and leaves a marker for your own steering, and `db/CLAUDE.md`, which imports it. Neither file is overwritten on re-init. Overwriting a settings file (`noorm init --force`, `noorm settings init --force`) keeps the `paths` already in it. The `noorm` skill now states what `sql/` and `changes/` are for, which edits belong in each, how to reference `sql/` files from a change with a `.txt` manifest, and when to use a `.sql.tmpl`. diff --git a/.changeset/update-skill-prompt.md b/.changeset/update-skill-prompt.md new file mode 100644 index 00000000..b2fe1f59 --- /dev/null +++ b/.changeset/update-skill-prompt.md @@ -0,0 +1,6 @@ +--- +'@noormdev/cli': minor +'@noormdev/sdk': minor +--- + +After a successful update on a terminal, `noorm update` asks whether to install or update the noorm agent skill, and runs `npx skills add noormdev/noorm/skills` on yes. A failed or declined install does not change the exit code. `--json` and piped output skip the prompt, and piped text output prints the command instead. diff --git a/docs/cli/init.md b/docs/cli/init.md index 25c1c787..b11efadd 100644 --- a/docs/cli/init.md +++ b/docs/cli/init.md @@ -8,7 +8,7 @@ It also writes `db/AGENTS.md` and `db/CLAUDE.md`. `AGENTS.md` points coding agen `/noorm` skill and holds a marker for your own steering; `CLAUDE.md` imports it with `@AGENTS.md`. Both are written only when absent, so re-running with `--force` keeps your edits. -A project that already has a `settings.yml` keeps its `paths`. `--force` resets the rest of the file, but a project laid out at `sql/` and `changes/` stays there, and no `db/` directory is created for it. +A project that already has `paths` in its `settings.yml` keeps them. `--force` resets the rest of the file, but a project laid out at `sql/` and `changes/` stays there, and no `db/` directory is created for it. ## Flags diff --git a/docs/dev/ci.md b/docs/dev/ci.md index 60c5cf6a..6e300a7d 100644 --- a/docs/dev/ci.md +++ b/docs/dev/ci.md @@ -106,8 +106,8 @@ a partial one needs a human, so the two must never share a code. | Variable | Required | Description | |----------|----------|-------------| -| `NOORM_PATHS_SQL` | No | Schema directory (default: `./sql`) | -| `NOORM_PATHS_CHANGES` | No | Changes directory (default: `./changes`) | +| `NOORM_PATHS_SQL` | No | Schema directory (default: `./db/sql`) | +| `NOORM_PATHS_CHANGES` | No | Changes directory (default: `./db/changes`) | ## GitHub Actions Example (Test CI) diff --git a/docs/dev/headless.md b/docs/dev/headless.md index 67499a55..ea5f736a 100644 --- a/docs/dev/headless.md +++ b/docs/dev/headless.md @@ -163,7 +163,7 @@ string, `success` is derived from it (`partial` is never reported as `true`). Execute a single SQL file. ```bash -noorm run file sql/01_tables/001_users.sql +noorm run file db/sql/01_tables/001_users.sql ``` **JSON output:** @@ -182,7 +182,7 @@ noorm run file sql/01_tables/001_users.sql Execute all SQL files in a directory. ```bash -noorm run dir sql/01_tables/ +noorm run dir db/sql/01_tables/ ``` @@ -660,7 +660,7 @@ src/cli/ ├── run/ ├── secret/ ├── settings/ -├── sql/ +├── db/sql/ └── vault/ ``` diff --git a/docs/dev/runner.md b/docs/dev/runner.md index 36d66048..b45577b3 100644 --- a/docs/dev/runner.md +++ b/docs/dev/runner.md @@ -71,9 +71,9 @@ The `--dry-run` flag renders all SQL files and writes them to a local `tmp/` dir ``` Source: Dry run output: -sql/02_views/001_my_view.sql → tmp/sql/02_views/001_my_view.sql -sql/03_seeds/001_users.sql.tmpl → tmp/sql/03_seeds/001_users.sql -sql/04_auth/002_permissions.sql.tmpl → tmp/sql/04_auth/002_permissions.sql +db/sql/02_views/001_my_view.sql → tmp/db/sql/02_views/001_my_view.sql +db/sql/03_seeds/001_users.sql.tmpl → tmp/db/sql/03_seeds/001_users.sql +db/sql/04_auth/002_permissions.sql.tmpl → tmp/db/sql/04_auth/002_permissions.sql ``` Templates are fully rendered with the current config context. The `.tmpl` extension is stripped from output files. @@ -115,7 +115,7 @@ noorm run build --preview --output build.sql Files ending in `.sql.tmpl` are processed through the template engine before execution: ```sql --- sql/03_seeds/001_users.sql.tmpl +-- db/sql/03_seeds/001_users.sql.tmpl {% for (const user of $.users) { %} INSERT INTO users (email, name) VALUES ({%~ $.quote(user.email) %}, {%~ $.quote(user.name) %}); {% } %} @@ -165,16 +165,16 @@ console.log(`Ran ${result.filesRun} files in ${result.durationMs}ms`) console.log(`Skipped ${result.filesSkipped} unchanged files`) // Execute a single file -const fileResult = await runFile(context, '/project/sql/001_users.sql') +const fileResult = await runFile(context, '/project/db/sql/001_users.sql') // Execute all files in a directory -const dirResult = await runDir(context, '/project/sql/02_views') +const dirResult = await runDir(context, '/project/db/sql/02_views') // Execute specific files (selective execution) const filesResult = await runFiles(context, [ - '/project/sql/01_tables/001_users.sql', - '/project/sql/01_tables/002_posts.sql', - '/project/sql/02_views/001_active_users.sql', + '/project/db/sql/01_tables/001_users.sql', + '/project/db/sql/01_tables/002_posts.sql', + '/project/db/sql/02_views/001_active_users.sql', ]) // Dry run - render to tmp/ without executing @@ -184,13 +184,13 @@ const dryResult = await runBuild(context, '/project/sql', { // Preview rendered SQL const previews = await preview(context, [ - '/project/sql/seed.sql.tmpl', + '/project/db/sql/seed.sql.tmpl', ]) console.log(previews[0].renderedSql) // Preview to a file (optional 3rd parameter) const previewsToFile = await preview(context, [ - '/project/sql/seed.sql.tmpl', + '/project/db/sql/seed.sql.tmpl', ], '/project/output.sql') ``` @@ -248,7 +248,7 @@ When `context.dialect === 'mssql'`, the runner splits SQL content on `^\s*GO\s*$ ```typescript -const result = await ctx.noorm.run.file('sql/procs/checkout.sql'); +const result = await ctx.noorm.run.file('db/sql/procs/checkout.sql'); if (result.status === 'failed') { @@ -402,18 +402,18 @@ Filter files by include/exclude path patterns: import { filterFilesByPaths } from './core/shared' const files = [ - '/project/sql/01_tables/001_users.sql', - '/project/sql/02_views/001_active.sql', - '/project/sql/archive/old.sql', + '/project/db/sql/01_tables/001_users.sql', + '/project/db/sql/02_views/001_active.sql', + '/project/db/sql/archive/old.sql', ] const filtered = filterFilesByPaths( files, '/project', - ['sql/01_tables', 'sql/02_views'], // include - ['sql/archive'] // exclude + ['db/sql/01_tables', 'db/sql/02_views'], // include + ['db/sql/archive'] // exclude ) -// ['/project/sql/01_tables/001_users.sql', '/project/sql/02_views/001_active.sql'] +// ['/project/db/sql/01_tables/001_users.sql', '/project/db/sql/02_views/001_active.sql'] ``` This utility is used internally by build operations when applying settings rules. The CLI passes the SQL directory (`paths.sql`) as the base directory, so settings patterns like `01_tables` resolve relative to the SQL path, not the project root. Exclude patterns take precedence when both match. @@ -427,8 +427,8 @@ The `executeFiles` function provides low-level file execution with full tracking import { executeFiles, type FileInput } from './core/runner' const files: FileInput[] = [ - { path: '/project/sql/001_users.sql', type: 'sql' }, - { path: '/project/sql/002_posts.sql', type: 'sql' }, + { path: '/project/db/sql/001_users.sql', type: 'sql' }, + { path: '/project/db/sql/002_posts.sql', type: 'sql' }, ] // Four arguments: run options and execution options are separate objects diff --git a/docs/dev/sdk.md b/docs/dev/sdk.md index 880554f6..89719e0f 100644 --- a/docs/dev/sdk.md +++ b/docs/dev/sdk.md @@ -449,7 +449,7 @@ await ctx.noorm.run.dir('seeds/') Discover SQL files in a directory. Defaults to the configured SQL directory. ```typescript -const files = await ctx.noorm.run.discover('sql/') +const files = await ctx.noorm.run.discover('db/sql/') ``` ##### `run.preview(filepaths, output?)` @@ -457,7 +457,7 @@ const files = await ctx.noorm.run.discover('sql/') Preview SQL files — render templates without executing. ```typescript -const results = await ctx.noorm.run.preview(['sql/001.sql', 'sql/002.sql']) +const results = await ctx.noorm.run.preview(['db/sql/001.sql', 'db/sql/002.sql']) ``` @@ -693,7 +693,7 @@ const { released, holder } = await ctx.noorm.lock.forceRelease() Render a template file without executing. ```typescript -const result = await ctx.noorm.templates.render('sql/001_users.sql.tmpl') +const result = await ctx.noorm.templates.render('db/sql/001_users.sql.tmpl') console.log(result.sql) ``` @@ -801,7 +801,7 @@ const result = await ctx.noorm.vault.copy(destConfig, ['API_KEY'], privateKey) Compute SHA-256 checksum for a file. ```typescript -const checksum = await ctx.noorm.utils.checksum('sql/001_users.sql') +const checksum = await ctx.noorm.utils.checksum('db/sql/001_users.sql') ``` ##### `utils.testConnection()` diff --git a/docs/dev/settings.md b/docs/dev/settings.md index eb40938f..b08c383c 100644 --- a/docs/dev/settings.md +++ b/docs/dev/settings.md @@ -164,11 +164,11 @@ Defaults: | Path | Default | |------|---------| -| `sql` | `./sql` | -| `changes` | `./changes` | +| `sql` | `./db/sql` | +| `changes` | `./db/changes` | -These apply only when `settings.yml` has no `paths` key. `noorm init` writes `./db/sql` and -`./db/changes` into the file explicitly, so a new project never relies on them. +These apply when `settings.yml` has no `paths` key. `noorm init` writes them into the file +explicitly. A project laid out elsewhere, such as `./sql`, sets `paths` to say so. ```typescript const paths = settings.getPaths() @@ -719,7 +719,7 @@ import { DEFAULT_SETTINGS, createDefaultSettings } from './core/settings' console.log(DEFAULT_SETTINGS) // { // build: { include: [], exclude: [] }, // empty include = all files under paths.sql -// paths: { sql: './sql', changes: './changes' }, +// paths: { sql: './db/sql', changes: './db/changes' }, // rules: [], // stages: {}, // strict: { enabled: false, stages: [] }, diff --git a/docs/dev/template.md b/docs/dev/template.md index 75dd4ae2..37f6d2b3 100644 --- a/docs/dev/template.md +++ b/docs/dev/template.md @@ -69,7 +69,7 @@ INSERT INTO users (id) VALUES ('{%~ $.uuid() %}'); Data files in the same directory as the template are automatically loaded into the context: ``` -sql/users/ +db/sql/users/ ├── 001_seed.sql.tmpl # Template ├── users.json5 # → $.users ├── roles.yml # → $.roles @@ -132,7 +132,7 @@ strings work', Helper files named `$helpers.ts` or `$helpers.js` are automatically loaded. They're inherited up the directory tree - child helpers override parent helpers. ``` -sql/ +db/sql/ ├── $helpers.ts # Project-wide helpers ├── users/ │ ├── $helpers.ts # Overrides/extends parent @@ -152,7 +152,7 @@ sql/ ### Writing Helpers ```typescript -// sql/$helpers.ts +// db/sql/$helpers.ts export function padId(id: number, length = 6): string { return String(id).padStart(length, '0') @@ -234,7 +234,7 @@ secret, a bad lookup), never an intentional value. `null` is unaffected and stil Define users, roles, and permissions in YAML - generate all the grants from config: ```yaml -# sql/Auth/auth.yml +# db/sql/Auth/auth.yml users: - username: __web_app password: $DB_WEB_APP_PASSWORD @@ -267,7 +267,7 @@ permissions: ``` ```sql --- sql/Auth/02-Permissions.sql.tmpl +-- db/sql/Auth/02-Permissions.sql.tmpl {% for (const role in $.auth.permissions) { %} {% const perms = $.auth.permissions[role] %} @@ -290,7 +290,7 @@ GRANT EXECUTE ON [{%~ proc %}] TO [{%~ role %}]; Manage cron jobs from a config file - no more clicking through SSMS: ```yaml -# sql/Crons/cron.yml +# db/sql/Crons/cron.yml Jobs: Sync_Entrata: Description: 'Sync property data from Entrata API' @@ -321,7 +321,7 @@ Schedules: ``` ```sql --- sql/Crons/Crons.sql.tmpl +-- db/sql/Crons/Crons.sql.tmpl -- Create jobs {% for (const job in $.cron.Jobs) { %} @@ -353,7 +353,7 @@ EXEC msdb.dbo.sp_attach_schedule ### Seeding from CSV ```sql --- sql/seed/users.sql.tmpl +-- db/sql/seed/users.sql.tmpl INSERT INTO users (email, name, role) VALUES {% $.users.forEach((user, i) => { %} ({%~ $.quote(user.email) %}, {%~ $.quote(user.name) %}, {%~ $.quote(user.role) %}){% if (i < $.users.length - 1) { %},{% } %} @@ -372,7 +372,7 @@ bob@example.com,Bob,user ### Using Secrets ```sql --- sql/config/api_keys.sql.tmpl +-- db/sql/config/api_keys.sql.tmpl INSERT INTO config (key, value) VALUES ('stripe_key', '{%~ $.secrets.STRIPE_KEY %}'), ('api_url', '{%~ $.env.API_URL || 'https://api.example.com' %}'); @@ -398,7 +398,7 @@ INSERT INTO config (key, value) VALUES ### Dialect-Specific SQL ```sql --- sql/indexes/create.sql.tmpl +-- db/sql/indexes/create.sql.tmpl CREATE INDEX idx_users_email ON users(email); {% if ($.config.connection.dialect === 'postgres') { %} diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index d2bf3929..ed24b939 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -85,6 +85,8 @@ noorm update # Or launch the TUI (`noorm ui`) and press [u] from the home screen ``` +On a terminal, `noorm update` finishes by asking whether to install or update the [agent skill](#install-the-agent-skill-optional). + ## Install the SDK (Optional) @@ -118,7 +120,7 @@ npx skills add noormdev/noorm/skills Agents otherwise guess at noorm's conventions, and several of them are non-obvious enough to guess wrong: SDK methods throw named errors rather than returning them, `attempt()` is used deliberately rather than everywhere, the codebase uses no `try`/`catch`, and test helpers carry safety guards that exist for a reason. The skill covers all of it, plus the headless CLI flags used in CI. -It installs four references the agent reads on demand: +Run the same command again to update an installed copy; it replaces the copy. It installs the references the agent reads on demand: | Reference | Covers | |-----------|--------| @@ -126,6 +128,7 @@ It installs four references the agent reads on demand: | `cli.md` | Headless commands, flags, exit codes, CI usage | | `templates.md` | `.sql.tmpl` syntax and data loaders | | `config.md` | `settings.yml`, paths, stages, secrets | +| `schema-files.md` | What `sql/` and `changes/` are for, which edits go where, manifests, when to use a template | ::: tip Include paths are relative to `paths.sql` The single most common mistake — by people and agents alike — is writing `build.include` entries relative to the project root. They resolve against `paths.sql`. The skill states this explicitly; `noorm run build` also warns when an include entry matches nothing. diff --git a/docs/guide/changes/forward-revert.md b/docs/guide/changes/forward-revert.md index 8c46640a..f54d0276 100644 --- a/docs/guide/changes/forward-revert.md +++ b/docs/guide/changes/forward-revert.md @@ -39,7 +39,7 @@ Fast-forward is the workhorse for deployments. It finds every outstanding change } ``` -Each entry in `changes` also carries a `files` array with one record per SQL file: relative path, checksum, status, duration, and the error message when the file failed. If the `db/changes/` directory is missing, the payload gains a `warnings` array saying so, because an absent directory otherwise looks identical to an up-to-date database. +Each entry in `changes` also carries a `files` array with one record per SQL file: relative path, checksum, status, duration, and the error message when the file failed. If the `db/changes/` directory is missing, the payload gains a `warnings` array saying so, because an absent directory otherwise looks identical to an up-to-date database. A change folder that cannot be read, such as a manifest naming a deleted `sql/` file, stops the batch at that change when it would run, and only adds a warning when it is already applied. ### Apply a Specific Change diff --git a/docs/guide/changes/overview.md b/docs/guide/changes/overview.md index f30c1277..97f079a6 100644 --- a/docs/guide/changes/overview.md +++ b/docs/guide/changes/overview.md @@ -205,6 +205,8 @@ Manifest files participate in ordering. Here, `001_rebuild-views.txt` runs first This keeps your `db/sql/` folder as the single source of truth. Views and functions are defined once, referenced wherever needed. +The reference is resolved every time the change is read. Delete or rename a file a manifest lists and that change can no longer be read: `noorm change ff` stops at it if it has not run, and `noorm change list` shows it as `failed` with the missing path. A change that already ran keeps its status, and `ff` only warns. A manifest also runs the file's current content, so list only objects that are safe to run repeatedly, never a `CREATE TABLE` file. + ## Checking Change Status diff --git a/docs/guide/database/teardown.md b/docs/guide/database/teardown.md index c3da576d..07e855af 100644 --- a/docs/guide/database/teardown.md +++ b/docs/guide/database/teardown.md @@ -162,7 +162,7 @@ teardown: These tables will be skipped during both truncate and teardown operations. Their data and structure remain intact. -`ctx.noorm.db.reset()` is the exception: it ignores `preserveTables` on purpose. Reset rebuilds the whole schema from `sql/`, so any table left standing would collide with the build's `CREATE TABLE` and abort the rebuild. +`ctx.noorm.db.reset()` is the exception: it ignores `preserveTables` on purpose. Reset rebuilds the whole schema from `db/sql/`, so any table left standing would collide with the build's `CREATE TABLE` and abort the rebuild. ## Post-Teardown Scripts @@ -172,7 +172,7 @@ Re-seed essential data after teardown completes: ```yaml # .noorm/settings.yml teardown: - postScript: sql/teardown/seed.sql + postScript: db/sql/teardown/seed.sql ``` The `postScript` runs immediately after teardown finishes. Use it to: @@ -182,7 +182,7 @@ The `postScript` runs immediately after teardown finishes. Use it to: - Set up test fixtures ```sql --- sql/teardown/seed.sql +-- db/sql/teardown/seed.sql INSERT INTO countries (code, name) VALUES ('US', 'United States'), ('CA', 'Canada'), diff --git a/docs/guide/environments/secrets.md b/docs/guide/environments/secrets.md index a5293548..89feefba 100644 --- a/docs/guide/environments/secrets.md +++ b/docs/guide/environments/secrets.md @@ -81,7 +81,7 @@ See [Templates](/guide/sql-files/templates) for full syntax reference. Secrets inject into SQL templates via the `$` context object: ```sql --- sql/users/create-readonly.sql.tmpl +-- db/sql/users/create-readonly.sql.tmpl CREATE USER {%~ $.secrets.READONLY_USER %} WITH PASSWORD '{%~ $.secrets.READONLY_PASSWORD %}'; diff --git a/docs/guide/environments/vault.md b/docs/guide/environments/vault.md index 36c1d728..763ce2ce 100644 --- a/docs/guide/environments/vault.md +++ b/docs/guide/environments/vault.md @@ -172,7 +172,7 @@ If the destination vault isn't initialized, noorm initializes it automatically u Vault secrets merge into the template context alongside local secrets: ```sql --- sql/setup/external-api.sql.tmpl +-- db/sql/setup/external-api.sql.tmpl -- Vault secret (or local override if set) INSERT INTO api_config (provider, key) VALUES ('stripe', {%~ $.quote($.secrets.STRIPE_KEY) %}); diff --git a/docs/guide/sql-files/templates.md b/docs/guide/sql-files/templates.md index 2676d1ee..145e146c 100644 --- a/docs/guide/sql-files/templates.md +++ b/docs/guide/sql-files/templates.md @@ -339,7 +339,7 @@ The active configuration is available through `$.config`. Use it to adapt SQL ba ```sql -- setup_monitoring.sql.tmpl {% const db = $.config.connection.database %} -{% const stage = $.config.stage %} +{% const stage = $.config.name %} -- Tag all entries with the source database INSERT INTO monitoring.activity_log (source_db, event, created_at) VALUES @@ -360,7 +360,7 @@ Common conditions: | Condition | Use Case | |-----------|----------| -| `$.config.stage === 'prod'` | Production-only constraints or settings | +| `$.config.name === 'prod'` | Production-only constraints or settings (a config links to the stage of the same name) | | `$.config.isTest` | Test fixtures and sample data | | `$.config.name === 'dev'` | Local development overrides | | `$.config.connection.database` | Database-specific configuration | diff --git a/docs/headless.md b/docs/headless.md index bec2684e..e2bf63ec 100644 --- a/docs/headless.md +++ b/docs/headless.md @@ -127,8 +127,8 @@ NOORM_CI_CONFIG_NAME # Default config name for `ci init` (override: --na **Paths:** ```bash -NOORM_PATHS_SQL # Schema directory (default: ./sql when settings.yml has no paths) -NOORM_PATHS_CHANGES # Changes directory (default: ./changes when settings.yml has no paths) +NOORM_PATHS_SQL # Schema directory (default: ./db/sql) +NOORM_PATHS_CHANGES # Changes directory (default: ./db/changes) ``` **Behavior:** @@ -875,6 +875,15 @@ noorm change ff --force `--dry-run` adds `"dryRun": true` to the payload. A missing `db/changes/` directory is a warning, not a failure: it lands in `warnings` rather than making the batch fail. +A change folder that cannot be parsed, for example a `.txt` manifest that names a file no longer in `db/sql/`, is handled by what `ff` would do with it: + +| The unreadable change | `ff` / `next` | +|-----------------------|---------------| +| Never ran, `reverted`, or `stale` | Fails at that change's position, so later changes do not run out of order. The result is `failed` (exit `1`), or `partial` (exit `3`) when earlier changes applied, and `error` carries the parse message | +| Applied | Keeps its status and adds a message to `warnings` | + +A folder with neither `change/` nor `revert/` is not a change and is ignored. `change list` shows an unreadable change that never ran as `failed`, with the parse message after it in text output and in `errorMessage` under `--json`. + #### `change next` @@ -1575,6 +1584,8 @@ noorm update --json |------|-------------| | `--insecure` | Downgrade an unreachable `checksums.txt` from a failure to a warning. Also settable as `NOORM_INSECURE=1`. It never bypasses a confirmed checksum mismatch, which always fails | +After a successful update on a terminal, `noorm update` asks whether to install or update the agent skill and, on yes, runs `npx skills add noormdev/noorm/skills`. Declining, `--json`, and piped output skip the prompt; piped text output prints the command instead. A failed skill install does not change the exit code, since the update itself succeeded. + #### `ui` diff --git a/docs/index.md b/docs/index.md index 3e96d6cc..22ac21e5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -109,7 +109,7 @@ When the schema evolves, update the SQL file **and** add a change. The file keep ```bash # Edit db/sql/01_tables/001_users.sql to add an email column -# Add db/changes/2024-01-add-email/forward.sql +# Run `noorm change add add-email`, then put the ALTER in its change/001_add-email.sql noorm change ff # fast-forward: apply pending changes ``` diff --git a/skills/noorm/SKILL.md b/skills/noorm/SKILL.md index cd2f5ac2..51e48999 100644 --- a/skills/noorm/SKILL.md +++ b/skills/noorm/SKILL.md @@ -1,6 +1,6 @@ --- name: noorm -description: Guide for building applications and tests with the NoORM database SDK (@noormdev/sdk) and composing CLI headless commands for CI/CD pipelines. Use this skill whenever the user imports @noormdev/sdk, calls createContext(), writes Kysely queries in a noorm-managed project, writes database integration tests, sets up CI/CD with noorm commands, manages database schemas or migrations, runs SQL files through noorm, transfers data between databases, manages vault secrets, or discusses noorm in any development context. Also trigger when you see noorm environment variables (NOORM_*), .sql.tmpl template files, .txt change manifests, a `.noorm/` directory, `db/sql` or `db/changes` folders, or change/revert directory structures, and whenever the task is to add, alter, or drop a table, column, view, function, or seed data in a noorm project. Trigger for any database task in a noorm-managed project — the SDK conventions (SDK methods throw named errors, `attempt()` wraps deliberately, no try-catch, test safety guards, Kysely patterns) and CLI flags are non-obvious and critical to get right. +description: Guide for building applications and tests with the NoORM database SDK (@noormdev/sdk) and composing CLI headless commands for CI/CD pipelines. Use this skill whenever the user imports @noormdev/sdk, calls createContext(), writes Kysely queries in a noorm-managed project, writes database integration tests, sets up CI/CD with noorm commands, manages database schemas or migrations, runs SQL files through noorm, transfers data between databases, manages vault secrets, or discusses noorm in any development context. Also trigger on noorm environment variables (NOORM_*), .sql.tmpl templates, .txt change manifests, a `.noorm/` directory, `db/sql` or `db/changes` folders, change/revert directories, and any task that adds, alters, or drops a table, column, view, function, or seed data in a noorm project. Trigger for any database task in a noorm-managed project: the SDK conventions (named errors, no try-catch, test safety guards) and CLI flags are non-obvious. --- # NoORM @@ -41,7 +41,7 @@ Database schema and change manager with two interfaces: └── CLAUDE.md imports AGENTS.md -The real locations are `paths.sql` and `paths.changes` in `.noorm/settings.yml`. Older projects use `./sql` and `./changes`. Read the settings before creating a file, and never add a second `sql/` or `changes/` beside the configured ones. +The real locations are `paths.sql` and `paths.changes` in `.noorm/settings.yml`. Projects created before the `db/` layout have `./sql` and `./changes` written in their `paths`; a settings file with no `paths` key means `./db/sql` and `./db/changes`. Read the settings before creating a file, and never add a second `sql/` or `changes/` beside the configured ones. Four rules that every schema edit follows (details and examples in `references/schema-files.md`): diff --git a/skills/noorm/references/cli.md b/skills/noorm/references/cli.md index 80717157..4fff1feb 100644 --- a/skills/noorm/references/cli.md +++ b/skills/noorm/references/cli.md @@ -62,8 +62,8 @@ When all three identity vars are set at process startup, every `noorm` command i ### Paths ```bash -NOORM_PATHS_SQL # Schema directory (default: ./sql when settings.yml has no paths) -NOORM_PATHS_CHANGES # Changes directory (default: ./changes when settings.yml has no paths) +NOORM_PATHS_SQL # Schema directory (default: ./db/sql) +NOORM_PATHS_CHANGES # Changes directory (default: ./db/changes) ``` ### Behavior diff --git a/skills/noorm/references/config.md b/skills/noorm/references/config.md index 35c02813..0822af39 100644 --- a/skills/noorm/references/config.md +++ b/skills/noorm/references/config.md @@ -26,8 +26,8 @@ build: # Override default directory locations (relative to project root). paths: - sql: ./db/sql # Schema SQL files (`noorm init` writes this; ./sql when the key is absent) - changes: ./db/changes # Change directories (`noorm init` writes this; ./changes when the key is absent) + sql: ./db/sql # Schema SQL files (default: ./db/sql) + changes: ./db/changes # Change directories (default: ./db/changes) # ── Rules ──────────────────────────────────────────────────── # Conditional include/exclude based on the active config's properties. @@ -155,8 +155,8 @@ Use bare, unprefixed segment names. Nested paths (`06_seeds/cron`) are supported | Field | Type | Default | Description | |---|---|---|---| -| `sql` | `string` | `./sql` | Schema SQL directory. `noorm init` writes `./db/sql` | -| `changes` | `string` | `./changes` | Change directory. `noorm init` writes `./db/changes` | +| `sql` | `string` | `./db/sql` | Schema SQL directory | +| `changes` | `string` | `./db/changes` | Change directory | ### `rules[]` diff --git a/skills/noorm/references/schema-files.md b/skills/noorm/references/schema-files.md index 6dd060a0..56675f09 100644 --- a/skills/noorm/references/schema-files.md +++ b/skills/noorm/references/schema-files.md @@ -33,7 +33,7 @@ Where SQL goes in a noorm project and why. Read this before adding a table, alte └── CLAUDE.md # imports AGENTS.md -`noorm init` writes `paths.sql: ./db/sql` and `paths.changes: ./db/changes` into `.noorm/settings.yml`. Projects created before that use `./sql` and `./changes`, and a settings file with no `paths` key falls back to those. +`noorm init` writes `paths.sql: ./db/sql` and `paths.changes: ./db/changes` into `.noorm/settings.yml`. Projects created before that have `./sql` and `./changes` written in their `paths`, and a settings file with no `paths` key means the `db/` paths. Read `.noorm/settings.yml` before creating or moving a file. Never create a second `sql/` or `changes/` beside the configured ones: noorm does not read them, so their files never run. @@ -72,11 +72,25 @@ A table whose definition is spread over a `CREATE` and later `ALTER`s cannot be ### Rules for `changes/` - Everything that turns an existing database into the current state: `ALTER`, `DROP`, backfills, data fixes, type changes, index and constraint additions on live tables. -- Make every statement safe to run twice (`ADD COLUMN IF NOT EXISTS`, `DROP ... IF EXISTS`, `CREATE ... IF NOT EXISTS`). `noorm run build` records no change history, so the first `change ff` on a freshly built database replays every change in the folder, and a statement that is not safe to run twice fails there. +- Make every statement safe to run twice (see [Guards by dialect](#guards-by-dialect); the examples below are PostgreSQL). `noorm run build` records no change history, so the first `change ff` on a freshly built database replays every change in the folder, and a statement that is not safe to run twice fails there. - Write a `revert/` for each change. - Never edit a change that has been applied anywhere. Databases that already ran it keep the old body and the rest run the new one, so environments drift apart. Add a new change instead. `change ff` skips applied changes; `noorm change run ` re-executes an edited one on the one database you point it at. +### Guards by dialect + + +| Statement | PostgreSQL | MySQL 8 | SQL Server | SQLite | +|---|---|---|---|---| +| Create table | `CREATE TABLE IF NOT EXISTS` | `CREATE TABLE IF NOT EXISTS` | `IF OBJECT_ID('dbo.t', 'U') IS NULL CREATE TABLE ...` | `CREATE TABLE IF NOT EXISTS` | +| Add column | `ADD COLUMN IF NOT EXISTS` | Check `information_schema.columns` first | `IF COL_LENGTH('dbo.t', 'c') IS NULL ALTER TABLE ... ADD ...` | No guard | +| Drop column | `DROP COLUMN IF EXISTS` | Check `information_schema.columns` first | `DROP COLUMN IF EXISTS` | No guard | +| Create index | `CREATE INDEX IF NOT EXISTS` | Check `information_schema.statistics` first | `IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE ...)` | `CREATE INDEX IF NOT EXISTS` | +| Drop table or view | `DROP ... IF EXISTS` | `DROP ... IF EXISTS` | `DROP ... IF EXISTS` | `DROP ... IF EXISTS` | + +MySQL and SQL Server reject the PostgreSQL forms of `ADD COLUMN IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS` as syntax errors. SQLite has no guard for `ADD COLUMN`, so a replayed change that adds one fails there. + + --- @@ -118,6 +132,7 @@ Every edit to `sql/` that changes what a built database contains ships with a ch - `revert/` also runs in forward order (`001`, then `002`). Number the revert files in the order the undo has to happen. - `changelog.md` says why the change exists. The diff already shows what it does. - A change whose files contain only comments or placeholders is rejected. +- A folder under `changes/` with neither `change/` nor `revert/` is not a change and is ignored, so shared fragments and archives can live beside the real ones. --- @@ -136,7 +151,7 @@ A `.txt` file in `change/` or `revert/` runs existing `sql/` files in place inst - One path per line, relative to `paths.sql` (`views/active_users.sql`, not `db/sql/views/active_users.sql`). Blank lines and lines starting with `#` are ignored. - Lines run in file order. The manifest sorts among the other change files by filename, so `001_rebuild.txt` runs before `002_alter.sql`. -- A missing file fails `noorm change run ` with a `ManifestReferenceError`. `noorm change ff` and `change list` skip a change they cannot parse, still exit 0, and the change is absent from `change list`. A manifest with no path lines is treated the same way. +- A missing file makes the change unreadable. `noorm change ff` stops at that change and exits non-zero (1, or 3 when earlier changes in the batch applied) with the missing path in the message, `change list` shows it as `failed`, and `change run ` reports the same error. If the change already ran, it keeps its status and `ff` only warns. A manifest with no path lines is treated the same way. - A manifest runs the referenced file's current content, not its content when the change was written. A change still pending on a lagging database, or replayed after a build, executes today's definition. - Because of that, reference only objects that are safe to run repeatedly: `CREATE OR REPLACE` views, functions, procedures, triggers. Never reference a `CREATE TABLE` file: replayed, it hits a table that already exists. - A path listed more than once across a change's manifests runs once, at its first position. @@ -169,7 +184,7 @@ Reach for a template when any of these holds: |---|---| | Rows come from data: lookups, enums, roles, feature flags, cron schedules | Keep the data in `roles.yml` beside the template and loop over `$.roles` | | Three or more statements differ only by a name or value: grants, audit triggers, per-table indexes | Loop over a list in a data file, or call a function from `$helpers.ts` | -| A value differs by environment: stage, test flag, host | Branch on `$.config.stage` / `$.config.isTest`, or read `$.env` | +| A value differs by environment: stage, test flag, host | Branch on `$.config.name` (a config named `prod` is the `prod` stage) or `$.config.isTest`, or read `$.env` | | A value is secret | Read `$.secrets.KEY`. Never write a secret into a plain `.sql` file | | The same fragment appears in several files | `$.include()` it | @@ -225,7 +240,7 @@ becomes data plus a template: ## Worked example -Add a `bio` column to `users`. +Add a `bio` column to `users` (PostgreSQL). -- db/sql/01_tables/001_users.sql @@ -263,4 +278,4 @@ The `ALTER` is in the change and the column is declared once, inline, in `sql/`. - No `CREATE OR REPLACE` object was copied into a change when a manifest would do. - Seed lists, grants and repeated statements went through a template. - No file landed outside the configured `paths`. -- The change appears in `noorm change list`. A broken manifest makes `ff` skip the change and still exit 0. +- No change shows `failed` in `noorm change list` because of a manifest that names a file `sql/` no longer has. Deleting or renaming a `sql/` file breaks every unapplied change that lists it. diff --git a/skills/noorm/references/templates.md b/skills/noorm/references/templates.md index 65f0acd2..34177c2c 100644 --- a/skills/noorm/references/templates.md +++ b/skills/noorm/references/templates.md @@ -64,8 +64,7 @@ The active database configuration object: | Key | Example | Description | |---|---|---| -| `$.config.name` | `'dev'` | Config name | -| `$.config.stage` | `'prod'` | Stage name | +| `$.config.name` | `'dev'` | Config name. A config links to the stage of the same name | | `$.config.isTest` | `true` | Whether config is marked as test | | `$.config.connection.database` | `'myapp'` | Database name | | `$.config.connection.host` | `'localhost'` | Database host | @@ -116,7 +115,7 @@ Combine with config for environment-aware templates: ```sql -- monitoring.sql.tmpl -{% if ($.config.stage === 'prod') { %} +{% if ($.config.name === 'prod') { %} -- Production: use real monitoring endpoint INSERT INTO settings (key, value) VALUES ('monitor_url', {%~ $.quote($.env.MONITOR_URL) %}); @@ -301,7 +300,7 @@ INSERT INTO roles (name, permissions) VALUES ```sql -- setup.sql.tmpl -{% if ($.config.stage === 'prod') { %} +{% if ($.config.name === 'prod') { %} ALTER TABLE orders ADD CONSTRAINT chk_amount CHECK (amount > 0); {% } %} diff --git a/src/cli/change/add.ts b/src/cli/change/add.ts index df8f29af..167dd3b8 100644 --- a/src/cli/change/add.ts +++ b/src/cli/change/add.ts @@ -18,6 +18,7 @@ import { createChange } from '../../core/change/index.js'; import { getSettingsManager } from '../../core/settings/index.js'; import { outputResult, outputError, sharedArgs } from '../_utils.js'; import { requireTty } from './_prompt.js'; +import { DEFAULT_CHANGES_PATH } from '../../core/settings/defaults.js'; const addCommand = defineCommand({ meta: { name: 'add', description: 'Create a new change directory' }, @@ -45,7 +46,7 @@ const addCommand = defineCommand({ const changesDir = join( projectRoot, - settingsManager.settings.paths?.changes ?? 'changes', + settingsManager.settings.paths?.changes ?? DEFAULT_CHANGES_PATH, ); let description = args.name; diff --git a/src/cli/change/edit.ts b/src/cli/change/edit.ts index a09c676e..89450e69 100644 --- a/src/cli/change/edit.ts +++ b/src/cli/change/edit.ts @@ -24,6 +24,7 @@ import { getSettingsManager } from '../../core/settings/index.js'; import { outputError } from '../_utils.js'; import { selectChangeFromFs, requireTty } from './_prompt.js'; import { EXIT } from '../_exit.js'; +import { DEFAULT_CHANGES_PATH } from '../../core/settings/defaults.js'; /** * Spawn an editor against a target path and wait for it to exit. @@ -77,7 +78,7 @@ const editCommand = defineCommand({ const changesDir = join( projectRoot, - settingsManager.settings.paths?.changes ?? 'changes', + settingsManager.settings.paths?.changes ?? DEFAULT_CHANGES_PATH, ); let changeName = args.name; diff --git a/src/cli/change/list.ts b/src/cli/change/list.ts index c6d0dba4..3fe8543a 100644 --- a/src/cli/change/list.ts +++ b/src/cli/change/list.ts @@ -35,7 +35,7 @@ const listCommand = defineCommand({ : [ // `orphaned` is computed but was never rendered, so a change // deleted from disk printed identically to a live applied one. - ...changes.map((cs) => `${cs.name} (${cs.status}${cs.orphaned ? ', orphaned' : ''})`), + ...changes.map((cs) => `${cs.name} (${cs.status}${cs.orphaned ? ', orphaned' : ''})${cs.status === 'failed' && cs.errorMessage ? `: ${cs.errorMessage.split('\n')[0]}` : ''}`), ...(pending > 0 ? [`${pending} pending change(s)`] : []), ].join('\n'); diff --git a/src/cli/change/rm.ts b/src/cli/change/rm.ts index fbf17cb9..a39be2f9 100644 --- a/src/cli/change/rm.ts +++ b/src/cli/change/rm.ts @@ -30,6 +30,7 @@ import { checkConfigPolicy, resolveChannel } from '../../core/policy/index.js'; import { outputResult, outputError, sharedArgs, isYesMode } from '../_utils.js'; import { selectChangeFromFs, requireTty } from './_prompt.js'; import { EXIT } from '../_exit.js'; +import { DEFAULT_CHANGES_PATH } from '../../core/settings/defaults.js'; const rmCommand = defineCommand({ meta: { name: 'rm', description: 'Delete a change directory' }, @@ -82,7 +83,7 @@ const rmCommand = defineCommand({ const changesDir = join( projectRoot, - settingsManager.settings.paths?.changes ?? 'changes', + settingsManager.settings.paths?.changes ?? DEFAULT_CHANGES_PATH, ); let changeName = args.name; diff --git a/src/cli/dev/test-helpers.ts b/src/cli/dev/test-helpers.ts index c8b9b6a2..f2374851 100644 --- a/src/cli/dev/test-helpers.ts +++ b/src/cli/dev/test-helpers.ts @@ -7,8 +7,8 @@ * * @example * ```bash - * noorm dev test-helpers sql/core/05_Cron/Crons.sql.tmpl - * noorm dev test-helpers --json sql/seed.sql.tmpl + * noorm dev test-helpers db/sql/core/05_Cron/Crons.sql.tmpl + * noorm dev test-helpers --json db/sql/seed.sql.tmpl * ``` */ import { dirname, join, relative } from 'node:path'; @@ -160,8 +160,8 @@ const testHelpersCommand = defineCommand({ }); (testHelpersCommand as typeof testHelpersCommand & { examples: string[] }).examples = [ - 'noorm dev test-helpers sql/core/05_Cron/Crons.sql.tmpl', - 'noorm dev test-helpers --json sql/seed.sql.tmpl', + 'noorm dev test-helpers db/sql/core/05_Cron/Crons.sql.tmpl', + 'noorm dev test-helpers --json db/sql/seed.sql.tmpl', ]; export default testHelpersCommand; diff --git a/src/cli/run/dir.ts b/src/cli/run/dir.ts index 26296741..4685a583 100644 --- a/src/cli/run/dir.ts +++ b/src/cli/run/dir.ts @@ -123,7 +123,7 @@ const dirCommand = defineCommand({ (dirCommand as typeof dirCommand & { examples: string[] }).examples = [ 'noorm run dir migrations/', 'noorm run dir seeds/', - 'noorm run dir sql/01_tables/ --json', + 'noorm run dir db/sql/01_tables/ --json', ]; export default dirCommand; diff --git a/src/cli/run/exec.ts b/src/cli/run/exec.ts index c6be6c66..88b0e552 100644 --- a/src/cli/run/exec.ts +++ b/src/cli/run/exec.ts @@ -56,7 +56,7 @@ async function expandGlob(pattern: string, cwd: string): Promise { * discovery. Otherwise treats the argument as a glob pattern and expands it. * * @example - * const files = await resolveInputPaths('sql/', cwd); + * const files = await resolveInputPaths('db/sql/', cwd); * const files = await resolveInputPaths('migrations/*.sql', cwd); */ async function resolveInputPaths(input: string, cwd: string): Promise { @@ -188,11 +188,11 @@ const execCommand = defineCommand({ }); (execCommand as typeof execCommand & { examples: string[] }).examples = [ - 'noorm run exec sql/', + 'noorm run exec db/sql/', 'noorm run exec migrations/', - 'noorm run exec "sql/**/*.sql"', + 'noorm run exec "db/sql/**/*.sql"', 'noorm run exec "seeds/*.sql" --force', - 'noorm run exec sql/ --json', + 'noorm run exec db/sql/ --json', 'noorm run exec "migrations/*.sql" --dry-run', ]; diff --git a/src/cli/run/file.ts b/src/cli/run/file.ts index 046dabae..cddfa766 100644 --- a/src/cli/run/file.ts +++ b/src/cli/run/file.ts @@ -74,7 +74,7 @@ const fileCommand = defineCommand({ (fileCommand as typeof fileCommand & { examples: string[] }).examples = [ 'noorm run file seed.sql', 'noorm run file migrations/001_init.sql', - 'noorm run file sql/init.sql.tmpl --json', + 'noorm run file db/sql/init.sql.tmpl --json', ]; export default fileCommand; diff --git a/src/cli/run/files.ts b/src/cli/run/files.ts index 7bfa7213..f575f176 100644 --- a/src/cli/run/files.ts +++ b/src/cli/run/files.ts @@ -104,7 +104,7 @@ const filesCommand = defineCommand({ (filesCommand as typeof filesCommand & { examples: string[] }).examples = [ 'noorm run files --paths seed.sql,fixtures.sql', - 'noorm run files --paths sql/001_tables.sql,sql/002_indexes.sql', + 'noorm run files --paths db/sql/001_tables.sql,db/sql/002_indexes.sql', 'noorm run files --paths migrations/001.sql,migrations/002.sql --json', 'noorm run files --paths triggers/audit.sql --force', ]; diff --git a/src/cli/run/inspect.ts b/src/cli/run/inspect.ts index e7d755ed..8a95e4b5 100644 --- a/src/cli/run/inspect.ts +++ b/src/cli/run/inspect.ts @@ -6,8 +6,8 @@ * * @example * ```bash - * noorm run inspect sql/users/001_create.sql.tmpl - * noorm run inspect sql/core/05_Cron/Crons.sql.tmpl --json + * noorm run inspect db/sql/users/001_create.sql.tmpl + * noorm run inspect db/sql/core/05_Cron/Crons.sql.tmpl --json * ``` */ import { stat } from 'node:fs/promises'; @@ -272,8 +272,8 @@ const inspectCommand = defineCommand({ }); (inspectCommand as typeof inspectCommand & { examples: string[] }).examples = [ - 'noorm run inspect sql/users/001_create.sql.tmpl', - 'noorm run inspect sql/core/05_Cron/Crons.sql.tmpl --json', + 'noorm run inspect db/sql/users/001_create.sql.tmpl', + 'noorm run inspect db/sql/core/05_Cron/Crons.sql.tmpl --json', ]; export default inspectCommand; diff --git a/src/cli/run/preview.ts b/src/cli/run/preview.ts index 24bf425f..2c7ecf61 100644 --- a/src/cli/run/preview.ts +++ b/src/cli/run/preview.ts @@ -6,9 +6,9 @@ * * @example * ```bash - * noorm run preview sql/users/001_create.sql.tmpl - * noorm run preview sql/core/05_Cron/Crons.sql.tmpl --json - * noorm run preview sql/migrations/002.sql.tmpl > rendered.sql + * noorm run preview db/sql/users/001_create.sql.tmpl + * noorm run preview db/sql/core/05_Cron/Crons.sql.tmpl --json + * noorm run preview db/sql/migrations/002.sql.tmpl > rendered.sql * ``` */ import { stat } from 'node:fs/promises'; @@ -147,9 +147,9 @@ const previewCommand = defineCommand({ }); (previewCommand as typeof previewCommand & { examples: string[] }).examples = [ - 'noorm run preview sql/users/001_create.sql.tmpl', - 'noorm run preview sql/core/05_Cron/Crons.sql.tmpl --json', - 'noorm run preview sql/migrations/002.sql.tmpl > rendered.sql', + 'noorm run preview db/sql/users/001_create.sql.tmpl', + 'noorm run preview db/sql/core/05_Cron/Crons.sql.tmpl --json', + 'noorm run preview db/sql/migrations/002.sql.tmpl > rendered.sql', ]; export default previewCommand; diff --git a/src/cli/settings/edit.ts b/src/cli/settings/edit.ts index 4b64a904..e3c11cc7 100644 --- a/src/cli/settings/edit.ts +++ b/src/cli/settings/edit.ts @@ -22,6 +22,7 @@ import type { } from '../../core/settings/types.js'; import { isYesMode, sharedArgs } from '../_utils.js'; import { EXIT } from '../_exit.js'; +import { DEFAULT_CHANGES_PATH, DEFAULT_SQL_PATH } from '../../core/settings/defaults.js'; type Manager = ReturnType; @@ -77,7 +78,7 @@ async function editPaths(manager: Manager): Promise { const sql = await p.text({ message: 'SQL files path', - initialValue: current.sql ?? './sql', + initialValue: current.sql ?? DEFAULT_SQL_PATH, validate: (v) => { if (v && v.trim()) return undefined; @@ -91,7 +92,7 @@ async function editPaths(manager: Manager): Promise { const changes = await p.text({ message: 'Changes path', - initialValue: current.changes ?? './changes', + initialValue: current.changes ?? DEFAULT_CHANGES_PATH, validate: (v) => { if (v && v.trim()) return undefined; diff --git a/src/cli/update.ts b/src/cli/update.ts index 36ef4d38..06f455ac 100644 --- a/src/cli/update.ts +++ b/src/cli/update.ts @@ -4,12 +4,14 @@ * Downloads the platform-appropriate binary from GitHub releases * and replaces the running binary in-place. */ +import * as p from '@clack/prompts'; import { defineCommand } from 'citty'; import { attempt } from '@logosdx/utils'; import { observer } from '../core/observer.js'; import { checkForUpdate, getCurrentVersion } from '../core/update/checker.js'; import { installUpdate } from '../core/update/updater.js'; +import { SKILL_COMMAND, canPromptForSkill, installSkill } from '../core/update/skill.js'; import { isInsecureMode, outputError, outputResult, sharedArgs } from './_utils.js'; import { EXIT } from './_exit.js'; @@ -20,6 +22,57 @@ function toMb(bytes: number): string { } +/** + * Ask whether to install or update the noorm agent skill, once the binary is + * updated. The update already succeeded, so nothing here changes the exit code: + * a declined prompt or a failed install only prints how to do it by hand. + */ +export async function offerSkill( + json: boolean | undefined, + interactive = canPromptForSkill({ stdinTty: process.stdin.isTTY, stdoutTty: process.stdout.isTTY }), +): Promise { + + if (json) return; + + if (!interactive) { + + process.stdout.write(`Agent skill: run \`${SKILL_COMMAND}\` to install or update it.\n`); + + return; + + } + + const answer = await p.confirm({ + message: `Install or update the noorm agent skill? (${SKILL_COMMAND}, run in ${process.cwd()})`, + initialValue: true, + }); + + if (p.isCancel(answer) || !answer) { + + process.stdout.write(`Skipped. Run \`${SKILL_COMMAND}\` any time.\n`); + + return; + + } + + const [code, spawnErr] = await attempt(() => installSkill()); + + if (spawnErr) { + + process.stderr.write(`Could not run npx (${spawnErr.message}). Install Node.js, then run: ${SKILL_COMMAND}\n`); + + return; + + } + + if (code !== 0) { + + process.stderr.write(`Skill install exited with code ${code}. Re-run: ${SKILL_COMMAND}\n`); + + } + +} + const updateCommand = defineCommand({ meta: { name: 'update', @@ -181,6 +234,8 @@ const updateCommand = defineCommand({ process.stdout.write(`Updated to ${result.newVersion}. Restart noorm to use the new version.\n`); + await offerSkill(args.json); + } else if (!args.json) { diff --git a/src/core/change/executor.ts b/src/core/change/executor.ts index 86d439b7..21c5f9fd 100644 --- a/src/core/change/executor.ts +++ b/src/core/change/executor.ts @@ -23,7 +23,6 @@ import path from 'node:path'; import { readFile, writeFile as fsWriteFile, mkdir } from 'node:fs/promises'; import type { Kysely } from 'kysely'; -import { sql } from 'kysely'; import { attempt, attemptSync } from '@logosdx/utils'; @@ -35,6 +34,7 @@ import type { Permission } from '../policy/index.js'; import type { Dialect } from '../connection/types.js'; import { computeChecksum, computeCombinedChecksum } from '../runner/checksum.js'; import { StatementWatcher } from '../runner/statement-watcher.js'; +import { executeSqlBody } from '../runner/mssql-batches.js'; import { getSqlErrorMessage } from '../shared/index.js'; import type { NoormDatabase } from '../shared/index.js'; import { getLockManager } from '../lock/index.js'; @@ -724,17 +724,18 @@ async function runFileBatch( } // Execute SQL - const [, execErr] = await attempt(() => - watcher.run(file.path, executor, (conn) => sql.raw(sqlContent).execute(conn)), + const [execMessage, execErr] = await attempt(() => + watcher.run(file.path, executor, (conn) => executeSqlBody({ db: conn, dialect: context.dialect }, sqlContent)), ); const durationMs = performance.now() - fileStart; - if (execErr) { + // executeSqlBody reports a failed statement as a message, not a throw. + // getSqlErrorMessage preserves TDS diagnostics (line numbers, error codes, procedure names). + const errorMessage = execErr ? getSqlErrorMessage(execErr) : execMessage; + + if (errorMessage) { - // Capture error info FIRST — use getSqlErrorMessage to preserve - // TDS diagnostics (line numbers, error codes, procedure names) - const errorMessage = getSqlErrorMessage(execErr); failed = true; failedFile = file.path; failureError = errorMessage; diff --git a/src/core/change/manager.ts b/src/core/change/manager.ts index 2acc9739..0cc87339 100644 --- a/src/core/change/manager.ts +++ b/src/core/change/manager.ts @@ -28,7 +28,8 @@ import { stat } from 'node:fs/promises'; import { attempt } from '@logosdx/utils'; -import { parseChange, discoverChanges } from './parser.js'; +import { failureListItem, parseChange, scanChanges } from './parser.js'; +import type { ChangeParseFailure } from './parser.js'; import { deleteChange as deleteFromDisk } from './scaffold.js'; import { ChangeHistory } from './history.js'; import { executeChange, revertChange } from './executor.js'; @@ -99,14 +100,35 @@ export class ChangeManager { * List all changes with status. * * Merges changes from disk with status from database. - * Includes orphaned changes (in DB but not on disk). + * Includes orphaned changes (in DB but not on disk), and folders on disk + * that cannot be parsed: `failed` with the parse error when they never + * ran, otherwise their recorded status. * * @returns Array of changes with merged status */ async list(): Promise { + const { items } = await this.#scan(); + + return items; + + } + + /** + * Merge disk and database state, and report the folders that cannot be read. + * + * An unreadable folder that a batch would run (never run, or `reverted` or + * `stale`) is pending work: the batch fails at its position rather than + * skipping it and running later changes out of order. `unreadable` holds the + * applied ones; they keep their status and only warrant a warning. + */ + async #scan(): Promise<{ + items: ChangeListItem[]; + unreadable: ChangeParseFailure[]; + }> { + // Discover changes from disk - const diskChanges = await discoverChanges( + const { changes: diskChanges, failures } = await scanChanges( this.#context.changesDir, this.#context.sqlDir, ); @@ -115,7 +137,7 @@ export class ChangeManager { const statuses = await this.#history.getAllStatuses(); // Create set of disk names for orphan detection - const diskNames = new Set(diskChanges.map((cs) => cs.name)); + const diskNames = new Set([...diskChanges, ...failures].map((cs) => cs.name)); // Get orphaned changes const orphanedNames = await this.#history.getOrphaned(diskNames); @@ -143,6 +165,25 @@ export class ChangeManager { } + // Add folders that are on disk but cannot be parsed + const unreadable: ChangeParseFailure[] = []; + + for (const failure of failures) { + + const status = statuses.get(failure.name); + + const item = failureListItem(failure, status); + + if (!isPendingChange(item)) { + + unreadable.push(failure); + + } + + result.push(item); + + } + // Add orphaned changes for (const name of orphanedNames) { @@ -170,7 +211,10 @@ export class ChangeManager { } // Sort by name - return result.sort((a, b) => a.name.localeCompare(b.name)); + return { + items: result.sort((a, b) => a.name.localeCompare(b.name)), + unreadable, + }; } @@ -255,11 +299,17 @@ export class ChangeManager { const opts = { ...DEFAULT_BATCH, ...options }; const start = performance.now(); - const warnings = await this.#collectWarnings(); + const { items, unreadable } = await this.#scan(); - // Get pending changes - const list = await this.list(); - const pending = list + const messages = [ + ...(await this.#collectWarnings() ?? []), + ...unreadable.map((f) => `Change '${f.name}' is applied but its files can no longer be read: ${f.message}`), + ]; + const warnings = messages.length > 0 ? messages : undefined; + + // An unreadable pending change keeps its place in the sequence: + // loading it fails, which stops the batch there. + const pending = items .filter(isPendingChange) .slice(0, count); @@ -361,11 +411,7 @@ export class ChangeManager { */ async ff(options: BatchChangeOptions = {}): Promise { - // Get count of pending - const list = await this.list(); - const pendingCount = list.filter(isPendingChange).length; - - return this.next(pendingCount, options); + return this.next(Number.POSITIVE_INFINITY, options); } @@ -633,6 +679,12 @@ export class ChangeManager { if (err) { + // A folder that is there but will not parse needs its own error: + // "not found" sends the user looking for a file that exists. + const [onDisk] = await attempt(() => stat(changePath)); + + if (onDisk) throw err; + // Check if it exists in DB (orphaned) const status = await this.#history.getStatus(name); diff --git a/src/core/change/parser.ts b/src/core/change/parser.ts index 77633fd3..a3e5b70f 100644 --- a/src/core/change/parser.ts +++ b/src/core/change/parser.ts @@ -25,7 +25,7 @@ import { constants as fsConstants } from 'node:fs'; import { attempt } from '@logosdx/utils'; import { observer } from '../observer.js'; -import type { Change, ChangeFile, ChangeFileType } from './types.js'; +import type { Change, ChangeFile, ChangeFileType, ChangeListItem, ChangeStatus } from './types.js'; import { ChangeValidationError, ChangeNotFoundError, @@ -137,9 +137,49 @@ export async function parseChange(folderPath: string, sqlDir?: string): Promise< // Discover All Changes // ───────────────────────────────────────────────────────────── +/** A change folder that exists on disk but could not be parsed. */ +export interface ChangeParseFailure { + name: string; + path: string; + message: string; +} + +/** + * The list entry for a folder that could not be parsed: its recorded status + * when it has history, otherwise `failed` with the parse error, which is what + * marks it as outstanding work. + * + * @example + * const item = failureListItem(failure, statuses.get(failure.name)) + */ +export function failureListItem(failure: ChangeParseFailure, status?: ChangeStatus): ChangeListItem { + + const { date, description } = parseName(failure.name); + + return { + name: failure.name, + path: failure.path, + date, + description, + status: status?.status ?? 'failed', + appliedAt: status?.appliedAt ?? null, + appliedBy: status?.appliedBy ?? null, + revertedAt: status?.revertedAt ?? null, + errorMessage: status ? status.errorMessage : failure.message, + appliedHistoryId: status?.appliedHistoryId ?? null, + isNew: !status, + orphaned: false, + }; + +} + /** * Discover all changes in a directory. * + * Folders that fail to parse are dropped here. Callers that must not lose + * track of them (a change that never ran would silently never run) use + * `scanChanges`. + * * @param changesDir - Directory containing change folders * @param sqlDir - Schema directory for resolving .txt references * @returns Array of parsed changes, sorted by name @@ -158,12 +198,30 @@ export async function discoverChanges( sqlDir?: string, ): Promise { + const { changes } = await scanChanges(changesDir, sqlDir); + + return changes; + +} + +/** + * Discover all changes in a directory, and report the folders that could not + * be parsed instead of dropping them. + * + * @example + * const { changes, failures } = await scanChanges('/project/db/changes', '/project/db/sql') + */ +export async function scanChanges( + changesDir: string, + sqlDir?: string, +): Promise<{ changes: Change[]; failures: ChangeParseFailure[] }> { + // Check directory exists const [exists] = await attempt(() => stat(changesDir)); if (!exists) { - return []; + return { changes: [], failures: [] }; } @@ -178,7 +236,7 @@ export async function discoverChanges( context: { changesDir, operation: 'discover' }, }); - return []; + return { changes: [], failures: [] }; } @@ -187,11 +245,14 @@ export async function discoverChanges( // Parse each change const changes: Change[] = []; + const failures: ChangeParseFailure[] = []; for (const folder of folders) { const folderPath = path.join(changesDir, folder.name); + if (!(await looksLikeChange(folderPath))) continue; + const [change, parseErr] = await attempt(() => parseChange(folderPath, sqlDir)); if (parseErr) { @@ -203,6 +264,8 @@ export async function discoverChanges( context: { folder: folder.name, operation: 'parse' }, }); + failures.push({ name: folder.name, path: folderPath, message: parseErr.message }); + continue; } @@ -212,7 +275,10 @@ export async function discoverChanges( } // Sort by name (date prefix ensures chronological order) - return changes.sort((a, b) => a.name.localeCompare(b.name)); + return { + changes: changes.sort((a, b) => a.name.localeCompare(b.name)), + failures, + }; } @@ -381,6 +447,24 @@ function parseName(name: string): { date: Date | null; description: string } { } +/** + * A folder with neither `change/` nor `revert/` is not a change attempt: + * shared `$.include()` fragments and archives live beside real changes. + */ +async function looksLikeChange(folderPath: string): Promise { + + for (const subfolder of ['change', 'revert']) { + + const [entry] = await attempt(() => stat(path.join(folderPath, subfolder))); + + if (entry?.isDirectory()) return true; + + } + + return false; + +} + /** * Scan a folder for SQL and manifest files. */ diff --git a/src/core/change/types.ts b/src/core/change/types.ts index a00a26a9..dc71caac 100644 --- a/src/core/change/types.ts +++ b/src/core/change/types.ts @@ -253,6 +253,9 @@ export interface ChangeListItem { * `stale` is set by teardown on changes that did apply but whose objects * no longer exist, so it is pending work in the only sense that matters. * + * A `failed` item that is also new never ran and could not be parsed. A batch + * has to stop at it, not step over it, so it counts as outstanding work. + * * @example * const outstanding = list.filter(isPendingChange); */ @@ -262,6 +265,7 @@ export function isPendingChange(item: ChangeListItem): boolean { item.status === 'pending' || item.status === 'reverted' || item.status === 'stale' + || (item.status === 'failed' && item.isNew) ); } diff --git a/src/core/project-init.ts b/src/core/project-init.ts index 2a97bdd9..9087dbb6 100644 --- a/src/core/project-init.ts +++ b/src/core/project-init.ts @@ -32,12 +32,10 @@ import { } from './identity/index.js'; import { SettingsManager } from './settings/manager.js'; import { - DEFAULT_PATH_CONFIG, - INIT_CHANGES_PATH, - INIT_DB_DIR, - INIT_PATH_CONFIG, - INIT_SQL_PATH, - isInitLayout, + DEFAULT_CHANGES_PATH, + DEFAULT_DB_DIR, + DEFAULT_SQL_PATH, + isDefaultLayout, } from './settings/defaults.js'; import type { PathConfig } from './settings/types.js'; import { StateManager, getStateManager } from './state/index.js'; @@ -74,37 +72,17 @@ export interface ProjectInitResult { createdFiles: string[]; } -/** - * Paths a project's settings.yml carries after init. A project that already has - * a settings.yml keeps what it resolves to, so `--force` never repoints it at - * a layout it does not have; only a new project gets the `db/` layout. - * - * @example - * const paths = await resolveInitPaths('/project'); // INIT_PATH_CONFIG when new - */ -export async function resolveInitPaths(projectRoot: string): Promise { - - const manager = new SettingsManager(projectRoot); - - if (!(await manager.exists())) return INIT_PATH_CONFIG; - - const [settings] = await attempt(() => manager.load()); - - return settings?.paths ?? DEFAULT_PATH_CONFIG; - -} - /** * Creates `db/sql/`, `db/changes/`, and the agent steering files beside them, - * for projects on the init layout. The steering files hold the user's edits, + * for projects on the default layout. The steering files hold the user's edits, * so they are written only when absent. * * @example - * scaffoldDbLayout('/project', INIT_PATH_CONFIG); // ['db/sql/.gitkeep', ..., 'db/CLAUDE.md'] + * scaffoldDbLayout('/project', DEFAULT_PATH_CONFIG); // ['db/sql/.gitkeep', ..., 'db/CLAUDE.md'] */ export function scaffoldDbLayout(projectRoot: string, paths: PathConfig): string[] { - if (!isInitLayout(paths)) return []; + if (!isDefaultLayout(paths)) return []; const created: string[] = []; const agentFiles = { @@ -112,7 +90,7 @@ export function scaffoldDbLayout(projectRoot: string, paths: PathConfig): string 'CLAUDE.md': CLAUDE_TEMPLATE, }; - for (const dir of [INIT_SQL_PATH, INIT_CHANGES_PATH]) { + for (const dir of [DEFAULT_SQL_PATH, DEFAULT_CHANGES_PATH]) { const relativeDir = posix.normalize(dir); @@ -124,12 +102,12 @@ export function scaffoldDbLayout(projectRoot: string, paths: PathConfig): string for (const [name, body] of Object.entries(agentFiles)) { - const filePath = join(projectRoot, INIT_DB_DIR, name); + const filePath = join(projectRoot, DEFAULT_DB_DIR, name); if (existsSync(filePath)) continue; writeFileSync(filePath, body); - created.push(`${INIT_DB_DIR}/${name}`); + created.push(`${DEFAULT_DB_DIR}/${name}`); } @@ -152,9 +130,6 @@ export async function performProjectInit( const gitignorePath = join(projectRoot, '.gitignore'); const created: string[] = []; - const paths = await resolveInitPaths(projectRoot); - - created.push(...scaffoldDbLayout(projectRoot, paths)); mkdirSync(noormPath, { recursive: true }); mkdirSync(statePath, { recursive: true }); @@ -205,7 +180,9 @@ export async function performProjectInit( const settingsManager = new SettingsManager(projectRoot); await settingsManager.init(force); - await settingsManager.setPaths(paths); + + const scaffolded = scaffoldDbLayout(projectRoot, settingsManager.getPaths()); + created.push('.noorm/settings.yml'); const stateManager = new StateManager(projectRoot, { privateKey }); @@ -240,7 +217,7 @@ export async function performProjectInit( return { success: true, createdIdentity, - createdFiles: created, + createdFiles: [...scaffolded, ...created], }; } diff --git a/src/core/runner/mssql-batches.ts b/src/core/runner/mssql-batches.ts index 19ccc8f0..3449dd06 100644 --- a/src/core/runner/mssql-batches.ts +++ b/src/core/runner/mssql-batches.ts @@ -19,6 +19,9 @@ import { getSqlErrorMessage } from '../shared/index.js'; import { splitSqliteStatements } from './sqlite-statements.js'; import type { RunContext } from './types.js'; +/** The two fields body execution reads; a change context supplies them too. */ +type SqlBodyContext = Pick; + /** * Split MSSQL SQL content on the `GO` batch separator. @@ -118,7 +121,7 @@ function isCommentOnly(batch: string): boolean { * } */ export async function executeSqlBody( - context: RunContext, + context: SqlBodyContext, sqlContent: string, ): Promise { @@ -154,7 +157,7 @@ export async function executeSqlBody( * usually the whole file. */ async function executeFragments( - context: RunContext, + context: SqlBodyContext, fragments: string[], label: 'batch' | 'statement', ): Promise { diff --git a/src/core/runner/runner.ts b/src/core/runner/runner.ts index 3ab691cd..45034466 100644 --- a/src/core/runner/runner.ts +++ b/src/core/runner/runner.ts @@ -1215,7 +1215,7 @@ async function executeDryRun(context: RunContext, files: string[]): Promise this.load()) : [undefined]; + const keptPaths = previous ? this.#document?.paths : undefined; + this.#document = createDefaultSettings(); + + if (keptPaths) this.#document.paths = clone(keptPaths); + this.#refreshResolved(); this.#loaded = true; diff --git a/src/core/update/skill.ts b/src/core/update/skill.ts new file mode 100644 index 00000000..a351a02c --- /dev/null +++ b/src/core/update/skill.ts @@ -0,0 +1,54 @@ +/** + * Install or update the noorm agent skill. + * + * The skill ships from this repo's `skills/` directory and is installed with + * the `skills` CLI, which asks which coding agents to install it into. + */ +import { spawn } from 'node:child_process'; + +export const SKILL_SOURCE = 'noormdev/noorm/skills'; + +export const SKILL_COMMAND = `npx skills add ${SKILL_SOURCE}`; + +/** + * Whether `noorm update` may ask about the skill: a person is at the terminal. + * + * @example + * if (canPromptForSkill({ stdinTty: process.stdin.isTTY, stdoutTty: process.stdout.isTTY })) { ... } + */ +export function canPromptForSkill(opts: { + stdinTty: boolean | undefined; + stdoutTty: boolean | undefined; +}): boolean { + + return Boolean(opts.stdinTty) && Boolean(opts.stdoutTty); + +} + +/** + * Run `npx skills add noormdev/noorm/skills` attached to the terminal, so the + * skills CLI can ask which agents to install into. Running it again replaces + * an installed copy. Rejects only when `npx` cannot be started. + * + * @example + * const [code, err] = await attempt(() => installSkill()); + */ +export function installSkill(): Promise { + + const isWindows = process.platform === 'win32'; + + return new Promise((resolve, reject) => { + + const child = spawn('npx', ['skills', 'add', SKILL_SOURCE], { + stdio: 'inherit', + // npx is npx.cmd on Windows, which Node refuses to spawn without a shell + shell: isWindows, + }); + + child.on('error', reject); + // A signal (Ctrl-C at the skills prompt) leaves no exit code; that is a failure + child.on('exit', (code) => resolve(code ?? 1)); + + }); + +} diff --git a/src/rpc/commands/run.ts b/src/rpc/commands/run.ts index 27ba5ffb..fa5f8de7 100644 --- a/src/rpc/commands/run.ts +++ b/src/rpc/commands/run.ts @@ -36,7 +36,7 @@ const runFileCommand: RpcCommand = { name: 'run_file', description: 'Execute a single SQL file against the database.', examples: [ - { description: 'run a file', input: { path: 'sql/procedures/usp_get_user.sql' } }, + { description: 'run a file', input: { path: 'db/sql/procedures/usp_get_user.sql' } }, ], inputSchema: runFileSchema, permission: 'run:file', diff --git a/src/sdk/namespaces/changes.ts b/src/sdk/namespaces/changes.ts index c07a38a2..5cf906e9 100644 --- a/src/sdk/namespaces/changes.ts +++ b/src/sdk/namespaces/changes.ts @@ -41,6 +41,7 @@ import { checkProtectedConfig } from '../guards.js'; import type { ContextState } from '../state.js'; import { requireConnection } from '../state.js'; +import { DEFAULT_CHANGES_PATH, DEFAULT_SQL_PATH } from '../../core/settings/defaults.js'; // ───────────────────────────────────────────────────────────── // ChangesNamespace @@ -426,7 +427,7 @@ export class ChangesNamespace { return path.join( this.#state.projectRoot, - this.#state.settings.paths?.changes ?? 'changes', + this.#state.settings.paths?.changes ?? DEFAULT_CHANGES_PATH, ); } @@ -435,7 +436,7 @@ export class ChangesNamespace { return path.join( this.#state.projectRoot, - this.#state.settings.paths?.sql ?? 'sql', + this.#state.settings.paths?.sql ?? DEFAULT_SQL_PATH, ); } diff --git a/src/sdk/namespaces/run.ts b/src/sdk/namespaces/run.ts index 17d43575..53ac11e3 100644 --- a/src/sdk/namespaces/run.ts +++ b/src/sdk/namespaces/run.ts @@ -38,6 +38,7 @@ import { checkProtectedConfig } from '../guards.js'; import type { ContextState } from '../state.js'; import { requireConnection } from '../state.js'; import type { BuildOptions } from '../types.js'; +import { DEFAULT_SQL_PATH } from '../../core/settings/defaults.js'; // ───────────────────────────────────────────────────────────── // RunNamespace @@ -62,13 +63,13 @@ export class RunNamespace { * * @example * ```typescript - * const files = await ctx.noorm.run.discover('sql/') + * const files = await ctx.noorm.run.discover('db/sql/') * ``` */ async discover(dirpath?: string): Promise { const absolutePath = this.#resolvePath( - dirpath ?? this.#state.settings.paths?.sql ?? 'sql', + dirpath ?? this.#state.settings.paths?.sql ?? DEFAULT_SQL_PATH, ); return coreDiscoverFiles(absolutePath); @@ -84,7 +85,7 @@ export class RunNamespace { * * @example * ```typescript - * const results = await ctx.noorm.run.preview(['sql/001.sql', 'sql/002.sql']) + * const results = await ctx.noorm.run.preview(['db/sql/001.sql', 'db/sql/002.sql']) * ``` */ async preview( @@ -186,7 +187,7 @@ export class RunNamespace { const context = await this.#createRunContext(); const sqlPath = path.join( this.#state.projectRoot, - this.#state.settings.paths?.sql ?? 'sql', + this.#state.settings.paths?.sql ?? DEFAULT_SQL_PATH, ); const configForMatch = { @@ -227,7 +228,7 @@ export class RunNamespace { // Reported on the result rather than thrown: an unmatched entry is a // misconfiguration, not an execution failure, and a build that legitimately // matches nothing must keep succeeding. Surfacing it is what stops the - // `sql/`-prefix mistake from reading as a clean build. + // `db/sql/`-prefix mistake from reading as a clean build. // // Both directions, because the exclude side is the one that fails // dangerously: it fences off nothing and the held-back files run. diff --git a/src/tui/screens/change/ChangeEditScreen.tsx b/src/tui/screens/change/ChangeEditScreen.tsx index 1ecfaf9c..0a6b4439 100644 --- a/src/tui/screens/change/ChangeEditScreen.tsx +++ b/src/tui/screens/change/ChangeEditScreen.tsx @@ -23,7 +23,7 @@ import { useAppContext } from '../../app-context.js'; import { Panel, Spinner, StatusMessage, MissingParamPanel } from '../../components/index.js'; import { useAsyncEffect } from '../../hooks/index.js'; import { getErrorMessage, resolveChangesDir, resolveSqlDir } from '../../utils/index.js'; -import { discoverChanges } from '../../../core/change/parser.js'; +import { scanChanges } from '../../../core/change/parser.js'; /** * Edit steps. @@ -63,12 +63,12 @@ export function ChangeEditScreen({ params }: ScreenProps): ReactElement { const [_, err] = await attempt(async () => { // Find the change - const changes = await discoverChanges( + const { changes, failures } = await scanChanges( resolveChangesDir(projectRoot, settings), resolveSqlDir(projectRoot, settings), ); - const change = changes.find((cs) => cs.name === changeName); + const change = [...changes, ...failures].find((cs) => cs.name === changeName); if (!change) { diff --git a/src/tui/screens/change/ChangeFFScreen.tsx b/src/tui/screens/change/ChangeFFScreen.tsx index 78f625a0..4fc29ddd 100644 --- a/src/tui/screens/change/ChangeFFScreen.tsx +++ b/src/tui/screens/change/ChangeFFScreen.tsx @@ -78,13 +78,13 @@ export function ChangeFFScreen({ params: _params }: ScreenProps): ReactElement { const [_, err] = await attempt(async () => { - const { changes, statuses } = await loadChangesWithStatus( + const { changes, statuses, failures } = await loadChangesWithStatus( activeConfig, activeConfigName ?? '', settings, projectRoot, ); if (isCancelled()) return; - const pending = buildPendingChangeList(changes, statuses); + const pending = buildPendingChangeList(changes, statuses, failures); // Validate all pending changes have actual content const emptyChanges: string[] = []; diff --git a/src/tui/screens/change/ChangeListScreen.tsx b/src/tui/screens/change/ChangeListScreen.tsx index 3f08a3a4..42f7bd76 100644 --- a/src/tui/screens/change/ChangeListScreen.tsx +++ b/src/tui/screens/change/ChangeListScreen.tsx @@ -86,13 +86,13 @@ export function ChangeListScreen({ params: _params }: ScreenProps): ReactElement const [_, err] = await attempt(async () => { - const { changes: diskChanges, statuses } = await loadChangesWithStatus( + const { changes: diskChanges, statuses, failures } = await loadChangesWithStatus( activeConfig, activeConfigName ?? '', settings, projectRoot, ); if (isCancelled()) return; - const merged = buildMergedChangeList(diskChanges, statuses); + const merged = buildMergedChangeList(diskChanges, statuses, failures); setChanges(merged); diff --git a/src/tui/screens/change/ChangeNextScreen.tsx b/src/tui/screens/change/ChangeNextScreen.tsx index ec556b6c..e215be4f 100644 --- a/src/tui/screens/change/ChangeNextScreen.tsx +++ b/src/tui/screens/change/ChangeNextScreen.tsx @@ -85,13 +85,13 @@ export function ChangeNextScreen({ params }: ScreenProps): ReactElement { const [_, err] = await attempt(async () => { - const { changes, statuses } = await loadChangesWithStatus( + const { changes, statuses, failures } = await loadChangesWithStatus( activeConfig, activeConfigName ?? '', settings, projectRoot, ); if (isCancelled()) return; - const pending = buildPendingChangeList(changes, statuses); + const pending = buildPendingChangeList(changes, statuses, failures); setPendingChanges(pending); diff --git a/src/tui/screens/home.tsx b/src/tui/screens/home.tsx index df8d42c3..155e14f4 100644 --- a/src/tui/screens/home.tsx +++ b/src/tui/screens/home.tsx @@ -32,7 +32,8 @@ import { CURRENT_VERSIONS } from '../../core/version/types.js'; import { getFullVersionRecord, type FullVersionRecord } from '../../core/version/schema/index.js'; import { fetchOverview, type ExploreOverview } from '../../core/explore/index.js'; import { ChangeHistory } from '../../core/change/history.js'; -import { discoverChanges } from '../../core/change/parser.js'; +import { failureListItem, scanChanges } from '../../core/change/parser.js'; +import { isPendingChange } from '../../core/change/types.js'; import { getLockManager } from '../../core/lock/manager.js'; import type { UnifiedHistoryRecord } from '../../core/change/types.js'; import type { LockStatus } from '../../core/lock/types.js'; @@ -179,7 +180,7 @@ export function HomeScreen({ params: _params }: ScreenProps): ReactElement { const changeHistory = new ChangeHistory(db, activeConfigName ?? '', connDialect); // Discover changes from disk - const diskChanges = await discoverChanges( + const { changes: diskChanges, failures } = await scanChanges( resolveChangesDir(projectRoot, settings), resolveSqlDir(projectRoot, settings), ); @@ -200,6 +201,13 @@ export function HomeScreen({ params: _params }: ScreenProps): ReactElement { } + // An unreadable change that a batch would run is outstanding work too + for (const failure of failures) { + + if (isPendingChange(failureListItem(failure, statuses.get(failure.name)))) pendingCount++; + + } + // Get recent activity (all operation types) const history = await changeHistory.getUnifiedHistory(undefined, 5); diff --git a/src/tui/screens/init/InitScreen.tsx b/src/tui/screens/init/InitScreen.tsx index baea3a86..1e372bc0 100644 --- a/src/tui/screens/init/InitScreen.tsx +++ b/src/tui/screens/init/InitScreen.tsx @@ -8,7 +8,7 @@ * 4. Optionally add first config * * Creates: - * - db/sql/, db/changes/, db/AGENTS.md, db/CLAUDE.md (new projects only) + * - db/sql/, db/changes/, db/AGENTS.md, db/CLAUDE.md (default layout only) * - .noorm/settings.yml * - .noorm/state/state.enc * - Keypair in ~/.noorm/ @@ -39,9 +39,9 @@ import { loadIdentityMetadata, } from '../../../core/identity/index.js'; import { SettingsManager } from '../../../core/settings/manager.js'; -import { INIT_PATH_CONFIG } from '../../../core/settings/defaults.js'; +import { DEFAULT_PATH_CONFIG } from '../../../core/settings/defaults.js'; import type { PathConfig } from '../../../core/settings/types.js'; -import { resolveInitPaths, scaffoldDbLayout } from '../../../core/project-init.js'; +import { scaffoldDbLayout } from '../../../core/project-init.js'; import { StateManager, getStateManager } from '../../../core/state/index.js'; import { observer } from '../../../core/observer.js'; @@ -105,8 +105,7 @@ export function InitScreen({ params }: ScreenProps): ReactElement { // Project root const projectRoot = process.cwd(); - // Paths the project will carry: its own when settings.yml exists, else the db/ layout - const [initPaths, setInitPaths] = useState(INIT_PATH_CONFIG); + const [initPaths, setInitPaths] = useState(DEFAULT_PATH_CONFIG); // Check existing state on mount useEffect(() => { @@ -117,7 +116,9 @@ export function InitScreen({ params }: ScreenProps): ReactElement { const noormDir = join(projectRoot, '.noorm'); const alreadyInitialized = existsSync(noormDir); - setInitPaths(await resolveInitPaths(projectRoot)); + const [existing] = await attempt(() => new SettingsManager(projectRoot).load()); + + setInitPaths(existing?.paths ?? DEFAULT_PATH_CONFIG); // Check if identity key files exist const keysExist = await hasKeyFiles(); @@ -286,7 +287,6 @@ export function InitScreen({ params }: ScreenProps): ReactElement { const settingsManager = new SettingsManager(projectRoot); await settingsManager.init(force); - await settingsManager.setPaths(initPaths); updateItem(settingsIndex, { status: 'success' }); diff --git a/src/tui/screens/init/ProjectSetup.tsx b/src/tui/screens/init/ProjectSetup.tsx index ef4b49dc..c7397c20 100644 --- a/src/tui/screens/init/ProjectSetup.tsx +++ b/src/tui/screens/init/ProjectSetup.tsx @@ -12,7 +12,7 @@ import { Box, Text } from 'ink'; import { Panel } from '../../components/layout/index.js'; import { SelectList } from '../../components/lists/index.js'; import type { SelectListItem } from '../../components/lists/index.js'; -import { INIT_CHANGES_PATH, INIT_DB_DIR, INIT_SQL_PATH, isInitLayout } from '../../../core/settings/defaults.js'; +import { DEFAULT_CHANGES_PATH, DEFAULT_DB_DIR, DEFAULT_SQL_PATH, isDefaultLayout } from '../../../core/settings/defaults.js'; /** * Props for ProjectSetup. @@ -40,14 +40,14 @@ export interface ProjectSetupProps { * Shows what will be created and offers to add first config. */ export function ProjectSetup({ - sqlPath = INIT_SQL_PATH, - changesPath = INIT_CHANGES_PATH, + sqlPath = DEFAULT_SQL_PATH, + changesPath = DEFAULT_CHANGES_PATH, onAddConfig, onSkipConfig, onCancel: _onCancel, }: ProjectSetupProps): ReactElement { - const scaffoldsDb = isInitLayout({ sql: sqlPath, changes: changesPath }); + const scaffoldsDb = isDefaultLayout({ sql: sqlPath, changes: changesPath }); // Note: No useFocusScope here - let the SelectList manage its own focus. // Parent focus scopes interfere with child focus due to React effect order. @@ -126,11 +126,11 @@ export function ProjectSetup({ • - ./{INIT_DB_DIR}/AGENTS.md + ./{DEFAULT_DB_DIR}/AGENTS.md • - ./{INIT_DB_DIR}/CLAUDE.md + ./{DEFAULT_DB_DIR}/CLAUDE.md )} diff --git a/src/tui/screens/run/RunBuildScreen.tsx b/src/tui/screens/run/RunBuildScreen.tsx index f921e37f..bc7ae651 100644 --- a/src/tui/screens/run/RunBuildScreen.tsx +++ b/src/tui/screens/run/RunBuildScreen.tsx @@ -40,6 +40,7 @@ import { runCancelMessage, } from '../../utils/index.js'; import { attempt } from '@logosdx/utils'; +import { DEFAULT_SQL_PATH } from '../../../core/settings/defaults.js'; type Phase = 'loading' | 'confirm' | 'running' | 'complete' | 'error'; @@ -76,7 +77,7 @@ export function RunBuildScreen({ params: _params }: ScreenProps): ReactElement { // Get project root const projectRoot = process.cwd(); - const schema = settings.paths?.sql ?? 'sql'; + const schema = settings.paths?.sql ?? DEFAULT_SQL_PATH; setSqlPath(schema); // Get effective build paths diff --git a/src/tui/screens/run/RunDirScreen.tsx b/src/tui/screens/run/RunDirScreen.tsx index 7364b245..1b120d2a 100644 --- a/src/tui/screens/run/RunDirScreen.tsx +++ b/src/tui/screens/run/RunDirScreen.tsx @@ -28,6 +28,7 @@ import { useConnection } from '../../hooks/index.js'; import { attempt } from '@logosdx/utils'; import type { SelectListItem } from '../../components/index.js'; +import { DEFAULT_SQL_PATH } from '../../../core/settings/defaults.js'; type Phase = 'loading' | 'picker' | 'confirm' | 'checking' | 'rerun-confirm' | 'running' | 'complete' | 'error'; @@ -101,7 +102,7 @@ export function RunDirScreen({ params }: ScreenProps): ReactElement { const cancelledRef = useRef(false); const projectRoot = process.cwd(); - const sqlPath = settings?.paths?.sql ?? 'sql'; + const sqlPath = settings?.paths?.sql ?? DEFAULT_SQL_PATH; // Load files and extract directories on mount useAsyncEffect(async (isCancelled) => { diff --git a/src/tui/screens/run/RunExecScreen.tsx b/src/tui/screens/run/RunExecScreen.tsx index c74bd1cf..2955c6c5 100644 --- a/src/tui/screens/run/RunExecScreen.tsx +++ b/src/tui/screens/run/RunExecScreen.tsx @@ -46,6 +46,7 @@ import { runCancelMessage, } from '../../utils/index.js'; import { attempt } from '@logosdx/utils'; +import { DEFAULT_SQL_PATH } from '../../../core/settings/defaults.js'; type Phase = 'loading' | 'picker' | 'confirm' | 'running' | 'complete' | 'error'; @@ -78,7 +79,7 @@ export function RunExecScreen({ params: _params }: ScreenProps): ReactElement { setPhase('loading'); - const sqlPath = settings.paths?.sql ?? 'sql'; + const sqlPath = settings.paths?.sql ?? DEFAULT_SQL_PATH; const sqlFullPath = join(projectRoot, sqlPath); const [files, err] = await attempt(() => discoverFiles(sqlFullPath)); diff --git a/src/tui/screens/run/RunFileScreen.tsx b/src/tui/screens/run/RunFileScreen.tsx index dd665c6a..7a7d41ff 100644 --- a/src/tui/screens/run/RunFileScreen.tsx +++ b/src/tui/screens/run/RunFileScreen.tsx @@ -27,6 +27,7 @@ import { useConnection } from '../../hooks/index.js'; import { attempt } from '@logosdx/utils'; import type { SelectListItem } from '../../components/index.js'; +import { DEFAULT_SQL_PATH } from '../../../core/settings/defaults.js'; type Phase = 'loading' | 'picker' | 'confirm' | 'checking' | 'rerun-confirm' | 'running' | 'complete' | 'error'; @@ -65,7 +66,7 @@ export function RunFileScreen({ params }: ScreenProps): ReactElement { setPhase('loading'); - const sqlPath = settings.paths?.sql ?? 'sql'; + const sqlPath = settings.paths?.sql ?? DEFAULT_SQL_PATH; const sqlFullPath = join(projectRoot, sqlPath); const [files, err] = await attempt(() => discoverFiles(sqlFullPath)); @@ -381,7 +382,7 @@ export function RunFileScreen({ params }: ScreenProps): ReactElement { if (phase === 'picker') { const hasFiles = allFiles.length > 0; - const sqlPath = settings?.paths?.sql ?? 'sql'; + const sqlPath = settings?.paths?.sql ?? DEFAULT_SQL_PATH; return ( diff --git a/src/tui/screens/run/RunInspectScreen.tsx b/src/tui/screens/run/RunInspectScreen.tsx index 8481b617..d00dc074 100644 --- a/src/tui/screens/run/RunInspectScreen.tsx +++ b/src/tui/screens/run/RunInspectScreen.tsx @@ -39,6 +39,7 @@ import type { TemplateContext } from '../../../core/template/types.js'; import type { Dialect } from '../../../core/connection/types.js'; import type { NoormDatabase } from '../../../core/shared/index.js'; import type { StateManager } from '../../../core/state/index.js'; +import { DEFAULT_SQL_PATH } from '../../../core/settings/defaults.js'; type Phase = 'loading' | 'picker' | 'inspecting' | 'expanded' | 'preview' | 'error'; @@ -568,7 +569,7 @@ export function RunInspectScreen({ params }: ScreenProps): ReactElement { setPhase('loading'); - const sqlPath = settings.paths?.sql ?? 'sql'; + const sqlPath = settings.paths?.sql ?? DEFAULT_SQL_PATH; const sqlFullPath = join(projectRoot, sqlPath); const [files, err] = await attempt(() => discoverFiles(sqlFullPath)); @@ -855,7 +856,7 @@ export function RunInspectScreen({ params }: ScreenProps): ReactElement { if (phase === 'picker') { const hasFiles = allFiles.length > 0; - const sqlPath = settings?.paths?.sql ?? 'sql'; + const sqlPath = settings?.paths?.sql ?? DEFAULT_SQL_PATH; return ( diff --git a/src/tui/screens/run/RunListScreen.tsx b/src/tui/screens/run/RunListScreen.tsx index 4cf64567..f4404907 100644 --- a/src/tui/screens/run/RunListScreen.tsx +++ b/src/tui/screens/run/RunListScreen.tsx @@ -30,6 +30,7 @@ import { useFocusScope } from '../../focus.js'; import { useActiveConfig, useSettings, useGlobalModes } from '../../app-context.js'; import { Panel, Spinner } from '../../components/index.js'; import { getEffectiveBuildPaths } from '../../../core/settings/rules.js'; +import { DEFAULT_SQL_PATH } from '../../../core/settings/defaults.js'; /** * RunListScreen component. @@ -171,7 +172,7 @@ export function RunListScreen({ params: _params }: ScreenProps): ReactElement { } - const sqlPath = settings?.paths?.sql ?? 'sql'; + const sqlPath = settings?.paths?.sql ?? DEFAULT_SQL_PATH; return ( diff --git a/src/tui/screens/settings/SettingsBuildScreen.tsx b/src/tui/screens/settings/SettingsBuildScreen.tsx index 4b04d034..579956ba 100644 --- a/src/tui/screens/settings/SettingsBuildScreen.tsx +++ b/src/tui/screens/settings/SettingsBuildScreen.tsx @@ -20,7 +20,7 @@ import { useRouter } from '../../router.js'; import { useAppContext } from '../../app-context.js'; import { Panel, Form } from '../../components/index.js'; import { useSettingsOperation } from '../../hooks/index.js'; -import { DEFAULT_PATH_CONFIG } from '../../../core/settings/defaults.js'; +import { DEFAULT_SQL_PATH } from '../../../core/settings/defaults.js'; /** * Parse comma-separated string to array. @@ -72,11 +72,11 @@ export function SettingsBuildScreen({ params: _params }: ScreenProps): ReactElem // Get current SQL path const sqlPath = useMemo(() => { - if (!settingsManager) return DEFAULT_PATH_CONFIG.sql ?? './sql'; + if (!settingsManager) return DEFAULT_SQL_PATH; const paths = settingsManager.getPaths(); - return paths.sql ?? DEFAULT_PATH_CONFIG.sql ?? './sql'; + return paths.sql ?? DEFAULT_SQL_PATH; }, [settingsManager]); diff --git a/src/tui/screens/settings/SettingsListScreen.tsx b/src/tui/screens/settings/SettingsListScreen.tsx index 7089cd93..48dcace7 100644 --- a/src/tui/screens/settings/SettingsListScreen.tsx +++ b/src/tui/screens/settings/SettingsListScreen.tsx @@ -23,6 +23,7 @@ import { useRouter } from '../../router.js'; import { useFocusScope } from '../../focus.js'; import { useAppContext } from '../../app-context.js'; import { Panel, SelectList, type SelectListItem } from '../../components/index.js'; +import { DEFAULT_CHANGES_PATH, DEFAULT_SQL_PATH } from '../../../core/settings/defaults.js'; /** * Settings section item value. @@ -56,7 +57,7 @@ function pathsSectionSummary(settings: Settings | null): string { const paths = settings.paths ?? {}; - return `schema: ${paths.sql ?? './sql'}, changes: ${paths.changes ?? './changes'}`; + return `schema: ${paths.sql ?? DEFAULT_SQL_PATH}, changes: ${paths.changes ?? DEFAULT_CHANGES_PATH}`; } diff --git a/src/tui/screens/settings/SettingsPathsScreen.tsx b/src/tui/screens/settings/SettingsPathsScreen.tsx index abeaefd9..3fa2aecd 100644 --- a/src/tui/screens/settings/SettingsPathsScreen.tsx +++ b/src/tui/screens/settings/SettingsPathsScreen.tsx @@ -19,6 +19,7 @@ import { useRouter } from '../../router.js'; import { useAppContext } from '../../app-context.js'; import { Panel, Form } from '../../components/index.js'; import { useSettingsOperation } from '../../hooks/index.js'; +import { DEFAULT_CHANGES_PATH, DEFAULT_SQL_PATH } from '../../../core/settings/defaults.js'; /** * SettingsPathsScreen component. @@ -36,7 +37,7 @@ export function SettingsPathsScreen({ params: _params }: ScreenProps): ReactElem // Get current paths config const paths = useMemo(() => { - if (!settingsManager) return { sql: './sql', changes: './changes' }; + if (!settingsManager) return { sql: DEFAULT_SQL_PATH, changes: DEFAULT_CHANGES_PATH }; return settingsManager.getPaths(); @@ -49,15 +50,15 @@ export function SettingsPathsScreen({ params: _params }: ScreenProps): ReactElem key: 'sql', label: 'SQL Path', type: 'text', - defaultValue: paths.sql ?? './sql', - placeholder: './sql', + defaultValue: paths.sql ?? DEFAULT_SQL_PATH, + placeholder: DEFAULT_SQL_PATH, }, { key: 'changes', label: 'Changes Path', type: 'text', - defaultValue: paths.changes ?? './changes', - placeholder: './changes', + defaultValue: paths.changes ?? DEFAULT_CHANGES_PATH, + placeholder: DEFAULT_CHANGES_PATH, }, ], [paths], @@ -68,8 +69,8 @@ export function SettingsPathsScreen({ params: _params }: ScreenProps): ReactElem async (values: FormValues) => { await execute({ - sql: String(values['sql'] || './sql'), - changes: String(values['changes'] || './changes'), + sql: String(values['sql'] || DEFAULT_SQL_PATH), + changes: String(values['changes'] || DEFAULT_CHANGES_PATH), }); }, diff --git a/src/tui/utils/change-loader.ts b/src/tui/utils/change-loader.ts index dbf1b296..7bd86d06 100644 --- a/src/tui/utils/change-loader.ts +++ b/src/tui/utils/change-loader.ts @@ -18,7 +18,9 @@ import type { Config, Settings } from '../../core/index.js'; import type { Change, ChangeStatus, ChangeListItem } from '../../core/change/types.js'; import type { NoormDatabase } from '../../core/shared/index.js'; -import { discoverChanges } from '../../core/change/parser.js'; +import { failureListItem, scanChanges } from '../../core/change/parser.js'; +import type { ChangeParseFailure } from '../../core/change/parser.js'; +import { isPendingChange } from '../../core/change/types.js'; import { ChangeHistory } from '../../core/change/history.js'; import { createConnection } from '../../core/connection/factory.js'; import { resolveChangesDir, resolveSqlDir } from './paths.js'; @@ -29,6 +31,9 @@ import { resolveChangesDir, resolveSqlDir } from './paths.js'; export interface ChangesWithStatus { changes: Change[]; statuses: Map; + + /** Folders on disk that could not be parsed; they must not vanish from the views */ + failures: ChangeParseFailure[]; } /** @@ -52,7 +57,7 @@ export async function loadChangesWithStatus( projectRoot: string, ): Promise { - const changes = await discoverChanges( + const { changes, failures } = await scanChanges( resolveChangesDir(projectRoot, settings), resolveSqlDir(projectRoot, settings), ); @@ -68,7 +73,7 @@ export async function loadChangesWithStatus( await conn.destroy(); - return { changes, statuses }; + return { changes, statuses, failures }; } @@ -80,8 +85,13 @@ export async function loadChangesWithStatus( export function buildPendingChangeList( changes: Change[], statuses: Map, + failures: ChangeParseFailure[] = [], ): ChangeListItem[] { + const unreadable = failures + .map((failure) => failureListItem(failure, statuses.get(failure.name))) + .filter(isPendingChange); + return changes .filter((cs) => { @@ -90,7 +100,7 @@ export function buildPendingChangeList( return !status || status.status === 'pending' || status.status === 'reverted'; }) - .map((cs) => ({ + .map((cs): ChangeListItem => ({ name: cs.name, path: cs.path, date: cs.date, @@ -105,6 +115,7 @@ export function buildPendingChangeList( changeFiles: cs.changeFiles, revertFiles: cs.revertFiles, })) + .concat(unreadable) .sort((a, b) => { const dateA = a.date?.getTime() ?? 0; @@ -177,9 +188,10 @@ export function buildAppliedChangeList( export function buildMergedChangeList( changes: Change[], statuses: Map, + failures: ChangeParseFailure[] = [], ): ChangeListItem[] { - const merged: ChangeListItem[] = []; + const merged: ChangeListItem[] = failures.map((failure) => failureListItem(failure, statuses.get(failure.name))); for (const cs of changes) { @@ -213,6 +225,12 @@ export function buildMergedChangeList( } + for (const failure of failures) { + + diskNames[failure.name] = true; + + } + for (const [name, status] of statuses) { if (!diskNames[name]) { diff --git a/src/tui/utils/paths.ts b/src/tui/utils/paths.ts index d460e5c3..f8150f2c 100644 --- a/src/tui/utils/paths.ts +++ b/src/tui/utils/paths.ts @@ -13,21 +13,22 @@ import { join } from 'path'; import type { Settings } from '../../core/settings/types.js'; +import { DEFAULT_CHANGES_PATH, DEFAULT_SQL_PATH } from '../../core/settings/defaults.js'; /** * Resolve the absolute changes directory from settings. * - * Uses settings.paths.changes with fallback to 'changes'. + * Uses settings.paths.changes with fallback to DEFAULT_CHANGES_PATH. * * @example * ```typescript * const dir = resolveChangesDir('/project', settings); - * // '/project/changes' or '/project/custom-changes' + * // '/project/db/changes' or '/project/custom-changes' * ``` */ export function resolveChangesDir(projectRoot: string, settings: Settings | null): string { - const changesPath = settings?.paths?.changes ?? 'changes'; + const changesPath = settings?.paths?.changes ?? DEFAULT_CHANGES_PATH; return join(projectRoot, changesPath); @@ -36,17 +37,17 @@ export function resolveChangesDir(projectRoot: string, settings: Settings | null /** * Resolve the absolute SQL directory from settings. * - * Uses settings.paths.sql with fallback to 'sql'. + * Uses settings.paths.sql with fallback to DEFAULT_SQL_PATH. * * @example * ```typescript * const dir = resolveSqlDir('/project', settings); - * // '/project/sql' or '/project/custom-sql' + * // '/project/db/sql' or '/project/custom-sql' * ``` */ export function resolveSqlDir(projectRoot: string, settings: Settings | null): string { - const sqlPath = settings?.paths?.sql ?? 'sql'; + const sqlPath = settings?.paths?.sql ?? DEFAULT_SQL_PATH; return join(projectRoot, sqlPath); diff --git a/tests/cli/run/_setup.ts b/tests/cli/run/_setup.ts index 499be847..6f91fc63 100644 --- a/tests/cli/run/_setup.ts +++ b/tests/cli/run/_setup.ts @@ -59,7 +59,7 @@ export async function setupProject(): Promise { NOORM_CONNECTION_DIALECT: 'sqlite', NOORM_CONNECTION_DATABASE: dbPath, NOORM_PATHS_SQL: './sql', - NOORM_PATHS_CHANGESETS: './changes', + NOORM_PATHS_CHANGES: './changes', NOORM_NAME: '__test__', NOORM_ISTEST: 'true', NOORM_IDENTITY: 'cli-run-tester', diff --git a/tests/cli/screens/change/change-dry-run.test.tsx b/tests/cli/screens/change/change-dry-run.test.tsx index e03f0fe6..db3e46be 100644 --- a/tests/cli/screens/change/change-dry-run.test.tsx +++ b/tests/cli/screens/change/change-dry-run.test.tsx @@ -228,10 +228,10 @@ describe('cli: ChangeFFScreen global modes', () => { // A real pending change on disk: the screen refuses to fast-forward // template-only or empty changes before it ever builds a manager. - const changeDir = join(tempDir, 'changes', 'add-widgets-table', 'change'); + const changeDir = join(tempDir, 'db', 'changes', 'add-widgets-table', 'change'); await mkdir(changeDir, { recursive: true }); - await mkdir(join(tempDir, 'sql'), { recursive: true }); + await mkdir(join(tempDir, 'db', 'sql'), { recursive: true }); await writeFile( join(changeDir, '001.sql'), 'CREATE TABLE widgets (id INTEGER PRIMARY KEY)', diff --git a/tests/cli/utils/change-context.test.ts b/tests/cli/utils/change-context.test.ts index c1f8926e..eb9d5bc5 100644 --- a/tests/cli/utils/change-context.test.ts +++ b/tests/cli/utils/change-context.test.ts @@ -115,8 +115,8 @@ describe('tui: createChangeManager', () => { capturedContexts.length = 0; tempDir = await mkdtemp(join(tmpdir(), 'noorm-change-context-test-')); - changesDir = join(tempDir, 'changes'); - sqlDir = join(tempDir, 'sql'); + changesDir = join(tempDir, 'db', 'changes'); + sqlDir = join(tempDir, 'db', 'sql'); await mkdir(changesDir, { recursive: true }); await mkdir(sqlDir, { recursive: true }); diff --git a/tests/cli/utils/change-loader.test.ts b/tests/cli/utils/change-loader.test.ts new file mode 100644 index 00000000..34168897 --- /dev/null +++ b/tests/cli/utils/change-loader.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'bun:test'; + +import { buildMergedChangeList, buildPendingChangeList } from '../../../src/tui/utils/change-loader.js'; +import type { ChangeStatus } from '../../../src/core/change/types.js'; +import type { ChangeParseFailure } from '../../../src/core/change/parser.js'; + +function status(name: string, state: ChangeStatus['status']): ChangeStatus { + + return { + name, + status: state, + appliedAt: null, + appliedBy: null, + revertedAt: null, + errorMessage: null, + }; + +} + +const broken: ChangeParseFailure = { + name: '2026-02-01-broken', + path: '/project/db/changes/2026-02-01-broken', + message: "Manifest 'x.txt' references missing file: views/missing.sql", +}; + +describe('tui: change loader with unreadable changes', () => { + + describe('buildPendingChangeList', () => { + + it('should list an unreadable change that never ran, so the screens do not say there is nothing pending', () => { + + const pending = buildPendingChangeList([], new Map(), [broken]); + + expect(pending.map((c) => c.name)).toEqual(['2026-02-01-broken']); + expect(pending[0]?.status).toBe('failed'); + expect(pending[0]?.errorMessage).toContain('missing.sql'); + + }); + + it('should list an unreadable change that was reverted', () => { + + const statuses = new Map([[broken.name, status(broken.name, 'reverted')]]); + + expect(buildPendingChangeList([], statuses, [broken]).map((c) => c.name)).toEqual([broken.name]); + + }); + + it('should not list an unreadable change that is applied', () => { + + const statuses = new Map([[broken.name, status(broken.name, 'success')]]); + + expect(buildPendingChangeList([], statuses, [broken])).toEqual([]); + + }); + + }); + + describe('buildMergedChangeList', () => { + + it('should show an unreadable change as failed, not orphaned', () => { + + const merged = buildMergedChangeList([], new Map(), [broken]); + + expect(merged).toHaveLength(1); + expect(merged[0]?.status).toBe('failed'); + expect(merged[0]?.orphaned).toBe(false); + + }); + + it('should keep the recorded status of an applied change whose files can no longer be read', () => { + + const statuses = new Map([[broken.name, status(broken.name, 'success')]]); + const merged = buildMergedChangeList([], statuses, [broken]); + + expect(merged).toHaveLength(1); + expect(merged[0]?.status).toBe('success'); + expect(merged[0]?.orphaned).toBe(false); + + }); + + }); + +}); diff --git a/tests/core/change/manager.test.ts b/tests/core/change/manager.test.ts index 5fe234af..857400f1 100644 --- a/tests/core/change/manager.test.ts +++ b/tests/core/change/manager.test.ts @@ -701,4 +701,179 @@ describe('change: manager', () => { }); + // sqlite's prepare() runs only the first statement and reports success + describe('multi-statement change files', () => { + + it('should run every statement of a change file on sqlite', async () => { + + await createTestChange('multi-ok', [{ + name: '001.sql', + content: 'CREATE TABLE multi_first (id INTEGER PRIMARY KEY);\nCREATE TABLE multi_second (id INTEGER PRIMARY KEY);', + }]); + + const manager = new ChangeManager(buildContext()); + const result = await manager.run('multi-ok'); + + expect(result.status).toBe('success'); + expect(await tableExists('multi_first')).toBe(true); + expect(await tableExists('multi_second')).toBe(true); + + }); + + it('should fail the change and name the statement when a later statement fails', async () => { + + await createTestChange('multi-bad', [{ + name: '001.sql', + content: 'CREATE TABLE multi_bad_first (id INTEGER PRIMARY KEY);\nSELECT * FROM no_such_table_xyz;', + }]); + + const manager = new ChangeManager(buildContext()); + const result = await manager.run('multi-bad'); + + expect(result.status).toBe('failed'); + expect(result.error).toContain('[statement 2 of 2]'); + + }); + + }); + + describe('changes that cannot be parsed', () => { + + async function createBrokenManifestChange(name: string): Promise { + + await createTestChange(name, [{ name: '001_views.txt', content: 'views/missing.sql\n' }]); + + } + + it('should fail ff at the broken change instead of reporting success', async () => { + + await createTestChange('2025-01-01-good', [ + { name: '001.sql', content: 'CREATE TABLE parse_good (id INTEGER PRIMARY KEY)' }, + ]); + await createBrokenManifestChange('2025-01-02-broken'); + await createTestChange('2025-01-03-after', [ + { name: '001.sql', content: 'CREATE TABLE parse_after (id INTEGER PRIMARY KEY)' }, + ]); + + const manager = new ChangeManager(buildContext()); + const result = await manager.ff(); + + expect(result.status).toBe('partial'); + expect(result.executed).toBe(1); + expect(result.failed).toBe(1); + + const broken = result.changes.find((c) => c.name === '2025-01-02-broken'); + + expect(broken?.status).toBe('failed'); + expect(broken?.error).toContain('missing.sql'); + expect(await tableExists('parse_good')).toBe(true); + expect(await tableExists('parse_after')).toBe(false); + + }); + + it('should ignore a folder that has neither change/ nor revert/', async () => { + + await mkdir(join(changesDir, 'lib'), { recursive: true }); + await writeFile(join(changesDir, 'lib', 'shared.sql'), 'SELECT 1'); + await createTestChange('2025-07-01-real', [ + { name: '001.sql', content: 'CREATE TABLE parse_real (id INTEGER PRIMARY KEY)' }, + ]); + + const manager = new ChangeManager(buildContext()); + const result = await manager.ff(); + + expect((await manager.list()).map((c) => c.name)).toEqual(['2025-07-01-real']); + expect(result.status).toBe('success'); + expect(result.failed).toBe(0); + expect(await tableExists('parse_real')).toBe(true); + + }); + + it('should fail ff --dry-run for the same change', async () => { + + await createBrokenManifestChange('2025-02-01-broken'); + + const manager = new ChangeManager(buildContext()); + const result = await manager.ff({ dryRun: true }); + + expect(result.status).toBe('failed'); + expect(result.failed).toBe(1); + + }); + + it('should name the missing file, not "not found", when run is asked for the broken change', async () => { + + await createBrokenManifestChange('2025-05-01-broken'); + + const manager = new ChangeManager(buildContext()); + const [, err] = await attempt(() => manager.run('2025-05-01-broken')); + + expect(err?.message).toContain('missing.sql'); + expect(err?.message).not.toContain('not found'); + + }); + + it('should list the unapplied broken change as failed with the parse error', async () => { + + await createBrokenManifestChange('2025-03-01-broken'); + + const manager = new ChangeManager(buildContext()); + const list = await manager.list(); + const item = list.find((c) => c.name === '2025-03-01-broken'); + + expect(item?.status).toBe('failed'); + expect(item?.errorMessage).toContain('missing.sql'); + expect(item?.orphaned).toBe(false); + + }); + + it('should fail ff at a reverted change whose manifest later breaks', async () => { + + await mkdir(join(sqlDir, 'views'), { recursive: true }); + await writeFile(join(sqlDir, 'views', 'v.sql'), 'CREATE VIEW parse_reverted_view AS SELECT 1 AS a'); + await createTestChange( + '2025-06-01-reverted', + [{ name: '001_views.txt', content: 'views/v.sql\n' }], + [{ name: '001.sql', content: 'DROP VIEW IF EXISTS parse_reverted_view' }], + ); + + const manager = new ChangeManager(buildContext()); + + await manager.run('2025-06-01-reverted'); + await manager.revert('2025-06-01-reverted'); + await rm(join(sqlDir, 'views', 'v.sql')); + + const result = await manager.ff(); + + expect(result.status).toBe('failed'); + expect(result.changes[0]?.error).toContain('v.sql'); + expect(result.warnings?.join('\n') ?? '').not.toContain('is applied'); + + }); + + it('should keep an applied change visible and warn when its manifest later breaks', async () => { + + await mkdir(join(sqlDir, 'views'), { recursive: true }); + await writeFile(join(sqlDir, 'views', 'v.sql'), 'CREATE VIEW parse_view AS SELECT 1 AS a'); + await createTestChange('2025-04-01-applied', [{ name: '001_views.txt', content: 'views/v.sql\n' }]); + + const manager = new ChangeManager(buildContext()); + + expect((await manager.run('2025-04-01-applied')).status).toBe('success'); + + await rm(join(sqlDir, 'views', 'v.sql')); + + const item = (await manager.list()).find((c) => c.name === '2025-04-01-applied'); + const result = await manager.ff(); + + expect(item?.status).toBe('success'); + expect(item?.orphaned).toBe(false); + expect(result.status).toBe('success'); + expect(result.failed).toBe(0); + expect(result.warnings?.join('\n')).toContain('2025-04-01-applied'); + + }); + + }); + }); diff --git a/tests/core/project-init.test.ts b/tests/core/project-init.test.ts index 11bed804..4680b652 100644 --- a/tests/core/project-init.test.ts +++ b/tests/core/project-init.test.ts @@ -3,8 +3,8 @@ import { mkdtempSync, mkdirSync, rmSync, existsSync, readFileSync, writeFileSync import { tmpdir, homedir } from 'node:os'; import { join } from 'node:path'; -import { performProjectInit, resolveInitPaths, scaffoldDbLayout } from '../../src/core/project-init.js'; -import { DEFAULT_PATH_CONFIG, INIT_PATH_CONFIG } from '../../src/core/settings/defaults.js'; +import { performProjectInit, scaffoldDbLayout } from '../../src/core/project-init.js'; +import { DEFAULT_PATH_CONFIG } from '../../src/core/settings/defaults.js'; import { SettingsManager } from '../../src/core/settings/manager.js'; import { resolveChangesDir, resolveSqlDir } from '../../src/tui/utils/paths.js'; @@ -176,7 +176,6 @@ describe('core: performProjectInit', () => { }); - // --force resets settings.yml; a project laid out at ./sql must not be repointed at an empty db/. it.skipIf(!hasGlobalIdentity)('should keep a legacy project layout on --force', async () => { mkdirSync(join(tmpDir, '.noorm'), { recursive: true }); @@ -200,56 +199,6 @@ describe('core: performProjectInit', () => { }); -describe('core: resolveInitPaths', () => { - - let tmpDir: string; - - beforeEach(() => { - - tmpDir = mkdtempSync(join(tmpdir(), 'noorm-init-paths-')); - mkdirSync(join(tmpDir, '.noorm'), { recursive: true }); - - }); - - afterEach(() => { - - rmSync(tmpDir, { recursive: true, force: true }); - - }); - - it('should give a project with no settings.yml the db/ layout', async () => { - - expect(await resolveInitPaths(tmpDir)).toEqual(INIT_PATH_CONFIG); - - }); - - it('should keep the paths an existing settings.yml carries', async () => { - - writeFileSync(join(tmpDir, '.noorm', 'settings.yml'), 'paths:\n sql: ./schema\n changes: ./deltas\n'); - - expect(await resolveInitPaths(tmpDir)).toEqual({ sql: './schema', changes: './deltas' }); - - }); - - // A settings.yml without `paths` resolves to ./sql today; init must not silently move it. - it('should resolve a settings.yml with no paths key to the legacy default', async () => { - - writeFileSync(join(tmpDir, '.noorm', 'settings.yml'), 'build:\n include: []\n'); - - expect(await resolveInitPaths(tmpDir)).toEqual(DEFAULT_PATH_CONFIG); - - }); - - it('should resolve an unreadable settings.yml to the legacy default', async () => { - - writeFileSync(join(tmpDir, '.noorm', 'settings.yml'), 'paths: [unclosed\n'); - - expect(await resolveInitPaths(tmpDir)).toEqual(DEFAULT_PATH_CONFIG); - - }); - -}); - describe('core: scaffoldDbLayout', () => { let tmpDir: string; @@ -268,7 +217,7 @@ describe('core: scaffoldDbLayout', () => { it('should nest sql/ and changes/ under db/', () => { - const created = scaffoldDbLayout(tmpDir, INIT_PATH_CONFIG); + const created = scaffoldDbLayout(tmpDir, DEFAULT_PATH_CONFIG); expect(created).toEqual([ 'db/sql/.gitkeep', @@ -283,7 +232,7 @@ describe('core: scaffoldDbLayout', () => { it('should template AGENTS.md for the user and import it from CLAUDE.md', () => { - scaffoldDbLayout(tmpDir, INIT_PATH_CONFIG); + scaffoldDbLayout(tmpDir, DEFAULT_PATH_CONFIG); const agents = readFileSync(join(tmpDir, 'db', 'AGENTS.md'), 'utf-8'); const claude = readFileSync(join(tmpDir, 'db', 'CLAUDE.md'), 'utf-8'); @@ -296,7 +245,7 @@ describe('core: scaffoldDbLayout', () => { it('should create nothing for a project on its own layout', () => { - const created = scaffoldDbLayout(tmpDir, DEFAULT_PATH_CONFIG); + const created = scaffoldDbLayout(tmpDir, { sql: './sql', changes: './changes' }); expect(created).toEqual([]); expect(existsSync(join(tmpDir, 'db'))).toBe(false); @@ -308,7 +257,7 @@ describe('core: scaffoldDbLayout', () => { mkdirSync(join(tmpDir, 'db'), { recursive: true }); writeFileSync(join(tmpDir, 'db', 'CLAUDE.md'), 'mine\n'); - const created = scaffoldDbLayout(tmpDir, INIT_PATH_CONFIG); + const created = scaffoldDbLayout(tmpDir, DEFAULT_PATH_CONFIG); expect(readFileSync(join(tmpDir, 'db', 'CLAUDE.md'), 'utf-8')).toBe('mine\n'); expect(created).toContain('db/AGENTS.md'); diff --git a/tests/core/settings/manager.test.ts b/tests/core/settings/manager.test.ts index 54fdbe55..888a83f1 100644 --- a/tests/core/settings/manager.test.ts +++ b/tests/core/settings/manager.test.ts @@ -6,6 +6,7 @@ import { SettingsManager, resetSettingsManager, DEFAULT_SETTINGS, + DEFAULT_PATH_CONFIG, } from '../../../src/core/settings/index.js'; import type { Stage, Rule } from '../../../src/core/settings/types.js'; @@ -347,6 +348,78 @@ ui: }); + it('should keep the paths already in the file when overwriting', async () => { + + const { manager, cleanup } = createTestContext(); + + try { + + await manager.init(); + await manager.setPaths({ sql: './sql', changes: './changes' }); + + await manager.init(true); + + expect(manager.getPaths()).toEqual({ sql: './sql', changes: './changes' }); + expect(readFileSync(manager.settingsFilePath, 'utf-8')).toContain('./sql'); + + } + finally { + + cleanup(); + + } + + }); + + it('should give an overwritten file with no paths the default layout', async () => { + + const { manager, cleanup } = createTestContext(); + + try { + + await manager.init(); + writeFileSync(manager.settingsFilePath, 'build:\n include: []\n'); + + await manager.init(true); + + expect(manager.getPaths()).toEqual(DEFAULT_PATH_CONFIG); + + } + finally { + + cleanup(); + + } + + }); + + // The resolved view merges NOORM_* variables; saving it would turn one + // shell's override into the project's settings. + it('should not write an environment override into the file', async () => { + + const { manager, cleanup } = createTestContext(); + + process.env['NOORM_PATHS_SQL'] = './env-sql'; + + try { + + await manager.init(); + + const written = readFileSync(manager.settingsFilePath, 'utf-8'); + + expect(written).not.toContain('env-sql'); + expect(written).toContain('./db/sql'); + + } + finally { + + delete process.env['NOORM_PATHS_SQL']; + cleanup(); + + } + + }); + }); describe('exists', () => { @@ -441,8 +514,8 @@ ui: const paths = manager.getPaths(); - expect(paths.sql).toBe('./sql'); - expect(paths.changes).toBe('./changes'); + expect(paths.sql).toBe('./db/sql'); + expect(paths.changes).toBe('./db/changes'); } finally { diff --git a/tests/core/update/skill.test.ts b/tests/core/update/skill.test.ts new file mode 100644 index 00000000..7a41dac6 --- /dev/null +++ b/tests/core/update/skill.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test'; +import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { attempt } from '@logosdx/utils'; + +import { offerSkill } from '../../../src/cli/update.js'; +import { + SKILL_COMMAND, + canPromptForSkill, + installSkill, +} from '../../../src/core/update/skill.js'; + +describe('update: skill', () => { + + let binDir: string; + let originalPath: string | undefined; + let originalArgsFile: string | undefined; + + beforeEach(async () => { + + binDir = await mkdtemp(join(tmpdir(), 'noorm-skill-bin-')); + originalPath = process.env['PATH']; + originalArgsFile = process.env['NPX_ARGS_FILE']; + + }); + + afterEach(async () => { + + process.env['PATH'] = originalPath; + + if (originalArgsFile === undefined) delete process.env['NPX_ARGS_FILE']; + else process.env['NPX_ARGS_FILE'] = originalArgsFile; + + await rm(binDir, { recursive: true, force: true }); + + }); + + describe('installSkill', () => { + + it.skipIf(process.platform === 'win32')('should run `npx skills add` for the noorm skill and return its exit code', async () => { + + const argsFile = join(binDir, 'args.txt'); + + await writeFile(join(binDir, 'npx'), '#!/bin/sh\nprintf \'%s\\n\' "$@" > "$NPX_ARGS_FILE"\nexit 3\n'); + await chmod(join(binDir, 'npx'), 0o755); + + process.env['PATH'] = `${binDir}:${originalPath ?? ''}`; + process.env['NPX_ARGS_FILE'] = argsFile; + + const code = await installSkill(); + const args = (await readFile(argsFile, 'utf-8')).trim().split('\n'); + + expect(code).toBe(3); + expect(args).toEqual(['skills', 'add', 'noormdev/noorm/skills']); + + }); + + // Node.js is not required to run the noorm binary, so a missing npx has + // to surface as an error the caller can explain, not a crash. + it.skipIf(process.platform === 'win32')('should reject when npx is not installed', async () => { + + process.env['PATH'] = binDir; + + const [, err] = await attempt(() => installSkill()); + + expect(err).toBeInstanceOf(Error); + expect((err as NodeJS.ErrnoException).code).toBe('ENOENT'); + + }); + + }); + + describe('canPromptForSkill', () => { + + it('should prompt only when both stdin and stdout are terminals', () => { + + expect(canPromptForSkill({ stdinTty: true, stdoutTty: true })).toBe(true); + expect(canPromptForSkill({ stdinTty: false, stdoutTty: true })).toBe(false); + expect(canPromptForSkill({ stdinTty: true, stdoutTty: false })).toBe(false); + expect(canPromptForSkill({ stdinTty: undefined, stdoutTty: undefined })).toBe(false); + + }); + + }); + + // The prompt needs a terminal; these are the paths that stay quiet or print the command. + describe('offerSkill', () => { + + it('should print nothing for --json, even on a terminal', async () => { + + const write = spyOn(process.stdout, 'write').mockImplementation(() => true); + + await offerSkill(true, true); + + expect(write).not.toHaveBeenCalled(); + + write.mockRestore(); + + }); + + it('should print the command instead of prompting when there is no terminal', async () => { + + const write = spyOn(process.stdout, 'write').mockImplementation(() => true); + + await offerSkill(false, false); + + const printed = write.mock.calls.map((call) => String(call[0])).join(''); + + expect(printed).toContain(SKILL_COMMAND); + + write.mockRestore(); + + }); + + }); + +}); diff --git a/tests/integration/change/mssql-batches.test.ts b/tests/integration/change/mssql-batches.test.ts new file mode 100644 index 00000000..a8b430fb --- /dev/null +++ b/tests/integration/change/mssql-batches.test.ts @@ -0,0 +1,174 @@ +/** + * Integration test: change files with `GO` batch separators on live SQL Server. + * + * `GO` is a client-side directive, not T-SQL, so the change executor has to + * split it as the runner does for `sql/` files. Drives `ChangeManager`'s + * public API and asks the server itself which tables exist. + * + * Requires the docker-compose.test.yml SQL Server container (11433); skips + * itself when unreachable. + */ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { mkdtemp, rm, mkdir, writeFile } from 'fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Kysely, sql } from 'kysely'; + +import { attempt } from '@logosdx/utils'; + +import { ChangeManager } from '../../../src/core/change/manager.js'; +import { migrateSchema } from '../../../src/core/version/schema/index.js'; +import { getNoormTables, noormDb } from '../../../src/core/shared/index.js'; +import { resetLockManager } from '../../../src/core/lock/index.js'; +import type { NoormDatabase } from '../../../src/core/shared/index.js'; +import type { ConnectionResult } from '../../../src/core/connection/types.js'; +import type { ChangeContext } from '../../../src/core/change/types.js'; + +import { createTestConnection, isContainerRunning } from '../../utils/db.js'; + +const CONFIG_NAME = '__change_mssql_batches__'; +const FIRST_TABLE = 'noorm_it_batch_first'; +const SECOND_TABLE = 'noorm_it_batch_second'; +const OK_CHANGE = '2026-01-01-mssql-batches'; +const BAD_CHANGE = '2026-01-02-mssql-bad-batch'; + +describe('change: GO batches on live mssql', () => { + + let conn: ConnectionResult | null = null; + let reachable = false; + let tempDir: string; + let changesDir: string; + let sqlDir: string; + + beforeAll(async () => { + + reachable = await isContainerRunning('mssql'); + + if (!reachable) return; + + resetLockManager(); + + conn = await createTestConnection('mssql'); + + await migrateSchema(conn.db as unknown as Kysely, 'mssql'); + + tempDir = await mkdtemp(join(tmpdir(), 'noorm-mssql-change-')); + changesDir = join(tempDir, 'changes'); + sqlDir = join(tempDir, 'sql'); + + await mkdir(join(changesDir, OK_CHANGE, 'change'), { recursive: true }); + await mkdir(join(changesDir, BAD_CHANGE, 'change'), { recursive: true }); + await mkdir(sqlDir, { recursive: true }); + + await writeFile( + join(changesDir, OK_CHANGE, 'change', '001_two_tables.sql'), + `CREATE TABLE ${FIRST_TABLE} (id INT PRIMARY KEY)\nGO\nCREATE TABLE ${SECOND_TABLE} (id INT PRIMARY KEY)\nGO\n`, + ); + await writeFile( + join(changesDir, BAD_CHANGE, 'change', '001_bad_second_batch.sql'), + 'SELECT 1\nGO\nSELECT * FROM noorm_it_no_such_table\nGO\n', + ); + + }); + + afterAll(async () => { + + if (!conn) return; + + const db = conn.db as unknown as Kysely; + const tables = getNoormTables('mssql'); + + await attempt(() => sql.raw(`DROP TABLE IF EXISTS ${FIRST_TABLE}`).execute(db)); + await attempt(() => sql.raw(`DROP TABLE IF EXISTS ${SECOND_TABLE}`).execute(db)); + + // Child rows first — executions carries an FK to change. + await attempt(() => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (noormDb(db, 'mssql') as any) + .deleteFrom(tables.executions) + .where('change_id', 'in', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (noormDb(db, 'mssql') as any) + .selectFrom(tables.change) + .select('id') + .where('config_name', '=', CONFIG_NAME), + ) + .execute(), + ); + + await attempt(() => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (noormDb(db, 'mssql') as any) + .deleteFrom(tables.change) + .where('config_name', '=', CONFIG_NAME) + .execute(), + ); + + await conn.destroy(); + await rm(tempDir, { recursive: true, force: true }); + + resetLockManager(); + + }); + + function buildContext(): ChangeContext { + + return { + db: conn!.db as unknown as Kysely, + configName: CONFIG_NAME, + identity: { name: 'Test User', email: 'test@example.com', source: 'config' }, + projectRoot: tempDir, + changesDir, + sqlDir, + access: { user: 'admin', agent: 'admin' }, + channel: 'user', + dialect: 'mssql', + }; + + } + + async function tableExists(name: string): Promise { + + const db = conn!.db as unknown as Kysely; + + const rows = await sql<{ c: number }>` + SELECT COUNT(*) AS c FROM sys.tables WHERE name = ${name} + `.execute(db); + + return Number(rows.rows[0]?.c ?? 0) > 0; + + } + + it('should run every GO-separated batch of a change file', async () => { + + if (!reachable) { + + console.warn('Skipping mssql: container not reachable'); + + return; + + } + + const manager = new ChangeManager(buildContext()); + const result = await manager.run(OK_CHANGE); + + expect(result.error ?? null).toBeNull(); + expect(result.status).toBe('success'); + expect(await tableExists(FIRST_TABLE)).toBe(true); + expect(await tableExists(SECOND_TABLE)).toBe(true); + + }); + + it('should name the batch that failed', async () => { + + if (!reachable) return; + + const manager = new ChangeManager(buildContext()); + const result = await manager.run(BAD_CHANGE); + + expect(result.status).toBe('failed'); + expect(result.error).toContain('[batch 2 of 2]'); + + }); + +}); diff --git a/tests/integration/cli/setup.ts b/tests/integration/cli/setup.ts index 597826b7..91458121 100644 --- a/tests/integration/cli/setup.ts +++ b/tests/integration/cli/setup.ts @@ -390,7 +390,7 @@ export async function setupTestProject(): Promise { NOORM_CONNECTION_DIALECT: 'sqlite', NOORM_CONNECTION_DATABASE: dbPath, NOORM_PATHS_SQL: './sql', - NOORM_PATHS_CHANGESETS: './changes', + NOORM_PATHS_CHANGES: './changes', NOORM_NAME: '__test__', NOORM_ISTEST: 'true', // Bypass identity check in headless mode (CI has no ~/.noorm/identity.key) diff --git a/tests/sdk/run-build-filtering.test.ts b/tests/sdk/run-build-filtering.test.ts index 7fca2b2d..64292daa 100644 --- a/tests/sdk/run-build-filtering.test.ts +++ b/tests/sdk/run-build-filtering.test.ts @@ -171,10 +171,10 @@ async function makeSqlProject(): Promise { const projectRoot = await mkdtemp(join(tmpdir(), 'noorm-build-filter-')); - await mkdir(join(projectRoot, 'sql', 'a'), { recursive: true }); - await mkdir(join(projectRoot, 'sql', 'b'), { recursive: true }); - await writeFile(join(projectRoot, 'sql', 'a', '001_a.sql'), 'select 1;'); - await writeFile(join(projectRoot, 'sql', 'b', '001_b.sql'), 'select 1;'); + await mkdir(join(projectRoot, 'db', 'sql', 'a'), { recursive: true }); + await mkdir(join(projectRoot, 'db', 'sql', 'b'), { recursive: true }); + await writeFile(join(projectRoot, 'db', 'sql', 'a', '001_a.sql'), 'select 1;'); + await writeFile(join(projectRoot, 'db', 'sql', 'b', '001_b.sql'), 'select 1;'); return projectRoot; @@ -221,7 +221,7 @@ describe('sdk: RunNamespace.build() filtering', () => { const result = await run.build(); expect(result.files.map((f) => f.filepath)).toEqual([ - join(projectRoot, 'sql', 'a', '001_a.sql'), + join(projectRoot, 'db', 'sql', 'a', '001_a.sql'), ]); }); @@ -259,8 +259,8 @@ describe('sdk: RunNamespace.build() filtering', () => { const result = await run.build(); expect(result.files.map((f) => f.filepath).sort()).toEqual([ - join(projectRoot, 'sql', 'a', '001_a.sql'), - join(projectRoot, 'sql', 'b', '001_b.sql'), + join(projectRoot, 'db', 'sql', 'a', '001_a.sql'), + join(projectRoot, 'db', 'sql', 'b', '001_b.sql'), ].sort()); }); @@ -281,7 +281,7 @@ describe('sdk: RunNamespace.build() filtering', () => { const result = await run.build(); expect(result.files.map((f) => f.filepath)).toEqual([ - join(projectRoot, 'sql', 'a', '001_a.sql'), + join(projectRoot, 'db', 'sql', 'a', '001_a.sql'), ]); }); From 62ebd7ecdf8a506584173ed4a05e2072ece28aba Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Tue, 20 Oct 2026 12:28:52 -0400 Subject: [PATCH 5/6] feat: ignatius hint, gh skill fallback, update UI The db/ default-path change is documented as non-breaking: noorm init writes paths, so only projects without them move. --- .changeset/init-db-layout-agent-files.md | 4 +- .changeset/update-skill-prompt.md | 4 +- .claude/rules/documentation.md | 2 - docs/cli/init.md | 3 +- docs/getting-started/installation.md | 12 +- docs/guide/changes/overview.md | 2 - docs/headless.md | 12 +- src/cli/update.ts | 216 +++++++++++++++++------ src/core/project-init.ts | 10 +- src/core/update/skill.ts | 75 ++++++-- tests/cli/update.test.ts | 191 ++++++++++++++++++++ tests/core/project-init.test.ts | 2 + tests/core/update/skill.test.ts | 123 ++++++++++--- 13 files changed, 549 insertions(+), 107 deletions(-) create mode 100644 tests/cli/update.test.ts diff --git a/.changeset/init-db-layout-agent-files.md b/.changeset/init-db-layout-agent-files.md index 923b574c..2154b20b 100644 --- a/.changeset/init-db-layout-agent-files.md +++ b/.changeset/init-db-layout-agent-files.md @@ -3,6 +3,6 @@ '@noormdev/sdk': minor --- -**Breaking:** the default for `paths.sql` and `paths.changes` is now `./db/sql` and `./db/changes`. A `settings.yml` with no `paths` key resolves there, so a project laid out at `./sql` and `./changes` must add `paths: { sql: ./sql, changes: ./changes }`. A `settings.yml` written by `noorm init` already carries `paths` and is unaffected. +`noorm init` now creates `db/sql/` and `db/changes/` instead of `sql/` and `changes/`, and writes `db/AGENTS.md` and `db/CLAUDE.md`. `AGENTS.md` points coding agents at the `/noorm` skill and points data modeling at ignatius and its `/ignatius-modeling` skill. It also leaves a marker for your own steering. `CLAUDE.md` imports `AGENTS.md`. Neither file is overwritten on re-init. Overwriting a settings file (`noorm init --force`, `noorm settings init --force`) keeps the `paths` already in it. The `noorm` skill now states what `sql/` and `changes/` are for, which edits belong in each, how to reference `sql/` files from a change with a `.txt` manifest, and when to use a `.sql.tmpl`. -`noorm init` now creates `db/sql/` and `db/changes/` instead of `sql/` and `changes/`, and writes `db/AGENTS.md`, which points coding agents at the `/noorm` skill and leaves a marker for your own steering, and `db/CLAUDE.md`, which imports it. Neither file is overwritten on re-init. Overwriting a settings file (`noorm init --force`, `noorm settings init --force`) keeps the `paths` already in it. The `noorm` skill now states what `sql/` and `changes/` are for, which edits belong in each, how to reference `sql/` files from a change with a `.txt` manifest, and when to use a `.sql.tmpl`. +The built-in default for `paths.sql` and `paths.changes` is now `./db/sql` and `./db/changes`, matching what `noorm init` creates. `noorm init` writes `paths` into `settings.yml`, so an initialized project keeps its layout. A project laid out at `./sql` and `./changes` whose `settings.yml` is missing, empty, or has no `paths` key now resolves to `./db/sql` and `./db/changes`. Add `paths: { sql: ./sql, changes: ./changes }` to `settings.yml`, or set `NOORM_PATHS_SQL` and `NOORM_PATHS_CHANGES`. diff --git a/.changeset/update-skill-prompt.md b/.changeset/update-skill-prompt.md index b2fe1f59..a54276de 100644 --- a/.changeset/update-skill-prompt.md +++ b/.changeset/update-skill-prompt.md @@ -3,4 +3,6 @@ '@noormdev/sdk': minor --- -After a successful update on a terminal, `noorm update` asks whether to install or update the noorm agent skill, and runs `npx skills add noormdev/noorm/skills` on yes. A failed or declined install does not change the exit code. `--json` and piped output skip the prompt, and piped text output prints the command instead. +After a successful update, `noorm update` offers the noorm agent skill. It runs `npx skills add noormdev/noorm/skills` when `npx` is on `PATH`, else `gh skill install noormdev/noorm skills/noorm` (GitHub CLI 2.90 or newer). With neither, or when the command fails, it prints both commands and where to get Node.js and the GitHub CLI. On a terminal it asks first; piped output prints the command instead, and `--json` skips the offer. A declined or failed skill install does not change the exit code. + +On a terminal, `noorm update` now rewrites its status line in place: `Checking for updates...` is replaced by the result, and the download counter ends on `Downloaded X MB (100%)`. `noorm update --json` now writes only the JSON document to stdout; before, status lines preceded it. diff --git a/.claude/rules/documentation.md b/.claude/rules/documentation.md index 8e57c69c..36ccf76a 100644 --- a/.claude/rules/documentation.md +++ b/.claude/rules/documentation.md @@ -19,8 +19,6 @@ Every claim has to be one you can point at. Do not invent a position to argue against. No "some people say", no "you may have been told", no opponent who does not hold the view. State the finding and let it stand without a foil. -`docs/guide/changes/overview.md:15` currently breaks this ("Some argue that you *are* moving data..."). Fix it when you next touch that file, and do not copy the shape. - Measurements carry their conditions: the tool, the scale, the run. Never combine numbers from different runs or builds into one comparison. If the conditions differ, re-measure or say which figure came from where. Mark inference as inference. An extrapolation from a mechanism reads as a measured result unless the text says otherwise. diff --git a/docs/cli/init.md b/docs/cli/init.md index b11efadd..02f47160 100644 --- a/docs/cli/init.md +++ b/docs/cli/init.md @@ -5,7 +5,8 @@ Bootstrap a new noorm project interactively. Creates identity (if not present), project structure (`db/sql/`, `db/changes/`, `.noorm/`), `settings.yml`, and `state.enc`. It also writes `db/AGENTS.md` and `db/CLAUDE.md`. `AGENTS.md` points coding agents at the -`/noorm` skill and holds a marker for your own steering; `CLAUDE.md` imports it with +`/noorm` skill, points data modeling at [ignatius](../modeling/index.md) and its +[`/ignatius-modeling` skill](../modeling/modeling-skill.md), and holds a marker for your own steering; `CLAUDE.md` imports it with `@AGENTS.md`. Both are written only when absent, so re-running with `--force` keeps your edits. A project that already has `paths` in its `settings.yml` keeps them. `--force` resets the rest of the file, but a project laid out at `sql/` and `changes/` stays there, and no `db/` directory is created for it. diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index ed24b939..45229381 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -85,7 +85,7 @@ noorm update # Or launch the TUI (`noorm ui`) and press [u] from the home screen ``` -On a terminal, `noorm update` finishes by asking whether to install or update the [agent skill](#install-the-agent-skill-optional). +After an update, `noorm update` offers to install or update the [agent skill](#install-the-agent-skill-optional). ## Install the SDK (Optional) @@ -120,15 +120,21 @@ npx skills add noormdev/noorm/skills Agents otherwise guess at noorm's conventions, and several of them are non-obvious enough to guess wrong: SDK methods throw named errors rather than returning them, `attempt()` is used deliberately rather than everywhere, the codebase uses no `try`/`catch`, and test helpers carry safety guards that exist for a reason. The skill covers all of it, plus the headless CLI flags used in CI. -Run the same command again to update an installed copy; it replaces the copy. It installs the references the agent reads on demand: +Without Node.js, GitHub CLI 2.90 or newer installs the same skill from the latest release: + +```bash +gh skill install noormdev/noorm skills/noorm +``` + +Run either command again to update an installed copy. The skill includes these references, which the agent reads on demand: | Reference | Covers | |-----------|--------| +| `schema-files.md` | What belongs in `db/sql/` and `db/changes/`, manifests, templates | | `sdk.md` | `createContext()`, Kysely patterns, error handling, testing | | `cli.md` | Headless commands, flags, exit codes, CI usage | | `templates.md` | `.sql.tmpl` syntax and data loaders | | `config.md` | `settings.yml`, paths, stages, secrets | -| `schema-files.md` | What `sql/` and `changes/` are for, which edits go where, manifests, when to use a template | ::: tip Include paths are relative to `paths.sql` The single most common mistake — by people and agents alike — is writing `build.include` entries relative to the project root. They resolve against `paths.sql`. The skill states this explicitly; `noorm run build` also warns when an include entry matches nothing. diff --git a/docs/guide/changes/overview.md b/docs/guide/changes/overview.md index 97f079a6..18236f91 100644 --- a/docs/guide/changes/overview.md +++ b/docs/guide/changes/overview.md @@ -12,8 +12,6 @@ Most tools call these migrations. We don't, and here's why. **Changes describe what you *do*, not what you *move*.** To migrate implies moving something from one place to another. Data migrations traditionally mean moving data between servers, warehouses, or systems. But when you add a column to an existing table, you're not migrating anything—you're changing the schema. -Some argue that you *are* moving data—adding a column means moving data into it, splitting `full_name` into `first_name` and `last_name` is moving data between columns, or you're "moving the schema from one version to the next." This is mental gymnastics. It's stretching a word to fit a process it was never meant to describe. - The term "migration" originated in enterprise contexts where it actually made sense—incrementally moving schemas and data from Oracle to SQL Server, or consolidating legacy systems into a new platform. Rails borrowed this terminology in 2005 and popularized it for web applications. Everyone followed suit without questioning whether the word fit. We're not moving databases between systems. We're changing them in place. The language matters. **Kysely already has migrations.** If you want a traditional migration system, Kysely has one built in. But it lacks what noorm provides: diff --git a/docs/headless.md b/docs/headless.md index e2bf63ec..bbf3458b 100644 --- a/docs/headless.md +++ b/docs/headless.md @@ -1584,7 +1584,17 @@ noorm update --json |------|-------------| | `--insecure` | Downgrade an unreachable `checksums.txt` from a failure to a warning. Also settable as `NOORM_INSECURE=1`. It never bypasses a confirmed checksum mismatch, which always fails | -After a successful update on a terminal, `noorm update` asks whether to install or update the agent skill and, on yes, runs `npx skills add noormdev/noorm/skills`. Declining, `--json`, and piped output skip the prompt; piped text output prints the command instead. A failed skill install does not change the exit code, since the update itself succeeded. +On a terminal, the download is one line rewritten in place with megabytes and percent, ending on `Downloaded X MB (100%)`. Piped output prints one line per step instead. `--json` prints only the JSON document. + +After a successful update, `noorm update` offers the agent skill through the first installer it finds on `PATH`: + +| Found | Command | +|-------|---------| +| `npx` | `npx skills add noormdev/noorm/skills` | +| `gh` (no `npx`) | `gh skill install noormdev/noorm skills/noorm`, which needs GitHub CLI 2.90 or newer | +| Neither | None run; both commands are printed with where to get Node.js and the GitHub CLI | + +On a terminal it asks first and runs the command on yes. Piped output prints the command instead of asking, and `--json` skips the offer. When the command fails, both commands are printed. A declined or failed skill install does not change the exit code, since the update itself succeeded. #### `ui` diff --git a/src/cli/update.ts b/src/cli/update.ts index 06f455ac..499144f6 100644 --- a/src/cli/update.ts +++ b/src/cli/update.ts @@ -11,10 +11,20 @@ import { attempt } from '@logosdx/utils'; import { observer } from '../core/observer.js'; import { checkForUpdate, getCurrentVersion } from '../core/update/checker.js'; import { installUpdate } from '../core/update/updater.js'; -import { SKILL_COMMAND, canPromptForSkill, installSkill } from '../core/update/skill.js'; +import { + SKILL_INSTALLERS, + canPromptForSkill, + findSkillInstaller, + installSkill, + skillCommand, +} from '../core/update/skill.js'; import { isInsecureMode, outputError, outputResult, sharedArgs } from './_utils.js'; import { EXIT } from './_exit.js'; +// Wider than any line rewritten in place, so each `\r` rewrite erases the +// previous one by construction. +const STATUS_LINE_WIDTH = 40; + /** Render a byte count as MB with one decimal (e.g. 41.2). */ function toMb(bytes: number): string { @@ -23,9 +33,117 @@ function toMb(bytes: number): string { } /** - * Ask whether to install or update the noorm agent skill, once the binary is - * updated. The update already succeeded, so nothing here changes the exit code: - * a declined prompt or a failed install only prints how to do it by hand. + * Status output for `noorm update`. On a terminal, `pending` leaves its line + * open and the next `say` rewrites it in place; off one, every line is final. + * Under `--json` neither prints, so stdout holds only the JSON document. + * + * @example + * const status = updateStatus((text) => process.stdout.write(text), { isTty: true, json: false }); + * status.pending('Checking for updates...'); + * status.say('Already on the latest version (1.5.1)'); + */ +export function updateStatus(write: (text: string) => void, opts: { isTty: boolean; json: boolean }) { + + let lineOpen = false; + + const raw = (text: string) => { + + lineOpen = !text.endsWith('\n'); + write(text); + + }; + + return { + /** Writes text as given; the download renderer draws through this. */ + raw, + + say(line: string) { + + if (opts.json) return; + + raw(lineOpen ? `\r${line.padEnd(STATUS_LINE_WIDTH)}\n` : `${line}\n`); + + }, + + pending(line: string) { + + if (opts.json) return; + + raw(opts.isTty ? line : `${line}\n`); + + }, + + /** Ends a line left open, so what prints next starts on a line of its own. */ + endLine() { + + if (lineOpen) raw('\n'); + + }, + }; + +} + +/** + * Builds the `\r`-rewritten download line for a terminal, or null off one: + * piped output has no cursor to rewind, so every tick would land on its own + * line. The tick that reaches the total writes `Downloaded X MB (100%)` and + * ends the line; ticks after it write nothing. + * + * @example + * const render = downloadProgressRenderer((text) => process.stdout.write(text), true); + * observer.on('update:progress', (progress) => render?.(progress)); + */ +export function downloadProgressRenderer( + write: (text: string) => void, + isTty: boolean, +): ((progress: { received: number; total: number }) => void) | null { + + let done = false; + + if (!isTty) return null; + + return ({ received, total }) => { + + if (done) return; + + if (total > 0 && received >= total) { + + done = true; + write(`\r${`Downloaded ${toMb(total)} MB (100%)`.padEnd(STATUS_LINE_WIDTH)}\n`); + + return; + + } + + const line = total > 0 + ? `Downloading ${toMb(received)} / ${toMb(total)} MB (${Math.floor((received * 100) / total)}%)` + : `Downloading ${toMb(received)} MB`; + + write(`\r${line.padEnd(STATUS_LINE_WIDTH)}`); + + }; + +} + +/** Every way to install the skill, one per line, with what each needs. */ +function skillInstallOptions(): string { + + const width = Math.max(...SKILL_INSTALLERS.map((installer) => skillCommand(installer).length)); + + return SKILL_INSTALLERS + .map((installer) => ` ${skillCommand(installer).padEnd(width)} (${installer.bin}: ${installer.provider})`) + .join('\n'); + +} + +/** + * Offer to install or update the noorm agent skill once the binary is + * updated, through the first installer on PATH. With none, or when the + * installer fails, it prints every way to install the skill. The update + * already succeeded, so nothing here changes the exit code. + * + * @example + * if (result.success) await offerSkill(args.json); */ export async function offerSkill( json: boolean | undefined, @@ -34,32 +152,44 @@ export async function offerSkill( if (json) return; + const installer = findSkillInstaller(); + + if (!installer) { + + process.stdout.write(`Agent skill: install npx or gh, then run its command:\n${skillInstallOptions()}\n`); + + return; + + } + + const command = skillCommand(installer); + if (!interactive) { - process.stdout.write(`Agent skill: run \`${SKILL_COMMAND}\` to install or update it.\n`); + process.stdout.write(`Agent skill: run \`${command}\` to install or update it.\n`); return; } const answer = await p.confirm({ - message: `Install or update the noorm agent skill? (${SKILL_COMMAND}, run in ${process.cwd()})`, + message: `Install or update the noorm agent skill? (${command}, run in ${process.cwd()})`, initialValue: true, }); if (p.isCancel(answer) || !answer) { - process.stdout.write(`Skipped. Run \`${SKILL_COMMAND}\` any time.\n`); + process.stdout.write(`Skipped. Run \`${command}\` any time.\n`); return; } - const [code, spawnErr] = await attempt(() => installSkill()); + const [code, spawnErr] = await attempt(() => installSkill(installer)); if (spawnErr) { - process.stderr.write(`Could not run npx (${spawnErr.message}). Install Node.js, then run: ${SKILL_COMMAND}\n`); + process.stderr.write(`Could not run ${installer.bin} (${spawnErr.message}). Install the skill with either:\n${skillInstallOptions()}\n`); return; @@ -67,7 +197,7 @@ export async function offerSkill( if (code !== 0) { - process.stderr.write(`Skill install exited with code ${code}. Re-run: ${SKILL_COMMAND}\n`); + process.stderr.write(`Skill install exited with code ${code}. Install the skill with either:\n${skillInstallOptions()}\n`); } @@ -88,9 +218,10 @@ const updateCommand = defineCommand({ async run({ args }) { const currentVersion = getCurrentVersion(); + const isTty = Boolean(process.stdout.isTTY) && !args.json; + const status = updateStatus((text) => process.stdout.write(text), { isTty, json: Boolean(args.json) }); - process.stdout.write(`Current version: ${currentVersion}\n`); - process.stdout.write('Checking for updates...\n'); + status.pending('Checking for updates...'); const [checkResult, checkErr] = await attempt(() => checkForUpdate()); @@ -116,6 +247,7 @@ const updateCommand = defineCommand({ } else { + status.endLine(); outputError(args, errorMsg); } @@ -126,7 +258,7 @@ const updateCommand = defineCommand({ if (!checkResult.updateAvailable) { - process.stdout.write(`Already on the latest version (${currentVersion})\n`); + status.say(`Already on the latest version (${currentVersion})`); if (args.json) { @@ -143,64 +275,40 @@ const updateCommand = defineCommand({ } - process.stdout.write(`Update available: ${currentVersion} → ${checkResult.latestVersion}\n`); + const latestVersion = checkResult.latestVersion; - // Render a live progress line for the binary download, TTY only. Piped - // output (CI, JSON mode) has no cursor to rewind, so every tick would - // land on its own line and bury the log under a few hundred of them. - // The static "Installing" line below is that case's only progress note. - const isTty = Boolean(process.stdout.isTTY) && !args.json; + status.say(`Update available: ${currentVersion} → ${latestVersion}`); - const onProgress = ({ received, total }: { received: number; total: number }) => { + const insecure = isInsecureMode(args); - if (!isTty) return; + if (insecure) { - const pct = total > 0 ? ` (${Math.floor((received / total) * 100)}%)` : ''; - const of = total > 0 ? ` / ${toMb(total)}` : ''; - const line = `Downloading ${toMb(received)}${of} MB${pct}`; + process.stderr.write('Warning: checksum verification will be skipped if checksums.txt is unreachable (--insecure).\n'); - process.stdout.write(`\r${line} `); + } - }; + const renderProgress = downloadProgressRenderer(status.raw, isTty); - const onRetry = ({ attempt: n, maxAttempts, error }: { attempt: number; maxAttempts: number; error: string }) => { + status.pending(isTty ? 'Downloading...' : 'Installing (downloading binary)...'); - if (args.json) return; + const onProgress = (progress: { received: number; total: number }) => renderProgress?.(progress); - // End the in-place progress line before printing the notice. - const prefix = isTty ? '\n' : ''; + const onRetry = ({ attempt: n, maxAttempts, error }: { attempt: number; maxAttempts: number; error: string }) => { - process.stdout.write(`${prefix}${error} — resuming (attempt ${n + 1}/${maxAttempts})...\n`); + status.endLine(); + status.say(`${error} — resuming (attempt ${n + 1}/${maxAttempts})...`); }; - if (!args.json) { - - process.stdout.write(isTty ? 'Installing...\n' : 'Installing (downloading binary)...\n'); - - } - - const insecure = isInsecureMode(args); - - if (insecure) { - - process.stderr.write('Warning: checksum verification will be skipped if checksums.txt is unreachable (--insecure).\n'); - - } - observer.on('update:progress', onProgress); observer.on('update:retry', onRetry); - const [result, installErr] = await attempt(() => installUpdate(checkResult.latestVersion, { insecure })); + const [result, installErr] = await attempt(() => installUpdate(latestVersion, { insecure })); observer.off('update:progress', onProgress); observer.off('update:retry', onRetry); - if (isTty) { - - process.stdout.write('\n'); - - } + status.endLine(); if (installErr || !result) { @@ -214,7 +322,7 @@ const updateCommand = defineCommand({ success: false, error: errorMsg, currentVersion, - latestVersion: checkResult.latestVersion, + latestVersion, updateAvailable: true, installed: false, }, ''); @@ -232,7 +340,7 @@ const updateCommand = defineCommand({ if (result.success) { - process.stdout.write(`Updated to ${result.newVersion}. Restart noorm to use the new version.\n`); + status.say(`Updated to ${result.newVersion}. Restart noorm to use the new version.`); await offerSkill(args.json); @@ -251,7 +359,7 @@ const updateCommand = defineCommand({ outputResult(args, { success: result.success, currentVersion, - latestVersion: checkResult.latestVersion, + latestVersion, updateAvailable: true, installed: result.success, error: result.error ?? undefined, diff --git a/src/core/project-init.ts b/src/core/project-init.ts index 9087dbb6..d64245bf 100644 --- a/src/core/project-init.ts +++ b/src/core/project-init.ts @@ -49,7 +49,15 @@ const NOORM_IGNORE_ENTRY = '.noorm/state/'; const NOORM_GITIGNORE_BLOCK = `\n# noorm\n${NOORM_IGNORE_ENTRY}\n`; -const AGENTS_TEMPLATE = 'Use the `/noorm` skill for managing files here.\n\n\n'; +const AGENTS_TEMPLATE = [ + 'Use the `/noorm` skill for managing files here.', + '', + 'For data modeling, use ignatius (https://noorm.dev/modeling/) and its `/ignatius-modeling` skill ' + + '(https://noorm.dev/modeling/modeling-skill).', + '', + '', + '', +].join('\n'); // Claude Code skips AGENTS.md when a CLAUDE.md is present, so CLAUDE.md imports it. const CLAUDE_TEMPLATE = '@AGENTS.md\n'; diff --git a/src/core/update/skill.ts b/src/core/update/skill.ts index a351a02c..4fa749b1 100644 --- a/src/core/update/skill.ts +++ b/src/core/update/skill.ts @@ -1,14 +1,67 @@ /** * Install or update the noorm agent skill. * - * The skill ships from this repo's `skills/` directory and is installed with - * the `skills` CLI, which asks which coding agents to install it into. + * The skill ships from this repo's `skills/` directory. Either the `skills` + * CLI (through npx) or the GitHub CLI can install it, and both ask which + * coding agents to install it into. */ import { spawn } from 'node:child_process'; -export const SKILL_SOURCE = 'noormdev/noorm/skills'; +/** A program that can install the skill, and how to reach it when missing. */ +export interface SkillInstaller { -export const SKILL_COMMAND = `npx skills add ${SKILL_SOURCE}`; + /** Executable looked up on PATH. */ + bin: 'npx' | 'gh'; + + args: string[]; + + /** What the user installs to get `bin`, and where. */ + provider: string; + +} + +/** In order of preference: npx is what the docs teach, gh is the fallback. */ +export const SKILL_INSTALLERS: readonly [SkillInstaller, SkillInstaller] = [ + { + bin: 'npx', + args: ['skills', 'add', 'noormdev/noorm/skills'], + provider: 'Node.js, https://nodejs.org', + }, + { + bin: 'gh', + args: ['skill', 'install', 'noormdev/noorm', 'skills/noorm'], + provider: 'GitHub CLI 2.90 or newer, https://cli.github.com', + }, +]; + +/** + * The installer as a command line to show the user. + * + * @example + * skillCommand(SKILL_INSTALLERS[0]); // 'npx skills add noormdev/noorm/skills' + */ +export function skillCommand(installer: SkillInstaller): string { + + return [installer.bin, ...installer.args].join(' '); + +} + +/** + * The first installer whose program is on PATH, or null when there is none. + * Checked before prompting, so the prompt names the command that will run. + * + * @example + * const installer = findSkillInstaller(); + * if (!installer) printBothOptions(); + */ +export function findSkillInstaller(): SkillInstaller | null { + + // Bun.which otherwise reads the PATH the process started with + const PATH = process.env['PATH'] ?? ''; + + return SKILL_INSTALLERS.find((installer) => Bun.which(installer.bin, { PATH }) !== null) ?? null; + +} /** * Whether `noorm update` may ask about the skill: a person is at the terminal. @@ -26,27 +79,27 @@ export function canPromptForSkill(opts: { } /** - * Run `npx skills add noormdev/noorm/skills` attached to the terminal, so the - * skills CLI can ask which agents to install into. Running it again replaces - * an installed copy. Rejects only when `npx` cannot be started. + * Run the installer attached to the terminal, so it can ask which agents to + * install into. Running it again replaces an installed copy. Rejects only + * when the program cannot be started. * * @example - * const [code, err] = await attempt(() => installSkill()); + * const [code, err] = await attempt(() => installSkill(installer)); */ -export function installSkill(): Promise { +export function installSkill(installer: SkillInstaller): Promise { const isWindows = process.platform === 'win32'; return new Promise((resolve, reject) => { - const child = spawn('npx', ['skills', 'add', SKILL_SOURCE], { + const child = spawn(installer.bin, installer.args, { stdio: 'inherit', // npx is npx.cmd on Windows, which Node refuses to spawn without a shell shell: isWindows, }); child.on('error', reject); - // A signal (Ctrl-C at the skills prompt) leaves no exit code; that is a failure + // A signal (Ctrl-C at the installer's prompt) leaves no exit code; that is a failure child.on('exit', (code) => resolve(code ?? 1)); }); diff --git a/tests/cli/update.test.ts b/tests/cli/update.test.ts new file mode 100644 index 00000000..45482ed0 --- /dev/null +++ b/tests/cli/update.test.ts @@ -0,0 +1,191 @@ +/** + * The subprocess runs the dist build, whose version is 0.0.0-dev, so the + * update check answers "up to date" without touching the network. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'bun:test'; +import { mkdir } from 'node:fs/promises'; + +import { downloadProgressRenderer, updateStatus } from '../../src/cli/update.js'; +import { TMP_BASE, cleanupProject, runCli, setupProject, type TestProject } from './run/_setup.js'; + +const MB = 1024 * 1024; + +describe('cli: update status lines', () => { + + function status(opts: { isTty: boolean; json: boolean }) { + + const writes: string[] = []; + + return { writes, status: updateStatus((text) => writes.push(text), opts) }; + + } + + it('should replace a pending line in place on a terminal', () => { + + const { writes, status: lines } = status({ isTty: true, json: false }); + + lines.pending('Checking for updates...'); + lines.say('Update available: 1.5.0 → 1.5.1'); + + expect(writes[0]).toBe('Checking for updates...'); + expect(writes[1]?.startsWith('\rUpdate available: 1.5.0 → 1.5.1')).toBe(true); + expect(writes[1]?.endsWith('\n')).toBe(true); + + }); + + it('should print every line in full off a terminal', () => { + + const { writes, status: lines } = status({ isTty: false, json: false }); + + lines.pending('Checking for updates...'); + lines.say('Update available: 1.5.0 → 1.5.1'); + + expect(writes).toEqual(['Checking for updates...\n', 'Update available: 1.5.0 → 1.5.1\n']); + + }); + + it('should print nothing under --json', () => { + + const { writes, status: lines } = status({ isTty: false, json: true }); + + lines.pending('Checking for updates...'); + lines.say('Already on the latest version (1.5.1)'); + lines.endLine(); + + expect(writes).toEqual([]); + + }); + + // A resumed download keeps the progress it had, so the notice must not overwrite it. + it('should put a retry notice on its own line below the download line', () => { + + const { writes, status: lines } = status({ isTty: true, json: false }); + const render = downloadProgressRenderer(lines.raw, true); + + render?.({ received: 5 * MB, total: 10 * MB }); + lines.endLine(); + lines.say('download stalled — resuming (attempt 2/5)...'); + + expect(writes[1]).toBe('\n'); + expect(writes[2]).toBe('download stalled — resuming (attempt 2/5)...\n'); + + }); + + it('should end an open download line before an error, and add nothing once it is closed', () => { + + const { writes, status: lines } = status({ isTty: true, json: false }); + const render = downloadProgressRenderer(lines.raw, true); + + render?.({ received: 5 * MB, total: 10 * MB }); + lines.endLine(); + lines.endLine(); + + expect(writes.slice(1)).toEqual(['\n']); + + }); + +}); + +describe('cli: update download progress', () => { + + function render(isTty: boolean) { + + const writes: string[] = []; + const renderer = downloadProgressRenderer((text) => writes.push(text), isTty); + + return { writes, renderer }; + + } + + it('should render nothing off a terminal, where every tick would be its own line', () => { + + expect(render(false).renderer).toBeNull(); + + }); + + it('should rewrite one line in place while the download runs', () => { + + const { writes, renderer } = render(true); + + renderer?.({ received: 5 * MB, total: 10 * MB }); + renderer?.({ received: 6 * MB, total: 10 * MB }); + + expect(writes[0]?.startsWith('\rDownloading 5.0 / 10.0 MB (50%)')).toBe(true); + expect(writes[1]?.startsWith('\rDownloading 6.0 / 10.0 MB (60%)')).toBe(true); + expect(writes.some((text) => text.includes('\n'))).toBe(false); + + }); + + // A rewrite shorter than the line it replaces must not leave its tail behind. + it('should pad every rewrite to the same width', () => { + + const { writes, renderer } = render(true); + + renderer?.({ received: 999 * MB, total: 1000 * MB }); + renderer?.({ received: 1 * MB, total: 0 }); + + expect(writes[1]?.length).toBe(writes[0]?.length); + + }); + + it('should end on a Downloaded line and ignore ticks after it', () => { + + const { writes, renderer } = render(true); + + renderer?.({ received: 10 * MB, total: 10 * MB }); + renderer?.({ received: 10 * MB, total: 10 * MB }); + + expect(writes).toHaveLength(1); + expect(writes[0]?.startsWith('\rDownloaded 10.0 MB (100%)')).toBe(true); + expect(writes[0]?.endsWith('\n')).toBe(true); + + }); + + it('should show megabytes alone when the size is unknown', () => { + + const { writes, renderer } = render(true); + + renderer?.({ received: 3 * MB, total: 0 }); + + expect(writes[0]?.startsWith('\rDownloading 3.0 MB')).toBe(true); + expect(writes[0]).not.toContain('%'); + + }); + +}); + +describe('cli: update --json', () => { + + let project: TestProject; + + beforeAll(async () => { + + await mkdir(TMP_BASE, { recursive: true }); + + }); + + beforeEach(async () => { + + project = await setupProject(); + + }); + + afterEach(async () => { + + await cleanupProject(project); + + }); + + // Status lines printed ahead of the document made `noorm update --json | jq` fail. + it('should write exactly one JSON document to stdout', () => { + + const { stdout, status } = runCli(project, ['update', '--json']); + + const payload = JSON.parse(stdout); + + expect(status).toBe(0); + expect(payload).toMatchObject({ success: true, updateAvailable: false, installed: false }); + + }); + +}); diff --git a/tests/core/project-init.test.ts b/tests/core/project-init.test.ts index 4680b652..bb537972 100644 --- a/tests/core/project-init.test.ts +++ b/tests/core/project-init.test.ts @@ -238,6 +238,8 @@ describe('core: scaffoldDbLayout', () => { const claude = readFileSync(join(tmpDir, 'db', 'CLAUDE.md'), 'utf-8'); expect(agents).toContain('Use the `/noorm` skill for managing files here.'); + expect(agents).toContain('`/ignatius-modeling` skill'); + expect(agents).toContain('https://noorm.dev/modeling/'); expect(agents).toContain(''); expect(claude.split('\n')).toContain('@AGENTS.md'); diff --git a/tests/core/update/skill.test.ts b/tests/core/update/skill.test.ts index 7a41dac6..d354a36b 100644 --- a/tests/core/update/skill.test.ts +++ b/tests/core/update/skill.test.ts @@ -7,22 +7,38 @@ import { attempt } from '@logosdx/utils'; import { offerSkill } from '../../../src/cli/update.js'; import { - SKILL_COMMAND, + SKILL_INSTALLERS, canPromptForSkill, + findSkillInstaller, installSkill, + skillCommand, } from '../../../src/core/update/skill.js'; +const [NPX, GH] = SKILL_INSTALLERS; + describe('update: skill', () => { let binDir: string; let originalPath: string | undefined; let originalArgsFile: string | undefined; + /** Put a fake program on the test PATH that records its arguments and exits with `code`. */ + async function fakeBin(name: string, code = 0): Promise { + + await writeFile(join(binDir, name), `#!/bin/sh\nprintf '%s\\n' "$@" > "$FAKE_ARGS_FILE"\nexit ${code}\n`); + await chmod(join(binDir, name), 0o755); + + } + beforeEach(async () => { binDir = await mkdtemp(join(tmpdir(), 'noorm-skill-bin-')); originalPath = process.env['PATH']; - originalArgsFile = process.env['NPX_ARGS_FILE']; + originalArgsFile = process.env['FAKE_ARGS_FILE']; + + // Only the fakes are reachable, so the machine's own npx and gh never count. + process.env['PATH'] = binDir; + process.env['FAKE_ARGS_FILE'] = join(binDir, 'args.txt'); }); @@ -30,43 +46,75 @@ describe('update: skill', () => { process.env['PATH'] = originalPath; - if (originalArgsFile === undefined) delete process.env['NPX_ARGS_FILE']; - else process.env['NPX_ARGS_FILE'] = originalArgsFile; + if (originalArgsFile === undefined) delete process.env['FAKE_ARGS_FILE']; + else process.env['FAKE_ARGS_FILE'] = originalArgsFile; await rm(binDir, { recursive: true, force: true }); }); - describe('installSkill', () => { + describe('findSkillInstaller', () => { - it.skipIf(process.platform === 'win32')('should run `npx skills add` for the noorm skill and return its exit code', async () => { + it.skipIf(process.platform === 'win32')('should prefer npx when both npx and gh are installed', async () => { + + await fakeBin('npx'); + await fakeBin('gh'); + + expect(findSkillInstaller()).toBe(NPX); + + }); + + // Node.js is not required to run the noorm binary, so a machine + // without npx may still have the GitHub CLI. + it.skipIf(process.platform === 'win32')('should fall back to gh when npx is missing', async () => { + + await fakeBin('gh'); + + expect(findSkillInstaller()).toBe(GH); + + }); + + it('should find nothing when neither npx nor gh is installed', () => { + + expect(findSkillInstaller()).toBeNull(); + + }); + + }); - const argsFile = join(binDir, 'args.txt'); + describe('installSkill', () => { - await writeFile(join(binDir, 'npx'), '#!/bin/sh\nprintf \'%s\\n\' "$@" > "$NPX_ARGS_FILE"\nexit 3\n'); - await chmod(join(binDir, 'npx'), 0o755); + it.skipIf(process.platform === 'win32')('should run `npx skills add` for the noorm skill and return its exit code', async () => { - process.env['PATH'] = `${binDir}:${originalPath ?? ''}`; - process.env['NPX_ARGS_FILE'] = argsFile; + await fakeBin('npx', 3); - const code = await installSkill(); - const args = (await readFile(argsFile, 'utf-8')).trim().split('\n'); + const code = await installSkill(NPX); + const args = (await readFile(join(binDir, 'args.txt'), 'utf-8')).trim().split('\n'); expect(code).toBe(3); expect(args).toEqual(['skills', 'add', 'noormdev/noorm/skills']); }); - // Node.js is not required to run the noorm binary, so a missing npx has - // to surface as an error the caller can explain, not a crash. - it.skipIf(process.platform === 'win32')('should reject when npx is not installed', async () => { + // An exact skill path skips gh's search of the whole repository. + it.skipIf(process.platform === 'win32')('should run `gh skill install` with the skill path', async () => { - process.env['PATH'] = binDir; + await fakeBin('gh'); - const [, err] = await attempt(() => installSkill()); + const code = await installSkill(GH); + const args = (await readFile(join(binDir, 'args.txt'), 'utf-8')).trim().split('\n'); + + expect(code).toBe(0); + expect(args).toEqual(['skill', 'install', 'noormdev/noorm', 'skills/noorm']); + + }); + + it.skipIf(process.platform === 'win32')('should reject when the program cannot be started', async () => { + + const [, err] = await attempt(() => installSkill(NPX)); expect(err).toBeInstanceOf(Error); - expect((err as NodeJS.ErrnoException).code).toBe('ENOENT'); + expect(err).toHaveProperty('code', 'ENOENT'); }); @@ -85,32 +133,49 @@ describe('update: skill', () => { }); - // The prompt needs a terminal; these are the paths that stay quiet or print the command. + // The prompt needs a terminal; these are the paths that stay quiet or print commands. describe('offerSkill', () => { - it('should print nothing for --json, even on a terminal', async () => { + async function printedBy(run: () => Promise): Promise { const write = spyOn(process.stdout, 'write').mockImplementation(() => true); - await offerSkill(true, true); + await run(); - expect(write).not.toHaveBeenCalled(); + const printed = write.mock.calls.map((call) => String(call[0])).join(''); write.mockRestore(); + return printed; + + } + + it('should print nothing for --json, even on a terminal', async () => { + + expect(await printedBy(() => offerSkill(true, true))).toBe(''); + }); - it('should print the command instead of prompting when there is no terminal', async () => { + it.skipIf(process.platform === 'win32')('should print the command it would run when there is no terminal', async () => { - const write = spyOn(process.stdout, 'write').mockImplementation(() => true); + await fakeBin('gh'); - await offerSkill(false, false); + const printed = await printedBy(() => offerSkill(false, false)); - const printed = write.mock.calls.map((call) => String(call[0])).join(''); + expect(printed).toContain(skillCommand(GH)); + expect(printed).not.toContain(skillCommand(NPX)); - expect(printed).toContain(SKILL_COMMAND); + }); - write.mockRestore(); + // With nothing to run, the user needs both ways in and where to get them. + it('should print both commands and where to get each program when neither is installed', async () => { + + const printed = await printedBy(() => offerSkill(false, true)); + + expect(printed).toContain(skillCommand(NPX)); + expect(printed).toContain(skillCommand(GH)); + expect(printed).toContain('https://nodejs.org'); + expect(printed).toContain('https://cli.github.com'); }); From 472cefc13d0a1fd6466228995d172174de90f5be Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Fri, 23 Oct 2026 00:33:59 -0400 Subject: [PATCH 6/6] docs: rewrite pages around examples, diagrams, and tables Every user-facing page now opens on the thing working, uses the order-to-cash schema, and puts caveats in a what-bites table. 37 mermaid diagrams are new; claims were checked against src/. Also: landing and /modeling redesign, download page, ignatius.sh short installer, ModelViewer, and a brand mermaid theme that holds renders until Geist loads. --- .claude/rules/documentation.md | 2 +- CLAUDE.md | 19 +- docs/.vitepress/config.mts | 50 +- docs/.vitepress/mermaid.ts | 96 + docs/.vitepress/theme/HeroEyebrow.vue | 23 - docs/.vitepress/theme/HeroStats.vue | 26 - docs/.vitepress/theme/HeroTerminal.vue | 156 -- docs/.vitepress/theme/ModelViewer.vue | 65 + docs/.vitepress/theme/brand.css | 173 +- docs/.vitepress/theme/index.ts | 33 +- docs/.vitepress/theme/landing.css | 645 +++++ docs/cli/flags.md | 121 +- docs/cli/help.md | 64 +- docs/cli/identity.md | 175 +- docs/cli/init.md | 126 +- docs/cli/run.md | 244 +- docs/cli/secret.md | 135 +- docs/cli/settings-edit.md | 33 +- docs/cli/settings-secret.md | 43 +- docs/cli/sql-repl.md | 39 +- docs/cli/sql.md | 181 +- docs/design/site-running-example.md | 438 ++++ docs/download.md | 109 + docs/getting-started/building-your-sdk.md | 763 ++---- docs/getting-started/concepts.md | 389 +-- docs/getting-started/first-build.md | 378 +-- docs/getting-started/installation.md | 177 +- docs/guide/automation/ci.md | 359 +-- docs/guide/automation/mcp.md | 151 +- docs/guide/automation/non-interactive.md | 157 +- docs/guide/changes/add-a-column.md | 120 + docs/guide/changes/forward-revert.md | 333 +-- docs/guide/changes/history.md | 367 +-- docs/guide/changes/overview.md | 427 +--- docs/guide/database/create.md | 211 +- docs/guide/database/explore.md | 538 +---- docs/guide/database/teardown.md | 412 +--- docs/guide/database/terminal.md | 292 +-- docs/guide/database/transfer.md | 415 +--- docs/guide/deployment.md | 181 +- docs/guide/environments/configs.md | 438 ++-- docs/guide/environments/secrets.md | 223 +- docs/guide/environments/stages.md | 259 +- docs/guide/environments/vault.md | 268 +-- docs/guide/relational-design.md | 351 +-- docs/guide/sql-files/execution.md | 287 +-- docs/guide/sql-files/organization.md | 360 +-- docs/guide/sql-files/templates.md | 544 +---- docs/guide/troubleshooting.md | 156 +- docs/headless.md | 1906 ++++++--------- docs/index.md | 269 ++- docs/modeling/best-practices.md | 150 +- docs/modeling/branding.md | 101 +- docs/modeling/data-flows.md | 204 +- docs/modeling/entities.md | 427 ++-- docs/modeling/index.md | 189 +- docs/modeling/installation.md | 185 +- docs/modeling/model-to-sql.md | 119 + docs/modeling/modeling-skill.md | 107 +- docs/modeling/reverse-engineering.md | 218 +- docs/modeling/why.md | 95 + .../order-to-cash/data/billing/invoice.md | 28 + .../data/billing/invoice_line.md | 50 + .../order-to-cash/data/billing/payment.md | 30 + .../data/billing/payment_allocation.md | 47 + .../order-to-cash/data/parties/business.md | 30 + .../order-to-cash/data/parties/party.md | 40 + .../order-to-cash/data/parties/party_type.md | 22 + .../order-to-cash/data/parties/person.md | 26 + .../order-to-cash/data/sales/order_line.md | 49 + .../order-to-cash/data/sales/product.md | 32 + .../order-to-cash/data/sales/sales_order.md | 24 + .../order-to-cash/externals/Customer.md | 5 + .../flows/order-to-cash/Collect-Payment.md | 27 + .../flows/order-to-cash/Issue-Invoice.md | 21 + .../flows/order-to-cash/Place-Order.md | 21 + docs/models/order-to-cash/groups/billing.md | 6 + docs/models/order-to-cash/groups/parties.md | 6 + docs/models/order-to-cash/groups/sales.md | 6 + docs/models/order-to-cash/ignatius.yml | 7 + .../order-to-cash/stores/gateway-log.md | 6 + .../polymorphic/data/content/comments.md | 28 - .../models/polymorphic/data/content/photos.md | 28 - docs/models/polymorphic/data/content/posts.md | 28 - docs/models/polymorphic/data/content/tags.md | 28 - .../polymorphic/data/identity/groups.md | 18 - .../polymorphic/data/identity/profiles.md | 25 - .../models/polymorphic/data/identity/users.md | 18 - docs/models/polymorphic/groups/content.md | 6 - docs/models/polymorphic/groups/identity.md | 6 - docs/models/polymorphic/ignatius.yml | 8 - .../social/data/content/comment_comments.md | 29 - docs/models/social/data/content/comments.md | 34 - .../social/data/content/group_comments.md | 29 - .../social/data/content/group_photos.md | 29 - .../models/social/data/content/group_posts.md | 29 - docs/models/social/data/content/photos.md | 34 - .../social/data/content/post_comments.md | 29 - docs/models/social/data/content/posts.md | 30 - .../social/data/content/profile_photos.md | 29 - .../social/data/content/user_comments.md | 29 - .../models/social/data/content/user_photos.md | 29 - .../social/data/content/user_post_photos.md | 29 - docs/models/social/data/content/user_posts.md | 29 - docs/models/social/data/identity/groups.md | 18 - docs/models/social/data/identity/profiles.md | 24 - docs/models/social/data/identity/users.md | 18 - .../social/data/tagging/comment_tags.md | 29 - docs/models/social/data/tagging/photo_tags.md | 29 - docs/models/social/data/tagging/post_tags.md | 29 - docs/models/social/data/tagging/tags.md | 32 - docs/models/social/groups/content.md | 6 - docs/models/social/groups/identity.md | 6 - docs/models/social/groups/tagging.md | 6 - docs/models/social/ignatius.yml | 12 - docs/models/todo-list/data/core/todo_items.md | 55 - docs/models/todo-list/data/core/todos.md | 40 - docs/models/todo-list/data/core/users.md | 29 - docs/models/todo-list/groups/core.md | 6 - docs/models/todo-list/ignatius.yml | 8 - docs/parts/ignatius-cmds.md | 21 + docs/parts/ignatius-dfd.md | 19 + docs/parts/ignatius-problems.md | 22 + docs/parts/ignatius-viewer.md | 32 + docs/public/icons/bolt.svg | 1 - docs/public/icons/cubes.svg | 1 - docs/public/icons/database.svg | 1 - docs/public/icons/fast-forward.svg | 1 - docs/public/icons/flask.svg | 1 - docs/public/icons/git-branch.svg | 1 - docs/public/icons/lock.svg | 1 - docs/public/icons/terminal.svg | 1 - docs/public/icons/toolbox.svg | 1 - docs/public/icons/users.svg | 1 - docs/public/ignatius.sh | 10 + docs/public/models/order-to-cash-dark.html | 640 +++++ docs/public/models/order-to-cash.html | 640 +++++ docs/public/models/polymorphic.html | 629 ----- docs/public/models/social.html | 629 ----- docs/public/models/todo-list.html | 629 ----- docs/reference/sdk.md | 2108 ++++++----------- docs/tui.md | 564 ++--- docs/why-noorm.md | 113 +- 143 files changed, 9138 insertions(+), 13861 deletions(-) create mode 100644 docs/.vitepress/mermaid.ts delete mode 100644 docs/.vitepress/theme/HeroEyebrow.vue delete mode 100644 docs/.vitepress/theme/HeroStats.vue delete mode 100644 docs/.vitepress/theme/HeroTerminal.vue create mode 100644 docs/.vitepress/theme/ModelViewer.vue create mode 100644 docs/.vitepress/theme/landing.css create mode 100644 docs/design/site-running-example.md create mode 100644 docs/download.md create mode 100644 docs/guide/changes/add-a-column.md create mode 100644 docs/modeling/model-to-sql.md create mode 100644 docs/modeling/why.md create mode 100644 docs/models/order-to-cash/data/billing/invoice.md create mode 100644 docs/models/order-to-cash/data/billing/invoice_line.md create mode 100644 docs/models/order-to-cash/data/billing/payment.md create mode 100644 docs/models/order-to-cash/data/billing/payment_allocation.md create mode 100644 docs/models/order-to-cash/data/parties/business.md create mode 100644 docs/models/order-to-cash/data/parties/party.md create mode 100644 docs/models/order-to-cash/data/parties/party_type.md create mode 100644 docs/models/order-to-cash/data/parties/person.md create mode 100644 docs/models/order-to-cash/data/sales/order_line.md create mode 100644 docs/models/order-to-cash/data/sales/product.md create mode 100644 docs/models/order-to-cash/data/sales/sales_order.md create mode 100644 docs/models/order-to-cash/externals/Customer.md create mode 100644 docs/models/order-to-cash/flows/order-to-cash/Collect-Payment.md create mode 100644 docs/models/order-to-cash/flows/order-to-cash/Issue-Invoice.md create mode 100644 docs/models/order-to-cash/flows/order-to-cash/Place-Order.md create mode 100644 docs/models/order-to-cash/groups/billing.md create mode 100644 docs/models/order-to-cash/groups/parties.md create mode 100644 docs/models/order-to-cash/groups/sales.md create mode 100644 docs/models/order-to-cash/ignatius.yml create mode 100644 docs/models/order-to-cash/stores/gateway-log.md delete mode 100644 docs/models/polymorphic/data/content/comments.md delete mode 100644 docs/models/polymorphic/data/content/photos.md delete mode 100644 docs/models/polymorphic/data/content/posts.md delete mode 100644 docs/models/polymorphic/data/content/tags.md delete mode 100644 docs/models/polymorphic/data/identity/groups.md delete mode 100644 docs/models/polymorphic/data/identity/profiles.md delete mode 100644 docs/models/polymorphic/data/identity/users.md delete mode 100644 docs/models/polymorphic/groups/content.md delete mode 100644 docs/models/polymorphic/groups/identity.md delete mode 100644 docs/models/polymorphic/ignatius.yml delete mode 100644 docs/models/social/data/content/comment_comments.md delete mode 100644 docs/models/social/data/content/comments.md delete mode 100644 docs/models/social/data/content/group_comments.md delete mode 100644 docs/models/social/data/content/group_photos.md delete mode 100644 docs/models/social/data/content/group_posts.md delete mode 100644 docs/models/social/data/content/photos.md delete mode 100644 docs/models/social/data/content/post_comments.md delete mode 100644 docs/models/social/data/content/posts.md delete mode 100644 docs/models/social/data/content/profile_photos.md delete mode 100644 docs/models/social/data/content/user_comments.md delete mode 100644 docs/models/social/data/content/user_photos.md delete mode 100644 docs/models/social/data/content/user_post_photos.md delete mode 100644 docs/models/social/data/content/user_posts.md delete mode 100644 docs/models/social/data/identity/groups.md delete mode 100644 docs/models/social/data/identity/profiles.md delete mode 100644 docs/models/social/data/identity/users.md delete mode 100644 docs/models/social/data/tagging/comment_tags.md delete mode 100644 docs/models/social/data/tagging/photo_tags.md delete mode 100644 docs/models/social/data/tagging/post_tags.md delete mode 100644 docs/models/social/data/tagging/tags.md delete mode 100644 docs/models/social/groups/content.md delete mode 100644 docs/models/social/groups/identity.md delete mode 100644 docs/models/social/groups/tagging.md delete mode 100644 docs/models/social/ignatius.yml delete mode 100644 docs/models/todo-list/data/core/todo_items.md delete mode 100644 docs/models/todo-list/data/core/todos.md delete mode 100644 docs/models/todo-list/data/core/users.md delete mode 100644 docs/models/todo-list/groups/core.md delete mode 100644 docs/models/todo-list/ignatius.yml create mode 100644 docs/parts/ignatius-cmds.md create mode 100644 docs/parts/ignatius-dfd.md create mode 100644 docs/parts/ignatius-problems.md create mode 100644 docs/parts/ignatius-viewer.md delete mode 100644 docs/public/icons/bolt.svg delete mode 100644 docs/public/icons/cubes.svg delete mode 100644 docs/public/icons/database.svg delete mode 100644 docs/public/icons/fast-forward.svg delete mode 100644 docs/public/icons/flask.svg delete mode 100644 docs/public/icons/git-branch.svg delete mode 100644 docs/public/icons/lock.svg delete mode 100644 docs/public/icons/terminal.svg delete mode 100644 docs/public/icons/toolbox.svg delete mode 100644 docs/public/icons/users.svg create mode 100644 docs/public/ignatius.sh create mode 100644 docs/public/models/order-to-cash-dark.html create mode 100644 docs/public/models/order-to-cash.html delete mode 100644 docs/public/models/polymorphic.html delete mode 100644 docs/public/models/social.html delete mode 100644 docs/public/models/todo-list.html diff --git a/.claude/rules/documentation.md b/.claude/rules/documentation.md index 36ccf76a..a0de70db 100644 --- a/.claude/rules/documentation.md +++ b/.claude/rules/documentation.md @@ -26,6 +26,6 @@ Mark inference as inference. An extrapolation from a mechanism reads as a measur ## Structure -Lead with the problem the reader wants to solve, then the mechanism. Code follows the explanation that motivates it. +Lead with the problem the reader wants to solve, in one sentence. The example comes next, the file or command with its real output, and the explanation follows it. A reader should see the thing working before the first `##` heading. Prefer a table, tree, or diagram wherever the content has a shape. Reserve prose for reasoning and motivation, which is the one thing a table cannot carry. diff --git a/CLAUDE.md b/CLAUDE.md index 2b67db0e..a113cb86 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,18 +125,20 @@ For setup wizards where the target database may not exist yet, use `testConnecti | Path | Covers | Voice | |------|--------|-------| | `README.md` | project overview, install, quick start | atomic-writing | -| `docs/index.md` | docs landing, why noorm, quick start | atomic-writing | +| `docs/index.md` | landing: problems noorm and ignatius remove, how it works, FAQ | atomic-writing | +| `docs/download.md` | install noorm and ignatius, agent skills, SDK, install options | atomic-writing | | `docs/why-noorm.md` | rationale, prior tools, history | atomic-writing | | `docs/tui.md` | TUI screens, navigation, keyboard shortcuts | terse-technical | | `docs/headless.md` | CLI reference, global flags, command discovery | terse-technical | -| `docs/getting-started/installation.md` | requirements, CLI install, SDK install | atomic-writing | -| `docs/getting-started/concepts.md` | SQL files as source of truth, execution order, changes | atomic-writing | +| `docs/getting-started/installation.md` | CLI install, SDK install, agent skill, update, download verification | atomic-writing | +| `docs/getting-started/concepts.md` | db/sql vs db/changes, execution order, templates, configs and roles, secrets, what lives where | atomic-writing | | `docs/getting-started/first-build.md` | init, first build walkthrough | atomic-writing | -| `docs/getting-started/building-your-sdk.md` | monorepo setup, database package, SDK wiring | atomic-writing | +| `docs/getting-started/building-your-sdk.md` | database package layout, schema types, domain classes, client, tests | atomic-writing | | `docs/guide/automation/ci.md` | test CI, prod CI shapes | atomic-writing | | `docs/guide/automation/mcp.md` | MCP server, AI agent integration, tools | atomic-writing | | `docs/guide/automation/non-interactive.md` | --yes semantics, CI bootstrap | atomic-writing | -| `docs/guide/changes/overview.md` | changes vs migrations, directory structure | atomic-writing | +| `docs/guide/changes/overview.md` | change folder anatomy, file types, .txt manifests, ordering | atomic-writing | +| `docs/guide/changes/add-a-column.md` | adding a column: db/sql/ declaration, change with backfill, revert | atomic-writing | | `docs/guide/changes/forward-revert.md` | apply and revert lifecycle | atomic-writing | | `docs/guide/changes/history.md` | execution history, TUI history views | atomic-writing | | `docs/guide/database/create.md` | db create, configs-first workflow | atomic-writing | @@ -189,10 +191,13 @@ For setup wizards where the target database may not exist yet, use `testConnecti | `docs/dev/version.md` | version layers, migration | atomic-writing | | `docs/dev/ink-cheatsheet.md` | Ink API cheatsheet | terse-technical | | `docs/dev/ink-testing-library-cheatsheet.md` | ink-testing-library cheatsheet | terse-technical | -| `docs/modeling/index.md` | ignatius overview, IDEF1X modeling | atomic-writing | -| `docs/modeling/installation.md` | ignatius install | atomic-writing | +| `docs/modeling/index.md` | ignatius landing: data flows, live model, two-minute try-it, links into the section | atomic-writing | +| `docs/modeling/why.md` | case for modeling first, why a separate tool, the two notations, the name | atomic-writing | +| `docs/parts/` | fragments shared by the two landings through ``; not pages | atomic-writing | +| `docs/modeling/installation.md` | ignatius commands: install, serve, validate, export, index, approve, update | atomic-writing | | `docs/modeling/entities.md` | entity format, key inheritance | atomic-writing | | `docs/modeling/data-flows.md` | SSADM data flow diagrams | atomic-writing | +| `docs/modeling/model-to-sql.md` | entity file fields mapped to DDL, constraint naming | atomic-writing | | `docs/modeling/best-practices.md` | modeling best practices | atomic-writing | | `docs/modeling/branding.md` | model branding | atomic-writing | | `docs/modeling/modeling-skill.md` | /ignatius-modeling skill | atomic-writing | diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 8b94275b..279a0268 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -1,5 +1,6 @@ import { defineConfig } from 'vitepress'; import { withMermaid } from 'vitepress-plugin-mermaid'; +import { mermaid } from './mermaid'; // https://vitepress.dev/reference/site-config const SITE_URL = 'https://noorm.dev'; @@ -11,6 +12,7 @@ export default withMermaid( title: 'noorm', description: DESCRIPTION, base: process.env.VITEPRESS_BASE || '/', + mermaid, // `docs/wiki/` is generated repo-analysis output for tooling and // contributors, not published documentation. VitePress compiles every @@ -24,8 +26,9 @@ export default withMermaid( // `models/**` joins the list for the same reason as `tapes/**`: it is // source for a generated artifact, not pages. Left in, VitePress // compiles every entity file into a route like - // /models/todo-list/data/core/users.html. - srcExclude: ['wiki/**', 'spec/**', 'design/**', 'superpowers/**', 'tmp/**', 'tapes/**', 'models/**'], + // /models/todo-list/data/core/users.html. `parts/**` holds fragments + // that pages pull in with ``; they are not pages either. + srcExclude: ['wiki/**', 'spec/**', 'design/**', 'superpowers/**', 'tmp/**', 'tapes/**', 'models/**', 'parts/**'], markdown: { // The terminal recordings are the heaviest assets on the site @@ -85,15 +88,13 @@ gtag('config', 'G-Y69K95866J');`], // https://vitepress.dev/reference/default-theme-config logo: { light: '/image/logo.svg', dark: '/image/logo-dark.svg' }, siteTitle: 'noorm', - // No Home entry: the logo already links to `/`. Dev docs are not a - // top-bar concern either — they live in the Reference group of the - // main sidebar as "Contributor Documentation". + // No Home entry: the logo already links to `/`. Contributor docs are + // not a reader's concern, so they sit in the footer, not the nav. nav: [ - { text: 'Getting Started', link: '/getting-started/installation' }, - { text: 'Guide', link: '/guide/sql-files/organization' }, - { text: 'CLI', link: '/headless' }, - { text: 'TUI', link: '/tui' }, + { text: 'Docs', link: '/getting-started/installation' }, { text: 'Modeling', link: '/modeling/' }, + { text: 'Reference', link: '/headless' }, + { text: 'Download', link: '/download' }, ], sidebar: { @@ -149,7 +150,6 @@ gtag('config', 'G-Y69K95866J');`], { text: 'Getting Started', items: [ - { text: 'Why noorm', link: '/why-noorm' }, { text: 'Installation', link: '/getting-started/installation' }, { text: 'First Build', link: '/getting-started/first-build' }, { text: 'Building Your SDK', link: '/getting-started/building-your-sdk' }, @@ -160,15 +160,6 @@ gtag('config', 'G-Y69K95866J');`], // is what pins them open with no toggle. Every other group // is collapsible and starts closed, so the sidebar opens as // a short list of topics rather than a wall of links. - { - text: 'Features', - collapsed: true, - items: [ - { text: 'CLI Reference', link: '/headless' }, - { text: 'Terminal UI', link: '/tui' }, - { text: 'Relational Design', link: '/guide/relational-design' }, - ], - }, { // ignatius is a separate binary with its own release // cadence, so these pages carry the narrative and link @@ -178,13 +169,15 @@ gtag('config', 'G-Y69K95866J');`], collapsed: true, items: [ { text: 'Overview', link: '/modeling/' }, - { text: 'Installation', link: '/modeling/installation' }, + { text: 'Commands', link: '/modeling/installation' }, { text: 'Entities & Key Inheritance', link: '/modeling/entities' }, { text: 'Data Flows', link: '/modeling/data-flows' }, + { text: 'From Model to SQL', link: '/modeling/model-to-sql' }, { text: 'Modeling Skill', link: '/modeling/modeling-skill' }, { text: 'Reverse-Engineering', link: '/modeling/reverse-engineering' }, { text: 'Branding', link: '/modeling/branding' }, { text: 'Best Practices', link: '/modeling/best-practices' }, + { text: 'Why ignatius', link: '/modeling/why' }, ], }, { @@ -215,6 +208,7 @@ gtag('config', 'G-Y69K95866J');`], collapsed: true, items: [ { text: 'Overview', link: '/guide/changes/overview' }, + { text: 'Add a Column', link: '/guide/changes/add-a-column' }, { text: 'Forward & Revert', link: '/guide/changes/forward-revert' }, { text: 'History', link: '/guide/changes/history' }, ], @@ -241,8 +235,17 @@ gtag('config', 'G-Y69K95866J');`], { text: 'Reference', items: [ + { text: 'CLI', link: '/headless' }, { text: 'SDK', link: '/reference/sdk' }, - { text: 'Contributor Documentation', link: '/dev/' }, + { text: 'Terminal UI', link: '/tui' }, + ], + }, + { + text: 'Background', + collapsed: true, + items: [ + { text: 'Why noorm', link: '/why-noorm' }, + { text: 'Relational Design', link: '/guide/relational-design' }, ], }, ], @@ -268,15 +271,16 @@ gtag('config', 'G-Y69K95866J');`], }, }, + // Depth 4 because the CLI reference puts each command at ####. outline: { - level: [2, 3], + level: [2, 4], }, footer: { message: 'Database Schema & Change Manager', // Apache-2.0 to match LICENSE and every package.json in the repo. This // read "ISC" until 2026-08 and matched nothing that ships. - copyright: `© ${new Date().getFullYear()} noorm · GitHub · npm · Apache 2.0`, + copyright: `© ${new Date().getFullYear()} noorm · GitHub · npm · Apache 2.0 · Contributor docs`, }, }, }), diff --git a/docs/.vitepress/mermaid.ts b/docs/.vitepress/mermaid.ts new file mode 100644 index 00000000..c1af2956 --- /dev/null +++ b/docs/.vitepress/mermaid.ts @@ -0,0 +1,96 @@ +/** + * Mermaid look for the docs: solid brand-colored nodes with ink borders. + * + * Node fills and their ink are fixed in both themes. Connectors, edge labels, and the + * canvas read --mm-* custom properties set on .vp-doc .mermaid in brand.css, so a theme + * toggle needs no re-render. Anything that changes label size (font, size) lives here, + * because Mermaid measures labels with this config. HTML label weight is not set: Mermaid + * 11 measures it at the default weight, so a heavier one clips. + */ + +const INK = '#161A20'; +const CREAM = '#F2ECE0'; +const FILLS = ['#E05742', '#D2A47A', '#8DB26A', '#6FA0CC', CREAM]; + +const SHAPE = ':is(rect, polygon, circle, ellipse, path):not(:is(.row-rect-odd, .row-rect-even, .divider) *)'; +const NODE = `.node ${SHAPE}`; + +const cycle = FILLS.map( + (fill, i) => `.node:nth-child(${FILLS.length}n+${i + 1}) ${SHAPE} { fill: ${fill}; }`, +).join('\n'); + +const themeCSS = ` + ${NODE}, rect.actor, rect.note, .cluster rect, .labelBox { + stroke: ${INK}; + stroke-width: 2.5px; + rx: 8px; + ry: 8px; + filter: drop-shadow(3px 5px 7px rgba(0, 0, 0, 0.35)); + } + ${cycle} + .nodeLabel, .nodeLabel p, .label, .label p, .node .label, .node .label p { color: ${INK}; } + + .cluster rect { + fill: transparent; + filter: none; + stroke: var(--mm-dim); + stroke-width: 1.5px; + stroke-dasharray: 5 4; + } + .cluster-label .nodeLabel, .cluster-label p { color: var(--mm-line); } + + .flowchart-link, .edgePath path, .transition, .messageLine0, .messageLine1 { + stroke: var(--mm-line); + stroke-width: 2.5px; + fill: none; + } + /* ER markers scale with the line width (markerUnits=strokeWidth), so keep it thin. */ + .relationshipLine { stroke: var(--mm-line); stroke-width: 1.5px; fill: none; } + .arrowMarkerPath { fill: var(--mm-line); stroke: var(--mm-line); } + marker:not([id*="Arrow"]):not([id$="-pointEnd"]):not([id$="-pointStart"]) path { + fill: none; + stroke: var(--mm-line); + stroke-width: 1px; + } + marker circle { fill: var(--mm-canvas); stroke: var(--mm-line); stroke-width: 1px; } + marker:is([id$="-pointEnd"], [id$="-pointStart"]) { overflow: visible; } + marker[id$="-pointEnd"] .arrowMarkerPath { transform-origin: 10px 5px; transform: translateX(-1px) scale(1.2); } + marker[id$="-pointStart"] .arrowMarkerPath { transform-origin: 0 5px; transform: translateX(1px) scale(1.2); } + + .edgeLabel, .edgeLabel p, .labelBkg, .edgeLabel span { + background-color: var(--mm-canvas); + color: var(--mm-text); + } + .edgeLabel rect, .relationshipLabelBox { fill: var(--mm-canvas); opacity: 1; } + + rect.actor { fill: ${FILLS[1]}; } + text.actor > tspan, .actor tspan { fill: ${INK}; stroke: none; font-weight: 600; } + .actor-line { stroke: var(--mm-dim); stroke-width: 1.5px; stroke-dasharray: 5 4; } + .messageText { fill: var(--mm-text); stroke: none; } + rect.note { fill: ${CREAM}; } + .noteText, .noteText > tspan { fill: ${INK}; stroke: none; } + .loopText, .loopText > tspan, .labelText, .labelText > tspan { fill: var(--mm-text); stroke: none; } + .loopLine { stroke: var(--mm-dim); stroke-width: 1.5px; stroke-dasharray: 5 4; } + .activation0, .activation1, .activation2 { fill: ${CREAM}; stroke: ${INK}; } + + .state-start, .state-end circle:last-child { fill: var(--mm-text); stroke: var(--mm-text); filter: none; } + .end-state-inner { fill: var(--mm-canvas); } + + .row-rect-odd path { fill: ${CREAM}; } + .row-rect-even path { fill: #E7DFCF; } + .row-rect-odd path, .row-rect-even path, .divider path { stroke: ${INK}; stroke-width: 1.5px; } +`; + +export const mermaid = { + theme: 'base', + fontFamily: 'Geist, ui-sans-serif, system-ui, sans-serif', + flowchart: { padding: 14, nodeSpacing: 44, rankSpacing: 52, diagramPadding: 8 }, + sequence: { actorMargin: 56, messageMargin: 36, diagramMarginX: 8, diagramMarginY: 8 }, + themeVariables: { + fontFamily: 'Geist, ui-sans-serif, system-ui, sans-serif', + fontSize: '14px', + textColor: '#808080', + lineColor: '#808080', + }, + themeCSS, +}; diff --git a/docs/.vitepress/theme/HeroEyebrow.vue b/docs/.vitepress/theme/HeroEyebrow.vue deleted file mode 100644 index ac637718..00000000 --- a/docs/.vitepress/theme/HeroEyebrow.vue +++ /dev/null @@ -1,23 +0,0 @@ - - - diff --git a/docs/.vitepress/theme/HeroStats.vue b/docs/.vitepress/theme/HeroStats.vue deleted file mode 100644 index ee0072c6..00000000 --- a/docs/.vitepress/theme/HeroStats.vue +++ /dev/null @@ -1,26 +0,0 @@ - - - diff --git a/docs/.vitepress/theme/HeroTerminal.vue b/docs/.vitepress/theme/HeroTerminal.vue deleted file mode 100644 index 9df0ee53..00000000 --- a/docs/.vitepress/theme/HeroTerminal.vue +++ /dev/null @@ -1,156 +0,0 @@ - - - - - diff --git a/docs/.vitepress/theme/ModelViewer.vue b/docs/.vitepress/theme/ModelViewer.vue new file mode 100644 index 00000000..966b94b2 --- /dev/null +++ b/docs/.vitepress/theme/ModelViewer.vue @@ -0,0 +1,65 @@ + + + diff --git a/docs/.vitepress/theme/brand.css b/docs/.vitepress/theme/brand.css index 7ca20190..66337edf 100644 --- a/docs/.vitepress/theme/brand.css +++ b/docs/.vitepress/theme/brand.css @@ -80,6 +80,9 @@ --vp-code-block-bg: #F0EBE0; --vp-code-color: #212931; --vp-code-bg: rgba(69, 74, 89, 0.06); + + --noorm-bronze: #6E4A22; + --noorm-bronze-strong: #916336; } /* ───────────────────────────────────────────── @@ -113,6 +116,9 @@ --vp-code-color: #F2ECE0; --vp-code-bg: rgba(255, 255, 255, 0.06); + --noorm-bronze: #D2A47A; + --noorm-bronze-strong: #D2A47A; + --vp-button-brand-bg: #E05742; --vp-button-brand-hover-bg: #ED7561; --vp-button-brand-active-bg: #F49A8A; @@ -151,7 +157,7 @@ code, kbd, samp, pre { Body links — bronze for docs, ember on hover ───────────────────────────────────────────── */ .vp-doc a { - color: #6E4A22; + color: var(--noorm-bronze); text-decoration-color: rgba(110, 74, 34, 0.3); } @@ -161,7 +167,7 @@ code, kbd, samp, pre { } .dark .vp-doc a { - color: #D2A47A; + color: var(--noorm-bronze); text-decoration-color: rgba(210, 164, 122, 0.3); } @@ -310,116 +316,7 @@ code, kbd, samp, pre { } /* ───────────────────────────────────────────── - Buttons — override scoped VPButton radius - ───────────────────────────────────────────── */ -.VPHero .actions .action .VPButton.medium { - border-radius: 8px; - font-weight: 500; -} - -.VPHero .actions .action .VPButton.brand { - font-weight: 600; -} - -.VPHero .actions .VPButton.alt[href*="github"]::before { - content: ''; - display: inline-block; - width: 16px; - height: 16px; - margin-right: 6px; - vertical-align: -3px; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4'/%3E%3Cpath d='M9 18c-4.51 2-5-2-7-2'/%3E%3C/svg%3E"); - background-size: contain; - background-repeat: no-repeat; -} - -.dark .VPHero .actions .VPButton.alt[href*="github"]::before { - filter: invert(1) brightness(0.85); -} - -/* ───────────────────────────────────────────── - Hero - ───────────────────────────────────────────── */ -.VPHero .name { - --vp-home-hero-name-color: transparent; - --vp-home-hero-name-background: linear-gradient(135deg, #E05742 0%, #B47A45 100%); -} - -.VPHero .name .clip { - font-family: var(--vp-font-family-mono); - font-weight: 800; - letter-spacing: -0.04em; -} - -.VPHero .text { - font-weight: 700; - letter-spacing: -0.03em; - line-height: 1.15; -} - -.VPHero .tagline { - max-width: 480px; - font-weight: 400; - color: var(--vp-c-text-2); -} - -.VPHero .VPImage { - border-radius: 10px; - border: 1px solid var(--vp-c-border); -} - -/* Below 960px the theme sizes the image slot as a fixed 320/392px square and - bleeds it upward under the nav — sized for a floating logo, not the terminal - card we render there. Size it to content and move it below the pitch. */ -/* Doubled selectors for the same reason as the nav block above — the theme's - scoped `.image[data-v-…]` would otherwise tie or win. */ -@media (max-width: 959px) { - .VPHero .image.image { - order: 3; - margin: 40px 0 0; - /* Flex items default to min-width:auto, so the terminal's `white-space: pre` - lines would set the slot's floor. Zeroing it lets the card shrink and - its own overflow-x take over. */ - min-width: 0; - } - - .VPHero .image-container.image-container { - width: 100%; - max-width: 100%; - height: auto; - min-width: 0; - } -} - -.dark .VPHero .VPImage { - box-shadow: 0 18px 40px -12px rgba(0, 0, 0, 0.35), 0 6px 12px rgba(0, 0, 0, 0.15); -} - -/* ───────────────────────────────────────────── - Feature cards - ───────────────────────────────────────────── */ -.VPFeature { - border-radius: 10px; - transition: border-color 180ms ease-out, box-shadow 180ms ease-out; -} - -.VPFeature:hover { - border-color: var(--vp-c-brand-1); - box-shadow: 0 4px 10px -2px rgba(33, 41, 49, 0.08), 0 2px 4px rgba(33, 41, 49, 0.05); -} - -.VPFeature .title { - font-weight: 600; - letter-spacing: -0.01em; -} - -.VPFeature .details { - line-height: 1.65; - color: var(--vp-c-text-2); -} - -/* ───────────────────────────────────────────── - Next steps — 3-up card row on the home page + Next steps — 3-up card row (why-noorm, modeling overview) ───────────────────────────────────────────── */ .next-steps { display: grid; @@ -613,3 +510,55 @@ html.dark { height: 460px; } } + +/* ───────────────────────────────────────────── + Paired code — a source and what it becomes + ───────────────────────────────────────────── */ + +/* Stacked: side by side, the doc column clips both blocks' longest lines. */ +.pair { + margin: 16px 0; +} + +.vp-doc .pair div[class*='language-'] { + margin: 0; +} + +.vp-doc .pair .pair-joint { + margin: 0 0 0 24px; + padding: 6px 0 6px 20px; + border-left: 2px solid var(--vp-c-divider); + font-family: var(--vp-font-family-mono); + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--vp-c-text-3); +} + +/* Mermaid diagrams: palette read by docs/.vitepress/mermaid.ts, card on the container. */ +.vp-doc .mermaid { + --mm-canvas: var(--vp-c-bg); + --mm-line: var(--vp-c-text-2); + --mm-dim: var(--vp-c-text-3); + --mm-text: var(--vp-c-text-1); + + display: flex; + justify-content: center; + margin: 16px 0; + padding: 20px 16px; + border: 1px solid var(--vp-c-border); + border-radius: 10px; + background-color: var(--vp-c-bg); + background-image: radial-gradient(var(--vp-c-divider) 1px, transparent 1px); + background-size: 18px 18px; +} + +.vp-doc .mermaid svg { + overflow: visible; +} + +/* Mermaid sizes each label for its own 1.5 line height; .vp-doc p's 28px clips the last line. */ +.vp-doc .mermaid p { + margin: 0; + line-height: 1.5; +} diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts index 0565c116..ddec2e26 100644 --- a/docs/.vitepress/theme/index.ts +++ b/docs/.vitepress/theme/index.ts @@ -1,5 +1,4 @@ import DefaultTheme from 'vitepress/theme' -import { h } from 'vue' import { library } from '@fortawesome/fontawesome-svg-core' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' import { @@ -17,10 +16,10 @@ import { faBook } from '@fortawesome/free-solid-svg-icons' import { faGithub } from '@fortawesome/free-brands-svg-icons' -import HeroTerminal from './HeroTerminal.vue' -import HeroEyebrow from './HeroEyebrow.vue' -import HeroStats from './HeroStats.vue' +import mermaid from 'mermaid' +import ModelViewer from './ModelViewer.vue' import './brand.css' +import './landing.css' library.add( faCodeBranch, @@ -40,14 +39,26 @@ library.add( export default { extends: DefaultTheme, - Layout() { - return h(DefaultTheme.Layout, null, { - 'home-hero-info-before': () => h(HeroEyebrow), - 'home-hero-image': () => h(HeroTerminal), - 'home-hero-actions-after': () => h(HeroStats), - }) - }, enhanceApp({ app }) { app.component('FontAwesomeIcon', FontAwesomeIcon) + app.component('ModelViewer', ModelViewer) + + // Mermaid measures each label once and memoizes the width, so a render before + // Geist loads keeps fallback-font widths for good. Hold every render until it is in. + if (typeof document !== 'undefined') { + // load() resolves at once while the font stylesheet has not registered its + // @font-face rules, so wait for the rules (up to 3s), then for the files. + const fontsReady = (async () => { + const hasGeist = () => [...document.fonts].some((f) => f.family.replace(/"/g, '') === 'Geist') + const deadline = Date.now() + 3000 + while (!hasGeist() && Date.now() < deadline) await new Promise((r) => setTimeout(r, 25)) + await Promise.allSettled(['400', '600'].map((w) => document.fonts.load(`${w} 14px Geist`))) + })() + const render = mermaid.render.bind(mermaid) + mermaid.render = async (...args: Parameters) => { + await fontsReady + return render(...args) + } + } } } diff --git a/docs/.vitepress/theme/landing.css b/docs/.vitepress/theme/landing.css new file mode 100644 index 00000000..2f9598ea --- /dev/null +++ b/docs/.vitepress/theme/landing.css @@ -0,0 +1,645 @@ +/* ───────────────────────────────────────────── + Landing pages — docs/index.md, docs/modeling/index.md, docs/download.md + (.nh-file panels also frame the files on docs/modeling/model-to-sql.md) + ───────────────────────────────────────────── */ +.nh-hero { + padding: 56px 0 24px; +} + +.vp-doc .nh-eyebrow { + margin: 0 0 12px; + font-family: var(--vp-font-family-mono); + font-size: 0.75rem; + font-weight: 500; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--noorm-bronze); +} + +.vp-doc .nh-title { + margin: 0 0 24px; + font-size: clamp(2.2rem, 2.4vw + 1rem, 3.5rem); + line-height: 1.08; + font-weight: 700; + letter-spacing: -0.03em; +} + +.vp-doc .nh-title span, +.vp-doc .nh-big span { + color: var(--noorm-bronze-strong); +} + +/* The wordmark gradient from the previous hero. */ +.vp-doc .nh-title span { + background: linear-gradient(135deg, #E05742 0%, #B47A45 100%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.nh-row { + display: grid; + grid-template-columns: 7fr 5fr; + gap: 48px; + align-items: center; + margin-bottom: 32px; +} + +.vp-doc .nh-lead { + margin: 0; + max-width: 60ch; + font-size: 1.125rem; + line-height: 1.6; + color: var(--vp-c-text-2); +} + +.nh-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; +} + +.vp-doc .nh-btn { + display: inline-flex; + align-items: center; + height: 42px; + padding: 0 18px; + border-radius: 8px; + border: 1px solid transparent; + font-size: 15px; + font-weight: 500; + text-decoration: none; + transition: background-color 120ms ease-out, border-color 120ms ease-out; +} + +.vp-doc .nh-btn.brand { + background: var(--vp-button-brand-bg); + color: var(--vp-button-brand-text); + font-weight: 600; +} + +.vp-doc .nh-btn.brand:hover { + background: var(--vp-button-brand-hover-bg); + color: var(--vp-button-brand-hover-text); +} + +.vp-doc .nh-btn.alt { + background: var(--vp-c-bg-elv); + border-color: var(--vp-c-border); + color: var(--vp-c-text-1); +} + +.vp-doc .nh-btn.alt:hover { + border-color: var(--vp-c-text-3); +} + +.vp-doc .nh-caption { + flex-basis: 100%; + margin: 4px 0 0; + font-family: var(--vp-font-family-mono); + font-size: 0.75rem; + color: var(--vp-c-text-3); +} + +/* Two-panel comparisons: the dim panel is the usual way, the other is noorm's or ignatius's. */ +.nh-compare { + display: grid; + grid-template-columns: 5fr 7fr; + gap: 14px; + align-items: stretch; +} + +.nh-panel, +.nh-file { + display: flex; + flex-direction: column; + min-width: 0; + border: 1px solid var(--vp-c-border); + border-radius: 10px; + overflow: hidden; + background: var(--vp-c-bg-elv); +} + +.nh-file { + background: var(--vp-code-block-bg); +} + +/* Not this, but that: red marks the usual way, green marks noorm's. */ +.nh-bad { + border-color: color-mix(in srgb, var(--vp-c-danger-1) 45%, var(--vp-c-border)); +} + +.nh-good { + border-color: color-mix(in srgb, var(--vp-c-success-1) 55%, var(--vp-c-border)); +} + +.nh-bad .nh-panel-head { + color: var(--vp-c-danger-1); + background: color-mix(in srgb, var(--vp-c-danger-1) 10%, transparent); +} + +.nh-good .nh-panel-head { + color: var(--vp-c-success-1); + background: color-mix(in srgb, var(--vp-c-success-1) 10%, transparent); +} + +.vp-doc .nh-bad .nh-panel-foot { + color: var(--vp-c-danger-1); +} + +.nh-mark { + margin-right: 4px; + font-size: 0.875rem; +} + +.nh-panel-head { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 8px 14px; + border-bottom: 1px solid var(--vp-c-divider); + font-family: var(--vp-font-family-mono); + font-size: 0.6875rem; + letter-spacing: 0.04em; + color: var(--vp-c-text-3); +} + +.vp-doc .nh-panel-body { + flex: 1; + margin: 0; + padding: 14px 16px; + font-family: var(--vp-font-family-mono); + font-size: 0.8125rem; + line-height: 1.6; + color: var(--vp-c-text-3); + background: transparent; + overflow-x: auto; +} + +.vp-doc .nh-panel-foot { + margin: 0; + padding: 8px 14px; + border-top: 1px dashed var(--vp-c-divider); + font-size: 0.8125rem; + line-height: 1.5; + color: var(--vp-c-text-2); +} + +.vp-doc .nh-file div[class*='language-'], +.vp-doc .nh-cmds div[class*='language-'], +.vp-doc .nh-split div[class*='language-'] { + margin: 0; + border-radius: 0; +} + +.vp-doc .nh-file div[class*='language-'] { + flex: 1; + border: 0; +} + +.vp-doc .nh-split div[class*='language-'] + div[class*='language-'] { + margin-top: 12px; +} + +.vp-doc .nh-split div[class*='language-'], +.vp-doc .nh-cmds div[class*='language-'] { + border-radius: 10px; +} + +.nh-section { + padding: 64px 0 8px; + border-top: 1px solid var(--vp-c-divider); + margin-top: 56px; +} + +.vp-doc .nh-h2 { + margin: 0 0 24px; + padding: 0; + border-top: 0; + font-size: 1.875rem; + line-height: 1.2; + letter-spacing: -0.02em; +} + +.vp-doc .nh-big { + font-size: clamp(2rem, 2vw + 1rem, 3rem); + line-height: 1.1; +} + +.vp-doc .nh-h3 { + margin: 0 0 10px; + font-size: 1.5rem; + letter-spacing: -0.01em; +} + +.nh-r { + display: grid; + grid-template-columns: 4fr 5fr 3fr; + gap: 28px; + align-items: start; + padding: 18px 0; +} + +.nh-r + .nh-r { + border-top: 1px solid var(--vp-c-divider); +} + +.vp-doc .nh-pain { + margin: 0; + font-weight: 500; + color: var(--vp-c-text-1); +} + +.vp-doc .nh-fix { + margin: 0; + font-size: 0.9375rem; + color: var(--vp-c-text-2); +} + +.vp-doc .nh-proof { + justify-self: start; + max-width: 100%; + overflow-wrap: anywhere; + font-size: 0.75rem; +} + +.nh-split { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 48px; + align-items: start; +} + +.vp-doc .nh-flow { + margin: 20px 0; + padding: 14px 18px; + border: 1px solid var(--vp-c-border); + border-radius: 10px; + background: var(--vp-c-bg-elv); + font-family: var(--vp-font-family-mono); + font-size: 0.8125rem; + line-height: 1.9; + color: var(--vp-c-text-1); + overflow-x: auto; +} + +.nh-dim { + color: var(--vp-c-text-3); +} + +.nh-arrow { + color: var(--noorm-bronze); +} + +.vp-doc .nh-links { + font-size: 0.875rem; +} + +/* Full-width band for the second tool. --vp-offset is VPHomeContent's distance to the + viewport edge; padding by its negation keeps the content on the page column. */ +.nh-section.nh-band { + padding-top: 0; + border-top: 0; +} + +.nh-tool { + margin-left: var(--vp-offset, calc(50% - 50vw)); + margin-right: var(--vp-offset, calc(50% - 50vw)); + padding: 64px calc(-1 * var(--vp-offset, calc(50% - 50vw))) 56px; + border-top: 1px solid var(--vp-c-border); + border-bottom: 1px solid var(--vp-c-border); + background: var(--vp-c-bg-soft); +} + +.nh-dfd { + margin: 8px 0 40px; + padding-top: 28px; + border-top: 1px solid var(--vp-c-divider); +} + +.vp-doc .nh-dfd-lead { + max-width: 76ch; + margin: 0 0 20px; +} + +.nh-dfd-compare { + display: grid; + grid-template-columns: 4fr 8fr; + gap: 14px; + align-items: stretch; +} + +.vp-doc .nh-ascii { + display: flex; + align-items: center; + justify-content: center; + font-size: 0.875rem; + line-height: 1.18; + color: var(--vp-c-text-2); +} + +.vp-doc .model-embed.nh-dfd-frame, +.vp-doc .nh-viewer .model-embed { + display: flex; + flex-direction: column; + margin: 0; +} + +.vp-doc .nh-dfd-frame iframe, +.vp-doc .nh-viewer .model-embed iframe { + flex: 1; + min-height: 470px; +} + +.model-viewer-bar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 7px 12px; + border-bottom: 1px solid var(--vp-c-divider); + font-family: var(--vp-font-family-mono); + font-size: 0.6875rem; + letter-spacing: 0.04em; + color: var(--vp-c-text-3); + background: var(--vp-c-bg-elv); +} + +.model-viewer-tabs { + display: inline-flex; + gap: 2px; +} + +.model-viewer-tabs button { + padding: 2px 8px; + border: 1px solid transparent; + border-radius: 4px; + font-family: var(--vp-font-family-mono); + font-size: 0.6875rem; + color: var(--vp-c-text-2); + cursor: pointer; +} + +.model-viewer-tabs button.on { + border-color: var(--vp-c-border); + background: var(--vp-c-bg); + color: var(--vp-c-text-1); +} + +.nh-viewer { + display: grid; + grid-template-columns: 5fr 7fr; + gap: 14px; + align-items: stretch; +} + +.vp-doc .nh-note { + margin: 12px 0 28px; + font-size: 0.875rem; + color: var(--vp-c-text-2); +} + +.nh-tool .nh-rows { + margin-top: 8px; +} + +.nh-cmds { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; + margin-top: 28px; +} + +.nh-cmds > div { + display: flex; + flex-direction: column; +} + +.vp-doc .nh-cmds div[class*='language-'] { + flex: 1; +} + +.nh-band + .nh-section { + border-top: 0; +} + +.nh-start { + text-align: center; +} + +.nh-start .nh-h2 { + max-width: 32ch; + margin-left: auto; + margin-right: auto; +} + +.vp-doc .nh-start div[class*='language-'] { + max-width: 640px; + margin: 0 auto; + text-align: left; +} + +.nh-inline { + justify-content: center; + margin-top: 20px; +} + +.nh-faq { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 18px; +} + +.nh-faq > div { + padding: 20px; + border: 1px solid var(--vp-c-border); + border-radius: 10px; + background: var(--vp-c-bg-elv); +} + +.vp-doc .nh-faq h3 { + margin: 0 0 8px; + font-size: 1rem; +} + +.vp-doc .nh-faq p { + margin: 0; + font-size: 0.9375rem; + color: var(--vp-c-text-2); +} + +@media (max-width: 959px) { + + .nh-row, + .nh-compare, + .nh-split, + .nh-dfd-compare, + .nh-viewer, + .nh-cmds { + grid-template-columns: 1fr; + } + + .nh-r { + grid-template-columns: 1fr; + gap: 6px; + } + + .nh-faq { + grid-template-columns: 1fr; + } + + .nh-tool { + padding: 28px 20px; + } +} + +/* ───────────────────────────────────────────── + Download page + ───────────────────────────────────────────── */ + +/* Wider than VPDoc's 784px; doubled classes outrank its scoped width rule. */ +.page-download .VPDoc.VPDoc .content.content { + max-width: 1104px; +} + +.dl-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 22px; + margin: 28px 0 8px; +} + +.dl-tool { + min-width: 0; + padding: 4px 26px 22px; + border: 1px solid var(--vp-c-border); + border-radius: 14px; + background: var(--vp-c-bg-elv); +} + +.vp-doc .dl-tool h2 { + margin-top: 20px; + padding-top: 0; + border-top: 0; +} + +.vp-doc .dl-what { + margin: 8px 0 16px; + color: var(--vp-c-text-2); +} + +.vp-doc .dl-label { + margin: 20px 0 6px; + font-family: var(--vp-font-family-mono); + font-size: 0.6875rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--vp-c-text-3); +} + +.vp-doc .dl-label + div[class*='language-'] { + margin-top: 0; +} + +.vp-doc .dl-small { + font-size: 0.8125rem; + color: var(--vp-c-text-2); +} + +@media (max-width: 959px) { + + .dl-grid { + grid-template-columns: 1fr; + } +} + +/* ───────────────────────────────────────────── + Modeling landing — docs/modeling/index.md + ───────────────────────────────────────────── */ +.nh-steps { + display: grid; + gap: 28px; +} + +.nh-steps > div { + display: grid; + grid-template-columns: 4fr 8fr; + gap: 28px; + align-items: start; +} + +.vp-doc .nh-step { + margin: 0 0 8px; + font-family: var(--vp-font-family-mono); + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--noorm-bronze); +} + +.vp-doc .nh-steps div[class*='language-'] { + margin-top: 0; +} + +.vp-doc .nh-step-note { + margin: 10px 0 0; + font-size: 0.875rem; + color: var(--vp-c-text-2); +} + +.nh-cards { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; +} + +.vp-doc .nh-cards a { + display: flex; + flex-direction: column; + gap: 6px; + padding: 18px; + border: 1px solid var(--vp-c-border); + border-radius: 10px; + background: var(--vp-c-bg-elv); + text-decoration: none; + transition: border-color 120ms ease-out; +} + +.vp-doc .nh-cards a:hover { + border-color: var(--vp-c-text-3); +} + +.vp-doc .nh-cards strong { + color: var(--vp-c-text-1); +} + +.vp-doc .nh-cards span { + font-size: 0.875rem; + line-height: 1.5; + color: var(--vp-c-text-2); +} + +@media (max-width: 959px) { + + .nh-cards { + grid-template-columns: 1fr; + } + + .nh-steps > div { + grid-template-columns: 1fr; + gap: 12px; + } +} + +.vp-doc .nh-steps .nh-file + .nh-file { + margin-top: 10px; +} + +.vp-doc .nh-chapter { + max-width: 76ch; + margin: 0 0 24px; + font-size: 1.0625rem; + line-height: 1.65; + color: var(--vp-c-text-2); +} diff --git a/docs/cli/flags.md b/docs/cli/flags.md index d5745ea3..dd2bfd28 100644 --- a/docs/cli/flags.md +++ b/docs/cli/flags.md @@ -1,100 +1,69 @@ # CLI flag conventions -`noorm`'s CLI is built on [citty](https://github.com/unjs/citty), which parses flags -relative to the subcommand they belong to — a flag typed before the subcommand name never -reaches the leaf command's own parser. `noorm` works around that for the one flag that -genuinely needs to run before dispatch, by stripping it out of `argv` before citty ever sees -it. This page covers that flag and the rule for everything else. +Put every flag after the subcommand. The one exception is `-c `, which goes before it. +```bash +noorm -c packages/db run build --dry-run +``` -## Global flags vs per-subcommand flags +Here `-c packages/db` sets the working directory, and `--dry-run` belongs to `run build`. A flag in the wrong position is an error, not a silent no-op: -A flag is **global** when `noorm` recognizes and hoists it regardless of where it appears — -before or after the subcommand. A flag is **per-subcommand** when it's owned by an -individual command like `noorm sql query` and only works *after* the subcommand name. +``` +$ noorm --dry-run run build +Error: Unrecognized flag '--dry-run' before the subcommand — noorm can't forward it there. The only root-level flag is -c/--cwd ; every other flag goes on the command that uses it. Move '--dry-run' after the subcommand instead, e.g. noorm ... --dry-run. +``` -`noorm` hoists exactly one flag on purpose — most flags vary in meaning per command (`-c` is -`--cwd` at the root, but `--config` on many subcommands), so per-subcommand parsing is the -default for everything else, `--dry-run`/`--json`/`--yes` included. +The first non-flag token is the subcommand. It decides who reads each flag: +```mermaid +flowchart TD + F["a flag in argv"] --> Q{"before the
subcommand?"} + Q -->|no| CMD["the command reads it:
--config, --json, -c name"] + Q -->|yes| W{"-c or --cwd?"} + W -->|yes| CWD["working directory
for this run"] + W -->|no| ERR["Error, exit 2"] +``` -### The one global: `-c` / `--cwd ` +`--help`, `-h`, `--version`, and `-v` skip this check. `--version` and `-v` work only as the sole argument. -| Flag | Purpose | -|------|---------| -| `--cwd ` / `-c ` | Change working directory before resolving `.noorm/`. Same idea as `git -C`. Recognized only **before** the subcommand — see the note below. | -`--cwd`/`-c` is global for two reasons: +## Which flags go where? -1. It's consumed **before dispatch** — it sets the working directory everything else - (project discovery, config resolution) resolves against, so it's genuinely the CLI's own - flag rather than any subcommand's. -2. `-c` already means `--config` *after* the subcommand (see the overload note below). - Hoisting it lets `noorm -c run build` mean cwd while `noorm run build -c ` - means config, without either shadowing the other. -Examples: +| Flag | Before the subcommand | After the subcommand | +|------|-----------------------|----------------------| +| `-c `, `--cwd `, `--cwd=` | Working directory, like `git -C` | `-c` means `--config` | +| `-c `, `--config ` | `-c` is read as `--cwd`; `--config` is an error | Config to use instead of the active one | +| `--json` | Error | Print JSON on stdout | +| `--dry-run` | Error | Preview without executing | +| `--force`, `-f` | Error | Force the operation; the effect varies per command | +| `--yes`, `-y` | Error | Skip confirmations, same as `NOORM_YES=1` | - noorm -c packages/db run build - noorm --cwd /repos/myapp change ff +Each command declares the subset it accepts, and `noorm --help` lists them. It prints `--dryRun` for `--dry-run`; the two spellings are one flag. After the subcommand, a misspelled flag is dropped without an error, so check the spelling there. The full table is in the [CLI reference](/headless#where-do-flags-go). -`--help`/`-h` and `--version`/`-v` are also recognized outside this mechanism — that's citty's -own framework behaviour, not a `noorm`-specific hoist. -An unrecognized flag placed before the subcommand is an error, not a silent no-op: +## What does `-c` / `--cwd` do? - $ noorm --bogus-flag config list - Error: Unrecognized flag '--bogus-flag' before the subcommand — noorm can't forward it there. - The only root-level flag is -c/--cwd ; every other flag goes on the command that uses it. - Move '--bogus-flag' after the subcommand instead, e.g. noorm ... --bogus-flag. +noorm normally walks up from the current directory to the nearest `.noorm/` and runs there. `--cwd` skips the walk and runs in the path you name. -### Per-subcommand flags (after the subcommand) +```bash +noorm -c packages/db run build +noorm --cwd /repos/shop change ff +``` -Everything else lives on a specific subcommand — `--config `, `--force`, `--dry-run`, -`--json`, `--yes`/`-y`, and any command-specific flag. These are **not** hoisted, so they only -work after the subcommand name: +| Situation | Result | +|-----------|--------| +| Path is not a directory | `Error: --cwd path is not a directory: `, exit 2 | +| `-c` followed by the subcommand, `noorm -c run build` | `run` is taken as the path, so this fails as above | +| `-c` with no value | Ignored; noorm walks up as usual | +| `-c prod` meant as a config, `noorm -c prod run build` | `prod` is a path, not a config. Use `noorm run build --config prod` | - noorm run build --dry-run - noorm config list --json - noorm secret rm OLD_KEY --yes -Placed before the subcommand instead, none of them are honoured — `noorm --dry-run run build` -is rejected exactly like `noorm --bogus-flag config list` above, naming `--dry-run` as the -offending flag. To see exactly which flags a command accepts, append `--help` to it: +## Next - noorm change ff --help - noorm db create --help - noorm sql query --help - -## The `--config` / `-c` overload - -`-c` is overloaded by position, which is the one sharp edge left in this system: - -- `noorm -c run build` — global `--cwd`, because `-c` appears **before** the subcommand. -- `noorm run build -c ` — per-command `--config`, because `-c` appears **after** the subcommand. - -The first non-flag token (the subcommand name) is the boundary that decides which meaning -applies. `--config` in long form has no global meaning at all — it only works after the -subcommand, so `noorm --config prod run build` is not the same as `noorm run build --config prod` -(the former is rejected as an unrecognized global flag). - - -## Mental model - -`noorm` has exactly one true global, `-c`/`--cwd`, and everything else is command-scoped. Put -every flag right next to the subcommand it modifies: - - noorm - -If a flag isn't being honored, it's placed before the subcommand boundary — or it's a typo, -in which case `noorm` now says so instead of silently dropping it. - - -## Related - -- [`noorm` help conventions](./help.md) — how to find a command's flags. -- [Headless reference](../headless.md) — full table of common flags. -- [Troubleshooting](../guide/troubleshooting.md) — quick fixes for common surprises. +- [Discovering command help](/cli/help) +- [CLI reference](/headless): every command and the exit-code table +- [Troubleshooting](/guide/troubleshooting) diff --git a/docs/cli/help.md b/docs/cli/help.md index ef68c9ef..6c4eeb3e 100644 --- a/docs/cli/help.md +++ b/docs/cli/help.md @@ -1,47 +1,51 @@ # Discovering command help -Every `noorm` command exposes its own help via the standard `--help` -(or `-h`) flag. There is no `noorm help ` form — `noorm`'s -CLI framework (citty) renders help natively per command, and that's the -canonical surface. +Append `--help` or `-h` to any command to see its usage, flags, and examples. +``` +$ noorm run inspect --help +Inspect template context for a .sql.tmpl file (noorm run inspect) -## Canonical form +USAGE noorm run inspect [OPTIONS] -Append `--help` (or the short `-h`) to any command — including -multi-segment commands like `db create` or `change ff`: +ARGUMENTS - noorm --help - noorm db --help - noorm db create --help - noorm change ff --help - noorm sql query --help + PATH Path to the .sql.tmpl file (Required) -The help output includes the usage line, every flag with its -description, and a curated `EXAMPLES` block where the command author -added one. +OPTIONS + -c, --config= Use specific configuration + --json Output JSON -## Why not `noorm help `? -citty (the CLI framework `noorm` is built on) doesn't ship a -re-dispatching `help` subcommand. Adding one would mean a parallel -copy of every command's help text — easy to let drift. The `--help` -flag is uniform, always current, and matches the convention used by -`git`, `kubectl`, `docker`, and most other modern CLIs. +EXAMPLES -If you arrive from a tool like `npm`, `yarn`, or older `commander`-based -CLIs that DO support `npm help `, just swap to the flag form: + noorm run inspect db/sql/users/001_create.sql.tmpl + noorm run inspect db/sql/core/05_Cron/Crons.sql.tmpl --json +``` - # this errors with "Unknown command help" - noorm help db create +The same flag works at every depth: `noorm --help`, `noorm db --help`, `noorm db create --help`, `noorm change ff --help`. A parent command lists its subcommands. A leaf command lists its flags and an `EXAMPLES` block when its author wrote one. - # this is the equivalent that works - noorm db create --help +## What if I type `noorm help `? -## Related -- [Flag conventions](./flags.md) — where `--help` and other flags must - be placed relative to the subcommand. +noorm has no `help` subcommand. citty renders help per command, so one source serves every command and no second copy can drift. + +| Input | Result | +|-------|--------| +| `noorm --help`, `-h` | Usage, flags, examples; exit 0 | +| `noorm --help` | Command list; exit 0 | +| `noorm` | Command list, then `No command specified.`; exit 1 | +| `noorm help db create` | Command list, then `Unknown command help`; exit 1 | +| `noorm --version`, `noorm -v` | Version only, when it is the sole argument | + +For shell completion, run `noorm complete ` and source the script it prints. [Flag placement](/cli/flags) explains where `--help` and the other flags may go. + + +## Next + + +- [CLI flag conventions](/cli/flags) +- [CLI reference](/headless) diff --git a/docs/cli/identity.md b/docs/cli/identity.md index 06e52575..c6480103 100644 --- a/docs/cli/identity.md +++ b/docs/cli/identity.md @@ -1,79 +1,180 @@ # Identity Management -This guide covers noorm's cryptographic identity system — the keys that authenticate you to shared state, sign your changes, and let teammates encrypt vault secrets to you. +Create the keypair that unlocks your local state, tags your changes in the audit trail, and lets teammates share vault secrets with you. +```bash +noorm identity init --name "Alice Cooper" --email alice@example.com +``` + +``` +Identity created. + Name: Alice Cooper + Email: alice@example.com + Fingerprint: 5e217a60eb7c27d5390cf2f3c8639fb7158dc3006df5057be03d83e3be51d721 +``` -## Overview +`init` writes three files in `~/.noorm/`. The private key never leaves your machine. -noorm uses X25519 cryptographic identities for secure config sharing and audit tracking. Each identity is stored locally in `~/.noorm/identity.key` (private key) and `~/.noorm/identity.json` (metadata). The public key is derived deterministically from the private key, so it never has to be stored separately to be recovered. +| File | Mode | Holds | +|------|------|-------| +| `identity.key` | `0600` | Private key | +| `identity.pub` | `0644` | Public key, derived from the private key | +| `identity.json` | umask default | Name, email, public key, machine, OS, fingerprint | -A disk identity is hashed as `SHA-256(email + name + machine + os)`, where `machine` is the hostname. The same person on two laptops is therefore two identities in the audit trail, and each needs vault access propagated to it. Env-var identities hash differently on purpose, so a CI fleet reads as one user: see [Env-Var Identity (CI)](#env-var-identity-ci) below. -For CI runners, identities load from environment variables instead of files — see the [CI automation guide](../guide/automation/ci.md) for end-to-end setup, including `noorm ci identity new` (generate keypair) and `noorm ci identity enroll` (grant vault access on a target database). +## What is an identity used for? -## Creating an Identity +One identity serves every project on the machine. On CI, environment variables replace the files. -Run `noorm identity init` on your machine to create a keypair. `--name` and `--email` are both required; the command does not prompt. +```mermaid +flowchart TD + ENV["NOORM_IDENTITY_*
environment"] -->|wins when valid| ID["identity in use"] + DISK["~/.noorm/identity.key
identity.json"] -->|otherwise| ID + ID --> ST[".noorm/state/state.enc
decrypted"] + ID --> VT["vault secrets
encrypted to you"] + ID --> AU["fingerprint in
change history"] +``` -```bash -noorm identity init --name "Alice Cooper" --email alice@example.com +The fingerprint identifies you in the database. A disk identity hashes the machine too, so the same person on two laptops appears twice, and each laptop needs vault access. + +| | Disk identity | Environment identity | +|---|---|---| +| Fingerprint input | `SHA-256(email, name, hostname, OS)` | `SHA-256(email, name, public key, "env")` | +| Same person on two machines | Two identities | One identity | +| Files written | `~/.noorm/*` | None | + + +## identity init + + +``` +noorm identity init --name --email [--force --yes] [--json] ``` -This generates a keypair and writes: +| Flag | Effect | +|------|--------| +| `--name ` | Display name. Required | +| `--email ` | Email address. Required | +| `--force` | Replace an existing identity. Needs `--yes` | +| `--yes`, `-y` | Confirm the replacement. `NOORM_YES` does not count | +| `--json` | JSON result with `name`, `email`, `fingerprint`, `publicKey` | -- Private key: `~/.noorm/identity.key` (mode `0600`) -- Public key: `~/.noorm/identity.pub` (mode `0644`) -- Metadata: `~/.noorm/identity.json` +| Condition | Behavior | +|-----------|----------| +| `--name` or `--email` missing | Usage text and `Missing required argument`, exit 1. No prompt | +| Identity exists, no `--force` | `Identity already exists. Use --force to overwrite.`, exit 1 | +| `--force` without `--yes` | Prints the warning below and `Pass --yes to confirm.`, exit 1 | +| `--force --yes` | Copies the key files to `.bak-`, then replaces them. Prints the backup paths | +| Backup fails | Refuses to overwrite, exit 1 | -The private key never leaves your machine. Your public key is what teammates use to share encrypted state with you. +::: danger +Replacing an identity makes every `.noorm/state/state.enc` on the machine unreadable. State encryption is keyed off the private key, and nothing re-encrypts it under the new one. Restore `identity.key` from the `.bak-` copy to get the configs, secrets, and database passwords back. +::: -If an identity already exists, `init` refuses. Replacing it takes `--force --yes`, and it is destructive: state encryption is keyed off the private key, so every noorm project on the machine loses access to the configs, secrets, and database passwords in its `.noorm/state/state.enc`. Nothing re-encrypts existing state under the new key. noorm copies the old key files to `.bak-` siblings before overwriting, and aborts if it cannot. An ambient `NOORM_YES` does not satisfy this confirmation; only the literal `--yes` flag does. +## identity edit -## Editing Your Identity -Update the display name or email on the existing identity. At least one of `--name` or `--email` must be provided. +``` +noorm identity edit [--name ] [--email ] [--json] +``` ```bash -noorm identity edit --name "Alice Cooper" -noorm identity edit --email "alice@newjob.com" +noorm identity edit --name "Alice C. Cooper" +``` + +``` +Identity updated. + Name: Alice C. Cooper + Email: alice@example.com + Fingerprint: bf151889b92fdc4f65e0b717dc3e93465488beba27f4df00be06095a70547936 + +NOTE: the fingerprint changed, because it is derived from your name and email. +Vault access is granted against the old fingerprint, so you will need a team +member with vault access to propagate it to the new one. + Previous: 5e217a60eb7c27d5390cf2f3c8639fb7158dc3006df5057be03d83e3be51d721 ``` +| Condition | Behavior | +|-----------|----------| +| Neither `--name` nor `--email` | `At least one of --name or --email must be provided.`, exit 2 | +| No identity yet | ``No identity found. Run `noorm identity init` first.``, exit 2 | +| Name or email changes | The fingerprint changes. `--json` reports `previousFingerprint` and `fingerprintChanged` | +| Machine | Carried over unchanged | -## Exporting Your Public Key -Print your public key so teammates can add you to encrypted vaults: +## identity export + + +``` +noorm identity export [--json] +``` + +Prints your name, email, fingerprint, and public key. Send the public key to a teammate who is adding you to the vault. ```bash -noorm identity export noorm identity export --json ``` +```json +{"success":true,"name":"Alice Cooper","email":"alice@example.com","fingerprint":"5e217a60…","publicKey":"302a3005…"} +``` -## Listing Known Identities +| Condition | Behavior | +|-----------|----------| +| No identity | ``No identity found. Run `noorm identity init` to create one.``, exit 2 | +| Public key unreadable | `Failed to load public key. Identity files may be corrupted.`, exit 1 | -Show every identity discovered from database syncs (the audit trail of who has touched shared state): -```bash -noorm identity list -noorm identity list --json +## identity list + + +``` +noorm identity list [--json] ``` +Lists the people this project has seen, collected when a config syncs with its database. + +``` +Known users (1): + Alice Cooper + Fingerprint: 5e217a60eb7c27d5390cf2f3c8639fb7158dc3006df5057be03d83e3be51d721 + Machine: alice-mbp + Source: prod + Last seen: 2026-10-21 05:41:59 +``` + +| Condition | Behavior | +|-----------|----------| +| Nobody synced yet | `No known users found.`, exit 0. `--json` gives `{"users":[]}` | +| State cannot be decrypted | `Failed to load state: …`, exit 1 | + + +## How does CI get an identity? + + +Set three variables before the first `noorm` command. Every command in that process then uses the environment identity and writes nothing to `~/.noorm/`. + +| Variable | Value | +|----------|-------| +| `NOORM_IDENTITY_PRIVATE_KEY` | Hex-encoded X25519 private key, 96 hex characters | +| `NOORM_IDENTITY_NAME` | Display name | +| `NOORM_IDENTITY_EMAIL` | Email address | -## Env-Var Identity (CI) +| Condition | Behavior | +|-----------|----------| +| All three valid | Environment identity is used, even if `~/.noorm/` also holds one | +| One missing, blank, or the key is not valid hex | Ignored without an error, and the disk identity is used | +| Leading or trailing whitespace | Trimmed | -When `NOORM_IDENTITY_PRIVATE_KEY`, `NOORM_IDENTITY_NAME`, and `NOORM_IDENTITY_EMAIL` are set at process startup, every `noorm` command in that process uses the env-derived identity without touching `~/.noorm/`. +`noorm ci identity new` generates a CI keypair, and `noorm ci identity enroll` grants it vault access on a database. The [CI guide](/guide/automation/ci) walks through both. -The variables are read once when the process starts, so they apply to every command that process runs. -**Guarantees:** +## Next -- The public key is **derived** from the private key, not separately specified — same key, same public half. -- The identity hash is **independent of hostname or OS** (CI uses `os='env'`, `machine=publicKey`) — same key, same identity across every runner. -- Env vars are **trimmed automatically** — leading/trailing whitespace is removed. -- **Env vars win over disk** — if both are present, env takes precedence. -- **No files written** — env-based bootstrap is read-only. -To generate a CI-shaped keypair without touching the database, use `noorm ci identity new` (see the [CI automation guide](../guide/automation/ci.md)). +- [CI automation](/guide/automation/ci): provision and enroll a CI identity +- [Vault](/guide/environments/vault): share secrets between identities +- [Secrets](/cli/secret): config-scoped secrets, which the identity key protects diff --git a/docs/cli/init.md b/docs/cli/init.md index 02f47160..361a1b56 100644 --- a/docs/cli/init.md +++ b/docs/cli/init.md @@ -1,77 +1,105 @@ # noorm init -Bootstrap a new noorm project interactively. Creates identity (if not present), -project structure (`db/sql/`, `db/changes/`, `.noorm/`), `settings.yml`, and `state.enc`. - -It also writes `db/AGENTS.md` and `db/CLAUDE.md`. `AGENTS.md` points coding agents at the -`/noorm` skill, points data modeling at [ignatius](../modeling/index.md) and its -[`/ignatius-modeling` skill](../modeling/modeling-skill.md), and holds a marker for your own steering; `CLAUDE.md` imports it with -`@AGENTS.md`. Both are written only when absent, so re-running with `--force` keeps your edits. - -A project that already has `paths` in its `settings.yml` keeps them. `--force` resets the rest of the file, but a project laid out at `sql/` and `changes/` stays there, and no `db/` directory is created for it. +Create a noorm project in the current repository: the `.noorm/` state folder and the `db/` folder that holds your schema. + +```bash +noorm init --yes +``` + +``` +┌ noorm init +│ +● Using existing identity: Alice Cooper +│ +◇ Done +│ +└ noorm initialized. + Created: + db/sql/.gitkeep + db/changes/.gitkeep + db/AGENTS.md + db/CLAUDE.md + .noorm/.gitignore + .noorm/settings.yml + .noorm/state/state.enc +``` + +``` +. +├── .gitignore # gains "# noorm" and ".noorm/state/" +├── .noorm/ +│ ├── .gitignore # state/ +│ ├── settings.yml # paths: ./db/sql, ./db/changes +│ └── state/state.enc # configs and secrets, encrypted with your identity key +└── db/ + ├── AGENTS.md # points coding agents at the /noorm skill + ├── CLAUDE.md # imports AGENTS.md + ├── changes/ + └── sql/ +``` + +`db/AGENTS.md` also points data modeling at [ignatius](/modeling/) and its [`/ignatius-modeling` skill](/modeling/modeling-skill), and holds a marker comment for your own steering. `db/CLAUDE.md` is one line, `@AGENTS.md`. Without `--yes`, `init` prompts for a name and email when no identity exists yet. ## Flags -- `--force` / `-f` — overwrite an existing `.noorm/` directory -- `--here` — initialize in the original cwd, ignoring any parent `.noorm/` discovered while walking up -- `--yes` / `-y` — proceed non-interactively when a full identity already exists at `~/.noorm/identity.{key,pub,json}`. Equivalent to setting `NOORM_YES=1`. See [Non-interactive operation](../guide/automation/non-interactive.md) for details. +| Flag | Effect | +|------|--------| +| `--force`, `-f` | Initialize a folder that already has `.noorm/`. Resets `settings.yml` to defaults and keeps its `paths` | +| `--here` | Initialize the directory you launched from, ignoring a parent `.noorm/` | +| `--yes`, `-y` | Skip prompts. Needs an identity in `~/.noorm/`. `NOORM_YES=1` is the same | + +`noorm init` has no `--json` flag. -## Picking the project root -Every `noorm` invocation walks up from the current directory looking for a -`.noorm/` folder, then `chdir`s into it. That is what lets you run commands from -deep inside a repo without thinking about paths. It also means `noorm init` from -a subfolder of an already-initialized project tries to re-init the *parent*. +## Which folder becomes the project root? -Two flags opt out of that behavior, depending on where you want to invoke from: -- `noorm init --here` — stay in the cwd you launched from. Use this when you want to nest a fresh noorm project inside one that already exists (e.g. a `packages/db/` workspace under a repo that has its own `.noorm/`). -- `noorm -c init` — global flag (must precede the subcommand, like `git -C`). Resolves `` relative to the current shell, `chdir`s into it, and skips the walk-up entirely. +Every `noorm` command walks up from the current directory to the nearest `.noorm/` and runs there. `init` follows the same rule, so it targets the parent project unless you say otherwise. +| Invocation | Project root | +|------------|--------------| +| `noorm init` | Nearest parent holding `.noorm/`; the current directory when none does | +| `noorm init --here` | The directory you launched from. Nests a project inside another, such as `packages/db/` | +| `noorm -c packages/db init` | `packages/db`, with no walk up. `-c` must come before `init` | -## Environment -Interactive TTY required by default. Fails with exit code 2 (usage error) in CI or -piped stdin unless `--yes` / `NOORM_YES=1` is set AND a full identity already exists. +## What happens to an existing project? -## Non-interactive (CI) flow +| Condition | Behavior | +|-----------|----------| +| `.noorm/` exists, no `--force` | `Error: noorm project already initialized at . Use --force to reinitialize.`, exit 1 | +| `--force` | `settings.yml` resets to defaults except `paths`. `db/AGENTS.md` and `db/CLAUDE.md` are written only when absent, so your edits stay | +| `paths` are not `./db/sql` and `./db/changes` | Layout kept; no `db/` folder is created. A project at `./sql` and `./changes` stays there | +| `.gitignore` already lists `.noorm/state/` | Left alone; otherwise the block is appended | -`noorm init --yes` (or `NOORM_YES=1 noorm init`) skips all prompts when a full -crypto identity already lives at `~/.noorm/identity.{key,pub,json}`. The identity -must be in place first — `--yes` does NOT bootstrap one. If any of the three -files is missing, `noorm init --yes` exits 2 with: +`db/sql/.gitkeep` and `db/changes/.gitkeep` are listed under `Created:` on every run for a default layout. They are empty files and are not overwritten. - Error: noorm init --yes requires an existing identity at ~/.noorm/identity.{key,pub,json}. - Run: noorm identity init --name "Your Name" --email "you@example.com" - Then re-run: noorm init --yes -The end-to-end CI bootstrap is two commands: +## How do I run it in CI? - # CI flow - noorm identity init --name "$CI_USER" --email "$CI_EMAIL" - noorm init --yes +`--yes` skips every prompt but cannot create an identity. Create one first. A checkout that already has `.noorm/` needs no `init`, and running it there exits 1, so guard it: -## Example +```bash +[ -f ~/.noorm/identity.key ] || noorm identity init --name "$CI_USER" --email "$CI_EMAIL" +[ -d .noorm ] || noorm init --yes +``` - noorm init - noorm init --force - noorm init --here - noorm init --yes # non-interactive, requires existing identity +| Condition | Behavior | +|-----------|----------| +| No terminal and no `--yes` | `Error: noorm init requires an interactive terminal.`, exit 2 | +| `--yes`, but one of `identity.key`, `identity.pub`, `identity.json` is missing | `Error: noorm init --yes requires an existing identity at ~/.noorm/identity.{key,pub,json}.` with the two commands to fix it, exit 2 | +| Ctrl+C at a prompt | `Cancelled.`, exit 0 | +| Bootstrap fails after the prompts | The error text, exit 1 | - # From the repo root, init a sub-project at packages/db - noorm -c packages/db init +## Next -## Next steps -After `init` lays down `settings.yml` and `.noorm/`, the database -itself still has to be provisioned. See -[Creating a database](../guide/database/create.md) for the configs-first -workflow (`config import` → `db create` → `config use`), and -[CLI flag conventions](./flags.md) for `--json` placement and the -global-vs-per-subcommand rules. +- [Creating a database](/guide/database/create): add a config, create the database, build +- [First build](/getting-started/first-build) +- [CLI flag conventions](/cli/flags): where `-c` and `--yes` go diff --git a/docs/cli/run.md b/docs/cli/run.md index 398cdfdb..66e49e33 100644 --- a/docs/cli/run.md +++ b/docs/cli/run.md @@ -1,134 +1,190 @@ # noorm run -Execute SQL files against the active database. Five subcommands execute SQL — -`build`, `file`, `dir`, `files`, and `exec` — and they all share the same -result shape and exit-code semantics. Two more, `inspect` and `preview`, read a -`.sql.tmpl` file without touching the database. +Execute SQL files against the active database, or render a template without touching it. +```bash +noorm run build +noorm run build +``` -## Subcommands +``` +• log Build completed status=success filesRun=3 filesSkipped=0 filesFailed=0 durationMs=13.153750000000002 -| Command | Purpose | -|---------|---------| -| `noorm run build` | Execute every file under `paths.sql/` | -| `noorm run file ` | Execute a single file | -| `noorm run dir ` | Execute every file in a directory | -| `noorm run files --paths a.sql,b.sql` | Execute an explicit, ordered list | -| `noorm run exec ` | Discover files by glob, then execute | -| `noorm run inspect ` | Show the template context for a `.sql.tmpl` file | -| `noorm run preview ` | Render a `.sql.tmpl` file and print the SQL | +• log /work/shop/db/sql/01_tables/01_party.sql (skipped: unchanged) +• log /work/shop/db/sql/01_tables/02_sales_order.sql (skipped: unchanged) +• log /work/shop/db/sql/02_views/01_open_orders.sql (skipped: unchanged) +• log Build completed status=success filesRun=0 filesSkipped=3 filesFailed=0 durationMs=3.4714580000000126 +``` + +The first run executes all three files. The second skips them, because their checksums match a prior success. Log lines go to stderr. With `--json`, the result is one JSON object on stdout. + + +## Which command runs what? + + +| Command | Runs | Needs the database | +|---------|------|--------------------| +| `noorm run build` | Every file under `paths.sql`, narrowed by `build.include` and `build.exclude` | Yes | +| `noorm run file ` | One file | Yes | +| `noorm run dir ` | Every `.sql` and `.sql.tmpl` file under a directory, recursively, sorted | Yes | +| `noorm run files --paths a.sql,b.sql` | The listed files, in the order given | Yes | +| `noorm run exec ` | A directory (as `dir`) or the `.sql` and `.sql.tmpl` files a glob matches, sorted | Yes | +| `noorm run inspect ` | Nothing. Lists what a `.sql.tmpl` can read | Only to read vault secrets | +| `noorm run preview ` | Nothing. Prints the rendered SQL | Only to read vault secrets | + +Each file takes one of these paths, and the first failure stops the batch: + +```mermaid +flowchart TD + F["next file"] --> C{"last run succeeded
on the same SQL?"} + C -->|"yes, no --force"| S["skipped: unchanged"] + C -->|"no, or --force"| X["execute"] + X -->|ok| N["next file"] + X -->|error| STOP["failed: batch stops"] + S --> N +``` + +Files after the failed one do not run and do not appear in the result. ## Flags -Common to the five executing subcommands: -- `--config ` / `-c ` — use a named config from `state.enc` -- `--force` / `-f` — re-run even if the file's checksum matches a prior success -- `--dry-run` — render templates and write to `tmp/` without executing -- `--json` — emit machine-readable JSON on stdout +| Flag | Commands | Effect | +|------|----------|--------| +| `--config `, `-c ` | all seven | Use a stored config instead of the active one | +| `--force`, `-f` | the five executing commands | Run even when the checksum matches a prior success | +| `--dry-run` | the five executing commands | Render templates into `tmp/` and write nothing to the database | +| `--json` | all seven | Print the result as JSON on stdout | -`inspect` and `preview` take `--config` and `--json` only. Neither executes, so -`--force` and `--dry-run` have nothing to act on. +`inspect` and `preview` never execute, so they have no `--force` or `--dry-run`. +::: warning +A dry run writes every rendered file with its secrets resolved, in plaintext, to `tmp/`. noorm creates the files owner-only (`0600`) but does not add `tmp/` to `.gitignore`. A template at `db/sql/09_seeds/01_products.sql.tmpl` renders to `tmp/db/sql/09_seeds/01_products.sql`. +::: -## Exit codes -| Code | Meaning | -|------|---------| -| `0` | Every file succeeded or was skipped | -| `1` | Total failure — setup failed (could not connect, project not initialized), or the runner ran and nothing succeeded | -| `2` | Usage error — a missing flag, a directory or glob that matched no SQL files (`dir`, `exec`), or a template that isn't there (`inspect`, `preview`). Nothing was attempted | -| `3` | Partial failure — some files succeeded and some failed. Re-running is not automatically safe | +## What does each command do at the edges? + +| Command | Condition | Behavior | +|---------|-----------|----------| +| `build` | A `build.include` entry matches no file | Warns `Ignored N build.include entries that matched no files`. Paths are relative to `paths.sql`: `01_tables`, not `sql/01_tables` | +| `build` | A `build.exclude` entry matches no file | Warns that nothing was excluded, so those files ran | +| `build` | `--dry-run` | Header reads `Build (dry-run) completed`; each file line ends `-> `; `--json` adds `"dryRun": true` | +| `file` | File does not exist | `error: Failed to read file: `, exit 1 | +| `dir` | Directory does not exist | `Error: Directory not found: `, exit 2 | +| `dir` | No SQL files inside | `Error: No SQL files found in: `, exit 2 | +| `files` | `--paths` omitted | Usage text and `Missing required argument: --paths`, exit 1 | +| `files` | `--paths ""` or only commas | `Error: --paths must contain at least one file path`, exit 1 | +| `exec` | Glob or directory matches nothing | `Error: No SQL files found matching: `, exit 2 | +| `inspect`, `preview` | Template does not exist | `Error: Template not found: `, exit 2 | +| `inspect`, `preview` | The database is unreachable | Vault secrets are skipped, local secrets still render, and the output says `Vault-backed secrets were not resolved (no reachable database) — showing local-only secrets.`; `--json` adds `vaultProbeFailed: true` and `notice` | +| `inspect`, `preview` | The role lacks `run:file` on the config | Policy message, exit 1 | +| any executing command | Role lacks the permission, such as the default `viewer` agent role | `Cannot run.build on config "dev": "run:build" is not allowed on config "dev" (role: viewer).`, exit 1 | +| MSSQL files | `GO` alone on a line | Split into batches. A failure is prefixed `[batch N of M]`. See [execution](/guide/sql-files/execution) | -## Output: failure -When a file errors, the human-readable output includes the SQL error -beneath the file's status line so you don't have to scroll the log -looking for the cause: +## What do inspect and preview show? - db/sql/02_tables/Memory.sql (failed) - error: relation "memory" does not exist - Build completed status=partial filesRun=12 filesSkipped=0 filesFailed=1 durationMs=84 -A batch where some files ran and some failed reports `partial` and exits `3`. -Only a batch where nothing succeeded reports `failed` and exits `1`. +`inspect` lists the variables a template can read. `preview` prints the SQL the template renders to, on stdout, so it pipes to a file. -The same information lives on the JSON output's per-file entries. The -`--json` payload is the `BatchResult` (or `FileResult` for `noorm run file`) -returned by the SDK plus the envelope's `success` flag, so `files[].error` is -populated for every failed file: +```bash +noorm run inspect db/sql/09_seeds/01_products.sql.tmpl +``` - { - "success": false, - "status": "partial", - "files": [ - { - "filepath": "db/sql/02_tables/Memory.sql", - "status": "failed", - "error": "relation \"memory\" does not exist", - "durationMs": 4.2, - "checksum": "..." - } - ], - "filesRun": 12, - "filesSkipped": 0, - "filesFailed": 1, - "durationMs": 84 - } +``` +Template: db/sql/09_seeds/01_products.sql.tmpl +Config: dev +Data Files: + $.products Array [1] -## Output: skip +Built-ins: include, escape, quote, json, now, uuid +Secrets: 0 resolved, 0 global +``` -A file is skipped when its checksum matches a previous successful -execution. The reason is shown inline: +```bash +noorm run preview db/sql/09_seeds/01_products.sql.tmpl > rendered.sql +``` - db/sql/seeds/Sentinels.sql.tmpl (skipped: unchanged) +```sql +INSERT INTO product (sku, name, list_price) +VALUES ('HW-DONGLE', 'USB security key', 49); +``` -In JSON form, `skipReason` is set to `'unchanged'` (or `'already-run'` -for the change-level checks): +`inspect` adds `Helpers ($helpers):` and `Helper Errors:` sections when the folder has a `$helpers` file. `preview` prints resolved secrets, and both commands execute the template's helper scripts, so both check the `run:file` permission first. `--json` gives `{ filepath, context, … }` for `inspect` and `{ filepath, sql, durationMs }` for `preview`. - { - "filepath": "db/sql/seeds/Sentinels.sql.tmpl", - "status": "skipped", - "skipReason": "unchanged", - "checksum": "..." - } +## What does the output look like? + + +A failed file prints its SQL error under its status line: + +``` +✗ log /work/shop/db/sql/03_mixed/02_bad.sql (failed) +✗ log error: [Err 1] no such table: invoice_line_missing +✗ log Run directory partial filesRun=1 filesSkipped=0 filesFailed=1 +``` + +The summary line reads `Build completed` for `build`, `Run directory ` for `dir`, `Run files ` for `files`, and `Run exec ` for `exec`. `--json` prints the batch result, with `success` added: + +```json +{ + "success": false, + "status": "partial", + "files": [ + { "filepath": "/work/shop/db/sql/03_mixed/01_ok.sql", "checksum": "8ece28fd…", "status": "success", "durationMs": 1.36 }, + { "filepath": "/work/shop/db/sql/03_mixed/02_bad.sql", "checksum": "3dd15369…", "status": "failed", "error": "[Err 1] no such table: invoice_line_missing", "durationMs": 0.24 } + ], + "filesRun": 1, + "filesSkipped": 0, + "filesFailed": 1, + "durationMs": 7.4, + "changeId": 1 +} +``` + +| Field | Appears when | +|-------|--------------| +| `files[].skipReason` | A file was skipped: `unchanged` | +| `files[].outputPath` | A dry run wrote the file | +| `unmatchedInclude`, `unmatchedExclude` | `build` had patterns that matched nothing | +| `error` | The batch failed before any file ran, or `dir` found no files | +| `dryRun: true` | `build --dry-run` | + +`run file --json` returns one file result, with `success` added. + + +## Exit codes -If a file was skipped because you forgot `--force` after wiping the -database, this is the signal to look for. Pass `--force` to re-run -regardless of the checksum cache. +| Code | Meaning | Remedy | +|------|---------|--------| +| 0 | Every file succeeded or was skipped | None | +| 1 | Nothing succeeded: no connection, project not initialized, role denied, a missing required flag, `file` or `files` target missing, or every file that ran failed | Read the `error:` line, fix the SQL or the connection, run again | +| 2 | Nothing was attempted: `dir` or `exec` found no SQL files, or `inspect` or `preview` found no template | Fix the path or glob | +| 3 | Some files succeeded and some failed | Part of the database changed. Check the failed file's effect, fix it, run again; files that succeeded skip | -## MSSQL: `GO` batches +A re-run that fails again at the same file reports `failed` and exits 1, not 3: the earlier files skip, so nothing executed successfully in that run. -On MSSQL connections, files containing multiple statements separated by `GO` -(the T-SQL batch separator) work as you'd expect — `CREATE PROCEDURE`, -`CREATE FUNCTION`, `CREATE TRIGGER`, `CREATE VIEW`, and `CREATE TYPE` for -table-valued parameters can be grouped in a single file. The runner splits on -`GO` (anchored to its own line) and executes batches sequentially. If a batch -fails, the error is prefixed with `[batch N of M]` so you can identify the -offending statement without re-reading the file. See -[Writing MSSQL Files with GO](/guide/sql-files/execution#mssql-multiple-statements-per-file) -for examples and the -[runner internals](/dev/runner#mssql-batch-handling) for the splitter rules -and known limitations. +## What bites -## Examples +| Symptom | Cause | Fix | +|---------|-------|-----| +| `--force` fails with `table party already exists; 1 prior successful execution` | `--force` re-executes files that already ran, and `CREATE TABLE` is not guarded | Use `--force` only on files that are safe to run twice; new schema reaches an existing database through a [change](/guide/changes/add-a-column) | +| `run build` fails after you edit a table file | The new checksum makes the file run again against a table that exists | Build only fresh databases. Existing ones catch up with `noorm change ff` | +| Seed files report `skipped: unchanged` after `db truncate` | Truncate keeps noorm's tracking tables, so each file still has a successful record | Re-run the seed files with `--force` | - noorm run build - noorm run build --force - noorm run build --json - noorm run file db/sql/init.sql - noorm run file seeds/test-data.sql.tmpl --dry-run +## Next - noorm run dir db/sql/02_views/ - noorm run files --paths db/sql/01_tables/users.sql,db/sql/01_tables/orders.sql - noorm run exec "db/sql/**/*.sql" +- [Execution](/guide/sql-files/execution): skipping, tracking, and slow files +- [Templates](/guide/sql-files/templates): what `inspect` lists +- [Forward and revert](/guide/changes/forward-revert): the other way SQL reaches a database +- [CLI reference](/headless#exit-codes): the shared exit-code contract diff --git a/docs/cli/secret.md b/docs/cli/secret.md index d23560c0..3a3f7c57 100644 --- a/docs/cli/secret.md +++ b/docs/cli/secret.md @@ -1,77 +1,114 @@ # noorm secret -Manage **config-scoped local secrets** — values stored on disk in `.noorm/state/state.enc`, -tied to a single config, and never shared with the rest of the team. This is one of three -places a secret value can live; see [Which tier?](#which-tier) below before reaching for it. +Store a secret on one config, encrypted on your machine and shared with no one. +```bash +printf %s "$GATEWAY_KEY" | noorm secret set PAYMENT_GATEWAY_KEY --stdin --config prod +noorm secret list --config prod +``` -## Subcommands +``` +Secret "PAYMENT_GATEWAY_KEY" set for config "prod". +Secrets for "prod" (1): + PAYMENT_GATEWAY_KEY +``` -| Command | Purpose | -|---------|---------| -| `noorm secret set [value]` | Store a secret for the active or named config | -| `noorm secret list` | List secret keys for a config (values never shown) | -| `noorm secret rm ` | Remove a secret from a config | +The value lands in `.noorm/state/state.enc`. `list` shows keys only. A template reads it as `$.secrets.PAYMENT_GATEWAY_KEY`. -## Flags +## Where should a secret live? -Common to every subcommand: -- `--config ` / `-c ` — target a config other than the active one -- `--json` — emit machine-readable JSON on stdout +A template takes the first tier that holds the key: config, then global, then vault. A miss throws `Secret "KEY" not found (searched: config-local, global-local, vault)`. -`secret set` additionally accepts: +| Tier | Stored in | Set with | Shared with the team | +|------|-----------|----------|----------------------| +| Config | `.noorm/state/state.enc`, per config | `noorm secret set`, or the TUI (`c`, highlight a config, `k`) | No. Per user, per machine | +| Global | `state.enc`, all your configs | No CLI or TUI command writes one | No | +| Vault | The database, encrypted | [`noorm vault set`](/guide/environments/vault) | Yes | -- `--stdin` — read the value from stdin instead of argv, so it never reaches the process table, shell history, or a `set -x` trace. Omit the positional value when you pass it. -- `--yes` / `-y` — confirm the write on a config whose `secret:write` access is a confirm cell (an `operator` user role). Without it the command exits non-zero. +Use `noorm secret` for a value that differs for you. Use `noorm vault set` for a value the whole team and CI need. A team value set with `noorm secret set` lives in your `state.enc` only, so a teammate or a CI runner finds nothing, or finds a stale vault value. [`noorm settings secret`](/cli/settings-secret) declares which secrets a stage requires; it never stores a value. -`secret rm` additionally requires: -- `--yes` / `-y` — confirm the deletion; omitted, the command exits non-zero without deleting +## secret set -## Which tier? +``` +noorm secret set [value] [--stdin] [--config ] [--json] [--yes] +``` -noorm resolves `$.secrets.KEY` in a template from three tiers, config-local first: +| Flag | Effect | +|------|--------| +| `` | Secret name. Required | +| `[value]` | The value. Omit it with `--stdin` | +| `--stdin` | Read the value from stdin, keeping it out of the process table, shell history, and `set -x` traces. One trailing newline is stripped | +| `--config `, `-c ` | Target a config other than the active one | +| `--json` | `{"success":true,"configName":"prod","key":"PAYMENT_GATEWAY_KEY"}` | +| `--yes`, `-y` | Confirm the write when your role on the config asks first | -| Tier | Command | Scope | Shared with team? | -|------|---------|-------|--------------------| -| Config-local | `noorm secret set` | One config | No — per-user, per-machine | -| Global-local | TUI only (`noorm ui` → Settings → Secrets) | Every config on this machine | No — per-user, per-machine | -| Vault | [`noorm vault set`](../guide/environments/vault.md) | Every config, every teammate | Yes — encrypted in the database | +| Condition | Behavior | +|-----------|----------| +| Value and `--stdin` together | `Pass the value as an argument or via --stdin, not both.`, exit 1 | +| Neither | `Missing secret value. Pass it as an argument, or pipe it in with --stdin.`, exit 1 | +| `--stdin` with empty input | `No secret value on stdin.`, exit 1 | +| No `--config` and no active config | `No config specified and no active config set. Use --config or run "noorm config use ".`, exit 1 | +| Config does not exist | `Config "nope" not found.`, exit 1 | +| Your role is `operator` | `Writing a secret to config "staging" requires confirmation (yes-staging). Pass --yes to confirm.`, exit 1 | +| Your role does not allow `secret:write` | The policy message, exit 1 | -If a value should be different for you than for the rest of the team, it belongs in -`noorm secret` (or the TUI's global-local screen). If it should be the same for everyone — -CI included — it belongs in `noorm vault set`. `noorm secret set` on a value the whole team -needs is the mistake this page exists to prevent: it lands in your local `state.enc` only, -so a teammate or a CI runner resolving the same key finds nothing and either fails loudly (a -missing secret now throws — see [Templates](../guide/sql-files/templates.md)) or silently -picks up a stale vault value instead of the one you just set. +`--yes` and `NOORM_YES=1` both confirm. -See [Secrets](../guide/environments/secrets.md) for config-scoped vs. global-local, and -[Vault](../guide/environments/vault.md) for the full three-tier resolution hierarchy and -team-provisioning workflow. +## secret list -## Examples - noorm secret set API_KEY "sk-live-..." - echo "$API_KEY" | noorm secret set API_KEY --stdin - noorm secret set DB_PASSWORD "secret123" --config prod - noorm secret set API_KEY "sk-live-..." --json +``` +noorm secret list [--config ] [--json] +``` - noorm secret list - noorm secret list --config staging --json +```bash +noorm secret list --json +``` - noorm secret rm OLD_KEY --yes - noorm secret rm OLD_KEY --yes --config prod +```json +{"success":true,"configName":"prod","keys":["API_KEY","DB_PASSWORD"]} +``` +An empty config prints `No secrets set for config "prod".`. Unknown config, no active config, or a role without `secret:read` exit 1, with the messages above. -## Related -- [`noorm settings secret`](./settings-secret.md) — edits which secrets a stage *requires*, - not their values. -- [Secrets guide](../guide/environments/secrets.md) — full CLI + TUI workflow. -- [Vault guide](../guide/environments/vault.md) — team-shared secrets and CI provisioning. +## secret rm + + +``` +noorm secret rm --yes [--config ] [--json] +``` + +```bash +noorm secret rm OLD_KEY --yes --config prod +``` + +| Condition | Behavior | +|-----------|----------| +| No `--yes` and no `NOORM_YES` | `Pass --yes to confirm deletion of secret "OLD_KEY" from config "prod".`, exit 1 | +| Key not on the config | `Secret "OLD_KEY" not found in config "prod".`, exit 2 | +| Removed | `Secret "OLD_KEY" removed from config "prod".`, exit 0 | + + +## Exit codes + + +| Code | Meaning | Remedy | +|------|---------|--------| +| 0 | Done | None | +| 1 | Bad value input, unknown or missing config, state would not load, role denied, or confirmation missing | Read the message. Add `--config`, `--yes`, or `--stdin` as it says | +| 2 | `rm` named a key the config does not hold | `noorm secret list` shows the keys | + + +## Next + + +- [Secrets](/guide/environments/secrets): config-scoped versus global secrets +- [Vault](/guide/environments/vault): team secrets and CI provisioning +- [`noorm settings secret`](/cli/settings-secret): declare required secrets per stage diff --git a/docs/cli/settings-edit.md b/docs/cli/settings-edit.md index 3df3c810..ab2f8014 100644 --- a/docs/cli/settings-edit.md +++ b/docs/cli/settings-edit.md @@ -1,21 +1,32 @@ # noorm settings edit -Interactive editor for `settings.yml` — paths, build rules, strict mode, -logging, stages, conditional rules, teardown. +Edit `.noorm/settings.yml` from a menu instead of by hand. +```bash +noorm settings edit +``` -## Environment +The menu offers Paths, Build, Strict, Logging, Stages, Rules, Teardown, and Done. Each section saves as soon as its prompts finish. Cancelling inside a section drops that section's edit and returns to the menu. Done, or a cancel at the menu, exits 0 with `Settings saved.` -Interactive TTY required. Fails with exit code 2 (usage error) in CI or piped stdin. -`settings.yml` is plain YAML, so `--yes` / `NOORM_YES` has no useful -non-interactive path here. Both produce a redirect hint pointing you at -direct YAML edits, with `noorm settings build` available afterwards to -re-validate and apply defaults. See -[Non-interactive operation](../guide/automation/non-interactive.md). +## Where does it refuse to run? -## Example +It needs a terminal. `settings.yml` is plain YAML, so a script edits the file itself. - noorm settings edit +| Condition | Behavior | +|-----------|----------| +| `--yes`, `-y`, or `NOORM_YES=1` | `Error: noorm settings edit is interactive only.` Edit `settings.yml` directly, then run `noorm settings build` to validate. Exit 2 | +| No terminal on stdin | `Error: noorm settings edit requires an interactive terminal.`, exit 2 | +| No `settings.yml` | `Error: No settings.yml found. Run: noorm settings init`, exit 2 | +| `settings.yml` does not load | `Error: Failed to load settings: …`, exit 1 | + +`--yes` is the only flag, and it exists for the redirect above. See [non-interactive operation](/guide/automation/non-interactive). + + +## Next + + +- [`noorm settings secret`](/cli/settings-secret): declare required secrets per stage +- [Stages](/guide/environments/stages) diff --git a/docs/cli/settings-secret.md b/docs/cli/settings-secret.md index 16c7b8c5..ceda6ead 100644 --- a/docs/cli/settings-secret.md +++ b/docs/cli/settings-secret.md @@ -1,23 +1,42 @@ # noorm settings secret -Interactive editor for the secret *requirement* declarations in -`settings.yml`. Manages which secrets each stage requires — not the -actual values. +Declare which secrets a stage requires, from a menu. It writes requirements to `settings.yml` and never stores a value. -For secret values, see [`noorm secret set`](./secret.md). +```bash +noorm settings secret +``` +It prints the current requirements, then loops over Add, Edit, Remove, List, and Done. A requirement has a scope (`Universal`, or one stage), a key, a type (`string`, `password`, `api_key`, or `connection_string`), a description, and a required flag. It lands in `settings.yml`: -## Environment +```yaml +# .noorm/settings.yml +stages: + prod: + secrets: + - key: PAYMENT_GATEWAY_KEY + type: password + required: true +``` -Interactive TTY required. Fails with exit code 2 (usage error) in CI or piped stdin. +A universal requirement sits in the top-level `secrets:` list, and `required` defaults to true. Values are set with [`noorm secret set`](/cli/secret). -`--yes` / `NOORM_YES` produces a redirect hint here — the requirement -list lives directly in `settings.yml`, so editing the YAML by hand is the -non-interactive path. Use `noorm secret set ` for values. -See [Non-interactive operation](../guide/automation/non-interactive.md). +## Where does it refuse to run? -## Example - noorm settings secret +| Condition | Behavior | +|-----------|----------| +| `--yes`, `-y`, or `NOORM_YES=1` | `Error: noorm settings secret is interactive only.` Edit the `secrets` section of `settings.yml` directly, and use `noorm secret set ` for values. Exit 2 | +| No terminal on stdin | `Error: noorm settings secret requires an interactive terminal.`, exit 2 | +| No `settings.yml` | `Error: No settings.yml found. Run: noorm settings init`, exit 2 | +| `settings.yml` does not load | `Error: Failed to load settings: …`, exit 1 | + +`--yes` is the only flag. See [non-interactive operation](/guide/automation/non-interactive). + + +## Next + + +- [Stages](/guide/environments/stages#how-do-i-require-a-secret): how a config meets a requirement +- [`noorm secret`](/cli/secret): store the values diff --git a/docs/cli/sql-repl.md b/docs/cli/sql-repl.md index 46b7c2de..d3e046f8 100644 --- a/docs/cli/sql-repl.md +++ b/docs/cli/sql-repl.md @@ -1,28 +1,41 @@ # noorm sql repl -Launch the TUI directly on the SQL Terminal screen. Uses the active config -unless `--config` is provided. +Open the TUI directly on the SQL terminal, against the active config or the one you name. -For one-off non-interactive queries (CI scripts, ad-hoc inspection), use [`noorm sql query`](./sql.md) — `repl` requires an interactive TTY. +```bash +noorm sql repl --config dev +``` + +The terminal opens with the query box focused. `Enter` runs the query and `Esc` backs out. [The TUI page](/tui#sql-terminal) lists the keys, and [the SQL terminal guide](/guide/database/terminal) covers history, result browsing, and cancel. ## Flags -- `--config ` / `-c` — switch active config before launching +| Flag | Effect | +|------|--------| +| `--config `, `-c ` | Make this config the active one, then launch. The switch is saved, so it outlasts the session | +| `--yes`, `-y` | Accepted only to refuse: see below | + + +## Where does it refuse to run? + + +A REPL is interactive, so `--yes` and `NOORM_YES` cannot make it run unattended. -## Environment +| Condition | Behavior | +|-----------|----------| +| `--yes`, `-y`, or `NOORM_YES=1` | `Error: noorm sql repl is interactive only.` with `noorm sql query "SELECT 1"` and `noorm sql query --file query.sql` as the headless routes. Exit 2 | +| No terminal on stdin | `Error: noorm sql repl requires an interactive terminal.`, exit 2 | +| `--config` names a config that does not exist | `Error: Config not found: `, exit 2 | +| State cannot be decrypted | `Error: `, exit 1 | -Interactive TTY required. Fails with exit code 2 (usage error) in CI or piped stdin. +Use [`noorm sql query`](/cli/sql) for one-shot SQL in scripts and CI. History from the terminal is what [`noorm sql history`](/cli/sql#sql-history) reads. -A REPL is interactive by definition, so `--yes` / `NOORM_YES` cannot make it -succeed. They produce a redirect hint instead, pointing at `noorm sql query` -for one-shot SQL and `noorm sql query --file` for files. See -[Non-interactive operation](../guide/automation/non-interactive.md). +## Next -## Example - noorm sql repl - noorm sql repl --config dev +- [`noorm sql`](/cli/sql): one-shot queries and history +- [The TUI](/tui): keys for every screen diff --git a/docs/cli/sql.md b/docs/cli/sql.md index fe0060a7..fd769ba0 100644 --- a/docs/cli/sql.md +++ b/docs/cli/sql.md @@ -1,77 +1,170 @@ # noorm sql -Execute SQL statements and manage query history from the command line. +Run a SQL statement against a config from the command line, and manage the saved query history. -The `sql` namespace groups four subcommands: +```bash +noorm sql query "SELECT party_id, order_no FROM sales_order LIMIT 2" +``` -| Subcommand | Purpose | -|------------|---------| -| `query` | Run a SQL statement against the active config | -| `history` | Show recent queries (with rows and timings) | -| `clear` | Clear the saved query history | -| `repl` | Launch the TUI directly on the SQL Terminal screen | +``` +Columns: party_id, order_no +{"party_id":1,"order_no":1} +{"party_id":1,"order_no":2} +2 rows returned (3ms) +``` +| Subcommand | Does | +|------------|------| +| `query` | Runs one SQL statement, or a file of them, against the active config | +| `history` | Lists recent queries from the SQL terminal | +| `clear` | Deletes saved history | +| `repl` | Opens the TUI on the SQL terminal: [`noorm sql repl`](/cli/sql-repl) | -## Running a single query +The role your channel holds on the config decides which statements run. The statement is classified as read, write, or DDL; see [access roles](/guide/environments/configs#access-roles). -### Canonical form +## Can I skip the word `query`? -The explicit form is always correct and is what scripts and CI pipelines should use: - noorm sql query "SELECT 1" - noorm sql query "SELECT id, name FROM users LIMIT 10" - noorm sql query --json "SELECT id, name FROM users" - noorm sql query -f reports/monthly.sql +Yes, when the first word after `sql` that is not a flag starts with a SQL verb. noorm inserts `query` for you: +```bash +noorm sql "SELECT 1" # runs: noorm sql query "SELECT 1" +noorm sql --json "SELECT 1 AS one" # runs: noorm sql query --json "SELECT 1 AS one" +``` -### Bare form (convenience) +That word picks one of three outcomes: -The shorter `noorm sql ""` form is also supported. Internally, the CLI inspects argv before handing off to citty — if the first non-flag token after `sql` looks like a SQL statement (it starts with one of `SELECT`, `INSERT`, `UPDATE`, `DELETE`, `MERGE`, `WITH`, `CREATE`, `DROP`, `ALTER`, `TRUNCATE`, `EXEC`, `EXECUTE`, `CALL`, `SHOW`, `EXPLAIN`, `GRANT`, `REVOKE`, `COMMENT`, `PRAGMA`, `VACUUM`, `ANALYZE`, `BEGIN`, `COMMIT`, `ROLLBACK`, `SAVEPOINT`, or `USE`), the CLI inserts the `query` subcommand for you: +```mermaid +flowchart TD + A["noorm sql ..."] --> B{"first token after sql
that is not a flag"} + B -->|"query, history,
clear, repl"| C["that subcommand runs"] + B -->|"starts with a SQL verb"| D["query is inserted"] + B -->|"anything else"| E["Unknown command, exit 1"] +``` - noorm sql "SELECT 1" # → noorm sql query "SELECT 1" - noorm sql --json "SELECT 1 AS one" # → noorm sql query --json "SELECT 1 AS one" +The rewrite is positional and never touches the SQL or the flags. A SQL verb is one of `SELECT`, `INSERT`, `UPDATE`, `DELETE`, `MERGE`, `WITH`, `CREATE`, `DROP`, `ALTER`, `TRUNCATE`, `EXEC`, `EXECUTE`, `CALL`, `SHOW`, `EXPLAIN`, `GRANT`, `REVOKE`, `COMMENT`, `PRAGMA`, `VACUUM`, `ANALYZE`, `BEGIN`, `COMMIT`, `ROLLBACK`, `SAVEPOINT`, or `USE`, in any case. -The rewrite is purely positional — it never modifies the actual SQL string or flags. The real subcommands (`query`, `history`, `clear`, `repl`) are reserved words that never trigger the rewriter. +A flag that takes a value puts the value first, so the rewrite misses: -The rewriter reads the *first non-flag token* after `sql`, so a flag that takes a value puts something else there and no rewrite happens: +| Typed | Result | Write instead | +|-------|--------|---------------| +| `noorm sql -c prod "SELECT 1"` | `Unknown command prod`, exit 1 | `noorm sql query -c prod "SELECT 1"` | +| `noorm sql -f reports/monthly.sql` | `Unknown command reports/monthly.sql`, exit 1 | `noorm sql query -f reports/monthly.sql` | +| `noorm sql --json "SELECT 1"` | Works. `--json` takes no value | | - noorm sql -f reports/monthly.sql # → "Unknown command reports/monthly.sql" - noorm sql -c prod "SELECT 1" # → "Unknown command prod" -Both need the explicit form (`noorm sql query -f …`, `noorm sql query -c prod "…"`). A leading `--json`, which takes no value, is fine: `noorm sql --json "SELECT 1"` still rewrites. +## sql query -### Flags (on `query`) +``` +noorm sql query [] [--file ] [--config ] [--json] +``` -- `--config ` / `-c` — switch the active config for this invocation -- `--json` — print the result as JSON instead of human output -- `--file ` / `-f` — read SQL from a file +| Flag | Effect | +|------|--------| +| `` | The statement. Optional when `--file` is given | +| `--file `, `-f ` | Read the SQL from a file. It wins over `` | +| `--config `, `-c ` | Run against this config instead of the active one | +| `--json` | Print `{"success":true,"columns":[…],"rows":[…],"durationMs":…}` on stdout | -> `--json` is per-subcommand — place it after `query` (`noorm sql query "…" --json`), -> not before `sql`. See [CLI flag conventions](./flags.md) for the full rule. +Human output is `Columns:`, one JSON line per row, then `N row(s) returned (Xms)`. A statement that changes rows prints `Rows affected: N` instead. `--json` is a per-subcommand flag: write it after `query`, not before `sql`. The [flag rules](/cli/flags) explain why. +```bash +noorm sql query -f reports/monthly.sql --json +``` -## Sibling commands +| Condition | Behavior | +|-----------|----------| +| No `` and no `--file` | `No query provided. Usage: noorm sql "SELECT ..."`, exit 2 | +| `--file` cannot be read | `Failed to read SQL file: : `, exit 2 | +| The database rejects the statement | `Query failed: `, exit 1 | +| Your role does not allow that class of statement | The policy message, exit 1 | +| Cannot connect | `Failed to connect: `, exit 1 | -For interactive use and history management: +`sql query` writes nothing to history. It is the headless path, and persisting query text and returned rows on a build agent is an exposure nobody asked for. -- [`noorm sql repl`](./sql-repl.md) — launch the TUI on the SQL terminal screen -- `noorm sql history` — list recent queries (`-n`/`--limit`, default 50). Only the interactive SQL terminal records history; `sql query` deliberately writes none. -- `noorm sql clear --yes` — wipe the saved history (`--older-than ` to keep recent entries). Without `--yes` it prints what it would clear and exits without clearing. +## sql history -## Examples - # One-off query against the active config - noorm sql query "SELECT count(*) FROM users" +``` +noorm sql history [--limit ] [--config ] [--json] +``` - # Same thing, bare form - noorm sql "SELECT count(*) FROM users" +| Flag | Effect | +|------|--------| +| `--limit `, `-n ` | Entries to show. Default 50 | +| `--config `, `-c ` | History of this config | +| `--json` | `{"configName":…,"entries":[{id, query, executedAt, durationMs, success, errorMessage, rowCount}]}` | - # Run a SQL file (CI-friendly) - noorm sql query -f reports/monthly.sql --json +```bash +noorm sql history -n 2 +``` - # Pick a different config - noorm sql query -c prod "SELECT count(*) FROM orders" +``` +SQL history for 'prod' (2 entries): + + ✓ Oct 21, 01:41:59 12ms · 2 row(s) + SELECT party_id, order_no FROM sales_order LIMIT 2 + + ✗ Oct 21, 01:40:12 4ms · relation "invoice_line" does not exist + SELECT * FROM invoice_line +``` + +Only the interactive SQL terminal (`noorm sql repl`, or `Shift+Q` in the TUI) records history. Query text longer than 80 characters is cut with `…`. History needs a config name but no connection. The name comes from `--config`, then `NOORM_CONFIG`, then the active config. + +| Condition | Behavior | +|-----------|----------| +| `--limit` is not a positive integer | `Invalid limit: . Must be a positive integer.`, exit 2 | +| No config named and none active | `No config specified and no active config set. Use --config or run "noorm config use ".`, exit 2 | +| Nothing recorded | `No SQL history found for config 'prod'.`, exit 0 | + + +## sql clear + + +``` +noorm sql clear [--older-than ] [--yes] [--config ] [--json] +``` + +| Flag | Effect | +|------|--------| +| `--older-than ` | Delete only entries older than this many months | +| `--yes`, `-y` | Delete without the preview step | +| `--config `, `-c ` | History of this config | +| `--json` | Print `{configName, entriesRemoved, filesRemoved}`. Also skips the preview step | + +```bash +noorm sql clear --older-than 3 --yes +``` + +``` +Cleared 12 entries older than 3 month(s) (4 result files removed). +``` + +| Condition | Behavior | +|-----------|----------| +| Neither `--yes` nor `--json` | Prints `This will clear all entries from SQL history for config 'prod'.` and `Pass --yes to skip this confirmation.`, deletes nothing, exit 0 | +| `--json` without `--yes` | Deletes at once | +| `--older-than` is not a positive integer | `Invalid --older-than value: . Must be a positive integer (months).`, exit 2 | +| No config named and none active | Same message and exit 2 as `history` | + + +## Exit codes + + +| Code | Meaning | Remedy | +|------|---------|--------| +| 0 | Statement ran, or history was listed or cleared | None | +| 1 | Connection failed, the database rejected the statement, role denied, or `Unknown command` | Read the message. Spell out `query` when a flag precedes the SQL | +| 2 | No SQL given, unreadable `--file`, a bad `--limit` or `--older-than`, or no config to read history from | Fix the argument, or pass `--config` | + + +## Next + + +- [`noorm sql repl`](/cli/sql-repl): the interactive SQL terminal +- [The SQL terminal](/guide/database/terminal): history, result browsing, and cancel +- [CLI reference](/headless#sql): every flag in one place diff --git a/docs/design/site-running-example.md b/docs/design/site-running-example.md new file mode 100644 index 00000000..9741ac88 --- /dev/null +++ b/docs/design/site-running-example.md @@ -0,0 +1,438 @@ +# noorm.dev: one running example, less prose + + +This is an audit of the published docs site (`docs/`, deployed to noorm.dev) and a proposal to rebuild it around one example project. The audit covered all 48 user-facing pages (14,339 lines). The contributor pages under `docs/dev/` were read only where the user sidebar links into them. + +The proposal has three parts: + +- one example schema that follows the modeling rules the site teaches +- one source for every snippet, so examples cannot drift +- one page shape that puts the command and its output on the first screen + + +## Problem + + +A developer arriving at noorm.dev hits these problems in roughly this order. + + +### ignatius has no obvious download + + +| Where a reader looks | What they find | +|---|---| +| Hero buttons | "Get started" (noorm install) and "View on GitHub". Nothing for ignatius. | +| Top nav | "Modeling", which opens a narrative overview. None of its 8 "On this page" sections is an install step. | +| Sidebar | "Installation" under "Information Modeling", a group that starts collapsed. | +| Landing page | One install line, mid-page, in "Plan it before you build it". | +| Install URL | `https://raw.githubusercontent.com/noormdev/ignatius/main/install.sh`. noorm gets `noorm.dev/install.sh`; `noorm.dev/ignatius.sh` returns 404. | +| npm | ignatius is not published (`ignatius/package.json` is `"private": true`). | + + +### The modeling pages contradict each other and the rules they teach + + +The site argues for inherited keys, and its own examples break the rules that make inherited keys work. + +| Page | Example | Problem | +|---|---|---| +| `guide/relational-design.md:32,42` | `todos PRIMARY KEY (user_id, created_at)`, `todo_items (user_id, created_at, item_index)` | A timestamp is a key component. The per-parent sequence (`todo_no`) is the rule. | +| `models/todo-list/` (embedded in that page) | Same keys | The diagram readers see repeats the defect. | +| `modeling/entities.md` | `Party → SalesInvoice → SI_Line` with `line_seq` | Correct, but a different domain from the page that makes the argument. | +| `getting-started/first-build.md` | `CREATE TABLE IF NOT EXISTS users (id SERIAL ...)`, `posts (id SERIAL, user_id REFERENCES users(id))` | `IF NOT EXISTS` in `sql/`, surrogate key on a child table, unnamed FK. | +| `guide/changes/overview.md:147` | `verification_tokens (id SERIAL ...)` | Surrogate key on a child of `users`. | +| `guide/database/explore.md:248-254` | `posts_user_id_fkey`, `comments_post_id_fkey` | Default FK names instead of predicate names. | +| `examples/todo-db/sql/00_tables/03_task.sql:26`, `04_association.sql:20` | `todo PRIMARY KEY (user_id, category_id, created_at)`, FK `todo_has_tag` | Timestamp and a reclassifiable column in the key, ORM-verb FK name. | +| `tapes/demo-project/sql/01_tables/003_task.sql:9` | `task (user_id, created_at, task_index)` | Source of every TUI screenshot. | + +`modeling/entities.md` adds a second kind of confusion. It opens on IDEF1X theory and an optional skill, and reaches the file format in its second section. It ends in viewer internals (the lineage spotlight's subset test, classification precedence) that belong in the ignatius repo's reference docs. Nothing on the site shows how an entity file becomes a `CREATE TABLE`, which is the handoff the whole modeling section exists to support. + + +### At least six example domains, none shared + + +| Domain | Where | +|---|---| +| Blog: `users`, `posts`, `comments` | First Build, Building Your SDK, 9 guide pages, `headless.md`, `cli/sql.md`. `users` alone has 4+ incompatible definitions. | +| Todo: `users/todos/todo_items` | `relational-design.md`, `models/todo-list` | +| Todo variant: `app_user/project/task` | TUI screenshots (`tapes/demo-project`) | +| Order-to-cash: `Party/SalesInvoice/SI_Line` | `modeling/entities.md` | +| LLM memory: `Memory`, `Sentinels`, `sp_Memory_Create` | ignatius demo video, `cli/run.md`, `reference/sdk.md` | +| Social graph: `posts/photos/tags/groups` | `relational-design.md`, `models/social`, `models/polymorphic` | + +`reference/sdk.md` alone draws from about seven: blog, accounts and invoices, departments, orders, task batches, memory, and abstract helpers (`add_numbers`, `simple_proc`). Eleven guide pages show no schema at all, only connection strings or secret names. + + +### Prose before the command + + +| Page | First command | Notes | +|---|---|---| +| `getting-started/first-build.md` | line 27 | Four TUI detours (config, create, new change, test config). Each has a headless equivalent: `noorm config import`, `noorm db create`, `noorm change add`. | +| `getting-started/building-your-sdk.md` | line 399 (`createContext`) | Steps 1-4 are monorepo scaffolding. Several samples do not compile (`app`, `emailService` undefined). | +| `guide/changes/history.md` | line 113 | | +| `guide/environments/vault.md` | line 91 | Lines 60-84 are X25519/HKDF internals. | +| `guide/database/explore.md` | line 69 | 13 ASCII TUI mockups before the CLI form. | +| `guide/sql-files/templates.md`, `guide/environments/stages.md`, `guide/sql-files/organization.md`, `guide/relational-design.md` | none | No `noorm` command block on the page. | + + +### Navigation + + +- Nine of the ten `cli/*` pages are in no nav or sidebar. `cli/run.md` and `cli/settings-edit.md` have no inbound links at all. +- `headless.md` (1,870 lines) puts its 70 commands at `####`, and `outline.level` is `[2, 3]`, so the right-hand TOC lists group names only. There is no command index. +- `dev/sdk.md` (1,159 lines) is an older copy of the user SDK reference, reachable from the user sidebar as "Contributor Documentation". +- The same content lives in several places: the access-role matrix (configs, teardown, mcp, terminal), the CI bootstrap script (create, non-interactive, troubleshooting), the env-var table (configs, ci, deployment), and dry-run (changes overview, forward-revert). + + +### Correctness bugs found along the way + + +These ship regardless of the redesign. + +| Bug | Evidence | +|---|---| +| `noorm -c prod run build` runs in a directory named `prod` | `src/cli/index.ts:179` reads `-c` before the subcommand as `--cwd`. `guide/environments/configs.md:86,169,189,375` use it as `--config`. | +| `https://noorm.dev/install.sh` verifies nothing | The served copy, `docs/public/install.sh` (116 lines), has no checksum code and ignores `NOORM_INSECURE`. The repo-root `install.sh` (200 lines, the GitHub-mirror URL) verifies against `checksums.txt`. The two copies drifted, so `getting-started/installation.md`'s "every install path verifies the downloaded binary" is false for the primary URL. Fix: deploy the root script at build time and delete the copy. | +| `NOORM_INSTALL_DIR=~/my-tools curl … \| sh` never reaches the installer | A prefix assignment applies to `curl` only. `getting-started/installation.md:38,64` (`NOORM_INSECURE` too). Fix: `curl … \| NOORM_INSTALL_DIR=~/my-tools sh`. | +| SDK install line omits the `kysely` peer | `reference/sdk.md:16-18` vs `packages/sdk/package.json` peers. | +| "Four permissions require confirmation even for admin" | `changes.delete` is a fifth (`src/core/policy/matrix.ts:17`). | +| `secret set` shown without `--stdin`/`--yes`; `init` shown without `--here` | `headless.md` vs `src/cli/secret/set.ts`, `src/cli/init.ts`. | +| Settings link loops | `guide/sql-files/organization.md:351` → `stages.md`, which has no `build.include` content and links back. | + + +## Goals / Non-goals + + +Goals: + +- A reader can install either tool from the first screen of noorm.dev. +- Every code sample on the site comes from one example project, and that project's CI proves the samples run. +- The example follows every modeling rule the site teaches. +- Every page shows its command and real output within the first screen. +- Fewer pages: one home per topic, everything else links to it. + +Non-goals: + +- Visual redesign beyond the landing and Download pages. The landing replaces the stock VitePress hero, so its hero components and their CSS go; the rest of the theme and brand CSS stay. +- New CLI features. A `noorm init --example` scaffold would help, but it is a public contract and a separate decision. +- Rewriting `docs/dev/`, apart from removing `dev/sdk.md`'s duplicate of the user reference. +- Re-running the 1M-row benchmark in `relational-design.md`. + + +## Approaches: which running example + + +| # | Example | Wins | Loses | +|---|---|---|---| +| A | **Order-to-cash**, trimmed from ignatius's `models/key-inherited` | Already follows the rules (`line_seq`, `places / is placed by`). Has a real subtype cluster (Party → Person, Business). Payments that can only settle the same party's invoice show what inherited keys buy, in one FK. Every noorm feature has a natural use. ignatius already ships the `order-to-cash` flow. | New example project to build. Business vocabulary (invoice, allocation) is one notch past a toy. | +| B | Todo, remodeled correctly (`todo_no`, `item_no`) | Smallest. Every reader knows the domain. `examples/todo-db` exists. | Subtypes, stored procedures, transfer, and vault all feel staged in a todo app. `todo-db` needs a full remodel anyway. | +| C | LLM memory (`examples/llm-memory-db-*`) | Exists with 230 tests, matches the MCP pitch. | 38 tables, 140 SQL files. The domain needs explaining before any example lands. | +| D | Blog (`users/posts`), cleaned up | Already the most-used domain. | No subtype story, no procedure story. Every ORM tutorial uses it, so it does nothing to show why noorm is different. | + + +## Recommendation + + +**A, order-to-cash.** It is the only candidate that is already correct, already modeled in ignatius, and large enough to carry every feature without staging. Its central FK is the site's argument in one line. `payment_allocation` carries `party_id` from both parents, so a payment from one customer cannot settle another customer's invoice. The database enforces that without a trigger. + + +### The schema + + +Eleven tables. The quick start uses two, and later pages add tables as the topic needs them. + +The transactional spine. Every child inherits `party_id`, which is what lets one constraint stop cross-customer payments: + +```mermaid +erDiagram + party ||--o{ sales_order : "places" + sales_order ||--|{ order_line : "contains" + product ||--o{ order_line : "is sold via" + party ||--o{ invoice : "owes on" + invoice ||--|{ invoice_line : "contains" + order_line ||--o{ invoice_line : "is billed by" + party ||--o{ payment : "makes" + payment ||--|{ payment_allocation : "is applied as" + invoice_line ||--o{ payment_allocation : "is settled by" +``` + +The party cluster. `party_type` is the discriminator, so a party is exactly one of person or business: + +```mermaid +erDiagram + party_type ||--o{ party : "classifies" + party ||--o| person : "is realized as" + party ||--o| business : "is realized as" +``` + +| Table | Primary key | Rule it demonstrates | +|---|---|---| +| `party` | `party_id` | Surrogate allowed at a root: nothing above it supplies identity. | +| `person`, `business` | `party_id` | Exclusive subtypes, one-to-one with the basetype. | +| `product` | `product_id`, AK `sku` | Root entity with an alternate key named for its rule. | +| `sales_order` | `party_id, order_no` | Per-parent sequence, assigned by `place_order()`. No timestamp in the key. | +| `order_line` | `party_id, order_no, line_no` | Full lineage in the key; the owner is readable without a join. | +| `invoice`, `invoice_line` | `party_id, invoice_no[, line_no]` | Same shape, second spine. | +| `payment` | `party_id, payment_no` | | +| `payment_allocation` | `party_id, payment_no, invoice_no, line_no` | Two identifying parents share `party_id`, which makes cross-party allocation unrepresentable. | + +FKs follow `__`: `sales_order_isplacedby_party`, `order_line_ispartof_sales_order`, `payment_allocation_settles_invoice_line`. `sql/` declares every column and constraint once, inline in `CREATE TABLE`. Every `ALTER`, backfill, and drop lives in `changes/`. + + +### How the example grows across the site + + +| Page | Tables and objects it uses | Feature it shows | +|---|---|---| +| Landing, Quick start | `party`, `sales_order` | `run build`, then a change that adds `order_line` | +| Concepts | same | SQL files are current state, changes are how existing databases catch up | +| SQL files & build order | all tables, `01_tables/` … `04_views/` | Ordering, `build.include` | +| Templates & seeds | `products.yml` → `product` rows, `roles.yml` → `GRANT`s | `.sql.tmpl`, data loaders | +| Changes | `order-status` change: `ALTER TABLE sales_order ADD COLUMN status` + backfill + `SET NOT NULL` | Why history lives in `changes/` and never in `sql/` | +| Configs & stages | `dev`, `staging`, `prod` | Stages, access roles | +| Secrets & vault | `PAYMENT_GATEWAY_KEY`, read in a `.sql.tmpl` as `$.secrets.PAYMENT_GATEWAY_KEY` | Sensitive values stay out of committed SQL; the template fills them in when the SQL runs | +| Explore, SQL terminal | the built schema | Real `noorm db explore --json` output | +| Transfer | staging → qa: `party` → `product` → `sales_order` → `order_line` | FK-ordered copy | +| SDK | `ctx.proc('place_order', …)` with a TVP of lines, `ctx.func('party_balance', …)` | Typed procs, functions, TVPs | +| Testing | `requireTest`, truncate between tests | Test guards | +| CI | the example's own workflow job | Headless build + change + tests | +| MCP | an agent with `viewer` reads `sales_order` | Per-channel roles | +| Modeling | the same 11 entities plus the `order-to-cash` flow | IDEF1X, DFDs, model → SQL | +| Relational design | `payment_allocation` | The case for inherited keys, with this schema | + + +### One source for every snippet + + +The example lives in `examples/order-to-cash/` and holds both halves: the ignatius model and the noorm project. Docs import files from it with VitePress's `<<<` syntax, which resolves paths relative to the page with no restriction to `srcDir` (checked in VitePress 2.0.0-alpha.15's snippet plugin). A sample on the site is therefore a file CI has run. + +Each artifact the site shows is built from the example project, so a schema change reaches every page on the next deploy: + +```mermaid +flowchart TD + EX["examples/order-to-cash/"] --> CI["CI: build, change ff, tests"] + EX --> SNIP["docs pages via snippet import"] + EX --> EXP["ignatius export"] + EXP --> HTML["public/models/order-to-cash.html"] + EX --> TAPE["tapes/*.tape"] + TAPE --> GIF["public/image/*.gif"] +``` + +``` +examples/order-to-cash/ + model/ ignatius root (ignatius.yml, data/, flows/) + sql/ + 01_tables/ + 02_views/ + 03_functions/ + 09_seeds/ products.yml, roles.yml, *.sql.tmpl + changes/ + scripts/quickstart.sh the landing quick start, run in CI + tests/ SDK integration tests +``` + +Until checkpoint 3 creates the example project, the model lives at `docs/models/order-to-cash/` beside the site's other models, and moves into `examples/order-to-cash/model/` with it. + +`examples/todo-db` stays as an SDK test target and drops out of the docs. `docs/models/todo-list` is replaced by the order-to-cash export. `tapes/demo-project` is replaced by the example project, and the tapes re-record from it. + + +### Before and after: the landing quick start + + +Before (`docs/index.md`): + +```bash +mkdir -p sql/01_tables +echo "CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT);" > sql/01_tables/001_users.sql + +noorm run build +``` + +After, imported from `examples/order-to-cash`. The exact config commands get settled when `scripts/quickstart.sh` runs green in CI: + +```sql +-- sql/01_tables/01_party.sql +CREATE TABLE party ( + party_id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name text NOT NULL +); + +-- sql/01_tables/02_sales_order.sql +CREATE TABLE sales_order ( + party_id integer NOT NULL, + order_no integer NOT NULL, + CONSTRAINT sales_order_pk PRIMARY KEY (party_id, order_no), + CONSTRAINT sales_order_isplacedby_party + FOREIGN KEY (party_id) REFERENCES party (party_id) +); +``` + +```bash +noorm run build # fresh database: runs sql/ in order +noorm change add order-lines # add sql/01_tables/03_order_line.sql, list it in the change +noorm change ff # existing database: runs pending changes +``` + +The reader sees a correct key, a named FK, and both halves of the model (build and change) before the first heading. + + +### Before and after: model to SQL + + +The site never shows this handoff today. A new "From model to SQL" page puts the entity file and the DDL side by side, using a VitePress `code-group`: + +```markdown +--- +entity: sales_order +group: sales +pk: [party_id, order_no] +columns: + party_id: { type: integer } + order_no: { type: integer, desc: "Per-party sequence, assigned by place_order()." } +relationships: + - target: party + on: { party_id: party_id } + predicate: { fwd: places, rev: is placed by } +--- +``` + +| Entity file | SQL | +|---|---| +| `pk:` | `PRIMARY KEY (…)` | +| `relationships[].on` | `FOREIGN KEY (…) REFERENCES …` | +| `predicate.rev` + target | constraint name: `sales_order_isplacedby_party` | +| `type: integer` | the physical type, chosen per dialect in `sql/` | +| `ak:` rule | `CONSTRAINT product_isidentifiedby_sku UNIQUE (sku)` | +| `subtypes:` | one table per member, PK = FK to the basetype | + + +### Site structure + + +48 user pages become 34. Each topic gets one page, and everything that repeated it links there instead. + +``` +Docs + Start + Quick start installation + first-build + Concepts + The example project new: tour of examples/order-to-cash + Schema + SQL files & build order organization + execution + Templates & seeds + Changes overview + forward-revert + history + Environments + Configs & stages configs + stages + Secrets & vault secrets + vault + Identity cli/identity + Data + Explore CLI output first, TUI second + Transfer + Create, reset & teardown create + teardown + SQL terminal terminal + cli/sql + cli/sql-repl + App code + SDK in 5 minutes top of building-your-sdk + Procedures, functions, TVPs + Testing against a real DB + Deploying deployment + Automation + CI ci + non-interactive + Agents (MCP) + Troubleshooting symptom | cause | fix table + Background + Why noorm + Relational design rewritten on order-to-cash +Modeling (ignatius) + Install first page of the section + Model the example serve the order-to-cash model, edit one entity + Entity files format only; internals move to the ignatius repo + Data flows + From model to SQL new + Reverse-engineering + Modeling skill absorbs branding + best practices +Reference + CLI headless + cli/flags, help, init, run, secret, settings-* + SDK API + settings.yml + TUI keys tui.md +Download new: both tools, every platform, both skills +``` + +Top nav becomes `Docs · Modeling · Reference · Download`. "Contributor Documentation" moves from the user sidebar to the footer, and `dev/sdk.md` shrinks to internals that `reference/sdk.md` does not cover. + + +### Page shape + + +Every guide page follows the same order, so a reader learns the layout once: + +``` +# "Add a column to a live database" + + +<<< the example file or command + + +## How it works diagram or table; prose only for reasoning +## Options flag table, linking to Reference +## What bites symptom | cause | fix +Next: +``` + +Budgets: first code block before line 15, no paragraph over four sentences, internals (crypto, classifiers, driver quirks) go to `docs/dev/`. + +The reference pages get one entry shape. CLI: synopsis, flag table, example, JSON output, exit codes, with a command index at the top and `outline.level` raised to `[2, 4]`. SDK: signature, params table, returns, example, with an API index at the top. + + +### Download page + + +One page, linked from the hero and the nav. + +| | noorm | ignatius | +|---|---|---| +| macOS / Linux | `curl -fsSL https://noorm.dev/install.sh \| sh` | `curl -fsSL https://noorm.dev/ignatius.sh \| sh` | +| npm | `npm i -g @noormdev/cli` | none | +| Windows | `noorm-windows-x64.exe` from releases | `ignatius-windows-x64.exe` from releases | +| Agent skill | `npx skills add noormdev/noorm/skills` | `npx skills add https://github.com/noormdev/ignatius --skill ignatius-modeling` | +| Verify | `noorm --version` | `ignatius --help` | + +`noorm.dev/ignatius.sh` is a two-line file in `docs/public/` that pipes the ignatius repo's `install.sh` into `sh`. It needs no copy of the installer, so it cannot go stale. `IGNATIUS_*` variables set on the outer `sh` (`curl … | IGNATIUS_VERSION=v0.23.0 sh`) reach the inner one. The hero's secondary button changes from "View on GitHub" (already in the nav icons) to "Download". + + +## What will bite + + +| Risk | Cause | Mitigation | +|---|---|---| +| A moved example file blanks a code block on the site | VitePress renders `Code snippet path not found: …` in place of the block and does not fail the build | Grep `.vitepress/dist` for that string in `docs.yml` and fail the job | +| The docs deploy misses an example change | `docs.yml` triggers on `docs/**` only | Add `examples/order-to-cash/**` to its path filter | +| The model drifts from the SQL | ignatius and noorm read different files | CI runs `ignatius validate model/` and a check that every entity has a table | +| ignatius format changes break the example | ignatius releases on its own cadence | Pin `IGNATIUS_VERSION` in CI; bump deliberately | +| `docs/models/key-inherited` style models multiply | ignatius repo keeps its own 24-entity reference model | Out of scope here; the site uses only the order-to-cash model | +| The page shape conflicts with the docs rule | `.claude/rules/documentation.md` says "code follows the explanation that motivates it" | Change the rule to "one-line problem, then the example, then the explanation" in the same PR as the first rewritten page | + + +## Decisions needed + + +1. **Running example.** Order-to-cash (recommended), remodeled todo, LLM memory, or blog. +2. **Tutorial database.** Postgres in Docker for every page (recommended: one dialect keeps procs, TVPs, and transfer real) or SQLite for the quick start only (zero install, but the reader switches dialects at the SDK page). +3. **Relational-design essay.** Rewrite it on order-to-cash and drop `models/social` and `models/polymorphic` (recommended), or keep the social-graph section and its 1M-row benchmark as is. +4. **`examples/todo-db`.** Keep as an SDK test target outside the docs (recommended), or retire it once order-to-cash covers its tests. + + +## Rollout + + +| # | Checkpoint | Depends on | +|---|---|---| +| 1 | Correctness fixes from the bug table above | nothing; ships first | +| 2 | Download page, `noorm.dev/ignatius.sh`, hero button, nav rename | nothing | +| 3 | `examples/order-to-cash`: model, `sql/`, `changes/`, seeds, `quickstart.sh`, tests, CI job, `ignatius export` | decisions 1, 2 | +| 4 | Landing, Quick start, Concepts, Example project page, doc-rule change | 3 | +| 5 | Modeling section: Install first, Model the example, From model to SQL, internals moved out | 3 | +| 6 | Guides rewritten to the page shape, merges and redirects | 4 | +| 7 | CLI and SDK reference restructure, orphan `cli/*` folded in, `dev/sdk.md` trimmed | 6 | +| 8 | Tapes re-recorded from the example, `tapes/demo-project` and `models/todo-list` removed | 3 | diff --git a/docs/download.md b/docs/download.md new file mode 100644 index 00000000..6d6eafb4 --- /dev/null +++ b/docs/download.md @@ -0,0 +1,109 @@ +--- +title: Download +description: Install noorm and ignatius on macOS, Linux, or Windows, plus the agent skills and the SDK. +sidebar: false +aside: false +pageClass: page-download +prev: false +next: false +--- + +# Download + +noorm builds and changes databases. ignatius models them before they exist. Each is one binary with no runtime to install. + +
+
+ +## noorm + +

CLI, terminal UI, and MCP server. Builds schemas from SQL files, applies changes, and manages configs, secrets, and data.

+ +::: code-group + +```bash [macOS / Linux] +curl -fsSL https://noorm.dev/install.sh | sh +``` + +```bash [npm] +npm install -g @noormdev/cli +``` + +```powershell [Windows] +npm install -g @noormdev/cli +# or noorm-windows-x64.exe from the GitHub releases page +``` + +::: + +

Agent skill

+ +```bash +npx skills add noormdev/noorm/skills +``` + +

SDK, for application code

+ +```bash +npm install @noormdev/sdk kysely pg +``` + +

Swap pg for mysql2, better-sqlite3, or tedious tarn. Verify with noorm --version; update with noorm update.

+ +
+
+ +## ignatius + +

Data modeler. Turns a folder of markdown into an IDEF1X entity diagram, a data dictionary, and data flow diagrams.

+ +::: code-group + +```bash [macOS / Linux] +curl -fsSL https://noorm.dev/ignatius.sh | sh +``` + +```powershell [Windows] +# ignatius-windows-x64.exe from the GitHub releases page +``` + +```bash [From source] +git clone https://github.com/noormdev/ignatius +cd ignatius && bun install && bun run build:cli +``` + +::: + +

Agent skill

+ +```bash +npx skills add https://github.com/noormdev/ignatius --skill ignatius-modeling +``` + +

Try it on a model

+ +```bash +ignatius serve path/to/model -o +``` + +

Verify with ignatius version; update with ignatius update. See ignatius commands for serve, validate, export, and the rest.

+ +
+
+ +## Both at once + +```bash +curl -fsSL https://noorm.dev/install.sh | sh && curl -fsSL https://noorm.dev/ignatius.sh | sh +``` + +## Install options + +| Need | noorm | ignatius | +|---|---|---| +| Install somewhere else | `curl … \| NOORM_INSTALL_DIR=~/bin sh` | `curl … \| IGNATIUS_INSTALL_DIR=~/bin sh` | +| Pin a version | `npm install -g @noormdev/cli@` | `curl … \| IGNATIUS_VERSION=v0.23.0 sh` | +| `noorm.dev` is blocked | `raw.githubusercontent.com/noormdev/noorm/master/install.sh` | `raw.githubusercontent.com/noormdev/ignatius/main/install.sh` | +| Releases and checksums | [noorm releases](https://github.com/noormdev/noorm/releases) | [ignatius releases](https://github.com/noormdev/ignatius/releases/latest) | + +The variable goes on `sh`, after the pipe. Set before `curl`, it reaches only `curl` and the installer never sees it. diff --git a/docs/getting-started/building-your-sdk.md b/docs/getting-started/building-your-sdk.md index 147aa04e..392c5e4b 100644 --- a/docs/getting-started/building-your-sdk.md +++ b/docs/getting-started/building-your-sdk.md @@ -1,273 +1,94 @@ -# Building Your SDK +# Building your SDK -You've built your schema. Now you want to use it. This guide shows how to wrap noorm in a TypeScript SDK that provides type-safe database access across your workers, servers, and CLI tools. +Wrap `createContext` in one workspace package, so every app and test queries the database through the same typed client. +```typescript +// apps/api/src/orders.ts +import { connect } from '@acme/db'; -## What You'll Build - -A dedicated database package in your monorepo: +const db = await connect({ config: 'dev' }); -``` -packages/ -└── db/ - ├── package.json - ├── src/ - │ ├── index.ts # Public API - │ ├── client.ts # Database client - │ ├── testing.ts # Test utilities - │ ├── types.ts # Database types - │ └── domains/ # Domain classes - │ ├── users.ts - │ └── posts.ts - └── tests/ # Integration tests +const orders = await db.orders.listByParty(2); +// [ { party_id: 2, order_no: 1 }, { party_id: 2, order_no: 2 } ] ``` -This structure gives you: -- **Type safety** - Kysely types mirror your database -- **Testability** - Integration tests use real databases -- **Portability** - Same SDK works in workers, servers, CLI +## How is the package laid out? -## Step 1: Set Up the Monorepo +Apps and tests share one client, and only the test entry point sets `requireTest`. -Create a pnpm workspace: - -```bash -mkdir my-project && cd my-project -pnpm init +```mermaid +flowchart TD + APPS["apps/api
apps/worker"] --> CONNECT["connect()"] + TESTS["tests/orders.test.ts"] --> SETUP["setupTestDb()
requireTest: true"] + SETUP --> CREATE["createClient()"] + CONNECT --> CREATE + CREATE --> CTX["createContext()"] + CTX --> KYSELY["ctx.kysely"] + CTX --> OPS["ctx.noorm"] + KYSELY --> DB[(database)] + OPS --> DB ``` -Create `pnpm-workspace.yaml`: - -```yaml -packages: - - 'packages/*' - - 'apps/*' +``` +packages/db/ +├── src/ +│ ├── index.ts # re-exports connect, createClient, Client, Database +│ ├── client.ts +│ ├── testing.ts +│ ├── types.ts +│ └── domains/orders.ts +└── tests/orders.test.ts ``` - -## Step 2: Create the Database Package +In `packages/db`, install the SDK, Kysely, and your driver ([Installation](/getting-started/installation#install-the-sdk-optional) lists the driver per dialect), plus the test tools: ```bash -mkdir -p packages/db -cd packages/db -pnpm init +pnpm add @noormdev/sdk kysely pg +pnpm add -D typescript vitest ``` -Update `packages/db/package.json`: - -```json -{ - "name": "@my-project/db", - "version": "1.0.0", - "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": "./dist/index.js", - "./testing": "./dist/testing.js" - }, - "scripts": { - "build": "tsc", - "test": "vitest run", - "test:watch": "vitest" - }, - "dependencies": { - "@noormdev/sdk": "*", - "kysely": "^0.28.0", - "pg": "^8.16.0", - "zod": "^3.24.0" - }, - "devDependencies": { - "typescript": "^5.9.0", - "vitest": "^4.0.0" - } -} -``` +## Describe the schema -## Step 3: Define Your Types -Create `src/types.ts` with types that match your schema: +One interface per table, mirroring `db/sql/`. Update an interface in the same commit that changes its table. ```typescript -import { z } from 'zod'; - -/** - * Database type definitions matching SQL schema. - * - * These types are used by Kysely for type-safe queries. - * Keep them in sync with your SQL files. - */ +// packages/db/src/types.ts +import type { Generated } from 'kysely'; -export interface UsersTable { - id: number; +export interface PartyTable { + party_id: Generated; name: string; - email: string | null; - bio: string | null; - created_at: Date; -} - -export interface PostsTable { - id: number; - user_id: number; - title: string; - body: string | null; - created_at: Date; } -export interface RecentPostsView { - id: number; - user_id: number; - title: string; - body: string | null; - created_at: Date; - author_name: string; +export interface SalesOrderTable { + party_id: number; + order_no: number; + placed_at: Generated; } -/** - * Complete database schema for Kysely. - */ export interface Database { - users: UsersTable; - posts: PostsTable; - recent_posts: RecentPostsView; + party: PartyTable; + sales_order: SalesOrderTable; } - -// Domain types (what comes OUT of the database) -export type User = UsersTable; -export type Post = PostsTable; -export type RecentPost = RecentPostsView; - -// ───────────────────────────────────────────────────────────── -// Input Schemas (what goes INTO the database) -// ───────────────────────────────────────────────────────────── - -/** - * Define inputs as Zod schemas, then infer types from them. - * - * Input types differ from table types—they don't include - * auto-generated fields like `id` or `created_at`. - */ - -export const CreateUserSchema = z.object({ - name: z.string().min(1).max(255), - email: z.string().email().optional(), - bio: z.string().max(1000).optional(), -}); - -export const CreatePostSchema = z.object({ - user_id: z.number().int().positive(), - title: z.string().min(1).max(255), - body: z.string().max(10000).optional(), -}); - -// Infer input types from schemas—no duplication -export type CreateUserInput = z.infer; -export type CreatePostInput = z.infer; -``` - -Zod gives you runtime validation that TypeScript can't. Never trust user input—validate it before it touches your database. This protects against malformed data, provides clear error messages, and catches mistakes that even you might make. - -::: tip Type Generation -Kysely provides [kysely-codegen](https://github.com/RobinBlomberg/kysely-codegen) to automatically generate types from your database: - -```bash -pnpm add -D kysely-codegen -npx kysely-codegen --dialect postgres --out-file src/types.ts ``` -This introspects your database and generates accurate TypeScript types. -::: - - -## Step 4: Create Domain Classes - -Create classes for each domain that hold a reference to the database context. - -`src/domains/users.ts`: - -```typescript -import type { Context } from '@noormdev/sdk'; -import type { Database, User, CreateUserInput } from '../types.js'; -import { CreateUserSchema } from '../types.js'; - -export class Users { - - #ctx: Context; - - constructor(ctx: Context) { - - this.#ctx = ctx; - - } - - async findById(id: number): Promise { - - const result = await this.#ctx.kysely - .selectFrom('users') - .selectAll() - .where('id', '=', id) - .executeTakeFirst(); - - return result ?? null; - - } - - async findByEmail(email: string): Promise { - - const result = await this.#ctx.kysely - .selectFrom('users') - .selectAll() - .where('email', '=', email) - .executeTakeFirst(); - - return result ?? null; - - } - - async create(input: CreateUserInput): Promise { - - // Validate input before touching the database - const validated = CreateUserSchema.parse(input); - - const result = await this.#ctx.kysely - .insertInto('users') - .values({ - name: validated.name, - email: validated.email ?? null, - bio: validated.bio ?? null, - }) - .returningAll() - .executeTakeFirstOrThrow(); - - return result; - - } - - async list(): Promise { - - return this.#ctx.kysely - .selectFrom('users') - .selectAll() - .orderBy('created_at', 'desc') - .execute(); - } +## Write a domain class -} -``` -`src/domains/posts.ts`: +Each domain holds the context and exposes the queries for its tables: ```typescript +// packages/db/src/domains/orders.ts import type { Context } from '@noormdev/sdk'; -import type { Database, Post, RecentPost, CreatePostInput } from '../types.js'; -import { CreatePostSchema } from '../types.js'; +import type { Database } from '../types.js'; -export class Posts { +export class Orders { #ctx: Context; @@ -277,41 +98,13 @@ export class Posts { } - async create(input: CreatePostInput): Promise { - - const validated = CreatePostSchema.parse(input); - - const result = await this.#ctx.kysely - .insertInto('posts') - .values({ - user_id: validated.user_id, - title: validated.title, - body: validated.body ?? null, - }) - .returningAll() - .executeTakeFirstOrThrow(); - - return result; - - } - - async getRecent(): Promise { - - return this.#ctx.kysely - .selectFrom('recent_posts') - .selectAll() - .orderBy('created_at', 'desc') - .execute(); - - } - - async getByUser(userId: number): Promise { + listByParty(partyId: number) { return this.#ctx.kysely - .selectFrom('posts') - .selectAll() - .where('user_id', '=', userId) - .orderBy('created_at', 'desc') + .selectFrom('sales_order') + .select(['party_id', 'order_no']) + .where('party_id', '=', partyId) + .orderBy('order_no') .execute(); } @@ -320,103 +113,72 @@ export class Posts { ``` -## Step 5: Create the Database Client +## Assemble the client -The client assembles all domains and exposes them as properties. -`src/client.ts`: +`createContext` returns an unconnected context. `connect()` creates and connects it: ```typescript -import { createContext, type Context } from '@noormdev/sdk'; +// packages/db/src/client.ts +import { createContext, type Context, type CreateContextOptions } from '@noormdev/sdk'; import type { Database } from './types.js'; -import { Users } from './domains/users.js'; -import { Posts } from './domains/posts.js'; - -export interface ClientOptions { - config?: string; - requireTest?: boolean; - projectRoot?: string; - yes?: boolean; -} +import { Orders } from './domains/orders.js'; export class Client { #ctx: Context; - readonly users: Users; - readonly posts: Posts; + readonly orders: Orders; constructor(ctx: Context) { this.#ctx = ctx; - this.users = new Users(ctx); - this.posts = new Posts(ctx); + this.orders = new Orders(ctx); } - async connect(): Promise { + get kysely() { - await this.#ctx.connect(); + return this.#ctx.kysely; } - async disconnect(): Promise { + connect() { - await this.#ctx.disconnect(); + return this.#ctx.connect(); } - /** - * Reset database (teardown + build). - */ - async reset(): Promise { + disconnect() { - await this.#ctx.noorm.db.reset(); + return this.#ctx.disconnect(); } - /** - * Truncate all tables (keep schema). - */ - async truncate(): Promise { + reset() { - await this.#ctx.noorm.db.truncate(); + return this.#ctx.noorm.db.reset(); + + } + + truncate() { + + return this.#ctx.noorm.db.truncate(); } } -/** - * Create a database client. - * - * @example - * const db = await createClient({ config: 'dev' }); - * await db.connect(); - * const user = await db.users.findById(1); - */ -export async function createClient(options: ClientOptions = {}): Promise { - - const ctx = await createContext({ - config: options.config, - requireTest: options.requireTest, - projectRoot: options.projectRoot, - yes: options.yes, - }); - - return new Client(ctx); +export async function createClient(options: CreateContextOptions = {}) { + + return new Client(await createContext(options)); } -/** - * Create a connected client. - * - * @example - * const db = await connect({ config: 'dev' }); - * const user = await db.users.findById(1); - */ -export async function connect(options: ClientOptions = {}): Promise { +export async function connect(options: CreateContextOptions = {}) { const client = await createClient(options); + await client.connect(); return client; @@ -424,87 +186,28 @@ export async function connect(options: ClientOptions = {}): Promise { } ``` +`reset()` tears the schema down and rebuilds it from `db/sql/`. `truncate()` empties every table and keeps the schema. -## Step 6: Create the Public API -`src/index.ts`: +## Test against a real database -```typescript -// Client -export { Client, createClient, connect, type ClientOptions } from './client.js'; - -// Types -export type { - Database, - User, - Post, - RecentPost, - CreateUserInput, - CreatePostInput, -} from './types.js'; -``` +The test client refuses any config that is not marked as a test database: -## Step 7: Add Testing Utilities +```typescript +// packages/db/src/testing.ts +import { createClient } from './client.js'; -Create `src/testing.ts` for integration test helpers: +export function createTestClient(projectRoot?: string) { -```typescript -import { createClient, Client } from './client.js'; + return createClient({ config: 'test', requireTest: true, yes: true, projectRoot }); -export interface TestClientOptions { - projectRoot?: string; } -/** - * Create a test database client. - * - * requireTest: true refuses any config that isn't marked as a test database. - * yes: true pre-confirms the operations noorm would otherwise stop on. - * db:truncate needs confirmation even on an admin config, so without it - * every truncate() throws ProtectedConfigError. - * - * @example - * const db = await createTestClient(); - * await db.connect(); - * await db.reset(); - */ -export async function createTestClient( - options: TestClientOptions = {}, -): Promise { - - return createClient({ - config: 'test', - requireTest: true, - yes: true, - projectRoot: options.projectRoot, - }); +export async function setupTestDb(projectRoot?: string) { -} + const db = await createTestClient(projectRoot); -/** - * Setup hook for test suites. - * - * Returns connected client with clean database. - * - * @example - * describe('Users', () => { - * let db: Client; - * - * beforeAll(async () => { - * db = await setupTestDb(); - * }); - * - * afterAll(async () => { - * await db.disconnect(); - * }); - * }); - */ -export async function setupTestDb( - options: TestClientOptions = {}, -): Promise { - - const db = await createTestClient(options); await db.connect(); await db.reset(); @@ -513,265 +216,107 @@ export async function setupTestDb( } ``` +Export it as its own entry, so production code never imports it. In `packages/db/package.json`: -## Step 8: Write Integration Tests - -By testing your SDK against a real database, you're testing your entire data layer—tables, views, stored procedures, constraints, and all. If someone changes a column type, renames a table, or breaks a view, your tests will catch it. This is how you detect schema drift before it reaches production. +```json +{ + "type": "module", + "exports": { + ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }, + "./testing": { "types": "./dist/testing.d.ts", "import": "./dist/testing.js" } + }, + "scripts": { "build": "tsc", "test": "vitest run" } +} +``` -Create `tests/users.test.ts`: +Then write tests against the real schema: ```typescript -import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +// packages/db/tests/orders.test.ts +import { afterAll, beforeAll, beforeEach, expect, it } from 'vitest'; import { setupTestDb } from '../src/testing.js'; import type { Client } from '../src/client.js'; -describe('User Queries', () => { - let db: Client; - - beforeAll(async () => { - db = await setupTestDb(); - }); - - afterAll(async () => { - await db.disconnect(); - }); - - beforeEach(async () => { - await db.truncate(); - }); - - describe('create', () => { - it('creates a user with required fields', async () => { - const user = await db.users.create({ name: 'Alice' }); - - expect(user.id).toBeDefined(); - expect(user.name).toBe('Alice'); - expect(user.email).toBeNull(); - expect(user.created_at).toBeInstanceOf(Date); - }); - - it('creates a user with all fields', async () => { - const user = await db.users.create({ - name: 'Bob', - email: 'bob@example.com', - bio: 'Hello world', - }); - - expect(user.name).toBe('Bob'); - expect(user.email).toBe('bob@example.com'); - expect(user.bio).toBe('Hello world'); - }); - }); - - describe('findByEmail', () => { - it('finds existing user', async () => { - await db.users.create({ name: 'Alice', email: 'alice@example.com' }); - - const found = await db.users.findByEmail('alice@example.com'); - - expect(found).not.toBeNull(); - expect(found?.name).toBe('Alice'); - }); - - it('returns null for non-existent email', async () => { - const found = await db.users.findByEmail('nobody@example.com'); - - expect(found).toBeNull(); - }); - }); - - describe('list', () => { - it('returns users in descending order by created_at', async () => { - await db.users.create({ name: 'First' }); - await db.users.create({ name: 'Second' }); - - const list = await db.users.list(); - - expect(list).toHaveLength(2); - expect(list[0].name).toBe('Second'); - expect(list[1].name).toBe('First'); - }); - }); -}); -``` - +let db: Client; -## Step 9: Configure Test Database - -The SDK uses noorm configs. You'll need a `test` config that points to your test database. Add one via the noorm TUI wherever your noorm project lives: - -```bash -noorm ui -``` +beforeAll(async () => { -Press `c` for config, `a` to add: + db = await setupTestDb(); -- **Config Name**: `test` -- **Database**: `my_project_test` -- **Test Database (skipped in production builds)**: checked - -The checkbox is what `requireTest: true` reads. A context created with `requireTest: true` against a config without it throws `RequireTestError` before it ever connects. - - -## Using Your SDK +}); -Now you can use your SDK anywhere in the monorepo. +afterAll(async () => { + await db.disconnect(); -### In a Server +}); -```typescript -// apps/api/src/routes/users.ts -import { connect } from '@my-project/db'; +beforeEach(async () => { -const db = await connect({ config: 'prod' }); + await db.truncate(); -app.get('/users', async (req, res) => { - const list = await db.users.list(); - res.json(list); }); -app.get('/users/:id', async (req, res) => { - const user = await db.users.findById(Number(req.params.id)); - if (!user) { - return res.status(404).json({ error: 'User not found' }); - } - res.json(user); -}); -``` - - -### In a Worker +it('lists a party\'s orders by order_no', async () => { -```typescript -// apps/worker/src/jobs/send-welcome-email.ts -import { connect } from '@my-project/db'; + const { party_id } = await db.kysely + .insertInto('party') + .values({ name: 'Ada Lovelace' }) + .returning('party_id') + .executeTakeFirstOrThrow(); -export async function sendWelcomeEmail(userId: number) { - const db = await connect(); + await db.kysely + .insertInto('sales_order') + .values([{ party_id, order_no: 1 }, { party_id, order_no: 2 }]) + .execute(); - const user = await db.users.findById(userId); - if (!user?.email) { - console.log('User has no email, skipping'); - return; - } + const orders = await db.orders.listByParty(party_id); - await emailService.send({ - to: user.email, - subject: 'Welcome!', - body: `Hi ${user.name}, welcome aboard!`, - }); + expect(orders.map((o) => o.order_no)).toEqual([1, 2]); - await db.disconnect(); -} +}); ``` +Add the `test` config once with `noorm ui`: press `c`, then `a`, name it `test`, and check Test Database. -### In a CLI Tool -```typescript -// apps/cli/src/commands/list-users.ts -import { connect } from '@my-project/db'; +## Where does the connection come from? -export async function listUsersCommand() { - const db = await connect({ config: process.env.DB_CONFIG }); - const list = await db.users.list(); +| Where it runs | Connection source | Call | +|---|---|---| +| Your machine | A stored config in `.noorm/state/state.enc` | `connect({ config: 'dev' })` | +| Tests | A stored config marked as a test database | `createTestClient()` | +| CI | `noorm ci init` writes the `test` config from env vars | `createTestClient()` | +| Production | `NOORM_CONNECTION_*` env vars, no stored config | `connect()` | - console.table(list.map(u => ({ - id: u.id, - name: u.name, - email: u.email ?? '(none)', - }))); +In CI, create the config before the tests run: - await db.disconnect(); -} +```bash +noorm -c packages/db ci init --name test --test +pnpm --filter @acme/db test ``` +Both steps need `NOORM_IDENTITY_PRIVATE_KEY`, `NOORM_IDENTITY_NAME`, and `NOORM_IDENTITY_EMAIL`. `ci init` also needs `NOORM_CONNECTION_*`. [CI](/guide/automation/ci) has the full workflow, and [Deploying](/guide/deployment) covers production. -### In CI/CD - -```yaml -# .github/workflows/test.yml -name: Tests -on: [push] - -jobs: - test: - runs-on: ubuntu-latest - services: - postgres: - image: postgres:15 - env: - POSTGRES_DB: test - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - ports: - - 5432:5432 - - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v2 - - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'pnpm' - - - run: pnpm install - - # createTestClient asks for a stored config named `test`, and a - # fresh runner has no .noorm/state/state.enc. `ci init` writes one - # from the env vars and marks the config active and isTest. - # -c runs it in packages/db, which is where vitest's cwd will be, - # and createContext resolves state against cwd. - - run: npx noorm -c packages/db ci init --name test - env: - NOORM_IDENTITY_PRIVATE_KEY: ${{ secrets.NOORM_CI_KEY }} - NOORM_IDENTITY_NAME: "GitHub CI" - NOORM_IDENTITY_EMAIL: "ci@example.com" - NOORM_CONNECTION_DIALECT: postgres - NOORM_CONNECTION_HOST: localhost - NOORM_CONNECTION_DATABASE: test - NOORM_CONNECTION_USER: postgres - NOORM_CONNECTION_PASSWORD: postgres - - - run: pnpm --filter @my-project/db test - env: - NOORM_IDENTITY_PRIVATE_KEY: ${{ secrets.NOORM_CI_KEY }} - NOORM_IDENTITY_NAME: "GitHub CI" - NOORM_IDENTITY_EMAIL: "ci@example.com" -``` -Generate `NOORM_CI_KEY` once with `noorm ci identity new --name "GitHub CI" --email ci@example.com`, which prints the env block to stdout, then paste it into your provider's secret store. If your templates read vault secrets, use `noorm ci identity enroll` instead so the key also gets vault access. See [CI/CD](/guide/automation/ci) for the full walkthrough. +## What bites -## Final Structure +| Symptom | Cause | Fix | +|---|---|---| +| `RequireTestError: Config "test" does not have isTest: true` | The config is not marked as a test database. `ci init` leaves it unmarked | Check Test Database in `noorm ui`, or run `ci init --test` | +| PostgreSQL: `db.truncate()` fails with `permission denied to set parameter "session_replication_role"` | Truncate turns off foreign-key triggers with a setting only superusers, or roles granted `SET` on it, may change | Use a superuser login for the test database, or [grant the setting](/guide/database/teardown#what-bites) | +| `ProtectedConfigError` from `truncate()` | `db:truncate` asks for confirmation even on an `admin` config, and the SDK cannot ask | Pass `yes: true` to `createContext`, or set `NOORM_YES=1` | +| `Config "dev" not found` | Stored configs resolve against `projectRoot`, which defaults to the process's cwd | Run from the directory that holds `.noorm/`, or pass `projectRoot` | +| `NotConnectedError: Not connected` | `createContext` returns an unconnected context | Call `connect()` first | +| A query fails: column does not exist | The interface in `types.ts` drifted from `db/sql/` | Update the interface with the table | -``` -my-project/ -├── pnpm-workspace.yaml -├── packages/ -│ └── db/ -│ ├── package.json -│ ├── tsconfig.json -│ ├── src/ -│ │ ├── index.ts -│ │ ├── client.ts -│ │ ├── testing.ts -│ │ ├── types.ts -│ │ └── domains/ -│ │ ├── users.ts -│ │ └── posts.ts -│ └── tests/ -│ └── users.test.ts -└── apps/ - ├── api/ # Uses @my-project/db - ├── worker/ # Uses @my-project/db - └── cli/ # Uses @my-project/db -``` +## Next -## What's Next? -- [SDK Reference](/reference/sdk) - Full API documentation -- [Templates](/guide/sql-files/templates) - Dynamic SQL generation +- [SDK reference](/reference/sdk): every method on `ctx` and `ctx.noorm` +- [Deploying](/guide/deployment): env-only connections and one context per process +- [CI](/guide/automation/ci): `ci init` and the workflow shapes +- [Templates](/guide/sql-files/templates): generate SQL from data diff --git a/docs/getting-started/concepts.md b/docs/getting-started/concepts.md index 8f129202..ce687ebb 100644 --- a/docs/getting-started/concepts.md +++ b/docs/getting-started/concepts.md @@ -1,358 +1,183 @@ # Concepts -This page explains how noorm thinks about your database. Understanding these concepts makes everything else click. - - -## SQL Files Are Your Source of Truth - -Your SQL files define what your schema **is**. They represent the ideal, current state of your database structure. +A noorm project keeps its schema in `db/`. `db/sql/` holds the schema as it stands today, and `db/changes/` holds the steps that bring databases that already exist up to it. ``` -db/sql/ -├── 01_tables/ -│ ├── 001_users.sql # CREATE TABLE users... -│ └── 002_posts.sql # CREATE TABLE posts... -└── 02_views/ - └── 001_recent_posts.sql # CREATE VIEW recent_posts... +db/ +├── sql/ +│ ├── 01_tables/ +│ │ ├── 01_party.sql +│ │ └── 02_sales_order.sql +│ └── 02_views/ +│ └── 01_open_orders.sql +└── changes/ + └── 2026-10-20-order-status/ + ├── change/001_order-status.sql + └── revert/001_order-status.sql ``` -Need a fresh database? Run your SQL files. They contain everything needed to build your schema from scratch—no need to replay years of migrations. - -This is different from migration-based tools where the "current state" only exists by running every migration in order. With noorm, your SQL files **are** the current state. - - -## Execution Order Is Alphanumeric +A new database and an old one reach the same schema by different commands: -Everything runs in alphanumeric order. Directories, files within directories, change scripts—all sorted the same way. +```mermaid +flowchart TD + SQL["db/sql/"] -->|noorm run build| NEW[(new database)] + CH["db/changes/"] -->|noorm change ff| OLD[(existing database)] + NEW --> SAME[same schema] + OLD --> SAME +``` -This matters because dependencies have order. Tables must exist before views reference them. Functions must exist before triggers call them. Without numeric prefixes, you get alphabetical order: +Every edit to `db/sql/` comes with the change that brings an already-built database to the same state. -``` -db/sql/functions/ ← runs first (f < t < v) -db/sql/tables/ ← runs second -db/sql/views/ ← runs third -``` -That's broken—views can't reference tables that don't exist yet. Use numeric prefixes: +## SQL files are the current state -``` -db/sql/01_tables/ ← runs first -db/sql/02_views/ ← runs second -db/sql/03_functions/ ← runs third (functions can reference tables/views) -``` -The same rule applies to files within directories: +Each file in `db/sql/` declares one object the way it should exist now. Every column and constraint is declared once, inline in `CREATE TABLE`. A test run or a new developer gets a database with `noorm run build`, and no history is replayed. -``` -01_tables/ -├── 01_users.sql ← runs first -├── 02_posts.sql ← runs second (can reference users) -└── 03_comments.sql ← runs third (can reference posts) -``` +History never goes in `db/sql/`. `DROP`, an `ALTER` of an existing table, an `IF NOT EXISTS` guard, and a backfill all belong in a change. -And to change scripts: +| | `db/sql/` | `db/changes/` | +|---|---|---| +| Holds | the schema today | how existing databases catch up | +| Runs on | new databases | databases that already exist | +| Command | `noorm run build` | `noorm change ff` | +| Undo | none | each change has a `revert/` folder | +| Order | path, alphanumeric | date prefix | -``` -change/ -├── 001_create_table.sql -├── 002_add_indexes.sql -└── 003_seed_data.sql -``` +[Add a column](/guide/changes/add-a-column) walks one edit through both folders. -One rule, everywhere. Look at the filesystem and you know the order. +## Order is the sort order of the path -## Changes Are Your Changelog -Changes document what you did to evolve an existing database. They answer: who changed what, when, and why. +noorm sorts every path alphanumerically: folders, the files inside them, and the scripts inside a change. A view can only reference a table that already exists, so the folders need number prefixes: ``` -db/changes/ -└── 2024-01-15-add-user-roles/ - ├── change/ - │ └── 001_add_role_column.sql - └── revert/ - └── 001_remove_role_column.sql +db/sql/tables/ runs 2nd db/sql/01_tables/ runs 1st +db/sql/functions/ runs 1st db/sql/02_views/ runs 2nd +db/sql/views/ runs 3rd db/sql/03_functions/ runs 3rd ``` -When you need to add a column to an existing production database, you don't just edit the SQL file—you also write a change that performs the `ALTER TABLE`. - - -## Templates Turn Configuration Into SQL - -Sometimes you need more than static SQL. Templates let you generate SQL from structured data—define your rules in YAML, JSON, or CSV, and let noorm write the repetitive SQL for you. +Without prefixes, `functions/` sorts first and fails on tables that do not exist yet. `build.include` in settings picks which folders take part in a build. It does not reorder them. -Add `.tmpl` to any SQL file to make it a template: -``` -db/sql/ -├── tables/ -│ └── users.sql # Static SQL -└── security/ - ├── rls.yml # Configuration data - └── rls.sql.tmpl # Template that reads it -``` +## Templates turn data into SQL -Templates can read adjacent data files. Place a `rls.yml` next to `rls.sql.tmpl`, and the data is available as `rls` in your template. -Here's a real example: PostgreSQL Row-Level Security. Without templates, you'd write dozens of similar `CREATE POLICY` statements. With templates, you define policies in YAML: +Add `.tmpl` to a SQL file and noorm renders it before running it. A data file in the same folder becomes a variable named after the file, so `products.yml` is `$.products`: ```yaml -# db/sql/security/rls.yml -policies: - users: - - name: users_own_data - for: ALL - check: user_id = current_user_id() - - posts: - - name: posts_read_public - for: SELECT - check: is_public = true - - - name: posts_owner_write - for: ALL - check: author_id = current_user_id() - - comments: - - name: comments_read_if_post_visible - for: SELECT - check: | - EXISTS ( - SELECT 1 FROM posts - WHERE posts.id = comments.post_id - AND (posts.is_public OR posts.author_id = current_user_id()) - ) +# db/sql/09_seeds/products.yml +- { sku: HW-DONGLE, name: USB security key, list_price: 49.00 } +- { sku: SW-SEAT, name: "Desktop seat, yearly", list_price: 89.00 } ``` -Then generate all the SQL: - ```sql --- db/sql/security/rls.sql.tmpl -{% for (const [table, policies] of Object.entries($.rls.policies)) { %} - -ALTER TABLE {%~ table %} ENABLE ROW LEVEL SECURITY; - -{% for (const policy of policies) { %} -DROP POLICY IF EXISTS {%~ policy.name %} ON {%~ table %}; -CREATE POLICY {%~ policy.name %} ON {%~ table %} - FOR {%~ policy.for %} - USING ({%~ policy.check %}); -{% } %} - -{% } %} +-- db/sql/09_seeds/01_products.sql.tmpl +-- {% for (const p of $.products) { %} +INSERT INTO product (sku, name, list_price) +VALUES ({%~ $.quote(p.sku) %}, {%~ $.quote(p.name) %}, {%~ p.list_price %}); +-- {% } %} ``` -This generates correct, complete SQL for every table. Add a new policy? Edit the YAML. The template handles the boilerplate. +A template reads from four sources, and renders to plain SQL that the database runs: -You can also define **helper functions** in `$helpers.ts` files. Helpers are inherited up the directory tree—define utilities at the root, specialize them in subdirectories: - -```typescript -// db/sql/$helpers.ts -export function grantAll(role: string, tables: string[]) { - return tables - .map(t => `GRANT SELECT, INSERT, UPDATE, DELETE ON ${t} TO ${role};`) - .join('\n'); -} -``` - -```sql --- db/sql/security/grants.sql.tmpl -{%~ $.grantAll('app_user', ['users', 'posts', 'comments']) %} +```mermaid +flowchart TD + D["products.yml
(same folder)"] --> T["01_products.sql.tmpl"] + H["$helpers.ts
(this folder and above)"] --> T + S["$.secrets"] --> T + C["$.config"] --> T + T --> R[rendered SQL] --> DB[(database)] ``` -Templates are powerful for: -- **Row-level security policies** - Define access rules, generate USING/CHECK clauses -- **Role permissions** - List roles and grants in YAML, generate GRANT/REVOKE statements -- **Scheduled jobs** - Define cron schedules in YAML, generate pg_cron or SQL Agent setup -- **Seed data** - Store reference data in CSV, generate INSERT statements -- **Custom types** - Define domain constraints in YAML, generate CREATE DOMAIN statements - -The pattern is always the same: human-readable configuration goes in a data file, repetitive SQL comes out of the template. - +Data files can be YAML, JSON, JSON5, or CSV. A `$helpers.ts` applies to its folder and every folder below it, and a deeper one overrides a shallower one. [Templates](/guide/sql-files/templates) covers the syntax. -## The Workflow -1. **Update your SQL files first.** They should always reflect the ideal schema. -2. **Write a change** to evolve existing databases to match. +## Configs, stages, and roles -Your SQL files stay in sync with reality. Changes track how you got there. -::: tip Fresh vs Existing -- **Fresh database** → Run SQL files with `noorm run build` -- **Existing database** → Apply changes with `noorm change ff` -::: +A **config** is a saved connection: `dev`, `staging`, `prod`. Commands use the active one unless `--config` names another: - -## Configs - -A **config** is a saved database connection. You can have multiple configs for different environments: - -- `dev` - Your local database -- `test` - Test database (gets wiped between runs) -- `staging` - Staging server -- `prod` - Production (guarded — see Access Roles below) - -``` -┌─ Configs ─────────────────────────────────┐ -│ • dev postgres localhost:5432 │ -│ staging postgres db.staging.com │ -│ prod postgres db.prod.com │ -└───────────────────────────────────────────┘ -``` - -The dot (•) shows which config is active. All commands use the active config unless you specify otherwise. - -Switch configs: ```bash noorm config use staging -``` - -Or specify per-command: -```bash noorm run build --config prod ``` -`--config` is a per-subcommand flag, so it goes *after* the subcommand. Before the subcommand, `-c` means `--cwd` (the only root-level flag), so `noorm -c prod run build` runs the build in a directory named `prod` instead. - - -## Access Roles - -Every config declares an access role — `viewer`, `operator`, or `admin` — separately for two **channels**: `user` (you, at the CLI or TUI) and `agent` (an AI agent, whether it reaches noorm over MCP or by running the CLI). This is how you prevent accidental destructive operations: - -- `viewer` - Read-only. `db teardown`, writes, and changes are all blocked. -- `operator` - Reads and writes are fine; builds, changes, and `db create` require typing a confirmation phrase. Raw DDL, `db teardown`, and `db drop` are denied outright. -- `admin` - Builds and changes run without prompting. The operations that cannot be undone still ask: `db drop`, `db teardown`, `db truncate`, `change rm`, `config` edits, `vault propagate`, and `lock force`. -- Accidental `DROP TABLE` via raw SQL - Still executes if the role allows SQL DDL, which only `admin` does (be careful!) - -Roles are a safety net, not a security boundary. They catch mistakes like running teardown against the wrong database — they won't stop a determined user or malicious script. - - -## Secrets - -Secrets are sensitive values that get inserted **into** your database via SQL templates. They're for application configuration you store in the database: - -- AWS credentials (access key, secret key) -- SMTP credentials for sending emails -- API keys for third-party services - -noorm stores secrets encrypted in your local state. When you run SQL templates, secrets are available but never written to disk in plain text. - -```sql --- db/sql/config/aws.sql.tmpl -INSERT INTO app_config (key, value) VALUES - ('aws_access_key', '{%~ $.secrets.AWS_ACCESS_KEY %}'), - ('aws_secret_key', '{%~ $.secrets.AWS_SECRET_KEY %}'); -``` - -```bash -# Set a secret for the current config -noorm secret set AWS_ACCESS_KEY "AKIAEXAMPLE" - -# Or pipe the value in so it never reaches the process table or shell history -printf %s "$AWS_SECRET_KEY" | noorm secret set AWS_SECRET_KEY --stdin -``` - -When you rebuild your database, your credentials are always there—but never exposed in your SQL files or version control. - -Secrets are **config-scoped**. Each config has its own set of secrets (dev AWS vs prod AWS). There are also **global secrets** shared across all configs. - - -## State and Identity - -Your **identity** lives in `~/.noorm/`, split across three files: `identity.key` (private key, owner-readable only), `identity.pub` (public key), and `identity.json` (metadata such as name, email, and machine). These are machine-local, and the private key is never shared. - -Your **state** lives in `.noorm/state/state.enc` and contains: - -- Your saved configs (database credentials) -- Secrets (encrypted with your keys) - -State is encrypted with your identity keys. It's not portable between machines—each developer has their own configs and secrets. - -::: warning Don't Commit State -Add `.noorm/state/` to `.gitignore`. It contains machine-specific encrypted data that won't work on other machines anyway. -::: - - -## Settings - -Settings live in `.noorm/settings.yml` and **should be committed**. They define project-wide behavior: - -- Schema and changes directory paths -- Build include/exclude patterns -- Stage definitions -- Secret requirements +A **stage** in `.noorm/settings.yml` applies to the config with the same name. Its `defaults` seed that config when the TUI first creates it, `protected: true` caps its access, and `secrets` lists what it needs: ```yaml # .noorm/settings.yml -paths: - sql: ./db/sql - changes: ./db/changes - -build: - include: - - tables - - views - - functions - stages: prod: defaults: + dialect: postgres protected: true secrets: - - key: AWS_SECRET_KEY + - key: PAYMENT_GATEWAY_KEY type: password required: true ``` -The `include` array filters which folders are included in builds. Execution order is alphanumeric—use numeric prefixes (`01_tables`, `02_views`) to control the sequence. Paths are relative to your `paths.sql` directory. +Each config gives a **role** to two channels: `user` (you, in the CLI or TUI) and `agent` (an AI agent over MCP or the CLI). The role decides what runs, what asks for a typed confirmation, and what is refused: +| Role | Runs | Asks first | Refused | +|---|---|---|---| +| `viewer` | explore, read-only SQL | | writes, builds, changes, teardown | +| `operator` | explore, SQL reads and writes | build, change, `db create`, truncate | raw DDL, `db drop`, `db teardown` | +| `admin` | everything else | `db drop`, `db teardown`, `db truncate`, `change rm`, config edits, `vault propagate`, `lock force` | | -## Stages +A stage with `protected: true` caps its configs at `operator` for the user and `viewer` for agents. Roles catch mistakes, such as a teardown against the wrong database. They do not stop someone who has the credentials and connects with another client. [Stages](/guide/environments/stages) covers the rest. -A **stage** is a template for configs. When you create a config and assign it to a stage, it inherits the stage's defaults: -```yaml -stages: - development: - defaults: - dialect: sqlite - isTest: false +## Secrets stay out of committed SQL - production: - defaults: - dialect: postgres - protected: true - secrets: - - key: AWS_SECRET_KEY - type: password - required: true + +A secret is a value a template reads when the SQL runs. The value is stored encrypted and never written into a file: + +```sql +-- db/sql/09_seeds/02_gateway.sql.tmpl +INSERT INTO app_setting (name, value) +VALUES ('payment_gateway_key', {%~ $.quote($.secrets.PAYMENT_GATEWAY_KEY) %}); ``` -Now when you create a config with stage `production`, its resolved access is capped to at most `operator`/`viewer` (it can be stricter, never looser), and it requires an `AWS_SECRET_KEY` secret. +```bash +printf %s "$KEY" | noorm secret set PAYMENT_GATEWAY_KEY --stdin +``` + +When a template asks for `$.secrets.PAYMENT_GATEWAY_KEY`, noorm looks in the config's own secrets first, then in the [vault](/guide/environments/vault), which is stored encrypted in the database and shared with the team. A secret set on your config overrides the team's value for you alone. + + +## What lives where + + +| Path | Holds | Commit it | +|---|---|---| +| `db/sql/`, `db/changes/` | the schema and its changes | yes | +| `db/AGENTS.md`, `db/CLAUDE.md` | instructions for AI agents working in `db/`, written by `noorm init` | yes | +| `.noorm/settings.yml` | paths, `build.include`, stages, required secrets | yes | +| `.noorm/state/state.enc` | your configs, credentials, and secrets, encrypted with your identity key | no, `noorm init` adds it to `.gitignore` | +| `~/.noorm/identity.key`, `identity.pub`, `identity.json` | your keypair, name, and email | never leaves your machine | -Connection values go under `defaults`. Required secrets go under `secrets`, and each entry needs a `type` (`string`, `password`, `api_key`, or `connection_string`). Settings fail to load without it. +State is encrypted with your own key, so a copy is useless on another machine. Each developer keeps their own. -## SQL Files vs Changes +## What bites -| Aspect | SQL Files | Changes | -|--------|-----------|---------| -| Purpose | Define ideal schema | Evolve existing databases | -| Location | `db/sql/` | `db/changes/` | -| When to run | Fresh database, tests | Existing database | -| Rollback | No | Yes, with revert scripts | -| Order | Alphanumeric by full path | By date prefix | -**SQL files** are your source of truth. They define what your schema looks like today. Execution order is alphanumeric by full path. `build.include` picks which folders take part; it does not reorder them, so the numeric prefixes are what control the sequence. +| Mistake | What happens | Fix | +|---|---|---| +| Edit a table in `db/sql/` with no change | new databases get the edit, existing ones never do | add a change: `noorm change add ` | +| A change that is not safe to run twice | a database built from `db/sql/` has no change records, so `change ff` runs the change against a table that already has it | guard it: `ADD COLUMN IF NOT EXISTS`, `WHERE status IS NULL` | +| Folders without number prefixes | alphabetical order runs `functions/` before `tables/` | `01_tables/`, `02_views/` | +| `noorm -c prod run build` fails: `--cwd path is not a directory` | before the subcommand, `-c` is `--cwd`, so noorm looks for a project folder named `prod` | `noorm run build --config prod` | -**Changes** are your changelog. They document how to get an existing database to match your SQL files. +## Next -## What's Next? -- [Building Your SDK](/getting-started/building-your-sdk) - Wrap noorm in a TypeScript SDK for your apps -- [TUI Quick Reference](/tui) - Navigate the terminal interface -- [SQL Templates](/guide/sql-files/templates) - Dynamic SQL generation -- [Changes Guide](/guide/changes/overview) - Evolving existing databases +- [First build](/getting-started/first-build): init a project and build it +- [Add a column](/guide/changes/add-a-column): one edit, both folders +- [Templates](/guide/sql-files/templates): the full syntax +- [Building your SDK](/getting-started/building-your-sdk): call the database from TypeScript diff --git a/docs/getting-started/first-build.md b/docs/getting-started/first-build.md index b14b8546..a52f1456 100644 --- a/docs/getting-started/first-build.md +++ b/docs/getting-started/first-build.md @@ -1,28 +1,57 @@ -# First Build +# First build -This tutorial takes about 5 minutes. By the end, you'll understand how noorm separates **schema definition** from **schema evolution**. +Create a project, connect it to a PostgreSQL server, and build a schema from SQL files. -![Building a schema from SQL files, then fast-forwarding two pending changes](/image/build-and-change.gif) +```sql +-- db/sql/01_tables/02_sales_order.sql +CREATE TABLE sales_order ( + party_id integer NOT NULL, + order_no integer NOT NULL, + placed_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT sales_order_pk + PRIMARY KEY (party_id, order_no), + CONSTRAINT sales_order_isplacedby_party + FOREIGN KEY (party_id) REFERENCES party (party_id) +); +``` + +```bash +noorm run build +``` + +``` +• log Build completed status=success filesRun=2 filesSkipped=0 filesFailed=0 durationMs=31.208 +``` -That is the whole loop: `run build` applies the SQL files, `change ff` walks an existing database forward, and `change history` records what ran. The rest of this page does it step by step. +Real runs prefix each line with a timestamp. -## What You'll Do +## What do the commands touch? -1. Initialize a project and configure a database -2. Create SQL files and build your schema -3. Evolve the schema with a change -4. Create a fresh test database to see you don't need migrations +In this walkthrough, only three commands change the database: `db create`, `run build`, and `change ff`. -## Prerequisites +```mermaid +sequenceDiagram + participant You + participant noorm + participant Database + You->>noorm: noorm init + noorm-->>You: .noorm/ and db/ + You->>noorm: noorm ui, then c and a + noorm->>Database: test the server connection + You->>noorm: noorm db create + noorm->>Database: create it, add tracking tables + You->>noorm: noorm run build + noorm->>Database: run each db/sql/ file + You->>noorm: noorm change ff + noorm->>Database: run each pending change +``` -- noorm installed ([Installation Guide](/getting-started/installation)) -- A PostgreSQL database to connect to +## Create the project -## Step 1: Initialize Your Project ```bash mkdir noorm-tutorial @@ -30,332 +59,157 @@ cd noorm-tutorial noorm init ``` -`noorm init` sets up your identity (or reuses the one already in `~/.noorm/`) and creates the project scaffolding. When it finishes, you'll have this layout: +`noorm init` asks for a display name and email unless `~/.noorm/` already holds an identity. It creates: ``` noorm-tutorial/ +├── .gitignore # gets a .noorm/state/ line ├── .noorm/ -│ ├── .gitignore # ignores state/ -│ ├── settings.yml # Project settings (commit this) +│ ├── .gitignore +│ ├── settings.yml # commit this │ └── state/ -│ └── state.enc # Encrypted state (don't commit) +│ └── state.enc # your configs and secrets, encrypted └── db/ - ├── sql/ # Your SQL files go here - ├── changes/ # Versioned changes go here - ├── AGENTS.md # Steering for AI coding agents working in db/ - └── CLAUDE.md # Imports AGENTS.md for Claude Code + ├── sql/ + ├── changes/ + ├── AGENTS.md + └── CLAUDE.md ``` -Configs are added separately, from the TUI. That's the next step. +`db/AGENTS.md` sends AI agents to the `/noorm` skill, and `db/CLAUDE.md` imports it. [What lives where](/getting-started/concepts#what-lives-where) covers the rest. + +## Add a config -## Step 2: Add a Database Config -Launch the TUI: +A config is a saved connection. Creating one is interactive: ```bash noorm ui ``` -Press `c` for config, then `a` to add a new config. Fill out the form: - -- **Config Name**: `dev` -- **Database Type**: PostgreSQL (use arrow keys to select) -- **Host**: `localhost` -- **Port**: `5432` -- **Database**: `noorm_dev` -- **Username**: `postgres` -- **Password**: your password -- **User Role (CLI/TUI access)**: leave at `admin` -- **Agent Role (MCP/CLI access)**: leave at `viewer` -- **Test Database**: leave unchecked - -Submit the form to save your config. SQL and changes paths are not part of a config. They live in `.noorm/settings.yml` under `paths`, which `noorm init` sets to `./db/sql` and `./db/changes`. - - -## Step 3: Create the Database - -Back at the home screen, press `d` for Database. You'll see the Database Operations screen: - -``` -Database Operations - -Config: dev -Connection: NOT CREATED - -Available Actions - -[c] Create - Create database and tracking tables -[d] Destroy - Drop all managed objects -[x] Explore - Browse database schema -[w] Wipe - Truncate table data (keep schema) -[t] Teardown - Drop user objects (keep noorm) -[r] Transfer - Copy data to another database -``` - -Press `c` to create. noorm will create the `noorm_dev` database on your PostgreSQL server and initialize its tracking tables. - -::: tip No SQL Files Yet? -That's fine! At this point we're just creating the empty database and noorm's internal tracking tables. We'll add SQL files next. -::: - +Press `c`, then `a`. Enter name `dev`, type PostgreSQL, host `localhost`, port `5432`, database `noorm_dev`, and your server's username and password. Leave User Role at `admin`, Agent Role at `viewer`, and Test Database unchecked. -## Step 4: Configure Build Settings +The form tests the server connection before it saves, and the first config you save becomes the active one. Quit with `ctrl + c`. -Open `.noorm/settings.yml` and define which folders to include: - -```yaml -paths: - sql: ./db/sql - changes: ./db/changes - -build: - include: - - 01_tables -``` -The `include` array filters which folders are included in builds. Folder names are relative to your `paths.sql` directory. +## Create the database -::: warning Execution Order -Files and directories run in **alphanumeric order**. Without numeric prefixes: +```bash +noorm db create ``` -db/sql/functions/ ← runs first (alphabetically) -db/sql/tables/ ← runs second -db/sql/views/ ← runs third -``` - -That's wrong—tables should exist before views reference them. Use numeric prefixes: ``` -db/sql/01_tables/ ← runs first -db/sql/02_views/ ← runs second -db/sql/03_functions/ ← runs third +Database "noorm_dev" created and initialized. ``` -This rule applies everywhere: directories, files within directories, change scripts. One mental model for everything. -::: +It also adds noorm's tracking tables. -## Step 5: Create Your First SQL File +## Build the schema -Create the users table: ```bash -mkdir -p db/sql/01_tables +mkdir db/sql/01_tables ``` -Create `db/sql/01_tables/001_users.sql`: +Save the `sales_order` file from the top of this page in that folder, and add the table it points at: ```sql -CREATE TABLE users ( - id SERIAL PRIMARY KEY, - name TEXT NOT NULL, - email TEXT UNIQUE, - created_at TIMESTAMP DEFAULT NOW() +-- db/sql/01_tables/01_party.sql +CREATE TABLE party ( + party_id integer GENERATED ALWAYS AS IDENTITY, + name text NOT NULL, + CONSTRAINT party_pk PRIMARY KEY (party_id) ); ``` +Then run `noorm run build`. It prints the block at the top of this page: two files run in path order, so `party` exists before the foreign key needs it. -## Step 6: Run Build -```bash -noorm run build -``` +## Catch an existing database up -Output: - -``` -• build:start Starting schema build (1 files) -• file:after Executed db/sql/01_tables/001_users.sql (12ms) -• build:complete Build complete: 1 run, 0 skipped (18ms) -• log Build completed status=success filesRun=1 filesSkipped=0 filesFailed=0 durationMs=18 -``` - -Your users table now exists. - - -## Step 7: Evolve the Schema - -Now you want to expand the schema: add a `bio` column to users, create a posts table, and add a view. - -First, **update your SQL files** to reflect the new ideal state. - -Edit `db/sql/01_tables/001_users.sql`: - -```sql -CREATE TABLE users ( - id SERIAL PRIMARY KEY, - name TEXT NOT NULL, - email TEXT UNIQUE, - bio TEXT, - created_at TIMESTAMP DEFAULT NOW() -); -``` -Create `db/sql/01_tables/002_posts.sql`: +Treat `dev` as a database you cannot rebuild. Declare a new table in `db/sql/`, then add a change so `dev` gets it too. ```sql -CREATE TABLE posts ( - id SERIAL PRIMARY KEY, - user_id INTEGER REFERENCES users(id), - title TEXT NOT NULL, - body TEXT, - created_at TIMESTAMP DEFAULT NOW() +-- db/sql/01_tables/03_invoice.sql +CREATE TABLE invoice ( + party_id integer NOT NULL, + invoice_no integer NOT NULL, + issued_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT invoice_pk + PRIMARY KEY (party_id, invoice_no), + CONSTRAINT invoice_isowedby_party + FOREIGN KEY (party_id) REFERENCES party (party_id) ); ``` -Create `db/sql/02_views/001_recent_posts.sql`: - ```bash -mkdir -p db/sql/02_views -``` - -```sql -CREATE OR REPLACE VIEW recent_posts AS -SELECT p.*, u.name as author_name -FROM posts p -JOIN users u ON p.user_id = u.id -WHERE p.created_at > NOW() - INTERVAL '7 days'; -``` - -Since you added views, **update your settings** to include the new folder: - -```yaml -build: - include: - - 01_tables - - 02_views -``` - -The `02_` prefix ensures views run after tables—important since views reference tables. - -Your SQL files now represent the complete, ideal schema. But the dev database still only has the old users table. - - -## Step 8: Create a Change - -Use the TUI to create a change: - -```bash -noorm ui -``` - -Press `g` for changes, then `a` to add a new change. Enter a name like `add-posts-and-bio`. - -The TUI creates the folder structure and shows: - -``` -✓ Change "2024-01-15-add-posts-and-bio" created! -Created folder structure with template files. - -[e] Edit in editor [any] Back to list -``` - -Press `e` to open the change folder in your editor, or navigate to it manually. Add these files: - -`db/changes/2024-01-15-add-posts-and-bio/change/001_add_bio.sql`: - -```sql -ALTER TABLE users ADD COLUMN IF NOT EXISTS bio TEXT; +noorm change add invoice-table ``` -Changes should be idempotent—safe to run on any database state. PostgreSQL's `IF NOT EXISTS` ensures this won't error if the column already exists. A freshly built database records no change history, so its first `noorm change ff` runs every change again. - -`db/changes/2024-01-15-add-posts-and-bio/change/002_create_posts.sql`: +This scaffolds `db/changes/2026-10-21-invoice-table/` (your folder carries the day you run it) with comment-only stubs in `change/` and `revert/`. Replace both. The change carries its own guarded copy of the table: ```sql -CREATE TABLE IF NOT EXISTS posts ( - id SERIAL PRIMARY KEY, - user_id INTEGER REFERENCES users(id), - title TEXT NOT NULL, - body TEXT, - created_at TIMESTAMP DEFAULT NOW() +-- db/changes/2026-10-21-invoice-table/change/001_invoice-table.sql +CREATE TABLE IF NOT EXISTS invoice ( + party_id integer NOT NULL, + invoice_no integer NOT NULL, + issued_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT invoice_pk + PRIMARY KEY (party_id, invoice_no), + CONSTRAINT invoice_isowedby_party + FOREIGN KEY (party_id) REFERENCES party (party_id) ); ``` -The change carries its own copy of the `CREATE TABLE`. A manifest runs the current content of the file it names, so it should only name objects that are safe to run repeatedly. - -`db/changes/2024-01-15-add-posts-and-bio/change/003_views.txt`: - -``` -02_views/001_recent_posts.sql +```sql +-- db/changes/2026-10-21-invoice-table/revert/001_invoice-table.sql +DROP TABLE IF EXISTS invoice; ``` -The `.txt` file references a SQL file instead of copying it. When the change runs, it executes that file from your `db/sql/` directory, relative to `paths.sql`. The view is `CREATE OR REPLACE`, so running it again is harmless. - - -## Step 9: Apply the Change - ```bash noorm change ff ``` -Output: - ``` -• change:start Starting change for 2024-01-15-add-posts-and-bio (3 files) -• change:complete change 2024-01-15-add-posts-and-bio: success (29ms) -• log Fast-forward success executed=1 skipped=0 failed=0 -• log 2024-01-15-add-posts-and-bio (success) +• log Fast-forward success executed=1 skipped=0 failed=0 +• log 2026-10-21-invoice-table (success) ``` -Three files: the two `.sql` files, and the one view file that the `003_views.txt` manifest names. Per-file lines are `change:file` events, which sit at debug level. Raise `logging.level` to `verbose` in `.noorm/settings.yml` to see them. +Adding a column works the same way: see [Add a column](/guide/changes/add-a-column). -Your dev database now matches your SQL files. +## Build a second database -## Step 10: Fresh Database (No Migrations Needed) -Now create a test database to see the other path. In the TUI: - -1. Press `c` for Configurations, then `a` to add a new config named `test` pointing to `noorm_test` -2. Use arrow keys to select the `test` config and press `Enter` to switch to it -3. Press `d` for Database, then `c` to create the database -4. Press `r` for Run SQL, then `b` for Build - -If you already have a test config and database set up, the same flow runs from the non-interactive CLI: +A fresh database takes the schema from `db/sql/`. The change exists for databases that predate the table. Point one command at another database name with `NOORM_CONNECTION_DATABASE`: ```bash -noorm config use test -noorm run build +NOORM_CONNECTION_DATABASE=noorm_test noorm db create +NOORM_CONNECTION_DATABASE=noorm_test noorm run build ``` -Output: - -``` -• build:start Starting schema build (3 files) -• file:after Executed db/sql/01_tables/001_users.sql (14ms) -• file:after Executed db/sql/01_tables/002_posts.sql (11ms) -• file:after Executed db/sql/02_views/001_recent_posts.sql (7ms) -• build:complete Build complete: 3 run, 0 skipped (32ms) -• log Build completed status=success filesRun=3 filesSkipped=0 filesFailed=0 durationMs=32 -``` - -The test database has the complete schema from your SQL files. No changes needed—they're only for evolving existing databases. - +The build runs 3 files, `invoice` included. The variable overrides the stored database name for that command, and the `dev` config is unchanged. -## What Just Happened? -You experienced both paths: +## What bites -| Scenario | Command | What Runs | -|----------|---------|-----------| -| Fresh database | `noorm run build` | SQL files | -| Existing database | `noorm change ff` | Changes | -Your SQL files are the source of truth. Changes are the changelog of how you evolved existing databases to match them. +| Symptom | Cause | Fix | +|---|---|---| +| `noorm init` exits: requires an interactive terminal | `init` prompts, and a script has no terminal | Run `noorm identity init --name "Ada" --email ada@example.com` once, then `noorm init --yes` | +| `noorm config add` prints: Interactive only | The CLI cannot prompt for connection details | Use `noorm ui`, or `noorm config import ` in scripts | +| `"run:build" is not allowed on config "dev" (role: viewer)` | An AI agent is driving the CLI, so the config's agent role applies, and it defaults to `viewer` | Run the command in your own terminal, or raise Agent Role on the config | +| `change ff` on a freshly built database fails: relation "invoice" already exists | The change has no guard, and a built database has no change records, so `ff` runs every change | Guard it: `CREATE TABLE IF NOT EXISTS` | -::: tip The Workflow -1. Update your SQL files to reflect the ideal schema -2. Create a change to evolve existing databases -3. Fresh databases just run build—no migration history needed -::: +## Next -## What's Next? -- [Concepts](/getting-started/concepts) - Understand the full mental model -- [Building Your SDK](/getting-started/building-your-sdk) - Wrap noorm in a TypeScript SDK for your apps -- [TUI Quick Reference](/tui) - Navigate the terminal interface -- [Changes Guide](/guide/changes/overview) - Versioned changes in depth +- [Concepts](/getting-started/concepts): the two folders and the commands that apply them +- [Add a column](/guide/changes/add-a-column): one edit, both folders, with a backfill +- [Building your SDK](/getting-started/building-your-sdk): query the schema from TypeScript +- [Terminal UI](/tui): keys and screens diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 45229381..053f70d8 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -1,186 +1,111 @@ # Installation -![Installing noorm: the install script, creating an identity, and bootstrapping a project](/image/install.gif) - - -## Requirements - -- A supported database: - - PostgreSQL - - MySQL - - SQLite - - SQL Server (MSSQL) - - -## Install the CLI - -`noorm` is a non-interactive CLI by default. Every command emits structured output suitable for scripting or piping into CI/CD. Running `noorm ui` launches the optional Ink/React terminal wizard for when you'd rather point-and-click. - -### macOS / Linux +Install the noorm CLI, then the SDK and the agent skill if you need them. ```bash curl -fsSL https://noorm.dev/install.sh | sh ``` -This downloads a self-contained binary for your platform. The installer looks for a user-writable bin directory already in your PATH (`~/.local/bin`, `~/bin`, etc.) so you don't need sudo. No runtime dependencies needed. - -::: tip Corporate network? -If `noorm.dev` is blocked, use the GitHub mirror: -```bash -curl -fsSL https://raw.githubusercontent.com/noormdev/noorm/master/install.sh | sh ``` -::: - -You can also override the install location: - -```bash -NOORM_INSTALL_DIR=~/my-tools curl -fsSL https://noorm.dev/install.sh | sh +Installing noorm v1.5.1 (darwin-arm64) to /Users/ada/.local/bin... +Installed noorm v1.5.1 to /Users/ada/.local/bin/noorm ``` -### npm - -If you already work in a Node project, npm installs the same binary and puts it on your PATH for you: - ```bash -npm install -g @noormdev/cli +noorm --version ``` -The package is a thin wrapper: installing it downloads the prebuilt binary for your platform and verifies it, exactly as the shell installer does. `npx @noormdev/cli` works too if you'd rather not install globally. +``` +1.5.1 +``` -This is the most convenient route on Windows, and the one to reach for when you want noorm pinned per-project rather than per-machine. +The binary needs no runtime and includes the database drivers for PostgreSQL, MySQL, SQLite, and SQL Server. For npm, Windows, and the GitHub mirror, see [Download](/download). -### Windows +`noorm` on its own prints the command list. `noorm ui` opens the [terminal UI](/tui). -Use the npm install above, or download [noorm-windows-x64.exe](https://github.com/noormdev/noorm/releases) from the latest `@noormdev/cli` release, rename it to `noorm.exe`, and add it to your PATH. -### Integrity verification +## Install the SDK (optional) -Every install path verifies the downloaded binary before it runs. The release publishes a `checksums.txt` alongside the binaries, and the installer (`install.sh`), the npm `postinstall` step, and `noorm update` each recompute the binary's SHA-256 and compare it against that file before making the binary executable. A mismatch aborts — a corrupted download or a tampered release asset never gets run. -Verification is fail-closed: if `checksums.txt` can't be fetched (an offline mirror, a network block, an older release without it), the install stops rather than trusting an unverified binary. To override that one case — and only that case — set `NOORM_INSECURE=1`: +The SDK needs Node 22.13 or newer, `kysely`, and the driver for your dialect: ```bash -NOORM_INSECURE=1 curl -fsSL https://noorm.dev/install.sh | sh - -# self-update equivalent -noorm update --insecure +npm install @noormdev/sdk kysely pg ``` -`NOORM_INSECURE` skips verification only when the checksums can't be reached. A confirmed hash mismatch always fails, escape hatch or not — the flag can't wave through a binary that has actually been altered. +| Dialect | Driver | +|---|---| +| PostgreSQL | `pg` | +| MySQL | `mysql2` | +| SQLite | `better-sqlite3` | +| SQL Server | `tedious` and `tarn` | -### Verify +Drivers are optional peer dependencies, so install only the one you use. -```bash -noorm --version -``` -### Update +## Install the agent skill (optional) -noorm can update itself: -```bash -noorm update +The skill tells your coding agent where SQL goes in a noorm project, how SDK calls report errors, and which CLI flags CI needs, so it stops guessing. -# Or launch the TUI (`noorm ui`) and press [u] from the home screen +```bash +npx skills add noormdev/noorm/skills ``` -After an update, `noorm update` offers to install or update the [agent skill](#install-the-agent-skill-optional). - - -## Install the SDK (Optional) - -For programmatic access in scripts, tests, or custom tooling: +Without Node.js, GitHub CLI 2.90 or newer installs the same skill: ```bash -npm install @noormdev/sdk kysely +gh skill install noormdev/noorm skills/noorm ``` -The SDK uses peer dependencies. Install the driver for your database: - -| Dialect | Install | -|---------|---------| -| PostgreSQL | `npm install pg` | -| MySQL | `npm install mysql2` | -| SQLite | `npm install better-sqlite3` | -| SQL Server | `npm install tedious tarn` | - -::: tip CLI Includes Drivers -The CLI bundles all drivers — no extra installation needed. These are only required if you're using the SDK directly. -::: - -## Install the Agent Skill (Optional) +## Update noorm -If you work with an AI coding agent — Claude Code, Codex, Cursor, Gemini CLI — install the noorm skill: ```bash -npx skills add noormdev/noorm/skills +noorm update ``` -Agents otherwise guess at noorm's conventions, and several of them are non-obvious enough to guess wrong: SDK methods throw named errors rather than returning them, `attempt()` is used deliberately rather than everywhere, the codebase uses no `try`/`catch`, and test helpers carry safety guards that exist for a reason. The skill covers all of it, plus the headless CLI flags used in CI. - -Without Node.js, GitHub CLI 2.90 or newer installs the same skill from the latest release: +After a successful update, noorm offers the skill, using the same commands: -```bash -gh skill install noormdev/noorm skills/noorm -``` +| Where `noorm update` runs | Skill step | +|---|---| +| A terminal, with `npx` or `gh` on `PATH` | Asks before it installs or updates the skill | +| Piped output, with `npx` or `gh` on `PATH` | Prints the command to run | +| Neither `npx` nor `gh` on `PATH` | Prints both commands and where to get Node.js and the GitHub CLI | +| `--json` | Skips the offer | -Run either command again to update an installed copy. The skill includes these references, which the agent reads on demand: +A declined or failed skill install does not change the exit code. From the TUI, press `u` on the home screen. -| Reference | Covers | -|-----------|--------| -| `schema-files.md` | What belongs in `db/sql/` and `db/changes/`, manifests, templates | -| `sdk.md` | `createContext()`, Kysely patterns, error handling, testing | -| `cli.md` | Headless commands, flags, exit codes, CI usage | -| `templates.md` | `.sql.tmpl` syntax and data loaders | -| `config.md` | `settings.yml`, paths, stages, secrets | -::: tip Include paths are relative to `paths.sql` -The single most common mistake — by people and agents alike — is writing `build.include` entries relative to the project root. They resolve against `paths.sql`. The skill states this explicitly; `noorm run build` also warns when an include entry matches nothing. -::: +## How is the download verified? -## Initialize a Project +The npm package, `noorm update`, and the script at `raw.githubusercontent.com/noormdev/noorm/master/install.sh` compare the binary's SHA-256 with the `checksums.txt` published beside it. A mismatch always aborts. The short script at `noorm.dev/install.sh` does not check yet; install with the GitHub URL when you want the check. -Run `noorm init` in your project root. It prompts for the identity to create (skipped if you already have one) and scaffolds the project: +If `checksums.txt` cannot be fetched, they stop. `NOORM_INSECURE=1` overrides that case only: ```bash -cd your-project -noorm init +curl -fsSL https://raw.githubusercontent.com/noormdev/noorm/master/install.sh | NOORM_INSECURE=1 sh +noorm update --insecure ``` -The init wizard creates: - -``` -your-project/ -├── .gitignore # gets a .noorm/state/ entry appended -├── .noorm/ -│ ├── .gitignore # ignores state/ -│ ├── settings.yml # Project settings (commit this) -│ └── state/ -│ └── state.enc # Encrypted state (don't commit) -└── db/ - ├── sql/ # Your SQL files - │ └── .gitkeep - ├── changes/ # Versioned changes - │ └── .gitkeep - ├── AGENTS.md # Steering for AI coding agents working in db/ - └── CLAUDE.md # Imports AGENTS.md for Claude Code -``` -`noorm init` is strictly interactive and exits with an error on a non-TTY unless you pass `--yes` and already have an identity at `~/.noorm/identity.{key,pub,json}`. +## What bites -Adding the first database config comes next, from the TUI (`noorm ui`, then `c` for config and `a` to add). -::: warning Git Ignore -`noorm init` adds `.noorm/state/` to your `.gitignore` for you. That folder holds encrypted configs and secrets specific to each developer's machine. The `.noorm/settings.yml` should be committed — it's the shared project configuration. -::: +| Symptom | Cause | Fix | +|---|---|---| +| `noorm: command not found` | The install directory is not on `PATH` | Add the `export PATH=":$PATH"` line the installer prints to your shell profile | +| `NOORM_INSTALL_DIR` has no effect | The variable was set before `curl`, so only `curl` sees it | Set it after the pipe: `curl … \| NOORM_INSTALL_DIR=~/bin sh` | +| Install stops with "could not verify checksum" | `checksums.txt` is unreachable or has no entry for your platform | Retry, or set `NOORM_INSECURE=1` as shown above | +| The SDK cannot connect and reports a missing driver | The driver is a peer dependency | Install the driver for your dialect | -## Next Steps +## Next -You're ready to go! Continue to: -- [First Build](/getting-started/first-build) - Complete the 5-minute tutorial -- [Concepts](/getting-started/concepts) - Understand the mental model +- [First build](/getting-started/first-build): `noorm init`, a config, and a built schema +- [Concepts](/getting-started/concepts): the two folders and how databases reach the same schema +- [Download](/download): every install method and option diff --git a/docs/guide/automation/ci.md b/docs/guide/automation/ci.md index f08ddc33..f8961f3c 100644 --- a/docs/guide/automation/ci.md +++ b/docs/guide/automation/ci.md @@ -1,194 +1,117 @@ # CI/CD Automation -Running noorm from an automated pipeline feels a lot like running it on your laptop — the same `run build`, `change ff`, and `db transfer` commands do the same things — but a CI runner has no `.noorm/` directory, no interactive TTY, and no identity on disk. The `noorm ci` namespace exists to close that gap: bootstrap an ephemeral state from environment variables, enroll a long-lived identity once from a developer machine, and let the same commands work in both places. - -This guide walks through the two common shapes of CI and the commands that connect them. - - -## Two Shapes of CI - -Not every pipeline needs the same setup. Before writing YAML, decide which of these describes your job: - -| | Test CI | Prod CI | -|---|---|---| -| **Database** | Ephemeral (Postgres container, SQLite file, etc.) spun up in the job | Real, shared, already has data | -| **Vault-backed secrets?** | No — templates don't reference `secrets.*` from the vault | Yes — templates decrypt keys at render time | -| **Identity source** | Not required (no vault to decrypt, no audit trail worth sharing) | Required (enrolled once, keeps the audit trail of who deployed what) | -| **`noorm ci init`?** | Optional | **Required** | -| **Where to start** | [Test CI section](#test-ci) | [Prod CI section](#prod-ci) | - -When in doubt: if any change or template calls `secrets.KEY` and that key lives in the team vault, you need Prod CI. - - -## Test CI - -The goal is to verify the schema builds and changes apply against a throwaway database. Nothing shared, nothing persisted, nothing secret. - -The flow is three env vars and two commands: - -```bash -export NOORM_CONNECTION_DIALECT=postgres -export NOORM_CONNECTION_HOST=localhost -export NOORM_CONNECTION_DATABASE=test_db -export NOORM_CONNECTION_USER=postgres -export NOORM_CONNECTION_PASSWORD=test - -noorm run build -noorm change ff -``` - -No identity, no `ci init`, no vault. If the schema compiles and all changes apply cleanly, exit 0. - - -### GitHub Actions (Test CI) +Run `noorm run build` and `noorm change ff` from a pipeline that has no saved state, no terminal, and no identity file. ```yaml +# .github/workflows/database.yml name: Database CI on: pull_request: - branches: [main] jobs: - test: + schema: runs-on: ubuntu-latest services: postgres: image: postgres:16 env: POSTGRES_PASSWORD: test - POSTGRES_DB: test_db + POSTGRES_DB: order_to_cash_ci ports: - 5432:5432 options: >- --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s + --health-interval 5s --health-retries 5 + env: + NOORM_CONNECTION_DIALECT: postgres + NOORM_CONNECTION_HOST: localhost + NOORM_CONNECTION_DATABASE: order_to_cash_ci + NOORM_CONNECTION_USER: postgres + NOORM_CONNECTION_PASSWORD: test steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '22' - - run: npm ci - - - name: Apply schema and changes - env: - NOORM_CONNECTION_DIALECT: postgres - NOORM_CONNECTION_HOST: localhost - NOORM_CONNECTION_DATABASE: test_db - NOORM_CONNECTION_USER: postgres - NOORM_CONNECTION_PASSWORD: test - run: | - npx noorm run build - npx noorm change ff - + - run: npm install -g @noormdev/cli + - run: noorm run build + - run: noorm change ff - run: npm test ``` +A database built from `db/sql/` has no change records, so `change ff` replays every change in `db/changes/`. A change that is not safe to run twice fails here instead of in production. -## Prod CI - -When templates render vault-backed secrets or the target config's `access.user` role isn't `admin`, the runner needs two things the test flow skipped: - -1. **An identity** — an X25519 keypair whose public key is enrolled in the target database, giving it a slot in the `encrypted_vault_key` column. -2. **A bootstrapped `.noorm/state/state.enc`** — the ephemeral state that every later command expects to find. - -The provisioning (step 1) happens once, on a developer machine. The runtime (step 2) happens every job. - +Tests that open the database through the SDK with `requireTest` need a stored test config. Add the `NOORM_IDENTITY_*` variables and run `noorm ci init --name test --test` before `npm test`, as [Building your SDK](/getting-started/building-your-sdk#where-does-the-connection-come-from) shows. -### One-Time: Provision the CI Identity -Pick the flow that matches your trust model: +## Which shape does my pipeline need? -- **In-place** — mint the keypair right on the developer's laptop and grant it vault access. Simplest; the private key never leaves the developer's terminal. -- **Air-gapped** — mint the keypair on one machine, hand the public key to a developer with vault access, and have them enroll just the public half. Use when the machine that will hold the CI private key is different from the one with vault access. +Test CI builds a throwaway database and replays every change. Prod CI applies only the pending changes to a shared database: -#### In-Place - -Run this on a machine that already has vault access on the target config: - -```bash -noorm ci identity enroll \ - --config prod \ - --name "GitHub CI" \ - --email ci@example.com \ - --yes +```mermaid +flowchart TD + subgraph TEST["Test CI"] + T1["noorm run build"] --> T2["noorm change ff"] + T2 --> T3["npm test"] + end + subgraph PROD["Prod CI"] + P1["noorm ci init"] --> P2["noorm change ff"] + end ``` -`--yes` is required, not optional. Enrollment ends in a `vault:propagate` grant, which the matrix marks `confirm` for every role that holds it, and sealing the vault key to a public key cannot be undone. Without `--yes` (or `NOORM_YES=1`) the command prints the identity it was about to grant to and exits 1 so you can check it first. - -The command: - -1. Mints a new X25519 keypair. -2. Decrypts the vault key using your own private key. -3. Inserts the new identity row and re-encrypts the vault key for it. -4. **Prints the private key exactly once** — stdout contains `NOORM_IDENTITY_PRIVATE_KEY=<96-hex>`. - -Copy the printed block into your CI provider's secret store (GitHub Actions Secrets, GitLab CI Variables, etc.) under whatever name you like. You'll reference it later as `NOORM_IDENTITY_PRIVATE_KEY`. +| | Test CI | Prod CI | +|---|---|---| +| Database | Created in the job | Shared, holds data | +| Identity | None, unless SDK tests need a stored test config | Enrolled once, read from `NOORM_IDENTITY_*` | +| Vault secrets | Unavailable | Decrypted when templates render | +If a change or template reads `$.secrets.KEY` and the key lives in the team vault, you need prod CI. -#### Air-Gapped -On the machine that will hold the CI secret: +## How do I set up prod CI? -```bash -noorm ci identity new --name "GitHub CI" --email ci@example.com --json > ci-identity.json -``` -This prints the same env block as `ci identity enroll`, but does not touch any database. Extract the `publicKey` from `ci-identity.json` and send it to someone with vault access on the target config. They run: +A developer with vault access enrolls the CI identity once. Every job then builds its own state from environment variables: -```bash -noorm ci identity enroll \ - --config prod \ - --name "GitHub CI" \ - --email ci@example.com \ - --public-key \ - --yes +```mermaid +sequenceDiagram + participant Dev as Developer + participant DB as Database + participant Store as CI secrets + participant Job as Runner + Dev->>DB: ci identity enroll --yes + DB-->>Dev: identity added, vault key sealed to it + Dev->>Store: save the identity variables + Note over Dev,Store: once per CI identity + Job->>Store: read identity and connection variables + Job->>Job: ci init writes .noorm/state/state.enc + Job->>DB: change ff + DB-->>Job: pending changes applied ``` -Only the public half is enrolled. The private key never leaves the first machine. The public key is 88 hex characters; anything else is rejected before a row is written. - -`ci identity enroll` is **idempotent on identityHash** — re-running just ensures vault access, so you can replay it safely if anything fails partway through. It refuses outright when a row already exists under that hash carrying a *different* public key, since granting there would seal the vault key to someone else's key. - - -### Every Job: Runtime Bootstrap - -Inside the CI job, set the three identity env vars plus the connection and call `ci init`: +Enroll from a machine that has vault access on `prod`. The command prints `NOORM_IDENTITY_PRIVATE_KEY` once. Save it, with the name and email, in your CI secret store: ```bash -noorm ci init --name prod -noorm change ff +noorm ci identity enroll --config prod --name "GitHub CI" --email ci@example.com --yes ``` -`ci init` reads `NOORM_IDENTITY_*` and `NOORM_CONNECTION_*`, writes a fresh `.noorm/state/state.enc`, creates a config (default name `ci`, override via `--name` or `NOORM_CI_CONFIG_NAME`), and marks it active. After it runs, every other `noorm` command in the job works exactly as if a developer had bootstrapped the project manually. - -If the env vars are missing or malformed, `ci init` exits 2 (bad invocation) before doing anything else. If `state.enc` already exists and `--force` was not passed, it exits 1. With `--force` it backs the old file up next to it first, and from an interactive terminal it also demands `--yes`, since overwriting destroys every config and config-scoped secret in the project. - - -### Loading Per-Job Secrets - -When your templates need secrets that shouldn't live in the vault — one-off job tokens, ephemeral API keys — use `ci secrets` to batch-load them into the active config's vault from a dotenv file: +If the CI secret lives on a different machine than the vault access, mint the keypair there and enroll only the public half: ```bash -cat > ./ci-secrets.env < ci-identity.json +noorm ci identity enroll --config prod --name "GitHub CI" --email ci@example.com \ + --public-key --yes ``` -Existing keys are skipped by default (so a rerun is safe). Pass `--overwrite` to replace them. Exit codes: `0` clean load, `1` total failure, `2` bad invocation (unknown config, unreadable or malformed `--file`), `3` partial (some keys set, some errored). - - -### GitHub Actions (Prod CI) +Each job runs `ci init`, then `change ff`: ```yaml -name: Deploy Database Changes +# .github/workflows/deploy-db.yml +name: Deploy database changes on: push: @@ -197,158 +120,60 @@ on: jobs: deploy: runs-on: ubuntu-latest + env: + NOORM_IDENTITY_PRIVATE_KEY: ${{ secrets.NOORM_CI_KEY }} + NOORM_IDENTITY_NAME: GitHub CI + NOORM_IDENTITY_EMAIL: ci@example.com + NOORM_CONNECTION_DIALECT: postgres + NOORM_CONNECTION_HOST: ${{ secrets.DB_HOST }} + NOORM_CONNECTION_DATABASE: order_to_cash + NOORM_CONNECTION_USER: ${{ secrets.DB_USER }} + NOORM_CONNECTION_PASSWORD: ${{ secrets.DB_PASSWORD }} + # the prod stage is protected, so change ff asks for confirmation + NOORM_YES: "1" steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '22' - - run: npm ci - - - name: Bootstrap CI state - env: - NOORM_IDENTITY_PRIVATE_KEY: ${{ secrets.NOORM_CI_KEY }} - NOORM_IDENTITY_NAME: "GitHub CI" - NOORM_IDENTITY_EMAIL: "ci@example.com" - NOORM_CONNECTION_DIALECT: postgres - NOORM_CONNECTION_HOST: ${{ secrets.DB_HOST }} - NOORM_CONNECTION_DATABASE: ${{ secrets.DB_NAME }} - NOORM_CONNECTION_USER: ${{ secrets.DB_USER }} - NOORM_CONNECTION_PASSWORD: ${{ secrets.DB_PASSWORD }} - run: npx noorm ci init --name prod - - - name: Apply changes - run: npx noorm change ff + - run: npm install -g @noormdev/cli + - run: noorm ci init --name prod + - run: noorm change ff ``` +GitLab CI takes the same variables under `variables:` and the same three commands. `NOORM_CI_CONFIG_NAME` renames the config when `--name` is absent (default `ci`). The connection variables are listed under [environment-only mode](/headless#environment-only-mode). -### GitHub Actions (Prod CI with job-scoped secrets) - -If your changes read vault keys the runner doesn't already have, load them after `ci init`: - -```yaml -- name: Bootstrap CI state - env: - NOORM_IDENTITY_PRIVATE_KEY: ${{ secrets.NOORM_CI_KEY }} - NOORM_IDENTITY_NAME: "GitHub CI" - NOORM_IDENTITY_EMAIL: "ci@example.com" - NOORM_CONNECTION_DIALECT: postgres - NOORM_CONNECTION_HOST: ${{ secrets.DB_HOST }} - NOORM_CONNECTION_DATABASE: ${{ secrets.DB_NAME }} - NOORM_CONNECTION_USER: ${{ secrets.DB_USER }} - NOORM_CONNECTION_PASSWORD: ${{ secrets.DB_PASSWORD }} - run: npx noorm ci init --name prod - -- name: Load job secrets into vault - env: - API_KEY: ${{ secrets.API_KEY }} - STRIPE_KEY: ${{ secrets.STRIPE_KEY }} - run: | - cat > ./ci-secrets.env < ci-secrets.env +noorm ci secrets --file ci-secrets.env +rm ci-secrets.env ``` -## Reference +## What bites -### Environment Variables - -| Variable | Required for | Description | -|---|---|---| -| `NOORM_IDENTITY_PRIVATE_KEY` | Prod CI | X25519 private key, hex PKCS8 DER (96 hex chars) | -| `NOORM_IDENTITY_NAME` | Prod CI | Display name (e.g. `"GitHub CI"`) | -| `NOORM_IDENTITY_EMAIL` | Prod CI | Email address | -| `NOORM_CI_CONFIG_NAME` | Optional | Default config name for `ci init` | -| `NOORM_YES` | Optional | Truthy value stands in for `--yes` on every command. Truthy is any non-empty value except `0` and `false` | -| `NOORM_CONNECTION_DIALECT` | Always | `postgres`, `mysql`, `sqlite`, or `mssql` | -| `NOORM_CONNECTION_HOST` | Dialect-specific | Host | -| `NOORM_CONNECTION_PORT` | Dialect-specific | Port | -| `NOORM_CONNECTION_DATABASE` | Always | Database name / file path | -| `NOORM_CONNECTION_USER` | Dialect-specific | Username | -| `NOORM_CONNECTION_PASSWORD` | Dialect-specific | Password | - - -### `ci` Commands at a Glance - -| Command | Runs on | Purpose | +| Symptom | Cause | Fix | |---|---|---| -| `noorm ci identity new` | Developer machine | Mint a CI keypair + print env block. No DB contact. | -| `noorm ci identity enroll --yes` | Developer machine (with vault access) | Enroll a CI identity in a target DB; grant vault access. Idempotent. `--yes` required. | -| `noorm ci init` | CI runner | Bootstrap ephemeral `state.enc` from env; create & activate a config. | -| `noorm ci secrets --file ` | CI runner (after `ci init`) | Batch-load secrets from dotenv into the active config. | - - -### Exit Codes - -| Code | Meaning | -|---|---| -| `0` | Success — everything the caller asked for happened | -| `1` | Total failure — the operation ran and no unit of work succeeded | -| `2` | Bad invocation — a missing or malformed flag or env var, or a named target (config, file, change) that does not exist. Nothing was attempted | -| `3` | Partial — some units of work succeeded and some failed. The target is in a mixed state and re-running is not automatically safe | +| `ci init` exits 2: `Missing or invalid environment variables` | A secret is not passed to the step, or the key is not 96 hex characters | Map the secret into the step's `env`. Copy the full value `enroll` printed | +| `ci init` exits 1: `State already exists at … Use --force to overwrite.` | A cached or committed `.noorm/state/state.enc` is in the workspace | `ci init --force` backs it up first. At a terminal it also needs `--yes` | +| `ci identity enroll` exits 1: `About to grant irrevocable vault access` | `--yes` is missing, and sealing a vault key cannot be undone | Check the fingerprint it prints, then rerun with `--yes` | +| `change ff` exits 1: `requires confirmation` | A stage named like the config (`prod`) has `protected: true`, which caps `user` at `operator` | Set `NOORM_YES=1` on the deploy step | +| Tests fail: `Config "ci" does not have isTest: true` | `ci init` leaves `isTest` false, and an environment-only config has it false too | `ci init --test`. `ci identity new` mints a throwaway identity for it | +| `Secret "STRIPE_KEY" not found (searched: config-local, global-local, vault)` | A template reads a secret this job does not have | Use prod CI for vault secrets, or `ci secrets` for job-scoped ones | +| `ci secrets` exits 3 | Some keys were stored and some failed | Fix the errors it lists, then rerun. Stored keys are skipped | -These four codes are shared by every noorm command, not just `ci`. `2` and `3` are what let a pipeline tell "you named something that isn't there" (retry is pointless) from "half of it landed" (a human needs to look). +## Next -## See Also -- [Identity Management](../../cli/identity.md) — keypair model and env-var identity -- [Vault](../environments/vault.md) — how team secrets are encrypted and shared -- [Secrets](../environments/secrets.md) — the three-layer secret resolution hierarchy -- [CI Architecture](../../dev/ci.md) — developer-focused notes on the CI flow +- [Non-interactive operation](/guide/automation/non-interactive): what `--yes` answers and what still refuses +- [Deploying an application](/guide/deployment): schema once per release, the app on every replica +- [Vault](/guide/environments/vault): how team secrets are encrypted and shared +- [Exit codes](/headless#exit-codes): the 0, 1, 2, 3 contract every command shares diff --git a/docs/guide/automation/mcp.md b/docs/guide/automation/mcp.md index 7e6fa823..b234f542 100644 --- a/docs/guide/automation/mcp.md +++ b/docs/guide/automation/mcp.md @@ -1,140 +1,95 @@ # MCP (AI Agent Integration) -noorm runs an [MCP](https://modelcontextprotocol.io/) server over stdio. AI coding agents connect to it and get access to schema inspection, queries, changes, and config management. - - -## Quick Start +Let an AI agent explore and query a database through noorm, limited to what the `agent` role on each config allows. ```bash -# Generate config for your agent (default: Claude Code) noorm mcp init - -# Or for Cursor -noorm mcp init --agent cursor ``` -This writes (or extends) the agent's MCP configuration file. The agent picks up the noorm server on next launch. - -Pair it with the skill, which teaches the agent noorm's conventions rather than its tools: - -```bash -npx skills add noormdev/noorm/skills ``` - -MCP and the skill solve different halves of the same problem — MCP is what the agent can *do*, the skill is what it *knows*. An agent with MCP but no skill can query your schema and still write SDK code that wraps everything in `try`/`catch` this codebase never uses. See [Installation](/getting-started/installation#install-the-agent-skill-optional). - - -## How It Works - -The MCP server wraps noorm's internal RPC registry (the same command set behind the TUI) over stdio transport. Your agent connects, calls `noorm_help` to list available commands, then executes them through `run_noorm_cmd`. - -``` -┌─────────────┐ stdio ┌─────────────────┐ RPC ┌──────────┐ -│ AI Agent │ ◄──────────► │ noorm mcp serve │ ◄──────────► │ Database │ -└─────────────┘ └─────────────────┘ └──────────┘ +Created /home/dev/order-to-cash/.mcp.json ``` -The server stays alive as long as the agent's process keeps stdin open. - - -## Available Tools - -The server exposes two MCP tools: - -| Tool | Purpose | -|------|---------| -| `run_noorm_cmd` | Execute a noorm command (connect, sql, overview, list_configs, etc.) | -| `noorm_help` | List available commands and their parameter schemas | - -Commands load from the RPC registry at runtime, so the tool list matches whatever noorm version you have installed. - - -## Setup by Agent - -### Claude Code - -```bash -noorm mcp init -``` - -Writes to `.mcp.json` in the project root. Claude Code reads this on startup. - -### Cursor - -```bash -noorm mcp init --agent cursor -``` - -Writes to `.cursor/mcp.json`. - - -## Manual Configuration - -If you need to configure the server manually (e.g., for a custom agent), add this to your MCP config: - ```json +// .mcp.json { "mcpServers": { "noorm": { "command": "noorm", - "args": ["mcp", "serve"] + "args": [ + "mcp", + "serve" + ] } } } ``` -This is the exact entry `noorm mcp init` writes, so the two routes produce the same server. +That is Claude Code's file. `--agent cursor` writes `.cursor/mcp.json`. Other MCP clients take the same entry by hand. An existing file keeps its other entries, and `init` prints `Extended`. Pair it with the [agent skill](/getting-started/installation#install-the-agent-skill-optional): `npx skills add noormdev/noorm/skills`. + -The server uses stdio transport — no ports, no HTTP. +## How does a request reach the database? -## What the Agent Can Do +An agent holds `viewer` on a config named `orders`. Every command passes a policy check first: -Once connected, your agent can: +```mermaid +sequenceDiagram + participant Agent + participant Server + participant Policy + participant DB as Database + Agent->>Server: connect orders + Server-->>Agent: connected, role viewer + Agent->>Server: sql SELECT from sales_order + Server->>Policy: SELECT is sql:read + Policy-->>Server: allow + Server->>DB: SELECT from sales_order + DB-->>Server: rows + Server-->>Agent: rows as JSON + Agent->>Server: sql DELETE from sales_order + Server->>Policy: DELETE is sql:write + Policy-->>Server: deny + Server-->>Agent: error, database untouched +``` -- **Explore schemas** — `overview`, `list`, `detail` for tables, columns, foreign keys, indexes -- **Run SQL** — `sql` executes a query and returns structured results -- **Manage connections** — `list_configs`, `connect`, `disconnect`, `status` -- **Apply changes** — `change_run`, `change_ff`, `change_revert`, `change_history` -- **Build schemas** — `run_build`, `run_file` +The server offers two tools: `noorm_help` lists commands and their parameters, and `run_noorm_cmd` runs one as `{ command, config?, payload? }`. -Config resolution and identity attribution work the same as the CLI. Access control does not: every config-scoped command is gated by the config's **`agent` role** before its handler ever runs, and there is no confirmation flow — the agent gets an answer, not a prompt. +## Which commands can the agent run? -## Access Roles -Every config declares a role per channel: `access: { user, agent }`. The `agent` role decides what an AI agent can do to that config — independently of what a human gets in the CLI/TUI. +Each command needs one permission, and the agent's role on the config decides if it has it: -The channel names *who is driving*, not which binary was invoked. An agent that gets refused here and shells out to `noorm` on the command line is still an agent, and gets the same `agent` role: the CLI detects the harness it was spawned from. Raising or lowering `agent` therefore governs both routes at once. See [Access Roles](/guide/environments/configs#access-roles) for the detection rules and the `NOORM_CHANNEL` override. +| Commands | Permission | `viewer` | `operator` | `admin` | +|---|---|---|---|---| +| `list_configs`, `connect`, `disconnect`, `status` | none | yes | yes | yes | +| `overview`, `list`, `detail`, `change_history` | `explore` | yes | yes | yes | +| `sql` with `SELECT`, or `SHOW` and `DESCRIBE` on MySQL | `sql:read` | yes | yes | yes | +| `sql` with `INSERT`, `UPDATE`, `DELETE` | `sql:write` | no | yes | yes | +| `sql` with DDL | `sql:ddl` | no | no | yes | +| `run_build`, `run_file`, `change_run`, `change_ff`, `change_revert` | `run:*`, `change:*` | no | no | yes | -A config that never declared `access` gets `{ user: 'admin', agent: 'viewer' }`. Agents can explore and read on a fresh project without any setup; anything that writes needs you to raise the `agent` role on purpose. +`sql` is classified by what the statement does, and `EXPLAIN` takes the class of the statement it wraps. A config with no `access` gives agents `viewer`. -| Role | What the agent can do | -|------|------------------------| -| `viewer` | Explore schema, run read-only SQL (`SELECT`, `SHOW`, `DESCRIBE`) | -| `operator` | Everything `viewer` can, plus SQL writes (`INSERT`/`UPDATE`/`DELETE`) — DDL and destructive commands (`change_run`, `run_build`, etc.) are still out of reach | -| `admin` | Writes, DDL, changes, builds, all frictionless. Permissions the matrix marks `confirm` even for `admin` (`db:teardown`, `db:destroy`, `config:write`) still deny on this channel | -| `false` | Invisible — the config does not exist on this channel | +`access.agent: false` hides a config: `list_configs` omits it, and `connect` fails with the error an unknown name produces, `Config "orders" not found`. -Raw SQL is classified by what the statement actually does (`sql:read` / `sql:write` / `sql:ddl`), not by which command the agent called — an agent with `agent: viewer` gets a `SELECT` through but a same-shaped `INSERT` denied. `EXPLAIN` takes the class of the statement it wraps, because `EXPLAIN ANALYZE` runs that statement: `EXPLAIN SELECT` is a read, `EXPLAIN DELETE` is a write. +An agent that shells out to `noorm` after a refusal stays on the `agent` channel ([how the channel is chosen](/guide/environments/configs#which-channel-is-a-process-on)). -The matrix's `confirm` cells never prompt on this channel — they resolve straight to **deny**. An agent typing its own confirmation phrase would be theater, not a safeguard, and on the CLI it would need only `--yes`. If an agent legitimately needs to run changes on a database, give that config `agent: 'admin'` — reserve it for configs where that's an acceptable risk (a disposable dev database, say), not production. -```yaml -# Shape of the config's `access` field — set via `noorm ui` → Config → Edit, -# or by editing the JSON before `config import` (see Config Sharing) -access: - user: admin # what you get in the CLI/TUI - agent: viewer # what any connected agent gets -``` +## What bites -## Invisible Configs +| Symptom | Cause | Fix | +|---|---|---| +| `"sql:write" is not allowed on config "orders" (role: viewer).` | The default agent role is `viewer` | Raise `access.agent` for that config in the TUI | +| `"change:ff" on config "orders" requires confirmation from a human.` | A `confirm` cell is deny for agents, and `--yes` does not change that | Give a disposable database `agent: admin`, or run the command yourself | +| Agent still refused after you raised its role | A session keeps the config it loaded at `connect` | Have the agent call `connect` again, or restart the server | -Set `access.agent: false` to hide a config from agents entirely — over MCP it never appears in `list_configs` or `status`, and `connect` fails with the same error an unknown config name would produce. `noorm config list` filters it the same way when an agent runs it. An agent enumerating configs cannot tell the difference between "doesn't exist" and "exists but is off-limits." +## Next -## Security -The MCP server uses the identity and config of the shell session that spawned it. It has no way to escalate privileges beyond what `noorm` itself can do, and it cannot escalate past the `agent` role a config was given — there is no `--force` override on this channel, and shelling out to the CLI reaches the same role rather than the human's. +- [Access roles](/guide/environments/configs#access-roles): the full role matrix +- [MCP commands](/headless#mcp): `mcp init` and `mcp serve` flags diff --git a/docs/guide/automation/non-interactive.md b/docs/guide/automation/non-interactive.md index 209b750c..f6368818 100644 --- a/docs/guide/automation/non-interactive.md +++ b/docs/guide/automation/non-interactive.md @@ -1,138 +1,67 @@ # Non-interactive operation -CI runners, deploy scripts, and AI subagents don't get to "press Enter". -noorm's CLI defaults to assuming a human is at the keyboard for a -handful of commands; the universal `--yes` / `NOORM_YES` opt-in tells -those commands to stop asking. +Run noorm in CI, a deploy script, or an agent subprocess, where nobody can press Enter. -`--yes` does not mean "do anything regardless." It means: "use defaults, -don't prompt, fail loudly if a non-interactive path doesn't exist for -this command." +```bash +# bootstrap.sh +set -euo pipefail +# identity init exits 1 when an identity exists, so guard it +[ -f ~/.noorm/identity.key ] || noorm identity init --name "$CI_USER" --email "$CI_EMAIL" +# init exits 1 when .noorm/ exists, which a CI checkout of a project has +[ -d .noorm ] || noorm init --yes +noorm run build # connection comes from NOORM_CONNECTION_* variables +noorm change ff +``` -## The opt-in +This scaffolds a project if there is none, then builds it. -### Flag form +## What does `--yes` answer? -Every TTY-gated command accepts `--yes` (alias `-y`): - noorm init --yes +`--yes` (or `-y`) answers a confirmation for one command. `NOORM_YES` answers for every command: any non-empty value counts except `0` and `false`, in any letter case. The flag beats `NOORM_YES=0`. +A run with no terminal stops at the first gate it cannot answer. The role cells come from [Access roles](/guide/environments/configs#access-roles), and on the `agent` channel `confirm` is `deny`: -### Environment form +```mermaid +flowchart TD + A["noorm command"] --> B{"TTY-only command?
sql repl, settings edit"} + B -->|yes| X2["exit 2
needs a terminal"] + B -->|no| C{"role cell"} + C -->|deny| X1["exit 1
no flag opens it"] + C -->|allow| R["runs"] + C -->|confirm| D{"--yes or
NOORM_YES?"} + D -->|yes| R + D -->|no| X3["exit 1
requires confirmation"] +``` -`NOORM_YES` is equivalent and applies to every command at once. Useful in -CI where you set it once at the top of the pipeline. - export NOORM_YES=1 - noorm init - noorm change ff - noorm db reset +## Which prompts have a flag? -Truthy values: `1`, `true`, `yes`, `TRUE`, any non-empty string. Falsy -values: `0`, `false`, empty string, unset. The comparison for `false` is -case-insensitive. -The `--yes` flag wins over `NOORM_YES=0`. Use that if you want to override -a globally-set falsy default. +| Command | Without a terminal | +|---|---| +| `noorm init` | `--yes`, after `noorm identity init` has written `~/.noorm/identity.*` | +| `change run`, `revert`, `rm`, `edit`, `add` | Pass the name or description as the argument, or exit 1 | +| `db transfer` with a `.dtzx` file | `--passphrase`, or exit 2 | +| `sql repl` | `--yes` exits 2. Use `noorm sql query "SELECT 1"` or `--file query.sql` | +| `settings edit` | `--yes` exits 2. Edit `.noorm/settings.yml`, then `noorm settings build` | +| `settings secret` | `--yes` exits 2. Edit `secrets:`, and set values with `noorm secret set --stdin` | -## What `--yes` does per command +## What bites -Some commands have a genuine non-interactive equivalent — for those, -`--yes` succeeds. The rest are interactive by design; for them, `--yes` -errors fast and points at the headless alternative. +| Symptom | Cause | Fix | +|---|---|---| +| `identity init --force` still exits 1 | Replacing needs the `--yes` flag. `NOORM_YES` does not count, because a new keypair orphans every `state.enc` | Pass `--yes` | +| `db reset` exits 1: `This is a destructive operation. Pass --yes to confirm.` | It needs `--yes` or `NOORM_YES` even for `admin` | Pass it | -### Accepts `--yes` (proceeds) -| Command | Behavior with `--yes` | -|---------|-----------------------| -| `noorm init` | Skips all prompts. Requires an existing identity at `~/.noorm/identity.{key,pub,json}`. Errors with a bootstrap hint if missing. | +## Next -### Refuses `--yes` (redirect hint) - -| Command | Redirect | -|---------|----------| -| `noorm sql repl` | Use `noorm sql query ""` or `noorm sql query --file query.sql`. | -| `noorm settings edit` | Edit `settings.yml` directly; run `noorm settings build` to validate. | -| `noorm settings secret` | Edit the `secrets:` section of `settings.yml` directly. For values, use `noorm secret set `. | - -These commands exit 2 (bad invocation) with the redirect message on -stderr. Same exit code as their TTY refusal, so a pipeline sees one -code whichever way it hit the gate. - - -### Already non-interactive (no `--yes` needed) - -These accept explicit flags and don't gate on a TTY: - -- `noorm identity init --name "..." --email "..."` — bootstraps the identity used by `noorm init --yes`. -- `noorm config import ` — wizard-free config setup. -- `noorm ci init` / `ci secrets` / `ci identity new` — the dedicated CI bootstrap surface. - -One exception inside that surface: `noorm ci identity enroll` **does** need -`--yes` (or `NOORM_YES=1`). It ends in a `vault:propagate` grant, which the -matrix marks `confirm` for every role that holds it, and sealing the vault -key to a public key cannot be undone. - - -## CI bootstrap pipeline - -End-to-end happy path: from a clean machine to a usable noorm project, -with no prompts: - - #!/usr/bin/env bash - set -euo pipefail - - # 1. Identity (once per machine; exits 1 if one already exists). - noorm identity init \ - --name "$CI_USER" \ - --email "$CI_EMAIL" - - # 2. Project scaffold (db/sql/, db/changes/, settings.yml, state.enc). - cd "$PROJECT_ROOT" - noorm init --yes - - # 3. Connection config — env-only, no wizard. - export NOORM_CONNECTION_DIALECT=postgres - export NOORM_CONNECTION_HOST="$DB_HOST" - export NOORM_CONNECTION_DATABASE="$DB_NAME" - export NOORM_CONNECTION_USER="$DB_USER" - export NOORM_CONNECTION_PASSWORD="$DB_PASSWORD" - - # 4. Build schema + apply pending changes. - noorm run build - noorm change ff - -Step 1 is not idempotent, and under `set -e` it aborts the script if an -identity already exists. Replacing one takes `--force --yes` together, -mints a fresh keypair, and leaves any state encrypted under the old key -unreadable. Guard it instead of passing `--force` blind: - - [ -f ~/.noorm/identity.key ] || noorm identity init \ - --name "$CI_USER" --email "$CI_EMAIL" - -The raw `--yes` flag is required for a replacement; an ambient -`NOORM_YES` deliberately does not satisfy it. - -If you need a stored config (rather than env-only), use `noorm config -import ` with a pre-built JSON file. See -[`docs/headless.md`](../../headless.md) for the full env-only matrix. - - -## When `--yes` is NOT enough - -`--yes` covers prompts. It does NOT relax safety gates that exist for -destructive operations: - -- `noorm db drop`, `noorm db reset`, `noorm db teardown` still require `--yes` per their own contracts. `NOORM_YES=1` does count toward those gates. -- Every destructive command is also gated by the config's access role (`viewer`/`operator`/`admin` — see [Access Roles](../../headless.md#access-roles)). `--yes`/`NOORM_YES=1` only satisfies a `confirm` cell; it cannot open a `deny` cell. A `viewer`- or `operator`-role config still refuses `db teardown` no matter how many flags you pass. -- Vault and identity operations that need a private key still need the key — either on disk at `~/.noorm/identity.key` or in `$NOORM_IDENTITY_PRIVATE_KEY`. - -If a command refuses with a useful error in interactive mode, it refuses -with the same error in `--yes` mode. The flag turns prompts off, not -preconditions off. +- [CI/CD Automation](/guide/automation/ci): test CI and prod CI pipelines +- [Exit codes](/headless#exit-codes): the 0, 1, 2, 3 contract diff --git a/docs/guide/changes/add-a-column.md b/docs/guide/changes/add-a-column.md new file mode 100644 index 00000000..2f0018fc --- /dev/null +++ b/docs/guide/changes/add-a-column.md @@ -0,0 +1,120 @@ +--- +title: Add a column to a live database +description: Declare the column once in db/sql/, then add a change that brings existing databases to the same state, backfill included, and that is safe to run twice. +--- + +# Add a column to a live database + +Declare the column in `db/sql/` so fresh databases get it. Add a change so databases that already exist catch up, backfill included. + +The table's file holds the current state. The column is declared once, inline: + +```sql +-- db/sql/01_tables/02_sales_order.sql +CREATE TABLE sales_order ( + party_id integer NOT NULL, + order_no integer NOT NULL, + placed_at timestamptz NOT NULL DEFAULT now(), + status text NOT NULL DEFAULT 'open', -- [!code ++] + CONSTRAINT sales_order_pk + PRIMARY KEY (party_id, order_no), + CONSTRAINT sales_order_isplacedby_party + FOREIGN KEY (party_id) REFERENCES party (party_id) +); +``` + +Scaffold the change, then fill in its two files: + +```bash +noorm change add order-status +``` + +```sql +-- db/changes/2026-10-20-order-status/change/001_order-status.sql +ALTER TABLE sales_order ADD COLUMN IF NOT EXISTS status text; + +-- Invoiced orders become 'invoiced', the rest 'open'. Paid orders are +-- marked by the payment change that follows this one. +UPDATE sales_order so +SET status = CASE WHEN EXISTS ( + SELECT 1 FROM invoice_line il + WHERE il.party_id = so.party_id AND il.order_no = so.order_no + ) THEN 'invoiced' ELSE 'open' END +WHERE status IS NULL; + +ALTER TABLE sales_order + ALTER COLUMN status SET DEFAULT 'open', + ALTER COLUMN status SET NOT NULL; +``` + +```sql +-- db/changes/2026-10-20-order-status/revert/001_order-status.sql +ALTER TABLE sales_order DROP COLUMN IF EXISTS status; +``` + +Apply it to every database that predates the column: + +```bash +noorm change ff +``` + +``` +• log Fast-forward success executed=1 skipped=0 failed=0 +• log 2026-10-20-order-status (success) +``` + + +## How it works + + +Each database reaches the same table by one of two paths: + +```mermaid +flowchart TD + F[fresh database] -->|noorm run build| S["db/sql/01_tables/
02_sales_order.sql"] + E[existing database] -->|noorm change ff| C["change/
001_order-status.sql"] + S --> T[sales_order with status] + C --> T +``` + +| File | Runs on | Says | +|---|---|---| +| `db/sql/01_tables/02_sales_order.sql` | fresh databases, through `run build` | what the table is now | +| `change/001_order-status.sql` | each database where the change is pending, through `change ff` | how to get there from the old table, backfill included | +| `revert/001_order-status.sql` | `change revert` | how to undo it | + +`ALTER`, `UPDATE`, and `DROP` live only in `db/changes/`. The file in `db/sql/` never says how the table got to its current shape. + +A database built from `db/sql/` has no record of any change, so its first `change ff` runs every change it finds, this one included. The `IF NOT EXISTS` and the `WHERE status IS NULL` make that second run a no-op. + + +## Options + + +| Flag | Effect | +|---|---| +| `--dry-run` | Render the change to `tmp/` without touching the database | +| `--json` | Print the result as JSON | +| `--config`, `-c` | Run against another config: `noorm change ff -c staging` | +| `--force`, `-f` | Skip the checksum check and run every file of each pending change | +| `NOORM_YES=1` | Allow operations that need confirmation, for scripts and CI | + +Full entry: [`change ff`](/headless#change-ff). + + +## What bites + + +| Symptom | Cause | Fix | +|---|---|---| +| `change ff` fails on a freshly built database: column "status" already exists | The build created the column, and the change ran again because nothing recorded it | Write the change so a second run changes nothing: `IF NOT EXISTS`, and backfill only rows that still need it | +| `run build` on a fresh database fails: column "status" already exists | An `ALTER TABLE` landed in `db/sql/` | Declare the column inline in `CREATE TABLE`; the `ALTER` belongs in the change | +| `SET NOT NULL` fails: the column contains null values | The backfill missed rows | Backfill in the same change, before `SET NOT NULL` | +| `change ff` exits 1: requires confirmation | Your role on that config is `operator` | Set `NOORM_YES=1`, or confirm in `noorm ui` | + + +## Next + + +- [Forward and revert](/guide/changes/forward-revert): apply, revert, and the status of each change +- [Changes overview](/guide/changes/overview): folder anatomy and manifests diff --git a/docs/guide/changes/forward-revert.md b/docs/guide/changes/forward-revert.md index f54d0276..c7abdf2d 100644 --- a/docs/guide/changes/forward-revert.md +++ b/docs/guide/changes/forward-revert.md @@ -1,301 +1,188 @@ -# Forward and Revert Lifecycle +# Forward and revert -Database changes move in two directions: forward when you apply them, backward when you need to undo. Understanding this lifecycle helps you deploy with confidence and recover gracefully when things go wrong. +Apply outstanding changes in order, and roll one back when it goes wrong. - -## Applying Changes - -noorm provides three ways to apply changes, each suited to different situations. - - -### Fast-Forward All Pending - -Apply every pending change in chronological order: - -**TUI:** Navigate to Changes (`g` from home), then press `f` for fast-forward. - -**Headless:** ```bash noorm change ff -noorm change ff --json -``` - -Fast-forward is the workhorse for deployments. It finds every outstanding change (`pending`, `reverted`, or `stale` after a teardown), sorts them by folder name so the `YYYY-MM-DD` prefix puts them in chronological order, and executes each one in sequence. If any change fails, execution stops immediately. - -**JSON output:** -```json -{ - "success": true, - "status": "success", - "executed": 2, - "skipped": 0, - "failed": 0, - "durationMs": 77, - "changes": [ - {"name": "2024-02-01-add-notifications", "direction": "change", "status": "success", "durationMs": 45}, - {"name": "2024-02-15-user-preferences", "direction": "change", "status": "success", "durationMs": 32} - ] -} ``` -Each entry in `changes` also carries a `files` array with one record per SQL file: relative path, checksum, status, duration, and the error message when the file failed. If the `db/changes/` directory is missing, the payload gains a `warnings` array saying so, because an absent directory otherwise looks identical to an up-to-date database. A change folder that cannot be read, such as a manifest naming a deleted `sql/` file, stops the batch at that change when it would run, and only adds a warning when it is already applied. - - -### Apply a Specific Change - -Run one change by name: - -**TUI:** From the Changes list, select a change and press `r` for run. - -**Headless:** -```bash -noorm change run 2024-02-01-add-notifications ``` - -This is useful when you need to apply changes out of order during development, or re-run a failed change after fixing the underlying issue. - - -### Apply the Next Pending Change - -Apply only the next change in sequence: - -**TUI:** From Changes, press `n` for next. - -**Headless:** -```bash -noorm change next +• log Fast-forward success executed=2 skipped=0 failed=0 +• log 2026-10-22-open-orders (success) +• log 2026-10-23-payment (success) ``` -Useful for stepping through changes one at a time, perhaps while monitoring system behavior between each. - - -## Reverting Changes - -Rolling back requires that your change includes revert scripts in its `revert/` folder. - - -### Revert a Specific Change - -Undo one change: - -**TUI:** From Changes, select an applied change and press `v` for revert. - -**Headless:** ```bash -noorm change revert 2024-02-01-add-notifications +noorm change revert 2026-10-23-payment ``` -Revert scripts execute in forward sequence order, just like change scripts. You design them to undo in reverse—if your change had `001_create-table.sql` and `002_add-indexes.sql`, your revert should have `001_drop-indexes.sql` (undoes the last thing) and `002_drop-table.sql` (undoes the first thing). - - -### Rewind Multiple Changes - -Revert the last N applied changes: - -**TUI:** From Changes, press `w` for rewind, then specify how many. - -**Headless:** -```bash -noorm change rewind 3 ``` - -Rewind walks through your applied changes starting with the newest, reverting each one before moving to the next oldest. It considers only changes currently recorded as `success`, and orders them by when they were applied. Changes applied in the same second, which is everything a single `change ff` applies, tie on that timestamp, so the tracking table's row id breaks the tie and preserves true apply order. - -Pass a change name instead of a count to rewind back to and including that change: - -```bash -noorm change rewind 2024-02-01-add-notifications +• log 2026-10-23-payment reverted (success) ``` +`ff` ran both outstanding changes, oldest first. `revert` ran the `revert/` files of `payment` and set it to `reverted`, so the next `ff` applies it again. -## What Happens During Apply - -When you apply a change, noorm: - -1. **Reads the change folder** - Loads all files from `change/` and determines their types (`.sql`, `.sql.tmpl`, or `.txt` manifest). - -2. **Calculates a checksum** - Creates a combined hash of all files to detect future modifications. - -3. **Checks the database record** - Looks up this change's status to determine if it should run. - -4. **Acquires the config lock** - Stops a second operation on the same config from running concurrently. The lock is released when the change finishes, whatever the outcome. -5. **Resolves manifest references** - For `.txt` files, replaces file references with actual SQL content from your schema directory. +## Which statuses can a change have? -6. **Processes templates** - For `.sql.tmpl` files, runs them through the Eta templating engine. -7. **Executes SQL in sequence** - Runs each file in order (`001_`, `002_`, etc.) against the database. A file whose checksum matches a still-standing success from an earlier attempt is skipped, so a retry only re-runs what actually needs re-running. +A change has five statuses, and `ff` and `next` pick up three of them. -8. **Records the result** - Stores the execution status, checksum, and timestamp in the change tracking tables. - -If any file fails, execution stops, and the files that were never reached are recorded as skipped. On PostgreSQL the whole change runs inside one transaction, so the failure rolls back the DDL and the history rows together and the change leaves no record at all. On MySQL, SQL Server, and SQLite there is no wrapping transaction: whatever already committed stays committed, and the change is recorded as `failed`. - - -## What Happens During Revert - -Reverting follows a similar process but with key differences: - -1. **Verifies the change can be reverted** - A change recorded as `success` or `failed` can be reverted. One that was never applied, is already `reverted`, or went `stale` after a teardown cannot. `--force` skips this check. - -2. **Reads the revert folder** - Loads files from `revert/` instead of `change/`. +```mermaid +stateDiagram-v2 + [*] --> pending : change add + pending --> success : files pass + pending --> failed : a file fails + failed --> success : change run + success --> reverted : revert + reverted --> success : ff or run + success --> stale : db teardown + stale --> success : ff or run +``` -3. **Executes in sequence order** - Files run from lowest to highest (`001_`, `002_`, etc.), same as change scripts. +| Status | Means | `ff` and `next` | +|---|---|---| +| `pending` | no record yet | run it | +| `success` | applied | skip it | +| `failed` | last run failed and was recorded | skip it | +| `reverted` | applied, then rolled back | run it | +| `stale` | `db teardown` dropped what it built | run it | -4. **Updates the records** - Writes its own history record with direction `revert`, then flips the original forward record's status to `reverted`. The revert record's timestamp is what surfaces as `revertedAt`. +`revert` runs from `success` or `failed`, and `db teardown` turns both into `stale`. A revert from `reverted` or `stale` runs nothing and still reports success. A change with no record fails with `not applied`. -After reverting, the change returns to a state where it can be applied again. This is the `reverted` status, which noorm treats as "needs to run" during the next fast-forward. +## How do I apply changes? -## Dry Run Mode -Preview what would happen without touching the database: +| Command | Applies | TUI | +|---|---|---| +| `noorm change ff` | every outstanding change, oldest first | `f` | +| `noorm change next [n]` | the next `n` outstanding changes, default 1 | `n` | +| `noorm change run ` | one named change, in or out of order | `r` | -**TUI:** Press `Shift+D` to turn on dry-run mode. It is a global toggle, so every apply and revert stays a dry run until you press it again. +`ff` and `next` stop at the first failing change. Exit `0` is success, `1` failed, `3` partial: some applied before one failed. [CI](/guide/automation/ci) runs these in a pipeline. -**Headless:** - noorm change ff --dry-run - noorm change run 2024-02-01-add-notifications --dry-run - noorm change revert 2024-02-01-add-notifications --dry-run - noorm change next --dry-run - noorm change rewind 3 --dry-run +## What happens when a change runs? -Dry run writes rendered SQL to a `tmp/` folder so you can inspect exactly what would execute. Templates are processed and manifests are resolved, but nothing from your change files runs against the database and nothing is written to the change tracking tables: the dry-run path skips the status lookup, takes no lock, and creates no history record. `ff`, `next`, and `rewind` still read the tracking tables to decide which changes to render. -In human mode the CLI opens with `Dry run: rendering changes to tmp/ (no DB writes)`, labels the summary line `(dry-run)`, and tags each change `dry-run`, so log scrapers and operators can tell it apart from a real apply. With `--json` those lines are suppressed and the result payload carries a `dryRun: true` field instead (`rewind --json` is the exception and omits it): +A change takes the config lock, runs its files in order, and records the outcome. The first failing file stops it. - $ noorm change ff --dry-run --json - { - "success": true, - "status": "success", - "executed": 2, - "skipped": 0, - "failed": 0, - "durationMs": 12, - "changes": [], - "dryRun": true - } +```mermaid +flowchart TD + N{"needs to run?
status, checksums"} -->|no| S["skip"] + N -->|yes| L["take the config lock"] + L --> F["run files in order,
skip passed files"] + F --> P{"every file passes?"} + P -->|yes| OK["record success"] + P -->|no| D{"PostgreSQL?"} + D -->|yes| RB["roll back the DDL
and the records"] + D -->|no| FL["record failed,
skip unreached files"] +``` -This is essential for production deployments. Always preview before applying. +On PostgreSQL the whole change is one transaction, so a failure leaves no record and no applied file. MySQL, SQL Server, and SQLite have no wrapping transaction, so files that passed stay applied. -## Handling Failures +## How do I recover from a failed change? -When a change fails: -1. **Execution stops immediately** - No further files in the change execute. The ones never reached are recorded as `skipped`, with the failing file named as the reason. +Fix the file, then retry: -2. **Status is recorded as failed** - The change shows `failed` in the list. +| | PostgreSQL | MySQL, SQL Server, SQLite | +|---|---|---| +| Status after a failure | none, so `pending` | `failed`, with the error | +| Retry with | `noorm change ff` | `noorm change run ` | -3. **Error details are captured** - The failing file name and the database's error message are stored on both the change record and that file's record. +The retry skips files that already passed. If a half-applied run needs manual cleanup first, clean up, then add `--force` to run every file. [History](/guide/changes/history#which-file-broke-the-change) shows which file failed. -4. **Next fast-forward will retry** - Failed changes are outstanding work, so the next `ff` attempt includes them. -On PostgreSQL, steps 2 and 3 do not survive the failure. The change runs inside one transaction, DDL and history rows alike, so a failure rolls all of it back and leaves no record behind. You still see the error in the command's output and exit code, and the next `ff` reruns the change because the tracking tables show it as never applied. MySQL, SQL Server, and SQLite have no wrapping transaction and keep the `failed` record. +## What happens when a change folder cannot be read? -### Investigating Failures +A manifest that names a file no longer in `db/sql/` makes its change unreadable. `ff` and `next` do not step over it: -**TUI:** From Changes, press `h` for history, then select the failed operation to see file-level details. +| The unreadable change | `ff` and `next` | +|---|---| +| never ran, or is `reverted` or `stale` | fail at its position, so later changes do not run out of order. Exit `1`, or `3` when earlier changes applied | +| already applied | keep its status and add a `warnings` entry | -**Headless:** -```bash -noorm change history +``` +✗ log Fast-forward partial executed=1 skipped=1 failed=1 +• log 2026-10-20-order-status (success) +✗ log 2026-10-22-open-orders (failed) +✗ log error: Manifest '/home/dana/orders/db/changes/2026-10-22-open-orders/change/001_rebuild-views.txt' references missing file: 02_views/02_unpaid_invoices.sql ``` -The history shows which specific file failed and includes the error message from the database. On PostgreSQL there is nothing to show, since a failed change rolls its history rows back with the DDL; read the error off the failing command instead. See [History](/guide/changes/history) for detailed debugging workflows. - - -### Recovering from Failures - -Option 1: **Fix and retry** -- Edit the SQL file to fix the issue -- Run `change ff` again (the change will be picked up automatically) - -Option 2: **Manual cleanup** -- If partial SQL executed, you may need to manually clean up -- Then run with `--force` to skip the checksum check: +`skipped=1` is `2026-10-23-payment`, which never ran. - noorm change run 2024-02-01-add-notifications --force +## How do I roll back? -## Common Workflows +| Command | Reverts | TUI | +|---|---|---| +| `noorm change revert ` | one change | `v` | +| `noorm change rewind ` | the last `n` applied changes, newest first | `w` | +| `noorm change rewind ` | that change and every change applied after it | `w` | -### Deploying to Production +`revert` runs the `revert/` files in filename order, writes a history record with direction `revert`, then sets the original record to `reverted`. `rewind` considers only `success` changes and orders them by apply time, breaking same-second ties by history id. - # Preview what will run - noorm change ff --dry-run - # Review the rendered SQL in tmp/ - # Then apply for real - noorm change ff +## How do I preview a run? -### Rolling Back a Bad Deploy +`--dry-run` renders every file, templates and manifests included, to `tmp/` and runs nothing: ```bash -# See current state -noorm change list - -# Revert the problematic change -noorm change revert 2024-02-15-broken-change +noorm change ff --dry-run ``` - -### Starting Fresh After Teardown - -After running `db teardown`, all changes are marked as `stale`. The next fast-forward re-applies everything: - -```bash -noorm db teardown -y -noorm run build # Rebuild base schema -noorm change ff # Re-apply all changes +``` +• log Dry run: rendering changes to tmp/ (no DB writes) +• log Fast-forward (dry-run) success executed=2 skipped=0 failed=0 dryRun=true +• log 2026-10-22-open-orders (success, dry-run) +• log 2026-10-23-payment (success, dry-run) ``` +A dry run takes no lock and writes no status or history. `ff`, `next`, and `rewind` still read the tracking tables to choose what to render. In the TUI, `Shift+D` toggles it. -### Testing Revert Scripts +::: warning Rendered secrets +Dry-run files hold every secret a template resolved, in plaintext. noorm writes them owner-only, and `noorm init` does not add `tmp/` to `.gitignore`. +::: -During development, verify your revert logic works: -```bash -# Apply the change -noorm change run 2024-02-01-add-notifications +## What decides whether a change runs? -# Revert it -noorm change revert 2024-02-01-add-notifications -# Apply again to confirm round-trip works -noorm change run 2024-02-01-add-notifications -``` +noorm compares checksums of every file in `change/`, plus every file a manifest lists. -This apply-revert-apply cycle catches revert scripts that are missing steps or have incorrect SQL. +| The change is | `ff` and `next` | `change run ` | +|---|---|---| +| new, `reverted`, or `stale` | runs | runs | +| `failed` | skips | runs the files that have not passed | +| `success`, files unchanged | skips | skips | +| `success`, a file edited | skips | runs the edited files | +`--force` (TUI: `Shift+F`) runs every file and ignores file history. On `revert` it skips the status check. [`change ff`](/headless#change-ff) lists the other flags. -### CI/CD Pipeline -A typical deployment script: +## What bites -```bash -#!/bin/bash -set -e -# Build base schema (idempotent) -noorm run build - -# Apply pending changes -noorm change ff - -# Verify the result -noorm db explore --json -``` +| Symptom | Cause | Fix | +|---|---|---| +| `ff` reports `executed=0` while a change shows `failed` | `ff` skips a recorded `failed` change | Fix the file, then `noorm change run ` | +| `ff` fails after `run build`: already exists | A build records no changes, and `db teardown` marks applied ones `stale`, so `ff` replays them over the built schema | Write each change to be safe to run twice ([Add a column](/guide/changes/add-a-column)) | +| `ff` warns `Changes directory not found` and runs nothing | `settings.yml` has no `paths`, which now resolves to `./db/changes` | Add `paths: { sql: ./sql, changes: ./changes }` | +| Exit `1`: `requires confirmation` or `(role: viewer)` | Your role on that config is `operator` or `viewer` | `NOORM_YES=1` for `operator`. `viewer` is denied: [Access roles](/headless#access-roles) | +| Exit `1`: `Lock for '' held by ...` | Another operation holds the config lock | Wait, or check `noorm lock status` | -The build step handles the initial schema. The fast-forward applies any changes that have been merged since the last deploy. +## Next -## What's Next? -- [History](/guide/changes/history) - Track and debug execution history -- [Changes](/guide/changes/overview) - Change structure and file types -- [Teardown](/guide/database/teardown) - How teardown affects change status +- [History](/guide/changes/history): what each run recorded +- [Changes](/guide/changes/overview): folder layout and manifests +- [Add a column](/guide/changes/add-a-column): a change with a backfill +- [Teardown](/guide/database/teardown): how teardown makes changes `stale` diff --git a/docs/guide/changes/history.md b/docs/guide/changes/history.md index aba25435..5e79e5df 100644 --- a/docs/guide/changes/history.md +++ b/docs/guide/changes/history.md @@ -1,134 +1,105 @@ -# Execution History +# Execution history -Every database operation noorm performs is recorded. When a change runs, a build executes, or files get deployed, that execution goes into the history log with who ran it, when, how long it took, and whether it succeeded. +Find out who ran a change, when, and which file broke it. -A build, dir, files or file run where every file is unchanged writes no operation, so it appears neither in history nor in Recent Activity. - -This gives you an audit trail for compliance, a debugging tool for failures, and a reference for understanding what happened to your database. - - -## What Gets Tracked - -noorm records three types of operations: - -| Type | Description | Trigger | -|------|-------------|---------| -| **change** | Change execution | `change run`, `change ff`, `change next`, `change revert`, `change rewind` | -| **build** | Full schema rebuild | `run build` | -| **run** | Individual file execution | `run file`, `run directory` | +```bash +noorm change history +``` -Each record captures: +``` +Execution History: 4 records + 2026-10-20-order-status - success (10/21/2026, 1:40:29 AM) + 2026-10-21-invoice - failed (10/21/2026, 1:40:03 AM) + 2026-10-20-order-status - reverted (10/21/2026, 1:40:03 AM) + 2026-10-19-sales-order - success (10/21/2026, 1:40:03 AM) +``` -- **Name** - Which change, build, or file was executed -- **Direction** - Forward (`change`) or rollback (`revert`) -- **Status** - `pending` while the operation is in flight, then `success`, `failed`, `reverted`, or `stale` -- **Who** - The identity that ran it (name and email) -- **When** - Timestamp of execution -- **Duration** - How long it took in milliseconds -- **Checksum** - Hash of the executed files -- **Error** - If failed, what went wrong +Newest first, one line per operation. The first line is the revert of `2026-10-20-order-status`. The third is its original apply, now flagged `reverted`. The text omits direction, and `--json` carries it. -File-level details are also preserved. For a change with three SQL files, you can see exactly which file succeeded, which failed, and which were skipped. +## What does noorm record? -## Viewing History in the TUI -Navigate to history from the changes menu. Press `g` from home, then `h` for history. +Each operation is one `change` row, with one `executions` row per file that was not already applied: -``` -+- Execution History --------------------------------------------+ -| | -| Total: 47 Changes: 32 Builds: 10 Runs: 5 | -| Success: 45 Failed: 2 | -| | -| > [OK] [CHANGESET] 2025-01-15-add-verification 2 hours ago | -| [OK] [BUILD] schema-build 3 hours ago | -| [OK] [CHANGESET] 2025-01-14-add-user-roles yesterday | -| [ERR] [RUN] seed-data.sql yesterday | -| [OK] [CHANGESET] 2025-01-10-init-schema last week | -| | -| +------------------------------------------------------------+| -| | 2025-01-15-add-verification || -| | By: Alice || -| | Duration: 1.24s || -| | Press Enter to view file details || -| +------------------------------------------------------------+| -| | -| [Enter] View Files [Esc] Back | -+----------------------------------------------------------------+ +```mermaid +erDiagram + change ||--o{ executions : "is broken into" + change { + int id PK + text name + text change_type + text direction + text status + } + executions { + int id PK + int change_id FK + text filepath + text status + text skip_reason + } ``` -The summary bar shows totals by type. Color-coded status indicators make it easy to spot failures: +Rows also carry who ran it (`Name `), when, how long, a checksum, and the error text. MySQL and SQLite name the tables `__noorm_change__` and `__noorm_executions__`. [Execution](/guide/sql-files/execution#what-gets-tracked) lists every column. -- Green `[OK]` - Success -- Red `[ERR]` - Anything else +| `change_type` | Written by | In `change history` | +|---|---|---| +| `change` | `change run`, `ff`, `next`, `revert`, `rewind` | yes | +| `build` | `run build` | no, TUI only | +| `run` | `run file`, `run dir`, `run files` | no, TUI only | -The list only separates success from not-success. The per-file detail view adds a yellow `[-]` for skipped files. +A build or run where every file is unchanged writes no operation. -Arrow keys move through the list. The detail panel at the bottom shows who ran the selected operation and how long it took. +## Which file broke the change? -## History Detail View -Press Enter on any record to see file-level execution details: +```bash +noorm change history-detail 2026-10-24-order-line +``` ``` -+- File Executions (2025-01-15-add-verification) ----------------+ -| | -| Total: 3 Success: 2 Failed: 1 | -| | -| > [OK] 001_create-tokens-table.sql (0.4s) | -| [OK] 002_add-user-column.sql (0.3s) | -| [ERR] 003_add-index.sql (0.5s) | -| | -| +------------------------------------------------------------+| -| | File Details || -| | Path: db/changes/2025-01-15-.../change/001_create-tok... || -| | Checksum: 8f4a2b3c9d1e... || -| +------------------------------------------------------------+| -| | -| [Esc] Back | -+----------------------------------------------------------------+ +• log History for: 2026-10-24-order-line (1 operation(s)) +• log + [15] change — failed at 10/21/2026, 1:46:16 AM by Dana Reyes +• log duration: 27ms +• log error: 002_order-line-index.sql: [Err 1] no such column: nope +• log • 001_order-line.sql — success | 2ms +• log • 002_order-line-index.sql — failed | 1ms | error: [Err 1] no such column: nope +• log • 003_line-index.sql — skipped | skipped: 002_order-line-index.sql failed: [Err 1] no such column: nope ``` -For failed files, the detail panel shows the error message: +`[15]` is the operation id. The failing file carries the database's error, and every file after it is `skipped` with the failure as the reason. After you fix `002` and run `noorm change run 2026-10-24-order-line`, a second operation lists only `002` and `003`. File `001` already passed, so it leaves no row. -``` -| +------------------------------------------------------------+| -| | File Details || -| | Path: db/changes/2025-01-15-.../change/003_add-index.sql || -| | Checksum: 3b7c8d2a4f5e... || -| | Error: relation "users" does not exist || -| +------------------------------------------------------------+| -``` +| File status | TUI | Meaning | +|---|---|---| +| `success` | `[OK]` | ran without error | +| `failed` | `[ERR]` | threw an error | +| `skipped` | `[-]` | an earlier file failed, so this one was never reached | -This is invaluable for debugging. You can see exactly which file failed, what the error was, and what files succeeded before it. +`--count` sets how many operations to show, default 10. How to retry is in [Forward and revert](/guide/changes/forward-revert#how-do-i-recover-from-a-failed-change). -## Headless Mode +## How do I read history in the TUI? -For CI/CD pipelines and scripts, use the headless command: -```bash -noorm change history -``` +Press `g` from home, then `h`. [TUI](/tui#changes-list) shows the screens. -Unlike the TUI screen, this command returns change operations only. Builds and file runs live in the same tracking tables but do not appear here. +| Screen | Shows | Keys | +|---|---|---| +| History | the latest 200 operations of every type, with totals by type | `Enter` opens files, `e` shows the full error, `Esc` goes back | +| File executions | one operation's files, with path, checksum, and error or skip reason | `Enter` shows the full error, `Esc` goes back | -Output: +Rows carry `[OK]` for `success` and `[ERR]` for any other status, and a type tag: `[CHANGESET]`, `[BUILD]`, or `[RUN]`. Times show as `2 hours ago`. -``` -Execution History: 20 records - 2025-01-15-add-verification - success (1/15/2025, 2:30:00 PM) - 2025-01-14-add-user-roles - reverted (1/14/2025, 4:15:00 PM) - 2025-01-10-init-schema - success (1/10/2025, 9:05:00 AM) -``` -With JSON output for parsing: +## How do I script against history? + ```bash -noorm change history --json +noorm change history --json --count 100 ``` ```json @@ -136,201 +107,39 @@ noorm change history --json "success": true, "history": [ { - "id": 42, - "name": "2025-01-15-add-verification", + "id": 13, + "name": "2026-10-23-payment", "direction": "change", "status": "success", - "executedAt": "2025-01-15T14:30:00.000Z", - "executedBy": "Alice ", - "durationMs": 1240, + "executedAt": "2026-10-21T05:41:50.000Z", + "executedBy": "Dana Reyes ", + "durationMs": 19, "errorMessage": null, - "checksum": "8f4a2b3c9d1e..." - }, - { - "id": 41, - "name": "2025-01-14-add-user-roles", - "direction": "revert", - "status": "success", - "executedAt": "2025-01-14T16:15:00.000Z", - "executedBy": "CI Pipeline ", - "durationMs": 320, - "errorMessage": null, - "checksum": "3b7c8d2a4f5e..." + "checksum": "73fbc0894afc..." } ] } ``` -The `id` is what `noorm change history-detail` and the TUI drill-down use to fetch the per-file records for an operation. - -Limit the number of records (default 20): - -```bash -noorm change history --count 50 -``` - - -## Filtering by Type - -The TUI history screen shows all three operation types by default. The summary bar helps you understand the distribution: - -``` -Total: 47 Changes: 32 Builds: 10 Runs: 5 -``` - -Type indicators make scanning easy: - -| Indicator | Color | Meaning | -|-----------|-------|---------| -| `[CHANGESET]` | Cyan | Change execution | -| `[BUILD]` | Blue | Full schema rebuild | -| `[RUN]` | Magenta | Individual file execution | - -In the list, you can quickly spot operation types by color before reading the name. - - -## Understanding History Entries - -Each history entry tells a complete story. Here's how to read them: - - -### Who Ran It - -The identity shows who executed the operation: - -``` -By: Alice -``` - -In CI/CD, this might be: - -``` -By: GitHub Actions -``` - -Identity is captured from git config or explicitly set via `noorm identity`. This creates an audit trail for compliance requirements. You can also set a per-config identity override in [Configs](/guide/environments/configs). - - -### When It Ran - -Timestamps appear in relative format in the TUI, rendered by day.js `fromNow()`: - -- `a few seconds ago` - Just happened -- `2 hours ago` - Recent operations -- `a day ago` - Previous day -- `7 days ago` - Within the past week -- `a month ago` - Older records - -Relative time is all the TUI shows, however old the record. The JSON output provides ISO timestamps for precise filtering and analysis. - - -### How Long It Took - -Duration is shown in seconds: - -``` -Duration: 1.24s -``` - -For file-level details, you can see individual file timings: - -``` -[OK] 001_create-tokens-table.sql (0.4s) -[OK] 002_add-user-column.sql (0.3s) -``` - -This helps identify slow changes or unexpected performance issues. - - -### What Files Executed - -The detail view breaks down each file: - -| Status | Icon | Meaning | -|--------|------|---------| -| Success | `[OK]` | File executed without errors | -| Failed | `[ERR]` | Execution threw an error | -| Skipped | `[-]` | An earlier file in the change failed and this one was never reached | - -A file a prior run already applied leaves no row, so it does not appear here. For skipped files, the skip reason names the failure: - -``` -[-] setup.sql - 001_tables.sql failed: table users already exists -``` - - -## Using History for Debugging - -When something goes wrong, history is your first stop, with one dialect-specific exception. On PostgreSQL a change runs inside a transaction that covers its history rows as well as its DDL, so a failed change rolls back completely and writes nothing to history. Read the error off the failing command there. MySQL, SQL Server, and SQLite keep the failed record and everything below applies. - - -### Finding the Failure - -Look for red `[ERR]` indicators in the list. The most recent failure appears near the top. - -``` - [ERR] [CHANGESET] 2025-01-16-add-payments 10 minutes ago -``` - - -### Drilling into Details - -Press Enter to see which file failed: - -``` - [OK] 001_create-table.sql (0.3s) - [ERR] 002_add-constraint.sql (0.1s) - [-] 003_add-index.sql - 002_add-constraint.sql failed: ... -``` - -The third file was never executed because the second one failed, and its skip reason names the culprit. This tells you exactly where to start looking. - - -### Reading the Error - -The error message often points directly to the problem: - -``` -Error: duplicate key value violates unique constraint "users_email_key" -``` - -Now you know the issue---there's duplicate data that needs cleaning before the constraint can be added. - - -### Checking Previous Runs - -Compare with successful runs of the same change on other environments. Did something change between runs? Did the data differ? - -```bash -noorm change history --count 100 --json | jq '.history[] | select(.name == "2025-01-16-add-payments")' -``` - - -### Recovery Workflow - -After fixing the issue: - -1. Fix the underlying problem (clean data, modify SQL, etc.) -2. Re-run the change with `--force` if needed -3. Check history to confirm success -4. Continue with `change ff` for remaining changes - +`--count` defaults to 20 here. `executedAt` is ISO UTC. The text form prints local time. `change history-detail --json` returns each operation with its file records. -## History Retention -History is stored in noorm’s change tracking tables, described in [Execution](/guide/sql-files/execution#what-gets-tracked). These tables grow with usage. +## What bites -For long-running projects, consider: -- Periodic exports for archival -- Database maintenance to manage table size -- Separate tracking databases for high-volume environments +| Symptom | Cause | Fix | +|---|---|---| +| A failed change leaves no history on PostgreSQL | One transaction rolls back the DDL and the history rows together | Read the error from the failing command ([Forward and revert](/guide/changes/forward-revert#what-happens-when-a-change-runs)) | +| An entry named `__reset__` | `db teardown` records itself, with the drop summary in `errorMessage` | None. It marks where changes went `stale` | +| `[ERR]` on a `reverted` or `stale` row in the TUI | `[OK]` means `success` only | Check `change history --json` or `change list` for the exact status | +| `change history-detail` prints nothing into a pipe | The text goes through the logger to stderr | Use `--json`, or redirect with `2>&1` | +| History from another environment is missing | Each config keeps its own history | Pass `-c ` | -The history is per-database, per-config. Each configuration maintains its own execution history. +## Next -## What's Next? -- [Forward & Revert](/guide/changes/forward-revert) - Applying and rolling back changes -- [Changes](/guide/changes/overview) - Change directory structure and file types -- [Teardown](/guide/database/teardown) - What happens to history after teardown +- [Forward and revert](/guide/changes/forward-revert): apply, roll back, and recover +- [Changes](/guide/changes/overview): folder layout and manifests +- [Teardown](/guide/database/teardown): why changes go `stale` +- [`change history`](/headless#change-history): flags and JSON diff --git a/docs/guide/changes/overview.md b/docs/guide/changes/overview.md index 18236f91..492def28 100644 --- a/docs/guide/changes/overview.md +++ b/docs/guide/changes/overview.md @@ -1,409 +1,150 @@ # Changes -Changes are versioned operations that evolve an existing database to match your ideal schema. When you need to add a column, transform data, or run SQL that should execute exactly once with rollback support, changes are the answer. +A change takes a database that already exists to the schema in `db/sql/`. noorm runs each change once per database and records the result. - -## Why "Changes", Not "Migrations" - -Most tools call these migrations. We don't, and here's why. - -**Your SQL files are the source of truth.** They define what your schema *is*—the ideal, current state. You handcraft them, review them, keep them up to date. When you need a fresh database, you run your SQL files. No archaeology through years of migration history. - -**Changes describe what you *do*, not what you *move*.** To migrate implies moving something from one place to another. Data migrations traditionally mean moving data between servers, warehouses, or systems. But when you add a column to an existing table, you're not migrating anything—you're changing the schema. - -The term "migration" originated in enterprise contexts where it actually made sense—incrementally moving schemas and data from Oracle to SQL Server, or consolidating legacy systems into a new platform. Rails borrowed this terminology in 2005 and popularized it for web applications. Everyone followed suit without questioning whether the word fit. We're not moving databases between systems. We're changing them in place. The language matters. - -**Kysely already has migrations.** If you want a traditional migration system, Kysely has one built in. But it lacks what noorm provides: - -- **Identity tracking** - Who ran what, and when? -- **Flexible workflows** - Build from scratch *or* apply changes, your choice -- **Templates and secrets** - Dynamic SQL with encrypted credentials -- **Execution history** - Full audit trail with timing and checksums -- **Revert scripts** - Rollback support when things go wrong - -Changes are your changelog. They document *how* to get an existing database to match your SQL files. Your SQL files remain the source of truth. - - -## When to Use Changes - -The distinction is simple: **can you rebuild from scratch, or do you need to evolve what's there?** - -**Fresh database?** Run `noorm run build`. This executes your `db/sql/` folder—your source of truth. Tables, views, functions, seed data. Everything needed to create a working schema from nothing. - -**Existing database with data you can't lose?** Run `noorm change ff`. This applies pending changes from your `db/changes/` folder. Each change runs once, gets recorded in the database, and won't run again unless reverted. - -Changes exist because production databases can't be rebuilt. You can't drop a table with millions of rows and recreate it. You need to `ALTER TABLE` in place, transform data carefully, add indexes without blocking writes. Changes give you: - -- **Execution tracking** - Know which changes have been applied -- **Revert scripts** - Roll back when something goes wrong -- **Ordered execution** - Date prefixes ensure consistent ordering across teams - - -## Directory Structure - -Each change lives in its own folder with a specific structure: - -``` -db/changes/ -└── 2025-01-15-add-email-verification/ - ├── change/ # Forward scripts (required) - │ ├── 001_create-tokens-table.sql - │ └── 002_add-user-column.sql - ├── revert/ # Rollback scripts (optional) - │ ├── 001_drop-user-column.sql - │ └── 002_drop-tokens-table.sql - └── changelog.md # Human-readable description -``` - -**change/** contains your forward scripts. These run in sequence order when you apply the change. - -**revert/** contains rollback scripts. These run in forward sequence order (001, then 002), just like change scripts. You design them to undo your changes in reverse—notice how `001_drop-user-column` undoes the last change and `002_drop-tokens-table` undoes the first. - -**changelog.md** is optional but recommended. Document what the change does and why. Your future self will thank you. - - -## Naming Conventions - -Change folders follow the pattern `YYYY-MM-DD-description`: - -``` -2025-01-15-add-email-verification -2025-02-01-migrate-user-roles -2025-02-14-add-search-indexes +```bash +noorm change add order-status ``` -The date prefix ensures changes apply in chronological order across your team. When Alice creates `2025-01-15-add-users` and Bob creates `2025-01-16-add-posts`, they'll apply in the right order regardless of when they merge. - -Files within `change/` and `revert/` use sequence prefixes: - ``` -001_create-table.sql -002_add-column.sql -003_update-data.sql -``` - -The three-digit prefix guarantees execution order. Names after the underscore are for humans. - - -## Creating a Change - -Scaffold a change headlessly with `noorm change add`: - -```bash -noorm change add add-email-verification -noorm change add # prompts for a description (TTY only) +Created: 2026-10-20-order-status + /home/dana/orders/db/changes/2026-10-20-order-status ``` -Or from the TUI: run `noorm ui`, press `g` from home to enter the changes screen, then `a` -and enter a name like `add-email-verification`. Either path scaffolds the same layout: - ``` -db/changes/2025-01-15-add-email-verification/ +db/changes/2026-10-20-order-status/ ├── change/ -│ └── 001_add-email-verification.sql +│ └── 001_order-status.sql ├── revert/ -│ └── 001_add-email-verification.sql +│ └── 001_order-status.sql └── changelog.md ``` -Unlike an empty folder pair, `001_add-email-verification.sql` is a real, runnable stub — one -comment line naming what belongs there ("Add the SQL statements that apply this change" in -`change/`, "...undo this change" in `revert/`). Replace the comment with your SQL. A comment -alone isn't enough to run: `change run` rejects a changeset whose files contain only -comments or template placeholders, with a message that tells you to edit the files — not the -misleading "change not found" a genuinely empty folder pair used to produce. - -For a multi-statement change, add more files by hand following the same `NNN_description` -convention (see [Naming Conventions](#naming-conventions) above) — `002_`, `003_`, and so on. -Files run in filename order (a plain string sort, so keep sequence numbers zero-padded to -three digits or the sort stops being numeric past `009_`). - -Once the directory exists you can apply, revert, and inspect the change from the headless -CLI (`noorm change ff`, `noorm change run`, `noorm change revert`, `noorm change history`). +Each `.sql` file starts as one comment line. Replace it with SQL, as [Add a column](/guide/changes/add-a-column) does for this change. In the TUI, press `g` from home, then `a`. -## Write Idempotent DDL +## What is in a change folder? -Always check for existence before making changes. Use `IF NOT EXISTS` when creating, `IF EXISTS` when dropping. This protects you when: -- A change accidentally runs twice -- Someone executes the file directly outside of noorm -- You need to re-run after a partial failure +| Path | Holds | Read by | +|---|---|---| +| `change/` | forward scripts | `change run`, `ff`, `next` | +| `revert/` | rollback scripts | `change revert`, `rewind` | +| `changelog.md` | why the change exists | people. noorm records only that it exists | -```sql --- change/001_add-verification-column.sql (forward) -ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN DEFAULT FALSE; +Files in `change/` and `revert/` run by extension: --- revert/001_drop-verification-column.sql (rollback) -ALTER TABLE users DROP COLUMN IF EXISTS email_verified; -``` - -```sql --- change/001_create-tokens-table.sql (forward) -CREATE TABLE IF NOT EXISTS verification_tokens ( - id SERIAL PRIMARY KEY, - user_id INTEGER REFERENCES users(id), - token TEXT NOT NULL, - expires_at TIMESTAMP NOT NULL -); - --- revert/001_drop-tokens-table.sql (rollback) -DROP TABLE IF EXISTS verification_tokens; -``` - -This applies to both forward and revert scripts. A revert that fails because "column doesn't exist" is just as broken as a change that fails because "column already exists." - - -## File Types - -Changes support three file types: +| Extension | Runs | +|---|---| +| `.sql` | as written | +| `.sql.tmpl` | after Eta renders it ([Templates](/guide/sql-files/templates)) | +| `.txt` | a manifest: files from `db/sql/`, in place | -### SQL Files (.sql) +Other files are not run, though a template still reads the data files beside it. A folder with neither `change/` nor `revert/` is not a change, so shared fragments and archives can sit beside the real ones. -Raw SQL executed directly. The most common type. -```sql --- 001_add-verification-column.sql -ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN DEFAULT FALSE; -``` - -### Templates (.sql.tmpl) +## How are changes and files ordered? -Dynamic SQL processed through the Eta template engine before execution. Useful when you need conditional logic or variable substitution. See [Templates](/guide/sql-files/templates) for full syntax reference. -```sql --- 001_add-audit-table.sql.tmpl -CREATE TABLE {%~ $.config.schema %}.audit_log ( - id SERIAL PRIMARY KEY, - action TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); -``` +noorm sorts by name at both levels, so the name is the order. -### Manifests (.txt) +| Level | Pattern | Example | +|---|---|---| +| Change folder | `YYYY-MM-DD-slug` | `2026-10-20-order-status` | +| File | `NNN_slug.ext` | `001_order-status.sql` | -Reference existing schema files instead of duplicating SQL. The manifest lists paths relative to your schema directory. +- `change add` builds the folder name from today's date and a slug of your description. +- Pad file numbers to three digits. Names sort as text, so `10_` comes before `9_`. +- `revert/` runs in the same filename order as `change/`. Number its files in the order the undo has to happen. -Many database objects use `CREATE OR REPLACE`—views, functions, procedures, triggers. They're already idempotent. When you change a table, you often need to recreate dependent views or functions. Instead of copying that SQL into your change folder, reference the source of truth: +`invoice_line` references `invoice`, so the revert drops the child first: -```txt -# change/001_rebuild-views.txt -views/user_summary.sql -views/active_users.sql -functions/get_user_stats.sql ``` - -```sql --- change/002_add-status-column.sql -ALTER TABLE users ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'active'; +change/ revert/ +├── 001_invoice.sql ├── 001_invoice-line.sql +└── 002_invoice-line.sql └── 002_invoice.sql ``` -Manifest files participate in ordering. Here, `001_rebuild-views.txt` runs first (recreating views that might depend on the old schema), then `002_add-status-column.sql` adds the column. You could also flip this—add the column first with `001_`, then rebuild views with `002_` so they pick up the new column. -This keeps your `db/sql/` folder as the single source of truth. Views and functions are defined once, referenced wherever needed. +## How does a change reuse a view or function from db/sql/? -The reference is resolved every time the change is read. Delete or rename a file a manifest lists and that change can no longer be read: `noorm change ff` stops at it if it has not run, and `noorm change list` shows it as `failed` with the missing path. A change that already ran keeps its status, and `ff` only warns. A manifest also runs the file's current content, so list only objects that are safe to run repeatedly, never a `CREATE TABLE` file. - -## Checking Change Status - -From the TUI, the changes screen shows status for each change: +A `.txt` manifest lists files in `db/sql/`. noorm runs them in place, so the view is defined once and the change never copies its SQL: ``` -┌─ Changes ─────────────────────────────────────────────┐ -│ • 2025-01-15-add-email-verification success │ -│ 2025-02-01-migrate-user-roles pending │ -│ 2025-02-14-add-search-indexes pending │ -└───────────────────────────────────────────────────────┘ +# db/changes/2026-10-22-open-orders/change/001_rebuild-views.txt +02_views/01_open_orders.sql +02_views/02_unpaid_invoices.sql ``` -Each change has a status: - -| Status | Meaning | -|--------|---------| -| `pending` | Not yet applied | -| `success` | Applied successfully | -| `failed` | Last execution failed | -| `reverted` | Was applied, then rolled back | -| `stale` | Was applied, then `db teardown` dropped the objects it created | +A manifest sorts among the other files of its folder. Use that to drop a dependent view, alter the table, then rebuild the view: -`pending`, `reverted`, and `stale` all count as outstanding work, so `change ff` and -`change next` pick up all three. - -From the headless CLI, `noorm change list` prints every known change with its status. Bare `noorm change` renders help -- it does not connect to the database. - -```bash -noorm change list # Human-friendly table -noorm change list --json # Same data as JSON ``` - -```json -{ - "success": true, - "changes": [ - { - "name": "2025-01-15-add-email-verification", - "status": "success", - "appliedAt": "2025-01-15T14:30:00.000Z", - "appliedBy": "Alice ", - "revertedAt": null, - "errorMessage": null, - "isNew": false, - "orphaned": false - } - ], - "pending": 1 -} -``` - -Each entry merges the database record above with the on-disk one, so it also carries -`path`, `date`, `description`, `changeFiles`, `revertFiles`, and `hasChangelog`. An entry -flagged `orphaned` exists in the change tracking tables but no longer on disk, so those -disk fields are absent. - -When you omit the change name on a TTY, several subcommands open an interactive picker. -`run`, `revert`, `rewind`, and `history-detail` query the database and filter to the -relevant subset: outstanding changes for `run`, successfully applied ones for `revert` and -`rewind`, anything with a history record for `history-detail`. `edit` and `rm` work offline -and list every folder in `db/changes/`. Non-TTY callers must supply the name. - - -## Common Workflows - - -### Apply a Single Change - -Run a specific change by name: - -```bash -noorm change run 2025-02-01-migrate-user-roles +change/ +├── 001_drop-unpaid-invoices.sql -- DROP VIEW IF EXISTS unpaid_invoices; +├── 002_widen-amount.sql -- ALTER TABLE invoice_line ALTER COLUMN amount TYPE numeric(14,2); +└── 003_rebuild-views.txt 02_views/02_unpaid_invoices.sql ``` -From the TUI, select the change and press enter. +| Rule | Detail | +|---|---| +| Paths | one per line, relative to `paths.sql`: `02_views/01_open_orders.sql`, not `db/sql/02_views/...` | +| Ignored lines | blank lines and lines starting with `#` | +| Order | lines run in file order. A path listed twice in one change runs once, at its first position | +| Content | the file as it is at run time, not as it was when you wrote the change | +| Revert | a manifest in `revert/` rebuilds the current definition. To restore an older one, write its `CREATE OR REPLACE` inline | +A manifest is resolved every time noorm reads the change, so one missing path makes the whole change unreadable: -### Apply All Pending Changes - -Fast-forward applies all pending changes in order: - -```bash -noorm change ff +```mermaid +flowchart TD + M["001_rebuild-views.txt"] --> E{"every listed path
exists in db/sql/?"} + E -->|yes| R["runs each file's
current content"] + E -->|no| U["change is unreadable"] + U --> L["change list shows
failed with the path"] + U --> F["ff and next stop here
unless it already ran"] ``` -This is what you typically run in CI/CD after deployment. +[Forward and revert](/guide/changes/forward-revert#what-happens-when-a-change-folder-cannot-be-read) covers what `ff` does with it. An empty manifest, or one with only comments, is unreadable too. -### Apply the Next Pending Change +## How do I see what is pending? -Run just the next change in sequence: ```bash -noorm change next +noorm change list ``` -Useful when you want to apply changes one at a time and verify each. - - -### Revert a Change - -Roll back a previously applied change: - -```bash -noorm change revert 2025-02-01-migrate-user-roles ``` - -This runs the scripts in `revert/` in sequence order. The change status becomes `reverted`, which means it will run again on the next fast-forward. - - -### Rewind Multiple Changes - -Revert the last N applied changes: - -```bash -noorm change rewind 3 +2026-10-19-sales-order (success) +2026-10-20-order-status (reverted) +2026-10-21-invoice (failed): 002_invoice-line.sql: [Err 1] near "(": syntax error +2026-10-23-payment (pending) +1 pending change(s) ``` -This reverts the three most recently applied changes, starting with the newest. Pass a -change name instead of a count to rewind back to and including that change: - -```bash -noorm change rewind 2025-01-15-add-email-verification -``` - - -### Preview Before Running - -`--dry-run` is the preview: it renders every file, templates and manifests included, and -writes the result under `tmp/` instead of executing it. See [Dry Run](#dry-run) below. - -To render a single template file to stdout without involving a change at all, use -`noorm run preview`: - -```bash -noorm run preview db/sql/views/user_summary.sql.tmpl -``` - - -### Dry Run - -Render files to a temporary directory without executing. Available on every apply/revert command: `change run`, `change revert`, `change ff`, `change next`, and `change rewind`: - - noorm change run 2025-02-01-migrate-user-roles --dry-run - noorm change ff --dry-run - -Files are written to `tmp/` so you can inspect them. A dry run never writes to the change -tracking tables: it skips the "does this need to run?" lookup, takes no lock, and creates -no history record, which makes it safe to run repeatedly in production. `ff`, `next`, and -`rewind` still read the tables to work out which changes to render. The CLI tags human -output with `(dry-run)` and adds `dryRun: true` to the JSON payload of `run`, `revert`, -`ff`, and `next` so CI pipelines can verify the result didn't touch the database. -`rewind --json` does not add the flag. - - -## When Changes Re-Run - -noorm tracks whether each change needs to run based on several conditions: - -| Condition | What Happens | -|-----------|--------------| -| New change folder | Runs | -| Files modified since last run | Runs | -| Previous execution failed | Runs | -| Change was reverted | Runs | -| Database was torn down | Runs (marked stale) | -| `--force` flag provided | Runs | -| Successfully applied, unchanged | Skipped | - -After a successful run, the change won't run again unless something changes. This is tracked via a combined checksum of every file in `change/` or `revert/` plus the schema files any `.txt` manifest references, so editing a referenced view or function re-triggers the change. - -::: tip Force Re-Run -Need to re-apply a change? Use `--force`: -```bash -noorm change run my-change --force -``` -::: - - -## Best Practices - -**One logical change per folder.** Don't bundle unrelated changes. If you're adding users and adding products, make two separate changes. - -**Always include revert scripts.** Even if you think you'll never need them. Recovery is worth the extra files. +Each line is `name (status)`. A change recorded in the database whose folder is gone shows `(status, orphaned)`. `--json` adds `appliedAt`, `appliedBy`, `revertedAt`, `errorMessage`, `isNew`, and `orphaned` to each entry. [Forward and revert](/guide/changes/forward-revert#which-statuses-can-a-change-have) defines the five statuses. -**Test reverts in development.** Run `revert` then `change` to verify rollback works before deploying. -**Verify with explore and terminal.** noorm includes a database explorer (`d` → `e` from home) and SQL terminal (`d` → `t`) so you can inspect your schema and query data without leaving the tool. After applying a change, explore the table to confirm the column exists. After reverting, run a quick SELECT to verify it's gone. Don't trust—verify. +## What bites -**Use manifests for shared SQL.** Reference existing schema files with `.txt` manifests instead of duplicating SQL. -**Preview before production.** Use `--dry-run` to render everything to `tmp/` and inspect exactly what will execute. +| Symptom | Cause | Fix | +|---|---|---| +| `change run` fails: files are empty or contain only template placeholders | The scaffolded stub is a comment, and a change needs real SQL | Replace the comment with SQL | +| A folder in `db/changes/` never appears in `change list` | It has neither `change/` nor `revert/` | Add `change/` with at least one file | +| `change list` shows `failed` with `references missing file` | A manifest names a file that `db/sql/` no longer has. Deleting or renaming a `db/sql/` file breaks every unapplied change that lists it | Restore the file, or fix the manifest line | +| You edited an applied change and `change ff` does nothing | `ff` skips applied changes, so databases that ran it keep the old body | Add a new change. `change run ` re-executes the edit on the one database you point it at | +| `change ff` fails on a database built from `db/sql/` | A build records no changes, so `ff` replays all of them | Write each statement to be safe to run twice ([Add a column](/guide/changes/add-a-column)) | +| A manifest-listed table file fails on replay: already exists | A manifest runs the file's current content, and `CREATE TABLE` fails on an existing table | List only `CREATE OR REPLACE` objects: views, functions, procedures, triggers | +| `1 pending change(s)` is fewer than `ff` will run | The footer counts `pending` only. `ff` also runs `reverted` and `stale` | Read each status in the list | -**Write meaningful changelog.md files.** Explain *why* the change exists, not just what it does. Future debugging sessions will benefit. +## Next -## What's Next? -- [Forward & Revert](/guide/changes/forward-revert) - Applying and rolling back changes -- [History](/guide/changes/history) - Tracking execution history -- [Templates](/guide/sql-files/templates) - Using `.sql.tmpl` files in changes +- [Forward and revert](/guide/changes/forward-revert): apply and roll back +- [History](/guide/changes/history): what each run recorded +- [Add a column](/guide/changes/add-a-column): one edit, both folders +- [Templates](/guide/sql-files/templates): `.sql.tmpl` files in changes diff --git a/docs/guide/database/create.md b/docs/guide/database/create.md index a59091d0..7f455bfb 100644 --- a/docs/guide/database/create.md +++ b/docs/guide/database/create.md @@ -1,169 +1,108 @@ # Creating a database -`noorm db create` provisions the database named in a stored config. It -has no `--name`, `--dialect`, or `--host` flag — by design. +Create the database a config points at, and set it up for noorm, before the first build. -The reasoning is short: a noorm config is the single source of truth -for *which* database, *where*, *as whom*, and *with what guardrails*. -Inline flags would duplicate that record and create two ways to -describe the same target. The CLI sidesteps that by requiring you to -declare the config first, then create against it. +`db create` takes no `--name`, `--dialect`, or `--host`. The config holds the connection, so write it first. This `dev-config.json` targets local PostgreSQL: -This page covers the intended order of operations and the cross-tool -discovery hints that get users back on track when their muscle memory -expects a `--name` flag. +```json +{ + "name": "dev", + "connection": { + "dialect": "postgres", + "host": "localhost", + "database": "order_to_cash_dev", + "user": "postgres", + "password": "postgres" + } +} +``` +```bash +noorm config import dev-config.json +noorm db create -c dev +noorm config use dev +``` -## The configs-first workflow +``` +Imported config 'dev'. +Database "order_to_cash_dev" created and initialized. +Active config set to: dev +``` -End-to-end, from a clean repo to a usable database: +Run `noorm init` before these and `noorm run build` after. In the TUI, press `d` on Home, then `c`. - # 1. Scaffold the project (settings.yml, .noorm/, db/sql/, db/changes/). - noorm init --yes - # 2. Add one config per environment, non-interactively. - noorm config import dev-config.json - noorm config import test-config.json +## What does it do? - # 3. Create the underlying database for each config. - noorm db create -c dev - noorm db create -c test - # 4. Pick the active config for ad-hoc commands. - noorm config use dev +`db create` checks the role first, then creates only what is missing: -After step 4, every `noorm` command that doesn't pass `-c ` -inherits the active config. `noorm run build`, `noorm change ff`, and -`noorm sql query "…"` all target the `dev` database until you switch. +```mermaid +flowchart TD + G{"db:create allowed
for this role?"} -->|no| D["exit 1
nothing touched"] + G -->|yes| S{"database exists?"} + S -->|no| C["CREATE DATABASE"] + S -->|yes| T{"tracking tables
exist?"} + C --> B["create tracking tables"] + T -->|no| B + T -->|yes| N["alreadyExists: true
exit 0"] + B --> R["Database created
and initialized"] +``` +`CREATE DATABASE` runs over a connection that does not need the target to exist: -## What `noorm db create` does (and what it needs) +| Dialect | Connects to | +|---|---| +| PostgreSQL | the `postgres` database | +| MSSQL | the `master` database | +| MySQL | no database selected | +| SQLite | nothing; it checks that the folder is writable | -The command connects to the dialect's *system database* -(`postgres` for PostgreSQL, `master` for MSSQL; MySQL connects with no -database named at all) with the credentials in the named config, then -runs the dialect's `CREATE DATABASE` statement targeted at -`config.connection.database`. SQLite has no system database to borrow, -so noorm checks that the directory holding the file is writable -instead. That probe leaves nothing behind. +The tracking tables are `version`, `change`, `executions`, `lock`, `identities`, and `vault`. PostgreSQL and MSSQL keep them in a `noorm` schema. MySQL and SQLite name them `__noorm_
__`. -Creating the database is only half the job. `db create` then connects -to the new database and bootstraps noorm's own tracking tables -(`version`, `change`, `executions`, `lock`, `identities`, `vault`), so -the config is ready for `run build` and `change ff` when the command -returns. Running it against a database that already exists and is -already initialized is a no-op that reports `alreadyExists: true`. +`viewer` is refused, `operator` needs `--yes`, and `admin` runs. A new config gets `user: admin` and `agent: viewer`. [Configs](/guide/environments/configs#access-roles) has the full matrix. -It needs: -- An active config (or an explicit `-c `). -- Credentials in the config that can reach the server. -- A server-level permission to create databases (e.g. `CREATEDB` on - PostgreSQL, `dbcreator` role on MSSQL). -- An access role that permits `db:create`. A `viewer` config is denied - outright; an `operator` config needs `--yes` (or `NOORM_YES=1`) to - satisfy the confirmation; an `admin` config runs unconfirmed. +## How do I bootstrap CI? -It does not need: -- The target database to already exist — that's the whole point. -- A `.noorm/` schema to be built — `noorm run build` is a separate - step. +A CI checkout already has the committed `.noorm/`, so skip `init`. With no stored config, the `NOORM_CONNECTION_*` variables define the connection: -The access check runs before any probe touches the server. That -ordering matters on SQLite, where merely opening the target creates the -file: checking afterwards would hand a denied role a database anyway. +```bash +# ci/bootstrap.sh +set -euo pipefail +export NOORM_CONNECTION_DIALECT=postgres +export NOORM_CONNECTION_HOST=localhost +export NOORM_CONNECTION_DATABASE=order_to_cash_ci +export NOORM_CONNECTION_USER=ci +export NOORM_CONNECTION_PASSWORD="$DB_PASSWORD" -## Why no `--name ` flag? +noorm db create +noorm run build +``` -Three reasons, in order of importance: +[CI](/guide/automation/ci) covers runners that need an identity or the vault. -1. **Configs already hold the database name.** The connection block - in a noorm config carries `dialect`, `host`, `port`, `database`, - `user`, `password`, and a handful of safety flags (`isTest`, - `access`). Passing `--name foo` would let you bypass the config's - `access` role and create a database the active config doesn't - know about — a foot-gun. -2. **The config is also where stage defaults, secrets, and identity - bindings live.** A flag-only path would need its own copy of every - one of those, or it would inherit silently from the active config - and surprise the user. +## What bites -3. **It pushes you to write the config down.** `noorm config import` - is non-interactive and idempotent; the JSON file becomes the - reviewable artifact in your repo or CI runner. -If you genuinely need ad-hoc database creation without a stored -config, fall back to a one-shot SQL statement issued through an -existing one: +| Symptom | Cause | Fix | +|---|---|---| +| `db create --name foo` creates nothing named `foo` | Unknown flags are ignored, so it used the config's database | Set `connection.database` in the config | +| `"db:create" is not allowed on config "dev" (role: viewer).` | The role is `viewer`, or the process is on the `agent` channel, which defaults to `viewer` | Raise the role, or see [channels](/guide/environments/configs#which-channel-is-a-process-on) | +| `This is a destructive operation requiring confirmation (yes-dev). Pass --yes to confirm.` | The role is `operator` | Pass `--yes` or set `NOORM_YES=1` | +| `Failed to create database: permission denied to create database` | The login lacks server-level rights | Grant `CREATEDB` on PostgreSQL or `dbcreator` on SQL Server | +| `Cannot connect to server: …` | Wrong host, port, or credentials | Fix the JSON, then `config import --force --yes` | +| MySQL: `Cannot connect to server: Database 'order_to_cash_dev' does not exist on …` | MySQL has no system database for the existence check to fall back to | Create the empty database with another client, then run `db create` | - noorm sql query -c dev "CREATE DATABASE foo" -That forces you to pick a config, and therefore a connection identity, -for the operation. Two caveats: the statement runs against the `dev` -database rather than the system database, so `dev` itself has to exist -and be reachable; and `CREATE DATABASE` classifies as `sql:ddl`, which -only an `admin`-role config may run. The SQL is passed through -verbatim, so it is on you to write it for the right dialect. +## Next -## Companion commands - -| Command | Purpose | -|---------|---------| -| `noorm config import ` | Add a config from a JSON file (non-interactive). | -| `noorm config list` | See which configs exist and which is active. | -| `noorm config use ` | Switch the active config. | -| `noorm db create [-c ]` | Create the database described by the named config. | -| `noorm db drop [-c ]` | Drop the database described by the named config. | - -`db drop` is gated harder than `db create`. Its `db:destroy` permission -denies both `viewer` and `operator`, and an `admin` config still has to -confirm, so dropping a database always takes `--yes` (or `NOORM_YES=1`). -A config using the default access role is `user: admin`, which is why -`db create` needs no flag on a fresh project. - -For interactive setup (when you're at a terminal), `noorm ui` launches -a wizard that walks through identity → config → DB create in order. -Scripted setups should stick with the `import` + `create` flow above. - - -## CI / non-interactive bootstrap - -The same flow inside a CI runner — every step has a non-interactive -path: - - #!/usr/bin/env bash - set -euo pipefail - - # 1. Identity (once per runner). - noorm identity init --name "$CI_USER" --email "$CI_EMAIL" - - # 2. Project scaffold. - noorm init --yes - - # 3. Configs from version-controlled JSON. - noorm config import ./ci/dev.config.json - noorm config import ./ci/test.config.json - - # 4. Databases. - noorm db create -c dev - noorm db create -c test - - # 5. Schema + changes. - noorm config use dev - noorm run build - noorm change ff - -See [Non-interactive operation](../automation/non-interactive.md) for -the full matrix of `--yes` / `NOORM_YES` behavior. - - -## Related - -- [`noorm init`](../../cli/init.md) — project scaffold and identity bootstrap. -- [Non-interactive operation](../automation/non-interactive.md) — `--yes` opt-in and CI flow. -- [Troubleshooting](../troubleshooting.md) — what to do when `noorm db create --name foo` doesn't behave as expected. +- [First build](/getting-started/first-build): build `db/sql/` into it +- [Configs](/guide/environments/configs): roles and channels +- [Non-interactive operation](/guide/automation/non-interactive): `--yes` per command +- [`db create` reference](/headless#db-create): flags and `--json` output diff --git a/docs/guide/database/explore.md b/docs/guide/database/explore.md index 797639f2..6ddfe48d 100644 --- a/docs/guide/database/explore.md +++ b/docs/guide/database/explore.md @@ -1,521 +1,129 @@ # Schema Explorer -The Schema Explorer gives you read-only access to your database structure directly from the terminal. No need to switch to pgAdmin, MySQL Workbench, or Azure Data Studio. Browse tables, views, indexes, foreign keys, functions, procedures, types, and triggers without leaving your workflow. +Read your database's structure, from tables to triggers, without opening a second client. - -## What It Shows - -The explorer queries your database's system catalogs to reveal its structure: - -| Category | Information | -|----------|-------------| -| Tables | Columns, data types, row estimates, indexes, foreign keys | -| Views | Columns, definition SQL, whether it is updatable | -| Indexes | Table, columns, unique/primary status | -| Foreign Keys | Source and target tables, column mappings, cascade rules | -| Functions | Parameters, return type, definition | -| Procedures | Parameters, definition | -| Types | Enum values, composite attributes, domain base types | -| Triggers | Table, timing (BEFORE/AFTER/INSTEAD OF), events, definition | - -Two more categories, **locks** and **connections**, describe runtime state rather than schema. They are counted in the `--json` overview and reachable from the SDK, but they have no CLI listing and no TUI screen. - -Which catalogs each dialect reads: - -| Dialect | Catalogs | -|---------|----------| -| PostgreSQL | `information_schema` for tables, columns, views, FKs, and parameters; `pg_catalog` (`pg_proc`, `pg_type`, `pg_enum`, `pg_indexes`, `pg_locks`, `pg_stat_activity`) for everything it cannot express | -| MySQL | `information_schema` throughout, plus `performance_schema.metadata_locks` for locks | -| MSSQL | `sys.*` catalog views (`sys.tables`, `sys.views`, `sys.procedures`, `sys.types`, `sys.indexes`, `sys.foreign_keys`, `sys.triggers`) | -| SQLite | `sqlite_master` for object names and DDL, `PRAGMA table_info` / `index_info` / `foreign_key_list` for the rest | - -noorm's own tracking objects are filtered out: PostgreSQL and MSSQL exclude the `noorm` schema in the catalog query, MySQL and SQLite drop names starting with `__noorm_`. Set the log level to `verbose` to include them. - - -## Accessing the Explorer - - -### Terminal UI - -From the home screen, press `d` to enter the Database menu, then `x` to explore: - -``` -Home → [d] Database → [x] Explore -``` - -The Database menu: - -``` -┌─ Database ──────────────────────────────────────────────────┐ -│ │ -│ [c] create Create the database for this config │ -│ [d] destroy Drop the database for this config │ -│ [x] explore Browse tables, views, indexes │ -│ [w] wipe Truncate all data (keep schema) │ -│ [t] teardown Drop all objects │ -│ [r] transfer Move data between configs │ -│ │ -└─────────────────────────────────────────────────────────────┘ -``` - -The SQL terminal is not on this menu. Press `Shift+Q` from anywhere, or run `noorm sql repl`. See [SQL Terminal](/guide/database/terminal). - - -### Scripted Usage - -The same subcommands run non-interactively — add `--json` to get machine-readable output: +Counts first, then one table: ```bash -noorm db explore # Overview -noorm db explore tables # List tables -noorm db explore tables detail users # Table detail -noorm db explore fks # List foreign keys -noorm db explore triggers # List triggers -noorm db explore tables --schema app # Restrict to one schema -noorm db explore --json > schema.json # JSON output -``` - -The subcommands are `tables`, `views`, `procedures`, `functions`, `types`, `indexes`, `fks`, and `triggers`. Every one accepts `--schema` except on SQLite, which has no schemas and rejects the flag rather than returning an empty list. `tables`, `views`, `procedures`, `functions`, `types`, and `triggers` also take a `detail ` subcommand. - - -## Overview Screen - -The overview shows counts of all database objects at a glance: - -``` -┌─ DB Explore ────────────────────────────────────────────────┐ -│ │ -│ Config: dev (postgres) │ -│ Database: myapp_development │ -│ Total Objects: 47 │ -│ │ -│ [1] Tables 12 │ -│ [2] Views 3 │ -│ [3] Procedures 0 │ -│ [4] Functions 5 │ -│ [5] Types 2 │ -│ [6] Indexes 18 │ -│ [7] Foreign Keys 7 │ -│ │ -└─────────────────────────────────────────────────────────────┘ - [1-7] Navigate [Esc] Back -``` - -Press the number key or hotkey to drill into any category. Categories with zero items appear dimmed and cannot be selected. - -| Key | Hotkey | Category | -|-----|--------|----------| -| `1` | `t` | Tables | -| `2` | `v` | Views | -| `3` | `p` | Procedures | -| `4` | `f` | Functions | -| `5` | `y` | Types | -| `6` | `i` | Indexes | -| `7` | `k` | Foreign Keys | - -The TUI overview lists these seven. `noorm db explore` on the command line prints a Triggers count as well, and `--json` carries all ten counters including locks and connections. The Total Objects figure sums every counter the overview computed, so it can exceed the seven rows on screen when triggers, locks, or connections are present. - - -## Browsing Tables - -Press `1` or `t` from the overview to see all tables: - -``` -┌─ Tables (12) ───────────────────────────────────────────────┐ -│ │ -│ / Filter tables... │ -│ │ -│ 1. > public.users 8 columns, ~1.5K rows │ -│ 2. public.posts 5 columns, ~42K rows │ -│ 3. public.comments 4 columns, ~128K rows │ -│ 4. public.notifications 6 columns, ~8.9K rows │ -│ 5. public.sessions 3 columns, ~2.1K rows │ -│ │ -│ ... 7 more │ -│ │ -└─────────────────────────────────────────────────────────────┘ - [Enter] View detail [Esc] Back -``` - -The list is searchable. Press `/` to focus the filter field and type to narrow results. Press `1-9` for quick selection. - - -### Table Detail - -Select a table to see its full schema: - -``` -┌─ Table ─────────────────────────────────────────────────────┐ -│ │ -│ public.users ~1,500 rows │ -│ │ -│ Columns (8) │ -│ ───────────────────────────────────────────────────────── │ -│ * id uuid NOT NULL │ -│ email varchar(255) NOT NULL │ -│ name varchar(100) NULL │ -│ password_hash text NOT NULL │ -│ role user_role NOT NULL DEFAULT 'user' │ -│ active boolean NOT NULL DEFAULT true │ -│ created_at timestamptz NOT NULL DEFAULT now() │ -│ updated_at timestamptz NULL │ -│ │ -│ Indexes (2) │ -│ ───────────────────────────────────────────────────────── │ -│ * users_pkey (id) │ -│ users_email_idx (email) UNIQUE │ -│ │ -│ Foreign Keys (0) │ -│ │ -└─────────────────────────────────────────────────────────────┘ - [Esc] Back -``` - -The `*` marker indicates primary key columns and primary key indexes. - - -## Browsing Views - -Press `2` or `v` from the overview: - -``` -┌─ Views (3) ─────────────────────────────────────────────────┐ -│ │ -│ 1. > public.active_users 4 columns, READ-ONLY │ -│ 2. public.post_stats 6 columns, READ-ONLY │ -│ 3. public.user_activity 8 columns, UPDATABLE │ -│ │ -└─────────────────────────────────────────────────────────────┘ - [Enter] View detail [Esc] Back -``` - - -### View Detail - -``` -┌─ View ──────────────────────────────────────────────────────┐ -│ │ -│ public.active_users READ-ONLY │ -│ │ -│ Columns (4) │ -│ ───────────────────────────────────────────────────────── │ -│ id uuid NOT NULL │ -│ email varchar(255) NOT NULL │ -│ name varchar(100) NULL │ -│ created_at timestamptz NOT NULL │ -│ │ -│ Definition │ -│ ───────────────────────────────────────────────────────── │ -│ SELECT id, email, name, created_at │ -│ FROM users │ -│ WHERE active = true │ -│ │ -└─────────────────────────────────────────────────────────────┘ - [Esc] Back -``` - - -## Browsing Indexes and Foreign Keys - -Indexes and foreign keys are list-only categories. They show all the information inline without a separate detail screen. - - -### Indexes - -``` -┌─ Indexes (18) ──────────────────────────────────────────────┐ -│ │ -│ 1. users_pkey on users, PRIMARY │ -│ 2. users_email_idx on users, UNIQUE │ -│ 3. posts_pkey on posts, PRIMARY │ -│ 4. posts_user_id_idx on posts │ -│ 5. posts_created_at_idx on posts │ -│ 6. comments_pkey on comments, PRIMARY │ -│ │ -│ ... 12 more │ -│ │ -└─────────────────────────────────────────────────────────────┘ - [Esc] Back -``` - - -### Foreign Keys - +noorm db explore +noorm db explore tables detail sales_order ``` -┌─ Foreign Keys (7) ──────────────────────────────────────────┐ -│ │ -│ 1. posts_user_id_fkey │ -│ posts(user_id) → users(id) ON DELETE CASCADE │ -│ │ -│ 2. comments_post_id_fkey │ -│ comments(post_id) → posts(id) ON DELETE CASCADE │ -│ │ -│ 3. comments_user_id_fkey │ -│ comments(user_id) → users(id) ON DELETE SET NULL │ -│ │ -└─────────────────────────────────────────────────────────────┘ - [Esc] Back -``` - - -## Browsing Functions and Procedures - -Functions and procedures show parameters, return types, and definitions. - - -### Functions ``` -┌─ Functions (5) ─────────────────────────────────────────────┐ -│ │ -│ 1. > public.calculate_total 2 params → numeric │ -│ 2. public.get_user_posts 1 param → SETOF posts │ -│ 3. public.update_timestamp 0 params → trigger │ -│ │ -└─────────────────────────────────────────────────────────────┘ - [Enter] View detail [Esc] Back +Database Overview + Tables: 11 + Views: 1 + Functions: 1 + Procedures: 0 + Types: 0 + Indexes: 14 + Foreign Keys: 12 + Triggers: 0 ``` - -### Function Detail - ``` -┌─ Function ──────────────────────────────────────────────────┐ -│ │ -│ public.calculate_total → numeric │ -│ │ -│ Parameters (2) │ -│ ───────────────────────────────────────────────────────── │ -│ price numeric IN │ -│ quantity integer IN │ -│ │ -│ Definition │ -│ ───────────────────────────────────────────────────────── │ -│ BEGIN │ -│ RETURN price * quantity; │ -│ END; │ -│ │ -└─────────────────────────────────────────────────────────────┘ - [Esc] Back +• log Table: sales_order +• log party_id: integer (not null) [PK] +• log order_no: integer (not null) [PK] +• log placed_at: timestamp with time zone (not null) +• log status: text (not null) ``` +The explorer is read-only and every role may use it. The TUI path is Home, `d` for Database, `x` for Explore. -### Procedures - -Procedures work the same way as functions, but without a return type. +## What can I look at? -## Browsing Types -Custom types are available in PostgreSQL and MSSQL. The explorer shows enum values, composite type attributes, and domain/alias base types. +| Category | List | One object | TUI keys | Empty on | +|---|---|---|---|---| +| Tables | `tables` | `tables detail ` | `1` `t` | | +| Views | `views` | `views ` | `2` `v` | | +| Procedures | `procedures` | `procedures ` | `3` `p` | SQLite | +| Functions | `functions` | `functions ` | `4` `f` | SQLite | +| Types | `types` | `types ` | `5` `y` | MySQL, SQLite | +| Indexes | `indexes` | | `6` `i` | | +| Foreign keys | `fks` | | `7` `k` | | +| Triggers | `triggers` | `triggers ` | | | -``` -┌─ Types (2) ─────────────────────────────────────────────────┐ -│ │ -│ 1. > public.user_role ENUM, 3 values │ -│ 2. public.address COMPOSITE, 5 attributes │ -│ │ -└─────────────────────────────────────────────────────────────┘ - [Enter] View detail [Esc] Back -``` +Every list and detail command takes `--schema `. SQLite has no schemas and rejects the flag. +Types are enums, composites, and domains on PostgreSQL, and alias and table types on MSSQL. Triggers have no TUI screen. Locks and connections are runtime state, so they appear only as counters in the `--json` overview. -### Enum Type Detail -``` -┌─ Type ──────────────────────────────────────────────────────┐ -│ │ -│ public.user_role ENUM │ -│ │ -│ Values (3) │ -│ ───────────────────────────────────────────────────────── │ -│ user │ -│ moderator │ -│ admin │ -│ │ -└─────────────────────────────────────────────────────────────┘ - [Esc] Back -``` +## How do I move around the TUI? -### Composite Type Detail +Indexes and foreign keys end at the list. The other categories continue to a detail screen: -``` -┌─ Type ──────────────────────────────────────────────────────┐ -│ │ -│ public.address COMPOSITE │ -│ │ -│ Attributes (5) │ -│ ───────────────────────────────────────────────────────── │ -│ street varchar(200) NOT NULL │ -│ city varchar(100) NOT NULL │ -│ state varchar(50) NULL │ -│ postal_code varchar(20) NOT NULL │ -│ country varchar(100) NOT NULL │ -│ │ -└─────────────────────────────────────────────────────────────┘ - [Esc] Back +```mermaid +flowchart TD + DB["Database screen
x"] --> OV["Overview
counts per category"] + OV -->|"1-7 or t v p f y i k"| LS["List
filter with /"] + LS -->|Enter| DT["Detail
columns, parameters,
definition"] + DT -->|v| FT["Full text
nothing truncated"] + DT -->|r| RW["Rows
first and last"] ``` +| Screen | Keys | +|---|---| +| Overview | A category with a zero count is dimmed and cannot be opened. `Esc` goes back | +| List | `/` filters, `1`-`9` picks a row, `Enter` opens it. The filter text is kept until you leave the explorer | +| Detail | `↑` `↓` scroll, `Ctrl+U` `Ctrl+D` half a page, `PageUp` `PageDown` a page, `Home` `End` the ends | +| Rows | `Tab` switches between the first and last set, `Enter` opens a row, `/` filters, `s` sorts, `Esc` closes | -## Headless JSON Output +`r` works on tables only. It reads rows ordered by primary key: up to 10 from each end, fewer on a short terminal. A table with no primary key shows only its first rows, in storage order. -Use `--json` flag for machine-readable output. Every noorm command wraps its `--json` payload in an object carrying a top-level `success` boolean, so `jq -e '.success'` works the same way against all of them. A list is never emitted as a bare array; it is always named, as in `{ "tables": [...] }`. - - -### Overview - -```bash -noorm db explore --json -``` - -```json -{ - "success": true, - "tables": 12, - "views": 3, - "procedures": 0, - "functions": 5, - "types": 2, - "indexes": 18, - "foreignKeys": 7, - "triggers": 4, - "locks": 0, - "connections": 2 -} -``` - - -### Table List - -```bash -noorm db explore tables --json -``` - -```json -{ - "success": true, - "tables": [ - { "name": "users", "schema": "public", "columnCount": 8, "rowCountEstimate": 1500 }, - { "name": "posts", "schema": "public", "columnCount": 5, "rowCountEstimate": 42000 }, - { "name": "comments", "schema": "public", "columnCount": 4, "rowCountEstimate": 128000 } - ] -} -``` -The other list subcommands follow the same shape under their own key: `views`, `procedures`, `functions`, `types`, `indexes`, `foreignKeys` (from `noorm db explore fks`), and `triggers`. +## How do I use it in scripts? -### Table Detail +Add `--json`. Every payload carries a top-level `success`, and a list is always named, never a bare array: ```bash -noorm db explore tables detail users --json +noorm db explore tables detail sales_order --json ``` ```json { "success": true, - "name": "users", + "name": "sales_order", "schema": "public", "columns": [ - { "name": "id", "dataType": "uuid", "isNullable": false, "isPrimaryKey": true, "ordinalPosition": 1 }, - { "name": "email", "dataType": "varchar(255)", "isNullable": false, "isPrimaryKey": false, "ordinalPosition": 2 }, - { "name": "name", "dataType": "varchar(100)", "isNullable": true, "isPrimaryKey": false, "ordinalPosition": 3 }, - { "name": "created_at", "dataType": "timestamptz", "isNullable": false, "defaultValue": "now()", "isPrimaryKey": false, "ordinalPosition": 7 } + { "name": "party_id", "dataType": "integer", "isNullable": false, "isPrimaryKey": true, "ordinalPosition": 1 }, + { "name": "status", "dataType": "text", "isNullable": false, "defaultValue": "'open'::text", "isPrimaryKey": false, "ordinalPosition": 4 } ], "indexes": [ - { "name": "users_pkey", "columns": ["id"], "isUnique": true, "isPrimary": true }, - { "name": "users_email_idx", "columns": ["email"], "isUnique": true, "isPrimary": false } + { "name": "sales_order_pk", "columns": ["party_id", "order_no"], "isUnique": true, "isPrimary": true } ], - "foreignKeys": [], - "rowCountEstimate": 1500 -} -``` - - -### View Detail - -```bash -noorm db explore views detail active_users --json -``` - -```json -{ - "success": true, - "name": "active_users", - "schema": "public", - "columns": [ - { "name": "id", "dataType": "uuid", "isNullable": false, "isPrimaryKey": false, "ordinalPosition": 1 }, - { "name": "email", "dataType": "varchar(255)", "isNullable": false, "isPrimaryKey": false, "ordinalPosition": 2 } + "foreignKeys": [ + { "name": "sales_order_isplacedby_party", "columns": ["party_id"], "referencedTable": "party", "referencedColumns": ["party_id"], "onUpdate": "NO ACTION", "onDelete": "NO ACTION" } ], - "definition": "SELECT id, email FROM users WHERE active = true", - "isUpdatable": false + "rowCountEstimate": 3 } ``` +This output is trimmed to two columns, and the repeated `schema` keys inside indexes and foreign keys are dropped. The list commands return `{ "tables": [...] }`, `{ "views": [...] }`, and so on, with `foreignKeys` for `fks`. The overview adds `locks` and `connections` to the eight counters. Full flags: [`db explore`](/headless#db-explore). -### Function Detail - -```bash -noorm db explore functions detail calculate_total --json -``` - -```json -{ - "success": true, - "name": "calculate_total", - "schema": "public", - "parameters": [ - { "name": "price", "dataType": "numeric", "mode": "IN", "ordinalPosition": 1 }, - { "name": "quantity", "dataType": "integer", "mode": "IN", "ordinalPosition": 2 } - ], - "returnType": "numeric", - "definition": "BEGIN RETURN price * quantity; END;" -} -``` - - -## Dialect Support - -The explorer works across all supported databases with appropriate translations: - -| Category | PostgreSQL | MySQL | MSSQL | SQLite | -|----------|:----------:|:-----:|:-----:|:------:| -| Tables | Yes | Yes | Yes | Yes | -| Views | Yes | Yes | Yes | Yes | -| Indexes | Yes | Yes | Yes | Yes | -| Foreign Keys | Yes | Yes | Yes | Yes | -| Triggers | Yes | Yes | Yes | Yes | -| Functions | Yes | Yes | Yes | -- | -| Procedures | Yes | Yes | Yes | -- | -| Types | Yes | -- | Yes | -- | -| Locks | Yes | Yes | Yes | -- | -| Connections | Yes | Yes | Yes | -- | - -SQLite does not support stored procedures, functions, or user-defined types, and has no server to report locks or connections for. MySQL does not support user-defined types. These categories return empty results for unsupported dialects. - -**Type support by dialect:** - -- **PostgreSQL** - Enums, composite types, domains -- **MSSQL** - Alias types (e.g., `CREATE TYPE EmailAddress FROM VARCHAR(255)`), table types - - -## Common Use Cases - -**Verify builds.** After running [schema builds](/guide/sql-files/execution), use explore to confirm tables, views, and functions were created correctly. -**Check constraints.** Browse foreign keys to verify referential integrity is set up as expected before deploying. +## What bites -**Inspect indexes.** Review index coverage before and after adding new queries to ensure performance. -**Compare environments.** Run overview on dev vs prod to spot schema drift quickly. Use different [configs](/guide/environments/configs) with the `-c` flag. +| Symptom | Cause | Fix | +|---|---|---| +| `noorm db explore tables \| grep party` prints nothing | Listings and detail text are log lines on stderr. Only the overview text and `--json` use stdout | Use `--json` | +| `noorm db explore -c prod tables` explores the active config | A flag before the subcommand is read by `explore`, not by `tables` | `noorm db explore tables -c prod` | +| `View not found: detail` | Only `tables` has a `detail` subcommand | `noorm db explore views open_orders` | +| `SQLite has no schemas; drop the schema filter to explore this database` | `--schema` on SQLite | Drop the flag | +| noorm's tracking tables are missing from the lists | Explore hides the `noorm` schema on PostgreSQL and MSSQL, and `__noorm_*` tables on MySQL and SQLite | The TUI shows them when `logging.level` is `verbose` in `.noorm/settings.yml`. The CLI never does | +| A composite foreign key lists its columns several times, mispaired (PostgreSQL) | The query joins `key_column_usage` to `constraint_column_usage` on the constraint name alone | Read the key from its `CREATE TABLE` in `db/sql/` | -**Debug changes.** When [changes](/guide/changes/overview) fail, explore the current schema state to understand what exists. +## Next -## What's Next? -- [SQL Terminal](/guide/database/terminal) - Run queries against your database -- [Teardown](/guide/database/teardown) - Reset database objects -- [Configs](/guide/environments/configs) - Connect to different databases +- [SQL terminal](/guide/database/terminal): query the data itself +- [Teardown](/guide/database/teardown): drop what you found +- [Configs](/guide/environments/configs): point the explorer at another database +- [`db explore` reference](/headless#db-explore): every subcommand and flag diff --git a/docs/guide/database/teardown.md b/docs/guide/database/teardown.md index 07e855af..fff4893d 100644 --- a/docs/guide/database/teardown.md +++ b/docs/guide/database/teardown.md @@ -1,397 +1,141 @@ # Teardown -Database cleanup that handles the tedious parts. Foreign key constraints, drop order, identity resets. Two operations, one goal: a clean slate. +Reset a database to a known state: delete its rows, drop its objects, rebuild it from `db/sql/`, or drop the database. - -## When You Need This - -Development databases accumulate debris. Half-applied changes, test data from last week, orphaned tables from abandoned features. Dropping and recreating the entire database works, but it is slow. Filesystem operations, permission grants, connection pool resets. What should take milliseconds becomes 5-10 seconds. - -noorm provides controlled reset operations that complete in milliseconds while handling the complexity for you. - - -## Two Operations - -| Operation | What happens | When to use | -|-----------|--------------|-------------| -| `truncate` | Delete all rows, keep tables | Test resets, re-seeding data | -| `teardown` | Drop all database objects | Full rebuild, change testing | - -::: danger Destructive Operations -Both operations permanently destroy data. `truncate` wipes all rows. `teardown` drops tables, views, functions, procedures, types, and foreign key constraints. There is no undo. These commands require explicit confirmation. -::: - - -## Truncate: Wipe Data, Keep Schema - -Truncate removes all data while preserving table structure. Fast reset for test cycles. +Preview what a teardown would remove: ```bash -noorm db truncate -y -``` - -### What Gets Truncated - -- All user tables (rows deleted) -- Identity/auto-increment counters restarted, on every dialect except SQLite -- Foreign key constraints temporarily disabled during operation - -Foreign key enforcement is re-enabled even when the truncate itself fails partway through, so a failed run never leaves the database with constraints switched off. - -### What Stays - -- Table definitions and indexes -- Views, functions, stored procedures -- noorm internal tracking tables (the `noorm` schema, or `__noorm_*__` tables) - - -## Teardown: Drop Everything - -Teardown removes all database objects. Clean slate for full rebuilds. - -```bash -noorm db teardown -y -``` - -::: warning Access Roles -`db teardown` is gated by the config's `db:teardown` permission: `viewer` and `operator` are both denied outright, and even `admin` must confirm (`yes-`, or `NOORM_YES=1` in CI). Only an `admin`-role config can tear down at all. See [Access Roles](#access-roles) below. -::: - -### Drop Order - -Objects are removed in dependency-safe order: - -1. **Foreign key constraints** - Must go first to allow table drops -2. **CHECK constraints** - MSSQL only, and only when functions are being dropped: a scalar UDF referenced by a CHECK constraint cannot be dropped while its table stands -3. **Procedures** - Dropped early so they hold no references to the functions, views, and tables below -4. **Functions** - Must precede table drops so schema-bound UDFs release their dependency locks -5. **Views** - Schema-bound views also lock the tables they reference -6. **Tables** - Safe once every schema-bound dependent is gone -7. **Types** - Table types first, then the rest - -Procedures, functions, and views all go before tables, not after. A `WITH SCHEMABINDING` object on MSSQL holds a dependency lock on the table it references, and dropping the table first fails with "Cannot DROP TABLE ... because it is being referenced by object ...". - -You do not need to manage this ordering. noorm handles it automatically. - -Pass `--preserve-schemas ` to leave whole schemas untouched. Teardown reaches every non-system schema, so this is the way to protect one it did not create. - -### What Stays - -noorm internal tables are always preserved: - -- `change` - Change execution history -- `executions` - File execution records -- `lock` - Active operation locks - -On PostgreSQL and SQL Server these live in a dedicated `noorm` schema, so they read as `noorm.change`, `noorm.executions`, and `noorm.lock`. MySQL and SQLite have no schemas, so they keep the prefixed forms in the default schema: `__noorm_change__`, `__noorm_executions__`, `__noorm_lock__`. - -After teardown, noorm can still track what was applied previously. Changes are marked as `stale`, meaning they'll re-run on the next [fast-forward](/guide/changes/forward-revert). See [History](/guide/changes/history) for how this affects your execution log. - - -## Access Roles - -[Configs](/guide/environments/configs) declare an `access.user` role — `viewer`, `operator`, or `admin` — and each destructive operation resolves against its own permission, not one shared one. This prevents accidentally wiping production data. You can also cap it at the [stage level](/guide/environments/stages): a stage with `protected: true` clamps every linked config's resolved access to at most `operator`/`viewer`. - -```yaml -# .noorm/settings.yml -stages: - prod: - defaults: - protected: true # access ceiling: at most operator/viewer +noorm db teardown --dry-run ``` -The three permissions in this family resolve differently: - -| Operation | Permission | `viewer` | `operator` | `admin` | -|-----------|------------|----------|------------|---------| -| `db teardown` | `db:teardown` | Denied | Denied | Confirm | -| `db truncate` | `db:truncate` | Denied | Confirm | Confirm | -| `db reset` / `ctx.noorm.db.reset()` | `db:reset` | Denied | Confirm | Allowed | - -Teardown is the strictest of the three: it is the only one an `operator` cannot reach at all, and the only one that still asks an `admin` to confirm. `reset` is the loosest, because it rebuilds the schema it just dropped. - -When you attempt teardown on a `viewer`- or `operator`-role config: - ``` -Cannot teardown on config "prod": "db:teardown" is not allowed on config "prod" (role: operator) +• log Would drop 5 objects +• log Tables: 4 +• log Views: 1 +• log Functions: 0 +• log Types: 0 ``` -No flag, environment variable, or SDK option overrides a denial. Give the config `admin` access if it genuinely needs to be torn down. +The counts come from a project with `party`, `product`, `sales_order`, `order_line`, and the view `open_orders`. Replace `--dry-run` with `--yes` to run it. -### Confirming on `admin` -**CLI/TUI**: Type the confirmation phrase, or pass `--yes` (`NOORM_YES=1` in CI) to skip the prompt: +## Which command removes what? -```bash -NOORM_YES=1 noorm db teardown --config prod -``` -**SDK**: There is no prompt to answer. `ctx.noorm.db.teardown()` throws `ProtectedConfigError` on any config whose confirmation the caller has not pre-supplied. Pass `yes: true` to `createContext`, or set `NOORM_YES=1` in the environment, the same variable the CLI honors: +| Command | Removes | Survives | `viewer` | `operator` | `admin` | +|---|---|---|---|---|---| +| `db truncate` | every row | tables, indexes, views, routines, types, noorm's tracking tables | deny | confirm | confirm | +| `db teardown` | foreign keys, procedures, functions, views, tables, types | noorm's tracking tables, `preserveTables`, `--preserve-schemas` | deny | deny | confirm | +| `db reset` | what teardown removes, then rebuilds from `db/sql/` | noorm's tracking tables | deny | confirm | allow | +| `db drop` | the database | nothing in it; the config stays | deny | deny | confirm | -```typescript -const ctx = await createContext({ config: 'prod', yes: true }) - -await ctx.noorm.db.teardown() // Throws ProtectedConfigError without `yes` (or NOORM_YES=1) -``` +`allow` runs. `confirm` needs `--yes` or `NOORM_YES=1`, and the TUI asks for the phrase `yes-`. `deny` has no override. `db reset` asks for `--yes` even where its cell is `allow`. -Neither `yes: true` nor `NOORM_YES=1` unblocks a denial. On a `viewer`- or `operator`-role config, `teardown()` throws no matter what you pass. +On the `agent` channel every `confirm` is a `deny`. A stage with `protected: true` caps a config at `operator`, so teardown and drop are refused there. [Concepts](/getting-started/concepts#configs-stages-and-roles) defines the roles. -A dry run is checked against the role but never against the confirmation: any role allowed to tear down can preview one without `--yes`. Previewing is the safety mechanism, so it stays reachable. +noorm's tracking tables live in the `noorm` schema on PostgreSQL and MSSQL, and as `__noorm_*__` tables on MySQL and SQLite. -Contexts created on the `agent` channel (MCP, or an AI agent driving the CLI) never satisfy a confirmation at all. `confirm` collapses to `deny` before `yes` is consulted, so an agent cannot walk through a gate a human was meant to answer. -::: danger -Running teardown against production should be exceedingly rare. If you find yourself doing this regularly, reconsider your deployment workflow. -::: +## In what order does teardown drop things? -## Preserving Tables +Procedures, functions, and views drop before tables, because a schema-bound object on MSSQL locks the table it references: -Some tables should survive resets. Lookup tables, configuration tables, audit logs. Configure these in settings: - -```yaml -# .noorm/settings.yml -teardown: - preserveTables: - - countries - - currencies - - app_settings - - audit_log +```mermaid +flowchart TD + FK["foreign keys"] --> D{"MSSQL?"} + D -->|yes| CK["all CHECK constraints"] + D -->|no| PR[procedures] + CK --> PR + PR --> FN[functions] + FN --> VW[views] + VW --> TB[tables] + TB --> TY[types] ``` -These tables will be skipped during both truncate and teardown operations. Their data and structure remain intact. +`--preserve-schemas a,b` leaves whole schemas alone. Teardown otherwise reaches every non-system schema, including ones noorm did not create. -`ctx.noorm.db.reset()` is the exception: it ignores `preserveTables` on purpose. Reset rebuilds the whole schema from `db/sql/`, so any table left standing would collide with the build's `CREATE TABLE` and abort the rebuild. +## How do I keep tables through a wipe? -## Post-Teardown Scripts -Re-seed essential data after teardown completes: +List them in settings. `postScript` is relative to the project root: ```yaml # .noorm/settings.yml teardown: - postScript: db/sql/teardown/seed.sql + preserveTables: + - party_type + postScript: scripts/post-teardown.sql ``` -The `postScript` runs immediately after teardown finishes. Use it to: +| Setting | `db truncate` | `db teardown` | `db reset` | +|---|---|---|---| +| `preserveTables` | kept | kept | ignored, because a surviving table would collide with the rebuild's `CREATE TABLE` | +| `postScript` | not run | runs after the drop | runs after the drop, before the rebuild | -- Insert required lookup data -- Create default admin accounts -- Set up test fixtures +`db truncate --preserve a,b` replaces the settings list for that run. `--only a,b` wipes those tables alone. -```sql --- db/sql/teardown/seed.sql -INSERT INTO countries (code, name) VALUES - ('US', 'United States'), - ('CA', 'Canada'), - ('MX', 'Mexico'); -INSERT INTO app_settings (key, value) VALUES - ('version', '1.0.0'), - ('maintenance_mode', 'false'); -``` +## How do I reset between tests? -## Using in Tests - -The SDK provides teardown methods with test-oriented safety guards. - -### Basic Test Setup +Truncate between tests while the schema is stable: ```typescript -import { createContext, RequireTestError } from '@noormdev/sdk' - -describe('user service', () => { +// tests/setup.ts +import { createContext } from '@noormdev/sdk' - let ctx +const ctx = await createContext({ config: 'test', requireTest: true, yes: true }) - beforeAll(async () => { - // requireTest: true prevents accidents with real databases - ctx = await createContext({ - config: 'test', - requireTest: true, - }) - await ctx.connect() - }) +await ctx.connect() - beforeEach(async () => { - // Fast reset between tests - await ctx.noorm.db.truncate() - await ctx.noorm.run.file('./seeds/test-data.sql') - }) - - afterAll(async () => { - await ctx.disconnect() - }) - - it('creates a user', async () => { - // Clean database, ready for test - }) - -}) +beforeEach(() => ctx.noorm.db.truncate()) ``` -### The `requireTest` Guard - -When `requireTest: true`, the SDK throws if the config does not have `isTest: true`: - -```typescript -// If 'staging' does not have isTest: true, this throws -const ctx = await createContext({ - config: 'staging', - requireTest: true, // RequireTestError: Config "staging" does not have isTest: true -}) -``` - -This catches configuration mistakes before tests run against the wrong database. - -### Full Reset for Integration Tests - -When schema changes are involved: - -```typescript -beforeAll(async () => { - ctx = await createContext({ config: 'test', requireTest: true }) - await ctx.connect() - - // Full teardown + rebuild - await ctx.noorm.db.reset() -}) -``` - -The `reset()` method combines a full teardown with `run.build({ force: true })` for complete schema reconstruction. It is gated by `db:reset`, which an `admin`-role config satisfies without confirmation. - - -## Scripted Usage - -For CI/CD pipelines, drive the same teardown commands non-interactively. Always pair them with `-y` to skip the confirmation prompt — the CLI refuses to wipe data otherwise. - -### Basic Commands +`requireTest: true` throws `RequireTestError` unless the config has `isTest: true`. `yes: true` answers the confirmation the SDK cannot prompt for. When the schema changed, call `ctx.noorm.db.reset()`, which runs a teardown and a forced build. In CI: ```bash -# Truncate all data -noorm db truncate -y - -# Full teardown -noorm db teardown -y - -# Preview what would be dropped -noorm db teardown --dry-run -``` - -### JSON Output - -```bash -noorm db teardown --json --yes -``` - -```json -{ - "success": true, - "dropped": { - "tables": ["users", "posts", "comments"], - "views": ["active_users"], - "functions": [], - "procedures": [], - "types": [], - "foreignKeys": ["posts_user_id_fkey"] - }, - "count": 4 -} -``` - -`count` sums tables, views, functions, and types, but not procedures or foreign keys. A dry run adds `"dryRun": true`, and a configured post-script adds `postScriptResult`. When a post-script fails, the objects are still gone but the command exits non-zero, because a teardown whose seeding step never ran is half-finished. - -`noorm db truncate --json` reports `truncated`, `preserved`, and `count`, plus `statements` on a dry run. - -### CI/CD Pattern - -```yaml -# GitHub Actions example -test: - runs-on: ubuntu-latest - services: - postgres: - image: postgres:16 - env: - POSTGRES_PASSWORD: test - POSTGRES_DB: test_db - ports: - - 5432:5432 - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - - - name: Setup test database - env: - NOORM_CONNECTION_DIALECT: postgres - NOORM_CONNECTION_HOST: localhost - NOORM_CONNECTION_DATABASE: test_db - NOORM_CONNECTION_USER: postgres - NOORM_CONNECTION_PASSWORD: test - run: | - npx noorm run build - npx noorm change ff - - - name: Run tests - run: npm test -``` - -### Test Database Reset Script - -```bash -#!/bin/bash -set -e - -# Reset to clean state before test run -noorm db teardown --config test -y -noorm run build --config test -noorm change ff --config test - -echo "Test database ready" +noorm db reset --config test --yes ``` -## Dialect Differences +## How does each dialect empty a table? -noorm generates appropriate SQL for each database: -| Feature | PostgreSQL | MySQL | MSSQL | SQLite | -|---------|------------|-------|-------|--------| -| Truncate | `TRUNCATE TABLE ... CASCADE` | `TRUNCATE TABLE` | `DELETE FROM` | `DELETE FROM` | -| Reset identity | `RESTART IDENTITY` | Automatic with `TRUNCATE` | `DBCC CHECKIDENT ... RESEED` | N/A | -| Disable FKs | `SET session_replication_role` | `SET FOREIGN_KEY_CHECKS=0` | Per-table `NOCHECK CONSTRAINT` | `PRAGMA foreign_keys=OFF` | -| Drop cascade | `CASCADE` | Manual order | Manual order | Manual order | +| Dialect | Empties with | Disables foreign keys with | Restarts identity | +|---|---|---|---| +| PostgreSQL | `TRUNCATE ... CASCADE` | `session_replication_role` | yes | +| MySQL | `TRUNCATE` | `FOREIGN_KEY_CHECKS` | yes | +| MSSQL | `DELETE FROM` | `NOCHECK CONSTRAINT` per table | yes, reseed to 0 | +| SQLite | `DELETE FROM` | `PRAGMA foreign_keys` | no | -Two dialects use DELETE rather than TRUNCATE, for different reasons. SQLite has no TRUNCATE statement. MSSQL has one, but it refuses to run against a table referenced by a foreign key even with constraints set to NOCHECK, so noorm issues a DELETE and reseeds the identity separately. +Foreign keys come back on even when the truncate fails partway. [Teardown internals](/dev/teardown#mssql-truncate-strategy) covers why MSSQL cannot use `TRUNCATE`. -MSSQL also has no session-level foreign key switch, so noorm emits one `ALTER TABLE ... NOCHECK CONSTRAINT ALL` per table rather than using `sp_MSforeachtable`, whose parallel workers deadlock on schema locks. +## What bites -## Best Practices -**Preview first.** Run with `--dry-run` before executing, especially in shared environments. +| Mistake or symptom | Cause | Fix | +|---|---|---| +| PostgreSQL truncate fails: `permission denied to set parameter "session_replication_role"` | Truncate turns off foreign-key triggers with `session_replication_role`, which only superusers, or roles granted `SET` on it, may change | Use a superuser for the test database, or on PostgreSQL 15+ run `GRANT SET ON PARAMETER session_replication_role TO ` | +| `"db:teardown" is not allowed on config "prod" (role: operator)` | The matrix denies `db:teardown` to `viewer` and `operator`. No flag overrides a denial | Give the config the `admin` role, or tear down another config | +| `requires confirmation` | The role's cell is `confirm` | Pass `--yes`, or set `NOORM_YES=1` | +| `requires confirmation from a human` | The `agent` channel never confirms | Run it as `user` | +| `db.truncate()` throws `ProtectedConfigError` on an `admin` config | `db:truncate` is `confirm` even for `admin`, and the SDK has no prompt | `createContext({ yes: true })` or `NOORM_YES=1` | +| `db teardown --dry-run` is refused on `operator` | A dry run skips the confirmation, not the role check | Look with `noorm db explore` | +| `Post-teardown script failed: Script failed: no such table` | The script runs after the tables drop. It splits on `;` and skips every statement that starts with `--`, comment line included | Put no comment above a statement, and do not insert into dropped tables | +| `postScript` seed rows are missing after `db reset`, which exits 0 | Reset runs the script before the rebuild recreates the tables | Seed from `db/sql/`, where the build inserts them | +| A preserved table is empty after `db truncate` on PostgreSQL | `TRUNCATE ... CASCADE` also empties every table that references a truncated one, and `--only` does the same | Preserve the tables it references too | +| `--preserve seeds` wipes a table listed in `preserveTables` | The flag replaces the settings list | Repeat every table in the flag | +| `change ff` re-runs every change after a teardown or reset | Teardown marks applied changes `stale`, and the rebuilt schema already holds their effect | Write changes that are safe to run twice: [Add a column](/guide/changes/add-a-column) | -**Preserve audit tables.** If you have logging or audit tables, add them to `preserveTables`. - -**Use truncate for test cycles.** Faster than teardown + rebuild when schema has not changed. - -**Use postScript for seeds.** Re-insert required data automatically after teardown. - -**Check the config's access role.** A `viewer`/`operator` role exists for a reason. Switching a config to `admin` is what makes teardown possible at all, so think twice before doing it to get past a denial. - -```bash -# Safe teardown pattern -noorm db teardown --dry-run # Preview first -noorm db teardown -y # Execute after review -``` +## Next -## What's Next? -- [Execution](/guide/sql-files/execution) - Rebuild schema after teardown -- [Changes](/guide/changes/overview) - How changes work with teardown -- [Schema Explorer](/guide/database/explore) - Verify what was dropped +- [Execution](/guide/sql-files/execution): rebuild the schema after a teardown +- [Forward and revert](/guide/changes/forward-revert): what `stale` changes do on the next `change ff` +- [Stages](/guide/environments/stages): cap a config's role with `protected: true` +- [CLI reference](/headless#db-truncate): every flag for `db truncate`, `db teardown`, `db reset`, and `db drop` diff --git a/docs/guide/database/terminal.md b/docs/guide/database/terminal.md index e9e3aae8..196444d0 100644 --- a/docs/guide/database/terminal.md +++ b/docs/guide/database/terminal.md @@ -1,269 +1,127 @@ # SQL Terminal -## What It Is - -The SQL terminal is an interactive REPL built into noorm. Run queries against your database without leaving the tool or switching to another client. - -Debugging a change? Check if that INSERT worked. Exploring a table structure? Run a quick SELECT. The terminal keeps you in flow while you work. - - -## Launching the Terminal - -Press `Shift+Q` from anywhere in the TUI. The terminal is a global shortcut, not an entry on the Database menu, so there is no `[d]` then `[t]` path to it. On the Database screen, `t` is teardown. - -From a shell, launch straight into it: +Run SQL against a config's database from the shell or the TUI, with every statement checked against the config's role first. ```bash -noorm sql repl # active config -noorm sql repl --config dev # a specific config +noorm sql query "SELECT party_id, order_no, status FROM sales_order ORDER BY 1, 2" ``` -`noorm sql repl` opens the same TUI screen, so everything on this page applies to both. It needs an interactive terminal and refuses to run under `--yes` or `NOORM_YES`; for scripted SQL use `noorm sql query "SELECT 1"` or `noorm sql query -f query.sql` instead. - -The terminal connects to your [active configuration](/guide/environments/configs) automatically. - - -## The Interface - ``` -┌─ SQL Terminal ──────────────────────────────────────────────┐ -│ │ -│ > SELECT * FROM users LIMIT 3; │ -│ │ -│ ┌────┬─────────┬─────────────────┬─────────────────────┐ │ -│ │ id │ name │ email │ created_at │ │ -│ ├────┼─────────┼─────────────────┼─────────────────────┤ │ -│ │ 1 │ Alice │ alice@email.com │ 2024-01-15 09:30:00 │ │ -│ │ 2 │ Bob │ bob@email.com │ 2024-01-16 14:22:00 │ │ -│ │ 3 │ Charlie │ charlie@co.com │ 2024-01-17 11:45:00 │ │ -│ └────┴─────────┴─────────────────┴─────────────────────┘ │ -│ │ -│ 3 rows (12ms) │ -│ │ -│ > _ │ -│ │ -│ [Shift+Tab] Edit mode [Enter] Execute [Shift+Enter] New │ -│ [h] History [Esc] Clear │ -└─────────────────────────────────────────────────────────────┘ +Columns: party_id, order_no, status +{"party_id":1,"order_no":1,"status":"paid"} +{"party_id":2,"order_no":1,"status":"invoiced"} +{"party_id":2,"order_no":2,"status":"open"} +3 rows returned (2ms) ``` -Type your SQL at the `>` prompt and press Enter to execute. The prompt changes to `[EDIT]>` in edit mode, where Enter inserts a newline instead of running the query. - - -## What You Are Allowed to Run - -Before anything reaches the database, noorm classifies the statement and checks that class against the [config's access role](/guide/environments/configs): - -| Class | Permission | `viewer` | `operator` | `admin` | -|-------|------------|----------|------------|---------| -| Reads (`SELECT`, `SHOW`, `DESCRIBE`) | `sql:read` | Yes | Yes | Yes | -| Writes (`INSERT`, `UPDATE`, `DELETE`, `MERGE`) | `sql:write` | No | Yes | Yes | -| Everything else | `sql:ddl` | No | No | Yes | - -`sql:ddl` is the catch-all, not a DDL-keyword list. `CREATE`/`ALTER`/`DROP`/`TRUNCATE`/`GRANT`/`REVOKE`/`SET`, `EXEC`/`CALL`, and anything the classifier cannot make sense of all land there. Unrecognized input fails closed, on the reasoning that a statement nobody can classify could do anything. +`Shift+Q` on any TUI screen opens the active config in an interactive terminal with a results grid and saved history. From a shell, run `noorm sql repl --config dev`, which needs a TTY and exits with a usage error under `--yes` or `NOORM_YES`. [`noorm sql`](/cli/sql) covers `query`, including `-f file.sql` and `--json`. -Classification looks past the outer keyword, which matters more than it sounds: -- A statement is parsed, not pattern-matched, and the **highest** class anywhere in the tree wins. `WITH t AS (DELETE FROM logs ...) SELECT * FROM t` is a write, not a read. -- `EXPLAIN` takes the class of the statement it wraps, because `EXPLAIN ANALYZE` executes that statement. `EXPLAIN (ANALYZE) DELETE FROM t` is a write. -- `SELECT ... INTO` creates a table or writes a file, so it classifies as `sql:ddl` rather than a read. -- A `SELECT` calling a side-effecting or filesystem builtin (`pg_terminate_backend`, `setval`, `pg_read_file`, and similar) is raised to `sql:write`, which is the lowest class a `viewer` cannot reach. -- Multi-statement input takes the highest class present, so one `DROP` after a semicolon governs the whole submission. +## What am I allowed to run? -The denylist behind that last rule covers known-dangerous builtins, not every function that could have a side effect. It stops casual and accidental writes on a `viewer` config; it is not a sandbox for a determined adversary. +The text is parsed and classified before anything reaches the database. The highest class wins: -## Running Queries - -The terminal runs any SQL your database understands, subject to the access role above: - -**SELECT queries** return formatted tables with timing: - -``` -> SELECT id, email FROM users WHERE active = true LIMIT 5; - -┌────┬────────────────────┐ -│ id │ email │ -├────┼────────────────────┤ -│ 1 │ alice@example.com │ -│ 2 │ bob@example.com │ -└────┴────────────────────┘ - -2 rows (8ms) -``` - -**INSERT, UPDATE, DELETE** report affected rows: - +```mermaid +flowchart TD + Q["SQL text"] --> P{"parses?"} + P -->|yes| T["class of every
statement in the tree"] + P -->|no| K["class of each statement
by its first keyword"] + T --> H["highest class wins"] + K --> H + H --> G{"role allows it?"} + G -->|no| X["refused, nothing sent"] + G -->|yes| DB["runs"] ``` -> UPDATE users SET last_login = NOW() WHERE id = 1; -1 row affected (5ms) -``` +| Class | Statements | `viewer` | `operator` | `admin` | +|---|---|---|---|---| +| `sql:read` | `SELECT`, `VALUES`, `TABLE`, an `EXPLAIN` of a read, `SHOW` and `DESCRIBE` on MySQL | yes | yes | yes | +| `sql:write` | `INSERT`, `UPDATE`, `DELETE`, `MERGE` | no | yes | yes | +| `sql:ddl` | everything else: `CREATE`, `ALTER`, `DROP`, `TRUNCATE`, `GRANT`, `SET`, `CALL`, `EXEC`, and input no keyword matches | no | no | yes | -**DDL statements** (CREATE, ALTER, DROP) work too, on an `admin`-role config: +`src/core/policy/classify.ts` decides these cases: -``` -> CREATE INDEX users_email_idx ON users(email); +| Input (PostgreSQL) | Class | Why | +|---|---|---| +| `WITH gone AS (DELETE FROM order_line RETURNING *) SELECT * FROM gone` | `sql:write` | a `DELETE` inside the `WITH` | +| `EXPLAIN ANALYZE DELETE FROM order_line` | `sql:write` | `EXPLAIN` takes the class of what it runs | +| `SELECT * INTO order_copy FROM sales_order` | `sql:ddl` | it creates a table | +| `SELECT pg_terminate_backend(pid) FROM pg_stat_activity` | `sql:write` | a side-effecting builtin on the denylist | -OK (42ms) -``` +The denylist names known-dangerous builtins, such as `nextval` and `pg_read_file`. It stops accidental writes on a `viewer` config and is not a sandbox. -## Stopping a Query That Will Not Come Back +## How do I work in the terminal? -`Escape` while a query is running gives the terminal back. What that does to the -database depends on the dialect, and the terminal says which one happened: -| Dialect | On `Escape` | What you see | -|---------|-------------|--------------| -| PostgreSQL | `pg_cancel_backend` on a second connection | `Cancelled. The server was asked to stop the query.` | -| MySQL | `KILL QUERY` on a second connection | `Cancelled. The server was asked to stop the query.` | -| SQL Server | nothing reaches the server | `Stopped waiting. The query may still be running on the server.` | -| SQLite | nothing reaches the server | `Stopped waiting. The query may still be running on the server.` | +| Where | Keys | +|---|---| +| Query input | `Enter` runs, `Shift+Enter` adds a line. `Shift+Tab` toggles edit mode (`[EDIT]>`), where `Enter` adds a line. `Tab` inserts four spaces. `↑` `↓` recall earlier queries and `h` opens history, both on an empty input | +| Results grid | `↑` `↓` move. `Enter` opens the row as YAML or JSON (`←` `→` change row, `f` switches format). `/` filters, `s` sorts, `c` clears both | +| Between them | A query that returns rows opens the grid. `Tab` or `Esc` returns to the query | +| Any time | `Esc` stops a running query or connect, otherwise clears the input, and on an empty input leaves | -The difference is not cosmetic. On the bottom two rows the query keeps running -to completion, holding locks and burning CPU, and the only thing that changed is -that noorm stopped listening for the answer. Kill it from the server side if it -matters. +A statement without rows shows `✓ 1 row(s) affected (5ms)` or `✓ Query executed (42ms)`. A failure shows `✗` and the server's message. -SQL Server is a limitation of the driver layer rather than of the database: -tedious exposes a per-request cancel, but Kysely's MSSQL dialect owns the -request object and never hands it out. SQLite has no second connection to -interrupt the first from. -`Escape` also works while the terminal is still connecting, which is what a -network problem looks like before a query ever runs. +## How do I stop a query? -## Result Formatting +`Esc` returns the terminal to you. Whether the server stops working depends on the dialect: -Results display in a formatted table with column alignment: +| Dialect | On `Esc` | +|---|---| +| PostgreSQL | `pg_cancel_backend` on a second connection | +| MySQL | `KILL QUERY` on a second connection | +| SQL Server | nothing reaches the server | +| SQLite | nothing reaches the server | -``` -┌─────┬────────────────────┬────────┬───────────────────────┐ -│ id │ email │ name │ created_at │ -├─────┼────────────────────┼────────┼───────────────────────┤ -│ 1 │ alice@example.com │ Alice │ 2024-01-15T10:30:00Z │ -│ 2 │ bob@example.com │ Bob │ 2024-01-15T11:45:00Z │ -└─────┴────────────────────┴────────┴───────────────────────┘ - -2 rows (15ms) -``` +The first two show `Cancelled. The server was asked to stop the query.` The last two show `Stopped waiting. The query may still be running on the server.` There the query may keep running and holding its locks, so kill it server-side if that matters. -Long values are truncated with an ellipsis to fit the column. There is no per-cell detail view, so to read a value in full, re-run the query selecting fewer columns. -Once a query returns rows, press `Tab` to move into the results table. From there: +## Where does history go? -| Key | Action | -|-----|--------| -| `↑` / `↓` | Navigate rows | -| `/` | Filter rows | -| `s` | Sort by a column | -| `c` | Clear the filter and sort | -| `Tab` or `Esc` | Return to the query input | -Errors display inline with the original query: +Only the interactive terminal records history, one file set per config. `sql query` records nothing. A statement the role refused is recorded as a failure. +```bash +noorm sql history -n 2 ``` -> SELECT * FROM nonexistent; - -Error: relation "nonexistent" does not exist -``` - - -## Query History - -Every query you run is saved. Browse and re-execute past queries without retyping. - - -### Browsing History - -Press `h` to open the history viewer. The input must be empty, so clear the prompt first: ``` -┌─ Query History ─────────────────────────────────────────────┐ -│ │ -│ 1. SELECT * FROM users LIMIT 10 12ms ✓ 1h ago │ -│ 2. UPDATE users SET active = true... 5ms ✓ 2h ago │ -│ 3. SELECT * FROM nonexistent - ✗ 2h ago │ -│ 4. INSERT INTO logs (msg) VALUES... 8ms ✓ 3h ago │ -│ │ -│ [r] Re-run [Enter] View result [c] Clear [↑/↓] Navigate │ -└─────────────────────────────────────────────────────────────┘ -``` - -- `✓` indicates successful queries -- `✗` indicates failed queries -- `r` sends the selected query back to the terminal, pre-filled and ready to edit before you run it -- `Enter` opens the stored results for that entry - -The history viewer has no search. To find an old query, scroll with the arrow keys. - - -### Viewing Saved Results +SQL history for 'dev' (2 entries): -Select a history entry and press `Enter` to view the full results from when the query ran. Results are stored gzipped, so you can review query output even after the data has changed. - -Only queries that returned rows have stored results. Pressing `Enter` on a failed entry shows its error message instead, and on a row-less entry it tells you there is nothing stored. - - -## Writing Multi-Line Queries - -The terminal has no tab completion. `Tab` inserts four spaces, so you can indent a query by hand. - -There are two ways to write across several lines. `Shift+Enter` inserts a newline without running anything. For a longer statement, press `Shift+Tab` to enter edit mode, where plain `Enter` becomes a newline and the prompt changes to `[EDIT]>`. Press `Shift+Tab` again to leave edit mode and get Enter-to-execute back. - - -## Keyboard Shortcuts - -| Key | Action | -|-----|--------| -| `Enter` | Execute query (insert a newline in edit mode) | -| `Shift+Enter` | Insert a newline | -| `Shift+Tab` | Toggle edit mode | -| `Tab` | Insert four spaces, or move between the query and the results table once a query has returned rows | -| `Up` / `Down` | Navigate command history when the input is empty, otherwise move the cursor | -| `Left` / `Right` | Move the cursor | -| `Escape` | Stop a running query or a connect in progress; otherwise clear the input, and on an empty input leave the terminal | -| `h` | Open the history viewer (empty input only) | - - -## Clearing History - -Open the history viewer with `h`, then press `c` for the clear menu: + ✓ Oct 21 at 01:42:51 5ms · 1 row(s) + UPDATE sales_order SET status = 'paid' WHERE party_id = 2 AND order_no = 1 -``` -┌─ Clear History ─────────────────────────────────────────────┐ -│ │ -│ 1. Clear last 3 months │ -│ 2. Clear all history │ -│ │ -│ History: 47 entries, 2.3 MB stored │ -│ │ -└─────────────────────────────────────────────────────────────┘ + ✗ Oct 21 at 01:42:51 2ms · relation "sale_order" does not exist + SELECT * FROM sale_order ``` -"Clear last 3 months" removes entries older than three months and keeps the rest. Either option deletes the stored result files along with their history entries. `noorm sql clear` does the same job from a shell. +In the TUI, `h` opens the same list. `r` sends the selected query back to the input, `Enter` opens its stored rows or error, and `c` opens the clear menu. From a shell: `noorm sql clear --older-than 3 --yes`. +::: warning History is not encrypted +Query text sits in `.noorm/state/history/.json` and returned rows sit gzipped in `.noorm/state/history//`. Both are owner-only (`0600`, in a `0700` directory), but anything a `SELECT` pulled from a credentials or PII table is readable by your user account. Clear the history after a sensitive query. +::: -## Tips -**Always use LIMIT** when exploring unfamiliar tables. A table with millions of rows will slow down your terminal. +## What bites -**Preview before modifying** - Run a SELECT with your WHERE clause before executing UPDATE or DELETE: -``` -> SELECT id, email FROM users WHERE last_login < '2023-01-01'; --- Review the results -> DELETE FROM users WHERE last_login < '2023-01-01'; -``` +| Symptom | Cause | Fix | +|---|---|---| +| `"sql:write" is not allowed on config "ro" (role: viewer).` | The class needs a higher role than your channel has. In an agent session the channel is `agent`, which defaults to `viewer` | Raise the role, or see [channels](/guide/environments/configs#which-channel-is-a-process-on) | +| `SHOW search_path` (PostgreSQL) or `PRAGMA …` (SQLite) is refused below `admin` | Both parse as statements the classifier does not list as reads, so they count as `sql:ddl` | `SELECT current_setting('search_path')`, or `SELECT * FROM pragma_table_info('sales_order')` | +| `noorm sql -f reports/monthly.sql` prints `Unknown command` | The bare form needs the SQL as the first token after `sql` | `noorm sql query -f reports/monthly.sql` | +| `noorm sql history` is empty after you ran queries | They ran through `sql query` | Run them in the terminal | -**History is not encrypted** - Query text sits in plain JSON at `.noorm/state/history/.json`, and returned rows sit gzipped beside it in `.noorm/state/history//`. Neither is encrypted, unlike `state.enc`. Both are written owner-only (`0600`, in a `0700` directory), but anything a `SELECT` pulled out of a credentials or PII table is readable by your user account. Avoid running queries that contain passwords or API keys, and clear the history after one slips through. +## Next -## What's Next? -- [Schema Explorer](/guide/database/explore) - Browse tables, views, and other database objects -- [Configs](/guide/environments/configs) - Switch between different databases -- [Execution](/guide/sql-files/execution) - Run SQL files with change detection +- [Schema Explorer](/guide/database/explore): read the structure before you query it +- [Configs](/guide/environments/configs): roles and channels +- [`noorm sql`](/cli/sql): flags for `query`, `history`, and `clear` +- [Run files](/guide/sql-files/execution): run SQL files with change detection diff --git a/docs/guide/database/transfer.md b/docs/guide/database/transfer.md index 362b2520..bad06167 100644 --- a/docs/guide/database/transfer.md +++ b/docs/guide/database/transfer.md @@ -1,388 +1,151 @@ # Data Transfer -Move data between databases using your existing noorm configs. Tables are transferred in foreign key order so referential integrity is maintained. Cross-dialect transfers (e.g., PostgreSQL to MySQL) are supported with automatic type conversion. +Copy rows from one database to another, parents before children, using two configs you already have. - -## When You Need This - -You have a staging database full of test data that you want to push to a fresh QA environment. Or you need to seed a local dev database from a shared development server. Or you're migrating data between two production instances. - -The databases share the same schema—same tables, same columns. You need the data moved, not the structure. - - -## Supported Dialects - -| Dialect | Supported | Same-server optimization | Cross-dialect | -|---------|-----------|--------------------------|---------------| -| PostgreSQL | Yes | No | Yes | -| MySQL | Yes | Yes (cross-database on same host) | Yes | -| MSSQL | Yes | Yes (cross-database on same host) | Yes | -| SQLite | No | — | — | - -Same-server optimization uses direct `INSERT...SELECT` SQL instead of reading data into the application and writing it back. Faster for large datasets. - -PostgreSQL never takes that path. Without `dblink` or `postgres_fdw` it cannot reach another database, and when both configs name the same database the statement degenerates into copying a table into itself. PostgreSQL transfers always run through the batch path instead. - -Cross-dialect transfers convert data types automatically through a universal type system. Most common types map cleanly; some dialect-specific features (like PostgreSQL arrays) become JSON in dialects that don't support them natively. - - -## Interactive Mode - -From the home screen: - -1. Press `d` to enter the database menu -2. Select a config -3. Choose the transfer option -4. Walk through the wizard: - - Pick destination (another config, export to file, or import from file) - - Select tables (all or specific) - - Choose a conflict strategy - - Review the plan - - Execute - -The TUI shows live progress per table with row counts and batch completion. - -**Export/Import options** appear in the destination selection list: -- "Export to .dt file" — saves data to portable files -- "Import from .dt file" — loads data from previously exported files - - -## Headless Mode - -Exactly one of `--to`, `--export`, or `--import` is required; they are mutually exclusive. The source is the active config unless `-c` names another. +Plan a copy of the order tables from `staging` to `qa`: ```bash -# Transfer all tables from active config to backup -noorm db transfer --to backup - -# Specify source explicitly with -c -noorm db transfer -c staging --to production - -# Transfer specific tables only -noorm db transfer --to backup --tables users,posts,comments +noorm db transfer -c staging --to qa \ + --tables party,product,sales_order,order_line \ + --dry-run ``` -There is no positional source argument. `noorm db transfer staging --to production` does not name `staging` as the source; use `-c staging` (or `--config staging`). - -### Dry Run - -Preview what will happen without transferring anything: - -```bash -noorm db transfer --to backup --dry-run ``` +Dry run - transfer plan: + Same server: no + Tables: 4 + Estimated rows: 1250 -Output shows: -- Whether same-server optimization applies -- Table count and estimated row counts -- Transfer order (FK dependency sorted) -- Warnings (missing destination tables, etc.) - -### Conflict Strategies - -When destination tables already contain data with matching primary keys: - -```bash -# Abort on first conflict (default) -noorm db transfer --to backup --on-conflict fail - -# Skip rows that already exist -noorm db transfer --to backup --on-conflict skip - -# Update existing rows with source data -noorm db transfer --to backup --on-conflict update - -# Delete and re-insert conflicting rows -noorm db transfer --to backup --on-conflict replace + Transfer order: + party - 120 rows + product - 40 rows + sales_order - 310 rows (after: party) + order_line - 780 rows (after: sales_order, product) ``` -| Strategy | What happens | -|----------|-------------| -| `fail` | Stops transfer on first primary key conflict | -| `skip` | Leaves existing rows untouched, inserts new ones | -| `update` | Overwrites non-PK columns on existing rows | -| `replace` | Removes conflicting rows entirely, inserts fresh copies | +Remove `--dry-run` to run it: -### Options - -```bash -# Set batch size for cross-server transfers (default: 1000) -noorm db transfer --to backup --batch-size 5000 - -# Clear destination tables before transfer -noorm db transfer --to backup --truncate - -# Don't disable foreign key checks (risky for dependent tables) -noorm db transfer --to backup --no-fk - -# Don't preserve identity/auto-increment values -noorm db transfer --to backup --no-identity -``` - -### JSON Output - -```bash -noorm db transfer --to backup --json ``` - -Transfer result: - -```json -{ - "success": true, - "status": "success", - "tables": [ - { - "table": "users", - "status": "success", - "rowsTransferred": 1500, - "rowsSkipped": 0, - "durationMs": 234 - } - ], - "totalRows": 1500, - "durationMs": 1234, - "fkChecksRestored": true -} -``` - -Check `fkChecksRestored`. When it is `false`, the transfer finished but foreign key enforcement could not be turned back on at the destination, and referential integrity is still disabled there until you re-enable it by hand. The CLI also writes a warning to stderr in that case. - -Dry run result: - -```json -{ - "success": true, - "dryRun": true, - "sameServer": false, - "tableCount": 5, - "estimatedRows": 10000, - "tables": [ - { - "name": "users", - "rowCount": 1000, - "hasIdentity": true, - "dependsOn": [] - } - ], - "warnings": [] -} +Transfer complete: success + Total rows: 1250 + Tables: 4 success, 0 failed + Duration: 0.84s ``` -## What Happens During a Transfer +## In what order are tables copied? -1. **Planning** — noorm reads the source schema, builds a foreign key dependency graph, and sorts tables so parents are transferred before children. A circular foreign key relationship cannot be sorted, so noorm falls back to the source's own table order and records a warning in the plan. -2. **FK checks disabled** — Foreign key constraints are temporarily disabled on the destination to avoid ordering issues within batches. +noorm copies a table after every table it references. An arrow means "copied before": -3. **Truncate (optional)** — If `--truncate` is set, destination tables are cleared first. PostgreSQL uses `TRUNCATE ... CASCADE`, MSSQL uses `DELETE` (can't truncate with FKs), MySQL uses standard `TRUNCATE`. - -4. **Data transfer** — Each table is transferred in order. Same-server uses direct SQL. Cross-server reads batches from source and writes them to destination. - -5. **Identity handling** — Identity/auto-increment values from the source are preserved by default. Sequences are reset after transfer to continue from the max value. - -6. **FK checks re-enabled** — Constraints are turned back on. - - -## Requirements - -- Destination tables must **already exist** with compatible column structure -- The noorm project must have configs for both source and destination databases -- The destination config's access role must permit `db:reset`: `viewer` is denied, `operator` and `admin` may write. From the SDK, an `operator` destination needs `yes: true` on the context (or `NOORM_YES=1`), since there is no prompt to answer -- For cross-dialect transfers, column types must be convertible (most are; check the dry-run output for warnings) - -noorm's own tracking tables are never transferred. They are filtered out of the plan on both ends. - - -## Common Patterns - -### Seed a dev database from staging - -```bash -noorm db transfer -c staging --to local --truncate +```mermaid +flowchart TD + party --> sales_order + sales_order --> order_line + product --> order_line ``` -Clears the local database first, then copies everything from staging. +Only foreign keys between the selected tables count, so a parent outside `--tables` must already exist in the destination. Tables with no key between them can appear in either order. On a foreign key cycle, noorm copies in alphabetical order and warns `Circular FK dependency detected`. -### Incremental sync with skip -```bash -noorm db transfer --to backup --on-conflict skip -``` +## What does a run do? -Only inserts rows that don't already exist in backup. Existing rows are left untouched. -### Upsert from source of truth +Foreign keys stay off at the destination until every table is copied: -```bash -noorm db transfer --to target --on-conflict update +```mermaid +flowchart TD + P["plan: sort tables,
parents first"] --> X["disable FK checks
on destination"] + X --> T["next table in order"] + T --> C{"--truncate?"} + C -->|yes| TR["empty the table"] + C -->|no| CP["copy rows"] + TR --> CP + CP -->|more tables| T + CP -->|done| E["re-enable FK checks"] + E --> R["status and
fkChecksRestored"] ``` -Updates all existing rows with the latest data from source, inserts new rows. +A failed table does not stop the run. The rest still copy, and the status is `partial`, or `failed` if none succeeded. A failed table can keep rows it inserted before the error. -### Transfer specific tables -```bash -noorm db transfer --to backup --tables users,user_preferences -``` +## What happens when a row already exists? -Only transfers the specified tables. FK dependencies between selected tables are still respected. -### CI/CD test data setup +A row exists when its primary key is already in the destination: -```bash -noorm db transfer -c staging --to ci-test --truncate --on-conflict fail --json -``` - -Clean transfer for test environments. JSON output for pipeline integration. Fails fast if anything goes wrong. +| `--on-conflict` | Existing row | +|---|---| +| `fail` (default) | The table fails at its first conflict | +| `skip` | Left as is | +| `update` | Non-key columns take the source values | +| `replace` | MySQL deletes and reinserts it. MSSQL overwrites every column. PostgreSQL leaves it as is | -### Cross-dialect migration +A cross-dialect transfer treats `update` and `replace` as `skip`. -```bash -noorm db transfer -c postgres-legacy --to mysql-new --dry-run -noorm db transfer -c postgres-legacy --to mysql-new -``` -Migrate from PostgreSQL to MySQL. Run `--dry-run` first to check for type conversion warnings. +## What do I need first? -## File Export/Import +| Needed | Detail | +|---|---| +| Destination tables | Present, with compatible columns. A missing one warns `Table "x" exists in source but not destination`, then fails | +| Saved configs | `--to` names the destination. The source is the active config, or `-c` | +| Destination role | `db:reset`: `viewer` denied, `operator` needs `--yes` or `NOORM_YES=1`, `admin` allowed. `--dry-run` needs `transfer:plan`, which `operator` has | +| Dialect | PostgreSQL, MySQL, or MSSQL, in any pairing. SQLite is refused | -Export data to portable `.dt` files for backup, sharing, or migration without a live destination database. +noorm's own tracking tables never transfer. Two MySQL or two MSSQL configs on one host and port copy each table with one `INSERT ... SELECT`, but only with `--on-conflict fail`. Everything else, PostgreSQL included, copies in batches of `--batch-size` rows. -### File Formats -| Extension | Description | -|-----------|-------------| -| `.dt` | Line-delimited JSON5, uncompressed and human-readable | -| `.dtz` | The same content, gzipped | -| `.dtzx` | Gzipped and AES-256-GCM encrypted (requires passphrase) | +## How do I move tables through a file? -`.dt` and `.dtz` stream row by row on import. `.dtzx` is decrypted and inflated whole before the first row is read, so it carries a size ceiling the other two do not. - -### Export to Files - -`--tables` defaults to every table in the source. Pass it to narrow the export. ```bash -# Export every table to a directory -noorm db transfer --export ./backup/ - -# Export single table -noorm db transfer --export ./backup/users.dt --tables users - -# Export multiple tables to a directory -noorm db transfer --export ./backup/ --tables users,posts,comments - -# Export compressed -noorm db transfer --export ./backup/ --tables users,posts --compress - -# Export encrypted (implies compression) -noorm db transfer --export ./backup/ --tables users --passphrase "my-secret" +noorm db transfer -c staging --export ./backup/ --tables party,product +noorm db transfer -c qa --import ./backup/party.dt ``` -An export that would write nothing is an error, not a silent success: both an empty `--tables` list and a source with no tables fail rather than leaving you with an empty backup you believe is good. +| Extension | Content | Made by | +|---|---|---| +| `.dt` | line-delimited JSON5 | the default | +| `.dtz` | the same, gzipped | `--compress` | +| `.dtzx` | gzipped and AES-256-GCM encrypted | `--passphrase`, 12 characters or more | -On an interactive terminal, omitting `--passphrase` for a `.dtzx` path prompts for it with masked input, which keeps the secret out of your shell history. Non-interactive runs need the flag. +One table writes to the path you give. Several write one file each into that directory. A path ending in `.dtzx` prompts for the passphrase on a terminal. The format is in [Data transfer internals](/dev/transfer#file-export-import). -**Path rules:** -- Single table → path is the output file, with the extension appended if missing -- Multiple tables → path is a directory, noorm creates `
.dt` per table +Import takes one file per call and leaves foreign keys on, so import parents first. -### Import from Files -```bash -# Import from .dt file -noorm db transfer --import ./backup/users.dt +## How do I script it? -# Import with upsert -noorm db transfer --import ./backup/users.dtz --on-conflict update -# Import encrypted file -noorm db transfer --import ./backup.dtzx --passphrase "my-secret" +| Exit | Meaning | +|---|---| +| `0` | Every table succeeded | +| `1` | No table succeeded, or the run failed before copying | +| `2` | A flag or config name was wrong. Nothing ran | +| `3` | Some tables succeeded and some failed: a mixed state | -# Validate schema compatibility only -noorm db transfer --import ./backup/users.dt --dry-run -``` +With `--json`, read `fkChecksRestored`. `false` means the data moved but the destination still has foreign keys off, and `status` does not change. -### Export/Import JSON Output - -Export result (single table): - -```json -{ - "success": true, - "mode": "export", - "filepath": "./backup/users.dt", - "tables": [ - { "table": "users", "filepath": "./backup/users.dt", "rowsExported": 1500, "bytesWritten": 45230 } - ], - "totalRows": 1500, - "totalBytes": 45230 -} -``` -Export result (multiple tables): - -```json -{ - "success": true, - "mode": "export", - "directory": "./backup/", - "tables": [ - { "table": "users", "filepath": "./backup/users.dt", "rowsExported": 1500, "bytesWritten": 45230 }, - { "table": "posts", "filepath": "./backup/posts.dt", "rowsExported": 800, "bytesWritten": 23100 } - ], - "totalRows": 2300, - "totalBytes": 68330 -} -``` +## What bites -Import result: -```json -{ - "success": true, - "mode": "import", - "filepath": "./backup/users.dt", - "rowsImported": 1500, - "rowsSkipped": 0 -} -``` +| Mistake or symptom | Cause | Fix | +|---|---|---| +| `noorm db transfer staging --to qa` copies from the active config | There is no positional source | `-c staging` | +| `Failed to disable FK checks` | The destination role cannot disable constraints. Nothing was copied | `--no-fk`, which relies on the parents-first order, or a more privileged role | +| Exit `3` | A table hit a conflict under `fail`, or had no destination | Read `tables[].error` in `--json`. For conflicts, rerun with `--on-conflict skip` | +| `--truncate` empties destination tables outside `--tables` (PostgreSQL) | `TRUNCATE ... CASCADE` also empties every table that references the truncated one | Put the child tables in `--tables` | +| `--import --dry-run` imports for real | `--dry-run` only plans database-to-database copies. `--export` ignores it too | Preview the schema in the TUI import wizard | -### Common Export/Import Patterns -**Backup specific tables before risky operation:** +## Next -```bash -noorm db transfer --export ./pre-migration-backup/ --tables users,orders --compress -# ... run migration ... -# If something goes wrong: -noorm db transfer --import ./pre-migration-backup/users.dt --truncate -noorm db transfer --import ./pre-migration-backup/orders.dt --truncate -``` - -**Share test data with team:** - -```bash -# Export encrypted for sharing -noorm db transfer --export ./fixtures.dtzx --passphrase "team-secret" --tables users,posts -# Teammate imports -noorm db transfer --import ./fixtures.dtzx --passphrase "team-secret" -``` - -**Cross-dialect migration via file:** - -```bash -# Export from PostgreSQL -noorm use postgres-source -noorm db transfer --export ./migration-data/ --tables users,posts - -# Import into MySQL -noorm use mysql-target -noorm db transfer --import ./migration-data/users.dt -noorm db transfer --import ./migration-data/posts.dt -``` +- [Teardown](/guide/database/teardown): empty or rebuild the destination first +- [Data transfer internals](/dev/transfer): planner, executors, file format +- [Execution](/guide/sql-files/execution): build the destination schema before copying +- [CLI reference](/headless#db-transfer): every `db transfer` flag diff --git a/docs/guide/deployment.md b/docs/guide/deployment.md index 7693f03d..d1e83b75 100644 --- a/docs/guide/deployment.md +++ b/docs/guide/deployment.md @@ -1,30 +1,10 @@ # Deploying an Application -On your laptop, `createContext({ config: 'dev' })` works because two things are sitting on disk: an encrypted `.noorm/state/state.enc` holding your configs, and an identity keypair in `~/.noorm/`. Neither belongs in a production image. You do not want your team's staging credentials baked into a container, and you certainly do not want a developer's private key there. - -The good news is that noorm already runs without either. The mode CI uses — configuration from environment variables alone — is the same mode your application should use in production. This guide covers what changes between the laptop and the cluster. - - -## Split the job in two - -Most deployment pain comes from treating "get the schema up to date" and "serve traffic" as one step. They have opposite requirements: - -| | Schema delivery | Application runtime | -|---|---|---| -| **Runs** | Once per release | In every replica, continuously | -| **Needs** | Write access, your `sql/` and `changes/` directories | A connection and nothing else | -| **Scales** | Exactly one at a time | Horizontally, to N replicas | -| **Surface** | `noorm run build`, `noorm change ff` | `createContext()` | - -Keep them apart and horizontal scaling is uneventful. Fold schema delivery into application boot and every new replica becomes a schema-mutating process racing its siblings. - - -## Runtime: connect from the environment - -When no stored config is in play and `NOORM_CONNECTION_DIALECT` and `NOORM_CONNECTION_DATABASE` are both set, `createContext()` builds a config from the environment alone. No project directory, no identity, no state file: a missing `state.enc` resolves to empty state rather than an error, and nothing asks for a private key. +Deliver the schema once per release, and give every application replica a connection and nothing else. ```typescript +// src/db.ts import { createContext } from '@noormdev/sdk'; import type { DB } from './schema'; @@ -33,158 +13,83 @@ export const ctx = await createContext(); await ctx.connect(); ``` -That is the whole runtime setup. The variables: +```bash +# environment of each replica +NOORM_CONNECTION_DIALECT=postgres +NOORM_CONNECTION_HOST=db.internal +NOORM_CONNECTION_DATABASE=order_to_cash +NOORM_CONNECTION_USER=app +NOORM_CONNECTION_PASSWORD=$DB_PASSWORD +``` -| Variable | Required | Notes | -|----------|----------|-------| -| `NOORM_CONNECTION_DIALECT` | Yes | `postgres`, `mysql`, `sqlite`, or `mssql` | -| `NOORM_CONNECTION_DATABASE` | Yes | Together with `DIALECT`, this is what triggers environment-only mode | -| `NOORM_CONNECTION_HOST` | No | Defaults to `localhost` | -| `NOORM_CONNECTION_PORT` | No | Defaults per dialect: `5432` / `3306` / `1433` | -| `NOORM_CONNECTION_USER` | Usually | | -| `NOORM_CONNECTION_PASSWORD` | Usually | Read from your secret store, never from the image | -| `NOORM_CONNECTION_SSL` | No | | -| `NOORM_CONNECTION_POOL_MIN` | No | Defaults to `0` | -| `NOORM_CONNECTION_POOL_MAX` | No | Defaults to `10` | +With no stored config, `createContext()` builds a config named `__env__` from these variables. The image needs no `.noorm/` directory, identity, or state file. Install the driver beside the SDK (`npm install @noormdev/sdk kysely pg`), because it is an optional peer dependency. -The resulting config is named `__env__`, which is what you will see in any error message it raises. +Unset variables take defaults: `_HOST` is `localhost`, `_PORT` follows the dialect (`5432`, `3306`, `1433`), and the pool runs from `_POOL_MIN=0` to `_POOL_MAX=10`. -Install the driver alongside the SDK — it is an optional peer dependency, so noorm does not pull it in for you: -```bash -npm install @noormdev/sdk kysely pg -``` +## Why split schema delivery from the application? -## One context per process +A replica that changes the schema on boot races its siblings. Run the schema step once, and let replicas start after it: -A context owns a connection pool. Create it once when the process starts, share it across requests, and close it on shutdown: +```mermaid +flowchart TD + MIG["migrate job
noorm change ff"] -->|exits 0| R1["replica 1
createContext()"] + MIG -->|exits 0| R2["replica 2
createContext()"] + MIG -->|exits 0| RN["replica N"] + R1 --> DB[(database)] + R2 --> DB + RN --> DB +``` -```typescript -const ctx = await createContext(); +The migrate step is a release step, an init container, or a Kubernetes `Job`. It connects as a deploy user through its own `NOORM_CONNECTION_*` variables, and only it needs `db/sql/` and `db/changes/`. An empty database starts with one `noorm run build`. Every later release runs `change ff` only. If your changes render vault secrets, it also needs an enrolled identity: [CI/CD Automation](/guide/automation/ci#how-do-i-set-up-prod-ci). -await ctx.connect(); -process.on('SIGTERM', async () => { +## Why one context per process? - await server.close(); - await ctx.disconnect(); - process.exit(0); -}); -``` -Calling `createContext()` per request would open a fresh pool per request, which is the classic way to exhaust a database's connection limit under load. +`connect()` opens a pool of up to `NOORM_CONNECTION_POOL_MAX` connections, so a context per request opens a pool per request. Create one at startup, share it, and call `ctx.disconnect()` on `SIGTERM`. -That limit is the number worth doing arithmetic on before you scale out. `NOORM_CONNECTION_POOL_MAX` is a **per-replica** ceiling, so your real demand is replicas × pool max. Twenty replicas at the default of 10 will try to open 200 backends — comfortably past a stock PostgreSQL's `max_connections` of 100. Either lower the per-replica pool or put PgBouncer in front: +Demand is replicas times pool max. Twenty replicas at the default ask for 200 backends, past PostgreSQL's default `max_connections` (typically 100). Lower the pool: ```bash NOORM_CONNECTION_POOL_MAX=4 # 20 replicas x 4 = 80 backends ``` -## Give the runtime the access it actually needs +## How do I limit what the runtime can do? -Your application queries tables. It does not need to build schemas or apply changes, and a config that permits those operations is a wider blast radius than the job requires. Configs carry per-channel roles, and environment-only configs default to `user: admin` — full access. -Narrow it: +Environment-only configs default to `user: admin`. The application only queries, so narrow it. Set both channels, because the access object needs both: ```bash NOORM_ACCESS_USER=viewer NOORM_ACCESS_AGENT=false ``` -With those set, Kysely queries work exactly as before, but the management operations refuse: - -``` -ProtectedConfigError: Cannot run.build on config "__env__": -"run:build" is not allowed on config "__env__" (role: viewer). -``` - -Set both variables or neither. The access object requires both channels, so setting only `NOORM_ACCESS_USER` fails config validation at startup rather than falling back to a default. - -This is defense in depth, not a substitute for database-level permissions. Grant the runtime's database user only the privileges it needs; the noorm role stops the SDK's own management surface, not hand-written SQL. - -See [Access Roles](/guide/environments/configs#access-roles) for what each role permits. - - -## Schema delivery runs once, elsewhere - -Run `build` and `change ff` as a release step, an init container, or a Kubernetes `Job` — anywhere that runs exactly once per deploy, before the new replicas take traffic: +Kysely queries still work. The management surface refuses: -```yaml -- name: Apply schema - env: - NOORM_CONNECTION_DIALECT: postgres - NOORM_CONNECTION_HOST: ${{ secrets.DB_HOST }} - NOORM_CONNECTION_DATABASE: myapp - NOORM_CONNECTION_USER: ${{ secrets.DB_DEPLOY_USER }} - NOORM_CONNECTION_PASSWORD: ${{ secrets.DB_DEPLOY_PASSWORD }} - run: | - noorm run build - noorm change ff ``` - -noorm takes a database lock around change execution, so a second run cannot interleave with the first. It does not queue: the lock is acquired with `wait: false`, so the loser fails immediately with a lock error naming the holder. Treat that as a safety net, not a design. Fold schema delivery into application boot and the second replica to start does not wait its turn, it crashes. - -If your changes render vault-backed secrets, the deploy step needs an enrolled identity too. That flow is covered in [CI/CD Automation](/guide/automation/ci#prod-ci) and applies unchanged here. - - -## Docker - -Nothing noorm-specific goes in the image. No `.noorm/`, no identity, no config: - -```dockerfile -FROM node:22-slim - -WORKDIR /app -COPY package*.json ./ -RUN npm ci --omit=dev -COPY dist ./dist - -CMD ["node", "dist/server.js"] -``` - -Configuration arrives as environment variables at run time: - -```yaml -services: - app: - image: myapp:latest - environment: - NOORM_CONNECTION_DIALECT: postgres - NOORM_CONNECTION_HOST: db - NOORM_CONNECTION_DATABASE: myapp - NOORM_CONNECTION_USER: app - NOORM_CONNECTION_PASSWORD: ${DB_PASSWORD} - NOORM_CONNECTION_POOL_MAX: 4 - NOORM_ACCESS_USER: viewer - NOORM_ACCESS_AGENT: 'false' +ProtectedConfigError: Cannot run.build on config "__env__": "run:build" is not allowed on config "__env__" (role: viewer). ``` -The `sql/` and `changes/` directories only need to be present wherever schema delivery runs — the CI checkout, or a separate migration image. Your application image does not need them. +This stops the SDK's own `ctx.noorm` operations, not hand-written SQL, so grant the runtime's database user only the privileges it needs. The migrate job needs its own environment without these variables. [Access roles](/guide/environments/configs#access-roles) lists what each role permits. -## One process, one database +## What bites -Connection details come from the process environment, and `createContext()` reads that environment when it resolves. There is no `createContext({ host, user, password })` overload today, which has a consequence worth stating plainly: **in environment-only mode, a process serves one database.** (A process that does have a `state.enc` can hold several contexts at once, one per stored config, by passing `createContext({ config: 'name' })`. That route is not available here, because environment-only mode is what you get when there is no state file.) -If you are tempted to reassign `process.env` between calls to reach a second database, don't. The environment is process-global while `createContext()` is asynchronous, so two concurrent calls interleave and the later assignment wins for both: - -```typescript -// Broken — both contexts connect to tenant_b. -await Promise.all([ - forTenant('tenant_a'), - forTenant('tenant_b'), -]); -``` - -For multi-tenant or shard-per-customer designs, run a process per database, or hold a long-lived context per tenant created serially at startup rather than per request. +| Symptom | Cause | Fix | +|---|---|---| +| `Private key required to decrypt state` at startup | A `.noorm/state/state.enc` is in the image | Keep `state.enc` out of the image | +| `Lock for 'prod' held by … since …, expires …` | Another `change ff` is running. The lock is per change and does not wait | Rerun after it finishes | +| Two deploys run `change ff` at once and neither fails | The lock holder is the identity's `name `. A second job with the same name and email extends the lock | Serialize deploys in your CI, for example with a `concurrency` group | +| Two contexts for two databases both connect to the last one | `createContext()` reads `process.env` after awaiting state and settings, so concurrent calls see the last assignment | One process per database, or create each context in turn at startup | +| `noorm run build` in the deploy step errors or changes nothing | A changed `CREATE TABLE` file re-runs and cannot alter a table that holds data | Run `noorm change ff` only | -Single-database services — the overwhelming majority — are unaffected. +## Next -## What's Next? -- [CI/CD Automation](/guide/automation/ci) — pipelines, identity enrollment, vault-backed secrets -- [Configs](/guide/environments/configs) — access roles and the full environment variable table -- [SDK Reference](/reference/sdk) — the context API in full +- [CI/CD Automation](/guide/automation/ci): pipelines, identity enrollment, vault secrets +- [SDK reference](/reference/sdk): the context API diff --git a/docs/guide/environments/configs.md b/docs/guide/environments/configs.md index c0e05e7b..8ef629a8 100644 --- a/docs/guide/environments/configs.md +++ b/docs/guide/environments/configs.md @@ -1,383 +1,219 @@ # Configs -You need to connect to different databases. Your local dev database uses SQLite. Staging runs PostgreSQL on a remote server. Production is locked down tight. noorm handles all of this with configs. +Switch between your dev, staging, and prod databases, and decide what a person or an AI agent may do on each. -A **config** is a saved database connection with all its details: dialect, host, credentials, paths. You create configs once, then switch between them as needed. - - -## Why Multiple Configs? - -Real projects talk to multiple databases: - -- `dev` - Your local machine, fast iteration -- `test` - Gets wiped between test runs -- `staging` - Mirrors production, catches issues early -- `prod` - The real thing, locked down by access roles - -Without saved configs, you'd type connection details every time. With them, you switch environments in one keystroke. - - -## Creating a Config (TUI) - -Launch the TUI and navigate to configs: - -```bash -noorm -``` - -Press `c` to open the config menu, then `a` to add a new config. - -The setup wizard walks you through each field: - -1. **Config Name** - A short identifier like `dev`, `staging`, or `prod`. Use lowercase with hyphens. - -2. **Database Type** - Your database engine: PostgreSQL, MySQL, SQLite, or SQL Server. - -3. **Host** - The server address. For local databases, this is usually `localhost`. SQLite doesn't need a host. - -4. **Port** - The port number. noorm fills in the default for your dialect (PostgreSQL: 5432, MySQL: 3306, MSSQL: 1433). - -5. **Database** - The database name, or file path for SQLite. - -6. **Username** and **Password** - Your credentials. Passwords are stored encrypted. - -7. **User Role** and **Agent Role** - Who may do what through this config. See [Access Roles](#access-roles) below. - -8. **Test Database** - Marks the config as a test target, which build rules and the SDK's `requireTest` guard read. - -Before saving, noorm connects to the server to check the credentials. It uses the dialect's system database, so the target database does not have to exist yet. Schema and change directories are not part of a config: they come from `paths` in `settings.yml`. - - -## Creating a Config Non-Interactively - -`noorm config add` and `edit` do not run headlessly. They print the interactive-only message and exit non-zero, because credential entry and the connection test belong in the wizard. Two commands are the exception: `noorm config rm --yes` deletes headlessly (see the [CLI Reference](/headless#config-rm-name)), and `noorm config import ` creates or replaces a config from JSON. For CI/CD pipelines and scripts that need to skip the config wizard entirely, build the config from **environment variables** instead: - -```bash -export NOORM_CONNECTION_DIALECT=postgres -export NOORM_CONNECTION_HOST=db.staging.example.com -export NOORM_CONNECTION_PORT=5432 -export NOORM_CONNECTION_DATABASE=myapp_staging -export NOORM_CONNECTION_USER=deploy -export NOORM_CONNECTION_PASSWORD="$DB_PASSWORD" - -noorm run build # Uses the env-only config -noorm change ff # Same — no stored config required -``` - -When at least `NOORM_CONNECTION_DIALECT` and `NOORM_CONNECTION_DATABASE` are set, noorm runs without ever touching `.noorm/state/state.enc`. See the [CLI Reference](/headless#environment-only-mode) for the full list of supported variables. - - -## Switching Configs - -Only one config is active at a time. All commands use the active config unless you specify otherwise. - -**In the TUI:** Press `c` for configs, highlight one in the list, then press `Enter` to activate it. - -**From the command line:** +A config is a saved database connection. This project has three: ```bash noorm config use staging +noorm config list ``` -**Per-command override:** - -```bash -noorm -c prod run build -``` - -`noorm config list` marks the active one and tags anything whose access is no longer the default: - ``` +Active config set to: staging Configurations: - dev (active) — sqlite/./data/dev.db - staging — postgres/staging_db - prod — postgres/prod_db [user:operator agent:off] + dev — postgres/order_to_cash_dev + staging (active) — postgres/order_to_cash_staging + prod — postgres/order_to_cash [user:operator agent:off] ``` +The bracket tag marks a test config, or roles that differ from the default (`user:admin agent:viewer`). `agent:off` hides the config from AI agents. -## Config Properties - -Every config has these fields: - -| Property | Required | Description | -|----------|----------|-------------| -| `name` | Yes | Unique identifier (alphanumeric, hyphens, underscores) | -| `type` | Yes | `local` or `remote` | -| `dialect` | Yes | `postgres`, `mysql`, `sqlite`, or `mssql` | -| `host` | No* | Server address (*required for non-SQLite) | -| `port` | No | Port number (defaults by dialect) | -| `database` | Yes | Database name or file path | -| `user` | No | Authentication username | -| `password` | No | Authentication password (stored encrypted) | -| `access.user` | No | CLI/TUI/SDK role: `viewer`, `operator`, or `admin` (default: `admin`) | -| `access.agent` | No | AI agent role, over MCP and the CLI alike: `viewer`, `operator`, `admin`, or `false` to hide the config from agents (default: `viewer`) | -| `isTest` | No | Marks as test database (default: false) | -| `ssl` | No | SSL/TLS configuration | -| `pool` | No | Connection pool settings | -| `identity` | No | Override the audit identity | - -Connection details nest under a `connection` object in the stored JSON, so `host` is `connection.host` and `ssl` is `connection.ssl`. `name`, `type`, `isTest`, `access`, and `identity` sit at the top level. - -**Default ports by dialect:** - -| Dialect | Default Port | -|---------|--------------| -| PostgreSQL | 5432 | -| MySQL | 3306 | -| MSSQL | 1433 | -| SQLite | N/A | - - -## Access Roles - -Production databases need safeguards. Every config carries an access role per **channel** — who's *driving*. `noorm ui` → Config → Edit sets `access.user` (a human) and `access.agent` (an AI agent) independently, so a config can be wide open to you at the terminal while showing an agent only read access. - -The channel is the caller, not the transport. An agent reaches noorm over MCP or by running `noorm` on the command line, and both get the `agent` role — the CLI recognizes the agent harness it was spawned from, so an agent that is refused over MCP cannot route around it by shelling out. -| Role | Behavior | -|------|----------| -| `viewer` | Read-only: explore schema, run `SELECT`/`EXPLAIN`. Cannot read secrets | -| `operator` | Reads plus writes; migrations, builds, and `db create`/`db reset` require typing a confirmation phrase; raw DDL, `db drop`, and `db teardown` are denied | -| `admin` | Everything is permitted, but the operations that cannot be walked back still prompt | +## How do I create a config? -| Operation | viewer | operator | admin | -|-----------|--------|----------|-------| -| `db explore`, `SELECT` / `EXPLAIN` | allowed | allowed | allowed | -| Raw `INSERT` / `UPDATE` / `DELETE` | denied | allowed | allowed | -| Raw DDL (`CREATE` / `ALTER` / `DROP`) | denied | denied | allowed | -| `change run` / `change ff` / `change revert` | denied | confirm | allowed | -| `run build` / `run file` / `run dir` | denied | confirm | allowed | -| `db create` / `db reset` | denied | confirm | allowed | -| `db truncate` | denied | confirm | confirm | -| `db drop` / `db teardown` | denied | denied | confirm | -| `change rm`, `config rm`, overwriting a config | denied | confirm | confirm | -| Reading vault or local secrets | denied | allowed | allowed | -| Writing vault or local secrets | denied | confirm | allowed | -| `vault propagate` | denied | confirm | confirm | -`admin` is not a bypass. Dropping a database, tearing down a schema, truncating tables, removing a change or a config, overwriting a config's access roles, and propagating the vault key all confirm at every role that is allowed to attempt them. The full matrix lives in `src/core/policy/matrix.ts`. +Use the TUI wizard: run `noorm ui`, press `c` for configs, then `a`. Before it saves, the wizard connects to the server's system database, so the target database does not have to exist yet. MySQL has no system database to fall back to, so on MySQL [create the database first](/guide/database/create#what-bites). -When an operation needs confirmation in the TUI, noorm asks you to type a phrase (`yes-`). This catches the "oops, wrong database" moment before damage happens. +| Field | Notes | +|---|---| +| Config Name | Letters, numbers, hyphens, underscores. A name that matches a [stage](/guide/environments/stages) links the config to it | +| Database Type | `postgres`, `mysql`, `sqlite`, `mssql` | +| Host, Port | SQLite has no host. Port defaults to 5432, 3306, or 1433 | +| Database | Database name, or file path for SQLite | +| Username, Password | Stored encrypted in `.noorm/state/state.enc` | +| User Role, Agent Role | See [Access roles](#access-roles) | +| Test Database | Sets `isTest`, read by `rules` in `settings.yml` and the SDK's `requireTest` | -**Skipping confirmations in CI:** +Schema and change folders are not config fields. `paths` in `.noorm/settings.yml` sets them. -Automated pipelines can't type confirmations. Set `NOORM_YES=1` to skip them on the `user` channel: +`noorm config add` and `config edit` exit 1 and point you at the TUI. To create a config without it, write a JSON file and import it. This `staging.json` is hidden from agents: -```bash -export NOORM_YES=1 -noorm -c prod change run +```json +{ + "name": "staging", + "access": { "user": "operator", "agent": false }, + "connection": { + "dialect": "postgres", + "host": "staging.db.internal", + "database": "order_to_cash_staging", + "user": "deploy" + } +} ``` -There is no equivalent skip on the `agent` channel — an agent hitting a `confirm` cell is always denied, and `NOORM_YES` / `--yes` do not change that. Otherwise the confirmation would be one flag away from meaningless. See [MCP](/guide/automation/mcp#access-roles) for the agent-facing side of this. - -**Which channel am I on?** - -noorm resolves the channel from the environment it was started in, in this order: - -1. `NOORM_CHANNEL=user` or `NOORM_CHANNEL=agent`, if set. -2. The variables agent harnesses export for their child processes — Claude Code, OpenAI Codex, Cursor, Gemini CLI, or the generic `AI_AGENT` / `NOORM_AGENT`. Any of those means `agent`. -3. Otherwise `user`. - -`TERM_PROGRAM`, `CI`, and whether stdout is a TTY are deliberately ignored: they describe the terminal or the pipeline, not the caller, and keying on them would lock a human out of their own CLI. - -`noorm mcp serve` sits above this chain. It declares `agent` outright, so stdio traffic stays on the agent channel even under `NOORM_CHANNEL=user`. - -Set `NOORM_CHANNEL=user` when *you* are scripting from inside an agent session and want your own role: - ```bash -NOORM_CHANNEL=user noorm -c prod change run +noorm config import ./staging.json ``` -::: warning Access Roles Are Not Security -Roles prevent accidents, not attacks. They won't stop a determined user or malicious script — an agent can set `NOORM_CHANNEL` too. The channel defends against an agent routing around a refusal, which is the realistic case, not one setting out to evade the check. Use proper database permissions for real security. -::: +``` +Imported config 'staging'. +``` -## Environment Variable Overrides +## How do I switch configs? -Environment variables override stored config values. This is how you inject secrets in CI/CD without storing them. -**Connection variables:** +Commands use the first of these that is set: -| Variable | Config Path | -|----------|-------------| -| `NOORM_CONNECTION_DIALECT` | `connection.dialect` | -| `NOORM_CONNECTION_HOST` | `connection.host` | -| `NOORM_CONNECTION_PORT` | `connection.port` | -| `NOORM_CONNECTION_DATABASE` | `connection.database` | -| `NOORM_CONNECTION_USER` | `connection.user` | -| `NOORM_CONNECTION_PASSWORD` | `connection.password` | -| `NOORM_CONNECTION_SSL` | `connection.ssl` | +| Scope | How | +|---|---| +| One command | `noorm run build --config prod`, with the flag after the subcommand | +| One shell | `export NOORM_CONFIG=prod` | +| Every command | `noorm config use staging`, or highlight a config in the TUI and press Enter | -**Path variables.** These two override `settings.yml`, not the config, because schema and change directories are project-wide: -| Variable | Settings Path | -|----------|---------------| -| `NOORM_PATHS_SQL` | `paths.sql` | -| `NOORM_PATHS_CHANGES` | `paths.changes` | +## How do I override a config in CI? -The overlay stays out of the file itself. noorm never writes an ambient `NOORM_*` value back into `settings.yml`, which is version controlled. -**Behavior variables:** +Environment variables override the stored config. [Stages](/guide/environments/stages#which-value-wins) shows the full order. -| Variable | Purpose | -|----------|---------| -| `NOORM_CONFIG` | Which config to use | -| `NOORM_YES` | Skip confirmations (set to `1`) | -| `NOORM_CHANNEL` | Force the policy channel: `user` or `agent` (default: detected from the environment) | +| Variable | Sets | +|---|---| +| `NOORM_CONNECTION_DIALECT`, `_HOST`, `_PORT`, `_DATABASE`, `_USER`, `_PASSWORD`, `_SSL` | `connection.*` on the config | +| `NOORM_PATHS_SQL`, `NOORM_PATHS_CHANGES` | `paths.sql`, `paths.changes`. Never written back to `settings.yml` | +| `NOORM_CONFIG` | Which stored config to use | +| `NOORM_YES` | Any value except empty, `0`, or `false` skips `confirm` prompts on the `user` channel | +| `NOORM_CHANNEL` | `user` or `agent`. See [Which channel is a process on?](#which-channel-is-a-process-on) | -**Example: Override host for CI runner** - -```bash -# Use stored 'staging' config but connect to CI database server -export NOORM_CONFIG=staging -export NOORM_CONNECTION_HOST=db.ci-runner.internal -noorm run build -``` - -**Example: Build config entirely from environment** - -When you provide at least `NOORM_CONNECTION_DIALECT` and `NOORM_CONNECTION_DATABASE`, noorm creates a temporary config from environment variables alone: +When no config is selected by flag, `NOORM_CONFIG`, or `config use`, noorm builds one named `__env__` from the variables. It needs `NOORM_CONNECTION_DIALECT` and `NOORM_CONNECTION_DATABASE`: ```bash export NOORM_CONNECTION_DIALECT=postgres -export NOORM_CONNECTION_HOST=localhost -export NOORM_CONNECTION_DATABASE=ci_test -export NOORM_CONNECTION_USER=postgres +export NOORM_CONNECTION_HOST=db.ci.internal +export NOORM_CONNECTION_DATABASE=order_to_cash_ci export NOORM_CONNECTION_PASSWORD="$DB_PASSWORD" -noorm run build # Uses env-only config +noorm run build ``` +[CI/CD](/guide/automation/ci) covers the full pipeline. -## Validating Connections -Before running changes, verify your config connects successfully. In the TUI, highlight a config, press `+` for More, then press `v` to run a full validation: +## Access roles -1. Can noorm connect with the provided credentials? -2. Does the target database exist? -3. Are the sql/changes paths accessible? -The TUI also offers a **server-only** mode that skips the database existence check by connecting to the dialect's system database (PostgreSQL uses `postgres`, MSSQL uses `master`). Use it during initial setup, before the target database is created. +Each config has two roles, one per channel. The `user` channel is you, in the CLI, TUI, or SDK. The `agent` channel is an AI agent, over MCP or the CLI. Change them in the TUI (config list, `e`). -For headless smoke checks, run any command that opens a connection — e.g. `noorm info` or `noorm db explore` — and inspect the exit code or `--json` output. +| Role | Meaning | +|---|---| +| `viewer` | Reads only, no secrets. Default for `agent` | +| `operator` | Writes data. Builds, changes, and `db create` ask first | +| `admin` | Everything allowed, but irreversible operations still ask. Default for `user` | +| `false` | `agent` only. Hides the config: `config list` omits it and MCP answers as if it does not exist | +Every permission has one cell per role, fixed in `src/core/policy/matrix.ts`: -## Exporting and Importing Configs +| Permission | viewer | operator | admin | +|---|---|---|---| +| `explore`, `sql:read` (`SELECT`, `EXPLAIN`) | allow | allow | allow | +| `sql:write` (`INSERT`, `UPDATE`, `DELETE`) | deny | allow | allow | +| `sql:ddl` (`CREATE`, `ALTER`, `DROP`) | deny | deny | allow | +| `run:build`, `run:file`, `run:dir`, `change:run`, `change:ff`, `change:revert`, `db:create`, `db:reset` | deny | confirm | allow | +| `db:truncate`, `change:rm`, `config:rm`, `config:write`, `vault:propagate`, `lock:force` | deny | confirm | confirm | +| `db:destroy` (`db drop`), `db:teardown` | deny | deny | confirm | +| `vault:read`, `secret:read`, `transfer:plan` | deny | allow | allow | +| `vault:write`, `secret:write` | deny | confirm | allow | +| `debug:read` | allow | allow | allow | +| `debug:write` | deny | deny | confirm | -There are two export paths, and they produce different files. +A `confirm` cell never opens for an agent: -**TUI, for sharing with a teammate:** `noorm ui` → Config → `+` for More → `x` exports the highlighted config, `i` imports one. This path encrypts the config and its local secrets for a single recipient, using an ephemeral X25519 key exchange and AES-256-GCM, and writes `.noorm.enc`. It leaves `user` and `password` out on purpose, so the recipient connects with their own credentials. You need the recipient's identity locally before you can export to them, and only their private key can decrypt the file. On import, noorm decrypts with your private key and prompts for your database credentials. +```mermaid +flowchart TD + A["noorm run build"] --> B{"role on this
channel"} + B -->|false| H["config hidden"] + B -->|a role| C{"matrix cell"} + C -->|allow| R["runs"] + C -->|deny| D["refused"] + C -->|confirm| E{"channel"} + E -->|user| F["type yes-prod
or pass --yes"] + E -->|agent| D +``` -**CLI, for backup and cross-machine transfer:** `noorm config export` writes plain JSON, and that JSON carries the connection password. Treat the file as a credential. noorm writes it with `0600` permissions and requires a role that can read secrets, so a `viewer` is refused. +`--yes` and `NOORM_YES=1` satisfy a `confirm` cell. They never open a `deny` cell. -```bash -# Export to stdout or file -noorm config export dev -noorm config export dev --output ./dev-config.json -# Import from file -noorm config import ./dev-config.json -noorm config import ./staging-config.json --force --yes # overwrite existing -``` +### Which channel is a process on? -The exported JSON looks like this: -```json -{ - "name": "dev", - "type": "local", - "isTest": false, - "access": { - "user": "admin", - "agent": "viewer" - }, - "connection": { - "dialect": "sqlite", - "database": "./data/dev.db" - } -} -``` +A process takes the channel from its environment, not from which binary it called: -Overwriting an existing config rewrites its access roles, so `--force` on its own is not enough: `config import` also wants `--yes` (or `NOORM_YES`) to confirm. When you import a file that has no credentials in it, set the password afterward through the TUI's edit screen or with `NOORM_CONNECTION_PASSWORD` at command time. +```mermaid +flowchart TD + S["noorm starts"] --> M{"noorm mcp serve?"} + M -->|yes| AG["agent"] + M -->|no| E{"NOORM_CHANNEL is
user or agent?"} + E -->|yes| SET["that value"] + E -->|no| H{"harness variable
set and non-empty?"} + H -->|yes| AG + H -->|no| US["user"] +``` +The harness variables are `CLAUDECODE`, `CLAUDE_CODE`, `CLAUDE_CODE_ENTRYPOINT`, `CODEX_SANDBOX`, `CURSOR_AGENT`, `GEMINI_CLI`, `AI_AGENT`, and `NOORM_AGENT`. `TERM_PROGRAM`, `CI`, and whether stdout is a terminal are ignored, because they describe the terminal, not the caller. -## Managing Configs from the CLI +::: warning Roles prevent accidents +Roles stop a wrong-database teardown and an agent that shells out after an MCP refusal. They do not stop someone with the credentials, and an agent can set `NOORM_CHANNEL` too. Use database permissions for security. +::: -noorm also has headless commands for scripting and CI: -```bash -# List all configs -noorm config list -noorm config list --json +## How do I check a config? -# Copy a config -noorm config cp dev staging -# Validate connection +```bash noorm config validate prod - -# Export and import (see above) -noorm config export dev --output ./backup.json -noorm config import ./backup.json ``` -`config validate` connects to the database and exits 0 on success, 1 on failure. Use it as a CI health check before running migrations. - - -## Common Workflows - - -### Setting Up a New Developer +``` +prod: VALID + [+] Connection: Connection successful + [+] Name: prod + [+] Database: order_to_cash + [+] Host: db.example.com +``` -1. Clone the repository -2. Run `noorm ui` and import the shared config under Config → `+` → `i` -3. Edit the imported config and set the password (Config → highlight → `e`) -4. Back on the config list, press `+` then `v` to validate the connection +Exit 0 when valid, 1 when not. Every check runs, so one report lists all failures. In the TUI, highlight a config, press `+`, then `v`. -### CI/CD Pipeline +## How do I share a config with a teammate? -```yaml -# GitHub Actions example -env: - NOORM_CONNECTION_DIALECT: postgres - NOORM_CONNECTION_HOST: ${{ secrets.DB_HOST }} - NOORM_CONNECTION_DATABASE: ${{ secrets.DB_NAME }} - NOORM_CONNECTION_USER: ${{ secrets.DB_USER }} - NOORM_CONNECTION_PASSWORD: ${{ secrets.DB_PASSWORD }} - NOORM_YES: 1 -steps: - - uses: actions/checkout@v4 - - run: npm ci - - run: noorm run build - - run: noorm change run -``` +| | TUI export (`+`, then `x`) | `noorm config export` | +|---|---|---| +| File | `.noorm.enc`, encrypted for one recipient | JSON to stdout, or `--output ` | +| Contents | The config and its local secrets, without `user` and `password` | The config, with `password` | +| Needs | The recipient's identity known locally | A role that can read secrets | +| Import | TUI: `+`, then `i` | `noorm config import ` | +`config export --output` writes the file with `0600` permissions. Treat it as a credential. Importing over an existing name needs `--force --yes`, because an overwrite rewrites the config's roles. -### Switching Between Dev and Staging -```bash -# Morning: work on local -noorm config use dev -noorm run build +## What bites -# Afternoon: deploy to staging -noorm config use staging -noorm change run -``` -Or use per-command overrides without switching: +| Symptom | Cause | Fix | +|---|---|---| +| `noorm -c prod run build` fails: `--cwd path is not a directory` | Before the subcommand, `-c` is `--cwd` | `noorm run build --config prod` | +| `"db:teardown" is not allowed on config "prod" (role: operator).` | The role's cell is `deny`, and no flag opens it | Change the role in the TUI, or use a config with `admin` | +| `"run:build" on config "prod" requires confirmation from a human.` | A `confirm` cell is `deny` on the `agent` channel | Run it in your own terminal, or with `NOORM_CHANNEL=user` when you script inside an agent session | +| An imported config is open to `admin` | The file has no `access`, so it gets `user: admin`, `agent: viewer` | Put `access` in the file | -```bash -noorm -c staging change run -``` +## Next -## What's Next? -- [Stages](/guide/environments/stages) - Config templates for teams -- [Secrets](/guide/environments/secrets) - Managing sensitive values -- [CLI Reference](/headless) - Environment variables and command surface for CI/CD +- [Stages](/guide/environments/stages): defaults and required secrets shared by a team +- [Secrets](/guide/environments/secrets): keep sensitive values out of SQL +- [Headless reference](/headless): every `config` flag and exit code +- [MCP](/guide/automation/mcp): the agent side of access roles diff --git a/docs/guide/environments/secrets.md b/docs/guide/environments/secrets.md index 89feefba..b057ea24 100644 --- a/docs/guide/environments/secrets.md +++ b/docs/guide/environments/secrets.md @@ -1,202 +1,93 @@ # Secrets -Sensitive values like database passwords and API keys have no place in config files. noorm provides encrypted secret storage that travels with your project, unlocks with your identity, and injects into templates at runtime. +Keep API keys and other sensitive values out of committed SQL, and still use them when a template runs. - -## What Secrets Are - -Secrets are encrypted key-value pairs stored in your local state file (`.noorm/state/state.enc`). They never touch disk in plain text. - -Secrets are for sensitive values that get **inserted into your database** via SQL templates—not for database connection credentials. Common uses: - -- API keys stored in configuration tables -- Encryption keys for sensitive data columns -- Service credentials (AWS, Stripe, SendGrid) -- OAuth client secrets - -Secrets integrate directly with SQL templates, so you can reference them without exposing values in your source code or version control. - - -## Setting a Secret - -You can manage local secrets through the TUI (masked input, nothing in shell history) or the CLI. - -**TUI:** Launch `noorm ui`, press `c` for configs, highlight a config, and press `k` for its secrets. The list groups required secrets (declared in `settings.yml`) above optional ones and shows which are set, which are missing, and their declared types. Values stay hidden. Press `a` to add one; the form masks `password` and `api_key` input, validates `connection_string` values as URIs, and writes the encrypted result to `.noorm/state/state.enc`. - -Settings → Secrets is a different screen. It edits the secret *declarations* in `settings.yml` (key, type, description, required), not the encrypted values. - -**CLI:** The `noorm secret` namespace manages config-scoped local secrets headlessly: - -```bash -# Set a secret -noorm secret set API_KEY "sk-live-abc123" -noorm secret set DB_PASSWORD "secret" --config prod - -# List secret keys (values never shown) -noorm secret list -noorm secret list --config staging --json - -# Remove a secret -noorm secret rm OLD_KEY --yes -``` - -::: tip Vault vs Local Secrets -**Local secrets** (`noorm secret`) are per-user, per-machine. They override vault values and stay on your disk. - -**Vault secrets** (`noorm vault`) are team-shared and stored encrypted in the database. Anyone with vault access can read them. - -In CI/CD pipelines, push secrets with the vault (`noorm vault set KEY "value"`) or batch-load via `noorm ci secrets --file`. -::: - - -## Config-Scoped vs Global Secrets - -Secrets come in two scopes: - -| Scope | Use Case | Example | -|-------|----------|---------| -| Config-scoped | Per-environment credentials | Database password for `prod` | -| Global | Shared across all configs | Third-party API key | - -**Config-scoped secrets** belong to a single config. When you delete the config, its secrets go with it. - -**Global secrets** persist independently and are available everywhere. - -In templates, access them differently: +The template names the secret. The value never appears in the file: ```sql --- Config-scoped secret -CREATE USER readonly WITH PASSWORD '{%~ $.secrets.DB_PASSWORD %}'; - --- Global secret --- Using API key: {%~ $.globalSecrets.SHARED_API_KEY %} +-- db/sql/09_seeds/02_gateway.sql.tmpl +INSERT INTO app_setting (name, value) +VALUES ('payment_gateway_key', {%~ $.quote($.secrets.PAYMENT_GATEWAY_KEY) %}); ``` -See [Templates](/guide/sql-files/templates) for full syntax reference. +Set the value once per config. `--stdin` keeps it out of shell history and the process table: - -## Using Secrets in Templates - -Secrets inject into SQL templates via the `$` context object: - -```sql --- db/sql/users/create-readonly.sql.tmpl -CREATE USER {%~ $.secrets.READONLY_USER %} -WITH PASSWORD '{%~ $.secrets.READONLY_PASSWORD %}'; - -GRANT SELECT ON ALL TABLES TO {%~ $.secrets.READONLY_USER %}; +```bash +printf %s "$KEY" | noorm secret set PAYMENT_GATEWAY_KEY --stdin --config prod +noorm secret list --config prod ``` -If a template references a missing secret, it fails at runtime. Define your required secrets upfront to catch this early. - - -## Secret Requirements in Settings - -You can require secrets at two levels: **universally** (all configs) or **per-stage** (specific environments). - - -### Universal Secrets - -Define in the root `secrets` section of `settings.yml`: - -```yaml -# .noorm/settings.yml -secrets: - - key: ENCRYPTION_KEY - type: password - description: App-wide encryption key ``` - -Every config requires these secrets, regardless of stage. - - -### Stage Secrets - -Define within a stage to require secrets only for matching configs: - -```yaml -# .noorm/settings.yml -stages: - prod: - description: Production database - secrets: - - key: DB_PASSWORD - type: password - description: Main database password - required: true - - staging: - secrets: - - key: DB_PASSWORD - type: password - - key: DEBUG_KEY - type: string +Secret "PAYMENT_GATEWAY_KEY" set for config "prod". +Secrets for "prod" (1): + PAYMENT_GATEWAY_KEY ``` -A config named `prod` requires its stage secrets plus universal secrets. A config named `dev` requires only universal secrets (plus any dev-stage secrets). - - -### Secret Types +Secrets are for values a template writes into your database. Database connection credentials belong to the [config](/guide/environments/configs). -The `type` field controls input behavior: -| Type | Input Behavior | -|------|----------------| -| `string` | Plain text | -| `password` | Masked, no echo | -| `api_key` | Masked, no echo | -| `connection_string` | Plain text, URI validation | +## Which value does a template get? -Types are hints for the CLI. All secrets are stored identically (encrypted strings). +The first tier that holds the key wins, so a local value overrides the team's: -## Secrets in CI/CD - -The local secret store exists for per-developer overrides — `noorm secret set` is headless, but it writes to `.noorm/state/state.enc` on disk, which a fresh CI runner doesn't have. For non-interactive environments, push values to the [vault](/guide/environments/vault) instead, which is scriptable end-to-end and reachable from a clean checkout: - -```bash -# Write a secret to the vault -noorm vault set DB_PASSWORD "$DB_PASSWORD" +```mermaid +flowchart TD + T["$.secrets.
PAYMENT_GATEWAY_KEY"] --> A{"set on this config?"} + A -->|yes| V["use it"] + A -->|no| B{"global secret?"} + B -->|yes| V + B -->|no| C{"in the vault?"} + C -->|yes| V + C -->|no| X["throw: not found"] +``` -# Pipe a secret to keep it out of the process table and shell history -echo "$DB_PASSWORD" | noorm vault set DB_PASSWORD --stdin +| Tier | Stored in | Set with | Seen by | +|---|---|---|---| +| Config-local | `.noorm/state/state.enc`, per config | `noorm secret set`, or the TUI | You | +| Global-local | `state.enc`, all your configs | No CLI or TUI command writes one | You | +| Vault | The database, encrypted | `noorm vault set` | The team | -# List vault secrets as JSON -noorm vault list --json +`$.secrets` merges all three tiers. `$.globalSecrets` holds only the global tier. A missing key throws `Secret "PAYMENT_GATEWAY_KEY" not found (searched: config-local, global-local, vault)` instead of rendering `undefined`. -# Remove a vault secret -noorm vault rm OLD_API_KEY -``` -Vault secrets sit below local secrets in the [resolution hierarchy](/guide/environments/vault#secret-resolution-hierarchy), so a developer can still override a vault value locally through `noorm ui` without affecting anyone else. +## How do I set a secret? -## Security Notes +| | CLI | TUI | +|---|---|---| +| Set | `noorm secret set [value] [--stdin] [--config ]` | `noorm ui`, `c`, highlight a config, `k`, then `a` | +| List keys | `noorm secret list`, which never prints values | The same screen. Values show masked, with their first characters visible | +| Remove | `noorm secret rm --yes` | The same screen, `d` | -noorm takes several measures to protect your secrets: +Keys start with a letter and contain only letters, numbers, and underscores. The TUI masks `password` and `api_key` input and checks that a `connection_string` parses as a URI. A `viewer` role cannot read or write secrets, and an `operator` needs `--yes` to write one. -1. **Encryption at rest** - Secrets are stored in `.noorm/state/state.enc` using AES-256-GCM encryption -2. **Key derivation** - The encryption key derives from your private identity key via HKDF -3. **Values never displayed** - The CLI shows secret keys only, never values -4. **Masked input** - Password-type secrets use non-echoing input -5. **No logging** - Secret values are never emitted to observer events -6. **Automatic redaction** - The logger masks secret fields if they appear in event data +[Stages](/guide/environments/stages#how-do-i-require-a-secret) declares which secrets a config must have. The TUI lists those first, marked set or missing. -::: warning Don't Commit state.enc -Add `.noorm/state/state.enc` to your `.gitignore`. Its encryption key is derived from your private identity key in `~/.noorm/identity.key`, so nobody else can read the file, and neither can you on a machine that does not have that key. +::: warning The rendered SQL holds the plain value +The database receives the secret as text, and so does anything that logs the rendered SQL. Render a secret only into the column that needs it. ::: -## Deleting Secrets +## What bites + -Remove a local secret through the TUI: Config → highlight the config → `k` → highlight the entry and press `d`. Headlessly, `noorm secret rm --yes`. For team-shared values stored in the vault, use `noorm vault rm `. +| Symptom | Cause | Fix | +|---|---|---| +| `Secret "PAYMENT_GATEWAY_KEY" not found (searched: ...)` | No tier has the key. The vault tier is skipped without vault access | Set it locally, or ask a teammate to run `noorm vault propagate` | +| `Failed to set secret: Secret key "payment-gateway-key" is invalid` | A hyphen or leading digit in the key | Use `PAYMENT_GATEWAY_KEY` | +| A secret set locally is missing in CI | A CI runner has no `state.enc` | Put it in the [vault](/guide/environments/vault), or run `noorm ci secrets --file ` after `noorm ci init` | +| `secret set` says it requires confirmation | The config's role for your channel is `operator` | Pass `--yes` | +| The TUI refuses to delete a secret | The stage requires it, and no vault copy exists | Remove the requirement from `settings.yml`. `noorm secret rm` does not check | +| A teammate's build fails where yours passes | Your local value hides a missing vault value | Run `noorm vault list`, then `noorm vault set` | +| Someone committed `state.enc` | It holds your credentials and secrets. `noorm init` ignores `.noorm/state/`, so the ignore line was removed or init never ran | Restore `.noorm/state/` in `.gitignore` and remove the file from the repo. Your identity key encrypts it, so another machine cannot read it | -A required secret (one declared in `settings.yml`) cannot be deleted outright, only updated. The exception is a required secret that also exists in the vault: deleting the local copy is allowed there, because the vault value takes over. To retire the requirement itself, edit `settings.yml`. +## Next -## What's Next? -- [Stages](/guide/environments/stages) - Environment templates with secret requirements -- [Templates](/guide/sql-files/templates) - Using secrets in dynamic SQL -- [Configs](/guide/environments/configs) - Config-scoped vs global secrets +- [Vault](/guide/environments/vault): share a secret with the team +- [Stages](/guide/environments/stages): require a secret per environment +- [Templates](/guide/sql-files/templates): the full `$` context +- [Headless reference](/headless): every `secret` and `vault` flag diff --git a/docs/guide/environments/stages.md b/docs/guide/environments/stages.md index f2d4b618..f8f1c69f 100644 --- a/docs/guide/environments/stages.md +++ b/docs/guide/environments/stages.md @@ -1,252 +1,127 @@ # Stages -## The Problem +Give every developer the same starting configs, the same access ceiling, and the same list of required secrets for dev, staging, and prod. -Your team has three environments: development, staging, and production. Each has different requirements: - -- **Production** should be protected from accidental teardowns -- **Staging** might require specific API keys for integration testing -- **Development** should default to localhost - -Without a shared definition, every developer configures these differently. Someone creates an unprotected production config. Someone else forgets the staging API key. The inconsistency causes problems down the line. - -Stages solve this. They're templates defined in your project's `settings.yml` that every team member shares. - - -## What Stages Are - -A **stage** is a named template for database configs. A config whose name matches a stage inherits that stage's defaults and requirements. - -Think of stages as blueprints: - -- `dev` stage: localhost defaults, no protection needed -- `staging` stage: requires integration API keys -- `prod` stage: always protected, cannot be deleted - -Your project defines these once in `settings.yml`. Every developer on the team gets the same blueprints. - - -## Defining Stages - -Stages live in `.noorm/settings.yml` under the `stages` key: +A stage is a template in `.noorm/settings.yml`. It is committed, so the team shares it: ```yaml # .noorm/settings.yml stages: dev: - description: Local development database + description: Local order-to-cash database defaults: dialect: postgres host: localhost port: 5432 - staging: - description: Staging environment - defaults: - dialect: postgres - protected: false - secrets: - - key: STRIPE_TEST_KEY - type: api_key - description: Stripe test API key for payment testing - required: true - prod: - description: Production database + description: Production locked: true defaults: dialect: postgres protected: true + secrets: + - key: PAYMENT_GATEWAY_KEY + type: api_key + description: Key the payment sync sends to the gateway + required: true ``` -A config named `staging` then automatically: -- Defaults to the postgres dialect -- Keeps its own access roles, with no ceiling applied (staging data is expendable) -- Declares `STRIPE_TEST_KEY` as required, so the TUI lists it as missing until it is set and any template that reads it fails until then +A [config](/guide/environments/configs) uses the stage with its name. The stage is not stored on the config, so it cannot differ between developers. A config named `prod`: +- is created by the TUI from `defaults` if it does not exist yet +- cannot be deleted, because the stage is `locked` +- resolves to at most `operator` for you and `viewer` for agents, because the stage is `protected` +- shows `PAYMENT_GATEWAY_KEY` as missing in the TUI until someone sets it -## Stage Properties +From the SDK, `createContext({ stage: 'prod' })` picks a stage for a config with another name. -Each stage can define these properties: -| Property | Type | Description | -|----------|------|-------------| -| `description` | string | Human-readable description shown in the TUI | -| `locked` | boolean | If true, configs cannot be deleted (default: false) | -| `defaults` | object | Values applied when creating a config from this stage | -| `secrets` | array | Secrets that must be configured | +## Where do `defaults` apply? -### Defaults +`defaults` is a template for the first config, not a layer under every config. When `noorm ui` opens, it creates a config for each stage that sets `defaults.dialect` and has no config of its name yet, provided this machine has an identity. `host` falls back to `localhost`, `database` to the stage name, and a `protected` stage starts the config at `operator` for you and `viewer` for agents. -Defaults provide initial values when creating a config. Most can be overridden by the developer in the TUI: +From then on, the stored config holds the values. Editing `defaults` later does not change it. A stage selected for an [environment-only](/headless#environment-only-mode) run also passes its `isTest`. -```yaml -stages: - dev: - defaults: - dialect: postgres - host: localhost - port: 5432 - database: myapp_dev -``` -Some defaults are **enforced** and cannot be overridden in the TUI: +## Which value wins? -| Default | Behavior | -|---------|----------| -| `protected: true` | Acts as an access ceiling: whatever `access` the config or the developer sets, the *resolved* access is clamped to at most `{ user: 'operator', agent: 'viewer' }` | -| `dialect` | Cannot be changed after config creation. The TUI edit screen shows it read-only | -This means if your `prod` stage sets `protected: true`, developers cannot create a fully-open production config through the normal workflow. Even picking `admin` in the TUI's access editor gets clamped back down at resolution time. +Each source overrides the one above it, then a protected stage caps `access`: -`isTest` is a seed value, not a ceiling. A stage that sets `isTest: true` supplies the default, but a stored config's own `isTest` wins at resolution, and the TUI edit screen lets a developer uncheck it. - -::: tip Manual Override -The ceiling is enforced every time the config is resolved, so it survives even a manually edited state file — there's no stored value to hand-edit around. The protection is against accidents, not an attempt at real security. -::: - - -### Locked Stages - -Setting `locked: true` prevents configs from being deleted: - -```yaml -stages: - prod: - locked: true - defaults: - protected: true +```mermaid +flowchart TD + D["built-in defaults"] --> C["stored config
state.enc"] + C --> E["NOORM_* variables"] + E --> P{"stage has
protected: true?"} + P -->|yes| K["cap access at
user operator
agent viewer"] + P -->|no| R["effective config"] + K --> R ``` -A locked production config cannot be accidentally removed. Deletion stays blocked until someone edits `settings.yml` or the config stops matching the stage name. - -## Assigning a Config to a Stage +## What can a stage set? -There is no stage picker. A config links to a stage when their names match: a config named `prod` picks up the `prod` stage's defaults, secrets, and lock. Name your configs after your stages and the link happens on its own. -`settings.yml` is the only place that link is defined, so a config's stage is not stored on the config and cannot drift per developer. The SDK can override the lookup with `createContext({ stage: 'prod' })` when a config's name does not match the stage you want. +| Key | Effect | +|---|---| +| `description` | Shown in the TUI | +| `locked: true` | Configs on this stage cannot be deleted | +| `defaults` | `dialect`, `host`, `port`, `database`, `user`, `password`, `ssl`, `isTest`: the template the TUI creates the config from. `protected: true` also caps access on every resolve, below | +| `secrets` | Secrets this stage requires | -## Stage-Specific Secrets - -Stages can require secrets that get inserted into your database via SQL templates. These are for sensitive values like API keys and encryption keys—not database connection credentials. - -```yaml -stages: - staging: - secrets: - - key: STRIPE_TEST_KEY - type: api_key - description: Stripe test API key for payment testing - required: true +### What does `protected: true` cap? - - key: SENDGRID_KEY - type: api_key - description: SendGrid API key for email testing - required: false -``` -Secret types control how the TUI handles input: +It lowers each channel to the ceiling and leaves stricter values alone. The cap applies on every resolve, so editing stored state does not raise it. -| Type | Behavior | -|------|----------| -| `string` | Plain text input | -| `password` | Masked input, no echo | -| `api_key` | Masked input, no echo | -| `connection_string` | Plain text, URI validation | +| Config sets | Resolves to | +|---|---| +| `user: admin` | `operator` | +| `user: viewer` | `viewer` | +| `agent: admin` or `agent: operator` | `viewer` | +| `agent: false` | `false` (hidden) | -**Required secrets** show as missing in the TUI secret list until you set them, and the enforcement point is the template render. Reading `$.secrets.STRIPE_TEST_KEY` when nothing resolved it throws `Secret "STRIPE_TEST_KEY" not found (searched: config-local, global-local, vault)` and the build stops. It never renders as the literal text `undefined`. -**Optional secrets** work the same way at render time, so a template that may run without one should probe first with `{% if ('DEBUG_KEY' in $.secrets) { %}`. The `in` check does not throw. +## How do I require a secret? -### Universal Secrets +List it under a stage, or at the root of `settings.yml` to require it on every stage. A stage entry replaces a root entry with the same key. -Some secrets apply to all configs regardless of stage. Define these at the settings root level: +| Field | Meaning | +|---|---| +| `key` | The name templates read: `$.secrets.PAYMENT_GATEWAY_KEY` | +| `type` | `string` is plain text. `password` and `api_key` are masked. `connection_string` is plain text and must parse as a URI | +| `description` | Hint shown in the value prompt | +| `required` | Defaults to `true` | -```yaml -# Required by ALL configs -secrets: - - key: ENCRYPTION_KEY - type: password - description: Application encryption key for sensitive data +A required secret does not block a command by itself. The TUI lists it as missing, and the first template that reads it fails: -stages: - prod: - # Additional secrets for prod only - secrets: - - key: AWS_SECRET_KEY - type: api_key - description: AWS credentials for S3 uploads ``` - -A production config would need both `ENCRYPTION_KEY` (universal) and `AWS_SECRET_KEY` (stage-specific). - - -## Common Stage Patterns - -Here are patterns that work well for most teams: - - -### Three-Environment Setup - -```yaml -stages: - dev: - description: Local development - defaults: - dialect: postgres - host: localhost - port: 5432 - - staging: - description: Staging server - defaults: - dialect: postgres - protected: false - secrets: - - key: STRIPE_TEST_KEY - type: api_key - required: true - - prod: - description: Production - locked: true - defaults: - dialect: postgres - protected: true - secrets: - - key: STRIPE_LIVE_KEY - type: api_key - required: true +Secret "PAYMENT_GATEWAY_KEY" not found (searched: config-local, global-local, vault) ``` +For an optional secret, probe first with `{% if ('PAYMENT_GATEWAY_KEY' in $.secrets) { %}`. The `in` check does not throw. [Secrets](/guide/environments/secrets) covers where values come from. -### Test Database Stage -```yaml -stages: - test: - description: Ephemeral test database - defaults: - dialect: postgres - host: localhost - port: 5432 - isTest: true -``` +## What bites + -The `isTest: true` flag marks this as a test database. noorm uses this for: +| Symptom | Cause | Fix | +|---|---|---| +| Editing a stage's `host` or `port` does not change the config | `defaults` only seeds a config the TUI creates; the stored config wins after that | Edit the config, or set a `NOORM_CONNECTION_*` variable | +| No config appears for a stage | The stage sets no `defaults.dialect`, or this machine had no identity when `noorm ui` opened | Add `dialect` under `defaults`, or set up an identity, then reopen `noorm ui` | +| A database password is in git history | `defaults.password` sits in the committed `settings.yml` | Leave `password` out of `defaults`. Set it on each config in `noorm ui` | +| `Config "prod" is linked to locked stage "prod" and cannot be deleted` | The stage has `locked: true` | Remove `locked` from the stage in `settings.yml` | +| A config set to `admin` behaves as `operator` | The stage is `protected` | Intended. Put the config on an unprotected stage | -- Conditional build rules (include seed data only in test environments) -- SDK's `requireTest` option (refuses to connect if `isTest` is not true) -- Preventing accidental test operations against production +## Next -## What's Next? -- [Secrets](/guide/environments/secrets) - Managing sensitive values per stage -- [Configs](/guide/environments/configs) - Creating configs from stages -- [Organization](/guide/sql-files/organization) - Using build rules with settings.yml +- [Configs](/guide/environments/configs): roles, channels, and the permission matrix +- [Secrets](/guide/environments/secrets): set values and resolve them in templates +- [Organization](/guide/sql-files/organization): build folders and rules in `settings.yml` diff --git a/docs/guide/environments/vault.md b/docs/guide/environments/vault.md index 763ce2ce..5a5bb295 100644 --- a/docs/guide/environments/vault.md +++ b/docs/guide/environments/vault.md @@ -1,258 +1,124 @@ # Vault -Team-shared secrets need to live somewhere everyone can access them. Local secrets stay on your machine, but what about API keys that the whole team needs? The vault stores encrypted secrets in the database itself, distributing them to team members automatically. +Share a secret with your whole team without chat messages or committed files. - -## Secret Resolution Hierarchy - -When a template references `secrets.API_KEY`, noorm checks three sources in order. The first match wins: - -``` -┌─────────────────────────────────────────────────────────────┐ -│ 1. Config-specific local secret (highest priority) │ -│ Your override for this specific config │ -│ Location: .noorm/state/state.enc │ -├─────────────────────────────────────────────────────────────┤ -│ 2. Global local secret │ -│ Your shared secret across all configs │ -│ Location: .noorm/state/state.enc │ -├─────────────────────────────────────────────────────────────┤ -│ 3. Vault secret (lowest priority) │ -│ Team-shared value from the database │ -│ Location: the vault table │ -└─────────────────────────────────────────────────────────────┘ -``` - -**Local always wins.** This lets you override team secrets for testing without affecting others. - - -### Example: Overriding a Team Secret - -Your team stores the production Stripe key in the vault: - -``` -Vault: STRIPE_KEY = "sk_live_abc123..." -``` - -For local development, you want to use Stripe's test mode. Set a local override through the TUI: `noorm ui` → Config → highlight the config → `k` → `a`, with key `STRIPE_KEY` and value `sk_test_xyz789...`. Headlessly, `noorm secret set STRIPE_KEY "sk_test_xyz789..."`. Local secrets are stored encrypted in your `.noorm/state/state.enc` and never leave your machine. - -Now when templates reference `secrets.STRIPE_KEY`: - -| Context | Resolved Value | Source | -|---------|----------------|--------| -| Your machine | `sk_test_xyz789...` | Local (your override) | -| Teammate's machine | `sk_live_abc123...` | Vault (no local override) | -| CI/CD pipeline | `sk_live_abc123...` | Vault (no local override) | - -Your local override doesn't affect anyone else. - - -### When to Use Each Layer - -| Layer | Use When | -|-------|----------| -| **Config-specific local** | Testing with different credentials per environment | -| **Global local** | Personal API keys you use across all configs | -| **Vault** | Team-shared secrets everyone needs | - - -## How Encryption Works - -The vault uses dual-layer encryption to share secrets securely: - -**Layer 1: Vault Key Distribution** - -A single 256-bit vault key encrypts all secrets. Each team member receives their own encrypted copy of this key: - -1. Generate ephemeral X25519 keypair -2. Perform ECDH with recipient's public key -3. Derive encryption key via HKDF-SHA256 -4. Encrypt vault key with AES-256-GCM -5. Store in user's `encrypted_vault_key` column - -When you connect, noorm decrypts your copy of the vault key using your private identity key. - -**Layer 2: Secret Encryption** - -Individual secrets are encrypted with the shared vault key: - -- AES-256-GCM authenticated encryption -- Random IV per secret -- Stored as JSON in the vault table - -This design means: compromising the database alone doesn't expose secrets. An attacker needs both database access AND a team member's private key. - - -## Initializing the Vault - -The first team member to initialize creates the vault key: +The first developer creates the vault and stores a secret in it: ```bash noorm vault init +printf %s "$KEY" | noorm vault set PAYMENT_GATEWAY_KEY --stdin +noorm vault list ``` -This generates a new vault key and stores it encrypted for your identity. Only one person needs to do this per database. - -If someone else already initialized: - ``` -Vault already initialized but you do not have access. -Ask a team member to propagate. +Vault initialized successfully +Vault secret "PAYMENT_GATEWAY_KEY" set successfully +Vault secrets (1): + PAYMENT_GATEWAY_KEY (set by ana@example.com) ``` +The vault lives in the config's own database, one per config. Templates read it as the last tier of [`$.secrets`](/guide/environments/secrets#which-value-does-a-template-get), so a local value overrides it for one developer. -## Managing Secrets -Once you have vault access, manage secrets with these commands: +## How does a teammate get access? -```bash -# Set a secret -noorm vault set API_KEY "sk-live-..." -noorm vault set DB_PASSWORD "secret123" -# List all secrets (values hidden) -noorm vault list +A new teammate is registered but holds no key. Someone with access grants it: -# Remove a secret -noorm vault rm OLD_API_KEY +```mermaid +sequenceDiagram + participant Ana + participant Database + participant Ben + Ana->>Database: vault init, key sealed to Ana + Ana->>Database: vault set PAYMENT_GATEWAY_KEY + Ben->>Database: vault list, Ben is registered + Database-->>Ben: refused, no key yet + Ana->>Database: vault propagate + Database-->>Ana: pending list, nothing granted + Ana->>Database: vault propagate --yes + Database->>Database: key sealed to Ben's public key + Ben->>Database: vault list + Database-->>Ben: secrets, decrypted with Ben's key ``` -The TUI provides a visual interface at **Vault** (`v` from home): - -- View all vault secrets, each labeled with who set it -- Add a secret with `a`, edit one by selecting it -- See pending users who need access -- One-key propagation (`p`) - -Removing a vault secret is CLI-only: `noorm vault rm `. - - -## Granting Team Access - -New team members register their identity but don't automatically get vault access. Someone with access must propagate the key: +Ben needs an identity first: `noorm identity init`. Without `--yes`, `propagate` lists who is waiting and grants nothing. With it: ```bash -noorm vault propagate +noorm vault propagate --yes ``` -This encrypts the vault key for each pending user's public key. After propagation, they can decrypt vault secrets on their next connection. - -The Vault screen counts them in its header: - ``` -3 secret(s) | 4 user(s) with access (2 pending) +Granted vault access to 1 identities + Ben Ortiz 4a5c14af... ``` -Press `p` there to propagate. noorm lists the recipients it is about to grant access to and asks you to confirm before it writes anything. +In the TUI, `v` from home opens the vault. The header counts waiting teammates, `p` grants after listing them, and `a` adds a secret. -## Copying Secrets Between Configs +## Which commands manage the vault? -Move a secret from one database to another. `vault cp` takes three positional arguments in this order: the key, the source config, and the destination config. All three are required, so copy one key per invocation. -```bash -# Copy one secret -noorm vault cp API_KEY staging production +| Command | Effect | +|---|---| +| `noorm vault init` | Create the vault key and seal it to you. Safe to repeat | +| `noorm vault set [value] [--stdin]` | Add or replace a secret | +| `noorm vault list` | Keys and who set them, never values | +| `noorm vault rm --yes` | Delete a secret. The TUI cannot | +| `noorm vault propagate [--to ] --yes` | Grant the key to waiting teammates | +| `noorm vault cp ` | Copy one secret between configs | -# Overwrite a key that already exists on the destination -noorm vault cp API_KEY staging production --force +Each takes `--json`, and every command but `cp` takes `--config`. Keys follow the [secret key rule](/guide/environments/secrets#how-do-i-set-a-secret). -# Preview without writing -noorm vault cp API_KEY staging production --dry-run -``` -A dry run performs the same preflight as a real copy (vault access on both ends, source key exists, destination collisions) and only skips the write. +## How is it encrypted? -If the destination vault isn't initialized, noorm initializes it automatically using your identity. +| What | Encrypted with | Stored in | +|---|---|---| +| Each secret | One 256-bit vault key, AES-256-GCM, random IV | `noorm.vault`, column `encrypted_value` | +| The vault key, once per teammate | That teammate's public key: ephemeral X25519, HKDF-SHA256, AES-256-GCM | `noorm.identities`, column `encrypted_vault_key` | -## Using Vault Secrets in Templates +A null `encrypted_vault_key` marks a teammate as waiting. Those are the PostgreSQL and SQL Server names. MySQL and SQLite use `__noorm_vault__` and `__noorm_identities__`. Someone with only the database sees ciphertext. Reading a secret takes the database and a granted teammate's private key. -Vault secrets merge into the template context alongside local secrets: -```sql --- db/sql/setup/external-api.sql.tmpl --- Vault secret (or local override if set) -INSERT INTO api_config (provider, key) -VALUES ('stripe', {%~ $.quote($.secrets.STRIPE_KEY) %}); -``` +## How do I copy a secret to another config? -The resolution order means you can: - -1. Store production `STRIPE_KEY` in the vault -2. Override with a test key locally for development -3. Templates work identically in both cases - - -## CI/CD Integration - -Vault commands run non-interactively, so you can manage secrets straight from a pipeline: ```bash -# Initialize vault -noorm vault init - -# Set secret (value as argument) -noorm vault set API_KEY "$API_KEY" - -# List with JSON output -noorm vault list --json - -# Copy a secret between environments -noorm vault cp API_KEY staging production +noorm vault cp PAYMENT_GATEWAY_KEY staging prod --dry-run ``` -Pipe the value in with `--stdin` to keep it out of the process table, shell history, and `set -x` traces: - -```bash -echo "$SECRET_VALUE" | noorm vault set MY_SECRET --stdin +``` +Would copy 1 secrets: PAYMENT_GATEWAY_KEY ``` +A dry run checks vault access on both configs, that the key exists, and collisions, then writes nothing. A real copy initializes the destination vault if it has none. An existing key is skipped unless you pass `--force`. -## Security Considerations - -The vault design provides several security properties: - -1. **End-to-end encryption** - Secrets encrypted in memory, transmitted encrypted, stored encrypted -2. **Zero-knowledge database** - Database administrators see only ciphertext -3. **Per-user key distribution** - Propagation re-encrypts the same vault key for each recipient, so every user holds unique ciphertext -4. **No shared secrets in transit** - ECDH derives keys without transmitting them -5. **Authenticated encryption** - AES-256-GCM detects tampering - -Limitations to understand: - -- Team members with vault access can read all vault secrets -- Removing access requires regenerating the vault key (not yet supported) -- Lost private keys mean lost vault access (identity must be re-registered) +## How does a CI runner get access? -## Database Schema -The vault uses two database structures. Their names depend on the dialect: PostgreSQL and SQL Server put noorm's tracking tables in a dedicated `noorm` schema (`noorm.vault`, `noorm.identities`), while MySQL and SQLite have no schemas and keep the prefixed forms (`__noorm_vault__`, `__noorm_identities__`) in the default schema. +A developer with access runs `noorm ci identity enroll --config prod --name "GitHub CI" --email ci@example.com --yes` once. It registers the runner's identity and seals the key to it. [CI/CD](/guide/automation/ci) covers the runner side. -**The vault table:** -| Column | Type | Description | -|--------|------|-------------| -| `id` | int | Primary key | -| `secret_key` | string | Secret name (unique) | -| `encrypted_value` | text | AES-256-GCM ciphertext (JSON) | -| `set_by` | string | Identity who set it | -| `created_at` | timestamp | Creation time | -| `updated_at` | timestamp | Last update time | +## What bites -**The identities table extension:** -| Column | Type | Description | -|--------|------|-------------| -| `encrypted_vault_key` | text | Vault key encrypted for this user (nullable) | +| Symptom | Cause | Fix | +|---|---|---| +| `No vault access. Wait for a team member to propagate access.` | Your identity holds no sealed key yet | Ask a teammate to run `noorm vault propagate --yes` | +| `vault rm` exits 1: cannot be undone | The vault keeps no history, so it needs `--yes` | Check the key, then pass `--yes` | +| A `vault set` or `vault init` asks for confirmation | Your role on the config is `operator` | Pass `--yes` | +| A former teammate who still reaches the database can read the vault | A grant cannot be revoked, and no command rotates the vault key | Change the secret at its source, then `vault set` the new value | +| Everyone with access reads every secret | The vault has one key for all of it | Keep per-person values in local secrets | -A null `encrypted_vault_key` means the user is pending vault access. +## Next -## What's Next? -- [Secrets](/guide/environments/secrets) - Local secret storage -- [Identity](/cli/identity) - The keypair that lets teammates encrypt secrets to you -- [Templates](/guide/sql-files/templates) - Using secrets in dynamic SQL +- [Secrets](/guide/environments/secrets): resolution order and local overrides +- [Configs](/guide/environments/configs): the `vault:*` permissions per role +- [Identity](/cli/identity): create the keypair a teammate is registered with +- [CI/CD](/guide/automation/ci): enroll a runner and load job secrets diff --git a/docs/guide/relational-design.md b/docs/guide/relational-design.md index dd71a016..1d43c75f 100644 --- a/docs/guide/relational-design.md +++ b/docs/guide/relational-design.md @@ -1,299 +1,206 @@ --- title: The case for proper relational design -description: Why inherited keys and basetype-subtypes beat the surrogate-ID-everywhere pattern ORMs push you toward. +description: Why inherited keys and a basetype table let the database refuse bad rows, shown on the order-to-cash schema, with a measured cost for the alternative. --- # The case for proper relational design -noorm does not stop you from designing a database the way an ORM would. It just stops making that the only option you can reach. This page explains what you get back once the tool is out of the way. - +A child table that inherits its parent's key lets the database refuse rows that break a business rule. A surrogate ID on every table leaves that refusal to application code. noorm runs the SQL you write, so which one you get is your decision. + +`party_id` enters at `party` and reaches `payment_allocation` down two branches, so the table stores it once and both parents must agree on it: + +```mermaid +erDiagram + party { + int party_id PK + } + payment { + int party_id PK, FK + int payment_no PK + } + invoice { + int party_id PK, FK + int invoice_no PK + } + invoice_line { + int party_id PK, FK + int invoice_no PK, FK + int line_no PK + } + payment_allocation { + int party_id PK, FK + int payment_no PK, FK + int invoice_no PK, FK + int line_no PK, FK + } + party ||--o{ payment : "makes" + party ||--o{ invoice : "owes on" + invoice ||--|{ invoice_line : "contains" + payment ||--|{ payment_allocation : "is applied as" + invoice_line ||--o{ payment_allocation : "is settled by" +``` -## Inherited keys -ORMs push you toward a pattern: every table gets a surrogate ID, relationships happen through foreign keys, and you join your way back to find what you need. It works—until you're seven joins deep trying to figure out which user owns a deeply nested entity, and your messy left joins are adding NULL rows or creating cartesian products. +## What does an inherited key enforce? -Proper relational design uses **inherited keys**. Instead of giving every entity an independent identity, child entities inherit their parent's key as part of their own. -**Example: A todo list** +`payment_allocation` has two parents, `payment` and `invoice_line`. Both keys start with `party_id`, so the table holds it once and both foreign keys read it: ```sql -CREATE TABLE users ( - user_id serial PRIMARY KEY, - email text NOT NULL UNIQUE, - created_at timestamptz NOT NULL DEFAULT now() -); - -CREATE TABLE todos ( - user_id int NOT NULL REFERENCES users (user_id), - created_at timestamptz NOT NULL, - title text NOT NULL, - - PRIMARY KEY (user_id, created_at) +CREATE TABLE payment_allocation ( + party_id integer NOT NULL, + payment_no integer NOT NULL, + invoice_no integer NOT NULL, + line_no integer NOT NULL, + amount numeric(12,2) NOT NULL, + CONSTRAINT payment_allocation_pk + PRIMARY KEY (party_id, payment_no, invoice_no, line_no), + CONSTRAINT payment_allocation_isdrawnfrom_payment + FOREIGN KEY (party_id, payment_no) + REFERENCES payment (party_id, payment_no), + CONSTRAINT payment_allocation_settles_invoice_line + FOREIGN KEY (party_id, invoice_no, line_no) + REFERENCES invoice_line (party_id, invoice_no, line_no) ); +``` -CREATE TABLE todo_items ( - user_id int NOT NULL, - created_at timestamptz NOT NULL, - item_index int NOT NULL, - body text NOT NULL, - done boolean NOT NULL DEFAULT false, +Payment 1 belongs to party 1. Invoice 1, line 1 belongs to party 2. Settling one with the other needs a row whose `party_id` is both: - PRIMARY KEY (user_id, created_at, item_index), - FOREIGN KEY (user_id, created_at) REFERENCES todos (user_id, created_at) -); +```sql +INSERT INTO payment_allocation (party_id, payment_no, invoice_no, line_no, amount) +VALUES (1, 1, 1, 1, 49.00); ``` -No `todo_id`, no `todo_item_id`. A todo is identified by whose it is and when it was made; an item by which list it sits on and where in that list. - -Two things follow, and neither costs any application code. +``` +ERROR: insert or update on table "payment_allocation" violates foreign key constraint "payment_allocation_settles_invoice_line" +DETAIL: Key (party_id, invoice_no, line_no)=(1, 1, 1) is not present in table "invoice_line". +``` -`todo_items` carries `user_id` on every row, two levels down from where it was introduced. Finding the owner of an item is a column read, not a join. +Two foreign keys enforce the rule. No trigger, no application check. The same shape puts `party_id` on every row below `party`, so finding who owes on an invoice line is a column read. -The composite foreign key `(user_id, created_at)` makes cross-tenant corruption unrepresentable. An item cannot reference a list belonging to a different user, because the `user_id` in the item's own key has to match the one in the list's key. Compare the surrogate version: +A surrogate ID on every table gives the rule no place to live: ```sql --- The ORM shape. todo_id alone says nothing about who owns the list. -CREATE TABLE todo_items ( - todo_item_id serial PRIMARY KEY, - todo_id int NOT NULL REFERENCES todos (todo_id), - body text NOT NULL +CREATE TABLE payment_allocation ( + payment_allocation_id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + payment_id integer NOT NULL REFERENCES payment (payment_id), + invoice_line_id integer NOT NULL REFERENCES invoice_line (invoice_line_id), + amount numeric(12,2) NOT NULL ); ``` -Here `todo_id` points at a list, and nothing in the schema relates that list to a user. Every query that needs the owner joins for it, and every write that must not cross tenants is guarded in application code you have to remember to write. - -*Try working that into your ORM. I'll wait...* - - -### What that model looks like - -The diagram below is generated from the schema above, not drawn. It is a live [ignatius](/modeling/) export: click an entity for its columns, sample rows, and prose. - -
- -
+Each foreign key checks that its row exists. Nothing relates the payment's party to the invoice line's party, so the database accepts any pair. Every write that must not cross parties is guarded in code you have to remember to write, and finding the party takes a join. -Note the two node shapes. `users` is square because nothing above it supplies its identity. `todos` and `todo_items` are rounded because they cannot be identified without their parent. Nobody labelled those. ignatius derived them from the key shape, which is the same information the `PRIMARY KEY` clauses above carry. -The hash marks on each edge mean the relationship is **identifying**: the parent's key is inside the child's, so the child depends on it for identity rather than merely pointing at it. +## Why is there a party table instead of a customer type and ID? -## ~~Polymorphism~~ Basetype-subtypes - -ORMs love polymorphic associations: a `comments` table with `commentable_type` and `commentable_id`. Fast, flexible—and completely breaks referential integrity. Complex app logic, no foreign keys, slow and awkward statistics, and even more awkward queries. - -Here is the shape they push you toward: +An order is placed by a person or a business. A common shape for one-of-several parents is a type column and an ID column: ```sql --- No foreign key is possible here: the table this --- points at changes from row to row. -CREATE TABLE comments ( - comment_id serial PRIMARY KEY, - -- 'Post', 'Photo', 'Comment', … - commentable_type text NOT NULL, - -- references nothing at all - commentable_id int NOT NULL, - body text NOT NULL +CREATE TABLE sales_order ( + sales_order_id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + customer_type text NOT NULL, -- 'PERSON' or 'BUSINESS' + customer_id integer NOT NULL -- no foreign key is possible ); ``` -`commentable_id` is an integer the database has no opinion about. Point it at a row that was deleted, or at a `Photo` id while `commentable_type` says `Post`, and nothing objects. Counting comments per post means filtering on a string first. - -Modelled out, the whole domain looks like this: - -
- -
+A foreign key has one target table, and `customer_id` has two. Point it at a deleted person, or at a business ID while `customer_type` says `PERSON`, and nothing objects. -Seven tables and one edge. `profiles` points at `users`, because a profile belongs to exactly one kind of thing and so was allowed to keep a foreign key. Everything else wanted to point at more than one kind, so it gave up pointing at all: `posts`, `photos`, `comments` and `tags` float unconnected, holding a type string and an integer nobody checks. - -That is the diagram the ORM shape produces. Compare it with the one at the end of this section. - -Proper relational design solved this years ago with **basetype-subtypes**: +A basetype gives the order one target. `party` holds the identity every customer shares, and `person` and `business` hold what is specific to each. A subtype's key is its basetype's key: +```mermaid +erDiagram + party_type ||--o{ party : "classifies" + party ||--o| person : "is realized as" + party ||--o| business : "is realized as" + party ||--o{ sales_order : "places" ``` -independent entities: user, group -dependent entities: profile -basetype-subtypes: post → user_post, group_post - photo → user_photo, group_photo, profile_photo, user_post_photo, ... - comment → user_comment, group_comment, post_comment, comment_comment, ... - tag → post_tag, photo_tag, comment_tag, ... -``` - -Polymorphism looks cheaper because it never makes you write this list down: one `comments` table absorbs every case, and the count of things you are actually modelling stays hidden in a string column. Here the count is on the page. Photo alone fans out four ways. - -More tables, and every one of them is a constraint the database enforces. The mechanism is the same for each, so here is one cluster in full: ```sql --- The owner types are rows, not a value list baked into DDL. --- Admitting ORGANIZATION later is an INSERT. -CREATE TABLE post_owner_types ( - owner_type text PRIMARY KEY -); - -INSERT INTO post_owner_types (owner_type) VALUES ('USER'), ('GROUP'); - -CREATE TABLE posts ( - post_id serial PRIMARY KEY, - owner_type text NOT NULL REFERENCES post_owner_types (owner_type), - body text NOT NULL, - posted_at timestamptz NOT NULL DEFAULT now() +CREATE TABLE party_type ( + code text NOT NULL, + description text NOT NULL, + CONSTRAINT party_type_pk PRIMARY KEY (code) ); -CREATE TABLE user_posts ( - post_id int PRIMARY KEY REFERENCES posts (post_id), - user_id int NOT NULL REFERENCES users (user_id) +CREATE TABLE party ( + party_id integer GENERATED ALWAYS AS IDENTITY, + party_type text NOT NULL, + name text NOT NULL, + CONSTRAINT party_pk PRIMARY KEY (party_id), + CONSTRAINT party_isclassifiedby_party_type + FOREIGN KEY (party_type) REFERENCES party_type (code) ); -CREATE TABLE group_posts ( - post_id int PRIMARY KEY REFERENCES posts (post_id), - group_id int NOT NULL REFERENCES groups (group_id) +CREATE TABLE person ( + party_id integer NOT NULL, + given_name text NOT NULL, + family_name text NOT NULL, + CONSTRAINT person_pk PRIMARY KEY (party_id), + CONSTRAINT person_isa_party + FOREIGN KEY (party_id) REFERENCES party (party_id) ); ``` -Two things to notice. - -The set of owner types is a table. A `CHECK (owner_type IN (…))` list would have made admitting `ORGANIZATION` a constraint rewrite on the basetype; here it is a row. - -Each subtype's key *is* the basetype's key, so a post gets at most one row in any subtype table and every one of those rows is anchored to a real `posts` row. Keeping a post out of two different subtype tables at once is the one rule this shape leaves to you if you're using postgres. Enforcement is fully possible in TSQL via cross-table constraints. - -`user_id` and `group_id` are real foreign keys to real tables, which the polymorphic version gives up. Counting a group's posts is a join, not a join plus a string comparison. - -Repeat that for photo, comment and tag and you get the list above. It is more tables than the polymorphic version. The tables are where the rules live, so they are not also living in application code you have to keep correct. - -Each relationship gets its own table with proper constraints against its parent. A `user_post` has a foreign key to `user` and `post`. A `group_photo` has a foreign key to `group` and `photo`. No nulls, no type string you have to read to know what you are holding, no ambiguity. - -A row exists or it does not, so nothing has to be computed to find out. Statistics are straightforward. Queries are clean. The database enforces integrity at every level. Illegal states become impossible. The trade is more tables for less application logic. - - -### What that model looks like - -
- -
- -All twenty entities, exactly as listed above. Four diamonds, one per cluster, each with an X marking it **exclusive**: a post is one or the other, never both. That X is the model stating the one rule the tables leave to you. - -`post_owner_types` and its siblings are not among the twenty. A table whose only job is to enumerate the legal values of a column is a domain rather than an entity: it describes what a value may be, not a thing the business has. It belongs in the data dictionary, not on the graph. - -Solid lines run to a basetype, dashed ones to an owner. Dashed means non-identifying, so a photo's subject is a fact *about* it rather than part of what identifies it. - -Count the edges. Every line is a foreign key the database enforces. - -Scroll back to the polymorphic diagram. Twenty entities bound by constraints, against seven that float. Fewer tables did not remove the relationships. It removed the database's knowledge of them, and moved every one into code you have to write, test, and keep correct. - - - -## Metrics, and the truth at 1M rows - -The usual claim is that polymorphism is slower. Here is a metric both designs have to answer, on a real database, so we can stop guessing. - -**Comments per group, across every group.** Polymorphic: +`business` has the same shape. The kinds of party are rows in `party_type`, so admitting a new kind is an `INSERT`, where a `CHECK` list would be a constraint rewrite. `sales_order.party_id` is now a real foreign key: ```sql -SELECT p.owner_id AS group_id, count(*) AS comments -FROM posts p -JOIN comments c - ON c.commentable_id = p.post_id - AND c.commentable_type = 'POST' -WHERE p.owner_type = 'GROUP' -GROUP BY p.owner_id -ORDER BY comments DESC -LIMIT 10; +INSERT INTO sales_order (party_id, order_no) VALUES (999, 1); ``` -Two string comparisons before any counting starts, and the join predicate has to carry the type or it silently counts photos as posts. Basetype-subtypes: - -```sql -SELECT gp.group_id, count(*) AS comments -FROM group_posts gp -JOIN post_comments pc ON pc.post_id = gp.post_id -GROUP BY gp.group_id -ORDER BY comments DESC -LIMIT 10; +``` +ERROR: insert or update on table "sales_order" violates foreign key constraint "sales_order_isplacedby_party" +DETAIL: Key (party_id)=(999) is not present in table "party". ``` -No type filter anywhere. `group_posts` is already only group posts, `post_comments` is already only comments on posts. The tables did the filtering when the rows were written. - +One rule stays open: nothing in these keys stops a party from having both a `person` row and a `business` row. The model marks the cluster exclusive, and enforcing that takes one more constraint. -### What the numbers say +[ignatius](/modeling/) draws the cluster from the same key shape: a diamond joins the basetype to its members, and an X marks it exclusive. -PostgreSQL 17, 1,000,000 posts and 5,000,000 comments, both designs indexed equivalently, best of three runs. + -Answering the metric above, one join: -| | Polymorphic | Basetype-subtypes | -|---|---|---| -| Time | 149.9 ms | 134.6 ms | -| Pages read from disk | 7,852 | 5,022 | +## What does a type-and-ID column cost on reads? -Close enough that nobody should pick a schema over it. -Now add a hop. Comments on a group's posts, plus the tags on those comments: +The measurement used a separate social-graph schema, built both ways: comments and tags in shared tables keyed by a type column and an ID column, against per-parent tables such as `post_comments` and `comment_tags`. PostgreSQL 17, 1,000,000 posts and 5,000,000 comments, both designs indexed equivalently, best of three runs. -```sql --- Polymorphic. Every hop re-states its type filter. -SELECT p.owner_id, count(DISTINCT c.comment_id) AS comments, count(t.tag_id) AS tags -FROM posts p -JOIN comments c ON c.commentable_id = p.post_id AND c.commentable_type = 'POST' -LEFT JOIN tags t ON t.target_id = c.comment_id AND t.target_type = 'COMMENT' -WHERE p.owner_type = 'GROUP' -GROUP BY p.owner_id; -``` +| Query | Design | Time | Pages read | +|---|---|---|---| +| Comments per group (one join) | Type and ID | 149.9 ms | 7,852 | +| Comments per group (one join) | Subtype tables | 134.6 ms | 5,022 | +| Comments on a group's posts, plus their tags (two joins) | Type and ID | 638.2 ms | 47,765 | +| Comments on a group's posts, plus their tags (two joins) | Subtype tables | 452.7 ms | 4,638 | -```sql --- Basetype-subtypes. No type filter anywhere. -SELECT gp.group_id, count(DISTINCT pc.comment_id) AS comments, count(ct.tag_id) AS tags -FROM group_posts gp -JOIN post_comments pc ON pc.post_id = gp.post_id -LEFT JOIN comment_tags ct ON ct.comment_id = pc.comment_id -GROUP BY gp.group_id; -``` +At one join the times are 15 ms apart. The second join took the type-and-ID query from 7,852 to 47,765 pages read and left the subtype query flat. -| | Polymorphic | Basetype-subtypes | -|---|---|---| -| Time | 638.2 ms | 452.7 ms | -| Pages read from disk | **47,765** | **4,638** | +The likely reason is that each type-and-ID join re-derives the row kind at read time, by sifting a large shared table for the right type. A subtype table holds only the right rows, because the write path sorted them once. A third join would pay the sift again. That is inference from where the work happens, and no third join was measured. -Adding one hop took the polymorphic query from 7,852 pages to 47,765, a six-fold jump. The same hop left the relational query flat, 5,022 to 4,638. +The subtype design used more disk, 803 MB against 638 MB, because it has more tables and indexes and stores inherited keys more than once. The two-join gap is 1.4x in wall-clock time because the data mostly fits in cache. -The reason is mechanical. Every polymorphic join has to re-derive the same fact at read time: sift a large shared table for the fraction of rows that are the right *kind*. Two hops means doing that twice, over five million comments and two million tags, and the intermediate results are large enough that both designs spill to temp files. -The subtype version never sifts anything. `post_comments` is already only comments on posts. `comment_tags` is already only tags on comments. Each join lands on a table that is exactly the rows wanted, because the write path sorted that out once instead of every query sorting it out again. +## What happens on delete? -That cost is per hop, so a third or fourth join pays it again while the subtype side does not. Only the two measurements above were taken; the rest follows from where the work happens. -Two caveats. The relational design uses **more** disk, 803 MB against the polymorphic 638 MB, because more tables mean more indexes and inherited keys are stored more than once. And on wall clock the two-hop gap is 1.4x, not 10x, because this data still mostly fits in cache. The read counts are what tell you which way that goes when it stops fitting. - - -### The part that has no benchmark - -Delete a post that has comments. +Delete an invoice line that an allocation settles: ```sql -DELETE FROM posts WHERE post_id = 2; +DELETE FROM invoice_line WHERE party_id = 2 AND invoice_no = 1 AND line_no = 1; ``` -Polymorphic: `DELETE 1`. It works. The comment now points at a post that does not exist, and the metric still reports **1,000,000** post comments, exactly as before. The number is wrong and nothing anywhere says so. - -Basetype-subtypes: the database refuses. - ``` -ERROR: update or delete on table "posts" violates foreign key -constraint "group_posts_post_id_fkey" +ERROR: update or delete on table "invoice_line" violates foreign key constraint "payment_allocation_settles_invoice_line" on table "payment_allocation" +DETAIL: Key (party_id, invoice_no, line_no)=(2, 1, 1) is still referenced from table "payment_allocation". ``` -You can find the orphans in the polymorphic design. It costs an anti-join across all five million comments, 154 ms here: +A type-and-ID pair cannot carry a foreign key, so the equivalent delete succeeds and the child row points at nothing. Totals built from it are wrong and nothing reports it. Finding such orphans takes an anti-join per pair, 154 ms across the five million comments above, and it reports damage already done. -```sql -SELECT count(*) FROM comments c -WHERE c.commentable_type = 'POST' - AND NOT EXISTS (SELECT 1 FROM posts p WHERE p.post_id = c.commentable_id); -``` -But you have to know to ask, on every polymorphic column, forever, and the answer only tells you about damage already done. That query does nothing for your product; it is rent. +## Next -So you spend more disk and get back two things: queries whose cost grows more slowly as they get deeper, and the guarantee that the number on the dashboard is true. The polymorphic version spends less disk and pays for it per query, per hop, and in the reconciliation jobs it obliges you to write. -You pay for bad relational design later, in complexity and bugs. Sometimes you pay for it in metrics nobody knew to distrust. +- [From model to SQL](/modeling/model-to-sql): how each entity file becomes a `CREATE TABLE` +- [Entities and key inheritance](/modeling/entities): the entity format and subtype clusters +- [Concepts](/getting-started/concepts): how `db/sql/` and `db/changes/` divide the work diff --git a/docs/guide/sql-files/execution.md b/docs/guide/sql-files/execution.md index b89567ff..487101fd 100644 --- a/docs/guide/sql-files/execution.md +++ b/docs/guide/sql-files/execution.md @@ -1,265 +1,178 @@ # Execution -## How Execution Works - -When you run SQL files with noorm, every file goes through a checksum-based change detection process. This is the core value proposition: files that haven't changed don't run again. - -Here's what happens under the hood: - -1. noorm computes a SHA-256 checksum of the SQL that will actually run — for a `.sql.tmpl`, that is the *rendered* output, not the file on disk -2. It checks the tracking database for a previous execution record -3. If the file is new, changed, previously failed, or belonged to an operation a teardown marked stale, it runs -4. If unchanged and successful, it's skipped -5. After execution, the result and new checksum are recorded - -Hashing the rendered output is what makes a template re-run when its data file, config, or secrets change even though its own bytes did not. The flip side: a template whose render is genuinely non-deterministic — one calling `$.now()` or `$.uuid()` — produces new SQL every time and therefore re-runs every build. That is the correct answer for a file whose output really is different each time; use a fixed value if you want it to settle. - -This makes builds idempotent. Run the same command twice—unchanged files won't re-execute. - - -## run build - -The primary command for executing your schema: +Run your SQL files against a database and re-run only the ones whose SQL changed. ```bash noorm run build ``` -This executes all SQL files in your schema directory (defined in settings). Files are processed alphabetically by path, which means you can control execution order with prefixes: - ``` -db/sql/ -├── 00_extensions/ -│ └── 001_extensions.sql # Runs first -├── 01_tables/ -│ ├── 001_users.sql # Runs second -│ └── 002_posts.sql # Runs third -└── 02_views/ - └── 001_active_users.sql # Runs last +• log Build completed status=success filesRun=5 filesSkipped=0 filesFailed=0 durationMs=53.388000000000034 ``` -Output shows what ran and what was skipped. Every line carries a timestamp and a level marker ahead of the message: +Run it again and every file is skipped: -``` -Starting schema build (3 files) -Executed db/sql/01_tables/001_users.sql (14ms) -Skipped db/sql/01_tables/002_posts.sql (unchanged) -Executed db/sql/02_views/001_active_users.sql (9ms) -Build complete: 2 run, 1 skipped (412ms) +```bash +noorm run build ``` -Add `--json` to get the same events as newline-delimited JSON on stderr and the batch result on stdout. +``` +• log /srv/shop/db/sql/01_tables/01_party.sql (skipped: unchanged) +• log /srv/shop/db/sql/01_tables/02_sales_order.sql (skipped: unchanged) +• log /srv/shop/db/sql/01_tables/03_product.sql (skipped: unchanged) +• log /srv/shop/db/sql/02_views/01_open_orders.sql (skipped: unchanged) +• log /srv/shop/db/sql/09_seeds/01_products.sql.tmpl (skipped: unchanged) +• log Build completed status=success filesRun=0 filesSkipped=5 filesFailed=0 durationMs=8.991874999999993 +``` +Each line also starts with a `[YY-MM-DD HH:mm:ss]` timestamp, left out here. -## run file -Execute a single SQL file: +## Which command runs what? -```bash -noorm run file db/sql/01_tables/001_users.sql -``` -Useful for testing a specific file or re-running one file after a quick fix. The same change detection applies—if the file hasn't changed since its last successful run, it won't execute unless you use `--force`. +| Command | Runs | Applies `build.include`, `build.exclude`, rules | +|---|---|---| +| `noorm run build` | every file under `paths.sql` | yes | +| `noorm run file ` | one file | no | +| `noorm run dir ` | every file under a directory | no | +| `noorm run exec ` | a directory, or the files a glob matches | no | +| `noorm run files --paths a.sql,b.sql` | the listed files, in the order listed | no | +| `noorm run preview ` | nothing: prints a template's rendered SQL | n/a | +| `noorm run inspect ` | nothing: lists what a template can read from `$` | n/a | +Directories and globs run in path order, the order [Organization](/guide/sql-files/organization) describes. The five commands that run SQL take these flags: -## run dir +| Flag | Effect | +|---|---| +| `--force`, `-f` | Run every file without the checksum test | +| `--dry-run` | Render to `tmp/`. Run nothing and record nothing | +| `--config`, `-c` | Use a config other than the active one | +| `--json` | Print the result as JSON on stdout | -Execute all files in a specific directory: +`preview` and `inspect` take `--config` and `--json` only. Exit codes and JSON shapes are in [`noorm run`](/cli/run). -```bash -noorm run dir db/sql/02_views -``` - -Processes all `.sql` and `.sql.tmpl` files in that directory (and subdirectories), alphabetically. This is helpful when you want to rebuild just views or just a subset of your schema. +## How does noorm decide to skip a file? -## Force Mode -Skip change detection entirely with `--force`: +A file skips only when its newest record succeeded on the same SQL. Everything else runs: -```bash -noorm run build --force +```mermaid +flowchart TD + A["render, then hash the
SQL with SHA-256"] --> B{"--force?"} + B -->|yes| RUN[run] + B -->|no| C{"record for this path
and this config?"} + C -->|no| RUN + C -->|yes| D{"last attempt failed
or never started?"} + D -->|yes| RUN + D -->|no| E{"marked stale by
a teardown?"} + E -->|yes| RUN + E -->|no| F{"checksum differs?"} + F -->|yes| RUN + F -->|no| SKIP[skip] ``` -Every file executes, regardless of whether it changed. Use this when: +The hash covers the SQL the database would receive. For a `.sql.tmpl` that is the rendered output, so a changed data file, config, or secret re-runs the template while its own bytes stay the same. -- You need to rebuild everything after a database restore -- Troubleshooting an issue and want fresh execution -- External changes happened that noorm doesn't know about +One lookup decides the whole batch before anything runs. A skipped file leaves no row, and a batch where every file skips creates no operation record. Records are per config and per file path, whichever command wrote them, so `run file` after `run build` skips an unchanged file. -In the TUI, the force option appears when you select a run command. +## What does --dry-run do? -## Dry Run Mode -Preview what would execute without actually running anything: +It renders every file, templates included, and writes the result under `tmp/`, mirroring the source path and dropping `.tmpl`. Nothing runs and nothing is recorded. The skip test does not apply, so unchanged files render too: ```bash noorm run build --dry-run ``` -This renders all SQL files (including templates) and writes them to a local `tmp/` directory that mirrors your source structure: - ``` -Source: Dry run output: -db/sql/02_views/001_my_view.sql → tmp/db/sql/02_views/001_my_view.sql -db/sql/03_seeds/001_users.sql.tmpl → tmp/db/sql/03_seeds/001_users.sql +• log /srv/shop/db/sql/09_seeds/01_products.sql.tmpl (success, dry-run) -> /srv/shop/tmp/db/sql/09_seeds/01_products.sql +• log Build (dry-run) completed status=success filesRun=5 filesSkipped=0 filesFailed=0 durationMs=11.70675 dryRun=true ``` -Templates are fully rendered with your current config context. The `.tmpl` extension is stripped from output files. Nothing is written to the database and nothing is tracked. - -::: warning Dry run output contains your secrets -Rendering resolves every secret the template reads, in plaintext, into the files under `tmp/`. noorm writes them owner-only (`0600`, in `0700` directories), but `noorm init` does not add `tmp/` to `.gitignore`. Add it yourself, and delete the output when you're done reading it. +::: warning Dry-run files hold your secrets +Rendering resolves every secret a template reads and writes it to `tmp/` in plain text. The files are owner-only (`0600`, in `0700` directories), but `noorm init` does not add `tmp/` to `.gitignore`. Add it, and delete the output when you finish. ::: -**When to use dry run:** +## What gets tracked -| Scenario | Command | -|----------|---------| -| Inspect rendered templates | `noorm run build --dry-run` | -| Review before production deploy | `noorm run build --dry-run -c production` | -| Debug one template | `noorm run preview seed.sql.tmpl` | -| CI/CD validation step | `noorm run build --dry-run` | +Each batch that runs writes to two tables in your database. PostgreSQL and SQL Server keep them in a `noorm` schema, as `noorm.change` and `noorm.executions`. MySQL and SQLite name them `__noorm_change__` and `__noorm_executions__`. -## What Gets Tracked +| Table | Columns | +|---|---| +| change (one row per batch) | `id`, `name`, `change_type`, `direction`, `checksum`, `executed_at`, `executed_by`, `config_name`, `cli_version`, `status`, `error_message`, `duration_ms` | +| executions (one row per file) | `id`, `change_id`, `filepath`, `file_type`, `checksum`, `cli_version`, `status`, `error_message`, `skip_reason`, `duration_ms` | -noorm maintains two tables in your database to track execution history. On PostgreSQL and SQL Server they live in a dedicated `noorm` schema; MySQL and SQLite have no schemas, so they keep prefixed names in the default schema. +`change_type` is `build`, `run`, or `change`. `filepath` is relative to the project root. Before the first file runs, every file in the run set gets a `pending` row, so an interrupted build still shows what it intended. The next build treats a `pending` row as never started. -**`noorm.change`** (`__noorm_change__` on MySQL and SQLite) - Operation records: -| Field | Description | -|-------|-------------| -| `name` | Operation identifier (e.g., `build:2024-01-15T10:30:00.000Z`) | -| `change_type` | `'build'`, `'run'`, or `'change'` | -| `executed_by` | Identity string (who ran it) | -| `config_name` | Which config was used | -| `status` | `'pending'`, `'success'`, `'failed'`, `'reverted'`, `'stale'` | +## What happens when a file fails? -**`noorm.executions`** (`__noorm_executions__` on MySQL and SQLite) - Individual file records: -| Field | Description | -|-------|-------------| -| `change_id` | FK to parent operation | -| `filepath` | File that was executed, relative to the project root | -| `checksum` | SHA-256 of the SQL that actually ran (the rendered output for a `.sql.tmpl`) | -| `status` | `'pending'`, `'success'`, `'failed'`, `'skipped'` | -| `skip_reason` | `Skipped: failure in ` when an earlier file in the batch failed | -| `duration_ms` | Execution time | +The batch stops at the first failure. Files after it are not attempted: -Every file that will run gets a `'pending'` row before the first one executes, so an interrupted build still shows you what it was going to do. Rows move to their final status as the run proceeds. A file that change detection skips gets no row. +| File | Recorded as | Next build | +|---|---|---| +| ran before the failure | `success` | skipped | +| the one that failed | `failed` | runs | +| after the failure | `skipped`, with `Skipped: failure in ` | runs | -These tables let noorm answer: "Has this exact file content been executed before, and did it succeed?" +The command exits `3` when some files ran and `1` when none did. -## Execution Order +## How do I watch a slow file? -Files execute **alphabetically by full path**. This is deterministic and predictable, but it means you need to name files carefully when order matters. See [Organization](/guide/sql-files/organization) for detailed naming strategies. -Common patterns: - -``` -db/sql/ -├── 00_extensions/ -│ └── 001_uuid.sql # Extensions first -├── 01_types/ -│ └── 001_enums.sql # Types before tables -├── 02_tables/ -│ ├── 001_users.sql # Independent tables first -│ ├── 002_profiles.sql # Tables with FKs after their dependencies -│ └── 003_posts.sql -└── 03_views/ - └── 001_active_users.sql # Views last (they depend on tables) -``` - -The numbered prefixes ensure: -1. Extensions load before anything uses them -2. Types exist before tables reference them -3. Tables exist before views reference them -4. Within each category, files run in predictable order - -::: tip File Naming -Use `001_`, `002_` prefixes rather than `1_`, `2_` for consistent sorting. Leading zeros ensure `002` comes before `010` alphabetically. -::: +A file still running after 10 seconds logs `Still running (Ns)` every 10 seconds. The entry carries the progress the database reports (an index build's phase and block count) and any session the file waits on for a lock, which tells a slow file from a stuck one. To cancel a run, use the TUI: [Long-Running Files](/tui#long-running-files). -## MSSQL: Multiple Statements per File +## Multiple statements per file -SQL Server has a particular rule: `CREATE PROCEDURE`, `CREATE FUNCTION`, `CREATE TRIGGER`, `CREATE VIEW`, and `CREATE TYPE` (for table-valued parameters) must each be the only statement in their batch. SQL Server expresses this with `GO` — a separator that ends a batch. - - -`GO` is not part of the SQL language. It's a sqlcmd / SSMS directive. Tools that pass raw file content directly to the driver get back `Incorrect syntax near 'GO'`. noorm handles this for you: on MSSQL connections, the runner splits files on `GO` (anchored to its own line) and executes each batch in order. +| Dialect | A file with several statements | +|---|---| +| PostgreSQL | Runs as one call, in one implicit transaction | +| SQL Server | Splits on a line that holds only `GO`, then runs each batch in order | +| SQLite | Splits on statement boundaries. Semicolons inside strings, quoted identifiers, comments, and trigger bodies do not count | +| MySQL | The driver rejects it. Use one statement per file | +SQL Server needs `GO` because `CREATE VIEW`, `CREATE PROCEDURE`, `CREATE FUNCTION`, `CREATE TRIGGER`, and `CREATE TYPE` must each be alone in their batch: ```sql --- db/sql/09_procedures/checkout.sql -CREATE PROCEDURE checkout_begin - @CustomerId INT -AS -BEGIN - SET NOCOUNT ON; - INSERT INTO orders (customer_id, status) VALUES (@CustomerId, 'pending'); -END +-- db/sql/02_views/02_order_views.sql +CREATE OR ALTER VIEW open_orders AS +SELECT party_id, order_no FROM sales_order WHERE status = 'open'; GO -CREATE PROCEDURE checkout_commit - @OrderId INT -AS -BEGIN - SET NOCOUNT ON; - UPDATE orders SET status = 'paid' WHERE id = @OrderId; -END -GO - -CREATE PROCEDURE checkout_cancel - @OrderId INT -AS -BEGIN - SET NOCOUNT ON; - UPDATE orders SET status = 'cancelled' WHERE id = @OrderId; -END +CREATE OR ALTER VIEW invoiced_orders AS +SELECT party_id, order_no FROM sales_order WHERE status = 'invoiced'; GO ``` +When a batch fails, the error starts with `[batch 2 of 2]`. SQLite errors start with `[statement N of M]`. Files in `db/changes/` split the same way. Splitter rules are in the [runner internals](/dev/runner#mssql-batch-handling). -Three related procedures, one file. If batch 2 fails, noorm reports `[batch 2 of 3]` in the error and skips batch 3 — so you know exactly which `CREATE PROCEDURE` blew up without re-reading the file in your head. - - -`GO` recognition is line-oriented: the literal token `GO` must be the entire trimmed content of a line (case-insensitive). `GO;`, `GOLANG`, or `GO` mid-line do not split. Keep `GO` tokens out of string literals and `/* ... */` block comments — the splitter does not parse SQL, so an inadvertent `GO` inside a string on its own line will still be treated as a separator. This matches sqlcmd behavior. - - -PostgreSQL runs a multi-statement file as-is, in one implicit transaction. SQLite gets its own splitter: its driver compiles the first statement of a string and discards the rest, so noorm splits on statement boundaries before handing anything over — semicolons inside string literals, quoted identifiers, comments, and trigger bodies are not treated as boundaries. MySQL's driver rejects multi-statement strings outright; put each statement in its own file, or use a dialect that supports them. - - -For the gory details on what the runner does under the hood, see [MSSQL Batch Handling](/dev/runner#mssql-batch-handling). - - -## Long-Running Files - -A file that is still running after 10 seconds gets a status report every 10 seconds until it finishes: elapsed time, the progress the database reports for the running command (an index build's phase and block count, for example), and any session it is waiting on for a lock. That tells a slow file apart from a stuck one. The TUI shows the report under the running file, and the log records a `Still running (Ns)` line with the full report attached. - -The report comes from a second pooled connection, opened only when a file first passes 10 seconds and kept until the run ends. With `connection.pool.max: 1` there is no second connection to take, so reports carry elapsed time only. Behind a transaction-mode pooler (PgBouncer transaction mode, RDS Proxy, Supabase port 6543) the report can describe the wrong session; connect directly for builds you want to watch. - -To cancel a build from the TUI, see [Long-Running Files](/tui#long-running-files). +## What bites -## Summary -| Command | Purpose | -|---------|---------| -| `noorm run build` | Execute entire schema directory | -| `noorm run file ` | Execute single file | -| `noorm run dir ` | Execute files in directory | -| `--force` | Re-run regardless of changes | -| `--dry-run` | Render to `tmp/` without executing (`run build`, `run dir`) | +| Mistake or symptom | Cause | Fix | +|---|---|---| +| A build skips files after you dropped tables by hand | The records still say `success` and the checksums match | `noorm run build --force`. To empty a database, use `noorm db teardown`, which marks the records stale | +| `run build` fails with `already exists`, and the error ends with `1 prior successful execution` | A `CREATE TABLE` file changed, so it ran again against a table that exists | `run build` is for new databases. Bring an existing one up to date with `noorm change ff` | +| A template runs on every build | Its SQL changes on every render: `$.now()`, `$.uuid()` | Use a fixed value | +| The first build on a second config runs every file | Records are per config name | None needed. The config has not run them yet | +| Progress reports show only elapsed time, or describe another session | `connection.pool.max: 1` leaves no second connection. A transaction-mode pooler (PgBouncer, RDS Proxy, Supabase port 6543) can route the report to another session | Raise the pool max, and connect to the database directly for builds you want to watch | +| A line that is only `GO` inside a string or block comment splits the file | The splitter reads lines, not SQL | Keep `GO` out of strings and `/* */` comments | -The checksum system means you can run `noorm run build` as often as you want—only changed or new files execute. This is the foundation of noorm's approach to database schema management. +## Next -## What's Next? -- [Organization](/guide/sql-files/organization) - Structure your SQL files for predictable execution order -- [Templates](/guide/sql-files/templates) - Add dynamic content to `.sql.tmpl` files -- [Changes](/guide/changes/overview) - One-time operations with rollback support +- [Organization](/guide/sql-files/organization): folder layout and which files take part +- [Templates](/guide/sql-files/templates): generate SQL from data files +- [Forward and revert](/guide/changes/forward-revert): bring existing databases up to date +- [`noorm run`](/cli/run): exit codes and JSON output diff --git a/docs/guide/sql-files/organization.md b/docs/guide/sql-files/organization.md index 916d142f..9d9f5635 100644 --- a/docs/guide/sql-files/organization.md +++ b/docs/guide/sql-files/organization.md @@ -1,351 +1,119 @@ # Organizing SQL Files -How you structure your SQL files determines how they execute. noorm processes files in alphabetical order by path, so a well-organized directory structure gives you predictable, repeatable builds. +Name your folders and files so the order they sort in is the order they run in. - -## Recommended Directory Structure - -Start with this layout: +`noorm run build` finds every `.sql` and `.sql.tmpl` file under `paths.sql` (`./db/sql` after `noorm init`), sorts the paths as text, and runs them in that order. Every other file is ignored. ``` db/sql/ ├── 01_tables/ -│ ├── 001_users.sql -│ ├── 002_posts.sql -│ └── 003_comments.sql +│ ├── 01_party.sql # runs 1st +│ ├── 02_sales_order.sql # 2nd +│ ├── 03_product.sql # 3rd +│ ├── 04_order_line.sql # 4th +│ └── 05_app_setting.sql # 5th ├── 02_views/ -│ ├── 001_active_users.sql -│ └── 002_recent_posts.sql +│ └── 01_open_orders.sql # 6th ├── 03_functions/ -│ └── 001_calculate_score.sql -└── 04_seeds/ - └── 001_default_roles.sql +│ └── 01_place_order.sql # 7th +├── 09_seeds/ +│ ├── products.yml # data file, never runs +│ ├── 01_products.sql.tmpl # 8th +│ └── 02_gateway.sql.tmpl # 9th +└── archive/ # excluded in settings, never runs ``` -This structure works because: - -- **Numbered folder prefixes** - Guarantee execution order between folders (`01_` before `02_`) -- **Numbered file prefixes** - Guarantee execution order within each folder -- **Logical grouping** - Easy to find and maintain related files - - -## Naming Conventions - -noorm doesn't enforce naming rules, but consistent naming makes life easier. - +`09_seeds` leaves `04` to `08` free for groups you add later, such as procedures and triggers. -### Use Numeric Prefixes for Order -Files execute alphabetically. Without prefixes, `accounts.sql` runs before `users.sql`—even if users should exist first. +## How are files ordered? -``` -# Without prefixes (unpredictable) -accounts.sql # Runs first (a < u) -users.sql # Runs second - -# With prefixes (explicit order) -001_users.sql # Runs first -002_accounts.sql # Runs second -``` - -Recommended formats: -| Format | Example | Best For | -|--------|---------|----------| -| `001_` | `001_users.sql` | Most projects (up to 999 files per folder) | -| `01_` | `01_users.sql` | Smaller projects (up to 99 files) | -| `0001_` | `0001_users.sql` | Large projects with many files | +noorm compares whole paths as text, one character at a time. The folder comes first in the path, so a folder prefix orders the folders and a file prefix orders the files inside one. +| Names, in the order you want | Run order | Why | +|---|---|---| +| `tables/`, `views/`, `functions/` | `functions/`, `tables/`, `views/` | `f` sorts before `t` before `v` | +| `2_party.sql`, `10_order.sql` | `10_order.sql`, `2_party.sql` | `1` sorts before `2` as text | +| `accounts.sql`, `Users.sql` | `Users.sql`, `accounts.sql` | every capital letter sorts before every lowercase one | -### Use Descriptive Names +Pad the prefixes in a folder to one width: `01_` through `99_`, or `001_` past that. Name each file for the object it creates. -The name should tell you what the file does without opening it: - -``` -# Good -001_create_users.sql -002_add_user_indexes.sql -003_create_posts.sql - -# Avoid -001.sql -002_update.sql -users_v2_final_FINAL.sql -``` +## Which files take part in a build? -### Template Files - -Files ending in `.sql.tmpl` are processed through the template engine before execution: - -``` -seeds/ -├── 001_default_roles.sql # Static SQL -└── 002_environment_config.sql.tmpl # Dynamic SQL with variables -``` - - -## Execution Order - -noorm walks your schema directory recursively, collects every `.sql` and `.sql.tmpl` file, and sorts the **full paths as strings**. That sorted list is the execution order. This is the critical rule for understanding how your schema executes. It means: - -1. The directory part of the path decides the order first -2. File names break ties inside the same directory -3. The comparison is by character code, not dictionary order, so every uppercase letter sorts before every lowercase one: `Users.sql` runs before `accounts.sql` - -Without numeric folder prefixes, alphabetical order causes problems: - -``` -db/sql/ -├── functions/ # f comes before t—runs first! -│ └── 001_helpers.sql -├── tables/ # t comes after f—runs second -│ ├── 001_users.sql -│ └── 002_posts.sql -└── views/ # v is last - └── 001_summary.sql -``` -This is wrong—functions often depend on tables. Use numeric prefixes on folders: +A file runs unless an exclude entry covers it, or an include list exists and does not cover it: +```mermaid +flowchart TD + A["db/sql/
.sql and .sql.tmpl files"] --> B{"under a
build.exclude
entry?"} + B -->|yes| X[skipped] + B -->|no| C{"build.include
empty?"} + C -->|yes| R[runs] + C -->|no| D{"under a
build.include
entry?"} + D -->|yes| R + D -->|no| X ``` -db/sql/ -├── 01_tables/ # runs first -│ ├── 001_users.sql -│ └── 002_posts.sql -├── 02_views/ # runs second -│ └── 001_summary.sql -└── 03_functions/ # runs third (can reference tables/views) - └── 001_helpers.sql -``` - -Execution order: -1. `db/sql/01_tables/001_users.sql` -2. `db/sql/01_tables/002_posts.sql` -3. `db/sql/02_views/001_summary.sql` -4. `db/sql/03_functions/001_helpers.sql` - -## Using Settings for Build Order - -The `settings.yml` file lets you control which folders run and in what order: +An entry is a folder or one file, relative to `paths.sql`. Both keys filter and neither reorders. `exclude` wins over `include`. ```yaml +# .noorm/settings.yml build: - include: - - 01_tables - - 02_views - - 03_functions - - 04_seeds exclude: - - archive # Never runs -``` - -Folder names are relative to your `paths.sql` directory, not the project root. With the `paths.sql: ./db/sql` that `noorm init` writes, an entry of `01_tables` means `./db/sql/01_tables` — so you do **not** repeat the `sql/` prefix. - -::: warning A wrong include path still exits 0 -`sql/01_tables` resolves to `./db/sql/sql/01_tables` and matches nothing. `noorm run build` names the offending entries in a warning: - -``` -Ignored 1 build.include entry that matched no files: sql/01_tables -Include paths are relative to paths.sql — use `01_tables`, not `sql/01_tables`. + - archive ``` -The build still reports success and exits `0`, so a pipeline that only checks the exit code will not catch it. Read the warnings. -A mistyped `exclude` entry gets the same treatment and is the more dangerous half: nothing is excluded, so the files you meant to fence off run against the target database. +## Can a config change which files run? -A leading `./` or a trailing `/` matches nothing for the same reason. -::: -The `include` array is a **filter**—it controls which folders are included, not their order. Execution order is always alphanumeric. Use numeric prefixes to control the sequence: +A rule adds include or exclude entries when the active config matches. Rules run in order, every key in `match` must hold, and a later rule overrides an earlier one for the same entry. -``` -db/sql/ -├── 01_tables/ ← runs first -├── 02_views/ ← runs second -├── 03_functions/ ← runs third -├── 04_seeds/ ← runs last -└── archive/ ← excluded, never runs -``` - -This keeps the filesystem self-documenting. Anyone can see the execution order without consulting settings. - - -### Conditional Includes with Rules - -Different environments often need different files. Use rules to include or exclude folders based on config properties: +Suppose test configs have no `PAYMENT_GATEWAY_KEY`. A template that reads a missing secret throws, so this rule keeps the gateway seed out of test builds: ```yaml +# .noorm/settings.yml rules: - # Only run seeds in test environments - - match: + - description: Test configs have no gateway key + match: isTest: true - include: - - 04_seeds - - 05_test-fixtures - - # Skip heavy seeds for CI - - match: - name: ci-test exclude: - - 04_seeds/003_large-dataset.sql -``` - -This means: -- Production builds skip test data entirely -- CI builds skip slow seed files -- Development gets everything - -::: warning A rule `include` narrows the whole build -An empty `build.include` means "every folder under `paths.sql`". A matching rule does not add to that set, it replaces it: its `include` entries are merged into `build.include`, and the moment that list is non-empty only the listed folders run. - -So on a project with no `build.include`, the rule above turns a test build into `04_seeds` and `05_test-fixtures` only. Your tables never get created. If you use rule `include` at all, list every folder you want in `build.include` too, and use rule `exclude` for the conditional part. -::: - - -## Organizing by Feature vs by Type - -There are two common approaches to organizing SQL files. - - -### By Type (Recommended for Most Projects) - -Group files by what they are: - -``` -db/sql/ -├── 01_tables/ -├── 02_views/ -├── 03_functions/ -├── 04_procedures/ -└── 05_seeds/ -``` - -**Pros:** -- Clear execution order from numeric prefixes -- Filesystem is self-documenting -- Matches how databases organize objects - -**Cons:** -- Related files are spread across folders - - -### By Domain (For Projects with SDK) - -If you're building a [typed SDK](/getting-started/building-your-sdk), organize SQL to mirror your domain classes: - -``` -db/sql/ -├── 01_core/ -│ ├── tables/ # Base entities (users, accounts) -│ └── functions/ # Shared utilities -├── 02_auth/ -│ ├── tables/ # Sessions, tokens, permissions -│ ├── views/ # Active sessions, user permissions -│ └── functions/ # Authentication helpers -├── 03_billing/ -│ ├── tables/ # Invoices, payments, subscriptions -│ ├── views/ # Revenue reports, aging -│ └── procedures/ # Billing transactions -└── 04_seeds/ - └── defaults.sql # Reference data -``` - -This aligns your database layer with your application layer. The SDK's `auth` domain class maps to `db/sql/02_auth/`, making it easy to find related code. - -**Pros:** -- SQL and SDK stay in sync -- Clear ownership boundaries -- Domain changes are localized - -**Cons:** -- Need numbered folders to control cross-domain dependencies -- More complex include configuration - - -### Hybrid Approach - -Start simple and add domains as needed: - -``` -db/sql/ -├── 01_tables/ # All tables (simple start) -├── 02_views/ -├── 03_functions/ -└── 04_seeds/ + - 09_seeds/02_gateway.sql.tmpl ``` -When a domain grows complex, extract it: - -``` -db/sql/ -├── 01_core/ # Moved tables, views, functions -├── 02_billing/ # Extracted billing domain -├── 03_functions/ # Shared functions -└── 04_seeds/ -``` +| `match` key | Holds when | +|---|---| +| `name` | the config's name equals the value | +| `isTest` | the config's `isTest` flag equals the value | +| `type` | the config's type is `local` or `remote` | +| `protected` | `true` when the config's `user` role is below `admin` | +Matching rules are merged into `build.include` and `build.exclude` before the build filters its files. `noorm run build --dry-run --config test` lists the files that config would run, and writes them rendered to `tmp/`. -## Common Patterns +## Can I group by domain instead of type? -### Environment-Specific Files -Use rules to include environment-specific folders: +Yes. Number the domain folders by dependency (`01_parties/`, `02_sales/`, `03_billing/`), and number `01_tables/` and `02_views/` inside each one. A view in `01_parties` runs before any `02_sales` table exists, so objects that reach across domains are easier to order by type. -``` -db/sql/ -├── 01_tables/ -├── 02_views/ -├── 03_seeds-dev/ # Development data -├── 03_seeds-staging/ # Staging data -└── 03_seeds-prod/ # Production defaults only -``` -```yaml -rules: - - match: - name: dev - include: - - 03_seeds-dev - - - match: - name: staging - include: - - 03_seeds-staging - - - match: - name: prod - include: - - 03_seeds-prod -``` +## What bites -### Archive Pattern - -Keep old files without running them: - -``` -db/sql/ -├── 01_tables/ -├── 02_views/ -└── archive/ # Old files, excluded from builds - └── deprecated_table.sql -``` - -```yaml -build: - exclude: - - archive -``` +| Mistake or symptom | Cause | Fix | +|---|---|---| +| The build reports `success` with `filesRun=0`, or a file you excluded ran | An entry matched nothing. `sql/01_tables`, `./01_tables`, and `01_tables/` all match nothing, because entries start inside `paths.sql` | Write `01_tables`. The build warns `Ignored 1 build.include entry that matched no files` and still exits `0`, so read the warnings in CI | +| A test build runs the seeds but creates no tables | A matching rule's `include` joins an empty `build.include`, so the list is no longer empty and only the rule's folders run | List every folder in `build.include`, and use rule `exclude` for the conditional part | +| `run dir` runs a file that `build.exclude` lists | Only `run build` applies include, exclude, and rules | Name the files you want, or use `run build` | -## What's Next? +## Next -Now that your files are organized: -- [Templates](/guide/sql-files/templates) - Add dynamic content to your SQL -- [Execution](/guide/sql-files/execution) - Understand builds and change detection -- [Settings](/guide/environments/stages) - Configure build order with settings.yml +- [Execution](/guide/sql-files/execution): what a build skips, runs, and records +- [Templates](/guide/sql-files/templates): generate seeds from data files +- [Concepts](/getting-started/concepts): how `db/sql/` and `db/changes/` fit together +- [Settings reference](/dev/settings): every key in `settings.yml` diff --git a/docs/guide/sql-files/templates.md b/docs/guide/sql-files/templates.md index 145e146c..fed67e62 100644 --- a/docs/guide/sql-files/templates.md +++ b/docs/guide/sql-files/templates.md @@ -1,508 +1,210 @@ # SQL Templates -Static SQL handles most database work. But sometimes you need SQL generated from structured data: +Generate repeated SQL from data files, config, and secrets instead of writing every statement by hand. -- Seeding reference data from CSV or JSON files -- Generating similar objects (triggers, policies, grants) from configuration -- Inserting secrets like API keys into config tables -- Adapting SQL based on environment or stage +A data file sits beside the template, and its name becomes a variable: -Custom scripts work, but then you're maintaining two systems. Templates keep everything in one place: structured data files plus SQL templates that read them. - -Any file ending in `.sql.tmpl` is processed before execution. Templates look like SQL with embedded logic. They auto-load data files, inherit helpers, and have access to secrets and config. - - -## Basic Syntax - -Templates use [Eta](https://eta.js.org/) with custom delimiters designed to avoid conflicts with SQL syntax: - -| Syntax | Purpose | Example | -|--------|---------|---------| -| `{% %}` | JavaScript code block | `{% for (const x of $.items) { %}` | -| `{%~ %}` | Output a value | `{%~ user.name %}` | -| `-- {% %}` | Code block on a line of its own, disguised as a SQL comment | `-- {% for (const x of $.items) { %}` | -| `$` | Context variable | `$.config`, `$.secrets`, `$.uuid()` | - -A line whose entire content is `-- {% ... %}` gets its `-- ` prefix and its newline stripped before Eta parses the file, so the directive produces no output. Writing loops and conditionals that way keeps the `.sql.tmpl` file valid SQL, which means your editor still highlights it and a SQL linter still reads it. Inline tags and real `--` comments are untouched. - -Here's a simple example: - -```sql --- seed_roles.sql.tmpl -{% for (const role of $.roles) { %} -INSERT INTO roles (name) VALUES ('{%~ role %}'); -{% } %} -``` - -When rendered with `$.roles = ['admin', 'editor', 'viewer']`, this produces: - -```sql -INSERT INTO roles (name) VALUES ('admin'); -INSERT INTO roles (name) VALUES ('editor'); -INSERT INTO roles (name) VALUES ('viewer'); -``` - - -## How Rendering Works - -When noorm encounters a `.sql.tmpl` file: - -1. Scans the template's directory for data files (JSON5, YAML, CSV, `.dt`, and referenced JS/TS modules) -2. Walks up the directory tree collecting `$helpers.ts` files -3. Builds a context object (`$`) with helpers, data, config, and secrets -4. Renders the template with Eta -5. Returns the SQL string for execution - - -## The Template Context ($) - -Everything available in your template lives on the `$` object: - -| Property | Description | -|----------|-------------| -| `$.` | Auto-loaded data from files in the same directory | -| `$.` | Functions from `$helpers.ts` files | -| `$.config` | Active database configuration | -| `$.secrets` | Decrypted secrets for active config | -| `$.globalSecrets` | Decrypted global secrets | -| `$.env` | `process.env` | - -Data files win the name: a `config.json` or `config.yml` sitting beside the template claims the `config` key, and `$.config` then holds that file instead of the active database configuration. Helpers lose to data files the same way, so keep helper exports and data file base names distinct. - - -## Built-in Helpers - -These helpers are always available in every template: - -### $.quote(value) - -Escapes a value and wraps it in single quotes. Use this for string values: - -```sql -INSERT INTO users (name) VALUES ({%~ $.quote(user.name) %}); --- Input: O'Reilly --- Output: INSERT INTO users (name) VALUES ('O''Reilly'); -``` - -`null` renders as the SQL keyword `NULL`. `undefined` throws, because no rendering of it is correct and the old behavior wrote the literal six-character string `undefined` into the database. Pass `null` when you mean SQL `NULL`. - -### $.escape(value) - -Escapes a string by doubling single quotes, without adding surrounding quotes: - -```sql -UPDATE users SET bio = '{%~ $.escape(user.bio) %}' WHERE id = 1; --- Input: It's great --- Output: UPDATE users SET bio = 'It''s great' WHERE id = 1; -``` - -### $.uuid() - -Generates a UUID v4: - -```sql -INSERT INTO tokens (id) VALUES ('{%~ $.uuid() %}'); --- Output: INSERT INTO tokens (id) VALUES ('550e8400-e29b-41d4-a716-446655440000'); -``` - -### $.now() - -Returns the current ISO timestamp: - -```sql -INSERT INTO logs (created_at) VALUES ('{%~ $.now() %}'); --- Output: INSERT INTO logs (created_at) VALUES ('2024-01-15T10:30:00.000Z'); +```yaml +# db/sql/09_seeds/products.yml +- { sku: HW-DONGLE, name: USB security key, list_price: 49.00 } +- { sku: SW-SEAT, name: "Desktop seat, yearly", list_price: 89.00 } ``` -### $.json(value) - -JSON stringifies a value: - ```sql -INSERT INTO config (data) VALUES ('{%~ $.escape($.json(settings)) %}'); +-- db/sql/09_seeds/01_products.sql.tmpl +-- {% for (const p of $.products) { %} +INSERT INTO product (sku, name, list_price) +VALUES ({%~ $.quote(p.sku) %}, {%~ $.quote(p.name) %}, {%~ p.list_price %}); +-- {% } %} ``` -### $.include(path) - -Includes another SQL file. The path is relative to the current template: - -```sql --- db/changes/2025-01-15-setup/change/001_setup.sql.tmpl - --- Load shared functions -{%~ await $.include('../lib/uuid_function.sql') %} - --- Load triggers -{%~ await $.include('../lib/audit_triggers.sql') %} +```bash +noorm run preview db/sql/09_seeds/01_products.sql.tmpl ``` -An included `.sql` file is inserted verbatim. An included `.sql.tmpl` file is rendered first, with the same config and secrets, and its output is inserted. - - -## Auto-Loading Data Files - -Data files in the same directory as the template are automatically loaded into `$`: - ``` -db/sql/users/ -├── 001_seed.sql.tmpl # Template -├── users.json # -> $.users -├── roles.yml # -> $.roles -└── seed-data.csv # -> $.seedData +INSERT INTO product (sku, name, list_price) +VALUES ('HW-DONGLE', 'USB security key', 49); +INSERT INTO product (sku, name, list_price) +VALUES ('SW-SEAT', 'Desktop seat, yearly', 89); ``` -File names are converted to camelCase: - -| Filename | Context Key | -|----------|-------------| -| `my-config.json` | `$.myConfig` | -| `seed_data.yml` | `$.seedData` | -| `API_KEYS.json` | `$.apiKeys` | - -Subdirectories are not scanned, and neither are `.sql` files (those are for `$.include()`), dotfiles, `$helpers.*`, or other `.tmpl` files. +Four inputs meet in one `$` object. The rendered SQL is what `run build` hashes and runs, and what `run preview` prints: - -### Executable Side-Cars Load Only When Referenced - -`.js`, `.mjs`, and `.ts` data files are an exception to auto-loading. Parsing a `.csv` cannot do anything, but importing a `.ts` runs whatever is in it. So noorm loads an executable side-car only when the template text actually names its key, as `$.key` or `$['key']`: - -```sql --- 001_seed.sql.tmpl, sitting next to build-rows.ts --- The mention of $.buildRows is what makes build-rows.ts load and run. -{% for (const row of $.buildRows) { %} -INSERT INTO rows (label) VALUES ({%~ $.quote(row.label) %}); -{% } %} +```mermaid +flowchart TD + D["products.yml
(same folder)"] --> C["$ context"] + H["$helpers.ts
(this folder and up)"] --> C + S["$.secrets
(config, global, vault)"] --> C + G["$.config and $.env"] --> C + C --> T["01_products.sql.tmpl
rendered by Eta"] + T --> Q[plain SQL] + Q --> B["run build
hash, then skip or run"] + Q --> P["run preview
print to stdout"] ``` -A `build-rows.ts` that no template mentions is skipped, including during `run preview`, `run inspect`, and `--dry-run`, the three commands whose whole point is not to act. The check is syntactic, so a key you reach some other way (a computed lookup, a helper that reads it) will not trigger the load. Reference it by name once and it works. +## What does the syntax look like? -### Naming Collisions -Different naming patterns can produce the same context key: +Templates use [Eta](https://eta.js.org/) with delimiters that do not collide with SQL. Output is never HTML-escaped. -| Filename | Context Key | -|----------|-------------| -| `user_roles.json` | `$.userRoles` | -| `user-roles.yaml` | `$.userRoles` | -| `user.roles.csv` | `$.userRoles` | +| Syntax | Does | Example | +|---|---|---| +| `{% %}` | runs JavaScript | `{% for (const p of $.products) { %}` | +| `{%~ %}` | writes a value into the SQL | `{%~ p.sku %}` | +| `-- {% %}` | a tag alone on a line, behind a SQL comment | `-- {% } %}` | +| `$` | the context object | `$.products` | -If two files resolve to the same key, one silently overwrites the other. Use distinct base names to avoid conflicts. +noorm removes the `-- ` and the line break from a line that holds only `-- ` and a tag, before Eta runs. The directive prints nothing, and the file stays valid SQL for your editor and linter. The result loses its leading and trailing blank lines. -### Supported Formats +## What can a template read? -| Extension | Notes | -|-----------|-------| -| `.json`, `.json5` | JSON5 supports comments and trailing commas | -| `.yaml`, `.yml` | Full YAML support | -| `.csv` | Parsed into array of row objects | -| `.dt`, `.dtz` | Exported database tables (see [Data Transfer](/guide/database/transfer)) | -| `.js`, `.mjs`, `.ts` | Module's default export or exports object (`.ts` requires Node >= 22.13) | +| Name | Holds | +|---|---| +| `$.` | each data file in the template's folder, under its camelCased name | +| `$.` | each export of the `$helpers` files from the template's folder up | +| `$.config` | the active config: `name`, `type`, `isTest`, `connection`, `access` | +| `$.secrets` | this config's secrets, merged with global and vault ones. A missing key throws | +| `$.globalSecrets` | global secrets only. A missing key reads as `undefined` | +| `$.env` | `process.env` | +| `$.quote`, `$.escape`, `$.json`, `$.uuid`, `$.now`, `$.include` | the built-ins | -### JSON5 Example - -JSON5 is easier to write by hand than strict JSON: - -```json5 -// users.json5 - Comments are allowed -{ - users: [ - { name: 'Alice', role: 'admin' }, - { name: 'Bob', role: 'user' }, // Trailing commas are fine - ] -} -``` - -### CSV Example - -Given `users.csv`: - -```csv -email,name,role -alice@example.com,Alice,admin -bob@example.com,Bob,user -``` - -Access in your template as an array of objects: +When names collide, the last wins: helpers, data files, then `$.config`, `$.secrets`, `$.globalSecrets`, `$.env`, and the built-ins. A data file named `config.*` is the exception: it replaces `$.config`. -```sql --- seed.sql.tmpl -{% for (const user of $.users) { %} -INSERT INTO users (email, name, role) VALUES - ({%~ $.quote(user.email) %}, {%~ $.quote(user.name) %}, {%~ $.quote(user.role) %}); -{% } %} -``` +## Where does data come from? -### .dt Files (Exported Database Tables) -`.dt` and `.dtz` files exported from [Data Transfer](/guide/database/transfer) can be used directly as seed data. The loader reads the embedded schema, maps positional values to column names, and exposes the data as an array of row objects—just like CSV. +noorm loads the data files in the template's own folder. It skips subfolders, dotfiles, `.sql` files, `$helpers` files, and other `.tmpl` files. `seed_data.yml` becomes `$.seedData`, and `my-config.json` becomes `$.myConfig`. -``` -db/sql/users/ -├── 001_seed.sql.tmpl # Template -└── users.dt # -> $.users (exported from another database) -``` +| Extension | `$.` holds | +|---|---| +| `.json`, `.json5` | the parsed value. JSON5 allows comments and trailing commas | +| `.yaml`, `.yml` | the parsed value | +| `.csv` | an array of row objects keyed by the header row. Every value is a string | +| `.dt`, `.dtz` | an array of row objects from a [data transfer](/guide/database/transfer) export. `.dtzx` is not supported | +| `.js`, `.mjs`, `.ts` | the module's default export, or its exports | -```sql --- seed.sql.tmpl -{% for (const user of $.users) { %} -INSERT INTO users (id, email, name) VALUES - ({%~ user.id %}, {%~ $.quote(user.email) %}, {%~ $.quote(user.name) %}); -{% } %} -``` +A `.js`, `.mjs`, or `.ts` file runs code, so noorm loads it only when the template names its key, as `$.buildRows` or `$['buildRows']`. -This lets you export data from one environment and use it as seed data in templates—without converting formats. Compressed `.dtz` files work too. Encrypted `.dtzx` files are not supported as template data since there's no way to provide a passphrase in the template context. +## How do helpers work? -## Helper Inheritance -Helper files named `$helpers.ts` (or `$helpers.js`) are automatically loaded and inherited up the directory tree: +A `$helpers.ts`, `$helpers.js`, or `$helpers.mjs` file exports functions and values for every template in its folder and below. The nearest file wins a name: ``` db/sql/ -├── $helpers.ts # Project-wide helpers -├── users/ -│ ├── $helpers.ts # Overrides/extends parent -│ ├── 001_create.sql.tmpl # Has access to both -│ └── roles.json -└── products/ - └── 001_create.sql.tmpl # Only has project-wide helpers +├── $helpers.ts # price() for every template +└── 09_seeds/ + ├── $helpers.ts # its own price() wins here + ├── products.yml + └── 01_products.sql.tmpl # sees both files ``` -Child helpers override parent helpers with the same name. This lets you define project-wide utilities at the root and specialize them in subdirectories. - - -### Writing Helpers - -Helper files can be written in TypeScript (`.ts`) or JavaScript (`.js`, `.mjs`). TypeScript helpers require Node >= 22.13, which supports type stripping natively. On older Node versions, use `.js` instead. - -```typescript +```ts // db/sql/$helpers.ts -export function padId(id: number, length = 6): string { - - return String(id).padStart(length, '0') -} - -export function formatDate(date: Date): string { - - return date.toISOString().split('T')[0] -} +export function price(amount: number): string { -export const defaults = { - pageSize: 50, - maxRetries: 3, + return amount.toFixed(2); } ``` -Use in templates: +`{%~ $.price(p.list_price) %}` now writes `49.00`. The search for helper files stops at the project root, not at `paths.sql`. -```sql -INSERT INTO users (id, created_at) VALUES - ('{%~ $.padId(1) %}', '{%~ $.formatDate(new Date()) %}'); +`noorm run inspect` lists what a template can read, without rendering it: --- Output: INSERT INTO users (id, created_at) VALUES ('000001', '2024-01-15'); +```bash +noorm run inspect db/sql/09_seeds/01_products.sql.tmpl ``` - -## Common Patterns - - -### Seeding from Data Files - -```yaml -# roles.yml -- name: admin - permissions: ["read", "write", "delete"] -- name: editor - permissions: ["read", "write"] -- name: viewer - permissions: ["read"] ``` +Template: db/sql/09_seeds/01_products.sql.tmpl +Config: dev -```sql --- seed_roles.sql.tmpl -{% for (const role of $.roles) { %} -INSERT INTO roles (name, permissions) VALUES - ({%~ $.quote(role.name) %}, {%~ $.quote($.json(role.permissions)) %}); -{% } %} -``` +Data Files: + $.products Array [2] +Helpers ($helpers): + $.price Function -### Environment-Aware SQL - -The active configuration is available through `$.config`. Use it to adapt SQL based on stage, config name, or database: - -```sql --- setup_monitoring.sql.tmpl -{% const db = $.config.connection.database %} -{% const stage = $.config.name %} - --- Tag all entries with the source database -INSERT INTO monitoring.activity_log (source_db, event, created_at) VALUES - ({%~ $.quote(db) %}, 'schema_deployed', '{%~ $.now() %}'); - -{% if (stage === 'prod') { %} --- Only production stage gets strict constraints -ALTER TABLE orders ADD CONSTRAINT chk_amount CHECK (amount > 0); -{% } %} - -{% if ($.config.isTest) { %} --- Test databases get sample data -INSERT INTO users (name, email) VALUES ('Test User', 'test@example.com'); -{% } %} -``` - -Common conditions: - -| Condition | Use Case | -|-----------|----------| -| `$.config.name === 'prod'` | Production-only constraints or settings (a config links to the stage of the same name) | -| `$.config.isTest` | Test fixtures and sample data | -| `$.config.name === 'dev'` | Local development overrides | -| `$.config.connection.database` | Database-specific configuration | - - -### Using Secrets - -Never hardcode credentials. Access them through `$.secrets`: - -```sql --- config_setup.sql.tmpl -INSERT INTO app_config (key, value) VALUES - ('stripe_key', {%~ $.quote($.secrets.STRIPE_KEY) %}), - ('api_endpoint', {%~ $.quote($.env.API_URL || 'https://api.example.com') %}); +Built-ins: include, escape, quote, json, now, uuid +Secrets: 1 resolved, 0 global ``` -Secrets resolve through a three-tier hierarchy: config-specific local secrets override global local secrets, which override team-shared [vault](/guide/environments/vault) secrets. This means you can store production credentials in the vault and override them locally for development without affecting teammates. See the [secret resolution hierarchy](/guide/environments/vault#secret-resolution-hierarchy) for details. - -A key that isn't set in any of the three tiers has no correct SQL rendering, so reading it -throws instead of quietly resolving to `undefined`: -```sql --- Throws: Secret "STRIPE_KEY" not found (searched: config-local, global-local, vault) -{%~ $.secrets.STRIPE_KEY %} -``` +## What do the built-ins do? -This is deliberate — a missing secret used to render as the literal six-character string -`undefined`, which is valid SQL and easy to miss in a review. `$.secrets.KEY ?? fallback` -can't paper over a missing key either, because `??` still evaluates the throwing read on its -left side. To probe for a secret that's genuinely optional, check for it first with `in`, -which never throws: - -```sql -{% const hasKey = 'STRIPE_KEY' in $.secrets; %} -{%~ hasKey ? $.quote($.secrets.STRIPE_KEY) : 'NULL' %} -``` -`in` before read is how a template says "this secret is allowed to be missing" on purpose, -rather than by accident. See -[`examples/todo-db/sql/10_seeds/feature_flags.sql.tmpl`](https://github.com/noormdev/noorm/blob/master/examples/todo-db/sql/10_seeds/feature_flags.sql.tmpl) -for a full example that forces a feature flag off when its backing secret isn't configured. +| Call | Returns | +|---|---| +| `$.quote(v)` | `v` as a SQL string literal: single quotes around it, quotes inside doubled. `null` gives `NULL`, `undefined` throws, and a number is quoted too (`'49'`) | +| `$.escape(s)` | `s` with single quotes doubled, and no surrounding quotes | +| `$.json(v)` | `JSON.stringify(v)` | +| `$.uuid()` | a UUID v4 | +| `$.now()` | the current time as an ISO 8601 string | +| `await $.include(path)` | the file at `path`, relative to the template. A `.sql` file is inserted as is. A `.sql.tmpl` file is rendered with the same context first. The path cannot leave the project root | -### Generating Multiple Similar Objects +## How do secrets and config reach a template? -When you have many similar tables, views, or procedures: -```yaml -# audit_tables.yml -tables: - - users - - posts - - comments - - orders -``` +Read them off `$`. This template writes a secret and picks a URL by config name: ```sql --- create_audit_triggers.sql.tmpl -{% for (const table of $.auditTables.tables) { %} -CREATE TRIGGER trg_{%~ table %}_audit -AFTER INSERT OR UPDATE OR DELETE ON {%~ table %} -FOR EACH ROW EXECUTE FUNCTION audit_log_changes(); - -{% } %} +-- db/sql/09_seeds/02_gateway.sql.tmpl +INSERT INTO app_setting (name, value) VALUES + ('payment_gateway_key', {%~ $.quote($.secrets.PAYMENT_GATEWAY_KEY) %}), + ('payment_gateway_url', {%~ $.quote($.config.name === 'prod' ? 'https://api.gateway.example' : 'https://sandbox.gateway.example') %}); ``` +On a config named `dev` with the secret set, `run preview` prints: -### Role-Based Permissions - -Define permissions in YAML and generate all the grants: - -```yaml -# permissions.yml -roles: - WebApp: - views: [Users_V, Posts_V, Settings_V] - procs: [Login_trx, Signup_trx] - Worker: - views: [Queue_V, Jobs_V] - procs: [ProcessJob_trx] ``` - -```sql --- grant_permissions.sql.tmpl -{% for (const [role, perms] of Object.entries($.permissions.roles)) { %} --- Grants for {%~ role %} -{% for (const view of perms.views || []) { %} -GRANT SELECT ON {%~ view %} TO {%~ role %}; -{% } %} -{% for (const proc of perms.procs || []) { %} -GRANT EXECUTE ON {%~ proc %} TO {%~ role %}; -{% } %} - -{% } %} +INSERT INTO app_setting (name, value) VALUES + ('payment_gateway_key', 'gw_live_123'), + ('payment_gateway_url', 'https://sandbox.gateway.example'); ``` +[Secrets](/guide/environments/secrets#which-value-does-a-template-get) explains which of the three tiers supplies `$.secrets.PAYMENT_GATEWAY_KEY`. `$.config.isTest` and `$.config.connection.database` are the other keys templates branch on. -### Composing from Fragments - -Large table schemas are easier to review when split into logical groups. Use `$.include()` to compose them into a single change: - -``` -db/changes/2025-01-15-full-setup/ -├── change/ -│ └── 001_schema.sql.tmpl -└── lib/ - ├── core_tables.sql - ├── content_tables.sql - └── seed_defaults.sql -``` - -```sql --- db/changes/2025-01-15-full-setup/change/001_schema.sql.tmpl --- Core tables (users, roles, user_roles) -{%~ await $.include('../lib/core_tables.sql') %} +## How do I check what a template renders? --- Content tables (posts, comments, tags, post_tags) -{%~ await $.include('../lib/content_tables.sql') %} --- Default seed data -{%~ await $.include('../lib/seed_defaults.sql') %} -``` +`noorm run preview ` prints the rendered SQL to stdout, so `> rendered.sql` works. `--json` returns `{ filepath, sql, durationMs }`. Both `preview` and `inspect` take `--config` and `--json`, and a `viewer` role cannot use either. -Each fragment is plain SQL that could run on its own. The template just stitches them together so the change stays atomic while the source stays organized. +A change folder renders `.sql.tmpl` files the same way. [Changes](/guide/changes/overview) covers where they go. +::: warning Helpers and scripts run as you +A `$helpers` file runs whenever a template at or below its folder renders, and a referenced `.js` or `.ts` data file runs too. Both execute in the noorm process with your environment, including during `preview`. Read them before you run a template from a branch you did not write. +::: -## Security Notes -**SQL Injection**: Always use `$.quote()` or `$.escape()` for values from data files. Even trusted data files can contain characters that break SQL syntax. +## What bites -**Secret Exposure**: The rendered SQL may contain sensitive values from `$.secrets`. Don't log rendered templates in production. -**Code Execution**: `$helpers.ts` files execute arbitrary code whenever a template in or under their directory renders. `.js`, `.mjs`, and `.ts` data files execute only when a template names their key. Both run in the noorm process with your environment. Only use trusted sources. +| Mistake or symptom | Cause | Fix | +|---|---|---| +| `49.00` renders as `49` | YAML and JSON parse it as the number 49 | Quote it in the data file (`"49.00"`), or format it in a helper such as `$.price()` | +| `Secret "PAYMENT_GATEWAY_KEY" not found (searched: config-local, global-local, vault)` | No tier holds the key. `$.secrets.KEY ?? fallback` cannot catch it, because the read throws first | Set the secret, or test first: `'PAYMENT_GATEWAY_KEY' in $.secrets ? $.quote($.secrets.PAYMENT_GATEWAY_KEY) : 'NULL'` | +| `sqlQuote() received undefined` | A field the row lacks, a typo, or an unset `$.globalSecrets` key | Fix the name. Pass `null` when you mean SQL `NULL` | +| `$.config.name` is undefined, or holds a file's fields | A `config.yml` beside the template replaced `$.config` | Rename the data file | +| `[object Promise]` in the SQL | `$.include()` is async | Write `{%~ await $.include('...') %}` | +| A string with `'` breaks the statement | The value went in with `{%~ %}` alone, which writes raw text | Wrap every string from a data file in `$.quote()` | +| One of two data files is ignored | `user_roles.json`, `user-roles.yaml`, and `user.roles.csv` all become `$.userRoles`, and the last one loaded wins | Give each file a distinct base name | +| A `.ts` data file never loads, even in `preview` | The template does not name its key | Write `$.key` in the template | +| A `$helpers.ts` fails to load on Node | Node older than 22.13 cannot strip TypeScript types | Use `$helpers.js`. `run inspect` lists the failure under `Helper Errors:` | -**Path Traversal**: `$.include()` resolves relative paths and cannot escape the project root. +## Next -## What's Next? -- [Organization](/guide/sql-files/organization) - Control execution order with file naming -- [Secrets](/guide/environments/secrets) - Securely store credentials -- [Vault](/guide/environments/vault) - Team-shared secrets with resolution hierarchy -- [Changes](/guide/changes/overview) - Versioned changes with template support -- [Data Transfer](/guide/database/transfer) - Export/import .dt files for seeding templates +- [Secrets](/guide/environments/secrets): set the values a template reads +- [Organization](/guide/sql-files/organization): where templates sit in the build order +- [Execution](/guide/sql-files/execution): how a rendered template is hashed and skipped +- [Data transfer](/guide/database/transfer): export `.dt` files to use as seed data diff --git a/docs/guide/troubleshooting.md b/docs/guide/troubleshooting.md index f3949bbb..c92917f3 100644 --- a/docs/guide/troubleshooting.md +++ b/docs/guide/troubleshooting.md @@ -1,146 +1,42 @@ -# Troubleshooting common surprises +# Troubleshooting -A short list of the issues users hit most often when first reaching -for `noorm`. Each entry is a search target plus a one-paragraph fix -and a link to the deeper doc. +Find the message you see, read the cause, and apply the fix. Exit code `2` means the command never ran, `1` means it failed and a retry is safe, and `3` means some work landed ([exit codes](/headless#exit-codes)). -## `noorm --config prod run build` fails or targets the wrong directory +## Why does a command fail or surprise me? -`--config` (long form) is per-subcommand only — it has no global meaning. Neither do -`--dry-run`, `--json`, or `--yes`: the only flag with a root-level meaning is `-c`/`--cwd`. -Placed before the subcommand, `--config` (and everything else) is rejected outright rather -than silently ignored: - # rejected: --config has no global form - noorm --config prod run build +| Message or symptom | Cause | Fix | +|---|---|---| +| `Unrecognized flag '--config' before the subcommand` | Only `-c`/`--cwd` is a root flag | `noorm run build --config prod` | +| `Error: --cwd path is not a directory: …/prod` | In `noorm -c prod run build`, `-c` means `--cwd` | `noorm run build -c prod` | +| `Unknown command help` | There is no `noorm help` | `noorm db create --help` | +| `db create --name foo` creates nothing named `foo` | `db create` has no `--name`. The config names the database | `noorm config import dev-config.json`, then `noorm db create -c dev` | +| `Unknown command` from `noorm sql "…"` | The `sql query` rewrite needs the query to start with a SQL verb, so a leading comment defeats it | `noorm sql query "…"` | +| `Error: noorm init requires an interactive terminal.` | No terminal, no `--yes` | `noorm identity init …`, then `noorm init --yes` | +| `--dry-run` leaves files in `tmp/` | It renders SQL there with secrets resolved, and `tmp/` is not gitignored | Delete `tmp/`, or use `noorm run preview ` | - # works - noorm run build --config prod - noorm run build -c prod -`-c` is the sharper trap: `noorm -c prod run build` is *not* the same command — before the -subcommand, `-c` means `--cwd`, not `--config`. See [CLI flag conventions](../cli/flags.md) -for the full rule. +## Why does the database refuse or misbehave? -## `noorm help db create` errors with "Unknown command help" +Each server sends one error for a wrong password and an unknown account. noorm rewords it, and the server's own text is in `.noorm/state/noorm.log` (`connection:error`, fields `serverCode` and `serverMessage`). -There is no `noorm help ` form — `noorm`'s CLI framework -(citty) doesn't ship one. Use `--help` (or `-h`) instead: +| Message | Also sent for | +|---|---| +| `Login failed for user …` (SQL Server) | Unknown login, login denied `CONNECT SQL`, Windows-only authentication | +| `Password authentication failed for user …` (PostgreSQL) | Unknown role, password past `VALID UNTIL` | +| `Access denied for user …` (MySQL) | Unknown user, no account for this client host, `REQUIRE SSL` account | - noorm db create --help - noorm change ff --help - noorm sql query --help +| Symptom | Cause | Fix | +|---|---|---| +| A right password fails from `NOORM_CONNECTION_PASSWORD` | The compiled binary does not read `.env` files | Export the variable, or set it in CI | +| An MSSQL procedure's `DEFAULT` is not applied | The SDK sends `undefined` as `NULL`, and MSSQL applies a `DEFAULT` only to an omitted parameter | `.default(value)` in the Zod schema, or omit the key ([details](/reference/sdk#parameter-handling-and-null-semantics)) | -See [Discovering command help](../cli/help.md). +## Next -## `noorm db create --name foo` doesn't work -`noorm db create` has no `--name` flag. The database name lives in the -stored config; create the config first, then point `db create` at it. -The intended workflow is: - - noorm config import dev-config.json - noorm db create -c dev - noorm config use dev - -See [Creating a database](./database/create.md) for the reasoning and -the full CI bootstrap. - - -## My MSSQL proc's `DEFAULT` value isn't applied - -A Zod schema with `.optional()` parses missing keys to `undefined`. -The SDK serializes `undefined` (and `null`) to SQL `NULL` when -building a named-parameter `EXEC`. MSSQL applies a parameter's -`DEFAULT` only when the parameter is *omitted* from the call, not when -it's explicitly `NULL`. Two fixes: - -1. Use `.default()` in your Zod schema so the parsed input - always carries a concrete value. -2. Or, build the params object without the key entirely (e.g. - conditionally `delete obj.key` before calling `ctx.proc(...)`). - The SDK only emits `@key = …` for keys present in the object. - -See [SDK reference — Parameter handling and NULL semantics](../reference/sdk.md#parameter-handling-and-null-semantics). - - -## `noorm sql "SELECT ..."` errors with "Unknown command" - -The bare `noorm sql ""` form is supported, but only when the -first token of the query is one of the SQL verbs noorm rewrites on -(`SELECT`, `INSERT`, `UPDATE`, `DELETE`, `WITH`, etc.). Citty resolves -`sql`'s subcommands first, so noorm rewrites `sql …` to -`sql query …` before citty ever sees it. For anything -that starts with a leading comment, a CTE-style preamble, or a -non-recognized verb, use the explicit subcommand: - - noorm sql query "" - noorm sql query -f path/to/file.sql - -See [`noorm sql`](../cli/sql.md) for the full list of recognized -keywords and when each form fires. - - -## `noorm change ff --dry-run` wrote files I didn't expect - -`change ff` does accept `--dry-run`, and it does not touch the -database. What it does do is *render* every pending change to `tmp/` -so you can read the SQL that would have run, including any secrets -the templates resolved, in plaintext. `tmp/` is not gitignored by a -project noorm scaffolds, so clean it up. - - noorm change ff --dry-run - noorm run build --dry-run - -To render a single template without the change machinery, use -`noorm run preview `. From the SDK the same preview is -`changes.apply(name, { dryRun: true })` or -`changes.ff({ dryRun: true })`. - - -## `noorm init` errors in CI with "requires an interactive terminal" - -The bootstrap path needs an existing identity at -`~/.noorm/identity.{key,pub,json}`, then `--yes` (or `NOORM_YES=1`) -skips every other prompt. Two commands: - - noorm identity init --name "$CI_USER" --email "$CI_EMAIL" - noorm init --yes - -See [Non-interactive operation](./automation/non-interactive.md). - - -## Login fails with a password I know is right - -SQL Server, PostgreSQL, and MySQL each send one error for a wrong password and -an unknown account, so a client cannot learn which accounts exist. noorm says so -and lists what that error covers; the server's own log names the cause: - -| Server | Error | Also sent for | -|------------|----------------------------------|------------------------------------------------------------------------| -| SQL Server | `Login failed` (18456) | unknown login, login denied `CONNECT SQL`, Windows-only authentication | -| PostgreSQL | `password authentication failed` | unknown role, password past `VALID UNTIL` | -| MySQL | `Access denied` (1045) | unknown user, no account for this client host, `REQUIRE SSL` account | - -The message noorm shows is reworded for the user. What the server itself -sent is in the log (`.noorm/state/noorm.log`): the `connection:error` -entry carries `serverCode` (error number, SQLSTATE, or driver code) and -`serverMessage` (the server's text, including any wrapped socket error). - -A password that reaches noorm through a `NOORM_CONNECTION_PASSWORD` -environment variable arrives exactly as the shell exported it. The -compiled `noorm` binary does not read `.env` files, so export the -variable or set it in CI. - - -## Related - -- [CLI flag conventions](../cli/flags.md) -- [Discovering command help](../cli/help.md) -- [Creating a database](./database/create.md) -- [Non-interactive operation](./automation/non-interactive.md) -- [SDK reference](../reference/sdk.md) +- [CLI flag conventions](/cli/flags): where each flag goes +- [Non-interactive operation](/guide/automation/non-interactive): `--yes` and what still refuses diff --git a/docs/headless.md b/docs/headless.md index bbf3458b..729d375c 100644 --- a/docs/headless.md +++ b/docs/headless.md @@ -1,479 +1,486 @@ # CLI Reference -Every `noorm` command runs as a non-interactive CLI by default. The Ink/React TUI lives behind a separate subcommand: +Run noorm from a script or CI job, read one JSON result, and branch on the exit code. + +With one pending change, `change ff` writes a single JSON document to stdout and its log to stderr. The exit code matches `success`: ```bash -noorm run build # CLI: build schema headlessly -noorm change ff --json # CLI: fast-forward with JSON output -noorm ui # TUI: launch the interactive terminal UI +noorm change ff --json > ff.json 2> ff.log; echo "exit $?" +jq -c '{success, status, executed, failed}' ff.json ``` -There is no mode detection, no `--headless` or `--tui` flag, no CI heuristic. If you want the wizard, run `noorm ui` explicitly. +```text +exit 0 +{"success":true,"status":"success","executed":1,"failed":0} +``` +`ui` and `sql repl` open the TUI, and `settings edit` and `settings secret` need a terminal. `init` needs a terminal or `--yes`. Every other command runs headless. -## Discovering Commands -Every command exposes native `--help` rendered by [citty](https://github.com/unjs/citty). The root help lists every subcommand; per-command help shows arguments, options, and examples. +## How a command runs -```bash -noorm --help # Top-level command list -noorm change --help # Subcommands of `change` -noorm change ff --help # Args, options, and examples for `change ff` -``` -For shell completion, run `noorm complete ` and source the script it prints. Supported shells: `zsh`, `bash`, `fish`, `powershell`, `fig`. A bare `noorm complete` is the runtime completion hook, not a generator, and errors without `--`. +### Where do flags go? -## Global Flags +Only `-c`/`--cwd` is read before the subcommand. Every other flag goes after it. -A flag goes on the command that uses it. `-c`/`--cwd ` is the one exception: it's -recognized **before** the subcommand instead, because it's consumed before dispatch — it sets -the working directory everything else (config discovery, project root) resolves against. An -unrecognized or misplaced flag typed before the subcommand exits non-zero naming the correct -form, rather than being silently dropped. +| Position | Flags | Effect | +|---|---|---| +| Before the subcommand | `-c `, `--cwd `, `--cwd=` | Run in `` instead of walking up to the nearest `.noorm/`. A path that is not a directory exits 2 | +| Anywhere | `--help`, `-h` | Print help | +| As the only argument | `--version`, `-v` | Print the version | +| After the subcommand | all other flags | Per-command flags | -| Flag | Short | Description | -|------|-------|-------------| -| `--cwd ` | `-c ` | Run the subcommand inside ``. Skips the walk-up that normally finds the nearest `.noorm/`. | +Any other flag before the subcommand exits 2 and names where to move it. ```bash -# Run the subcommand at packages/db, not the repo root -noorm -c packages/db run build - -# Initialize a nested project from elsewhere -noorm -c packages/db init - -# every other flag belongs on the subcommand -noorm run build --dry-run +noorm -c packages/db run build # --cwd: build inside packages/db +noorm run build -c staging # --config: build against the staging config ``` -`--cwd`/`-c` only has this global meaning *before* the subcommand — after it, `-c` is the -per-command `--config` alias instead (see the note below). +These flags recur across commands. Each command section lists the ones it accepts: +| Flag | Alias | Effect | +|---|---|---| +| `--config ` | `-c` | Use this stored config instead of the active one | +| `--json` | | Print one JSON document on stdout | +| `--dry-run` | | Preview without writing. `run` and `change` commands render to `tmp/` | +| `--force` | `-f` | `run` and `change`: skip the checksum check. `init`, `ci init`, `settings init`, `config import`, `vault cp`, `identity init`: overwrite what exists | +| `--yes` | `-y` | Confirm a command the config's role marks `confirm`. `NOORM_YES` does the same | -## Common Flags +Run `noorm --help` for a command's flags and an EXAMPLES block. Shell completion is [`complete`](#complete). -These options are reused across most commands. Run ` --help` to see exactly which apply. -| Flag | Short | Description | -|------|-------|-------------| -| `--json` | — | Emit machine-readable JSON instead of human-friendly text | -| `--config` | `-c` | Use a specific stored configuration (overrides the active one) | -| `--force` | `-f` | Skip checksum / safety checks | -| `--yes` | `-y` | Skip confirmation prompts | -| `--dry-run` | — | Preview without executing | -| `--help` | `-h` | Show command help (citty native) | +### Which database does a command use? -> **Note:** `-c` is overloaded by position. `noorm -c run` is the global cwd flag; `noorm run -c ` is the per-command config alias. The first non-flag token (the subcommand) is the boundary. Every flag other than `-c`/`--cwd` — `--config`, `--force`, `--json`, `--dry-run`, `--yes`, etc. — has no global meaning; it only works after the subcommand. See [CLI flag conventions](./cli/flags.md) for the full reasoning. For per-command help, see [Discovering command help](./cli/help.md). +A command resolves one config by name, or builds one from the environment when no name resolves: -## Configuration +```mermaid +flowchart TD + N{"Name from flag,
env, or active?"} + N -->|"name, stored"| ST["Stored config"] + N -->|"name, not stored"| E1["Error: Config not found"] + N -->|"no name"| V{"Dialect and database
set in environment?"} + V -->|yes| EO["Env-only config"] + V -->|no| E2["Error: no config
available"] + ST --> R["Resolved config"] + EO --> R +``` +The resolved config merges three layers, and a later layer wins. Then a `settings.yml` stage named like the config, if it has `protected: true`, caps the config's access: -### Using a Stored Config +1. Built-in defaults +2. The stored config +3. `NOORM_*` environment variables -```bash -# Use active config -noorm run build +A `NOORM_CONNECTION_HOST` therefore overrides the host of a stored config. CLI flags select the config but never change its connection fields. -# Use specific config -noorm run build --config staging - -# Or via environment -export NOORM_CONFIG=staging -noorm run build -``` +### Environment-only mode -### Environment-Only Mode -No stored config needed. Set connection via environment variables: +Set `NOORM_CONNECTION_DIALECT` and `NOORM_CONNECTION_DATABASE` and skip stored configs. The config is named `__env__`. Host defaults to `localhost`. ```bash export NOORM_CONNECTION_DIALECT=postgres export NOORM_CONNECTION_HOST=db.example.com -export NOORM_CONNECTION_DATABASE=myapp +export NOORM_CONNECTION_DATABASE=app export NOORM_CONNECTION_USER=deploy export NOORM_CONNECTION_PASSWORD=$DB_PASSWORD - noorm run build ``` -**Minimum required:** -- `NOORM_CONNECTION_DIALECT` (postgres, mysql, sqlite, mssql) -- `NOORM_CONNECTION_DATABASE` +Env-only mode applies only when no name resolves. A runner with an active stored config, such as one set up by `ci init`, uses that config and layers the variables over it. A clean checkout has no `state.enc`, so env-only mode needs no identity. -### All Environment Variables +### What does a command print? -**Connection:** -```bash -NOORM_CONNECTION_DIALECT # postgres, mysql, sqlite, mssql -NOORM_CONNECTION_HOST # Database host -NOORM_CONNECTION_PORT # Database port -NOORM_CONNECTION_DATABASE # Database name -NOORM_CONNECTION_USER # Username -NOORM_CONNECTION_PASSWORD # Password -``` -**Identity (CI):** -```bash -NOORM_IDENTITY_PRIVATE_KEY # X25519 private key, hex PKCS8 DER (96 hex chars) -NOORM_IDENTITY_NAME # Display name (e.g. "CI Bot") -NOORM_IDENTITY_EMAIL # Email (e.g. "ci@example.com") -NOORM_CI_CONFIG_NAME # Default config name for `ci init` (override: --name) -``` +| Stream | Carries | +|---|---| +| stdout | The result: summary text, or with `--json` exactly one JSON document | +| stderr | Progress lines, warnings, log events (JSON lines under `--json`), and error diagnostics | +| exit code | The outcome. See [Exit codes](#exit-codes) | -**Paths:** -```bash -NOORM_PATHS_SQL # Schema directory (default: ./db/sql) -NOORM_PATHS_CHANGES # Changes directory (default: ./db/changes) -``` +Every `--json` payload is an object with a boolean `success`. Three rules hold for all commands: -**Behavior:** -```bash -NOORM_CONFIG # Config name to use -NOORM_YES # Skip confirmations (1 or true) -NOORM_DEBUG # Enable debug logging +1. `jq -e '.success'` works on the `--json` output of every command except `dev test-helpers`, which prints a text report first. +2. `success` is `true` if and only if the exit code is `0`. A partial result is `success: false` with `"status": "partial"` and exit `3`. +3. A failed payload carries `error` as a message string, never a stack trace. + +```json +{"success":false,"error":"Template not found: db/sql/nope.sql.tmpl"} ``` +A command with nothing to report still writes a result: `change list` on a project with no changes prints `No changes found.`, or `{"success":true,"changes":[],"pending":0}` under `--json`. Lists sit under a named key (`changes`, `tables`, `configs`), never at the top level. -### Environment Variable Overrides -All `NOORM_*` environment variables are processed through a nesting convention that maps underscores to object depth. This lets you override any config property from the environment without editing files. +## Exit codes -**Meta variables** are handled separately and control CLI behavior rather than config values: -| Variable | Purpose | -|----------|---------| -| `NOORM_CONFIG` | Select which stored config to use | -| `NOORM_YES` | Skip confirmations (`1` or `true`) | -| `NOORM_CHANNEL` | Force the policy channel: `user` or `agent` (default: detected from the environment) | +Defined in `src/cli/_exit.ts`. The split between `1` and `3` matters most in a pipeline. -These are excluded from config nesting and consumed directly by the CLI. +| Code | Name | Meaning | Remedy | +|---|---|---|---| +| `0` | success | Everything asked for happened | Continue | +| `1` | failure | The operation ran and no unit of work succeeded, or it failed before starting: connection error, policy denial, missing `--yes`, unreadable state | Read `error` or stderr, fix the cause, retry | +| `2` | usage | A usage error the command checks for itself: an invalid flag value, a named target that does not exist (directory, glob, config, change, secret or vault key), a TTY-only command run without a terminal, or `lock force` with nothing to release. Nothing was attempted. A missing required flag (reported by the argument parser), `run file` on a missing file, and `secret set` on a missing config exit `1` | Fix the invocation. Retrying unchanged fails the same way | +| `3` | partial | Some units succeeded and some failed. The target is in a mixed state | Do not retry blindly. Inspect first with `change list`, `change history-detail`, or `vault list`, then repair or revert | -**Nesting rules** -- underscore separates object levels after stripping the `NOORM_` prefix: +Commands that can exit `3`: -``` -NOORM_CONNECTION_HOST → connection.host -NOORM_CONNECTION_PORT → connection.port -NOORM_PATHS_SQL → paths.sql -NOORM_PATHS_CHANGES → paths.changes -``` +- `run build`, `run dir`, `run exec`, `run files` +- `change ff`, `change next`, `change rewind` +- `db transfer` (database to database), `ci secrets`, `vault propagate` +- `dev test-helpers`, `dev test-workers` -**Type conversion** -- string values are auto-converted: -- Numbers: `NOORM_CONNECTION_PORT=5432` becomes `5432` (number) -- Booleans: `NOORM_DEBUG=true` becomes `true` (boolean) -- Password fields skip conversion and remain strings regardless of content +Policy denials exit `1`. They have no dedicated code. -**Priority order** (highest to lowest): -1. CLI flags (`--config`, `--json`, `--yes`) -2. Environment variables (`NOORM_*`) -3. Settings file (`.noorm/settings.yml`) -4. Config defaults +## Environment variables -## Access Roles -Each config carries a per-channel access grant: `access: { user, agent }`. The `user` role governs a human at the CLI, TUI, or SDK; `agent` governs an AI agent, over MCP or the CLI (see [MCP](./guide/automation/mcp.md)). Roles are fixed — `viewer`, `operator`, `admin` — and hard-coded to this matrix (cells: allow / confirm / deny): +Names after the `NOORM_` prefix nest on underscores: `NOORM_CONNECTION_HOST` sets `connection.host`. Numeric values become numbers, `true` and `false` become booleans, and values whose name contains `password` stay strings. Other unreserved `NOORM_*` names overlay the matching key in `settings.yml`. -| Command class | viewer | operator | admin | -|---|---|---|---| -| explore, `sql` reads | allow | allow | allow | -| `sql` writes (INSERT/UPDATE/DELETE) | deny | allow | allow | -| `sql` DDL (CREATE/ALTER/DROP/...) | deny | deny | allow | -| `change run`/`ff`/`revert`, `run build`/`file`/`dir` | deny | confirm | allow | -| `db create`, `db reset`, `db transfer --to` | deny | confirm | allow | -| `db truncate` | deny | confirm | confirm | -| `db teardown` | deny | deny | confirm | -| `db drop` | deny | deny | confirm | -| `config rm`, `config import --force` | deny | confirm | confirm | -| `change rm` | deny | confirm | confirm | -| `vault list`, `secret list`/`config export` | deny | allow | allow | -| `vault set`/`rm`, `secret set`/`rm` | deny | confirm | allow | -| `vault propagate` | deny | confirm | confirm | -| `lock force` | deny | confirm | confirm | +| Variable | Effect | +|---|---| +| `NOORM_CONNECTION_DIALECT` | `postgres`, `mysql`, `sqlite`, or `mssql`. With `NOORM_CONNECTION_DATABASE`, the minimum for env-only mode | +| `NOORM_CONNECTION_DATABASE` | Database name | +| `NOORM_CONNECTION_HOST`, `_PORT`, `_USER`, `_PASSWORD`, `_FILENAME` | Server, credentials, and the SQLite file | +| `NOORM_CONFIG` | Stored config to use. `--config` wins | +| `NOORM_YES` | Any value except empty, `0`, or `false` (case-insensitive). Confirms every `confirm` cell on the `user` channel, like `--yes`. `identity init --force` and `sql clear` read only the literal flag | +| `NOORM_CHANNEL` | `user` or `agent`. Forces the access channel. Other values are ignored | +| `NOORM_IDENTITY_PRIVATE_KEY` | X25519 private key, hex PKCS8 DER, 96 characters | +| `NOORM_IDENTITY_NAME`, `NOORM_IDENTITY_EMAIL` | Display name and email. All three `NOORM_IDENTITY_*` variables must be valid or the CLI ignores them | +| `NOORM_IDENTITY` | Audit identity as `Name `. Used for change and build records when no `NOORM_IDENTITY_*` set is present and the config has no `identity` override. Falls back to the git user, then the system user | +| `NOORM_CI_CONFIG_NAME` | Default config name for `ci init`. `--name` wins, then `ci` | +| `NOORM_PATHS_SQL`, `NOORM_PATHS_CHANGES` | Override `paths.sql` and `paths.changes`. Defaults are `./db/sql` and `./db/changes` | +| `NOORM_INSECURE` | Same as `noorm update --insecure` | +| `NOORM_DEBUG` | Truthy enables event tracing and connection debug output | +| `NOORM_DEV` | `true` raises the default log level from `info` to `verbose` | +| `NOORM_AGENT` | Marks the session as an agent. See [Which channel am I on?](#which-channel-am-i-on) | +| `NOORM_HEADLESS`, `NOORM_LOGGER_DEBUG`, `NOORM_JSON` | Reserved. Excluded from config nesting. The logger reads the first two. Use `--json` for JSON output | + + +## Access roles + + +Each config carries `access: { user, agent }`. The `user` role governs a human at the CLI, TUI, or SDK. The `agent` role governs an AI agent over MCP or the CLI. Roles are `viewer`, `operator`, and `admin`, and `agent` can also be `false`, which hides the config from agents. A config with no explicit access gets `{ user: admin, agent: viewer }`. A legacy `protected: true` maps to `{ user: operator, agent: viewer }`, and a stage with `protected: true` caps its configs at that pair. + + +### Which channel am I on? + + +noorm resolves the channel from who is driving, not from which binary ran: + +1. `NOORM_CHANNEL`, when set to exactly `user` or `agent`. +2. A non-empty harness marker variable: `CLAUDECODE`, `CLAUDE_CODE`, `CLAUDE_CODE_ENTRYPOINT` (Claude Code), `CODEX_SANDBOX` (OpenAI Codex), `CURSOR_AGENT` (Cursor), `GEMINI_CLI` (Gemini CLI), `AI_AGENT` or `NOORM_AGENT` (any other agent). The channel is `agent`. +3. Otherwise `user`. + +`noorm mcp serve` is always `agent`. `noorm info` reports the detected harness and the marker variables that triggered it, which is the first place to look when a denial has no visible cause. -Raw SQL (`noorm sql`) is gated by what the statement actually does, not by a flag — a multi-statement input is classified by its highest class (a `SELECT` plus a `DROP` classifies as DDL). Unparseable or unrecognized statements classify as DDL, fail closed. -`confirm` means: type the phrase `yes-` when prompted, or set `NOORM_YES=1` to skip the prompt in CI. There is no `--force` override for a denied permission — `--force` only skips file checksums (see [Common Flags](#common-flags)). +### What happens when a command is gated? -**Migration note:** the old `Config.protected: boolean` maps automatically on first load — `protected: true` becomes `{ user: 'operator', agent: 'viewer' }`, `protected: false` (or absent) becomes the default `{ user: 'admin', agent: 'viewer' }`. A config that already stores an explicit `access` keeps it untouched. The legacy field is still accepted on `config import` for one version, then dropped. + +Each command maps to a permission. The config's role for the channel picks a cell: `allow`, `deny`, or `confirm`. + +```mermaid +flowchart TD + C{"Role's cell for
the command"} + C -->|allow| RUN["Command runs"] + C -->|deny| FAIL["Exit 1 with reason"] + C -->|confirm| CH{"Channel"} + CH -->|agent| FAIL + CH -->|user| Y{"--yes or
NOORM_YES?"} + Y -->|yes| RUN + Y -->|no| FAIL +``` + +On the `agent` channel a `confirm` cell is a deny, so `--yes` never unlocks it. There is no `--force` override for a denied cell: `--force` only skips checksums. The TUI asks you to type `yes-` for the same cells. + +| Permission | Commands | viewer | operator | admin | +|---|---|---|---|---| +| `explore`, `sql:read` | `db explore`, `sql query` with `SELECT`, or `SHOW` and `DESCRIBE` on MySQL | allow | allow | allow | +| `sql:write` | `sql query` with `INSERT`, `UPDATE`, `DELETE`, `MERGE` | deny | allow | allow | +| `sql:ddl` | `sql query` with any other statement | deny | deny | allow | +| `change:run`, `change:revert` | `change run`, `ff`, `next`, `revert`, `rewind` | deny | confirm | allow | +| `run:build`, `run:file`, `run:dir` | `run build`, `file`, `dir`, `exec`, `files`, and `inspect`, `preview` against a resolved config | deny | confirm | allow | +| `db:create`, `db:reset` | `db create`, `db reset`, `db transfer --to`, `db transfer --import` | deny | confirm | allow | +| `db:truncate` | `db truncate` | deny | confirm | confirm | +| `db:teardown` | `db teardown` | deny | deny | confirm | +| `db:destroy` | `db drop` | deny | deny | confirm | +| `change:rm` | `change rm` | deny | confirm | confirm | +| `config:rm`, `config:write` | `config rm`, `config import --force` | deny | confirm | confirm | +| `vault:read`, `secret:read` | `vault list`, `secret list`, `config export` | deny | allow | allow | +| `vault:write`, `secret:write` | `vault init`, `set`, `rm`, `cp`, and `secret set`, `rm` | deny | confirm | allow | +| `vault:propagate` | `vault propagate`, `ci identity enroll` | deny | confirm | confirm | +| `transfer:plan` | `db transfer --to --dry-run` | deny | allow | allow | +| `lock:force` | `lock force` | deny | confirm | confirm | + +Commands not listed have no role gate. + +`sql query` classifies the statement, not a flag. Multiple statements take the highest class (`read` < `write` < `ddl`), `EXPLAIN` takes the class of what it wraps, and anything unparseable or unrecognized is `ddl`. + +Roles catch mistakes such as a teardown against the wrong database. They do not stop someone who holds the credentials and connects with another client. ## Commands -### Project Bootstrap +These commands never connect to a database: + +- `identity`, `settings`, `secret`, `version`, `mcp init` +- `ci init`, `ci secrets`, `ci identity new` +- `change add`, `change edit`, `change rm` +- `sql history`, `sql clear` +- `config`, except `use` and `validate` + +`run inspect` and `run preview` try one short connection to read vault secrets. + + +### Project + #### `init` -Bootstrap a new noorm project interactively. Creates identity (if needed), project structure (`db/sql/`, `db/changes/`, `db/AGENTS.md`, `db/CLAUDE.md`, `.noorm/`), `settings.yml`, and encrypted `state.enc`. + +Create `.noorm/`, `settings.yml`, an encrypted `state.enc`, and a `# noorm` block in `.gitignore`. On the default layout, also create `db/sql/`, `db/changes/`, `db/AGENTS.md`, and `db/CLAUDE.md`. Create an identity first when none exists. + +``` +noorm init [--force] [--yes] [--here] +``` + +| Flag | Effect | +|---|---| +| `--force`, `-f` | Reinitialize a directory that already has `.noorm/`. Without it, exit 1. Existing `paths` in `settings.yml` are kept, so a project on `./sql` and `./changes` gets no `db/` folder. Existing `db/AGENTS.md` and `db/CLAUDE.md` are not overwritten | +| `--yes`, `-y` | Skip prompts. Needs an identity at `~/.noorm/identity.{key,pub,json}`, else exit 2 | +| `--here` | Initialize in the directory you launched from, ignoring a parent `.noorm/` | ```bash -noorm init # Interactive, requires TTY -noorm init --force # Reinitialize an existing .noorm/ -noorm init --yes # Non-interactive, requires existing identity +noorm identity init --name "CI Bot" --email ci@example.com +noorm init --yes ``` -**TTY required by default.** Fails with exit code 2 (usage error) in CI or when stdin is piped. For scripted bootstrap, set up the identity with `noorm identity init --name ... --email ...` then run `noorm init --yes` (or set `NOORM_YES=1`). For the full non-interactive matrix across all TTY-gated commands, see [Non-interactive operation](./guide/automation/non-interactive.md). For ephemeral CI runners, use `noorm ci init` instead. +Without a terminal and without `--yes` or `NOORM_YES`, `init` exits 2. Ephemeral runners use [`ci init`](#ci-init) instead. See [`noorm init`](/cli/init). -### Configuration +#### `ui` -#### `config list` -List available configurations. +Open the TUI at the home screen. Takes no flags. ```bash -noorm config list -noorm config list --json +noorm ui ``` -**JSON output:** -```json -{ - "success": true, - "configs": [ - {"name": "dev", "type": "local", "dialect": "postgres", "database": "app", "isActive": true, "isTest": false, "access": {"user": "admin", "agent": "viewer"}}, - {"name": "staging", "type": "remote", "dialect": "postgres", "database": "app", "isActive": false, "isTest": false, "access": {"user": "admin", "agent": "viewer"}} - ] -} -``` -Configs whose `access.agent` is `false` are omitted when the caller resolves to the `agent` channel — the same filtering the `list_configs` MCP command applies. +### Config -#### `config use ` +Local state in `.noorm/state/state.enc`. `config use` also registers your identity in that config's database when it can connect. + + +#### `config list` -Set the active configuration. + +List stored configs. ```bash -noorm config use dev -noorm config use production --json +noorm config list --json ``` -**JSON output:** -```json -{ - "success": true, - "activeConfig": "production" -} -``` +JSON: `configs[]` with `name`, `type`, `isTest`, `access`, `isActive`, `dialect`, `database`. Configs whose `access.agent` is `false` are omitted on the `agent` channel. -#### `config add` +#### `config use ` -Create a new configuration. -> Wizard-only. Launches the TUI wizard. To configure non-interactively, use `NOORM_*` environment variables or `config import`. Exits 1 (message on stderr) — there is no headless equivalent. +Set the active config. An unknown name exits 2. JSON: `activeConfig`. ```bash -noorm config add +noorm config use staging ``` -#### `config edit [name]` +#### `config add` -Edit an existing configuration. -> Wizard-only. Launches the TUI wizard. Use `NOORM_*` env vars or `config export`/`config import` to modify non-interactively. Exits 1 (message on stderr) — there is no headless equivalent. +Wizard only. Exits 1 with a pointer to `noorm ui` and `config import`. -```bash -noorm config edit dev -``` + +#### `config edit [name]` + + +Wizard only. Exits 1 with a pointer to `noorm ui` and `config import --force --yes`. #### `config rm ` -Remove a configuration. + +Delete a stored config. ```bash noorm config rm staging --yes -noorm config rm staging --yes --json ``` -**JSON output:** -```json -{ - "success": true, - "name": "staging", - "deleted": true -} -``` +| Condition | Result | +|---|---| +| No `--yes` and no `NOORM_YES` | Exit 1. There is no confirmation prompt | +| Unknown name | Exit 2 | +| Config linked to a locked stage | Exit 1 until the stage is unlocked | +| Role denies `config:rm` | Exit 1 | -::: danger Destructive -Requires `--yes` (or `NOORM_YES=1`) — there is no TTY confirmation prompt, so the flag is mandatory even at an interactive terminal. An unknown config name fails with exit 2. Gated by the config's `config:rm` access (see [Access Roles](#access-roles) above); a config linked to a locked stage cannot be removed until the stage is unlocked. -::: +JSON: `name`, `deleted`. #### `config cp ` -Copy a configuration to a new name. + +Copy a config under a new name. Unknown `` exits 2. An existing `` exits 1, and there is no `--force`. JSON: `src`, `dest`. ```bash noorm config cp dev staging -noorm config cp dev prod --json ``` -**JSON output:** -```json -{ - "success": true, - "src": "dev", - "dest": "staging" -} -``` - -A destination name that already exists fails with exit 1; `config cp` has no `--force`. - #### `config export ` -Export a configuration to JSON. Useful for sharing configs between machines or backing up before edits. + +Write a config as JSON, password included. ```bash -noorm config export dev -noorm config export dev --output ./dev-config.json +noorm config export dev > dev.json +noorm config export dev --output ./dev.json ``` -| Flag | Description | -|------|-------------| -| `-o, --output` | Write to file instead of stdout (mode `0600`) | +| Flag | Effect | +|---|---| +| `--output`, `-o` | Write to a file with mode `0600` instead of stdout | -Plain `config export dev` writes the bare config object — the exact shape `config import` reads — so `noorm config export dev > dev.json` round-trips. `--json` wraps it in the envelope instead (`{"success": true, "name": "dev", "config": {…}}`), which is not importable as-is. - -The export carries the connection password in plaintext, so it is gated by `secret:read`: a `viewer` is denied. +Plain output is the bare config object that `config import` reads, so redirecting round-trips. `--json` wraps it as `{success, name, config}`, which `config import` does not accept. Gated by `secret:read`. #### `config import ` -Import a configuration from a JSON file. + +Create a config from JSON. Unreadable or invalid JSON, or a missing `name` or `connection`, exits 2. ```bash -noorm config import ./dev-config.json -noorm config import ./staging-config.json --force --yes +noorm config import ./staging.json +noorm config import ./staging.json --force --yes ``` -| Flag | Description | -|------|-------------| -| `--force` / `-f` | Overwrite existing config with the same name | -| `--yes` / `-y` | Confirm the overwrite. Required with `--force`, because replacing a config rewrites its `access` roles — a `config:write` confirm cell for every role that holds it | +| Flag | Effect | +|---|---| +| `--force`, `-f` | Overwrite an existing config of the same name. Without it, exit 1 | +| `--yes`, `-y` | Confirm the overwrite. An overwrite rewrites `access`, so it is a `config:write` confirm cell | + +JSON: `name`, `overwritten`. #### `config validate ` -Validate that a configuration can connect to its database. -```bash -noorm config validate dev -noorm config validate production --json -``` +Test that a config connects. -**JSON output:** -```json -{ - "success": true, - "config": "dev", - "valid": true, - "checks": [ - {"label": "Connection", "status": "success", "detail": "Connection successful"}, - {"label": "Name", "status": "success", "detail": "dev"} - ] -} +```bash +noorm config validate prod --json ``` -Exit code 0 if valid, 1 if any check fails, 2 if the config name is unknown. +Exit 0 when valid, 1 when a check fails, 2 for an unknown name. JSON: `config`, `valid`, `checks[]` with `label`, `status`, `detail`. ### Identity -Manage your cryptographic identity. Identity is used for change attribution, lock ownership, and vault encryption. + +Your keypair lives at `~/.noorm/identity.{key,pub,json}`. It signs change history, owns locks, and encrypts state and the vault. + #### `identity init` -Create a new cryptographic identity. Generates an X25519 keypair and stores it at `~/.noorm/identity.{key,pub,json}`. + +Create an X25519 keypair. ```bash -noorm identity init --name "Alice" --email "alice@example.com" -noorm identity init --name "Alice" --email "alice@example.com" --force --yes -noorm identity init --name "Alice" --email "alice@example.com" --json +noorm identity init --name "Ana Ruiz" --email ana@example.com ``` -| Flag | Description | -|------|-------------| -| `--name` | Display name (required) | -| `--email` | Email address (required) | -| `--force` | Replace an existing identity. Requires `--yes` | -| `--yes` / `-y` | Confirm the replacement. The flag is read literally here, so an ambient `NOORM_YES` does not satisfy it | +| Flag | Effect | +|---|---| +| `--name` | Display name. Required | +| `--email` | Email address. Required | +| `--force` | Replace an existing identity. Needs `--yes` | +| `--yes`, `-y` | Confirm the replacement. Only the literal flag counts, so an ambient `NOORM_YES` does not | -::: danger Destructive -The state encryption key derives from the private key, so replacing an identity orphans every `state.enc` on the machine — configs, secrets, and database passwords become permanently undecryptable. Nothing re-encrypts existing state under the new key. The old keypair is backed up first, and its path lands in `backedUpTo` on the result; restoring `identity.key` from it is the only way back. -::: +An existing identity without `--force` exits 1. JSON: `name`, `email`, `fingerprint`, `publicKey`, and on replacement `backedUpTo` and `warning`. -**JSON output:** -```json -{ - "success": true, - "name": "Alice", - "email": "alice@example.com", - "fingerprint": "...", - "publicKey": "..." -} -``` +::: danger +Replacing an identity re-keys state encryption. Every `state.enc` on the machine becomes unreadable: configs, secrets, and database passwords. Nothing re-encrypts them under the new key. The old keypair is backed up first, and restoring `identity.key` from `backedUpTo` is the only way back. +::: #### `identity edit` -Update the display name or email on the existing identity. + +Change the display name or email. At least one is required, else exit 2. No identity exits 2. ```bash -noorm identity edit --name "Alice Cooper" -noorm identity edit --email "alice@newjob.com" -noorm identity edit --name "Alice" --email "alice@new.com" --json +noorm identity edit --email ana@newjob.com ``` -At least one of `--name` or `--email` must be provided. +The fingerprint derives from name and email, so editing changes it. Vault access was granted to the old fingerprint, and a teammate with access must run `vault propagate` again. JSON: `name`, `email`, `fingerprint`, `publicKey`, `previousFingerprint`, `fingerprintChanged`. #### `identity export` -Print your public key so teammates can add you to encrypted vaults. + +Print your public key and fingerprint for a teammate. ```bash -noorm identity export noorm identity export --json ``` +No identity exits 2. JSON: `name`, `email`, `fingerprint`, `publicKey`. + #### `identity list` -List all known users discovered from database syncs (the audit trail of who has touched shared state). + +List users discovered from database syncs. ```bash -noorm identity list noorm identity list --json ``` +JSON: `users[]` with `name`, `email`, `identityHash`, `machine`, `source`, `lastSeen`. + ### Settings -Manage project settings stored in `.noorm/settings.yml`. Settings control paths, build rules, stages, strict mode, logging, and teardown behavior. + +Project settings in `.noorm/settings.yml`. + #### `settings init` -Initialize `settings.yml` with defaults. + +Write `settings.yml` with defaults. An existing file exits 1 unless you pass `--force`. JSON: `path`. ```bash -noorm settings init -noorm settings init --force # Overwrite existing +noorm settings init --force ``` +`--force` keeps the `paths` already in the file. + #### `settings build` -Reload `settings.yml` and re-save it. Applies missing defaults and normalizes formatting. + +Reload and re-save `settings.yml` to apply missing defaults and normalize formatting. JSON: `path`. ```bash noorm settings build @@ -482,779 +489,613 @@ noorm settings build #### `settings edit` -Interactive editor for settings sections: paths, build, strict, logging, stages, rules, teardown. - -```bash -noorm settings edit # Requires TTY -``` -**TTY required.** `--yes` / `NOORM_YES` errors with a redirect hint — edit `settings.yml` directly. See [Non-interactive operation](./guide/automation/non-interactive.md). +Interactive editor for paths, build, strict, logging, stages, rules, and teardown. Exits 2 with a pointer to editing the file directly when you pass `--yes` or `NOORM_YES`, or when stdin is not a terminal. Run `noorm settings build` to validate a hand edit. See [`settings edit`](/cli/settings-edit). #### `settings secret` -Interactive editor for secret requirement declarations: which secrets are required for each stage. -```bash -noorm settings secret # Requires TTY -``` +Interactive editor for the secret requirements each stage declares, not their values. Exits 2 under `--yes` or without a terminal. Edit the `secrets:` section of `settings.yml` directly and set values with `secret set`. See [`settings secret`](/cli/settings-secret). + -**TTY required.** This manages requirement declarations, not secret values. Use `noorm secret set` or `noorm vault set` for values. `--yes` / `NOORM_YES` errors with a redirect hint pointing at direct YAML edits. +### Secrets -### Secrets (Config-Scoped) +Config-scoped secrets, encrypted in your `state.enc` and never shared. A template reads `$.secrets.KEY` from the first match of: this config's secret, a global secret, then the [vault](/guide/environments/vault). + +All three take `--config` and `--json`, and `set` and `rm` take `--yes`. With no `--config`, they use the active config. An unknown config exits 1. -Local secrets that override vault secrets for a specific config. These are stored on disk per-user (not shared with the team). #### `secret list` -List secret keys for a config. + +List key names, never values. JSON: `configName`, `keys`. ```bash -noorm secret list noorm secret list --config prod -noorm secret list --json ``` -#### `secret set ` +#### `secret set [value]` -Set a local secret for a config. + +Set a secret. `--stdin` reads the value from stdin so it stays out of the process table, shell history, and `set -x` traces. Pass the value or `--stdin`, not both. ```bash -noorm secret set API_KEY "sk-live-..." -noorm secret set DB_PASSWORD "secret123" --config prod -noorm secret set API_KEY "sk-live-..." --json +printf %s "$KEY" | noorm secret set PAYMENT_GATEWAY_KEY --stdin ``` +An `operator` role needs `--yes`. JSON: `configName`, `key`. + #### `secret rm ` -Remove a local secret from a config. + +Delete a secret. `--yes` or `NOORM_YES` is required. An unknown key exits 2. JSON: `configName`, `key`. ```bash -noorm secret rm OLD_KEY --yes noorm secret rm OLD_KEY --yes --config prod ``` -Requires `--yes` to prevent accidental deletion. +### CI provisioning -### CI/CD Provisioning -The `ci` namespace bridges the gap between developer machines (which have `.noorm/` state on disk) and CI runners (which start from scratch on every job). +A CI runner starts with no `.noorm/` and no identity on disk. `ci` bridges that. The full pipelines are in [CI/CD automation](/guide/automation/ci). + #### `ci init` -Bootstrap ephemeral `state.enc` from `NOORM_IDENTITY_*` + `NOORM_CONNECTION_*` env vars. Run inside the CI job. -```bash -noorm ci init -noorm ci init --name staging -noorm ci init --force # Overwrite existing state.enc -noorm ci init --json +Build `state.enc` from `NOORM_IDENTITY_*` and `NOORM_CONNECTION_*`, create a `remote` config with default access, and make it active. + +``` +noorm ci init [--name ] [--force] [--test] [--yes] [--json] ``` -| Flag | Description | -|------|-------------| -| `--name` | Config name (default: `$NOORM_CI_CONFIG_NAME` or `"ci"`) | -| `--force` | Overwrite existing state.enc | +| Flag | Effect | +|---|---| +| `--name` | Config name. Defaults to `$NOORM_CI_CONFIG_NAME`, then `ci` | +| `--force`, `-f` | Replace an existing `state.enc`. The old one is copied to `state.enc.bak-` first | +| `--test` | Mark the config `isTest: true`, which satisfies the SDK's `requireTest` guard | +| `--yes`, `-y` | Needed with `--force` on a terminal. Without a terminal, `--force` alone is enough | -Creates a config, marks it active, sets `isTest: true`, and leaves state on disk so later commands (`run build`, `change ff`, `ci secrets`) work normally. +```bash +noorm ci init --name prod +``` -Fails fast with exit 1 if required env vars are missing or malformed. +| Condition | Result | +|---|---| +| Missing or invalid `NOORM_IDENTITY_*`, missing dialect or database | Exit 2 | +| `state.enc` exists without `--force` | Exit 1 | -**JSON output:** -```json -{ - "success": true, - "identity": { - "name": "CI Bot", - "email": "ci@example.com", - "publicKey": "...", - "identityHash": "...", - "source": "env" - }, - "config": {"name": "ci", "dialect": "postgres", "database": "app", "isTest": true}, - "stateFile": "/path/to/.noorm/state/state.enc" -} -``` +JSON: `identity` (`name`, `email`, `publicKey`, `identityHash`, `source`), `config` (`name`, `dialect`, `database`, `isTest`), `stateFile`, and `backedUpTo` after a replacement. #### `ci secrets` -Batch-load secrets from a dotenv-style file into the active vault. Run after `ci init`. + +Load a dotenv file into the config's secrets. Run it after `ci init`. ```bash -noorm ci secrets --file ./ci-secrets.env noorm ci secrets --file ./ci-secrets.env --overwrite -noorm ci secrets --file ./ci-secrets.env --config prod --json ``` -| Flag | Description | -|------|-------------| -| `--file` | Path to dotenv file (required) | -| `--config` | Target config (default: active) | -| `--overwrite` | Replace existing keys | +| Flag | Effect | +|---|---| +| `--file` | Dotenv file. Required | +| `--config` | Target config. Defaults to the active one | +| `--overwrite` | Replace keys that exist. Without it, existing keys are skipped | -**File format:** `KEY=value` per line; blank lines and `#` comments ignored; surrounding quotes stripped. +The file takes `KEY=value` lines. Blank lines and `#` lines are ignored, the line splits on the first `=`, one matched pair of quotes is stripped, and keys match `[A-Za-z_][A-Za-z0-9_]*`. -**Exit codes:** `0` all loaded; `1` total failure; `2` bad invocation (unknown config, unreadable or malformed `--file`); `3` partial (some keys set, some errored). +| Exit | Meaning | +|---|---| +| `0` | Every key loaded or skipped | +| `1` | State unreadable, or every write failed | +| `2` | No active config, unknown config, or an unreadable or malformed file | +| `3` | Some keys set, some failed | -**JSON output:** -```json -{ - "success": true, - "config": "ci", - "set": 3, - "skipped": 0, - "errors": 0 -} -``` +JSON: `config`, `set`, `skipped`, `errors`, and the first five `errorDetails`. #### `ci identity new` -Generate a test-CI keypair locally. No database contact, no state written. Prints an env block to copy into CI secrets. + +Generate a CI keypair locally. No database contact and nothing written to disk. ```bash noorm ci identity new --name "CI Bot" --email ci@example.com -noorm ci identity new --name "CI Bot" --email ci@example.com --json ``` -| Flag | Description | -|------|-------------| -| `--name` | Display name (required) | -| `--email` | Email address (required) | - -The private key is shown once and never stored. Save it immediately. - -**JSON output:** -```json -{ - "name": "CI Bot", - "email": "ci@example.com", - "publicKey": "...", - "identityHash": "...", - "privateKey": "...", - "envBlock": { - "NOORM_IDENTITY_PRIVATE_KEY": "...", - "NOORM_IDENTITY_NAME": "CI Bot", - "NOORM_IDENTITY_EMAIL": "ci@example.com" - } -} -``` +`--name` and `--email` are required. The private key prints once. JSON: `name`, `email`, `publicKey`, `identityHash`, `privateKey`, `envBlock`. #### `ci identity enroll` -Register a CI identity in the target database and grant vault access. Run once by a developer who already has vault access. + +Register a CI identity in a config's database and grant it vault access. A developer with vault access runs it once per config. ```bash -noorm ci identity enroll --config prod --name "CI Bot" --email ci@example.com -noorm ci identity enroll --config prod --name "CI Bot" --email ci@example.com --public-key +noorm ci identity enroll --config prod --name "CI Bot" --email ci@example.com --yes ``` -| Flag | Description | -|------|-------------| -| `--config` | Target configuration (required) | -| `--name` | Display name (required) | -| `--email` | Email address (required) | -| `--public-key` | Pre-generated public key hex (air-gapped flow) | +| Flag | Effect | +|---|---| +| `--config`, `-c` | Target config. Required | +| `--name`, `--email` | Identity to enroll. Required | +| `--public-key` | Hex X25519 public key from `ci identity new`, 88 characters. Without it, enroll mints a keypair and prints the private key once | +| `--yes`, `-y` | Needed to grant access, which cannot be undone. Skipped when the identity already holds access | + +Re-running is safe for the same key. An identity already registered under the same hash with a different public key is refused. JSON: `name`, `email`, `publicKey`, `identityHash`, `enrolledIn`, `alreadyEnrolled`, and `privateKey` and `envBlock` when it minted the keypair. + -Without `--public-key`, mints a keypair and returns the private key once. With `--public-key`, only enrolls the provided public half (for keys minted via `ci identity new`). +### Schema runs -Idempotent on identityHash. Re-running ensures vault access. +Executes SQL files against the database. A file whose checksum is unchanged is skipped unless `--force`. Every `run` command accepts `--config` and `--json`, and all but `inspect` and `preview` accept `--force` and `--dry-run`. `--dry-run` renders to `tmp/` and writes nothing to the database. -### Schema Operations #### `run build` -Execute all SQL files in the schema directory. Uses checksums to skip unchanged files. -```bash -noorm run build -noorm run build --force # Skip checksums, run everything -noorm run build --dry-run # Preview without executing +Execute every SQL file under `paths.sql`, filtered by `build.include` and `build.exclude`. + +``` +noorm run build [--config ] [--dry-run] [--force] [--json] ``` -**JSON output:** -```json -{ - "success": true, - "status": "success", - "files": [ - {"filepath": "db/sql/01_tables/users.sql", "status": "success", "checksum": "…", "durationMs": 12} - ], - "filesRun": 2, - "filesSkipped": 1, - "filesFailed": 0, - "durationMs": 234 -} +```bash +noorm run build --dry-run ``` -**Exit codes:** `0` success, `1` complete failure, `3` partial failure (some files ran, some failed). +| Exit | Meaning | +|---|---| +| `0` | Every file ran or was skipped as unchanged | +| `1` | No file succeeded | +| `3` | Some files ran, some failed | + +JSON: `status`, `files[]` (`filepath`, `checksum`, `status`, `durationMs`, `outputPath` on a dry run), `filesRun`, `filesSkipped`, `filesFailed`, `durationMs`, `unmatchedInclude`, `unmatchedExclude`. An include or exclude entry that matches nothing warns and does not fail. Entries are relative to `paths.sql`, so write `01_tables`, not `db/sql/01_tables`. #### `run file ` -Execute a single SQL or `.sql.tmpl` file. -```bash -noorm run file db/sql/01_tables/001_users.sql -noorm run file seed.sql.tmpl -noorm run file db/sql/init.sql --json -``` +Execute one `.sql` or `.sql.tmpl` file. JSON is the file result: `filepath`, `status`, `checksum`, `durationMs`. -**JSON output:** -```json -{ - "success": true, - "filepath": "seed.sql", - "status": "success", - "checksum": "…", - "durationMs": 45 -} +```bash +noorm run file db/sql/01_tables/02_sales_order.sql ``` #### `run dir ` -Execute all SQL files in a directory in alphabetical order. + +Execute every SQL file in a directory in path order. ```bash noorm run dir db/sql/01_tables/ -noorm run dir seeds/ -noorm run dir migrations/ --json ``` -A directory that does not exist, or that holds no SQL files, exits `2` — a directory with nothing in it is a mistyped path, not a successful no-op. +A missing directory, or one with no SQL files, exits 2. A directory with nothing in it is a mistyped path, not a successful no-op. #### `run exec ` -Batch-execute SQL files matching a glob pattern or directory. More flexible than `run dir`. + +Execute the files a glob or directory matches. A glob keeps only `.sql` and `.sql.tmpl` files, sorted. No matches exits 2. ```bash -noorm run exec db/sql/ -noorm run exec "db/sql/**/*.sql" -noorm run exec "seeds/*.sql" --force -noorm run exec migrations/ --dry-run +noorm run exec "db/sql/**/*.sql" --dry-run ``` -**Exit codes:** `0` success, `1` complete failure, `2` bad invocation, `3` partial failure. - #### `run files --paths ` -Execute multiple specific SQL files in order. + +Execute specific files in the order given. An empty list exits 1. ```bash -noorm run files --paths seed.sql,fixtures.sql -noorm run files --paths db/sql/001_tables.sql,db/sql/002_indexes.sql --json +noorm run files --paths db/sql/01_tables/01_party.sql,db/sql/01_tables/02_sales_order.sql ``` #### `run inspect ` -Inspect the template context for a `.sql.tmpl` file without executing. Shows data files, helpers, config, secrets, and built-in functions. + +Show what a `.sql.tmpl` file can see, without running it: data files, helpers, built-ins, config keys, and secret counts. ```bash -noorm run inspect db/sql/users/001_create.sql.tmpl -noorm run inspect db/sql/core/Crons.sql.tmpl --json +noorm run inspect db/sql/09_seeds/01_products.sql.tmpl --json ``` -**JSON output:** -```json -{ - "success": true, - "filepath": "db/sql/users/001_create.sql.tmpl", - "context": { - "dataFiles": [{"key": "roles", "type": "Array [3]"}], - "helpers": [{"key": "padId", "type": "Function"}], - "helperErrors": [], - "builtins": ["quote", "escape", "uuid", "now", "json", "include"], - "configKeys": ["name", "connection"], - "secretCount": 2, - "globalSecretCount": 0 - } -} -``` +JSON: `filepath`, `context` (`dataFiles`, `helpers`, `helperErrors`, `builtins`, `configKeys`, `secretCount`, `globalSecretCount`). A missing template exits 2. Building the context executes the template's helper scripts, so a resolved config must allow `run:file`. #### `run preview ` -Render a `.sql.tmpl` file and output the resulting SQL. Does **not** execute against the database. -```bash -noorm run preview db/sql/schema.sql.tmpl -noorm run preview db/sql/schema.sql.tmpl > rendered.sql -noorm run preview db/sql/seed.sql.tmpl --json -noorm run preview db/sql/migrations/002.sql.tmpl --config staging -``` +Render a `.sql.tmpl` file and print the SQL without running it. It tries a short, no-retry connection to read vault secrets. When the database is unreachable it renders with local secrets only and says so: stderr in text mode, `vaultProbeFailed` and `notice` in JSON. -**JSON output:** -```json -{ - "success": true, - "filepath": "db/sql/schema.sql.tmpl", - "sql": "CREATE TABLE ...", - "durationMs": 12 -} +```bash +noorm run preview db/sql/09_seeds/01_products.sql.tmpl > rendered.sql ``` -A missing template exits `2`. In text mode the rendered SQL is the only thing on stdout, so `noorm run preview x.sql.tmpl > rendered.sql` stays pipeable; warnings go to stderr. +Text mode writes only the SQL to stdout, so redirecting works. JSON: `filepath`, `sql`, `durationMs`. A missing template exits 2. A render error exits 1. The output contains resolved secrets in plaintext, so it needs `run:file` like `inspect`. -### Change Operations +### Changes -Bare `noorm change` renders citty's help output and does not connect to the database. Use `noorm change list` for the status table. -**Interactive prompts (TTY only).** When you omit the change name on a TTY, `run`, `revert`, `rewind`, `edit`, `rm`, and `history-detail` open a clack picker filtered to the relevant subset: +Bare `noorm change` prints help and does not connect. Every `change` command that connects accepts `--config` and `--json`. -| Command | Picker includes | -|---------|-----------------| -| `change run` | pending + reverted + stale (not orphaned) | -| `change revert` | success | -| `change rewind` | success | -| `change edit` | every directory under `db/changes/` (filesystem) | -| `change rm` | every directory under `db/changes/` (filesystem) | -| `change history-detail` | anything with an execution record (non-pending) | +When you omit the name on a terminal, these commands open a picker. Without a terminal they exit 1 and ask for the name. -Non-TTY callers (CI, scripts) must pass the name or the command errors out. +| Command | Picker lists | +|---|---| +| `change run` | pending, reverted, and stale changes, and never-run changes that cannot be parsed. Not orphaned ones | +| `change revert`, `change rewind` | applied changes | +| `change edit`, `change rm` | every directory under `paths.changes` | +| `change history-detail` | every change with an execution record | +| `change add` | prompts for a description | #### `change list` -List every known change with its status. + +List every known change and its status: `pending`, `success`, `failed`, `reverted`, or `stale`, plus `orphaned` when the folder is gone. ```bash -noorm change list noorm change list --json -noorm change list -c staging ``` -**JSON output:** -```json -{ - "success": true, - "changes": [ - {"name": "2024-01-15-init-schema", "status": "success"}, - {"name": "2024-02-01-notifications", "status": "pending"} - ], - "pending": 1 -} -``` +JSON: `changes[]` and `pending`. An unreadable change that never ran shows as `failed`, with the parse message after it in text and in `errorMessage` under `--json`. #### `change ff` -Fast-forward: apply all pending changes in order. + +Apply every pending change in order. + +``` +noorm change ff [--config ] [--dry-run] [--force] [--json] +``` + +| Flag | Type | Effect | +|---|---|---| +| `--config`, `-c` | string | Config to run against. Defaults to the active config | +| `--dry-run` | boolean | Render the changes to `tmp/` without writing to the database | +| `--force`, `-f` | boolean | Skip the checksum check and run every file of each pending change | +| `--json` | boolean | Print the result as JSON | ```bash noorm change ff +noorm change ff -c staging --json noorm change ff --dry-run -noorm change ff --force ``` -**JSON output:** -```json -{ - "success": true, - "status": "success", - "executed": 2, - "skipped": 0, - "failed": 0, - "durationMs": 90, - "changes": [ - {"name": "2024-02-01-notifications", "status": "success", "durationMs": 45} - ] -} -``` +| Exit code | Meaning | +|---|---| +| `0` | Every pending change applied, or none were pending | +| `1` | A change failed, or the command could not run | +| `3` | Partial: some changes applied before one failed | + +JSON fields: -`--dry-run` adds `"dryRun": true` to the payload. A missing `db/changes/` directory is a warning, not a failure: it lands in `warnings` rather than making the batch fail. +| Field | Meaning | +|---|---| +| `success`, `status` | `status` is `success`, `partial`, or `failed` | +| `executed`, `skipped`, `failed` | Change counts | +| `durationMs` | Total time | +| `changes[]` | Per change: `name`, `direction`, `status`, `files[]`, `durationMs`, `error` | +| `warnings[]` | Non-fatal problems, such as a missing `db/changes/` directory | +| `error` | Why the batch failed before any change ran | +| `dryRun` | `true` under `--dry-run` | -A change folder that cannot be parsed, for example a `.txt` manifest that names a file no longer in `db/sql/`, is handled by what `ff` would do with it: +A missing `db/changes/` directory is a warning, not a failure. A change folder that cannot be parsed, for example a `.txt` manifest naming a file no longer in `db/sql/`, is handled by its status: -| The unreadable change | `ff` / `next` | -|-----------------------|---------------| +| The unreadable change | `ff` and `next` | +|---|---| | Never ran, `reverted`, or `stale` | Fails at that change's position, so later changes do not run out of order. The result is `failed` (exit `1`), or `partial` (exit `3`) when earlier changes applied, and `error` carries the parse message | | Applied | Keeps its status and adds a message to `warnings` | -A folder with neither `change/` nor `revert/` is not a change and is ignored. `change list` shows an unreadable change that never ran as `failed`, with the parse message after it in text output and in `errorMessage` under `--json`. +A folder with neither `change/` nor `revert/` is not a change and is ignored. On a database built from `db/sql/`, the first `ff` runs every change, so write changes that are safe to run twice. See [Add a column](/guide/changes/add-a-column). #### `change next` -Apply the next pending change. An optional positional count applies the next `n` instead of one. + +Apply the next pending change, or the next `n` with a positional count. ```bash -noorm change next -noorm change next 3 -noorm change next --json +noorm change next 3 --dry-run ``` +Accepts `--config`, `--force`, `--dry-run`, and `--json`. A count that is not a positive integer exits 1. Failed and unreadable changes behave as in `ff`. JSON matches `ff`. + #### `change run [name]` -Apply a specific change by name. Omit the name on a TTY to pick interactively. -```bash -noorm change run # interactive picker (TTY) -noorm change run 2024-02-01-notifications -noorm change run 2024-02-01-notifications --json -``` +Apply one change by name. Without a name on a terminal, pick from the list. -**JSON output:** -```json -{ - "success": true, - "name": "2024-02-01-notifications", - "direction": "change", - "status": "success", - "files": [ - {"filepath": "change/001_create-table.sql", "checksum": "a1b2c3", "status": "success", "durationMs": 23} - ], - "durationMs": 45 -} +```bash +noorm change run 2026-10-20-order-status --json ``` -`direction` is `change` or `revert`. Per-file `status` is one of `pending`, `success`, `failed`, `skipped`; the change-level `status` adds `reverted` and `stale`. +Accepts `--config`, `--force`, `--dry-run`, and `--json`. A change whose folder cannot be parsed reports the parse error instead of "Change not found". JSON: `name`, `direction`, `status`, `files[]` (`filepath`, `checksum`, `status`, `durationMs`), `durationMs`. `direction` is `change` or `revert`. #### `change revert [name]` -Revert a previously applied change by running its rollback SQL. Omit the name on a TTY to pick interactively. + +Run one applied change's `revert/` scripts. ```bash -noorm change revert # interactive picker (TTY) -noorm change revert 2024-02-01-notifications -noorm change revert 002_users --json +noorm change revert 2026-10-20-order-status ``` +Accepts `--config`, `--force`, `--dry-run`, and `--json`. + #### `change rewind [name]` -Revert the given change **and every change applied after it**, in reverse order. Omit the name on a TTY to pick interactively. + +Revert the named change and every change applied after it, newest first. A bare integer reverts the last `n` instead. ```bash -noorm change rewind # interactive picker (TTY) -noorm change rewind 2024-02-01-notifications -noorm change rewind 001_init --json +noorm change rewind 2026-10-20-order-status +noorm change rewind 3 ``` +Accepts `--config`, `--force`, `--dry-run`, and `--json`. A name that matches no applied change fails with `error` set. + #### `change add [description]` -Scaffold a new change directory. Omit the description on a TTY to be prompted. + +Scaffold `/-/` with `change/` and `revert/`, each holding `001_.sql`, plus `changelog.md`. No database connection. ```bash -noorm change add # prompts for description (TTY) -noorm change add add-users-table -noorm change add "notification queue" --json +noorm change add order-status --json ``` -Creates `db/changes/-/` with `change/` and `revert/` directories, each -holding a `001_.sql` stub (a single comment naming what belongs there — "Add the SQL -statements that apply this change" / "...undo this change"), plus `changelog.md`. The stub -comments alone don't count as content: running the change before editing them fails with an -actionable message ("Files are empty or contain only template placeholders"), not "change not -found". +JSON: `name`, `path`. The stub files hold one comment each, so running the change before you edit them fails with "Files are empty or contain only template placeholders". An existing folder of the same name fails. #### `change edit [name]` -Open a change folder in `$EDITOR`. Omit the name on a TTY to pick interactively. + +Open a change folder in `$EDITOR`, then `$VISUAL`, then `code`. The command exits with the editor's exit code. An unknown change exits 2. Takes no flags besides the name. ```bash -noorm change edit # interactive picker (TTY) -noorm change edit 2024-02-01-notifications -EDITOR=vim noorm change edit 001_init +EDITOR=vim noorm change edit 2026-10-20-order-status ``` #### `change rm [name]` -Remove a change directory from disk. Does **not** touch database state. Gated by the config's `change:rm` access (see [Access Roles](#access-roles) above): `viewer` is denied; `operator` and `admin` confirm via `--yes` or `NOORM_YES=1`. On a TTY, omit the name to pick interactively. + +Delete a change folder from disk. The database is untouched. The role gate applies when a config resolves, and `--yes` or `NOORM_YES` confirms. With no config at all, confirmation is required. An unknown change exits 2. ```bash -noorm change rm # picker + confirm (TTY) -noorm change rm 2024-02-01-notifications --yes # CI +noorm change rm 2026-10-20-order-status --yes ``` +JSON: `name`, `deleted`. + #### `change history` -View execution history with timestamps, direction, and duration. + +Show execution records. ```bash -noorm change history -noorm change history --json -noorm change history --count 50 +noorm change history --count 50 --json ``` -| Flag | Description | -|------|-------------| -| `--count` | Show the last N records (default: 20) | +| Flag | Effect | +|---|---| +| `--count` | Last N records. Default 20 | -**JSON output:** -```json -{ - "success": true, - "history": [ - { - "id": 1, - "name": "001_init", - "direction": "change", - "status": "success", - "executedAt": "2024-01-15T10:30:00Z", - "executedBy": "Alice ", - "durationMs": 45, - "errorMessage": null, - "checksum": "abc123def456" - } - ] -} -``` +JSON: `history[]` with `id`, `name`, `direction`, `status`, `executedAt`, `executedBy`, `durationMs`, `errorMessage`, `checksum`. #### `change history-detail [name]` -Per-file execution detail for a single change. Omit the name on a TTY to pick interactively. + +Show per-file results for each operation on one change. ```bash -noorm change history-detail # interactive picker (TTY) -noorm change history-detail 001_init -noorm change history-detail 002_users --json +noorm change history-detail 2026-10-20-order-status --count 5 ``` +| Flag | Effect | +|---|---| +| `--count` | Last N operations. Default 10 | + +JSON: `name` and `operations[]`, each with the history `record` and its `files[]`. + + +### Database -### Database Operations #### `db create` -Create the target database if it does not exist. Connects to the dialect's system database (`postgres`, `master`, etc.) to issue the CREATE statement. + +Create the database if it does not exist and bootstrap noorm's tracking tables. It connects to the dialect's system database to issue the statement. An existing, initialized database reports `alreadyExists` and exits 0. ```bash -noorm db create -noorm db create -c dev -noorm db create --yes -noorm db create --json +noorm db create --config dev --yes ``` -Gated by the config's `db:create` access: `viewer` is denied, `operator` needs `--yes` (or `NOORM_YES=1`), `admin` runs unconfirmed. The gate fires before any probe touches the server, so a denied role never gets a SQLite file created as a side effect of the existence check. +Accepts `--config`, `--yes`, and `--json`. The role gate runs before any probe, so a denied role never gets a SQLite file created as a side effect. JSON: `config`, `database`, `created`, `trackingInitialized`. #### `db drop` -Drop the entire database. + +Drop the whole database. ```bash -noorm db drop --yes -noorm db drop -c dev --yes --json +noorm db drop --config dev --yes ``` -::: danger Destructive -Drops the entire database. Requires `--yes`. Gated by the config's `db:destroy` access: `viewer`/`operator` are denied outright — there is no flag that overrides this — and `admin` still requires confirmation. See [Access Roles](#access-roles) above. -::: +Accepts `--config`, `--yes`, and `--json`. When `NOORM_CONNECTION_*` points the config at another database than the one it stores, stderr warns before the drop, and JSON adds `targetOverridden` and `storedDatabase`. JSON: `config`, `database`, `dropped`. #### `db reset` -Teardown + build (idempotent rebuild). Drops all user objects then rebuilds from SQL files. + +Tear down every user object, then build from `db/sql/`. `--yes` or `NOORM_YES` is required up front, else exit 1. ```bash -noorm db reset --yes -noorm db reset -c dev --yes --json +noorm db reset --config dev --yes ``` -::: danger Destructive -Equivalent to `db teardown` followed by `run build`. Requires `--yes`. -::: +Accepts `--config`, `--yes`, and `--json`. JSON: `reset`. #### `db explore` -Explore database schema. + +Overview counts, with a subcommand per object type. ```bash -noorm db explore # Overview counts -noorm db explore tables # List all tables -noorm db explore tables detail users # Describe specific table -noorm db explore views # List all views -noorm db explore functions # List all functions -noorm db explore procedures # List all procedures -noorm db explore indexes # List all indexes -noorm db explore types # List custom types -noorm db explore fks # List foreign keys -noorm db explore triggers # List triggers -noorm db explore --json # JSON overview +noorm db explore +noorm db explore tables detail sales_order +noorm db explore fks --schema public ``` -**JSON output (overview):** -```json -{ - "success": true, - "tables": 12, - "views": 3, - "procedures": 0, - "functions": 2, - "types": 4, - "indexes": 8, - "foreignKeys": 6, - "triggers": 1, - "locks": 0, - "connections": 3 -} -``` +| Subcommand | Lists | JSON key | +|---|---|---| +| `tables` | tables. `tables detail ` describes one | `tables` | +| `views [name]` | views, or one view | `views` | +| `procedures [name]` | stored procedures, or one | `procedures` | +| `functions [name]` | functions, or one | `functions` | +| `types [name]` | custom types, or one | `types` | +| `indexes` | indexes | `indexes` | +| `fks` | foreign keys | `foreignKeys` | +| `triggers [name]` | triggers, or one | `triggers` | -`locks` and `connections` are runtime state, not schema, so they appear only in `--json` — the text overview lists the eight counters that have a drill-down subcommand. +Every subcommand accepts `--config` and `--json`, plus `--schema ` to restrict the listing or name the schema of the object you describe. A name that matches nothing exits 2. The overview JSON has `tables`, `views`, `procedures`, `functions`, `types`, `indexes`, `foreignKeys`, `triggers`, `locks`, and `connections`. `locks` and `connections` are runtime state, so only `--json` shows them. #### `db truncate` -Wipe all data from application tables. Schema and noorm tracking tables are preserved. + +Delete all rows from application tables. Schema and noorm tracking tables stay. ```bash -noorm db truncate -y -noorm db truncate --dry-run -noorm db truncate --preserve seeds,lookups --yes -noorm db truncate --only users,posts --yes +noorm db truncate --preserve party_type,product --yes ``` -| Flag | Description | -|------|-------------| +| Flag | Effect | +|---|---| | `--preserve` | Comma-separated tables to leave untouched | -| `--only` | Comma-separated tables to truncate, to the exclusion of all others | -| `--dry-run` | List the statements without executing them | +| `--only` | Comma-separated tables to truncate, and no others | +| `--dry-run` | List the statements without running them | +| `--yes`, `-y` | Confirm | -::: warning Access Roles -Gated by the config's `db:truncate` access: `viewer` is denied, `operator` and `admin` both confirm (`--yes`, or `NOORM_YES=1` in CI). There is no `--force` override — see [Access Roles](#access-roles) above. -::: +Also accepts `--config` and `--json`. JSON: `truncated`, `preserved`, `count`, and under `--dry-run` `dryRun` and `statements`. #### `db teardown` + Drop all database objects. ```bash -noorm db teardown -y -noorm db teardown --dry-run noorm db teardown --preserve-schemas app_private --yes ``` -| Flag | Description | -|------|-------------| -| `--preserve-schemas` | Comma-separated schemas to leave untouched (teardown otherwise reaches every non-system schema) | -| `--dry-run` | Report what would be dropped without dropping it | - -A teardown whose post-teardown script failed exits `1` even though the objects are already gone — a half-finished teardown must not report success. +| Flag | Effect | +|---|---| +| `--preserve-schemas` | Comma-separated schemas to leave untouched. Teardown otherwise reaches every non-system schema | +| `--dry-run` | Report what would drop | +| `--yes`, `-y` | Confirm | -::: warning Access Roles -Gated by the config's `db:teardown` access: `viewer` and `operator` are denied outright, `admin` confirms (`--yes`, or `NOORM_YES=1` in CI). There is no `--force` override — see [Access Roles](#access-roles) above. -::: +Also accepts `--config` and `--json`. JSON: `dropped` (`tables`, `views`, `functions`, `types`), `count`, `postScriptResult`, `dryRun`. A post-teardown script that fails exits 1 even though the objects are gone, so a half-finished teardown does not report success. #### `db transfer` -Transfer data between database configurations, or export/import `.dt` files. Three mutually exclusive modes. -**Database-to-database:** -```bash -noorm db transfer --to backup -noorm db transfer --to backup --tables users,posts -noorm db transfer --to backup --on-conflict update -noorm db transfer --to backup --truncate -noorm db transfer --to backup --dry-run -``` +Move data between configs, or to and from `.dt` files. Exactly one of `--to`, `--export`, and `--import`; none or several exit 2. -**Export to files:** ```bash -noorm db transfer --export ./backup/ +noorm db transfer --to backup --tables party,sales_order --on-conflict update noorm db transfer --export ./backup/ --compress -noorm db transfer --export ./backup/ --passphrase "correct-horse-battery" -noorm db transfer --export ./backup/users.dt --tables users +noorm db transfer --import ./backup/party.dtz --on-conflict skip ``` -**Import from files:** -```bash -noorm db transfer --import ./backup/users.dt -noorm db transfer --import ./backup/users.dtzx --passphrase "correct-horse-battery" --on-conflict skip -``` +| Flag | Applies to | Effect | +|---|---|---| +| `--to ` | database to database | Destination config. Unknown config exits 2 | +| `--export ` | export | A file for one table, or a directory for several | +| `--import ` | import | A `.dt`, `.dtz`, or `.dtzx` file | +| `--compress` | export | Write `.dtz`. Any other mode exits 2 | +| `--passphrase ` | export, import | Encrypt to `.dtzx`, or decrypt one. Export needs at least 12 characters. Omit it on a terminal for a masked prompt. In a non-interactive session or under `--json`, omitting it exits 2 | +| `--tables ` | database to database, export | Comma-separated tables. Default all | +| `--on-conflict ` | database to database, import | `fail`, `skip`, `update`, or `replace`. Default `fail` | +| `--batch-size ` | all | Rows per batch. Default 1000 | +| `--truncate` | database to database, import | Truncate destination tables first | +| `--no-fk` | database to database | Leave foreign key checks enforced. By default they are disabled during the transfer | +| `--no-identity` | database to database | Let the destination assign identity values. By default they are preserved | +| `--dry-run` | database to database | Print the plan and exit 0 | + +`--config` selects the source, and `--json` is accepted. The export extension follows `--passphrase` (`.dtzx`), then `--compress` (`.dtz`), then `.dt`. + +| Mode | Exit | JSON | +|---|---|---| +| Database to database | 0 success, 1 failed, 3 partial | `status`, `tables[]`, `totalRows`, `durationMs`, `fkChecksRestored` | +| `--dry-run` | 0 | `sameServer`, `tableCount`, `estimatedRows`, `tables[]`, `warnings[]` | +| Export | 0, or 1 on error | `mode: "export"`, `tables[]`, `totalRows`, `totalBytes`, and `filepath` or `directory` | +| Import | 0, or 1 on error | `mode: "import"`, `filepath`, `rowsImported`, `rowsSkipped` | -`--passphrase` implies encryption, so the export lands as `.dtzx` whether or not you also pass `--compress`. On export it must be at least 12 characters; on import there is no floor, so archives encrypted with an older, shorter passphrase still open. Omit it on a TTY and noorm prompts with masked input; in a non-interactive session or under `--json`, omitting it exits `2`. +When foreign key checks are not restored on the destination, stderr warns and `fkChecksRestored` is `false`. -**Transfer flags:** -| Flag | Description | -|------|-------------| -| `--to` | Destination config name (db-to-db mode) | -| `--export` | Export path: file or directory | -| `--import` | Import from `.dt`/`.dtz`/`.dtzx` file | -| `--compress` | Compress export as `.dtz` | -| `--passphrase` | Passphrase for `.dtzx` encryption/decryption | -| `--tables` | Comma-separated table list (default: all) | -| `--on-conflict` | `fail`, `skip`, `update`, or `replace` (default: `fail`) | -| `--batch-size` | Rows per batch for cross-server transfers (default: 1000) | -| `--truncate` | Truncate destination tables before transfer | -| `--no-fk` | Do not disable foreign key checks | -| `--no-identity` | Do not preserve identity/auto-increment values | -| `--dry-run` | Show transfer plan without executing | +### Locks -### Lock Operations +Tool-level mutual exclusion between noorm runs against one config. `change run`, `ff`, `next`, `revert`, and `rewind` take and release their own lock around each change. -Tool-level mutual exclusion. Prevents concurrent schema migrations across noorm instances or CI runners targeting the same database. #### `lock status` -Check if database is locked. + +Show whether the config is locked, and by whom. ```bash -noorm lock status noorm lock status --json ``` -**JSON output:** -```json -{ - "isLocked": true, - "lock": { - "lockedBy": "deploy@ci-runner", - "lockedAt": "2024-01-15T10:30:00Z", - "expiresAt": "2024-01-15T10:35:00Z" - } -} -``` +JSON: `isLocked` and `lock` (`lockedBy`, `lockedAt`, `expiresAt`), or `lock: null`. #### `lock acquire` -Acquire an exclusive lock. + +Take the config's lock so other noorm runs against it fail until you release it or it expires. ```bash -noorm lock acquire noorm lock acquire --timeout 600000 --reason "nightly migration" -noorm lock acquire --json ``` -| Flag | Description | -|------|-------------| -| `--timeout` | Lock duration in milliseconds before it expires (default: 300000) | +| Flag | Effect | +|---|---| +| `--timeout` | Milliseconds before the lock expires. Default 300000. A non-positive or non-numeric value exits 2 | | `--reason` | Text shown to anyone this lock blocks | -**CI/CD pattern with cleanup:** -```bash -noorm lock acquire -trap "noorm lock release" EXIT -noorm change ff -``` +Another holder fails the command with exit 1. The same identity extends its own lock. JSON: `acquired`, `lockedBy`, `expiresAt`, `reason`. #### `lock release` -Release the current lock. Only the lock holder can release. + +Release your own lock. A lock held by someone else, or no lock, exits 1. JSON: `released`. ```bash noorm lock release @@ -1263,513 +1104,320 @@ noorm lock release #### `lock force` -Force release any lock regardless of ownership. Gated by the config's `lock:force` access, which is a confirm cell for both `operator` and `admin`, so `--yes` (or `NOORM_YES=1`) is required. + +Release any holder's lock. Needs `--yes` or `NOORM_YES`. ```bash noorm lock force --yes -noorm lock force -c prod --yes --json ``` -Exits `0` when a lock was actually broken and `2` when there was nothing to release, so a script can tell "evicted a holder" from "no-op" without parsing text. The `--json` payload reports `success` as whether a lock was broken, not whether the command ran. +Exit 0 when a lock was broken and 2 when there was none, so a script can tell an eviction from a no-op. JSON: `success` (whether a lock was broken), `released`, `holder`, `forced`. Breaking a lock can corrupt data if the holder is still running. -::: warning -Force releasing a lock can cause data corruption if the original holder is still running. -::: +### Vault -### Vault Operations -Team-shared encrypted secrets stored in the database. +Team-shared secrets, encrypted in the config's own database. Vault commands load your identity first and exit 1 when it is missing. See [Vault](/guide/environments/vault). -**Secret resolution order:** -1. Config-specific local secrets (highest) -2. Global local secrets -3. Vault secrets (team-shared, lowest) +All but `cp` accept `--config`, `--json`, and `--yes`. #### `vault init` -Initialize the vault for the current database. + +Create the vault and seal its key to your identity. ```bash noorm vault init -noorm vault init --json ``` +```text +Vault initialized successfully +``` + +An initialized vault you can read reports success. One you cannot read exits 1 and asks for a `propagate`. An `operator` role needs `--yes`. + #### `vault set [value]` -Store an encrypted secret in the vault. + +Create or update a secret. `--stdin` reads the value from stdin. Pass the value or `--stdin`, not both. ```bash -noorm vault set API_KEY "sk-live-abc123" -echo "$API_KEY" | noorm vault set API_KEY --stdin -noorm vault set API_KEY "sk-live-abc123" --json +printf %s "$KEY" | noorm vault set PAYMENT_GATEWAY_KEY --stdin ``` -| Flag | Description | -|------|-------------| -| `--stdin` | Read the value from stdin instead of argv, so it never reaches the process table, shell history, or a `set -x` trace. Omit the positional value when you pass it | -| `--yes` / `-y` | Confirm the write on a config whose `vault:write` access is a confirm cell (an `operator` user role) | - -Upserts: creates if new, updates if the key exists. +An `operator` role needs `--yes`. Without vault access, exit 1. JSON: `key`, `action`. #### `vault list` -List all secrets in the vault (keys and metadata only, never values). + +List keys, who set them, and when. Never values. ```bash -noorm vault list noorm vault list --json ``` -**JSON output:** -```json -{ - "success": true, - "secrets": [ - {"key": "API_KEY", "setBy": "alice@example.com", "updatedAt": "2024-01-15T10:30:00Z"} - ], - "status": { - "usersWithAccess": 3, - "usersWithoutAccess": 1 - } -} -``` +JSON: `secrets[]` (`key`, `setBy`, `updatedAt`) and `status` (`usersWithAccess`, `usersWithoutAccess`). #### `vault rm ` -Remove a secret from the vault. `--yes` (or `NOORM_YES=1`) is mandatory: the vault has no soft-delete and no history table, so this destroys the team's only copy. + +Delete a secret. `--yes` or `NOORM_YES` is required, because the vault keeps no history and this destroys the team's only copy. A key that was never in the vault exits 2. ```bash noorm vault rm OLD_API_KEY --yes -noorm vault rm OLD_API_KEY --yes --json ``` -A key that was never in the vault exits `2`, which is distinct from a delete that failed (`1`). +JSON: `key`, `deleted`. #### `vault cp ` -Copy one secret from one config's vault to another's. All three arguments are positional and required — there is no bulk mode. + +Copy one secret from one config's vault to another's. All three arguments are positional. ```bash -noorm vault cp API_KEY staging production -noorm vault cp API_KEY staging production --dry-run -noorm vault cp DB_PASSWORD dev staging --force -noorm vault cp API_KEY staging production --json +noorm vault cp PAYMENT_GATEWAY_KEY staging production --dry-run ``` -| Flag | Description | -|------|-------------| -| `--force` / `-f` | Overwrite the key if it already exists in the destination | -| `--dry-run` | Preview without executing | +| Flag | Effect | +|---|---| +| `--force`, `-f` | Overwrite the key if the destination has it. Without it, the key is skipped | +| `--dry-run` | Run the same checks, write nothing | -Without `--force`, a key that already exists in the destination is skipped. An unknown source or destination config exits `2`. +Unknown source or destination exits 2. `--config` is accepted and ignored. JSON: `dryRun`, `copied`, `skipped`, `errors`. #### `vault propagate` -Grant vault access to team members who don't have it yet. Encrypts the vault key with each pending user's public key. -Propagation seals the vault key to a public key and cannot be revoked, so a bare `noorm vault propagate` only *lists* the identities awaiting access and exits `1`. Re-run with `--yes` to grant, or `--to ` to grant to specific identities. +Grant vault access to identities waiting for it. A grant cannot be revoked, so a bare `propagate` only lists who is waiting and exits 1. ```bash -noorm vault propagate # review the pending list -noorm vault propagate --yes # grant to everyone pending -noorm vault propagate --to 4a5c14af... --yes # grant to one identity -noorm vault propagate --json +noorm vault propagate # list who is waiting +noorm vault propagate --to 4a5c14af… --yes # grant one identity +noorm vault propagate --yes # grant everyone waiting ``` -| Flag | Description | -|------|-------------| -| `--to` | Comma-separated identity hashes to grant to (default: every pending identity) | -| `--yes` / `-y` | Perform the grant. Without it the command reports and stops | +| Flag | Effect | +|---|---| +| `--to` | Comma-separated identity hashes. Default: everyone waiting. A hash that is not waiting fails | +| `--yes`, `-y` | Perform the grant | -**JSON output:** -```json -{ - "success": true, - "granted": true, - "propagatedTo": ["4a5c14af…"], - "alreadyHadAccess": 3, - "failed": [], - "pending": [ - {"identityHash": "4a5c14af…", "name": "Alice", "email": "alice@example.com"} - ] -} -``` +JSON: `granted`, `propagatedTo[]` (hashes), `alreadyHadAccess`, `failed[]`, `pending[]` (`identityHash`, `name`, `email`). A grant that lands for some identities and fails for others exits 3. -`propagatedTo` holds identity hashes; `pending` is what maps them back to a name and email. A grant that lands for some identities and not others exits `3` with `success: false` — the vault is in a mixed state, and the teammate whose update did not land believes they have access. +### SQL -### SQL Operations -#### `sql [query]` +#### `sql query [sql]` -Execute a raw SQL query. + +Run one statement. The statement can be positional or read from a file. ```bash -noorm sql "SELECT * FROM users LIMIT 10" -noorm sql query -f query.sql -noorm sql query -c prod "SELECT count(*) FROM orders" -noorm sql "SELECT id, name FROM users" --json +noorm sql query "SELECT party_id, name FROM party ORDER BY party_id" ``` -| Flag | Description | -|------|-------------| -| `-f, --file` | Read SQL from a file | -| `-c, --config` | Use specific configuration | +```text +Columns: party_id, name +{"party_id":1,"name":"Meridian Analytics"} +{"party_id":2,"name":"Ada Lovelace"} +2 rows returned (3ms) +``` -The bare `noorm sql ""` form works by rewriting argv to `noorm sql query ""`, and it only fires when the first non-flag token after `sql` looks like SQL. A flag that takes a value puts something else there, so `noorm sql -f query.sql` and `noorm sql -c prod "SELECT 1"` print help instead — spell out `query` with either. See [`noorm sql`](./cli/sql.md). +| Flag | Effect | +|---|---| +| `--file`, `-f` | Read the SQL from a file. It replaces a positional statement | +| `--config`, `-c` | Config to run against | +| `--json` | Print the result as JSON | -**JSON output:** -```json -{ - "success": true, - "columns": ["id", "name"], - "rows": [ - {"id": 1, "name": "Alice"}, - {"id": 2, "name": "Bob"} - ], - "durationMs": 12.5 -} -``` +No statement, or an unreadable file, exits 2. A failed statement exits 1. A write prints `Rows affected: n` in place of the row count. JSON: `columns`, `rows`, `durationMs`. `rowsAffected` replaces the rows for a write and is absent on a `SELECT`. The role gate classifies the statement. + +`noorm sql "SELECT 1"` works because the entry point rewrites it to `sql query` when the first non-flag token starts with a SQL verb, such as `SELECT`, `INSERT`, `WITH`, `CREATE`, or `DROP`. `noorm sql -f q.sql` and `noorm sql -c prod "SELECT 1"` print help, because a flag's value sits in that position. Spell out `query` there. See [`noorm sql`](/cli/sql). -`rowsAffected` replaces the row payload for INSERT/UPDATE/DELETE and is absent (not `null`) on a SELECT. +`sql query` records no history, so a build agent never persists query text or rows. #### `sql repl` -Launch the TUI SQL Terminal with multi-line editing, sortable results, and history. + +Open the TUI at the SQL Terminal. Exits 2 under `--yes` or `NOORM_YES`, or without a terminal. `--config` makes that config the active one before it starts. ```bash -noorm sql repl noorm sql repl --config dev ``` -**TTY required.** `--yes` / `NOORM_YES` errors with a redirect hint — use `noorm sql query ""` or `noorm sql query --file query.sql` for non-interactive SQL. +See [`sql repl`](/cli/sql-repl). #### `sql history` -Show SQL execution history. Only the interactive SQL terminal records history — `sql query` deliberately writes none, so persisting query text and returned rows on a build agent never happens. + +Show history recorded by the interactive terminal for one config. ```bash -noorm sql history -noorm sql history -n 20 -noorm sql history --json -noorm sql history -c prod +noorm sql history -n 20 --json ``` -| Flag | Description | -|------|-------------| -| `-n, --limit` | Max entries (default: 50) | +| Flag | Effect | +|---|---| +| `--limit`, `-n` | Maximum entries. Default 50. A non-positive value exits 2 | +| `--config`, `-c` | Config whose history to read. Defaults to `NOORM_CONFIG`, then the active config. None resolved exits 2 | + +JSON: `configName` and `entries[]` (`id`, `query`, `executedAt`, `durationMs`, `success`, `errorMessage`, `rowCount`). #### `sql clear` -Clear SQL execution history. + +Delete recorded history and its result files. ```bash -noorm sql clear --yes noorm sql clear --older-than 3 --yes -noorm sql clear -c prod --yes ``` -| Flag | Description | -|------|-------------| -| `--older-than` | Only clear entries older than N months | -| `--yes` / `-y` | Confirm. Without it the command reports what it would clear and exits 0 without clearing | +| Flag | Effect | +|---|---| +| `--older-than` | Only entries older than N months. Must be a positive integer, else exit 2 | +| `--yes`, `-y` | Confirm. Without it, and without `--json`, the command reports what it would clear and exits 0 | -`--json` also counts as a confirmation, since it is only used non-interactively. +`NOORM_YES` does not count here. JSON: `configName`, `entriesRemoved`, `filesRemoved`. -### MCP (AI Agent Integration) +### MCP + + +Expose noorm to AI coding agents over the Model Context Protocol. See [MCP](/guide/automation/mcp). -Model Context Protocol server for AI coding agents. Exposes noorm operations as MCP tools. #### `mcp init` -Generate MCP configuration for a coding agent. + +Write or extend the agent's MCP config with a `noorm` server entry that runs `noorm mcp serve`. Other entries stay. ```bash -noorm mcp init # Default: Claude Code -noorm mcp init --agent cursor # Cursor editor -noorm mcp init --json +noorm mcp init --agent cursor ``` +| Flag | Effect | +|---|---| +| `--agent` | `claude` (default) writes `.mcp.json`. `cursor` writes `.cursor/mcp.json`. Other values write `.mcp.json` | + +JSON: `path`, `created`, `extended`. + #### `mcp serve` -Start the noorm MCP server on stdio transport. Designed to be spawned by an AI agent; stays alive as long as stdin is open. + +Start the MCP server on stdio. It stays alive while stdin is open. Always on the `agent` channel. ```bash noorm mcp serve ``` -### Utility Commands +### Diagnostics and updates + #### `info` -Project and database status overview. + +Report versions, the active config and its connection, your identity, the detected agent harness, and object counts. ```bash -noorm info noorm info --json ``` -**JSON output:** -```json -{ - "success": true, - "cli_version": "1.0.0-alpha.34", - "schema_version": 2, - "state_version": 3, - "settings_version": 1, - "installed_at": "2024-01-15T10:30:00.000Z", - "upgraded_at": null, - "active_config": "dev", - "config_count": 2, - "connection": { - "host": "localhost", - "port": 5432, - "database": "mydb", - "dialect": "postgres" - }, - "identity": { - "name": "Your Name", - "email": "you@example.com", - "machine": "laptop", - "registered_at": "2024-01-15T10:30:00.000Z", - "last_seen_at": "2024-02-01T09:00:00.000Z" - }, - "agent": null, - "objects": { - "tables": 5, - "views": 12, - "functions": 8, - "procedures": 9, - "types": 13 - } -} -``` - -`connection` is `null` and `connection_error` carries the message when the active config can't be reached. `agent` names the AI harness noorm detected, if any, along with the env markers that gave it away — worth checking first when a permission denial has no visible cause, since harness detection is what flips the policy channel from `user` to `agent`. +`--config` is accepted and ignored: `info` always reports the active config. An unreachable database still exits 0, with `connection: null` and `connection_error` set. JSON: `cli_version`, `schema_version`, `state_version`, `settings_version`, `installed_at`, `upgraded_at`, `active_config`, `config_count`, `connection`, `identity`, `agent` (`id`, `name`, `markers`), `objects`. #### `version` -CLI version and diagnostic information. + +Report the CLI version, Node, platform, identity files, and project detection. No database connection. Exits 0. ```bash -noorm version noorm version --json ``` +JSON: `version`, `node`, `platform`, `arch`, `identity`, `project`. + #### `update` -Check for and install noorm updates. + +Check for a newer release and replace the running binary. ```bash noorm update -noorm update --json ``` -| Flag | Description | -|------|-------------| -| `--insecure` | Downgrade an unreachable `checksums.txt` from a failure to a warning. Also settable as `NOORM_INSECURE=1`. It never bypasses a confirmed checksum mismatch, which always fails | +| Flag | Effect | +|---|---| +| `--insecure` | Downgrade an unreachable `checksums.txt` from a failure to a warning. A confirmed checksum mismatch always fails. `NOORM_INSECURE` does the same | +| `--json` | Print only the JSON document | -On a terminal, the download is one line rewritten in place with megabytes and percent, ending on `Downloaded X MB (100%)`. Piped output prints one line per step instead. `--json` prints only the JSON document. +On a terminal, status lines rewrite in place and the download ends on `Downloaded X MB (100%)`. Piped output prints one line per step. A failed check or install exits 1. JSON: `currentVersion`, `latestVersion`, `updateAvailable`, `installed`, and `error` on failure. -After a successful update, `noorm update` offers the agent skill through the first installer it finds on `PATH`: +After a successful update, `update` offers the agent skill through the first installer on `PATH`: | Found | Command | -|-------|---------| +|---|---| | `npx` | `npx skills add noormdev/noorm/skills` | -| `gh` (no `npx`) | `gh skill install noormdev/noorm skills/noorm`, which needs GitHub CLI 2.90 or newer | -| Neither | None run; both commands are printed with where to get Node.js and the GitHub CLI | +| `gh`, no `npx` | `gh skill install noormdev/noorm skills/noorm`, which needs GitHub CLI 2.90 or newer | +| Neither | Nothing runs. Both commands print, with where to get Node.js and the GitHub CLI | -On a terminal it asks first and runs the command on yes. Piped output prints the command instead of asking, and `--json` skips the offer. When the command fails, both commands are printed. A declined or failed skill install does not change the exit code, since the update itself succeeded. +On a terminal it asks first and runs the command on yes. Piped output prints the command without asking, and `--json` skips the offer. A failed install prints both commands. A declined or failed skill install does not change the exit code. -#### `ui` +#### `complete` + -Launch the interactive terminal UI. +Print a shell completion script. Takes `zsh`, `bash`, `fish`, `powershell`, or `fig`. ```bash -noorm ui +source <(noorm complete zsh) ``` -**TTY required.** - - -## Exit Codes - -| Code | Meaning | -|------|---------| -| `0` | Success — everything you asked for happened | -| `1` | Total failure — the operation ran and no unit of work succeeded | -| `2` | Usage error — a bad or missing flag, a TTY-only command run non-interactively, or a named target (file, directory, config, change, secret key, glob) that does not exist. Nothing was attempted, so nothing changed | -| `3` | Partial failure — some units succeeded and some failed. The target is in a mixed state and re-running is not automatically safe | +`fig` prints a JSON spec. A bare `noorm complete` is the runtime hook the scripts call, and it exits 1 without `--`. + + +#### `dev test-helpers