@typokit/db-raw 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/src/index.ts ADDED
@@ -0,0 +1,478 @@
1
+ // @typokit/db-raw — Raw SQL DDL 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 RawSqlDialect = "postgresql" | "sqlite";
12
+
13
+ export interface RawSqlAdapterOptions {
14
+ dialect?: RawSqlDialect;
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
+ function toEnumName(typeName: string, propName: string): string {
60
+ return `${toSnakeCase(typeName)}_${toSnakeCase(propName)}`;
61
+ }
62
+
63
+ // ─── PostgreSQL Column Mapping ──────────────────────────────
64
+
65
+ function mapPgColumnType(prop: {
66
+ type: string;
67
+ optional: boolean;
68
+ jsdoc?: Record<string, string>;
69
+ }): string {
70
+ const jsdoc = prop.jsdoc ?? {};
71
+
72
+ if (
73
+ prop.type === "string" &&
74
+ (jsdoc.id !== undefined || jsdoc.generated === "uuid")
75
+ ) {
76
+ return "UUID";
77
+ }
78
+ if (prop.type === "string" && jsdoc.maxLength) {
79
+ return `VARCHAR(${jsdoc.maxLength})`;
80
+ }
81
+ if (prop.type === "string" && jsdoc.format === "email") {
82
+ return "VARCHAR(255)";
83
+ }
84
+ if (prop.type === "string") {
85
+ return "TEXT";
86
+ }
87
+ if (prop.type === "number") {
88
+ return "INTEGER";
89
+ }
90
+ if (prop.type === "bigint") {
91
+ return "BIGINT";
92
+ }
93
+ if (prop.type === "boolean") {
94
+ return "BOOLEAN";
95
+ }
96
+ if (prop.type === "Date") {
97
+ return "TIMESTAMPTZ";
98
+ }
99
+ // object, Record, unknown → JSONB
100
+ return "JSONB";
101
+ }
102
+
103
+ function mapSqliteColumnType(prop: {
104
+ type: string;
105
+ optional: boolean;
106
+ jsdoc?: Record<string, string>;
107
+ }): string {
108
+ if (prop.type === "number" || prop.type === "bigint") {
109
+ return "INTEGER";
110
+ }
111
+ if (prop.type === "boolean") {
112
+ return "INTEGER";
113
+ }
114
+ // string, Date, object, Record, enum unions → TEXT
115
+ return "TEXT";
116
+ }
117
+
118
+ // ─── PostgreSQL DDL Generation ──────────────────────────────
119
+
120
+ interface EnumDef {
121
+ name: string;
122
+ values: string[];
123
+ }
124
+
125
+ function generatePgDdl(
126
+ typeName: string,
127
+ meta: TypeMetadata,
128
+ ): { ddl: string; enums: EnumDef[] } {
129
+ const tableName = toTableName(typeName, meta.jsdoc);
130
+ const enums: EnumDef[] = [];
131
+ const columns: string[] = [];
132
+
133
+ for (const [propName, prop] of Object.entries(meta.properties)) {
134
+ const col = toColumnName(propName);
135
+ const jsdoc = prop.jsdoc ?? {};
136
+ const parts: string[] = [col];
137
+
138
+ // Check for union → CREATE TYPE enum
139
+ const unionValues = parseUnionValues(prop.type);
140
+ if (unionValues) {
141
+ const enumName = toEnumName(typeName, propName);
142
+ enums.push({ name: enumName, values: unionValues });
143
+ parts.push(enumName);
144
+ } else {
145
+ parts.push(mapPgColumnType(prop));
146
+ }
147
+
148
+ // NOT NULL
149
+ if (!prop.optional) {
150
+ parts.push("NOT NULL");
151
+ }
152
+
153
+ // PRIMARY KEY
154
+ if (jsdoc.id !== undefined) {
155
+ parts.push("PRIMARY KEY");
156
+ }
157
+
158
+ // UNIQUE
159
+ if (jsdoc.unique !== undefined) {
160
+ parts.push("UNIQUE");
161
+ }
162
+
163
+ // DEFAULT
164
+ if (jsdoc.generated === "uuid") {
165
+ parts.push("DEFAULT gen_random_uuid()");
166
+ } else if (jsdoc.generated === "now" || jsdoc.onUpdate === "now") {
167
+ parts.push("DEFAULT now()");
168
+ } else if (jsdoc.default !== undefined) {
169
+ const defaultVal = jsdoc.default.replace(/^["']|["']$/g, "");
170
+ if (/^\d+$/.test(defaultVal)) {
171
+ parts.push(`DEFAULT ${defaultVal}`);
172
+ } else if (defaultVal === "true" || defaultVal === "false") {
173
+ parts.push(`DEFAULT ${defaultVal}`);
174
+ } else {
175
+ parts.push(`DEFAULT '${defaultVal}'`);
176
+ }
177
+ }
178
+
179
+ columns.push(" " + parts.join(" "));
180
+ }
181
+
182
+ let ddl = "";
183
+
184
+ // Enum type definitions
185
+ for (const e of enums) {
186
+ ddl += `CREATE TYPE ${e.name} AS ENUM (${e.values.map((v) => `'${v}'`).join(", ")});\n\n`;
187
+ }
188
+
189
+ // Table definition
190
+ ddl += `CREATE TABLE ${tableName} (\n`;
191
+ ddl += columns.join(",\n") + "\n";
192
+ ddl += ");";
193
+
194
+ return { ddl, enums };
195
+ }
196
+
197
+ // ─── SQLite DDL Generation ──────────────────────────────────
198
+
199
+ function generateSqliteDdl(typeName: string, meta: TypeMetadata): string {
200
+ const tableName = toTableName(typeName, meta.jsdoc);
201
+ const columns: string[] = [];
202
+
203
+ for (const [propName, prop] of Object.entries(meta.properties)) {
204
+ const col = toColumnName(propName);
205
+ const jsdoc = prop.jsdoc ?? {};
206
+ const parts: string[] = [col];
207
+
208
+ parts.push(mapSqliteColumnType(prop));
209
+
210
+ // NOT NULL
211
+ if (!prop.optional) {
212
+ parts.push("NOT NULL");
213
+ }
214
+
215
+ // PRIMARY KEY
216
+ if (jsdoc.id !== undefined) {
217
+ parts.push("PRIMARY KEY");
218
+ }
219
+
220
+ // UNIQUE
221
+ if (jsdoc.unique !== undefined) {
222
+ parts.push("UNIQUE");
223
+ }
224
+
225
+ // DEFAULT
226
+ if (jsdoc.default !== undefined) {
227
+ const defaultVal = jsdoc.default.replace(/^["']|["']$/g, "");
228
+ if (/^\d+$/.test(defaultVal)) {
229
+ parts.push(`DEFAULT ${defaultVal}`);
230
+ } else if (defaultVal === "true" || defaultVal === "false") {
231
+ parts.push(`DEFAULT ${defaultVal === "true" ? "1" : "0"}`);
232
+ } else {
233
+ parts.push(`DEFAULT '${defaultVal}'`);
234
+ }
235
+ }
236
+
237
+ columns.push(" " + parts.join(" "));
238
+ }
239
+
240
+ let ddl = `CREATE TABLE ${tableName} (\n`;
241
+ ddl += columns.join(",\n") + "\n";
242
+ ddl += ");";
243
+
244
+ return ddl;
245
+ }
246
+
247
+ // ─── TypeScript Interface Generation ────────────────────────
248
+
249
+ function tsTypeFromProp(prop: { type: string; optional: boolean }): string {
250
+ const unionValues = parseUnionValues(prop.type);
251
+ if (unionValues) {
252
+ return unionValues.map((v) => `"${v}"`).join(" | ");
253
+ }
254
+ return prop.type;
255
+ }
256
+
257
+ function generateTypeScriptInterface(
258
+ typeName: string,
259
+ meta: TypeMetadata,
260
+ ): string {
261
+ const lines: string[] = [];
262
+ lines.push(`export interface ${typeName} {`);
263
+
264
+ for (const [propName, prop] of Object.entries(meta.properties)) {
265
+ const tsType = tsTypeFromProp(prop);
266
+ const opt = prop.optional ? "?" : "";
267
+ lines.push(` ${propName}${opt}: ${tsType};`);
268
+ }
269
+
270
+ lines.push("}");
271
+ return lines.join("\n");
272
+ }
273
+
274
+ // ─── Full File Generation ───────────────────────────────────
275
+
276
+ function generatePgFile(typeName: string, meta: TypeMetadata): GeneratedOutput {
277
+ const tableName = toTableName(typeName, meta.jsdoc);
278
+ const { ddl } = generatePgDdl(typeName, meta);
279
+
280
+ let content = `-- AUTO-GENERATED by @typokit/db-raw from ${typeName} type\n`;
281
+ content += `-- Do not edit manually — modify the source type instead\n\n`;
282
+ content += ddl + "\n";
283
+
284
+ return {
285
+ filePath: `${tableName}.sql`,
286
+ content,
287
+ overwrite: true,
288
+ };
289
+ }
290
+
291
+ function generatePgTypesFile(types: SchemaTypeMap): GeneratedOutput {
292
+ let content = `// AUTO-GENERATED by @typokit/db-raw\n`;
293
+ content += `// Do not edit manually — modify the source types instead\n\n`;
294
+
295
+ const interfaces: string[] = [];
296
+ for (const [typeName, meta] of Object.entries(types)) {
297
+ interfaces.push(generateTypeScriptInterface(typeName, meta));
298
+ }
299
+
300
+ content += interfaces.join("\n\n") + "\n";
301
+
302
+ return {
303
+ filePath: "types.ts",
304
+ content,
305
+ overwrite: true,
306
+ };
307
+ }
308
+
309
+ function generateSqliteFile(
310
+ typeName: string,
311
+ meta: TypeMetadata,
312
+ ): GeneratedOutput {
313
+ const tableName = toTableName(typeName, meta.jsdoc);
314
+ const ddl = generateSqliteDdl(typeName, meta);
315
+
316
+ let content = `-- AUTO-GENERATED by @typokit/db-raw from ${typeName} type\n`;
317
+ content += `-- Do not edit manually — modify the source type instead\n\n`;
318
+ content += ddl + "\n";
319
+
320
+ return {
321
+ filePath: `${tableName}.sql`,
322
+ content,
323
+ overwrite: true,
324
+ };
325
+ }
326
+
327
+ // ─── Diff Logic ─────────────────────────────────────────────
328
+
329
+ function diffTypes(
330
+ types: SchemaTypeMap,
331
+ currentState: DatabaseState,
332
+ ): SchemaChange[] {
333
+ const changes: SchemaChange[] = [];
334
+
335
+ for (const [typeName, meta] of Object.entries(types)) {
336
+ const tableName = toTableName(typeName, meta.jsdoc);
337
+ const existing = currentState.tables[tableName];
338
+
339
+ if (!existing) {
340
+ changes.push({ type: "add", entity: tableName });
341
+ continue;
342
+ }
343
+
344
+ for (const [propName, prop] of Object.entries(meta.properties)) {
345
+ const colName = toColumnName(propName);
346
+ const existingCol = existing.columns[colName];
347
+
348
+ if (!existingCol) {
349
+ changes.push({
350
+ type: "add",
351
+ entity: tableName,
352
+ field: colName,
353
+ details: { tsType: prop.type },
354
+ });
355
+ } else {
356
+ const nullable = prop.optional;
357
+ if (existingCol.nullable !== nullable) {
358
+ changes.push({
359
+ type: "modify",
360
+ entity: tableName,
361
+ field: colName,
362
+ details: {
363
+ nullableFrom: existingCol.nullable,
364
+ nullableTo: nullable,
365
+ },
366
+ });
367
+ }
368
+ }
369
+ }
370
+
371
+ // Detect removed columns
372
+ for (const colName of Object.keys(existing.columns)) {
373
+ const hasProp = Object.keys(meta.properties).some(
374
+ (p) => toColumnName(p) === colName,
375
+ );
376
+ if (!hasProp) {
377
+ changes.push({ type: "remove", entity: tableName, field: colName });
378
+ }
379
+ }
380
+ }
381
+
382
+ // Detect removed tables
383
+ for (const tableName of Object.keys(currentState.tables)) {
384
+ const hasType = Object.entries(types).some(
385
+ ([name, meta]) => toTableName(name, meta.jsdoc) === tableName,
386
+ );
387
+ if (!hasType) {
388
+ changes.push({ type: "remove", entity: tableName });
389
+ }
390
+ }
391
+
392
+ return changes;
393
+ }
394
+
395
+ function generateMigrationSql(
396
+ changes: SchemaChange[],
397
+ dialect: RawSqlDialect,
398
+ ): string {
399
+ const lines: string[] = [];
400
+
401
+ for (const change of changes) {
402
+ if (change.type === "add" && !change.field) {
403
+ lines.push(`-- TODO: CREATE TABLE "${change.entity}" (define columns)`);
404
+ } else if (change.type === "add" && change.field) {
405
+ lines.push(
406
+ `ALTER TABLE "${change.entity}" ADD COLUMN "${change.field}" TEXT;`,
407
+ );
408
+ } else if (change.type === "remove" && !change.field) {
409
+ lines.push(`-- DESTRUCTIVE: requires review`);
410
+ lines.push(`DROP TABLE IF EXISTS "${change.entity}";`);
411
+ } else if (change.type === "remove" && change.field) {
412
+ lines.push(`-- DESTRUCTIVE: requires review`);
413
+ if (dialect === "postgresql") {
414
+ lines.push(
415
+ `ALTER TABLE "${change.entity}" DROP COLUMN "${change.field}";`,
416
+ );
417
+ } else {
418
+ lines.push(
419
+ `-- SQLite: cannot DROP COLUMN "${change.field}" from "${change.entity}" — recreate table`,
420
+ );
421
+ }
422
+ } else if (change.type === "modify") {
423
+ lines.push(
424
+ `-- TODO: ALTER TABLE "${change.entity}" modify column "${change.field}"`,
425
+ );
426
+ }
427
+ }
428
+
429
+ return lines.join("\n");
430
+ }
431
+
432
+ // ─── Adapter ────────────────────────────────────────────────
433
+
434
+ export class RawSqlDatabaseAdapter implements DatabaseAdapter {
435
+ private readonly dialect: RawSqlDialect;
436
+ private readonly outputDir: string;
437
+
438
+ constructor(options?: RawSqlAdapterOptions) {
439
+ this.dialect = options?.dialect ?? "postgresql";
440
+ this.outputDir = options?.outputDir ?? "sql";
441
+ }
442
+
443
+ generate(types: SchemaTypeMap): GeneratedOutput[] {
444
+ const outputs: GeneratedOutput[] = [];
445
+ const genFn =
446
+ this.dialect === "postgresql" ? generatePgFile : generateSqliteFile;
447
+
448
+ for (const [typeName, meta] of Object.entries(types)) {
449
+ const output = genFn(typeName, meta);
450
+ output.filePath = `${this.outputDir}/${output.filePath}`;
451
+ outputs.push(output);
452
+ }
453
+
454
+ // Generate TypeScript interfaces file
455
+ const tsOutput = generatePgTypesFile(types);
456
+ tsOutput.filePath = `${this.outputDir}/${tsOutput.filePath}`;
457
+ outputs.push(tsOutput);
458
+
459
+ return outputs;
460
+ }
461
+
462
+ diff(types: SchemaTypeMap, currentState: DatabaseState): MigrationDraft {
463
+ const changes = diffTypes(types, currentState);
464
+ const destructive = changes.some((c) => c.type === "remove");
465
+ const sql = generateMigrationSql(changes, this.dialect);
466
+ const timestamp = new Date()
467
+ .toISOString()
468
+ .replace(/[-:T]/g, "")
469
+ .slice(0, 14);
470
+
471
+ return {
472
+ name: `${timestamp}_schema_update`,
473
+ sql,
474
+ destructive,
475
+ changes,
476
+ };
477
+ }
478
+ }