@metaobjectsdev/cli 0.5.0-rc.1

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.
Files changed (67) hide show
  1. package/LICENSE +189 -0
  2. package/README.md +184 -0
  3. package/dist/bin/meta.d.ts +3 -0
  4. package/dist/bin/meta.d.ts.map +1 -0
  5. package/dist/bin/meta.js +7 -0
  6. package/dist/bin/meta.js.map +1 -0
  7. package/dist/src/commands/export.d.ts +2 -0
  8. package/dist/src/commands/export.d.ts.map +1 -0
  9. package/dist/src/commands/export.js +45 -0
  10. package/dist/src/commands/export.js.map +1 -0
  11. package/dist/src/commands/gen.d.ts +2 -0
  12. package/dist/src/commands/gen.d.ts.map +1 -0
  13. package/dist/src/commands/gen.js +86 -0
  14. package/dist/src/commands/gen.js.map +1 -0
  15. package/dist/src/commands/init.d.ts +16 -0
  16. package/dist/src/commands/init.d.ts.map +1 -0
  17. package/dist/src/commands/init.js +222 -0
  18. package/dist/src/commands/init.js.map +1 -0
  19. package/dist/src/commands/migrate.d.ts +2 -0
  20. package/dist/src/commands/migrate.d.ts.map +1 -0
  21. package/dist/src/commands/migrate.js +361 -0
  22. package/dist/src/commands/migrate.js.map +1 -0
  23. package/dist/src/index.d.ts +4 -0
  24. package/dist/src/index.d.ts.map +1 -0
  25. package/dist/src/index.js +105 -0
  26. package/dist/src/index.js.map +1 -0
  27. package/dist/src/lib/args.d.ts +34 -0
  28. package/dist/src/lib/args.d.ts.map +1 -0
  29. package/dist/src/lib/args.js +103 -0
  30. package/dist/src/lib/args.js.map +1 -0
  31. package/dist/src/lib/config.d.ts +17 -0
  32. package/dist/src/lib/config.d.ts.map +1 -0
  33. package/dist/src/lib/config.js +48 -0
  34. package/dist/src/lib/config.js.map +1 -0
  35. package/dist/src/lib/kysely.d.ts +28 -0
  36. package/dist/src/lib/kysely.d.ts.map +1 -0
  37. package/dist/src/lib/kysely.js +100 -0
  38. package/dist/src/lib/kysely.js.map +1 -0
  39. package/dist/src/lib/load-metaobjects-config.d.ts +3 -0
  40. package/dist/src/lib/load-metaobjects-config.d.ts.map +1 -0
  41. package/dist/src/lib/load-metaobjects-config.js +83 -0
  42. package/dist/src/lib/load-metaobjects-config.js.map +1 -0
  43. package/dist/src/lib/log.d.ts +6 -0
  44. package/dist/src/lib/log.d.ts.map +1 -0
  45. package/dist/src/lib/log.js +6 -0
  46. package/dist/src/lib/log.js.map +1 -0
  47. package/dist/src/lib/output.d.ts +38 -0
  48. package/dist/src/lib/output.d.ts.map +1 -0
  49. package/dist/src/lib/output.js +96 -0
  50. package/dist/src/lib/output.js.map +1 -0
  51. package/dist/src/lib/projection-migrations.d.ts +34 -0
  52. package/dist/src/lib/projection-migrations.d.ts.map +1 -0
  53. package/dist/src/lib/projection-migrations.js +112 -0
  54. package/dist/src/lib/projection-migrations.js.map +1 -0
  55. package/package.json +71 -0
  56. package/src/commands/export.ts +50 -0
  57. package/src/commands/gen.ts +88 -0
  58. package/src/commands/init.ts +272 -0
  59. package/src/commands/migrate.ts +390 -0
  60. package/src/index.ts +109 -0
  61. package/src/lib/args.ts +157 -0
  62. package/src/lib/config.ts +78 -0
  63. package/src/lib/kysely.ts +114 -0
  64. package/src/lib/load-metaobjects-config.ts +89 -0
  65. package/src/lib/log.ts +5 -0
  66. package/src/lib/output.ts +156 -0
  67. package/src/lib/projection-migrations.ts +159 -0
