@devdogsuga/devtools 0.1.51-next.551 → 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/README.md +8 -1
- package/dist/{cli-xAkuiAkl.js → cli-CzHls8xA.js} +1333 -845
- package/dist/launch.js +1 -1
- package/package.json +1 -1
|
@@ -7002,899 +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
|
-
|
|
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, "")}`;
|
|
7124
|
+
}
|
|
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);
|
|
7120
7138
|
}
|
|
7139
|
+
return null;
|
|
7121
7140
|
}
|
|
7122
|
-
|
|
7123
|
-
|
|
7124
|
-
|
|
7125
|
-
|
|
7126
|
-
|
|
7127
|
-
|
|
7141
|
+
const MULTIWORD_HEADS = /* @__PURE__ */ new Set([
|
|
7142
|
+
"double",
|
|
7143
|
+
"timestamp",
|
|
7144
|
+
"time",
|
|
7145
|
+
"character",
|
|
7146
|
+
"bit",
|
|
7147
|
+
"national",
|
|
7148
|
+
"interval"
|
|
7149
|
+
]);
|
|
7150
|
+
/**
|
|
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)`.
|
|
7153
|
+
*/
|
|
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(", ")})`;
|
|
7172
|
+
}
|
|
7173
|
+
/** Normalizes the `function:s.n(types)` spelling the database reports. */
|
|
7174
|
+
function normalizeKeySignature(types) {
|
|
7175
|
+
return signatureOf(types);
|
|
7176
|
+
}
|
|
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"]
|
|
7203
|
+
};
|
|
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 });
|
|
7128
7239
|
}
|
|
7129
|
-
|
|
7130
|
-
|
|
7131
|
-
|
|
7132
|
-
const
|
|
7133
|
-
...cli.prefix,
|
|
7134
|
-
"status",
|
|
7135
|
-
"-o",
|
|
7136
|
-
"env"
|
|
7137
|
-
];
|
|
7138
|
-
const result = await run(cli.command, args, { cwd: product.root });
|
|
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?");
|
|
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, "");
|
|
7146
7244
|
return {
|
|
7147
|
-
|
|
7148
|
-
|
|
7149
|
-
|
|
7150
|
-
|
|
7151
|
-
|
|
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
|
|
7152
7261
|
};
|
|
7153
7262
|
}
|
|
7154
|
-
|
|
7155
|
-
|
|
7156
|
-
|
|
7157
|
-
|
|
7158
|
-
|
|
7159
|
-
|
|
7160
|
-
|
|
7161
|
-
|
|
7162
|
-
|
|
7163
|
-
|
|
7164
|
-
|
|
7165
|
-
|
|
7166
|
-
|
|
7167
|
-
|
|
7168
|
-
|
|
7169
|
-
|
|
7170
|
-
|
|
7171
|
-
|
|
7172
|
-
|
|
7173
|
-
|
|
7174
|
-
|
|
7175
|
-
|
|
7176
|
-
|
|
7177
|
-
|
|
7178
|
-
|
|
7179
|
-
|
|
7180
|
-
|
|
7181
|
-
|
|
7182
|
-
|
|
7183
|
-
|
|
7184
|
-
|
|
7185
|
-
|
|
7186
|
-
|
|
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");
|
|
7276
|
+
/**
|
|
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).
|
|
7280
|
+
*/
|
|
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
|
|
7310
|
+
});
|
|
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
|
|
7347
|
+
});
|
|
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");
|
|
7414
|
+
}
|
|
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`);
|
|
7429
|
+
}
|
|
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);
|
|
7440
|
+
}
|
|
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
|
+
}
|
|
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);
|
|
7187
7535
|
return {
|
|
7188
|
-
|
|
7189
|
-
|
|
7190
|
-
|
|
7191
|
-
|
|
7192
|
-
|
|
7536
|
+
restore,
|
|
7537
|
+
restored,
|
|
7538
|
+
extras: dedupe(extras),
|
|
7539
|
+
conflicts: [...conflicts].map(([object, why]) => ({
|
|
7540
|
+
object,
|
|
7541
|
+
detail: [...why].join("; ")
|
|
7542
|
+
}))
|
|
7193
7543
|
};
|
|
7194
7544
|
}
|
|
7195
|
-
|
|
7196
|
-
|
|
7197
|
-
|
|
7198
|
-
|
|
7199
|
-
|
|
7200
|
-
|
|
7201
|
-
|
|
7202
|
-
|
|
7203
|
-
const recorded = tooling[TOOLING_KEYS.target];
|
|
7204
|
-
if (recorded === "local" || recorded === "hosted") return recorded;
|
|
7205
|
-
return tooling[TOOLING_KEYS.dbUrl] ? "hosted" : "local";
|
|
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
|
+
});
|
|
7206
7553
|
}
|
|
7207
|
-
|
|
7208
|
-
|
|
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)}`
|
|
7573
|
+
});
|
|
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)})`);
|
|
7209
7602
|
}
|
|
7210
7603
|
//#endregion
|
|
7211
|
-
//#region src/product/
|
|
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
|
+
]
|
|
7617
|
+
});
|
|
7618
|
+
let stdout = "";
|
|
7619
|
+
let stderr = "";
|
|
7620
|
+
child.stdout?.on("data", (chunk) => {
|
|
7621
|
+
stdout += chunk.toString("utf8");
|
|
7622
|
+
});
|
|
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
|
+
});
|
|
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(" ");
|
|
7638
|
+
}
|
|
7639
|
+
//#endregion
|
|
7640
|
+
//#region src/product/supabase-cli.ts
|
|
7212
7641
|
/**
|
|
7213
|
-
*
|
|
7214
|
-
* hosts, and open, full and past sessions for the member's own courses).
|
|
7215
|
-
*
|
|
7216
|
-
* The product supplies the SQL (`devData.file`); devtools supplies who to
|
|
7217
|
-
* run it for. The file reads the signed-in member from a session setting:
|
|
7218
|
-
*
|
|
7219
|
-
* ```sql
|
|
7220
|
-
* select current_setting('devtools.member_id')::uuid;
|
|
7221
|
-
* ```
|
|
7642
|
+
* Finding the Supabase CLI a product is pinned to.
|
|
7222
7643
|
*
|
|
7223
|
-
*
|
|
7224
|
-
*
|
|
7225
|
-
* names someone. Nothing is seeded until someone has signed in, which is why
|
|
7226
|
-
* this is a command run afterwards and not part of `setup`.
|
|
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:
|
|
7227
7646
|
*
|
|
7228
|
-
*
|
|
7229
|
-
*
|
|
7230
|
-
|
|
7231
|
-
|
|
7232
|
-
|
|
7233
|
-
|
|
7234
|
-
|
|
7235
|
-
|
|
7236
|
-
|
|
7237
|
-
|
|
7238
|
-
|
|
7239
|
-
|
|
7240
|
-
* Whether `err` is the product's own SQL being wrong: a Postgres error whose
|
|
7241
|
-
* SQLSTATE class is a cardinality violation (21), data exception (22),
|
|
7242
|
-
* integrity constraint violation (23) or syntax error or access rule
|
|
7243
|
-
* violation (42). Everything else is not the file's fault and reports as a
|
|
7244
|
-
* normal failure: a refused or dropped connection (class 08 and
|
|
7245
|
-
* `ECONNREFUSED`), an operator shutdown (57, `admin_shutdown`), a server out
|
|
7246
|
-
* of resources (53), a transaction that was rolled back (40), and so on.
|
|
7247
|
-
*/
|
|
7248
|
-
function isSqlFileError(err) {
|
|
7249
|
-
const code = err?.code;
|
|
7250
|
-
return typeof code === "string" && /^(21|22|23|42)[0-9A-Z]{3}$/.test(code);
|
|
7251
|
-
}
|
|
7252
|
-
/**
|
|
7253
|
-
* The line of `text` a Postgres error points at, from the error's 1-based
|
|
7254
|
-
* character `position`, or null when it carries none.
|
|
7255
|
-
*/
|
|
7256
|
-
function errorLine(err, text) {
|
|
7257
|
-
const raw = err?.position;
|
|
7258
|
-
const position = typeof raw === "string" ? Number(raw) : raw;
|
|
7259
|
-
if (typeof position !== "number" || !Number.isInteger(position)) return null;
|
|
7260
|
-
if (position < 1 || position > text.length + 1) return null;
|
|
7261
|
-
return text.slice(0, position - 1).split("\n").length;
|
|
7262
|
-
}
|
|
7263
|
-
function postgresSession(dbUrl) {
|
|
7264
|
-
const sql = postgres(dbUrl, {
|
|
7265
|
-
max: 1,
|
|
7266
|
-
onnotice: () => void 0
|
|
7267
|
-
});
|
|
7268
|
-
return {
|
|
7269
|
-
async members(provider) {
|
|
7270
|
-
return (await sql`
|
|
7271
|
-
select u.id, u.email, u.last_sign_in_at
|
|
7272
|
-
from auth.users u
|
|
7273
|
-
join auth.identities i on i.user_id = u.id
|
|
7274
|
-
where i.provider = ${provider}
|
|
7275
|
-
order by u.last_sign_in_at desc nulls last
|
|
7276
|
-
limit 5`).map((r) => ({
|
|
7277
|
-
id: r.id,
|
|
7278
|
-
email: r.email,
|
|
7279
|
-
lastSignInAt: r.last_sign_in_at?.toISOString() ?? null
|
|
7280
|
-
}));
|
|
7281
|
-
},
|
|
7282
|
-
async memberByRef(ref) {
|
|
7283
|
-
const row = (await sql`
|
|
7284
|
-
select id, email, last_sign_in_at from auth.users
|
|
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 from pg_proc p
|
|
7311
|
-
join pg_namespace n on n.oid = p.pronamespace
|
|
7312
|
-
where n.nspname = any(${list})`;
|
|
7313
|
-
for (const r of functions) found.add(`function:${r.s}.${r.n}`);
|
|
7314
|
-
const views = await sql`
|
|
7315
|
-
select n.nspname as s, c.relname as n from pg_class c
|
|
7316
|
-
join pg_namespace n on n.oid = c.relnamespace
|
|
7317
|
-
where n.nspname = any(${list}) and c.relkind in ('v', 'm')`;
|
|
7318
|
-
for (const r of views) found.add(`view:${r.s}.${r.n}`);
|
|
7319
|
-
const triggers = await sql`
|
|
7320
|
-
select n.nspname as s, c.relname as t, g.tgname as n from pg_trigger g
|
|
7321
|
-
join pg_class c on c.oid = g.tgrelid
|
|
7322
|
-
join pg_namespace n on n.oid = c.relnamespace
|
|
7323
|
-
where n.nspname = any(${list}) and not g.tgisinternal`;
|
|
7324
|
-
for (const r of triggers) found.add(`trigger:${r.s}.${r.t}:${r.n}`);
|
|
7325
|
-
return found;
|
|
7326
|
-
},
|
|
7327
|
-
async exec(text) {
|
|
7328
|
-
await sql.begin(async (tx) => {
|
|
7329
|
-
await tx.unsafe(text);
|
|
7330
|
-
});
|
|
7331
|
-
},
|
|
7332
|
-
async runAs(memberId, text) {
|
|
7333
|
-
await sql.begin(async (tx) => {
|
|
7334
|
-
await tx`select set_config('devtools.member_id', ${memberId}, true)`;
|
|
7335
|
-
await tx.unsafe(text);
|
|
7336
|
-
});
|
|
7337
|
-
},
|
|
7338
|
-
close: () => sql.end({ timeout: 5 })
|
|
7339
|
-
};
|
|
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.
|
|
7656
|
+
*/
|
|
7657
|
+
function cliLine(cli, args) {
|
|
7658
|
+
return commandLine(cli.command, [...cli.prefix, ...args]);
|
|
7340
7659
|
}
|
|
7341
|
-
|
|
7342
|
-
|
|
7343
|
-
const { devData } = product.config;
|
|
7344
|
-
if (!devData) throw new DevDataError(`${product.config.name} has no dev data: add "devData": { "file": "supabase/dev-data.sql" } to devtools.config.json.`);
|
|
7345
|
-
try {
|
|
7346
|
-
assertNotProduction(product, target.apiUrl, target.dbUrl);
|
|
7347
|
-
} catch (err) {
|
|
7348
|
-
if (err instanceof TargetError) throw new DevDataError(err.message);
|
|
7349
|
-
throw err;
|
|
7350
|
-
}
|
|
7351
|
-
const file = join(product.root, devData.file);
|
|
7352
|
-
const text = readFileSync(file, "utf8");
|
|
7353
|
-
const session = open(target.dbUrl);
|
|
7354
|
-
try {
|
|
7355
|
-
const missing = await session.missingSchemas(product.config.supabase.schemas);
|
|
7356
|
-
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.`);
|
|
7357
|
-
return await seed(session, devData.file, text, options, log);
|
|
7358
|
-
} finally {
|
|
7359
|
-
await session.close();
|
|
7360
|
-
}
|
|
7660
|
+
function versionOf$1(output) {
|
|
7661
|
+
return /\b(\d+\.\d+\.\d+)\b/.exec(output)?.[1] ?? null;
|
|
7361
7662
|
}
|
|
7362
|
-
async function
|
|
7363
|
-
const
|
|
7364
|
-
|
|
7365
|
-
|
|
7366
|
-
|
|
7367
|
-
|
|
7368
|
-
|
|
7369
|
-
}
|
|
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 {}
|
|
7370
7680
|
try {
|
|
7371
|
-
await
|
|
7372
|
-
|
|
7373
|
-
|
|
7374
|
-
|
|
7375
|
-
|
|
7376
|
-
}
|
|
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 {}
|
|
7377
7687
|
return {
|
|
7378
|
-
|
|
7379
|
-
|
|
7688
|
+
command: "npx",
|
|
7689
|
+
prefix: ["--yes", `supabase@${pin}`],
|
|
7690
|
+
source: "npx"
|
|
7380
7691
|
};
|
|
7381
7692
|
}
|
|
7382
7693
|
//#endregion
|
|
7383
|
-
//#region src/product/
|
|
7384
|
-
|
|
7385
|
-
|
|
7386
|
-
|
|
7387
|
-
|
|
7388
|
-
|
|
7389
|
-
|
|
7390
|
-
|
|
7391
|
-
|
|
7392
|
-
|
|
7393
|
-
*
|
|
7394
|
-
* Both are the Management API's `PATCH /v1/projects/{ref}/config/auth`, with a
|
|
7395
|
-
* personal access token (`SUPABASE_ACCESS_TOKEN`, from the dashboard's Access
|
|
7396
|
-
* Tokens page). Without a token `setup` prints the two dashboard steps.
|
|
7397
|
-
*/
|
|
7398
|
-
const API = "https://api.supabase.com";
|
|
7399
|
-
var ManagementError = class extends Error {
|
|
7400
|
-
status;
|
|
7401
|
-
constructor(message, status) {
|
|
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) {
|
|
7402
7704
|
super(message);
|
|
7403
|
-
this.
|
|
7404
|
-
this.name = "ManagementError";
|
|
7705
|
+
this.name = "TargetError";
|
|
7405
7706
|
}
|
|
7406
7707
|
};
|
|
7407
|
-
/** The project ref
|
|
7408
|
-
function
|
|
7708
|
+
/** The project ref or vanity name in a Supabase URL or pooler URL, if it has one. */
|
|
7709
|
+
function projectNameOf(url) {
|
|
7409
7710
|
try {
|
|
7410
|
-
|
|
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;
|
|
7411
7717
|
} catch {
|
|
7412
7718
|
return null;
|
|
7413
7719
|
}
|
|
7414
7720
|
}
|
|
7415
|
-
/**
|
|
7416
|
-
function
|
|
7417
|
-
const
|
|
7418
|
-
for (const
|
|
7419
|
-
|
|
7420
|
-
}
|
|
7421
|
-
async function ensureHostedAuthSettings(input) {
|
|
7422
|
-
const doFetch = input.fetch ?? fetch;
|
|
7423
|
-
const headers = {
|
|
7424
|
-
authorization: `Bearer ${input.token}`,
|
|
7425
|
-
"content-type": "application/json"
|
|
7426
|
-
};
|
|
7427
|
-
const url = `${API}/v1/projects/${input.projectRef}/config/auth`;
|
|
7428
|
-
const current = await doFetch(url, { headers });
|
|
7429
|
-
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);
|
|
7430
|
-
const settings = await current.json();
|
|
7431
|
-
const patch = {};
|
|
7432
|
-
const changed = [];
|
|
7433
|
-
if (settings.custom_oauth_enabled !== true) {
|
|
7434
|
-
patch["custom_oauth_enabled"] = true;
|
|
7435
|
-
changed.push("custom_oauth_enabled");
|
|
7436
|
-
}
|
|
7437
|
-
const allow = mergeAllowList(settings.uri_allow_list, input.redirectUris);
|
|
7438
|
-
if (allow !== (settings.uri_allow_list ?? "")) {
|
|
7439
|
-
patch["uri_allow_list"] = allow;
|
|
7440
|
-
changed.push("uri_allow_list");
|
|
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.`);
|
|
7441
7727
|
}
|
|
7442
|
-
if (changed.length === 0) return { changed };
|
|
7443
|
-
const response = await doFetch(url, {
|
|
7444
|
-
method: "PATCH",
|
|
7445
|
-
headers,
|
|
7446
|
-
body: JSON.stringify(patch)
|
|
7447
|
-
});
|
|
7448
|
-
if (!response.ok) throw new ManagementError(`Updating the project's auth settings failed (${response.status}).`, response.status);
|
|
7449
|
-
return { changed };
|
|
7450
7728
|
}
|
|
7451
|
-
|
|
7452
|
-
|
|
7453
|
-
|
|
7454
|
-
|
|
7455
|
-
|
|
7456
|
-
|
|
7457
|
-
|
|
7458
|
-
|
|
7459
|
-
|
|
7460
|
-
|
|
7461
|
-
|
|
7462
|
-
|
|
7463
|
-
|
|
7464
|
-
|
|
7465
|
-
|
|
7466
|
-
|
|
7467
|
-
constructor(message) {
|
|
7468
|
-
super(message);
|
|
7469
|
-
this.name = "OAuthRegistrationError";
|
|
7470
|
-
}
|
|
7471
|
-
};
|
|
7472
|
-
function credentialsFromEnv(env) {
|
|
7473
|
-
const clientId = env["DEVDOGS_OAUTH_CLIENT_ID"];
|
|
7474
|
-
const clientSecret = env["DEVDOGS_OAUTH_CLIENT_SECRET"];
|
|
7475
|
-
if (!clientId || !clientSecret) return null;
|
|
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?");
|
|
7476
7745
|
return {
|
|
7477
|
-
|
|
7478
|
-
|
|
7479
|
-
|
|
7746
|
+
kind: "local",
|
|
7747
|
+
apiUrl,
|
|
7748
|
+
dbUrl,
|
|
7749
|
+
publishableKey,
|
|
7750
|
+
secretKey
|
|
7480
7751
|
};
|
|
7481
7752
|
}
|
|
7482
|
-
/**
|
|
7483
|
-
|
|
7484
|
-
const
|
|
7485
|
-
const
|
|
7486
|
-
|
|
7487
|
-
|
|
7488
|
-
|
|
7489
|
-
|
|
7490
|
-
|
|
7491
|
-
|
|
7492
|
-
|
|
7493
|
-
|
|
7494
|
-
|
|
7495
|
-
|
|
7496
|
-
|
|
7497
|
-
[
|
|
7498
|
-
|
|
7499
|
-
|
|
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
|
|
7792
|
+
};
|
|
7500
7793
|
}
|
|
7501
|
-
/**
|
|
7502
|
-
|
|
7503
|
-
|
|
7504
|
-
|
|
7505
|
-
|
|
7506
|
-
|
|
7507
|
-
|
|
7508
|
-
|
|
7509
|
-
|
|
7510
|
-
|
|
7511
|
-
|
|
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";
|
|
7805
|
+
}
|
|
7806
|
+
async function resolveTarget(product, cli, run, preference) {
|
|
7807
|
+
return chooseKind(product, preference) === "hosted" ? hostedTarget(product) : localTarget(product, cli, run);
|
|
7512
7808
|
}
|
|
7513
7809
|
//#endregion
|
|
7514
|
-
//#region src/product/
|
|
7810
|
+
//#region src/product/dev-data.ts
|
|
7515
7811
|
/**
|
|
7516
|
-
*
|
|
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).
|
|
7517
7814
|
*
|
|
7518
|
-
*
|
|
7519
|
-
*
|
|
7520
|
-
* contributor who cannot (or would rather not) generate them downloads the
|
|
7521
|
-
* asset whose name carries the hash of the schema they have checked out. Two
|
|
7522
|
-
* checkouts with the same hash have the same types, so the hash must change
|
|
7523
|
-
* exactly when the generated output could.
|
|
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:
|
|
7524
7817
|
*
|
|
7525
|
-
*
|
|
7526
|
-
*
|
|
7527
|
-
*
|
|
7528
|
-
*
|
|
7529
|
-
*
|
|
7530
|
-
*
|
|
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.
|
|
7531
7829
|
*/
|
|
7532
|
-
|
|
7533
|
-
|
|
7534
|
-
|
|
7535
|
-
|
|
7536
|
-
|
|
7537
|
-
|
|
7538
|
-
else found.push(path);
|
|
7539
|
-
}
|
|
7540
|
-
return found;
|
|
7541
|
-
}
|
|
7542
|
-
/** The files a hash covers, as POSIX paths relative to the schemas directory, sorted. */
|
|
7543
|
-
function schemaFiles(schemasDir) {
|
|
7544
|
-
return listFiles(schemasDir).map((path) => relative(schemasDir, path).split(sep).join("/")).sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
|
|
7545
|
-
}
|
|
7546
|
-
function normalise(contents) {
|
|
7547
|
-
return Buffer.from(contents.toString("utf8").replace(/\r\n/g, "\n"), "utf8");
|
|
7548
|
-
}
|
|
7549
|
-
function hashSchemas(schemasDir, generator) {
|
|
7550
|
-
const hash = createHash("sha256");
|
|
7551
|
-
for (const path of schemaFiles(schemasDir)) {
|
|
7552
|
-
hash.update(`file:${path}\0`);
|
|
7553
|
-
hash.update(normalise(readFileSync(join(schemasDir, ...path.split("/")))));
|
|
7554
|
-
hash.update("\0");
|
|
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";
|
|
7555
7836
|
}
|
|
7556
|
-
|
|
7557
|
-
return hash.digest("hex");
|
|
7558
|
-
}
|
|
7837
|
+
};
|
|
7559
7838
|
/**
|
|
7560
|
-
*
|
|
7561
|
-
*
|
|
7562
|
-
*
|
|
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.
|
|
7563
7846
|
*/
|
|
7564
|
-
function
|
|
7565
|
-
const
|
|
7566
|
-
|
|
7567
|
-
const lock = join(product.root, "pubspec.lock");
|
|
7568
|
-
if (existsSync(lock)) {
|
|
7569
|
-
const match = /^ {2}supadart:\n(?:.*\n)*? {4}version: "([^"]+)"/m.exec(readFileSync(lock, "utf8"));
|
|
7570
|
-
if (match?.[1]) return `supadart@${match[1]}`;
|
|
7571
|
-
}
|
|
7572
|
-
const pubspec = join(product.root, "pubspec.yaml");
|
|
7573
|
-
if (existsSync(pubspec)) {
|
|
7574
|
-
const match = /^\s+supadart:\s*([^\s#]+)/m.exec(readFileSync(pubspec, "utf8"));
|
|
7575
|
-
if (match?.[1]) return `supadart@${match[1]}`;
|
|
7576
|
-
}
|
|
7577
|
-
return "supadart@unknown";
|
|
7578
|
-
}
|
|
7579
|
-
function schemaHashFor(product) {
|
|
7580
|
-
return hashSchemas(product.paths.schemasDir, generatorFor(product));
|
|
7847
|
+
function isSqlFileError(err) {
|
|
7848
|
+
const code = err?.code;
|
|
7849
|
+
return typeof code === "string" && /^(21|22|23|42)[0-9A-Z]{3}$/.test(code);
|
|
7581
7850
|
}
|
|
7582
|
-
/**
|
|
7583
|
-
|
|
7584
|
-
|
|
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;
|
|
7585
7861
|
}
|
|
7586
|
-
|
|
7587
|
-
|
|
7588
|
-
|
|
7589
|
-
|
|
7590
|
-
|
|
7591
|
-
|
|
7592
|
-
|
|
7593
|
-
|
|
7594
|
-
|
|
7595
|
-
|
|
7596
|
-
|
|
7597
|
-
|
|
7598
|
-
|
|
7599
|
-
|
|
7600
|
-
|
|
7601
|
-
|
|
7602
|
-
|
|
7603
|
-
|
|
7604
|
-
}
|
|
7605
|
-
|
|
7606
|
-
const
|
|
7607
|
-
|
|
7608
|
-
|
|
7609
|
-
|
|
7610
|
-
|
|
7611
|
-
|
|
7612
|
-
|
|
7613
|
-
|
|
7614
|
-
|
|
7615
|
-
|
|
7616
|
-
|
|
7617
|
-
|
|
7618
|
-
|
|
7619
|
-
|
|
7620
|
-
|
|
7621
|
-
|
|
7622
|
-
|
|
7623
|
-
|
|
7624
|
-
|
|
7625
|
-
|
|
7626
|
-
|
|
7627
|
-
|
|
7628
|
-
|
|
7629
|
-
|
|
7630
|
-
|
|
7631
|
-
|
|
7632
|
-
|
|
7633
|
-
|
|
7634
|
-
|
|
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})`);
|
|
7635
7917
|
}
|
|
7636
|
-
|
|
7637
|
-
|
|
7638
|
-
|
|
7639
|
-
|
|
7640
|
-
|
|
7641
|
-
|
|
7642
|
-
|
|
7643
|
-
|
|
7644
|
-
|
|
7645
|
-
|
|
7646
|
-
|
|
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
|
+
};
|
|
7647
8010
|
}
|
|
7648
|
-
|
|
7649
|
-
|
|
7650
|
-
const
|
|
7651
|
-
|
|
7652
|
-
|
|
7653
|
-
|
|
7654
|
-
|
|
7655
|
-
|
|
7656
|
-
|
|
7657
|
-
|
|
7658
|
-
|
|
7659
|
-
|
|
7660
|
-
|
|
7661
|
-
|
|
7662
|
-
|
|
7663
|
-
|
|
7664
|
-
|
|
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();
|
|
7665
8030
|
}
|
|
7666
|
-
parts.push(text.slice(start).trim());
|
|
7667
|
-
return parts.filter(Boolean);
|
|
7668
|
-
}
|
|
7669
|
-
const IDENT = String.raw`(?:"(?:[^"]|"")+"|[A-Za-z_][\w$]*)`;
|
|
7670
|
-
const QNAME = String.raw`${IDENT}(?:\s*\.\s*${IDENT})*`;
|
|
7671
|
-
/** `"a"."b"` -> ["a", "b"]; unquoted names fold to lower case like Postgres. */
|
|
7672
|
-
function parts(name) {
|
|
7673
|
-
return (name.match(new RegExp(IDENT, "g")) ?? []).map((part) => part.startsWith("\"") ? part.slice(1, -1).replace(/""/g, "\"") : part.toLowerCase());
|
|
7674
|
-
}
|
|
7675
|
-
function qualified(name) {
|
|
7676
|
-
return parts(name).join(".");
|
|
7677
8031
|
}
|
|
7678
|
-
function
|
|
7679
|
-
|
|
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).`);
|
|
8046
|
+
}
|
|
8047
|
+
return {
|
|
8048
|
+
member,
|
|
8049
|
+
file
|
|
8050
|
+
};
|
|
7680
8051
|
}
|
|
7681
|
-
|
|
7682
|
-
|
|
7683
|
-
TABLE: "table",
|
|
7684
|
-
"FOREIGN TABLE": "table",
|
|
7685
|
-
SEQUENCE: "sequence",
|
|
7686
|
-
TYPE: "type",
|
|
7687
|
-
DOMAIN: "type",
|
|
7688
|
-
VIEW: "view",
|
|
7689
|
-
"MATERIALIZED VIEW": "view",
|
|
7690
|
-
FUNCTION: "function",
|
|
7691
|
-
PROCEDURE: "function"
|
|
7692
|
-
};
|
|
7693
|
-
const WORD = (word) => new RegExp(`^${word}\\b`, "i");
|
|
8052
|
+
//#endregion
|
|
8053
|
+
//#region src/product/management.ts
|
|
7694
8054
|
/**
|
|
7695
|
-
*
|
|
7696
|
-
*
|
|
7697
|
-
*
|
|
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.
|
|
7698
8067
|
*/
|
|
7699
|
-
|
|
7700
|
-
|
|
7701
|
-
|
|
7702
|
-
|
|
7703
|
-
|
|
7704
|
-
|
|
7705
|
-
|
|
7706
|
-
|
|
7707
|
-
|
|
7708
|
-
|
|
7709
|
-
|
|
7710
|
-
|
|
7711
|
-
|
|
7712
|
-
|
|
7713
|
-
|
|
7714
|
-
|
|
7715
|
-
|
|
7716
|
-
|
|
7717
|
-
|
|
7718
|
-
|
|
7719
|
-
|
|
7720
|
-
|
|
7721
|
-
|
|
7722
|
-
|
|
7723
|
-
|
|
7724
|
-
|
|
7725
|
-
}
|
|
7726
|
-
|
|
7727
|
-
|
|
7728
|
-
|
|
7729
|
-
|
|
7730
|
-
|
|
7731
|
-
|
|
7732
|
-
|
|
7733
|
-
|
|
7734
|
-
|
|
7735
|
-
|
|
7736
|
-
|
|
7737
|
-
continue;
|
|
7738
|
-
}
|
|
7739
|
-
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);
|
|
7740
|
-
if (create) {
|
|
7741
|
-
const replace = Boolean(create[1]);
|
|
7742
|
-
const kindWord = create[2].toUpperCase().replace(/\s+/g, " ");
|
|
7743
|
-
const name = create[3];
|
|
7744
|
-
const rest = create[4];
|
|
7745
|
-
let key;
|
|
7746
|
-
let label;
|
|
7747
|
-
if (kindWord === "INDEX") {
|
|
7748
|
-
key = `index:${parts(name).at(-1)}`;
|
|
7749
|
-
label = `index ${shown(name)}`;
|
|
7750
|
-
} else if (kindWord === "POLICY" || kindWord === "TRIGGER" || kindWord === "CONSTRAINT TRIGGER") {
|
|
7751
|
-
const on = new RegExp(String.raw`\bON\s+(${QNAME})`, "i").exec(rest);
|
|
7752
|
-
const table = on ? qualified(on[1]) : "?";
|
|
7753
|
-
const noun = kindWord === "POLICY" ? "policy" : "trigger";
|
|
7754
|
-
key = `${noun}:${table}:${parts(name).at(-1)}`;
|
|
7755
|
-
label = `${noun} ${shown(name)} on ${on ? shown(on[1]) : "?"}`;
|
|
7756
|
-
} else {
|
|
7757
|
-
const kind = CREATE_KINDS[kindWord] ?? kindWord.toLowerCase();
|
|
7758
|
-
key = `${kind}:${qualified(name)}`;
|
|
7759
|
-
label = `${kind} ${shown(name)}`;
|
|
7760
|
-
}
|
|
7761
|
-
if (replace && existing.has(key)) conflict(label, "its definition differs from supabase/schemas");
|
|
7762
|
-
else creates.push({
|
|
7763
|
-
key,
|
|
7764
|
-
sql: statement,
|
|
7765
|
-
label
|
|
7766
|
-
});
|
|
7767
|
-
continue;
|
|
7768
|
-
}
|
|
7769
|
-
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);
|
|
7770
|
-
if (drop) {
|
|
7771
|
-
const kindWord = drop[1].toUpperCase();
|
|
7772
|
-
const name = drop[2];
|
|
7773
|
-
const rest = drop[3];
|
|
7774
|
-
if (kindWord === "INDEX") drops.push({
|
|
7775
|
-
key: `index:${parts(name).at(-1)}`,
|
|
7776
|
-
finding: {
|
|
7777
|
-
object: `index ${shown(name)}`,
|
|
7778
|
-
detail: "not in supabase/schemas"
|
|
7779
|
-
}
|
|
7780
|
-
});
|
|
7781
|
-
else if (kindWord === "POLICY" || kindWord === "TRIGGER") {
|
|
7782
|
-
const on = new RegExp(String.raw`\bON\s+(${QNAME})`, "i").exec(rest);
|
|
7783
|
-
const noun = kindWord.toLowerCase();
|
|
7784
|
-
drops.push({
|
|
7785
|
-
key: `${noun}:${on ? qualified(on[1]) : "?"}:${parts(name).at(-1)}`,
|
|
7786
|
-
finding: {
|
|
7787
|
-
object: `${noun} ${shown(name)} on ${on ? shown(on[1]) : "?"}`,
|
|
7788
|
-
detail: "not in supabase/schemas"
|
|
7789
|
-
}
|
|
7790
|
-
});
|
|
7791
|
-
} else {
|
|
7792
|
-
const kind = CREATE_KINDS[kindWord] ?? kindWord.toLowerCase();
|
|
7793
|
-
drops.push({
|
|
7794
|
-
key: `${kind}:${qualified(name)}`,
|
|
7795
|
-
finding: {
|
|
7796
|
-
object: `${kind} ${shown(name)}`,
|
|
7797
|
-
detail: "not in supabase/schemas"
|
|
7798
|
-
}
|
|
7799
|
-
});
|
|
7800
|
-
}
|
|
7801
|
-
continue;
|
|
7802
|
-
}
|
|
7803
|
-
const alterTable = new RegExp(String.raw`^ALTER\s+(?:FOREIGN\s+)?TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?(${QNAME})\s+(.*)$`, "is").exec(text);
|
|
7804
|
-
if (alterTable) {
|
|
7805
|
-
const table = alterTable[1];
|
|
7806
|
-
const t = qualified(table);
|
|
7807
|
-
for (const action of splitTopLevel(alterTable[2])) classifyTableAction(table, t, action, {
|
|
7808
|
-
creates,
|
|
7809
|
-
drops,
|
|
7810
|
-
conflict
|
|
7811
|
-
});
|
|
7812
|
-
continue;
|
|
7813
|
-
}
|
|
7814
|
-
const alterType = new RegExp(String.raw`^ALTER\s+TYPE\s+(${QNAME})\s+ADD\s+VALUE\b`, "i").exec(text);
|
|
7815
|
-
if (alterType) {
|
|
7816
|
-
creates.push({
|
|
7817
|
-
key: `enumvalue:${statement}`,
|
|
7818
|
-
sql: statement,
|
|
7819
|
-
label: `a value of type ${shown(alterType[1])}`
|
|
7820
|
-
});
|
|
7821
|
-
continue;
|
|
7822
|
-
}
|
|
7823
|
-
conflict(text.slice(0, 80), "a difference devtools does not know how to classify, so it will not touch it");
|
|
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";
|
|
8075
|
+
}
|
|
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;
|
|
8083
|
+
}
|
|
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");
|
|
7824
8106
|
}
|
|
7825
|
-
const
|
|
7826
|
-
|
|
7827
|
-
|
|
7828
|
-
|
|
7829
|
-
|
|
7830
|
-
|
|
7831
|
-
const
|
|
7832
|
-
|
|
7833
|
-
|
|
7834
|
-
|
|
7835
|
-
|
|
7836
|
-
|
|
7837
|
-
|
|
7838
|
-
|
|
7839
|
-
|
|
7840
|
-
|
|
7841
|
-
|
|
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;
|
|
7842
8146
|
return {
|
|
7843
|
-
|
|
7844
|
-
|
|
7845
|
-
|
|
7846
|
-
conflicts: [...conflicts].map(([object, why]) => ({
|
|
7847
|
-
object,
|
|
7848
|
-
detail: [...why].join("; ")
|
|
7849
|
-
}))
|
|
8147
|
+
clientId,
|
|
8148
|
+
clientSecret,
|
|
8149
|
+
baseUrl: env["DEVDOGS_OAUTH_BASE_URL"]
|
|
7850
8150
|
};
|
|
7851
8151
|
}
|
|
7852
|
-
|
|
7853
|
-
|
|
7854
|
-
|
|
7855
|
-
|
|
7856
|
-
|
|
7857
|
-
|
|
7858
|
-
|
|
7859
|
-
|
|
7860
|
-
|
|
7861
|
-
|
|
7862
|
-
|
|
7863
|
-
|
|
7864
|
-
if (m = new RegExp(String.raw`^ADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?(${IDENT})`, "i").exec(action)) ctx.creates.push({
|
|
7865
|
-
key: `column:${t}.${parts(m[1])[0]}`,
|
|
7866
|
-
sql,
|
|
7867
|
-
label: `column ${shown(table)}.${shown(m[1])}`
|
|
7868
|
-
});
|
|
7869
|
-
else if (m = new RegExp(String.raw`^ADD\s+CONSTRAINT\s+(${IDENT})`, "i").exec(action)) ctx.creates.push({
|
|
7870
|
-
key: `constraint:${t}.${parts(m[1])[0]}`,
|
|
7871
|
-
sql,
|
|
7872
|
-
label: `constraint ${shown(m[1])} on ${shown(table)}`
|
|
7873
|
-
});
|
|
7874
|
-
else if (m = new RegExp(String.raw`^DROP\s+COLUMN\s+(?:IF\s+EXISTS\s+)?(${IDENT})`, "i").exec(action)) ctx.drops.push({
|
|
7875
|
-
key: `column:${t}.${parts(m[1])[0]}`,
|
|
7876
|
-
finding: {
|
|
7877
|
-
object: `column ${shown(table)}.${shown(m[1])}`,
|
|
7878
|
-
detail: "not in supabase/schemas"
|
|
7879
|
-
}
|
|
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
|
|
7880
8164
|
});
|
|
7881
|
-
|
|
7882
|
-
|
|
7883
|
-
|
|
7884
|
-
object: `constraint ${shown(m[1])} on ${shown(table)}`,
|
|
7885
|
-
detail: "not in supabase/schemas"
|
|
7886
|
-
}
|
|
8165
|
+
upsertEnvFile(credentialsFile, {
|
|
8166
|
+
[ENV_KEYS.clientId]: credentials.clientId,
|
|
8167
|
+
[ENV_KEYS.clientSecret]: credentials.clientSecret
|
|
7887
8168
|
});
|
|
7888
|
-
|
|
7889
|
-
|
|
7890
|
-
|
|
7891
|
-
|
|
7892
|
-
|
|
7893
|
-
|
|
7894
|
-
|
|
7895
|
-
|
|
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]
|
|
7896
8181
|
});
|
|
7897
|
-
|
|
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`;
|
|
7898
8255
|
}
|
|
7899
8256
|
//#endregion
|
|
7900
8257
|
//#region src/product/sync.ts
|
|
@@ -7966,6 +8323,65 @@ const NOTHING = {
|
|
|
7966
8323
|
extras: [],
|
|
7967
8324
|
compared: false
|
|
7968
8325
|
};
|
|
8326
|
+
function recordFile(product) {
|
|
8327
|
+
return join(product.root, ".devtools", "record.json");
|
|
8328
|
+
}
|
|
8329
|
+
function pendingDir(product) {
|
|
8330
|
+
return join(product.root, ".devtools", "pending");
|
|
8331
|
+
}
|
|
8332
|
+
function readRecord(product) {
|
|
8333
|
+
try {
|
|
8334
|
+
const raw = JSON.parse(readFileSync(recordFile(product), "utf8"));
|
|
8335
|
+
return typeof raw.target === "string" && Array.isArray(raw.applied) ? {
|
|
8336
|
+
target: raw.target,
|
|
8337
|
+
applied: raw.applied.map(String)
|
|
8338
|
+
} : null;
|
|
8339
|
+
} catch {
|
|
8340
|
+
return null;
|
|
8341
|
+
}
|
|
8342
|
+
}
|
|
8343
|
+
function writeRecord(product, target, files) {
|
|
8344
|
+
mkdirSync(dirname(recordFile(product)), { recursive: true });
|
|
8345
|
+
const record = {
|
|
8346
|
+
target: targetKey(target),
|
|
8347
|
+
applied: files.map(versionOf)
|
|
8348
|
+
};
|
|
8349
|
+
writeFileSync(recordFile(product), JSON.stringify(record, null, 2) + "\n");
|
|
8350
|
+
}
|
|
8351
|
+
/** `local`, or `hosted:<project ref>` (never the password). */
|
|
8352
|
+
function targetKey(target) {
|
|
8353
|
+
if (target.kind === "local") return "local";
|
|
8354
|
+
try {
|
|
8355
|
+
const url = new URL(target.dbUrl);
|
|
8356
|
+
const user = decodeURIComponent(url.username);
|
|
8357
|
+
return `hosted:${(user.includes(".") ? user.split(".").slice(1).join(".") : "") || url.hostname}`;
|
|
8358
|
+
} catch {
|
|
8359
|
+
return "hosted";
|
|
8360
|
+
}
|
|
8361
|
+
}
|
|
8362
|
+
/** The migrations the target has applied (null: no history table); throws if it cannot be reached. */
|
|
8363
|
+
async function probe(open, target, redact) {
|
|
8364
|
+
let session;
|
|
8365
|
+
try {
|
|
8366
|
+
session = open(target.dbUrl);
|
|
8367
|
+
return await session.appliedMigrations();
|
|
8368
|
+
} catch (err) {
|
|
8369
|
+
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));
|
|
8370
|
+
} finally {
|
|
8371
|
+
await session?.close().catch(() => void 0);
|
|
8372
|
+
}
|
|
8373
|
+
}
|
|
8374
|
+
/** Puts back migrations a sync that was killed had set aside. */
|
|
8375
|
+
function restorePending(product) {
|
|
8376
|
+
const dir = pendingDir(product);
|
|
8377
|
+
if (!existsSync(dir)) return;
|
|
8378
|
+
mkdirSync(product.paths.migrationsDir, { recursive: true });
|
|
8379
|
+
for (const file of readdirSync(dir)) {
|
|
8380
|
+
const back = join(product.paths.migrationsDir, file);
|
|
8381
|
+
if (existsSync(back)) rmSync(join(dir, file));
|
|
8382
|
+
else renameSync(join(dir, file), back);
|
|
8383
|
+
}
|
|
8384
|
+
}
|
|
7969
8385
|
/** `20261011120000` from `20261011120000_devtools_sync.sql`. */
|
|
7970
8386
|
function versionOf(file) {
|
|
7971
8387
|
return file.split("_")[0] ?? file;
|
|
@@ -8047,34 +8463,65 @@ function listFindings(findings) {
|
|
|
8047
8463
|
*/
|
|
8048
8464
|
async function reconcileLive(product, cli, target, run, options, log, warn, redact) {
|
|
8049
8465
|
const files = migrationFiles(product.paths.migrationsDir);
|
|
8050
|
-
if (!options.open || files.length === 0) return
|
|
8466
|
+
if (!options.open || files.length === 0) return {
|
|
8467
|
+
...NOTHING,
|
|
8468
|
+
pending: []
|
|
8469
|
+
};
|
|
8051
8470
|
const { schemas } = product.config.supabase;
|
|
8052
8471
|
let session;
|
|
8053
8472
|
let ledger;
|
|
8054
8473
|
let existing;
|
|
8474
|
+
let columnTypes;
|
|
8475
|
+
let privileges;
|
|
8055
8476
|
try {
|
|
8056
8477
|
session = options.open(target.dbUrl);
|
|
8057
8478
|
ledger = await session.appliedMigrations();
|
|
8058
8479
|
existing = await session.existingObjects(schemas);
|
|
8480
|
+
columnTypes = await session.columnTypes(schemas);
|
|
8481
|
+
privileges = await session.privileges(schemas);
|
|
8059
8482
|
} catch (err) {
|
|
8060
8483
|
await session?.close().catch(() => void 0);
|
|
8061
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));
|
|
8062
8485
|
}
|
|
8486
|
+
const record = readRecord(product);
|
|
8487
|
+
const noted = record?.target === targetKey(target) ? record.applied : null;
|
|
8488
|
+
const known = /* @__PURE__ */ new Set([...ledger ?? [], ...noted ?? []]);
|
|
8489
|
+
const everythingApplied = ledger === null && noted === null;
|
|
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
|
+
}
|
|
8498
|
+
const appliedFiles = files.filter(isApplied);
|
|
8499
|
+
const pending = files.filter((file) => !isApplied(file));
|
|
8500
|
+
if (appliedFiles.length === 0) {
|
|
8501
|
+
await session.close().catch(() => void 0);
|
|
8502
|
+
return {
|
|
8503
|
+
...NOTHING,
|
|
8504
|
+
pending
|
|
8505
|
+
};
|
|
8506
|
+
}
|
|
8507
|
+
const aside = pendingDir(product);
|
|
8508
|
+
if (pending.length > 0) mkdirSync(aside, { recursive: true });
|
|
8509
|
+
for (const file of pending) renameSync(join(product.paths.migrationsDir, file), join(aside, file));
|
|
8063
8510
|
try {
|
|
8064
|
-
const drift = classifyDiff(await liveDiff(product, cli, target, run, log, redact), existing);
|
|
8511
|
+
const drift = classifyDiff(await liveDiff(product, cli, target, run, log, redact), existing, columnTypes, privileges);
|
|
8065
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)}`);
|
|
8066
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`"}.`);
|
|
8067
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)}`);
|
|
8068
8515
|
if (drift.restore.length > 0) {
|
|
8069
|
-
if (drift.restored.length > 0) log(`Your database is missing ${summarize(drift.restored)}; creating ${drift.restored.length === 1 ? "it" : "them"} again from supabase/schemas. Nothing that already exists is changed
|
|
8516
|
+
if (drift.restored.length > 0) log(`Your database is missing ${summarize(drift.restored)}; creating ${drift.restored.length === 1 ? "it" : "them"} again from supabase/schemas. Nothing that already exists is changed.${drift.restored.some((label) => label.startsWith("privileges on")) ? " For the privileges, what supabase/schemas grants was granted again; privileges beyond that were not taken away." : ""}`);
|
|
8070
8517
|
try {
|
|
8071
8518
|
await session.exec(drift.restore.join(";\n") + ";");
|
|
8072
8519
|
} catch (err) {
|
|
8073
8520
|
throw new SyncError("Could not recreate what the database is missing; nothing was changed.", `${redact(err.message)}\n${target.kind === "local" ? "`devtools db sync --rebuild` starts over (it wipes your development database)." : "Reset the project from its dashboard (Database > Settings), then run `devtools db sync`."}`);
|
|
8074
8521
|
}
|
|
8075
8522
|
}
|
|
8076
|
-
const
|
|
8077
|
-
const unlisted =
|
|
8523
|
+
const listed = new Set(ledger ?? []);
|
|
8524
|
+
const unlisted = appliedFiles.map(versionOf).filter((version) => !listed.has(version));
|
|
8078
8525
|
if (unlisted.length > 0) {
|
|
8079
8526
|
if (ledger === null) log("The database has no migration history table; recreating it from the local record.");
|
|
8080
8527
|
const repaired = await run(cli.command, [
|
|
@@ -8092,9 +8539,14 @@ async function reconcileLive(product, cli, target, run, options, log, warn, reda
|
|
|
8092
8539
|
return {
|
|
8093
8540
|
restored: drift.restored,
|
|
8094
8541
|
extras: drift.extras,
|
|
8095
|
-
compared: true
|
|
8542
|
+
compared: true,
|
|
8543
|
+
pending
|
|
8096
8544
|
};
|
|
8097
8545
|
} finally {
|
|
8546
|
+
for (const file of pending) {
|
|
8547
|
+
const from = join(aside, file);
|
|
8548
|
+
if (existsSync(from)) renameSync(from, join(product.paths.migrationsDir, file));
|
|
8549
|
+
}
|
|
8098
8550
|
await session.close().catch(() => void 0);
|
|
8099
8551
|
}
|
|
8100
8552
|
}
|
|
@@ -8104,6 +8556,7 @@ async function dbSync(product, cli, target, run, options) {
|
|
|
8104
8556
|
const redact = redactor(target);
|
|
8105
8557
|
const { migrationsDir, schemasDir } = product.paths;
|
|
8106
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;
|
|
8107
8560
|
mkdirSync(migrationsDir, { recursive: true });
|
|
8108
8561
|
const generateArgs = [
|
|
8109
8562
|
...cli.prefix,
|
|
@@ -8119,17 +8572,23 @@ async function dbSync(product, cli, target, run, options) {
|
|
|
8119
8572
|
...cli.prefix,
|
|
8120
8573
|
"migration",
|
|
8121
8574
|
"fetch",
|
|
8122
|
-
"--db-url",
|
|
8123
|
-
target.dbUrl
|
|
8575
|
+
...target.kind === "local" ? ["--local"] : ["--db-url", target.dbUrl]
|
|
8124
8576
|
];
|
|
8125
|
-
const
|
|
8577
|
+
const key = targetKey(target);
|
|
8578
|
+
const owner = readRecord(product)?.target;
|
|
8579
|
+
const foreign = owner !== void 0 && owner !== key && migrationFiles(migrationsDir).length > 0;
|
|
8580
|
+
let needsRecord = migrationFiles(migrationsDir).length === 0 && (target.kind === "hosted" || owner !== void 0 && owner !== key) || foreign;
|
|
8126
8581
|
if (options.dryRun) {
|
|
8127
|
-
if (needsRecord) log(`Would run: ${cliLine(cli, [
|
|
8582
|
+
if (needsRecord) log(`Would run: ${cliLine(cli, target.kind === "local" ? [
|
|
8583
|
+
"migration",
|
|
8584
|
+
"fetch",
|
|
8585
|
+
"--local"
|
|
8586
|
+
] : [
|
|
8128
8587
|
"migration",
|
|
8129
8588
|
"fetch",
|
|
8130
8589
|
"--db-url",
|
|
8131
8590
|
"<DB_URL>"
|
|
8132
|
-
])} (this machine has no record of what the project has had applied)`);
|
|
8591
|
+
])} (this machine has no record of what ${target.kind === "local" ? "the local stack" : "the project"} has had applied)`);
|
|
8133
8592
|
if (options.open) log(`Would run: ${cliLine(cli, liveDiffArgs(product, target).map((arg) => arg === target.dbUrl ? "<DB_URL>" : arg))} (and recreate anything the database is missing; extras are only listed)`);
|
|
8134
8593
|
log(`Would run: ${cliLine(cli, generateArgs.slice(cli.prefix.length))}`);
|
|
8135
8594
|
log(target.kind === "local" ? `Would run: ${cliLine(cli, [
|
|
@@ -8150,12 +8609,32 @@ async function dbSync(product, cli, target, run, options) {
|
|
|
8150
8609
|
...NOTHING
|
|
8151
8610
|
};
|
|
8152
8611
|
}
|
|
8612
|
+
restorePending(product);
|
|
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
|
+
}
|
|
8624
|
+
if (foreign) {
|
|
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.`);
|
|
8626
|
+
forgetMigrations(product);
|
|
8627
|
+
}
|
|
8153
8628
|
if (needsRecord) {
|
|
8154
8629
|
const fetched = await run(cli.command, fetchArgs, { cwd: product.root });
|
|
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));
|
|
8155
8631
|
const restoredFiles = migrationFiles(migrationsDir).length;
|
|
8156
|
-
if (fetched.code === 0 && restoredFiles > 0)
|
|
8632
|
+
if (fetched.code === 0 && restoredFiles > 0) {
|
|
8633
|
+
log(`Restored ${restoredFiles} applied migration${restoredFiles === 1 ? "" : "s"} from ${target.kind === "local" ? "the local stack's" : "the project's"} history.`);
|
|
8634
|
+
writeRecord(product, target, migrationFiles(migrationsDir));
|
|
8635
|
+
}
|
|
8157
8636
|
}
|
|
8158
|
-
const live = await reconcileLive(product, cli, target, run, options, log, warn, redact);
|
|
8637
|
+
const { pending, ...live } = await reconcileLive(product, cli, target, run, options, log, warn, redact);
|
|
8159
8638
|
const restoredSomething = live.restored.length > 0;
|
|
8160
8639
|
const before = new Set(migrationFiles(migrationsDir));
|
|
8161
8640
|
let generated = await run(cli.command, generateArgs, { cwd: product.root });
|
|
@@ -8165,11 +8644,13 @@ async function dbSync(product, cli, target, run, options) {
|
|
|
8165
8644
|
}
|
|
8166
8645
|
if (generated.code !== 0) throw new SyncError(isContainerFailure(generated) ? "Could not diff the schema: Docker is needed for the shadow database. Start Docker and try again." : "Could not diff the schema against what this database has had applied.", redact(generated.stderr || generated.stdout));
|
|
8167
8646
|
const created = migrationFiles(migrationsDir).filter((f) => !before.has(f));
|
|
8168
|
-
|
|
8647
|
+
const toApply = [...pending, ...created].sort();
|
|
8648
|
+
if (toApply.length === 0) {
|
|
8169
8649
|
if (live.compared && !restoredSomething) {
|
|
8170
8650
|
const n = live.extras.length;
|
|
8171
8651
|
log(n === 0 ? "The database already matches supabase/schemas." : `The database matches supabase/schemas, with ${n} extra thing${n === 1 ? "" : "s"} listed above that it does not define.`);
|
|
8172
8652
|
}
|
|
8653
|
+
writeRecord(product, target, migrationFiles(migrationsDir));
|
|
8173
8654
|
return {
|
|
8174
8655
|
migration: null,
|
|
8175
8656
|
applied: restoredSomething,
|
|
@@ -8177,8 +8658,8 @@ async function dbSync(product, cli, target, run, options) {
|
|
|
8177
8658
|
...live
|
|
8178
8659
|
};
|
|
8179
8660
|
}
|
|
8180
|
-
const migration =
|
|
8181
|
-
const sql = readFileSync(join(migrationsDir,
|
|
8661
|
+
const migration = toApply[toApply.length - 1];
|
|
8662
|
+
const sql = toApply.map((file) => readFileSync(join(migrationsDir, file), "utf8")).join("\n");
|
|
8182
8663
|
const destructive = destructiveStatements(sql);
|
|
8183
8664
|
if (destructive.length > 0 && !options.yes) {
|
|
8184
8665
|
if (!(options.confirmDestructive ? await options.confirmDestructive(sql, destructive) : false)) {
|
|
@@ -8186,7 +8667,7 @@ async function dbSync(product, cli, target, run, options) {
|
|
|
8186
8667
|
throw new SyncError(`The change drops data in your development database (${destructive.length} statement${destructive.length === 1 ? "" : "s"}). Re-run with --yes to allow it.`, destructive.join(";\n"));
|
|
8187
8668
|
}
|
|
8188
8669
|
}
|
|
8189
|
-
log(`Applying ${
|
|
8670
|
+
log(`Applying ${toApply.join(", ")}`);
|
|
8190
8671
|
const applyArgs = target.kind === "local" ? [
|
|
8191
8672
|
...cli.prefix,
|
|
8192
8673
|
"migration",
|
|
@@ -8203,8 +8684,10 @@ async function dbSync(product, cli, target, run, options) {
|
|
|
8203
8684
|
const applied = await run(cli.command, applyArgs, { cwd: product.root });
|
|
8204
8685
|
if (applied.code !== 0) {
|
|
8205
8686
|
for (const file of created) rmSync(join(migrationsDir, file));
|
|
8206
|
-
|
|
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);
|
|
8207
8689
|
}
|
|
8690
|
+
writeRecord(product, target, migrationFiles(migrationsDir));
|
|
8208
8691
|
return {
|
|
8209
8692
|
migration,
|
|
8210
8693
|
applied: true,
|
|
@@ -8219,6 +8702,11 @@ function isContainerFailure(result) {
|
|
|
8219
8702
|
function forgetMigrations(product) {
|
|
8220
8703
|
const files = migrationFiles(product.paths.migrationsDir);
|
|
8221
8704
|
for (const file of files) rmSync(join(product.paths.migrationsDir, file));
|
|
8705
|
+
rmSync(recordFile(product), { force: true });
|
|
8706
|
+
rmSync(pendingDir(product), {
|
|
8707
|
+
recursive: true,
|
|
8708
|
+
force: true
|
|
8709
|
+
});
|
|
8222
8710
|
return files;
|
|
8223
8711
|
}
|
|
8224
8712
|
//#endregion
|