@supacloud/cli 0.29.0 → 0.31.0
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/README.md +57 -0
- package/dist/index.js +1057 -61
- package/package.json +1 -1
- package/skills/supacloud-cli/references/command-map.md +2 -1
package/dist/index.js
CHANGED
|
@@ -6464,6 +6464,7 @@ var ACTION_POLICY = {
|
|
|
6464
6464
|
},
|
|
6465
6465
|
database: {
|
|
6466
6466
|
read: ["list_tables", "describe_columns", "list_indexes", "list_constraints", "list_extensions", "rls_status", "rls_policies", "list_auth_users", "get_auth_user", "connections", "stats", "slow_queries", "list_migrations", "migration_inventory", "project_url", "generate_types"],
|
|
6467
|
+
local: ["lint_migrations", "lint"],
|
|
6467
6468
|
write: ["query", "apply_migration", "push_migrations", "baseline_migrations", "create_table_rls"]
|
|
6468
6469
|
},
|
|
6469
6470
|
supabase: {
|
|
@@ -6471,8 +6472,8 @@ var ACTION_POLICY = {
|
|
|
6471
6472
|
write: ["push"]
|
|
6472
6473
|
},
|
|
6473
6474
|
auth: {
|
|
6474
|
-
read: ["list_users", "get_user", "list_providers", "get_provider", "supported_providers", "get_settings", "get_config"],
|
|
6475
|
-
write: ["generate_link", "configure_provider", "update_provider", "disable_provider", "wechat_mini", "wechat_open", "update_settings", "update_config"]
|
|
6475
|
+
read: ["list_users", "get_user", "list_providers", "get_provider", "supported_providers", "get_settings", "get_config", "get_oauth_server"],
|
|
6476
|
+
write: ["generate_link", "configure_provider", "update_provider", "disable_provider", "wechat_mini", "wechat_open", "update_settings", "update_config", "migrate_oauth_server"]
|
|
6476
6477
|
},
|
|
6477
6478
|
oauth_clients: {
|
|
6478
6479
|
read: ["list", "get"],
|
|
@@ -6491,7 +6492,7 @@ var ACTION_POLICY = {
|
|
|
6491
6492
|
mutations: { read: ["status"] },
|
|
6492
6493
|
release: {
|
|
6493
6494
|
read: ["logical_backup_list", "postgrest_status"],
|
|
6494
|
-
write: ["logical_backup_create", "logical_backup_restore", "postgrest_restart", "release_canary_fixture_stage_replay"]
|
|
6495
|
+
write: ["logical_backup_create", "logical_backup_restore", "postgrest_restart", "release_canary_fixture_stage_replay", "release_canary_fixture_disable_replay"]
|
|
6495
6496
|
},
|
|
6496
6497
|
secrets: { read: ["list"], write: ["upsert", "delete"] },
|
|
6497
6498
|
frontend: {
|
|
@@ -6986,12 +6987,643 @@ function releaseControlErrorResponse(payload) {
|
|
|
6986
6987
|
return { ...releaseControlResponse(payload), isError: true };
|
|
6987
6988
|
}
|
|
6988
6989
|
|
|
6990
|
+
// src/shared/tools/migration-risk.ts
|
|
6991
|
+
function startsEscapeString(sql, quoteIndex) {
|
|
6992
|
+
const prefixIndex = quoteIndex - 1;
|
|
6993
|
+
const prefix = sql[prefixIndex] || "";
|
|
6994
|
+
const preceding = sql[prefixIndex - 1] || "";
|
|
6995
|
+
return /[eE]/.test(prefix) && !/[A-Za-z0-9_$]/.test(preceding);
|
|
6996
|
+
}
|
|
6997
|
+
function dollarQuoteTagAt(sql, index) {
|
|
6998
|
+
const previous = sql[index - 1] || "";
|
|
6999
|
+
if (/[A-Za-z0-9_$]/.test(previous))
|
|
7000
|
+
return "";
|
|
7001
|
+
return sql.slice(index).match(/^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/)?.[0] || "";
|
|
7002
|
+
}
|
|
7003
|
+
function maskSingleQuotedString(sql, start) {
|
|
7004
|
+
const usesBackslashEscapes = startsEscapeString(sql, start);
|
|
7005
|
+
let end = start + 1;
|
|
7006
|
+
while (end < sql.length) {
|
|
7007
|
+
if (usesBackslashEscapes && sql[end] === "\\" && sql[end + 1]) {
|
|
7008
|
+
end += 2;
|
|
7009
|
+
} else if (sql[end] === "'" && sql[end + 1] === "'") {
|
|
7010
|
+
end += 2;
|
|
7011
|
+
} else if (sql[end] === "'") {
|
|
7012
|
+
end += 1;
|
|
7013
|
+
return { masked: " ".repeat(end - start), end };
|
|
7014
|
+
} else {
|
|
7015
|
+
end += 1;
|
|
7016
|
+
}
|
|
7017
|
+
}
|
|
7018
|
+
throw new Error("Unterminated SQL single-quoted string");
|
|
7019
|
+
}
|
|
7020
|
+
function maskDoubleQuotedIdentifier(sql, start) {
|
|
7021
|
+
let end = start + 1;
|
|
7022
|
+
while (end < sql.length) {
|
|
7023
|
+
if (sql[end] === '"' && sql[end + 1] === '"') {
|
|
7024
|
+
end += 2;
|
|
7025
|
+
} else if (sql[end] === '"') {
|
|
7026
|
+
end += 1;
|
|
7027
|
+
return { masked: `"${"_".repeat(Math.max(0, end - start - 2))}"`, end };
|
|
7028
|
+
} else {
|
|
7029
|
+
end += 1;
|
|
7030
|
+
}
|
|
7031
|
+
}
|
|
7032
|
+
throw new Error("Unterminated SQL double-quoted identifier");
|
|
7033
|
+
}
|
|
7034
|
+
function preserveDoubleQuotedIdentifier(sql, start) {
|
|
7035
|
+
const identifier = maskDoubleQuotedIdentifier(sql, start);
|
|
7036
|
+
return { masked: sql.slice(start, identifier.end), end: identifier.end };
|
|
7037
|
+
}
|
|
7038
|
+
function lineCommentEnd(sql, start) {
|
|
7039
|
+
const end = sql.indexOf(`
|
|
7040
|
+
`, start + 2);
|
|
7041
|
+
return end === -1 ? sql.length : end;
|
|
7042
|
+
}
|
|
7043
|
+
function blockCommentEnd(sql, start) {
|
|
7044
|
+
let depth = 1;
|
|
7045
|
+
let cursor = start + 2;
|
|
7046
|
+
while (cursor < sql.length && depth > 0) {
|
|
7047
|
+
if (sql.startsWith("/*", cursor)) {
|
|
7048
|
+
depth++;
|
|
7049
|
+
cursor += 2;
|
|
7050
|
+
} else if (sql.startsWith("*/", cursor)) {
|
|
7051
|
+
depth--;
|
|
7052
|
+
cursor += 2;
|
|
7053
|
+
} else {
|
|
7054
|
+
cursor++;
|
|
7055
|
+
}
|
|
7056
|
+
}
|
|
7057
|
+
if (depth > 0)
|
|
7058
|
+
throw new Error("Unterminated SQL block comment");
|
|
7059
|
+
return cursor;
|
|
7060
|
+
}
|
|
7061
|
+
function maskDollarQuotedString(sql, start) {
|
|
7062
|
+
const tag = dollarQuoteTagAt(sql, start);
|
|
7063
|
+
if (!tag)
|
|
7064
|
+
return null;
|
|
7065
|
+
const closingTag = sql.indexOf(tag, start + tag.length);
|
|
7066
|
+
if (closingTag === -1)
|
|
7067
|
+
throw new Error("Unterminated SQL dollar-quoted body");
|
|
7068
|
+
const end = closingTag + tag.length;
|
|
7069
|
+
return { masked: " ".repeat(end - start), end };
|
|
7070
|
+
}
|
|
7071
|
+
function maskSqlSpan(sql, start, quotedIdentifier) {
|
|
7072
|
+
if (sql.startsWith("--", start)) {
|
|
7073
|
+
const end = lineCommentEnd(sql, start);
|
|
7074
|
+
return { masked: " ".repeat(end - start), end };
|
|
7075
|
+
}
|
|
7076
|
+
if (sql.startsWith("/*", start)) {
|
|
7077
|
+
const end = blockCommentEnd(sql, start);
|
|
7078
|
+
return { masked: " ".repeat(end - start), end };
|
|
7079
|
+
}
|
|
7080
|
+
if (sql[start] === "'")
|
|
7081
|
+
return maskSingleQuotedString(sql, start);
|
|
7082
|
+
if (sql[start] === '"')
|
|
7083
|
+
return quotedIdentifier(sql, start);
|
|
7084
|
+
if (sql[start] === "$")
|
|
7085
|
+
return maskDollarQuotedString(sql, start);
|
|
7086
|
+
return null;
|
|
7087
|
+
}
|
|
7088
|
+
function maskSql(sql, quotedIdentifier) {
|
|
7089
|
+
let masked = "";
|
|
7090
|
+
let cursor = 0;
|
|
7091
|
+
while (cursor < sql.length) {
|
|
7092
|
+
const protectedSpan = maskSqlSpan(sql, cursor, quotedIdentifier);
|
|
7093
|
+
if (protectedSpan) {
|
|
7094
|
+
masked += protectedSpan.masked;
|
|
7095
|
+
cursor = protectedSpan.end;
|
|
7096
|
+
} else {
|
|
7097
|
+
masked += sql[cursor];
|
|
7098
|
+
cursor++;
|
|
7099
|
+
}
|
|
7100
|
+
}
|
|
7101
|
+
return masked;
|
|
7102
|
+
}
|
|
7103
|
+
function maskSqlNoise(sql) {
|
|
7104
|
+
return maskSql(sql, maskDoubleQuotedIdentifier);
|
|
7105
|
+
}
|
|
7106
|
+
function maskSqlPolicyNoise(sql) {
|
|
7107
|
+
return maskSql(sql, preserveDoubleQuotedIdentifier);
|
|
7108
|
+
}
|
|
7109
|
+
function splitSqlStatements(sql) {
|
|
7110
|
+
const masked = maskSqlNoise(sql);
|
|
7111
|
+
const statements = [];
|
|
7112
|
+
let lastIndex = 0;
|
|
7113
|
+
let cursor = 0;
|
|
7114
|
+
while (cursor < masked.length) {
|
|
7115
|
+
if (masked[cursor] === ";") {
|
|
7116
|
+
const rawStatement = sql.slice(lastIndex, cursor).trim();
|
|
7117
|
+
if (rawStatement.length > 0) {
|
|
7118
|
+
statements.push(rawStatement);
|
|
7119
|
+
}
|
|
7120
|
+
lastIndex = cursor + 1;
|
|
7121
|
+
}
|
|
7122
|
+
cursor++;
|
|
7123
|
+
}
|
|
7124
|
+
const trailing = sql.slice(lastIndex).trim();
|
|
7125
|
+
if (trailing.length > 0) {
|
|
7126
|
+
statements.push(trailing);
|
|
7127
|
+
}
|
|
7128
|
+
return statements;
|
|
7129
|
+
}
|
|
7130
|
+
function normalizedTransactionStatement(statement) {
|
|
7131
|
+
return maskSqlNoise(statement).replace(/\s+/g, " ").trim();
|
|
7132
|
+
}
|
|
7133
|
+
function migrationExecutionStatements(statements) {
|
|
7134
|
+
if (statements.length < 2)
|
|
7135
|
+
return [...statements];
|
|
7136
|
+
const first = normalizedTransactionStatement(statements[0]);
|
|
7137
|
+
const last = normalizedTransactionStatement(statements[statements.length - 1]);
|
|
7138
|
+
const hasOuterTransaction = /^(?:BEGIN(?:\s+(?:WORK|TRANSACTION))?|START\s+TRANSACTION)$/i.test(first) && /^(?:COMMIT|END)(?:\s+(?:WORK|TRANSACTION))?$/i.test(last);
|
|
7139
|
+
return hasOuterTransaction ? statements.slice(1, -1) : [...statements];
|
|
7140
|
+
}
|
|
7141
|
+
function splitTopLevelClauses(sql) {
|
|
7142
|
+
const masked = maskSqlNoise(sql);
|
|
7143
|
+
const clauses = [];
|
|
7144
|
+
let parenthesisDepth = 0;
|
|
7145
|
+
let bracketDepth = 0;
|
|
7146
|
+
let clauseStart = 0;
|
|
7147
|
+
for (let cursor = 0;cursor < masked.length; cursor += 1) {
|
|
7148
|
+
const character = masked[cursor];
|
|
7149
|
+
if (character === "(")
|
|
7150
|
+
parenthesisDepth += 1;
|
|
7151
|
+
else if (character === ")")
|
|
7152
|
+
parenthesisDepth -= 1;
|
|
7153
|
+
else if (character === "[")
|
|
7154
|
+
bracketDepth += 1;
|
|
7155
|
+
else if (character === "]")
|
|
7156
|
+
bracketDepth -= 1;
|
|
7157
|
+
if (parenthesisDepth < 0 || bracketDepth < 0)
|
|
7158
|
+
throw new Error("Unbalanced SQL delimiters");
|
|
7159
|
+
if (character === "," && parenthesisDepth === 0 && bracketDepth === 0) {
|
|
7160
|
+
clauses.push(sql.slice(clauseStart, cursor).trim());
|
|
7161
|
+
clauseStart = cursor + 1;
|
|
7162
|
+
}
|
|
7163
|
+
}
|
|
7164
|
+
if (parenthesisDepth !== 0 || bracketDepth !== 0)
|
|
7165
|
+
throw new Error("Unbalanced SQL delimiters");
|
|
7166
|
+
clauses.push(sql.slice(clauseStart).trim());
|
|
7167
|
+
return clauses.filter(Boolean);
|
|
7168
|
+
}
|
|
7169
|
+
var RISK_RULES = [
|
|
7170
|
+
{
|
|
7171
|
+
type: "destructive_drop_database",
|
|
7172
|
+
level: "HIGH",
|
|
7173
|
+
pattern: /\bDROP\s+DATABASE\b/i,
|
|
7174
|
+
description: "Drops entire database.",
|
|
7175
|
+
recommendation: "Never drop databases in automated migrations.",
|
|
7176
|
+
blocksTransactionalPush: true
|
|
7177
|
+
},
|
|
7178
|
+
{
|
|
7179
|
+
type: "destructive_drop_schema",
|
|
7180
|
+
level: "HIGH",
|
|
7181
|
+
pattern: /\bDROP\s+SCHEMA\b/i,
|
|
7182
|
+
description: "Drops entire schema and all contained objects.",
|
|
7183
|
+
recommendation: "Ensure schema objects are migrated or deleted in contract phase before dropping schema."
|
|
7184
|
+
},
|
|
7185
|
+
{
|
|
7186
|
+
type: "destructive_drop_table",
|
|
7187
|
+
level: "HIGH",
|
|
7188
|
+
pattern: /\bDROP\s+TABLE\b/i,
|
|
7189
|
+
description: "Drops table and permanently removes data. Old code referencing this table will break.",
|
|
7190
|
+
recommendation: "Follow Expand-Contract: Deprecate usage in application code before dropping table."
|
|
7191
|
+
},
|
|
7192
|
+
{
|
|
7193
|
+
type: "destructive_drop_column",
|
|
7194
|
+
level: "HIGH",
|
|
7195
|
+
pattern: /\bALTER\s+TABLE\b[\s\S]*?\bDROP\s+(?:COLUMN\s+)?[a-z0-9_"]+/i,
|
|
7196
|
+
excludePattern: /\bDROP\s+CONSTRAINT\b|\bALTER\s+(?:COLUMN\s+)?[a-z0-9_"]+\s+DROP\s+(?:DEFAULT|NOT\s+NULL|IDENTITY|EXPRESSION)\b/i,
|
|
7197
|
+
description: "Drops column. Running application code selecting or writing this column will fail immediately.",
|
|
7198
|
+
recommendation: "Follow Expand-Contract: Ensure all running application versions have stopped accessing this column before dropping."
|
|
7199
|
+
},
|
|
7200
|
+
{
|
|
7201
|
+
type: "destructive_drop_constraint",
|
|
7202
|
+
level: "HIGH",
|
|
7203
|
+
pattern: /\bALTER\s+TABLE\b[\s\S]*?\bDROP\s+CONSTRAINT\b/i,
|
|
7204
|
+
description: "Drops constraint. Dropping constraints may break uniqueness guarantees or foreign key integrity.",
|
|
7205
|
+
recommendation: "Ensure application code and data no longer depend on this constraint before dropping."
|
|
7206
|
+
},
|
|
7207
|
+
{
|
|
7208
|
+
type: "destructive_drop_view",
|
|
7209
|
+
level: "HIGH",
|
|
7210
|
+
pattern: /\bDROP\s+(?:MATERIALIZED\s+)?VIEW\b/i,
|
|
7211
|
+
description: "Drops view or materialized view. Queries or API endpoints selecting from this view will break.",
|
|
7212
|
+
recommendation: "Follow Expand-Contract: Deprecate client/API dependencies on this view before dropping."
|
|
7213
|
+
},
|
|
7214
|
+
{
|
|
7215
|
+
type: "destructive_truncate",
|
|
7216
|
+
level: "HIGH",
|
|
7217
|
+
pattern: /\bTRUNCATE(?:\s+TABLE)?\b/i,
|
|
7218
|
+
description: "Truncates table data permanently.",
|
|
7219
|
+
recommendation: "Avoid TRUNCATE in schema migrations; use soft deletes or targeted archived data cleanup."
|
|
7220
|
+
},
|
|
7221
|
+
{
|
|
7222
|
+
type: "destructive_rename_column",
|
|
7223
|
+
level: "HIGH",
|
|
7224
|
+
pattern: /\bALTER\s+TABLE\b[\s\S]*?\bRENAME\s+(?:COLUMN\s+)?[a-z0-9_"]+\s+TO\b/i,
|
|
7225
|
+
description: "Renames column. Causes immediate downtime for old application code expecting the previous column name.",
|
|
7226
|
+
recommendation: "Follow Expand-Contract: Add new column, dual-write, backfill, migrate reads, then drop old column."
|
|
7227
|
+
},
|
|
7228
|
+
{
|
|
7229
|
+
type: "destructive_rename_table",
|
|
7230
|
+
level: "HIGH",
|
|
7231
|
+
pattern: /\bALTER\s+TABLE\b[\s\S]*?\bRENAME\s+TO\b/i,
|
|
7232
|
+
description: "Renames table. Causes immediate downtime for application code.",
|
|
7233
|
+
recommendation: "Follow Expand-Contract: Create a view or alias table during transition."
|
|
7234
|
+
},
|
|
7235
|
+
{
|
|
7236
|
+
type: "manual_review_do_block",
|
|
7237
|
+
level: "HIGH",
|
|
7238
|
+
pattern: /^\s*DO\b/i,
|
|
7239
|
+
description: "DO blocks can execute dynamic or procedural DDL that static rules cannot inspect safely.",
|
|
7240
|
+
recommendation: "Move schema changes into explicit SQL statements; push_migrations rejects opaque procedural SQL.",
|
|
7241
|
+
blocksTransactionalPush: true
|
|
7242
|
+
},
|
|
7243
|
+
{
|
|
7244
|
+
type: "manual_review_procedural_definition",
|
|
7245
|
+
level: "HIGH",
|
|
7246
|
+
pattern: /^\s*CREATE\s+(?:OR\s+REPLACE\s+)?(?:FUNCTION|PROCEDURE)\b/i,
|
|
7247
|
+
description: "Defines procedural SQL whose body can hide data, privilege, or schema side effects from static rules.",
|
|
7248
|
+
recommendation: "Review the complete function or procedure body and require strict migration approval."
|
|
7249
|
+
},
|
|
7250
|
+
{
|
|
7251
|
+
type: "manual_review_procedure_call",
|
|
7252
|
+
level: "HIGH",
|
|
7253
|
+
pattern: /^\s*CALL\b/i,
|
|
7254
|
+
description: "Calls a stored procedure whose side effects cannot be determined statically.",
|
|
7255
|
+
recommendation: "Replace the call with explicit migration SQL or require strict manual approval."
|
|
7256
|
+
},
|
|
7257
|
+
{
|
|
7258
|
+
type: "locking_vacuum_full",
|
|
7259
|
+
level: "HIGH",
|
|
7260
|
+
pattern: /^\s*VACUUM\b/i,
|
|
7261
|
+
description: "VACUUM cannot run inside the transactional migration executor; VACUUM FULL also locks and rewrites the table.",
|
|
7262
|
+
recommendation: "Run VACUUM through an approved non-transactional maintenance path; do not place it in push_migrations files.",
|
|
7263
|
+
blocksTransactionalPush: true
|
|
7264
|
+
},
|
|
7265
|
+
{
|
|
7266
|
+
type: "locking_cluster",
|
|
7267
|
+
level: "HIGH",
|
|
7268
|
+
pattern: /^\s*CLUSTER\s+[a-z0-9_"]+/i,
|
|
7269
|
+
description: "CLUSTER acquires an ACCESS EXCLUSIVE lock on the table, blocking all concurrent access.",
|
|
7270
|
+
recommendation: "Avoid CLUSTER on active production tables; consider pg_repack for online table reordering."
|
|
7271
|
+
},
|
|
7272
|
+
{
|
|
7273
|
+
type: "unsupported_concurrent_index_migration",
|
|
7274
|
+
level: "HIGH",
|
|
7275
|
+
pattern: /\b(?:CREATE\s+(?:UNIQUE\s+)?INDEX|DROP\s+INDEX|REINDEX\b[\s\S]*?)\s+CONCURRENTLY\b/i,
|
|
7276
|
+
description: "CONCURRENTLY operations cannot run inside the transactional migration executor.",
|
|
7277
|
+
recommendation: "Run the concurrent index operation through an approved non-transactional maintenance path, outside push_migrations.",
|
|
7278
|
+
blocksTransactionalPush: true
|
|
7279
|
+
},
|
|
7280
|
+
{
|
|
7281
|
+
type: "unsupported_non_transactional_migration",
|
|
7282
|
+
level: "HIGH",
|
|
7283
|
+
pattern: /\b(?:CREATE\s+DATABASE|CREATE\s+TABLESPACE|DROP\s+TABLESPACE|CREATE\s+SUBSCRIPTION|DROP\s+SUBSCRIPTION|ALTER\s+SYSTEM)\b/i,
|
|
7284
|
+
description: "This operation cannot run inside the transactional, project-scoped migration executor.",
|
|
7285
|
+
recommendation: "Use an approved platform maintenance path instead of push_migrations.",
|
|
7286
|
+
blocksTransactionalPush: true
|
|
7287
|
+
},
|
|
7288
|
+
{
|
|
7289
|
+
type: "locking_non_concurrent_index",
|
|
7290
|
+
level: "MEDIUM",
|
|
7291
|
+
pattern: /\bCREATE\s+(?:UNIQUE\s+)?INDEX\s+(?!CONCURRENTLY\b)/i,
|
|
7292
|
+
description: "Creates index non-concurrently. Acquires SHARE lock and blocks concurrent writes (INSERT/UPDATE/DELETE) on the table until index build completes.",
|
|
7293
|
+
recommendation: "For a live large table, use the approved non-transactional maintenance path for CREATE INDEX CONCURRENTLY; push_migrations cannot execute it."
|
|
7294
|
+
},
|
|
7295
|
+
{
|
|
7296
|
+
type: "locking_drop_index_non_concurrent",
|
|
7297
|
+
level: "MEDIUM",
|
|
7298
|
+
pattern: /\bDROP\s+INDEX\s+(?!CONCURRENTLY\b)/i,
|
|
7299
|
+
description: "Drops index non-concurrently. Acquires ACCESS EXCLUSIVE lock on the table, blocking concurrent reads and writes.",
|
|
7300
|
+
recommendation: "For a live table, use the approved non-transactional maintenance path for DROP INDEX CONCURRENTLY; push_migrations cannot execute it."
|
|
7301
|
+
},
|
|
7302
|
+
{
|
|
7303
|
+
type: "locking_alter_column_set_not_null",
|
|
7304
|
+
level: "MEDIUM",
|
|
7305
|
+
pattern: /\bALTER\s+TABLE\b[\s\S]*?\bALTER\s+(?:COLUMN\s+)?[a-z0-9_"]+\s+SET\s+NOT\s+NULL\b/i,
|
|
7306
|
+
description: "Setting NOT NULL on an existing column scans the entire table under an ACCESS EXCLUSIVE lock unless a validated CHECK constraint already exists.",
|
|
7307
|
+
recommendation: "Add a CHECK (column IS NOT NULL) NOT VALID constraint first, validate it with VALIDATE CONSTRAINT, then SET NOT NULL."
|
|
7308
|
+
},
|
|
7309
|
+
{
|
|
7310
|
+
type: "locking_alter_type",
|
|
7311
|
+
level: "MEDIUM",
|
|
7312
|
+
pattern: /\bALTER\s+TABLE\b[\s\S]*?\bALTER\s+(?:COLUMN\s+)?[a-z0-9_"]+\s+(?:SET\s+DATA\s+)?TYPE\b/i,
|
|
7313
|
+
description: "Altering column type acquires ACCESS EXCLUSIVE lock and may trigger a full table rewrite.",
|
|
7314
|
+
recommendation: "Add a new column with the target type, dual-write, copy data, switch reads, then drop old column."
|
|
7315
|
+
},
|
|
7316
|
+
{
|
|
7317
|
+
type: "locking_unique_constraint_without_index",
|
|
7318
|
+
level: "MEDIUM",
|
|
7319
|
+
pattern: /\bALTER\s+TABLE\b[\s\S]*?\bADD\s+CONSTRAINT\b[\s\S]*?\bUNIQUE\b/i,
|
|
7320
|
+
excludePattern: /\bUNIQUE\s+USING\s+INDEX\b/i,
|
|
7321
|
+
description: "Adding UNIQUE constraint directly acquires an ACCESS EXCLUSIVE lock while creating the index.",
|
|
7322
|
+
recommendation: "Build the unique index through the approved non-transactional maintenance path, then attach it with ADD CONSTRAINT ... UNIQUE USING INDEX in a migration."
|
|
7323
|
+
},
|
|
7324
|
+
{
|
|
7325
|
+
type: "locking_foreign_key_without_not_valid",
|
|
7326
|
+
level: "MEDIUM",
|
|
7327
|
+
pattern: /\bALTER\s+TABLE\b[\s\S]*?\bADD\s+CONSTRAINT\b[\s\S]*?\bFOREIGN\s+KEY\b(?!\s*[^;]*?\bNOT\s+VALID\b)/i,
|
|
7328
|
+
description: "Adding a FOREIGN KEY constraint without NOT VALID locks the table for a full scan to validate existing rows.",
|
|
7329
|
+
recommendation: "Add foreign key constraint with 'NOT VALID' first, then run 'ALTER TABLE ... VALIDATE CONSTRAINT' separately."
|
|
7330
|
+
},
|
|
7331
|
+
{
|
|
7332
|
+
type: "locking_check_constraint_without_not_valid",
|
|
7333
|
+
level: "MEDIUM",
|
|
7334
|
+
pattern: /\bALTER\s+TABLE\b[\s\S]*?\bADD\s+CONSTRAINT\b[\s\S]*?\bCHECK\b(?!\s*[^;]*?\bNOT\s+VALID\b)/i,
|
|
7335
|
+
description: "Adding a CHECK constraint without NOT VALID scans the entire table under an ACCESS EXCLUSIVE lock.",
|
|
7336
|
+
recommendation: "Add constraint with 'NOT VALID' first, then validate with 'ALTER TABLE ... VALIDATE CONSTRAINT'."
|
|
7337
|
+
}
|
|
7338
|
+
];
|
|
7339
|
+
var MIGRATION_LEDGER_RELATION = String.raw`(?:(?:"?(?:supabase_migrations|public)"?)\s*\.\s*)?"?schema_migrations"?`;
|
|
7340
|
+
var MIGRATION_LEDGER_RELATION_END = String.raw`(?=$|[\s,;(*])`;
|
|
7341
|
+
var MIGRATION_LEDGER_DDL_OR_MAINTENANCE_PREFIX = String.raw`(?:CREATE\s+(?:UNIQUE\s+)?INDEX\b[^;]*\bON\s+(?:ONLY\s+)?|CREATE\s+(?:CONSTRAINT\s+)?TRIGGER\b[^;]*\bON\s+|DROP\s+TRIGGER\s+(?:IF\s+EXISTS\s+)?[^;]*\bON\s+|CREATE\s+RULE\b[^;]*\bTO\s+|DROP\s+RULE\s+(?:IF\s+EXISTS\s+)?[^;]*\bON\s+|CREATE\s+POLICY\b[^;]*\bON\s+|ALTER\s+POLICY\b[^;]*\bON\s+|DROP\s+POLICY\s+(?:IF\s+EXISTS\s+)?[^;]*\bON\s+|COMMENT\s+ON\s+TABLE\s+|SECURITY\s+LABEL(?:\s+FOR\s+[^;\s]+)?\s+ON\s+TABLE\s+|REINDEX(?:\s*\([^;)]*\))?\s+TABLE\s+(?:CONCURRENTLY\s+)?|CLUSTER(?:\s+VERBOSE)?\s+|VACUUM(?:\s*\([^;)]*\))?(?:\s+(?:FULL|FREEZE|VERBOSE|ANALYZE))*\s+|ANALYZE(?:\s*\([^;)]*\))?(?:\s+VERBOSE)?\s+)`;
|
|
7342
|
+
var MIGRATION_LEDGER_MODIFICATION_PATTERN = new RegExp(String.raw`\b(?:(?:CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?|INSERT\s+INTO\s+(?:ONLY\s+)?|UPDATE\s+(?:ONLY\s+)?|DELETE\s+FROM\s+(?:ONLY\s+)?|MERGE\s+INTO\s+(?:ONLY\s+)?|ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?|COPY\s+)` + MIGRATION_LEDGER_RELATION + MIGRATION_LEDGER_RELATION_END + String.raw`|(?:DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?|TRUNCATE(?:\s+TABLE)?\s+|LOCK\s+(?:TABLE\s+)?)[^;]*` + MIGRATION_LEDGER_RELATION + MIGRATION_LEDGER_RELATION_END + String.raw`|` + MIGRATION_LEDGER_DDL_OR_MAINTENANCE_PREFIX + MIGRATION_LEDGER_RELATION + MIGRATION_LEDGER_RELATION_END + String.raw`)`, "i");
|
|
7343
|
+
var NON_TABLE_PRIVILEGE_TARGET = String.raw`(?:ALL\s+(?:FUNCTIONS|PROCEDURES|ROUTINES|SEQUENCES)\s+IN\s+SCHEMA|DATABASE|DOMAIN|FOREIGN\s+DATA\s+WRAPPER|FOREIGN\s+SERVER|FUNCTION|LANGUAGE|LARGE\s+OBJECT|PARAMETER|PROCEDURE|ROUTINE|SCHEMA|SEQUENCE|TABLESPACE|TYPE)\b`;
|
|
7344
|
+
var MIGRATION_LEDGER_PRIVILEGE_PATTERN = new RegExp(String.raw`\b(?:GRANT|REVOKE)\b[^;]*\bON\s+(?:(?:TABLE\s+)?(?!` + NON_TABLE_PRIVILEGE_TARGET + String.raw`)(?:(?!\b(?:TO|FROM)\b)[^;])*?` + MIGRATION_LEDGER_RELATION + MIGRATION_LEDGER_RELATION_END + String.raw`(?=[^;]*\b(?:TO|FROM)\b)|ALL\s+TABLES\s+IN\s+SCHEMA\s+"?(?:supabase_migrations|public)"?(?=$|[\s,;]))`, "i");
|
|
7345
|
+
var PUSH_BLOCKER_RULES = [
|
|
7346
|
+
{
|
|
7347
|
+
type: "unsupported_project_scope_management",
|
|
7348
|
+
level: "HIGH",
|
|
7349
|
+
pattern: /\b(?:ALTER\s+DATABASE|DROP\s+SCHEMA\s+(?:IF\s+EXISTS\s+)?(?:[^;]*,\s*)?public(?=\s*(?:,|CASCADE\b|RESTRICT\b|$))|(?:DROP|REASSIGN)\s+OWNED\b|(?:CREATE|ALTER|DROP)\s+(?:ROLE|USER)\b|ALTER\s+SUBSCRIPTION\b)\b/i,
|
|
7350
|
+
description: "Attempts cluster-wide or platform-owned database management outside the project migration boundary.",
|
|
7351
|
+
recommendation: "Use an approved platform administration path instead of push_migrations.",
|
|
7352
|
+
blocksTransactionalPush: true
|
|
7353
|
+
},
|
|
7354
|
+
{
|
|
7355
|
+
type: "unsupported_platform_schema_management",
|
|
7356
|
+
level: "HIGH",
|
|
7357
|
+
pattern: /\b(?:ALTER\s+SCHEMA\s+"?public"?|(?:CREATE\s+SCHEMA\s+(?:IF\s+NOT\s+EXISTS\s+)?|ALTER\s+SCHEMA\s+)"?supabase_migrations"?|DROP\s+SCHEMA\s+(?:IF\s+EXISTS\s+)?(?:[^;]*,\s*)?"?supabase_migrations"?(?=\s*(?:,|CASCADE\b|RESTRICT\b|$)))\b/i,
|
|
7358
|
+
description: "Attempts to rename or re-own the public API schema, or to manage the platform migration schema.",
|
|
7359
|
+
recommendation: "Keep public and supabase_migrations under platform ownership; change project-owned schemas instead.",
|
|
7360
|
+
blocksTransactionalPush: true
|
|
7361
|
+
},
|
|
7362
|
+
{
|
|
7363
|
+
type: "unsupported_unicode_escaped_identifier",
|
|
7364
|
+
level: "HIGH",
|
|
7365
|
+
pattern: /\bU&"/i,
|
|
7366
|
+
description: "Uses a Unicode-escaped identifier that cannot be safely canonicalized by the migration policy.",
|
|
7367
|
+
recommendation: "Use a plain or directly quoted PostgreSQL identifier in controlled migrations.",
|
|
7368
|
+
blocksTransactionalPush: true
|
|
7369
|
+
},
|
|
7370
|
+
{
|
|
7371
|
+
type: "unsupported_server_access",
|
|
7372
|
+
level: "HIGH",
|
|
7373
|
+
pattern: /^\s*COPY\b|\b(?:(?:lo_import|lo_export|pg_read_file|pg_write_file|pg_ls_dir|pg_stat_file|pg_execute_server_program)\s*\(|pg_(?:terminate|cancel)_backend\s*\(|LOAD\b)/i,
|
|
7374
|
+
description: "Attempts server file, process, backend, or dynamic-library access from a project migration.",
|
|
7375
|
+
recommendation: "Run host-level maintenance through an approved platform administration path.",
|
|
7376
|
+
blocksTransactionalPush: true
|
|
7377
|
+
},
|
|
7378
|
+
{
|
|
7379
|
+
type: "unsupported_external_database_access",
|
|
7380
|
+
level: "HIGH",
|
|
7381
|
+
pattern: /\bdblink(?:_[a-z_]+)?\s*\(|\b(?:CREATE|ALTER|DROP)\s+(?:SERVER|USER\s+MAPPING|FOREIGN\s+DATA\s+WRAPPER)\b|\bIMPORT\s+FOREIGN\s+SCHEMA\b|\bCREATE\s+FOREIGN\s+TABLE\b/i,
|
|
7382
|
+
description: "Attempts external database or foreign-data-wrapper access outside the project migration boundary.",
|
|
7383
|
+
recommendation: "Provision external connectivity through an approved platform administration path.",
|
|
7384
|
+
blocksTransactionalPush: true
|
|
7385
|
+
},
|
|
7386
|
+
{
|
|
7387
|
+
type: "unsupported_transaction_control",
|
|
7388
|
+
level: "HIGH",
|
|
7389
|
+
pattern: /^\s*(?:BEGIN|START\s+TRANSACTION|COMMIT|END|ROLLBACK|ABORT|SAVEPOINT|RELEASE\s+SAVEPOINT|PREPARE\s+TRANSACTION)\b/i,
|
|
7390
|
+
description: "Contains transaction control inside a migration whose atomic transaction is owned by the platform.",
|
|
7391
|
+
recommendation: "Remove internal transaction control; one matching outer BEGIN/COMMIT wrapper is stripped automatically.",
|
|
7392
|
+
blocksTransactionalPush: true
|
|
7393
|
+
},
|
|
7394
|
+
{
|
|
7395
|
+
type: "unsupported_session_control",
|
|
7396
|
+
level: "HIGH",
|
|
7397
|
+
pattern: /\b(?:SET\s+(?:(?:LOCAL|SESSION)\s+)?(?:ROLE|SESSION\s+AUTHORIZATION)|RESET\s+(?:ROLE|SESSION\s+AUTHORIZATION)|DISCARD\s+ALL)\b/i,
|
|
7398
|
+
description: "Attempts to change the database session identity or discard platform-managed session state.",
|
|
7399
|
+
recommendation: "Keep migrations within the delegated project role and remove session identity control.",
|
|
7400
|
+
blocksTransactionalPush: true
|
|
7401
|
+
},
|
|
7402
|
+
{
|
|
7403
|
+
type: "unsupported_advisory_lock_control",
|
|
7404
|
+
level: "HIGH",
|
|
7405
|
+
pattern: /\bpg_(?:try_)?advisory_(?:xact_)?(?:lock|unlock)(?:_shared|_all)?\s*\(/i,
|
|
7406
|
+
description: "Attempts advisory lock control that can conflict with the platform migration lock.",
|
|
7407
|
+
recommendation: "Remove custom advisory lock operations; push_migrations already serializes project migrations.",
|
|
7408
|
+
blocksTransactionalPush: true
|
|
7409
|
+
}
|
|
7410
|
+
];
|
|
7411
|
+
var QUOTED_FUNCTION_BLOCKER_RULES = [
|
|
7412
|
+
{
|
|
7413
|
+
type: "unsupported_server_access",
|
|
7414
|
+
level: "HIGH",
|
|
7415
|
+
pattern: /"(?:lo_import|lo_export|pg_read_file|pg_write_file|pg_ls_dir|pg_stat_file|pg_execute_server_program|pg_terminate_backend|pg_cancel_backend)"\s*\(/,
|
|
7416
|
+
description: "Attempts server file, process, backend, or dynamic-library access from a project migration.",
|
|
7417
|
+
recommendation: "Run host-level maintenance through an approved platform administration path.",
|
|
7418
|
+
blocksTransactionalPush: true
|
|
7419
|
+
},
|
|
7420
|
+
{
|
|
7421
|
+
type: "unsupported_external_database_access",
|
|
7422
|
+
level: "HIGH",
|
|
7423
|
+
pattern: /"dblink(?:_[a-z_]+)?"\s*\(/,
|
|
7424
|
+
description: "Attempts external database or foreign-data-wrapper access outside the project migration boundary.",
|
|
7425
|
+
recommendation: "Provision external connectivity through an approved platform administration path.",
|
|
7426
|
+
blocksTransactionalPush: true
|
|
7427
|
+
},
|
|
7428
|
+
{
|
|
7429
|
+
type: "unsupported_advisory_lock_control",
|
|
7430
|
+
level: "HIGH",
|
|
7431
|
+
pattern: /"pg_(?:try_)?advisory_(?:xact_)?(?:lock|unlock)(?:_shared|_all)?"\s*\(/,
|
|
7432
|
+
description: "Attempts advisory lock control that can conflict with the platform migration lock.",
|
|
7433
|
+
recommendation: "Remove custom advisory lock operations; push_migrations already serializes project migrations.",
|
|
7434
|
+
blocksTransactionalPush: true
|
|
7435
|
+
}
|
|
7436
|
+
];
|
|
7437
|
+
var MIGRATION_LEDGER_BLOCKER_RULES = [
|
|
7438
|
+
{
|
|
7439
|
+
type: "unsupported_public_schema_removal",
|
|
7440
|
+
level: "HIGH",
|
|
7441
|
+
pattern: /\bDROP\s+SCHEMA\s+(?:IF\s+EXISTS\s+)?(?:[^;]*,\s*)?"?public"?(?=\s*(?:,|CASCADE\b|RESTRICT\b|$))/i,
|
|
7442
|
+
description: "Attempts to remove the platform-required public schema.",
|
|
7443
|
+
recommendation: "Drop project objects individually through an approved contract migration; do not remove public.",
|
|
7444
|
+
blocksTransactionalPush: true
|
|
7445
|
+
},
|
|
7446
|
+
{
|
|
7447
|
+
type: "unsupported_migration_ledger_access",
|
|
7448
|
+
level: "HIGH",
|
|
7449
|
+
pattern: MIGRATION_LEDGER_MODIFICATION_PATTERN,
|
|
7450
|
+
description: "Attempts to modify or bypass the platform-owned migration ledger.",
|
|
7451
|
+
recommendation: "Let push_migrations record the ledger entry after the migration transaction commits.",
|
|
7452
|
+
blocksTransactionalPush: true
|
|
7453
|
+
},
|
|
7454
|
+
{
|
|
7455
|
+
type: "unsupported_migration_ledger_privileges",
|
|
7456
|
+
level: "HIGH",
|
|
7457
|
+
pattern: MIGRATION_LEDGER_PRIVILEGE_PATTERN,
|
|
7458
|
+
description: "Attempts to change privileges on the platform-owned migration ledger.",
|
|
7459
|
+
recommendation: "Keep migration ledger privileges under platform control.",
|
|
7460
|
+
blocksTransactionalPush: true
|
|
7461
|
+
},
|
|
7462
|
+
{
|
|
7463
|
+
type: "unsupported_migration_ledger_recorder_access",
|
|
7464
|
+
level: "HIGH",
|
|
7465
|
+
pattern: /"?supabase_migrations"?\s*\.\s*"?record_schema_migration"?\s*\(/i,
|
|
7466
|
+
description: "Attempts to call or redefine the platform migration ledger recorder.",
|
|
7467
|
+
recommendation: "Let push_migrations invoke the recorder with a platform-issued lease.",
|
|
7468
|
+
blocksTransactionalPush: true
|
|
7469
|
+
}
|
|
7470
|
+
];
|
|
7471
|
+
function matchingRisks(rawStatement, maskedStatement, rules) {
|
|
7472
|
+
return rules.filter((rule) => rule.pattern.test(maskedStatement) && !rule.excludePattern?.test(maskedStatement)).map((rule) => ({
|
|
7473
|
+
level: rule.level,
|
|
7474
|
+
type: rule.type,
|
|
7475
|
+
description: rule.description,
|
|
7476
|
+
recommendation: rule.recommendation,
|
|
7477
|
+
statementSnippet: rawStatement.replace(/\s+/g, " ").slice(0, 100),
|
|
7478
|
+
...rule.blocksTransactionalPush ? { blocksTransactionalPush: true } : {}
|
|
7479
|
+
}));
|
|
7480
|
+
}
|
|
7481
|
+
function defaultExpression(maskedClause) {
|
|
7482
|
+
const defaultIndex = maskedClause.search(/\bDEFAULT\b/i);
|
|
7483
|
+
if (defaultIndex === -1)
|
|
7484
|
+
return "";
|
|
7485
|
+
const expression = maskedClause.slice(defaultIndex + "DEFAULT".length);
|
|
7486
|
+
const constraintIndex = expression.search(/\b(?:COLLATE|CONSTRAINT|GENERATED|NOT\s+NULL|NULL|PRIMARY\s+KEY|REFERENCES|UNIQUE|CHECK)\b/i);
|
|
7487
|
+
return (constraintIndex === -1 ? expression : expression.slice(0, constraintIndex)).trim();
|
|
7488
|
+
}
|
|
7489
|
+
function defaultMayRequireTableRewrite(maskedClause) {
|
|
7490
|
+
const expression = defaultExpression(maskedClause);
|
|
7491
|
+
if (!expression)
|
|
7492
|
+
return false;
|
|
7493
|
+
const withoutKnownStableCalls = expression.replace(/\b(?:now|transaction_timestamp|statement_timestamp)\s*\(\s*\)/gi, "");
|
|
7494
|
+
return /\b[A-Za-z_][A-Za-z0-9_$]*(?:\s*\.\s*[A-Za-z_][A-Za-z0-9_$]*)?\s*\(/.test(withoutKnownStableCalls);
|
|
7495
|
+
}
|
|
7496
|
+
function addedNotNullColumnRisks(rawStatement) {
|
|
7497
|
+
const maskedStatement = maskSqlNoise(rawStatement);
|
|
7498
|
+
if (!/^\s*ALTER\s+TABLE\b/i.test(maskedStatement))
|
|
7499
|
+
return [];
|
|
7500
|
+
const risks = [];
|
|
7501
|
+
for (const clause of splitTopLevelClauses(rawStatement)) {
|
|
7502
|
+
const maskedClause = maskSqlNoise(clause);
|
|
7503
|
+
if (!/\bADD\s+(?!CONSTRAINT\b)(?:COLUMN\s+)?[a-z0-9_"]+\s+[\s\S]*\bNOT\s+NULL\b/i.test(maskedClause))
|
|
7504
|
+
continue;
|
|
7505
|
+
const snippet = clause.replace(/\s+/g, " ").slice(0, 100);
|
|
7506
|
+
if (!/\bDEFAULT\b/i.test(maskedClause)) {
|
|
7507
|
+
risks.push({
|
|
7508
|
+
level: "MEDIUM",
|
|
7509
|
+
type: "locking_not_null_no_default",
|
|
7510
|
+
description: "Adding a NOT NULL column without a DEFAULT value requires full table verification and fails when existing rows cannot satisfy it.",
|
|
7511
|
+
recommendation: "Follow Expand-Contract: add the column as nullable, backfill it, then add NOT NULL separately.",
|
|
7512
|
+
statementSnippet: snippet
|
|
7513
|
+
});
|
|
7514
|
+
} else if (defaultMayRequireTableRewrite(maskedClause)) {
|
|
7515
|
+
risks.push({
|
|
7516
|
+
level: "MEDIUM",
|
|
7517
|
+
type: "locking_not_null_expression_default",
|
|
7518
|
+
description: "Adding a NOT NULL column with a function-based DEFAULT may rewrite the table while holding a strong lock.",
|
|
7519
|
+
recommendation: "Use a literal or known stable default, or add the column nullable and backfill in a separate step.",
|
|
7520
|
+
statementSnippet: snippet
|
|
7521
|
+
});
|
|
7522
|
+
}
|
|
7523
|
+
}
|
|
7524
|
+
return risks;
|
|
7525
|
+
}
|
|
7526
|
+
function analyzeMigrationSql(sql) {
|
|
7527
|
+
const statements = migrationExecutionStatements(splitSqlStatements(sql));
|
|
7528
|
+
if (statements.length === 0) {
|
|
7529
|
+
return [{
|
|
7530
|
+
level: "HIGH",
|
|
7531
|
+
type: "unsupported_empty_migration",
|
|
7532
|
+
description: "Contains no executable SQL after removing the platform-managed outer transaction wrapper.",
|
|
7533
|
+
recommendation: "Remove the empty migration file or add the intended project-scoped SQL statement.",
|
|
7534
|
+
statementSnippet: sql.replace(/\s+/g, " ").slice(0, 100),
|
|
7535
|
+
blocksTransactionalPush: true
|
|
7536
|
+
}];
|
|
7537
|
+
}
|
|
7538
|
+
const risks = [];
|
|
7539
|
+
for (const rawStatement of statements) {
|
|
7540
|
+
const masked = maskSqlNoise(rawStatement);
|
|
7541
|
+
const policyMasked = maskSqlPolicyNoise(rawStatement);
|
|
7542
|
+
risks.push(...matchingRisks(rawStatement, masked, RISK_RULES));
|
|
7543
|
+
risks.push(...addedNotNullColumnRisks(rawStatement));
|
|
7544
|
+
risks.push(...matchingRisks(rawStatement, masked, PUSH_BLOCKER_RULES));
|
|
7545
|
+
risks.push(...matchingRisks(rawStatement, policyMasked, QUOTED_FUNCTION_BLOCKER_RULES));
|
|
7546
|
+
risks.push(...matchingRisks(rawStatement, policyMasked, MIGRATION_LEDGER_BLOCKER_RULES));
|
|
7547
|
+
}
|
|
7548
|
+
return risks;
|
|
7549
|
+
}
|
|
7550
|
+
function analyzeMigrationFiles(files) {
|
|
7551
|
+
const fileRisks = [];
|
|
7552
|
+
let highCount = 0;
|
|
7553
|
+
let mediumCount = 0;
|
|
7554
|
+
let lowCount = 0;
|
|
7555
|
+
let transactionalPushBlockerCount = 0;
|
|
7556
|
+
for (const { file, sql } of files) {
|
|
7557
|
+
const risks = analyzeMigrationSql(sql);
|
|
7558
|
+
transactionalPushBlockerCount += risks.filter((risk) => risk.blocksTransactionalPush).length;
|
|
7559
|
+
let fileLevel = "LOW";
|
|
7560
|
+
for (const risk of risks) {
|
|
7561
|
+
if (risk.level === "HIGH") {
|
|
7562
|
+
fileLevel = "HIGH";
|
|
7563
|
+
highCount++;
|
|
7564
|
+
} else if (risk.level === "MEDIUM") {
|
|
7565
|
+
if (fileLevel !== "HIGH")
|
|
7566
|
+
fileLevel = "MEDIUM";
|
|
7567
|
+
mediumCount++;
|
|
7568
|
+
}
|
|
7569
|
+
}
|
|
7570
|
+
if (fileLevel === "LOW") {
|
|
7571
|
+
lowCount++;
|
|
7572
|
+
}
|
|
7573
|
+
fileRisks.push({
|
|
7574
|
+
file,
|
|
7575
|
+
overallRisk: fileLevel,
|
|
7576
|
+
risks
|
|
7577
|
+
});
|
|
7578
|
+
}
|
|
7579
|
+
let overallRisk = "LOW";
|
|
7580
|
+
if (highCount > 0)
|
|
7581
|
+
overallRisk = "HIGH";
|
|
7582
|
+
else if (mediumCount > 0)
|
|
7583
|
+
overallRisk = "MEDIUM";
|
|
7584
|
+
return {
|
|
7585
|
+
overallRisk,
|
|
7586
|
+
highRiskCount: highCount,
|
|
7587
|
+
mediumRiskCount: mediumCount,
|
|
7588
|
+
lowRiskCount: lowCount,
|
|
7589
|
+
transactionalPushBlockerCount,
|
|
7590
|
+
files: fileRisks
|
|
7591
|
+
};
|
|
7592
|
+
}
|
|
7593
|
+
function formatMigrationRiskReport(analysis) {
|
|
7594
|
+
const lines = [];
|
|
7595
|
+
const riskIcon = analysis.overallRisk === "HIGH" ? "\uD83D\uDD34" : analysis.overallRisk === "MEDIUM" ? "\uD83D\uDFE1" : "\uD83D\uDFE2";
|
|
7596
|
+
lines.push(`${riskIcon} Migration Risk Level: ${analysis.overallRisk}`);
|
|
7597
|
+
lines.push(`Total issues detected: ${analysis.highRiskCount} High (Destructive), ${analysis.mediumRiskCount} Medium (Locking/Constraint)`);
|
|
7598
|
+
const filesWithRisks = analysis.files.filter((fileRisk) => fileRisk.risks.length > 0);
|
|
7599
|
+
if (!filesWithRisks.length) {
|
|
7600
|
+
lines.push("", "✅ All migrations follow safe, non-blocking Expand-Contract patterns.");
|
|
7601
|
+
return lines.join(`
|
|
7602
|
+
`);
|
|
7603
|
+
}
|
|
7604
|
+
lines.push("", "Detailed Findings:");
|
|
7605
|
+
for (const { file, overallRisk, risks } of filesWithRisks) {
|
|
7606
|
+
const fileIcon = overallRisk === "HIGH" ? "\uD83D\uDD34" : "\uD83D\uDFE1";
|
|
7607
|
+
lines.push(` ${fileIcon} ${file} [${overallRisk}]`);
|
|
7608
|
+
for (const risk of risks) {
|
|
7609
|
+
lines.push(` - [${risk.level}] ${risk.description}`);
|
|
7610
|
+
if (risk.statementSnippet) {
|
|
7611
|
+
lines.push(` Snippet: ${risk.statementSnippet}`);
|
|
7612
|
+
}
|
|
7613
|
+
lines.push(` \uD83D\uDCA1 Suggestion: ${risk.recommendation}`);
|
|
7614
|
+
}
|
|
7615
|
+
}
|
|
7616
|
+
return lines.join(`
|
|
7617
|
+
`);
|
|
7618
|
+
}
|
|
7619
|
+
|
|
6989
7620
|
// src/shared/tools/database-tools.ts
|
|
6990
7621
|
var MAX_MIGRATION_VERSION = 9223372036854775807n;
|
|
6991
7622
|
var FALLBACK_MIGRATION_VERSION_BASE = 8000000000000000000n;
|
|
6992
7623
|
var FALLBACK_MIGRATION_VERSION_RANGE = 1000000000000000000n;
|
|
6993
7624
|
var FALLBACK_MIGRATION_VERSION_LIMIT = FALLBACK_MIGRATION_VERSION_BASE + FALLBACK_MIGRATION_VERSION_RANGE;
|
|
6994
7625
|
var MAX_MIGRATION_INVENTORY_BYTES = 64 * 1024 * 1024;
|
|
7626
|
+
var GENERIC_MIGRATION_NAMES = new Set(["cli_push"]);
|
|
6995
7627
|
function isMigrationInventoryVersion(version) {
|
|
6996
7628
|
if (typeof version !== "string" || !/^\d{1,19}$/.test(version))
|
|
6997
7629
|
return false;
|
|
@@ -7155,10 +7787,37 @@ function migrationIdentity(row) {
|
|
|
7155
7787
|
if (!isMigrationInventoryVersion(migration.version) || !isMigrationInventoryName(migration.name)) {
|
|
7156
7788
|
throw new Error("Invalid remote migration identity");
|
|
7157
7789
|
}
|
|
7158
|
-
return {
|
|
7790
|
+
return {
|
|
7791
|
+
version: migration.version,
|
|
7792
|
+
name: migration.name !== null && GENERIC_MIGRATION_NAMES.has(migration.name) ? null : migration.name,
|
|
7793
|
+
statements: migration.statements
|
|
7794
|
+
};
|
|
7795
|
+
}
|
|
7796
|
+
function historicalMigrationVersion(version) {
|
|
7797
|
+
if (isMigrationInventoryVersion(version))
|
|
7798
|
+
return version;
|
|
7799
|
+
if (typeof version !== "number" || !Number.isSafeInteger(version))
|
|
7800
|
+
return null;
|
|
7801
|
+
const normalized = String(version);
|
|
7802
|
+
return isMigrationInventoryVersion(normalized) ? normalized : null;
|
|
7803
|
+
}
|
|
7804
|
+
function baselineMigrationIdentity(row) {
|
|
7805
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) {
|
|
7806
|
+
throw new Error("Invalid remote migration identity");
|
|
7807
|
+
}
|
|
7808
|
+
const migration = row;
|
|
7809
|
+
const version = historicalMigrationVersion(migration.version);
|
|
7810
|
+
if (!version || !isMigrationInventoryName(migration.name)) {
|
|
7811
|
+
throw new Error("Invalid remote migration identity");
|
|
7812
|
+
}
|
|
7813
|
+
return {
|
|
7814
|
+
version,
|
|
7815
|
+
name: migration.name !== null && GENERIC_MIGRATION_NAMES.has(migration.name) ? null : migration.name,
|
|
7816
|
+
statements: migration.statements
|
|
7817
|
+
};
|
|
7159
7818
|
}
|
|
7160
|
-
function migrationIdentities(data) {
|
|
7161
|
-
const identities = migrationRows(data).map(
|
|
7819
|
+
function migrationIdentities(data, parseIdentity) {
|
|
7820
|
+
const identities = migrationRows(data).map(parseIdentity);
|
|
7162
7821
|
const versions = new Set;
|
|
7163
7822
|
const names = new Set;
|
|
7164
7823
|
for (const identity of identities) {
|
|
@@ -7201,15 +7860,21 @@ function migrationDisposition(local, remoteMigrations) {
|
|
|
7201
7860
|
if (sameVersion[0] && sameName[0] && sameVersion[0] !== sameName[0])
|
|
7202
7861
|
throw migrationIdentityConflict(local);
|
|
7203
7862
|
const remote = sameVersion[0] ?? sameName[0];
|
|
7204
|
-
const
|
|
7205
|
-
if (
|
|
7863
|
+
const versionIdentity = remote.version === local.version && (remote.name === local.name || remote.name === null);
|
|
7864
|
+
if (versionIdentity && exactIdentityIsApplied(local, remote))
|
|
7206
7865
|
return "applied";
|
|
7207
7866
|
if (!sameVersion.length && legacyIdentityIsApplied(local, remote))
|
|
7208
7867
|
return "applied";
|
|
7209
7868
|
throw migrationIdentityConflict(local);
|
|
7210
7869
|
}
|
|
7211
7870
|
function migrationPushPlan(data, migrationFiles) {
|
|
7212
|
-
|
|
7871
|
+
return migrationPlan(data, migrationFiles, migrationIdentity);
|
|
7872
|
+
}
|
|
7873
|
+
function migrationBaselinePlan(data, migrationFiles) {
|
|
7874
|
+
return migrationPlan(data, migrationFiles, baselineMigrationIdentity);
|
|
7875
|
+
}
|
|
7876
|
+
function migrationPlan(data, migrationFiles, parseIdentity) {
|
|
7877
|
+
const remoteMigrations = migrationIdentities(data, parseIdentity);
|
|
7213
7878
|
const plan = { alreadyApplied: [], pending: [] };
|
|
7214
7879
|
for (const migration of migrationFiles) {
|
|
7215
7880
|
const disposition = migrationDisposition(migration, remoteMigrations);
|
|
@@ -7303,6 +7968,13 @@ function confirmedSqlBatchReceipt(payload, expectedCommands) {
|
|
|
7303
7968
|
return true;
|
|
7304
7969
|
});
|
|
7305
7970
|
}
|
|
7971
|
+
function schemaReloadStatus(payload) {
|
|
7972
|
+
const receipt = recordPayload(payload);
|
|
7973
|
+
const schemaReload = recordPayload(receipt?.schema_reload);
|
|
7974
|
+
if (schemaReload?.ddl_committed !== true)
|
|
7975
|
+
return null;
|
|
7976
|
+
return schemaReload.status === "notified" || schemaReload.status === "notification_failed" ? schemaReload.status : null;
|
|
7977
|
+
}
|
|
7306
7978
|
function rlsStatementCommands(policyMode) {
|
|
7307
7979
|
return [
|
|
7308
7980
|
"CREATE",
|
|
@@ -7347,8 +8019,9 @@ function vectorWarningsForPendingMigrations(migrations, vectorEnabled) {
|
|
|
7347
8019
|
return warnings;
|
|
7348
8020
|
}
|
|
7349
8021
|
function registerDatabaseTools(server, http, config = {}) {
|
|
7350
|
-
const { readOnly = false, projectRef } = config;
|
|
7351
|
-
const
|
|
8022
|
+
const { localOnly = false, readOnly = false, projectRef } = config;
|
|
8023
|
+
const localActions = ["lint_migrations", "lint"];
|
|
8024
|
+
const readActions = [
|
|
7352
8025
|
"query",
|
|
7353
8026
|
"list_tables",
|
|
7354
8027
|
"describe_columns",
|
|
@@ -7368,16 +8041,21 @@ function registerDatabaseTools(server, http, config = {}) {
|
|
|
7368
8041
|
"generate_types"
|
|
7369
8042
|
];
|
|
7370
8043
|
const writeActions = ["apply_migration", "push_migrations", "baseline_migrations", "create_table_rls"];
|
|
7371
|
-
const
|
|
8044
|
+
const remoteActions = [...readActions, ...localActions];
|
|
8045
|
+
const allActions = localOnly ? localActions : readOnly ? remoteActions : [...remoteActions, ...writeActions];
|
|
7372
8046
|
server.tool("database", `Database operations: query, schema, RLS, migrations, stats.
|
|
7373
|
-
Actions: ${allActions.join(", ")}${readOnly ? " (read-only mode)" : ""}`, {
|
|
8047
|
+
Actions: ${allActions.join(", ")}${localOnly ? " (local-only mode)" : readOnly ? " (read-only mode)" : ""}`, {
|
|
7374
8048
|
action: withDescription(stringEnum(allActions), "Action"),
|
|
7375
8049
|
ref: projectRef ? Type.Optional(Type.String()) : optional(Type.String(), "Project ref"),
|
|
7376
|
-
sql: optional(Type.String(), "[query/apply_migration] SQL statement"),
|
|
7377
|
-
file: optional(Type.String(), "[query/apply_migration] Read SQL from local file path (avoids shell escaping issues with $$ and multi-statement DDL)"),
|
|
7378
|
-
dir: optional(Type.String(), "[push_migrations/baseline_migrations] Directory containing .sql migration files (default: supabase/migrations)"),
|
|
8050
|
+
sql: optional(Type.String(), "[query/apply_migration/lint_migrations/lint] SQL statement"),
|
|
8051
|
+
file: optional(Type.String(), "[query/apply_migration/lint_migrations/lint] Read SQL from local file path (avoids shell escaping issues with $$ and multi-statement DDL)"),
|
|
8052
|
+
dir: optional(Type.String(), "[push_migrations/baseline_migrations/lint_migrations/lint] Directory containing .sql migration files (default: supabase/migrations)"),
|
|
7379
8053
|
dry_run: optional(Type.Boolean(), "[push_migrations/baseline_migrations] Preview changes without applying them"),
|
|
7380
|
-
|
|
8054
|
+
strict: optional(Type.Boolean(), "[push_migrations/lint_migrations/lint] Exit with error if high-risk destructive migrations are detected"),
|
|
8055
|
+
fail_on_high: optional(Type.Boolean(), "[push_migrations/lint_migrations/lint] Alias for --strict"),
|
|
8056
|
+
fail_on_medium: optional(Type.Boolean(), "[push_migrations/lint_migrations/lint] Exit with error if medium-risk or high-risk migrations are detected"),
|
|
8057
|
+
json: optional(Type.Boolean(), "[lint_migrations/lint] Output structured JSON analysis"),
|
|
8058
|
+
schema: optional(Type.String(), "[describe_columns/list_indexes/list_constraints/rls_status/rls_policies/create_table_rls] Schema name (default: public)"),
|
|
7381
8059
|
table: optional(Type.String(), "[describe_columns/indexes/constraints/rls_*] Table name"),
|
|
7382
8060
|
schemas: optional(Type.Array(Type.String()), "[list_tables/generate_types] Schemas array"),
|
|
7383
8061
|
user_id: optional(Type.String(), "[get_auth_user] User UUID"),
|
|
@@ -7388,17 +8066,25 @@ Actions: ${allActions.join(", ")}${readOnly ? " (read-only mode)" : ""}`, {
|
|
|
7388
8066
|
owner_column: optional(Type.String(), "[create_table_rls owner] UUID owner column matched to auth.uid()")
|
|
7389
8067
|
}, async (args) => {
|
|
7390
8068
|
const { action } = args;
|
|
8069
|
+
if (localOnly && action !== "lint_migrations" && action !== "lint") {
|
|
8070
|
+
throw new Error(`Database action '${String(action)}' requires Management API context`);
|
|
8071
|
+
}
|
|
8072
|
+
const managementHttp = () => {
|
|
8073
|
+
if (!http)
|
|
8074
|
+
throw new Error(`Database action '${String(action)}' requires Management API context`);
|
|
8075
|
+
return http;
|
|
8076
|
+
};
|
|
7391
8077
|
const ref = args.ref || projectRef;
|
|
7392
8078
|
const schema = args.schema || "public";
|
|
7393
8079
|
const schemas = args.schemas || ["public"];
|
|
7394
|
-
if (args.file && !args.sql) {
|
|
8080
|
+
if (args.file && !args.sql && action !== "lint_migrations" && action !== "lint") {
|
|
7395
8081
|
try {
|
|
7396
8082
|
args.sql = readFileSync2(args.file, "utf-8");
|
|
7397
8083
|
} catch (e) {
|
|
7398
8084
|
return { content: [{ type: "text", text: `❌ Failed to read file ${args.file}: ${e.message}` }] };
|
|
7399
8085
|
}
|
|
7400
8086
|
}
|
|
7401
|
-
const execSql = async (sql, mode) =>
|
|
8087
|
+
const execSql = async (sql, mode) => managementHttp().post(`/v1/projects/${ref}/database/sql`, mode ? { sql, mode } : { sql });
|
|
7402
8088
|
let text;
|
|
7403
8089
|
switch (action) {
|
|
7404
8090
|
case "query": {
|
|
@@ -7504,11 +8190,11 @@ Actions: ${allActions.join(", ")}${readOnly ? " (read-only mode)" : ""}`, {
|
|
|
7504
8190
|
break;
|
|
7505
8191
|
}
|
|
7506
8192
|
case "migration_inventory": {
|
|
7507
|
-
const response = await
|
|
8193
|
+
const response = await managementHttp().get(migrationInventoryPath(ref), { maxResponseBytes: MAX_MIGRATION_INVENTORY_BYTES });
|
|
7508
8194
|
return migrationInventoryResponse(response);
|
|
7509
8195
|
}
|
|
7510
8196
|
case "project_url": {
|
|
7511
|
-
const r = await
|
|
8197
|
+
const r = await managementHttp().get(`/v1/projects/${ref}`);
|
|
7512
8198
|
text = r.ok ? JSON.stringify({ url: r.data.api?.url || `https://${ref}.supabase.co` }, null, 2) : `❌ Failed (${r.status})`;
|
|
7513
8199
|
break;
|
|
7514
8200
|
}
|
|
@@ -7518,10 +8204,49 @@ Actions: ${allActions.join(", ")}${readOnly ? " (read-only mode)" : ""}`, {
|
|
|
7518
8204
|
text = r.ok ? generateTypeScriptTypes(r.data, schemas) : `❌ Failed (${r.status})`;
|
|
7519
8205
|
break;
|
|
7520
8206
|
}
|
|
8207
|
+
case "lint_migrations":
|
|
8208
|
+
case "lint": {
|
|
8209
|
+
let migrationFiles = [];
|
|
8210
|
+
const explicitInputs = [args.sql, args.file, args.dir].filter((input) => typeof input === "string" && input.length > 0);
|
|
8211
|
+
if (explicitInputs.length > 1) {
|
|
8212
|
+
throw new Error("Use only one of --sql, --file, or --dir for migration lint");
|
|
8213
|
+
}
|
|
8214
|
+
if (args.sql) {
|
|
8215
|
+
migrationFiles = [{ file: "inline.sql", sql: args.sql }];
|
|
8216
|
+
} else if (args.file) {
|
|
8217
|
+
if (!existsSync2(args.file) || !statSync(args.file).isFile()) {
|
|
8218
|
+
throw new Error(`Migration file not found: ${args.file}`);
|
|
8219
|
+
}
|
|
8220
|
+
migrationFiles = [{ file: args.file, sql: readFileSync2(args.file, "utf8") }];
|
|
8221
|
+
} else {
|
|
8222
|
+
const dir = args.dir || "supabase/migrations";
|
|
8223
|
+
if (!existsSync2(dir) || !statSync(dir).isDirectory()) {
|
|
8224
|
+
throw new Error(`Migration directory not found: ${dir}`);
|
|
8225
|
+
}
|
|
8226
|
+
const files = readdirSync(dir).filter((file) => file.endsWith(".sql")).sort();
|
|
8227
|
+
if (!files.length) {
|
|
8228
|
+
text = `No .sql migration files found in ${dir}`;
|
|
8229
|
+
break;
|
|
8230
|
+
}
|
|
8231
|
+
migrationFiles = sortMigrationFiles(files.map((file) => readMigrationFile(dir, file)));
|
|
8232
|
+
}
|
|
8233
|
+
const analysis = analyzeMigrationFiles(migrationFiles);
|
|
8234
|
+
if (args.json) {
|
|
8235
|
+
text = JSON.stringify(analysis, null, 2);
|
|
8236
|
+
} else {
|
|
8237
|
+
text = formatMigrationRiskReport(analysis);
|
|
8238
|
+
}
|
|
8239
|
+
const strict = args.strict === true || args.fail_on_high === true;
|
|
8240
|
+
const failOnMedium = args.fail_on_medium === true;
|
|
8241
|
+
if (strict && analysis.highRiskCount > 0 || failOnMedium && (analysis.highRiskCount > 0 || analysis.mediumRiskCount > 0)) {
|
|
8242
|
+
return { content: [{ type: "text", text }], isError: true };
|
|
8243
|
+
}
|
|
8244
|
+
break;
|
|
8245
|
+
}
|
|
7521
8246
|
case "apply_migration": {
|
|
7522
8247
|
if (!args.name || !args.sql)
|
|
7523
8248
|
throw new Error("'name' and 'sql' required");
|
|
7524
|
-
const r = await
|
|
8249
|
+
const r = await managementHttp().postReleaseMutation(`/v1/projects/${ref}/database/migrations`, { name: args.name, sql: args.sql });
|
|
7525
8250
|
if (!r.ok)
|
|
7526
8251
|
return releaseControlMutationFailure("database.apply_migration", r);
|
|
7527
8252
|
if (r.status !== 200) {
|
|
@@ -7544,14 +8269,18 @@ Actions: ${allActions.join(", ")}${readOnly ? " (read-only mode)" : ""}`, {
|
|
|
7544
8269
|
break;
|
|
7545
8270
|
}
|
|
7546
8271
|
const migrationFiles = sortMigrationFiles(files.map((file) => readMigrationFile(dir, file)));
|
|
7547
|
-
const migrationsResult = await
|
|
8272
|
+
const migrationsResult = await managementHttp().get(`/v1/projects/${ref}/database/migrations`);
|
|
7548
8273
|
if (!migrationsResult.ok) {
|
|
7549
8274
|
return releaseControlFailure("database.push_migrations", "HTTP_ERROR", migrationsResult.transportError ? null : migrationsResult.status);
|
|
7550
8275
|
}
|
|
7551
|
-
const
|
|
8276
|
+
const migrationPlan2 = migrationPushPlan(migrationsResult.data, migrationFiles);
|
|
8277
|
+
const riskAnalysis = analyzeMigrationFiles(migrationPlan2.pending);
|
|
8278
|
+
const strict = args.strict === true || args.fail_on_high === true;
|
|
8279
|
+
const failOnMedium = args.fail_on_medium === true;
|
|
8280
|
+
const riskPolicyFailed = strict && riskAnalysis.highRiskCount > 0 || failOnMedium && (riskAnalysis.highRiskCount > 0 || riskAnalysis.mediumRiskCount > 0);
|
|
7552
8281
|
if (args.dry_run) {
|
|
7553
|
-
const pending2 =
|
|
7554
|
-
const alreadyApplied =
|
|
8282
|
+
const pending2 = migrationPlan2.pending;
|
|
8283
|
+
const alreadyApplied = migrationPlan2.alreadyApplied;
|
|
7555
8284
|
const pendingWithSql = pending2;
|
|
7556
8285
|
let vectorEnabled = null;
|
|
7557
8286
|
if (pendingWithSql.some(({ sql }) => sqlReferencesVector(sql) || sqlCreatesVectorExtension(sql))) {
|
|
@@ -7559,6 +8288,7 @@ Actions: ${allActions.join(", ")}${readOnly ? " (read-only mode)" : ""}`, {
|
|
|
7559
8288
|
vectorEnabled = extResult.ok ? extensionRows(extResult.data).some((row) => row.name === "vector" || row.extname === "vector") : null;
|
|
7560
8289
|
}
|
|
7561
8290
|
const warnings = vectorWarningsForPendingMigrations(pendingWithSql, vectorEnabled);
|
|
8291
|
+
const riskReport = formatMigrationRiskReport(riskAnalysis);
|
|
7562
8292
|
text = [
|
|
7563
8293
|
`Migration dry run for ${dir}`,
|
|
7564
8294
|
`Total: ${migrationFiles.length}`,
|
|
@@ -7568,12 +8298,43 @@ Actions: ${allActions.join(", ")}${readOnly ? " (read-only mode)" : ""}`, {
|
|
|
7568
8298
|
"",
|
|
7569
8299
|
"Already applied:",
|
|
7570
8300
|
...alreadyApplied.length ? alreadyApplied.map(({ file, version }) => ` - ${file} (${version})`) : [" - none"],
|
|
7571
|
-
...warnings.length ? ["", "Warnings:", ...warnings.map((warning) => ` - ${warning}`)] : []
|
|
8301
|
+
...warnings.length ? ["", "Warnings:", ...warnings.map((warning) => ` - ${warning}`)] : [],
|
|
8302
|
+
"",
|
|
8303
|
+
"Risk Assessment:",
|
|
8304
|
+
...riskReport.split(`
|
|
8305
|
+
`).map((line) => ` ${line}`)
|
|
7572
8306
|
].join(`
|
|
7573
8307
|
`);
|
|
7574
|
-
|
|
8308
|
+
return {
|
|
8309
|
+
content: [{ type: "text", text }],
|
|
8310
|
+
...riskPolicyFailed || riskAnalysis.transactionalPushBlockerCount > 0 ? { isError: true } : {}
|
|
8311
|
+
};
|
|
7575
8312
|
}
|
|
7576
|
-
|
|
8313
|
+
if (riskAnalysis.transactionalPushBlockerCount > 0) {
|
|
8314
|
+
const riskReport = formatMigrationRiskReport(riskAnalysis);
|
|
8315
|
+
return {
|
|
8316
|
+
content: [{
|
|
8317
|
+
type: "text",
|
|
8318
|
+
text: `❌ Migration push aborted because the transactional executor cannot run one or more statements:
|
|
8319
|
+
|
|
8320
|
+
${riskReport}`
|
|
8321
|
+
}],
|
|
8322
|
+
isError: true
|
|
8323
|
+
};
|
|
8324
|
+
}
|
|
8325
|
+
if (riskPolicyFailed) {
|
|
8326
|
+
const riskReport = formatMigrationRiskReport(riskAnalysis);
|
|
8327
|
+
return {
|
|
8328
|
+
content: [{
|
|
8329
|
+
type: "text",
|
|
8330
|
+
text: `❌ Migration push aborted due to strict risk policy:
|
|
8331
|
+
|
|
8332
|
+
${riskReport}`
|
|
8333
|
+
}],
|
|
8334
|
+
isError: true
|
|
8335
|
+
};
|
|
8336
|
+
}
|
|
8337
|
+
const pending = new Set(migrationPlan2.pending.map(({ file }) => file));
|
|
7577
8338
|
const applied = [];
|
|
7578
8339
|
const skipped = [];
|
|
7579
8340
|
for (const { file, name, version, sql } of migrationFiles) {
|
|
@@ -7581,7 +8342,7 @@ Actions: ${allActions.join(", ")}${readOnly ? " (read-only mode)" : ""}`, {
|
|
|
7581
8342
|
skipped.push(file);
|
|
7582
8343
|
continue;
|
|
7583
8344
|
}
|
|
7584
|
-
const r = await
|
|
8345
|
+
const r = await managementHttp().postReleaseMutation(`/v1/projects/${ref}/database/migrations`, { name, sql, version });
|
|
7585
8346
|
if (r.ok && r.status === 200 && confirmedMigrationReceipt(r.data, { name, version, sql })) {
|
|
7586
8347
|
applied.push(file);
|
|
7587
8348
|
} else if (isAlreadyAppliedMigrationResponse(r, { name, version, sql })) {
|
|
@@ -7615,13 +8376,13 @@ Actions: ${allActions.join(", ")}${readOnly ? " (read-only mode)" : ""}`, {
|
|
|
7615
8376
|
break;
|
|
7616
8377
|
}
|
|
7617
8378
|
const migrationFiles = sortMigrationFiles(files.map((file) => readMigrationFile(dir, file)));
|
|
7618
|
-
const r = await
|
|
8379
|
+
const r = await managementHttp().get(`/v1/projects/${ref}/database/migrations`);
|
|
7619
8380
|
if (!r.ok) {
|
|
7620
8381
|
return releaseControlFailure("database.baseline_migrations", "HTTP_ERROR", r.transportError ? null : r.status);
|
|
7621
8382
|
}
|
|
7622
|
-
const
|
|
7623
|
-
const missing =
|
|
7624
|
-
const alreadyApplied =
|
|
8383
|
+
const migrationPlan2 = migrationBaselinePlan(r.data, migrationFiles);
|
|
8384
|
+
const missing = migrationPlan2.pending;
|
|
8385
|
+
const alreadyApplied = migrationPlan2.alreadyApplied;
|
|
7625
8386
|
if (args.dry_run) {
|
|
7626
8387
|
text = [
|
|
7627
8388
|
`Migration baseline dry run for ${dir}`,
|
|
@@ -7644,7 +8405,7 @@ Actions: ${allActions.join(", ")}${readOnly ? " (read-only mode)" : ""}`, {
|
|
|
7644
8405
|
`);
|
|
7645
8406
|
break;
|
|
7646
8407
|
}
|
|
7647
|
-
const baselineResult = await
|
|
8408
|
+
const baselineResult = await managementHttp().postReleaseMutation(`/v1/projects/${ref}/database/migrations/baseline`, { migrations: missing.map(({ name, version }) => ({ name, version })) });
|
|
7648
8409
|
if (!baselineResult.ok) {
|
|
7649
8410
|
return releaseControlMutationFailure("database.baseline_migrations", baselineResult);
|
|
7650
8411
|
}
|
|
@@ -7655,7 +8416,7 @@ Actions: ${allActions.join(", ")}${readOnly ? " (read-only mode)" : ""}`, {
|
|
|
7655
8416
|
if (!receipt) {
|
|
7656
8417
|
return releaseControlFailure("database.baseline_migrations", "OUTCOME_UNKNOWN", baselineResult.status);
|
|
7657
8418
|
}
|
|
7658
|
-
const inventoryResult = await
|
|
8419
|
+
const inventoryResult = await managementHttp().get(`/v1/projects/${ref}/database/migrations`, { maxResponseBytes: MAX_MIGRATION_INVENTORY_BYTES });
|
|
7659
8420
|
if (!inventoryResult.ok || !baselineInventoryIsApplied(inventoryResult.data, missing)) {
|
|
7660
8421
|
return releaseControlFailure("database.baseline_migrations", "OUTCOME_UNKNOWN", inventoryResult.transportError ? null : inventoryResult.status);
|
|
7661
8422
|
}
|
|
@@ -7678,7 +8439,7 @@ Actions: ${allActions.join(", ")}${readOnly ? " (read-only mode)" : ""}`, {
|
|
|
7678
8439
|
throw new Error("Invalid RLS policy mode");
|
|
7679
8440
|
const policySql = buildRlsPolicySql(qualifiedTable, policyMode, args.owner_column);
|
|
7680
8441
|
const sql = `BEGIN; CREATE TABLE IF NOT EXISTS ${qualifiedTable} (${columns}); ALTER TABLE ${qualifiedTable} ENABLE ROW LEVEL SECURITY; ${policySql} COMMIT;`;
|
|
7681
|
-
const r = await
|
|
8442
|
+
const r = await managementHttp().postReleaseMutation(`/v1/projects/${ref}/database/sql`, { sql, mode: "migration" });
|
|
7682
8443
|
if (!r.ok)
|
|
7683
8444
|
return releaseControlMutationFailure("database.create_table_rls", r);
|
|
7684
8445
|
if (r.status !== 200) {
|
|
@@ -7687,6 +8448,13 @@ Actions: ${allActions.join(", ")}${readOnly ? " (read-only mode)" : ""}`, {
|
|
|
7687
8448
|
if (!confirmedSqlBatchReceipt(r.data, rlsStatementCommands(policyMode))) {
|
|
7688
8449
|
return releaseControlFailure("database.create_table_rls", "OUTCOME_UNKNOWN", r.status);
|
|
7689
8450
|
}
|
|
8451
|
+
const reloadStatus = schemaReloadStatus(r.data);
|
|
8452
|
+
if (reloadStatus === "notification_failed") {
|
|
8453
|
+
return releaseControlFailure("database.create_table_rls", "PARTIAL_SUCCESS", r.status, { ddl_committed: true, schema_reload: { status: reloadStatus } });
|
|
8454
|
+
}
|
|
8455
|
+
if (reloadStatus !== "notified") {
|
|
8456
|
+
return releaseControlFailure("database.create_table_rls", "OUTCOME_UNKNOWN", r.status);
|
|
8457
|
+
}
|
|
7690
8458
|
text = `✅ Table '${schema}.${args.table}' created with RLS (${policyMode === "owner" ? "auth.uid() owner policy" : "deny-all by default"})`;
|
|
7691
8459
|
break;
|
|
7692
8460
|
}
|
|
@@ -7975,6 +8743,7 @@ var MAX_AUTH_READ_BYTES = 64 * 1024;
|
|
|
7975
8743
|
var AUTH_READ_TIMEOUT_MS = 5000;
|
|
7976
8744
|
var USER_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
7977
8745
|
var AUTH_LINK_TYPES = ["magiclink", "recovery", "invite"];
|
|
8746
|
+
var OAUTH_AUTHORIZATION_PATH_MAX_LENGTH = 2048;
|
|
7978
8747
|
var SAFE_USER_FIELDS = [
|
|
7979
8748
|
"email",
|
|
7980
8749
|
"phone",
|
|
@@ -8044,6 +8813,81 @@ function requiredRef(candidate) {
|
|
|
8044
8813
|
throw new Error("'ref' is required");
|
|
8045
8814
|
return projectRefPathSegment(candidate.trim(), "Auth");
|
|
8046
8815
|
}
|
|
8816
|
+
function optionalAuthorizationPath(candidate) {
|
|
8817
|
+
if (candidate === undefined)
|
|
8818
|
+
return;
|
|
8819
|
+
return boundedText(candidate, "authorization_path", OAUTH_AUTHORIZATION_PATH_MAX_LENGTH);
|
|
8820
|
+
}
|
|
8821
|
+
function oauthServerStatus(value, expectedRef) {
|
|
8822
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
8823
|
+
return null;
|
|
8824
|
+
const status = value;
|
|
8825
|
+
if (status.project_ref !== expectedRef || typeof status.enabled !== "boolean" || typeof status.allow_dynamic_registration !== "boolean" || typeof status.issuer !== "string" || typeof status.signing_alg !== "string" || typeof status.oidc_id_token_ready !== "boolean" || typeof status.migration_status !== "string")
|
|
8826
|
+
return null;
|
|
8827
|
+
return {
|
|
8828
|
+
project_ref: status.project_ref,
|
|
8829
|
+
enabled: status.enabled,
|
|
8830
|
+
allow_dynamic_registration: status.allow_dynamic_registration,
|
|
8831
|
+
issuer: status.issuer,
|
|
8832
|
+
authorization_path: typeof status.authorization_path === "string" ? status.authorization_path : null,
|
|
8833
|
+
signing_alg: status.signing_alg,
|
|
8834
|
+
key_id: typeof status.key_id === "string" ? status.key_id : null,
|
|
8835
|
+
oidc_id_token_ready: status.oidc_id_token_ready,
|
|
8836
|
+
migration_status: status.migration_status
|
|
8837
|
+
};
|
|
8838
|
+
}
|
|
8839
|
+
function oauthServerFailure(operation, response) {
|
|
8840
|
+
return {
|
|
8841
|
+
isError: true,
|
|
8842
|
+
content: [{
|
|
8843
|
+
type: "text",
|
|
8844
|
+
text: JSON.stringify({
|
|
8845
|
+
ok: false,
|
|
8846
|
+
operation,
|
|
8847
|
+
http_status: response.transportError || response.responseReadError ? null : response.status,
|
|
8848
|
+
error: response.responseReadError ? "INVALID_RESPONSE" : response.transportError ? "NETWORK_ERROR" : "HTTP_ERROR"
|
|
8849
|
+
}, null, 2)
|
|
8850
|
+
}]
|
|
8851
|
+
};
|
|
8852
|
+
}
|
|
8853
|
+
async function getOAuthServer(http, ref) {
|
|
8854
|
+
const response = await http.get(`/v1/projects/${ref}/auth/oauth-server`, {
|
|
8855
|
+
maxJsonBytes: MAX_AUTH_READ_BYTES,
|
|
8856
|
+
responseTimeoutMs: AUTH_READ_TIMEOUT_MS
|
|
8857
|
+
});
|
|
8858
|
+
if (!response.ok)
|
|
8859
|
+
return oauthServerFailure("auth.get_oauth_server", response);
|
|
8860
|
+
const status = oauthServerStatus(response.data, ref);
|
|
8861
|
+
if (!status)
|
|
8862
|
+
return oauthServerFailure("auth.get_oauth_server", { ...response, responseReadError: true });
|
|
8863
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: true, operation: "auth.get_oauth_server", ...status }, null, 2) }] };
|
|
8864
|
+
}
|
|
8865
|
+
async function migrateOAuthServer(http, args) {
|
|
8866
|
+
const ref = requiredRef(args.ref);
|
|
8867
|
+
const body = {
|
|
8868
|
+
allow_dynamic_registration: args.allow_dynamic_registration === true
|
|
8869
|
+
};
|
|
8870
|
+
const authorizationPath = optionalAuthorizationPath(args.authorization_path);
|
|
8871
|
+
if (authorizationPath !== undefined)
|
|
8872
|
+
body.authorization_path = authorizationPath;
|
|
8873
|
+
const mutation = await http.postReleaseMutation(`/v1/projects/${ref}/auth/oauth-server/migrate`, body);
|
|
8874
|
+
if (!mutation.ok)
|
|
8875
|
+
return oauthServerFailure("auth.migrate_oauth_server", mutation);
|
|
8876
|
+
const mutationStatus = oauthServerStatus(mutation.data, ref);
|
|
8877
|
+
if (!mutationStatus)
|
|
8878
|
+
return oauthServerFailure("auth.migrate_oauth_server", { ...mutation, responseReadError: true });
|
|
8879
|
+
const read = await http.get(`/v1/projects/${ref}/auth/oauth-server`, {
|
|
8880
|
+
maxJsonBytes: MAX_AUTH_READ_BYTES,
|
|
8881
|
+
responseTimeoutMs: AUTH_READ_TIMEOUT_MS
|
|
8882
|
+
});
|
|
8883
|
+
if (!read.ok)
|
|
8884
|
+
return oauthServerFailure("auth.migrate_oauth_server", read);
|
|
8885
|
+
const status = oauthServerStatus(read.data, ref);
|
|
8886
|
+
if (!status || status.signing_alg !== "ES256" || status.oidc_id_token_ready !== true) {
|
|
8887
|
+
return oauthServerFailure("auth.migrate_oauth_server", { ...read, responseReadError: true });
|
|
8888
|
+
}
|
|
8889
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: true, operation: "auth.migrate_oauth_server", ...status }, null, 2) }] };
|
|
8890
|
+
}
|
|
8047
8891
|
function requiredUserId(candidate) {
|
|
8048
8892
|
if (typeof candidate !== "string" || !USER_ID_PATTERN.test(candidate.trim())) {
|
|
8049
8893
|
throw new Error("'user_id' must be a UUID");
|
|
@@ -8300,7 +9144,7 @@ function formatProviders(data) {
|
|
|
8300
9144
|
}
|
|
8301
9145
|
function registerAuthTools(server, http) {
|
|
8302
9146
|
server.tool("auth", `Auth & OAuth provider management, controlled user lookup, and login-link generation.
|
|
8303
|
-
Actions: list_users, get_user, generate_link, list_providers, get_provider, configure_provider, update_provider, disable_provider, supported_providers, wechat_mini, wechat_open, get_settings, update_settings, get_config, update_config`, {
|
|
9147
|
+
Actions: list_users, get_user, generate_link, list_providers, get_provider, configure_provider, update_provider, disable_provider, supported_providers, wechat_mini, wechat_open, get_settings, update_settings, get_config, update_config, get_oauth_server, migrate_oauth_server`, {
|
|
8304
9148
|
action: withDescription(stringEnum([
|
|
8305
9149
|
"list_users",
|
|
8306
9150
|
"get_user",
|
|
@@ -8316,7 +9160,9 @@ Actions: list_users, get_user, generate_link, list_providers, get_provider, conf
|
|
|
8316
9160
|
"get_settings",
|
|
8317
9161
|
"update_settings",
|
|
8318
9162
|
"get_config",
|
|
8319
|
-
"update_config"
|
|
9163
|
+
"update_config",
|
|
9164
|
+
"get_oauth_server",
|
|
9165
|
+
"migrate_oauth_server"
|
|
8320
9166
|
]), "Action to perform"),
|
|
8321
9167
|
ref: optional(Type.String(), "Project ref (required for most actions)"),
|
|
8322
9168
|
user_id: optional(Type.String(), "[get_user] Exact auth user UUID"),
|
|
@@ -8334,7 +9180,9 @@ Actions: list_users, get_user, generate_link, list_providers, get_provider, conf
|
|
|
8334
9180
|
url: optional(Type.String(), "[configure] Custom OAuth URL"),
|
|
8335
9181
|
app_id: optional(Type.String(), "[wechat_*] WeChat App ID"),
|
|
8336
9182
|
app_secret: optional(Type.String(), "[wechat_*] WeChat App Secret"),
|
|
8337
|
-
config: optional(authConfigSchema, "[update_settings/update_config] Config fields as a JSON object")
|
|
9183
|
+
config: optional(authConfigSchema, "[update_settings/update_config] Config fields as a JSON object"),
|
|
9184
|
+
allow_dynamic_registration: optional(Type.Boolean(), "[migrate_oauth_server] Enable dynamic client registration"),
|
|
9185
|
+
authorization_path: optional(Type.String(), "[migrate_oauth_server] Hosted OAuth authorization path")
|
|
8338
9186
|
}, async (args) => {
|
|
8339
9187
|
const { action, ref, provider, client_id, client_secret, redirect_uri, url, app_id, app_secret, config } = args;
|
|
8340
9188
|
const need = (f) => {
|
|
@@ -8350,6 +9198,10 @@ Actions: list_users, get_user, generate_link, list_providers, get_provider, conf
|
|
|
8350
9198
|
return getUser(http, args);
|
|
8351
9199
|
case "generate_link":
|
|
8352
9200
|
return generateLink(http, args);
|
|
9201
|
+
case "get_oauth_server":
|
|
9202
|
+
return getOAuthServer(http, requiredRef(ref));
|
|
9203
|
+
case "migrate_oauth_server":
|
|
9204
|
+
return migrateOAuthServer(http, args);
|
|
8353
9205
|
case "list_providers":
|
|
8354
9206
|
need("ref");
|
|
8355
9207
|
const lp = await http.get(`/v1/projects/${ref}/auth/providers`);
|
|
@@ -9078,15 +9930,17 @@ import {
|
|
|
9078
9930
|
constants as fsConstants,
|
|
9079
9931
|
existsSync as existsSync3,
|
|
9080
9932
|
fstatSync,
|
|
9933
|
+
lstatSync,
|
|
9081
9934
|
mkdtempSync,
|
|
9082
9935
|
openSync,
|
|
9083
9936
|
readFileSync as readFileSync3,
|
|
9937
|
+
readdirSync as readdirSync2,
|
|
9084
9938
|
rmSync,
|
|
9085
9939
|
statSync as statSync2,
|
|
9086
9940
|
writeFileSync
|
|
9087
9941
|
} from "node:fs";
|
|
9088
9942
|
import { tmpdir } from "node:os";
|
|
9089
|
-
import { basename as basename2, join as join2, resolve as resolve2 } from "node:path";
|
|
9943
|
+
import { basename as basename2, join as join2, relative, resolve as resolve2, sep } from "node:path";
|
|
9090
9944
|
import { promisify } from "node:util";
|
|
9091
9945
|
import { execFile } from "node:child_process";
|
|
9092
9946
|
|
|
@@ -9240,6 +10094,7 @@ function confirmedFunctionDeletion(payload, expectation) {
|
|
|
9240
10094
|
// src/shared/tools/advanced-tools.ts
|
|
9241
10095
|
var execFileAsync = promisify(execFile);
|
|
9242
10096
|
var SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/;
|
|
10097
|
+
var FORBIDDEN_BUNDLE_SEGMENTS = new Set(["node_modules", ".git"]);
|
|
9243
10098
|
function openFileIdentity(descriptor) {
|
|
9244
10099
|
const state = fstatSync(descriptor, { bigint: true });
|
|
9245
10100
|
if (!state.isFile())
|
|
@@ -9294,6 +10149,51 @@ function readVerifiedPrebundledCode(pathArg, expectedSha256) {
|
|
|
9294
10149
|
closeSync(descriptor);
|
|
9295
10150
|
}
|
|
9296
10151
|
}
|
|
10152
|
+
function canonicalBundlePath(root, filePath) {
|
|
10153
|
+
const bundlePath = relative(root, filePath).split(sep).join("/");
|
|
10154
|
+
const segments = bundlePath.split("/");
|
|
10155
|
+
if (!bundlePath || bundlePath.startsWith("/") || segments.some((segment) => !segment || segment === "." || segment === ".." || segment.startsWith("._") || FORBIDDEN_BUNDLE_SEGMENTS.has(segment))) {
|
|
10156
|
+
throw new Error(`Bundle directory contains a forbidden path: ${bundlePath || filePath}`);
|
|
10157
|
+
}
|
|
10158
|
+
return bundlePath;
|
|
10159
|
+
}
|
|
10160
|
+
function readBundleDirectory(root, directory = root) {
|
|
10161
|
+
const files = {};
|
|
10162
|
+
const entries = readdirSync2(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name));
|
|
10163
|
+
for (const entry of entries) {
|
|
10164
|
+
const entryPath = join2(directory, entry.name);
|
|
10165
|
+
const bundlePath = canonicalBundlePath(root, entryPath);
|
|
10166
|
+
const entryState = lstatSync(entryPath);
|
|
10167
|
+
if (entryState.isSymbolicLink())
|
|
10168
|
+
throw new Error(`Bundle directory must not contain symlinks: ${bundlePath}`);
|
|
10169
|
+
if (entryState.isDirectory()) {
|
|
10170
|
+
Object.assign(files, readBundleDirectory(root, entryPath));
|
|
10171
|
+
continue;
|
|
10172
|
+
}
|
|
10173
|
+
if (!entryState.isFile())
|
|
10174
|
+
throw new Error(`Bundle directory must contain only regular files: ${bundlePath}`);
|
|
10175
|
+
files[bundlePath] = verifiedUtf8Code(readFileSync3(entryPath));
|
|
10176
|
+
}
|
|
10177
|
+
return files;
|
|
10178
|
+
}
|
|
10179
|
+
function preparedBundleFiles(args) {
|
|
10180
|
+
const files = args.files;
|
|
10181
|
+
const bundleDirectory = args["bundle-dir"];
|
|
10182
|
+
if (files === undefined === (bundleDirectory === undefined)) {
|
|
10183
|
+
throw new Error("Exactly one of '--files' or '--bundle-dir' is required for 'deploy_bundle'");
|
|
10184
|
+
}
|
|
10185
|
+
if (typeof bundleDirectory !== "string")
|
|
10186
|
+
return files;
|
|
10187
|
+
const resolvedDirectory = resolve2(bundleDirectory);
|
|
10188
|
+
const rootState = lstatSync(resolvedDirectory);
|
|
10189
|
+
if (rootState.isSymbolicLink() || !rootState.isDirectory()) {
|
|
10190
|
+
throw new Error("'--bundle-dir' must be a regular directory and must not be a symlink");
|
|
10191
|
+
}
|
|
10192
|
+
const bundleFiles = readBundleDirectory(resolvedDirectory);
|
|
10193
|
+
if (Object.keys(bundleFiles).length === 0)
|
|
10194
|
+
throw new Error("'--bundle-dir' must not be empty");
|
|
10195
|
+
return bundleFiles;
|
|
10196
|
+
}
|
|
9297
10197
|
async function runBunBuild(args) {
|
|
9298
10198
|
try {
|
|
9299
10199
|
return await execFileAsync("bun", ["build", ...args], { maxBuffer: 10 * 1024 * 1024 });
|
|
@@ -9359,12 +10259,15 @@ async function bundledDeployCode(pathArg) {
|
|
|
9359
10259
|
throw new Error(`Failed to bundle/read path ${pathArg}: ${message}`);
|
|
9360
10260
|
}
|
|
9361
10261
|
}
|
|
9362
|
-
function
|
|
10262
|
+
function rejectActionSpecificFlags(action, args) {
|
|
9363
10263
|
for (const flag of ["prebundled-path", "expected-sha256"]) {
|
|
9364
10264
|
if (action !== "deploy" && args[flag] !== undefined) {
|
|
9365
10265
|
throw new Error(`'--${flag}' is not supported for '${action}'`);
|
|
9366
10266
|
}
|
|
9367
10267
|
}
|
|
10268
|
+
if (action !== "deploy_bundle" && args["bundle-dir"] !== undefined) {
|
|
10269
|
+
throw new Error(`'--bundle-dir' is not supported for '${action}'`);
|
|
10270
|
+
}
|
|
9368
10271
|
}
|
|
9369
10272
|
function resolveEntrypoint(pathArg) {
|
|
9370
10273
|
const resolved = resolve2(pathArg);
|
|
@@ -9794,6 +10697,7 @@ Actions: list, get_config, deploy, deploy_bundle, config, source, activate, dele
|
|
|
9794
10697
|
"expected-sha256": optional(Type.String({ pattern: SHA256_HEX_PATTERN.source, minLength: 64, maxLength: 64 }), "[deploy] Required lowercase SHA-256 of the exact prebundled-path bytes"),
|
|
9795
10698
|
output: optional(Type.String(), "[source] Write source to this local file instead of stdout; the file must not already exist"),
|
|
9796
10699
|
files: optional(functionFilesSchema, "[deploy_bundle] File map as a JSON object: { 'index.ts': '...', '_shared/x.ts': '...' }"),
|
|
10700
|
+
"bundle-dir": optional(Type.String(), "[deploy_bundle] Local self-contained UTF-8 bundle directory; excludes node_modules, .git, AppleDouble, symlinks, and special files"),
|
|
9797
10701
|
entrypoint: optional(Type.String(), "[deploy_bundle] Entrypoint file (default: index.ts)"),
|
|
9798
10702
|
minify: optional(Type.Boolean(), "[deploy/deploy_bundle] Minify bundle"),
|
|
9799
10703
|
verify_jwt: optional(Type.Boolean(), "[deploy/deploy_bundle/config] Set JWT verification for this function"),
|
|
@@ -9803,8 +10707,8 @@ Actions: list, get_config, deploy, deploy_bundle, config, source, activate, dele
|
|
|
9803
10707
|
}, async (args) => {
|
|
9804
10708
|
if (args.action === "activate")
|
|
9805
10709
|
return activateFunctionVersion(http, args, options.readOnly);
|
|
9806
|
-
const { action, ref, slug, path: pathArg, output,
|
|
9807
|
-
|
|
10710
|
+
const { action, ref, slug, path: pathArg, output, entrypoint, minify, verify_jwt, background_routes } = args;
|
|
10711
|
+
rejectActionSpecificFlags(action, args);
|
|
9808
10712
|
const expectedActiveVersion = action === "deploy" || action === "deploy_bundle" ? requiredExpectedActiveVersion(args, action) : undefined;
|
|
9809
10713
|
const expectedActivationId = FUNCTION_IDENTITY_MUTATIONS.has(action) ? requiredExpectedActivationId(args, action) : undefined;
|
|
9810
10714
|
let code = args.code;
|
|
@@ -9884,9 +10788,9 @@ ${deployCheck.err}`;
|
|
|
9884
10788
|
}, deploymentResponse);
|
|
9885
10789
|
case "deploy_bundle":
|
|
9886
10790
|
need("slug", slug);
|
|
9887
|
-
|
|
10791
|
+
const deployBundleFiles = preparedBundleFiles(args);
|
|
9888
10792
|
const bundleResponse = await http.postReleaseMutation(`${edgeFunctionResourcePath(ref, slug)}/bundle`, {
|
|
9889
|
-
files,
|
|
10793
|
+
files: deployBundleFiles,
|
|
9890
10794
|
entrypoint,
|
|
9891
10795
|
minify,
|
|
9892
10796
|
expected_active_version: expectedActiveVersion,
|
|
@@ -11988,33 +12892,33 @@ function registerSupabaseCliTools(server, options = {}) {
|
|
|
11988
12892
|
import {
|
|
11989
12893
|
cpSync,
|
|
11990
12894
|
existsSync as existsSync6,
|
|
11991
|
-
lstatSync,
|
|
12895
|
+
lstatSync as lstatSync2,
|
|
11992
12896
|
mkdirSync as mkdirSync2,
|
|
11993
12897
|
mkdtempSync as mkdtempSync2,
|
|
11994
|
-
readdirSync as
|
|
12898
|
+
readdirSync as readdirSync3,
|
|
11995
12899
|
readFileSync as readFileSync5,
|
|
11996
12900
|
renameSync,
|
|
11997
12901
|
rmSync as rmSync2
|
|
11998
12902
|
} from "node:fs";
|
|
11999
12903
|
import { homedir as homedir2 } from "node:os";
|
|
12000
|
-
import { dirname as dirname2, join as join4, relative, resolve as resolve4, sep } from "node:path";
|
|
12904
|
+
import { dirname as dirname2, join as join4, relative as relative2, resolve as resolve4, sep as sep2 } from "node:path";
|
|
12001
12905
|
import { fileURLToPath } from "node:url";
|
|
12002
12906
|
var SKILL_NAME = "supacloud-cli";
|
|
12003
12907
|
function regularFiles(rootDirectory, currentDirectory = rootDirectory) {
|
|
12004
12908
|
const files = [];
|
|
12005
|
-
for (const directoryEntry of
|
|
12909
|
+
for (const directoryEntry of readdirSync3(currentDirectory, { withFileTypes: true })) {
|
|
12006
12910
|
const entryPath = join4(currentDirectory, directoryEntry.name);
|
|
12007
12911
|
if (directoryEntry.isSymbolicLink())
|
|
12008
12912
|
throw new Error(`Skill directories cannot contain symlinks: ${entryPath}`);
|
|
12009
12913
|
if (directoryEntry.isDirectory())
|
|
12010
12914
|
files.push(...regularFiles(rootDirectory, entryPath));
|
|
12011
12915
|
if (directoryEntry.isFile())
|
|
12012
|
-
files.push(
|
|
12916
|
+
files.push(relative2(rootDirectory, entryPath).split(sep2).join("/"));
|
|
12013
12917
|
}
|
|
12014
12918
|
return files.sort();
|
|
12015
12919
|
}
|
|
12016
12920
|
function directoriesMatch(sourceDirectory, destinationDirectory) {
|
|
12017
|
-
if (!existsSync6(destinationDirectory) || !
|
|
12921
|
+
if (!existsSync6(destinationDirectory) || !lstatSync2(destinationDirectory).isDirectory())
|
|
12018
12922
|
return false;
|
|
12019
12923
|
const sourceFiles = regularFiles(sourceDirectory);
|
|
12020
12924
|
const destinationFiles = regularFiles(destinationDirectory);
|
|
@@ -12844,6 +13748,10 @@ var RELEASE_READ_RESPONSE_TIMEOUT_MS = 5000;
|
|
|
12844
13748
|
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
12845
13749
|
var RELEASE_CANARY_TENANT_KEY = /^release-canary-[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
12846
13750
|
var RELEASE_CANARY_STAGE_RECEIPT_KEYS = new Set(["fixtureId", "tenantKey", "state", "idempotent"]);
|
|
13751
|
+
var RELEASE_CANARY_DISABLE_RECEIPT_KEYS = new Set(["fixtureId", "state", "idempotent"]);
|
|
13752
|
+
var RELEASE_CANARY_DISABLE_RPC_PATH = "/rest/v1/rpc/fa_release_canary_fixture_disable";
|
|
13753
|
+
var RELEASE_CANARY_PENDING_RPC_PATH = "/rest/v1/rpc/fa_release_canary_fixture_pending";
|
|
13754
|
+
var RELEASE_CANARY_CLAIM_MAX_LENGTH = 2048;
|
|
12847
13755
|
function isRecord3(value) {
|
|
12848
13756
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
12849
13757
|
}
|
|
@@ -12993,27 +13901,79 @@ function releaseCanaryStageReceipt(value) {
|
|
|
12993
13901
|
idempotent: true
|
|
12994
13902
|
};
|
|
12995
13903
|
}
|
|
13904
|
+
function releaseCanaryIssuer(value) {
|
|
13905
|
+
if (typeof value !== "string" || value.length === 0 || value.length > RELEASE_CANARY_CLAIM_MAX_LENGTH || value !== value.trim() || /[\u0000-\u001f\u007f]/u.test(value)) {
|
|
13906
|
+
throw new Error("'issuer' must be a bounded absolute HTTP(S) issuer");
|
|
13907
|
+
}
|
|
13908
|
+
let parsed;
|
|
13909
|
+
try {
|
|
13910
|
+
parsed = new URL(value);
|
|
13911
|
+
} catch {
|
|
13912
|
+
throw new Error("'issuer' must be a bounded absolute HTTP(S) issuer");
|
|
13913
|
+
}
|
|
13914
|
+
const loopbackHttp = parsed.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname) && parsed.port.length > 0;
|
|
13915
|
+
if (parsed.protocol !== "https:" && !loopbackHttp || parsed.username || parsed.password || parsed.search || parsed.hash) {
|
|
13916
|
+
throw new Error("'issuer' must be a bounded absolute HTTP(S) issuer");
|
|
13917
|
+
}
|
|
13918
|
+
return value;
|
|
13919
|
+
}
|
|
13920
|
+
function releaseCanaryDisableInput(fixtureId, disableRequestId, issuer, subject) {
|
|
13921
|
+
if (typeof fixtureId !== "string" || !UUID.test(fixtureId)) {
|
|
13922
|
+
throw new Error("'fixture_id' must be a canonical UUID");
|
|
13923
|
+
}
|
|
13924
|
+
if (typeof disableRequestId !== "string" || !UUID.test(disableRequestId)) {
|
|
13925
|
+
throw new Error("'disable_request_id' must be a canonical UUID");
|
|
13926
|
+
}
|
|
13927
|
+
if (typeof subject !== "string" || !UUID.test(subject)) {
|
|
13928
|
+
throw new Error("'subject' must be a canonical UUID");
|
|
13929
|
+
}
|
|
13930
|
+
return {
|
|
13931
|
+
p_fixture_id: fixtureId,
|
|
13932
|
+
p_disable_request_id: disableRequestId,
|
|
13933
|
+
p_issuer: releaseCanaryIssuer(issuer),
|
|
13934
|
+
p_subject: subject
|
|
13935
|
+
};
|
|
13936
|
+
}
|
|
13937
|
+
function releaseCanaryDisableReceipt(receiptCandidate, fixtureId) {
|
|
13938
|
+
if (!isRecord3(receiptCandidate) || Object.keys(receiptCandidate).some((key) => !RELEASE_CANARY_DISABLE_RECEIPT_KEYS.has(key)) || Object.keys(receiptCandidate).length !== RELEASE_CANARY_DISABLE_RECEIPT_KEYS.size || receiptCandidate.fixtureId !== fixtureId || receiptCandidate.state !== "disabled" || typeof receiptCandidate.idempotent !== "boolean")
|
|
13939
|
+
return null;
|
|
13940
|
+
return { fixtureId, state: "disabled", idempotent: receiptCandidate.idempotent };
|
|
13941
|
+
}
|
|
13942
|
+
function releaseCanaryPendingPath(request) {
|
|
13943
|
+
const params = new URLSearchParams({
|
|
13944
|
+
p_issuer: request.p_issuer,
|
|
13945
|
+
p_subject: request.p_subject
|
|
13946
|
+
});
|
|
13947
|
+
return `${RELEASE_CANARY_PENDING_RPC_PATH}?${params.toString()}`;
|
|
13948
|
+
}
|
|
13949
|
+
function releaseCanaryPendingReadback(readbackCandidate) {
|
|
13950
|
+
return readbackCandidate === false;
|
|
13951
|
+
}
|
|
12996
13952
|
async function applicationOriginMatches(http, projectRef2, applicationOrigin) {
|
|
12997
|
-
const endpointRead = await http.get(`${endpoint(projectRef2)}/endpoint/projection`, {
|
|
13953
|
+
const endpointRead = await http.get(`${endpoint(projectRef2)}/endpoint/projection`, { maxJsonBytes: PROJECT_ENDPOINT_RESPONSE_MAX_BYTES, responseTimeoutMs: RELEASE_READ_RESPONSE_TIMEOUT_MS });
|
|
12998
13954
|
return projectApiOrigins(endpointRead, projectRef2)?.includes(applicationOrigin) === true;
|
|
12999
13955
|
}
|
|
13000
13956
|
function registerReleaseTools(server, http, options = {}) {
|
|
13001
|
-
server.tool("release", "Verified release controls. Management actions use the Management API;
|
|
13957
|
+
server.tool("release", "Verified release controls. Management actions use the Management API; release canary stage/disable replay additionally require the selected project's SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY.", {
|
|
13002
13958
|
action: withDescription(stringEnum([
|
|
13003
13959
|
"logical_backup_list",
|
|
13004
13960
|
"logical_backup_create",
|
|
13005
13961
|
"logical_backup_restore",
|
|
13006
13962
|
"postgrest_status",
|
|
13007
13963
|
"postgrest_restart",
|
|
13008
|
-
"release_canary_fixture_stage_replay"
|
|
13964
|
+
"release_canary_fixture_stage_replay",
|
|
13965
|
+
"release_canary_fixture_disable_replay"
|
|
13009
13966
|
]), "Release control action"),
|
|
13010
13967
|
ref: optional(Type.String(), options.projectRef ? "Optional override when not auto-linked" : "Project ref"),
|
|
13011
13968
|
backup_id: optional(Type.String(), "[logical_backup_restore] Exact verified logical-full backup ID from the selected project inventory"),
|
|
13012
13969
|
expected_sha256: optional(Type.String(), "[logical_backup_restore] Exact lowercase SHA-256 from the selected project inventory"),
|
|
13013
13970
|
restore_confirmation: optional(Type.String(), "[logical_backup_restore] Exact RESTORE_PROJECT:<ref>:<backup_id>:<sha256> confirmation"),
|
|
13014
|
-
subject: optional(Type.String(), "[release_canary_fixture_stage_replay] Exact central subject UUID"),
|
|
13015
|
-
request_id: optional(Type.String(), "[release_canary_fixture_stage_replay] Exact idempotent stage request UUID")
|
|
13016
|
-
|
|
13971
|
+
subject: optional(Type.String(), "[release_canary_fixture_stage_replay/disable_replay] Exact central subject UUID"),
|
|
13972
|
+
request_id: optional(Type.String(), "[release_canary_fixture_stage_replay] Exact idempotent stage request UUID"),
|
|
13973
|
+
fixture_id: optional(Type.String(), "[release_canary_fixture_disable_replay] Exact staged fixture UUID"),
|
|
13974
|
+
disable_request_id: optional(Type.String(), "[release_canary_fixture_disable_replay] Exact idempotent disable request UUID"),
|
|
13975
|
+
issuer: optional(Type.String(), "[release_canary_fixture_disable_replay] Exact HTTP(S) issuer")
|
|
13976
|
+
}, async ({ action, ref, backup_id, expected_sha256, restore_confirmation, subject, request_id, fixture_id, disable_request_id, issuer }) => {
|
|
13017
13977
|
const projectRef2 = typeof ref === "string" && ref || options.projectRef;
|
|
13018
13978
|
if (!projectRef2)
|
|
13019
13979
|
throw new Error("'ref' is required for release controls");
|
|
@@ -13105,6 +14065,36 @@ function registerReleaseTools(server, http, options = {}) {
|
|
|
13105
14065
|
receipt
|
|
13106
14066
|
});
|
|
13107
14067
|
}
|
|
14068
|
+
if (action === "release_canary_fixture_disable_replay") {
|
|
14069
|
+
if (!options.applicationHttp || !options.applicationOrigin) {
|
|
14070
|
+
throw new Error("release_canary_fixture_disable_replay requires SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY");
|
|
14071
|
+
}
|
|
14072
|
+
const request = releaseCanaryDisableInput(fixture_id, disable_request_id, issuer, subject);
|
|
14073
|
+
if (!await applicationOriginMatches(http, projectRef2, options.applicationOrigin)) {
|
|
14074
|
+
return releaseControlFailure("release.release_canary.fixture_disable_replay", "INVALID_RESPONSE", null);
|
|
14075
|
+
}
|
|
14076
|
+
const response = await options.applicationHttp.postReleaseMutation(RELEASE_CANARY_DISABLE_RPC_PATH, request);
|
|
14077
|
+
if (!response.ok || response.status !== 200) {
|
|
14078
|
+
return mutationFailure("release.release_canary.fixture_disable_replay", response);
|
|
14079
|
+
}
|
|
14080
|
+
const receipt = releaseCanaryDisableReceipt(response.data, request.p_fixture_id);
|
|
14081
|
+
if (!receipt) {
|
|
14082
|
+
return releaseControlFailure("release.release_canary.fixture_disable_replay", "OUTCOME_UNKNOWN", response.status);
|
|
14083
|
+
}
|
|
14084
|
+
const pendingRequest = {
|
|
14085
|
+
p_issuer: request.p_issuer,
|
|
14086
|
+
p_subject: request.p_subject
|
|
14087
|
+
};
|
|
14088
|
+
const pendingResponse = await options.applicationHttp.get(releaseCanaryPendingPath(pendingRequest), { maxJsonBytes: MUTATION_MAX_BYTES, responseTimeoutMs: RELEASE_READ_RESPONSE_TIMEOUT_MS });
|
|
14089
|
+
if (!pendingResponse.ok || pendingResponse.status !== 200 || !releaseCanaryPendingReadback(pendingResponse.data) || !await applicationOriginMatches(http, projectRef2, options.applicationOrigin)) {
|
|
14090
|
+
return releaseControlFailure("release.release_canary.fixture_disable_replay", "OUTCOME_UNKNOWN", pendingResponse.transportError ? null : pendingResponse.status);
|
|
14091
|
+
}
|
|
14092
|
+
return releaseControlSuccess("release.release_canary.fixture_disable_replay", {
|
|
14093
|
+
project_ref: projectRef2,
|
|
14094
|
+
receipt,
|
|
14095
|
+
pending: false
|
|
14096
|
+
});
|
|
14097
|
+
}
|
|
13108
14098
|
if (action !== "postgrest_restart")
|
|
13109
14099
|
throw new Error("Unknown release control action");
|
|
13110
14100
|
const mutation = await http.postReleaseMutation(`${endpoint(projectRef2)}/services/postgrest/restart`);
|
|
@@ -13125,7 +14115,7 @@ function registerReleaseTools(server, http, options = {}) {
|
|
|
13125
14115
|
// package.json
|
|
13126
14116
|
var package_default = {
|
|
13127
14117
|
name: "@supacloud/cli",
|
|
13128
|
-
version: "0.
|
|
14118
|
+
version: "0.31.0",
|
|
13129
14119
|
description: "Project-scoped CLI for SupaCloud users",
|
|
13130
14120
|
type: "module",
|
|
13131
14121
|
main: "./dist/index.js",
|
|
@@ -13354,8 +14344,8 @@ DEFAULT CONTEXT
|
|
|
13354
14344
|
Without a selector or project variables, runs use the current project's legacy .env.
|
|
13355
14345
|
Application status accepts SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY.
|
|
13356
14346
|
Management-backed project commands require SUPACLOUD_API_URL +
|
|
13357
|
-
SUPACLOUD_API_TOKEN. Only release
|
|
13358
|
-
the selected project's SUPABASE_* pair for
|
|
14347
|
+
SUPACLOUD_API_TOKEN. Only release canary stage/disable replay actions may additionally use
|
|
14348
|
+
the selected project's SUPABASE_* pair for fixed application-origin RPCs.
|
|
13359
14349
|
SUPACLOUD_PROJECT_REF is required when it cannot be inferred from <ref>.api.*.
|
|
13360
14350
|
|
|
13361
14351
|
SUPACLOUD_READ_ONLY=true blocks remote writes. Production writes require an
|
|
@@ -13377,12 +14367,14 @@ EXAMPLES
|
|
|
13377
14367
|
${preferredCommand} release postgrest_status --ref abc123
|
|
13378
14368
|
${preferredCommand} release postgrest_restart --ref abc123
|
|
13379
14369
|
${preferredCommand} release release_canary_fixture_stage_replay --ref abc123 --subject <uuid> --request_id <uuid>
|
|
14370
|
+
${preferredCommand} release release_canary_fixture_disable_replay --ref abc123 --fixture_id <uuid> --disable_request_id <uuid> --issuer <issuer-url> --subject <uuid>
|
|
13380
14371
|
${preferredCommand} queue stats --queue emails
|
|
13381
14372
|
${preferredCommand} queue dlq --queue emails --limit 20
|
|
13382
14373
|
${preferredCommand} frontend list --ref abc123
|
|
13383
14374
|
${preferredCommand} database query --sql "select now()"
|
|
13384
14375
|
${preferredCommand} database query --ref abc123 --file ./queries/vector-search.sql
|
|
13385
14376
|
${preferredCommand} database migration_inventory --ref abc123
|
|
14377
|
+
${preferredCommand} database lint_migrations --dir supabase/migrations
|
|
13386
14378
|
${preferredCommand} database push_migrations --ref abc123 --dir supabase/migrations --dry_run
|
|
13387
14379
|
${preferredCommand} supabase migration_new --name add_accounts
|
|
13388
14380
|
${preferredCommand} supabase db_diff --schema public --name add_accounts
|
|
@@ -13396,6 +14388,7 @@ EXAMPLES
|
|
|
13396
14388
|
${preferredCommand} edge_functions get_config --ref abc123 --slug hello
|
|
13397
14389
|
${preferredCommand} edge_functions deploy --ref abc123 --slug hello --path ./supabase/functions/hello --expected-active-version absent --expected-activation-id legacy
|
|
13398
14390
|
${preferredCommand} edge_functions deploy --ref abc123 --slug hello --prebundled-path ./dist/hello.js --expected-sha256 <sha256> --expected-active-version 4 --expected-activation-id <uuid>
|
|
14391
|
+
${preferredCommand} edge_functions deploy_bundle --ref abc123 --slug supauth --bundle-dir ./artifacts/supacloud-app/function-bundle --entrypoint index.ts --expected-active-version 4 --expected-activation-id <uuid>
|
|
13399
14392
|
${preferredCommand} edge_functions activate --ref abc123 --slug hello --version 3 --expected-active-version 4 --expected-activation-id <uuid>
|
|
13400
14393
|
${preferredCommand} scheduled_functions list --ref abc123
|
|
13401
14394
|
${preferredCommand} mutations status --ref abc123 --mutation_id 00000000-0000-4000-8000-000000000001
|
|
@@ -13498,6 +14491,9 @@ function createCliTools(context, confirmProduction) {
|
|
|
13498
14491
|
};
|
|
13499
14492
|
if (context.credentialScope !== "management" || !context.apiUrl || !context.apiToken) {
|
|
13500
14493
|
registerContextAwareHelp();
|
|
14494
|
+
Object.assign(tools, captureTools((server) => registerDatabaseTools(server, undefined, {
|
|
14495
|
+
localOnly: true
|
|
14496
|
+
})));
|
|
13501
14497
|
tools.setup_help = {
|
|
13502
14498
|
schema: {},
|
|
13503
14499
|
callback: async () => ({
|