@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
JavaScript
import{existsSync as T,readFileSync as O}from"node:fs";import{writeFile as x,mkdir as D}from"node:fs/promises";import{join as v}from"@visulima/path";import{i as B}from"../packem_shared/command-0l-ZPhIX.mjs";import{createRequire as q}from"node:module";import{LunoraError as w}from"@lunora/errors";import{P as U,l as F}from"../packem_shared/detect-package-manager-DXDstphE.mjs";import{parseSchema as Y,applyAdditiveEdit as j}from"@lunora/config";const H=/^\//,J=(e,n)=>{try{const{args:t,command:a}=U(F(e),[n],{dev:!0});return`${a} ${t.join(" ")}`}catch{return`<your-package-manager> add -D ${n}`}},R=(e,n,t)=>{const a=q(v(t,"noop.cjs"));try{a.resolve(e)}catch{throw new w("INTERNAL",`\`lunora introspect\` needs the \`${n}\` driver to read this database, and it isn't installed.
Install it in your project:
${J(t,n)}`)}return a(e)},P=e=>{const n=e.slice(0,Math.max(0,e.indexOf(":"))).toLowerCase();if(n==="postgres"||n==="postgresql")return"postgres";if(n==="mysql"||n==="mariadb")return"mysql";throw new w("INTERNAL",`Unrecognised database URL scheme \`${n}\`. Expected a \`postgres://\`, \`postgresql://\`, \`mysql://\`, or \`mariadb://\` connection string.`)},W=e=>{try{const n=new URL(e).pathname.replace(H,"");return n===""?void 0:decodeURIComponent(n)}catch{return}},K=async(e,n,t,a=process.cwd())=>{if(n==="postgres"){const o=R("pg","pg",a).Client,c=new o({connectionString:e});return await c.connect(),{close:async()=>{await c.end()},execute:async(m,d)=>(await c.query(m,[...d])).rows,schema:t??"public"}}const s=R("mysql2/promise","mysql2",a).createConnection,r=await s(e),i=t??W(e);if(i===void 0)throw new w("INTERNAL","A MySQL connection string must name a database (`mysql://host/<database>`), or pass `--schema`.");return{close:async()=>{await r.end()},execute:async(o,c)=>{const[m]=await r.execute(o,[...c]);return Array.isArray(m)?m:[]},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()"},G={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()"},N=(e,n)=>{const t=(n==="postgres"?Q:G)[e.dataType],a=e.references===void 0?t:`v.id(${JSON.stringify(e.references.table)})`;let s=a??"v.any()";for(let r=0;r<e.arrayDepth;r+=1)s=`v.array(${s})`;return e.nullable&&(s=`v.optional(${s})`),{expression:s,known:a!==void 0}},M=/^[A-Z_$][\w$]*$/i,z=/[^\dA-Z]+(.)?/gi,X=/^\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`},b=e=>JSON.stringify(e).replaceAll(V,n=>ee[n]??n),$=e=>M.test(e)?e:b(e),te=e=>M.test(e)?`.${e}`:`[${b(e)}]`,I=e=>e.replaceAll("*/",String.raw`*\/`),ne=e=>{const n=e.replaceAll(Z,"_");return n===""?"table":n},ae=e=>{const n=e.replaceAll(z,(t,a)=>a?.toUpperCase()??"").replace(X,"");return n===""?"table":`${n.charAt(0).toLowerCase()}${n.slice(1)}`},C=e=>e.columns.filter(n=>!h.has(n.name)),re=e=>{const n=new Set(e.primaryKey);for(const t of e.indexes)for(const a of t.columns)n.add(a);for(const t of e.columns)t.references!==void 0&&n.add(t.name);return[...n].filter(t=>!h.has(t))},k=(e,n,t,a)=>e.references===void 0||n.has(e.references.table)?e:(a.push(`${t}.${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,n,t,a)=>{const s=[];for(const i of e.columns){if(h.has(i.name)){a.push(`${e.name}.${i.name}: skipped — \`${i.name}\` is a Lunora system column. Rename it in the source database or map it by hand.`);continue}const o=k(i,t,e.name,a),{expression:c,known:m}=N(o,n);!m&&o.references===void 0&&(a.push(`${e.name}.${i.name}: no mapping for SQL type \`${i.dataType}\` — emitted as \`v.any()\`.`),s.push(` // TODO: \`${I(i.dataType)}\` has no direct validator; narrow this.`)),s.push(` ${$(i.name)}: ${c},`)}const r=[' .global({ backend: "hyperdrive" })'];if(e.primaryKey.length>0&&e.primaryKey.every(i=>!h.has(i))){const i=e.primaryKey.map(o=>b(o)).join(", ");r.push(` .index(${b(`by_${e.primaryKey.join("_")}`)}, [${i}], { unique: true })`)}for(const i of e.indexes){const o=i.columns.filter(d=>!h.has(d));if(o.length===0)continue;const c=o.map(d=>b(d)).join(", "),m=i.unique?", { unique: true }":"";r.push(` .index(${b(i.name)}, [${c}]${m})`)}return` ${$(e.name)}: defineTable({
${s.join(`
`)}
})
${r.join(`
`)},`},ie=(e,n,t)=>{const a=new Set(e.tables.map(r=>r.name)),s=e.tables.map(r=>se(r,e.dialect,a,t)).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 "${n.serverImport}";
export default defineSchema({
${s}
});
`},oe=(e,n,t,a=new Set([e.name]),s=[])=>{const r=ae(e.name),i=re(e),o=new Map(C(e).map(l=>[l.name,l])),c=i.map(l=>{const g=o.get(l);if(g===void 0)return;const p=k({...g,nullable:!1},a,e.name,[]),{expression:E,known:A}=N(p,n);return!A&&p.references===void 0&&s.push(`${e.name}.${l}: filter falls back to \`v.any()\` (no mapping for SQL type \`${g.dataType}\`).`),` ${$(l)}: ${E},`}).filter(l=>l!==void 0),m=[b("_creationTime"),...i.map(l=>b(l))].join(", "),d=`ctx.db${te(e.name)}`;return`/**
* Generated by \`lunora introspect\` for the \`${I(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 "${t.serverImport}";
import type { Doc } from "./_generated/dataModel";
import { c } from "./_generated/server";
const ${r}List = defineListArgs<Doc<${b(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(${r}List.args).query(({ args, ctx }) => ${d}.findMany(${r}List.toQueryArgs(args)));
export const get = c.query.input({ id: v.id(${b(e.name)}) }).query(({ args, ctx }) => ${d}.get(args.id));
`},ce=(e,n)=>{const t=[],a=new Set(e.tables.map(r=>r.name)),s=[{contents:ie(e,n,t),path:"schema.ts"}];if(n.procedures){const r=new Map([["schema","the generated schema module"]]);for(const i of e.tables){if(C(i).length===0){t.push(`${i.name}: no usable columns — procedure module skipped.`);continue}const o=ne(i.name);o!==i.name&&t.push(`${i.name}: written as \`${o}.ts\` — the table name isn't usable as a filename.`);const c=r.get(o);if(c!==void 0){t.push(`${i.name}: procedure module skipped — its filename \`${o}.ts\` collides with table \`${c}\`.`);continue}r.set(o,i.name),s.push({contents:oe(i,e.dialect,n,a,t),path:`${o}.ts`})}}return{files:s,warnings:t}},_=/^[A-Z_$][\w$]*$/i,me=e=>e.startsWith("v.optional(")&&e.endsWith(")")?e.slice(11,-1):e,le=(e,n,t)=>{const a=[],s=new Set(n?.columns.map(r=>r.name));for(const r of e.columns){if(h.has(r.name)||s.has(r.name))continue;if(!_.test(r.name)){t.warnings.push(`${e.name}.${r.name}: skipped — column names must be bare identifiers to merge.`);continue}const i=r.references!==void 0&&!t.present.has(r.references.table),{expression:o}=N(i?{...r,references:void 0}:r,t.dialect);a.push({column:r.name,kind:"addOptionalColumn",table:e.name,validator:me(o)}),!r.nullable&&n!==void 0&&t.warnings.push(`${e.name}.${r.name}: added as \`v.optional(...)\` — a required column on an existing table needs a backfill migration.`)}return a},ue=(e,n,t)=>{const a=[],s=new Set(n?.indexes.map(r=>r.name));for(const r of e.indexes){const i=r.columns.filter(o=>!h.has(o));if(!(s.has(r.name)||i.length===0)){if(!_.test(r.name)||!i.every(o=>_.test(o))){t.push(`${e.name}: index \`${r.name}\` skipped — index and column names must be bare identifiers to merge.`);continue}a.push({fields:i,kind:"addIndex",name:r.name,table:e.name,...r.unique?{unique:!0}:{}})}}return a},de=(e,n,t)=>{const a=[],s=[],r=new Map(n.map(o=>[o.name,o])),i=new Set([...r.keys(),...e.tables.map(o=>o.name)]);for(const o of e.tables){if(!_.test(o.name)){s.push(`${o.name}: skipped — merging into an existing schema needs a table name that is a bare identifier.`);continue}const c=r.get(o.name);c===void 0&&a.push({global:{backend:"hyperdrive"},kind:"addTable",table:o.name}),a.push(...le(o,c,{dialect:t,present:i,warnings:s}),...ue(o,c,s))}return{edits:a,warnings:s}},pe=(e,n,t)=>{const a=Y(e);if(!a.ok)return{applied:0,warnings:[`lunora/schema.ts could not be parsed (${a.reason}) — leave it alone and merge by hand, or pass --force.`]};const s=de(n,a.tables,t),r=[...s.warnings];let i=e,o=0;for(const c of s.edits){const m=j(i,c);m.ok?(i=m.text,o+=1):r.push(`${c.table}: skipped one edit (${m.reason}).`)}return{applied:o,warnings:r,...o===0?{}:{text:i}}},u=(e,n)=>{const t=e[n]??e[n.toLowerCase()]??e[n.toUpperCase()];return typeof t=="string"?t:typeof t=="number"||typeof t=="bigint"||typeof t=="boolean"?String(t):""},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`,be=`
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`,fe=`
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`,ve=`
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`,ye=`
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`,Ee=`
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`,y=e=>{const n=new Map;for(const t of e){const a=u(t,"table_name"),s=n.get(a);s===void 0?n.set(a,[t]):s.push(t)}return n},Ae=(e,n)=>{const t=u(e,"data_type").toLowerCase();if(n!=="postgres")return{arrayDepth:0,dataType:t};const a=u(e,"udt_name").toLowerCase();return t==="array"&&a.startsWith("_")?{arrayDepth:1,dataType:a.slice(1)}:{arrayDepth:0,dataType:a===""?t:a}},$e=(e,n)=>{const t=new Map;for(const a of e){const s=u(a,"index_name"),r=n==="postgres"?u(a,"is_unique")==="true":u(a,"NON_UNIQUE")==="0",i=t.get(s);i===void 0?t.set(s,{columns:[u(a,"column_name")],unique:r}):i.columns.push(u(a,"column_name"))}return[...t.entries()].map(([a,s])=>({columns:s.columns,name:a,unique:s.unique}))},we=async(e,n,t)=>{const a=n==="postgres",[s,r,i,o]=await Promise.all([e(a?ge:ve,[t]),e(a?be:ye,[t]),e(a?fe:_e,[t]),e(a?he:Ee,[t])]),c=y(r),m=y(i),d=y(o),l=[];for(const[g,p]of y(s)){const E=new Map((m.get(g)??[]).map(f=>[u(f,"column_name"),{column:u(f,"foreign_column"),table:u(f,"foreign_table")}])),A=p.map(f=>{const S=u(f,"column_name"),L=E.get(S);return{...Ae(f,n),name:S,nullable:u(f,"is_nullable").toUpperCase()==="YES",...L===void 0?{}:{references:L}}});l.push({columns:A,indexes:$e(d.get(g)??[],n),name:g,primaryKey:(c.get(g)??[]).map(f=>u(f,"column_name"))})}return l.sort((g,p)=>g.name.localeCompare(p.name)),{dialect:n,tables:l}},Ne=e=>{try{const n=JSON.parse(O(v(e,"package.json"),"utf8"));return n.dependencies?.lunorash===void 0&&n.devDependencies?.lunorash===void 0?"@lunora/server":"lunorash/server"}catch{return"@lunora/server"}},Se=async(e,n,t)=>{const a=v(n,e.path);return T(a)&&t.force!==!0?(t.logger.warn(`skipped lunora/${e.path} — it already exists (pass --force to overwrite)`),!1):t.dryRun===!0?(t.logger.info(`would write lunora/${e.path} (${String(e.contents.split(`
`).length)} lines)`),!1):(await D(n,{recursive:!0}),await x(a,e.contents,"utf8"),t.logger.info(`wrote lunora/${e.path}`),!0)},Le=(e,n)=>{const t=n.tables;if(t===void 0||t.length===0)return e.tables;for(const a of t)e.tables.some(s=>s.name===a)||n.logger.warn(`--tables: no table named "${a}" exists in this schema — skipped.`);return e.tables.filter(a=>t.includes(a.name))},Re=async(e,n,t,a)=>{const s=pe(O(e,"utf8"),n,t);for(const r of s.warnings)a.logger.warn(r);return s.text===void 0?(a.logger.info("lunora/schema.ts is already up to date with the database."),!1):a.dryRun===!0?(a.logger.info(`would merge ${String(s.applied)} addition(s) into lunora/schema.ts`),!1):(await x(e,s.text,"utf8"),a.logger.info(`merged ${String(s.applied)} addition(s) into lunora/schema.ts`),!0)},Te=async e=>{const n=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 t=e.connection===void 0?P(e.url):e.dialect??"postgres",a=e.connection??await K(e.url,t,e.schema,n);let s;try{s=await we(a.execute,t,a.schema)}finally{e.connection===void 0&&await a.close()}const r=Le(s,e);if(r.length===0)return e.logger.error("no tables found to introspect — check --schema and --tables."),{code:1,written:[]};const{files:i,warnings:o}=ce({...s,tables:r},{procedures:e.procedures!==!1,serverImport:Ne(n)}),c=v(n,"lunora"),m=[],d=v(c,"schema.ts"),l=T(d)&&e.force!==!0,g=l?i.filter(p=>p.path!=="schema.ts"):i;l&&await Re(d,{tables:r},t,e)&&m.push("schema.ts");for(const p of g)await Se(p,c,e)&&m.push(p.path);for(const p of o)e.logger.warn(p);return e.logger.info(`introspected ${String(r.length)} table(s) from ${t}. Review the generated files before running \`lunora dev\`.`),{code:0,written:m}},qe=B(async({cwd:e,logger:n,options:t})=>({code:(await Te({cwd:e,dryRun:t.dryRun===!0,force:t.force===!0,logger:n,procedures:t.procedures!==!1,schema:t.schema,tables:t.tables===void 0?void 0:t.tables.split(",").map(a=>a.trim()),url:t.url??process.env.DATABASE_URL})).code}));export{qe as execute,Ne as resolveServerImport,Te as runIntrospectCommand};