longcelot-sheet-db
Version:
Google Sheets-backed staging database adapter for Node.js with schema-first design
162 lines • 6.33 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.DEFAULT_ER_DIAGRAM_FILENAME = void 0;
exports.generateMermaidERDiagram = generateMermaidERDiagram;
exports.erdiagramCommand = erdiagramCommand;
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const chalk_1 = __importDefault(require("chalk"));
const inquirer_1 = __importDefault(require("inquirer"));
const adminAdapter_1 = require("../lib/adminAdapter");
const schemaLoader_1 = require("../lib/schemaLoader");
exports.DEFAULT_ER_DIAGRAM_FILENAME = 'ER-DIAGRAM.md';
function keyMarker(colName, col, pkColumn) {
if (colName === pkColumn)
return 'PK';
if (col.ref)
return 'FK';
if (col.unique)
return 'UK';
return '';
}
/**
* Renders `erDiagram` body only (no ```mermaid fences) so it can be embedded in a
* Markdown file or tested directly against its text output.
*/
function generateMermaidERDiagram(schemas) {
const knownTables = new Set(schemas.map((s) => s.name));
const lines = ['erDiagram'];
for (const schema of schemas) {
const pkColumn = schema.pkColumn ?? '_id';
lines.push(` ${schema.name} {`);
for (const [colName, col] of Object.entries(schema.columns)) {
const marker = keyMarker(colName, col, pkColumn);
const parts = [col.type, colName];
if (marker)
parts.push(marker);
lines.push(` ${parts.join(' ')}`);
}
lines.push(' }');
}
const relationLines = [];
for (const schema of schemas) {
for (const [colName, col] of Object.entries(schema.columns)) {
if (!col.ref)
continue;
const [refTable] = col.ref.split('.');
if (!knownTables.has(refTable))
continue; // dangling ref() — not registered on this adapter
const cardinality = col.unique ? '||--||' : '||--o{';
relationLines.push(` ${refTable} ${cardinality} ${schema.name} : "${colName}"`);
}
}
if (relationLines.length > 0) {
lines.push('');
lines.push(...relationLines);
}
return lines.join('\n');
}
function buildMarkdown(schemas) {
const diagram = generateMermaidERDiagram(schemas);
const byActor = new Map();
for (const schema of schemas) {
const list = byActor.get(schema.actor) ?? [];
list.push(schema.name);
byActor.set(schema.actor, list);
}
const tableList = [...byActor.entries()]
.map(([actor, tables]) => `- **${actor}**: ${tables.sort().join(', ')}`)
.join('\n');
return [
'# ER Diagram',
'',
`Auto-generated by \`lsdb erdiagram\` on ${new Date().toISOString()}. Re-run after schema changes to refresh.`,
'',
'## Tables by actor',
'',
tableList,
'',
'## Diagram',
'',
'```mermaid',
diagram,
'```',
'',
].join('\n');
}
/** Suggests `name-1.ext`, bumping an existing trailing `-N` to `-(N+1)`. */
function nextCandidateName(filename) {
const ext = path_1.default.extname(filename);
const base = path_1.default.basename(filename, ext);
const match = base.match(/^(.*)-(\d+)$/);
if (match) {
return `${match[1]}-${parseInt(match[2], 10) + 1}${ext}`;
}
return `${base}-1${ext}`;
}
/**
* Resolves the final write path, prompting the developer when the target already exists:
* overwrite it, save under a different name (looped until a free/confirmed path is chosen),
* or cancel. Returns null on cancel. `yes` bypasses the prompt and always overwrites.
*/
async function resolveOutputPath(initialPath, yes) {
let candidate = initialPath;
while (fs_1.default.existsSync(candidate)) {
if (yes)
return candidate;
const { action } = await inquirer_1.default.prompt([
{
type: 'list',
name: 'action',
message: `${path_1.default.basename(candidate)} already exists. What would you like to do?`,
choices: [
{ name: 'Overwrite existing file', value: 'overwrite' },
{ name: 'Save as a different file name', value: 'rename' },
{ name: 'Cancel', value: 'cancel' },
],
},
]);
if (action === 'overwrite')
return candidate;
if (action === 'cancel')
return null;
const { newName } = await inquirer_1.default.prompt([
{
type: 'input',
name: 'newName',
message: 'Enter a new file name:',
default: nextCandidateName(path_1.default.basename(candidate)),
validate: (input) => (input.trim().length > 0 ? true : 'File name cannot be empty'),
},
]);
candidate = path_1.default.join(path_1.default.dirname(candidate), newName.trim());
}
return candidate;
}
async function erdiagramCommand(options = {}) {
console.log(chalk_1.default.blue.bold('🗺️ Generating ER diagram...\n'));
const config = (0, adminAdapter_1.loadCLIConfig)();
const loaded = (0, schemaLoader_1.loadSchemasWithPaths)(config);
if (loaded.length === 0) {
console.log(chalk_1.default.yellow('⚠️ No schemas found'));
return;
}
const schemas = loaded.map((l) => l.schema);
const markdown = buildMarkdown(schemas);
const requestedPath = options.output
? path_1.default.resolve(process.cwd(), options.output)
: path_1.default.join(process.cwd(), exports.DEFAULT_ER_DIAGRAM_FILENAME);
const outPath = await resolveOutputPath(requestedPath, options.yes ?? false);
if (!outPath) {
console.log(chalk_1.default.gray('Cancelled.'));
return;
}
fs_1.default.writeFileSync(outPath, markdown);
console.log(chalk_1.default.green(`✅ ER diagram written to ${outPath}`));
console.log(chalk_1.default.cyan(`\n${schemas.length} table(s) diagrammed`));
}
exports.default = erdiagramCommand;
//# sourceMappingURL=erdiagram.js.map