UNPKG

@athenox/talos

Version:

Talos: a backend you describe instead of write. Declare a schema, write the rules in plain language, map URLs to sentences; a small built-in model turns them into query plans.

109 lines (108 loc) • 5.31 kB
/** * The database brought up to a Drizzle schema, additively: a missing table is * created, a missing column added — nothing is ever dropped, renamed or * retyped (that is a migration to write by hand, with its data in mind). * What an app's migration folder did for fresh installs and new columns. * * await syncSchema(store, schema) // at startup * * Returns what it did, so a deploy log says it. */ export async function syncSchema(store, schema, o = {}) { const NAME = Symbol.for('drizzle:Name'), COLUMNS = Symbol.for('drizzle:Columns'); const mysql = store.dialect === 'mysql'; const q = (s) => mysql ? `\`${s.replace(/`/g, '``')}\`` : `"${s.replace(/"/g, '""')}"`; const done = []; const existing = async (table) => { if (mysql) { const cols = await store.all('SELECT COLUMN_NAME AS c FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?', table); return cols.length ? new Set(cols.map(r => r.c)) : null; } const cols = await store.all(`PRAGMA table_info(${q(table)})`); return cols.length ? new Set(cols.map(r => r.name)) : null; }; const literal = (v) => typeof v === 'number' ? String(v) : typeof v === 'boolean' ? (v ? '1' : '0') : v === null ? 'NULL' : `'${String(v).replace(/'/g, "''")}'`; // A default written as SQL (`sql\`(datetime('now'))\``, `defaultNow()`) goes out as an expression. const sqlText = (v) => Array.isArray(v?.queryChunks) ? v.queryChunks.map((ch) => Array.isArray(ch?.value) ? ch.value.join('') : typeof ch === 'string' ? ch : '').join('') : null; const defaultOf = (c) => { if (c.default === undefined) return ''; const expr = sqlText(c.default); if (expr !== null) return ` DEFAULT ${/^\(.*\)$/.test(expr.trim()) ? expr.trim() : `(${expr.trim()})`}`; if (c.default instanceof Date) return ` DEFAULT ${literal(c.default.toISOString())}`; if (typeof c.default === 'object' && c.default !== null) return ` DEFAULT ${literal(JSON.stringify(c.default))}`; return ` DEFAULT ${literal(c.default)}`; }; const definition = (c, adding) => { let sql = `${q(c.name)} ${c.getSQLType()}`; if (c.primary) sql += ' PRIMARY KEY'; if (c.autoIncrement) sql += mysql ? ' AUTO_INCREMENT' : ' AUTOINCREMENT'; // An added NOT NULL column needs a default for the rows already there. const def = defaultOf(c); if (c.notNull && !c.primary && (!adding || def)) sql += ' NOT NULL'; sql += def; if (c.isUnique && !adding) sql += ' UNIQUE'; return sql; }; const tables = Object.values(schema).filter((t) => !!t && typeof t === 'object' && NAME in t && COLUMNS in t); const fksOf = (t) => Object.getOwnPropertySymbols(t).filter(s => /InlineForeignKeys/.test(s.description ?? '')) .flatMap(s => t[s] ?? []); // A table after the tables it points at (MySQL checks a foreign key's target on CREATE). const ordered = [], placed = new Set(); const place = (t, path = new Set()) => { if (placed.has(t) || path.has(t)) return; path.add(t); for (const fk of fksOf(t)) { const to = fk.reference().foreignTable; if (to !== t && tables.includes(to)) place(to, path); } placed.add(t); ordered.push(t); }; for (const t of tables) place(t); for (const t of ordered) { const table = t[NAME]; const cols = Object.values(t[COLUMNS]); const have = await existing(table); if (!have) { // Foreign keys, with what a delete does to the rows pointing here. const fks = fksOf(t) .map(fk => { const r = fk.reference(); const to = r.foreignTable[NAME]; return `FOREIGN KEY (${r.columns.map((c) => q(c.name)).join(', ')}) REFERENCES ${q(to)} (${r.foreignColumns.map((c) => q(c.name)).join(', ')})` + (fk.onDelete ? ` ON DELETE ${fk.onDelete.toUpperCase()}` : '') + (fk.onUpdate ? ` ON UPDATE ${fk.onUpdate.toUpperCase()}` : ''); }); await store.run(`CREATE TABLE ${q(table)} (${[...cols.map(c => definition(c, false)), ...fks].join(', ')})`); done.push(`created ${table}`); continue; } for (const c of cols) if (!have.has(c.name)) { await store.run(`ALTER TABLE ${q(table)} ADD COLUMN ${definition(c, true)}`); done.push(`added ${table}.${c.name}`); } } // Rows that must be there, left alone when they already are. for (const [table, rows] of Object.entries(o.seed ?? {})) for (const row of rows) { const cols = Object.keys(row); const r = await store.run(`INSERT ${mysql ? 'IGNORE ' : 'OR IGNORE '}INTO ${q(table)} (${cols.map(q).join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`, ...cols.map(c => { const v = row[c]; return typeof v === 'boolean' ? Number(v) : v; })); if (Number(r.changes)) done.push(`seeded ${table}`); } return done; }