gencow 0.1.267 → 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.
- package/lib/cli-command-runner.mjs +58 -0
- package/lib/db-command.mjs +44 -6
- package/lib/db-push-command.mjs +5 -1
- package/lib/db-seed-failure-render.mjs +84 -0
- package/lib/deploy-package-runtime.mjs +5 -6
- package/lib/deploy-project-package-json.mjs +79 -0
- package/lib/drizzle-generate.mjs +42 -2
- package/lib/migration-client-diagnostic.mjs +32 -0
- package/lib/migration-hints.mjs +116 -0
- package/package.json +3 -3
- package/runtime/server.mjs +61 -61
|
@@ -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;
|
package/lib/db-command.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
});
|
package/lib/db-push-command.mjs
CHANGED
|
@@ -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
|
-
:
|
|
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
|
+
}
|
|
@@ -7,6 +7,7 @@ import { fetchAppDiagnosticLogsViaRest, normalizeDiagnosticLogLines } from "./ap
|
|
|
7
7
|
import { createCloudAppIfNeeded as createCloudAppIfNeededImpl } from "./deploy-app-creation.mjs";
|
|
8
8
|
import { rejectExplicitAppNotFound } from "./deploy-app-target.mjs";
|
|
9
9
|
import { loadProjectConfigForDeploy } from "./deploy-project-config-load.mjs";
|
|
10
|
+
import { loadPackageJson } from "./deploy-project-package-json.mjs";
|
|
10
11
|
import { resolveConfiguredAuditorImplementation } from "./auditor-mode.mjs";
|
|
11
12
|
import {
|
|
12
13
|
loadDeployMigrationGenerationContext,
|
|
@@ -398,12 +399,13 @@ export function createDeployPackageRuntime({
|
|
|
398
399
|
warnRuntimePathsOutsideBackendRoot({ entryPoint, projectRoot: cwd, backendRoot, warnImpl });
|
|
399
400
|
let dependencyAuditManifest = null;
|
|
400
401
|
let dependencyVersionMessage = "";
|
|
401
|
-
|
|
402
|
+
const packageJson = loadPackageJson({ cwd, readFileSyncImpl, resolvePathImpl, errorImpl, exitImpl });
|
|
403
|
+
if (!packageJson) return null;
|
|
404
|
+
const projectPkg = packageJson.pkg;
|
|
402
405
|
try {
|
|
403
406
|
const { auditDependencyVersions, formatDependencyVersionAuditError } = await (
|
|
404
407
|
loadDeployAuditorImpl ?? loadDeployAuditorDefault
|
|
405
408
|
)();
|
|
406
|
-
projectPkg = JSON.parse(readFileSyncImpl(resolvePathImpl(cwd, "package.json"), "utf8"));
|
|
407
409
|
const auditOptions = { cwd, existsSyncImpl, readFileSyncImpl };
|
|
408
410
|
const dependencyVersionResult = auditDependencyVersions(projectPkg, auditOptions);
|
|
409
411
|
dependencyVersionMessage = formatDependencyVersionAuditError(dependencyVersionResult);
|
|
@@ -447,9 +449,6 @@ export function createDeployPackageRuntime({
|
|
|
447
449
|
}
|
|
448
450
|
}
|
|
449
451
|
|
|
450
|
-
// Keep the manifest byte-compatible with the canonical pnpm lock importer.
|
|
451
|
-
const stagedPackageJson = readFileSyncImpl(resolvePathImpl(cwd, "package.json"), "utf8");
|
|
452
|
-
|
|
453
452
|
let bundleBackendRoot;
|
|
454
453
|
let bundleFileRows = [];
|
|
455
454
|
try {
|
|
@@ -472,7 +471,7 @@ export function createDeployPackageRuntime({
|
|
|
472
471
|
readFileSyncImpl,
|
|
473
472
|
resolvePathImpl,
|
|
474
473
|
rmSyncImpl,
|
|
475
|
-
stagedPackageJson,
|
|
474
|
+
stagedPackageJson: packageJson.text,
|
|
476
475
|
tarCreateImpl,
|
|
477
476
|
tmpBundle,
|
|
478
477
|
writeCronManifestForProjectImpl,
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Reads the project package.json for deploy packaging. A missing, unreadable or non-JSON file is reported
|
|
2
|
+
// as a typed, secret-free diagnosis (no raw fs/parser text, no absolute workspace path) and returns null.
|
|
3
|
+
|
|
4
|
+
export const DEPLOY_PACKAGE_JSON_CODES = Object.freeze({
|
|
5
|
+
MISSING: "DEPLOY_PACKAGE_JSON_MISSING",
|
|
6
|
+
UNREADABLE: "DEPLOY_PACKAGE_JSON_UNREADABLE",
|
|
7
|
+
INVALID: "DEPLOY_PACKAGE_JSON_INVALID",
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const NEXT_ACTION_FOR_MISSING =
|
|
11
|
+
"Next action: for a static-only site, run `gencow static --prod <build-output-dir>` (for example `gencow static --prod dist/`); for a backend app, run `npx gencow@latest init` to create a project with a package.json, then rerun deploy.";
|
|
12
|
+
|
|
13
|
+
const DIAGNOSES = Object.freeze({
|
|
14
|
+
[DEPLOY_PACKAGE_JSON_CODES.MISSING]: {
|
|
15
|
+
message: "DEPLOY BLOCKED — package.json was not found in the project root.",
|
|
16
|
+
retryable:
|
|
17
|
+
"Retryable: yes, after package.json exists in the project root — run `ls package.json` to confirm, then rerun the same deploy command.",
|
|
18
|
+
nextAction: NEXT_ACTION_FOR_MISSING,
|
|
19
|
+
},
|
|
20
|
+
[DEPLOY_PACKAGE_JSON_CODES.UNREADABLE]: {
|
|
21
|
+
message: "DEPLOY BLOCKED — package.json exists but could not be read.",
|
|
22
|
+
retryable:
|
|
23
|
+
"Retryable: yes, after package.json is readable by the current user — check its file permissions, then rerun the same deploy command.",
|
|
24
|
+
nextAction:
|
|
25
|
+
"Next action: make package.json a readable regular file in the project root, then rerun deploy.",
|
|
26
|
+
},
|
|
27
|
+
[DEPLOY_PACKAGE_JSON_CODES.INVALID]: {
|
|
28
|
+
message: "DEPLOY BLOCKED — package.json is not a valid JSON object.",
|
|
29
|
+
retryable:
|
|
30
|
+
"Retryable: yes, after package.json is fixed — run `node -p \"require('./package.json').name\"` to see the syntax error, correct the file, then rerun the same deploy command.",
|
|
31
|
+
nextAction:
|
|
32
|
+
"Next action: correct the JSON syntax of package.json in the project root, then rerun deploy.",
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export function formatDeployPackageJsonDiagnostic(code) {
|
|
37
|
+
const diagnosis = DIAGNOSES[code];
|
|
38
|
+
return [
|
|
39
|
+
diagnosis.message,
|
|
40
|
+
`Code: ${code}`,
|
|
41
|
+
"Stage: packaging",
|
|
42
|
+
"Database mutation: NONE",
|
|
43
|
+
"Incumbent serving: PRESERVED",
|
|
44
|
+
diagnosis.retryable,
|
|
45
|
+
diagnosis.nextAction,
|
|
46
|
+
].join("\n");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isPlainObject(value) {
|
|
50
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Returns { pkg, text } (text is the exact file content, so the staged manifest stays byte-identical to what
|
|
54
|
+
// was validated), or null after printing the typed diagnosis and exiting with code 1.
|
|
55
|
+
export function loadPackageJson({ cwd, readFileSyncImpl, resolvePathImpl, errorImpl, exitImpl }) {
|
|
56
|
+
let text;
|
|
57
|
+
let code = null;
|
|
58
|
+
try {
|
|
59
|
+
text = readFileSyncImpl(resolvePathImpl(cwd, "package.json"), "utf8");
|
|
60
|
+
} catch (caught) {
|
|
61
|
+
code =
|
|
62
|
+
caught?.code === "ENOENT" ? DEPLOY_PACKAGE_JSON_CODES.MISSING : DEPLOY_PACKAGE_JSON_CODES.UNREADABLE;
|
|
63
|
+
}
|
|
64
|
+
let pkg;
|
|
65
|
+
if (code === null) {
|
|
66
|
+
try {
|
|
67
|
+
pkg = JSON.parse(text);
|
|
68
|
+
} catch {
|
|
69
|
+
pkg = undefined;
|
|
70
|
+
}
|
|
71
|
+
if (!isPlainObject(pkg)) code = DEPLOY_PACKAGE_JSON_CODES.INVALID;
|
|
72
|
+
}
|
|
73
|
+
if (code !== null) {
|
|
74
|
+
errorImpl(formatDeployPackageJsonDiagnostic(code));
|
|
75
|
+
exitImpl(1);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
return { pkg, text };
|
|
79
|
+
}
|
package/lib/drizzle-generate.mjs
CHANGED
|
@@ -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
|
-
|
|
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) {
|