From 14fc291d3c3bcaf0b5f51d67d0c15365ab402dd9 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Mon, 15 Sep 2025 17:50:28 +0900 Subject: [PATCH 1/9] Add SQLite dialect and file-based provider --- packages/cli/src/config/loader.ts | 5 +- packages/cli/src/dev/providers/file.ts | 153 +++++++++++++ packages/cli/src/dialect/sqlite.ts | 288 +++++++++++++++++++++++++ 3 files changed, 445 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/dev/providers/file.ts create mode 100644 packages/cli/src/dialect/sqlite.ts diff --git a/packages/cli/src/config/loader.ts b/packages/cli/src/config/loader.ts index aaca5d25..70e50a53 100644 --- a/packages/cli/src/config/loader.ts +++ b/packages/cli/src/config/loader.ts @@ -7,6 +7,7 @@ import { foreignKeyConstraintSchema, } from "../operations/shared/types"; import { buildContainerDevDatabaseConfigSchema } from "../dev/providers/container"; +import { buildFileDevDatabaseConfig } from "../dev/providers/file"; const columnSchema = z.object({ type: z.string(), @@ -29,7 +30,7 @@ const indexSchema = z.object({ }); export type IndexSchema = z.infer; -const dialectEnum = z.enum(["postgres", "cockroachdb"]); +const dialectEnum = z.enum(["postgres", "cockroachdb", "sqlite"]); export type DialectEnum = z.infer; const databaseSchema = z.object({ @@ -41,6 +42,8 @@ export type DatabaseValue = z.infer; const devDatabaseSchema = z.union([ // Container-based configuration buildContainerDevDatabaseConfigSchema({ defaultImage: "" }), + // File-based configuration + buildFileDevDatabaseConfig(), ]); export type DevDatabaseValue = z.infer; diff --git a/packages/cli/src/dev/providers/file.ts b/packages/cli/src/dev/providers/file.ts new file mode 100644 index 00000000..8822858f --- /dev/null +++ b/packages/cli/src/dev/providers/file.ts @@ -0,0 +1,153 @@ +import { + DevDatabaseProvider, + DevDatabaseInstance, + DevDatabaseManageType, + DevDatabaseStatus, +} from "../types"; +import * as fs from "fs/promises"; +import * as path from "path"; +import * as os from "os"; +import z from "zod"; + +export const buildFileDevDatabaseConfig = () => + z.object({ + file: z.object({ + name: z.string().optional(), + }), + }); +export type FileDevDatabaseConfig = z.infer< + ReturnType +>; + +/** + * File-based dev database provider + * + * Provides file-based or memory-based development database environments. + * Used by SQLite dialect. + */ +export class FileDevDatabaseProvider implements DevDatabaseProvider { + async setup( + config: FileDevDatabaseConfig, + manageType: DevDatabaseManageType + ): Promise { + return new FileDevDatabaseInstance({ + manageType: manageType, + name: config.file.name, + }); + } + + async hasExisting(manageType: DevDatabaseManageType): Promise { + if (manageType === "dev-start") { + // dev-startの固定パスファイルが存在するかチェック + const devStartPath = this.getDevStartPath(); + try { + await fs.access(devStartPath); + return true; + } catch { + return false; + } + } + return false; // one-offは常に新規作成(memory) + } + + private getDevStartPath(name?: string): string { + const basename = name || "default"; + return path.join(os.tmpdir(), `kyrage___dev-${basename}.db`); + } + + async cleanup(): Promise { + // Clean up temporary kyrage database files + try { + const tempDir = os.tmpdir(); + const files = await fs.readdir(tempDir); + const kyrageFiles = files.filter((f) => f.startsWith("kyrage___dev-")); + + await Promise.allSettled( + kyrageFiles.map((f) => fs.unlink(path.join(tempDir, f))) + ); + } catch { + // Ignore cleanup errors + } + } +} + +/** + * File-based dev database instance + * + * Manages the lifecycle of a SQLite database file or memory database. + */ +class FileDevDatabaseInstance implements DevDatabaseInstance { + private connectionString: string | null = null; + private filePath: string | null = null; + + constructor( + private options: { + manageType: DevDatabaseManageType; + name?: string; + } + ) {} + + async start(): Promise { + if (this.options.manageType === "dev-start") { + // dev-start: 固定名の再利用可能ファイル(tmpdir内) + this.filePath = this.getDevStartPath(); + this.connectionString = this.filePath; + } else { + // one-off: メモリベース(高速、自動削除) + this.connectionString = ":memory:"; + } + } + + async stop(): Promise { + // SQLite doesn't require explicit stopping + // Connection will be closed when database client is disposed + } + + async remove(): Promise { + if (this.filePath) { + try { + await fs.unlink(this.filePath); + } catch { + // Ignore file removal errors + } + } + this.connectionString = null; + this.filePath = null; + } + + getConnectionString(): string { + if (!this.connectionString) { + throw new Error("File database is not started"); + } + return this.connectionString; + } + + async getStatus(): Promise { + if (!this.connectionString) { + return { type: "unavailable" }; + } + + if (this.connectionString === ":memory:") { + return { + type: "file", + filePath: ":memory:", + mode: "memory", + }; + } + + return { + type: "file", + filePath: this.connectionString, + mode: "file", + }; + } + + isAvailable(): boolean { + return !!this.connectionString; + } + + private getDevStartPath(): string { + const basename = this.options.name || "default"; + return path.join(os.tmpdir(), `kyrage___dev-${basename}.db`); + } +} diff --git a/packages/cli/src/dialect/sqlite.ts b/packages/cli/src/dialect/sqlite.ts new file mode 100644 index 00000000..2409340f --- /dev/null +++ b/packages/cli/src/dialect/sqlite.ts @@ -0,0 +1,288 @@ +import { SqliteDialect, sql } from "kysely"; +import Database from "better-sqlite3"; +import { IntrospectProps, KyrageDialect } from "./types"; +import { DBClient, PlannableKysely } from "../client"; +import { ReferentialActions } from "../operation"; +import { computeAutoGeneratedIndexesAndConstraints } from "./shared"; +import { + buildFileDevDatabaseConfig, + FileDevDatabaseProvider, +} from "../dev/providers/file"; + +export class SQLiteKyrageDialect implements KyrageDialect { + getName() { + return "sqlite" as const; + } + + createKyselyDialect(connectionString: string) { + return new SqliteDialect({ + database: new Database(connectionString), + }); + } + + createIntrospectionDriver(client: DBClient) { + return { + convertTypeName: convertSQLiteTypeName, + introspect: doSQLiteIntrospect(client), + }; + } + + createDevDatabaseProvider() { + return new FileDevDatabaseProvider(); + } + + parseDevDatabaseConfig(config: unknown) { + // SQLite supports file-based dev databases only + return buildFileDevDatabaseConfig().parse(config); + } + + async hasReusableDevDatabase(): Promise { + // File-based dialects don't support reusable dev databases + return false; + } +} + +export const doSQLiteIntrospect = + (client: DBClient) => async (props: IntrospectProps) => { + await using db = client.getDB(); + const [tables, indexes, constraints] = await Promise.all([ + introspectSQLiteTables(db), + introspectSQLiteIndexes(db), + introspectSQLiteConstraints(db), + ]); + const { + indexes: normalizedIndexes, + uniqueConstraints: normalizedUniqueConstraints, + } = computeAutoGeneratedIndexesAndConstraints(props.config, { + indexes, + constraints, + }); + + return { + tables, + indexes: normalizedIndexes, + constraints: { + ...constraints, + unique: normalizedUniqueConstraints, + }, + }; + }; + +/* + * Convert SQLite type names to more general SQL type names. + */ +export const convertSQLiteTypeName = (typeName: string) => { + const nameDict = { + INTEGER: "integer", + TEXT: "text", + REAL: "real", + BLOB: "blob", + NUMERIC: "numeric", + }; + + return ( + nameDict[typeName.toUpperCase() as keyof typeof nameDict] ?? + typeName.toLowerCase() + ); +}; + +export const introspectSQLiteTables = async (db: PlannableKysely) => { + // Get list of tables excluding sqlite system tables + const { rows: tables } = await sql` + SELECT name FROM sqlite_master + WHERE type = 'table' + AND name NOT LIKE 'sqlite_%' + AND name != 'kysely_migration' + AND name != 'kysely_migration_lock' + ` + .$castTo<{ name: string }>() + .execute(db); + + const tableInfos = []; + + for (const table of tables) { + // Get column information for each table using PRAGMA table_info + const { rows: columns } = await sql` + SELECT * FROM pragma_table_info(${table.name}) + ` + .$castTo() + .execute(db); + + tableInfos.push( + ...columns.map((col) => ({ + schema: "main", // SQLite default schema + table: table.name, + name: col.name, + default: col.dflt_value, + characterMaximumLength: null, // SQLite doesn't enforce string length limits + })) + ); + } + + return tableInfos; +}; + +type SQLiteColumnInfo = { + cid: number; + name: string; + type: string; + notnull: number; + dflt_value: string | null; + pk: number; +}; + +export const introspectSQLiteIndexes = async (db: PlannableKysely) => { + const { rows: tables } = await sql` + SELECT name FROM sqlite_master + WHERE type = 'table' + AND name NOT LIKE 'sqlite_%' + AND name != 'kysely_migration' + AND name != 'kysely_migration_lock' + ` + .$castTo<{ name: string }>() + .execute(db); + + const indexes = []; + + for (const table of tables) { + const { rows: indexList } = await sql` + SELECT * FROM pragma_index_list(${table.name}) + ` + .$castTo() + .execute(db); + + for (const index of indexList) { + // Only include indexes created by CREATE INDEX (origin 'c'), not auto-generated ones + if (index.origin === "c") { + const { rows: indexInfo } = await sql` + SELECT * FROM pragma_index_info(${index.name}) + ` + .$castTo() + .execute(db); + + indexes.push({ + table: table.name, + name: index.name, + columns: indexInfo.map((info) => info.name), + unique: index.unique === 1, + }); + } + } + } + + return indexes; +}; + +type SQLiteIndexList = { + seq: number; + name: string; + unique: number; + origin: string; + partial: number; +}; + +type SQLiteIndexInfo = { + seqno: number; + cid: number; + name: string; +}; + +export const introspectSQLiteConstraints = async (db: PlannableKysely) => { + const { rows: tables } = await sql` + SELECT name FROM sqlite_master + WHERE type = 'table' + AND name NOT LIKE 'sqlite_%' + AND name != 'kysely_migration' + AND name != 'kysely_migration_lock' + ` + .$castTo<{ name: string }>() + .execute(db); + + const primaryKeys = []; + const uniqueConstraints: ReadonlyArray = []; + const foreignKeys: any[] = []; + + for (const table of tables) { + // Get primary key information + const { rows: tableInfo } = await sql` + SELECT * FROM pragma_table_info(${table.name}) + ` + .$castTo() + .execute(db); + + const pkColumns = tableInfo + .filter((col) => col.pk > 0) + .sort((a, b) => a.pk - b.pk) + .map((col) => col.name); + + if (pkColumns.length > 0) { + primaryKeys.push({ + schema: "main", + table: table.name, + name: `${table.name}_primary_key`, + type: "PRIMARY KEY" as const, + columns: pkColumns, + }); + } + + // Get foreign key information + const { rows: fkList } = await sql` + SELECT * FROM pragma_foreign_key_list(${table.name}) + ` + .$castTo() + .execute(db); + + // Group foreign keys by id + const fkGroups = fkList.reduce( + (acc, fk) => { + if (!acc[fk.id]) acc[fk.id] = []; + acc[fk.id].push(fk); + return acc; + }, + {} as Record + ); + + Object.values(fkGroups).forEach((group) => { + foreignKeys.push({ + schema: "main", + table: table.name, + name: `fk_${table.name}_${group[0].table}`, + type: "FOREIGN KEY" as const, + columns: group.map((fk) => fk.from), + referencedTable: group[0].table, + referencedColumns: group.map((fk) => fk.to), + onDelete: mapSQLiteAction(group[0].on_delete), + onUpdate: mapSQLiteAction(group[0].on_update), + }); + }); + } + + return { + primaryKey: primaryKeys, + unique: uniqueConstraints, + foreignKey: foreignKeys, + }; +}; + +type SQLiteForeignKey = { + id: number; + seq: number; + table: string; + from: string; + to: string; + on_update: string; + on_delete: string; + match: string; +}; + +const mapSQLiteAction = (action: string): ReferentialActions | undefined => { + const actionMap: Record = { + CASCADE: "cascade", + "SET NULL": "set null", + "SET DEFAULT": "set default", + RESTRICT: "restrict", + "NO ACTION": "no action", + }; + + return actionMap[action.toUpperCase()]; +}; From 94df8289522ef46a0c3d96f064fd77f0a7f70906 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Mon, 15 Sep 2025 17:52:03 +0900 Subject: [PATCH 2/9] Add sqlite key in dialects --- packages/cli/src/dialect/factory.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cli/src/dialect/factory.ts b/packages/cli/src/dialect/factory.ts index acc92df5..0204bc52 100644 --- a/packages/cli/src/dialect/factory.ts +++ b/packages/cli/src/dialect/factory.ts @@ -2,10 +2,12 @@ import { KyrageDialect } from "./types"; import { PostgresKyrageDialect } from "./postgres"; import { CockroachDBKyrageDialect } from "./cockroachdb"; import { DialectEnum } from "../config/loader"; +import { SQLiteKyrageDialect } from "./sqlite"; const dialects = { postgres: new PostgresKyrageDialect(), cockroachdb: new CockroachDBKyrageDialect(), + sqlite: new SQLiteKyrageDialect(), } as const; export const getDialect = (dialectName: DialectEnum) => { From 8dffa24bd1f828e48bb951a45af52a077168bc46 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Wed, 17 Sep 2025 14:34:49 +0900 Subject: [PATCH 3/9] Add sqlite in CI matrix --- .github/workflows/test.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 5549b3dd..24764a03 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -32,6 +32,7 @@ jobs: dialect: - postgres - cockroachdb + - sqlite steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 From 068741c9b5c964c26c3aa14c3c1bb9c3f2b7ce5a Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Wed, 17 Sep 2025 14:38:41 +0900 Subject: [PATCH 4/9] Fix SQLite tests --- packages/cli/tests/helper.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/cli/tests/helper.ts b/packages/cli/tests/helper.ts index 65ca1473..1d466cdd 100644 --- a/packages/cli/tests/helper.ts +++ b/packages/cli/tests/helper.ts @@ -29,6 +29,12 @@ const getConfigForTest = (kyrageDialect: KyrageDialect) => { image: "cockroachdb/cockroach:latest-v24.3", }, }; + case "sqlite": + return { + file: { + name: "test", + }, + }; default: throw new Error("unsupported dialect specified"); } From 8bcc9a116842b6b850553e2718a29d1b61cb7bf9 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Wed, 17 Sep 2025 15:59:41 +0900 Subject: [PATCH 5/9] Fix configuration Zod schema fallback --- packages/cli/src/dev/providers/container.ts | 12 ++++++++---- packages/cli/src/dev/providers/file.ts | 17 +++++++++++------ 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/dev/providers/container.ts b/packages/cli/src/dev/providers/container.ts index ea888164..0ce6c037 100644 --- a/packages/cli/src/dev/providers/container.ts +++ b/packages/cli/src/dev/providers/container.ts @@ -16,10 +16,14 @@ export const buildContainerDevDatabaseConfigSchema = (options: { defaultImage: string; }) => z.object({ - container: z.object({ - image: z.string().default(options.defaultImage), - name: z.string().optional(), - }), + container: z + .object({ + image: z.string().default(options.defaultImage), + name: z.string().optional(), + }) + .default({ + image: options.defaultImage, + }), }); export type ContainerDevDatabaseConfig = z.infer< ReturnType diff --git a/packages/cli/src/dev/providers/file.ts b/packages/cli/src/dev/providers/file.ts index 8822858f..be274242 100644 --- a/packages/cli/src/dev/providers/file.ts +++ b/packages/cli/src/dev/providers/file.ts @@ -9,11 +9,16 @@ import * as path from "path"; import * as os from "os"; import z from "zod"; +const defaultName = "default"; export const buildFileDevDatabaseConfig = () => z.object({ - file: z.object({ - name: z.string().optional(), - }), + file: z + .object({ + name: z.string().default(defaultName), + }) + .default({ + name: defaultName, + }), }); export type FileDevDatabaseConfig = z.infer< ReturnType @@ -32,7 +37,7 @@ export class FileDevDatabaseProvider implements DevDatabaseProvider { ): Promise { return new FileDevDatabaseInstance({ manageType: manageType, - name: config.file.name, + name: config.file?.name, }); } @@ -51,7 +56,7 @@ export class FileDevDatabaseProvider implements DevDatabaseProvider { } private getDevStartPath(name?: string): string { - const basename = name || "default"; + const basename = name || defaultName; return path.join(os.tmpdir(), `kyrage___dev-${basename}.db`); } @@ -147,7 +152,7 @@ class FileDevDatabaseInstance implements DevDatabaseInstance { } private getDevStartPath(): string { - const basename = this.options.name || "default"; + const basename = this.options.name || defaultName; return path.join(os.tmpdir(), `kyrage___dev-${basename}.db`); } } From 3945f47aedd380f879a1ca31a7208420cd8dd90e Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Wed, 17 Sep 2025 16:01:25 +0900 Subject: [PATCH 6/9] Use empty for SQLite testing configuration --- packages/cli/tests/helper.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/cli/tests/helper.ts b/packages/cli/tests/helper.ts index 1d466cdd..1af19a3a 100644 --- a/packages/cli/tests/helper.ts +++ b/packages/cli/tests/helper.ts @@ -30,11 +30,7 @@ const getConfigForTest = (kyrageDialect: KyrageDialect) => { }, }; case "sqlite": - return { - file: { - name: "test", - }, - }; + return {}; default: throw new Error("unsupported dialect specified"); } From 6462a7d92b4320c6aba9a1520759eda7767b826e Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Wed, 17 Sep 2025 22:14:38 +0900 Subject: [PATCH 7/9] Fix type error --- packages/cli/src/dev/providers/container.ts | 4 ++-- packages/cli/src/dev/providers/file.ts | 11 ++++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/dev/providers/container.ts b/packages/cli/src/dev/providers/container.ts index 0ce6c037..fc0befe1 100644 --- a/packages/cli/src/dev/providers/container.ts +++ b/packages/cli/src/dev/providers/container.ts @@ -154,13 +154,13 @@ class ContainerDevDatabaseInstance implements DevDatabaseInstance { if (runningContainer) { const r = await runningContainer.inspect(); return { - type: "container", + type: "container" as const, imageName: r.Config.Image, containerID: r.Id, }; } - return { type: "unavailable" }; + return { type: "unavailable" as const }; } async isAvailable(): Promise { diff --git a/packages/cli/src/dev/providers/file.ts b/packages/cli/src/dev/providers/file.ts index be274242..53727ee6 100644 --- a/packages/cli/src/dev/providers/file.ts +++ b/packages/cli/src/dev/providers/file.ts @@ -129,26 +129,27 @@ class FileDevDatabaseInstance implements DevDatabaseInstance { async getStatus(): Promise { if (!this.connectionString) { - return { type: "unavailable" }; + return { type: "unavailable" as const }; } if (this.connectionString === ":memory:") { return { - type: "file", + type: "file" as const, filePath: ":memory:", mode: "memory", }; } return { - type: "file", + type: "file" as const, filePath: this.connectionString, mode: "file", }; } - isAvailable(): boolean { - return !!this.connectionString; + async isAvailable(): Promise { + const status = await this.getStatus(); + return status.type === "file"; } private getDevStartPath(): string { From d69a4786c5e162f4d3a27a3140bdac8e88211bb4 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Fri, 26 Sep 2025 00:02:56 +0900 Subject: [PATCH 8/9] Fix import --- packages/cli/src/dialect/sqlite.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/dialect/sqlite.ts b/packages/cli/src/dialect/sqlite.ts index 2409340f..3e23c374 100644 --- a/packages/cli/src/dialect/sqlite.ts +++ b/packages/cli/src/dialect/sqlite.ts @@ -2,12 +2,12 @@ import { SqliteDialect, sql } from "kysely"; import Database from "better-sqlite3"; import { IntrospectProps, KyrageDialect } from "./types"; import { DBClient, PlannableKysely } from "../client"; -import { ReferentialActions } from "../operation"; import { computeAutoGeneratedIndexesAndConstraints } from "./shared"; import { buildFileDevDatabaseConfig, FileDevDatabaseProvider, } from "../dev/providers/file"; +import { ReferentialActions } from "../operations/shared/types"; export class SQLiteKyrageDialect implements KyrageDialect { getName() { From 1f23f54ddbb48591bb58e4c7ed9fb7cd97cf893c Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Sat, 4 Oct 2025 16:32:22 +0900 Subject: [PATCH 9/9] Split preparation SQL --- packages/cli/tests/regenerate.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/cli/tests/regenerate.test.ts b/packages/cli/tests/regenerate.test.ts index 963ddc9a..487441ac 100644 --- a/packages/cli/tests/regenerate.test.ts +++ b/packages/cli/tests/regenerate.test.ts @@ -18,8 +18,11 @@ beforeAll(async () => { name TEXT NOT NULL, email TEXT NOT NULL CONSTRAINT members_email_unique UNIQUE ); + `.execute(db); + await sql` CREATE UNIQUE INDEX "idx_members_name_email" ON "members" ("name", "email"); - + `.execute(db); + await sql` CREATE TABLE orders ( customer_id UUID NOT NULL, product_id UUID NOT NULL,