openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
185 lines (172 loc) • 6.3 kB
text/typescript
// Generates Kysely database types from the SQLite schema.
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import type { DatabaseSync } from "node:sqlite";
import { fileURLToPath } from "node:url";
import { isDirectRunUrl } from "./lib/direct-run.mjs";
const SCHEMAS = ["openclaw-state", "openclaw-agent"];
const REPO_ROOT = fileURLToPath(new URL("../", import.meta.url));
function toInterfaceName(tableName: string): string {
return tableName
.split("_")
.map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`)
.join("");
}
function columnBaseType(columnTypeLocal: string): string {
const normalized = columnTypeLocal.toUpperCase();
if (normalized.includes("BLOB")) {
return "Uint8Array";
}
if (
normalized.includes("INT") ||
normalized.includes("REAL") ||
normalized.includes("FLOA") ||
normalized.includes("DOUB") ||
normalized.includes("NUM") ||
normalized.includes("DEC")
) {
return "number";
}
return "string";
}
function columnType(column: Record<string, unknown>, primaryKeyColumnCount: number): string {
const sqliteType = typeof column.type === "string" ? column.type : "";
const baseType = columnBaseType(sqliteType);
const generatedPrimaryKey =
primaryKeyColumnCount === 1 &&
Number(column.pk) > 0 &&
sqliteType.toUpperCase().includes("INT");
const generated = column.dflt_value != null || generatedPrimaryKey;
const nullable = Number(column.notnull) !== 1 && !generatedPrimaryKey;
const valueType = nullable ? `${baseType} | null` : baseType;
return generated ? `Generated<${valueType}>` : valueType;
}
function quoteSqliteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
function generateTypes(db: DatabaseSync): string {
const tables = db
.prepare(
"SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name;",
)
.all()
.map((row) => String(row.name));
const lines = [
"/**",
" * This file was generated by kysely-codegen.",
" * Please do not edit it manually.",
" */",
"",
'import type { ColumnType } from "kysely";',
"",
"export type Generated<T> =",
" T extends ColumnType<infer S, infer I, infer U>",
" ? ColumnType<S, I | undefined, U>",
" : ColumnType<T, T | undefined, T>;",
"",
];
const interfaces: Array<{ interfaceName: string; table: string }> = [];
for (const table of tables) {
const interfaceName = toInterfaceName(table);
interfaces.push({ interfaceName, table });
lines.push(`export interface ${interfaceName} {`);
const columns = db
.prepare(`PRAGMA table_xinfo(${quoteSqliteIdentifier(table)});`)
.all()
.filter((column) => Number(column.hidden) === 0)
.toSorted((left, right) => String(left.name).localeCompare(String(right.name), "en"));
const primaryKeyColumnCount = columns.filter((column) => Number(column.pk) > 0).length;
for (const column of columns) {
lines.push(` ${String(column.name)}: ${columnType(column, primaryKeyColumnCount)};`);
}
lines.push("}", "");
}
lines.push("export interface DB {");
for (const { interfaceName, table } of interfaces) {
lines.push(` ${table}: ${interfaceName};`);
}
lines.push("}", "");
return lines.join("\n");
}
async function generateKyselyTypes(schemaSource: string): Promise<string> {
const { DatabaseSync } = await import("node:sqlite");
const db = new DatabaseSync(":memory:");
try {
db.exec(schemaSource);
return generateTypes(db);
} finally {
db.close();
}
}
export async function ensureKyselyTypes(
cwd = REPO_ROOT,
verify = false,
options: { allowPartialCheckout?: boolean } = {},
): Promise<void> {
const outputDir = path.join(cwd, ".artifacts/kysely");
const stampFile = path.join(outputDir, "inputs.sha256");
const inputs = SCHEMAS.map((name) => ({
name,
input: path.join(cwd, "src/state", `${name}-schema.sql`),
output: path.join(outputDir, `${name}-db.generated.ts`),
}));
const available = inputs.filter((schema) => fs.existsSync(schema.input));
const partial = !verify && options.allowPartialCheckout === true;
if (partial) {
// Sparse lint must not consume a previous projection whose source is now omitted.
for (const schema of inputs.filter((input) => !available.includes(input))) {
fs.rmSync(schema.output, { force: true });
}
}
// Packaged installs and dependency-only Docker stages have no source schemas.
if (!verify && available.length === 0) {
if (partial) {
fs.rmSync(stampFile, { force: true });
}
return;
}
const schemas = (partial ? available : inputs).map((schema) => ({
name: schema.name,
source: fs.readFileSync(schema.input, "utf8"),
output: schema.output,
}));
const fingerprint = () => {
const hash = createHash("sha256").update(fs.readFileSync(fileURLToPath(import.meta.url)));
for (const schema of schemas) {
hash.update(schema.name).update("\0").update(schema.source).update("\0");
hash.update(fs.existsSync(schema.output) ? fs.readFileSync(schema.output) : "missing");
}
return hash.digest("hex");
};
if (!verify && fs.existsSync(stampFile) && fs.readFileSync(stampFile, "utf8") === fingerprint()) {
return;
}
for (const schema of schemas) {
const source = await generateKyselyTypes(schema.source);
const existing = fs.existsSync(schema.output)
? fs.readFileSync(schema.output, "utf8")
: undefined;
if (verify) {
if (existing !== source) {
throw new Error(`${schema.output} is out of date. Run pnpm db:kysely:gen.`);
}
} else if (existing !== source) {
fs.mkdirSync(outputDir, { recursive: true });
const temporary = `${schema.output}.${process.pid}.tmp`;
fs.writeFileSync(temporary, source);
fs.renameSync(temporary, schema.output);
}
}
if (!verify) {
fs.writeFileSync(stampFile, fingerprint());
}
}
if (isDirectRunUrl(process.argv[1], import.meta.url)) {
await ensureKyselyTypes(
REPO_ROOT,
process.argv.includes("--verify") || process.argv.includes("--check"),
);
}