@devdogsuga/devtools 0.1.51-next.552 → 0.1.51-next.553
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{cli-DfHsw0fp.js → cli-CzHls8xA.js} +1206 -992
- package/dist/launch.js +1 -1
- package/package.json +3 -3
|
@@ -7002,1063 +7002,1256 @@ const OAUTH = {
|
|
|
7002
7002
|
"callbackPath": "/auth/v1/callback"
|
|
7003
7003
|
};
|
|
7004
7004
|
//#endregion
|
|
7005
|
-
//#region src/product/
|
|
7006
|
-
|
|
7007
|
-
|
|
7008
|
-
|
|
7009
|
-
|
|
7010
|
-
|
|
7011
|
-
|
|
7012
|
-
|
|
7013
|
-
|
|
7014
|
-
|
|
7015
|
-
|
|
7016
|
-
|
|
7017
|
-
]
|
|
7018
|
-
|
|
7019
|
-
|
|
7020
|
-
|
|
7021
|
-
|
|
7022
|
-
|
|
7023
|
-
|
|
7024
|
-
|
|
7025
|
-
|
|
7026
|
-
|
|
7027
|
-
|
|
7028
|
-
|
|
7029
|
-
|
|
7030
|
-
|
|
7031
|
-
|
|
7032
|
-
|
|
7033
|
-
|
|
7034
|
-
|
|
7035
|
-
|
|
7036
|
-
|
|
7037
|
-
|
|
7038
|
-
|
|
7005
|
+
//#region src/product/drift.ts
|
|
7006
|
+
/** Splits SQL at top-level semicolons, honoring quotes, dollar quotes and comments. Comments are dropped. */
|
|
7007
|
+
function splitStatements(sql) {
|
|
7008
|
+
const out = [];
|
|
7009
|
+
let current = "";
|
|
7010
|
+
let i = 0;
|
|
7011
|
+
const push = () => {
|
|
7012
|
+
const text = current.trim();
|
|
7013
|
+
if (text) out.push(text);
|
|
7014
|
+
current = "";
|
|
7015
|
+
};
|
|
7016
|
+
while (i < sql.length) {
|
|
7017
|
+
const ch = sql[i];
|
|
7018
|
+
const next = sql[i + 1];
|
|
7019
|
+
if (ch === "-" && next === "-") {
|
|
7020
|
+
while (i < sql.length && sql[i] !== "\n") i++;
|
|
7021
|
+
continue;
|
|
7022
|
+
}
|
|
7023
|
+
if (ch === "/" && next === "*") {
|
|
7024
|
+
const end = sql.indexOf("*/", i + 2);
|
|
7025
|
+
i = end === -1 ? sql.length : end + 2;
|
|
7026
|
+
current += " ";
|
|
7027
|
+
continue;
|
|
7028
|
+
}
|
|
7029
|
+
if (ch === "'" || ch === "\"") {
|
|
7030
|
+
let j = i + 1;
|
|
7031
|
+
while (j < sql.length) {
|
|
7032
|
+
if (sql[j] === ch) {
|
|
7033
|
+
if (sql[j + 1] === ch) {
|
|
7034
|
+
j += 2;
|
|
7035
|
+
continue;
|
|
7036
|
+
}
|
|
7037
|
+
break;
|
|
7038
|
+
}
|
|
7039
|
+
j++;
|
|
7040
|
+
}
|
|
7041
|
+
current += sql.slice(i, j + 1);
|
|
7042
|
+
i = j + 1;
|
|
7043
|
+
continue;
|
|
7044
|
+
}
|
|
7045
|
+
if (ch === "$") {
|
|
7046
|
+
const tag = /^\$[A-Za-z_]*\$/.exec(sql.slice(i))?.[0];
|
|
7047
|
+
if (tag) {
|
|
7048
|
+
const end = sql.indexOf(tag, i + tag.length);
|
|
7049
|
+
const stop = end === -1 ? sql.length : end + tag.length;
|
|
7050
|
+
current += sql.slice(i, stop);
|
|
7051
|
+
i = stop;
|
|
7052
|
+
continue;
|
|
7053
|
+
}
|
|
7054
|
+
}
|
|
7055
|
+
if (ch === ";") {
|
|
7056
|
+
push();
|
|
7057
|
+
i++;
|
|
7058
|
+
continue;
|
|
7059
|
+
}
|
|
7060
|
+
current += ch;
|
|
7061
|
+
i++;
|
|
7062
|
+
}
|
|
7063
|
+
push();
|
|
7064
|
+
return out;
|
|
7039
7065
|
}
|
|
7040
|
-
|
|
7041
|
-
|
|
7042
|
-
|
|
7043
|
-
|
|
7044
|
-
|
|
7045
|
-
|
|
7046
|
-
|
|
7047
|
-
|
|
7048
|
-
|
|
7049
|
-
|
|
7050
|
-
|
|
7051
|
-
|
|
7052
|
-
|
|
7053
|
-
|
|
7054
|
-
|
|
7055
|
-
|
|
7056
|
-
|
|
7057
|
-
|
|
7058
|
-
|
|
7059
|
-
return
|
|
7066
|
+
/** Splits `text` at commas that are not inside parentheses or quotes. */
|
|
7067
|
+
function splitTopLevel(text) {
|
|
7068
|
+
const parts = [];
|
|
7069
|
+
let depth = 0;
|
|
7070
|
+
let quote = null;
|
|
7071
|
+
let start = 0;
|
|
7072
|
+
for (let i = 0; i < text.length; i++) {
|
|
7073
|
+
const ch = text[i];
|
|
7074
|
+
if (quote) {
|
|
7075
|
+
if (ch === quote) quote = null;
|
|
7076
|
+
} else if (ch === "'" || ch === "\"") quote = ch;
|
|
7077
|
+
else if (ch === "(") depth++;
|
|
7078
|
+
else if (ch === ")") depth--;
|
|
7079
|
+
else if (ch === "," && depth === 0) {
|
|
7080
|
+
parts.push(text.slice(start, i).trim());
|
|
7081
|
+
start = i + 1;
|
|
7082
|
+
}
|
|
7083
|
+
}
|
|
7084
|
+
parts.push(text.slice(start).trim());
|
|
7085
|
+
return parts.filter(Boolean);
|
|
7060
7086
|
}
|
|
7061
|
-
|
|
7062
|
-
|
|
7087
|
+
const IDENT = String.raw`(?:"(?:[^"]|"")+"|[A-Za-z_][\w$]*)`;
|
|
7088
|
+
const QNAME = String.raw`${IDENT}(?:\s*\.\s*${IDENT})*`;
|
|
7089
|
+
/** `"a"."b"` -> ["a", "b"]; unquoted names fold to lower case like Postgres. */
|
|
7090
|
+
function parts(name) {
|
|
7091
|
+
return (name.match(new RegExp(IDENT, "g")) ?? []).map((part) => part.startsWith("\"") ? part.slice(1, -1).replace(/""/g, "\"") : part.toLowerCase());
|
|
7063
7092
|
}
|
|
7064
|
-
|
|
7065
|
-
|
|
7066
|
-
const local = join(root, "node_modules", ".bin", "supabase");
|
|
7067
|
-
if (fileExists(local)) candidates.push({
|
|
7068
|
-
command: local,
|
|
7069
|
-
prefix: [],
|
|
7070
|
-
source: "repo"
|
|
7071
|
-
});
|
|
7072
|
-
candidates.push({
|
|
7073
|
-
command: "supabase",
|
|
7074
|
-
prefix: [],
|
|
7075
|
-
source: "path"
|
|
7076
|
-
});
|
|
7077
|
-
for (const candidate of candidates) try {
|
|
7078
|
-
const result = await run(candidate.command, ["--version"], { cwd: root });
|
|
7079
|
-
if (result.code === 0 && versionOf$1(result.stdout) === pin) return candidate;
|
|
7080
|
-
} catch {}
|
|
7081
|
-
try {
|
|
7082
|
-
if ((await run("pnpm", ["--version"], { cwd: root })).code === 0) return {
|
|
7083
|
-
command: "pnpm",
|
|
7084
|
-
prefix: ["dlx", `supabase@${pin}`],
|
|
7085
|
-
source: "pnpm"
|
|
7086
|
-
};
|
|
7087
|
-
} catch {}
|
|
7088
|
-
return {
|
|
7089
|
-
command: "npx",
|
|
7090
|
-
prefix: ["--yes", `supabase@${pin}`],
|
|
7091
|
-
source: "npx"
|
|
7092
|
-
};
|
|
7093
|
+
function qualified(name) {
|
|
7094
|
+
return parts(name).join(".");
|
|
7093
7095
|
}
|
|
7094
|
-
|
|
7095
|
-
|
|
7096
|
-
|
|
7097
|
-
|
|
7098
|
-
|
|
7099
|
-
|
|
7100
|
-
|
|
7101
|
-
|
|
7102
|
-
|
|
7103
|
-
|
|
7104
|
-
|
|
7105
|
-
|
|
7106
|
-
|
|
7107
|
-
|
|
7096
|
+
function shown(name) {
|
|
7097
|
+
return parts(name).map((part) => `"${part}"`).join(".");
|
|
7098
|
+
}
|
|
7099
|
+
const TYPE_ALIASES = {
|
|
7100
|
+
int: "integer",
|
|
7101
|
+
int4: "integer",
|
|
7102
|
+
int8: "bigint",
|
|
7103
|
+
int2: "smallint",
|
|
7104
|
+
bool: "boolean",
|
|
7105
|
+
float8: "double precision",
|
|
7106
|
+
float4: "real",
|
|
7107
|
+
varchar: "character varying",
|
|
7108
|
+
bpchar: "character",
|
|
7109
|
+
timestamptz: "timestamp with time zone",
|
|
7110
|
+
timetz: "time with time zone",
|
|
7111
|
+
decimal: "numeric"
|
|
7108
7112
|
};
|
|
7109
|
-
/**
|
|
7110
|
-
function
|
|
7111
|
-
|
|
7112
|
-
|
|
7113
|
-
|
|
7114
|
-
|
|
7115
|
-
|
|
7116
|
-
|
|
7117
|
-
|
|
7118
|
-
|
|
7119
|
-
|
|
7120
|
-
}
|
|
7113
|
+
/** `Int4[ ]`, `pg_catalog.text`, `"text"` -> a comparable spelling. */
|
|
7114
|
+
function normalizeType(type) {
|
|
7115
|
+
let t = type.trim().replace(/\s+/g, " ").toLowerCase();
|
|
7116
|
+
const array = /(\s*\[\d*\])+$/.exec(t)?.[0] ?? "";
|
|
7117
|
+
t = t.slice(0, t.length - array.length);
|
|
7118
|
+
t = t.replace(/^pg_catalog\./, "").replace(/"/g, "");
|
|
7119
|
+
t = t.replace(/\s*\(.*\)$/, (m) => m.replace(/\s+/g, ""));
|
|
7120
|
+
const base = /^([a-z0-9_ ]+?)(\(.*\))?$/.exec(t);
|
|
7121
|
+
const head = base?.[1]?.trim() ?? t;
|
|
7122
|
+
const tail = base?.[2] ?? "";
|
|
7123
|
+
return `${TYPE_ALIASES[head] ?? head}${tail}${array.replace(/\s/g, "")}`;
|
|
7121
7124
|
}
|
|
7122
|
-
/**
|
|
7123
|
-
function
|
|
7124
|
-
const
|
|
7125
|
-
|
|
7126
|
-
|
|
7127
|
-
|
|
7125
|
+
/** The text inside the first balanced parentheses of `text`, or null. */
|
|
7126
|
+
function balanced(text) {
|
|
7127
|
+
const start = text.indexOf("(");
|
|
7128
|
+
if (start === -1) return null;
|
|
7129
|
+
let depth = 0;
|
|
7130
|
+
let quote = null;
|
|
7131
|
+
for (let i = start; i < text.length; i++) {
|
|
7132
|
+
const ch = text[i];
|
|
7133
|
+
if (quote) {
|
|
7134
|
+
if (ch === quote) quote = null;
|
|
7135
|
+
} else if (ch === "'" || ch === "\"") quote = ch;
|
|
7136
|
+
else if (ch === "(") depth++;
|
|
7137
|
+
else if (ch === ")" && --depth === 0) return text.slice(start + 1, i);
|
|
7128
7138
|
}
|
|
7139
|
+
return null;
|
|
7129
7140
|
}
|
|
7130
|
-
|
|
7131
|
-
|
|
7132
|
-
|
|
7133
|
-
|
|
7134
|
-
|
|
7135
|
-
|
|
7136
|
-
|
|
7137
|
-
|
|
7138
|
-
|
|
7139
|
-
if (result.code !== 0) throw new TargetError(`The local Supabase stack is not running (${cliLine(cli, ["status"])} failed). Start it with \`devtools supabase start\`, or run \`devtools setup\`.`);
|
|
7140
|
-
const env = parse(result.stdout);
|
|
7141
|
-
const apiUrl = env["API_URL"];
|
|
7142
|
-
const dbUrl = env["DB_URL"];
|
|
7143
|
-
const publishableKey = env["PUBLISHABLE_KEY"] ?? env["ANON_KEY"];
|
|
7144
|
-
const secretKey = env["SERVICE_ROLE_KEY"] ?? env["SECRET_KEY"];
|
|
7145
|
-
if (!apiUrl || !dbUrl || !publishableKey || !secretKey) throw new TargetError("`supabase status -o env` did not report the API URL, DB URL and keys. Is the stack fully started?");
|
|
7146
|
-
return {
|
|
7147
|
-
kind: "local",
|
|
7148
|
-
apiUrl,
|
|
7149
|
-
dbUrl,
|
|
7150
|
-
publishableKey,
|
|
7151
|
-
secretKey
|
|
7152
|
-
};
|
|
7153
|
-
}
|
|
7154
|
-
/** The contributor's own hosted project, from `.env` and `.env.tooling`. */
|
|
7155
|
-
function hostedTarget(product) {
|
|
7156
|
-
const pub = readEnvFile(product.paths.envFile);
|
|
7157
|
-
const tooling = readEnvFile(product.paths.toolingFile);
|
|
7158
|
-
const { keys } = product.config.env;
|
|
7159
|
-
const apiUrl = pub[keys.url];
|
|
7160
|
-
const publishableKey = pub[keys.publishableKey];
|
|
7161
|
-
const dbUrl = tooling[TOOLING_KEYS.dbUrl];
|
|
7162
|
-
const secretKey = tooling[TOOLING_KEYS.secretKey];
|
|
7163
|
-
const missing = [
|
|
7164
|
-
[
|
|
7165
|
-
keys.url,
|
|
7166
|
-
apiUrl,
|
|
7167
|
-
product.config.env.file
|
|
7168
|
-
],
|
|
7169
|
-
[
|
|
7170
|
-
keys.publishableKey,
|
|
7171
|
-
publishableKey,
|
|
7172
|
-
product.config.env.file
|
|
7173
|
-
],
|
|
7174
|
-
[
|
|
7175
|
-
TOOLING_KEYS.dbUrl,
|
|
7176
|
-
dbUrl,
|
|
7177
|
-
product.config.env.tooling
|
|
7178
|
-
],
|
|
7179
|
-
[
|
|
7180
|
-
TOOLING_KEYS.secretKey,
|
|
7181
|
-
secretKey,
|
|
7182
|
-
product.config.env.tooling
|
|
7183
|
-
]
|
|
7184
|
-
].filter(([, value]) => !value);
|
|
7185
|
-
if (missing.length > 0 || !apiUrl || !publishableKey || !dbUrl || !secretKey) throw new TargetError(`A hosted project needs ${missing.map(([key, , file]) => `${String(key)} (in ${String(file)})`).join(", ")}. Run \`devtools setup --hosted\`.`);
|
|
7186
|
-
assertNotProduction(product, apiUrl, dbUrl);
|
|
7187
|
-
return {
|
|
7188
|
-
kind: "hosted",
|
|
7189
|
-
apiUrl,
|
|
7190
|
-
dbUrl,
|
|
7191
|
-
publishableKey,
|
|
7192
|
-
secretKey
|
|
7193
|
-
};
|
|
7194
|
-
}
|
|
7141
|
+
const MULTIWORD_HEADS = /* @__PURE__ */ new Set([
|
|
7142
|
+
"double",
|
|
7143
|
+
"timestamp",
|
|
7144
|
+
"time",
|
|
7145
|
+
"character",
|
|
7146
|
+
"bit",
|
|
7147
|
+
"national",
|
|
7148
|
+
"interval"
|
|
7149
|
+
]);
|
|
7195
7150
|
/**
|
|
7196
|
-
*
|
|
7197
|
-
*
|
|
7198
|
-
* contributor chose their own project) and the local stack when it does not.
|
|
7151
|
+
* The argument types of a function's parameter list, as the database spells
|
|
7152
|
+
* them: `a int, OUT b text, c text DEFAULT 'x'` -> `(integer, text)`.
|
|
7199
7153
|
*/
|
|
7200
|
-
function
|
|
7201
|
-
|
|
7202
|
-
|
|
7203
|
-
|
|
7204
|
-
|
|
7205
|
-
|
|
7154
|
+
function signatureOf(argList) {
|
|
7155
|
+
const types = [];
|
|
7156
|
+
for (let arg of splitTopLevel(argList)) {
|
|
7157
|
+
if (/^(OUT|TABLE)\b/i.test(arg)) continue;
|
|
7158
|
+
arg = arg.replace(/^(?:IN\s+OUT|INOUT|IN|VARIADIC)\s+/i, "");
|
|
7159
|
+
const dflt = /\s+DEFAULT\s|\s*=\s*/i.exec(arg);
|
|
7160
|
+
if (dflt) arg = arg.slice(0, dflt.index);
|
|
7161
|
+
arg = arg.trim();
|
|
7162
|
+
const named = new RegExp(`^(${IDENT})\\s+(.+)$`, "s").exec(arg);
|
|
7163
|
+
let type = arg;
|
|
7164
|
+
if (named) {
|
|
7165
|
+
const head = named[1].toLowerCase();
|
|
7166
|
+
const second = named[2].split(/[\s(]/)[0].toLowerCase();
|
|
7167
|
+
if (!(MULTIWORD_HEADS.has(head) && (/^(precision|with|without|varying)$/.test(second) || named[2].startsWith("(")))) type = named[2];
|
|
7168
|
+
}
|
|
7169
|
+
types.push(normalizeType(type));
|
|
7170
|
+
}
|
|
7171
|
+
return `(${types.join(", ")})`;
|
|
7206
7172
|
}
|
|
7207
|
-
|
|
7208
|
-
|
|
7173
|
+
/** Normalizes the `function:s.n(types)` spelling the database reports. */
|
|
7174
|
+
function normalizeKeySignature(types) {
|
|
7175
|
+
return signatureOf(types);
|
|
7209
7176
|
}
|
|
7210
|
-
|
|
7211
|
-
|
|
7212
|
-
|
|
7213
|
-
|
|
7214
|
-
|
|
7215
|
-
|
|
7216
|
-
|
|
7217
|
-
|
|
7218
|
-
|
|
7219
|
-
|
|
7220
|
-
|
|
7221
|
-
|
|
7222
|
-
|
|
7223
|
-
|
|
7224
|
-
|
|
7225
|
-
|
|
7226
|
-
|
|
7227
|
-
|
|
7228
|
-
|
|
7229
|
-
|
|
7230
|
-
|
|
7231
|
-
|
|
7232
|
-
|
|
7233
|
-
|
|
7234
|
-
|
|
7235
|
-
|
|
7236
|
-
this.name = "DevDataError";
|
|
7237
|
-
}
|
|
7177
|
+
const ALL_PRIVILEGES = {
|
|
7178
|
+
TABLE: [
|
|
7179
|
+
"SELECT",
|
|
7180
|
+
"INSERT",
|
|
7181
|
+
"UPDATE",
|
|
7182
|
+
"DELETE",
|
|
7183
|
+
"TRUNCATE",
|
|
7184
|
+
"REFERENCES",
|
|
7185
|
+
"TRIGGER"
|
|
7186
|
+
],
|
|
7187
|
+
COLUMN: [
|
|
7188
|
+
"SELECT",
|
|
7189
|
+
"INSERT",
|
|
7190
|
+
"UPDATE",
|
|
7191
|
+
"REFERENCES"
|
|
7192
|
+
],
|
|
7193
|
+
SEQUENCE: [
|
|
7194
|
+
"USAGE",
|
|
7195
|
+
"SELECT",
|
|
7196
|
+
"UPDATE"
|
|
7197
|
+
],
|
|
7198
|
+
FUNCTION: ["EXECUTE"],
|
|
7199
|
+
PROCEDURE: ["EXECUTE"],
|
|
7200
|
+
SCHEMA: ["USAGE", "CREATE"],
|
|
7201
|
+
TYPE: ["USAGE"],
|
|
7202
|
+
DOMAIN: ["USAGE"]
|
|
7238
7203
|
};
|
|
7239
|
-
/**
|
|
7240
|
-
|
|
7241
|
-
|
|
7242
|
-
|
|
7243
|
-
|
|
7244
|
-
|
|
7245
|
-
|
|
7246
|
-
|
|
7247
|
-
|
|
7248
|
-
|
|
7249
|
-
|
|
7250
|
-
|
|
7204
|
+
/** Reads what a GRANT or REVOKE is on. */
|
|
7205
|
+
function privilegeOf(text, revoke) {
|
|
7206
|
+
const on = new RegExp(String.raw`\bON\s+(?:(TABLE|SEQUENCE|FUNCTION|PROCEDURE|SCHEMA|TYPE|DOMAIN)\s+)?(${QNAME})(\s*\(.*\))?\s+(?:FROM|TO)\s+(.+)$`, "is").exec(text);
|
|
7207
|
+
const base = {
|
|
7208
|
+
sql: text,
|
|
7209
|
+
revoke,
|
|
7210
|
+
targets: [],
|
|
7211
|
+
base: "",
|
|
7212
|
+
object: null,
|
|
7213
|
+
from: "",
|
|
7214
|
+
kind: "TABLE",
|
|
7215
|
+
units: [],
|
|
7216
|
+
grantees: [],
|
|
7217
|
+
onText: "",
|
|
7218
|
+
option: ""
|
|
7219
|
+
};
|
|
7220
|
+
if (!on) return base;
|
|
7221
|
+
const kind = (on[1] ?? "").toUpperCase();
|
|
7222
|
+
const name = on[2];
|
|
7223
|
+
const isFunction = kind === "FUNCTION" || kind === "PROCEDURE";
|
|
7224
|
+
const sig = isFunction ? signatureOf(balanced(on[3] ?? "") ?? "") : "";
|
|
7225
|
+
const object = `${qualified(name)}${sig}`;
|
|
7226
|
+
const head = text.slice(0, on.index).replace(/^(?:GRANT|REVOKE)\s+(?:GRANT\s+OPTION\s+FOR\s+)?/i, "");
|
|
7227
|
+
const units = [];
|
|
7228
|
+
for (const entry of splitTopLevel(head)) {
|
|
7229
|
+
const word = entry.replace(/\(.*\)/s, "").trim().toUpperCase().replace(/^ALL\s+PRIVILEGES$/, "ALL");
|
|
7230
|
+
const cols = /\(([^)]*)\)/.exec(entry)?.[1];
|
|
7231
|
+
if (cols && !isFunction) for (const c of cols.split(",")) {
|
|
7232
|
+
const column = parts(c)[0];
|
|
7233
|
+
if (column) units.push({
|
|
7234
|
+
priv: word,
|
|
7235
|
+
column
|
|
7236
|
+
});
|
|
7237
|
+
}
|
|
7238
|
+
else if (word) units.push({ priv: word });
|
|
7239
|
+
}
|
|
7240
|
+
const columns = units.flatMap((u) => u.column ? [u.column] : []);
|
|
7241
|
+
const tail = on[4];
|
|
7242
|
+
const option = /\bWITH\s+GRANT\s+OPTION\b/i.test(tail) ? " WITH GRANT OPTION" : "";
|
|
7243
|
+
const who = tail.replace(/\s+WITH\s+GRANT\s+OPTION\b/i, "").replace(/\s+(?:CASCADE|RESTRICT)\s*$/i, "").replace(/\s+GRANTED\s+BY\s+.+$/i, "");
|
|
7244
|
+
return {
|
|
7245
|
+
...base,
|
|
7246
|
+
base: object,
|
|
7247
|
+
targets: columns.length > 0 ? columns.map((c) => `${object}.${c}`) : [object],
|
|
7248
|
+
object: `privileges on ${isFunction ? "function " : ""}${shown(name)}${sig}`,
|
|
7249
|
+
from: tail.replace(/"/g, ""),
|
|
7250
|
+
kind: kind || "TABLE",
|
|
7251
|
+
units,
|
|
7252
|
+
grantees: splitTopLevel(who).map((raw) => {
|
|
7253
|
+
const name = raw.replace(/^GROUP\s+/i, "").trim();
|
|
7254
|
+
return {
|
|
7255
|
+
raw: name,
|
|
7256
|
+
name: /^public$/i.test(name) ? "public" : parts(name)[0] ?? name
|
|
7257
|
+
};
|
|
7258
|
+
}),
|
|
7259
|
+
onText: `ON ${kind ? kind + " " : ""}${name}${on[3] ?? ""}`,
|
|
7260
|
+
option
|
|
7261
|
+
};
|
|
7251
7262
|
}
|
|
7263
|
+
const CREATE_KINDS = {
|
|
7264
|
+
SCHEMA: "schema",
|
|
7265
|
+
TABLE: "table",
|
|
7266
|
+
"FOREIGN TABLE": "table",
|
|
7267
|
+
SEQUENCE: "sequence",
|
|
7268
|
+
TYPE: "type",
|
|
7269
|
+
DOMAIN: "type",
|
|
7270
|
+
VIEW: "view",
|
|
7271
|
+
"MATERIALIZED VIEW": "view",
|
|
7272
|
+
FUNCTION: "function",
|
|
7273
|
+
PROCEDURE: "function"
|
|
7274
|
+
};
|
|
7275
|
+
const WORD = (word) => new RegExp(`^${word}\\b`, "i");
|
|
7252
7276
|
/**
|
|
7253
|
-
*
|
|
7254
|
-
*
|
|
7277
|
+
* Classifies the statements of a live diff. `existing` is what the live
|
|
7278
|
+
* database has (for `CREATE OR REPLACE`, which reads the same whether the
|
|
7279
|
+
* object is missing or merely different).
|
|
7255
7280
|
*/
|
|
7256
|
-
function
|
|
7257
|
-
const
|
|
7258
|
-
const
|
|
7259
|
-
|
|
7260
|
-
|
|
7261
|
-
|
|
7262
|
-
|
|
7263
|
-
|
|
7264
|
-
const
|
|
7265
|
-
|
|
7266
|
-
|
|
7267
|
-
|
|
7268
|
-
|
|
7269
|
-
|
|
7270
|
-
|
|
7271
|
-
|
|
7272
|
-
|
|
7273
|
-
|
|
7274
|
-
|
|
7275
|
-
|
|
7276
|
-
|
|
7277
|
-
|
|
7278
|
-
|
|
7279
|
-
|
|
7280
|
-
|
|
7281
|
-
|
|
7282
|
-
|
|
7283
|
-
|
|
7284
|
-
|
|
7285
|
-
where id::text = ${ref} or lower(email) = lower(${ref})
|
|
7286
|
-
limit 1`)[0];
|
|
7287
|
-
return row ? {
|
|
7288
|
-
id: row.id,
|
|
7289
|
-
email: row.email,
|
|
7290
|
-
lastSignInAt: row.last_sign_in_at?.toISOString() ?? null
|
|
7291
|
-
} : null;
|
|
7292
|
-
},
|
|
7293
|
-
async appliedMigrations() {
|
|
7294
|
-
const [table] = await sql`
|
|
7295
|
-
select to_regclass('supabase_migrations.schema_migrations')::text as present`;
|
|
7296
|
-
if (!table?.present) return null;
|
|
7297
|
-
return (await sql`
|
|
7298
|
-
select version from supabase_migrations.schema_migrations`).map((r) => r.version);
|
|
7299
|
-
},
|
|
7300
|
-
async missingSchemas(schemas) {
|
|
7301
|
-
const rows = await sql`
|
|
7302
|
-
select nspname from pg_namespace where nspname = any(${[...schemas]})`;
|
|
7303
|
-
const present = new Set(rows.map((r) => r.nspname));
|
|
7304
|
-
return schemas.filter((name) => !present.has(name));
|
|
7305
|
-
},
|
|
7306
|
-
async existingObjects(schemas) {
|
|
7307
|
-
const list = [...schemas];
|
|
7308
|
-
const found = /* @__PURE__ */ new Set();
|
|
7309
|
-
const functions = await sql`
|
|
7310
|
-
select n.nspname as s, p.proname as n,
|
|
7311
|
-
oidvectortypes(p.proargtypes) as sig
|
|
7312
|
-
from pg_proc p
|
|
7313
|
-
join pg_namespace n on n.oid = p.pronamespace
|
|
7314
|
-
where n.nspname = any(${list}) and p.prokind in ('f', 'p')`;
|
|
7315
|
-
for (const r of functions) {
|
|
7316
|
-
found.add(`function:${r.s}.${r.n}`);
|
|
7317
|
-
found.add(`signature:${r.s}.${r.n}(${r.sig})`);
|
|
7318
|
-
}
|
|
7319
|
-
const views = await sql`
|
|
7320
|
-
select n.nspname as s, c.relname as n from pg_class c
|
|
7321
|
-
join pg_namespace n on n.oid = c.relnamespace
|
|
7322
|
-
where n.nspname = any(${list}) and c.relkind in ('v', 'm')`;
|
|
7323
|
-
for (const r of views) found.add(`view:${r.s}.${r.n}`);
|
|
7324
|
-
const triggers = await sql`
|
|
7325
|
-
select n.nspname as s, c.relname as t, g.tgname as n from pg_trigger g
|
|
7326
|
-
join pg_class c on c.oid = g.tgrelid
|
|
7327
|
-
join pg_namespace n on n.oid = c.relnamespace
|
|
7328
|
-
where n.nspname = any(${list}) and not g.tgisinternal`;
|
|
7329
|
-
for (const r of triggers) found.add(`trigger:${r.s}.${r.t}:${r.n}`);
|
|
7330
|
-
return found;
|
|
7331
|
-
},
|
|
7332
|
-
async columnTypes(schemas) {
|
|
7333
|
-
const rows = await sql`
|
|
7334
|
-
select n.nspname as s, c.relname as t, a.attname as c,
|
|
7335
|
-
format_type(a.atttypid, a.atttypmod) as type
|
|
7336
|
-
from pg_attribute a
|
|
7337
|
-
join pg_class c on c.oid = a.attrelid
|
|
7338
|
-
join pg_namespace n on n.oid = c.relnamespace
|
|
7339
|
-
where n.nspname = any(${[...schemas]}) and c.relkind in ('r', 'p', 'f')
|
|
7340
|
-
and a.attnum > 0 and not a.attisdropped`;
|
|
7341
|
-
return new Map(rows.map((r) => [`${r.s}.${r.t}.${r.c}`, r.type]));
|
|
7342
|
-
},
|
|
7343
|
-
async exec(text) {
|
|
7344
|
-
await sql.begin(async (tx) => {
|
|
7345
|
-
await tx.unsafe(text);
|
|
7281
|
+
function classifyDiff(sql, existing, columnTypes = /* @__PURE__ */ new Map(), livePrivileges) {
|
|
7282
|
+
const signatures = /* @__PURE__ */ new Set();
|
|
7283
|
+
for (const key of existing) {
|
|
7284
|
+
const m = /^signature:(.*?)\((.*)\)$/.exec(key);
|
|
7285
|
+
if (m) signatures.add(`${m[1]}${normalizeKeySignature(m[2])}`);
|
|
7286
|
+
}
|
|
7287
|
+
const creates = [];
|
|
7288
|
+
const drops = [];
|
|
7289
|
+
const conflicts = /* @__PURE__ */ new Map();
|
|
7290
|
+
const conflicted = /* @__PURE__ */ new Set();
|
|
7291
|
+
const extras = [];
|
|
7292
|
+
const preamble = [];
|
|
7293
|
+
const privileges = [];
|
|
7294
|
+
const conflict = (object, why) => {
|
|
7295
|
+
const set = conflicts.get(object) ?? /* @__PURE__ */ new Set();
|
|
7296
|
+
set.add(why);
|
|
7297
|
+
conflicts.set(object, set);
|
|
7298
|
+
};
|
|
7299
|
+
for (const statement of splitStatements(sql)) {
|
|
7300
|
+
const text = statement.replace(/\s+/g, " ");
|
|
7301
|
+
if (WORD("SET").test(text)) {
|
|
7302
|
+
preamble.push(statement);
|
|
7303
|
+
continue;
|
|
7304
|
+
}
|
|
7305
|
+
if (WORD("GRANT").test(text) || WORD("REVOKE").test(text)) {
|
|
7306
|
+
const p = privilegeOf(text, WORD("REVOKE").test(text));
|
|
7307
|
+
privileges.push({
|
|
7308
|
+
...p,
|
|
7309
|
+
sql: statement
|
|
7346
7310
|
});
|
|
7347
|
-
|
|
7348
|
-
|
|
7349
|
-
|
|
7350
|
-
|
|
7351
|
-
|
|
7311
|
+
continue;
|
|
7312
|
+
}
|
|
7313
|
+
const create = new RegExp(String.raw`^CREATE\s+(OR\s+REPLACE\s+)?(?:UNIQUE\s+)?(?:TEMP(?:ORARY)?\s+)?(SCHEMA|TABLE|FOREIGN TABLE|SEQUENCE|TYPE|DOMAIN|VIEW|MATERIALIZED VIEW|FUNCTION|PROCEDURE|INDEX|POLICY|TRIGGER|CONSTRAINT TRIGGER)\s+(?:IF\s+NOT\s+EXISTS\s+)?(${IDENT}(?:\s*\.\s*${IDENT})*)(.*)$`, "is").exec(text);
|
|
7314
|
+
if (create) {
|
|
7315
|
+
const replace = Boolean(create[1]);
|
|
7316
|
+
const kindWord = create[2].toUpperCase().replace(/\s+/g, " ");
|
|
7317
|
+
const name = create[3];
|
|
7318
|
+
const rest = create[4];
|
|
7319
|
+
let key;
|
|
7320
|
+
let label;
|
|
7321
|
+
if (kindWord === "INDEX") {
|
|
7322
|
+
key = `index:${parts(name).at(-1)}`;
|
|
7323
|
+
label = `index ${shown(name)}`;
|
|
7324
|
+
} else if (kindWord === "POLICY" || kindWord === "TRIGGER" || kindWord === "CONSTRAINT TRIGGER") {
|
|
7325
|
+
const on = new RegExp(String.raw`\bON\s+(${QNAME})`, "i").exec(rest);
|
|
7326
|
+
const table = on ? qualified(on[1]) : "?";
|
|
7327
|
+
const noun = kindWord === "POLICY" ? "policy" : "trigger";
|
|
7328
|
+
key = `${noun}:${table}:${parts(name).at(-1)}`;
|
|
7329
|
+
label = `${noun} ${shown(name)} on ${on ? shown(on[1]) : "?"}`;
|
|
7330
|
+
} else if (kindWord === "FUNCTION" || kindWord === "PROCEDURE") {
|
|
7331
|
+
const sig = signatureOf(balanced(rest) ?? "");
|
|
7332
|
+
key = `function:${qualified(name)}${sig}`;
|
|
7333
|
+
label = `function ${shown(name)}${sig}`;
|
|
7334
|
+
} else {
|
|
7335
|
+
const kind = CREATE_KINDS[kindWord] ?? kindWord.toLowerCase();
|
|
7336
|
+
key = `${kind}:${qualified(name)}`;
|
|
7337
|
+
label = `${kind} ${shown(name)}`;
|
|
7338
|
+
}
|
|
7339
|
+
const exists = key.startsWith("function:") ? signatures.has(key.slice(9)) || signatures.size === 0 && existing.has(`function:${qualified(name)}`) : existing.has(key);
|
|
7340
|
+
if (replace && exists) {
|
|
7341
|
+
conflicted.add(key);
|
|
7342
|
+
conflict(label, "its definition differs from supabase/schemas");
|
|
7343
|
+
} else creates.push({
|
|
7344
|
+
key,
|
|
7345
|
+
sql: statement,
|
|
7346
|
+
label
|
|
7352
7347
|
});
|
|
7353
|
-
|
|
7354
|
-
|
|
7355
|
-
|
|
7356
|
-
|
|
7357
|
-
|
|
7358
|
-
|
|
7359
|
-
|
|
7360
|
-
|
|
7361
|
-
|
|
7362
|
-
|
|
7363
|
-
|
|
7364
|
-
|
|
7365
|
-
|
|
7348
|
+
continue;
|
|
7349
|
+
}
|
|
7350
|
+
const drop = new RegExp(String.raw`^DROP\s+(SCHEMA|TABLE|FOREIGN TABLE|SEQUENCE|TYPE|DOMAIN|VIEW|MATERIALIZED VIEW|FUNCTION|PROCEDURE|INDEX|POLICY|TRIGGER|EXTENSION)\s+(?:IF\s+EXISTS\s+)?(${QNAME})(.*)$`, "is").exec(text);
|
|
7351
|
+
if (drop) {
|
|
7352
|
+
const kindWord = drop[1].toUpperCase();
|
|
7353
|
+
const name = drop[2];
|
|
7354
|
+
const rest = drop[3];
|
|
7355
|
+
if (kindWord === "INDEX") drops.push({
|
|
7356
|
+
key: `index:${parts(name).at(-1)}`,
|
|
7357
|
+
finding: {
|
|
7358
|
+
object: `index ${shown(name)}`,
|
|
7359
|
+
detail: "not in supabase/schemas"
|
|
7360
|
+
}
|
|
7361
|
+
});
|
|
7362
|
+
else if (kindWord === "POLICY" || kindWord === "TRIGGER") {
|
|
7363
|
+
const on = new RegExp(String.raw`\bON\s+(${QNAME})`, "i").exec(rest);
|
|
7364
|
+
const noun = kindWord.toLowerCase();
|
|
7365
|
+
drops.push({
|
|
7366
|
+
key: `${noun}:${on ? qualified(on[1]) : "?"}:${parts(name).at(-1)}`,
|
|
7367
|
+
finding: {
|
|
7368
|
+
object: `${noun} ${shown(name)} on ${on ? shown(on[1]) : "?"}`,
|
|
7369
|
+
detail: "not in supabase/schemas"
|
|
7370
|
+
}
|
|
7371
|
+
});
|
|
7372
|
+
} else if (kindWord === "FUNCTION" || kindWord === "PROCEDURE") {
|
|
7373
|
+
const sig = signatureOf(balanced(rest) ?? "");
|
|
7374
|
+
drops.push({
|
|
7375
|
+
key: `function:${qualified(name)}${sig}`,
|
|
7376
|
+
finding: {
|
|
7377
|
+
object: `function ${shown(name)}${sig}`,
|
|
7378
|
+
detail: "not in supabase/schemas"
|
|
7379
|
+
}
|
|
7380
|
+
});
|
|
7381
|
+
} else {
|
|
7382
|
+
const kind = CREATE_KINDS[kindWord] ?? kindWord.toLowerCase();
|
|
7383
|
+
drops.push({
|
|
7384
|
+
key: `${kind}:${qualified(name)}`,
|
|
7385
|
+
finding: {
|
|
7386
|
+
object: `${kind} ${shown(name)}`,
|
|
7387
|
+
detail: "not in supabase/schemas"
|
|
7388
|
+
}
|
|
7389
|
+
});
|
|
7390
|
+
}
|
|
7391
|
+
continue;
|
|
7392
|
+
}
|
|
7393
|
+
const alterTable = new RegExp(String.raw`^ALTER\s+(?:FOREIGN\s+)?TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?(${QNAME})\s+(.*)$`, "is").exec(text);
|
|
7394
|
+
if (alterTable) {
|
|
7395
|
+
const table = alterTable[1];
|
|
7396
|
+
const t = qualified(table);
|
|
7397
|
+
for (const action of splitTopLevel(alterTable[2])) classifyTableAction(table, t, action, {
|
|
7398
|
+
creates,
|
|
7399
|
+
drops,
|
|
7400
|
+
conflict
|
|
7401
|
+
});
|
|
7402
|
+
continue;
|
|
7403
|
+
}
|
|
7404
|
+
const alterType = new RegExp(String.raw`^ALTER\s+TYPE\s+(${QNAME})\s+ADD\s+VALUE\b`, "i").exec(text);
|
|
7405
|
+
if (alterType) {
|
|
7406
|
+
creates.push({
|
|
7407
|
+
key: `enumvalue:${statement}`,
|
|
7408
|
+
sql: statement,
|
|
7409
|
+
label: `a value of type ${shown(alterType[1])}`
|
|
7410
|
+
});
|
|
7411
|
+
continue;
|
|
7412
|
+
}
|
|
7413
|
+
conflict(text.slice(0, 80), "a difference devtools does not know how to classify, so it will not touch it");
|
|
7366
7414
|
}
|
|
7367
|
-
const
|
|
7368
|
-
const
|
|
7369
|
-
const
|
|
7370
|
-
|
|
7371
|
-
const
|
|
7372
|
-
|
|
7373
|
-
|
|
7374
|
-
|
|
7375
|
-
|
|
7415
|
+
const renamedCreates = /* @__PURE__ */ new Set();
|
|
7416
|
+
const renamedDrops = /* @__PURE__ */ new Set();
|
|
7417
|
+
for (const c of creates) {
|
|
7418
|
+
if (!c.column) continue;
|
|
7419
|
+
const from = drops.find((d) => {
|
|
7420
|
+
if (!d.column || renamedDrops.has(d)) return false;
|
|
7421
|
+
if (d.column.table !== c.column.table) return false;
|
|
7422
|
+
const type = columnTypes.get(`${d.column.table}.${d.column.name}`);
|
|
7423
|
+
return type !== void 0 && normalizeType(type) === c.column.type;
|
|
7424
|
+
});
|
|
7425
|
+
if (!from) continue;
|
|
7426
|
+
renamedCreates.add(c);
|
|
7427
|
+
renamedDrops.add(from);
|
|
7428
|
+
conflict(c.label, `supabase/schemas has it, and your database has ${from.finding.object} of the same type (${c.column.type}); that looks like a rename, so devtools will not add an empty column beside it. If you renamed it on purpose, rename it in supabase/schemas too; otherwise rename it back`);
|
|
7376
7429
|
}
|
|
7377
|
-
|
|
7378
|
-
|
|
7379
|
-
const
|
|
7380
|
-
|
|
7381
|
-
|
|
7382
|
-
|
|
7383
|
-
|
|
7384
|
-
|
|
7430
|
+
const skipped = new Set([...renamedCreates].map((c) => c.key.slice(7)));
|
|
7431
|
+
const liveCreates = creates.filter((c) => !renamedCreates.has(c));
|
|
7432
|
+
const liveDrops = drops.filter((d) => !renamedDrops.has(d));
|
|
7433
|
+
const created = new Set(liveCreates.map((c) => c.key));
|
|
7434
|
+
const dropped = new Set(liveDrops.map((d) => d.key));
|
|
7435
|
+
const restoring = liveCreates.filter((c) => !dropped.has(c.key));
|
|
7436
|
+
for (const d of liveDrops) {
|
|
7437
|
+
if (conflicted.has(d.key)) continue;
|
|
7438
|
+
if (created.has(d.key)) conflict(d.finding.object, "it is defined differently from supabase/schemas");
|
|
7439
|
+
else extras.push(d.finding);
|
|
7385
7440
|
}
|
|
7386
|
-
|
|
7387
|
-
|
|
7388
|
-
|
|
7389
|
-
|
|
7390
|
-
|
|
7391
|
-
|
|
7441
|
+
const newNames = new Set(restoring.map((c) => c.key).filter((key) => !/^(index|policy|trigger|constraint|rls)/.test(key)).map((key) => key.slice(key.indexOf(":") + 1)));
|
|
7442
|
+
const tail = [];
|
|
7443
|
+
const regranted = /* @__PURE__ */ new Set();
|
|
7444
|
+
const grants = /* @__PURE__ */ new Map();
|
|
7445
|
+
const revokes = /* @__PURE__ */ new Map();
|
|
7446
|
+
const grantOf = (target, grantee) => {
|
|
7447
|
+
const key = `${target}|${grantee}`;
|
|
7448
|
+
const set = grants.get(key) ?? /* @__PURE__ */ new Set();
|
|
7449
|
+
grants.set(key, set);
|
|
7450
|
+
return set;
|
|
7451
|
+
};
|
|
7452
|
+
const expand = (p, u) => u.priv === "ALL" ? ALL_PRIVILEGES[u.column ? "COLUMN" : p.kind] ?? [] : [u.priv];
|
|
7453
|
+
const granting = [];
|
|
7454
|
+
for (const p of privileges) {
|
|
7455
|
+
if (p.targets.length > 0 && p.targets.every((t) => skipped.has(t))) continue;
|
|
7456
|
+
if (p.targets.length > 0 && (newNames.has(p.base) || p.targets.every((t) => newNames.has(t)))) {
|
|
7457
|
+
tail.push(p.sql);
|
|
7458
|
+
continue;
|
|
7459
|
+
}
|
|
7460
|
+
if (!p.object || p.grantees.length === 0 || p.units.length === 0) {
|
|
7461
|
+
if (!p.revoke) {
|
|
7462
|
+
tail.push(p.sql);
|
|
7463
|
+
regranted.add(p.object ?? "privileges");
|
|
7464
|
+
} else extras.push({
|
|
7465
|
+
object: p.object ?? "privileges",
|
|
7466
|
+
detail: "differ from what supabase/schemas grants"
|
|
7467
|
+
});
|
|
7468
|
+
continue;
|
|
7469
|
+
}
|
|
7470
|
+
for (const grantee of p.grantees) {
|
|
7471
|
+
for (const u of p.units) {
|
|
7472
|
+
const target = u.column ? `${p.base}.${u.column}` : p.base;
|
|
7473
|
+
if (p.revoke) {
|
|
7474
|
+
const key = `${target}|${grantee.name}`;
|
|
7475
|
+
const entry = revokes.get(key) ?? {
|
|
7476
|
+
p,
|
|
7477
|
+
grantee: grantee.name,
|
|
7478
|
+
target,
|
|
7479
|
+
privs: /* @__PURE__ */ new Set()
|
|
7480
|
+
};
|
|
7481
|
+
for (const priv of u.priv === "ALL" ? ["ALL"] : [u.priv]) entry.privs.add(priv);
|
|
7482
|
+
revokes.set(key, entry);
|
|
7483
|
+
} else for (const priv of expand(p, u)) grantOf(target, grantee.name).add(priv);
|
|
7484
|
+
}
|
|
7485
|
+
if (!p.revoke) granting.push({
|
|
7486
|
+
p,
|
|
7487
|
+
grantee
|
|
7488
|
+
});
|
|
7489
|
+
}
|
|
7490
|
+
}
|
|
7491
|
+
const liveOf = (target, grantee) => livePrivileges ? livePrivileges.get(target)?.get(grantee) ?? /* @__PURE__ */ new Set() : null;
|
|
7492
|
+
const written = /* @__PURE__ */ new Set();
|
|
7493
|
+
for (const { p, grantee } of granting) {
|
|
7494
|
+
const byPriv = [];
|
|
7495
|
+
const byColumn = [];
|
|
7496
|
+
for (const u of p.units) {
|
|
7497
|
+
const live = liveOf(u.column ? `${p.base}.${u.column}` : p.base, grantee.name);
|
|
7498
|
+
for (const priv of expand(p, u)) {
|
|
7499
|
+
if (live?.has(priv)) continue;
|
|
7500
|
+
if (u.column) byColumn.push(`${priv} ("${u.column}")`);
|
|
7501
|
+
else byPriv.push(u.priv === "ALL" ? priv : u.priv);
|
|
7502
|
+
}
|
|
7503
|
+
}
|
|
7504
|
+
const missing = [...new Set(byPriv)];
|
|
7505
|
+
const statements = [...missing.length > 0 ? [`GRANT ${missing.join(", ")} ${p.onText} TO ${grantee.raw}${p.option}`] : [], ...[...new Set(byColumn)].map((c) => `GRANT ${c} ${p.onText} TO ${grantee.raw}${p.option}`)];
|
|
7506
|
+
for (const statement of statements) {
|
|
7507
|
+
if (written.has(statement)) continue;
|
|
7508
|
+
written.add(statement);
|
|
7509
|
+
tail.push(statement);
|
|
7510
|
+
regranted.add(p.object);
|
|
7511
|
+
}
|
|
7392
7512
|
}
|
|
7513
|
+
for (const { p, grantee, target, privs } of revokes.values()) {
|
|
7514
|
+
const granted = grants.get(`${target}|${grantee}`) ?? /* @__PURE__ */ new Set();
|
|
7515
|
+
const live = liveOf(target, grantee);
|
|
7516
|
+
if (live === null) {
|
|
7517
|
+
if (granted.size > 0) continue;
|
|
7518
|
+
extras.push({
|
|
7519
|
+
object: p.object,
|
|
7520
|
+
detail: `granted to ${grantee} beyond (or instead of) what supabase/schemas grants`
|
|
7521
|
+
});
|
|
7522
|
+
continue;
|
|
7523
|
+
}
|
|
7524
|
+
const beyond = (privs.has("ALL") ? [...live] : [...privs]).filter((priv) => live.has(priv) && !granted.has(priv));
|
|
7525
|
+
if (beyond.length === 0) continue;
|
|
7526
|
+
const column = target === p.base ? "" : ` (column "${target.slice(p.base.length + 1)}")`;
|
|
7527
|
+
extras.push({
|
|
7528
|
+
object: p.object,
|
|
7529
|
+
detail: `${beyond.sort().join(", ")}${column} granted to ${grantee} beyond what supabase/schemas grants`
|
|
7530
|
+
});
|
|
7531
|
+
}
|
|
7532
|
+
const restore = [];
|
|
7533
|
+
const restored = [...restoring.map((c) => c.label), ...regranted];
|
|
7534
|
+
if (restoring.length > 0 || tail.length > 0) restore.push(...preamble, ...restoring.map((c) => c.sql), ...tail);
|
|
7393
7535
|
return {
|
|
7394
|
-
|
|
7395
|
-
|
|
7536
|
+
restore,
|
|
7537
|
+
restored,
|
|
7538
|
+
extras: dedupe(extras),
|
|
7539
|
+
conflicts: [...conflicts].map(([object, why]) => ({
|
|
7540
|
+
object,
|
|
7541
|
+
detail: [...why].join("; ")
|
|
7542
|
+
}))
|
|
7396
7543
|
};
|
|
7397
7544
|
}
|
|
7398
|
-
|
|
7399
|
-
|
|
7400
|
-
|
|
7401
|
-
|
|
7402
|
-
|
|
7403
|
-
|
|
7404
|
-
|
|
7405
|
-
|
|
7406
|
-
* on, so registering `custom:devdogsuga` fails without it;
|
|
7407
|
-
* * `uri_allow_list`: the app's return addresses, which the local stack
|
|
7408
|
-
* reads from `supabase/config.toml` instead.
|
|
7409
|
-
*
|
|
7410
|
-
* Both are the Management API's `PATCH /v1/projects/{ref}/config/auth`, with a
|
|
7411
|
-
* personal access token (`SUPABASE_ACCESS_TOKEN`, from the dashboard's Access
|
|
7412
|
-
* Tokens page). Without a token `setup` prints the two dashboard steps.
|
|
7413
|
-
*/
|
|
7414
|
-
const API = "https://api.supabase.com";
|
|
7415
|
-
var ManagementError = class extends Error {
|
|
7416
|
-
status;
|
|
7417
|
-
constructor(message, status) {
|
|
7418
|
-
super(message);
|
|
7419
|
-
this.status = status;
|
|
7420
|
-
this.name = "ManagementError";
|
|
7421
|
-
}
|
|
7422
|
-
};
|
|
7423
|
-
/** The project ref in `https://<ref>.supabase.co`, or null for a custom domain or local URL. */
|
|
7424
|
-
function projectRefOf(apiUrl) {
|
|
7425
|
-
try {
|
|
7426
|
-
return /^([a-z0-9]{20})\.supabase\.co$/.exec(new URL(apiUrl).hostname)?.[1] ?? null;
|
|
7427
|
-
} catch {
|
|
7428
|
-
return null;
|
|
7429
|
-
}
|
|
7430
|
-
}
|
|
7431
|
-
/** The allow list with `wanted` added, keeping what was there; comma-separated as GoTrue stores it. */
|
|
7432
|
-
function mergeAllowList(current, wanted) {
|
|
7433
|
-
const entries = (current ?? "").split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
7434
|
-
for (const uri of wanted) if (!entries.includes(uri)) entries.push(uri);
|
|
7435
|
-
return entries.join(",");
|
|
7545
|
+
function dedupe(findings) {
|
|
7546
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7547
|
+
return findings.filter((f) => {
|
|
7548
|
+
const key = `${f.object}|${f.detail}`;
|
|
7549
|
+
if (seen.has(key)) return false;
|
|
7550
|
+
seen.add(key);
|
|
7551
|
+
return true;
|
|
7552
|
+
});
|
|
7436
7553
|
}
|
|
7437
|
-
|
|
7438
|
-
const
|
|
7439
|
-
|
|
7440
|
-
|
|
7441
|
-
|
|
7442
|
-
|
|
7443
|
-
|
|
7444
|
-
|
|
7445
|
-
|
|
7446
|
-
|
|
7447
|
-
|
|
7448
|
-
|
|
7449
|
-
|
|
7450
|
-
|
|
7451
|
-
|
|
7452
|
-
}
|
|
7453
|
-
|
|
7454
|
-
|
|
7455
|
-
|
|
7456
|
-
changed.push("uri_allow_list");
|
|
7457
|
-
}
|
|
7458
|
-
if (changed.length === 0) return { changed };
|
|
7459
|
-
const response = await doFetch(url, {
|
|
7460
|
-
method: "PATCH",
|
|
7461
|
-
headers,
|
|
7462
|
-
body: JSON.stringify(patch)
|
|
7554
|
+
function classifyTableAction(table, t, action, ctx) {
|
|
7555
|
+
const sql = `ALTER TABLE ${table} ${action}`;
|
|
7556
|
+
let m;
|
|
7557
|
+
if (m = new RegExp(String.raw`^ADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?(${IDENT})`, "i").exec(action)) {
|
|
7558
|
+
const typeText = action.slice(m[0].length).trim().split(/\s+(?:DEFAULT|NOT\s+NULL|NULL|GENERATED|CONSTRAINT|REFERENCES|CHECK|COLLATE|UNIQUE|PRIMARY)\b/i)[0];
|
|
7559
|
+
ctx.creates.push({
|
|
7560
|
+
key: `column:${t}.${parts(m[1])[0]}`,
|
|
7561
|
+
sql,
|
|
7562
|
+
label: `column ${shown(table)}.${shown(m[1])}`,
|
|
7563
|
+
column: {
|
|
7564
|
+
table: t,
|
|
7565
|
+
name: parts(m[1])[0],
|
|
7566
|
+
type: normalizeType(typeText)
|
|
7567
|
+
}
|
|
7568
|
+
});
|
|
7569
|
+
} else if (m = new RegExp(String.raw`^ADD\s+CONSTRAINT\s+(${IDENT})`, "i").exec(action)) ctx.creates.push({
|
|
7570
|
+
key: `constraint:${t}.${parts(m[1])[0]}`,
|
|
7571
|
+
sql,
|
|
7572
|
+
label: `constraint ${shown(m[1])} on ${shown(table)}`
|
|
7463
7573
|
});
|
|
7464
|
-
if (
|
|
7465
|
-
|
|
7574
|
+
else if (m = new RegExp(String.raw`^DROP\s+COLUMN\s+(?:IF\s+EXISTS\s+)?(${IDENT})`, "i").exec(action)) ctx.drops.push({
|
|
7575
|
+
key: `column:${t}.${parts(m[1])[0]}`,
|
|
7576
|
+
finding: {
|
|
7577
|
+
object: `column ${shown(table)}.${shown(m[1])}`,
|
|
7578
|
+
detail: "not in supabase/schemas"
|
|
7579
|
+
},
|
|
7580
|
+
column: {
|
|
7581
|
+
table: t,
|
|
7582
|
+
name: parts(m[1])[0]
|
|
7583
|
+
}
|
|
7584
|
+
});
|
|
7585
|
+
else if (m = new RegExp(String.raw`^DROP\s+CONSTRAINT\s+(?:IF\s+EXISTS\s+)?(${IDENT})`, "i").exec(action)) ctx.drops.push({
|
|
7586
|
+
key: `constraint:${t}.${parts(m[1])[0]}`,
|
|
7587
|
+
finding: {
|
|
7588
|
+
object: `constraint ${shown(m[1])} on ${shown(table)}`,
|
|
7589
|
+
detail: "not in supabase/schemas"
|
|
7590
|
+
}
|
|
7591
|
+
});
|
|
7592
|
+
else if (m = new RegExp(String.raw`^ALTER\s+COLUMN\s+(${IDENT})\s+(.*)$`, "i").exec(action)) {
|
|
7593
|
+
const what = m[2];
|
|
7594
|
+
const aspect = /^(?:SET\s+DATA\s+)?TYPE\b/i.test(what) ? "type" : /\bDEFAULT\b/i.test(what) ? "default" : /\bNOT\s+NULL\b/i.test(what) ? "nullability" : "definition";
|
|
7595
|
+
ctx.conflict(`column ${shown(table)}.${shown(m[1])}`, `its ${aspect} differs from supabase/schemas`);
|
|
7596
|
+
} else if (/^(?:ENABLE|FORCE)\s+ROW\s+LEVEL\s+SECURITY\b/i.test(action)) ctx.creates.push({
|
|
7597
|
+
key: `rls:${t}:${action.toUpperCase()}`,
|
|
7598
|
+
sql,
|
|
7599
|
+
label: `row level security on ${shown(table)}`
|
|
7600
|
+
});
|
|
7601
|
+
else ctx.conflict(`table ${shown(table)}`, `a change devtools will not make for you (${action.slice(0, 60)})`);
|
|
7466
7602
|
}
|
|
7467
7603
|
//#endregion
|
|
7468
|
-
//#region src/product/
|
|
7469
|
-
|
|
7470
|
-
|
|
7471
|
-
|
|
7472
|
-
|
|
7473
|
-
|
|
7474
|
-
|
|
7475
|
-
|
|
7476
|
-
|
|
7477
|
-
|
|
7478
|
-
|
|
7479
|
-
|
|
7480
|
-
|
|
7481
|
-
*/
|
|
7482
|
-
var OAuthRegistrationError = class extends Error {
|
|
7483
|
-
constructor(message) {
|
|
7484
|
-
super(message);
|
|
7485
|
-
this.name = "OAuthRegistrationError";
|
|
7486
|
-
}
|
|
7487
|
-
};
|
|
7488
|
-
function credentialsFromEnv(env) {
|
|
7489
|
-
const clientId = env["DEVDOGS_OAUTH_CLIENT_ID"];
|
|
7490
|
-
const clientSecret = env["DEVDOGS_OAUTH_CLIENT_SECRET"];
|
|
7491
|
-
if (!clientId || !clientSecret) return null;
|
|
7492
|
-
return {
|
|
7493
|
-
clientId,
|
|
7494
|
-
clientSecret,
|
|
7495
|
-
baseUrl: env["DEVDOGS_OAUTH_BASE_URL"]
|
|
7496
|
-
};
|
|
7497
|
-
}
|
|
7498
|
-
/** Registers `custom:devdogsuga` on the target with these credentials. */
|
|
7499
|
-
async function registerProvider(credentialsFile, target, credentials) {
|
|
7500
|
-
const { issuer } = await fetchIssuerFromAny(credentials.baseUrl ? [credentials.baseUrl] : DEFAULT_API_URLS);
|
|
7501
|
-
const row = await upsertDevDogsProvider({
|
|
7502
|
-
apiUrl: target.apiUrl,
|
|
7503
|
-
serviceRoleKey: target.secretKey
|
|
7504
|
-
}, {
|
|
7505
|
-
identifier: OAUTH.providerId,
|
|
7506
|
-
name: PROVIDER_NAME,
|
|
7507
|
-
clientId: credentials.clientId,
|
|
7508
|
-
clientSecret: credentials.clientSecret,
|
|
7509
|
-
issuer
|
|
7604
|
+
//#region src/product/exec.ts
|
|
7605
|
+
const spawnRunner = (command, args, options) => new Promise((resolve, reject) => {
|
|
7606
|
+
const child = spawnProcess(command, args, {
|
|
7607
|
+
cwd: options.cwd,
|
|
7608
|
+
env: {
|
|
7609
|
+
...process.env,
|
|
7610
|
+
...options.env
|
|
7611
|
+
},
|
|
7612
|
+
stdio: options.inherit ? "inherit" : [
|
|
7613
|
+
"ignore",
|
|
7614
|
+
"pipe",
|
|
7615
|
+
"pipe"
|
|
7616
|
+
]
|
|
7510
7617
|
});
|
|
7511
|
-
|
|
7512
|
-
|
|
7513
|
-
|
|
7618
|
+
let stdout = "";
|
|
7619
|
+
let stderr = "";
|
|
7620
|
+
child.stdout?.on("data", (chunk) => {
|
|
7621
|
+
stdout += chunk.toString("utf8");
|
|
7514
7622
|
});
|
|
7515
|
-
|
|
7516
|
-
|
|
7517
|
-
|
|
7518
|
-
|
|
7519
|
-
|
|
7520
|
-
|
|
7521
|
-
|
|
7522
|
-
|
|
7523
|
-
|
|
7524
|
-
|
|
7525
|
-
clientSecret,
|
|
7526
|
-
baseUrl: saved[ENV_KEYS.baseUrl]
|
|
7623
|
+
child.stderr?.on("data", (chunk) => {
|
|
7624
|
+
stderr += chunk.toString("utf8");
|
|
7625
|
+
});
|
|
7626
|
+
child.on("error", reject);
|
|
7627
|
+
child.on("close", (code) => {
|
|
7628
|
+
resolve({
|
|
7629
|
+
code: code ?? 1,
|
|
7630
|
+
stdout,
|
|
7631
|
+
stderr
|
|
7632
|
+
});
|
|
7527
7633
|
});
|
|
7634
|
+
});
|
|
7635
|
+
/** `command arg arg`, quoted only where a shell would need it. For messages. */
|
|
7636
|
+
function commandLine(command, args) {
|
|
7637
|
+
return [command, ...args].map((part) => /^[\w@%+=:,./-]+$/.test(part) ? part : JSON.stringify(part)).join(" ");
|
|
7528
7638
|
}
|
|
7529
7639
|
//#endregion
|
|
7530
|
-
//#region src/product/
|
|
7640
|
+
//#region src/product/supabase-cli.ts
|
|
7531
7641
|
/**
|
|
7532
|
-
*
|
|
7642
|
+
* Finding the Supabase CLI a product is pinned to.
|
|
7533
7643
|
*
|
|
7534
|
-
*
|
|
7535
|
-
*
|
|
7536
|
-
* contributor who cannot (or would rather not) generate them downloads the
|
|
7537
|
-
* asset whose name carries the hash of the schema they have checked out. Two
|
|
7538
|
-
* checkouts with the same hash have the same types, so the hash must change
|
|
7539
|
-
* exactly when the generated output could.
|
|
7644
|
+
* A product repo may have no `package.json` (DogPack has none), so the CLI
|
|
7645
|
+
* cannot be assumed to be a dependency. In order:
|
|
7540
7646
|
*
|
|
7541
|
-
*
|
|
7542
|
-
*
|
|
7543
|
-
*
|
|
7544
|
-
*
|
|
7545
|
-
*
|
|
7546
|
-
*
|
|
7647
|
+
* 1. `node_modules/.bin/supabase` in the repo, when its version is the pin;
|
|
7648
|
+
* 2. a `supabase` on PATH whose version is the pin;
|
|
7649
|
+
* 3. `pnpm dlx supabase@<pin>`, which downloads and caches the right one.
|
|
7650
|
+
* Members are expected to have pnpm (both product repos pin it);
|
|
7651
|
+
* 4. `npx --yes supabase@<pin>`, only when pnpm is not on PATH.
|
|
7652
|
+
*
|
|
7653
|
+
* The pin is also part of the generated-types hash, so a contributor on a
|
|
7654
|
+
* different CLI would generate different types than CI; matching it is not
|
|
7655
|
+
* cosmetic.
|
|
7547
7656
|
*/
|
|
7548
|
-
function
|
|
7549
|
-
|
|
7550
|
-
const found = [];
|
|
7551
|
-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
7552
|
-
const path = join(dir, entry.name);
|
|
7553
|
-
if (entry.isDirectory()) found.push(...listFiles(path));
|
|
7554
|
-
else found.push(path);
|
|
7555
|
-
}
|
|
7556
|
-
return found;
|
|
7557
|
-
}
|
|
7558
|
-
/** The files a hash covers, as POSIX paths relative to the schemas directory, sorted. */
|
|
7559
|
-
function schemaFiles(schemasDir) {
|
|
7560
|
-
return listFiles(schemasDir).map((path) => relative(schemasDir, path).split(sep).join("/")).sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
|
|
7561
|
-
}
|
|
7562
|
-
function normalise(contents) {
|
|
7563
|
-
return Buffer.from(contents.toString("utf8").replace(/\r\n/g, "\n"), "utf8");
|
|
7657
|
+
function cliLine(cli, args) {
|
|
7658
|
+
return commandLine(cli.command, [...cli.prefix, ...args]);
|
|
7564
7659
|
}
|
|
7565
|
-
function
|
|
7566
|
-
|
|
7567
|
-
for (const path of schemaFiles(schemasDir)) {
|
|
7568
|
-
hash.update(`file:${path}\0`);
|
|
7569
|
-
hash.update(normalise(readFileSync(join(schemasDir, ...path.split("/")))));
|
|
7570
|
-
hash.update("\0");
|
|
7571
|
-
}
|
|
7572
|
-
hash.update(`generator:${generator}`);
|
|
7573
|
-
return hash.digest("hex");
|
|
7660
|
+
function versionOf$1(output) {
|
|
7661
|
+
return /\b(\d+\.\d+\.\d+)\b/.exec(output)?.[1] ?? null;
|
|
7574
7662
|
}
|
|
7575
|
-
|
|
7576
|
-
|
|
7577
|
-
|
|
7578
|
-
|
|
7579
|
-
|
|
7580
|
-
|
|
7581
|
-
|
|
7582
|
-
|
|
7583
|
-
|
|
7584
|
-
|
|
7585
|
-
|
|
7586
|
-
|
|
7663
|
+
async function resolveSupabaseCli(root, pin, run, fileExists = existsSync) {
|
|
7664
|
+
const candidates = [];
|
|
7665
|
+
const local = join(root, "node_modules", ".bin", "supabase");
|
|
7666
|
+
if (fileExists(local)) candidates.push({
|
|
7667
|
+
command: local,
|
|
7668
|
+
prefix: [],
|
|
7669
|
+
source: "repo"
|
|
7670
|
+
});
|
|
7671
|
+
candidates.push({
|
|
7672
|
+
command: "supabase",
|
|
7673
|
+
prefix: [],
|
|
7674
|
+
source: "path"
|
|
7675
|
+
});
|
|
7676
|
+
for (const candidate of candidates) try {
|
|
7677
|
+
const result = await run(candidate.command, ["--version"], { cwd: root });
|
|
7678
|
+
if (result.code === 0 && versionOf$1(result.stdout) === pin) return candidate;
|
|
7679
|
+
} catch {}
|
|
7680
|
+
try {
|
|
7681
|
+
if ((await run("pnpm", ["--version"], { cwd: root })).code === 0) return {
|
|
7682
|
+
command: "pnpm",
|
|
7683
|
+
prefix: ["dlx", `supabase@${pin}`],
|
|
7684
|
+
source: "pnpm"
|
|
7685
|
+
};
|
|
7686
|
+
} catch {}
|
|
7687
|
+
return {
|
|
7688
|
+
command: "npx",
|
|
7689
|
+
prefix: ["--yes", `supabase@${pin}`],
|
|
7690
|
+
source: "npx"
|
|
7691
|
+
};
|
|
7692
|
+
}
|
|
7693
|
+
//#endregion
|
|
7694
|
+
//#region src/product/target.ts
|
|
7695
|
+
const TOOLING_KEYS = {
|
|
7696
|
+
/** `local` or `hosted`: what `setup` last chose. Wins over everything else. */
|
|
7697
|
+
target: "DEVTOOLS_TARGET",
|
|
7698
|
+
dbUrl: "DB_URL",
|
|
7699
|
+
secretKey: "SECRET_KEY",
|
|
7700
|
+
accessToken: "SUPABASE_ACCESS_TOKEN"
|
|
7701
|
+
};
|
|
7702
|
+
var TargetError = class extends Error {
|
|
7703
|
+
constructor(message) {
|
|
7704
|
+
super(message);
|
|
7705
|
+
this.name = "TargetError";
|
|
7587
7706
|
}
|
|
7588
|
-
|
|
7589
|
-
|
|
7590
|
-
|
|
7591
|
-
|
|
7707
|
+
};
|
|
7708
|
+
/** The project ref or vanity name in a Supabase URL or pooler URL, if it has one. */
|
|
7709
|
+
function projectNameOf(url) {
|
|
7710
|
+
try {
|
|
7711
|
+
const { hostname, username } = new URL(url);
|
|
7712
|
+
const fromHost = /^([a-z0-9-]+)\.supabase\.co$/.exec(hostname)?.[1];
|
|
7713
|
+
if (fromHost) return fromHost;
|
|
7714
|
+
const fromUser = /^postgres\.([a-z0-9]+)$/.exec(username)?.[1];
|
|
7715
|
+
if (fromUser) return fromUser;
|
|
7716
|
+
return /^db\.([a-z0-9]+)\.supabase\.co$/.exec(hostname)?.[1] ?? null;
|
|
7717
|
+
} catch {
|
|
7718
|
+
return null;
|
|
7592
7719
|
}
|
|
7593
|
-
return "supadart@unknown";
|
|
7594
7720
|
}
|
|
7595
|
-
|
|
7596
|
-
|
|
7721
|
+
/** Refuses a URL that names one of the product's production projects. */
|
|
7722
|
+
function assertNotProduction(product, ...urls) {
|
|
7723
|
+
const production = new Set(product.config.production.projects);
|
|
7724
|
+
for (const url of urls) {
|
|
7725
|
+
const name = projectNameOf(url);
|
|
7726
|
+
if (name !== null && production.has(name)) throw new TargetError(`${url} is ${product.config.name}'s production project (${name}). devtools only acts on your own development project or the local Docker stack.`);
|
|
7727
|
+
}
|
|
7597
7728
|
}
|
|
7598
|
-
/**
|
|
7599
|
-
function
|
|
7600
|
-
|
|
7729
|
+
/** Reads `supabase status -o env` for the running local stack. */
|
|
7730
|
+
async function localTarget(product, cli, run) {
|
|
7731
|
+
const args = [
|
|
7732
|
+
...cli.prefix,
|
|
7733
|
+
"status",
|
|
7734
|
+
"-o",
|
|
7735
|
+
"env"
|
|
7736
|
+
];
|
|
7737
|
+
const result = await run(cli.command, args, { cwd: product.root });
|
|
7738
|
+
if (result.code !== 0) throw new TargetError(`The local Supabase stack is not running (${cliLine(cli, ["status"])} failed). Start it with \`devtools supabase start\`, or run \`devtools setup\`.`);
|
|
7739
|
+
const env = parse(result.stdout);
|
|
7740
|
+
const apiUrl = env["API_URL"];
|
|
7741
|
+
const dbUrl = env["DB_URL"];
|
|
7742
|
+
const publishableKey = env["PUBLISHABLE_KEY"] ?? env["ANON_KEY"];
|
|
7743
|
+
const secretKey = env["SERVICE_ROLE_KEY"] ?? env["SECRET_KEY"];
|
|
7744
|
+
if (!apiUrl || !dbUrl || !publishableKey || !secretKey) throw new TargetError("`supabase status -o env` did not report the API URL, DB URL and keys. Is the stack fully started?");
|
|
7745
|
+
return {
|
|
7746
|
+
kind: "local",
|
|
7747
|
+
apiUrl,
|
|
7748
|
+
dbUrl,
|
|
7749
|
+
publishableKey,
|
|
7750
|
+
secretKey
|
|
7751
|
+
};
|
|
7601
7752
|
}
|
|
7602
|
-
|
|
7603
|
-
|
|
7604
|
-
|
|
7605
|
-
|
|
7606
|
-
const
|
|
7607
|
-
|
|
7608
|
-
|
|
7609
|
-
const
|
|
7610
|
-
|
|
7611
|
-
|
|
7612
|
-
|
|
7753
|
+
/** The contributor's own hosted project, from `.env` and `.env.tooling`. */
|
|
7754
|
+
function hostedTarget(product) {
|
|
7755
|
+
const pub = readEnvFile(product.paths.envFile);
|
|
7756
|
+
const tooling = readEnvFile(product.paths.toolingFile);
|
|
7757
|
+
const { keys } = product.config.env;
|
|
7758
|
+
const apiUrl = pub[keys.url];
|
|
7759
|
+
const publishableKey = pub[keys.publishableKey];
|
|
7760
|
+
const dbUrl = tooling[TOOLING_KEYS.dbUrl];
|
|
7761
|
+
const secretKey = tooling[TOOLING_KEYS.secretKey];
|
|
7762
|
+
const missing = [
|
|
7763
|
+
[
|
|
7764
|
+
keys.url,
|
|
7765
|
+
apiUrl,
|
|
7766
|
+
product.config.env.file
|
|
7767
|
+
],
|
|
7768
|
+
[
|
|
7769
|
+
keys.publishableKey,
|
|
7770
|
+
publishableKey,
|
|
7771
|
+
product.config.env.file
|
|
7772
|
+
],
|
|
7773
|
+
[
|
|
7774
|
+
TOOLING_KEYS.dbUrl,
|
|
7775
|
+
dbUrl,
|
|
7776
|
+
product.config.env.tooling
|
|
7777
|
+
],
|
|
7778
|
+
[
|
|
7779
|
+
TOOLING_KEYS.secretKey,
|
|
7780
|
+
secretKey,
|
|
7781
|
+
product.config.env.tooling
|
|
7782
|
+
]
|
|
7783
|
+
].filter(([, value]) => !value);
|
|
7784
|
+
if (missing.length > 0 || !apiUrl || !publishableKey || !dbUrl || !secretKey) throw new TargetError(`A hosted project needs ${missing.map(([key, , file]) => `${String(key)} (in ${String(file)})`).join(", ")}. Run \`devtools setup --hosted\`.`);
|
|
7785
|
+
assertNotProduction(product, apiUrl, dbUrl);
|
|
7786
|
+
return {
|
|
7787
|
+
kind: "hosted",
|
|
7788
|
+
apiUrl,
|
|
7789
|
+
dbUrl,
|
|
7790
|
+
publishableKey,
|
|
7791
|
+
secretKey
|
|
7613
7792
|
};
|
|
7614
|
-
while (i < sql.length) {
|
|
7615
|
-
const ch = sql[i];
|
|
7616
|
-
const next = sql[i + 1];
|
|
7617
|
-
if (ch === "-" && next === "-") {
|
|
7618
|
-
while (i < sql.length && sql[i] !== "\n") i++;
|
|
7619
|
-
continue;
|
|
7620
|
-
}
|
|
7621
|
-
if (ch === "/" && next === "*") {
|
|
7622
|
-
const end = sql.indexOf("*/", i + 2);
|
|
7623
|
-
i = end === -1 ? sql.length : end + 2;
|
|
7624
|
-
current += " ";
|
|
7625
|
-
continue;
|
|
7626
|
-
}
|
|
7627
|
-
if (ch === "'" || ch === "\"") {
|
|
7628
|
-
let j = i + 1;
|
|
7629
|
-
while (j < sql.length) {
|
|
7630
|
-
if (sql[j] === ch) {
|
|
7631
|
-
if (sql[j + 1] === ch) {
|
|
7632
|
-
j += 2;
|
|
7633
|
-
continue;
|
|
7634
|
-
}
|
|
7635
|
-
break;
|
|
7636
|
-
}
|
|
7637
|
-
j++;
|
|
7638
|
-
}
|
|
7639
|
-
current += sql.slice(i, j + 1);
|
|
7640
|
-
i = j + 1;
|
|
7641
|
-
continue;
|
|
7642
|
-
}
|
|
7643
|
-
if (ch === "$") {
|
|
7644
|
-
const tag = /^\$[A-Za-z_]*\$/.exec(sql.slice(i))?.[0];
|
|
7645
|
-
if (tag) {
|
|
7646
|
-
const end = sql.indexOf(tag, i + tag.length);
|
|
7647
|
-
const stop = end === -1 ? sql.length : end + tag.length;
|
|
7648
|
-
current += sql.slice(i, stop);
|
|
7649
|
-
i = stop;
|
|
7650
|
-
continue;
|
|
7651
|
-
}
|
|
7652
|
-
}
|
|
7653
|
-
if (ch === ";") {
|
|
7654
|
-
push();
|
|
7655
|
-
i++;
|
|
7656
|
-
continue;
|
|
7657
|
-
}
|
|
7658
|
-
current += ch;
|
|
7659
|
-
i++;
|
|
7660
|
-
}
|
|
7661
|
-
push();
|
|
7662
|
-
return out;
|
|
7663
7793
|
}
|
|
7664
|
-
/**
|
|
7665
|
-
|
|
7666
|
-
|
|
7667
|
-
|
|
7668
|
-
|
|
7669
|
-
|
|
7670
|
-
|
|
7671
|
-
|
|
7672
|
-
|
|
7673
|
-
|
|
7674
|
-
|
|
7675
|
-
else if (ch === "(") depth++;
|
|
7676
|
-
else if (ch === ")") depth--;
|
|
7677
|
-
else if (ch === "," && depth === 0) {
|
|
7678
|
-
parts.push(text.slice(start, i).trim());
|
|
7679
|
-
start = i + 1;
|
|
7680
|
-
}
|
|
7681
|
-
}
|
|
7682
|
-
parts.push(text.slice(start).trim());
|
|
7683
|
-
return parts.filter(Boolean);
|
|
7794
|
+
/**
|
|
7795
|
+
* `--local` or `--hosted` if given; else what `setup` recorded in
|
|
7796
|
+
* `.env.tooling`; else hosted when that file carries a `DB_URL` (the
|
|
7797
|
+
* contributor chose their own project) and the local stack when it does not.
|
|
7798
|
+
*/
|
|
7799
|
+
function chooseKind(product, preference) {
|
|
7800
|
+
if (preference) return preference;
|
|
7801
|
+
const tooling = readEnvFile(product.paths.toolingFile);
|
|
7802
|
+
const recorded = tooling[TOOLING_KEYS.target];
|
|
7803
|
+
if (recorded === "local" || recorded === "hosted") return recorded;
|
|
7804
|
+
return tooling[TOOLING_KEYS.dbUrl] ? "hosted" : "local";
|
|
7684
7805
|
}
|
|
7685
|
-
|
|
7686
|
-
|
|
7687
|
-
/** `"a"."b"` -> ["a", "b"]; unquoted names fold to lower case like Postgres. */
|
|
7688
|
-
function parts(name) {
|
|
7689
|
-
return (name.match(new RegExp(IDENT, "g")) ?? []).map((part) => part.startsWith("\"") ? part.slice(1, -1).replace(/""/g, "\"") : part.toLowerCase());
|
|
7806
|
+
async function resolveTarget(product, cli, run, preference) {
|
|
7807
|
+
return chooseKind(product, preference) === "hosted" ? hostedTarget(product) : localTarget(product, cli, run);
|
|
7690
7808
|
}
|
|
7691
|
-
|
|
7692
|
-
|
|
7809
|
+
//#endregion
|
|
7810
|
+
//#region src/product/dev-data.ts
|
|
7811
|
+
/**
|
|
7812
|
+
* `dev-data`: fill a development database with believable rows (DogPack's fake
|
|
7813
|
+
* hosts, and open, full and past sessions for the member's own courses).
|
|
7814
|
+
*
|
|
7815
|
+
* The product supplies the SQL (`devData.file`); devtools supplies who to
|
|
7816
|
+
* run it for. The file reads the signed-in member from a session setting:
|
|
7817
|
+
*
|
|
7818
|
+
* ```sql
|
|
7819
|
+
* select current_setting('devtools.member_id')::uuid;
|
|
7820
|
+
* ```
|
|
7821
|
+
*
|
|
7822
|
+
* `devtools.member_id` is the Supabase `auth.users` id of the member, found as
|
|
7823
|
+
* the most recent sign-in through "Sign in with DevDogs" unless `--member`
|
|
7824
|
+
* names someone. Nothing is seeded until someone has signed in, which is why
|
|
7825
|
+
* this is a command run afterwards and not part of `setup`.
|
|
7826
|
+
*
|
|
7827
|
+
* It refuses any database that is not the local stack or the contributor's own
|
|
7828
|
+
* project, and the SQL runs in one transaction.
|
|
7829
|
+
*/
|
|
7830
|
+
var DevDataError = class extends Error {
|
|
7831
|
+
/** A failure the contributor or the product's SQL caused, not a devtools bug: never sent to Sentry. */
|
|
7832
|
+
expected = true;
|
|
7833
|
+
constructor(message) {
|
|
7834
|
+
super(message);
|
|
7835
|
+
this.name = "DevDataError";
|
|
7836
|
+
}
|
|
7837
|
+
};
|
|
7838
|
+
/**
|
|
7839
|
+
* Whether `err` is the product's own SQL being wrong: a Postgres error whose
|
|
7840
|
+
* SQLSTATE class is a cardinality violation (21), data exception (22),
|
|
7841
|
+
* integrity constraint violation (23) or syntax error or access rule
|
|
7842
|
+
* violation (42). Everything else is not the file's fault and reports as a
|
|
7843
|
+
* normal failure: a refused or dropped connection (class 08 and
|
|
7844
|
+
* `ECONNREFUSED`), an operator shutdown (57, `admin_shutdown`), a server out
|
|
7845
|
+
* of resources (53), a transaction that was rolled back (40), and so on.
|
|
7846
|
+
*/
|
|
7847
|
+
function isSqlFileError(err) {
|
|
7848
|
+
const code = err?.code;
|
|
7849
|
+
return typeof code === "string" && /^(21|22|23|42)[0-9A-Z]{3}$/.test(code);
|
|
7693
7850
|
}
|
|
7694
|
-
|
|
7695
|
-
|
|
7851
|
+
/**
|
|
7852
|
+
* The line of `text` a Postgres error points at, from the error's 1-based
|
|
7853
|
+
* character `position`, or null when it carries none.
|
|
7854
|
+
*/
|
|
7855
|
+
function errorLine(err, text) {
|
|
7856
|
+
const raw = err?.position;
|
|
7857
|
+
const position = typeof raw === "string" ? Number(raw) : raw;
|
|
7858
|
+
if (typeof position !== "number" || !Number.isInteger(position)) return null;
|
|
7859
|
+
if (position < 1 || position > text.length + 1) return null;
|
|
7860
|
+
return text.slice(0, position - 1).split("\n").length;
|
|
7696
7861
|
}
|
|
7697
|
-
|
|
7698
|
-
|
|
7699
|
-
|
|
7700
|
-
|
|
7701
|
-
|
|
7702
|
-
|
|
7703
|
-
|
|
7704
|
-
|
|
7705
|
-
|
|
7706
|
-
|
|
7707
|
-
|
|
7708
|
-
|
|
7709
|
-
|
|
7710
|
-
|
|
7711
|
-
|
|
7712
|
-
|
|
7713
|
-
|
|
7714
|
-
|
|
7715
|
-
|
|
7716
|
-
|
|
7717
|
-
|
|
7718
|
-
|
|
7719
|
-
|
|
7720
|
-
|
|
7721
|
-
|
|
7862
|
+
function postgresSession(dbUrl) {
|
|
7863
|
+
const sql = postgres(dbUrl, {
|
|
7864
|
+
max: 1,
|
|
7865
|
+
onnotice: () => void 0
|
|
7866
|
+
});
|
|
7867
|
+
return {
|
|
7868
|
+
async members(provider) {
|
|
7869
|
+
return (await sql`
|
|
7870
|
+
select u.id, u.email, u.last_sign_in_at
|
|
7871
|
+
from auth.users u
|
|
7872
|
+
join auth.identities i on i.user_id = u.id
|
|
7873
|
+
where i.provider = ${provider}
|
|
7874
|
+
order by u.last_sign_in_at desc nulls last
|
|
7875
|
+
limit 5`).map((r) => ({
|
|
7876
|
+
id: r.id,
|
|
7877
|
+
email: r.email,
|
|
7878
|
+
lastSignInAt: r.last_sign_in_at?.toISOString() ?? null
|
|
7879
|
+
}));
|
|
7880
|
+
},
|
|
7881
|
+
async memberByRef(ref) {
|
|
7882
|
+
const row = (await sql`
|
|
7883
|
+
select id, email, last_sign_in_at from auth.users
|
|
7884
|
+
where id::text = ${ref} or lower(email) = lower(${ref})
|
|
7885
|
+
limit 1`)[0];
|
|
7886
|
+
return row ? {
|
|
7887
|
+
id: row.id,
|
|
7888
|
+
email: row.email,
|
|
7889
|
+
lastSignInAt: row.last_sign_in_at?.toISOString() ?? null
|
|
7890
|
+
} : null;
|
|
7891
|
+
},
|
|
7892
|
+
async appliedMigrations() {
|
|
7893
|
+
const [table] = await sql`
|
|
7894
|
+
select to_regclass('supabase_migrations.schema_migrations')::text as present`;
|
|
7895
|
+
if (!table?.present) return null;
|
|
7896
|
+
return (await sql`
|
|
7897
|
+
select version from supabase_migrations.schema_migrations`).map((r) => r.version);
|
|
7898
|
+
},
|
|
7899
|
+
async missingSchemas(schemas) {
|
|
7900
|
+
const rows = await sql`
|
|
7901
|
+
select nspname from pg_namespace where nspname = any(${[...schemas]})`;
|
|
7902
|
+
const present = new Set(rows.map((r) => r.nspname));
|
|
7903
|
+
return schemas.filter((name) => !present.has(name));
|
|
7904
|
+
},
|
|
7905
|
+
async existingObjects(schemas) {
|
|
7906
|
+
const list = [...schemas];
|
|
7907
|
+
const found = /* @__PURE__ */ new Set();
|
|
7908
|
+
const functions = await sql`
|
|
7909
|
+
select n.nspname as s, p.proname as n,
|
|
7910
|
+
oidvectortypes(p.proargtypes) as sig
|
|
7911
|
+
from pg_proc p
|
|
7912
|
+
join pg_namespace n on n.oid = p.pronamespace
|
|
7913
|
+
where n.nspname = any(${list}) and p.prokind in ('f', 'p')`;
|
|
7914
|
+
for (const r of functions) {
|
|
7915
|
+
found.add(`function:${r.s}.${r.n}`);
|
|
7916
|
+
found.add(`signature:${r.s}.${r.n}(${r.sig})`);
|
|
7917
|
+
}
|
|
7918
|
+
const views = await sql`
|
|
7919
|
+
select n.nspname as s, c.relname as n from pg_class c
|
|
7920
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
7921
|
+
where n.nspname = any(${list}) and c.relkind in ('v', 'm')`;
|
|
7922
|
+
for (const r of views) found.add(`view:${r.s}.${r.n}`);
|
|
7923
|
+
const triggers = await sql`
|
|
7924
|
+
select n.nspname as s, c.relname as t, g.tgname as n from pg_trigger g
|
|
7925
|
+
join pg_class c on c.oid = g.tgrelid
|
|
7926
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
7927
|
+
where n.nspname = any(${list}) and not g.tgisinternal`;
|
|
7928
|
+
for (const r of triggers) found.add(`trigger:${r.s}.${r.t}:${r.n}`);
|
|
7929
|
+
return found;
|
|
7930
|
+
},
|
|
7931
|
+
async columnTypes(schemas) {
|
|
7932
|
+
const rows = await sql`
|
|
7933
|
+
select n.nspname as s, c.relname as t, a.attname as c,
|
|
7934
|
+
format_type(a.atttypid, a.atttypmod) as type
|
|
7935
|
+
from pg_attribute a
|
|
7936
|
+
join pg_class c on c.oid = a.attrelid
|
|
7937
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
7938
|
+
where n.nspname = any(${[...schemas]}) and c.relkind in ('r', 'p', 'f')
|
|
7939
|
+
and a.attnum > 0 and not a.attisdropped`;
|
|
7940
|
+
return new Map(rows.map((r) => [`${r.s}.${r.t}.${r.c}`, r.type]));
|
|
7941
|
+
},
|
|
7942
|
+
async privileges(schemas) {
|
|
7943
|
+
const list = [...schemas];
|
|
7944
|
+
const out = /* @__PURE__ */ new Map();
|
|
7945
|
+
const add = (key, role, priv) => {
|
|
7946
|
+
const roles = out.get(key) ?? /* @__PURE__ */ new Map();
|
|
7947
|
+
out.set(key, roles);
|
|
7948
|
+
const name = role ?? "public";
|
|
7949
|
+
const set = roles.get(name) ?? /* @__PURE__ */ new Set();
|
|
7950
|
+
roles.set(name, set);
|
|
7951
|
+
set.add(priv);
|
|
7952
|
+
};
|
|
7953
|
+
const relations = await sql`
|
|
7954
|
+
select n.nspname || '.' || c.relname as key, r.rolname as role,
|
|
7955
|
+
a.privilege_type as priv
|
|
7956
|
+
from pg_class c
|
|
7957
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
7958
|
+
cross join lateral aclexplode(coalesce(c.relacl, acldefault(
|
|
7959
|
+
case when c.relkind = 'S' then 's'::"char" else 'r'::"char" end,
|
|
7960
|
+
c.relowner))) a
|
|
7961
|
+
left join pg_roles r on r.oid = a.grantee
|
|
7962
|
+
where n.nspname = any(${list})
|
|
7963
|
+
and c.relkind in ('r', 'p', 'v', 'm', 'f', 'S')`;
|
|
7964
|
+
for (const r of relations) add(r.key, r.role, r.priv);
|
|
7965
|
+
const columns = await sql`
|
|
7966
|
+
select n.nspname || '.' || c.relname || '.' || t.attname as key,
|
|
7967
|
+
r.rolname as role, a.privilege_type as priv
|
|
7968
|
+
from pg_attribute t
|
|
7969
|
+
join pg_class c on c.oid = t.attrelid
|
|
7970
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
7971
|
+
cross join lateral aclexplode(t.attacl) a
|
|
7972
|
+
left join pg_roles r on r.oid = a.grantee
|
|
7973
|
+
where n.nspname = any(${list}) and t.attacl is not null
|
|
7974
|
+
and t.attnum > 0 and not t.attisdropped`;
|
|
7975
|
+
for (const r of columns) add(r.key, r.role, r.priv);
|
|
7976
|
+
const functions = await sql`
|
|
7977
|
+
select n.nspname || '.' || p.proname as key, r.rolname as role,
|
|
7978
|
+
a.privilege_type as priv,
|
|
7979
|
+
pg_get_function_identity_arguments(p.oid) as args
|
|
7980
|
+
from pg_proc p
|
|
7981
|
+
join pg_namespace n on n.oid = p.pronamespace
|
|
7982
|
+
cross join lateral aclexplode(coalesce(p.proacl,
|
|
7983
|
+
acldefault('f'::"char", p.proowner))) a
|
|
7984
|
+
left join pg_roles r on r.oid = a.grantee
|
|
7985
|
+
where n.nspname = any(${list}) and p.prokind in ('f', 'p')`;
|
|
7986
|
+
for (const r of functions) add(`${r.key}${signatureOf(r.args)}`, r.role, r.priv);
|
|
7987
|
+
const schemaRows = await sql`
|
|
7988
|
+
select n.nspname as key, r.rolname as role, a.privilege_type as priv
|
|
7989
|
+
from pg_namespace n
|
|
7990
|
+
cross join lateral aclexplode(coalesce(n.nspacl,
|
|
7991
|
+
acldefault('n'::"char", n.nspowner))) a
|
|
7992
|
+
left join pg_roles r on r.oid = a.grantee
|
|
7993
|
+
where n.nspname = any(${list})`;
|
|
7994
|
+
for (const r of schemaRows) add(r.key, r.role, r.priv);
|
|
7995
|
+
return out;
|
|
7996
|
+
},
|
|
7997
|
+
async exec(text) {
|
|
7998
|
+
await sql.begin(async (tx) => {
|
|
7999
|
+
await tx.unsafe(text);
|
|
8000
|
+
});
|
|
8001
|
+
},
|
|
8002
|
+
async runAs(memberId, text) {
|
|
8003
|
+
await sql.begin(async (tx) => {
|
|
8004
|
+
await tx`select set_config('devtools.member_id', ${memberId}, true)`;
|
|
8005
|
+
await tx.unsafe(text);
|
|
8006
|
+
});
|
|
8007
|
+
},
|
|
8008
|
+
close: () => sql.end({ timeout: 5 })
|
|
8009
|
+
};
|
|
7722
8010
|
}
|
|
7723
|
-
|
|
7724
|
-
|
|
7725
|
-
const
|
|
7726
|
-
if (
|
|
7727
|
-
|
|
7728
|
-
|
|
7729
|
-
|
|
7730
|
-
|
|
7731
|
-
|
|
7732
|
-
|
|
7733
|
-
|
|
7734
|
-
|
|
7735
|
-
|
|
8011
|
+
async function runDevData(product, target, open, options) {
|
|
8012
|
+
const log = options.log ?? (() => void 0);
|
|
8013
|
+
const { devData } = product.config;
|
|
8014
|
+
if (!devData) throw new DevDataError(`${product.config.name} has no dev data: add "devData": { "file": "supabase/dev-data.sql" } to devtools.config.json.`);
|
|
8015
|
+
try {
|
|
8016
|
+
assertNotProduction(product, target.apiUrl, target.dbUrl);
|
|
8017
|
+
} catch (err) {
|
|
8018
|
+
if (err instanceof TargetError) throw new DevDataError(err.message);
|
|
8019
|
+
throw err;
|
|
8020
|
+
}
|
|
8021
|
+
const file = join(product.root, devData.file);
|
|
8022
|
+
const text = readFileSync(file, "utf8");
|
|
8023
|
+
const session = open(target.dbUrl);
|
|
8024
|
+
try {
|
|
8025
|
+
const missing = await session.missingSchemas(product.config.supabase.schemas);
|
|
8026
|
+
if (missing.length > 0) throw new DevDataError(`This database does not have ${product.config.name}'s ${missing.length === 1 ? "schema" : "schemas"} yet (missing: ${missing.join(", ")}). Run \`devtools setup\` first, or \`devtools db sync\` if you have already set up.`);
|
|
8027
|
+
return await seed(session, devData.file, text, options, log);
|
|
8028
|
+
} finally {
|
|
8029
|
+
await session.close();
|
|
7736
8030
|
}
|
|
7737
|
-
return null;
|
|
7738
8031
|
}
|
|
7739
|
-
|
|
7740
|
-
|
|
7741
|
-
|
|
7742
|
-
|
|
7743
|
-
|
|
7744
|
-
|
|
7745
|
-
|
|
7746
|
-
|
|
7747
|
-
|
|
7748
|
-
|
|
7749
|
-
|
|
7750
|
-
|
|
7751
|
-
|
|
7752
|
-
|
|
7753
|
-
const types = [];
|
|
7754
|
-
for (let arg of splitTopLevel(argList)) {
|
|
7755
|
-
if (/^(OUT|TABLE)\b/i.test(arg)) continue;
|
|
7756
|
-
arg = arg.replace(/^(?:IN\s+OUT|INOUT|IN|VARIADIC)\s+/i, "");
|
|
7757
|
-
const dflt = /\s+DEFAULT\s|\s*=\s*/i.exec(arg);
|
|
7758
|
-
if (dflt) arg = arg.slice(0, dflt.index);
|
|
7759
|
-
arg = arg.trim();
|
|
7760
|
-
const named = new RegExp(`^(${IDENT})\\s+(.+)$`, "s").exec(arg);
|
|
7761
|
-
let type = arg;
|
|
7762
|
-
if (named) {
|
|
7763
|
-
const head = named[1].toLowerCase();
|
|
7764
|
-
const second = named[2].split(/[\s(]/)[0].toLowerCase();
|
|
7765
|
-
if (!(MULTIWORD_HEADS.has(head) && (/^(precision|with|without|varying)$/.test(second) || named[2].startsWith("(")))) type = named[2];
|
|
7766
|
-
}
|
|
7767
|
-
types.push(normalizeType(type));
|
|
8032
|
+
async function seed(session, file, text, options, log) {
|
|
8033
|
+
const member = options.member ? await session.memberByRef(options.member) : (await session.members(options.provider))[0] ?? null;
|
|
8034
|
+
if (!member) throw new DevDataError(options.member ? `No user matches ${options.member} in this database.` : `Nobody has signed in with DevDogs on this database yet. Start the app, sign in, then run this again (or name someone with --member).`);
|
|
8035
|
+
log(`Seeding as ${member.email ?? member.id} (${member.id})`);
|
|
8036
|
+
if (options.dryRun) {
|
|
8037
|
+
log(`Would run ${file} (${text.split("\n").length} lines) in one transaction.`);
|
|
8038
|
+
return null;
|
|
8039
|
+
}
|
|
8040
|
+
try {
|
|
8041
|
+
await session.runAs(member.id, text);
|
|
8042
|
+
} catch (err) {
|
|
8043
|
+
if (!isSqlFileError(err)) throw err;
|
|
8044
|
+
const line = errorLine(err, text);
|
|
8045
|
+
throw new DevDataError(`${file}${line === null ? "" : `:${line}`}: ${err.message}. This is a problem in the product's dev-data SQL, not in devtools; nothing was seeded (the file runs in one transaction).`);
|
|
7768
8046
|
}
|
|
7769
|
-
return `(${types.join(", ")})`;
|
|
7770
|
-
}
|
|
7771
|
-
/** Normalizes the `function:s.n(types)` spelling the database reports. */
|
|
7772
|
-
function normalizeKeySignature(types) {
|
|
7773
|
-
return signatureOf(types);
|
|
7774
|
-
}
|
|
7775
|
-
/** Reads what a GRANT or REVOKE is on. */
|
|
7776
|
-
function privilegeOf(text, revoke) {
|
|
7777
|
-
const on = new RegExp(String.raw`\bON\s+(?:(TABLE|SEQUENCE|FUNCTION|PROCEDURE|SCHEMA|TYPE|DOMAIN)\s+)?(${QNAME})(\s*\(.*\))?\s+(?:FROM|TO)\s+(.+)$`, "is").exec(text);
|
|
7778
|
-
const base = {
|
|
7779
|
-
sql: text,
|
|
7780
|
-
revoke,
|
|
7781
|
-
targets: [],
|
|
7782
|
-
base: "",
|
|
7783
|
-
object: null,
|
|
7784
|
-
from: ""
|
|
7785
|
-
};
|
|
7786
|
-
if (!on) return base;
|
|
7787
|
-
const kind = (on[1] ?? "").toUpperCase();
|
|
7788
|
-
const name = on[2];
|
|
7789
|
-
const isFunction = kind === "FUNCTION" || kind === "PROCEDURE";
|
|
7790
|
-
const sig = isFunction ? signatureOf(balanced(on[3] ?? "") ?? "") : "";
|
|
7791
|
-
const object = `${qualified(name)}${sig}`;
|
|
7792
|
-
const columns = isFunction ? [] : [...text.slice(0, on.index).matchAll(/\(([^)]*)\)/g)].flatMap((m) => m[1].split(",").map((c) => parts(c)[0]).filter((c) => Boolean(c)));
|
|
7793
8047
|
return {
|
|
7794
|
-
|
|
7795
|
-
|
|
7796
|
-
targets: columns.length > 0 ? columns.map((c) => `${object}.${c}`) : [object],
|
|
7797
|
-
object: `privileges on ${isFunction ? "function " : ""}${shown(name)}${sig}`,
|
|
7798
|
-
from: on[4].replace(/"/g, "")
|
|
8048
|
+
member,
|
|
8049
|
+
file
|
|
7799
8050
|
};
|
|
7800
8051
|
}
|
|
7801
|
-
|
|
7802
|
-
|
|
7803
|
-
TABLE: "table",
|
|
7804
|
-
"FOREIGN TABLE": "table",
|
|
7805
|
-
SEQUENCE: "sequence",
|
|
7806
|
-
TYPE: "type",
|
|
7807
|
-
DOMAIN: "type",
|
|
7808
|
-
VIEW: "view",
|
|
7809
|
-
"MATERIALIZED VIEW": "view",
|
|
7810
|
-
FUNCTION: "function",
|
|
7811
|
-
PROCEDURE: "function"
|
|
7812
|
-
};
|
|
7813
|
-
const WORD = (word) => new RegExp(`^${word}\\b`, "i");
|
|
8052
|
+
//#endregion
|
|
8053
|
+
//#region src/product/management.ts
|
|
7814
8054
|
/**
|
|
7815
|
-
*
|
|
7816
|
-
*
|
|
7817
|
-
*
|
|
8055
|
+
* The two hosted-project auth settings "Sign in with DevDogs" needs and a
|
|
8056
|
+
* contributor would otherwise click through the dashboard for:
|
|
8057
|
+
*
|
|
8058
|
+
* * `custom_oauth_enabled`: off by default, and GoTrue's custom-provider
|
|
8059
|
+
* endpoint answers 404 "Custom OAuth providers are disabled" until it is
|
|
8060
|
+
* on, so registering `custom:devdogsuga` fails without it;
|
|
8061
|
+
* * `uri_allow_list`: the app's return addresses, which the local stack
|
|
8062
|
+
* reads from `supabase/config.toml` instead.
|
|
8063
|
+
*
|
|
8064
|
+
* Both are the Management API's `PATCH /v1/projects/{ref}/config/auth`, with a
|
|
8065
|
+
* personal access token (`SUPABASE_ACCESS_TOKEN`, from the dashboard's Access
|
|
8066
|
+
* Tokens page). Without a token `setup` prints the two dashboard steps.
|
|
7818
8067
|
*/
|
|
7819
|
-
|
|
7820
|
-
|
|
7821
|
-
|
|
7822
|
-
|
|
7823
|
-
|
|
7824
|
-
|
|
7825
|
-
|
|
7826
|
-
const drops = [];
|
|
7827
|
-
const conflicts = /* @__PURE__ */ new Map();
|
|
7828
|
-
const extras = [];
|
|
7829
|
-
const preamble = [];
|
|
7830
|
-
const privileges = [];
|
|
7831
|
-
const conflict = (object, why) => {
|
|
7832
|
-
const set = conflicts.get(object) ?? /* @__PURE__ */ new Set();
|
|
7833
|
-
set.add(why);
|
|
7834
|
-
conflicts.set(object, set);
|
|
7835
|
-
};
|
|
7836
|
-
for (const statement of splitStatements(sql)) {
|
|
7837
|
-
const text = statement.replace(/\s+/g, " ");
|
|
7838
|
-
if (WORD("SET").test(text)) {
|
|
7839
|
-
preamble.push(statement);
|
|
7840
|
-
continue;
|
|
7841
|
-
}
|
|
7842
|
-
if (WORD("GRANT").test(text) || WORD("REVOKE").test(text)) {
|
|
7843
|
-
const p = privilegeOf(text, WORD("REVOKE").test(text));
|
|
7844
|
-
privileges.push({
|
|
7845
|
-
...p,
|
|
7846
|
-
sql: statement
|
|
7847
|
-
});
|
|
7848
|
-
continue;
|
|
7849
|
-
}
|
|
7850
|
-
const create = new RegExp(String.raw`^CREATE\s+(OR\s+REPLACE\s+)?(?:UNIQUE\s+)?(?:TEMP(?:ORARY)?\s+)?(SCHEMA|TABLE|FOREIGN TABLE|SEQUENCE|TYPE|DOMAIN|VIEW|MATERIALIZED VIEW|FUNCTION|PROCEDURE|INDEX|POLICY|TRIGGER|CONSTRAINT TRIGGER)\s+(?:IF\s+NOT\s+EXISTS\s+)?(${IDENT}(?:\s*\.\s*${IDENT})*)(.*)$`, "is").exec(text);
|
|
7851
|
-
if (create) {
|
|
7852
|
-
const replace = Boolean(create[1]);
|
|
7853
|
-
const kindWord = create[2].toUpperCase().replace(/\s+/g, " ");
|
|
7854
|
-
const name = create[3];
|
|
7855
|
-
const rest = create[4];
|
|
7856
|
-
let key;
|
|
7857
|
-
let label;
|
|
7858
|
-
if (kindWord === "INDEX") {
|
|
7859
|
-
key = `index:${parts(name).at(-1)}`;
|
|
7860
|
-
label = `index ${shown(name)}`;
|
|
7861
|
-
} else if (kindWord === "POLICY" || kindWord === "TRIGGER" || kindWord === "CONSTRAINT TRIGGER") {
|
|
7862
|
-
const on = new RegExp(String.raw`\bON\s+(${QNAME})`, "i").exec(rest);
|
|
7863
|
-
const table = on ? qualified(on[1]) : "?";
|
|
7864
|
-
const noun = kindWord === "POLICY" ? "policy" : "trigger";
|
|
7865
|
-
key = `${noun}:${table}:${parts(name).at(-1)}`;
|
|
7866
|
-
label = `${noun} ${shown(name)} on ${on ? shown(on[1]) : "?"}`;
|
|
7867
|
-
} else if (kindWord === "FUNCTION" || kindWord === "PROCEDURE") {
|
|
7868
|
-
const sig = signatureOf(balanced(rest) ?? "");
|
|
7869
|
-
key = `function:${qualified(name)}${sig}`;
|
|
7870
|
-
label = `function ${shown(name)}${sig}`;
|
|
7871
|
-
} else {
|
|
7872
|
-
const kind = CREATE_KINDS[kindWord] ?? kindWord.toLowerCase();
|
|
7873
|
-
key = `${kind}:${qualified(name)}`;
|
|
7874
|
-
label = `${kind} ${shown(name)}`;
|
|
7875
|
-
}
|
|
7876
|
-
const exists = key.startsWith("function:") ? signatures.has(key.slice(9)) || signatures.size === 0 && existing.has(`function:${qualified(name)}`) : existing.has(key);
|
|
7877
|
-
if (replace && exists) conflict(label, "its definition differs from supabase/schemas");
|
|
7878
|
-
else creates.push({
|
|
7879
|
-
key,
|
|
7880
|
-
sql: statement,
|
|
7881
|
-
label
|
|
7882
|
-
});
|
|
7883
|
-
continue;
|
|
7884
|
-
}
|
|
7885
|
-
const drop = new RegExp(String.raw`^DROP\s+(SCHEMA|TABLE|FOREIGN TABLE|SEQUENCE|TYPE|DOMAIN|VIEW|MATERIALIZED VIEW|FUNCTION|PROCEDURE|INDEX|POLICY|TRIGGER|EXTENSION)\s+(?:IF\s+EXISTS\s+)?(${QNAME})(.*)$`, "is").exec(text);
|
|
7886
|
-
if (drop) {
|
|
7887
|
-
const kindWord = drop[1].toUpperCase();
|
|
7888
|
-
const name = drop[2];
|
|
7889
|
-
const rest = drop[3];
|
|
7890
|
-
if (kindWord === "INDEX") drops.push({
|
|
7891
|
-
key: `index:${parts(name).at(-1)}`,
|
|
7892
|
-
finding: {
|
|
7893
|
-
object: `index ${shown(name)}`,
|
|
7894
|
-
detail: "not in supabase/schemas"
|
|
7895
|
-
}
|
|
7896
|
-
});
|
|
7897
|
-
else if (kindWord === "POLICY" || kindWord === "TRIGGER") {
|
|
7898
|
-
const on = new RegExp(String.raw`\bON\s+(${QNAME})`, "i").exec(rest);
|
|
7899
|
-
const noun = kindWord.toLowerCase();
|
|
7900
|
-
drops.push({
|
|
7901
|
-
key: `${noun}:${on ? qualified(on[1]) : "?"}:${parts(name).at(-1)}`,
|
|
7902
|
-
finding: {
|
|
7903
|
-
object: `${noun} ${shown(name)} on ${on ? shown(on[1]) : "?"}`,
|
|
7904
|
-
detail: "not in supabase/schemas"
|
|
7905
|
-
}
|
|
7906
|
-
});
|
|
7907
|
-
} else if (kindWord === "FUNCTION" || kindWord === "PROCEDURE") {
|
|
7908
|
-
const sig = signatureOf(balanced(rest) ?? "");
|
|
7909
|
-
drops.push({
|
|
7910
|
-
key: `function:${qualified(name)}${sig}`,
|
|
7911
|
-
finding: {
|
|
7912
|
-
object: `function ${shown(name)}${sig}`,
|
|
7913
|
-
detail: "not in supabase/schemas"
|
|
7914
|
-
}
|
|
7915
|
-
});
|
|
7916
|
-
} else {
|
|
7917
|
-
const kind = CREATE_KINDS[kindWord] ?? kindWord.toLowerCase();
|
|
7918
|
-
drops.push({
|
|
7919
|
-
key: `${kind}:${qualified(name)}`,
|
|
7920
|
-
finding: {
|
|
7921
|
-
object: `${kind} ${shown(name)}`,
|
|
7922
|
-
detail: "not in supabase/schemas"
|
|
7923
|
-
}
|
|
7924
|
-
});
|
|
7925
|
-
}
|
|
7926
|
-
continue;
|
|
7927
|
-
}
|
|
7928
|
-
const alterTable = new RegExp(String.raw`^ALTER\s+(?:FOREIGN\s+)?TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?(${QNAME})\s+(.*)$`, "is").exec(text);
|
|
7929
|
-
if (alterTable) {
|
|
7930
|
-
const table = alterTable[1];
|
|
7931
|
-
const t = qualified(table);
|
|
7932
|
-
for (const action of splitTopLevel(alterTable[2])) classifyTableAction(table, t, action, {
|
|
7933
|
-
creates,
|
|
7934
|
-
drops,
|
|
7935
|
-
conflict
|
|
7936
|
-
});
|
|
7937
|
-
continue;
|
|
7938
|
-
}
|
|
7939
|
-
const alterType = new RegExp(String.raw`^ALTER\s+TYPE\s+(${QNAME})\s+ADD\s+VALUE\b`, "i").exec(text);
|
|
7940
|
-
if (alterType) {
|
|
7941
|
-
creates.push({
|
|
7942
|
-
key: `enumvalue:${statement}`,
|
|
7943
|
-
sql: statement,
|
|
7944
|
-
label: `a value of type ${shown(alterType[1])}`
|
|
7945
|
-
});
|
|
7946
|
-
continue;
|
|
7947
|
-
}
|
|
7948
|
-
conflict(text.slice(0, 80), "a difference devtools does not know how to classify, so it will not touch it");
|
|
7949
|
-
}
|
|
7950
|
-
const renamedCreates = /* @__PURE__ */ new Set();
|
|
7951
|
-
const renamedDrops = /* @__PURE__ */ new Set();
|
|
7952
|
-
for (const c of creates) {
|
|
7953
|
-
if (!c.column) continue;
|
|
7954
|
-
const from = drops.find((d) => {
|
|
7955
|
-
if (!d.column || renamedDrops.has(d)) return false;
|
|
7956
|
-
if (d.column.table !== c.column.table) return false;
|
|
7957
|
-
const type = columnTypes.get(`${d.column.table}.${d.column.name}`);
|
|
7958
|
-
return type !== void 0 && normalizeType(type) === c.column.type;
|
|
7959
|
-
});
|
|
7960
|
-
if (!from) continue;
|
|
7961
|
-
renamedCreates.add(c);
|
|
7962
|
-
renamedDrops.add(from);
|
|
7963
|
-
conflict(c.label, `supabase/schemas has it, and your database has ${from.finding.object} of the same type (${c.column.type}); that looks like a rename, so devtools will not add an empty column beside it. If you renamed it on purpose, rename it in supabase/schemas too; otherwise rename it back`);
|
|
8068
|
+
const API = "https://api.supabase.com";
|
|
8069
|
+
var ManagementError = class extends Error {
|
|
8070
|
+
status;
|
|
8071
|
+
constructor(message, status) {
|
|
8072
|
+
super(message);
|
|
8073
|
+
this.status = status;
|
|
8074
|
+
this.name = "ManagementError";
|
|
7964
8075
|
}
|
|
7965
|
-
|
|
7966
|
-
|
|
7967
|
-
|
|
7968
|
-
|
|
7969
|
-
|
|
7970
|
-
|
|
7971
|
-
|
|
7972
|
-
else extras.push(d.finding);
|
|
7973
|
-
const newNames = new Set(restoring.map((c) => c.key).filter((key) => !/^(index|policy|trigger|constraint|rls)/.test(key)).map((key) => key.slice(key.indexOf(":") + 1)));
|
|
7974
|
-
const tail = [];
|
|
7975
|
-
const regranted = /* @__PURE__ */ new Set();
|
|
7976
|
-
const revokes = [];
|
|
7977
|
-
for (const p of privileges) {
|
|
7978
|
-
if (p.targets.length > 0 && p.targets.every((t) => skipped.has(t))) continue;
|
|
7979
|
-
if (p.targets.length > 0 && (newNames.has(p.base) || p.targets.every((t) => newNames.has(t)))) tail.push(p.sql);
|
|
7980
|
-
else if (!p.revoke) {
|
|
7981
|
-
tail.push(p.sql);
|
|
7982
|
-
regranted.add(p.object ?? "privileges");
|
|
7983
|
-
} else revokes.push(p);
|
|
8076
|
+
};
|
|
8077
|
+
/** The project ref in `https://<ref>.supabase.co`, or null for a custom domain or local URL. */
|
|
8078
|
+
function projectRefOf(apiUrl) {
|
|
8079
|
+
try {
|
|
8080
|
+
return /^([a-z0-9]{20})\.supabase\.co$/.exec(new URL(apiUrl).hostname)?.[1] ?? null;
|
|
8081
|
+
} catch {
|
|
8082
|
+
return null;
|
|
7984
8083
|
}
|
|
7985
|
-
|
|
7986
|
-
|
|
7987
|
-
|
|
7988
|
-
|
|
7989
|
-
|
|
7990
|
-
|
|
8084
|
+
}
|
|
8085
|
+
/** The allow list with `wanted` added, keeping what was there; comma-separated as GoTrue stores it. */
|
|
8086
|
+
function mergeAllowList(current, wanted) {
|
|
8087
|
+
const entries = (current ?? "").split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
8088
|
+
for (const uri of wanted) if (!entries.includes(uri)) entries.push(uri);
|
|
8089
|
+
return entries.join(",");
|
|
8090
|
+
}
|
|
8091
|
+
async function ensureHostedAuthSettings(input) {
|
|
8092
|
+
const doFetch = input.fetch ?? fetch;
|
|
8093
|
+
const headers = {
|
|
8094
|
+
authorization: `Bearer ${input.token}`,
|
|
8095
|
+
"content-type": "application/json"
|
|
8096
|
+
};
|
|
8097
|
+
const url = `${API}/v1/projects/${input.projectRef}/config/auth`;
|
|
8098
|
+
const current = await doFetch(url, { headers });
|
|
8099
|
+
if (!current.ok) throw new ManagementError(current.status === 401 || current.status === 403 ? "The Supabase access token was refused. Create one at https://supabase.com/dashboard/account/tokens." : `Reading the project's auth settings failed (${current.status}).`, current.status);
|
|
8100
|
+
const settings = await current.json();
|
|
8101
|
+
const patch = {};
|
|
8102
|
+
const changed = [];
|
|
8103
|
+
if (settings.custom_oauth_enabled !== true) {
|
|
8104
|
+
patch["custom_oauth_enabled"] = true;
|
|
8105
|
+
changed.push("custom_oauth_enabled");
|
|
7991
8106
|
}
|
|
7992
|
-
const
|
|
7993
|
-
|
|
7994
|
-
|
|
8107
|
+
const allow = mergeAllowList(settings.uri_allow_list, input.redirectUris);
|
|
8108
|
+
if (allow !== (settings.uri_allow_list ?? "")) {
|
|
8109
|
+
patch["uri_allow_list"] = allow;
|
|
8110
|
+
changed.push("uri_allow_list");
|
|
8111
|
+
}
|
|
8112
|
+
if (changed.length === 0) return { changed };
|
|
8113
|
+
const response = await doFetch(url, {
|
|
8114
|
+
method: "PATCH",
|
|
8115
|
+
headers,
|
|
8116
|
+
body: JSON.stringify(patch)
|
|
8117
|
+
});
|
|
8118
|
+
if (!response.ok) throw new ManagementError(`Updating the project's auth settings failed (${response.status}).`, response.status);
|
|
8119
|
+
return { changed };
|
|
8120
|
+
}
|
|
8121
|
+
//#endregion
|
|
8122
|
+
//#region src/product/oauth.ts
|
|
8123
|
+
/**
|
|
8124
|
+
* "Sign in with DevDogs" for a product's development database, without
|
|
8125
|
+
* prompts: the non-interactive halves of `devtools oauth`.
|
|
8126
|
+
*
|
|
8127
|
+
* The wizard (`../oauth/wizard.ts`) is the front door and is what `setup`
|
|
8128
|
+
* runs for a person. These two exist for the cases that have no person:
|
|
8129
|
+
*
|
|
8130
|
+
* * a fresh-machine or CI run that already holds an OAuth client's id and
|
|
8131
|
+
* secret (`DEVDOGS_OAUTH_CLIENT_ID` / `DEVDOGS_OAUTH_CLIENT_SECRET`);
|
|
8132
|
+
* * `db sync --rebuild`, which resets the local database and with it
|
|
8133
|
+
* `auth.custom_oauth_providers`, so the provider is registered again from
|
|
8134
|
+
* the credentials the wizard saved to the product's `.env.tooling`.
|
|
8135
|
+
*/
|
|
8136
|
+
var OAuthRegistrationError = class extends Error {
|
|
8137
|
+
constructor(message) {
|
|
8138
|
+
super(message);
|
|
8139
|
+
this.name = "OAuthRegistrationError";
|
|
8140
|
+
}
|
|
8141
|
+
};
|
|
8142
|
+
function credentialsFromEnv(env) {
|
|
8143
|
+
const clientId = env["DEVDOGS_OAUTH_CLIENT_ID"];
|
|
8144
|
+
const clientSecret = env["DEVDOGS_OAUTH_CLIENT_SECRET"];
|
|
8145
|
+
if (!clientId || !clientSecret) return null;
|
|
7995
8146
|
return {
|
|
7996
|
-
|
|
7997
|
-
|
|
7998
|
-
|
|
7999
|
-
conflicts: [...conflicts].map(([object, why]) => ({
|
|
8000
|
-
object,
|
|
8001
|
-
detail: [...why].join("; ")
|
|
8002
|
-
}))
|
|
8147
|
+
clientId,
|
|
8148
|
+
clientSecret,
|
|
8149
|
+
baseUrl: env["DEVDOGS_OAUTH_BASE_URL"]
|
|
8003
8150
|
};
|
|
8004
8151
|
}
|
|
8005
|
-
|
|
8006
|
-
|
|
8007
|
-
|
|
8008
|
-
|
|
8009
|
-
|
|
8010
|
-
|
|
8011
|
-
|
|
8012
|
-
|
|
8013
|
-
|
|
8014
|
-
|
|
8015
|
-
|
|
8016
|
-
|
|
8017
|
-
if (m = new RegExp(String.raw`^ADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?(${IDENT})`, "i").exec(action)) {
|
|
8018
|
-
const typeText = action.slice(m[0].length).trim().split(/\s+(?:DEFAULT|NOT\s+NULL|NULL|GENERATED|CONSTRAINT|REFERENCES|CHECK|COLLATE|UNIQUE|PRIMARY)\b/i)[0];
|
|
8019
|
-
ctx.creates.push({
|
|
8020
|
-
key: `column:${t}.${parts(m[1])[0]}`,
|
|
8021
|
-
sql,
|
|
8022
|
-
label: `column ${shown(table)}.${shown(m[1])}`,
|
|
8023
|
-
column: {
|
|
8024
|
-
table: t,
|
|
8025
|
-
name: parts(m[1])[0],
|
|
8026
|
-
type: normalizeType(typeText)
|
|
8027
|
-
}
|
|
8028
|
-
});
|
|
8029
|
-
} else if (m = new RegExp(String.raw`^ADD\s+CONSTRAINT\s+(${IDENT})`, "i").exec(action)) ctx.creates.push({
|
|
8030
|
-
key: `constraint:${t}.${parts(m[1])[0]}`,
|
|
8031
|
-
sql,
|
|
8032
|
-
label: `constraint ${shown(m[1])} on ${shown(table)}`
|
|
8033
|
-
});
|
|
8034
|
-
else if (m = new RegExp(String.raw`^DROP\s+COLUMN\s+(?:IF\s+EXISTS\s+)?(${IDENT})`, "i").exec(action)) ctx.drops.push({
|
|
8035
|
-
key: `column:${t}.${parts(m[1])[0]}`,
|
|
8036
|
-
finding: {
|
|
8037
|
-
object: `column ${shown(table)}.${shown(m[1])}`,
|
|
8038
|
-
detail: "not in supabase/schemas"
|
|
8039
|
-
},
|
|
8040
|
-
column: {
|
|
8041
|
-
table: t,
|
|
8042
|
-
name: parts(m[1])[0]
|
|
8043
|
-
}
|
|
8152
|
+
/** Registers `custom:devdogsuga` on the target with these credentials. */
|
|
8153
|
+
async function registerProvider(credentialsFile, target, credentials) {
|
|
8154
|
+
const { issuer } = await fetchIssuerFromAny(credentials.baseUrl ? [credentials.baseUrl] : DEFAULT_API_URLS);
|
|
8155
|
+
const row = await upsertDevDogsProvider({
|
|
8156
|
+
apiUrl: target.apiUrl,
|
|
8157
|
+
serviceRoleKey: target.secretKey
|
|
8158
|
+
}, {
|
|
8159
|
+
identifier: OAUTH.providerId,
|
|
8160
|
+
name: PROVIDER_NAME,
|
|
8161
|
+
clientId: credentials.clientId,
|
|
8162
|
+
clientSecret: credentials.clientSecret,
|
|
8163
|
+
issuer
|
|
8044
8164
|
});
|
|
8045
|
-
|
|
8046
|
-
|
|
8047
|
-
|
|
8048
|
-
object: `constraint ${shown(m[1])} on ${shown(table)}`,
|
|
8049
|
-
detail: "not in supabase/schemas"
|
|
8050
|
-
}
|
|
8165
|
+
upsertEnvFile(credentialsFile, {
|
|
8166
|
+
[ENV_KEYS.clientId]: credentials.clientId,
|
|
8167
|
+
[ENV_KEYS.clientSecret]: credentials.clientSecret
|
|
8051
8168
|
});
|
|
8052
|
-
|
|
8053
|
-
|
|
8054
|
-
|
|
8055
|
-
|
|
8056
|
-
|
|
8057
|
-
|
|
8058
|
-
|
|
8059
|
-
|
|
8169
|
+
return row;
|
|
8170
|
+
}
|
|
8171
|
+
/** Registers the provider again from what the wizard saved to `.env.tooling`. */
|
|
8172
|
+
async function reRegisterSavedProvider(credentialsFile, target) {
|
|
8173
|
+
const saved = readEnvFile(credentialsFile);
|
|
8174
|
+
const clientId = saved[ENV_KEYS.clientId];
|
|
8175
|
+
const clientSecret = saved[ENV_KEYS.clientSecret];
|
|
8176
|
+
if (!clientId || !clientSecret) throw new OAuthRegistrationError(`No saved OAuth client in ${basename(credentialsFile)}, so "Sign in with DevDogs" cannot be registered again automatically. Run \`devtools oauth\`.`);
|
|
8177
|
+
return registerProvider(credentialsFile, target, {
|
|
8178
|
+
clientId,
|
|
8179
|
+
clientSecret,
|
|
8180
|
+
baseUrl: saved[ENV_KEYS.baseUrl]
|
|
8060
8181
|
});
|
|
8061
|
-
|
|
8182
|
+
}
|
|
8183
|
+
//#endregion
|
|
8184
|
+
//#region src/product/schema-hash.ts
|
|
8185
|
+
/**
|
|
8186
|
+
* The schema hash that names a set of generated types.
|
|
8187
|
+
*
|
|
8188
|
+
* Generated types are never committed (Q82). CI builds them once per schema
|
|
8189
|
+
* change and uploads them to a rolling `generated-types` release; a
|
|
8190
|
+
* contributor who cannot (or would rather not) generate them downloads the
|
|
8191
|
+
* asset whose name carries the hash of the schema they have checked out. Two
|
|
8192
|
+
* checkouts with the same hash have the same types, so the hash must change
|
|
8193
|
+
* exactly when the generated output could.
|
|
8194
|
+
*
|
|
8195
|
+
* It covers:
|
|
8196
|
+
* * every file under `supabase/schemas`, by sorted path (POSIX separators,
|
|
8197
|
+
* relative to the schemas directory) and LF-normalised contents, so a
|
|
8198
|
+
* Windows checkout with CRLF hashes like a Linux one;
|
|
8199
|
+
* * the generator and its version (the pinned Supabase CLI for TypeScript,
|
|
8200
|
+
* supadart for Dart), since a new generator rewrites the output.
|
|
8201
|
+
*/
|
|
8202
|
+
function listFiles(dir) {
|
|
8203
|
+
if (!existsSync(dir)) return [];
|
|
8204
|
+
const found = [];
|
|
8205
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
8206
|
+
const path = join(dir, entry.name);
|
|
8207
|
+
if (entry.isDirectory()) found.push(...listFiles(path));
|
|
8208
|
+
else found.push(path);
|
|
8209
|
+
}
|
|
8210
|
+
return found;
|
|
8211
|
+
}
|
|
8212
|
+
/** The files a hash covers, as POSIX paths relative to the schemas directory, sorted. */
|
|
8213
|
+
function schemaFiles(schemasDir) {
|
|
8214
|
+
return listFiles(schemasDir).map((path) => relative(schemasDir, path).split(sep).join("/")).sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
|
|
8215
|
+
}
|
|
8216
|
+
function normalise(contents) {
|
|
8217
|
+
return Buffer.from(contents.toString("utf8").replace(/\r\n/g, "\n"), "utf8");
|
|
8218
|
+
}
|
|
8219
|
+
function hashSchemas(schemasDir, generator) {
|
|
8220
|
+
const hash = createHash("sha256");
|
|
8221
|
+
for (const path of schemaFiles(schemasDir)) {
|
|
8222
|
+
hash.update(`file:${path}\0`);
|
|
8223
|
+
hash.update(normalise(readFileSync(join(schemasDir, ...path.split("/")))));
|
|
8224
|
+
hash.update("\0");
|
|
8225
|
+
}
|
|
8226
|
+
hash.update(`generator:${generator}`);
|
|
8227
|
+
return hash.digest("hex");
|
|
8228
|
+
}
|
|
8229
|
+
/**
|
|
8230
|
+
* The generator and version string a product's types come from. For supadart
|
|
8231
|
+
* it is the version `pubspec.lock` resolved, else the constraint in
|
|
8232
|
+
* `pubspec.yaml`, so a Dart SDK bump that changes the models changes the hash.
|
|
8233
|
+
*/
|
|
8234
|
+
function generatorFor(product) {
|
|
8235
|
+
const { types, supabase } = product.config;
|
|
8236
|
+
if (types.kind === "typescript") return `supabase@${supabase.version}`;
|
|
8237
|
+
const lock = join(product.root, "pubspec.lock");
|
|
8238
|
+
if (existsSync(lock)) {
|
|
8239
|
+
const match = /^ {2}supadart:\n(?:.*\n)*? {4}version: "([^"]+)"/m.exec(readFileSync(lock, "utf8"));
|
|
8240
|
+
if (match?.[1]) return `supadart@${match[1]}`;
|
|
8241
|
+
}
|
|
8242
|
+
const pubspec = join(product.root, "pubspec.yaml");
|
|
8243
|
+
if (existsSync(pubspec)) {
|
|
8244
|
+
const match = /^\s+supadart:\s*([^\s#]+)/m.exec(readFileSync(pubspec, "utf8"));
|
|
8245
|
+
if (match?.[1]) return `supadart@${match[1]}`;
|
|
8246
|
+
}
|
|
8247
|
+
return "supadart@unknown";
|
|
8248
|
+
}
|
|
8249
|
+
function schemaHashFor(product) {
|
|
8250
|
+
return hashSchemas(product.paths.schemasDir, generatorFor(product));
|
|
8251
|
+
}
|
|
8252
|
+
/** The release asset CI uploads for a hash, and `db types --download` looks for. */
|
|
8253
|
+
function typesAssetName(product, hash) {
|
|
8254
|
+
return `${product}-types-${hash}.tgz`;
|
|
8062
8255
|
}
|
|
8063
8256
|
//#endregion
|
|
8064
8257
|
//#region src/product/sync.ts
|
|
@@ -8279,11 +8472,13 @@ async function reconcileLive(product, cli, target, run, options, log, warn, reda
|
|
|
8279
8472
|
let ledger;
|
|
8280
8473
|
let existing;
|
|
8281
8474
|
let columnTypes;
|
|
8475
|
+
let privileges;
|
|
8282
8476
|
try {
|
|
8283
8477
|
session = options.open(target.dbUrl);
|
|
8284
8478
|
ledger = await session.appliedMigrations();
|
|
8285
8479
|
existing = await session.existingObjects(schemas);
|
|
8286
8480
|
columnTypes = await session.columnTypes(schemas);
|
|
8481
|
+
privileges = await session.privileges(schemas);
|
|
8287
8482
|
} catch (err) {
|
|
8288
8483
|
await session?.close().catch(() => void 0);
|
|
8289
8484
|
throw new SyncError("Could not connect to the database to compare it with supabase/schemas, so nothing was changed, and the database was not checked against supabase/schemas. Fix the connection (is the database running, is the password right?) and run `devtools db sync` again.", redact(err.message));
|
|
@@ -8293,6 +8488,13 @@ async function reconcileLive(product, cli, target, run, options, log, warn, reda
|
|
|
8293
8488
|
const known = /* @__PURE__ */ new Set([...ledger ?? [], ...noted ?? []]);
|
|
8294
8489
|
const everythingApplied = ledger === null && noted === null;
|
|
8295
8490
|
const isApplied = (file) => everythingApplied || known.has(versionOf(file));
|
|
8491
|
+
if (everythingApplied) {
|
|
8492
|
+
const drops = files.filter((file) => destructiveStatements(readFileSync(join(product.paths.migrationsDir, file), "utf8")).length > 0);
|
|
8493
|
+
if (drops.length > 0) {
|
|
8494
|
+
await session.close().catch(() => void 0);
|
|
8495
|
+
throw new SyncError(`devtools cannot tell whether ${drops.length === 1 ? "this migration" : "these migrations"} ran in your database: it has no migration history table and this folder has no record of what was applied. ${drops.length === 1 ? "It drops" : "They drop"} something, so devtools will not guess and nothing was changed or marked applied.`, `${drops.join("\n")}\n${target.kind === "local" ? "Run `devtools db sync --rebuild` (it wipes your development database), then `devtools dev-data`; or delete supabase/migrations and run `devtools db sync` again." : "Delete supabase/migrations and run `devtools db sync` again, or reset the project from its dashboard (Database > Settings)."}`);
|
|
8496
|
+
}
|
|
8497
|
+
}
|
|
8296
8498
|
const appliedFiles = files.filter(isApplied);
|
|
8297
8499
|
const pending = files.filter((file) => !isApplied(file));
|
|
8298
8500
|
if (appliedFiles.length === 0) {
|
|
@@ -8306,7 +8508,7 @@ async function reconcileLive(product, cli, target, run, options, log, warn, reda
|
|
|
8306
8508
|
if (pending.length > 0) mkdirSync(aside, { recursive: true });
|
|
8307
8509
|
for (const file of pending) renameSync(join(product.paths.migrationsDir, file), join(aside, file));
|
|
8308
8510
|
try {
|
|
8309
|
-
const drift = classifyDiff(await liveDiff(product, cli, target, run, log, redact), existing, columnTypes);
|
|
8511
|
+
const drift = classifyDiff(await liveDiff(product, cli, target, run, log, redact), existing, columnTypes, privileges);
|
|
8310
8512
|
if (drift.extras.length > 0 && !options.strict) warn(`Your database has ${drift.extras.length} thing${drift.extras.length === 1 ? "" : "s"} that supabase/schemas does not define. They are left as they are:\n${listFindings(drift.extras)}\n${cleanupHint(target)}`);
|
|
8311
8513
|
if (drift.conflicts.length > 0) throw new SyncError(`Your database defines ${drift.conflicts.length} thing${drift.conflicts.length === 1 ? "" : "s"} differently from supabase/schemas. devtools will not change them for you, and nothing was changed.`, `${listFindings(drift.conflicts)}\nIf the database is right, edit supabase/schemas to match it. If the schema files are right, ${target.kind === "local" ? "run `devtools db sync --rebuild` (it wipes your development database), then `devtools dev-data`" : "reset the project from its dashboard (Database > Settings), then run `devtools db sync` and `devtools dev-data`"}.`);
|
|
8312
8514
|
if (options.strict && drift.extras.length > 0) throw new SyncError(`--strict: your database has ${drift.extras.length} thing${drift.extras.length === 1 ? "" : "s"} that supabase/schemas does not define. Nothing was changed or deleted.`, `${listFindings(drift.extras)}\n${cleanupHint(target)}`);
|
|
@@ -8354,6 +8556,7 @@ async function dbSync(product, cli, target, run, options) {
|
|
|
8354
8556
|
const redact = redactor(target);
|
|
8355
8557
|
const { migrationsDir, schemasDir } = product.paths;
|
|
8356
8558
|
if (!existsSync(schemasDir)) throw new SyncError(`${product.config.supabase.dir}/schemas does not exist; there is nothing to sync.`);
|
|
8559
|
+
const ledger0 = !options.dryRun && options.open ? await probe(options.open, target, redact) : void 0;
|
|
8357
8560
|
mkdirSync(migrationsDir, { recursive: true });
|
|
8358
8561
|
const generateArgs = [
|
|
8359
8562
|
...cli.prefix,
|
|
@@ -8407,14 +8610,24 @@ async function dbSync(product, cli, target, run, options) {
|
|
|
8407
8610
|
};
|
|
8408
8611
|
}
|
|
8409
8612
|
restorePending(product);
|
|
8410
|
-
if (!needsRecord && target.kind === "local" &&
|
|
8613
|
+
if (!needsRecord && target.kind === "local" && ledger0 !== void 0 && migrationFiles(migrationsDir).length === 0) needsRecord = (ledger0 ?? []).length > 0;
|
|
8614
|
+
let unknownFiles = false;
|
|
8615
|
+
if (!foreign && owner === void 0 && ledger0 !== void 0 && migrationFiles(migrationsDir).length > 0) {
|
|
8616
|
+
const listed = new Set(ledger0 ?? []);
|
|
8617
|
+
unknownFiles = ledger0 === null ? target.kind === "hosted" : !migrationFiles(migrationsDir).some((file) => listed.has(versionOf(file)));
|
|
8618
|
+
if (unknownFiles) {
|
|
8619
|
+
log(`supabase/migrations has migrations that ${key === "local" ? "the local stack" : "this project"} has not applied and no record of whose they are (they were probably written for another database); they are set aside and this database's own history is used.`);
|
|
8620
|
+
forgetMigrations(product);
|
|
8621
|
+
needsRecord = true;
|
|
8622
|
+
}
|
|
8623
|
+
}
|
|
8411
8624
|
if (foreign) {
|
|
8412
8625
|
log(`supabase/migrations holds the record of another database (${owner}); this sync is for ${key}, so that record is set aside and this database's own history is used.`);
|
|
8413
8626
|
forgetMigrations(product);
|
|
8414
8627
|
}
|
|
8415
8628
|
if (needsRecord) {
|
|
8416
8629
|
const fetched = await run(cli.command, fetchArgs, { cwd: product.root });
|
|
8417
|
-
if (fetched.code !== 0 &&
|
|
8630
|
+
if (fetched.code !== 0 && (ledger0 ?? []).length > 0) throw new SyncError("Could not restore this database's migration history from the database itself, so nothing was changed.", redact(fetched.stderr || fetched.stdout));
|
|
8418
8631
|
const restoredFiles = migrationFiles(migrationsDir).length;
|
|
8419
8632
|
if (fetched.code === 0 && restoredFiles > 0) {
|
|
8420
8633
|
log(`Restored ${restoredFiles} applied migration${restoredFiles === 1 ? "" : "s"} from ${target.kind === "local" ? "the local stack's" : "the project's"} history.`);
|
|
@@ -8471,7 +8684,8 @@ async function dbSync(product, cli, target, run, options) {
|
|
|
8471
8684
|
const applied = await run(cli.command, applyArgs, { cwd: product.root });
|
|
8472
8685
|
if (applied.code !== 0) {
|
|
8473
8686
|
for (const file of created) rmSync(join(migrationsDir, file));
|
|
8474
|
-
|
|
8687
|
+
const why = redact(applied.stderr || applied.stdout);
|
|
8688
|
+
throw new SyncError(/migration versions not found|history does not match/i.test(why) ? "The change did not apply: the database's migration history and supabase/migrations disagree. The error below is the database's own." : "The change did not apply. Your schema files may not be valid SQL, or the database has drifted from what devtools recorded (`devtools db sync --rebuild` starts over).", why);
|
|
8475
8689
|
}
|
|
8476
8690
|
writeRecord(product, target, migrationFiles(migrationsDir));
|
|
8477
8691
|
return {
|