@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
JavaScript
/**
* 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;
}