gencow 0.1.268 → 0.1.269

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.
@@ -1,5 +1,10 @@
1
1
  import { formatProcedureRpcDiagnostic } from "./procedure-rpc-diagnostic.mjs";
2
2
  import { describeMigrationFolderFormatFailure } from "./migration-folder-format.mjs";
3
+ import {
4
+ buildHintsRequiredCommand,
5
+ HINTS_DATA_LOSS_NOTICE,
6
+ SAFE_COLUMN_REFERENCE,
7
+ } from "./migration-hints.mjs";
3
8
 
4
9
  const SAFE_CORRELATION_ID = /^[A-Za-z0-9_-]{8,128}$/u;
5
10
  const SAFE_APP_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u;
@@ -166,8 +171,61 @@ const SAFE_LOCAL_ERROR_CODES = new Map([
166
171
  },
167
172
  },
168
173
  ],
174
+ [
175
+ "MIGRATION_HINTS_REQUIRED",
176
+ {
177
+ message: (caught) => {
178
+ const count = safeHintCount(caught);
179
+ const columns = safeHintColumns(caught);
180
+ if (columns.length === 0) {
181
+ return `Migration generation stopped: ${count} schema change(s) need your decision.`;
182
+ }
183
+ return `Migration generation stopped: ${count} column change(s) need your decision (${columns.join(", ")}).`;
184
+ },
185
+ stage: "migration",
186
+ action: (caught) => {
187
+ const example = `\`${buildHintsRequiredCommand(safeHintColumns(caught))}\``;
188
+ const createExample = `{"type":"create",...} to add a new column instead`;
189
+ const tail = `${HINTS_DATA_LOSS_NOTICE} Do not edit applied migration files.`;
190
+ if (caught.migrationHints?.hintsProvided) {
191
+ return `Retryable: yes, after your --hints array decides every listed column change — rerun with one rename or create decision per column, for example ${example}, or use ${createExample}. ${tail}`;
192
+ }
193
+ return `Retryable: yes, after you declare the decision — rerun ${example} (or use ${createExample}). ${tail}`;
194
+ },
195
+ },
196
+ ],
197
+ [
198
+ "MIGRATION_HINTS_INVALID",
199
+ {
200
+ message: (caught) => {
201
+ const source = caught.hintsSource === "file" ? "--hints-file" : "--hints";
202
+ const reasons = {
203
+ conflicting_options: "use only one of --hints or --hints-file.",
204
+ value_missing: `${source} needs a value.`,
205
+ json_invalid: `${source} value is not valid JSON.`,
206
+ not_array: `${source} must be a JSON array of decisions.`,
207
+ too_large: `${source} value is too large.`,
208
+ file_unreadable: "the --hints-file could not be read.",
209
+ };
210
+ return `Migration generation not started: ${reasons[caught.hintsReason] ?? "the hints value is not accepted."}`;
211
+ },
212
+ stage: "input",
213
+ action:
214
+ "Retryable: yes, after the hints value is fixed — pass a JSON array with `--hints '<json-array>'` or a JSON file with `--hints-file <path>`, then rerun `npx gencow@latest db:generate`.",
215
+ },
216
+ ],
169
217
  ]);
170
218
 
