@typokit/db-kysely 0.1.4
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.
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +264 -0
- package/dist/index.js.map +1 -0
- package/package.json +29 -0
- package/src/index.test.ts +435 -0
- package/src/index.ts +351 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { SchemaTypeMap, GeneratedOutput, MigrationDraft } from "@typokit/types";
|
|
2
|
+
import type { DatabaseAdapter, DatabaseState } from "@typokit/core";
|
|
3
|
+
export type KyselyDialect = "postgresql" | "sqlite";
|
|
4
|
+
export interface KyselyAdapterOptions {
|
|
5
|
+
dialect?: KyselyDialect;
|
|
6
|
+
outputDir?: string;
|
|
7
|
+
}
|
|
8
|
+
/** Parse string union type like `"a" | "b" | "c"` into values */
|
|
9
|
+
export declare function parseUnionValues(typeStr: string): string[] | null;
|
|
10
|
+
export declare class KyselyDatabaseAdapter implements DatabaseAdapter {
|
|
11
|
+
private readonly dialect;
|
|
12
|
+
private readonly outputDir;
|
|
13
|
+
constructor(options?: KyselyAdapterOptions);
|
|
14
|
+
generate(types: SchemaTypeMap): GeneratedOutput[];
|
|
15
|
+
diff(types: SchemaTypeMap, currentState: DatabaseState): MigrationDraft;
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,aAAa,EAEb,eAAe,EACf,cAAc,EAEf,MAAM,gBAAgB,CAAC;AACxB,OAAO,KAAK,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAEpE,MAAM,MAAM,aAAa,GAAG,YAAY,GAAG,QAAQ,CAAC;AAEpD,MAAM,WAAW,oBAAoB;IACnC,OAAO,CAAC,EAAE,aAAa,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AA4BD,iEAAiE;AACjE,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,IAAI,CAYjE;AAuQD,qBAAa,qBAAsB,YAAW,eAAe;IAC3D,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAgB;IACxC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;gBAEvB,OAAO,CAAC,EAAE,oBAAoB;IAK1C,QAAQ,CAAC,KAAK,EAAE,aAAa,GAAG,eAAe,EAAE;IAMjD,IAAI,CAAC,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,aAAa,GAAG,cAAc;CAgBxE"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// ─── Helpers ─────────────────────────────────────────────────
|
|
2
|
+
function toSnakeCase(str) {
|
|
3
|
+
return str
|
|
4
|
+
.replace(/([A-Z])/g, (_, letter, index) => index === 0 ? letter.toLowerCase() : `_${letter.toLowerCase()}`)
|
|
5
|
+
.replace(/__+/g, "_");
|
|
6
|
+
}
|
|
7
|
+
function pluralize(str) {
|
|
8
|
+
if (str.endsWith("s"))
|
|
9
|
+
return str;
|
|
10
|
+
if (str.endsWith("y") && !/[aeiou]y$/i.test(str))
|
|
11
|
+
return str.slice(0, -1) + "ies";
|
|
12
|
+
return str + "s";
|
|
13
|
+
}
|
|
14
|
+
function toTableName(typeName, jsdoc) {
|
|
15
|
+
if (jsdoc?.table)
|
|
16
|
+
return jsdoc.table;
|
|
17
|
+
return pluralize(toSnakeCase(typeName));
|
|
18
|
+
}
|
|
19
|
+
function toColumnName(propName) {
|
|
20
|
+
return toSnakeCase(propName);
|
|
21
|
+
}
|
|
22
|
+
/** Parse string union type like `"a" | "b" | "c"` into values */
|
|
23
|
+
export function parseUnionValues(typeStr) {
|
|
24
|
+
const trimmed = typeStr.trim();
|
|
25
|
+
if (!trimmed.includes("|"))
|
|
26
|
+
return null;
|
|
27
|
+
const parts = trimmed.split("|").map((p) => p.trim());
|
|
28
|
+
const values = [];
|
|
29
|
+
for (const part of parts) {
|
|
30
|
+
const match = /^"([^"]*)"$/.exec(part);
|
|
31
|
+
if (!match)
|
|
32
|
+
return null;
|
|
33
|
+
values.push(match[1]);
|
|
34
|
+
}
|
|
35
|
+
return values.length > 0 ? values : null;
|
|
36
|
+
}
|
|
37
|
+
// ─── PostgreSQL Column Type Mapping ─────────────────────────
|
|
38
|
+
function mapPgColumnType(prop) {
|
|
39
|
+
const jsdoc = prop.jsdoc ?? {};
|
|
40
|
+
const unionValues = parseUnionValues(prop.type);
|
|
41
|
+
let baseType;
|
|
42
|
+
if (unionValues) {
|
|
43
|
+
baseType = unionValues.map((v) => `"${v}"`).join(" | ");
|
|
44
|
+
}
|
|
45
|
+
else if (prop.type === "string") {
|
|
46
|
+
baseType = "string";
|
|
47
|
+
}
|
|
48
|
+
else if (prop.type === "number") {
|
|
49
|
+
baseType = "number";
|
|
50
|
+
}
|
|
51
|
+
else if (prop.type === "bigint") {
|
|
52
|
+
baseType = "number";
|
|
53
|
+
}
|
|
54
|
+
else if (prop.type === "boolean") {
|
|
55
|
+
baseType = "boolean";
|
|
56
|
+
}
|
|
57
|
+
else if (prop.type === "Date") {
|
|
58
|
+
baseType = "Date";
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
// object, Record, unknown → unknown (JSON)
|
|
62
|
+
baseType = "unknown";
|
|
63
|
+
}
|
|
64
|
+
// Wrap with Generated<T> if auto-generated
|
|
65
|
+
const isGenerated = jsdoc.generated !== undefined;
|
|
66
|
+
const hasDefault = jsdoc.default !== undefined;
|
|
67
|
+
if (isGenerated || hasDefault) {
|
|
68
|
+
return `Generated<${baseType}>`;
|
|
69
|
+
}
|
|
70
|
+
return baseType;
|
|
71
|
+
}
|
|
72
|
+
// ─── SQLite Column Type Mapping ─────────────────────────────
|
|
73
|
+
function mapSqliteColumnType(prop) {
|
|
74
|
+
const jsdoc = prop.jsdoc ?? {};
|
|
75
|
+
let baseType;
|
|
76
|
+
if (prop.type === "number" || prop.type === "bigint") {
|
|
77
|
+
baseType = "number";
|
|
78
|
+
}
|
|
79
|
+
else if (prop.type === "boolean") {
|
|
80
|
+
baseType = "number";
|
|
81
|
+
}
|
|
82
|
+
else if (prop.type === "Date") {
|
|
83
|
+
baseType = "string";
|
|
84
|
+
}
|
|
85
|
+
else if (prop.type === "object" ||
|
|
86
|
+
prop.type.startsWith("Record<") ||
|
|
87
|
+
parseUnionValues(prop.type)) {
|
|
88
|
+
baseType = "string";
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
baseType = "string";
|
|
92
|
+
}
|
|
93
|
+
const isGenerated = jsdoc.generated !== undefined;
|
|
94
|
+
const hasDefault = jsdoc.default !== undefined;
|
|
95
|
+
if (isGenerated || hasDefault) {
|
|
96
|
+
return `Generated<${baseType}>`;
|
|
97
|
+
}
|
|
98
|
+
return baseType;
|
|
99
|
+
}
|
|
100
|
+
// ─── Code Generation ────────────────────────────────────────
|
|
101
|
+
function generateTableInterface(typeName, meta, dialect) {
|
|
102
|
+
const mapFn = dialect === "postgresql" ? mapPgColumnType : mapSqliteColumnType;
|
|
103
|
+
let needsGenerated = false;
|
|
104
|
+
const columns = [];
|
|
105
|
+
for (const [propName, prop] of Object.entries(meta.properties)) {
|
|
106
|
+
const colName = toColumnName(propName);
|
|
107
|
+
const tsType = mapFn(prop);
|
|
108
|
+
if (tsType.startsWith("Generated<"))
|
|
109
|
+
needsGenerated = true;
|
|
110
|
+
columns.push({ colName, tsType, optional: prop.optional });
|
|
111
|
+
}
|
|
112
|
+
const interfaceName = `${typeName}Table`;
|
|
113
|
+
let code = `export interface ${interfaceName} {\n`;
|
|
114
|
+
for (const col of columns) {
|
|
115
|
+
const nullSuffix = col.optional ? " | null" : "";
|
|
116
|
+
code += ` ${col.colName}: ${col.tsType}${nullSuffix};\n`;
|
|
117
|
+
}
|
|
118
|
+
code += "}\n";
|
|
119
|
+
return { interfaceName, code, needsGenerated };
|
|
120
|
+
}
|
|
121
|
+
function generateFile(types, dialect) {
|
|
122
|
+
const tableInterfaces = [];
|
|
123
|
+
let needsGenerated = false;
|
|
124
|
+
for (const [typeName, meta] of Object.entries(types)) {
|
|
125
|
+
const tableName = toTableName(typeName, meta.jsdoc);
|
|
126
|
+
const result = generateTableInterface(typeName, meta, dialect);
|
|
127
|
+
if (result.needsGenerated)
|
|
128
|
+
needsGenerated = true;
|
|
129
|
+
tableInterfaces.push({
|
|
130
|
+
tableName,
|
|
131
|
+
interfaceName: result.interfaceName,
|
|
132
|
+
code: result.code,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
let content = `// AUTO-GENERATED by @typokit/db-kysely\n`;
|
|
136
|
+
content += `// Do not edit manually — modify the source type instead\n\n`;
|
|
137
|
+
if (needsGenerated) {
|
|
138
|
+
content += `import type { Generated } from "kysely";\n\n`;
|
|
139
|
+
}
|
|
140
|
+
// Emit table interfaces
|
|
141
|
+
for (const ti of tableInterfaces) {
|
|
142
|
+
content += ti.code + "\n";
|
|
143
|
+
}
|
|
144
|
+
// Emit Database interface
|
|
145
|
+
content += `export interface Database {\n`;
|
|
146
|
+
for (const ti of tableInterfaces) {
|
|
147
|
+
content += ` ${ti.tableName}: ${ti.interfaceName};\n`;
|
|
148
|
+
}
|
|
149
|
+
content += "}\n";
|
|
150
|
+
return {
|
|
151
|
+
filePath: `database.ts`,
|
|
152
|
+
content,
|
|
153
|
+
overwrite: true,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
// ─── Diff Logic ─────────────────────────────────────────────
|
|
157
|
+
function diffTypes(types, currentState) {
|
|
158
|
+
const changes = [];
|
|
159
|
+
for (const [typeName, meta] of Object.entries(types)) {
|
|
160
|
+
const tableName = toTableName(typeName, meta.jsdoc);
|
|
161
|
+
const existing = currentState.tables[tableName];
|
|
162
|
+
if (!existing) {
|
|
163
|
+
changes.push({ type: "add", entity: tableName });
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
for (const [propName, prop] of Object.entries(meta.properties)) {
|
|
167
|
+
const colName = toColumnName(propName);
|
|
168
|
+
const existingCol = existing.columns[colName];
|
|
169
|
+
if (!existingCol) {
|
|
170
|
+
changes.push({
|
|
171
|
+
type: "add",
|
|
172
|
+
entity: tableName,
|
|
173
|
+
field: colName,
|
|
174
|
+
details: { tsType: prop.type },
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
const nullable = prop.optional;
|
|
179
|
+
if (existingCol.nullable !== nullable) {
|
|
180
|
+
changes.push({
|
|
181
|
+
type: "modify",
|
|
182
|
+
entity: tableName,
|
|
183
|
+
field: colName,
|
|
184
|
+
details: {
|
|
185
|
+
nullableFrom: existingCol.nullable,
|
|
186
|
+
nullableTo: nullable,
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// Detect removed columns
|
|
193
|
+
for (const colName of Object.keys(existing.columns)) {
|
|
194
|
+
const hasProp = Object.keys(meta.properties).some((p) => toColumnName(p) === colName);
|
|
195
|
+
if (!hasProp) {
|
|
196
|
+
changes.push({ type: "remove", entity: tableName, field: colName });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// Detect removed tables
|
|
201
|
+
for (const tableName of Object.keys(currentState.tables)) {
|
|
202
|
+
const hasType = Object.entries(types).some(([name, meta]) => toTableName(name, meta.jsdoc) === tableName);
|
|
203
|
+
if (!hasType) {
|
|
204
|
+
changes.push({ type: "remove", entity: tableName });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return changes;
|
|
208
|
+
}
|
|
209
|
+
function generateMigrationSql(changes, dialect) {
|
|
210
|
+
const lines = [];
|
|
211
|
+
for (const change of changes) {
|
|
212
|
+
if (change.type === "add" && !change.field) {
|
|
213
|
+
lines.push(`-- TODO: CREATE TABLE "${change.entity}" (define columns)`);
|
|
214
|
+
}
|
|
215
|
+
else if (change.type === "add" && change.field) {
|
|
216
|
+
lines.push(`ALTER TABLE "${change.entity}" ADD COLUMN "${change.field}" TEXT;`);
|
|
217
|
+
}
|
|
218
|
+
else if (change.type === "remove" && !change.field) {
|
|
219
|
+
lines.push(`DROP TABLE IF EXISTS "${change.entity}";`);
|
|
220
|
+
}
|
|
221
|
+
else if (change.type === "remove" && change.field) {
|
|
222
|
+
if (dialect === "postgresql") {
|
|
223
|
+
lines.push(`ALTER TABLE "${change.entity}" DROP COLUMN "${change.field}";`);
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
lines.push(`-- SQLite: cannot DROP COLUMN "${change.field}" from "${change.entity}" — recreate table`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
else if (change.type === "modify") {
|
|
230
|
+
lines.push(`-- TODO: ALTER TABLE "${change.entity}" modify column "${change.field}"`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return lines.join("\n");
|
|
234
|
+
}
|
|
235
|
+
// ─── Adapter ────────────────────────────────────────────────
|
|
236
|
+
export class KyselyDatabaseAdapter {
|
|
237
|
+
dialect;
|
|
238
|
+
outputDir;
|
|
239
|
+
constructor(options) {
|
|
240
|
+
this.dialect = options?.dialect ?? "postgresql";
|
|
241
|
+
this.outputDir = options?.outputDir ?? "kysely";
|
|
242
|
+
}
|
|
243
|
+
generate(types) {
|
|
244
|
+
const output = generateFile(types, this.dialect);
|
|
245
|
+
output.filePath = `${this.outputDir}/${output.filePath}`;
|
|
246
|
+
return [output];
|
|
247
|
+
}
|
|
248
|
+
diff(types, currentState) {
|
|
249
|
+
const changes = diffTypes(types, currentState);
|
|
250
|
+
const destructive = changes.some((c) => c.type === "remove");
|
|
251
|
+
const sql = generateMigrationSql(changes, this.dialect);
|
|
252
|
+
const timestamp = new Date()
|
|
253
|
+
.toISOString()
|
|
254
|
+
.replace(/[-:T]/g, "")
|
|
255
|
+
.slice(0, 14);
|
|
256
|
+
return {
|
|
257
|
+
name: `${timestamp}_schema_update`,
|
|
258
|
+
sql,
|
|
259
|
+
destructive,
|
|
260
|
+
changes,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@typokit/db-kysely",
|
|
3
|
+
"exports": {
|
|
4
|
+
".": {
|
|
5
|
+
"import": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts"
|
|
7
|
+
}
|
|
8
|
+
},
|
|
9
|
+
"version": "0.1.4",
|
|
10
|
+
"type": "module",
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"src"
|
|
14
|
+
],
|
|
15
|
+
"main": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@typokit/types": "0.1.4",
|
|
19
|
+
"@typokit/core": "0.1.4"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "https://github.com/KyleBastien/typokit",
|
|
24
|
+
"directory": "packages/db-kysely"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "rstest run --passWithNoTests"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import { describe, it, expect } from "@rstest/core";
|
|
2
|
+
import { KyselyDatabaseAdapter, parseUnionValues } from "./index.js";
|
|
3
|
+
import type { SchemaTypeMap } from "@typokit/types";
|
|
4
|
+
import type { DatabaseState } from "@typokit/core";
|
|
5
|
+
|
|
6
|
+
describe("KyselyDatabaseAdapter", () => {
|
|
7
|
+
describe("parseUnionValues", () => {
|
|
8
|
+
it("should parse string union types", () => {
|
|
9
|
+
const values = parseUnionValues('"active" | "suspended" | "deleted"');
|
|
10
|
+
expect(values).toEqual(["active", "suspended", "deleted"]);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("should return null for non-union types", () => {
|
|
14
|
+
expect(parseUnionValues("string")).toBeNull();
|
|
15
|
+
expect(parseUnionValues("number")).toBeNull();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("should return null for mixed unions", () => {
|
|
19
|
+
expect(parseUnionValues('"a" | number')).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("generate() - PostgreSQL", () => {
|
|
24
|
+
const adapter = new KyselyDatabaseAdapter({ dialect: "postgresql" });
|
|
25
|
+
|
|
26
|
+
it("should generate Kysely types from User type", () => {
|
|
27
|
+
const types: SchemaTypeMap = {
|
|
28
|
+
User: {
|
|
29
|
+
name: "User",
|
|
30
|
+
jsdoc: { table: "users" },
|
|
31
|
+
properties: {
|
|
32
|
+
id: {
|
|
33
|
+
type: "string",
|
|
34
|
+
optional: false,
|
|
35
|
+
jsdoc: { id: "", generated: "uuid" },
|
|
36
|
+
},
|
|
37
|
+
email: {
|
|
38
|
+
type: "string",
|
|
39
|
+
optional: false,
|
|
40
|
+
jsdoc: { format: "email", unique: "" },
|
|
41
|
+
},
|
|
42
|
+
displayName: {
|
|
43
|
+
type: "string",
|
|
44
|
+
optional: false,
|
|
45
|
+
jsdoc: { maxLength: "100" },
|
|
46
|
+
},
|
|
47
|
+
status: {
|
|
48
|
+
type: '"active" | "suspended" | "deleted"',
|
|
49
|
+
optional: false,
|
|
50
|
+
jsdoc: { default: "active" },
|
|
51
|
+
},
|
|
52
|
+
createdAt: {
|
|
53
|
+
type: "Date",
|
|
54
|
+
optional: false,
|
|
55
|
+
jsdoc: { generated: "now" },
|
|
56
|
+
},
|
|
57
|
+
updatedAt: {
|
|
58
|
+
type: "Date",
|
|
59
|
+
optional: false,
|
|
60
|
+
jsdoc: { onUpdate: "now" },
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const outputs = adapter.generate(types);
|
|
67
|
+
expect(outputs).toHaveLength(1);
|
|
68
|
+
|
|
69
|
+
const output = outputs[0];
|
|
70
|
+
expect(output.filePath).toBe("kysely/database.ts");
|
|
71
|
+
expect(output.overwrite).toBe(true);
|
|
72
|
+
|
|
73
|
+
const content = output.content;
|
|
74
|
+
|
|
75
|
+
// Header
|
|
76
|
+
expect(content).toContain("// AUTO-GENERATED by @typokit/db-kysely");
|
|
77
|
+
|
|
78
|
+
// Generated import
|
|
79
|
+
expect(content).toContain('import type { Generated } from "kysely"');
|
|
80
|
+
|
|
81
|
+
// Table interface
|
|
82
|
+
expect(content).toContain("export interface UserTable");
|
|
83
|
+
|
|
84
|
+
// Columns — id is Generated<string> (auto-generated uuid)
|
|
85
|
+
expect(content).toContain("id: Generated<string>");
|
|
86
|
+
|
|
87
|
+
// email is plain string
|
|
88
|
+
expect(content).toContain("email: string;");
|
|
89
|
+
|
|
90
|
+
// displayName → display_name is plain string
|
|
91
|
+
expect(content).toContain("display_name: string;");
|
|
92
|
+
|
|
93
|
+
// status is Generated<union> (has default)
|
|
94
|
+
expect(content).toContain(
|
|
95
|
+
'status: Generated<"active" | "suspended" | "deleted">',
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// createdAt → created_at is Generated<Date>
|
|
99
|
+
expect(content).toContain("created_at: Generated<Date>");
|
|
100
|
+
|
|
101
|
+
// updatedAt → updated_at is plain Date (onUpdate is not a generation)
|
|
102
|
+
expect(content).toContain("updated_at: Date;");
|
|
103
|
+
|
|
104
|
+
// Database interface
|
|
105
|
+
expect(content).toContain("export interface Database");
|
|
106
|
+
expect(content).toContain("users: UserTable;");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("should handle all supported column types", () => {
|
|
110
|
+
const types: SchemaTypeMap = {
|
|
111
|
+
Product: {
|
|
112
|
+
name: "Product",
|
|
113
|
+
properties: {
|
|
114
|
+
id: {
|
|
115
|
+
type: "string",
|
|
116
|
+
optional: false,
|
|
117
|
+
jsdoc: { id: "", generated: "uuid" },
|
|
118
|
+
},
|
|
119
|
+
name: { type: "string", optional: false },
|
|
120
|
+
price: { type: "number", optional: false },
|
|
121
|
+
stock: { type: "bigint", optional: false },
|
|
122
|
+
active: { type: "boolean", optional: false },
|
|
123
|
+
metadata: { type: "Record<string, unknown>", optional: true },
|
|
124
|
+
description: { type: "string", optional: true },
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const outputs = adapter.generate(types);
|
|
130
|
+
expect(outputs).toHaveLength(1);
|
|
131
|
+
const content = outputs[0].content;
|
|
132
|
+
|
|
133
|
+
expect(content).toContain("id: Generated<string>");
|
|
134
|
+
expect(content).toContain("name: string;");
|
|
135
|
+
expect(content).toContain("price: number;");
|
|
136
|
+
expect(content).toContain("stock: number;");
|
|
137
|
+
expect(content).toContain("active: boolean;");
|
|
138
|
+
expect(content).toContain("metadata: unknown | null;");
|
|
139
|
+
expect(content).toContain("description: string | null;");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("should use @table JSDoc tag for table name", () => {
|
|
143
|
+
const types: SchemaTypeMap = {
|
|
144
|
+
UserAccount: {
|
|
145
|
+
name: "UserAccount",
|
|
146
|
+
jsdoc: { table: "custom_accounts" },
|
|
147
|
+
properties: {
|
|
148
|
+
id: { type: "string", optional: false, jsdoc: { id: "" } },
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const outputs = adapter.generate(types);
|
|
154
|
+
expect(outputs[0].content).toContain(
|
|
155
|
+
"custom_accounts: UserAccountTable;",
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("should pluralize table names from type names", () => {
|
|
160
|
+
const types: SchemaTypeMap = {
|
|
161
|
+
Category: {
|
|
162
|
+
name: "Category",
|
|
163
|
+
properties: {
|
|
164
|
+
id: { type: "string", optional: false, jsdoc: { id: "" } },
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const outputs = adapter.generate(types);
|
|
170
|
+
expect(outputs[0].content).toContain("categories: CategoryTable;");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("should handle string union types in PostgreSQL", () => {
|
|
174
|
+
const types: SchemaTypeMap = {
|
|
175
|
+
Order: {
|
|
176
|
+
name: "Order",
|
|
177
|
+
properties: {
|
|
178
|
+
status: {
|
|
179
|
+
type: '"pending" | "shipped" | "delivered"',
|
|
180
|
+
optional: false,
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const outputs = adapter.generate(types);
|
|
187
|
+
const content = outputs[0].content;
|
|
188
|
+
expect(content).toContain('"pending" | "shipped" | "delivered"');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("should handle @default with Generated wrapper", () => {
|
|
192
|
+
const types: SchemaTypeMap = {
|
|
193
|
+
Config: {
|
|
194
|
+
name: "Config",
|
|
195
|
+
properties: {
|
|
196
|
+
retryCount: {
|
|
197
|
+
type: "number",
|
|
198
|
+
optional: false,
|
|
199
|
+
jsdoc: { default: "3" },
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const outputs = adapter.generate(types);
|
|
206
|
+
expect(outputs[0].content).toContain("retry_count: Generated<number>");
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe("generate() - SQLite", () => {
|
|
211
|
+
const adapter = new KyselyDatabaseAdapter({ dialect: "sqlite" });
|
|
212
|
+
|
|
213
|
+
it("should generate SQLite Kysely types", () => {
|
|
214
|
+
const types: SchemaTypeMap = {
|
|
215
|
+
User: {
|
|
216
|
+
name: "User",
|
|
217
|
+
jsdoc: { table: "users" },
|
|
218
|
+
properties: {
|
|
219
|
+
id: { type: "string", optional: false, jsdoc: { id: "" } },
|
|
220
|
+
name: { type: "string", optional: false },
|
|
221
|
+
age: { type: "number", optional: false },
|
|
222
|
+
active: { type: "boolean", optional: false },
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const outputs = adapter.generate(types);
|
|
228
|
+
expect(outputs).toHaveLength(1);
|
|
229
|
+
const content = outputs[0].content;
|
|
230
|
+
|
|
231
|
+
// SQLite maps boolean → number, Date → string
|
|
232
|
+
expect(content).toContain("id: string;");
|
|
233
|
+
expect(content).toContain("name: string;");
|
|
234
|
+
expect(content).toContain("age: number;");
|
|
235
|
+
expect(content).toContain("active: number;");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("should map Date to string in SQLite", () => {
|
|
239
|
+
const types: SchemaTypeMap = {
|
|
240
|
+
Event: {
|
|
241
|
+
name: "Event",
|
|
242
|
+
properties: {
|
|
243
|
+
createdAt: {
|
|
244
|
+
type: "Date",
|
|
245
|
+
optional: false,
|
|
246
|
+
jsdoc: { generated: "now" },
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const outputs = adapter.generate(types);
|
|
253
|
+
expect(outputs[0].content).toContain("created_at: Generated<string>");
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe("diff()", () => {
|
|
258
|
+
const adapter = new KyselyDatabaseAdapter({ dialect: "postgresql" });
|
|
259
|
+
|
|
260
|
+
it("should detect new tables", () => {
|
|
261
|
+
const types: SchemaTypeMap = {
|
|
262
|
+
User: {
|
|
263
|
+
name: "User",
|
|
264
|
+
jsdoc: { table: "users" },
|
|
265
|
+
properties: {
|
|
266
|
+
id: { type: "string", optional: false },
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
const state: DatabaseState = { tables: {} };
|
|
271
|
+
|
|
272
|
+
const draft = adapter.diff(types, state);
|
|
273
|
+
expect(draft.changes).toHaveLength(1);
|
|
274
|
+
expect(draft.changes[0]).toEqual({ type: "add", entity: "users" });
|
|
275
|
+
expect(draft.destructive).toBe(false);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("should detect new columns", () => {
|
|
279
|
+
const types: SchemaTypeMap = {
|
|
280
|
+
User: {
|
|
281
|
+
name: "User",
|
|
282
|
+
jsdoc: { table: "users" },
|
|
283
|
+
properties: {
|
|
284
|
+
id: { type: "string", optional: false },
|
|
285
|
+
email: { type: "string", optional: false },
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
const state: DatabaseState = {
|
|
290
|
+
tables: {
|
|
291
|
+
users: {
|
|
292
|
+
columns: {
|
|
293
|
+
id: { type: "text", nullable: false },
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const draft = adapter.diff(types, state);
|
|
300
|
+
expect(draft.changes).toHaveLength(1);
|
|
301
|
+
expect(draft.changes[0].type).toBe("add");
|
|
302
|
+
expect(draft.changes[0].field).toBe("email");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("should detect removed columns", () => {
|
|
306
|
+
const types: SchemaTypeMap = {
|
|
307
|
+
User: {
|
|
308
|
+
name: "User",
|
|
309
|
+
jsdoc: { table: "users" },
|
|
310
|
+
properties: {
|
|
311
|
+
id: { type: "string", optional: false },
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
const state: DatabaseState = {
|
|
316
|
+
tables: {
|
|
317
|
+
users: {
|
|
318
|
+
columns: {
|
|
319
|
+
id: { type: "text", nullable: false },
|
|
320
|
+
old_col: { type: "text", nullable: true },
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const draft = adapter.diff(types, state);
|
|
327
|
+
expect(draft.changes).toHaveLength(1);
|
|
328
|
+
expect(draft.changes[0]).toEqual({
|
|
329
|
+
type: "remove",
|
|
330
|
+
entity: "users",
|
|
331
|
+
field: "old_col",
|
|
332
|
+
});
|
|
333
|
+
expect(draft.destructive).toBe(true);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("should detect removed tables", () => {
|
|
337
|
+
const types: SchemaTypeMap = {};
|
|
338
|
+
const state: DatabaseState = {
|
|
339
|
+
tables: {
|
|
340
|
+
old_table: { columns: { id: { type: "text", nullable: false } } },
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const draft = adapter.diff(types, state);
|
|
345
|
+
expect(draft.changes).toHaveLength(1);
|
|
346
|
+
expect(draft.changes[0]).toEqual({ type: "remove", entity: "old_table" });
|
|
347
|
+
expect(draft.destructive).toBe(true);
|
|
348
|
+
expect(draft.sql).toContain("DROP TABLE");
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("should detect nullable changes", () => {
|
|
352
|
+
const types: SchemaTypeMap = {
|
|
353
|
+
User: {
|
|
354
|
+
name: "User",
|
|
355
|
+
jsdoc: { table: "users" },
|
|
356
|
+
properties: {
|
|
357
|
+
email: { type: "string", optional: true },
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
const state: DatabaseState = {
|
|
362
|
+
tables: {
|
|
363
|
+
users: {
|
|
364
|
+
columns: {
|
|
365
|
+
email: { type: "text", nullable: false },
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const draft = adapter.diff(types, state);
|
|
372
|
+
expect(draft.changes).toHaveLength(1);
|
|
373
|
+
expect(draft.changes[0].type).toBe("modify");
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("should generate migration SQL", () => {
|
|
377
|
+
const types: SchemaTypeMap = {
|
|
378
|
+
User: {
|
|
379
|
+
name: "User",
|
|
380
|
+
jsdoc: { table: "users" },
|
|
381
|
+
properties: {
|
|
382
|
+
id: { type: "string", optional: false },
|
|
383
|
+
newField: { type: "string", optional: false },
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
};
|
|
387
|
+
const state: DatabaseState = {
|
|
388
|
+
tables: {
|
|
389
|
+
users: {
|
|
390
|
+
columns: {
|
|
391
|
+
id: { type: "text", nullable: false },
|
|
392
|
+
},
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
const draft = adapter.diff(types, state);
|
|
398
|
+
expect(draft.sql).toContain(
|
|
399
|
+
'ALTER TABLE "users" ADD COLUMN "new_field" TEXT;',
|
|
400
|
+
);
|
|
401
|
+
expect(draft.name).toMatch(/^\d{14}_schema_update$/);
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
describe("constructor options", () => {
|
|
406
|
+
it("should use custom output directory", () => {
|
|
407
|
+
const adapter = new KyselyDatabaseAdapter({ outputDir: "db/types" });
|
|
408
|
+
const types: SchemaTypeMap = {
|
|
409
|
+
Item: {
|
|
410
|
+
name: "Item",
|
|
411
|
+
properties: { id: { type: "string", optional: false } },
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const outputs = adapter.generate(types);
|
|
416
|
+
expect(outputs[0].filePath).toContain("db/types/");
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("should default to postgresql dialect", () => {
|
|
420
|
+
const adapter = new KyselyDatabaseAdapter();
|
|
421
|
+
const types: SchemaTypeMap = {
|
|
422
|
+
Item: {
|
|
423
|
+
name: "Item",
|
|
424
|
+
properties: {
|
|
425
|
+
active: { type: "boolean", optional: false },
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const outputs = adapter.generate(types);
|
|
431
|
+
// PostgreSQL maps boolean to boolean, not number
|
|
432
|
+
expect(outputs[0].content).toContain("active: boolean;");
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
// @typokit/db-kysely — Kysely Type Generation
|
|
2
|
+
import type {
|
|
3
|
+
SchemaTypeMap,
|
|
4
|
+
TypeMetadata,
|
|
5
|
+
GeneratedOutput,
|
|
6
|
+
MigrationDraft,
|
|
7
|
+
SchemaChange,
|
|
8
|
+
} from "@typokit/types";
|
|
9
|
+
import type { DatabaseAdapter, DatabaseState } from "@typokit/core";
|
|
10
|
+
|
|
11
|
+
export type KyselyDialect = "postgresql" | "sqlite";
|
|
12
|
+
|
|
13
|
+
export interface KyselyAdapterOptions {
|
|
14
|
+
dialect?: KyselyDialect;
|
|
15
|
+
outputDir?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ─── Helpers ─────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function toSnakeCase(str: string): string {
|
|
21
|
+
return str
|
|
22
|
+
.replace(/([A-Z])/g, (_, letter, index) =>
|
|
23
|
+
index === 0 ? letter.toLowerCase() : `_${letter.toLowerCase()}`,
|
|
24
|
+
)
|
|
25
|
+
.replace(/__+/g, "_");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function pluralize(str: string): string {
|
|
29
|
+
if (str.endsWith("s")) return str;
|
|
30
|
+
if (str.endsWith("y") && !/[aeiou]y$/i.test(str))
|
|
31
|
+
return str.slice(0, -1) + "ies";
|
|
32
|
+
return str + "s";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function toTableName(typeName: string, jsdoc?: Record<string, string>): string {
|
|
36
|
+
if (jsdoc?.table) return jsdoc.table;
|
|
37
|
+
return pluralize(toSnakeCase(typeName));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function toColumnName(propName: string): string {
|
|
41
|
+
return toSnakeCase(propName);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Parse string union type like `"a" | "b" | "c"` into values */
|
|
45
|
+
export function parseUnionValues(typeStr: string): string[] | null {
|
|
46
|
+
const trimmed = typeStr.trim();
|
|
47
|
+
if (!trimmed.includes("|")) return null;
|
|
48
|
+
|
|
49
|
+
const parts = trimmed.split("|").map((p) => p.trim());
|
|
50
|
+
const values: string[] = [];
|
|
51
|
+
for (const part of parts) {
|
|
52
|
+
const match = /^"([^"]*)"$/.exec(part);
|
|
53
|
+
if (!match) return null;
|
|
54
|
+
values.push(match[1]);
|
|
55
|
+
}
|
|
56
|
+
return values.length > 0 ? values : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── PostgreSQL Column Type Mapping ─────────────────────────
|
|
60
|
+
|
|
61
|
+
function mapPgColumnType(prop: {
|
|
62
|
+
type: string;
|
|
63
|
+
optional: boolean;
|
|
64
|
+
jsdoc?: Record<string, string>;
|
|
65
|
+
}): string {
|
|
66
|
+
const jsdoc = prop.jsdoc ?? {};
|
|
67
|
+
const unionValues = parseUnionValues(prop.type);
|
|
68
|
+
|
|
69
|
+
let baseType: string;
|
|
70
|
+
|
|
71
|
+
if (unionValues) {
|
|
72
|
+
baseType = unionValues.map((v) => `"${v}"`).join(" | ");
|
|
73
|
+
} else if (prop.type === "string") {
|
|
74
|
+
baseType = "string";
|
|
75
|
+
} else if (prop.type === "number") {
|
|
76
|
+
baseType = "number";
|
|
77
|
+
} else if (prop.type === "bigint") {
|
|
78
|
+
baseType = "number";
|
|
79
|
+
} else if (prop.type === "boolean") {
|
|
80
|
+
baseType = "boolean";
|
|
81
|
+
} else if (prop.type === "Date") {
|
|
82
|
+
baseType = "Date";
|
|
83
|
+
} else {
|
|
84
|
+
// object, Record, unknown → unknown (JSON)
|
|
85
|
+
baseType = "unknown";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Wrap with Generated<T> if auto-generated
|
|
89
|
+
const isGenerated = jsdoc.generated !== undefined;
|
|
90
|
+
const hasDefault = jsdoc.default !== undefined;
|
|
91
|
+
if (isGenerated || hasDefault) {
|
|
92
|
+
return `Generated<${baseType}>`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return baseType;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ─── SQLite Column Type Mapping ─────────────────────────────
|
|
99
|
+
|
|
100
|
+
function mapSqliteColumnType(prop: {
|
|
101
|
+
type: string;
|
|
102
|
+
optional: boolean;
|
|
103
|
+
jsdoc?: Record<string, string>;
|
|
104
|
+
}): string {
|
|
105
|
+
const jsdoc = prop.jsdoc ?? {};
|
|
106
|
+
|
|
107
|
+
let baseType: string;
|
|
108
|
+
|
|
109
|
+
if (prop.type === "number" || prop.type === "bigint") {
|
|
110
|
+
baseType = "number";
|
|
111
|
+
} else if (prop.type === "boolean") {
|
|
112
|
+
baseType = "number";
|
|
113
|
+
} else if (prop.type === "Date") {
|
|
114
|
+
baseType = "string";
|
|
115
|
+
} else if (
|
|
116
|
+
prop.type === "object" ||
|
|
117
|
+
prop.type.startsWith("Record<") ||
|
|
118
|
+
parseUnionValues(prop.type)
|
|
119
|
+
) {
|
|
120
|
+
baseType = "string";
|
|
121
|
+
} else {
|
|
122
|
+
baseType = "string";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const isGenerated = jsdoc.generated !== undefined;
|
|
126
|
+
const hasDefault = jsdoc.default !== undefined;
|
|
127
|
+
if (isGenerated || hasDefault) {
|
|
128
|
+
return `Generated<${baseType}>`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return baseType;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ─── Code Generation ────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
function generateTableInterface(
|
|
137
|
+
typeName: string,
|
|
138
|
+
meta: TypeMetadata,
|
|
139
|
+
dialect: KyselyDialect,
|
|
140
|
+
): { interfaceName: string; code: string; needsGenerated: boolean } {
|
|
141
|
+
const mapFn =
|
|
142
|
+
dialect === "postgresql" ? mapPgColumnType : mapSqliteColumnType;
|
|
143
|
+
let needsGenerated = false;
|
|
144
|
+
|
|
145
|
+
const columns: Array<{ colName: string; tsType: string; optional: boolean }> =
|
|
146
|
+
[];
|
|
147
|
+
|
|
148
|
+
for (const [propName, prop] of Object.entries(meta.properties)) {
|
|
149
|
+
const colName = toColumnName(propName);
|
|
150
|
+
const tsType = mapFn(prop);
|
|
151
|
+
if (tsType.startsWith("Generated<")) needsGenerated = true;
|
|
152
|
+
columns.push({ colName, tsType, optional: prop.optional });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const interfaceName = `${typeName}Table`;
|
|
156
|
+
|
|
157
|
+
let code = `export interface ${interfaceName} {\n`;
|
|
158
|
+
for (const col of columns) {
|
|
159
|
+
const nullSuffix = col.optional ? " | null" : "";
|
|
160
|
+
code += ` ${col.colName}: ${col.tsType}${nullSuffix};\n`;
|
|
161
|
+
}
|
|
162
|
+
code += "}\n";
|
|
163
|
+
|
|
164
|
+
return { interfaceName, code, needsGenerated };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function generateFile(
|
|
168
|
+
types: SchemaTypeMap,
|
|
169
|
+
dialect: KyselyDialect,
|
|
170
|
+
): GeneratedOutput {
|
|
171
|
+
const tableInterfaces: Array<{
|
|
172
|
+
tableName: string;
|
|
173
|
+
interfaceName: string;
|
|
174
|
+
code: string;
|
|
175
|
+
}> = [];
|
|
176
|
+
let needsGenerated = false;
|
|
177
|
+
|
|
178
|
+
for (const [typeName, meta] of Object.entries(types)) {
|
|
179
|
+
const tableName = toTableName(typeName, meta.jsdoc);
|
|
180
|
+
const result = generateTableInterface(typeName, meta, dialect);
|
|
181
|
+
if (result.needsGenerated) needsGenerated = true;
|
|
182
|
+
tableInterfaces.push({
|
|
183
|
+
tableName,
|
|
184
|
+
interfaceName: result.interfaceName,
|
|
185
|
+
code: result.code,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let content = `// AUTO-GENERATED by @typokit/db-kysely\n`;
|
|
190
|
+
content += `// Do not edit manually — modify the source type instead\n\n`;
|
|
191
|
+
|
|
192
|
+
if (needsGenerated) {
|
|
193
|
+
content += `import type { Generated } from "kysely";\n\n`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Emit table interfaces
|
|
197
|
+
for (const ti of tableInterfaces) {
|
|
198
|
+
content += ti.code + "\n";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Emit Database interface
|
|
202
|
+
content += `export interface Database {\n`;
|
|
203
|
+
for (const ti of tableInterfaces) {
|
|
204
|
+
content += ` ${ti.tableName}: ${ti.interfaceName};\n`;
|
|
205
|
+
}
|
|
206
|
+
content += "}\n";
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
filePath: `database.ts`,
|
|
210
|
+
content,
|
|
211
|
+
overwrite: true,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ─── Diff Logic ─────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
function diffTypes(
|
|
218
|
+
types: SchemaTypeMap,
|
|
219
|
+
currentState: DatabaseState,
|
|
220
|
+
): SchemaChange[] {
|
|
221
|
+
const changes: SchemaChange[] = [];
|
|
222
|
+
|
|
223
|
+
for (const [typeName, meta] of Object.entries(types)) {
|
|
224
|
+
const tableName = toTableName(typeName, meta.jsdoc);
|
|
225
|
+
const existing = currentState.tables[tableName];
|
|
226
|
+
|
|
227
|
+
if (!existing) {
|
|
228
|
+
changes.push({ type: "add", entity: tableName });
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
for (const [propName, prop] of Object.entries(meta.properties)) {
|
|
233
|
+
const colName = toColumnName(propName);
|
|
234
|
+
const existingCol = existing.columns[colName];
|
|
235
|
+
|
|
236
|
+
if (!existingCol) {
|
|
237
|
+
changes.push({
|
|
238
|
+
type: "add",
|
|
239
|
+
entity: tableName,
|
|
240
|
+
field: colName,
|
|
241
|
+
details: { tsType: prop.type },
|
|
242
|
+
});
|
|
243
|
+
} else {
|
|
244
|
+
const nullable = prop.optional;
|
|
245
|
+
if (existingCol.nullable !== nullable) {
|
|
246
|
+
changes.push({
|
|
247
|
+
type: "modify",
|
|
248
|
+
entity: tableName,
|
|
249
|
+
field: colName,
|
|
250
|
+
details: {
|
|
251
|
+
nullableFrom: existingCol.nullable,
|
|
252
|
+
nullableTo: nullable,
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Detect removed columns
|
|
260
|
+
for (const colName of Object.keys(existing.columns)) {
|
|
261
|
+
const hasProp = Object.keys(meta.properties).some(
|
|
262
|
+
(p) => toColumnName(p) === colName,
|
|
263
|
+
);
|
|
264
|
+
if (!hasProp) {
|
|
265
|
+
changes.push({ type: "remove", entity: tableName, field: colName });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Detect removed tables
|
|
271
|
+
for (const tableName of Object.keys(currentState.tables)) {
|
|
272
|
+
const hasType = Object.entries(types).some(
|
|
273
|
+
([name, meta]) => toTableName(name, meta.jsdoc) === tableName,
|
|
274
|
+
);
|
|
275
|
+
if (!hasType) {
|
|
276
|
+
changes.push({ type: "remove", entity: tableName });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return changes;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function generateMigrationSql(
|
|
284
|
+
changes: SchemaChange[],
|
|
285
|
+
dialect: KyselyDialect,
|
|
286
|
+
): string {
|
|
287
|
+
const lines: string[] = [];
|
|
288
|
+
|
|
289
|
+
for (const change of changes) {
|
|
290
|
+
if (change.type === "add" && !change.field) {
|
|
291
|
+
lines.push(`-- TODO: CREATE TABLE "${change.entity}" (define columns)`);
|
|
292
|
+
} else if (change.type === "add" && change.field) {
|
|
293
|
+
lines.push(
|
|
294
|
+
`ALTER TABLE "${change.entity}" ADD COLUMN "${change.field}" TEXT;`,
|
|
295
|
+
);
|
|
296
|
+
} else if (change.type === "remove" && !change.field) {
|
|
297
|
+
lines.push(`DROP TABLE IF EXISTS "${change.entity}";`);
|
|
298
|
+
} else if (change.type === "remove" && change.field) {
|
|
299
|
+
if (dialect === "postgresql") {
|
|
300
|
+
lines.push(
|
|
301
|
+
`ALTER TABLE "${change.entity}" DROP COLUMN "${change.field}";`,
|
|
302
|
+
);
|
|
303
|
+
} else {
|
|
304
|
+
lines.push(
|
|
305
|
+
`-- SQLite: cannot DROP COLUMN "${change.field}" from "${change.entity}" — recreate table`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
} else if (change.type === "modify") {
|
|
309
|
+
lines.push(
|
|
310
|
+
`-- TODO: ALTER TABLE "${change.entity}" modify column "${change.field}"`,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return lines.join("\n");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ─── Adapter ────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
export class KyselyDatabaseAdapter implements DatabaseAdapter {
|
|
321
|
+
private readonly dialect: KyselyDialect;
|
|
322
|
+
private readonly outputDir: string;
|
|
323
|
+
|
|
324
|
+
constructor(options?: KyselyAdapterOptions) {
|
|
325
|
+
this.dialect = options?.dialect ?? "postgresql";
|
|
326
|
+
this.outputDir = options?.outputDir ?? "kysely";
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
generate(types: SchemaTypeMap): GeneratedOutput[] {
|
|
330
|
+
const output = generateFile(types, this.dialect);
|
|
331
|
+
output.filePath = `${this.outputDir}/${output.filePath}`;
|
|
332
|
+
return [output];
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
diff(types: SchemaTypeMap, currentState: DatabaseState): MigrationDraft {
|
|
336
|
+
const changes = diffTypes(types, currentState);
|
|
337
|
+
const destructive = changes.some((c) => c.type === "remove");
|
|
338
|
+
const sql = generateMigrationSql(changes, this.dialect);
|
|
339
|
+
const timestamp = new Date()
|
|
340
|
+
.toISOString()
|
|
341
|
+
.replace(/[-:T]/g, "")
|
|
342
|
+
.slice(0, 14);
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
name: `${timestamp}_schema_update`,
|
|
346
|
+
sql,
|
|
347
|
+
destructive,
|
|
348
|
+
changes,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
}
|