@runa-ai/runa-cli 0.10.3 → 0.10.5
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/{build-P2A6345N.js → build-C65G2QQE.js} +3 -2
- package/dist/{chunk-UHDAYPHH.js → chunk-47BG6DRP.js} +1 -1
- package/dist/{chunk-MAFJAA2P.js → chunk-C3SRIUWX.js} +1 -1
- package/dist/{chunk-QSEF4T3Y.js → chunk-F2AQ3EYJ.js} +10 -199
- package/dist/{chunk-SS7RIWW3.js → chunk-FZONYKNR.js} +1 -1
- package/dist/chunk-NOXYPVMZ.js +204 -0
- package/dist/chunk-TXXGDJYB.js +3636 -0
- package/dist/{chunk-S7VGVFYF.js → chunk-WHVJ4JMQ.js} +274 -3863
- package/dist/{chunk-XFXGFUAM.js → chunk-XVGMGFKF.js} +1 -1
- package/dist/{chunk-WGRVAGSR.js → chunk-ZDETCPCE.js} +2 -2
- package/dist/{ci-6P7VK6WB.js → ci-WOP7K7MS.js} +11 -9
- package/dist/{cli-Q665YRVT.js → cli-4HI3D2II.js} +12 -12
- package/dist/{db-BQOVOQXU.js → db-DO6L72RR.js} +91 -53
- package/dist/{dev-QR55VDNZ.js → dev-N3BFJZ7F.js} +3 -2
- package/dist/{env-KYR6Q7WO.js → env-2XM45E7O.js} +4 -3
- package/dist/{env-XPPACZM4.js → env-KIMSQSPS.js} +3 -2
- package/dist/helpers-RHAZLOLQ.js +15 -0
- package/dist/{hotfix-JYHDY2M6.js → hotfix-QP5J6FCD.js} +4 -3
- package/dist/index.js +3 -3
- package/dist/local-supabase-KTTC3O2L.js +8 -0
- package/dist/{risk-detector-GDDLISVE.js → risk-detector-4D5HRUMY.js} +1 -1
- package/dist/{risk-detector-core-YI3M6INI.js → risk-detector-core-CHUY6M5N.js} +1 -1
- package/dist/{vuln-check-WW43E7PS.js → vuln-check-OE4KSASO.js} +1 -1
- package/dist/{vuln-checker-BC3ZAXJ3.js → vuln-checker-LMHRSMYY.js} +1 -1
- package/dist/{watch-4RHXVCQ3.js → watch-VQQHKDNQ.js} +1 -1
- package/package.json +3 -3
- package/dist/{risk-detector-plpgsql-4GWEQXUG.js → risk-detector-plpgsql-NNUZU3OQ.js} +1 -1
|
@@ -0,0 +1,3636 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from 'module';
|
|
3
|
+
import { acquireAdvisoryLock, releaseAdvisoryLock, checkPasswordSecurity, setRolePasswords, handleFreshDbCase, detectAppSchemas, tryReuseDbPlanArtifact, cleanPartitionAclsForPgSchemaDiff, parsePlanOutput, bootstrapTempDbFromSource, isNoChangePlanOutput, handleHazardsWithContext, getIdempotentProtectedTables, getIdempotentProtectedObjects, validateDependencyOrder, getTableRowEstimates, verifyDataIntegrity, parseExpectedPartitions, queryActualPartitions, detectPartitionDrift, formatPartitionWarnings, detectPlannerSchemas, buildTargetFingerprint, normalizeDatabaseUrlForDdl, DbApplyOutputSchema, analyzeDuplicateFunctionOwnership, formatDuplicateFunctionOwnershipFinding, prefilterPartitionStubs, needsShadowDb, cleanupOrphanShadowDatabases, createShadowDbWithExtensions, createPlanAnalysisSession, analyzePlanStatement, checkDataCompatibility, displayDataCompatibilityResults, filterCheckModePlanStatements, buildCheckModePlanSummary, displayCheckModeResults, backupIdempotentTables, buildAllowedHazards, executePlanSqlWithRetry, persistDbPlanArtifact, stripLeadingSetStatements, calculateBackoffDelay, sleep, getTransactionStrategy, wrapInTransaction, isLockTimeoutError, maskDbCredentials, normalizeFunctionDefinitionAst, DbPreviewProfileSchema } from './chunk-WHVJ4JMQ.js';
|
|
4
|
+
import { resolveDatabaseUrl, resolveDatabaseTarget } from './chunk-ZDETCPCE.js';
|
|
5
|
+
import { verifyPgSchemaDiffBinary, verifyDatabaseConnection, freeConnectionSlotsForPgSchemaDiff, executePgSchemaDiffPlan, detectDropTableStatements, analyzeDeclarativeDependencyContract, DB_APPLY_CHECK_MODE_CONTRACT_NOTE, formatDeclarativeDependencyViolation, summarizeDeclarativeDependencyWarnings, MAX_DETAILED_DECLARATIVE_WARNINGS, formatDeclarativeDependencyWarning } from './chunk-HWR5NUUZ.js';
|
|
6
|
+
import { generateTablesManifest } from './chunk-O3M7A73M.js';
|
|
7
|
+
import { psqlSyncQuery, psqlSyncFile } from './chunk-A6A7JIRD.js';
|
|
8
|
+
import { loadEnvFiles } from './chunk-IWVXI5O4.js';
|
|
9
|
+
import { emitJsonSuccess } from './chunk-KE6QJBZG.js';
|
|
10
|
+
import { init_local_supabase, buildLocalDatabaseUrl } from './chunk-F2AQ3EYJ.js';
|
|
11
|
+
import { loadRunaConfig } from './chunk-OERS32LW.js';
|
|
12
|
+
import { init_esm_shims } from './chunk-VRXHCR5K.js';
|
|
13
|
+
import { createCLILogger, CLIError, buildCommandOutcomeSummary, deriveCommandExitMode, isTimeoutLikeMessage, formatDuration } from '@runa-ai/runa';
|
|
14
|
+
import { Command } from 'commander';
|
|
15
|
+
import { fromPromise, setup, assign, createActor } from 'xstate';
|
|
16
|
+
import { existsSync, readdirSync, mkdtempSync, readFileSync, writeFileSync, rmSync, unlinkSync } from 'fs';
|
|
17
|
+
import { randomUUID } from 'crypto';
|
|
18
|
+
import { tmpdir } from 'os';
|
|
19
|
+
import path2, { join } from 'path';
|
|
20
|
+
import { z } from 'zod';
|
|
21
|
+
|
|
22
|
+
createRequire(import.meta.url);
|
|
23
|
+
|
|
24
|
+
// src/commands/db/commands/db-apply.ts
|
|
25
|
+
init_esm_shims();
|
|
26
|
+
|
|
27
|
+
// src/commands/db/apply/index.ts
|
|
28
|
+
init_esm_shims();
|
|
29
|
+
|
|
30
|
+
// src/commands/db/apply/actors.ts
|
|
31
|
+
init_esm_shims();
|
|
32
|
+
|
|
33
|
+
// src/commands/db/apply/actors/lock-actors.ts
|
|
34
|
+
init_esm_shims();
|
|
35
|
+
|
|
36
|
+
// src/commands/db/apply/actors/shared.ts
|
|
37
|
+
init_esm_shims();
|
|
38
|
+
var logger = createCLILogger("db:apply");
|
|
39
|
+
function isLocalHostname(hostname) {
|
|
40
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "host.docker.internal";
|
|
41
|
+
}
|
|
42
|
+
function getDbUrl(input) {
|
|
43
|
+
if (input.databaseUrl) {
|
|
44
|
+
try {
|
|
45
|
+
const parsed = new URL(input.databaseUrl);
|
|
46
|
+
const isLocal = isLocalHostname(parsed.hostname);
|
|
47
|
+
if (input.env === "local" && !isLocal) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`--env local but --databaseUrl points to remote host (${parsed.hostname}). Use --env preview or --env production for remote databases.`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
if (input.env === "production" && isLocal) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
"--env production but --databaseUrl points to localhost. Use --env local for local Supabase operations."
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (error instanceof Error && error.message.includes("--env")) throw error;
|
|
59
|
+
}
|
|
60
|
+
return normalizeDatabaseUrlForDdl(input.databaseUrl);
|
|
61
|
+
}
|
|
62
|
+
const resolvedUrl = resolveDatabaseUrl(input.env);
|
|
63
|
+
return normalizeDatabaseUrlForDdl(resolvedUrl);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// src/commands/db/apply/actors/lock-actors.ts
|
|
67
|
+
var acquireLock = fromPromise(
|
|
68
|
+
async ({ input: { input } }) => {
|
|
69
|
+
const dbUrl = getDbUrl(input);
|
|
70
|
+
const acquired = await acquireAdvisoryLock(dbUrl, input.verbose);
|
|
71
|
+
if (!acquired) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
"Could not acquire migration lock. Another migration may be running. Wait for it to complete or manually release the lock."
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
return { acquired };
|
|
77
|
+
}
|
|
78
|
+
);
|
|
79
|
+
var releaseLock = fromPromise(
|
|
80
|
+
async ({ input: { input } }) => {
|
|
81
|
+
const dbUrl = getDbUrl(input);
|
|
82
|
+
releaseAdvisoryLock(dbUrl, input.verbose);
|
|
83
|
+
}
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// src/commands/db/apply/actors/idempotent-actors.ts
|
|
87
|
+
init_esm_shims();
|
|
88
|
+
var DEFERRABLE_ERROR_PATTERNS = [
|
|
89
|
+
/relation ".*" does not exist/i,
|
|
90
|
+
/schema ".*" does not exist/i,
|
|
91
|
+
/type ".*" does not exist/i,
|
|
92
|
+
/function ".*" does not exist/i,
|
|
93
|
+
/table ".*" does not exist/i,
|
|
94
|
+
/column ".*" does not exist/i,
|
|
95
|
+
/view ".*" does not exist/i,
|
|
96
|
+
/sequence ".*" does not exist/i,
|
|
97
|
+
/index ".*" does not exist/i,
|
|
98
|
+
/trigger ".*" does not exist/i,
|
|
99
|
+
/role ".*" does not exist/i,
|
|
100
|
+
/extension ".*" does not exist/i,
|
|
101
|
+
/operator(?:\s+(?:class|family))?\s+".*" does not exist/i
|
|
102
|
+
];
|
|
103
|
+
var NON_DEFERRABLE_ERROR_PATTERNS = [
|
|
104
|
+
/syntax error/i,
|
|
105
|
+
/unterminated/i,
|
|
106
|
+
/permission denied/i,
|
|
107
|
+
/must be owner/i,
|
|
108
|
+
/access denied/i,
|
|
109
|
+
/invalid input syntax/i
|
|
110
|
+
];
|
|
111
|
+
function isDeferrableError(stderr) {
|
|
112
|
+
if (NON_DEFERRABLE_ERROR_PATTERNS.some((p) => p.test(stderr))) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
if (DEFERRABLE_ERROR_PATTERNS.some((p) => p.test(stderr))) {
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
var IDEMPOTENT_MAX_RETRIES = 3;
|
|
121
|
+
var IDEMPOTENT_MAX_DELAY_MS = 1e4;
|
|
122
|
+
function emptyRiskSummary() {
|
|
123
|
+
return { high: 0, low: 0, medium: 0 };
|
|
124
|
+
}
|
|
125
|
+
function collectSortedSqlFiles(dir) {
|
|
126
|
+
return readdirSync(dir).filter((file) => file.endsWith(".sql")).sort();
|
|
127
|
+
}
|
|
128
|
+
function writeVerboseSqlOutput(result, verbose) {
|
|
129
|
+
if (!verbose) return;
|
|
130
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
131
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
132
|
+
}
|
|
133
|
+
async function waitBeforeIdempotentRetry(file, attempt, maxAttempts) {
|
|
134
|
+
if (attempt <= 0) return;
|
|
135
|
+
const delay = calculateBackoffDelay(attempt - 1, IDEMPOTENT_MAX_DELAY_MS);
|
|
136
|
+
logger.info(` Retry ${attempt}/${maxAttempts - 1} for ${file} after ${Math.round(delay)}ms...`);
|
|
137
|
+
await sleep(delay);
|
|
138
|
+
}
|
|
139
|
+
function throwPrePassFailure(file, stderr) {
|
|
140
|
+
const errorMsg = stderr ? maskDbCredentials(stderr) : "";
|
|
141
|
+
throw new Error(
|
|
142
|
+
`Failed to apply idempotent schema (1st pass): ${file}
|
|
143
|
+
This is NOT a dependency error (would be deferred). Fix the script before retrying.
|
|
144
|
+
${errorMsg}`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
function throwPostPassFailure(file, stderr) {
|
|
148
|
+
const errorMsg = stderr ? maskDbCredentials(stderr) : "";
|
|
149
|
+
throw new Error(`Failed to apply idempotent schema: ${file}
|
|
150
|
+
${errorMsg}`);
|
|
151
|
+
}
|
|
152
|
+
function handlePrePassFailure(file, stderr) {
|
|
153
|
+
if (isDeferrableError(stderr)) {
|
|
154
|
+
logger.info(` \u21A9 Deferred ${file} \u2192 will retry in 2nd pass`);
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
throwPrePassFailure(file, stderr);
|
|
158
|
+
}
|
|
159
|
+
function shouldRetryPostPass(file, stderr, attempt, maxAttempts) {
|
|
160
|
+
if (!isLockTimeoutError(stderr) || attempt >= maxAttempts - 1) {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
logger.warn(` Lock timeout on ${file} (attempt ${attempt + 1}/${maxAttempts})`);
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
function logApplySummary(pass, filesApplied, filesSkipped, filesLength) {
|
|
167
|
+
if (pass === "post") {
|
|
168
|
+
logger.success(
|
|
169
|
+
`2nd pass: Applied ${filesApplied} idempotent schema(s) \u2014 all deferred scripts resolved`
|
|
170
|
+
);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (filesSkipped > 0) {
|
|
174
|
+
logger.success(
|
|
175
|
+
`Applied ${filesApplied}/${filesApplied + filesSkipped} idempotent schema(s) (${filesSkipped} deferred to 2nd pass)`
|
|
176
|
+
);
|
|
177
|
+
if (filesSkipped === filesLength) {
|
|
178
|
+
logger.warn("ALL idempotent files skipped in 1st pass.");
|
|
179
|
+
logger.warn("This may indicate a syntax error. Check logs with --verbose.");
|
|
180
|
+
}
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
logger.success(`Applied ${filesApplied} idempotent schema(s)`);
|
|
184
|
+
}
|
|
185
|
+
async function applyIdempotentFiles(dbUrl, schemasDir, files, verbose, pass) {
|
|
186
|
+
let filesApplied = 0;
|
|
187
|
+
let filesSkipped = 0;
|
|
188
|
+
for (const file of files) {
|
|
189
|
+
const applied = await applySingleIdempotentFile(dbUrl, schemasDir, file, verbose, pass);
|
|
190
|
+
if (applied) {
|
|
191
|
+
filesApplied += 1;
|
|
192
|
+
} else {
|
|
193
|
+
filesSkipped += 1;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return { filesApplied, filesSkipped };
|
|
197
|
+
}
|
|
198
|
+
function incrementRiskSummary(summary, level) {
|
|
199
|
+
if (level === "high") {
|
|
200
|
+
summary.high += 1;
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (level === "medium") {
|
|
204
|
+
summary.medium += 1;
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
summary.low += 1;
|
|
208
|
+
}
|
|
209
|
+
function logIdempotentRiskSummary(summary) {
|
|
210
|
+
if (summary.high > 0 || summary.medium > 0) {
|
|
211
|
+
logger.warn(
|
|
212
|
+
`Idempotent risk summary: ${summary.high} HIGH, ${summary.medium} MEDIUM, ${summary.low} LOW`
|
|
213
|
+
);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
logger.success(`Idempotent risk scan: no HIGH/MEDIUM risks detected`);
|
|
217
|
+
}
|
|
218
|
+
async function detectIdempotentRiskSummary(schemasDir, files, verbose) {
|
|
219
|
+
const summary = emptyRiskSummary();
|
|
220
|
+
try {
|
|
221
|
+
const { detectSchemaRisks } = await import('./risk-detector-4D5HRUMY.js');
|
|
222
|
+
for (const file of files) {
|
|
223
|
+
const filePath = join(schemasDir, file);
|
|
224
|
+
const risks = await detectSchemaRisks(filePath);
|
|
225
|
+
for (const risk of risks) {
|
|
226
|
+
incrementRiskSummary(summary, risk.level);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
logIdempotentRiskSummary(summary);
|
|
230
|
+
} catch {
|
|
231
|
+
if (verbose) {
|
|
232
|
+
logger.debug("Could not load risk detector for idempotent preview");
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return summary;
|
|
236
|
+
}
|
|
237
|
+
function executeSqlFileWithTransactionStrategy(dbUrl, filePath, verbose) {
|
|
238
|
+
const strategy = getTransactionStrategy(filePath);
|
|
239
|
+
if (strategy === "skip") {
|
|
240
|
+
if (verbose) logger.debug(` Transaction: skip (incompatible statements detected)`);
|
|
241
|
+
const result = psqlSyncFile({ databaseUrl: dbUrl, filePath, onErrorStop: true });
|
|
242
|
+
return result;
|
|
243
|
+
}
|
|
244
|
+
const content = readFileSync(filePath, "utf-8");
|
|
245
|
+
const wrapped = wrapInTransaction(content);
|
|
246
|
+
const tempFile = join(tmpdir(), `runa-idempotent-${randomUUID()}.sql`);
|
|
247
|
+
writeFileSync(tempFile, wrapped, "utf-8");
|
|
248
|
+
try {
|
|
249
|
+
if (verbose) logger.debug(` Transaction: wrap (BEGIN/COMMIT)`);
|
|
250
|
+
return psqlSyncFile({ databaseUrl: dbUrl, filePath: tempFile, onErrorStop: true });
|
|
251
|
+
} finally {
|
|
252
|
+
try {
|
|
253
|
+
unlinkSync(tempFile);
|
|
254
|
+
} catch {
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
async function applySingleIdempotentFile(dbUrl, schemasDir, file, verbose, pass) {
|
|
259
|
+
const filePath = join(schemasDir, file);
|
|
260
|
+
if (verbose) logger.debug(`Applying ${file}...`);
|
|
261
|
+
const maxAttempts = pass === "post" ? IDEMPOTENT_MAX_RETRIES : 1;
|
|
262
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
263
|
+
await waitBeforeIdempotentRetry(file, attempt, maxAttempts);
|
|
264
|
+
const result = executeSqlFileWithTransactionStrategy(dbUrl, filePath, verbose);
|
|
265
|
+
writeVerboseSqlOutput(result, verbose);
|
|
266
|
+
if (result.status === 0) {
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
const stderr = result.stderr?.trim() ?? "";
|
|
270
|
+
if (pass === "pre") {
|
|
271
|
+
return handlePrePassFailure(file, stderr);
|
|
272
|
+
}
|
|
273
|
+
if (shouldRetryPostPass(file, stderr, attempt, maxAttempts)) {
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
throwPostPassFailure(file, stderr);
|
|
277
|
+
}
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
var applyIdempotentSchemas = fromPromise(async ({ input: { input, targetDir, pass } }) => {
|
|
281
|
+
if (pass === "pre") checkPasswordSecurity();
|
|
282
|
+
const schemasDir = join(targetDir, "supabase/schemas/idempotent");
|
|
283
|
+
const dbUrl = getDbUrl(input);
|
|
284
|
+
let filesApplied = 0;
|
|
285
|
+
let filesSkipped = 0;
|
|
286
|
+
if (!existsSync(schemasDir)) {
|
|
287
|
+
logger.info("No idempotent schemas found");
|
|
288
|
+
} else {
|
|
289
|
+
const files = collectSortedSqlFiles(schemasDir);
|
|
290
|
+
if (files.length > 0) {
|
|
291
|
+
const result = await applyIdempotentFiles(dbUrl, schemasDir, files, input.verbose, pass);
|
|
292
|
+
filesApplied = result.filesApplied;
|
|
293
|
+
filesSkipped = result.filesSkipped;
|
|
294
|
+
logApplySummary(pass, filesApplied, filesSkipped, files.length);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const rolePasswordsSet = setRolePasswords(dbUrl, input.verbose);
|
|
298
|
+
return { filesApplied, filesSkipped, rolePasswordsSet };
|
|
299
|
+
});
|
|
300
|
+
var previewIdempotentSchemas = fromPromise(async ({ input: { input, targetDir } }) => {
|
|
301
|
+
const schemasDir = join(targetDir, "supabase/schemas/idempotent");
|
|
302
|
+
if (!existsSync(schemasDir)) {
|
|
303
|
+
return { files: [], riskSummary: emptyRiskSummary() };
|
|
304
|
+
}
|
|
305
|
+
const files = collectSortedSqlFiles(schemasDir);
|
|
306
|
+
if (files.length === 0) {
|
|
307
|
+
return { files: [], riskSummary: emptyRiskSummary() };
|
|
308
|
+
}
|
|
309
|
+
logger.info(`Idempotent schemas (${files.length} file(s)):`);
|
|
310
|
+
for (const file of files) {
|
|
311
|
+
logger.info(` ${file}`);
|
|
312
|
+
}
|
|
313
|
+
const riskSummary = await detectIdempotentRiskSummary(schemasDir, files, input.verbose);
|
|
314
|
+
return { files, riskSummary };
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// src/commands/db/apply/actors/pg-schema-diff-actors.ts
|
|
318
|
+
init_esm_shims();
|
|
319
|
+
|
|
320
|
+
// src/commands/db/utils/plan-size-guard.ts
|
|
321
|
+
init_esm_shims();
|
|
322
|
+
var PLAN_SIZE_WARNING_THRESHOLD = {
|
|
323
|
+
effectiveStatements: 200,
|
|
324
|
+
rawStatements: 500
|
|
325
|
+
};
|
|
326
|
+
var PLAN_SIZE_BLOCKER_THRESHOLD = {
|
|
327
|
+
effectiveStatements: 500,
|
|
328
|
+
rawStatements: 1e3
|
|
329
|
+
};
|
|
330
|
+
function buildThresholdReasons(summary, threshold) {
|
|
331
|
+
const reasons = [];
|
|
332
|
+
if (summary.effectiveStatements >= threshold.effectiveStatements) {
|
|
333
|
+
reasons.push(
|
|
334
|
+
`effective structural statements ${summary.effectiveStatements} >= ${threshold.effectiveStatements}`
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
if (summary.rawStatements >= threshold.rawStatements) {
|
|
338
|
+
reasons.push(`raw statements ${summary.rawStatements} >= ${threshold.rawStatements}`);
|
|
339
|
+
}
|
|
340
|
+
return reasons;
|
|
341
|
+
}
|
|
342
|
+
function assessPlanSize(summary) {
|
|
343
|
+
if (!summary) {
|
|
344
|
+
return { severity: "ok", reasons: [] };
|
|
345
|
+
}
|
|
346
|
+
const blockerReasons = buildThresholdReasons(summary, PLAN_SIZE_BLOCKER_THRESHOLD);
|
|
347
|
+
if (blockerReasons.length > 0) {
|
|
348
|
+
return { severity: "blocker", reasons: blockerReasons };
|
|
349
|
+
}
|
|
350
|
+
const warningReasons = buildThresholdReasons(summary, PLAN_SIZE_WARNING_THRESHOLD);
|
|
351
|
+
if (warningReasons.length > 0) {
|
|
352
|
+
return { severity: "warning", reasons: warningReasons };
|
|
353
|
+
}
|
|
354
|
+
return { severity: "ok", reasons: [] };
|
|
355
|
+
}
|
|
356
|
+
function formatPlanSizeSummary(summary) {
|
|
357
|
+
return `${summary.rawStatements} raw statement(s), ${summary.effectiveStatements} structural, ${summary.noiseStatements} collapsed noise`;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// src/commands/db/apply/actors/pg-schema-diff-actors.ts
|
|
361
|
+
init_local_supabase();
|
|
362
|
+
|
|
363
|
+
// src/commands/db/utils/declarative-dependency-warning-governance.ts
|
|
364
|
+
init_esm_shims();
|
|
365
|
+
|
|
366
|
+
// src/commands/db/utils/boundary-policy.ts
|
|
367
|
+
init_esm_shims();
|
|
368
|
+
|
|
369
|
+
// src/commands/db/utils/boundary-policy/types.ts
|
|
370
|
+
init_esm_shims();
|
|
371
|
+
var BOUNDARY_POLICY_FILENAME = ".boundary-policy.json";
|
|
372
|
+
var DEFAULT_POLICY_VERSION = "1.0";
|
|
373
|
+
var SAFE_REGEXP = /a^/;
|
|
374
|
+
var POLICY_VERSION_PATTERN = /^[0-9]+\.[0-9]+(\.[0-9]+)?$/;
|
|
375
|
+
var OBJECT_NAME_PATTERN = /^[A-Z][A-Z0-9_]*$/;
|
|
376
|
+
var DATE_ISO_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
377
|
+
var FALLBACK_DECLARATIVE_OBJECTS = /* @__PURE__ */ new Set([
|
|
378
|
+
"SCHEMA",
|
|
379
|
+
"COLUMN",
|
|
380
|
+
"CONSTRAINT",
|
|
381
|
+
"TABLE",
|
|
382
|
+
"INDEX",
|
|
383
|
+
"MATERIALIZED_VIEW",
|
|
384
|
+
"VIEW",
|
|
385
|
+
"FUNCTION",
|
|
386
|
+
"PROCEDURE",
|
|
387
|
+
"AGGREGATE",
|
|
388
|
+
"TRIGGER",
|
|
389
|
+
"POLICY",
|
|
390
|
+
"RULE",
|
|
391
|
+
"TYPE",
|
|
392
|
+
"SEQUENCE",
|
|
393
|
+
"DOMAIN",
|
|
394
|
+
"STATISTICS",
|
|
395
|
+
"CAST",
|
|
396
|
+
"LANGUAGE",
|
|
397
|
+
"ACCESS_METHOD",
|
|
398
|
+
"COLLATION",
|
|
399
|
+
"CONVERSION",
|
|
400
|
+
"OPERATOR",
|
|
401
|
+
"OPERATOR_CLASS",
|
|
402
|
+
"OPERATOR_FAMILY",
|
|
403
|
+
"TEXT_SEARCH",
|
|
404
|
+
"TEXT_SEARCH_CONFIGURATION",
|
|
405
|
+
"TEXT_SEARCH_DICTIONARY",
|
|
406
|
+
"TEXT_SEARCH_PARSER",
|
|
407
|
+
"TEXT_SEARCH_TEMPLATE"
|
|
408
|
+
]);
|
|
409
|
+
var FALLBACK_IDEMPOTENT_OBJECTS = /* @__PURE__ */ new Set([
|
|
410
|
+
"EXTENSION",
|
|
411
|
+
"ROLE",
|
|
412
|
+
"USER",
|
|
413
|
+
"PUBLICATION",
|
|
414
|
+
"SUBSCRIPTION",
|
|
415
|
+
"DEFAULT_PRIVILEGES",
|
|
416
|
+
"FOREIGN_TABLE",
|
|
417
|
+
"FOREIGN_DATA_WRAPPER",
|
|
418
|
+
"USER_MAPPING",
|
|
419
|
+
"SERVER",
|
|
420
|
+
"DATABASE",
|
|
421
|
+
"TABLESPACE",
|
|
422
|
+
"EVENT_TRIGGER"
|
|
423
|
+
]);
|
|
424
|
+
var FALLBACK_DECLARATIVE_RISK_ALLOWLIST = [
|
|
425
|
+
{
|
|
426
|
+
id: "declarative:public-auth-wrapper",
|
|
427
|
+
filePattern: /supabase\/schemas\/declarative\/00_public\.sql$/,
|
|
428
|
+
descriptionPattern: /Direct auth\.(?:jwt|uid)\(\) usage violates/,
|
|
429
|
+
level: "high",
|
|
430
|
+
owner: "platform-platform",
|
|
431
|
+
ticket: "DB-PLATFORM-001",
|
|
432
|
+
reason: "Wrapper function intentionally keeps backward-compatibility path.",
|
|
433
|
+
active: true
|
|
434
|
+
}
|
|
435
|
+
];
|
|
436
|
+
var FALLBACK_DIRECTORY_PLACEMENT_ALLOWLIST = [
|
|
437
|
+
{
|
|
438
|
+
id: "placement:rbac-idempotent-bootstrap",
|
|
439
|
+
filePattern: /supabase\/schemas\/idempotent\/15_rbac_roles\.sql$/,
|
|
440
|
+
messagePattern: /Grant\/REVOKE statements are usually idempotent\/bootstrap ACL setup and should be treated as such \(idempotent exception needed if kept\)/,
|
|
441
|
+
lineStart: 110,
|
|
442
|
+
lineEnd: 169,
|
|
443
|
+
level: "medium",
|
|
444
|
+
owner: "platform-sre",
|
|
445
|
+
ticket: "DB-SEC-002",
|
|
446
|
+
reason: "RBAC bootstrap is intentional idempotent initialization logic.",
|
|
447
|
+
active: true
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
id: "placement:security-hardening-privileges",
|
|
451
|
+
filePattern: /supabase\/schemas\/idempotent\/20_security_hardening\.sql$/,
|
|
452
|
+
messagePattern: /Grant\/REVOKE statements are usually idempotent\/bootstrap ACL setup and should be treated as such \(idempotent exception needed if kept\)/,
|
|
453
|
+
lineStart: 35,
|
|
454
|
+
lineEnd: 135,
|
|
455
|
+
level: "medium",
|
|
456
|
+
owner: "platform-sre",
|
|
457
|
+
ticket: "DB-SEC-003",
|
|
458
|
+
reason: "Security hardening privilege adjustments are intentionally managed in idempotent SQL.",
|
|
459
|
+
active: true
|
|
460
|
+
},
|
|
461
|
+
{
|
|
462
|
+
id: "placement:extensions-management",
|
|
463
|
+
filePattern: /supabase\/schemas\/idempotent\/00_extensions\.sql$/,
|
|
464
|
+
messagePattern: /ALTER EXTENSION should usually be explicit and rare; review migration intent/,
|
|
465
|
+
lineStart: 39,
|
|
466
|
+
lineEnd: 111,
|
|
467
|
+
level: "high",
|
|
468
|
+
owner: "platform-db",
|
|
469
|
+
ticket: "DB-OPS-004",
|
|
470
|
+
reason: "Extensions lifecycle is intentionally centralized in idempotent SQL.",
|
|
471
|
+
active: true
|
|
472
|
+
}
|
|
473
|
+
];
|
|
474
|
+
var FALLBACK_DECLARATIVE_CLOSURE_WARNING_ALLOWLIST = [];
|
|
475
|
+
function isObject(value) {
|
|
476
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
477
|
+
}
|
|
478
|
+
function isStringArray(value) {
|
|
479
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
480
|
+
}
|
|
481
|
+
function isPastDate(value, today) {
|
|
482
|
+
return value < today;
|
|
483
|
+
}
|
|
484
|
+
function isAllowlistRuleUsable(rule) {
|
|
485
|
+
if (!rule.active) return false;
|
|
486
|
+
if (rule.expiresAt && isPastDate(rule.expiresAt, (/* @__PURE__ */ new Date()).toISOString().slice(0, 10))) {
|
|
487
|
+
return false;
|
|
488
|
+
}
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
491
|
+
function formatBoundaryPolicyIssue(filePath, issue) {
|
|
492
|
+
const level = issue.severity === "error" ? "ERROR" : "WARN";
|
|
493
|
+
return `[boundary-policy] [${level}] ${filePath}: ${issue.field} -> ${issue.message}`;
|
|
494
|
+
}
|
|
495
|
+
function formatAllowlistMetadata(rule) {
|
|
496
|
+
const entries = [];
|
|
497
|
+
if (rule.owner) entries.push(`owner=${rule.owner}`);
|
|
498
|
+
if (rule.ticket) entries.push(`ticket=${rule.ticket}`);
|
|
499
|
+
if (rule.expiresAt) entries.push(`expiresAt=${rule.expiresAt}`);
|
|
500
|
+
if (rule.active === false) entries.push("active=false");
|
|
501
|
+
return entries.join(", ");
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// src/commands/db/utils/boundary-policy/validation.ts
|
|
505
|
+
init_esm_shims();
|
|
506
|
+
function reportUnexpectedTopLevelKeys(raw, issues) {
|
|
507
|
+
const allowedTopLevelKeys = /* @__PURE__ */ new Set([
|
|
508
|
+
"version",
|
|
509
|
+
"description",
|
|
510
|
+
"strictByDefault",
|
|
511
|
+
"declarativePreferredObjects",
|
|
512
|
+
"idempotentPreferredObjects",
|
|
513
|
+
"declarativeRiskAllowlist",
|
|
514
|
+
"directoryPlacementAllowlist",
|
|
515
|
+
"declarativeClosureWarningAllowlist"
|
|
516
|
+
]);
|
|
517
|
+
for (const key of Object.keys(raw)) {
|
|
518
|
+
if (allowedTopLevelKeys.has(key)) continue;
|
|
519
|
+
issues.push({
|
|
520
|
+
field: key,
|
|
521
|
+
message: `Unexpected top-level key "${key}".`,
|
|
522
|
+
severity: "error"
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
function validateVersion(raw, issues) {
|
|
527
|
+
if (raw.version === void 0) {
|
|
528
|
+
issues.push({
|
|
529
|
+
field: "version",
|
|
530
|
+
message: "version is required.",
|
|
531
|
+
severity: "error"
|
|
532
|
+
});
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
if (typeof raw.version !== "string" || raw.version.trim().length === 0) {
|
|
536
|
+
issues.push({
|
|
537
|
+
field: "version",
|
|
538
|
+
message: "version must be a non-empty string.",
|
|
539
|
+
severity: "error"
|
|
540
|
+
});
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
if (!POLICY_VERSION_PATTERN.test(raw.version.trim())) {
|
|
544
|
+
issues.push({
|
|
545
|
+
field: "version",
|
|
546
|
+
message: `version must match ${POLICY_VERSION_PATTERN.source}.`,
|
|
547
|
+
severity: "error"
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
function validateStrictByDefault(raw, issues) {
|
|
552
|
+
if (raw.strictByDefault !== void 0 && typeof raw.strictByDefault !== "boolean") {
|
|
553
|
+
issues.push({
|
|
554
|
+
field: "strictByDefault",
|
|
555
|
+
message: "strictByDefault must be boolean.",
|
|
556
|
+
severity: "error"
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
function validateDescription(raw, issues) {
|
|
561
|
+
if (raw.description !== void 0 && (typeof raw.description !== "string" || raw.description.trim().length === 0)) {
|
|
562
|
+
issues.push({
|
|
563
|
+
field: "description",
|
|
564
|
+
message: "description must be a non-empty string.",
|
|
565
|
+
severity: "error"
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
function validateStringArrayField(raw, field, issues) {
|
|
570
|
+
if (raw[field] !== void 0 && !isStringArray(raw[field])) {
|
|
571
|
+
issues.push({
|
|
572
|
+
field,
|
|
573
|
+
message: `${field} must be an array of strings.`,
|
|
574
|
+
severity: "error"
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
function validateArrayField(raw, field, issues) {
|
|
579
|
+
if (raw[field] !== void 0 && !Array.isArray(raw[field])) {
|
|
580
|
+
issues.push({
|
|
581
|
+
field,
|
|
582
|
+
message: `${field} must be an array.`,
|
|
583
|
+
severity: "error"
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
function validateBoundaryPolicy(raw) {
|
|
588
|
+
const issues = [];
|
|
589
|
+
if (!isObject(raw)) {
|
|
590
|
+
issues.push({ field: "root", message: "Policy root must be an object.", severity: "error" });
|
|
591
|
+
return issues;
|
|
592
|
+
}
|
|
593
|
+
reportUnexpectedTopLevelKeys(raw, issues);
|
|
594
|
+
validateVersion(raw, issues);
|
|
595
|
+
validateStrictByDefault(raw, issues);
|
|
596
|
+
validateDescription(raw, issues);
|
|
597
|
+
validateStringArrayField(raw, "declarativePreferredObjects", issues);
|
|
598
|
+
validateStringArrayField(raw, "idempotentPreferredObjects", issues);
|
|
599
|
+
validateArrayField(raw, "declarativeRiskAllowlist", issues);
|
|
600
|
+
validateArrayField(raw, "directoryPlacementAllowlist", issues);
|
|
601
|
+
validateArrayField(raw, "declarativeClosureWarningAllowlist", issues);
|
|
602
|
+
return issues;
|
|
603
|
+
}
|
|
604
|
+
function coercePolicyStringList(value, field, issueFormatter) {
|
|
605
|
+
if (!Array.isArray(value)) return /* @__PURE__ */ new Set();
|
|
606
|
+
const values = /* @__PURE__ */ new Set();
|
|
607
|
+
const seenRaw = /* @__PURE__ */ new Set();
|
|
608
|
+
for (const entry of value) {
|
|
609
|
+
if (typeof entry !== "string") {
|
|
610
|
+
issueFormatter(field, `Non-string value "${String(entry)}" in policy object list.`, "error");
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
const normalized = entry.trim().toUpperCase();
|
|
614
|
+
if (normalized.length === 0) {
|
|
615
|
+
issueFormatter(field, "Object name entry cannot be empty.", "error");
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
if (!OBJECT_NAME_PATTERN.test(normalized)) {
|
|
619
|
+
issueFormatter(
|
|
620
|
+
field,
|
|
621
|
+
`Object name "${normalized}" does not match expected pattern ${OBJECT_NAME_PATTERN.source}.`,
|
|
622
|
+
"warning"
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
if (seenRaw.has(normalized)) {
|
|
626
|
+
issueFormatter(
|
|
627
|
+
field,
|
|
628
|
+
`Duplicate object name "${normalized}" is accepted but de-duplicated.`,
|
|
629
|
+
"warning"
|
|
630
|
+
);
|
|
631
|
+
} else {
|
|
632
|
+
seenRaw.add(normalized);
|
|
633
|
+
values.add(normalized);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
return values;
|
|
637
|
+
}
|
|
638
|
+
function coerceRiskLevel(level) {
|
|
639
|
+
if (level === "high" || level === "medium" || level === "low") return level;
|
|
640
|
+
return void 0;
|
|
641
|
+
}
|
|
642
|
+
function compileRegExp(pattern, issueFormatter) {
|
|
643
|
+
try {
|
|
644
|
+
return new RegExp(pattern);
|
|
645
|
+
} catch {
|
|
646
|
+
issueFormatter("compileRegExp", `Invalid regex pattern "${pattern}".`, "error");
|
|
647
|
+
return SAFE_REGEXP;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
function coerceRuleId(raw, index, field, issueFormatter) {
|
|
651
|
+
if (typeof raw !== "string") {
|
|
652
|
+
issueFormatter(field, `Rule #${index} requires id string.`, "error");
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
const value = raw.trim();
|
|
656
|
+
if (value.length === 0) {
|
|
657
|
+
issueFormatter(field, `Rule #${index} id cannot be empty.`, "error");
|
|
658
|
+
return null;
|
|
659
|
+
}
|
|
660
|
+
return value;
|
|
661
|
+
}
|
|
662
|
+
function coerceRulePattern(raw, index, field, issueFormatter) {
|
|
663
|
+
if (typeof raw !== "string") {
|
|
664
|
+
issueFormatter(field, `Rule #${index} pattern must be string.`, "error");
|
|
665
|
+
return null;
|
|
666
|
+
}
|
|
667
|
+
const value = raw.trim();
|
|
668
|
+
if (value.length === 0) {
|
|
669
|
+
issueFormatter(field, `Rule #${index} pattern cannot be empty.`, "error");
|
|
670
|
+
return null;
|
|
671
|
+
}
|
|
672
|
+
return value;
|
|
673
|
+
}
|
|
674
|
+
function coerceOptionalString(raw, index, field, issueFormatter) {
|
|
675
|
+
if (raw === void 0) return void 0;
|
|
676
|
+
if (typeof raw !== "string") {
|
|
677
|
+
issueFormatter(field, `Rule #${index} ${field} must be string if provided.`, "warning");
|
|
678
|
+
return void 0;
|
|
679
|
+
}
|
|
680
|
+
const value = raw.trim();
|
|
681
|
+
return value.length > 0 ? value : void 0;
|
|
682
|
+
}
|
|
683
|
+
function coerceOptionalPositiveInteger(raw, index, field, issueFormatter) {
|
|
684
|
+
if (raw === void 0) return void 0;
|
|
685
|
+
if (typeof raw !== "number" || !Number.isFinite(raw) || !Number.isInteger(raw)) {
|
|
686
|
+
issueFormatter(field, `Rule #${index} ${field} must be integer if provided.`, "warning");
|
|
687
|
+
return void 0;
|
|
688
|
+
}
|
|
689
|
+
if (raw <= 0) {
|
|
690
|
+
issueFormatter(field, `Rule #${index} ${field} must be a positive integer.`, "warning");
|
|
691
|
+
return void 0;
|
|
692
|
+
}
|
|
693
|
+
return raw;
|
|
694
|
+
}
|
|
695
|
+
function coerceBoolean(raw, index, field, issueFormatter) {
|
|
696
|
+
if (raw === void 0) {
|
|
697
|
+
issueFormatter(field, `Rule #${index} should explicitly set active (true/false).`, "warning");
|
|
698
|
+
return true;
|
|
699
|
+
}
|
|
700
|
+
if (typeof raw !== "boolean") {
|
|
701
|
+
issueFormatter(field, `Rule #${index} active must be boolean.`, "warning");
|
|
702
|
+
return true;
|
|
703
|
+
}
|
|
704
|
+
return raw;
|
|
705
|
+
}
|
|
706
|
+
function coerceExpiresAt(raw, ruleId, index, field, issueFormatter) {
|
|
707
|
+
if (raw === void 0) return void 0;
|
|
708
|
+
if (typeof raw !== "string") {
|
|
709
|
+
issueFormatter(field, `Rule #${index} expiresAt must be YYYY-MM-DD string.`, "warning");
|
|
710
|
+
return void 0;
|
|
711
|
+
}
|
|
712
|
+
const value = raw.trim();
|
|
713
|
+
if (!DATE_ISO_PATTERN.test(value)) {
|
|
714
|
+
issueFormatter(
|
|
715
|
+
field,
|
|
716
|
+
`Rule "${ruleId}" has invalid expiresAt "${value}" (expected YYYY-MM-DD).`,
|
|
717
|
+
"warning"
|
|
718
|
+
);
|
|
719
|
+
return void 0;
|
|
720
|
+
}
|
|
721
|
+
const parsed = (/* @__PURE__ */ new Date(`${value}T00:00:00.000Z`)).getTime();
|
|
722
|
+
if (Number.isNaN(parsed)) {
|
|
723
|
+
issueFormatter(field, `Rule "${ruleId}" has unparseable expiresAt "${value}".`, "warning");
|
|
724
|
+
return void 0;
|
|
725
|
+
}
|
|
726
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
727
|
+
if (isPastDate(value, today)) {
|
|
728
|
+
issueFormatter(
|
|
729
|
+
field,
|
|
730
|
+
`Rule "${ruleId}" expired on ${value} (today: ${today}). Add a valid owner-justified extension.`,
|
|
731
|
+
"error"
|
|
732
|
+
);
|
|
733
|
+
return value;
|
|
734
|
+
}
|
|
735
|
+
return value;
|
|
736
|
+
}
|
|
737
|
+
function mapPolicyVersion(rawVersion) {
|
|
738
|
+
if (typeof rawVersion === "string" && POLICY_VERSION_PATTERN.test(rawVersion.trim())) {
|
|
739
|
+
return rawVersion.trim();
|
|
740
|
+
}
|
|
741
|
+
return "1.0";
|
|
742
|
+
}
|
|
743
|
+
function appendValidationIssue(issues, field, message, severity, policyFile, output) {
|
|
744
|
+
const issue = { field, message, severity };
|
|
745
|
+
issues.push(issue);
|
|
746
|
+
output.push(formatBoundaryPolicyIssue(policyFile, issue));
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// src/commands/db/utils/boundary-policy/rule-compiler.ts
|
|
750
|
+
init_esm_shims();
|
|
751
|
+
function toScopedLineRange(lineStart, lineEnd, id, section, issueFormatter) {
|
|
752
|
+
if (lineStart === void 0 || lineEnd === void 0) {
|
|
753
|
+
issueFormatter(
|
|
754
|
+
section,
|
|
755
|
+
`Rule "${id}" has incomplete line scope; set both lineStart and lineEnd.`,
|
|
756
|
+
"error"
|
|
757
|
+
);
|
|
758
|
+
return void 0;
|
|
759
|
+
}
|
|
760
|
+
if (lineStart > lineEnd) {
|
|
761
|
+
issueFormatter(
|
|
762
|
+
section,
|
|
763
|
+
`Rule "${id}" has inverted line scope: lineStart (${lineStart}) > lineEnd (${lineEnd}).`,
|
|
764
|
+
"error"
|
|
765
|
+
);
|
|
766
|
+
return void 0;
|
|
767
|
+
}
|
|
768
|
+
return { lineStart, lineEnd };
|
|
769
|
+
}
|
|
770
|
+
var INVALID_RULE_LEVEL = /* @__PURE__ */ Symbol("invalid-rule-level");
|
|
771
|
+
function asRawRule(entry, index, section, issueFormatter) {
|
|
772
|
+
if (typeof entry === "object" && entry !== null) {
|
|
773
|
+
return entry;
|
|
774
|
+
}
|
|
775
|
+
issueFormatter(section, `Entry #${index} must be an object.`, "error");
|
|
776
|
+
return null;
|
|
777
|
+
}
|
|
778
|
+
function claimUniqueRuleId(section, id, seenIds, issueFormatter) {
|
|
779
|
+
if (seenIds.has(id)) {
|
|
780
|
+
issueFormatter(section, `Duplicate rule id "${id}" ignored.`, "warning");
|
|
781
|
+
return false;
|
|
782
|
+
}
|
|
783
|
+
seenIds.add(id);
|
|
784
|
+
return true;
|
|
785
|
+
}
|
|
786
|
+
function readReason(section, id, rawReason, issueFormatter) {
|
|
787
|
+
const reason = typeof rawReason === "string" ? rawReason.trim() : "";
|
|
788
|
+
if (reason.length > 0) {
|
|
789
|
+
return reason;
|
|
790
|
+
}
|
|
791
|
+
issueFormatter(section, `Rule "${id}" requires reason for traceability.`, "error");
|
|
792
|
+
return null;
|
|
793
|
+
}
|
|
794
|
+
function warnMissingTraceability(section, id, owner, ticket, issueFormatter) {
|
|
795
|
+
if (!owner) {
|
|
796
|
+
issueFormatter(section, `Rule "${id}" should include owner for traceability.`, "warning");
|
|
797
|
+
}
|
|
798
|
+
if (!ticket) {
|
|
799
|
+
issueFormatter(section, `Rule "${id}" should include ticket for traceability.`, "warning");
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
function isRuleUsable(section, id, active, expiresAt, issueFormatter) {
|
|
803
|
+
if (isAllowlistRuleUsable({ active, expiresAt })) {
|
|
804
|
+
return true;
|
|
805
|
+
}
|
|
806
|
+
if (!active) {
|
|
807
|
+
issueFormatter(
|
|
808
|
+
section,
|
|
809
|
+
`Rule "${id}" is marked active=false and is ignored until re-enabled.`,
|
|
810
|
+
"warning"
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
return false;
|
|
814
|
+
}
|
|
815
|
+
function parseRuleLevel(section, id, rawLevel, issueFormatter) {
|
|
816
|
+
const level = coerceRiskLevel(rawLevel);
|
|
817
|
+
if (rawLevel === void 0 || level !== void 0) {
|
|
818
|
+
return level;
|
|
819
|
+
}
|
|
820
|
+
issueFormatter(section, `Rule "${id}" has unsupported level "${String(rawLevel)}".`, "error");
|
|
821
|
+
return INVALID_RULE_LEVEL;
|
|
822
|
+
}
|
|
823
|
+
function compileRulePatterns(firstPattern, secondPattern, issueFormatter) {
|
|
824
|
+
const firstRegex = compileRegExp(firstPattern, issueFormatter);
|
|
825
|
+
const secondRegex = compileRegExp(secondPattern, issueFormatter);
|
|
826
|
+
if (firstRegex === SAFE_REGEXP || secondRegex === SAFE_REGEXP) {
|
|
827
|
+
return null;
|
|
828
|
+
}
|
|
829
|
+
return [firstRegex, secondRegex];
|
|
830
|
+
}
|
|
831
|
+
function compileOptionalRulePattern(pattern, issueFormatter) {
|
|
832
|
+
if (!pattern) return void 0;
|
|
833
|
+
const compiled = compileRegExp(pattern, issueFormatter);
|
|
834
|
+
if (compiled === SAFE_REGEXP) {
|
|
835
|
+
return void 0;
|
|
836
|
+
}
|
|
837
|
+
return compiled;
|
|
838
|
+
}
|
|
839
|
+
function parseClosureWarningCode(rawCode, id, issueFormatter) {
|
|
840
|
+
if (rawCode === "UNQUALIFIED_CUSTOM_FUNCTION_CALL" || rawCode === "DYNAMIC_SQL_EXECUTE") {
|
|
841
|
+
return rawCode;
|
|
842
|
+
}
|
|
843
|
+
issueFormatter(
|
|
844
|
+
"declarativeClosureWarningAllowlist",
|
|
845
|
+
`Rule "${id}" has unsupported code "${String(rawCode)}".`,
|
|
846
|
+
"error"
|
|
847
|
+
);
|
|
848
|
+
return null;
|
|
849
|
+
}
|
|
850
|
+
function parseClosureWarningLevel(id, rawLevel, issueFormatter) {
|
|
851
|
+
const level = parseRuleLevel("declarativeClosureWarningAllowlist", id, rawLevel, issueFormatter);
|
|
852
|
+
if (level === INVALID_RULE_LEVEL) {
|
|
853
|
+
return INVALID_RULE_LEVEL;
|
|
854
|
+
}
|
|
855
|
+
if (level === void 0 || level === "high" || level === "medium") {
|
|
856
|
+
return level;
|
|
857
|
+
}
|
|
858
|
+
issueFormatter(
|
|
859
|
+
"declarativeClosureWarningAllowlist",
|
|
860
|
+
`Rule "${id}" has unsupported level "${String(rawLevel)}"; use "medium" or "high".`,
|
|
861
|
+
"error"
|
|
862
|
+
);
|
|
863
|
+
return INVALID_RULE_LEVEL;
|
|
864
|
+
}
|
|
865
|
+
function buildDeclarativeRiskRule(entry, index, seenIds, issueFormatter) {
|
|
866
|
+
const section = "declarativeRiskAllowlist";
|
|
867
|
+
const rawRule = asRawRule(entry, index, section, issueFormatter);
|
|
868
|
+
if (!rawRule) return null;
|
|
869
|
+
const id = coerceRuleId(rawRule.id, index, section, issueFormatter);
|
|
870
|
+
const filePattern = coerceRulePattern(rawRule.filePattern, index, section, issueFormatter);
|
|
871
|
+
const descriptionPattern = coerceRulePattern(
|
|
872
|
+
rawRule.descriptionPattern,
|
|
873
|
+
index,
|
|
874
|
+
section,
|
|
875
|
+
issueFormatter
|
|
876
|
+
);
|
|
877
|
+
if (!id || !filePattern || !descriptionPattern) return null;
|
|
878
|
+
if (!claimUniqueRuleId(section, id, seenIds, issueFormatter)) return null;
|
|
879
|
+
const reason = readReason(section, id, rawRule.reason, issueFormatter);
|
|
880
|
+
if (!reason) return null;
|
|
881
|
+
const owner = coerceOptionalString(
|
|
882
|
+
rawRule.owner,
|
|
883
|
+
index,
|
|
884
|
+
`declarativeRiskAllowlist[${index}]`,
|
|
885
|
+
issueFormatter
|
|
886
|
+
);
|
|
887
|
+
const ticket = coerceOptionalString(
|
|
888
|
+
rawRule.ticket,
|
|
889
|
+
index,
|
|
890
|
+
`declarativeRiskAllowlist[${index}]`,
|
|
891
|
+
issueFormatter
|
|
892
|
+
);
|
|
893
|
+
const active = coerceBoolean(
|
|
894
|
+
rawRule.active,
|
|
895
|
+
index,
|
|
896
|
+
`declarativeRiskAllowlist[${index}]`,
|
|
897
|
+
issueFormatter
|
|
898
|
+
);
|
|
899
|
+
const expiresAt = coerceExpiresAt(
|
|
900
|
+
rawRule.expiresAt,
|
|
901
|
+
id,
|
|
902
|
+
index,
|
|
903
|
+
`declarativeRiskAllowlist[${index}]`,
|
|
904
|
+
issueFormatter
|
|
905
|
+
);
|
|
906
|
+
warnMissingTraceability(section, id, owner, ticket, issueFormatter);
|
|
907
|
+
if (!isRuleUsable(section, id, active, expiresAt, issueFormatter)) return null;
|
|
908
|
+
const level = parseRuleLevel(section, id, rawRule.level, issueFormatter);
|
|
909
|
+
if (level === INVALID_RULE_LEVEL) return null;
|
|
910
|
+
const compiledPatterns = compileRulePatterns(filePattern, descriptionPattern, issueFormatter);
|
|
911
|
+
if (!compiledPatterns) return null;
|
|
912
|
+
return {
|
|
913
|
+
id,
|
|
914
|
+
filePattern: compiledPatterns[0],
|
|
915
|
+
descriptionPattern: compiledPatterns[1],
|
|
916
|
+
level,
|
|
917
|
+
reason,
|
|
918
|
+
owner,
|
|
919
|
+
ticket,
|
|
920
|
+
active,
|
|
921
|
+
expiresAt
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
function reportDeprecatedLineUsage(id, line, lineStart, lineEnd, issueFormatter) {
|
|
925
|
+
if (line === void 0) {
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
issueFormatter(
|
|
929
|
+
"directoryPlacementAllowlist",
|
|
930
|
+
`Rule "${id}" uses deprecated line field. Replace with explicit lineStart and lineEnd.`,
|
|
931
|
+
"error"
|
|
932
|
+
);
|
|
933
|
+
if (lineStart !== void 0 || lineEnd !== void 0) {
|
|
934
|
+
issueFormatter(
|
|
935
|
+
"directoryPlacementAllowlist",
|
|
936
|
+
`Rule "${id}" has both deprecated line and lineStart/lineEnd. Use lineStart + lineEnd only.`,
|
|
937
|
+
"error"
|
|
938
|
+
);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
function buildDirectoryPlacementRule(entry, index, seenIds, issueFormatter) {
|
|
942
|
+
const section = "directoryPlacementAllowlist";
|
|
943
|
+
const rawRule = asRawRule(entry, index, section, issueFormatter);
|
|
944
|
+
if (!rawRule) return null;
|
|
945
|
+
const id = coerceRuleId(rawRule.id, index, section, issueFormatter);
|
|
946
|
+
const filePattern = coerceRulePattern(rawRule.filePattern, index, section, issueFormatter);
|
|
947
|
+
const messagePattern = coerceRulePattern(rawRule.messagePattern, index, section, issueFormatter);
|
|
948
|
+
if (!id || !filePattern || !messagePattern) return null;
|
|
949
|
+
if (!claimUniqueRuleId(section, id, seenIds, issueFormatter)) return null;
|
|
950
|
+
const reason = readReason(section, id, rawRule.reason, issueFormatter);
|
|
951
|
+
if (!reason) return null;
|
|
952
|
+
const owner = coerceOptionalString(
|
|
953
|
+
rawRule.owner,
|
|
954
|
+
index,
|
|
955
|
+
`directoryPlacementAllowlist[${index}]`,
|
|
956
|
+
issueFormatter
|
|
957
|
+
);
|
|
958
|
+
const ticket = coerceOptionalString(
|
|
959
|
+
rawRule.ticket,
|
|
960
|
+
index,
|
|
961
|
+
`directoryPlacementAllowlist[${index}]`,
|
|
962
|
+
issueFormatter
|
|
963
|
+
);
|
|
964
|
+
const active = coerceBoolean(
|
|
965
|
+
rawRule.active,
|
|
966
|
+
index,
|
|
967
|
+
`directoryPlacementAllowlist[${index}]`,
|
|
968
|
+
issueFormatter
|
|
969
|
+
);
|
|
970
|
+
const lineStart = coerceOptionalPositiveInteger(
|
|
971
|
+
rawRule.lineStart,
|
|
972
|
+
index,
|
|
973
|
+
`directoryPlacementAllowlist[${index}].lineStart`,
|
|
974
|
+
issueFormatter
|
|
975
|
+
);
|
|
976
|
+
const lineEnd = coerceOptionalPositiveInteger(
|
|
977
|
+
rawRule.lineEnd,
|
|
978
|
+
index,
|
|
979
|
+
`directoryPlacementAllowlist[${index}].lineEnd`,
|
|
980
|
+
issueFormatter
|
|
981
|
+
);
|
|
982
|
+
const line = coerceOptionalPositiveInteger(
|
|
983
|
+
rawRule.line,
|
|
984
|
+
index,
|
|
985
|
+
`directoryPlacementAllowlist[${index}].line`,
|
|
986
|
+
issueFormatter
|
|
987
|
+
);
|
|
988
|
+
reportDeprecatedLineUsage(id, line, lineStart, lineEnd, issueFormatter);
|
|
989
|
+
const objectPatternStr = coerceOptionalString(
|
|
990
|
+
rawRule.objectPattern,
|
|
991
|
+
index,
|
|
992
|
+
`directoryPlacementAllowlist[${index}].objectPattern`,
|
|
993
|
+
issueFormatter
|
|
994
|
+
);
|
|
995
|
+
const compiledObjectPattern = compileOptionalRulePattern(
|
|
996
|
+
objectPatternStr ?? null,
|
|
997
|
+
issueFormatter
|
|
998
|
+
);
|
|
999
|
+
if (objectPatternStr && !compiledObjectPattern) return null;
|
|
1000
|
+
const hasObjectPattern = compiledObjectPattern !== void 0;
|
|
1001
|
+
let scopedLineRange;
|
|
1002
|
+
if (!hasObjectPattern) {
|
|
1003
|
+
scopedLineRange = toScopedLineRange(lineStart, lineEnd, id, section, issueFormatter);
|
|
1004
|
+
if (!scopedLineRange) return null;
|
|
1005
|
+
} else {
|
|
1006
|
+
if (lineStart !== void 0 && lineEnd !== void 0) {
|
|
1007
|
+
scopedLineRange = toScopedLineRange(lineStart, lineEnd, id, section, issueFormatter);
|
|
1008
|
+
if (!scopedLineRange) return null;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
const expiresAt = coerceExpiresAt(
|
|
1012
|
+
rawRule.expiresAt,
|
|
1013
|
+
id,
|
|
1014
|
+
index,
|
|
1015
|
+
`directoryPlacementAllowlist[${index}]`,
|
|
1016
|
+
issueFormatter
|
|
1017
|
+
);
|
|
1018
|
+
warnMissingTraceability(section, id, owner, ticket, issueFormatter);
|
|
1019
|
+
if (!isRuleUsable(section, id, active, expiresAt, issueFormatter)) return null;
|
|
1020
|
+
const level = parseRuleLevel(section, id, rawRule.level, issueFormatter);
|
|
1021
|
+
if (level === INVALID_RULE_LEVEL) return null;
|
|
1022
|
+
const compiledPatterns = compileRulePatterns(filePattern, messagePattern, issueFormatter);
|
|
1023
|
+
if (!compiledPatterns) return null;
|
|
1024
|
+
return {
|
|
1025
|
+
id,
|
|
1026
|
+
filePattern: compiledPatterns[0],
|
|
1027
|
+
messagePattern: compiledPatterns[1],
|
|
1028
|
+
objectPattern: compiledObjectPattern,
|
|
1029
|
+
level,
|
|
1030
|
+
lineStart: scopedLineRange?.lineStart,
|
|
1031
|
+
lineEnd: scopedLineRange?.lineEnd,
|
|
1032
|
+
reason,
|
|
1033
|
+
owner,
|
|
1034
|
+
ticket,
|
|
1035
|
+
active,
|
|
1036
|
+
expiresAt
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
function buildDeclarativeClosureWarningRule(entry, index, seenIds, issueFormatter) {
|
|
1040
|
+
const section = "declarativeClosureWarningAllowlist";
|
|
1041
|
+
const rawRule = asRawRule(entry, index, section, issueFormatter);
|
|
1042
|
+
if (!rawRule) return null;
|
|
1043
|
+
const id = coerceRuleId(rawRule.id, index, section, issueFormatter);
|
|
1044
|
+
const code = id ? parseClosureWarningCode(rawRule.code, id, issueFormatter) : null;
|
|
1045
|
+
const filePattern = coerceRulePattern(rawRule.filePattern, index, section, issueFormatter);
|
|
1046
|
+
const anchorPattern = coerceOptionalString(
|
|
1047
|
+
rawRule.anchorPattern,
|
|
1048
|
+
index,
|
|
1049
|
+
`declarativeClosureWarningAllowlist[${index}].anchorPattern`,
|
|
1050
|
+
issueFormatter
|
|
1051
|
+
);
|
|
1052
|
+
if (!id || !code || !filePattern) return null;
|
|
1053
|
+
if (!claimUniqueRuleId(section, id, seenIds, issueFormatter)) return null;
|
|
1054
|
+
const reason = readReason(section, id, rawRule.reason, issueFormatter);
|
|
1055
|
+
if (!reason) return null;
|
|
1056
|
+
const owner = coerceOptionalString(
|
|
1057
|
+
rawRule.owner,
|
|
1058
|
+
index,
|
|
1059
|
+
`declarativeClosureWarningAllowlist[${index}]`,
|
|
1060
|
+
issueFormatter
|
|
1061
|
+
);
|
|
1062
|
+
const ticket = coerceOptionalString(
|
|
1063
|
+
rawRule.ticket,
|
|
1064
|
+
index,
|
|
1065
|
+
`declarativeClosureWarningAllowlist[${index}]`,
|
|
1066
|
+
issueFormatter
|
|
1067
|
+
);
|
|
1068
|
+
const active = coerceBoolean(
|
|
1069
|
+
rawRule.active,
|
|
1070
|
+
index,
|
|
1071
|
+
`declarativeClosureWarningAllowlist[${index}]`,
|
|
1072
|
+
issueFormatter
|
|
1073
|
+
);
|
|
1074
|
+
const expiresAt = coerceExpiresAt(
|
|
1075
|
+
rawRule.expiresAt,
|
|
1076
|
+
id,
|
|
1077
|
+
index,
|
|
1078
|
+
`declarativeClosureWarningAllowlist[${index}]`,
|
|
1079
|
+
issueFormatter
|
|
1080
|
+
);
|
|
1081
|
+
const lineStart = coerceOptionalPositiveInteger(
|
|
1082
|
+
rawRule.lineStart,
|
|
1083
|
+
index,
|
|
1084
|
+
`declarativeClosureWarningAllowlist[${index}].lineStart`,
|
|
1085
|
+
issueFormatter
|
|
1086
|
+
);
|
|
1087
|
+
const lineEnd = coerceOptionalPositiveInteger(
|
|
1088
|
+
rawRule.lineEnd,
|
|
1089
|
+
index,
|
|
1090
|
+
`declarativeClosureWarningAllowlist[${index}].lineEnd`,
|
|
1091
|
+
issueFormatter
|
|
1092
|
+
);
|
|
1093
|
+
warnMissingTraceability(section, id, owner, ticket, issueFormatter);
|
|
1094
|
+
if (!isRuleUsable(section, id, active, expiresAt, issueFormatter)) return null;
|
|
1095
|
+
const scopedLineRange = toScopedLineRange(lineStart, lineEnd, id, section, issueFormatter);
|
|
1096
|
+
if (!scopedLineRange) return null;
|
|
1097
|
+
const level = parseClosureWarningLevel(id, rawRule.level, issueFormatter);
|
|
1098
|
+
if (level === INVALID_RULE_LEVEL) return null;
|
|
1099
|
+
const compiledPattern = compileRegExp(filePattern, issueFormatter);
|
|
1100
|
+
if (compiledPattern === SAFE_REGEXP) return null;
|
|
1101
|
+
const compiledAnchorPattern = compileOptionalRulePattern(anchorPattern ?? null, issueFormatter);
|
|
1102
|
+
if (anchorPattern && !compiledAnchorPattern) return null;
|
|
1103
|
+
return {
|
|
1104
|
+
id,
|
|
1105
|
+
code,
|
|
1106
|
+
filePattern: compiledPattern,
|
|
1107
|
+
anchorPattern: compiledAnchorPattern,
|
|
1108
|
+
lineStart: scopedLineRange.lineStart,
|
|
1109
|
+
lineEnd: scopedLineRange.lineEnd,
|
|
1110
|
+
level,
|
|
1111
|
+
reason,
|
|
1112
|
+
owner,
|
|
1113
|
+
ticket,
|
|
1114
|
+
active,
|
|
1115
|
+
expiresAt
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
function toDeclarativeRiskRules(rawList, issueFormatter) {
|
|
1119
|
+
if (!Array.isArray(rawList)) return [];
|
|
1120
|
+
const rules = [];
|
|
1121
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
1122
|
+
for (const [index, entry] of rawList.entries()) {
|
|
1123
|
+
const rule = buildDeclarativeRiskRule(entry, index, seenIds, issueFormatter);
|
|
1124
|
+
if (rule) {
|
|
1125
|
+
rules.push(rule);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
return rules;
|
|
1129
|
+
}
|
|
1130
|
+
function toDirectoryPlacementRules(rawList, issueFormatter) {
|
|
1131
|
+
if (!Array.isArray(rawList)) return [];
|
|
1132
|
+
const rules = [];
|
|
1133
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
1134
|
+
for (const [index, entry] of rawList.entries()) {
|
|
1135
|
+
const rule = buildDirectoryPlacementRule(entry, index, seenIds, issueFormatter);
|
|
1136
|
+
if (rule) {
|
|
1137
|
+
rules.push(rule);
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
return rules;
|
|
1141
|
+
}
|
|
1142
|
+
function toDeclarativeClosureWarningRules(rawList, issueFormatter) {
|
|
1143
|
+
if (!Array.isArray(rawList)) return [];
|
|
1144
|
+
const rules = [];
|
|
1145
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
1146
|
+
for (const [index, entry] of rawList.entries()) {
|
|
1147
|
+
const rule = buildDeclarativeClosureWarningRule(entry, index, seenIds, issueFormatter);
|
|
1148
|
+
if (rule) {
|
|
1149
|
+
rules.push(rule);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
return rules;
|
|
1153
|
+
}
|
|
1154
|
+
function normalizePolicySource(raw, source, sourcePath, issueFormatter) {
|
|
1155
|
+
const sourcePolicy = typeof raw === "object" && raw !== null ? raw : {};
|
|
1156
|
+
const declarativePreferredObjects = coercePolicyStringList(
|
|
1157
|
+
sourcePolicy.declarativePreferredObjects,
|
|
1158
|
+
"declarativePreferredObjects",
|
|
1159
|
+
issueFormatter
|
|
1160
|
+
);
|
|
1161
|
+
const idempotentPreferredObjects = coercePolicyStringList(
|
|
1162
|
+
sourcePolicy.idempotentPreferredObjects,
|
|
1163
|
+
"idempotentPreferredObjects",
|
|
1164
|
+
issueFormatter
|
|
1165
|
+
);
|
|
1166
|
+
const safeDeclarative = declarativePreferredObjects.size > 0 ? declarativePreferredObjects : new Set(FALLBACK_DECLARATIVE_OBJECTS);
|
|
1167
|
+
const safeIdempotent = idempotentPreferredObjects.size > 0 ? idempotentPreferredObjects : new Set(FALLBACK_IDEMPOTENT_OBJECTS);
|
|
1168
|
+
const overlapObjects = [...safeDeclarative].filter((entry) => safeIdempotent.has(entry));
|
|
1169
|
+
if (overlapObjects.length > 0) {
|
|
1170
|
+
issueFormatter(
|
|
1171
|
+
"overlap",
|
|
1172
|
+
`Object(s) declared in both declarative and idempotent preferred sets: ${overlapObjects.join(", ")}`,
|
|
1173
|
+
"error"
|
|
1174
|
+
);
|
|
1175
|
+
}
|
|
1176
|
+
const declarativeRiskAllowlist = toDeclarativeRiskRules(
|
|
1177
|
+
sourcePolicy.declarativeRiskAllowlist,
|
|
1178
|
+
issueFormatter
|
|
1179
|
+
);
|
|
1180
|
+
const directoryPlacementAllowlist = toDirectoryPlacementRules(
|
|
1181
|
+
sourcePolicy.directoryPlacementAllowlist,
|
|
1182
|
+
issueFormatter
|
|
1183
|
+
);
|
|
1184
|
+
const declarativeClosureWarningAllowlist = toDeclarativeClosureWarningRules(
|
|
1185
|
+
sourcePolicy.declarativeClosureWarningAllowlist,
|
|
1186
|
+
issueFormatter
|
|
1187
|
+
);
|
|
1188
|
+
const ruleIds = /* @__PURE__ */ new Set();
|
|
1189
|
+
for (const rule of [
|
|
1190
|
+
...declarativeRiskAllowlist,
|
|
1191
|
+
...directoryPlacementAllowlist,
|
|
1192
|
+
...declarativeClosureWarningAllowlist
|
|
1193
|
+
]) {
|
|
1194
|
+
if (ruleIds.has(rule.id)) {
|
|
1195
|
+
issueFormatter(
|
|
1196
|
+
"allowlist",
|
|
1197
|
+
`Duplicate allowlist id "${rule.id}" appears in multiple allowlist sections.`,
|
|
1198
|
+
"error"
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
1201
|
+
ruleIds.add(rule.id);
|
|
1202
|
+
}
|
|
1203
|
+
return {
|
|
1204
|
+
version: mapPolicyVersion(sourcePolicy.version),
|
|
1205
|
+
strictByDefault: typeof sourcePolicy.strictByDefault === "boolean" ? sourcePolicy.strictByDefault : false,
|
|
1206
|
+
description: typeof sourcePolicy.description === "string" ? sourcePolicy.description : "Boundary policy for declarative/idempotent SQL placement.",
|
|
1207
|
+
declarativePreferredObjects: safeDeclarative,
|
|
1208
|
+
idempotentPreferredObjects: safeIdempotent,
|
|
1209
|
+
declarativeRiskAllowlist,
|
|
1210
|
+
directoryPlacementAllowlist,
|
|
1211
|
+
declarativeClosureWarningAllowlist: declarativeClosureWarningAllowlist.length > 0 ? declarativeClosureWarningAllowlist : [...FALLBACK_DECLARATIVE_CLOSURE_WARNING_ALLOWLIST],
|
|
1212
|
+
__meta: {
|
|
1213
|
+
source,
|
|
1214
|
+
sourcePath,
|
|
1215
|
+
fallbackUsed: source === "default",
|
|
1216
|
+
invalidPolicy: false,
|
|
1217
|
+
issues: [],
|
|
1218
|
+
warningCount: 0
|
|
1219
|
+
}
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// src/commands/db/utils/boundary-policy.ts
|
|
1224
|
+
function loadPolicyFromFile(policyFile) {
|
|
1225
|
+
const issues = [];
|
|
1226
|
+
const report = [];
|
|
1227
|
+
const addIssue = (field, message, severity = "warning") => {
|
|
1228
|
+
appendValidationIssue(issues, field, message, severity, policyFile, report);
|
|
1229
|
+
};
|
|
1230
|
+
try {
|
|
1231
|
+
const raw = readFileSync(policyFile, "utf-8").trim();
|
|
1232
|
+
if (raw.length === 0) {
|
|
1233
|
+
addIssue("root", "empty file", "error");
|
|
1234
|
+
return {
|
|
1235
|
+
...normalizePolicySource({}, "policy-file", policyFile, addIssue),
|
|
1236
|
+
__meta: {
|
|
1237
|
+
source: "policy-file",
|
|
1238
|
+
sourcePath: policyFile,
|
|
1239
|
+
fallbackUsed: true,
|
|
1240
|
+
invalidPolicy: true,
|
|
1241
|
+
issues: [...report],
|
|
1242
|
+
warningCount: issues.filter((entry) => entry.severity === "warning").length
|
|
1243
|
+
}
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
const parsed = JSON.parse(raw);
|
|
1247
|
+
const validationIssues = validateBoundaryPolicy(parsed);
|
|
1248
|
+
for (const issue of validationIssues) {
|
|
1249
|
+
appendValidationIssue(issues, issue.field, issue.message, issue.severity, policyFile, report);
|
|
1250
|
+
}
|
|
1251
|
+
const policy = normalizePolicySource(parsed, "policy-file", policyFile, addIssue);
|
|
1252
|
+
const hasError = issues.some((entry) => entry.severity === "error");
|
|
1253
|
+
return {
|
|
1254
|
+
...policy,
|
|
1255
|
+
__meta: {
|
|
1256
|
+
source: "policy-file",
|
|
1257
|
+
sourcePath: policyFile,
|
|
1258
|
+
fallbackUsed: hasError,
|
|
1259
|
+
invalidPolicy: hasError,
|
|
1260
|
+
issues: [...report],
|
|
1261
|
+
warningCount: issues.filter((entry) => entry.severity === "warning").length
|
|
1262
|
+
}
|
|
1263
|
+
};
|
|
1264
|
+
} catch {
|
|
1265
|
+
appendValidationIssue(issues, "json", "invalid JSON", "error", policyFile, report);
|
|
1266
|
+
return {
|
|
1267
|
+
version: DEFAULT_POLICY_VERSION,
|
|
1268
|
+
strictByDefault: false,
|
|
1269
|
+
description: "Fallback boundary policy.",
|
|
1270
|
+
declarativePreferredObjects: FALLBACK_DECLARATIVE_OBJECTS,
|
|
1271
|
+
idempotentPreferredObjects: FALLBACK_IDEMPOTENT_OBJECTS,
|
|
1272
|
+
declarativeRiskAllowlist: FALLBACK_DECLARATIVE_RISK_ALLOWLIST,
|
|
1273
|
+
directoryPlacementAllowlist: FALLBACK_DIRECTORY_PLACEMENT_ALLOWLIST,
|
|
1274
|
+
declarativeClosureWarningAllowlist: FALLBACK_DECLARATIVE_CLOSURE_WARNING_ALLOWLIST,
|
|
1275
|
+
__meta: {
|
|
1276
|
+
source: "policy-file",
|
|
1277
|
+
sourcePath: policyFile,
|
|
1278
|
+
fallbackUsed: true,
|
|
1279
|
+
invalidPolicy: true,
|
|
1280
|
+
issues: [...report],
|
|
1281
|
+
warningCount: issues.filter((entry) => entry.severity === "warning").length
|
|
1282
|
+
}
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
function loadBoundaryPolicy(projectRoot, policyPath) {
|
|
1287
|
+
const policyFile = policyPath ?? path2.join(projectRoot, "supabase", "schemas", BOUNDARY_POLICY_FILENAME);
|
|
1288
|
+
if (!existsSync(policyFile)) {
|
|
1289
|
+
return {
|
|
1290
|
+
version: DEFAULT_POLICY_VERSION,
|
|
1291
|
+
strictByDefault: false,
|
|
1292
|
+
description: "Fallback boundary policy.",
|
|
1293
|
+
declarativePreferredObjects: FALLBACK_DECLARATIVE_OBJECTS,
|
|
1294
|
+
idempotentPreferredObjects: FALLBACK_IDEMPOTENT_OBJECTS,
|
|
1295
|
+
declarativeRiskAllowlist: FALLBACK_DECLARATIVE_RISK_ALLOWLIST,
|
|
1296
|
+
directoryPlacementAllowlist: FALLBACK_DIRECTORY_PLACEMENT_ALLOWLIST,
|
|
1297
|
+
declarativeClosureWarningAllowlist: FALLBACK_DECLARATIVE_CLOSURE_WARNING_ALLOWLIST,
|
|
1298
|
+
__meta: {
|
|
1299
|
+
source: "default",
|
|
1300
|
+
sourcePath: policyFile,
|
|
1301
|
+
fallbackUsed: true,
|
|
1302
|
+
invalidPolicy: false,
|
|
1303
|
+
issues: [`[boundary-policy] ${policyFile} not found; using fallback values`],
|
|
1304
|
+
warningCount: 0
|
|
1305
|
+
}
|
|
1306
|
+
};
|
|
1307
|
+
}
|
|
1308
|
+
return loadPolicyFromFile(policyFile);
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// src/commands/db/utils/boundary-policy-runtime.ts
|
|
1312
|
+
init_esm_shims();
|
|
1313
|
+
var boundaryPolicyCache = /* @__PURE__ */ new Map();
|
|
1314
|
+
function getBoundaryPolicy(cwd = process.cwd()) {
|
|
1315
|
+
const resolved = path2.resolve(cwd);
|
|
1316
|
+
let cached = boundaryPolicyCache.get(resolved);
|
|
1317
|
+
if (!cached) {
|
|
1318
|
+
cached = loadBoundaryPolicy(cwd);
|
|
1319
|
+
boundaryPolicyCache.set(resolved, cached);
|
|
1320
|
+
}
|
|
1321
|
+
return cached;
|
|
1322
|
+
}
|
|
1323
|
+
function reportBoundaryPolicyState(logger2, policy) {
|
|
1324
|
+
if (policy.__meta.issues.length === 0) return;
|
|
1325
|
+
const issuePrefix = policy.__meta.fallbackUsed ? "Boundary policy issue (fallback in effect):" : "Boundary policy issue:";
|
|
1326
|
+
logger2.warn(`
|
|
1327
|
+
${issuePrefix}`);
|
|
1328
|
+
for (const issue of policy.__meta.issues) {
|
|
1329
|
+
logger2.info(` \u2022 ${issue}`);
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
function assertBoundaryPolicyUsable(logger2, policy) {
|
|
1333
|
+
reportBoundaryPolicyState(logger2, policy);
|
|
1334
|
+
if (!policy.__meta.invalidPolicy) return;
|
|
1335
|
+
const failureMessage = policy.__meta.source === "default" ? "Boundary policy missing or unreadable" : `Boundary policy has schema issues: ${policy.__meta.sourcePath}`;
|
|
1336
|
+
throw new CLIError(
|
|
1337
|
+
failureMessage,
|
|
1338
|
+
"DB_BOUNDARY_POLICY_INVALID",
|
|
1339
|
+
[
|
|
1340
|
+
...policy.__meta.issues,
|
|
1341
|
+
"Fix boundary policy JSON before running production apply precheck",
|
|
1342
|
+
"Hint: check regex syntax, required fields, and allowlist entry shape in .boundary-policy.json"
|
|
1343
|
+
],
|
|
1344
|
+
void 0
|
|
1345
|
+
);
|
|
1346
|
+
}
|
|
1347
|
+
function assertBoundaryPolicyQualityGate(logger2, policy, strict) {
|
|
1348
|
+
if (policy.__meta.warningCount === 0) return;
|
|
1349
|
+
logger2.warn(`Boundary policy has ${policy.__meta.warningCount} warning(s).`);
|
|
1350
|
+
for (const issue of policy.__meta.issues) {
|
|
1351
|
+
logger2.info(` \u2022 ${issue}`);
|
|
1352
|
+
}
|
|
1353
|
+
if (!strict) return;
|
|
1354
|
+
throw new CLIError("Boundary policy quality gate failed", "DB_BOUNDARY_POLICY_QUALITY_GATE", [
|
|
1355
|
+
...policy.__meta.issues,
|
|
1356
|
+
"Run `node scripts/validate-boundary-policy-allowlist.mjs` and clear warnings before strict precheck."
|
|
1357
|
+
]);
|
|
1358
|
+
}
|
|
1359
|
+
function findDeclarativeRiskAllowlistMatch(risk, policy) {
|
|
1360
|
+
return policy.declarativeRiskAllowlist.find((entry) => {
|
|
1361
|
+
if (entry.level !== void 0 && entry.level !== risk.level) return false;
|
|
1362
|
+
return entry.filePattern.test(risk.file) && entry.descriptionPattern.test(risk.description);
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
function hasExplicitLineScope(rule) {
|
|
1366
|
+
return rule.lineStart !== void 0 && rule.lineEnd !== void 0;
|
|
1367
|
+
}
|
|
1368
|
+
function entryLineScopeMatches(issueLine, rule) {
|
|
1369
|
+
if (!hasExplicitLineScope(rule)) return false;
|
|
1370
|
+
if (issueLine === void 0) return false;
|
|
1371
|
+
const lineStart = rule.lineStart;
|
|
1372
|
+
const lineEnd = rule.lineEnd;
|
|
1373
|
+
if (lineStart === void 0 || lineEnd === void 0) return false;
|
|
1374
|
+
return issueLine >= lineStart && issueLine <= lineEnd;
|
|
1375
|
+
}
|
|
1376
|
+
function findDirectoryPlacementAllowlistMatch(issue, policy) {
|
|
1377
|
+
return policy.directoryPlacementAllowlist.find((entry) => {
|
|
1378
|
+
if (entry.level !== void 0 && entry.level !== issue.level) return false;
|
|
1379
|
+
if (!entry.filePattern.test(issue.file)) return false;
|
|
1380
|
+
if (!entry.messagePattern.test(issue.message)) return false;
|
|
1381
|
+
if (entry.objectPattern) {
|
|
1382
|
+
if (entry.objectPattern.test(issue.message)) return true;
|
|
1383
|
+
if (!hasExplicitLineScope(entry)) return false;
|
|
1384
|
+
}
|
|
1385
|
+
if (!hasExplicitLineScope(entry)) return false;
|
|
1386
|
+
return entryLineScopeMatches(issue.line, entry);
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1389
|
+
function findDeclarativeClosureWarningAllowlistMatch(warning, policy, options = {}) {
|
|
1390
|
+
const warningLineText = readWarningLineText(warning.file, warning.line, options.cwd);
|
|
1391
|
+
return policy.declarativeClosureWarningAllowlist.find((entry) => {
|
|
1392
|
+
if (entry.code !== warning.code) return false;
|
|
1393
|
+
if (entry.level !== void 0 && entry.level !== warning.level) return false;
|
|
1394
|
+
if (!entry.filePattern.test(warning.file)) return false;
|
|
1395
|
+
if (entry.anchorPattern && warningLineText && entry.anchorPattern.test(warningLineText)) {
|
|
1396
|
+
return true;
|
|
1397
|
+
}
|
|
1398
|
+
return entryLineScopeMatches(warning.line, entry);
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1401
|
+
function readWarningLineText(relativeFilePath, line, cwd) {
|
|
1402
|
+
if (!cwd) return void 0;
|
|
1403
|
+
try {
|
|
1404
|
+
const absolutePath = path2.join(cwd, relativeFilePath);
|
|
1405
|
+
const lines = readFileSync(absolutePath, "utf-8").split(/\r?\n/);
|
|
1406
|
+
return lines[line - 1];
|
|
1407
|
+
} catch {
|
|
1408
|
+
return void 0;
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
function resolveProductionApplyStrictMode(policy, strictOption) {
|
|
1412
|
+
if (strictOption === true) return true;
|
|
1413
|
+
const envStrict = process.env.RUNA_SCHEMA_PRECHECK_STRICT;
|
|
1414
|
+
if (envStrict === "1") return true;
|
|
1415
|
+
if (envStrict === "0") return false;
|
|
1416
|
+
return policy.strictByDefault;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// src/commands/db/utils/declarative-dependency-warning-governance.ts
|
|
1420
|
+
var SHOW_ALLOWLIST_REPORT = process.env.RUNA_DB_PRECHECK_ALLOWLIST_REPORT === "1";
|
|
1421
|
+
function formatAllowlistedWarningEntry(entry) {
|
|
1422
|
+
const formatted = formatDeclarativeDependencyWarning(entry.warning);
|
|
1423
|
+
const metadata = formatAllowlistMetadata(entry.rule);
|
|
1424
|
+
const metaSuffix = metadata ? ` (${metadata})` : "";
|
|
1425
|
+
return `[allowlist:closure-warning] ${entry.rule.id}: ${formatted.summary} (${entry.rule.reason})${metaSuffix}`;
|
|
1426
|
+
}
|
|
1427
|
+
function reviewDeclarativeDependencyWarnings(params) {
|
|
1428
|
+
const policy = getBoundaryPolicy(params.cwd);
|
|
1429
|
+
assertBoundaryPolicyUsable(params.logger, policy);
|
|
1430
|
+
const strict = resolveProductionApplyStrictMode(policy, params.strictOption);
|
|
1431
|
+
assertBoundaryPolicyQualityGate(params.logger, policy, strict);
|
|
1432
|
+
const allowlistedWarnings = [];
|
|
1433
|
+
const unreviewedWarnings = [];
|
|
1434
|
+
for (const warning of params.warnings) {
|
|
1435
|
+
const matched = findDeclarativeClosureWarningAllowlistMatch(warning, policy, {
|
|
1436
|
+
cwd: params.cwd
|
|
1437
|
+
});
|
|
1438
|
+
if (matched) {
|
|
1439
|
+
allowlistedWarnings.push({ warning, rule: matched });
|
|
1440
|
+
continue;
|
|
1441
|
+
}
|
|
1442
|
+
unreviewedWarnings.push(warning);
|
|
1443
|
+
}
|
|
1444
|
+
return {
|
|
1445
|
+
strict,
|
|
1446
|
+
allowlistReportEnabled: SHOW_ALLOWLIST_REPORT,
|
|
1447
|
+
allowlistedWarnings,
|
|
1448
|
+
unreviewedWarnings
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
function logDeclarativeDependencyWarnings(logger2, review) {
|
|
1452
|
+
if (review.allowlistReportEnabled) {
|
|
1453
|
+
for (const allowlisted of review.allowlistedWarnings) {
|
|
1454
|
+
logger2.info(` ${formatAllowlistedWarningEntry(allowlisted)}`);
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
if (review.unreviewedWarnings.length === 0) {
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
const lead = review.strict ? `Found ${review.unreviewedWarnings.length} unreviewed declarative dependency boundary warning(s) in strict mode:` : `Found ${review.unreviewedWarnings.length} declarative dependency boundary warning(s):`;
|
|
1461
|
+
logger2.warn(lead);
|
|
1462
|
+
for (const summary of summarizeDeclarativeDependencyWarnings(review.unreviewedWarnings)) {
|
|
1463
|
+
logger2.info(` [SUMMARY] ${summary.label}: ${summary.count}`);
|
|
1464
|
+
}
|
|
1465
|
+
for (const warning of review.unreviewedWarnings.slice(0, MAX_DETAILED_DECLARATIVE_WARNINGS)) {
|
|
1466
|
+
const formatted = formatDeclarativeDependencyWarning(warning);
|
|
1467
|
+
logger2.info(` [WARN] ${formatted.summary}`);
|
|
1468
|
+
logger2.info(` ${formatted.suggestion}`);
|
|
1469
|
+
}
|
|
1470
|
+
if (review.unreviewedWarnings.length > MAX_DETAILED_DECLARATIVE_WARNINGS) {
|
|
1471
|
+
logger2.info(
|
|
1472
|
+
` ... ${review.unreviewedWarnings.length - MAX_DETAILED_DECLARATIVE_WARNINGS} more warning(s) omitted`
|
|
1473
|
+
);
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
function buildDeclarativeDependencyWarningFailureLines(review) {
|
|
1477
|
+
if (!review.strict || review.unreviewedWarnings.length === 0) {
|
|
1478
|
+
return [];
|
|
1479
|
+
}
|
|
1480
|
+
const lines = [
|
|
1481
|
+
`Declarative dependency boundary strict mode failed (${review.unreviewedWarnings.length} unreviewed warning(s)).`
|
|
1482
|
+
];
|
|
1483
|
+
for (const summary of summarizeDeclarativeDependencyWarnings(review.unreviewedWarnings)) {
|
|
1484
|
+
lines.push(`- [SUMMARY] ${summary.label}: ${summary.count}`);
|
|
1485
|
+
}
|
|
1486
|
+
for (const warning of review.unreviewedWarnings.slice(0, MAX_DETAILED_DECLARATIVE_WARNINGS)) {
|
|
1487
|
+
const formatted = formatDeclarativeDependencyWarning(warning);
|
|
1488
|
+
lines.push(`- ${formatted.summary}`);
|
|
1489
|
+
lines.push(`- ${formatted.suggestion}`);
|
|
1490
|
+
}
|
|
1491
|
+
if (review.unreviewedWarnings.length > MAX_DETAILED_DECLARATIVE_WARNINGS) {
|
|
1492
|
+
lines.push(
|
|
1493
|
+
`- ... ${review.unreviewedWarnings.length - MAX_DETAILED_DECLARATIVE_WARNINGS} more warning(s) omitted`
|
|
1494
|
+
);
|
|
1495
|
+
}
|
|
1496
|
+
lines.push(
|
|
1497
|
+
"- Remediation: schema-qualify helper calls, rewrite/remove EXECUTE, or add a reviewed declarativeClosureWarningAllowlist entry with owner/ticket/reason/expiresAt."
|
|
1498
|
+
);
|
|
1499
|
+
return lines;
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
// src/commands/db/apply/helpers/function-plan-false-positive-filter.ts
|
|
1503
|
+
init_esm_shims();
|
|
1504
|
+
var CREATE_OR_REPLACE_FUNCTION_PATTERN = /^\s*CREATE\s+OR\s+REPLACE\s+FUNCTION\b/i;
|
|
1505
|
+
var PLPGSQL_LANGUAGE_PATTERN = /\bLANGUAGE\s+plpgsql\b/i;
|
|
1506
|
+
async function normalizePlpgsqlFunctionDefinition(sql, dbProconfig, session = createPlanAnalysisSession()) {
|
|
1507
|
+
const analysis = await analyzePlanStatement({ index: 0, sql, hazards: [] }, session);
|
|
1508
|
+
return normalizeFunctionDefinitionAst(analysis, dbProconfig);
|
|
1509
|
+
}
|
|
1510
|
+
function escapeSqlLiteral(value) {
|
|
1511
|
+
return value.replace(/'/g, "''");
|
|
1512
|
+
}
|
|
1513
|
+
function toGlobRegex(pattern) {
|
|
1514
|
+
const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1515
|
+
return new RegExp(`^${escaped.replace(/\\\*/g, ".*")}$`);
|
|
1516
|
+
}
|
|
1517
|
+
function matchesSchemaCheckExclusion(objectKey, patterns) {
|
|
1518
|
+
return patterns.some((pattern) => {
|
|
1519
|
+
const normalizedPattern = pattern.toLowerCase();
|
|
1520
|
+
if (normalizedPattern.includes("*")) {
|
|
1521
|
+
return toGlobRegex(normalizedPattern).test(objectKey);
|
|
1522
|
+
}
|
|
1523
|
+
return objectKey === normalizedPattern;
|
|
1524
|
+
});
|
|
1525
|
+
}
|
|
1526
|
+
function serializePlanStatements(statements) {
|
|
1527
|
+
if (statements.length === 0) {
|
|
1528
|
+
return "";
|
|
1529
|
+
}
|
|
1530
|
+
return statements.map((statement) => {
|
|
1531
|
+
const lines = [`-- Statement Idx. ${statement.index}`];
|
|
1532
|
+
for (const hazard of statement.hazards) {
|
|
1533
|
+
lines.push(`-- Hazard ${hazard.type}: ${hazard.message}`);
|
|
1534
|
+
}
|
|
1535
|
+
lines.push(statement.sql.trimEnd());
|
|
1536
|
+
return lines.join("\n");
|
|
1537
|
+
}).join("\n");
|
|
1538
|
+
}
|
|
1539
|
+
function buildFunctionMetadataQuery(references) {
|
|
1540
|
+
if (references.length === 0) {
|
|
1541
|
+
return null;
|
|
1542
|
+
}
|
|
1543
|
+
const filters = references.map(
|
|
1544
|
+
(reference) => `(n.nspname = '${escapeSqlLiteral(reference.schema)}' AND p.proname = '${escapeSqlLiteral(reference.name)}')`
|
|
1545
|
+
).join(" OR ");
|
|
1546
|
+
return `
|
|
1547
|
+
SELECT COALESCE(
|
|
1548
|
+
json_agg(
|
|
1549
|
+
json_build_object(
|
|
1550
|
+
'schema', n.nspname,
|
|
1551
|
+
'name', p.proname,
|
|
1552
|
+
'definition', pg_get_functiondef(p.oid),
|
|
1553
|
+
'proconfig', COALESCE(p.proconfig, ARRAY[]::text[])
|
|
1554
|
+
)
|
|
1555
|
+
)::text,
|
|
1556
|
+
'[]'
|
|
1557
|
+
)
|
|
1558
|
+
FROM pg_proc p
|
|
1559
|
+
JOIN pg_namespace n ON p.pronamespace = n.oid
|
|
1560
|
+
WHERE p.prokind = 'f'
|
|
1561
|
+
AND (${filters});
|
|
1562
|
+
`;
|
|
1563
|
+
}
|
|
1564
|
+
function loadExistingFunctionMetadata(dbUrl, references) {
|
|
1565
|
+
const query = buildFunctionMetadataQuery(references);
|
|
1566
|
+
if (!query) {
|
|
1567
|
+
return /* @__PURE__ */ new Map();
|
|
1568
|
+
}
|
|
1569
|
+
const result = psqlSyncQuery({ databaseUrl: dbUrl, sql: query, timeout: 1e4 });
|
|
1570
|
+
if (result.status !== 0) {
|
|
1571
|
+
throw new Error(result.stderr || "Failed to query pg_proc metadata");
|
|
1572
|
+
}
|
|
1573
|
+
const parsed = JSON.parse(result.stdout.trim());
|
|
1574
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
1575
|
+
for (const row of parsed) {
|
|
1576
|
+
const key = `${row.schema.toLowerCase()}.${row.name.toLowerCase()}`;
|
|
1577
|
+
const existing = grouped.get(key) ?? [];
|
|
1578
|
+
existing.push(row);
|
|
1579
|
+
grouped.set(key, existing);
|
|
1580
|
+
}
|
|
1581
|
+
return grouped;
|
|
1582
|
+
}
|
|
1583
|
+
async function isSemanticallyEquivalentPlpgsqlFunction(sql, candidates, session) {
|
|
1584
|
+
const planned = await normalizePlpgsqlFunctionDefinition(sql, void 0, session);
|
|
1585
|
+
if (!planned) return false;
|
|
1586
|
+
const matchingCandidates = [];
|
|
1587
|
+
for (const candidate of candidates) {
|
|
1588
|
+
const current = await normalizePlpgsqlFunctionDefinition(
|
|
1589
|
+
candidate.definition,
|
|
1590
|
+
candidate.proconfig,
|
|
1591
|
+
session
|
|
1592
|
+
);
|
|
1593
|
+
if (current !== null && current.normalizedDefinition === planned.normalizedDefinition && current.normalizedConfig === planned.normalizedConfig) {
|
|
1594
|
+
matchingCandidates.push(candidate);
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
return matchingCandidates.length === 1;
|
|
1598
|
+
}
|
|
1599
|
+
function shouldInspectStatement(statement) {
|
|
1600
|
+
const strippedSql = stripLeadingSetStatements(statement.sql);
|
|
1601
|
+
return CREATE_OR_REPLACE_FUNCTION_PATTERN.test(strippedSql) && PLPGSQL_LANGUAGE_PATTERN.test(strippedSql);
|
|
1602
|
+
}
|
|
1603
|
+
async function suppressFunctionSchemaCheckFalsePositives(params) {
|
|
1604
|
+
const plan = parsePlanOutput(params.planOutput);
|
|
1605
|
+
const session = createPlanAnalysisSession();
|
|
1606
|
+
if (plan.statements.length === 0) {
|
|
1607
|
+
return { planOutput: params.planOutput, suppressedStatements: [], warnings: [] };
|
|
1608
|
+
}
|
|
1609
|
+
const excludePatterns = params.excludeFromSchemaCheck ?? [];
|
|
1610
|
+
const suppressedStatements = [];
|
|
1611
|
+
const warnings = [];
|
|
1612
|
+
const pendingStatements = [];
|
|
1613
|
+
const retainedStatements = [];
|
|
1614
|
+
for (const statement of plan.statements) {
|
|
1615
|
+
if (!shouldInspectStatement(statement)) {
|
|
1616
|
+
retainedStatements.push(statement);
|
|
1617
|
+
continue;
|
|
1618
|
+
}
|
|
1619
|
+
const analysis = await analyzePlanStatement(statement, session);
|
|
1620
|
+
if (!analysis.targetFunctionKey) {
|
|
1621
|
+
retainedStatements.push(statement);
|
|
1622
|
+
continue;
|
|
1623
|
+
}
|
|
1624
|
+
const [schema, name] = analysis.targetFunctionKey.split(".");
|
|
1625
|
+
const reference = { schema, name, key: analysis.targetFunctionKey };
|
|
1626
|
+
if (matchesSchemaCheckExclusion(reference.key, excludePatterns)) {
|
|
1627
|
+
suppressedStatements.push(statement);
|
|
1628
|
+
warnings.push(
|
|
1629
|
+
`Suppressed pg-schema-diff schema check for ${reference.key} via database.pgSchemaDiff.excludeFromSchemaCheck.`
|
|
1630
|
+
);
|
|
1631
|
+
continue;
|
|
1632
|
+
}
|
|
1633
|
+
pendingStatements.push({ statement, reference });
|
|
1634
|
+
}
|
|
1635
|
+
if (pendingStatements.length === 0) {
|
|
1636
|
+
return {
|
|
1637
|
+
planOutput: serializePlanStatements(retainedStatements),
|
|
1638
|
+
suppressedStatements,
|
|
1639
|
+
warnings
|
|
1640
|
+
};
|
|
1641
|
+
}
|
|
1642
|
+
const metadataByFunction = loadExistingFunctionMetadata(
|
|
1643
|
+
params.dbUrl,
|
|
1644
|
+
pendingStatements.map(({ reference }) => reference)
|
|
1645
|
+
);
|
|
1646
|
+
for (const { statement, reference } of pendingStatements) {
|
|
1647
|
+
const candidates = metadataByFunction.get(reference.key) ?? [];
|
|
1648
|
+
if (await isSemanticallyEquivalentPlpgsqlFunction(statement.sql, candidates, session)) {
|
|
1649
|
+
suppressedStatements.push(statement);
|
|
1650
|
+
warnings.push(
|
|
1651
|
+
`Suppressed likely pg-schema-diff false positive for ${reference.key}: current pg_proc.proconfig matches the declarative function config semantically, so repeated CREATE OR REPLACE was removed from schema check output.`
|
|
1652
|
+
);
|
|
1653
|
+
continue;
|
|
1654
|
+
}
|
|
1655
|
+
retainedStatements.push(statement);
|
|
1656
|
+
}
|
|
1657
|
+
return {
|
|
1658
|
+
planOutput: serializePlanStatements(retainedStatements),
|
|
1659
|
+
suppressedStatements,
|
|
1660
|
+
warnings
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
// src/commands/db/apply/helpers/temp-db-dsn.ts
|
|
1665
|
+
init_esm_shims();
|
|
1666
|
+
function resolvePgSchemaDiffTempDbDsn(params) {
|
|
1667
|
+
if (params.shadowDbDsn) {
|
|
1668
|
+
return params.shadowDbDsn;
|
|
1669
|
+
}
|
|
1670
|
+
const envTempDbDsn = params.envTempDbDsn?.trim();
|
|
1671
|
+
if (envTempDbDsn) {
|
|
1672
|
+
return envTempDbDsn;
|
|
1673
|
+
}
|
|
1674
|
+
const localTempDbDsn = params.localTempDbDsn?.trim();
|
|
1675
|
+
return localTempDbDsn ? localTempDbDsn : void 0;
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
// src/commands/db/apply/actors/pg-schema-diff-actors.ts
|
|
1679
|
+
var PROGRESS_HEARTBEAT_MS = 3e4;
|
|
1680
|
+
function formatElapsed(ms) {
|
|
1681
|
+
if (ms < 1e3) return `${ms}ms`;
|
|
1682
|
+
if (ms < 6e4) return `${(ms / 1e3).toFixed(1)}s`;
|
|
1683
|
+
const minutes = Math.floor(ms / 6e4);
|
|
1684
|
+
const seconds = Math.round(ms % 6e4 / 1e3);
|
|
1685
|
+
return `${minutes}m ${seconds}s`;
|
|
1686
|
+
}
|
|
1687
|
+
async function withProgress(label, operation) {
|
|
1688
|
+
const startedAt = Date.now();
|
|
1689
|
+
logger.info(`[db apply] ${label}...`);
|
|
1690
|
+
const heartbeat = setInterval(() => {
|
|
1691
|
+
logger.info(`[db apply] ${label} still running (${formatElapsed(Date.now() - startedAt)})`);
|
|
1692
|
+
}, PROGRESS_HEARTBEAT_MS);
|
|
1693
|
+
heartbeat.unref?.();
|
|
1694
|
+
try {
|
|
1695
|
+
const result = await operation();
|
|
1696
|
+
logger.info(`[db apply] ${label} completed (${formatElapsed(Date.now() - startedAt)})`);
|
|
1697
|
+
return result;
|
|
1698
|
+
} catch (error) {
|
|
1699
|
+
logger.warn(`[db apply] ${label} failed after ${formatElapsed(Date.now() - startedAt)}`);
|
|
1700
|
+
throw error;
|
|
1701
|
+
} finally {
|
|
1702
|
+
clearInterval(heartbeat);
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
async function applyWithRetry(params) {
|
|
1706
|
+
const {
|
|
1707
|
+
dbUrl,
|
|
1708
|
+
targetDir,
|
|
1709
|
+
schemasDir,
|
|
1710
|
+
includeSchemas,
|
|
1711
|
+
input,
|
|
1712
|
+
planOutput,
|
|
1713
|
+
hazards,
|
|
1714
|
+
protectedTables,
|
|
1715
|
+
protectedObjects,
|
|
1716
|
+
tempDbDsn,
|
|
1717
|
+
pgSchemaDiffDir
|
|
1718
|
+
} = params;
|
|
1719
|
+
logger.step("Applying schema changes (plan+psql)...");
|
|
1720
|
+
const allowedHazardTypes = buildAllowedHazards(input);
|
|
1721
|
+
const result = await executePlanSqlWithRetry(dbUrl, planOutput, input.verbose, {
|
|
1722
|
+
maxDelayMs: input.maxLockWaitMs,
|
|
1723
|
+
allowedHazardTypes,
|
|
1724
|
+
protectedTables,
|
|
1725
|
+
protectedObjects,
|
|
1726
|
+
failOnLowParseConfidence: input.env === "production",
|
|
1727
|
+
retryRequiresReplanMessage: params.disableRePlanOnRetry ? "artifact_retry_requires_replan: Checked planner artifact hit retryable lock contention during apply. Re-run `runa db plan production` to generate a fresh checked plan before retrying." : void 0,
|
|
1728
|
+
rePlanFn: params.disableRePlanOnRetry ? void 0 : () => {
|
|
1729
|
+
const { planOutput: freshPlan } = executePgSchemaDiffPlan(
|
|
1730
|
+
dbUrl,
|
|
1731
|
+
pgSchemaDiffDir ?? schemasDir,
|
|
1732
|
+
includeSchemas,
|
|
1733
|
+
input.verbose,
|
|
1734
|
+
{ tempDbDsn, targetDir }
|
|
1735
|
+
);
|
|
1736
|
+
if (!freshPlan.trim() || freshPlan.includes("No changes")) {
|
|
1737
|
+
return null;
|
|
1738
|
+
}
|
|
1739
|
+
return freshPlan;
|
|
1740
|
+
}
|
|
1741
|
+
});
|
|
1742
|
+
if (!result.success) {
|
|
1743
|
+
throw result.error || new Error("Migration failed");
|
|
1744
|
+
}
|
|
1745
|
+
if (input.verbose && result.attempts > 0) {
|
|
1746
|
+
logger.debug(`Retry metrics: ${result.attempts} attempts, ${result.totalWaitMs}ms total wait`);
|
|
1747
|
+
}
|
|
1748
|
+
logger.success("Schema changes applied");
|
|
1749
|
+
return {
|
|
1750
|
+
sql: planOutput,
|
|
1751
|
+
hazards,
|
|
1752
|
+
applied: true,
|
|
1753
|
+
retryAttempts: result.attempts,
|
|
1754
|
+
retryWaitMs: result.totalWaitMs
|
|
1755
|
+
};
|
|
1756
|
+
}
|
|
1757
|
+
function autoDetectRequiredExtensions(targetDir) {
|
|
1758
|
+
const EXTENSION_TYPE_KEYWORDS = {
|
|
1759
|
+
geometry: "postgis",
|
|
1760
|
+
geography: "postgis",
|
|
1761
|
+
box2d: "postgis",
|
|
1762
|
+
box3d: "postgis",
|
|
1763
|
+
raster: "postgis_raster",
|
|
1764
|
+
vector: "vector",
|
|
1765
|
+
halfvec: "vector",
|
|
1766
|
+
sparsevec: "vector",
|
|
1767
|
+
citext: "citext",
|
|
1768
|
+
hstore: "hstore",
|
|
1769
|
+
ltree: "ltree"
|
|
1770
|
+
};
|
|
1771
|
+
const declarativeDir = join(targetDir, "supabase", "schemas", "declarative");
|
|
1772
|
+
if (!existsSync(declarativeDir)) return [];
|
|
1773
|
+
const detected = /* @__PURE__ */ new Set();
|
|
1774
|
+
try {
|
|
1775
|
+
const files = readdirSync(declarativeDir).filter((f) => f.endsWith(".sql"));
|
|
1776
|
+
for (const file of files) {
|
|
1777
|
+
const content = readFileSync(join(declarativeDir, file), "utf-8").toLowerCase();
|
|
1778
|
+
for (const [typeName, extName] of Object.entries(EXTENSION_TYPE_KEYWORDS)) {
|
|
1779
|
+
if (content.includes(typeName)) {
|
|
1780
|
+
detected.add(extName);
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
} catch {
|
|
1785
|
+
}
|
|
1786
|
+
return [...detected].sort();
|
|
1787
|
+
}
|
|
1788
|
+
function resolveEffectiveShadowExtensions(configured, targetDir, verbose) {
|
|
1789
|
+
if (configured && configured.length > 0) return configured;
|
|
1790
|
+
const autoDetected = autoDetectRequiredExtensions(targetDir);
|
|
1791
|
+
if (autoDetected.length > 0 && verbose) {
|
|
1792
|
+
logger.debug(`Auto-detected shadow DB extensions from SQL: ${autoDetected.join(", ")}`);
|
|
1793
|
+
}
|
|
1794
|
+
return autoDetected.length > 0 ? autoDetected : void 0;
|
|
1795
|
+
}
|
|
1796
|
+
function loadPgSchemaDiffConfigState(targetDir, verbose) {
|
|
1797
|
+
try {
|
|
1798
|
+
const config = loadRunaConfig(targetDir);
|
|
1799
|
+
return {
|
|
1800
|
+
shadowExtensions: resolveEffectiveShadowExtensions(
|
|
1801
|
+
config.database?.pgSchemaDiff?.shadowDbExtensions,
|
|
1802
|
+
targetDir,
|
|
1803
|
+
verbose
|
|
1804
|
+
),
|
|
1805
|
+
configExclusions: config.database?.pgSchemaDiff?.excludeFromOrphanDetection ? [...config.database.pgSchemaDiff.excludeFromOrphanDetection] : void 0,
|
|
1806
|
+
schemaCheckExclusions: config.database?.pgSchemaDiff?.excludeFromSchemaCheck ? [...config.database.pgSchemaDiff.excludeFromSchemaCheck] : void 0
|
|
1807
|
+
};
|
|
1808
|
+
} catch {
|
|
1809
|
+
if (verbose) {
|
|
1810
|
+
logger.debug("Could not load runa.config.ts - continuing without extension support");
|
|
1811
|
+
}
|
|
1812
|
+
return {
|
|
1813
|
+
shadowExtensions: resolveEffectiveShadowExtensions(void 0, targetDir, verbose)
|
|
1814
|
+
};
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
function createPrefilterState(schemasDir, verbose, configExclusions) {
|
|
1818
|
+
try {
|
|
1819
|
+
const prefilter = prefilterPartitionStubs(schemasDir);
|
|
1820
|
+
if (!prefilter) {
|
|
1821
|
+
return { prefilter: null, pgSchemaDiffDir: schemasDir, configExclusions };
|
|
1822
|
+
}
|
|
1823
|
+
if (verbose) {
|
|
1824
|
+
logger.debug(`Prefiltered ${prefilter.detectedStubs.length} partition stub(s)`);
|
|
1825
|
+
}
|
|
1826
|
+
return {
|
|
1827
|
+
prefilter,
|
|
1828
|
+
pgSchemaDiffDir: prefilter.filteredDir,
|
|
1829
|
+
configExclusions: [...configExclusions ?? [], ...prefilter.autoProtectedTables]
|
|
1830
|
+
};
|
|
1831
|
+
} catch {
|
|
1832
|
+
return { prefilter: null, pgSchemaDiffDir: schemasDir, configExclusions };
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
function collectSchemaFiles(schemasDir) {
|
|
1836
|
+
return readdirSync(schemasDir).filter((file) => file.endsWith(".sql")).sort().map((file) => join(schemasDir, file));
|
|
1837
|
+
}
|
|
1838
|
+
function assertDeclarativeDependencyBoundary(targetDir, input) {
|
|
1839
|
+
logger.step("Checking declarative dependency boundary");
|
|
1840
|
+
const analysis = analyzeDeclarativeDependencyContract(targetDir);
|
|
1841
|
+
if (input.check) {
|
|
1842
|
+
logger.info(DB_APPLY_CHECK_MODE_CONTRACT_NOTE);
|
|
1843
|
+
}
|
|
1844
|
+
if (analysis.violations.length === 0) {
|
|
1845
|
+
logger.success("Declarative dependency boundary is consistent");
|
|
1846
|
+
} else {
|
|
1847
|
+
const lines = [
|
|
1848
|
+
`Declarative dependency boundary check failed (${analysis.violations.length} violation(s)).`,
|
|
1849
|
+
analysis.contractNote
|
|
1850
|
+
];
|
|
1851
|
+
for (const violation of analysis.violations) {
|
|
1852
|
+
const formatted = formatDeclarativeDependencyViolation(violation);
|
|
1853
|
+
lines.push(`- ${formatted.summary}`);
|
|
1854
|
+
lines.push(`- ${formatted.callee}`);
|
|
1855
|
+
if (formatted.definedIn) {
|
|
1856
|
+
lines.push(`- ${formatted.definedIn}`);
|
|
1857
|
+
}
|
|
1858
|
+
lines.push(`- ${formatted.suggestion}`);
|
|
1859
|
+
}
|
|
1860
|
+
throw new Error(lines.join("\n"));
|
|
1861
|
+
}
|
|
1862
|
+
const warningReview = reviewDeclarativeDependencyWarnings({
|
|
1863
|
+
cwd: targetDir,
|
|
1864
|
+
logger,
|
|
1865
|
+
warnings: analysis.warnings,
|
|
1866
|
+
strictOption: input.strict
|
|
1867
|
+
});
|
|
1868
|
+
logDeclarativeDependencyWarnings(logger, warningReview);
|
|
1869
|
+
if (warningReview.strict && warningReview.unreviewedWarnings.length > 0) {
|
|
1870
|
+
throw new Error(buildDeclarativeDependencyWarningFailureLines(warningReview).join("\n"));
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
function warnDuplicateFunctionOwnership(targetDir) {
|
|
1874
|
+
const analysis = analyzeDuplicateFunctionOwnership(targetDir);
|
|
1875
|
+
if (analysis.findings.length === 0) {
|
|
1876
|
+
return;
|
|
1877
|
+
}
|
|
1878
|
+
logger.warn(`Duplicate function ownership detected (${analysis.findings.length} finding(s))`);
|
|
1879
|
+
logger.info(` ${analysis.contractNote}`);
|
|
1880
|
+
for (const finding of analysis.findings) {
|
|
1881
|
+
const formatted = formatDuplicateFunctionOwnershipFinding(finding);
|
|
1882
|
+
logger.info(` [WARN] ${formatted.summary}`);
|
|
1883
|
+
for (const location of formatted.declarativeLocations) {
|
|
1884
|
+
logger.info(` declarative: ${location}`);
|
|
1885
|
+
}
|
|
1886
|
+
for (const location of formatted.idempotentLocations) {
|
|
1887
|
+
logger.info(` idempotent: ${location}`);
|
|
1888
|
+
}
|
|
1889
|
+
logger.info(` ${formatted.suggestion}`);
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
function createCombinedSchemaBundle(schemaFiles, verbose) {
|
|
1893
|
+
const tmpDir = mkdtempSync(join(tmpdir(), "runa_pg_schema_diff_"));
|
|
1894
|
+
const combinedSchemaPath = join(tmpDir, "desired_schema.sql");
|
|
1895
|
+
const schemaContents = schemaFiles.map((filePath) => {
|
|
1896
|
+
const content = readFileSync(filePath, "utf-8");
|
|
1897
|
+
return `-- Source: ${filePath}
|
|
1898
|
+
${content}`;
|
|
1899
|
+
});
|
|
1900
|
+
writeFileSync(combinedSchemaPath, schemaContents.join("\n\n"), "utf-8");
|
|
1901
|
+
if (verbose) {
|
|
1902
|
+
logger.debug(`Combined ${schemaFiles.length} schema files`);
|
|
1903
|
+
}
|
|
1904
|
+
return tmpDir;
|
|
1905
|
+
}
|
|
1906
|
+
async function createShadowDbForRun(dbUrl, shadowExtensions, verbose) {
|
|
1907
|
+
if (!needsShadowDb(shadowExtensions)) {
|
|
1908
|
+
return null;
|
|
1909
|
+
}
|
|
1910
|
+
cleanupOrphanShadowDatabases(dbUrl);
|
|
1911
|
+
logger.step(`Creating shadow DB with extensions: ${shadowExtensions.join(", ")}`);
|
|
1912
|
+
const shadowDb = await createShadowDbWithExtensions({
|
|
1913
|
+
extensions: shadowExtensions,
|
|
1914
|
+
sourceDbUrl: dbUrl
|
|
1915
|
+
});
|
|
1916
|
+
if (verbose) {
|
|
1917
|
+
logger.debug(`Shadow DB created: ${shadowDb.dbName}`);
|
|
1918
|
+
}
|
|
1919
|
+
return shadowDb;
|
|
1920
|
+
}
|
|
1921
|
+
function buildNoChangesResult(planOutput) {
|
|
1922
|
+
if (isNoChangePlanOutput(planOutput)) {
|
|
1923
|
+
logger.success("No schema changes detected");
|
|
1924
|
+
return { sql: "", hazards: [], applied: true };
|
|
1925
|
+
}
|
|
1926
|
+
return null;
|
|
1927
|
+
}
|
|
1928
|
+
function enforceDropSafety(input, droppedTables) {
|
|
1929
|
+
if (droppedTables.length === 0) {
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1932
|
+
logger.warn("");
|
|
1933
|
+
logger.warn(`Plan will DROP ${droppedTables.length} table(s):`);
|
|
1934
|
+
for (const table of droppedTables) {
|
|
1935
|
+
logger.warn(` - ${table}`);
|
|
1936
|
+
}
|
|
1937
|
+
logger.warn("Verify these tables were intentionally removed from declarative/*.sql");
|
|
1938
|
+
if (input.env === "production" && !input.allowDataLoss) {
|
|
1939
|
+
throw new Error(
|
|
1940
|
+
`Production deployment blocked: ${droppedTables.length} table(s) would be dropped. Use --allow-data-loss to proceed.`
|
|
1941
|
+
);
|
|
1942
|
+
}
|
|
1943
|
+
logger.warn("");
|
|
1944
|
+
}
|
|
1945
|
+
function runPreApplyDataCompatibility(dbUrl, planOutput, input) {
|
|
1946
|
+
if (input.skipDataCheck || input.compareOnly) {
|
|
1947
|
+
return 0;
|
|
1948
|
+
}
|
|
1949
|
+
const plan = parsePlanOutput(planOutput);
|
|
1950
|
+
const compatResult = checkDataCompatibility(dbUrl, plan, {
|
|
1951
|
+
verbose: input.verbose,
|
|
1952
|
+
timeout: 1e4
|
|
1953
|
+
});
|
|
1954
|
+
const dataViolationCount = compatResult.violations.length;
|
|
1955
|
+
if (dataViolationCount > 0) {
|
|
1956
|
+
displayDataCompatibilityResults(compatResult, input.verbose);
|
|
1957
|
+
if (input.check) return dataViolationCount;
|
|
1958
|
+
if (input.env === "production" && !input.allowDataLoss) {
|
|
1959
|
+
throw new Error(
|
|
1960
|
+
`Pre-apply data validation: ${dataViolationCount} violation(s).
|
|
1961
|
+
Fix data issues or use --allow-data-loss / --skip-data-check.`
|
|
1962
|
+
);
|
|
1963
|
+
}
|
|
1964
|
+
if (!input.autoApprove) {
|
|
1965
|
+
throw new Error(
|
|
1966
|
+
`Pre-apply data validation: ${dataViolationCount} violation(s).
|
|
1967
|
+
Fix data issues or use --auto-approve / --skip-data-check.`
|
|
1968
|
+
);
|
|
1969
|
+
}
|
|
1970
|
+
return dataViolationCount;
|
|
1971
|
+
}
|
|
1972
|
+
if (compatResult.checkedStatements > 0) {
|
|
1973
|
+
displayDataCompatibilityResults(compatResult, input.verbose);
|
|
1974
|
+
}
|
|
1975
|
+
return 0;
|
|
1976
|
+
}
|
|
1977
|
+
function buildCheckModeResult(input, planOutput, hazards, protectedTables, protectedObjects, dataViolationCount, schemasDir, options) {
|
|
1978
|
+
if (!input.check) {
|
|
1979
|
+
return null;
|
|
1980
|
+
}
|
|
1981
|
+
const plan = parsePlanOutput(planOutput);
|
|
1982
|
+
const { filteredPlan, removedDropStatements, removedAuthzStatements, removedRlsStatements } = filterCheckModePlanStatements(plan, protectedTables, protectedObjects, schemasDir);
|
|
1983
|
+
const planSummary = buildCheckModePlanSummary({
|
|
1984
|
+
rawStatementCount: options?.rawStatementCount ?? plan.statements.length,
|
|
1985
|
+
filteredPlan,
|
|
1986
|
+
removedDropStatements,
|
|
1987
|
+
removedAuthzStatements,
|
|
1988
|
+
removedRlsStatements,
|
|
1989
|
+
suppressedFunctionStatements: options?.suppressedFunctionStatements
|
|
1990
|
+
});
|
|
1991
|
+
displayCheckModeResults(planOutput, {
|
|
1992
|
+
verbose: input.verbose,
|
|
1993
|
+
filterInfo: {
|
|
1994
|
+
filteredPlanSql: filteredPlan.rawSql,
|
|
1995
|
+
removedDropStatements,
|
|
1996
|
+
removedAuthzStatements,
|
|
1997
|
+
removedRlsStatements,
|
|
1998
|
+
suppressedFunctionStatements: options?.suppressedFunctionStatements,
|
|
1999
|
+
planSummary
|
|
2000
|
+
}
|
|
2001
|
+
});
|
|
2002
|
+
const planSizeAssessment = assessPlanSize(planSummary);
|
|
2003
|
+
if (planSizeAssessment.severity !== "ok") {
|
|
2004
|
+
logger.warn(`Large plan warning: ${formatPlanSizeSummary(planSummary)}`);
|
|
2005
|
+
for (const reason of planSizeAssessment.reasons) {
|
|
2006
|
+
logger.info(` \u2022 ${reason}`);
|
|
2007
|
+
}
|
|
2008
|
+
logger.info(" Small production instances may see elevated load during apply.");
|
|
2009
|
+
}
|
|
2010
|
+
return {
|
|
2011
|
+
sql: planOutput,
|
|
2012
|
+
filteredPlanSql: filteredPlan.rawSql,
|
|
2013
|
+
planSummary,
|
|
2014
|
+
hazards,
|
|
2015
|
+
applied: false,
|
|
2016
|
+
dataViolations: dataViolationCount > 0 ? dataViolationCount : void 0
|
|
2017
|
+
};
|
|
2018
|
+
}
|
|
2019
|
+
function backupProtectedTablesForProduction(dbUrl, protectedTables, input) {
|
|
2020
|
+
if (input.env !== "production") {
|
|
2021
|
+
return;
|
|
2022
|
+
}
|
|
2023
|
+
const { backupPath } = backupIdempotentTables(dbUrl, protectedTables, input.verbose);
|
|
2024
|
+
if (backupPath) {
|
|
2025
|
+
logger.info(`Recovery: pg_restore -d <DATABASE_URL> ${backupPath}`);
|
|
2026
|
+
return;
|
|
2027
|
+
}
|
|
2028
|
+
if (protectedTables.length > 0 && !input.allowDataLoss) {
|
|
2029
|
+
throw new Error(
|
|
2030
|
+
"Pre-apply backup failed for production deployment.\n Protected tables exist but could not be backed up.\n Use --allow-data-loss to proceed without backup (emergency only)."
|
|
2031
|
+
);
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
async function cleanupApplyResources(params) {
|
|
2035
|
+
if (params.shadowDb) {
|
|
2036
|
+
try {
|
|
2037
|
+
await params.shadowDb.cleanup();
|
|
2038
|
+
if (params.verbose) {
|
|
2039
|
+
logger.debug("Shadow DB cleaned up");
|
|
2040
|
+
}
|
|
2041
|
+
} catch (cleanupError) {
|
|
2042
|
+
logger.warn(`Failed to cleanup shadow DB: ${cleanupError}`);
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
if (params.prefilter) {
|
|
2046
|
+
try {
|
|
2047
|
+
rmSync(params.prefilter.filteredDir, { recursive: true, force: true });
|
|
2048
|
+
} catch {
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
if (params.tmpDir) {
|
|
2052
|
+
try {
|
|
2053
|
+
rmSync(params.tmpDir, { recursive: true, force: true });
|
|
2054
|
+
} catch {
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
var applyPgSchemaDiff = fromPromise(async ({ input: { input, targetDir, preIdempotentTargetFingerprint } }) => {
|
|
2059
|
+
const schemasDir = join(targetDir, "supabase/schemas/declarative");
|
|
2060
|
+
if (!existsSync(schemasDir)) {
|
|
2061
|
+
logger.info("No declarative schemas found");
|
|
2062
|
+
return { sql: "", hazards: [], applied: false };
|
|
2063
|
+
}
|
|
2064
|
+
warnDuplicateFunctionOwnership(targetDir);
|
|
2065
|
+
assertDeclarativeDependencyBoundary(targetDir, input);
|
|
2066
|
+
const dbUrl = getDbUrl(input);
|
|
2067
|
+
const configState = loadPgSchemaDiffConfigState(targetDir, input.verbose);
|
|
2068
|
+
const prefilterState = createPrefilterState(
|
|
2069
|
+
schemasDir,
|
|
2070
|
+
input.verbose,
|
|
2071
|
+
configState.configExclusions
|
|
2072
|
+
);
|
|
2073
|
+
if (!input.compareOnly) {
|
|
2074
|
+
const freshDbResult = handleFreshDbCase(
|
|
2075
|
+
input,
|
|
2076
|
+
dbUrl,
|
|
2077
|
+
targetDir,
|
|
2078
|
+
prefilterState.pgSchemaDiffDir
|
|
2079
|
+
);
|
|
2080
|
+
if (freshDbResult) return freshDbResult;
|
|
2081
|
+
}
|
|
2082
|
+
const schemaFiles = collectSchemaFiles(schemasDir);
|
|
2083
|
+
if (schemaFiles.length === 0) {
|
|
2084
|
+
logger.info("No schema files to apply");
|
|
2085
|
+
return { sql: "", hazards: [], applied: false };
|
|
2086
|
+
}
|
|
2087
|
+
let shadowDb = null;
|
|
2088
|
+
let tmpDir = null;
|
|
2089
|
+
try {
|
|
2090
|
+
verifyPgSchemaDiffBinary({ strictVersion: input.env === "production" });
|
|
2091
|
+
await withProgress(
|
|
2092
|
+
"checking target database connection",
|
|
2093
|
+
() => verifyDatabaseConnection(dbUrl, { maxRetries: input.compareOnly ? 2 : void 0 })
|
|
2094
|
+
);
|
|
2095
|
+
const includeSchemas = detectAppSchemas(schemasDir, input.verbose);
|
|
2096
|
+
const artifactDecision = !input.check && input.plannerArtifactPath ? tryReuseDbPlanArtifact({
|
|
2097
|
+
repoRoot: targetDir,
|
|
2098
|
+
artifactPath: input.plannerArtifactPath,
|
|
2099
|
+
input,
|
|
2100
|
+
precomputedTargetFingerprint: preIdempotentTargetFingerprint ?? void 0
|
|
2101
|
+
}) : {
|
|
2102
|
+
artifact: null,
|
|
2103
|
+
planner: {
|
|
2104
|
+
source: "inline",
|
|
2105
|
+
path: input.plannerArtifactPath,
|
|
2106
|
+
reuseAttempted: Boolean(input.plannerArtifactPath)
|
|
2107
|
+
}
|
|
2108
|
+
};
|
|
2109
|
+
const plannerMetadata = artifactDecision.planner;
|
|
2110
|
+
if (!artifactDecision.artifact && input.plannerArtifactRequired) {
|
|
2111
|
+
const reason = plannerMetadata.reuseMessage ?? "unknown";
|
|
2112
|
+
const isTargetMismatch = reason.includes("target fingerprint mismatch");
|
|
2113
|
+
if (isTargetMismatch) {
|
|
2114
|
+
logger.warn(
|
|
2115
|
+
`Checked plan artifact stale (${reason}). Production DB state changed between plan and apply. Falling back to fresh plan.`
|
|
2116
|
+
);
|
|
2117
|
+
input.plannerArtifactPath = void 0;
|
|
2118
|
+
} else {
|
|
2119
|
+
throw new Error(`Checked plan artifact could not be reused: ${reason}`);
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
if (!input.check) {
|
|
2123
|
+
cleanPartitionAclsForPgSchemaDiff(dbUrl, includeSchemas, input.verbose);
|
|
2124
|
+
}
|
|
2125
|
+
let tempDbDsn;
|
|
2126
|
+
let rawPlanStatementCount = 0;
|
|
2127
|
+
let effectivePlanOutput = artifactDecision.artifact?.planSql ?? "";
|
|
2128
|
+
const filteredPlanSql = artifactDecision.artifact?.filteredPlanSql;
|
|
2129
|
+
const planSummary = artifactDecision.artifact?.planSummary;
|
|
2130
|
+
let hazards = [...artifactDecision.artifact?.hazards ?? []];
|
|
2131
|
+
let suppressedPlan = {
|
|
2132
|
+
planOutput: effectivePlanOutput,
|
|
2133
|
+
suppressedStatements: [],
|
|
2134
|
+
warnings: []
|
|
2135
|
+
};
|
|
2136
|
+
if (artifactDecision.artifact) {
|
|
2137
|
+
logger.step(`Reusing checked planner artifact: ${artifactDecision.artifact.previewProfile}`);
|
|
2138
|
+
rawPlanStatementCount = artifactDecision.artifact.planSummary?.rawStatements ?? parsePlanOutput(artifactDecision.artifact.planSql).statements.length;
|
|
2139
|
+
if (!artifactDecision.artifact.hasChanges) {
|
|
2140
|
+
logger.success("No schema changes detected (planner artifact)");
|
|
2141
|
+
return {
|
|
2142
|
+
sql: "",
|
|
2143
|
+
hazards: [...artifactDecision.artifact.hazards],
|
|
2144
|
+
applied: false,
|
|
2145
|
+
filteredPlanSql: filteredPlanSql || void 0,
|
|
2146
|
+
planSummary,
|
|
2147
|
+
planner: plannerMetadata
|
|
2148
|
+
};
|
|
2149
|
+
}
|
|
2150
|
+
} else {
|
|
2151
|
+
tmpDir = createCombinedSchemaBundle(schemaFiles, input.verbose);
|
|
2152
|
+
logger.step("Running pg-schema-diff (incremental changes)...");
|
|
2153
|
+
if (!input.compareOnly) {
|
|
2154
|
+
shadowDb = await withProgress(
|
|
2155
|
+
"preparing shadow database",
|
|
2156
|
+
() => createShadowDbForRun(dbUrl, configState.shadowExtensions, input.verbose)
|
|
2157
|
+
);
|
|
2158
|
+
}
|
|
2159
|
+
freeConnectionSlotsForPgSchemaDiff(dbUrl, input.verbose);
|
|
2160
|
+
let localTempDbDsn;
|
|
2161
|
+
if (!shadowDb && !process.env.PG_SCHEMA_DIFF_TEMP_DB_DSN && input.env !== "local") {
|
|
2162
|
+
const candidateLocalTempDbDsn = buildLocalDatabaseUrl(process.cwd());
|
|
2163
|
+
try {
|
|
2164
|
+
await verifyDatabaseConnection(candidateLocalTempDbDsn, { maxRetries: 0 });
|
|
2165
|
+
localTempDbDsn = candidateLocalTempDbDsn;
|
|
2166
|
+
if (input.verbose) {
|
|
2167
|
+
logger.step("Using local Supabase as pg-schema-diff temp DB fallback");
|
|
2168
|
+
}
|
|
2169
|
+
} catch {
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
tempDbDsn = resolvePgSchemaDiffTempDbDsn({
|
|
2173
|
+
shadowDbDsn: shadowDb?.dsn,
|
|
2174
|
+
envTempDbDsn: process.env.PG_SCHEMA_DIFF_TEMP_DB_DSN,
|
|
2175
|
+
localTempDbDsn
|
|
2176
|
+
});
|
|
2177
|
+
if (!shadowDb && tempDbDsn) {
|
|
2178
|
+
const resolvedTempDbDsn = tempDbDsn;
|
|
2179
|
+
await withProgress(
|
|
2180
|
+
"bootstrapping external temp database",
|
|
2181
|
+
() => bootstrapTempDbFromSource({
|
|
2182
|
+
sourceDbUrl: dbUrl,
|
|
2183
|
+
tempDbDsn: resolvedTempDbDsn
|
|
2184
|
+
})
|
|
2185
|
+
);
|
|
2186
|
+
}
|
|
2187
|
+
const { planOutput } = await withProgress(
|
|
2188
|
+
"running pg-schema-diff plan",
|
|
2189
|
+
() => executePgSchemaDiffPlan(
|
|
2190
|
+
dbUrl,
|
|
2191
|
+
prefilterState.pgSchemaDiffDir,
|
|
2192
|
+
includeSchemas,
|
|
2193
|
+
input.verbose,
|
|
2194
|
+
{
|
|
2195
|
+
tempDbDsn,
|
|
2196
|
+
targetDir
|
|
2197
|
+
}
|
|
2198
|
+
)
|
|
2199
|
+
);
|
|
2200
|
+
rawPlanStatementCount = parsePlanOutput(planOutput).statements.length;
|
|
2201
|
+
effectivePlanOutput = planOutput;
|
|
2202
|
+
suppressedPlan = {
|
|
2203
|
+
planOutput,
|
|
2204
|
+
suppressedStatements: [],
|
|
2205
|
+
warnings: []
|
|
2206
|
+
};
|
|
2207
|
+
if (!input.compareOnly) {
|
|
2208
|
+
try {
|
|
2209
|
+
suppressedPlan = await withProgress(
|
|
2210
|
+
"reviewing plan false positives",
|
|
2211
|
+
() => suppressFunctionSchemaCheckFalsePositives({
|
|
2212
|
+
dbUrl,
|
|
2213
|
+
planOutput,
|
|
2214
|
+
excludeFromSchemaCheck: configState.schemaCheckExclusions
|
|
2215
|
+
})
|
|
2216
|
+
);
|
|
2217
|
+
} catch (error) {
|
|
2218
|
+
logger.warn(
|
|
2219
|
+
`Skipping pg-schema-diff false-positive suppression due to metadata lookup failure: ${error instanceof Error ? error.message : String(error)}`
|
|
2220
|
+
);
|
|
2221
|
+
}
|
|
2222
|
+
for (const warning of suppressedPlan.warnings) {
|
|
2223
|
+
logger.warn(warning);
|
|
2224
|
+
}
|
|
2225
|
+
effectivePlanOutput = suppressedPlan.planOutput;
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
const noChangesResult = rawPlanStatementCount === 0 || isNoChangePlanOutput(effectivePlanOutput) ? buildNoChangesResult(effectivePlanOutput) : null;
|
|
2229
|
+
if (noChangesResult) return { ...noChangesResult, planner: plannerMetadata };
|
|
2230
|
+
if (!artifactDecision.artifact) {
|
|
2231
|
+
const extracted = await withProgress(
|
|
2232
|
+
"extracting migration hazards",
|
|
2233
|
+
() => handleHazardsWithContext(effectivePlanOutput, input, schemasDir)
|
|
2234
|
+
);
|
|
2235
|
+
hazards = extracted.hazards;
|
|
2236
|
+
}
|
|
2237
|
+
const droppedTables = detectDropTableStatements(effectivePlanOutput);
|
|
2238
|
+
enforceDropSafety(input, droppedTables);
|
|
2239
|
+
const dataViolationCount = await withProgress(
|
|
2240
|
+
"checking data compatibility",
|
|
2241
|
+
() => runPreApplyDataCompatibility(dbUrl, effectivePlanOutput, input)
|
|
2242
|
+
);
|
|
2243
|
+
const protectedTables = getIdempotentProtectedTables(
|
|
2244
|
+
schemasDir,
|
|
2245
|
+
prefilterState.configExclusions
|
|
2246
|
+
);
|
|
2247
|
+
const protectedObjects = getIdempotentProtectedObjects(
|
|
2248
|
+
schemasDir,
|
|
2249
|
+
prefilterState.configExclusions
|
|
2250
|
+
);
|
|
2251
|
+
const planForValidation = parsePlanOutput(effectivePlanOutput);
|
|
2252
|
+
const orderIssues = validateDependencyOrder(planForValidation);
|
|
2253
|
+
if (orderIssues.length > 0) {
|
|
2254
|
+
for (const issue of orderIssues) {
|
|
2255
|
+
logger.error(issue);
|
|
2256
|
+
}
|
|
2257
|
+
throw new Error(
|
|
2258
|
+
`DDL ordering issue detected: ${orderIssues.length} policy/function dependency problem(s).
|
|
2259
|
+
` + orderIssues.map((i) => ` \u2022 ${i}`).join("\n") + "\n\nThis plan would fail on production. Ensure CREATE FUNCTION is defined before policies that reference it."
|
|
2260
|
+
);
|
|
2261
|
+
}
|
|
2262
|
+
const checkModeResult = buildCheckModeResult(
|
|
2263
|
+
input,
|
|
2264
|
+
effectivePlanOutput,
|
|
2265
|
+
hazards,
|
|
2266
|
+
protectedTables,
|
|
2267
|
+
protectedObjects,
|
|
2268
|
+
dataViolationCount,
|
|
2269
|
+
schemasDir,
|
|
2270
|
+
{
|
|
2271
|
+
rawStatementCount: rawPlanStatementCount,
|
|
2272
|
+
suppressedFunctionStatements: !input.compareOnly ? suppressedPlan.suppressedStatements : void 0
|
|
2273
|
+
}
|
|
2274
|
+
);
|
|
2275
|
+
if (checkModeResult) {
|
|
2276
|
+
return {
|
|
2277
|
+
...checkModeResult,
|
|
2278
|
+
planner: plannerMetadata
|
|
2279
|
+
};
|
|
2280
|
+
}
|
|
2281
|
+
backupProtectedTablesForProduction(dbUrl, protectedTables, input);
|
|
2282
|
+
const preApplyCounts = getTableRowEstimates(dbUrl, schemasDir, input.verbose);
|
|
2283
|
+
const applyResult = await applyWithRetry({
|
|
2284
|
+
dbUrl,
|
|
2285
|
+
targetDir,
|
|
2286
|
+
schemasDir,
|
|
2287
|
+
includeSchemas,
|
|
2288
|
+
input,
|
|
2289
|
+
planOutput: effectivePlanOutput,
|
|
2290
|
+
hazards,
|
|
2291
|
+
protectedTables,
|
|
2292
|
+
protectedObjects,
|
|
2293
|
+
tempDbDsn,
|
|
2294
|
+
pgSchemaDiffDir: prefilterState.pgSchemaDiffDir,
|
|
2295
|
+
disableRePlanOnRetry: artifactDecision.artifact != null
|
|
2296
|
+
});
|
|
2297
|
+
if (applyResult.applied) {
|
|
2298
|
+
verifyDataIntegrity(dbUrl, schemasDir, preApplyCounts, input.verbose, input.allowDataLoss);
|
|
2299
|
+
}
|
|
2300
|
+
return {
|
|
2301
|
+
...applyResult,
|
|
2302
|
+
filteredPlanSql: filteredPlanSql || void 0,
|
|
2303
|
+
planSummary,
|
|
2304
|
+
planner: plannerMetadata,
|
|
2305
|
+
dataViolations: dataViolationCount > 0 ? dataViolationCount : void 0
|
|
2306
|
+
};
|
|
2307
|
+
} finally {
|
|
2308
|
+
await cleanupApplyResources({
|
|
2309
|
+
shadowDb,
|
|
2310
|
+
prefilter: prefilterState.prefilter,
|
|
2311
|
+
tmpDir,
|
|
2312
|
+
verbose: input.verbose
|
|
2313
|
+
});
|
|
2314
|
+
}
|
|
2315
|
+
});
|
|
2316
|
+
var validatePartitions = fromPromise(async ({ input: { input, targetDir } }) => {
|
|
2317
|
+
if (input.check) return { warnings: [] };
|
|
2318
|
+
const idempotentDir = join(targetDir, "supabase/schemas/idempotent");
|
|
2319
|
+
if (!existsSync(idempotentDir)) return { warnings: [] };
|
|
2320
|
+
const expected = parseExpectedPartitions(idempotentDir);
|
|
2321
|
+
if (expected.length === 0) return { warnings: [] };
|
|
2322
|
+
const dbUrl = getDbUrl(input);
|
|
2323
|
+
const schemas = [...new Set(expected.map((e) => e.parent.split(".")[0] ?? ""))];
|
|
2324
|
+
const actual = queryActualPartitions(dbUrl, schemas);
|
|
2325
|
+
const drift = detectPartitionDrift(expected, actual);
|
|
2326
|
+
if (drift.missing.length === 0) {
|
|
2327
|
+
logger.success(`All ${expected.length} expected partition(s) verified`);
|
|
2328
|
+
return { warnings: [] };
|
|
2329
|
+
}
|
|
2330
|
+
const warnings = formatPartitionWarnings(drift);
|
|
2331
|
+
for (const w of warnings) logger.warn(w);
|
|
2332
|
+
return { warnings };
|
|
2333
|
+
});
|
|
2334
|
+
|
|
2335
|
+
// src/commands/db/apply/actors/seed-actors.ts
|
|
2336
|
+
init_esm_shims();
|
|
2337
|
+
var DESTRUCTIVE_SEED_PATTERNS = [
|
|
2338
|
+
{ pattern: /\bDELETE\s+FROM\b/i, description: "DELETE FROM" },
|
|
2339
|
+
{ pattern: /\bTRUNCATE\b/i, description: "TRUNCATE" },
|
|
2340
|
+
{ pattern: /\bDROP\s+TABLE\b/i, description: "DROP TABLE" },
|
|
2341
|
+
{ pattern: /\bDROP\s+SCHEMA\b/i, description: "DROP SCHEMA" }
|
|
2342
|
+
];
|
|
2343
|
+
function detectDestructiveSeedPatterns(seedFilePath) {
|
|
2344
|
+
const content = readFileSync(seedFilePath, "utf-8");
|
|
2345
|
+
const detected = [];
|
|
2346
|
+
for (const { pattern, description } of DESTRUCTIVE_SEED_PATTERNS) {
|
|
2347
|
+
pattern.lastIndex = 0;
|
|
2348
|
+
if (pattern.test(content)) {
|
|
2349
|
+
detected.push(description);
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
return detected;
|
|
2353
|
+
}
|
|
2354
|
+
function isUnsafeProductionSeed(input, seedFile) {
|
|
2355
|
+
if (input.env !== "production") return false;
|
|
2356
|
+
const destructivePatterns = detectDestructiveSeedPatterns(seedFile);
|
|
2357
|
+
if (destructivePatterns.length === 0) {
|
|
2358
|
+
return false;
|
|
2359
|
+
}
|
|
2360
|
+
logger.warn("");
|
|
2361
|
+
logger.warn("\u26A0\uFE0F DANGER: Seed file contains destructive SQL for PRODUCTION");
|
|
2362
|
+
logger.warn(` Detected: ${destructivePatterns.join(", ")}`);
|
|
2363
|
+
logger.warn(" This WILL delete real user data. Aborting seed application.");
|
|
2364
|
+
logger.warn(" If this is intentional, review and apply seeds manually.");
|
|
2365
|
+
logger.warn("");
|
|
2366
|
+
return true;
|
|
2367
|
+
}
|
|
2368
|
+
function parseSeedErrorDiagnostics(stderr) {
|
|
2369
|
+
const psqlLocation = stderr.match(/psql:([^:]+):(\d+):\s*ERROR:/);
|
|
2370
|
+
const locationInfo = psqlLocation ? { file: psqlLocation[1], line: Number(psqlLocation[2]) } : {};
|
|
2371
|
+
const columnMissing = stderr.match(/column "([^"]+)" of relation "([^"]+)" does not exist/);
|
|
2372
|
+
if (columnMissing) {
|
|
2373
|
+
return {
|
|
2374
|
+
...locationInfo,
|
|
2375
|
+
table: columnMissing[2],
|
|
2376
|
+
errorType: "missing_column",
|
|
2377
|
+
hint: `Column "${columnMissing[1]}" missing from ${columnMissing[2]}. Schema may have changed. Regenerate seeds: pnpm generate:seeds ci`
|
|
2378
|
+
};
|
|
2379
|
+
}
|
|
2380
|
+
const relationMissing = stderr.match(/relation "([^"]+)" does not exist/);
|
|
2381
|
+
if (relationMissing) {
|
|
2382
|
+
return {
|
|
2383
|
+
...locationInfo,
|
|
2384
|
+
table: relationMissing[1],
|
|
2385
|
+
errorType: "missing_relation",
|
|
2386
|
+
hint: `Table ${relationMissing[1]} does not exist. Regenerate seeds: pnpm generate:seeds ci`
|
|
2387
|
+
};
|
|
2388
|
+
}
|
|
2389
|
+
const fkViolation = stderr.match(
|
|
2390
|
+
/insert or update on table "([^"]+)" violates foreign key constraint/
|
|
2391
|
+
);
|
|
2392
|
+
if (fkViolation) {
|
|
2393
|
+
return {
|
|
2394
|
+
...locationInfo,
|
|
2395
|
+
table: fkViolation[1],
|
|
2396
|
+
errorType: "fk_violation",
|
|
2397
|
+
hint: `FK constraint failed on ${fkViolation[1]}. Check seed insertion order or missing parent records.`
|
|
2398
|
+
};
|
|
2399
|
+
}
|
|
2400
|
+
const checkViolation = stderr.match(/new row for relation "([^"]+)" violates check constraint/);
|
|
2401
|
+
if (checkViolation) {
|
|
2402
|
+
return {
|
|
2403
|
+
...locationInfo,
|
|
2404
|
+
table: checkViolation[1],
|
|
2405
|
+
errorType: "check_violation",
|
|
2406
|
+
hint: `CHECK constraint failed on ${checkViolation[1]}. Seed data may not match column constraints. Regenerate seeds: pnpm generate:seeds ci`
|
|
2407
|
+
};
|
|
2408
|
+
}
|
|
2409
|
+
const uniqueViolation = stderr.match(
|
|
2410
|
+
/duplicate key value violates unique constraint.*?on table "([^"]+)"/s
|
|
2411
|
+
);
|
|
2412
|
+
if (uniqueViolation) {
|
|
2413
|
+
return {
|
|
2414
|
+
...locationInfo,
|
|
2415
|
+
table: uniqueViolation[1],
|
|
2416
|
+
errorType: "unique_violation",
|
|
2417
|
+
hint: `Duplicate key on ${uniqueViolation[1]}. Seeds may have been partially applied. Try: runa db reset`
|
|
2418
|
+
};
|
|
2419
|
+
}
|
|
2420
|
+
return { ...locationInfo };
|
|
2421
|
+
}
|
|
2422
|
+
function createSeedFailureMessage(diagnostics, locationSuffix) {
|
|
2423
|
+
if (diagnostics.table) {
|
|
2424
|
+
const summary = `Seed apply failed on table: ${diagnostics.table} (${diagnostics.errorType})${locationSuffix}`;
|
|
2425
|
+
return {
|
|
2426
|
+
summary,
|
|
2427
|
+
hint: diagnostics.hint
|
|
2428
|
+
};
|
|
2429
|
+
}
|
|
2430
|
+
if (locationSuffix) {
|
|
2431
|
+
return { summary: `Seed apply failed${locationSuffix} (non-blocking)` };
|
|
2432
|
+
}
|
|
2433
|
+
return { summary: "Seed apply failed (non-blocking)" };
|
|
2434
|
+
}
|
|
2435
|
+
function logSeedFailureSummary(seedFailure) {
|
|
2436
|
+
logger.warn(seedFailure.summary);
|
|
2437
|
+
if (seedFailure.hint) {
|
|
2438
|
+
logger.info(` Hint: ${seedFailure.hint}`);
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
function buildSeedLocationSuffix(diagnostics) {
|
|
2442
|
+
if (diagnostics.line == null) return "";
|
|
2443
|
+
const fileSuffix = diagnostics.file ? ` of ${diagnostics.file}` : "";
|
|
2444
|
+
return ` at line ${diagnostics.line}${fileSuffix}`;
|
|
2445
|
+
}
|
|
2446
|
+
function handleFailedSeed(result, verbose) {
|
|
2447
|
+
const errorMsg = result.stderr ? maskDbCredentials(result.stderr) : "";
|
|
2448
|
+
const diagnostics = parseSeedErrorDiagnostics(result.stderr);
|
|
2449
|
+
const failure = createSeedFailureMessage(diagnostics, buildSeedLocationSuffix(diagnostics));
|
|
2450
|
+
logSeedFailureSummary(failure);
|
|
2451
|
+
if (errorMsg && !verbose) {
|
|
2452
|
+
logger.debug(` Error: ${errorMsg.split("\n")[0]}`);
|
|
2453
|
+
}
|
|
2454
|
+
return false;
|
|
2455
|
+
}
|
|
2456
|
+
function applySeedFile(dbUrl, seedFile, verbose) {
|
|
2457
|
+
logger.step("Applying seeds...");
|
|
2458
|
+
const result = psqlSyncFile({
|
|
2459
|
+
databaseUrl: dbUrl,
|
|
2460
|
+
filePath: seedFile,
|
|
2461
|
+
onErrorStop: true
|
|
2462
|
+
});
|
|
2463
|
+
const stdout = result.stdout || "";
|
|
2464
|
+
const stderr = result.stderr || "";
|
|
2465
|
+
if (verbose) {
|
|
2466
|
+
if (stdout) process.stdout.write(stdout);
|
|
2467
|
+
if (stderr) process.stderr.write(stderr);
|
|
2468
|
+
}
|
|
2469
|
+
if (result.status === 0) {
|
|
2470
|
+
logger.success("Seeds applied");
|
|
2471
|
+
return true;
|
|
2472
|
+
}
|
|
2473
|
+
return handleFailedSeed(result, verbose);
|
|
2474
|
+
}
|
|
2475
|
+
function runSeeds(input, targetDir, dbUrl) {
|
|
2476
|
+
if (input.noSeed) {
|
|
2477
|
+
logger.info("Skipping seeds (--no-seed)");
|
|
2478
|
+
return false;
|
|
2479
|
+
}
|
|
2480
|
+
const seedFile = join(targetDir, "supabase/seeds/ci.sql");
|
|
2481
|
+
if (!existsSync(seedFile)) {
|
|
2482
|
+
logger.info("No seed file found (supabase/seeds/ci.sql)");
|
|
2483
|
+
return false;
|
|
2484
|
+
}
|
|
2485
|
+
if (isUnsafeProductionSeed(input, seedFile)) {
|
|
2486
|
+
return false;
|
|
2487
|
+
}
|
|
2488
|
+
return applySeedFile(dbUrl, seedFile, input.verbose);
|
|
2489
|
+
}
|
|
2490
|
+
async function generateTablesManifestSafely(targetDir, dbUrl) {
|
|
2491
|
+
try {
|
|
2492
|
+
await generateTablesManifest(targetDir, { databaseUrl: dbUrl });
|
|
2493
|
+
return void 0;
|
|
2494
|
+
} catch (error) {
|
|
2495
|
+
return `Failed to generate tables manifest: ${error}`;
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
var applySeeds = fromPromise(async ({ input: { input, targetDir } }) => {
|
|
2499
|
+
const dbUrl = getDbUrl(input);
|
|
2500
|
+
const seedsApplied = runSeeds(input, targetDir, dbUrl);
|
|
2501
|
+
const manifestWarning = await generateTablesManifestSafely(targetDir, dbUrl);
|
|
2502
|
+
return { applied: seedsApplied, warnings: manifestWarning ? [manifestWarning] : [] };
|
|
2503
|
+
});
|
|
2504
|
+
|
|
2505
|
+
// src/commands/db/apply/machine.ts
|
|
2506
|
+
init_esm_shims();
|
|
2507
|
+
var e2eMeta = {
|
|
2508
|
+
idle: {
|
|
2509
|
+
description: "Waiting for START event to begin db apply workflow",
|
|
2510
|
+
severity: "non-critical",
|
|
2511
|
+
observables: {
|
|
2512
|
+
log: "Starting db apply"
|
|
2513
|
+
},
|
|
2514
|
+
assertions: ["expect(log).toContain('Starting db apply')", "expect(state).toBe('idle')"],
|
|
2515
|
+
nextStates: ["acquiringLock", "previewingIdempotent"]
|
|
2516
|
+
},
|
|
2517
|
+
acquiringLock: {
|
|
2518
|
+
description: "Acquire advisory lock to prevent concurrent migrations",
|
|
2519
|
+
severity: "blocking",
|
|
2520
|
+
observables: {
|
|
2521
|
+
log: "Advisory lock acquired"
|
|
2522
|
+
},
|
|
2523
|
+
assertions: ["expect(log).toContain('Advisory lock')"],
|
|
2524
|
+
nextStates: ["capturingTargetFingerprint", "applyingIdempotentPre", "failed"]
|
|
2525
|
+
},
|
|
2526
|
+
capturingTargetFingerprint: {
|
|
2527
|
+
description: "Capture target DB fingerprint before idempotent pre-pass mutates state",
|
|
2528
|
+
severity: "non-critical",
|
|
2529
|
+
observables: {
|
|
2530
|
+
log: "Target fingerprint captured"
|
|
2531
|
+
},
|
|
2532
|
+
assertions: ["expect(log).toContain('Target fingerprint')"],
|
|
2533
|
+
nextStates: ["applyingIdempotentPre"]
|
|
2534
|
+
},
|
|
2535
|
+
previewingIdempotent: {
|
|
2536
|
+
description: "Preview idempotent schema files and risks (check mode)",
|
|
2537
|
+
severity: "non-critical",
|
|
2538
|
+
observables: {
|
|
2539
|
+
log: "Idempotent schemas"
|
|
2540
|
+
},
|
|
2541
|
+
assertions: ["expect(log).toContain('Idempotent')"],
|
|
2542
|
+
nextStates: ["applyingPgSchemaDiff"]
|
|
2543
|
+
},
|
|
2544
|
+
applyingIdempotentPre: {
|
|
2545
|
+
description: "Apply idempotent schemas (1st pass: extensions, roles)",
|
|
2546
|
+
severity: "blocking",
|
|
2547
|
+
observables: {
|
|
2548
|
+
log: "Applied idempotent schema",
|
|
2549
|
+
db: "Extensions enabled, roles created"
|
|
2550
|
+
},
|
|
2551
|
+
assertions: [
|
|
2552
|
+
"expect(log).toContain('idempotent')",
|
|
2553
|
+
"expect(ctx.idempotentPreApplied).toBeGreaterThanOrEqual(0)"
|
|
2554
|
+
],
|
|
2555
|
+
nextStates: ["applyingPgSchemaDiff", "failed"]
|
|
2556
|
+
},
|
|
2557
|
+
applyingPgSchemaDiff: {
|
|
2558
|
+
description: "Run pg-schema-diff to apply schema changes",
|
|
2559
|
+
severity: "blocking",
|
|
2560
|
+
observables: {
|
|
2561
|
+
log: "pg-schema-diff",
|
|
2562
|
+
db: "Schema changes applied"
|
|
2563
|
+
},
|
|
2564
|
+
assertions: [
|
|
2565
|
+
"expect(log).toContain('pg-schema-diff')",
|
|
2566
|
+
"expect(ctx.schemaChangesApplied).toBeDefined()"
|
|
2567
|
+
],
|
|
2568
|
+
nextStates: ["applyingIdempotentPost", "validatingPartitions", "done", "failed"]
|
|
2569
|
+
},
|
|
2570
|
+
applyingIdempotentPost: {
|
|
2571
|
+
description: "Apply idempotent schemas (2nd pass: dependent tables, RLS)",
|
|
2572
|
+
severity: "non-critical",
|
|
2573
|
+
observables: {
|
|
2574
|
+
log: "Applied idempotent schema",
|
|
2575
|
+
db: "Dependent tables created, RLS applied"
|
|
2576
|
+
},
|
|
2577
|
+
assertions: [
|
|
2578
|
+
"expect(log).toContain('idempotent')",
|
|
2579
|
+
"expect(ctx.idempotentPostApplied).toBeGreaterThanOrEqual(0)"
|
|
2580
|
+
],
|
|
2581
|
+
nextStates: ["validatingPartitions", "failed"]
|
|
2582
|
+
},
|
|
2583
|
+
validatingPartitions: {
|
|
2584
|
+
description: "Validate expected partitions exist in the database",
|
|
2585
|
+
severity: "non-critical",
|
|
2586
|
+
observables: {
|
|
2587
|
+
log: "partition"
|
|
2588
|
+
},
|
|
2589
|
+
assertions: ["expect(log).toContain('partition')"],
|
|
2590
|
+
nextStates: ["releasingLock"]
|
|
2591
|
+
},
|
|
2592
|
+
releasingLock: {
|
|
2593
|
+
description: "Release advisory lock after migration lifecycle completes",
|
|
2594
|
+
severity: "non-critical",
|
|
2595
|
+
observables: {
|
|
2596
|
+
log: "Advisory lock released"
|
|
2597
|
+
},
|
|
2598
|
+
assertions: ["expect(log).toContain('Advisory lock')"],
|
|
2599
|
+
nextStates: ["applyingSeeds"]
|
|
2600
|
+
},
|
|
2601
|
+
applyingSeeds: {
|
|
2602
|
+
description: "Apply seed data to database",
|
|
2603
|
+
severity: "non-critical",
|
|
2604
|
+
observables: {
|
|
2605
|
+
log: "Applying seeds",
|
|
2606
|
+
db: "Seed data inserted"
|
|
2607
|
+
},
|
|
2608
|
+
assertions: ["expect(log).toContain('Applying seeds')"],
|
|
2609
|
+
nextStates: ["done"]
|
|
2610
|
+
},
|
|
2611
|
+
done: {
|
|
2612
|
+
description: "Database apply completed successfully",
|
|
2613
|
+
severity: "final",
|
|
2614
|
+
observables: {
|
|
2615
|
+
log: "db apply complete",
|
|
2616
|
+
exit: 0
|
|
2617
|
+
},
|
|
2618
|
+
assertions: ["expect(log).toContain('db apply complete')", "expect(exitCode).toBe(0)"],
|
|
2619
|
+
nextStates: []
|
|
2620
|
+
},
|
|
2621
|
+
failed: {
|
|
2622
|
+
description: "Database apply failed with error",
|
|
2623
|
+
severity: "final",
|
|
2624
|
+
observables: {
|
|
2625
|
+
log: "db apply failed",
|
|
2626
|
+
exit: 1
|
|
2627
|
+
},
|
|
2628
|
+
assertions: ["expect(log).toContain('db apply failed')", "expect(exitCode).toBe(1)"],
|
|
2629
|
+
nextStates: []
|
|
2630
|
+
}
|
|
2631
|
+
};
|
|
2632
|
+
function classifyDbApplyFailure(errorMessage) {
|
|
2633
|
+
if (isTimeoutLikeMessage(errorMessage)) {
|
|
2634
|
+
return {
|
|
2635
|
+
code: "PHASE_TIMEOUT",
|
|
2636
|
+
retryable: true
|
|
2637
|
+
};
|
|
2638
|
+
}
|
|
2639
|
+
if (errorMessage.startsWith("artifact_retry_requires_replan:")) {
|
|
2640
|
+
return {
|
|
2641
|
+
code: "artifact_retry_requires_replan",
|
|
2642
|
+
retryable: false
|
|
2643
|
+
};
|
|
2644
|
+
}
|
|
2645
|
+
return {
|
|
2646
|
+
code: "DB_APPLY_FAILED",
|
|
2647
|
+
retryable: false
|
|
2648
|
+
};
|
|
2649
|
+
}
|
|
2650
|
+
function getDurationMs(startTime, endTime) {
|
|
2651
|
+
if (startTime === null || endTime === null) {
|
|
2652
|
+
return void 0;
|
|
2653
|
+
}
|
|
2654
|
+
return endTime - startTime;
|
|
2655
|
+
}
|
|
2656
|
+
function toOptionalPositive(value) {
|
|
2657
|
+
return value > 0 ? value : void 0;
|
|
2658
|
+
}
|
|
2659
|
+
function toOptionalTrue(value) {
|
|
2660
|
+
return value ? true : void 0;
|
|
2661
|
+
}
|
|
2662
|
+
function toOptionalArray(value) {
|
|
2663
|
+
return value.length > 0 ? value : void 0;
|
|
2664
|
+
}
|
|
2665
|
+
function buildDbApplyMetrics(context, endTime) {
|
|
2666
|
+
const idempotentPreMs = getDurationMs(context.idempotentPreStartTime, context.idempotentPreEndTime) ?? 0;
|
|
2667
|
+
const idempotentPostMs = getDurationMs(context.idempotentPostStartTime, context.idempotentPostEndTime) ?? 0;
|
|
2668
|
+
const totalIdempotentMs = idempotentPreMs + idempotentPostMs;
|
|
2669
|
+
return {
|
|
2670
|
+
totalMs: endTime - context.startTime,
|
|
2671
|
+
idempotentMs: toOptionalPositive(totalIdempotentMs),
|
|
2672
|
+
applyMs: getDurationMs(context.pgSchemaDiffStartTime, context.pgSchemaDiffEndTime),
|
|
2673
|
+
seedMs: getDurationMs(context.seedStartTime, context.seedEndTime),
|
|
2674
|
+
retryAttempts: toOptionalPositive(context.retryAttempts),
|
|
2675
|
+
retryWaitMs: toOptionalPositive(context.retryWaitMs)
|
|
2676
|
+
};
|
|
2677
|
+
}
|
|
2678
|
+
function createMachineWarning(code, message, phase) {
|
|
2679
|
+
return { code, message, phase };
|
|
2680
|
+
}
|
|
2681
|
+
function createDbApplyOutput(context, endTime) {
|
|
2682
|
+
const metrics = buildDbApplyMetrics(context, endTime);
|
|
2683
|
+
const totalIdempotentApplied = context.idempotentPreApplied + context.idempotentPostApplied;
|
|
2684
|
+
const totalIdempotentSkipped = context.idempotentPreSkipped + context.idempotentPostSkipped;
|
|
2685
|
+
const failureMetadata = context.error ? classifyDbApplyFailure(context.error) : null;
|
|
2686
|
+
const phases = context.error ? [
|
|
2687
|
+
{
|
|
2688
|
+
id: "dbApply",
|
|
2689
|
+
label: "Database apply",
|
|
2690
|
+
status: failureMetadata?.retryable ? "timeout" : "failed",
|
|
2691
|
+
error: {
|
|
2692
|
+
code: failureMetadata?.code ?? "DB_APPLY_FAILED",
|
|
2693
|
+
message: context.error,
|
|
2694
|
+
retryable: failureMetadata?.retryable ?? false,
|
|
2695
|
+
phase: "dbApply"
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
] : [];
|
|
2699
|
+
return {
|
|
2700
|
+
success: !context.error,
|
|
2701
|
+
idempotentSchemasApplied: totalIdempotentApplied,
|
|
2702
|
+
idempotentSchemasSkipped: toOptionalPositive(totalIdempotentSkipped),
|
|
2703
|
+
rolePasswordsSet: toOptionalPositive(context.rolePasswordsSet),
|
|
2704
|
+
schemaChangesApplied: context.schemaChangesApplied,
|
|
2705
|
+
hazards: context.hazards,
|
|
2706
|
+
seedsApplied: context.seedsApplied,
|
|
2707
|
+
error: context.error ?? void 0,
|
|
2708
|
+
ssotWarning: context.ssotWarning ?? void 0,
|
|
2709
|
+
dataViolations: toOptionalPositive(context.dataViolations),
|
|
2710
|
+
planSql: context.planSql ?? void 0,
|
|
2711
|
+
filteredPlanSql: context.filteredPlanSql ?? void 0,
|
|
2712
|
+
planSummary: context.planSummary ?? void 0,
|
|
2713
|
+
planner: context.planner ?? void 0,
|
|
2714
|
+
checkOnly: toOptionalTrue(context.input.check === true),
|
|
2715
|
+
previewProfile: context.input.check === true ? context.input.compareOnly === true ? "compare-only" : "full" : void 0,
|
|
2716
|
+
partitionWarnings: toOptionalArray(context.partitionWarnings),
|
|
2717
|
+
idempotentFiles: toOptionalArray(context.idempotentFiles),
|
|
2718
|
+
idempotentRisks: context.idempotentRisks ?? void 0,
|
|
2719
|
+
metrics,
|
|
2720
|
+
outcome: {
|
|
2721
|
+
command: `runa db apply ${context.input.env}${context.input.check ? " --check" : ""}`,
|
|
2722
|
+
exitMode: deriveCommandExitMode(phases),
|
|
2723
|
+
startedAt: new Date(context.startTime).toISOString(),
|
|
2724
|
+
endedAt: new Date(endTime).toISOString(),
|
|
2725
|
+
durationMs: endTime - context.startTime,
|
|
2726
|
+
phases,
|
|
2727
|
+
warnings: context.nonCriticalWarnings,
|
|
2728
|
+
errors: phases.map((phase) => phase.error).filter((value) => value != null),
|
|
2729
|
+
summary: buildCommandOutcomeSummary(phases)
|
|
2730
|
+
}
|
|
2731
|
+
};
|
|
2732
|
+
}
|
|
2733
|
+
var dbApplyMachine = setup({
|
|
2734
|
+
types: {},
|
|
2735
|
+
actions: {
|
|
2736
|
+
assignPgSchemaDiffResult: assign(({ event }) => {
|
|
2737
|
+
if (!("output" in event)) {
|
|
2738
|
+
return {};
|
|
2739
|
+
}
|
|
2740
|
+
const output = event.output;
|
|
2741
|
+
return {
|
|
2742
|
+
schemaChangesApplied: output.applied,
|
|
2743
|
+
dataViolations: output.dataViolations ?? 0,
|
|
2744
|
+
hazards: output.hazards,
|
|
2745
|
+
planSql: output.sql ?? null,
|
|
2746
|
+
filteredPlanSql: output.filteredPlanSql ?? null,
|
|
2747
|
+
planSummary: output.planSummary ?? null,
|
|
2748
|
+
planner: output.planner ?? null,
|
|
2749
|
+
ssotWarning: output.ssotWarning ?? null,
|
|
2750
|
+
pgSchemaDiffEndTime: Date.now(),
|
|
2751
|
+
retryAttempts: output.retryAttempts ?? 0,
|
|
2752
|
+
retryWaitMs: output.retryWaitMs ?? 0
|
|
2753
|
+
};
|
|
2754
|
+
}),
|
|
2755
|
+
releaseAdvisoryLockOnFailure: ({ context }) => {
|
|
2756
|
+
if (context.lockAcquired) {
|
|
2757
|
+
try {
|
|
2758
|
+
const dbUrl = context.input.databaseUrl ? normalizeDatabaseUrlForDdl(context.input.databaseUrl) : normalizeDatabaseUrlForDdl(resolveDatabaseUrl(context.input.env));
|
|
2759
|
+
releaseAdvisoryLock(dbUrl, context.input.verbose);
|
|
2760
|
+
} catch {
|
|
2761
|
+
}
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
},
|
|
2765
|
+
actors: {
|
|
2766
|
+
acquireLock,
|
|
2767
|
+
releaseLock,
|
|
2768
|
+
captureTargetFingerprint: fromPromise(async ({ input: { input, targetDir } }) => {
|
|
2769
|
+
if (!input.plannerArtifactPath) {
|
|
2770
|
+
return null;
|
|
2771
|
+
}
|
|
2772
|
+
const dbUrl = getDbUrl(input);
|
|
2773
|
+
const includeSchemas = detectPlannerSchemas(targetDir);
|
|
2774
|
+
if (includeSchemas.length === 0) {
|
|
2775
|
+
return null;
|
|
2776
|
+
}
|
|
2777
|
+
return buildTargetFingerprint({ databaseUrl: dbUrl, includeSchemas });
|
|
2778
|
+
}),
|
|
2779
|
+
previewIdempotentSchemas,
|
|
2780
|
+
applyIdempotentSchemas,
|
|
2781
|
+
applyPgSchemaDiff,
|
|
2782
|
+
validatePartitions,
|
|
2783
|
+
applySeeds
|
|
2784
|
+
}
|
|
2785
|
+
}).createMachine({
|
|
2786
|
+
id: "dbApply",
|
|
2787
|
+
initial: "idle",
|
|
2788
|
+
context: ({ input }) => ({
|
|
2789
|
+
input: input.input,
|
|
2790
|
+
targetDir: input.targetDir,
|
|
2791
|
+
// Advisory lock
|
|
2792
|
+
lockAcquired: false,
|
|
2793
|
+
// Pre-idempotent target fingerprint
|
|
2794
|
+
preIdempotentTargetFingerprint: null,
|
|
2795
|
+
// 2-pass idempotent
|
|
2796
|
+
idempotentPreApplied: 0,
|
|
2797
|
+
idempotentPreSkipped: 0,
|
|
2798
|
+
idempotentPostApplied: 0,
|
|
2799
|
+
idempotentPostSkipped: 0,
|
|
2800
|
+
rolePasswordsSet: 0,
|
|
2801
|
+
schemaChangesApplied: false,
|
|
2802
|
+
dataViolations: 0,
|
|
2803
|
+
hazards: [],
|
|
2804
|
+
seedsApplied: false,
|
|
2805
|
+
partitionWarnings: [],
|
|
2806
|
+
error: null,
|
|
2807
|
+
planSql: null,
|
|
2808
|
+
filteredPlanSql: null,
|
|
2809
|
+
planSummary: null,
|
|
2810
|
+
planner: null,
|
|
2811
|
+
ssotWarning: null,
|
|
2812
|
+
nonCriticalWarnings: [],
|
|
2813
|
+
// Idempotent preview (check mode)
|
|
2814
|
+
idempotentFiles: [],
|
|
2815
|
+
idempotentRisks: null,
|
|
2816
|
+
// Initialize metrics
|
|
2817
|
+
startTime: Date.now(),
|
|
2818
|
+
idempotentPreStartTime: null,
|
|
2819
|
+
idempotentPreEndTime: null,
|
|
2820
|
+
pgSchemaDiffStartTime: null,
|
|
2821
|
+
pgSchemaDiffEndTime: null,
|
|
2822
|
+
idempotentPostStartTime: null,
|
|
2823
|
+
idempotentPostEndTime: null,
|
|
2824
|
+
seedStartTime: null,
|
|
2825
|
+
seedEndTime: null,
|
|
2826
|
+
retryAttempts: 0,
|
|
2827
|
+
retryWaitMs: 0
|
|
2828
|
+
}),
|
|
2829
|
+
states: {
|
|
2830
|
+
idle: {
|
|
2831
|
+
meta: { e2e: e2eMeta.idle },
|
|
2832
|
+
on: {
|
|
2833
|
+
START: [
|
|
2834
|
+
// Check mode: preview idempotent files then dry-run pg-schema-diff
|
|
2835
|
+
{
|
|
2836
|
+
guard: ({ context }) => context.input.check === true && context.input.compareOnly === true,
|
|
2837
|
+
target: "applyingPgSchemaDiff"
|
|
2838
|
+
},
|
|
2839
|
+
{
|
|
2840
|
+
guard: ({ context }) => context.input.check === true,
|
|
2841
|
+
target: "previewingIdempotent"
|
|
2842
|
+
},
|
|
2843
|
+
// Normal mode: acquire lock first, then run full migration
|
|
2844
|
+
{ target: "acquiringLock" }
|
|
2845
|
+
]
|
|
2846
|
+
}
|
|
2847
|
+
},
|
|
2848
|
+
acquiringLock: {
|
|
2849
|
+
meta: { e2e: e2eMeta.acquiringLock },
|
|
2850
|
+
invoke: {
|
|
2851
|
+
src: "acquireLock",
|
|
2852
|
+
input: ({ context }) => ({ input: context.input }),
|
|
2853
|
+
onDone: {
|
|
2854
|
+
target: "capturingTargetFingerprint",
|
|
2855
|
+
actions: assign({ lockAcquired: true })
|
|
2856
|
+
},
|
|
2857
|
+
onError: {
|
|
2858
|
+
target: "failed",
|
|
2859
|
+
actions: assign({
|
|
2860
|
+
error: ({ event }) => event.error instanceof Error ? event.error.message : "Failed to acquire advisory lock"
|
|
2861
|
+
})
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2864
|
+
},
|
|
2865
|
+
capturingTargetFingerprint: {
|
|
2866
|
+
meta: { e2e: e2eMeta.capturingTargetFingerprint },
|
|
2867
|
+
invoke: {
|
|
2868
|
+
src: "captureTargetFingerprint",
|
|
2869
|
+
input: ({ context }) => ({ input: context.input, targetDir: context.targetDir }),
|
|
2870
|
+
onDone: {
|
|
2871
|
+
target: "applyingIdempotentPre",
|
|
2872
|
+
actions: assign({
|
|
2873
|
+
preIdempotentTargetFingerprint: ({ event }) => event.output ?? null
|
|
2874
|
+
})
|
|
2875
|
+
},
|
|
2876
|
+
onError: {
|
|
2877
|
+
// Non-blocking: fingerprint capture failure should not block migration
|
|
2878
|
+
target: "applyingIdempotentPre"
|
|
2879
|
+
}
|
|
2880
|
+
}
|
|
2881
|
+
},
|
|
2882
|
+
previewingIdempotent: {
|
|
2883
|
+
meta: { e2e: e2eMeta.previewingIdempotent },
|
|
2884
|
+
invoke: {
|
|
2885
|
+
src: "previewIdempotentSchemas",
|
|
2886
|
+
input: ({ context }) => ({ input: context.input, targetDir: context.targetDir }),
|
|
2887
|
+
onDone: {
|
|
2888
|
+
target: "applyingPgSchemaDiff",
|
|
2889
|
+
actions: assign({
|
|
2890
|
+
idempotentFiles: ({ event }) => event.output.files,
|
|
2891
|
+
idempotentRisks: ({ event }) => event.output.riskSummary
|
|
2892
|
+
})
|
|
2893
|
+
},
|
|
2894
|
+
onError: {
|
|
2895
|
+
// Non-blocking: continue to pg-schema-diff even if preview fails
|
|
2896
|
+
target: "applyingPgSchemaDiff"
|
|
2897
|
+
}
|
|
2898
|
+
}
|
|
2899
|
+
},
|
|
2900
|
+
applyingIdempotentPre: {
|
|
2901
|
+
meta: { e2e: e2eMeta.applyingIdempotentPre },
|
|
2902
|
+
entry: assign({ idempotentPreStartTime: () => Date.now() }),
|
|
2903
|
+
invoke: {
|
|
2904
|
+
src: "applyIdempotentSchemas",
|
|
2905
|
+
input: ({ context }) => ({
|
|
2906
|
+
input: context.input,
|
|
2907
|
+
targetDir: context.targetDir,
|
|
2908
|
+
pass: "pre"
|
|
2909
|
+
}),
|
|
2910
|
+
onDone: {
|
|
2911
|
+
target: "applyingPgSchemaDiff",
|
|
2912
|
+
actions: assign({
|
|
2913
|
+
idempotentPreApplied: ({ event }) => event.output.filesApplied,
|
|
2914
|
+
idempotentPreSkipped: ({ event }) => event.output.filesSkipped,
|
|
2915
|
+
rolePasswordsSet: ({ event }) => event.output.rolePasswordsSet,
|
|
2916
|
+
idempotentPreEndTime: () => Date.now()
|
|
2917
|
+
})
|
|
2918
|
+
},
|
|
2919
|
+
onError: {
|
|
2920
|
+
target: "failed",
|
|
2921
|
+
actions: assign({
|
|
2922
|
+
error: ({ event }) => event.error instanceof Error ? event.error.message : "Idempotent schemas (pre) failed",
|
|
2923
|
+
idempotentPreEndTime: () => Date.now()
|
|
2924
|
+
})
|
|
2925
|
+
}
|
|
2926
|
+
}
|
|
2927
|
+
},
|
|
2928
|
+
applyingPgSchemaDiff: {
|
|
2929
|
+
meta: { e2e: e2eMeta.applyingPgSchemaDiff },
|
|
2930
|
+
entry: assign({ pgSchemaDiffStartTime: () => Date.now() }),
|
|
2931
|
+
invoke: {
|
|
2932
|
+
src: "applyPgSchemaDiff",
|
|
2933
|
+
input: ({ context }) => ({
|
|
2934
|
+
input: context.input,
|
|
2935
|
+
targetDir: context.targetDir,
|
|
2936
|
+
preIdempotentTargetFingerprint: context.preIdempotentTargetFingerprint
|
|
2937
|
+
}),
|
|
2938
|
+
onDone: [
|
|
2939
|
+
// In check mode, skip 2nd pass idempotent and seeds, go directly to done
|
|
2940
|
+
{
|
|
2941
|
+
guard: ({ context }) => context.input.check === true,
|
|
2942
|
+
target: "done",
|
|
2943
|
+
actions: "assignPgSchemaDiffResult"
|
|
2944
|
+
},
|
|
2945
|
+
// Normal mode: always run 2nd pass to apply post-declarative grants/revokes.
|
|
2946
|
+
// DO-block guards (RAISE NOTICE + RETURN) allow 1st-pass files to succeed
|
|
2947
|
+
// without applying REVOKEs, so we cannot rely on idempotentPreSkipped to
|
|
2948
|
+
// decide whether the 2nd pass is needed.
|
|
2949
|
+
{
|
|
2950
|
+
target: "applyingIdempotentPost",
|
|
2951
|
+
actions: "assignPgSchemaDiffResult"
|
|
2952
|
+
}
|
|
2953
|
+
],
|
|
2954
|
+
onError: {
|
|
2955
|
+
target: "failed",
|
|
2956
|
+
actions: assign({
|
|
2957
|
+
error: ({ event }) => event.error instanceof Error ? event.error.message : "pg-schema-diff failed",
|
|
2958
|
+
pgSchemaDiffEndTime: () => Date.now()
|
|
2959
|
+
})
|
|
2960
|
+
}
|
|
2961
|
+
}
|
|
2962
|
+
},
|
|
2963
|
+
applyingIdempotentPost: {
|
|
2964
|
+
meta: { e2e: e2eMeta.applyingIdempotentPost },
|
|
2965
|
+
entry: assign({ idempotentPostStartTime: () => Date.now() }),
|
|
2966
|
+
invoke: {
|
|
2967
|
+
src: "applyIdempotentSchemas",
|
|
2968
|
+
input: ({ context }) => ({
|
|
2969
|
+
input: context.input,
|
|
2970
|
+
targetDir: context.targetDir,
|
|
2971
|
+
pass: "post"
|
|
2972
|
+
}),
|
|
2973
|
+
onDone: {
|
|
2974
|
+
target: "validatingPartitions",
|
|
2975
|
+
actions: assign({
|
|
2976
|
+
idempotentPostApplied: ({ event }) => event.output.filesApplied,
|
|
2977
|
+
idempotentPostSkipped: ({ event }) => event.output.filesSkipped,
|
|
2978
|
+
idempotentPostEndTime: () => Date.now()
|
|
2979
|
+
})
|
|
2980
|
+
},
|
|
2981
|
+
onError: {
|
|
2982
|
+
target: "failed",
|
|
2983
|
+
actions: assign({
|
|
2984
|
+
error: ({ event }) => event.error instanceof Error ? event.error.message : "Idempotent schemas (post) failed",
|
|
2985
|
+
idempotentPostEndTime: () => Date.now()
|
|
2986
|
+
})
|
|
2987
|
+
}
|
|
2988
|
+
}
|
|
2989
|
+
},
|
|
2990
|
+
validatingPartitions: {
|
|
2991
|
+
meta: { e2e: e2eMeta.validatingPartitions },
|
|
2992
|
+
invoke: {
|
|
2993
|
+
src: "validatePartitions",
|
|
2994
|
+
input: ({ context }) => ({ input: context.input, targetDir: context.targetDir }),
|
|
2995
|
+
onDone: {
|
|
2996
|
+
target: "releasingLock",
|
|
2997
|
+
actions: assign({
|
|
2998
|
+
partitionWarnings: ({ event }) => event.output.warnings
|
|
2999
|
+
})
|
|
3000
|
+
},
|
|
3001
|
+
onError: {
|
|
3002
|
+
// Non-blocking: skip on error, continue to lock release
|
|
3003
|
+
target: "releasingLock",
|
|
3004
|
+
actions: assign({
|
|
3005
|
+
partitionWarnings: ({ context, event }) => [
|
|
3006
|
+
...context.partitionWarnings,
|
|
3007
|
+
`Partition validation failed: ${event.error instanceof Error ? event.error.message : "Unknown error"}`
|
|
3008
|
+
]
|
|
3009
|
+
})
|
|
3010
|
+
}
|
|
3011
|
+
}
|
|
3012
|
+
},
|
|
3013
|
+
releasingLock: {
|
|
3014
|
+
meta: { e2e: e2eMeta.releasingLock },
|
|
3015
|
+
invoke: {
|
|
3016
|
+
src: "releaseLock",
|
|
3017
|
+
input: ({ context }) => ({ input: context.input }),
|
|
3018
|
+
onDone: {
|
|
3019
|
+
target: "applyingSeeds",
|
|
3020
|
+
actions: assign({ lockAcquired: false })
|
|
3021
|
+
},
|
|
3022
|
+
onError: {
|
|
3023
|
+
// Non-blocking: continue to seeds even if lock release fails
|
|
3024
|
+
target: "applyingSeeds",
|
|
3025
|
+
actions: assign({ lockAcquired: false })
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
3028
|
+
},
|
|
3029
|
+
applyingSeeds: {
|
|
3030
|
+
meta: { e2e: e2eMeta.applyingSeeds },
|
|
3031
|
+
entry: assign({ seedStartTime: () => Date.now() }),
|
|
3032
|
+
invoke: {
|
|
3033
|
+
src: "applySeeds",
|
|
3034
|
+
input: ({ context }) => ({ input: context.input, targetDir: context.targetDir }),
|
|
3035
|
+
onDone: {
|
|
3036
|
+
target: "done",
|
|
3037
|
+
actions: assign({
|
|
3038
|
+
seedsApplied: ({ event }) => event.output.applied,
|
|
3039
|
+
nonCriticalWarnings: ({ context, event }) => [
|
|
3040
|
+
...context.nonCriticalWarnings,
|
|
3041
|
+
...event.output.warnings.map(
|
|
3042
|
+
(warning) => createMachineWarning("TABLES_MANIFEST_WARNING", warning, "applyingSeeds")
|
|
3043
|
+
)
|
|
3044
|
+
],
|
|
3045
|
+
seedEndTime: () => Date.now()
|
|
3046
|
+
})
|
|
3047
|
+
},
|
|
3048
|
+
onError: {
|
|
3049
|
+
// Seeds are non-blocking - continue to done
|
|
3050
|
+
target: "done",
|
|
3051
|
+
actions: assign({
|
|
3052
|
+
seedsApplied: false,
|
|
3053
|
+
seedEndTime: () => Date.now()
|
|
3054
|
+
})
|
|
3055
|
+
}
|
|
3056
|
+
}
|
|
3057
|
+
},
|
|
3058
|
+
done: {
|
|
3059
|
+
meta: { e2e: e2eMeta.done },
|
|
3060
|
+
type: "final"
|
|
3061
|
+
},
|
|
3062
|
+
failed: {
|
|
3063
|
+
meta: { e2e: e2eMeta.failed },
|
|
3064
|
+
type: "final",
|
|
3065
|
+
entry: "releaseAdvisoryLockOnFailure"
|
|
3066
|
+
}
|
|
3067
|
+
},
|
|
3068
|
+
output: ({ context }) => createDbApplyOutput(context, Date.now())
|
|
3069
|
+
});
|
|
3070
|
+
function getDbApplyStateName(snapshot) {
|
|
3071
|
+
return snapshot.value;
|
|
3072
|
+
}
|
|
3073
|
+
function isDbApplyComplete(snapshot) {
|
|
3074
|
+
return snapshot.status === "done";
|
|
3075
|
+
}
|
|
3076
|
+
|
|
3077
|
+
// src/commands/db/commands/db-apply-error.ts
|
|
3078
|
+
init_esm_shims();
|
|
3079
|
+
|
|
3080
|
+
// src/commands/db/commands/db-sync/error-classifier.ts
|
|
3081
|
+
init_esm_shims();
|
|
3082
|
+
|
|
3083
|
+
// src/utils/type-guards.ts
|
|
3084
|
+
init_esm_shims();
|
|
3085
|
+
function isExecaError(error) {
|
|
3086
|
+
return error instanceof Error && ("stdout" in error || "stderr" in error || "exitCode" in error);
|
|
3087
|
+
}
|
|
3088
|
+
|
|
3089
|
+
// src/commands/db/commands/db-sync/error-classifier.ts
|
|
3090
|
+
var DNS_RESOLUTION_PATTERNS = [
|
|
3091
|
+
"could not translate host name",
|
|
3092
|
+
"could not resolve host",
|
|
3093
|
+
"name or service not known",
|
|
3094
|
+
"nodename nor servname provided",
|
|
3095
|
+
"temporary failure in name resolution"
|
|
3096
|
+
];
|
|
3097
|
+
var CONNECTION_PATTERNS = [
|
|
3098
|
+
"econnrefused",
|
|
3099
|
+
"connection refused",
|
|
3100
|
+
"could not connect to server",
|
|
3101
|
+
"connection timed out",
|
|
3102
|
+
"timeout expired",
|
|
3103
|
+
"no pg_hba.conf entry",
|
|
3104
|
+
"the database system is starting up",
|
|
3105
|
+
"the database system is shutting down"
|
|
3106
|
+
];
|
|
3107
|
+
var FALLBACK_SUGGESTIONS = [
|
|
3108
|
+
"Check database connectivity",
|
|
3109
|
+
"Verify psql is installed and accessible",
|
|
3110
|
+
"Ensure packages/database exists"
|
|
3111
|
+
];
|
|
3112
|
+
function getEnvironmentLabel(environment) {
|
|
3113
|
+
switch (environment) {
|
|
3114
|
+
case "production":
|
|
3115
|
+
return "production";
|
|
3116
|
+
case "main":
|
|
3117
|
+
return "main";
|
|
3118
|
+
case "preview":
|
|
3119
|
+
return "preview";
|
|
3120
|
+
case "local":
|
|
3121
|
+
return "local";
|
|
3122
|
+
}
|
|
3123
|
+
}
|
|
3124
|
+
function buildCombinedMessage(error) {
|
|
3125
|
+
if (isExecaError(error)) {
|
|
3126
|
+
return [error.message, error.stderr, error.stdout].filter(Boolean).join("\n");
|
|
3127
|
+
}
|
|
3128
|
+
if (error instanceof Error) {
|
|
3129
|
+
return error.message;
|
|
3130
|
+
}
|
|
3131
|
+
return String(error);
|
|
3132
|
+
}
|
|
3133
|
+
function findRelevantLine(message, patterns) {
|
|
3134
|
+
const lines = message.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
|
|
3135
|
+
const matched = lines.find((line) => {
|
|
3136
|
+
const lower = line.toLowerCase();
|
|
3137
|
+
return patterns.some((pattern) => lower.includes(pattern));
|
|
3138
|
+
});
|
|
3139
|
+
return matched ?? null;
|
|
3140
|
+
}
|
|
3141
|
+
function classifyDbSyncCommandFailure(error, environment) {
|
|
3142
|
+
const message = buildCombinedMessage(error);
|
|
3143
|
+
const messageLower = message.toLowerCase();
|
|
3144
|
+
const environmentLabel = getEnvironmentLabel(environment);
|
|
3145
|
+
if (DNS_RESOLUTION_PATTERNS.some((pattern) => messageLower.includes(pattern))) {
|
|
3146
|
+
const detail = findRelevantLine(message, DNS_RESOLUTION_PATTERNS);
|
|
3147
|
+
return {
|
|
3148
|
+
code: "DB_HOST_RESOLUTION_FAILED",
|
|
3149
|
+
message: detail ? `Could not resolve the ${environmentLabel} database host: ${detail}` : `Could not resolve the ${environmentLabel} database host`,
|
|
3150
|
+
suggestions: [
|
|
3151
|
+
"Verify the database host in DATABASE_URL / DATABASE_URL_ADMIN",
|
|
3152
|
+
"Check DNS resolution and outbound network access to the database host",
|
|
3153
|
+
"Re-run from an environment that can reach the target database"
|
|
3154
|
+
]
|
|
3155
|
+
};
|
|
3156
|
+
}
|
|
3157
|
+
if (CONNECTION_PATTERNS.some((pattern) => messageLower.includes(pattern))) {
|
|
3158
|
+
const detail = findRelevantLine(message, CONNECTION_PATTERNS);
|
|
3159
|
+
return {
|
|
3160
|
+
code: "DB_CONNECTION_FAILED",
|
|
3161
|
+
message: detail ? `Could not connect to the ${environmentLabel} database: ${detail}` : `Could not connect to the ${environmentLabel} database`,
|
|
3162
|
+
suggestions: [
|
|
3163
|
+
"Verify the database is reachable from this environment",
|
|
3164
|
+
"Check DATABASE_URL / DATABASE_URL_ADMIN credentials and firewall settings",
|
|
3165
|
+
"Retry after confirming the target database is accepting connections"
|
|
3166
|
+
]
|
|
3167
|
+
};
|
|
3168
|
+
}
|
|
3169
|
+
return null;
|
|
3170
|
+
}
|
|
3171
|
+
function getDbSyncFallbackSuggestions() {
|
|
3172
|
+
return [...FALLBACK_SUGGESTIONS];
|
|
3173
|
+
}
|
|
3174
|
+
|
|
3175
|
+
// src/commands/db/commands/db-apply-error.ts
|
|
3176
|
+
var DEFAULT_DB_APPLY_SUGGESTIONS = [
|
|
3177
|
+
"Check the error message above",
|
|
3178
|
+
"Verify DATABASE_URL / DATABASE_URL_ADMIN is correct",
|
|
3179
|
+
"Check pg-schema-diff output for details"
|
|
3180
|
+
];
|
|
3181
|
+
function buildDbApplySuggestions(environment, suggestions, options) {
|
|
3182
|
+
const merged = [...suggestions];
|
|
3183
|
+
if (environment !== "local") {
|
|
3184
|
+
merged.push(
|
|
3185
|
+
"db apply prefers DATABASE_URL_ADMIN for DDL; verify that admin host resolves and targets the intended database"
|
|
3186
|
+
);
|
|
3187
|
+
}
|
|
3188
|
+
if (environment !== "local" && options.previewProfile === "full") {
|
|
3189
|
+
merged.push(
|
|
3190
|
+
"Full preview may need a writable pg-schema-diff temp DB. Set `PG_SCHEMA_DIFF_TEMP_DB_DSN` to a Postgres instance you can bootstrap (CI: postgres service container, local: local Supabase)."
|
|
3191
|
+
);
|
|
3192
|
+
}
|
|
3193
|
+
if (options.fromPlan) {
|
|
3194
|
+
merged.push(`Re-run \`runa db plan ${environment}\` to generate a fresh checked plan artifact`);
|
|
3195
|
+
}
|
|
3196
|
+
return [...new Set(merged)];
|
|
3197
|
+
}
|
|
3198
|
+
function buildDbApplyCliError(errorMessage, environment, options = {}) {
|
|
3199
|
+
const cause = new Error(errorMessage);
|
|
3200
|
+
const classified = classifyDbSyncCommandFailure(cause, environment);
|
|
3201
|
+
if (classified) {
|
|
3202
|
+
return new CLIError(
|
|
3203
|
+
classified.message,
|
|
3204
|
+
classified.code,
|
|
3205
|
+
buildDbApplySuggestions(environment, classified.suggestions, options),
|
|
3206
|
+
cause
|
|
3207
|
+
);
|
|
3208
|
+
}
|
|
3209
|
+
return new CLIError(
|
|
3210
|
+
errorMessage,
|
|
3211
|
+
"DB_APPLY_FAILED",
|
|
3212
|
+
buildDbApplySuggestions(environment, DEFAULT_DB_APPLY_SUGGESTIONS, options),
|
|
3213
|
+
cause
|
|
3214
|
+
);
|
|
3215
|
+
}
|
|
3216
|
+
|
|
3217
|
+
// src/commands/db/commands/db-preview-profile.ts
|
|
3218
|
+
init_esm_shims();
|
|
3219
|
+
var DEFAULT_DB_PREVIEW_PROFILE = "compare-only";
|
|
3220
|
+
var DbPreviewEnvironmentSchema = z.enum(["local", "preview", "production"]);
|
|
3221
|
+
var LEGACY_DB_APPLY_CHECK_REMOVAL_TARGET = "the next major CLI release";
|
|
3222
|
+
function parseDbPreviewProfile(value) {
|
|
3223
|
+
return DbPreviewProfileSchema.parse(value);
|
|
3224
|
+
}
|
|
3225
|
+
function resolveDbPreviewEnvironment(value) {
|
|
3226
|
+
const parsed = DbPreviewEnvironmentSchema.safeParse(value.trim());
|
|
3227
|
+
return parsed.success ? parsed.data : null;
|
|
3228
|
+
}
|
|
3229
|
+
function resolveDbPreviewProfile(params) {
|
|
3230
|
+
if (params.check !== true) {
|
|
3231
|
+
return null;
|
|
3232
|
+
}
|
|
3233
|
+
return params.compareOnly === true ? "compare-only" : "full";
|
|
3234
|
+
}
|
|
3235
|
+
function isCompareOnlyPreviewProfile(profile) {
|
|
3236
|
+
return profile === "compare-only";
|
|
3237
|
+
}
|
|
3238
|
+
function getDbPreviewModeLabel(profile) {
|
|
3239
|
+
return profile === "compare-only" ? "compare-only preview (read-only)" : "full preview (read-only)";
|
|
3240
|
+
}
|
|
3241
|
+
function buildDbPreviewCommandLabel(env, profile) {
|
|
3242
|
+
return `runa db preview ${env} --profile ${profile}`;
|
|
3243
|
+
}
|
|
3244
|
+
function buildDbPlanCommandLabel(env) {
|
|
3245
|
+
return `runa db plan ${env}`;
|
|
3246
|
+
}
|
|
3247
|
+
function getDbPreviewIdempotentSchemaCount(result) {
|
|
3248
|
+
return result.idempotentFiles?.length ?? result.idempotentSchemasApplied;
|
|
3249
|
+
}
|
|
3250
|
+
function buildLegacyDbApplyCheckWarning(profile) {
|
|
3251
|
+
return `\`runa db apply --check\` is deprecated for preview workflows. Use \`runa db preview <env> --profile ${profile}\` instead. Legacy check flags will remain supported until ${LEGACY_DB_APPLY_CHECK_REMOVAL_TARGET}.`;
|
|
3252
|
+
}
|
|
3253
|
+
|
|
3254
|
+
// src/commands/db/commands/db-apply.ts
|
|
3255
|
+
function isCliErrorLike(error) {
|
|
3256
|
+
if (!(error instanceof Error)) {
|
|
3257
|
+
return false;
|
|
3258
|
+
}
|
|
3259
|
+
const candidate = error;
|
|
3260
|
+
return typeof candidate.code === "string" && Array.isArray(candidate.suggestions);
|
|
3261
|
+
}
|
|
3262
|
+
var PHASE_LABELS = {
|
|
3263
|
+
acquiringLock: "Acquire advisory lock",
|
|
3264
|
+
previewingIdempotent: "Preview idempotent schemas",
|
|
3265
|
+
applyingIdempotentPre: "Apply idempotent schemas (pre-pass)",
|
|
3266
|
+
applyingPgSchemaDiff: "Apply pg-schema-diff",
|
|
3267
|
+
applyingIdempotentPost: "Apply idempotent schemas (post-pass)",
|
|
3268
|
+
validatingPartitions: "Validate partitions",
|
|
3269
|
+
releasingLock: "Release advisory lock",
|
|
3270
|
+
applyingSeeds: "Apply seeds"
|
|
3271
|
+
};
|
|
3272
|
+
function isTrackedPhase(state) {
|
|
3273
|
+
return state in PHASE_LABELS;
|
|
3274
|
+
}
|
|
3275
|
+
function finalizeTrackedPhase(phases, current, status, options = {}) {
|
|
3276
|
+
if (!current) return;
|
|
3277
|
+
phases.push({
|
|
3278
|
+
id: current.id,
|
|
3279
|
+
label: current.label,
|
|
3280
|
+
status,
|
|
3281
|
+
startedAt: new Date(current.startedAtMs).toISOString(),
|
|
3282
|
+
endedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3283
|
+
durationMs: Math.max(0, Date.now() - current.startedAtMs),
|
|
3284
|
+
warningCount: options.warnings?.length,
|
|
3285
|
+
error: options.error,
|
|
3286
|
+
warnings: options.warnings && options.warnings.length > 0 ? options.warnings : void 0
|
|
3287
|
+
});
|
|
3288
|
+
}
|
|
3289
|
+
function buildDbApplyOutcome(params) {
|
|
3290
|
+
const warnings = [...params.result.outcome.warnings];
|
|
3291
|
+
const phases = [...params.phases];
|
|
3292
|
+
if (params.result.partitionWarnings && params.result.partitionWarnings.length > 0) {
|
|
3293
|
+
const warningList = params.result.partitionWarnings.map(
|
|
3294
|
+
(warning) => createMachineWarning("PARTITION_WARNING", warning, "validatingPartitions")
|
|
3295
|
+
);
|
|
3296
|
+
warnings.push(...warningList);
|
|
3297
|
+
const target = phases.find((phase) => phase.id === "validatingPartitions");
|
|
3298
|
+
if (target) {
|
|
3299
|
+
target.status = "warning";
|
|
3300
|
+
target.warningCount = warningList.length;
|
|
3301
|
+
target.warnings = warningList;
|
|
3302
|
+
}
|
|
3303
|
+
}
|
|
3304
|
+
const seedPhase = phases.find((phase) => phase.id === "applyingSeeds");
|
|
3305
|
+
if (seedPhase) {
|
|
3306
|
+
if (params.result.checkOnly || params.noSeed) {
|
|
3307
|
+
seedPhase.status = "skipped";
|
|
3308
|
+
} else if (!params.result.seedsApplied) {
|
|
3309
|
+
const seedWarning = createMachineWarning(
|
|
3310
|
+
"SEED_APPLY_WARNING",
|
|
3311
|
+
"Seed apply failed in non-blocking mode",
|
|
3312
|
+
"applyingSeeds"
|
|
3313
|
+
);
|
|
3314
|
+
warnings.push(seedWarning);
|
|
3315
|
+
seedPhase.status = "warning";
|
|
3316
|
+
seedPhase.warningCount = (seedPhase.warningCount ?? 0) + 1;
|
|
3317
|
+
seedPhase.warnings = [...seedPhase.warnings ?? [], seedWarning];
|
|
3318
|
+
}
|
|
3319
|
+
}
|
|
3320
|
+
for (const warning of params.result.outcome.warnings) {
|
|
3321
|
+
const target = phases.find((phase) => phase.id === warning.phase);
|
|
3322
|
+
if (!target) {
|
|
3323
|
+
continue;
|
|
3324
|
+
}
|
|
3325
|
+
if (target.status === "passed") {
|
|
3326
|
+
target.status = "warning";
|
|
3327
|
+
}
|
|
3328
|
+
target.warningCount = (target.warningCount ?? 0) + 1;
|
|
3329
|
+
target.warnings = [...target.warnings ?? [], warning];
|
|
3330
|
+
}
|
|
3331
|
+
const errors = phases.map((phase) => phase.error).filter((value) => value != null);
|
|
3332
|
+
return {
|
|
3333
|
+
command: params.command,
|
|
3334
|
+
exitMode: deriveCommandExitMode(phases),
|
|
3335
|
+
startedAt: new Date(params.startedAtMs).toISOString(),
|
|
3336
|
+
endedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3337
|
+
durationMs: Math.max(0, Date.now() - params.startedAtMs),
|
|
3338
|
+
phases,
|
|
3339
|
+
warnings,
|
|
3340
|
+
errors,
|
|
3341
|
+
summary: buildCommandOutcomeSummary(phases)
|
|
3342
|
+
};
|
|
3343
|
+
}
|
|
3344
|
+
function resolveNoSeed(env, seedFlag) {
|
|
3345
|
+
if (seedFlag === true) return false;
|
|
3346
|
+
if (seedFlag === false) return true;
|
|
3347
|
+
return env === "production";
|
|
3348
|
+
}
|
|
3349
|
+
function logStateChange(logger2, snapshot) {
|
|
3350
|
+
const state = getDbApplyStateName(snapshot);
|
|
3351
|
+
switch (state) {
|
|
3352
|
+
case "idle":
|
|
3353
|
+
logger2.info("Starting db apply...");
|
|
3354
|
+
break;
|
|
3355
|
+
case "applyingIdempotentPre":
|
|
3356
|
+
logger2.step("Applying idempotent schemas (extensions, cleanup, pre-pass)", 1);
|
|
3357
|
+
break;
|
|
3358
|
+
case "applyingIdempotentPost":
|
|
3359
|
+
logger2.step("Applying idempotent schemas (dependencies, post-pass)", 3);
|
|
3360
|
+
break;
|
|
3361
|
+
case "applyingPgSchemaDiff":
|
|
3362
|
+
logger2.step(
|
|
3363
|
+
"Running pg-schema-diff (current DB \u2192 desired state)",
|
|
3364
|
+
snapshot.context.input.check ? 1 : 2
|
|
3365
|
+
);
|
|
3366
|
+
break;
|
|
3367
|
+
case "applyingSeeds":
|
|
3368
|
+
logger2.step("Applying seeds", snapshot.context.input.check ? 2 : 3);
|
|
3369
|
+
break;
|
|
3370
|
+
}
|
|
3371
|
+
}
|
|
3372
|
+
function describeSeedStatus(env, _noSeed, seedFlag) {
|
|
3373
|
+
if (env === "production") {
|
|
3374
|
+
if (seedFlag === true) return "enabled (--seed)";
|
|
3375
|
+
if (seedFlag === false) return "disabled (--no-seed)";
|
|
3376
|
+
return "disabled (production default)";
|
|
3377
|
+
}
|
|
3378
|
+
if (seedFlag === false) return "disabled (--no-seed)";
|
|
3379
|
+
return "enabled";
|
|
3380
|
+
}
|
|
3381
|
+
function getDbApplyCommandLabel(env, previewProfile, fromPlan) {
|
|
3382
|
+
if (fromPlan) {
|
|
3383
|
+
return `runa db apply ${env} --from-plan ${fromPlan}`;
|
|
3384
|
+
}
|
|
3385
|
+
if (!previewProfile) {
|
|
3386
|
+
return `runa db apply ${env}`;
|
|
3387
|
+
}
|
|
3388
|
+
return previewProfile === "compare-only" ? `runa db apply ${env} --check --compare-only` : `runa db apply ${env} --check`;
|
|
3389
|
+
}
|
|
3390
|
+
async function runDbApply(env, options) {
|
|
3391
|
+
const logger2 = createCLILogger("db:apply");
|
|
3392
|
+
const resolvedEnv = env === "preview" ? "preview" : env === "production" ? "production" : "local";
|
|
3393
|
+
loadEnvFiles({ runaEnv: resolvedEnv, silent: true });
|
|
3394
|
+
const noSeed = resolveNoSeed(resolvedEnv, options.seed);
|
|
3395
|
+
const previewProfile = resolveDbPreviewProfile(options);
|
|
3396
|
+
const compareOnly = previewProfile === "compare-only";
|
|
3397
|
+
const fromPlan = options.fromPlan;
|
|
3398
|
+
if (fromPlan && options.check) {
|
|
3399
|
+
throw new CLIError(
|
|
3400
|
+
"`--from-plan` can only be used in apply mode",
|
|
3401
|
+
"DB_APPLY_FROM_PLAN_REQUIRES_APPLY_MODE",
|
|
3402
|
+
[
|
|
3403
|
+
"Remove `--check` when reusing a checked plan artifact",
|
|
3404
|
+
`Use \`runa db plan ${resolvedEnv}\` to generate a fresh checked plan artifact`
|
|
3405
|
+
]
|
|
3406
|
+
);
|
|
3407
|
+
}
|
|
3408
|
+
if (resolvedEnv === "production" && !noSeed) {
|
|
3409
|
+
logger2.warn("");
|
|
3410
|
+
logger2.warn("\u26A0\uFE0F WARNING: Seeds will be applied to PRODUCTION database");
|
|
3411
|
+
logger2.warn(" Seeds may contain DELETE/TRUNCATE statements that destroy real data.");
|
|
3412
|
+
logger2.warn(" Only use --seed with production if you are absolutely certain.");
|
|
3413
|
+
logger2.warn("");
|
|
3414
|
+
}
|
|
3415
|
+
const input = {
|
|
3416
|
+
env: resolvedEnv,
|
|
3417
|
+
verbose: options.verbose ?? false,
|
|
3418
|
+
noSeed,
|
|
3419
|
+
autoApprove: options.autoApprove ?? false,
|
|
3420
|
+
allowDataLoss: options.allowDataLoss ?? false,
|
|
3421
|
+
confirmAuthzUpdate: options.confirmAuthzUpdate ?? false,
|
|
3422
|
+
check: options.check ?? false,
|
|
3423
|
+
strict: options.strict ?? false,
|
|
3424
|
+
skipDataCheck: options.skipDataCheck ?? false,
|
|
3425
|
+
compareOnly,
|
|
3426
|
+
databaseUrl: options.databaseUrl,
|
|
3427
|
+
maxLockWaitMs: options.maxLockWaitMs ?? 3e4,
|
|
3428
|
+
freshDbCheckSql: options.freshDbCheckSql,
|
|
3429
|
+
plannerArtifactPath: fromPlan,
|
|
3430
|
+
plannerArtifactRequired: Boolean(fromPlan)
|
|
3431
|
+
};
|
|
3432
|
+
const result = await new Promise((resolve, reject) => {
|
|
3433
|
+
const startedAtMs = Date.now();
|
|
3434
|
+
const actor = createActor(dbApplyMachine, {
|
|
3435
|
+
input: { input, targetDir: process.cwd() }
|
|
3436
|
+
});
|
|
3437
|
+
let previousState = "";
|
|
3438
|
+
let activePhase = null;
|
|
3439
|
+
const completedPhases = [];
|
|
3440
|
+
actor.subscribe((snapshot) => {
|
|
3441
|
+
const currentState = getDbApplyStateName(snapshot);
|
|
3442
|
+
if (currentState !== previousState) {
|
|
3443
|
+
if (activePhase && currentState !== "done" && currentState !== "failed") {
|
|
3444
|
+
finalizeTrackedPhase(completedPhases, activePhase, "passed");
|
|
3445
|
+
activePhase = null;
|
|
3446
|
+
}
|
|
3447
|
+
if (isTrackedPhase(currentState)) {
|
|
3448
|
+
activePhase = {
|
|
3449
|
+
id: currentState,
|
|
3450
|
+
label: PHASE_LABELS[currentState],
|
|
3451
|
+
startedAtMs: Date.now()
|
|
3452
|
+
};
|
|
3453
|
+
}
|
|
3454
|
+
logStateChange(logger2, snapshot);
|
|
3455
|
+
previousState = currentState;
|
|
3456
|
+
}
|
|
3457
|
+
if (isDbApplyComplete(snapshot)) {
|
|
3458
|
+
const output = snapshot.output;
|
|
3459
|
+
if (output) {
|
|
3460
|
+
if (activePhase) {
|
|
3461
|
+
const failureMetadata = output.error ? classifyDbApplyFailure(output.error) : null;
|
|
3462
|
+
finalizeTrackedPhase(
|
|
3463
|
+
completedPhases,
|
|
3464
|
+
activePhase,
|
|
3465
|
+
output.error ? failureMetadata?.retryable ? "timeout" : "failed" : "passed",
|
|
3466
|
+
output.error ? {
|
|
3467
|
+
error: {
|
|
3468
|
+
code: failureMetadata?.code ?? "DB_APPLY_FAILED",
|
|
3469
|
+
message: output.error,
|
|
3470
|
+
retryable: failureMetadata?.retryable ?? false,
|
|
3471
|
+
phase: activePhase.id
|
|
3472
|
+
}
|
|
3473
|
+
} : {}
|
|
3474
|
+
);
|
|
3475
|
+
activePhase = null;
|
|
3476
|
+
}
|
|
3477
|
+
resolve({
|
|
3478
|
+
...output,
|
|
3479
|
+
previewProfile: output.previewProfile ?? previewProfile ?? void 0,
|
|
3480
|
+
outcome: buildDbApplyOutcome({
|
|
3481
|
+
command: options.commandLabel ?? getDbApplyCommandLabel(resolvedEnv, previewProfile, fromPlan),
|
|
3482
|
+
startedAtMs,
|
|
3483
|
+
phases: completedPhases,
|
|
3484
|
+
result: output,
|
|
3485
|
+
noSeed
|
|
3486
|
+
})
|
|
3487
|
+
});
|
|
3488
|
+
}
|
|
3489
|
+
actor.stop();
|
|
3490
|
+
}
|
|
3491
|
+
});
|
|
3492
|
+
actor.subscribe({
|
|
3493
|
+
error: (error) => {
|
|
3494
|
+
reject(error);
|
|
3495
|
+
actor.stop();
|
|
3496
|
+
}
|
|
3497
|
+
});
|
|
3498
|
+
actor.start();
|
|
3499
|
+
actor.send({ type: "START" });
|
|
3500
|
+
});
|
|
3501
|
+
if (!result.error && options.persistPlanArtifact === true) {
|
|
3502
|
+
try {
|
|
3503
|
+
result.planner = persistDbPlanArtifact({
|
|
3504
|
+
repoRoot: process.cwd(),
|
|
3505
|
+
input,
|
|
3506
|
+
output: result
|
|
3507
|
+
});
|
|
3508
|
+
} catch (error) {
|
|
3509
|
+
logger2.warn(
|
|
3510
|
+
`Planner artifact was not persisted: ${error instanceof Error ? error.message : "unknown error"}`
|
|
3511
|
+
);
|
|
3512
|
+
}
|
|
3513
|
+
}
|
|
3514
|
+
return result;
|
|
3515
|
+
}
|
|
3516
|
+
function printCheckSummary(logger2, result) {
|
|
3517
|
+
logger2.success("db apply check complete!");
|
|
3518
|
+
logger2.info("\n\u{1F4CA} Check Summary (Dry-Run):");
|
|
3519
|
+
logger2.info(` Result: ${result.outcome.exitMode}`);
|
|
3520
|
+
if (result.previewProfile) {
|
|
3521
|
+
logger2.info(` \u2713 Preview profile: ${result.previewProfile}`);
|
|
3522
|
+
}
|
|
3523
|
+
logger2.info(` \u2713 Idempotent schemas: ${getDbPreviewIdempotentSchemaCount(result)} files`);
|
|
3524
|
+
logger2.info(` \u2713 Schema changes: ${result.planSql ? "detected" : "none"}`);
|
|
3525
|
+
if (result.planSummary) {
|
|
3526
|
+
logger2.info(
|
|
3527
|
+
` \u2713 Plan summary: ${result.planSummary.rawStatements} raw / ${result.planSummary.effectiveStatements} structural / ${result.planSummary.noiseStatements} collapsed noise`
|
|
3528
|
+
);
|
|
3529
|
+
}
|
|
3530
|
+
if (result.hazards.length > 0) logger2.info(` \u26A0 Hazards: ${result.hazards.join(", ")}`);
|
|
3531
|
+
logger2.info(" \u2139 No changes were applied (check mode)");
|
|
3532
|
+
}
|
|
3533
|
+
function buildMetricsString(metrics) {
|
|
3534
|
+
const parts = [];
|
|
3535
|
+
if (metrics.idempotentMs) parts.push(`idempotent=${formatDuration(metrics.idempotentMs)}`);
|
|
3536
|
+
if (metrics.applyMs) parts.push(`apply=${formatDuration(metrics.applyMs)}`);
|
|
3537
|
+
if (metrics.seedMs) parts.push(`seed=${formatDuration(metrics.seedMs)}`);
|
|
3538
|
+
if (metrics.retryAttempts) parts.push(`retries=${metrics.retryAttempts}`);
|
|
3539
|
+
return ` \u23F1 Total: ${formatDuration(metrics.totalMs)} (${parts.join(", ")})`;
|
|
3540
|
+
}
|
|
3541
|
+
function printApplySummary(logger2, result) {
|
|
3542
|
+
logger2.success("db apply complete!");
|
|
3543
|
+
logger2.info("\n\u{1F4CA} Summary (SQL-First Architecture):");
|
|
3544
|
+
logger2.info(` Result: ${result.outcome.exitMode}`);
|
|
3545
|
+
logger2.info(` \u2713 Idempotent schemas: ${result.idempotentSchemasApplied} files`);
|
|
3546
|
+
if (result.rolePasswordsSet && result.rolePasswordsSet > 0) {
|
|
3547
|
+
logger2.info(` \u2713 RBAC role passwords: ${result.rolePasswordsSet} set`);
|
|
3548
|
+
}
|
|
3549
|
+
logger2.info(` \u2713 Schema changes: ${result.schemaChangesApplied ? "applied" : "no changes"}`);
|
|
3550
|
+
if (result.hazards.length > 0) logger2.info(` \u26A0 Hazards: ${result.hazards.join(", ")}`);
|
|
3551
|
+
logger2.info(` \u2713 Seeds: ${result.seedsApplied ? "applied" : "skipped"}`);
|
|
3552
|
+
if (result.outcome.summary.warnings > 0) {
|
|
3553
|
+
logger2.info(` \u26A0 Warnings: ${result.outcome.summary.warnings}`);
|
|
3554
|
+
}
|
|
3555
|
+
if (result.metrics) logger2.info(buildMetricsString(result.metrics));
|
|
3556
|
+
if (result.outcome.warnings.length > 0) {
|
|
3557
|
+
logger2.warn("\nNon-critical warnings:");
|
|
3558
|
+
for (const warning of result.outcome.warnings) {
|
|
3559
|
+
logger2.info(` \u2022 [${warning.phase}] ${warning.message}`);
|
|
3560
|
+
}
|
|
3561
|
+
}
|
|
3562
|
+
}
|
|
3563
|
+
function printSummary(logger2, result) {
|
|
3564
|
+
if (result.error) {
|
|
3565
|
+
logger2.error(`db apply failed: ${result.error}`);
|
|
3566
|
+
logger2.info(` Result: ${result.outcome.exitMode}`);
|
|
3567
|
+
const failedPhase = result.outcome.phases.find(
|
|
3568
|
+
(phase) => phase.status === "failed" || phase.status === "timeout"
|
|
3569
|
+
);
|
|
3570
|
+
if (failedPhase) {
|
|
3571
|
+
logger2.info(` Failed phase: ${failedPhase.id}`);
|
|
3572
|
+
}
|
|
3573
|
+
return;
|
|
3574
|
+
}
|
|
3575
|
+
if (result.checkOnly) {
|
|
3576
|
+
printCheckSummary(logger2, result);
|
|
3577
|
+
} else {
|
|
3578
|
+
printApplySummary(logger2, result);
|
|
3579
|
+
}
|
|
3580
|
+
}
|
|
3581
|
+
var applyCommand = new Command("apply").description("Apply schema changes to any DB using pg-schema-diff (SQL-First Architecture)").argument("[env]", "Environment: local, preview, or production", "preview").option("--verbose", "Show detailed output").option("--seed", "Explicitly enable seed application (required for production)").option("--no-seed", "Skip seed application").option("--auto-approve", "Auto-approve hazards (CI mode)").option("--allow-data-loss", "Allow DELETES_DATA hazard in production (DANGEROUS)").option("--confirm-authz-update", "Confirm RLS policy changes in production").option("--check", "Check mode: show what would be applied without making changes (dry-run)").option(
|
|
3582
|
+
"--compare-only",
|
|
3583
|
+
"Lightweight compare-only dry-run: skip idempotent preview and data validation"
|
|
3584
|
+
).option(
|
|
3585
|
+
"--strict",
|
|
3586
|
+
"Treat declarative closure warnings as blockers when they are not reviewed in boundary policy"
|
|
3587
|
+
).option("--skip-data-check", "Skip pre-apply data compatibility validation").option("--database-url <url>", "Override DATABASE_URL").option("--from-plan <path>", "Reuse a checked plan artifact generated by `runa db plan`").option(
|
|
3588
|
+
"--max-lock-wait-ms <ms>",
|
|
3589
|
+
"Maximum time to wait for lock acquisition (default: 30000)",
|
|
3590
|
+
(value) => Number.parseInt(value, 10)
|
|
3591
|
+
).option("--fresh-db-check-sql <sql>", "Custom SQL to check if DB is fresh").action(async (env, options) => {
|
|
3592
|
+
const logger2 = createCLILogger("db:apply");
|
|
3593
|
+
const resolvedEnv = env === "preview" ? "preview" : env === "production" ? "production" : "local";
|
|
3594
|
+
loadEnvFiles({ runaEnv: resolvedEnv, silent: true });
|
|
3595
|
+
try {
|
|
3596
|
+
const noSeed = resolveNoSeed(env, options.seed);
|
|
3597
|
+
const previewProfile = resolveDbPreviewProfile(options);
|
|
3598
|
+
const connectionSource = options.databaseUrl ? "--database-url" : resolveDatabaseTarget(resolvedEnv).source;
|
|
3599
|
+
logger2.section(
|
|
3600
|
+
options.check ? "Database Apply Check (Dry-Run)" : "Database Apply (SQL-First Architecture)"
|
|
3601
|
+
);
|
|
3602
|
+
logger2.info(`Environment: ${env}`);
|
|
3603
|
+
logger2.info(`Mode: ${previewProfile ? getDbPreviewModeLabel(previewProfile) : "apply"}`);
|
|
3604
|
+
logger2.info(`Strict: ${options.strict ? "enabled" : "disabled"}`);
|
|
3605
|
+
logger2.info(`Seeds: ${describeSeedStatus(env, noSeed, options.seed)}`);
|
|
3606
|
+
logger2.info(`Auto-approve: ${options.autoApprove ? "enabled" : "disabled"}`);
|
|
3607
|
+
logger2.info(`Connection source: ${connectionSource}`);
|
|
3608
|
+
if (options.fromPlan) {
|
|
3609
|
+
logger2.info(`Plan artifact: ${options.fromPlan}`);
|
|
3610
|
+
}
|
|
3611
|
+
if (previewProfile && process.env.RUNA_OUTPUT_FORMAT !== "json") {
|
|
3612
|
+
logger2.warn(buildLegacyDbApplyCheckWarning(previewProfile));
|
|
3613
|
+
}
|
|
3614
|
+
const result = await runDbApply(env, options);
|
|
3615
|
+
printSummary(logger2, result);
|
|
3616
|
+
emitJsonSuccess(applyCommand, DbApplyOutputSchema, result);
|
|
3617
|
+
if (result.error) {
|
|
3618
|
+
throw buildDbApplyCliError(result.error, resolvedEnv, {
|
|
3619
|
+
previewProfile,
|
|
3620
|
+
fromPlan: Boolean(options.fromPlan)
|
|
3621
|
+
});
|
|
3622
|
+
}
|
|
3623
|
+
} catch (error) {
|
|
3624
|
+
if (error instanceof CLIError || isCliErrorLike(error)) {
|
|
3625
|
+
throw error;
|
|
3626
|
+
}
|
|
3627
|
+
throw new CLIError(
|
|
3628
|
+
"db apply failed with unknown error",
|
|
3629
|
+
"DB_APPLY_UNKNOWN_ERROR",
|
|
3630
|
+
["Check the error output above", "Verify database connection"],
|
|
3631
|
+
error instanceof Error ? error : void 0
|
|
3632
|
+
);
|
|
3633
|
+
}
|
|
3634
|
+
});
|
|
3635
|
+
|
|
3636
|
+
export { DEFAULT_DB_PREVIEW_PROFILE, applyCommand, assertBoundaryPolicyQualityGate, assertBoundaryPolicyUsable, assessPlanSize, buildDbApplyCliError, buildDbPlanCommandLabel, buildDbPreviewCommandLabel, buildDeclarativeDependencyWarningFailureLines, classifyDbSyncCommandFailure, findDeclarativeRiskAllowlistMatch, findDirectoryPlacementAllowlistMatch, formatAllowlistMetadata, formatPlanSizeSummary, getBoundaryPolicy, getDbPreviewIdempotentSchemaCount, getDbPreviewModeLabel, getDbSyncFallbackSuggestions, isCompareOnlyPreviewProfile, isExecaError, logDeclarativeDependencyWarnings, parseDbPreviewProfile, resolveDbPreviewEnvironment, resolveProductionApplyStrictMode, reviewDeclarativeDependencyWarnings, runDbApply };
|