219
+ function safeHintCount(caught) {
220
+ const count = caught.migrationHints?.count;
221
+ return Number.isInteger(count) && count > 0 && count <= 9999 ? count : 1;
222
+ }
223
+
224
+ function safeHintColumns(caught) {
225
+ const columns = caught.migrationHints?.columns;
226
+ return Array.isArray(columns) ? columns.filter((value) => typeof value === "string" && SAFE_COLUMN_REFERENCE.test(value)) : [];
227
+ }
228
+
171
229
  function safeDisplayDir(caught) {
172
230
  const { displayDir } = caught;
173
231
  return typeof displayDir === "string" && SAFE_DISPLAY_DIR.test(displayDir) ? displayDir : null;
@@ -14,8 +14,10 @@ import {
14
14
  import { dirname, resolve } from "path";
15
15
  import { tmpdir } from "os";
16
16
  import { createDbCheckCommand, createDbPushCommand } from "./db-push-command.mjs";
17
+ import { renderMaintenanceFailure } from "./db-seed-failure-render.mjs";
17
18
  import { logEnvFileOperationWithType } from "./backend-env-resolver.mjs";
18
19
  import { runDrizzleGenerateWithFallback } from "./drizzle-generate.mjs";
20
+ import { HINTS_DATA_LOSS_NOTICE, parseMigrationHintsArgs } from "./migration-hints.mjs";
19
21
  import { BOLD, CYAN, DIM, GREEN, RED, RESET, error, info, log, success, warn } from "./output.mjs";
20
22
  import {
21
23
  describeProdTargetFailure,
@@ -150,8 +152,17 @@ function renderDbCheckHelp(logImpl = log) {
150
152
 
151
153
  function renderDbGenerateHelp(logImpl = log) {
152
154
  logImpl(`\n${BOLD}${CYAN}gencow db:generate${RESET} — Generate SQL migration files\n`);
153
- logImpl(` ${BOLD}Usage:${RESET} gencow db:generate\n`);
155
+ logImpl(` ${BOLD}Usage:${RESET} gencow db:generate [--hints '<json-array>' | --hints-file <path>]\n`);
154
156
  logImpl(` Generates migration files from schema.ts using drizzle-kit.\n`);
157
+ logImpl(` ${BOLD}Options:${RESET}`);
158
+ logImpl(` --hints '<json-array>' Decide each changed column: rename or create.`);
159
+ logImpl(` --hints-file <path> Same decisions, read from a JSON array file.\n`);
160
+ logImpl(` Example (rename column label to tag in table files):`);
161
+ logImpl(
162
+ ` gencow db:generate --hints '[{"type":"rename","kind":"column","from":["public","files","label"],"to":["public","files","tag"]}]'\n`,
163
+ );
164
+ logImpl(` Use only one of --hints or --hints-file. Hints are never inferred automatically.`);
165
+ logImpl(` ${HINTS_DATA_LOSS_NOTICE}\n`);
155
166
  }
156
167
 
157
168
  function renderLocalSeedMissingFile({ infoImpl = info, logImpl = log, warnImpl = warn } = {}) {
@@ -200,6 +211,7 @@ export function createDbCommands({
200
211
  resolvePathImpl = resolve,
201
212
  rmSyncImpl = rmSync,
202
213
  runInServer,
214
+ spawnSyncImpl,
203
215
  statSyncImpl = statSync,
204
216
  successImpl = success,
205
217
  tarCreateImpl,
@@ -296,12 +308,23 @@ export function createDbCommands({
296
308
  headers: { "Content-Type": "application/json" },
297
309
  body: JSON.stringify({ confirm: "delete all data" }),
298
310
  });
299
- const data = await res.json();
311
+ const data = await res.json().catch(() => null);
312
+ if (data === null) {
313
+ errorImpl("Server connection failed: the server response could not be read.");
314
+ logImpl("");
315
+ return 1;
316
+ }
300
317
 
301
318
  if (!res.ok) {
302
- errorImpl(`Reset failed: ${data.error || "Unknown error"}`);
319
+ renderMaintenanceFailure(data, res.status, {
320
+ errorImpl,
321
+ infoImpl,
322
+ label: "Reset failed",
323
+ retryCommand: "gencow db:reset",
324
+ impactNote: "verify which tables still contain data before rerunning",
325
+ });
303
326
  logImpl("");
304
- return;
327
+ return 1;
305
328
  }
306
329
 
307
330
  successImpl(`TRUNCATE done: ${data.truncated?.length || 0} tables`);
@@ -315,6 +338,8 @@ export function createDbCommands({
315
338
  }
316
339
  } catch (e) {
317
340
  errorImpl(`Server connection failed: ${e.message}`);
341
+ logImpl("");
342
+ return 1;
318
343
  }
319
344
  } else {
320
345
  const dbPath = resolvePathImpl(cwd, config.db.url);
@@ -442,13 +467,18 @@ export function createDbCommands({
442
467
  headers: { "Content-Type": "application/json" },
443
468
  signal: AbortSignal.timeout(30000),
444
469
  });
445
- const data = await res.json();
470
+ const data = await res.json().catch(() => null);
471
+ if (data === null) {
472
+ errorImpl("Cloud app connection failed: the platform response could not be read.");
473
+ infoImpl(`${DIM}Check if the platform is reachable: ${targetCreds.platformUrl}${RESET}`);
474
+ return 1;
475
+ }
446
476
 
447
477
  if (res.status === 404 && String(data.error || "").includes("seed.ts")) {
448
478
  renderCloudSeedMissingFile({ infoImpl, logImpl, warnImpl });
449
479
  return 1;
450
480
  } else if (!res.ok) {
451
- errorImpl(`Cloud seed failed: ${data.error || "Unknown error"}`);
481
+ renderMaintenanceFailure(data, res.status, { errorImpl, infoImpl });
452
482
  return 1;
453
483
  } else {
454
484
  successImpl("Cloud seed completed ✓");
@@ -531,6 +561,12 @@ export function createDbCommands({
531
561
  return;
532
562
  }
533
563
 
564
+ // Hints are validated before the config is read or the generator starts.
565
+ const hints = parseMigrationHintsArgs(args, {
566
+ cwd: cwdImpl(),
567
+ readFileSyncImpl,
568
+ resolvePathImpl,
569
+ });
534
570
  const config = await loadConfig();
535
571
  logImpl(`\n${BOLD}${CYAN}Gencow DB Generate${RESET}\n`);
536
572
  infoImpl("Generating migration files from schema.ts...");
@@ -540,6 +576,8 @@ export function createDbCommands({
540
576
  drizzleKitCmdImpl,
541
577
  env: buildEnv(config),
542
578
  execSyncImpl,
579
+ generatorArgs: hints.args,
580
+ spawnSyncImpl,
543
581
  stdio: "inherit",
544
582
  warnImpl,
545
583
  });
@@ -28,7 +28,9 @@ import {
28
28
  buildClientPreflightFailureDiagnostic,
29
29
  buildClientUserFixableDiagnostic,
30
30
  buildMigrationFolderFormatDiagnostic,
31
+ buildMigrationHintsRequiredDiagnostic,
31
32
  } from "./migration-client-diagnostic.mjs";
33
+ import { MIGRATION_HINTS_REQUIRED } from "./migration-hints.mjs";
32
34
  import { detectConflictingAuthSchemaFiles, formatAuthSchemaConflictMessage } from "./codegen-command.mjs";
33
35
  import { BOLD, CYAN, DIM, RESET } from "./output.mjs";
34
36
  import { stageMigrationBundleArchive } from "./migration-bundle-staging.mjs";
@@ -468,7 +470,9 @@ function createDbMigrationCommand(deps, command) {
468
470
  emitFailure(
469
471
  folderFormat
470
472
  ? buildMigrationFolderFormatDiagnostic(folderFormat, clientCorrelationId())
471
- : buildClientUserFixableDiagnostic("MIGRATION_GENERATION_FAILED", clientCorrelationId()),
473
+ : error?.code === MIGRATION_HINTS_REQUIRED
474
+ ? buildMigrationHintsRequiredDiagnostic(error.migrationHints, clientCorrelationId())
475
+ : buildClientUserFixableDiagnostic("MIGRATION_GENERATION_FAILED", clientCorrelationId()),
472
476
  );
473
477
  return;
474
478
  }
@@ -0,0 +1,84 @@
1
+ import { DIM, RESET } from "./output.mjs";
2
+
3
+ const TYPED_CODE_PATTERN = /^(SEED|RESET)_[A-Z_]+$/;
4
+ const UNAUTHORIZED_TEXT = "Seed request was not authorized for this app.";
5
+ const UNEXPECTED_TEXT = "the platform returned an unexpected response.";
6
+ const GENERIC_NEXT_ACTION = "Verify the app state, then rerun";
7
+ const PLAIN_TEXT_LIMIT = 400;
8
+
9
+ // Exact platform messages from packages/platform/gencow/apps-seed-handler.ts and apps-account-hold.ts.
10
+ // Any other untyped text is never printed, even when it looks harmless.
11
+ const KNOWN_PLATFORM_MESSAGES = new Set([
12
+ "App not found or not owned by you",
13
+ "Static-only app has no backend to seed",
14
+ "App is frozen — thaw before seed",
15
+ "App is locked because the owner account is suspended.",
16
+ "App is locked because the owner account is pending deletion.",
17
+ "App is locked because the owner account is deleted.",
18
+ ]);
19
+ const KNOWN_NOT_RUNNING_PATTERN = /^Cloud app "[^"\n]{1,128}" is not running$/;
20
+
21
+ // Typed text is rebuilt from the server catalog by the platform. This screen only blocks SQL-shaped text
22
+ // that a tampered or future body could carry, because the CLI does not import the catalog.
23
+ const SQL_SHAPED_PATTERN =
24
+ /Failed query|params:|\b(select|insert|update|delete|truncate|from|into|values|where)\b|\$\d/i;
25
+
26
+ export function isTypedMaintenanceFailure(data) {
27
+ return (
28
+ data !== null &&
29
+ typeof data === "object" &&
30
+ typeof data.code === "string" &&
31
+ TYPED_CODE_PATTERN.test(data.code)
32
+ );
33
+ }
34
+
35
+ function typedText(value, fallback) {
36
+ if (typeof value !== "string" || value.trim() === "" || value.length > PLAIN_TEXT_LIMIT) return fallback;
37
+ return SQL_SHAPED_PATTERN.test(value) ? fallback : value;
38
+ }
39
+
40
+ function isKnownPlatformMessage(text) {
41
+ return KNOWN_PLATFORM_MESSAGES.has(text) || KNOWN_NOT_RUNNING_PATTERN.test(text);
42
+ }
43
+
44
+ /**
45
+ * Renders a failed db:seed or db:reset response. The label and retry command name the operation.
46
+ * - Typed platform failures show the code, stage, data impact, retry condition, next action and correlation ID.
47
+ * - Untyped responses print only a fixed sentence per status, or an exact known platform message.
48
+ * Everything else, including raw database text, is replaced by the generic sentence.
49
+ */
50
+ export function renderMaintenanceFailure(
51
+ data,
52
+ status,
53
+ { errorImpl, infoImpl, label = "Cloud seed failed", retryCommand = "gencow db:seed --prod", impactNote },
54
+ ) {
55
+ if (!isTypedMaintenanceFailure(data)) {
56
+ const text = typeof data?.error === "string" ? data.error : "";
57
+ if (status === 401 || status === 403) {
58
+ errorImpl(`${label}: ${UNAUTHORIZED_TEXT}`);
59
+ return;
60
+ }
61
+ if (isKnownPlatformMessage(text)) {
62
+ errorImpl(`${label}: ${text}`);
63
+ return;
64
+ }
65
+ errorImpl(`${label}: ${UNEXPECTED_TEXT}`);
66
+ infoImpl(`${DIM}Next action: ${GENERIC_NEXT_ACTION} \`${retryCommand}\`.${RESET}`);
67
+ return;
68
+ }
69
+
70
+ errorImpl(`${label}: ${typedText(data.error, "the platform reported a failure.")}`);
71
+ infoImpl(`${DIM}Code: ${data.code}${RESET}`);
72
+ infoImpl(`${DIM}Stage: ${typedText(data.stage, "unknown")}${RESET}`);
73
+ infoImpl(
74
+ `${DIM}Data impact: unknown — ${impactNote ?? "verify what was inserted before rerunning"}${RESET}`,
75
+ );
76
+ infoImpl(`${DIM}Retryable: ${data.retryable === true ? "yes" : "no"}${RESET}`);
77
+ infoImpl(
78
+ `${DIM}Retry condition: ${typedText(data.retryPrecondition, "Verify the current data before retrying.")}${RESET}`,
79
+ );
80
+ infoImpl(
81
+ `${DIM}Next action: ${typedText(data.nextAction, `${GENERIC_NEXT_ACTION} \`${retryCommand}\`.`)}${RESET}`,
82
+ );
83
+ infoImpl(`${DIM}Correlation: ${typedText(data.correlationId, "unavailable")}${RESET}`);
84
+ }
@@ -4,6 +4,11 @@ import { existsSync, readFileSync } from "node:fs";
4
4
  import { dirname, resolve } from "node:path";
5
5
 
6
6
  import { classifyMigrationFolderFormatFailure } from "./migration-folder-format.mjs";
7
+ import {
8
+ classifyMissingHintsOutput,
9
+ createMigrationHintsRequiredError,
10
+ MIGRATION_HINTS_REQUIRED,
11
+ } from "./migration-hints.mjs";
7
12
 
8
13
  export const SUPPORTED_DRIZZLE_KIT_GENERATOR_VERSION = SUPPORTED_MIGRATION_GENERATOR_VERSION;
9
14
  export const UNKNOWN_DRIZZLE_KIT_GENERATOR_VERSION = "unknown";
@@ -119,7 +124,22 @@ export function getMigrationFolderFormatFailure(error) {
119
124
  );
120
125
  }
121
126
 
127
+ // Every raw stream the generator wrote; drizzle-kit reports hint requests on stdout.
128
+ function rawGeneratorOutput(error) {
129
+ return (
130
+ error?.gencowRawGeneratorOutput ??
131
+ [error?.stdout, error?.stderr, error?.message].map((value) => String(value ?? "")).join("\n")
132
+ );
133
+ }
134
+
135
+ function getMissingHintsFailure(error) {
136
+ return classifyMissingHintsOutput(rawGeneratorOutput(error));
137
+ }
138
+
122
139
  export function buildMigrationGenerationError(error) {
140
+ if (error?.code === MIGRATION_HINTS_REQUIRED && error.migrationHints) {
141
+ return createMigrationHintsRequiredError(error.migrationHints);
142
+ }
123
143
  const firstLine = getCommandErrorMessage(error).split("\n")[0] || "unknown generator error";
124
144
  const failure = new Error(`Migration generation failed: ${firstLine}`);
125
145
  const folderFormat = getMigrationFolderFormatFailure(error);
@@ -135,11 +155,17 @@ export function shouldRetryLegacyGenerate(error) {
135
155
  return message.includes("unknown command 'generate'") && message.includes("generate:pg");
136
156
  }
137
157
 
158
+ // Quotes one argv element for the legacy string-command path (never used for spawn).
159
+ function shellQuote(value) {
160
+ return `'${String(value).replace(/'/gu, `'\\''`)}'`;
161
+ }
162
+
138
163
  export function runDrizzleGenerateWithFallback({
139
164
  cwd,
140
165
  drizzleKitCmdImpl,
141
166
  env = {},
142
167
  execSyncImpl,
168
+ generatorArgs = [],
143
169
  spawnSyncImpl = spawnSync,
144
170
  stderrWriteImpl = (value) => process.stderr.write(value),
145
171
  stdio = "inherit",
@@ -151,7 +177,7 @@ export function runDrizzleGenerateWithFallback({
151
177
  try {
152
178
  const generatorEnv = selectDrizzleGeneratorEnvironment(env, dk.env);
153
179
  if (typeof dk.command === "string" && Array.isArray(dk.args)) {
154
- const result = spawnSyncImpl(dk.command, dk.args, {
180
+ const result = spawnSyncImpl(dk.command, [...dk.args, ...generatorArgs], {
155
181
  cwd,
156
182
  encoding: "utf8",
157
183
  env: generatorEnv,
@@ -169,6 +195,7 @@ export function runDrizzleGenerateWithFallback({
169
195
  Object.defineProperties(failure, {
170
196
  gencowGeneratorCwd: { value: cwd },
171
197
  gencowRawGeneratorMessage: { value: rawMessage },
198
+ gencowRawGeneratorOutput: { value: `${rawStdout}\n${rawStderr}\n${result.error?.message ?? ""}` },
172
199
  });
173
200
  throw failure;
174
201
  }
@@ -179,7 +206,10 @@ export function runDrizzleGenerateWithFallback({
179
206
  if (stderr) stderrWriteImpl(stderr);
180
207
  }
181
208
  } else {
182
- execSyncImpl(dk.cmd, { cwd, env: generatorEnv, stdio });
209
+ const command = generatorArgs.length
210
+ ? [dk.cmd, ...generatorArgs.map(shellQuote)].join(" ")
211
+ : dk.cmd;
212
+ execSyncImpl(command, { cwd, env: generatorEnv, stdio });
183
213
  }
184
214
  } catch (error) {
185
215
  if (error && typeof error === "object") {
@@ -188,6 +218,16 @@ export function runDrizzleGenerateWithFallback({
188
218
  } catch {
189
219
  /* preserve the original generator error when the thrown value is immutable */
190
220
  }
221
+ const missingHints = getMissingHintsFailure(error);
222
+ if (missingHints) {
223
+ try {
224
+ error.code = MIGRATION_HINTS_REQUIRED;
225
+ error.migrationHints = { ...missingHints, hintsProvided: generatorArgs.length > 0 };
226
+ } catch {
227
+ /* keep the raw generator error; the hints code is then unavailable */
228
+ }
229
+ throw error;
230
+ }
191
231
  const folderFormat = getMigrationFolderFormatFailure(error);
192
232
  if (folderFormat) {
193
233
  try {
@@ -6,6 +6,7 @@ import {
6
6
  } from "@gencow/migration-contract";
7
7
 
8
8
  import { describeMigrationFolderFormatFailure } from "./migration-folder-format.mjs";
9
+ import { buildHintsRequiredCommand, HINTS_DATA_LOSS_NOTICE, SAFE_COLUMN_REFERENCE } from "./migration-hints.mjs";
9
10
 
10
11
  const FORBIDDEN_ACTIONS = [
11
12
  "Do not use --force to bypass migration safety checks.",
@@ -50,6 +51,13 @@ const USER_FIXABLE_FAILURES = {
50
51
  detail:
51
52
  "Fix schema imports, types, or Drizzle configuration before uploading any existing migration bundle.",
52
53
  },
54
+ MIGRATION_HINTS_REQUIRED: {
55
+ phase: "GENERATE",
56
+ target: "SCHEMA_SOURCE",
57
+ summary: "Drizzle needs a rename or create decision for changed columns before it can generate migrations.",
58
+ detail:
59
+ "Run `npx gencow@latest db:generate --hints '<json-array>'` with one rename or create decision per changed column, then rerun this command. Do not edit applied migration files.",
60
+ },
53
61
  MIGRATION_BUNDLE_SCHEMA_CHANGE_WITHOUT_FORWARD_MIGRATION: {
54
62
  phase: "PREFLIGHT",
55
63
  target: "PENDING_MIGRATION",
@@ -93,6 +101,30 @@ export function buildClientUserFixableDiagnostic(code, correlationId) {
93
101
  };
94
102
  }
95
103
 
104
+ // Same stable code and state as MIGRATION_HINTS_REQUIRED from db:generate, with the exact
105
+ // retry command for the named column in text fields (USER_FIXABLE diagnostics cannot carry one).
106
+ export function buildMigrationHintsRequiredDiagnostic(hints, correlationId) {
107
+ const columns = Array.isArray(hints?.columns)
108
+ ? hints.columns.filter((value) => typeof value === "string" && SAFE_COLUMN_REFERENCE.test(value))
109
+ : [];
110
+ const command = buildHintsRequiredCommand(columns);
111
+ const detail = `Declare each changed column with \`${command}\` (use {"type":"create",...} to add a new column instead), then rerun this command. ${HINTS_DATA_LOSS_NOTICE} Do not edit applied migration files.`;
112
+ const base = buildClientUserFixableDiagnostic("MIGRATION_HINTS_REQUIRED", correlationId);
113
+ return {
114
+ ...base,
115
+ retryCondition: "Run db:generate with --hints for each changed column, then rerun this command.",
116
+ reason: { code: "migration_hints_required", summary: base.reason.summary, detail },
117
+ suggestedFixes: [
118
+ {
119
+ ...base.suggestedFixes[0],
120
+ id: "migration_hints_required-fix",
121
+ title: "Declare the column decisions with db:generate --hints",
122
+ detail,
123
+ },
124
+ ],
125
+ };
126
+ }
127
+
96
128
  // Same stable code and state as MIGRATION_GENERATION_FAILED, with the folder-format reason and the
97
129
  // pinned conversion command in text fields (USER_FIXABLE diagnostics cannot carry a command).
98
130
  export function buildMigrationFolderFormatDiagnostic(folderFormat, correlationId) {
@@ -0,0 +1,116 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+
4
+ // drizzle-kit reports column rename-or-create decisions it cannot infer as
5
+ // `missing_hints: N unresolved decisions` on stdout and exits non-zero. This module is the
6
+ // single judgment for that output and for the user's --hints / --hints-file input.
7
+ export const MIGRATION_HINTS_REQUIRED = "MIGRATION_HINTS_REQUIRED";
8
+ export const MIGRATION_HINTS_INVALID = "MIGRATION_HINTS_INVALID";
9
+
10
+ const MAX_HINTS_BYTES = 32 * 1024;
11
+ const MAX_LISTED_COLUMNS = 8;
12
+ const ANSI_PATTERN = /\u001b\[[0-9;]*m/gu;
13
+ const MISSING_HINTS_PATTERN = /^missing_hints:\s*(\d{1,4})\s+unresolved\b/mu;
14
+ const DECISION_LINE_PATTERN = /^\s*\d+\.\s+Rename or create\b/u;
15
+ const COLUMN_REF_PATTERN = /\bcolumn\s+(\S+)/u;
16
+ // schema.table.column only; anything else is never echoed back to the user.
17
+ export const SAFE_COLUMN_REFERENCE =
18
+ /^[A-Za-z_][A-Za-z0-9_]{0,62}\.[A-Za-z_][A-Za-z0-9_]{0,62}\.[A-Za-z_][A-Za-z0-9_]{0,62}$/u;
19
+
20
+ // Returns { count, columns } when the generator output asks for hints, otherwise null.
21
+ export function classifyMissingHintsOutput(rawOutput) {
22
+ const text = String(rawOutput ?? "").replace(ANSI_PATTERN, "");
23
+ const match = MISSING_HINTS_PATTERN.exec(text);
24
+ if (!match) return null;
25
+ const columns = [];
26
+ for (const line of text.split("\n")) {
27
+ if (!DECISION_LINE_PATTERN.test(line)) continue;
28
+ const ref = COLUMN_REF_PATTERN.exec(line)?.[1];
29
+ if (ref && SAFE_COLUMN_REFERENCE.test(ref) && !columns.includes(ref)) {
30
+ columns.push(ref);
31
+ if (columns.length === MAX_LISTED_COLUMNS) break;
32
+ }
33
+ }
34
+ return { count: Number(match[1]), columns };
35
+ }
36
+
37
+ // Shared by db:generate, db:push, and the db:generate help so the wording cannot drift.
38
+ export const HINTS_DATA_LOSS_NOTICE =
39
+ "A rename keeps the existing column's data, while create adds a new column; an old column that is not renamed may be dropped and its data lost. Review the generated migration SQL before applying it.";
40
+
41
+ // Exact retry command for the single decision we can name; placeholders otherwise.
42
+ export function buildHintsRequiredCommand(columns) {
43
+ const [table, column] =
44
+ columns.length === 1 ? columns[0].split(".").slice(1) : ["<table>", "<new-column>"];
45
+ return `npx gencow@latest db:generate --hints '[{"type":"rename","kind":"column","from":["public","${table}","<old-column>"],"to":["public","${table}","${column}"]}]'`;
46
+ }
47
+
48
+ export function createMigrationHintsRequiredError({ count, columns, hintsProvided }) {
49
+ const error = new Error(`Migration generation needs ${count} decision(s) for changed columns.`);
50
+ error.code = MIGRATION_HINTS_REQUIRED;
51
+ error.migrationHints = { count, columns, hintsProvided: hintsProvided === true };
52
+ return error;
53
+ }
54
+
55
+ function invalidHints(reason, source) {
56
+ const error = new Error(`Migration hints rejected: ${reason}.`);
57
+ error.code = MIGRATION_HINTS_INVALID;
58
+ error.hintsReason = reason;
59
+ error.hintsSource = source;
60
+ return error;
61
+ }
62
+
63
+ function isHintsArray(value) {
64
+ return (
65
+ Array.isArray(value) && value.every((item) => item !== null && typeof item === "object" && !Array.isArray(item))
66
+ );
67
+ }
68
+
69
+ function validateHintsJson(text, source) {
70
+ if (Buffer.byteLength(text, "utf8") > MAX_HINTS_BYTES) throw invalidHints("too_large", source);
71
+ let parsed;
72
+ try {
73
+ parsed = JSON.parse(text);
74
+ } catch {
75
+ throw invalidHints("json_invalid", source);
76
+ }
77
+ if (!isHintsArray(parsed)) throw invalidHints("not_array", source);
78
+ }
79
+
80
+ // Parses --hints / --hints-file (also in --flag=value form). Unknown arguments are ignored
81
+ // as before. Returns the extra generator arguments; never invokes the generator itself.
82
+ export function parseMigrationHintsArgs(args, { cwd, readFileSyncImpl = readFileSync, resolvePathImpl = resolve } = {}) {
83
+ const occurrences = [];
84
+ for (let index = 0; index < args.length; index += 1) {
85
+ const arg = String(args[index]);
86
+ const flag = arg.startsWith("--hints-file") ? "--hints-file" : arg.startsWith("--hints") ? "--hints" : null;
87
+ if (!flag) continue;
88
+ const rest = arg.slice(flag.length);
89
+ if (rest !== "" && rest[0] !== "=") continue;
90
+ if (rest.startsWith("=")) {
91
+ occurrences.push({ flag, value: rest.slice(1) });
92
+ } else {
93
+ const next = args[index + 1];
94
+ occurrences.push({ flag, value: typeof next === "string" && !next.startsWith("--") ? next : undefined });
95
+ if (next !== undefined && !String(next).startsWith("--")) index += 1;
96
+ }
97
+ }
98
+ if (occurrences.length === 0) return { args: [] };
99
+ if (occurrences.length > 1) throw invalidHints("conflicting_options", "argument");
100
+ const [{ flag, value }] = occurrences;
101
+ const source = flag === "--hints" ? "inline" : "file";
102
+ if (typeof value !== "string" || value === "") throw invalidHints("value_missing", source);
103
+ if (flag === "--hints") {
104
+ validateHintsJson(value, source);
105
+ return { args: ["--hints", value] };
106
+ }
107
+ const absolutePath = resolvePathImpl(cwd ?? ".", value);
108
+ let text;
109
+ try {
110
+ text = readFileSyncImpl(absolutePath, "utf8");
111
+ } catch {
112
+ throw invalidHints("file_unreadable", source);
113
+ }
114
+ validateHintsJson(String(text), source);
115
+ return { args: ["--hints-file", absolutePath] };
116
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gencow",
3
- "version": "0.1.268",
3
+ "version": "0.1.269",
4
4
  "description": "Gencow — AI Backend Engine",
5
5
  "type": "module",
6
6
  "bin": {
@@ -43,8 +43,8 @@
43
43
  "@gencow/client": "0.2.8",
44
44
  "@gencow/react": "0.2.8",
45
45
  "@gencow/core": "0.1.47",
46
- "@gencow/server": "0.1.3",
47
- "@gencow/shared": "0.0.0"
46
+ "@gencow/shared": "0.0.0",
47
+ "@gencow/server": "0.1.3"
48
48
  },
49
49
  "scripts": {
50
50
  "build": "node scripts/bundle-core.mjs && node scripts/bundle-shared.mjs && node scripts/bundle-server.mjs",