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 kB
import{existsSync as T,readFileSync as O}from"node:fs";import{writeFile as I,mkdir as k}from"node:fs/promises";import{join as b}from"@visulima/path";import{d as B}from"../packem_shared/command-DwC11Cwe.mjs";import{createRequire as U}from"node:module";import{LunoraError as N}from"@lunora/errors";import{b as Y,d as q}from"../packem_shared/detect-package-manager-DSaAqRof.mjs";import{parseSchema as F,applyAdditiveEdit as P}from"@lunora/config";const H=/^\//,K=(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}`}},L=(e,t,n)=>{const r=U(b(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: ${K(n,t)}`)}return r(e)},j=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.`)},G=e=>{try{const t=new URL(e).pathname.replace(H,"");return t===""?void 0:decodeURIComponent(t)}catch{return}},J=async(e,t,n,r=process.cwd())=>{if(t==="postgres"){const m=L("pg","pg",r).Client,u=new m({connectionString:e});return await u.connect(),{close:async()=>{await u.end()},execute:async(l,d)=>(await u.query(l,[...d])).rows,schema:n??"public"}}const a=L("mysql2/promise","mysql2",r).createConnection,o=await a(e),i=n??G(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}},h=new Set(["_creationTime","_id"]),Q={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()"},W={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"?Q:W)[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}},M=/^[A-Z_$][\w$]*$/i,X=/[^\dA-Z]+(.)?/gi,z=/^\d+/,Z=/[^\w-]/g,V=/[\u003C\u003E\u002F\u2028\u2029]/g,ee={"/":String.raw`\u002F`,"\u2028":String.raw`\u2028`,"\u2029":String.raw`\u2029`,"<":String.raw`\u003C`,">":String.raw`\u003E`},f=e=>JSON.stringify(e).replaceAll(V,t=>ee[t]??t),S=e=>M.test(e)?e:f(e),ne=e=>M.test(e)?`.${e}`:`[${f(e)}]`,x=e=>e.replaceAll("*/",String.raw`*\/`),te=e=>{const t=e.replaceAll(Z,"_");return t===""?"table":t},re=e=>{const n=e.replaceAll(X,(r,s)=>s?.toUpperCase()??"").replace(z,"");return n===""?"table":`${n.charAt(0).toLowerCase()}${n.slice(1)}`},C=e=>e.columns.filter(t=>!h.has(t.name)),ae=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=>!h.has(n))},D=(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}),se=(e,t,n,r)=>{const s=[];for(const o of e.columns){if(h.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=D(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: \`${x(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=>!h.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=>!h.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(` `)},`},oe=(e,t,n)=>{const r=new Set(e.tables.map(a=>a.name)),s=e.tables.map(a=>se(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} }); `},ie=(e,t,n,r=new Set([e.name]),s=[])=>{const a=re(e.name),o=ae(e),i=new Map(C(e).map(l=>[l.name,l])),c=o.map(l=>{const d=i.get(l);if(d===void 0)return;const p=D({...d,nullable:!1},r,e.name,[]),{expression:y,known:A}=w(p,t);return!A&&p.references===void 0&&s.push(`${e.name}.${l}: filter falls back to \`v.any()\` (no mapping for SQL type \`${d.dataType}\`).`),` ${S(l)}: ${y},`}).filter(l=>l!==void 0),m=[f("_creationTime"),...o.map(l=>f(l))].join(", "),u=`ctx.db${ne(e.name)}`;return`/** * Generated by \`lunora introspect\` for the \`${x(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)); `},ce=(e,t)=>{const n=[],r=new Set(e.tables.map(a=>a.name)),s=[{contents:oe(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(C(o).length===0){n.push(`${o.name}: no usable columns — procedure module skipped.`);continue}const i=te(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:ie(o,e.dialect,t,r,n),path:`${i}.ts`})}}return{files:s,warnings:n}},_=/^[A-Z_$][\w$]*$/i,me=e=>e.startsWith("v.optional(")&&e.endsWith(")")?e.slice(11,-1):e,le=(e,t,n)=>{const r=[],s=new Set(t?.columns.map(a=>a.name));for(const a of e.columns){if(h.has(a.name)||s.has(a.name))continue;if(!_.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:me(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},ue=(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=>!h.has(i));if(!(s.has(a.name)||o.length===0)){if(!_.test(a.name)||!o.every(i=>_.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},de=(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(!_.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(...le(i,c,{dialect:n,present:o,warnings:s}),...ue(i,c,s))}return{edits:r,warnings:s}},pe=(e,t,n)=>{const r=F(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=de(t,r.tables,n),a=[...s.warnings];let o=e,i=0;for(const c of s.edits){const m=P(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):""},ge=` 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`,fe=` 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`,ve=` 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'`,he=` 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`,be=` 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`,Ee=` SELECT TABLE_NAME, COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = ? AND CONSTRAINT_NAME = 'PRIMARY' ORDER BY TABLE_NAME, ORDINAL_POSITION`,_e=` 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`,ye=` 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=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},Ae=(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}},Se=(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}))},Ne=async(e,t,n)=>{const r=t==="postgres",[s,a,o,i]=await Promise.all([e(r?ge:be,[n]),e(r?fe:Ee,[n]),e(r?ve:_e,[n]),e(r?he:ye,[n])]),c=E(a),m=E(o),u=E(i),l=[];for(const[d,p]of E(s)){const y=new Map((m.get(d)??[]).map(v=>[g(v,"column_name"),{column:g(v,"foreign_column"),table:g(v,"foreign_table")}])),A=p.map(v=>{const R=g(v,"column_name"),$=y.get(R);return{...Ae(v,t),name:R,nullable:g(v,"is_nullable").toUpperCase()==="YES",...$===void 0?{}:{references:$}}});l.push({columns:A,indexes:Se(u.get(d)??[],t),name:d,primaryKey:(c.get(d)??[]).map(v=>g(v,"column_name"))})}return l.sort((d,p)=>d.name.localeCompare(p.name)),{dialect:t,tables:l}},we=e=>{try{const t=JSON.parse(O(b(e,"package.json"),"utf8"));return t.dependencies?.lunorash===void 0&&t.devDependencies?.lunorash===void 0?"@lunora/server":"lunorash/server"}catch{return"@lunora/server"}},Re=async(e,t,n)=>{const r=b(t,e.path);return T(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 k(t,{recursive:!0}),await I(r,e.contents,"utf8"),n.logger.info(`wrote lunora/${e.path}`),!0)},$e=(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))},Le=async(e,t,n,r)=>{const s=pe(O(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 I(e,s.text,"utf8"),r.logger.info(`merged ${String(s.applied)} addition(s) into lunora/schema.ts`),!0)},Te=async e=>{const t=e.cwd??process.cwd();if(e.connection===void 0&&(e.url===void 0||e.url===""))return e.logger.error("`lunora introspect` needs a database URL: pass --url, or set DATABASE_URL."),{code:1,written:[]};const n=e.connection===void 0?j(e.url):e.dialect??"postgres",r=e.connection??await J(e.url,n,e.schema,t);let s;try{s=await Ne(r.execute,n,r.schema)}finally{e.connection===void 0&&await r.close()}const a=$e(s,e);if(a.length===0)return e.logger.error("no tables found to introspect — check --schema and --tables."),{code:1,written:[]};const{files:o,warnings:i}=ce({...s,tables:a},{procedures:e.procedures!==!1,serverImport:we(t)}),c=b(t,"lunora"),m=[],u=b(c,"schema.ts"),l=T(u)&&e.force!==!0,d=l?o.filter(p=>p.path!=="schema.ts"):o;l&&await Le(u,{...s,tables:a},n,e)&&m.push("schema.ts");for(const p of d)await Re(p,c,e)&&m.push(p.path);for(const p of i)e.logger.warn(p);return e.logger.info(`introspected ${String(a.length)} table(s) from ${n}. Review the generated files before running \`lunora dev\`.`),{code:0,written:m}},Ue=B(async({cwd:e,logger:t,options:n})=>({code:(await Te({cwd:e,dryRun:n.dryRun===!0,force:n.force===!0,logger:t,procedures:n.procedures!==!1,schema:n.schema,tables:n.tables===void 0?void 0:n.tables.split(",").map(s=>s.trim()),url:n.url??process.env.DATABASE_URL})).code}));export{Ue as execute,we as resolveServerImport,Te as runIntrospectCommand};