mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
53d9ec5678
sql-functions.ts sat at 1007 lines, held there by the size baseline, so it had no room for the Postgres branch every helper is meant to grow. The Kysely-expression twins are a separate family already (a Kysely Expression and a MikroORM RawQueryFragment do not convert into each other), so they move to src/db/dialect/kysely-functions.ts, and the platform check plus the fail-closed unsupported() move to platform.ts, which both files share. No helper changes behaviour or spelling. lint:dialect now skips the whole src/db/dialect/ directory instead of the one file, and the size baseline drops its sql-functions.ts entry.
314 lines
12 KiB
JavaScript
314 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
/*
|
|
* lint:dialect: SQLite-only SQL may only shrink.
|
|
*
|
|
* The server runs on SQLite today and is meant to run on Postgres next. What
|
|
* stands in the way is SQL that only SQLite understands, spelled out inline:
|
|
* INSERT OR IGNORE, datetime('now'), strftime, json_extract, GLOB,
|
|
* last_insert_rowid, PRAGMA, AUTOINCREMENT and `||` string concatenation.
|
|
* Each of them has a dialect-neutral form: the query builder's onConflict(),
|
|
* returning('id'), or a helper in src/db/dialect/ (sql-functions.ts and its
|
|
* Kysely twin kysely-functions.ts), which dispatches on the live platform
|
|
* and is the one place allowed to spell an engine's own forms.
|
|
*
|
|
* The check reads every string and template literal under src/ (comments
|
|
* never count) and every `.insertId` read, counts the hits per file and rule,
|
|
* and holds them to scripts/dialect-baseline.json. A new hit fails, and so
|
|
* does a baseline entry above what its file holds now, so the change that
|
|
* removes one lowers the baseline with --update in the same commit.
|
|
*
|
|
* Not scanned: src/db/dialect/ (the dialect layer itself) and
|
|
* the migrations that have shipped (append-only, never edited again; the last
|
|
* one is FROZEN_MIGRATIONS_THROUGH). A newer migration is scanned for the
|
|
* DML rules; its DDL may still be engine specific. PRAGMA is allowed in the
|
|
* src/db/ lifecycle files (src/db/*.ts and the migrations), nowhere else.
|
|
*
|
|
* npm run lint:dialect check against the baseline (CI)
|
|
* npm run lint:dialect -- --update lower the baseline to what the files hold now;
|
|
* it never raises or adds an entry
|
|
*
|
|
* --dir=<path> points the check at another server root (the unit tests use it).
|
|
*/
|
|
import { createRequire } from 'node:module';
|
|
import { existsSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
import { join, relative, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const ts = require('typescript');
|
|
|
|
/** The last migration that shipped before this check existed. Later ones are scanned. */
|
|
export const FROZEN_MIGRATIONS_THROUGH = 'Migration20200101042400';
|
|
|
|
/** The dialect layer: the one place that may spell an engine's own SQL. */
|
|
const DIALECT_LAYER = 'src/db/dialect/';
|
|
|
|
const SQL_KEYWORDS = /\b(SELECT|UPDATE|INSERT|DELETE|WHERE|SET|CASE|WHEN|COALESCE|CAST|VALUES|FROM)\b/i;
|
|
|
|
/**
|
|
* Each rule: a name, a test over one literal's text, the advice the failure
|
|
* prints, and whether it applies to a migration newer than the frozen ones.
|
|
*/
|
|
export const RULES = [
|
|
{
|
|
name: 'insert-or',
|
|
test: (text) => /\bINSERT\s+OR\s+(IGNORE|REPLACE)\b|\bREPLACE\s+INTO\b/i.test(text),
|
|
advice: "use the query builder's onConflict((oc) => oc.columns([...]).doNothing() / .doUpdateSet(...))",
|
|
migrations: true,
|
|
},
|
|
{
|
|
name: 'datetime',
|
|
// The call in SQL text, or the bare name the query builder takes in eb.fn('datetime', [...]).
|
|
test: (text) =>
|
|
/\b(datetime|julianday|unixepoch)\s*\(/i.test(text) || /^(datetime|julianday|unixepoch)$/i.test(text.trim()),
|
|
advice: 'use currentTimestamp/nowMinusDays/nowPlusSeconds and friends from sql-functions.ts',
|
|
migrations: true,
|
|
},
|
|
{
|
|
name: 'strftime',
|
|
test: (text) => /\bstrftime\b/i.test(text),
|
|
advice: 'use unixEpochToIsoKysely or another sql-functions.ts helper',
|
|
migrations: true,
|
|
},
|
|
{
|
|
name: 'json',
|
|
test: (text) => /\bjson_(extract|each|set|patch|insert|replace|remove|group_array|group_object)\b/i.test(text),
|
|
advice: 'read the JSON column and parse it in the service, or add a helper to sql-functions.ts',
|
|
migrations: true,
|
|
},
|
|
{
|
|
name: 'glob',
|
|
test: (text) => /\bGLOB\b/.test(text) || text.trim().toLowerCase() === 'glob',
|
|
advice: 'use LIKE, or startsWithIsoDate and its siblings in sql-functions.ts',
|
|
migrations: true,
|
|
},
|
|
{
|
|
name: 'last-insert-rowid',
|
|
test: (text) => /\blast_insert_rowid\b/i.test(text),
|
|
advice: "read the new id with .returning('id')",
|
|
migrations: true,
|
|
},
|
|
{
|
|
name: 'autoincrement',
|
|
test: (text) => /\bAUTOINCREMENT\b/i.test(text),
|
|
advice: 'tables are created by migrations, not by query-builder code',
|
|
migrations: false,
|
|
},
|
|
{
|
|
name: 'pragma',
|
|
// The statement, `PRAGMA name`: the bare word is also an HTTP header.
|
|
test: (text) => /\bPRAGMA\s+[a-z_]/i.test(text),
|
|
advice: 'PRAGMA belongs in the src/db/ lifecycle files (connection, durability, migrations)',
|
|
migrations: false,
|
|
},
|
|
{
|
|
name: 'concat',
|
|
test: (text, inSqlTag) => text.includes('||') && (inSqlTag || SQL_KEYWORDS.test(text)),
|
|
advice: 'use concat/concatKysely from sql-functions.ts',
|
|
migrations: true,
|
|
},
|
|
];
|
|
|
|
/** `.insertId` reads, the JS face of last_insert_rowid. */
|
|
const INSERT_ID_RULE = 'insert-id';
|
|
|
|
const SOURCE = /\.ts$/;
|
|
const TEST = /\.(?:test|spec)\./;
|
|
|
|
/** 'skip', 'migration' (a newer one, DML rules only) or 'source'. */
|
|
export function classify(file) {
|
|
if (file.startsWith(DIALECT_LAYER)) return 'skip';
|
|
const migration = /^src\/db\/migrations\/(Migration\d+)_/.exec(file);
|
|
if (migration) return migration[1] <= FROZEN_MIGRATIONS_THROUGH ? 'skip' : 'migration';
|
|
if (file.startsWith('src/db/migrations/')) return 'skip';
|
|
return 'source';
|
|
}
|
|
|
|
/** PRAGMA is fine in src/db/*.ts (connection, durability, baseline, snapshot) and the migrations. */
|
|
export function pragmaAllowed(file) {
|
|
return /^src\/db\/[^/]+\.ts$/.test(file) || file.startsWith('src/db/migrations/');
|
|
}
|
|
|
|
function isSqlTag(node) {
|
|
let tag = node.tag;
|
|
if (ts.isPropertyAccessExpression(tag)) tag = tag.expression;
|
|
return ts.isIdentifier(tag) && tag.text === 'sql';
|
|
}
|
|
|
|
/** Hits per rule in one file's source. */
|
|
export function scanSource(file, text) {
|
|
const kind = classify(file);
|
|
const counts = {};
|
|
if (kind === 'skip') return counts;
|
|
const add = (rule) => {
|
|
counts[rule] = (counts[rule] ?? 0) + 1;
|
|
};
|
|
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
|
|
const checkText = (literal, inSqlTag) => {
|
|
for (const rule of RULES) {
|
|
if (kind === 'migration' && !rule.migrations) continue;
|
|
if (rule.name === 'pragma' && pragmaAllowed(file)) continue;
|
|
if (rule.test(literal, inSqlTag)) add(rule.name);
|
|
}
|
|
};
|
|
|
|
const visit = (node, inSqlTag) => {
|
|
if (ts.isTaggedTemplateExpression(node) && isSqlTag(node)) {
|
|
ts.forEachChild(node, (child) => visit(child, true));
|
|
return;
|
|
}
|
|
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
checkText(node.text, inSqlTag);
|
|
} else if (ts.isTemplateExpression(node)) {
|
|
// The literal parts only: an interpolated value is code, scanned on its own.
|
|
checkText([node.head.text, ...node.templateSpans.map((span) => span.literal.text)].join(' '), inSqlTag);
|
|
} else if (ts.isPropertyAccessExpression(node) && node.name.text === 'insertId') {
|
|
add(INSERT_ID_RULE);
|
|
} else if (
|
|
ts.isBindingElement(node) &&
|
|
((node.propertyName && ts.isIdentifier(node.propertyName) && node.propertyName.text === 'insertId') ||
|
|
(!node.propertyName && ts.isIdentifier(node.name) && node.name.text === 'insertId'))
|
|
) {
|
|
add(INSERT_ID_RULE);
|
|
}
|
|
ts.forEachChild(node, (child) => visit(child, inSqlTag));
|
|
};
|
|
visit(sf, false);
|
|
return counts;
|
|
}
|
|
|
|
function walk(dir, files = []) {
|
|
for (const name of readdirSync(dir)) {
|
|
if (name === 'node_modules') continue;
|
|
const path = join(dir, name);
|
|
if (statSync(path).isDirectory()) walk(path, files);
|
|
else if (SOURCE.test(name) && !TEST.test(name) && !name.endsWith('.d.ts')) files.push(path);
|
|
}
|
|
return files;
|
|
}
|
|
|
|
/** `{ file: { rule: n } }` for every file under src/ with at least one hit. */
|
|
export function scan(serverDir) {
|
|
const src = join(serverDir, 'src');
|
|
if (!existsSync(src) || !statSync(src).isDirectory()) {
|
|
throw new Error(`src/ does not exist under ${serverDir}: the check would pass without looking at anything`);
|
|
}
|
|
const result = {};
|
|
for (const path of walk(src)) {
|
|
const file = relative(serverDir, path).split('\\').join('/');
|
|
const counts = scanSource(file, readFileSync(path, 'utf8'));
|
|
if (Object.keys(counts).length > 0) result[file] = sortKeys(counts);
|
|
}
|
|
return sortKeys(result);
|
|
}
|
|
|
|
function sortKeys(obj) {
|
|
return Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b)));
|
|
}
|
|
|
|
const RULE_NAMES = new Set([...RULES.map((r) => r.name), INSERT_ID_RULE]);
|
|
|
|
/** The committed baseline. Missing or malformed stops the run rather than reading as empty. */
|
|
export function readBaseline(path) {
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
} catch (err) {
|
|
throw new Error(`scripts/dialect-baseline.json cannot be read: ${err.message}`);
|
|
}
|
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
throw new Error('scripts/dialect-baseline.json must be an object of file paths to { rule: count }');
|
|
}
|
|
for (const [file, rules] of Object.entries(parsed)) {
|
|
if (rules === null || typeof rules !== 'object' || Array.isArray(rules)) {
|
|
throw new Error(`scripts/dialect-baseline.json: ${file} must map rule names to counts`);
|
|
}
|
|
for (const [rule, n] of Object.entries(rules)) {
|
|
if (!RULE_NAMES.has(rule)) throw new Error(`scripts/dialect-baseline.json: ${file} names an unknown rule "${rule}"`);
|
|
if (!Number.isInteger(n) || n <= 0) {
|
|
throw new Error(`scripts/dialect-baseline.json: ${file} ${rule} holds ${JSON.stringify(n)}, expected a positive integer`);
|
|
}
|
|
}
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
/** The baseline lowered to what the files hold now. Never raises an entry, never adds one. */
|
|
export function lowerBaseline(baseline, counts) {
|
|
const lowered = {};
|
|
for (const [file, rules] of Object.entries(baseline)) {
|
|
const kept = {};
|
|
for (const [rule, allowed] of Object.entries(rules)) {
|
|
const now = counts[file]?.[rule] ?? 0;
|
|
if (now > 0) kept[rule] = Math.min(allowed, now);
|
|
}
|
|
if (Object.keys(kept).length > 0) lowered[file] = sortKeys(kept);
|
|
}
|
|
return sortKeys(lowered);
|
|
}
|
|
|
|
/** New hits over the baseline, and baseline entries above what their file holds now. */
|
|
export function compare(baseline, counts) {
|
|
const grown = [];
|
|
for (const [file, rules] of Object.entries(counts)) {
|
|
for (const [rule, n] of Object.entries(rules)) {
|
|
const allowed = baseline[file]?.[rule] ?? 0;
|
|
if (n > allowed) grown.push({ file, rule, n, allowed });
|
|
}
|
|
}
|
|
const lowered = lowerBaseline(baseline, counts);
|
|
const stale = [];
|
|
for (const [file, rules] of Object.entries(baseline)) {
|
|
for (const [rule, allowed] of Object.entries(rules)) {
|
|
const now = lowered[file]?.[rule] ?? 0;
|
|
if (now !== allowed) stale.push({ file, rule, allowed, now: counts[file]?.[rule] ?? 0 });
|
|
}
|
|
}
|
|
return { grown, stale };
|
|
}
|
|
|
|
function adviceFor(rule) {
|
|
if (rule === INSERT_ID_RULE) return "read the new id with .returning('id'); insertId is undefined on Postgres";
|
|
return RULES.find((r) => r.name === rule).advice;
|
|
}
|
|
|
|
function main(argv) {
|
|
const dirArg = argv.find((a) => a.startsWith('--dir='));
|
|
const serverDir = dirArg ? resolve(dirArg.slice('--dir='.length)) : fileURLToPath(new URL('..', import.meta.url));
|
|
const baselinePath = join(serverDir, 'scripts', 'dialect-baseline.json');
|
|
const update = argv.includes('--update');
|
|
|
|
const counts = scan(serverDir);
|
|
let baseline = readBaseline(baselinePath);
|
|
if (update) {
|
|
baseline = lowerBaseline(baseline, counts);
|
|
writeFileSync(baselinePath, JSON.stringify(baseline, null, 2) + '\n');
|
|
}
|
|
|
|
const { grown, stale } = compare(baseline, counts);
|
|
for (const { file, rule, n, allowed } of grown) {
|
|
console.error(`FAIL ${file}: ${n} SQLite-only ${rule} hit(s), ${allowed} allowed. ${adviceFor(rule)}.`);
|
|
}
|
|
for (const { file, rule, allowed, now } of stale) {
|
|
console.error(`FAIL ${file} is held at ${allowed} ${rule} hit(s) in scripts/dialect-baseline.json, but has ${now} now.`);
|
|
}
|
|
if (stale.length) {
|
|
console.error('Run npm run lint:dialect -- --update to lower the baseline with the change that removed them.');
|
|
}
|
|
const total = Object.values(baseline).reduce((sum, rules) => sum + Object.values(rules).reduce((a, b) => a + b, 0), 0);
|
|
console.log(`dialect: ${Object.keys(baseline).length} file(s) hold ${total} SQLite-only spelling(s) at their baseline`);
|
|
return grown.length || stale.length ? 1 : 0;
|
|
}
|
|
|
|
const isCli =
|
|
Boolean(process.argv[1]) && existsSync(process.argv[1]) && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
if (isCli) {
|
|
try {
|
|
process.exitCode = main(process.argv.slice(2));
|
|
} catch (err) {
|
|
console.error(`FAIL ${err.message}`);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|