UNPKG

@lunora/cli

Version:

The Lunora CLI: init, dev, deploy, codegen, migrate, seed, doctor, insights, logs, registry, and the rest of the project commands

100 lines (92 loc) 18.3 kB
import{existsSync as O,readFileSync as I}from"node:fs";import{writeFile as M,mkdir as U}from"node:fs/promises";import{join as E}from"@visulima/path";import{d as B}from"../packem_shared/command-Mmxzxyr5.mjs";import{EXIT_CODE as L}from"../packem_shared/EXIT_CODE-08cwt3MK.mjs";import{createRequire as F}from"node:module";import{LunoraError as N}from"@lunora/errors";import{addArgsFor as Y,detectPackageManager as q,parseSchema as P,applyAdditiveEdit as H}from"@lunora/config";const K=/^\//,j=(e,t)=>{try{const{args:n,command:r}=Y(q(e),[t],{dev:!0});return`${r} ${n.join(" ")}`}catch{return`<your-package-manager> add -D ${t}`}},T=(e,t,n)=>{const r=F(E(n,"noop.cjs"));try{r.resolve(e)}catch{throw new N("INTERNAL",`\`lunora introspect\` needs the \`${t}\` driver to read this database, and it isn't installed. Install it in your project: ${j(n,t)}`)}return r(e)},G=e=>{const t=e.slice(0,Math.max(0,e.indexOf(":"))).toLowerCase();if(t==="postgres"||t==="postgresql")return"postgres";if(t==="mysql"||t==="mariadb")return"mysql";throw new N("INTERNAL",`Unrecognised database URL scheme \`${t}\`. Expected a \`postgres://\`, \`postgresql://\`, \`mysql://\`, or \`mariadb://\` connection string.`)},J=e=>{try{const t=new URL(e).pathname.replace(K,"");return t===""?void 0:decodeURIComponent(t)}catch{return}},Q=async(e,t,n,r=process.cwd())=>{if(t==="postgres"){const m=T("pg","pg",r).Client,u=new m({connectionString:e});return await u.connect(),{close:async()=>{await u.end()},execute:async(l,p)=>(await u.query(l,[...p])).rows,schema:n??"public"}}const a=T("mysql2/promise","mysql2",r).createConnection,o=await a(e),i=n??J(e);if(i===void 0)throw new N("INTERNAL","A MySQL connection string must name a database (`mysql://host/<database>`), or pass `--schema`.");return{close:async()=>{await o.end()},execute:async(c,m)=>{const[u]=await o.execute(c,[...m]);return Array.isArray(u)?u:[]},schema:i}},b=new Set(["_creationTime","_id"]),W={bigint:"v.bigint()",bigserial:"v.bigint()",bit:"v.string()",bool:"v.boolean()",boolean:"v.boolean()",box:"v.string()",bpchar:"v.string()",bytea:"v.bytes()",char:"v.string()",character:"v.string()","character varying":"v.string()",cidr:"v.string()",circle:"v.string()",citext:"v.string()",date:"v.date()","double precision":"v.number()",float4:"v.number()",float8:"v.number()",inet:"v.string()",int:"v.number()",int2:"v.number()",int4:"v.number()",int8:"v.bigint()",integer:"v.number()",interval:"v.string()",json:"v.any()",jsonb:"v.any()",line:"v.string()",lseg:"v.string()",macaddr:"v.string()",money:"v.number()",name:"v.string()",numeric:"v.number()",path:"v.string()",point:"v.string()",polygon:"v.string()",real:"v.number()",serial:"v.number()",smallint:"v.number()",smallserial:"v.number()",text:"v.string()",time:"v.string()","time with time zone":"v.string()","time without time zone":"v.string()",timestamp:"v.timestamp()","timestamp with time zone":"v.timestamp()","timestamp without time zone":"v.timestamp()",timestamptz:"v.timestamp()",tsquery:"v.string()",tsvector:"v.string()",uuid:"v.string()",varbit:"v.string()",varchar:"v.string()",xml:"v.string()"},X={bigint:"v.bigint()",binary:"v.bytes()",bit:"v.number()",blob:"v.bytes()",char:"v.string()",date:"v.date()",datetime:"v.timestamp()",decimal:"v.number()",double:"v.number()",enum:"v.string()",float:"v.number()",int:"v.number()",integer:"v.number()",json:"v.any()",longblob:"v.bytes()",longtext:"v.string()",mediumblob:"v.bytes()",mediumint:"v.number()",mediumtext:"v.string()",numeric:"v.number()",set:"v.string()",smallint:"v.number()",text:"v.string()",time:"v.string()",timestamp:"v.timestamp()",tinyblob:"v.bytes()",tinyint:"v.number()",tinytext:"v.string()",varbinary:"v.bytes()",varchar:"v.string()",year:"v.number()"},w=(e,t)=>{const r=(t==="postgres"?W:X)[e.dataType],s=e.references===void 0?r:`v.id(${JSON.stringify(e.references.table)})`;let a=s??"v.any()";for(let o=0;o<e.arrayDepth;o+=1)a=`v.array(${a})`;return e.nullable&&(a=`v.optional(${a})`),{expression:a,known:s!==void 0}},x=/^[A-Z_$][\w$]*$/i,z=/[^\dA-Z]+(.)?/gi,Z=/^\d+/,V=/[^\w-]/g,ee=/[\u003C\u003E\u002F\u2028\u2029]/g,ne={"/":String.raw`\u002F`,"\u2028":String.raw`\u2028`,"\u2029":String.raw`\u2029`,"<":String.raw`\u003C`,">":String.raw`\u003E`},f=e=>JSON.stringify(e).replaceAll(ee,t=>ne[t]??t),S=e=>x.test(e)?e:f(e),te=e=>x.test(e)?`.${e}`:`[${f(e)}]`,C=e=>e.replaceAll("*/",String.raw`*\/`),re=e=>{const t=e.replaceAll(V,"_");return t===""?"table":t},ae=e=>{const n=e.replaceAll(z,(r,s)=>s?.toUpperCase()??"").replace(Z,"");return n===""?"table":`${n.charAt(0).toLowerCase()}${n.slice(1)}`},D=e=>e.columns.filter(t=>!b.has(t.name)),se=e=>{const t=new Set(e.primaryKey);for(const n of e.indexes)for(const r of n.columns)t.add(r);for(const n of e.columns)n.references!==void 0&&t.add(n.name);return[...t].filter(n=>!b.has(n))},k=(e,t,n,r)=>e.references===void 0||t.has(e.references.table)?e:(r.push(`${n}.${e.name}: references \`${e.references.table}\`, which isn't in the generated schema — emitted as a plain column instead of \`v.id(...)\`.`),{arrayDepth:e.arrayDepth,dataType:e.dataType,name:e.name,nullable:e.nullable}),oe=(e,t,n,r)=>{const s=[];for(const o of e.columns){if(b.has(o.name)){r.push(`${e.name}.${o.name}: skipped — \`${o.name}\` is a Lunora system column. Rename it in the source database or map it by hand.`);continue}const i=k(o,n,e.name,r),{expression:c,known:m}=w(i,t);!m&&i.references===void 0&&(r.push(`${e.name}.${o.name}: no mapping for SQL type \`${o.dataType}\` — emitted as \`v.any()\`.`),s.push(` // TODO: \`${C(o.dataType)}\` has no direct validator; narrow this.`)),s.push(` ${S(o.name)}: ${c},`)}const a=[' .global({ backend: "hyperdrive" })'];if(e.primaryKey.length>0&&e.primaryKey.every(o=>!b.has(o))){const o=e.primaryKey.map(i=>f(i)).join(", ");a.push(` .index(${f(`by_${e.primaryKey.join("_")}`)}, [${o}], { unique: true })`)}for(const o of e.indexes){const i=o.columns.filter(u=>!b.has(u));if(i.length===0)continue;const c=i.map(u=>f(u)).join(", "),m=o.unique?", { unique: true }":"";a.push(` .index(${f(o.name)}, [${c}]${m})`)}return` ${S(e.name)}: defineTable({ ${s.join(` `)} }) ${a.join(` `)},`},ie=(e,t,n)=>{const r=new Set(e.tables.map(a=>a.name)),s=e.tables.map(a=>oe(a,e.dialect,r,n)).join(` `);return`/** * Generated by \`lunora introspect\` from an existing ${e.dialect==="postgres"?"Postgres":"MySQL"} database. * * This file is a STARTING POINT, not a build artifact — it is written once and is * yours to edit. Review it before shipping: column types are mapped * conservatively, and every table is \`.global({ backend: "hyperdrive" })\` because * its rows live in the external database. Re-running \`lunora introspect\` will not * overwrite it unless you pass \`--force\`. */ import { defineSchema, defineTable, v } from "${t.serverImport}"; export default defineSchema({ ${s} }); `},ce=(e,t,n,r=new Set([e.name]),s=[])=>{const a=ae(e.name),o=se(e),i=new Map(D(e).map(l=>[l.name,l])),c=o.map(l=>{const p=i.get(l);if(p===void 0)return;const v=k({...p,nullable:!1},r,e.name,[]),{expression:d,known:A}=w(v,t);return!A&&v.references===void 0&&s.push(`${e.name}.${l}: filter falls back to \`v.any()\` (no mapping for SQL type \`${p.dataType}\`).`),` ${S(l)}: ${d},`}).filter(l=>l!==void 0),m=[f("_creationTime"),...o.map(l=>f(l))].join(", "),u=`ctx.db${te(e.name)}`;return`/** * Generated by \`lunora introspect\` for the \`${C(e.name)}\` table — a * starting point you own and edit. * * Both procedures are RPC-only. Add \`.expose({ rest: true })\` once you've decided * this data should be public, and gate them with your auth/RLS middleware first — * \`introspect\` cannot know who is allowed to read this table. */ import { defineListArgs, v } from "${n.serverImport}"; import type { Doc } from "./_generated/dataModel"; import { c } from "./_generated/server"; const ${a}List = defineListArgs<Doc<${f(e.name)}>>()({ // Only index-backed columns are published as filterable, so a caller cannot // reach a column you did not choose to expose. Note that \`contains\` and the // negative operators still scan — narrow this list, and review it, before // adding \`.expose({ rest: true })\`. filter: { ${c.join(` `)} }, orderBy: [${m}], }); export const list = c.query.input(${a}List.args).query(({ args, ctx }) => ${u}.findMany(${a}List.toQueryArgs(args))); export const get = c.query.input({ id: v.id(${f(e.name)}) }).query(({ args, ctx }) => ${u}.get(args.id)); `},me=(e,t)=>{const n=[],r=new Set(e.tables.map(a=>a.name)),s=[{contents:ie(e,t,n),path:"schema.ts"}];if(t.procedures){const a=new Map([["schema","the generated schema module"]]);for(const o of e.tables){if(D(o).length===0){n.push(`${o.name}: no usable columns — procedure module skipped.`);continue}const i=re(o.name);i!==o.name&&n.push(`${o.name}: written as \`${i}.ts\` — the table name isn't usable as a filename.`);const c=a.get(i);if(c!==void 0){n.push(`${o.name}: procedure module skipped — its filename \`${i}.ts\` collides with table \`${c}\`.`);continue}a.set(i,o.name),s.push({contents:ce(o,e.dialect,t,r,n),path:`${i}.ts`})}}return{files:s,warnings:n}},y=/^[A-Z_$][\w$]*$/i,le=e=>e.startsWith("v.optional(")&&e.endsWith(")")?e.slice(11,-1):e,ue=(e,t,n)=>{const r=[],s=new Set(t?.columns.map(a=>a.name));for(const a of e.columns){if(b.has(a.name)||s.has(a.name))continue;if(!y.test(a.name)){n.warnings.push(`${e.name}.${a.name}: skipped — column names must be bare identifiers to merge.`);continue}const o=a.references!==void 0&&!n.present.has(a.references.table),{expression:i}=w(o?{...a,references:void 0}:a,n.dialect);r.push({column:a.name,kind:"addOptionalColumn",table:e.name,validator:le(i)}),!a.nullable&&t!==void 0&&n.warnings.push(`${e.name}.${a.name}: added as \`v.optional(...)\` — a required column on an existing table needs a backfill migration.`)}return r},de=(e,t,n)=>{const r=[],s=new Set(t?.indexes.map(a=>a.name));for(const a of e.indexes){const o=a.columns.filter(i=>!b.has(i));if(!(s.has(a.name)||o.length===0)){if(!y.test(a.name)||!o.every(i=>y.test(i))){n.push(`${e.name}: index \`${a.name}\` skipped — index and column names must be bare identifiers to merge.`);continue}r.push({fields:o,kind:"addIndex",name:a.name,table:e.name,...a.unique?{unique:!0}:{}})}}return r},pe=(e,t,n)=>{const r=[],s=[],a=new Map(t.map(i=>[i.name,i])),o=new Set([...a.keys(),...e.tables.map(i=>i.name)]);for(const i of e.tables){if(!y.test(i.name)){s.push(`${i.name}: skipped — merging into an existing schema needs a table name that is a bare identifier.`);continue}const c=a.get(i.name);c===void 0&&r.push({global:{backend:"hyperdrive"},kind:"addTable",table:i.name}),r.push(...ue(i,c,{dialect:n,present:o,warnings:s}),...de(i,c,s))}return{edits:r,warnings:s}},ge=(e,t,n)=>{const r=P(e);if(!r.ok)return{applied:0,warnings:[`lunora/schema.ts could not be parsed (${r.reason}) — leave it alone and merge by hand, or pass --force.`]};const s=pe(t,r.tables,n),a=[...s.warnings];let o=e,i=0;for(const c of s.edits){const m=H(o,c);m.ok?(o=m.text,i+=1):a.push(`${c.table}: skipped one edit (${m.reason}).`)}return{applied:i,warnings:a,...i===0?{}:{text:o}}},g=(e,t)=>{const n=e[t]??e[t.toLowerCase()]??e[t.toUpperCase()];return typeof n=="string"?n:typeof n=="number"||typeof n=="bigint"||typeof n=="boolean"?String(n):""},fe=` SELECT c.table_name, c.column_name, c.is_nullable, c.data_type, c.udt_name FROM information_schema.columns c JOIN information_schema.tables t ON t.table_schema = c.table_schema AND t.table_name = c.table_name WHERE c.table_schema = $1 AND t.table_type = 'BASE TABLE' ORDER BY c.table_name, c.ordinal_position`,ve=` SELECT tc.table_name, kcu.column_name FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON kcu.constraint_name = tc.constraint_name AND kcu.table_schema = tc.table_schema WHERE tc.table_schema = $1 AND tc.constraint_type = 'PRIMARY KEY' ORDER BY tc.table_name, kcu.ordinal_position`,he=` SELECT tc.table_name, kcu.column_name, ccu.table_name AS foreign_table, ccu.column_name AS foreign_column FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON kcu.constraint_name = tc.constraint_name AND kcu.table_schema = tc.table_schema JOIN information_schema.constraint_column_usage ccu ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema WHERE tc.table_schema = $1 AND tc.constraint_type = 'FOREIGN KEY'`,be=` SELECT t.relname AS table_name, i.relname AS index_name, ix.indisunique AS is_unique, a.attname AS column_name FROM pg_class t JOIN pg_index ix ON t.oid = ix.indrelid JOIN pg_class i ON i.oid = ix.indexrelid JOIN pg_namespace n ON n.oid = t.relnamespace CROSS JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS k(attnum, ord) JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum WHERE n.nspname = $1 AND t.relkind = 'r' AND NOT ix.indisprimary ORDER BY t.relname, i.relname, k.ord`,Ee=` SELECT c.TABLE_NAME, c.COLUMN_NAME, c.IS_NULLABLE, c.DATA_TYPE FROM information_schema.COLUMNS c JOIN information_schema.TABLES t ON t.TABLE_SCHEMA = c.TABLE_SCHEMA AND t.TABLE_NAME = c.TABLE_NAME WHERE c.TABLE_SCHEMA = ? AND t.TABLE_TYPE = 'BASE TABLE' ORDER BY c.TABLE_NAME, c.ORDINAL_POSITION`,_e=` SELECT TABLE_NAME, COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = ? AND CONSTRAINT_NAME = 'PRIMARY' ORDER BY TABLE_NAME, ORDINAL_POSITION`,ye=` SELECT TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME AS foreign_table, REFERENCED_COLUMN_NAME AS foreign_column FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = ? AND REFERENCED_TABLE_NAME IS NOT NULL`,Ae=` SELECT TABLE_NAME, INDEX_NAME AS index_name, NON_UNIQUE, COLUMN_NAME FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = ? AND INDEX_NAME <> 'PRIMARY' ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX`,_=e=>{const t=new Map;for(const n of e){const r=g(n,"table_name"),s=t.get(r);s===void 0?t.set(r,[n]):s.push(n)}return t},Se=(e,t)=>{const n=g(e,"data_type").toLowerCase();if(t!=="postgres")return{arrayDepth:0,dataType:n};const r=g(e,"udt_name").toLowerCase();return n==="array"&&r.startsWith("_")?{arrayDepth:1,dataType:r.slice(1)}:{arrayDepth:0,dataType:r===""?n:r}},Ne=(e,t)=>{const n=new Map;for(const r of e){const s=g(r,"index_name"),a=t==="postgres"?g(r,"is_unique")==="true":g(r,"NON_UNIQUE")==="0",o=n.get(s);o===void 0?n.set(s,{columns:[g(r,"column_name")],unique:a}):o.columns.push(g(r,"column_name"))}return[...n.entries()].map(([r,s])=>({columns:s.columns,name:r,unique:s.unique}))},we=async(e,t,n)=>{const r=t==="postgres",[s,a,o,i]=await Promise.all([e(r?fe:Ee,[n]),e(r?ve:_e,[n]),e(r?he:ye,[n]),e(r?be:Ae,[n])]),c=_(a),m=_(o),u=_(i),l=[];for(const[p,v]of _(s)){const d=new Map((m.get(p)??[]).map(h=>[g(h,"column_name"),{column:g(h,"foreign_column"),table:g(h,"foreign_table")}])),A=v.map(h=>{const R=g(h,"column_name"),$=d.get(R);return{...Se(h,t),name:R,nullable:g(h,"is_nullable").toUpperCase()==="YES",...$===void 0?{}:{references:$}}});l.push({columns:A,indexes:Ne(u.get(p)??[],t),name:p,primaryKey:(c.get(p)??[]).map(h=>g(h,"column_name"))})}return l.sort((p,v)=>p.name.localeCompare(v.name)),{dialect:t,tables:l}},Re=e=>{try{const t=JSON.parse(I(E(e,"package.json"),"utf8"));return t.dependencies?.lunorash===void 0&&t.devDependencies?.lunorash===void 0?"@lunora/server":"lunorash/server"}catch{return"@lunora/server"}},$e=async(e,t,n)=>{const r=E(t,e.path);return O(r)&&n.force!==!0?(n.logger.warn(`skipped lunora/${e.path} — it already exists (pass --force to overwrite)`),!1):n.dryRun===!0?(n.logger.info(`would write lunora/${e.path} (${String(e.contents.split(` `).length)} lines)`),!1):(await U(t,{recursive:!0}),await M(r,e.contents,"utf8"),n.logger.info(`wrote lunora/${e.path}`),!0)},Le=(e,t)=>{const n=t.tables;if(n===void 0||n.length===0)return e.tables;for(const r of n)e.tables.some(s=>s.name===r)||t.logger.warn(`--tables: no table named "${r}" exists in this schema — skipped.`);return e.tables.filter(r=>n.includes(r.name))},Te=async(e,t,n,r)=>{const s=ge(I(e,"utf8"),t,n);for(const a of s.warnings)r.logger.warn(a);return s.text===void 0?(r.logger.info("lunora/schema.ts is already up to date with the database."),!1):r.dryRun===!0?(r.logger.info(`would merge ${String(s.applied)} addition(s) into lunora/schema.ts`),!1):(await M(e,s.text,"utf8"),r.logger.info(`merged ${String(s.applied)} addition(s) into lunora/schema.ts`),!0)},Oe=async e=>{const t=e.cwd??process.cwd();if(e.connection===void 0&&(e.url===void 0||e.url==="")){const d="`lunora introspect` needs a database URL: pass --url, or set DATABASE_URL.";return e.logger.error(d),{code:L.USAGE,error:d,written:[]}}const n=e.connection===void 0?G(e.url):e.dialect??"postgres",r=e.connection??await Q(e.url,n,e.schema,t);let s;try{s=await we(r.execute,n,r.schema)}finally{e.connection===void 0&&await r.close()}const a=Le(s,e);if(a.length===0){const d="no tables found to introspect — check --schema and --tables.";return e.logger.error(d),{code:L.NOT_FOUND,error:d,written:[]}}const{files:o,warnings:i}=me({...s,tables:a},{procedures:e.procedures!==!1,serverImport:Re(t)}),c=E(t,"lunora"),m=[],u=E(c,"schema.ts"),l=O(u)&&e.force!==!0,p=l?o.filter(d=>d.path!=="schema.ts"):o;l&&await Te(u,{...s,tables:a},n,e)&&m.push("schema.ts");for(const d of p)await $e(d,c,e)&&m.push(d.path);for(const d of i)e.logger.warn(d);e.logger.info(`introspected ${String(a.length)} table(s) from ${n}. Review the generated files before running \`lunora dev\`.`);const v=a.map(d=>d.name);return{code:0,dialect:n,tables:v,written:m}},Fe=B(async({cwd:e,format:t,logger:n,options:r})=>{const s=await Oe({cwd:e,dryRun:r.dryRun===!0,force:r.force===!0,format:t,logger:n,procedures:r.procedures!==!1,schema:r.schema,tables:r.tables===void 0?void 0:r.tables.split(",").map(a=>a.trim()),url:r.url??process.env.DATABASE_URL});return{code:s.code,data:s.dialect===void 0?void 0:{dialect:s.dialect,dryRun:r.dryRun===!0,tables:s.tables??[],written:s.written},error:s.error}});export{Fe as execute,Re as resolveServerImport,Oe as runIntrospectCommand};