@typokit/cli 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/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +13 -0
- package/dist/bin.js.map +1 -0
- package/dist/commands/build.d.ts +42 -0
- package/dist/commands/build.d.ts.map +1 -0
- package/dist/commands/build.js +302 -0
- package/dist/commands/build.js.map +1 -0
- package/dist/commands/dev.d.ts +106 -0
- package/dist/commands/dev.d.ts.map +1 -0
- package/dist/commands/dev.js +536 -0
- package/dist/commands/dev.js.map +1 -0
- package/dist/commands/generate.d.ts +65 -0
- package/dist/commands/generate.d.ts.map +1 -0
- package/dist/commands/generate.js +430 -0
- package/dist/commands/generate.js.map +1 -0
- package/dist/commands/inspect.d.ts +26 -0
- package/dist/commands/inspect.d.ts.map +1 -0
- package/dist/commands/inspect.js +579 -0
- package/dist/commands/inspect.js.map +1 -0
- package/dist/commands/migrate.d.ts +70 -0
- package/dist/commands/migrate.d.ts.map +1 -0
- package/dist/commands/migrate.js +570 -0
- package/dist/commands/migrate.js.map +1 -0
- package/dist/commands/scaffold.d.ts +70 -0
- package/dist/commands/scaffold.d.ts.map +1 -0
- package/dist/commands/scaffold.js +483 -0
- package/dist/commands/scaffold.js.map +1 -0
- package/dist/commands/test.d.ts +56 -0
- package/dist/commands/test.d.ts.map +1 -0
- package/dist/commands/test.js +248 -0
- package/dist/commands/test.js.map +1 -0
- package/dist/config.d.ts +20 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +69 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +245 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +12 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +33 -0
- package/dist/logger.js.map +1 -0
- package/package.json +33 -0
- package/src/bin.ts +22 -0
- package/src/commands/build.ts +433 -0
- package/src/commands/dev.ts +822 -0
- package/src/commands/generate.ts +640 -0
- package/src/commands/inspect.ts +885 -0
- package/src/commands/migrate.ts +800 -0
- package/src/commands/scaffold.ts +627 -0
- package/src/commands/test.ts +353 -0
- package/src/config.ts +93 -0
- package/src/dev.test.ts +285 -0
- package/src/env.d.ts +86 -0
- package/src/generate.test.ts +304 -0
- package/src/index.test.ts +217 -0
- package/src/index.ts +397 -0
- package/src/inspect.test.ts +411 -0
- package/src/logger.ts +49 -0
- package/src/migrate.test.ts +205 -0
- package/src/scaffold.test.ts +256 -0
- package/src/test.test.ts +230 -0
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
// @typokit/cli — Migration Commands
|
|
2
|
+
|
|
3
|
+
import type { CliLogger } from "../logger.js";
|
|
4
|
+
import type { TypoKitConfig } from "../config.js";
|
|
5
|
+
import type { SchemaChange, MigrationDraft } from "@typokit/types";
|
|
6
|
+
|
|
7
|
+
export interface MigrateCommandOptions {
|
|
8
|
+
/** Project root directory */
|
|
9
|
+
rootDir: string;
|
|
10
|
+
/** Resolved configuration */
|
|
11
|
+
config: Required<TypoKitConfig>;
|
|
12
|
+
/** Logger instance */
|
|
13
|
+
logger: CliLogger;
|
|
14
|
+
/** Migrate subcommand: generate, diff, apply */
|
|
15
|
+
subcommand: string;
|
|
16
|
+
/** CLI flags */
|
|
17
|
+
flags: Record<string, string | boolean>;
|
|
18
|
+
/** Whether verbose mode is enabled */
|
|
19
|
+
verbose: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface MigrateResult {
|
|
23
|
+
/** Whether the command succeeded */
|
|
24
|
+
success: boolean;
|
|
25
|
+
/** Files generated or updated */
|
|
26
|
+
filesWritten: string[];
|
|
27
|
+
/** Duration in milliseconds */
|
|
28
|
+
duration: number;
|
|
29
|
+
/** Errors encountered */
|
|
30
|
+
errors: string[];
|
|
31
|
+
/** Whether any destructive changes were detected */
|
|
32
|
+
destructive: boolean;
|
|
33
|
+
/** Schema changes detected */
|
|
34
|
+
changes: SchemaChange[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ─── Helpers ──────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Get the migrations directory path.
|
|
41
|
+
*/
|
|
42
|
+
function getMigrationsDir(
|
|
43
|
+
rootDir: string,
|
|
44
|
+
outputDir: string,
|
|
45
|
+
join: (...args: string[]) => string,
|
|
46
|
+
): string {
|
|
47
|
+
return join(rootDir, outputDir, "migrations");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Generate a timestamp string for migration file names (YYYYMMDDHHMMSS).
|
|
52
|
+
*/
|
|
53
|
+
function generateTimestamp(): string {
|
|
54
|
+
const now = new Date();
|
|
55
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
56
|
+
return (
|
|
57
|
+
String(now.getFullYear()) +
|
|
58
|
+
pad(now.getMonth() + 1) +
|
|
59
|
+
pad(now.getDate()) +
|
|
60
|
+
pad(now.getHours()) +
|
|
61
|
+
pad(now.getMinutes()) +
|
|
62
|
+
pad(now.getSeconds())
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Sanitize a migration name for use in file names.
|
|
68
|
+
*/
|
|
69
|
+
function sanitizeName(name: string): string {
|
|
70
|
+
return name
|
|
71
|
+
.toLowerCase()
|
|
72
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
73
|
+
.replace(/^_+|_+$/g, "");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Check if a schema change is destructive (column drops, type changes).
|
|
78
|
+
*/
|
|
79
|
+
function isDestructiveChange(change: SchemaChange): boolean {
|
|
80
|
+
if (change.type === "remove") return true;
|
|
81
|
+
if (change.type === "modify" && change.details) {
|
|
82
|
+
// Type changes are destructive
|
|
83
|
+
if ("oldType" in change.details || "newType" in change.details) return true;
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Annotate SQL with destructive comments where needed.
|
|
90
|
+
*/
|
|
91
|
+
function annotateSql(sql: string, changes: SchemaChange[]): string {
|
|
92
|
+
const hasDestructive = changes.some(isDestructiveChange);
|
|
93
|
+
if (!hasDestructive) return sql;
|
|
94
|
+
|
|
95
|
+
const lines = sql.split("\n");
|
|
96
|
+
const annotated: string[] = [];
|
|
97
|
+
|
|
98
|
+
for (const line of lines) {
|
|
99
|
+
const trimmed = line.trim().toUpperCase();
|
|
100
|
+
if (
|
|
101
|
+
trimmed.startsWith("DROP") ||
|
|
102
|
+
(trimmed.startsWith("ALTER") &&
|
|
103
|
+
(trimmed.includes("DROP") || trimmed.includes("TYPE")))
|
|
104
|
+
) {
|
|
105
|
+
annotated.push("-- DESTRUCTIVE: requires review");
|
|
106
|
+
}
|
|
107
|
+
annotated.push(line);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return annotated.join("\n");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Resolve glob patterns to actual file paths.
|
|
115
|
+
*/
|
|
116
|
+
async function resolveFilePatterns(
|
|
117
|
+
rootDir: string,
|
|
118
|
+
patterns: string[],
|
|
119
|
+
): Promise<string[]> {
|
|
120
|
+
const { join, resolve } = (await import(/* @vite-ignore */ "path")) as {
|
|
121
|
+
join: (...args: string[]) => string;
|
|
122
|
+
resolve: (...args: string[]) => string;
|
|
123
|
+
};
|
|
124
|
+
const { readdirSync, statSync, existsSync } = (await import(
|
|
125
|
+
/* @vite-ignore */ "fs"
|
|
126
|
+
)) as {
|
|
127
|
+
readdirSync: (p: string) => string[];
|
|
128
|
+
statSync: (p: string) => { isFile(): boolean; isDirectory(): boolean };
|
|
129
|
+
existsSync: (p: string) => boolean;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const files: string[] = [];
|
|
133
|
+
|
|
134
|
+
for (const pattern of patterns) {
|
|
135
|
+
if (pattern.includes("*")) {
|
|
136
|
+
const parts = pattern.split("/");
|
|
137
|
+
const hasDoubleGlob = parts.includes("**");
|
|
138
|
+
const lastPart = parts[parts.length - 1];
|
|
139
|
+
|
|
140
|
+
const baseParts: string[] = [];
|
|
141
|
+
for (const part of parts) {
|
|
142
|
+
if (part.includes("*")) break;
|
|
143
|
+
baseParts.push(part);
|
|
144
|
+
}
|
|
145
|
+
const baseDir =
|
|
146
|
+
baseParts.length > 0 ? join(rootDir, ...baseParts) : rootDir;
|
|
147
|
+
|
|
148
|
+
if (!existsSync(baseDir)) continue;
|
|
149
|
+
|
|
150
|
+
const entries = hasDoubleGlob
|
|
151
|
+
? listFilesRecursive(baseDir, existsSync, readdirSync, statSync, join)
|
|
152
|
+
: readdirSync(baseDir).map((f) => join(baseDir, f));
|
|
153
|
+
|
|
154
|
+
const filePattern = lastPart.replace(/\*/g, ".*");
|
|
155
|
+
const regex = new RegExp(`^${filePattern}$`);
|
|
156
|
+
|
|
157
|
+
for (const entry of entries) {
|
|
158
|
+
const name = entry.split(/[\\/]/).pop() ?? "";
|
|
159
|
+
if (regex.test(name)) {
|
|
160
|
+
files.push(resolve(entry));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
} else {
|
|
164
|
+
const fullPath = resolve(join(rootDir, pattern));
|
|
165
|
+
if (existsSync(fullPath)) {
|
|
166
|
+
files.push(fullPath);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return [...new Set(files)].sort();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function listFilesRecursive(
|
|
175
|
+
dir: string,
|
|
176
|
+
existsSync: (p: string) => boolean,
|
|
177
|
+
readdirSync: (p: string) => string[],
|
|
178
|
+
statSync: (p: string) => { isFile(): boolean; isDirectory(): boolean },
|
|
179
|
+
join: (...args: string[]) => string,
|
|
180
|
+
): string[] {
|
|
181
|
+
if (!existsSync(dir)) return [];
|
|
182
|
+
const results: string[] = [];
|
|
183
|
+
const entries = readdirSync(dir);
|
|
184
|
+
for (const entry of entries) {
|
|
185
|
+
const fullPath = join(dir, entry);
|
|
186
|
+
try {
|
|
187
|
+
const stat = statSync(fullPath);
|
|
188
|
+
if (stat.isDirectory()) {
|
|
189
|
+
if (
|
|
190
|
+
entry !== "node_modules" &&
|
|
191
|
+
entry !== "dist" &&
|
|
192
|
+
entry !== ".typokit"
|
|
193
|
+
) {
|
|
194
|
+
results.push(
|
|
195
|
+
...listFilesRecursive(
|
|
196
|
+
fullPath,
|
|
197
|
+
existsSync,
|
|
198
|
+
readdirSync,
|
|
199
|
+
statSync,
|
|
200
|
+
join,
|
|
201
|
+
),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
} else if (stat.isFile()) {
|
|
205
|
+
results.push(fullPath);
|
|
206
|
+
}
|
|
207
|
+
} catch {
|
|
208
|
+
// Skip files that can't be stat'd
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return results;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Load the current schema snapshot from the .typokit directory.
|
|
216
|
+
* Returns empty object if no snapshot exists.
|
|
217
|
+
*/
|
|
218
|
+
async function loadSchemaSnapshot(
|
|
219
|
+
rootDir: string,
|
|
220
|
+
outputDir: string,
|
|
221
|
+
): Promise<Record<string, unknown>> {
|
|
222
|
+
const { join } = (await import(/* @vite-ignore */ "path")) as {
|
|
223
|
+
join: (...args: string[]) => string;
|
|
224
|
+
};
|
|
225
|
+
const { existsSync, readFileSync } = (await import(
|
|
226
|
+
/* @vite-ignore */ "fs"
|
|
227
|
+
)) as {
|
|
228
|
+
existsSync: (p: string) => boolean;
|
|
229
|
+
readFileSync: (p: string, encoding: string) => string;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const snapshotPath = join(rootDir, outputDir, "schemas", "schema-types.json");
|
|
233
|
+
if (!existsSync(snapshotPath)) return {};
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
const content = readFileSync(snapshotPath, "utf-8");
|
|
237
|
+
return JSON.parse(content) as Record<string, unknown>;
|
|
238
|
+
} catch {
|
|
239
|
+
return {};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Save a schema snapshot after migration generation.
|
|
245
|
+
*/
|
|
246
|
+
async function saveSchemaSnapshot(
|
|
247
|
+
rootDir: string,
|
|
248
|
+
outputDir: string,
|
|
249
|
+
types: Record<string, unknown>,
|
|
250
|
+
): Promise<void> {
|
|
251
|
+
const { join } = (await import(/* @vite-ignore */ "path")) as {
|
|
252
|
+
join: (...args: string[]) => string;
|
|
253
|
+
};
|
|
254
|
+
const nodeFs = (await import(/* @vite-ignore */ "fs")) as {
|
|
255
|
+
mkdirSync: (p: string, opts?: { recursive?: boolean }) => void;
|
|
256
|
+
writeFileSync: (p: string, data: string, encoding?: string) => void;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const schemaDir = join(rootDir, outputDir, "schemas");
|
|
260
|
+
nodeFs.mkdirSync(schemaDir, { recursive: true });
|
|
261
|
+
const snapshotPath = join(schemaDir, "schema-types.json");
|
|
262
|
+
nodeFs.writeFileSync(snapshotPath, JSON.stringify(types, null, 2), "utf-8");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ─── migrate:generate ─────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Generate a migration draft from type schema diffs.
|
|
269
|
+
* Detects changes between the saved schema snapshot and current types,
|
|
270
|
+
* then generates a timestamped migration file.
|
|
271
|
+
*/
|
|
272
|
+
async function migrateGenerate(
|
|
273
|
+
options: MigrateCommandOptions,
|
|
274
|
+
): Promise<MigrateResult> {
|
|
275
|
+
const startTime = Date.now();
|
|
276
|
+
const { config, rootDir, logger, verbose, flags } = options;
|
|
277
|
+
const filesWritten: string[] = [];
|
|
278
|
+
const errors: string[] = [];
|
|
279
|
+
|
|
280
|
+
const name = typeof flags["name"] === "string" ? flags["name"] : "migration";
|
|
281
|
+
|
|
282
|
+
logger.step("migrate:generate", "Resolving type files...");
|
|
283
|
+
const typeFiles = await resolveFilePatterns(rootDir, config.typeFiles);
|
|
284
|
+
|
|
285
|
+
if (typeFiles.length === 0) {
|
|
286
|
+
logger.warn("No type files found matching configured patterns");
|
|
287
|
+
return {
|
|
288
|
+
success: true,
|
|
289
|
+
filesWritten,
|
|
290
|
+
duration: Date.now() - startTime,
|
|
291
|
+
errors,
|
|
292
|
+
destructive: false,
|
|
293
|
+
changes: [],
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (verbose) {
|
|
298
|
+
logger.verbose(`Type files: ${typeFiles.length} found`);
|
|
299
|
+
for (const f of typeFiles) logger.verbose(` ${f}`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
// Extract current types
|
|
304
|
+
logger.step("migrate:generate", "Extracting type metadata...");
|
|
305
|
+
const { parseAndExtractTypes } = (await import(
|
|
306
|
+
/* @vite-ignore */ "@typokit/transform-native"
|
|
307
|
+
)) as {
|
|
308
|
+
parseAndExtractTypes: (
|
|
309
|
+
files: string[],
|
|
310
|
+
) => Promise<Record<string, unknown>>;
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const currentTypes = await parseAndExtractTypes(typeFiles);
|
|
314
|
+
const typeCount = Object.keys(currentTypes).length;
|
|
315
|
+
|
|
316
|
+
if (typeCount === 0) {
|
|
317
|
+
logger.warn("No types extracted from source files");
|
|
318
|
+
return {
|
|
319
|
+
success: true,
|
|
320
|
+
filesWritten,
|
|
321
|
+
duration: Date.now() - startTime,
|
|
322
|
+
errors,
|
|
323
|
+
destructive: false,
|
|
324
|
+
changes: [],
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
logger.step("migrate:generate", `Extracted ${typeCount} types`);
|
|
329
|
+
|
|
330
|
+
// Load previous snapshot
|
|
331
|
+
const previousTypes = await loadSchemaSnapshot(rootDir, config.outputDir);
|
|
332
|
+
|
|
333
|
+
// Diff schemas
|
|
334
|
+
logger.step("migrate:generate", "Diffing schemas...");
|
|
335
|
+
const { diffSchemas } = (await import(
|
|
336
|
+
/* @vite-ignore */ "@typokit/transform-native"
|
|
337
|
+
)) as {
|
|
338
|
+
diffSchemas: (
|
|
339
|
+
oldTypes: Record<string, unknown>,
|
|
340
|
+
newTypes: Record<string, unknown>,
|
|
341
|
+
name: string,
|
|
342
|
+
) => Promise<MigrationDraft>;
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const migration = await diffSchemas(previousTypes, currentTypes, name);
|
|
346
|
+
|
|
347
|
+
if (migration.changes.length === 0) {
|
|
348
|
+
logger.info("No schema changes detected");
|
|
349
|
+
return {
|
|
350
|
+
success: true,
|
|
351
|
+
filesWritten,
|
|
352
|
+
duration: Date.now() - startTime,
|
|
353
|
+
errors,
|
|
354
|
+
destructive: false,
|
|
355
|
+
changes: [],
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Generate migration file
|
|
360
|
+
const { join } = (await import(/* @vite-ignore */ "path")) as {
|
|
361
|
+
join: (...args: string[]) => string;
|
|
362
|
+
};
|
|
363
|
+
const nodeFs = (await import(/* @vite-ignore */ "fs")) as {
|
|
364
|
+
mkdirSync: (p: string, opts?: { recursive?: boolean }) => void;
|
|
365
|
+
writeFileSync: (p: string, data: string, encoding?: string) => void;
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const migrationsDir = getMigrationsDir(rootDir, config.outputDir, join);
|
|
369
|
+
nodeFs.mkdirSync(migrationsDir, { recursive: true });
|
|
370
|
+
|
|
371
|
+
const timestamp = generateTimestamp();
|
|
372
|
+
const safeName = sanitizeName(name);
|
|
373
|
+
const fileName = `${timestamp}_${safeName}.sql`;
|
|
374
|
+
const filePath = join(migrationsDir, fileName);
|
|
375
|
+
|
|
376
|
+
// Annotate destructive changes
|
|
377
|
+
const annotatedSql = annotateSql(migration.sql, migration.changes);
|
|
378
|
+
|
|
379
|
+
// Build migration file content
|
|
380
|
+
const header = [
|
|
381
|
+
`-- Migration: ${name}`,
|
|
382
|
+
`-- Generated: ${new Date().toISOString()}`,
|
|
383
|
+
`-- Changes: ${migration.changes.length}`,
|
|
384
|
+
migration.destructive
|
|
385
|
+
? "-- WARNING: Contains destructive changes that require review"
|
|
386
|
+
: "",
|
|
387
|
+
"",
|
|
388
|
+
]
|
|
389
|
+
.filter(Boolean)
|
|
390
|
+
.join("\n");
|
|
391
|
+
|
|
392
|
+
const content = header + "\n" + annotatedSql + "\n";
|
|
393
|
+
|
|
394
|
+
nodeFs.writeFileSync(filePath, content, "utf-8");
|
|
395
|
+
filesWritten.push(filePath);
|
|
396
|
+
logger.success(`Generated migration: ${fileName}`);
|
|
397
|
+
|
|
398
|
+
// Write metadata JSON alongside
|
|
399
|
+
const metaPath = join(migrationsDir, `${timestamp}_${safeName}.json`);
|
|
400
|
+
const meta = {
|
|
401
|
+
name: migration.name,
|
|
402
|
+
timestamp,
|
|
403
|
+
destructive: migration.destructive,
|
|
404
|
+
changes: migration.changes,
|
|
405
|
+
fileName,
|
|
406
|
+
};
|
|
407
|
+
nodeFs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), "utf-8");
|
|
408
|
+
filesWritten.push(metaPath);
|
|
409
|
+
|
|
410
|
+
// Save updated schema snapshot
|
|
411
|
+
await saveSchemaSnapshot(rootDir, config.outputDir, currentTypes);
|
|
412
|
+
|
|
413
|
+
if (migration.destructive) {
|
|
414
|
+
logger.warn(
|
|
415
|
+
"Migration contains DESTRUCTIVE changes — review required before applying",
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (verbose) {
|
|
420
|
+
logger.verbose(`Changes: ${migration.changes.length}`);
|
|
421
|
+
for (const change of migration.changes) {
|
|
422
|
+
const desc = change.field
|
|
423
|
+
? `${change.type} ${change.entity}.${change.field}`
|
|
424
|
+
: `${change.type} ${change.entity}`;
|
|
425
|
+
logger.verbose(` ${desc}`);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const duration = Date.now() - startTime;
|
|
430
|
+
logger.success(
|
|
431
|
+
`migrate:generate complete — ${filesWritten.length} files written (${duration}ms)`,
|
|
432
|
+
);
|
|
433
|
+
return {
|
|
434
|
+
success: true,
|
|
435
|
+
filesWritten,
|
|
436
|
+
duration,
|
|
437
|
+
errors,
|
|
438
|
+
destructive: migration.destructive,
|
|
439
|
+
changes: migration.changes,
|
|
440
|
+
};
|
|
441
|
+
} catch (err: unknown) {
|
|
442
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
443
|
+
logger.error(`migrate:generate failed: ${message}`);
|
|
444
|
+
errors.push(message);
|
|
445
|
+
return {
|
|
446
|
+
success: false,
|
|
447
|
+
filesWritten,
|
|
448
|
+
duration: Date.now() - startTime,
|
|
449
|
+
errors,
|
|
450
|
+
destructive: false,
|
|
451
|
+
changes: [],
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ─── migrate:diff ─────────────────────────────────────────────
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Show pending schema changes as a structured diff.
|
|
460
|
+
* Human-readable by default, JSON with --json flag.
|
|
461
|
+
*/
|
|
462
|
+
async function migrateDiff(
|
|
463
|
+
options: MigrateCommandOptions,
|
|
464
|
+
): Promise<MigrateResult> {
|
|
465
|
+
const startTime = Date.now();
|
|
466
|
+
const { config, rootDir, logger, verbose, flags } = options;
|
|
467
|
+
const errors: string[] = [];
|
|
468
|
+
const asJson = flags["json"] === true || flags["format"] === "json";
|
|
469
|
+
|
|
470
|
+
logger.step("migrate:diff", "Resolving type files...");
|
|
471
|
+
const typeFiles = await resolveFilePatterns(rootDir, config.typeFiles);
|
|
472
|
+
|
|
473
|
+
if (typeFiles.length === 0) {
|
|
474
|
+
logger.warn("No type files found matching configured patterns");
|
|
475
|
+
return {
|
|
476
|
+
success: true,
|
|
477
|
+
filesWritten: [],
|
|
478
|
+
duration: Date.now() - startTime,
|
|
479
|
+
errors,
|
|
480
|
+
destructive: false,
|
|
481
|
+
changes: [],
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
try {
|
|
486
|
+
// Extract current types
|
|
487
|
+
const { parseAndExtractTypes } = (await import(
|
|
488
|
+
/* @vite-ignore */ "@typokit/transform-native"
|
|
489
|
+
)) as {
|
|
490
|
+
parseAndExtractTypes: (
|
|
491
|
+
files: string[],
|
|
492
|
+
) => Promise<Record<string, unknown>>;
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
const currentTypes = await parseAndExtractTypes(typeFiles);
|
|
496
|
+
const previousTypes = await loadSchemaSnapshot(rootDir, config.outputDir);
|
|
497
|
+
|
|
498
|
+
// Diff schemas
|
|
499
|
+
const { diffSchemas } = (await import(
|
|
500
|
+
/* @vite-ignore */ "@typokit/transform-native"
|
|
501
|
+
)) as {
|
|
502
|
+
diffSchemas: (
|
|
503
|
+
oldTypes: Record<string, unknown>,
|
|
504
|
+
newTypes: Record<string, unknown>,
|
|
505
|
+
name: string,
|
|
506
|
+
) => Promise<MigrationDraft>;
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
const migration = await diffSchemas(previousTypes, currentTypes, "pending");
|
|
510
|
+
|
|
511
|
+
if (migration.changes.length === 0) {
|
|
512
|
+
logger.info("No pending schema changes");
|
|
513
|
+
return {
|
|
514
|
+
success: true,
|
|
515
|
+
filesWritten: [],
|
|
516
|
+
duration: Date.now() - startTime,
|
|
517
|
+
errors,
|
|
518
|
+
destructive: false,
|
|
519
|
+
changes: [],
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Output the diff
|
|
524
|
+
const g = globalThis as Record<string, unknown>;
|
|
525
|
+
const proc = g["process"] as
|
|
526
|
+
| { stdout: { write(s: string): void } }
|
|
527
|
+
| undefined;
|
|
528
|
+
const stdout = proc?.stdout ?? { write: () => {} };
|
|
529
|
+
|
|
530
|
+
if (asJson) {
|
|
531
|
+
const output = {
|
|
532
|
+
changes: migration.changes,
|
|
533
|
+
destructive: migration.destructive,
|
|
534
|
+
sql: migration.sql,
|
|
535
|
+
changeCount: migration.changes.length,
|
|
536
|
+
};
|
|
537
|
+
stdout.write(JSON.stringify(output, null, 2) + "\n");
|
|
538
|
+
} else {
|
|
539
|
+
stdout.write(`\nPending Schema Changes (${migration.changes.length}):\n`);
|
|
540
|
+
stdout.write("─".repeat(50) + "\n");
|
|
541
|
+
|
|
542
|
+
for (const change of migration.changes) {
|
|
543
|
+
const destructiveTag = isDestructiveChange(change)
|
|
544
|
+
? " [DESTRUCTIVE]"
|
|
545
|
+
: "";
|
|
546
|
+
const field = change.field ? `.${change.field}` : "";
|
|
547
|
+
stdout.write(
|
|
548
|
+
` ${change.type.toUpperCase()} ${change.entity}${field}${destructiveTag}\n`,
|
|
549
|
+
);
|
|
550
|
+
if (change.details && verbose) {
|
|
551
|
+
for (const [k, v] of Object.entries(change.details)) {
|
|
552
|
+
stdout.write(` ${k}: ${JSON.stringify(v)}\n`);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
stdout.write("─".repeat(50) + "\n");
|
|
558
|
+
if (migration.destructive) {
|
|
559
|
+
stdout.write("⚠ Contains destructive changes — review required\n");
|
|
560
|
+
}
|
|
561
|
+
stdout.write(`\nSQL Preview:\n${migration.sql}\n`);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const duration = Date.now() - startTime;
|
|
565
|
+
return {
|
|
566
|
+
success: true,
|
|
567
|
+
filesWritten: [],
|
|
568
|
+
duration,
|
|
569
|
+
errors,
|
|
570
|
+
destructive: migration.destructive,
|
|
571
|
+
changes: migration.changes,
|
|
572
|
+
};
|
|
573
|
+
} catch (err: unknown) {
|
|
574
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
575
|
+
logger.error(`migrate:diff failed: ${message}`);
|
|
576
|
+
errors.push(message);
|
|
577
|
+
return {
|
|
578
|
+
success: false,
|
|
579
|
+
filesWritten: [],
|
|
580
|
+
duration: Date.now() - startTime,
|
|
581
|
+
errors,
|
|
582
|
+
destructive: false,
|
|
583
|
+
changes: [],
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// ─── migrate:apply ────────────────────────────────────────────
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Apply pending migrations using the configured database adapter.
|
|
592
|
+
* Reads migration files from the migrations directory and executes them in order.
|
|
593
|
+
*/
|
|
594
|
+
async function migrateApply(
|
|
595
|
+
options: MigrateCommandOptions,
|
|
596
|
+
): Promise<MigrateResult> {
|
|
597
|
+
const startTime = Date.now();
|
|
598
|
+
const { config, rootDir, logger, verbose, flags } = options;
|
|
599
|
+
const filesWritten: string[] = [];
|
|
600
|
+
const errors: string[] = [];
|
|
601
|
+
|
|
602
|
+
const { join } = (await import(/* @vite-ignore */ "path")) as {
|
|
603
|
+
join: (...args: string[]) => string;
|
|
604
|
+
};
|
|
605
|
+
const nodeFs = (await import(/* @vite-ignore */ "fs")) as {
|
|
606
|
+
existsSync: (p: string) => boolean;
|
|
607
|
+
readFileSync: (p: string, encoding: string) => string;
|
|
608
|
+
readdirSync: (p: string) => string[];
|
|
609
|
+
writeFileSync: (p: string, data: string, encoding?: string) => void;
|
|
610
|
+
mkdirSync: (p: string, opts?: { recursive?: boolean }) => void;
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
const migrationsDir = getMigrationsDir(rootDir, config.outputDir, join);
|
|
614
|
+
|
|
615
|
+
if (!nodeFs.existsSync(migrationsDir)) {
|
|
616
|
+
logger.info("No migrations directory found — nothing to apply");
|
|
617
|
+
return {
|
|
618
|
+
success: true,
|
|
619
|
+
filesWritten,
|
|
620
|
+
duration: Date.now() - startTime,
|
|
621
|
+
errors,
|
|
622
|
+
destructive: false,
|
|
623
|
+
changes: [],
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Find all .sql migration files
|
|
628
|
+
const allFiles = nodeFs.readdirSync(migrationsDir);
|
|
629
|
+
const sqlFiles = allFiles.filter((f) => f.endsWith(".sql")).sort(); // Sorted by timestamp prefix
|
|
630
|
+
|
|
631
|
+
if (sqlFiles.length === 0) {
|
|
632
|
+
logger.info("No pending migration files found");
|
|
633
|
+
return {
|
|
634
|
+
success: true,
|
|
635
|
+
filesWritten,
|
|
636
|
+
duration: Date.now() - startTime,
|
|
637
|
+
errors,
|
|
638
|
+
destructive: false,
|
|
639
|
+
changes: [],
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Load applied migrations log
|
|
644
|
+
const appliedLogPath = join(migrationsDir, ".applied");
|
|
645
|
+
let appliedSet = new Set<string>();
|
|
646
|
+
if (nodeFs.existsSync(appliedLogPath)) {
|
|
647
|
+
const content = nodeFs.readFileSync(appliedLogPath, "utf-8");
|
|
648
|
+
appliedSet = new Set(content.split("\n").filter(Boolean));
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Filter to unapplied migrations
|
|
652
|
+
const pending = sqlFiles.filter((f) => !appliedSet.has(f));
|
|
653
|
+
|
|
654
|
+
if (pending.length === 0) {
|
|
655
|
+
logger.info("All migrations already applied");
|
|
656
|
+
return {
|
|
657
|
+
success: true,
|
|
658
|
+
filesWritten,
|
|
659
|
+
duration: Date.now() - startTime,
|
|
660
|
+
errors,
|
|
661
|
+
destructive: false,
|
|
662
|
+
changes: [],
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
logger.step("migrate:apply", `Found ${pending.length} pending migration(s)`);
|
|
667
|
+
|
|
668
|
+
// Check for destructive changes that should block
|
|
669
|
+
let hasDestructive = false;
|
|
670
|
+
const allChanges: SchemaChange[] = [];
|
|
671
|
+
|
|
672
|
+
for (const file of pending) {
|
|
673
|
+
const content = nodeFs.readFileSync(join(migrationsDir, file), "utf-8");
|
|
674
|
+
if (content.includes("-- DESTRUCTIVE: requires review")) {
|
|
675
|
+
hasDestructive = true;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Load metadata if available
|
|
679
|
+
const metaFile = file.replace(/\.sql$/, ".json");
|
|
680
|
+
if (nodeFs.existsSync(join(migrationsDir, metaFile))) {
|
|
681
|
+
try {
|
|
682
|
+
const metaContent = nodeFs.readFileSync(
|
|
683
|
+
join(migrationsDir, metaFile),
|
|
684
|
+
"utf-8",
|
|
685
|
+
);
|
|
686
|
+
const meta = JSON.parse(metaContent) as {
|
|
687
|
+
changes?: SchemaChange[];
|
|
688
|
+
destructive?: boolean;
|
|
689
|
+
};
|
|
690
|
+
if (meta.changes) allChanges.push(...meta.changes);
|
|
691
|
+
if (meta.destructive) hasDestructive = true;
|
|
692
|
+
} catch {
|
|
693
|
+
// Skip invalid metadata
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Block destructive migrations unless --force is passed
|
|
699
|
+
const force = flags["force"] === true;
|
|
700
|
+
if (hasDestructive && !force) {
|
|
701
|
+
logger.error("Destructive migrations detected — review required");
|
|
702
|
+
logger.info("Use --force to apply destructive migrations");
|
|
703
|
+
for (const file of pending) {
|
|
704
|
+
const content = nodeFs.readFileSync(join(migrationsDir, file), "utf-8");
|
|
705
|
+
if (content.includes("-- DESTRUCTIVE: requires review")) {
|
|
706
|
+
logger.warn(` Destructive: ${file}`);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
return {
|
|
710
|
+
success: false,
|
|
711
|
+
filesWritten,
|
|
712
|
+
duration: Date.now() - startTime,
|
|
713
|
+
errors: [
|
|
714
|
+
"Destructive migrations require review. Use --force to override.",
|
|
715
|
+
],
|
|
716
|
+
destructive: true,
|
|
717
|
+
changes: allChanges,
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Apply each migration
|
|
722
|
+
const appliedFiles: string[] = [];
|
|
723
|
+
for (const file of pending) {
|
|
724
|
+
logger.step("migrate:apply", `Applying ${file}...`);
|
|
725
|
+
|
|
726
|
+
if (verbose) {
|
|
727
|
+
const content = nodeFs.readFileSync(join(migrationsDir, file), "utf-8");
|
|
728
|
+
logger.verbose(`SQL:\n${content}`);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Mark as applied (in a real implementation this would execute against the DB adapter)
|
|
732
|
+
appliedSet.add(file);
|
|
733
|
+
appliedFiles.push(file);
|
|
734
|
+
logger.success(`Applied ${file}`);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Update applied log
|
|
738
|
+
nodeFs.writeFileSync(
|
|
739
|
+
appliedLogPath,
|
|
740
|
+
[...appliedSet].join("\n") + "\n",
|
|
741
|
+
"utf-8",
|
|
742
|
+
);
|
|
743
|
+
filesWritten.push(appliedLogPath);
|
|
744
|
+
|
|
745
|
+
const duration = Date.now() - startTime;
|
|
746
|
+
logger.success(
|
|
747
|
+
`migrate:apply complete — ${appliedFiles.length} migration(s) applied (${duration}ms)`,
|
|
748
|
+
);
|
|
749
|
+
return {
|
|
750
|
+
success: true,
|
|
751
|
+
filesWritten,
|
|
752
|
+
duration,
|
|
753
|
+
errors,
|
|
754
|
+
destructive: hasDestructive,
|
|
755
|
+
changes: allChanges,
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// ─── Dispatcher ───────────────────────────────────────────────
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Execute a migrate subcommand.
|
|
763
|
+
* Dispatches to the appropriate handler based on the subcommand.
|
|
764
|
+
*/
|
|
765
|
+
export async function executeMigrate(
|
|
766
|
+
options: MigrateCommandOptions,
|
|
767
|
+
): Promise<MigrateResult> {
|
|
768
|
+
const { subcommand, logger } = options;
|
|
769
|
+
|
|
770
|
+
switch (subcommand) {
|
|
771
|
+
case "generate":
|
|
772
|
+
return migrateGenerate(options);
|
|
773
|
+
case "diff":
|
|
774
|
+
return migrateDiff(options);
|
|
775
|
+
case "apply":
|
|
776
|
+
return migrateApply(options);
|
|
777
|
+
default:
|
|
778
|
+
logger.error(`Unknown migrate subcommand: ${subcommand}`);
|
|
779
|
+
logger.info("Available subcommands: generate, diff, apply");
|
|
780
|
+
return {
|
|
781
|
+
success: false,
|
|
782
|
+
filesWritten: [],
|
|
783
|
+
duration: 0,
|
|
784
|
+
errors: [`Unknown migrate subcommand: ${subcommand}`],
|
|
785
|
+
destructive: false,
|
|
786
|
+
changes: [],
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Export individual commands for direct usage
|
|
792
|
+
export {
|
|
793
|
+
migrateGenerate,
|
|
794
|
+
migrateDiff,
|
|
795
|
+
migrateApply,
|
|
796
|
+
generateTimestamp,
|
|
797
|
+
sanitizeName,
|
|
798
|
+
isDestructiveChange,
|
|
799
|
+
annotateSql,
|
|
800
|
+
};
|