kysely-schema 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,150 @@
1
+ import type {
2
+ SchemaDefinition,
3
+ DiffOperation,
4
+ ColumnDefinition,
5
+ } from '../schema/types.js';
6
+
7
+ /**
8
+ * Compares two schema snapshots and returns a list of diff operations
9
+ * needed to migrate from `previous` to `current`.
10
+ */
11
+ export class SchemaDiffer {
12
+ diff(previous: SchemaDefinition, current: SchemaDefinition): DiffOperation[] {
13
+ const ops: DiffOperation[] = [];
14
+
15
+ const prevTables = new Set(Object.keys(previous.tables));
16
+ const currTables = new Set(Object.keys(current.tables));
17
+
18
+ // ── Added tables ──
19
+ for (const tableName of currTables) {
20
+ if (!prevTables.has(tableName)) {
21
+ ops.push({
22
+ type: 'addTable',
23
+ tableName,
24
+ table: current.tables[tableName],
25
+ description: `Create table '${tableName}'`,
26
+ });
27
+ }
28
+ }
29
+
30
+ // ── Dropped tables ──
31
+ for (const tableName of prevTables) {
32
+ if (!currTables.has(tableName)) {
33
+ ops.push({
34
+ type: 'dropTable',
35
+ tableName,
36
+ description: `Drop table '${tableName}'`,
37
+ });
38
+ }
39
+ }
40
+
41
+ // ── Changed tables (columns) ──
42
+ for (const tableName of currTables) {
43
+ if (!prevTables.has(tableName)) continue; // already handled as addTable
44
+
45
+ const prevCols = previous.tables[tableName].columns;
46
+ const currCols = current.tables[tableName].columns;
47
+ const prevColNames = new Set(Object.keys(prevCols));
48
+ const currColNames = new Set(Object.keys(currCols));
49
+
50
+ // Added columns
51
+ for (const colName of currColNames) {
52
+ if (!prevColNames.has(colName)) {
53
+ ops.push({
54
+ type: 'addColumn',
55
+ tableName,
56
+ columnName: colName,
57
+ column: currCols[colName],
58
+ description: `Add column '${colName}' to table '${tableName}'`,
59
+ });
60
+ }
61
+ }
62
+
63
+ // Dropped columns
64
+ for (const colName of prevColNames) {
65
+ if (!currColNames.has(colName)) {
66
+ ops.push({
67
+ type: 'dropColumn',
68
+ tableName,
69
+ columnName: colName,
70
+ description: `Drop column '${colName}' from table '${tableName}'`,
71
+ });
72
+ }
73
+ }
74
+
75
+ // Altered columns
76
+ for (const colName of currColNames) {
77
+ if (!prevColNames.has(colName)) continue;
78
+ if (!this.columnsEqual(prevCols[colName], currCols[colName])) {
79
+ ops.push({
80
+ type: 'alterColumn',
81
+ tableName,
82
+ columnName: colName,
83
+ oldColumn: prevCols[colName],
84
+ newColumn: currCols[colName],
85
+ description: `Alter column '${colName}' in table '${tableName}'`,
86
+ });
87
+ }
88
+ }
89
+ }
90
+
91
+ return ops;
92
+ }
93
+
94
+ /**
95
+ * Generate an ALTER-TABLE migration string from a list of diff operations.
96
+ */
97
+ generateAlterMigration(ops: DiffOperation[]): { up: string; down: string } {
98
+ const upLines: string[] = [];
99
+ const downLines: string[] = [];
100
+
101
+ for (const op of ops) {
102
+ switch (op.type) {
103
+ case 'addTable': {
104
+ upLines.push(` await db.schema`);
105
+ upLines.push(` .createTable('${op.tableName}')`);
106
+ for (const [colName, colDef] of Object.entries(op.table.columns)) {
107
+ upLines.push(` .addColumn('${colName}', '${colDef.type}')`);
108
+ }
109
+ upLines.push(` .execute();`);
110
+ downLines.push(` await db.schema.dropTable('${op.tableName}').ifExists().execute();`);
111
+ break;
112
+ }
113
+ case 'dropTable': {
114
+ upLines.push(` await db.schema.dropTable('${op.tableName}').ifExists().execute();`);
115
+ // down: cannot fully reverse without the old definition
116
+ downLines.push(` // TODO: Recreate table '${op.tableName}'`);
117
+ break;
118
+ }
119
+ case 'addColumn': {
120
+ upLines.push(` await db.schema.alterTable('${op.tableName}').addColumn('${op.columnName}', '${op.column.type}').execute();`);
121
+ downLines.push(` await db.schema.alterTable('${op.tableName}').dropColumn('${op.columnName}').execute();`);
122
+ break;
123
+ }
124
+ case 'dropColumn': {
125
+ upLines.push(` await db.schema.alterTable('${op.tableName}').dropColumn('${op.columnName}').execute();`);
126
+ downLines.push(` // TODO: Re-add column '${op.columnName}' to '${op.tableName}'`);
127
+ break;
128
+ }
129
+ case 'alterColumn': {
130
+ upLines.push(` await db.schema.alterTable('${op.tableName}').alterColumn('${op.columnName}', (col) => col.setDataType('${op.newColumn.type}')).execute();`);
131
+ downLines.push(` await db.schema.alterTable('${op.tableName}').alterColumn('${op.columnName}', (col) => col.setDataType('${op.oldColumn.type}')).execute();`);
132
+ break;
133
+ }
134
+ default:
135
+ break;
136
+ }
137
+ }
138
+
139
+ return {
140
+ up: upLines.join('\n'),
141
+ down: downLines.join('\n'),
142
+ };
143
+ }
144
+
145
+ // ── Private helpers ──────────────────────────────────────────────────
146
+
147
+ private columnsEqual(a: ColumnDefinition, b: ColumnDefinition): boolean {
148
+ return JSON.stringify(a) === JSON.stringify(b);
149
+ }
150
+ }
@@ -0,0 +1,33 @@
1
+ import type { DiffOperation } from '../schema/types.js';
2
+
3
+ // Re-export the operation types for convenience
4
+ export type {
5
+ DiffOperation,
6
+ AddTableOperation,
7
+ DropTableOperation,
8
+ AddColumnOperation,
9
+ DropColumnOperation,
10
+ AlterColumnOperation,
11
+ AddIndexOperation,
12
+ DropIndexOperation,
13
+ } from '../schema/types.js';
14
+
15
+ /**
16
+ * Labels used when printing diffs to the console.
17
+ */
18
+ export const operationLabels: Record<DiffOperation['type'], string> = {
19
+ addTable: '+ ADD TABLE',
20
+ dropTable: '- DROP TABLE',
21
+ addColumn: '+ ADD COLUMN',
22
+ dropColumn: '- DROP COLUMN',
23
+ alterColumn: '~ ALTER COLUMN',
24
+ addIndex: '+ ADD INDEX',
25
+ dropIndex: '- DROP INDEX',
26
+ };
27
+
28
+ /**
29
+ * Return a human-readable summary for a diff operation.
30
+ */
31
+ export function describeOperation(op: DiffOperation): string {
32
+ return `${operationLabels[op.type]}: ${op.description}`;
33
+ }
@@ -0,0 +1,172 @@
1
+ import type {
2
+ SchemaDefinition,
3
+ MigrationFile,
4
+ ColumnDefinition,
5
+ TableDefinition,
6
+ } from '../schema/types.js';
7
+
8
+ /**
9
+ * Generates Kysely migration files from a SchemaDefinition.
10
+ */
11
+ export class MigrationGenerator {
12
+ /**
13
+ * Generate a full CREATE-TABLE migration for the entire schema.
14
+ */
15
+ generate(schema: SchemaDefinition, migrationName: string): MigrationFile {
16
+ const timestamp = this.generateTimestamp();
17
+ const filename = `${timestamp}_${this.sanitize(migrationName)}.ts`;
18
+
19
+ const upCode = this.generateUpFunction(schema);
20
+ const downCode = this.generateDownFunction(schema);
21
+
22
+ const content = [
23
+ `import { Kysely, sql } from 'kysely';`,
24
+ ``,
25
+ `export async function up(db: Kysely<any>): Promise<void> {`,
26
+ upCode,
27
+ `}`,
28
+ ``,
29
+ `export async function down(db: Kysely<any>): Promise<void> {`,
30
+ downCode,
31
+ `}`,
32
+ ``,
33
+ ].join('\n');
34
+
35
+ return { filename, content };
36
+ }
37
+
38
+ // ── Private helpers ──────────────────────────────────────────────────────
39
+
40
+ private generateUpFunction(schema: SchemaDefinition): string {
41
+ const parts: string[] = [];
42
+
43
+ for (const [tableName, tableDef] of Object.entries(schema.tables)) {
44
+ parts.push(this.generateCreateTable(tableName, tableDef));
45
+ const indexes = this.generateIndexes(tableName, tableDef);
46
+ if (indexes) parts.push(indexes);
47
+ }
48
+
49
+ return parts.join('\n');
50
+ }
51
+
52
+ private generateCreateTable(name: string, def: TableDefinition): string {
53
+ const lines: string[] = [];
54
+ lines.push(` await db.schema`);
55
+ lines.push(` .createTable('${name}')`);
56
+
57
+ for (const [colName, colDef] of Object.entries(def.columns)) {
58
+ lines.push(this.generateColumnDefinition(colName, colDef));
59
+ }
60
+
61
+ lines.push(` .execute();`);
62
+ return lines.join('\n');
63
+ }
64
+
65
+ private generateColumnDefinition(name: string, def: ColumnDefinition): string {
66
+ const type = this.mapColumnType(def);
67
+ const modifiers = this.buildModifiers(def);
68
+
69
+ if (modifiers.length > 0) {
70
+ return ` .addColumn('${name}', '${type}', (col) => col.${modifiers.join('.')})`;
71
+ }
72
+ return ` .addColumn('${name}', '${type}')`;
73
+ }
74
+
75
+ private buildModifiers(def: ColumnDefinition): string[] {
76
+ const mods: string[] = [];
77
+
78
+ if (def.primaryKey) mods.push('primaryKey()');
79
+ if (def.notNull) mods.push('notNull()');
80
+ if (def.unique) mods.push('unique()');
81
+
82
+ if (def.default !== undefined) {
83
+ mods.push(`defaultTo(${this.formatDefaultValue(def.default, def.type)})`);
84
+ }
85
+
86
+ if (def.references) {
87
+ mods.push(`references('${def.references.table}.${def.references.column}')`);
88
+ if (def.onDelete) mods.push(`onDelete('${def.onDelete}')`);
89
+ if (def.onUpdate) mods.push(`onUpdate('${def.onUpdate}')`);
90
+ }
91
+
92
+ if (def.check) {
93
+ mods.push(`check(sql\`${def.check}\`)`);
94
+ }
95
+
96
+ return mods;
97
+ }
98
+
99
+ private generateDownFunction(schema: SchemaDefinition): string {
100
+ const tables = Object.keys(schema.tables).reverse();
101
+ return tables
102
+ .map((t) => ` await db.schema.dropTable('${t}').ifExists().execute();`)
103
+ .join('\n');
104
+ }
105
+
106
+ private generateIndexes(tableName: string, tableDef: TableDefinition): string {
107
+ const lines: string[] = [];
108
+
109
+ // Auto-generate indexes for indexed columns and foreign keys
110
+ for (const [colName, colDef] of Object.entries(tableDef.columns)) {
111
+ if (colDef.index || colDef.references) {
112
+ lines.push(` await db.schema`);
113
+ lines.push(` .createIndex('${tableName}_${colName}_index')`);
114
+ lines.push(` .on('${tableName}')`);
115
+ lines.push(` .column('${colName}')`);
116
+ lines.push(` .execute();`);
117
+ }
118
+ }
119
+
120
+ // Explicit indexes
121
+ for (const idx of tableDef.indexes) {
122
+ const idxName = idx.name || `${tableName}_${idx.columns.join('_')}_index`;
123
+ lines.push(` await db.schema`);
124
+ if (idx.unique) {
125
+ lines.push(` .createIndex('${idxName}')`);
126
+ lines.push(` .on('${tableName}')`);
127
+ lines.push(` .columns([${idx.columns.map((c) => `'${c}'`).join(', ')}])`);
128
+ lines.push(` .unique()`);
129
+ } else {
130
+ lines.push(` .createIndex('${idxName}')`);
131
+ lines.push(` .on('${tableName}')`);
132
+ lines.push(` .columns([${idx.columns.map((c) => `'${c}'`).join(', ')}])`);
133
+ }
134
+ lines.push(` .execute();`);
135
+ }
136
+
137
+ return lines.length > 0 ? lines.join('\n') : '';
138
+ }
139
+
140
+ private mapColumnType(def: ColumnDefinition): string {
141
+ switch (def.type) {
142
+ case 'varchar':
143
+ return def.length ? `varchar(${def.length})` : 'varchar';
144
+ case 'decimal':
145
+ if (def.precision !== undefined && def.scale !== undefined) {
146
+ return `decimal(${def.precision}, ${def.scale})`;
147
+ }
148
+ return 'decimal';
149
+ default:
150
+ return def.type;
151
+ }
152
+ }
153
+
154
+ private formatDefaultValue(value: string | number | boolean, _type: string): string {
155
+ if (typeof value === 'string' && value === 'now()') return 'sql`now()`';
156
+ if (typeof value === 'string') return `'${value}'`;
157
+ if (typeof value === 'boolean') return value.toString();
158
+ return String(value);
159
+ }
160
+
161
+ private generateTimestamp(): string {
162
+ const now = new Date();
163
+ return now
164
+ .toISOString()
165
+ .replace(/[-:]/g, '')
166
+ .split('.')[0];
167
+ }
168
+
169
+ private sanitize(name: string): string {
170
+ return name.replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase();
171
+ }
172
+ }
@@ -0,0 +1,33 @@
1
+ // ─── Code templates used by the CLI ────────────────────────────────────────
2
+
3
+ /**
4
+ * Default schema template written by `kysely-schema init`.
5
+ */
6
+ export const schemaTemplate = `import { defineSchema, table, column } from 'kysely-schema';
7
+
8
+ export default defineSchema({
9
+ // Define your tables here. Example:
10
+ //
11
+ // user: table({
12
+ // id: column.serial().primaryKey(),
13
+ // email: column.text().notNull().unique(),
14
+ // name: column.text().nullable(),
15
+ // createdAt: column.timestamp().default('now()').notNull(),
16
+ // }),
17
+ });
18
+ `;
19
+
20
+ /**
21
+ * Default config file template.
22
+ */
23
+ export const configTemplate = `import type { KyselySchemaConfig } from 'kysely-schema';
24
+
25
+ const config: KyselySchemaConfig = {
26
+ schemaPath: './schema/index.ts',
27
+ migrationsDir: './migrations',
28
+ generatedDir: './generated',
29
+ snapshotDir: './.kysely-schema',
30
+ };
31
+
32
+ export default config;
33
+ `;
@@ -0,0 +1,91 @@
1
+ import type { SchemaDefinition, ColumnDefinition } from '../schema/types.js';
2
+
3
+ /**
4
+ * Generates a TypeScript `Database` interface from a SchemaDefinition.
5
+ */
6
+ export class TypeGenerator {
7
+ generate(schema: SchemaDefinition): string {
8
+ const lines: string[] = [];
9
+
10
+ lines.push('// Auto-generated by kysely-schema — DO NOT EDIT');
11
+ lines.push('');
12
+ lines.push('import type { Generated, ColumnType } from \'kysely\';');
13
+ lines.push('');
14
+
15
+ // Generate individual table interfaces
16
+ for (const [tableName, tableDef] of Object.entries(schema.tables)) {
17
+ const pascalName = this.toPascalCase(tableName);
18
+ lines.push(`export interface ${pascalName}Table {`);
19
+
20
+ for (const [colName, colDef] of Object.entries(tableDef.columns)) {
21
+ const tsType = this.mapToTypeScript(colDef);
22
+ lines.push(` ${colName}: ${tsType};`);
23
+ }
24
+
25
+ lines.push('}');
26
+ lines.push('');
27
+ }
28
+
29
+ // Generate the Database interface
30
+ lines.push('export interface Database {');
31
+ for (const tableName of Object.keys(schema.tables)) {
32
+ const pascalName = this.toPascalCase(tableName);
33
+ lines.push(` ${tableName}: ${pascalName}Table;`);
34
+ }
35
+ lines.push('}');
36
+ lines.push('');
37
+
38
+ return lines.join('\n');
39
+ }
40
+
41
+ // ── Private helpers ──────────────────────────────────────────────────
42
+
43
+ private mapToTypeScript(def: ColumnDefinition): string {
44
+ const baseType = this.baseTypeMap(def.type);
45
+ let tsType = baseType;
46
+
47
+ // serial / auto-increment columns are Generated<>
48
+ if (def.type === 'serial') {
49
+ tsType = `Generated<${baseType}>`;
50
+ }
51
+
52
+ // Columns with a default value but not serial are also Generated<>
53
+ if (def.default !== undefined && def.type !== 'serial') {
54
+ tsType = `Generated<${baseType}>`;
55
+ }
56
+
57
+ // Nullable
58
+ if (def.nullable || (!def.notNull && !def.primaryKey && def.type !== 'serial')) {
59
+ tsType += ' | null';
60
+ }
61
+
62
+ return tsType;
63
+ }
64
+
65
+ private baseTypeMap(type: string): string {
66
+ const map: Record<string, string> = {
67
+ serial: 'number',
68
+ integer: 'number',
69
+ bigint: 'string',
70
+ decimal: 'string',
71
+ text: 'string',
72
+ varchar: 'string',
73
+ timestamp: 'Date',
74
+ date: 'Date',
75
+ time: 'string',
76
+ boolean: 'boolean',
77
+ json: 'unknown',
78
+ jsonb: 'unknown',
79
+ binary: 'Buffer',
80
+ uuid: 'string',
81
+ };
82
+ return map[type] || 'unknown';
83
+ }
84
+
85
+ private toPascalCase(str: string): string {
86
+ return str
87
+ .split(/[_\- ]+/)
88
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
89
+ .join('');
90
+ }
91
+ }
package/src/index.ts ADDED
@@ -0,0 +1,40 @@
1
+ // ─── Public API for kysely-schema ────────────────────────────────────────────
2
+
3
+ // Schema DSL
4
+ export { column, table, defineSchema, ColumnBuilder } from './schema/dsl.js';
5
+
6
+ // Schema types
7
+ export type {
8
+ ColumnDefinition,
9
+ ColumnType,
10
+ ReferentialAction,
11
+ ForeignKeyReference,
12
+ IndexDefinition,
13
+ TableDefinition,
14
+ SchemaDefinition,
15
+ MigrationFile,
16
+ DiffOperation,
17
+ DiffOperationType,
18
+ AddTableOperation,
19
+ DropTableOperation,
20
+ AddColumnOperation,
21
+ DropColumnOperation,
22
+ AlterColumnOperation,
23
+ AddIndexOperation,
24
+ DropIndexOperation,
25
+ SchemaSnapshot,
26
+ KyselySchemaConfig,
27
+ } from './schema/types.js';
28
+
29
+ // Validators
30
+ export { validateSchema } from './schema/validators.js';
31
+ export type { ValidationError } from './schema/validators.js';
32
+
33
+ // Generators
34
+ export { MigrationGenerator } from './generators/migration.js';
35
+ export { TypeGenerator } from './generators/types.js';
36
+ export { schemaTemplate, configTemplate } from './generators/templates.js';
37
+
38
+ // Differ
39
+ export { SchemaDiffer } from './differ/index.js';
40
+ export { operationLabels, describeOperation } from './differ/operations.js';
@@ -0,0 +1,125 @@
1
+ import type {
2
+ ColumnDefinition,
3
+ ColumnType,
4
+ ReferentialAction,
5
+ TableDefinition,
6
+ SchemaDefinition,
7
+ } from './types.js';
8
+
9
+ // ─── ColumnBuilder ────────────────────────────────────────────────────────────
10
+
11
+ export class ColumnBuilder {
12
+ private def: ColumnDefinition;
13
+
14
+ constructor(init: Partial<ColumnDefinition> & { type: ColumnType }) {
15
+ this.def = { ...init } as ColumnDefinition;
16
+ }
17
+
18
+ primaryKey(): this {
19
+ this.def.primaryKey = true;
20
+ return this;
21
+ }
22
+
23
+ notNull(): this {
24
+ this.def.notNull = true;
25
+ return this;
26
+ }
27
+
28
+ nullable(): this {
29
+ this.def.nullable = true;
30
+ return this;
31
+ }
32
+
33
+ unique(): this {
34
+ this.def.unique = true;
35
+ return this;
36
+ }
37
+
38
+ default(value: string | number | boolean): this {
39
+ this.def.default = value;
40
+ return this;
41
+ }
42
+
43
+ references(table: string, col: string): this {
44
+ this.def.references = { table, column: col };
45
+ return this;
46
+ }
47
+
48
+ onDelete(action: ReferentialAction): this {
49
+ this.def.onDelete = action;
50
+ return this;
51
+ }
52
+
53
+ onUpdate(action: ReferentialAction): this {
54
+ this.def.onUpdate = action;
55
+ return this;
56
+ }
57
+
58
+ index(): this {
59
+ this.def.index = true;
60
+ return this;
61
+ }
62
+
63
+ check(expression: string): this {
64
+ this.def.check = expression;
65
+ return this;
66
+ }
67
+
68
+ /** @internal Return the raw column definition. */
69
+ build(): ColumnDefinition {
70
+ return { ...this.def };
71
+ }
72
+ }
73
+
74
+ // ─── Column factory ──────────────────────────────────────────────────────────
75
+
76
+ export const column = {
77
+ // Numeric types
78
+ serial: () => new ColumnBuilder({ type: 'serial' }),
79
+ integer: () => new ColumnBuilder({ type: 'integer' }),
80
+ bigint: () => new ColumnBuilder({ type: 'bigint' }),
81
+ decimal: (precision?: number, scale?: number) =>
82
+ new ColumnBuilder({ type: 'decimal', precision, scale }),
83
+
84
+ // Text types
85
+ text: () => new ColumnBuilder({ type: 'text' }),
86
+ varchar: (length?: number) => new ColumnBuilder({ type: 'varchar', length }),
87
+
88
+ // Date / Time types
89
+ timestamp: () => new ColumnBuilder({ type: 'timestamp' }),
90
+ date: () => new ColumnBuilder({ type: 'date' }),
91
+ time: () => new ColumnBuilder({ type: 'time' }),
92
+
93
+ // Boolean
94
+ boolean: () => new ColumnBuilder({ type: 'boolean' }),
95
+
96
+ // JSON
97
+ json: () => new ColumnBuilder({ type: 'json' }),
98
+ jsonb: () => new ColumnBuilder({ type: 'jsonb' }),
99
+
100
+ // Binary
101
+ binary: () => new ColumnBuilder({ type: 'binary' }),
102
+
103
+ // UUID
104
+ uuid: () => new ColumnBuilder({ type: 'uuid' }),
105
+ };
106
+
107
+ // ─── Table helper ─────────────────────────────────────────────────────────────
108
+
109
+ export function table(
110
+ columns: Record<string, ColumnBuilder>,
111
+ ): TableDefinition {
112
+ const built: Record<string, ColumnDefinition> = {};
113
+ for (const [name, builder] of Object.entries(columns)) {
114
+ built[name] = builder.build();
115
+ }
116
+ return { columns: built, indexes: [] };
117
+ }
118
+
119
+ // ─── defineSchema ─────────────────────────────────────────────────────────────
120
+
121
+ export function defineSchema(
122
+ tables: Record<string, TableDefinition>,
123
+ ): SchemaDefinition {
124
+ return { tables };
125
+ }