@@ -0,0 +1,272 @@
1
+ import { mkdir, writeFile, readFile, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { basename } from "node:path";
4
+ import { DEFAULT_CONFIG, ConfigSchema, saveConfig, PACKAGE_MANIFEST_FILE, DEFAULT_METADATA_DIR, DEFAULT_METAOBJECTS_DIR } from "@metaobjectsdev/sdk";
5
+ import { parseInitArgs } from "../lib/args.js";
6
+ import { log } from "../lib/log.js";
7
+ import { AGENT_DOCS_BODY, withContentHash, isUnmodified } from "@metaobjectsdev/sdk/agent-docs";
8
+
9
+ const META_COMMON_JSON = JSON.stringify(
10
+ {
11
+ metadata: {
12
+ package: "",
13
+ children: [] as unknown[],
14
+ },
15
+ },
16
+ null,
17
+ 2,
18
+ ) + "\n";
19
+
20
+ const METAOBJECTS_GITIGNORE_BODY = `.gen-state/
21
+ `;
22
+
23
+ const FORGE_CONFIG_BODY = `import { defineConfig } from "@metaobjectsdev/cli";
24
+ import {
25
+ entityFile,
26
+ queriesFile,
27
+ routesFile,
28
+ // formFile, // opt-in: emit React form components
29
+ barrel,
30
+ } from "@metaobjectsdev/codegen-ts/generators";
31
+
32
+ export default defineConfig({
33
+ outDir: "./src/db",
34
+ extStyle: "none",
35
+ dbImport: "../db",
36
+ dialect: "sqlite",
37
+ apiPrefix: "", // set to "/api" if your routes mount under /api
38
+ generators: [
39
+ entityFile(),
40
+ queriesFile(),
41
+ routesFile(),
42
+ barrel(),
43
+ ],
44
+ });
45
+ `;
46
+
47
+ const NEXT_STEPS = `
48
+ Initialized metaobjects/ + .metaobjects/ + metaobjects.config.ts
49
+
50
+ Next steps (when later sub-projects ship):
51
+ meta ingest # propose entities from your existing TS code
52
+ meta gen # codegen TS targets from entities
53
+ meta serve # local viewer
54
+ meta install-hooks # register MCP server + Claude Code hooks
55
+ `;
56
+
57
+ const AGENT_DOC_FILES = ["AGENTS.md", "CLAUDE.md"] as const;
58
+
59
+ export interface InitOptions {
60
+ cwd: string;
61
+ force?: boolean;
62
+ quiet?: boolean;
63
+ printOnly?: boolean;
64
+ refreshDocs?: boolean;
65
+ }
66
+
67
+ export interface InitResult {
68
+ created: string[];
69
+ preserved: string[];
70
+ warnings: string[];
71
+ }
72
+
73
+ async function writeAgentDocs(agentDir: string, result: InitResult): Promise<void> {
74
+ const docsBody = withContentHash(AGENT_DOCS_BODY);
75
+ for (const filename of AGENT_DOC_FILES) {
76
+ const path = join(agentDir, filename);
77
+ const exists = await fileExists(path);
78
+
79
+ if (!exists) {
80
+ await writeFile(path, docsBody, "utf8");
81
+ result.created.push(`.metaobjects/${filename}`);
82
+ continue;
83
+ }
84
+
85
+ const existingBody = await readFile(path, "utf8");
86
+ if (isUnmodified(existingBody)) {
87
+ await writeFile(path, docsBody, "utf8");
88
+ result.created.push(`.metaobjects/${filename}`);
89
+ } else {
90
+ await writeFile(`${path}.new`, docsBody, "utf8");
91
+ result.created.push(`.metaobjects/${filename}.new`);
92
+ result.warnings.push(
93
+ `${filename} appears to have been hand-edited; refreshed docs written to ${filename}.new`,
94
+ );
95
+ }
96
+ }
97
+ }
98
+
99
+ export async function init(opts: InitOptions): Promise<InitResult> {
100
+ const result: InitResult = { created: [], preserved: [], warnings: [] };
101
+ const agentDir = join(opts.cwd, DEFAULT_METAOBJECTS_DIR);
102
+ const metaobjectsDir = join(opts.cwd, DEFAULT_METADATA_DIR);
103
+
104
+ const agentDirExists = await dirExists(agentDir);
105
+ const metaobjectsExists = await dirExists(metaobjectsDir);
106
+ const exists = agentDirExists || metaobjectsExists;
107
+
108
+ if (opts.refreshDocs && exists && !opts.force) {
109
+ // Refresh-only path: scaffold agent docs, leave everything else alone.
110
+ await writeAgentDocs(agentDir, result);
111
+ return result;
112
+ }
113
+
114
+ if (exists && !opts.force && !opts.refreshDocs) {
115
+ throw new Error(
116
+ "metaobjects/ or .metaobjects/ already exists; use --force to overwrite scaffold files (existing records are preserved), or --refresh-docs to update only agent docs",
117
+ );
118
+ }
119
+
120
+ const dirs = [
121
+ DEFAULT_METADATA_DIR,
122
+ DEFAULT_METAOBJECTS_DIR,
123
+ `${DEFAULT_METAOBJECTS_DIR}/.gen-state`,
124
+ ];
125
+
126
+ if (opts.printOnly) {
127
+ for (const d of dirs) result.created.push(d);
128
+ result.created.push(
129
+ "metaobjects/meta.common.json",
130
+ ".metaobjects/config.json",
131
+ ".metaobjects/.gitignore",
132
+ `.metaobjects/${PACKAGE_MANIFEST_FILE}`,
133
+ );
134
+ for (const filename of AGENT_DOC_FILES) result.created.push(`.metaobjects/${filename}`);
135
+ result.created.push("metaobjects.config.ts");
136
+ return result;
137
+ }
138
+
139
+ for (const d of dirs) {
140
+ await mkdir(join(opts.cwd, d), { recursive: true });
141
+ if (!result.created.includes(d)) result.created.push(d);
142
+ }
143
+
144
+ // metaobjects/meta.common.json — placeholder, only if absent
145
+ const commonJsonPath = join(metaobjectsDir, "meta.common.json");
146
+ if (!(await fileExists(commonJsonPath))) {
147
+ await writeFile(commonJsonPath, META_COMMON_JSON, "utf8");
148
+ result.created.push("metaobjects/meta.common.json");
149
+ } else {
150
+ result.preserved.push("metaobjects/meta.common.json");
151
+ }
152
+
153
+ // .metaobjects/config.json
154
+ if (agentDirExists) {
155
+ const configPath = join(agentDir, "config.json");
156
+ let priorContent: string | undefined;
157
+ try {
158
+ priorContent = await readFile(configPath, "utf8");
159
+ const parsed = ConfigSchema.parse(JSON.parse(priorContent));
160
+ const merged = ConfigSchema.parse({ ...DEFAULT_CONFIG, ...parsed });
161
+ await saveConfig(agentDir, merged);
162
+ result.preserved.push(".metaobjects/config.json");
163
+ } catch {
164
+ if (priorContent !== undefined) {
165
+ log.warn("existing .metaobjects/config.json was invalid — writing fresh defaults. Prior content:");
166
+ log.warn(priorContent);
167
+ result.warnings.push("invalid .metaobjects/config.json replaced with defaults");
168
+ }
169
+ await writeFile(
170
+ join(agentDir, "config.json"),
171
+ JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n",
172
+ "utf8",
173
+ );
174
+ if (priorContent === undefined) {
175
+ result.created.push(".metaobjects/config.json");
176
+ }
177
+ }
178
+ } else {
179
+ await writeFile(
180
+ join(agentDir, "config.json"),
181
+ JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n",
182
+ "utf8",
183
+ );
184
+ result.created.push(".metaobjects/config.json");
185
+ }
186
+
187
+ // .metaobjects/.gitignore
188
+ await writeFile(join(agentDir, ".gitignore"), METAOBJECTS_GITIGNORE_BODY, "utf8");
189
+ result.created.push(".metaobjects/.gitignore");
190
+
191
+ // .metaobjects/package.meta.json — scaffold v0.3 package manifest if absent
192
+ const manifestPath = join(agentDir, PACKAGE_MANIFEST_FILE);
193
+ if (!(await fileExists(manifestPath))) {
194
+ const defaultPackageName = basename(opts.cwd);
195
+ const manifestBody = {
196
+ name: defaultPackageName,
197
+ version: "0.1.0",
198
+ extends: [] as string[],
199
+ };
200
+ await writeFile(manifestPath, JSON.stringify(manifestBody, null, 2) + "\n", "utf8");
201
+ result.created.push(`.metaobjects/${PACKAGE_MANIFEST_FILE}`);
202
+ } else {
203
+ result.preserved.push(`.metaobjects/${PACKAGE_MANIFEST_FILE}`);
204
+ }
205
+
206
+ await writeAgentDocs(agentDir, result);
207
+
208
+ // Scaffold metaobjects.config.ts at the project root. Never overwrite if it exists.
209
+ const forgeConfigPath = join(opts.cwd, "metaobjects.config.ts");
210
+ if (!(await fileExists(forgeConfigPath))) {
211
+ await writeFile(forgeConfigPath, FORGE_CONFIG_BODY, "utf8");
212
+ result.created.push("metaobjects.config.ts");
213
+ }
214
+
215
+ return result;
216
+ }
217
+
218
+ export function nextStepsBlock(): string {
219
+ return NEXT_STEPS;
220
+ }
221
+
222
+ async function dirExists(p: string): Promise<boolean> {
223
+ try {
224
+ const s = await stat(p);
225
+ return s.isDirectory();
226
+ } catch {
227
+ return false;
228
+ }
229
+ }
230
+
231
+ async function fileExists(p: string): Promise<boolean> {
232
+ try {
233
+ const s = await stat(p);
234
+ return s.isFile();
235
+ } catch {
236
+ return false;
237
+ }
238
+ }
239
+
240
+ export async function initCommand(args: string[], cwd: string): Promise<number> {
241
+ let flags;
242
+ try {
243
+ flags = parseInitArgs(args);
244
+ } catch (err) {
245
+ log.error((err as Error).message);
246
+ return 2;
247
+ }
248
+
249
+ try {
250
+ const result = await init({
251
+ cwd,
252
+ force: flags.force,
253
+ quiet: flags.quiet,
254
+ printOnly: flags.printOnly,
255
+ refreshDocs: flags.refreshDocs,
256
+ });
257
+
258
+ if (flags.printOnly) {
259
+ log.info("Would create:");
260
+ for (const path of result.created) log.info(` ${path}`);
261
+ return 0;
262
+ }
263
+
264
+ if (!flags.quiet) {
265
+ log.info(nextStepsBlock());
266
+ }
267
+ return 0;
268
+ } catch (err) {
269
+ log.error((err as Error).message);
270
+ return 1;
271
+ }
272
+ }
@@ -0,0 +1,390 @@
1
+ import { resolve } from "node:path";
2
+ import { mkdir } from "node:fs/promises";
3
+ import { parseMigrateArgs } from "../lib/args.js";
4
+ import { resolveMigrateConfig } from "../lib/config.js";
5
+ import { formatMigrateResult, type BlockedEntry, type AmbiguousEntry } from "../lib/output.js";
6
+ import { buildKyselyFromUrl } from "../lib/kysely.js";
7
+ import { log } from "../lib/log.js";
8
+ import { loadMemory } from "@metaobjectsdev/sdk";
9
+ import { loadMetaobjectsConfig } from "../lib/load-metaobjects-config.js";
10
+ import {
11
+ buildExpectedSchema,
12
+ introspect,
13
+ diff,
14
+ emit,
15
+ writeMigration,
16
+ BlockedChangesError,
17
+ type AllowOptions,
18
+ type AmbiguousChange,
19
+ type AmbiguousResolution,
20
+ type Change,
21
+ type EmitResult,
22
+ } from "@metaobjectsdev/migrate-ts";
23
+ import {
24
+ computeProjectionMigrations,
25
+ computeProjectionViewDependencies,
26
+ } from "../lib/projection-migrations.js";
27
+
28
+ // Map CLI allow tokens → migrate-ts AllowOptions field names
29
+ const ALLOW_TOKEN_MAP: Record<string, keyof AllowOptions> = {
30
+ "drop-column": "dropColumn",
31
+ "drop-table": "dropTable",
32
+ "type-change": "typeChange",
33
+ "drop-index": "dropIndex",
34
+ "drop-fk": "dropFk",
35
+ "nullable-to-not-null": "nullableToNotNull",
36
+ };
37
+
38
+ function mapOnAmbiguous(v: "abort" | "rename" | "drop-add"): AmbiguousResolution {
39
+ return v === "drop-add" ? "drop+add" : v;
40
+ }
41
+
42
+ function tokensToAllowOptions(tokens: string[]): AllowOptions {
43
+ const opts: AllowOptions = {};
44
+ for (const tok of tokens) {
45
+ const field = ALLOW_TOKEN_MAP[tok];
46
+ if (field !== undefined) {
47
+ opts[field] = true;
48
+ }
49
+ }
50
+ return opts;
51
+ }
52
+
53
+ function summarizeChanges(changes: Change[]): Record<string, number> {
54
+ const counts: Record<string, number> = {};
55
+ for (const c of changes) {
56
+ counts[c.kind] = (counts[c.kind] ?? 0) + 1;
57
+ }
58
+ return counts;
59
+ }
60
+
61
+ function describeChangeForOutput(c: Change): string {
62
+ switch (c.kind) {
63
+ case "create-table": return c.table.name;
64
+ case "drop-table": return c.table;
65
+ case "rename-table": return `${c.from} → ${c.to}`;
66
+ case "add-column": return `${c.table}.${c.column.name}`;
67
+ case "drop-column": return `${c.table}.${c.column}`;
68
+ case "rename-column": return `${c.table}.${c.from} → ${c.table}.${c.to}`;
69
+ case "change-column-type": return `${c.table}.${c.column} (${c.from.kind} → ${c.to.kind})`;
70
+ case "change-column-nullable": return `${c.table}.${c.column} (${c.from ? "NULL" : "NOT NULL"} → ${c.to ? "NULL" : "NOT NULL"})`;
71
+ case "change-column-default": return `${c.table}.${c.column}`;
72
+ case "add-index": return `${c.table} idx ${c.index.name}`;
73
+ case "drop-index": return `${c.table} idx ${c.index}`;
74
+ case "add-fk": return `${c.table} fk ${c.fk.name}`;
75
+ case "drop-fk": return `${c.table} fk ${c.fk}`;
76
+ default: return JSON.stringify(c);
77
+ }
78
+ }
79
+
80
+ function allowFlagFor(kind: string): string {
81
+ switch (kind) {
82
+ case "drop-column": return "drop-column";
83
+ case "drop-table": return "drop-table";
84
+ case "drop-index": return "drop-index";
85
+ case "drop-fk": return "drop-fk";
86
+ case "change-column-type": return "type-change";
87
+ case "change-column-nullable": return "nullable-to-not-null";
88
+ default: return kind;
89
+ }
90
+ }
91
+
92
+ function blockedToEntries(err: BlockedChangesError): BlockedEntry[] {
93
+ return err.blocked.map((c) => ({
94
+ kind: c.kind,
95
+ description: describeChangeForOutput(c),
96
+ allowFlag: allowFlagFor(c.kind),
97
+ }));
98
+ }
99
+
100
+ function ambiguousToEntries(amb: AmbiguousChange[]): AmbiguousEntry[] {
101
+ return amb.map((a) => {
102
+ if (a.kind === "possible-column-rename") {
103
+ return {
104
+ kind: a.kind,
105
+ description: `${a.table}.${a.from.name} → ${a.table}.${a.to.name}`,
106
+ hint: `${a.from.sqlType.kind} → ${a.to.sqlType.kind}`,
107
+ };
108
+ }
109
+ return {
110
+ kind: a.kind,
111
+ description: `${a.from.name} → ${a.to.name}`,
112
+ hint: `column-set overlap ${a.columnOverlap.toFixed(2)}`,
113
+ };
114
+ });
115
+ }
116
+
117
+ export async function migrateCommand(args: string[], cwd: string): Promise<number> {
118
+ let flags;
119
+ try {
120
+ flags = parseMigrateArgs(args);
121
+ } catch (err) {
122
+ log.error(`migrate: ${(err as Error).message}`);
123
+ return 2;
124
+ }
125
+
126
+ const metaRoot = cwd;
127
+ const config = await resolveMigrateConfig(flags, metaRoot);
128
+
129
+ if (config.databaseUrl === undefined) {
130
+ log.error(`migrate: --db <url> required (or set DATABASE_URL, or add migrate.databaseUrl to .metaobjects/config.json)`);
131
+ return 2;
132
+ }
133
+
134
+ let metadata;
135
+ try {
136
+ metadata = await loadMemory(metaRoot);
137
+ } catch (err) {
138
+ const msg = (err as Error).message;
139
+ if (msg.includes("ENOENT") || msg.includes("no such") || msg.includes("cannot read")) {
140
+ log.error(`no metaobjects/ found in ${metaRoot}; run 'meta init' to scaffold`);
141
+ } else {
142
+ log.error(`failed to load metadata: ${msg}`);
143
+ }
144
+ return 2;
145
+ }
146
+
147
+ let kysely;
148
+ try {
149
+ kysely = await buildKyselyFromUrl(config.databaseUrl, config.dialect);
150
+ } catch (err) {
151
+ log.error(`migrate: ${(err as Error).message}`);
152
+ return 2;
153
+ }
154
+
155
+ let exitCode = 0;
156
+ let writtenPaths: string[] = [];
157
+ let blocked: BlockedEntry[] = [];
158
+ let ambiguous: AmbiguousEntry[] = [];
159
+ let changeCounts: Record<string, number> = {};
160
+
161
+ try {
162
+ const expected = buildExpectedSchema(metadata, { dialect: kysely.dialect });
163
+ let actual;
164
+ try {
165
+ actual = await introspect(kysely.db, kysely.dialect);
166
+ } catch (err) {
167
+ log.error(`migrate: failed to connect to ${kysely.displayUrl}: ${(err as Error).message}`);
168
+ await kysely.close();
169
+ return 2;
170
+ }
171
+
172
+ const collectedAmbiguous: AmbiguousChange[] = [];
173
+ const onAmbiguousResolution = mapOnAmbiguous(config.onAmbiguous);
174
+
175
+ let diffResult;
176
+ try {
177
+ diffResult = await diff({
178
+ expected,
179
+ actual,
180
+ allow: tokensToAllowOptions(config.allow),
181
+ onAmbiguous: async (a) => {
182
+ collectedAmbiguous.push(a);
183
+ return onAmbiguousResolution;
184
+ },
185
+ });
186
+ } catch (err) {
187
+ // diff() throws when onAmbiguous returns "abort" — surface as exit 1
188
+ // with the collected ambiguity list.
189
+ if ((err as Error).message.includes("aborted by onAmbiguous")) {
190
+ ambiguous = ambiguousToEntries(collectedAmbiguous);
191
+ const output = formatMigrateResult({
192
+ dialect: kysely.dialect,
193
+ displayUrl: kysely.displayUrl,
194
+ changeCounts: {},
195
+ blocked: [],
196
+ ambiguous,
197
+ writtenPaths: [],
198
+ dryRun: config.dryRun,
199
+ }, { isTTY: !!process.stdout.isTTY });
200
+ log.info(output);
201
+ await kysely.close();
202
+ return 1;
203
+ }
204
+ throw err;
205
+ }
206
+
207
+ changeCounts = summarizeChanges(diffResult.changes);
208
+
209
+ // Load metaobjects config to pick up columnNamingStrategy for view DDL emit.
210
+ // If metaobjects.config.ts is absent (e.g. in projects that don't use codegen),
211
+ // fall back to snake_case so migrate still works without it.
212
+ let columnNamingStrategy: "snake_case" | "literal" | "kebab-case" = "snake_case";
213
+ try {
214
+ const forgeConfig = await loadMetaobjectsConfig(metaRoot);
215
+ if (forgeConfig.columnNamingStrategy) {
216
+ columnNamingStrategy = forgeConfig.columnNamingStrategy;
217
+ }
218
+ } catch {
219
+ // metaobjects.config.ts absent or invalid — use default snake_case
220
+ }
221
+
222
+ // Pull existing view CREATE SQL from the DB so unchanged views can be
223
+ // skipped (no DROP+CREATE noise when the body hasn't changed).
224
+ const existingViewSql = await readExistingViewSql(kysely.db, kysely.dialect);
225
+
226
+ // Compute view migrations (projections) independently of table changes.
227
+ const viewResult = computeProjectionMigrations({
228
+ metadata,
229
+ dialect: kysely.dialect,
230
+ allowBreaking: false,
231
+ columnNamingStrategy,
232
+ existingViewSql,
233
+ });
234
+ if (viewResult.errors.length > 0) {
235
+ for (const err of viewResult.errors) log.error(err);
236
+ await kysely.close();
237
+ return 1;
238
+ }
239
+ const viewUpSql = viewResult.migrations.join("\n\n");
240
+
241
+ const hasTableChanges = diffResult.changes.length > 0;
242
+ const hasViewChanges = viewResult.migrations.length > 0;
243
+
244
+ if (!hasTableChanges && !hasViewChanges) {
245
+ // no-op — output will say "No schema changes"
246
+ } else {
247
+ // Emit table SQL (may be empty if only views changed).
248
+ let tableSql: EmitResult | undefined;
249
+ if (hasTableChanges) {
250
+ try {
251
+ tableSql = emit(diffResult.changes, {
252
+ dialect: kysely.dialect,
253
+ expectedSchema: expected,
254
+ ...(actual.meta !== undefined ? { actualMeta: actual.meta } : {}),
255
+ });
256
+ } catch (err) {
257
+ if (err instanceof BlockedChangesError) {
258
+ blocked = blockedToEntries(err);
259
+ exitCode = 1;
260
+ } else {
261
+ throw err;
262
+ }
263
+ }
264
+ }
265
+
266
+ // Combine table + view SQL into a single migration if no errors.
267
+ if (exitCode === 0) {
268
+ // Extract view names from the CREATE VIEW statements (used by both the
269
+ // pre-drop and down-migration paths below).
270
+ const viewNames = viewResult.migrations
271
+ .map((s) => {
272
+ const m = /CREATE(?:\s+OR\s+REPLACE)?\s+VIEW\s+(\S+)/i.exec(s);
273
+ return m ? m[1] : undefined;
274
+ })
275
+ .filter((n): n is string => Boolean(n));
276
+
277
+ // Pre-drop dependent views, BUT only the ones whose source tables are
278
+ // being recreated. SQLite's ALTER TABLE ... RENAME re-parses dependent
279
+ // view definitions and errors if any of them reference a source table
280
+ // that's mid-recreate (the recreate-and-copy pattern temporarily drops
281
+ // the source table and creates __new_<table>, then RENAMEs). The
282
+ // viewUpSql block below recreates the dropped views fresh.
283
+ const recreatedTables = tableSql?.recreatedTables ?? new Set<string>();
284
+ const viewPreDropSql = recreatedTables.size > 0 && viewNames.length > 0
285
+ ? (() => {
286
+ const deps = computeProjectionViewDependencies({
287
+ metadata,
288
+ columnNamingStrategy,
289
+ });
290
+ const affected = viewNames.filter((n) => {
291
+ const sources = deps.get(n);
292
+ if (!sources) return false;
293
+ for (const t of sources) if (recreatedTables.has(t)) return true;
294
+ return false;
295
+ });
296
+ return affected.length > 0
297
+ ? affected.map((n) => `DROP VIEW IF EXISTS ${n};`).join("\n")
298
+ : "";
299
+ })()
300
+ : "";
301
+
302
+ const upParts = [viewPreDropSql, tableSql?.up, viewUpSql].filter(Boolean);
303
+ const combinedUp = upParts.join("\n\n");
304
+ // Down SQL: DROP VIEW statements for any views we created.
305
+ const viewDownSql = viewNames
306
+ .map((n) => `DROP VIEW IF EXISTS ${n};`)
307
+ .join("\n");
308
+ const downParts = [viewDownSql, tableSql?.down].filter(Boolean);
309
+ const combinedDown = downParts.join("\n\n");
310
+
311
+ if (config.slug === undefined) {
312
+ log.error(`migrate: --slug <name> required when there are changes (e.g., --slug add-user-shipping)`);
313
+ await kysely.close();
314
+ return 2;
315
+ }
316
+
317
+ if (config.dryRun) {
318
+ log.info(`-- UP --\n${combinedUp}\n\n-- DOWN --\n${combinedDown}`);
319
+ } else {
320
+ const outDir = resolve(metaRoot, config.outDir);
321
+ await mkdir(outDir, { recursive: true });
322
+ const res = await writeMigration(
323
+ { up: combinedUp, down: combinedDown },
324
+ { dir: outDir, slug: config.slug },
325
+ );
326
+ writtenPaths = [res.upPath, res.downPath];
327
+ }
328
+ }
329
+ }
330
+ } finally {
331
+ try {
332
+ await kysely.close();
333
+ } catch (err) {
334
+ log.warn(`migrate: failed to close DB cleanly: ${(err as Error).message}`);
335
+ }
336
+ }
337
+
338
+ const output = formatMigrateResult({
339
+ dialect: kysely.dialect,
340
+ displayUrl: kysely.displayUrl,
341
+ changeCounts,
342
+ blocked,
343
+ ambiguous,
344
+ writtenPaths,
345
+ dryRun: config.dryRun,
346
+ }, { isTTY: !!process.stdout.isTTY });
347
+
348
+ log.info(output);
349
+ return exitCode;
350
+ }
351
+
352
+ /**
353
+ * Read existing view CREATE SQL from the DB. Returns an empty map on any
354
+ * introspection failure — the worst case is over-eager DROP+CREATE
355
+ * (the original behaviour), not data loss.
356
+ */
357
+ async function readExistingViewSql(
358
+ // biome-ignore lint/suspicious/noExplicitAny: kysely raw query, dialect-dispatched
359
+ db: any,
360
+ dialect: "sqlite" | "postgres",
361
+ ): Promise<ReadonlyMap<string, string>> {
362
+ const result = new Map<string, string>();
363
+ try {
364
+ if (dialect === "sqlite") {
365
+ const { sql } = await import("kysely");
366
+ const rows = await sql<{ name: string; sql: string }>`
367
+ SELECT name, sql FROM sqlite_master WHERE type='view' AND name NOT LIKE 'sqlite_%'
368
+ `.execute(db);
369
+ for (const r of rows.rows) {
370
+ if (r.name && r.sql) result.set(r.name, r.sql);
371
+ }
372
+ } else {
373
+ const { sql } = await import("kysely");
374
+ const rows = await sql<{ name: string; def: string }>`
375
+ SELECT viewname AS name, definition AS def
376
+ FROM pg_views
377
+ WHERE schemaname = 'public'
378
+ `.execute(db);
379
+ for (const r of rows.rows) {
380
+ // Postgres pg_views.definition returns only the SELECT body, not the
381
+ // full "CREATE VIEW ... AS ...". Synthesize so comparison is apples-
382
+ // to-apples with what emitViewDdl produces.
383
+ if (r.name && r.def) result.set(r.name, `CREATE VIEW ${r.name} AS ${r.def}`);
384
+ }
385
+ }
386
+ } catch {
387
+ // ignore — empty map means over-eager recreate, same as before this fix
388
+ }
389
+ return result;
390
+ }