@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.
@@ -7002,899 +7002,1256 @@ const OAUTH = {
7002
7002
  "callbackPath": "/auth/v1/callback"
7003
7003
  };
7004
7004
  //#endregion
7005
- //#region src/product/exec.ts
7006
- const spawnRunner = (command, args, options) => new Promise((resolve, reject) => {
7007
- const child = spawnProcess(command, args, {
7008
- cwd: options.cwd,
7009
- env: {
7010
- ...process.env,
7011
- ...options.env
7012
- },
7013
- stdio: options.inherit ? "inherit" : [
7014
- "ignore",
7015
- "pipe",
7016
- "pipe"
7017
- ]
7018
- });
7019
- let stdout = "";
7020
- let stderr = "";
7021
- child.stdout?.on("data", (chunk) => {
7022
- stdout += chunk.toString("utf8");
7023
- });
7024
- child.stderr?.on("data", (chunk) => {
7025
- stderr += chunk.toString("utf8");
7026
- });
7027
- child.on("error", reject);
7028
- child.on("close", (code) => {
7029
- resolve({
7030
- code: code ?? 1,
7031
- stdout,
7032
- stderr
7033
- });
7034
- });
7035
- });
7036
- /** `command arg arg`, quoted only where a shell would need it. For messages. */
7037
- function commandLine(command, args) {
7038
- return [command, ...args].map((part) => /^[\w@%+=:,./-]+$/.test(part) ? part : JSON.stringify(part)).join(" ");
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
- //#endregion
7041
- //#region src/product/supabase-cli.ts
7042
- /**
7043
- * Finding the Supabase CLI a product is pinned to.
7044
- *
7045
- * A product repo may have no `package.json` (DogPack has none), so the CLI
7046
- * cannot be assumed to be a dependency. In order:
7047
- *
7048
- * 1. `node_modules/.bin/supabase` in the repo, when its version is the pin;
7049
- * 2. a `supabase` on PATH whose version is the pin;
7050
- * 3. `pnpm dlx supabase@<pin>`, which downloads and caches the right one.
7051
- * Members are expected to have pnpm (both product repos pin it);
7052
- * 4. `npx --yes supabase@<pin>`, only when pnpm is not on PATH.
7053
- *
7054
- * The pin is also part of the generated-types hash, so a contributor on a
7055
- * different CLI would generate different types than CI; matching it is not
7056
- * cosmetic.
7057
- */
7058
- function cliLine(cli, args) {
7059
- return commandLine(cli.command, [...cli.prefix, ...args]);
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
- function versionOf$1(output) {
7062
- return /\b(\d+\.\d+\.\d+)\b/.exec(output)?.[1] ?? null;
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
- async function resolveSupabaseCli(root, pin, run, fileExists = existsSync) {
7065
- const candidates = [];
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
- //#endregion
7095
- //#region src/product/target.ts
7096
- const TOOLING_KEYS = {
7097
- /** `local` or `hosted`: what `setup` last chose. Wins over everything else. */
7098
- target: "DEVTOOLS_TARGET",
7099
- dbUrl: "DB_URL",
7100
- secretKey: "SECRET_KEY",
7101
- accessToken: "SUPABASE_ACCESS_TOKEN"
7102
- };
7103
- var TargetError = class extends Error {
7104
- constructor(message) {
7105
- super(message);
7106
- this.name = "TargetError";
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
- /** The project ref or vanity name in a Supabase URL or pooler URL, if it has one. */
7110
- function projectNameOf(url) {
7111
- try {
7112
- const { hostname, username } = new URL(url);
7113
- const fromHost = /^([a-z0-9-]+)\.supabase\.co$/.exec(hostname)?.[1];
7114
- if (fromHost) return fromHost;
7115
- const fromUser = /^postgres\.([a-z0-9]+)$/.exec(username)?.[1];
7116
- if (fromUser) return fromUser;
7117
- return /^db\.([a-z0-9]+)\.supabase\.co$/.exec(hostname)?.[1] ?? null;
7118
- } catch {
7119
- return null;
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
- /** Refuses a URL that names one of the product's production projects. */
7123
- function assertNotProduction(product, ...urls) {
7124
- const production = new Set(product.config.production.projects);
7125
- for (const url of urls) {
7126
- const name = projectNameOf(url);
7127
- 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.`);
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
- /** Reads `supabase status -o env` for the running local stack. */
7131
- async function localTarget(product, cli, run) {
7132
- const args = [
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
- kind: "local",
7148
- apiUrl,
7149
- dbUrl,
7150
- publishableKey,
7151
- secretKey
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
- /** The contributor's own hosted project, from `.env` and `.env.tooling`. */
7155
- function hostedTarget(product) {
7156
- const pub = readEnvFile(product.paths.envFile);
7157
- const tooling = readEnvFile(product.paths.toolingFile);
7158
- const { keys } = product.config.env;
7159
- const apiUrl = pub[keys.url];
7160
- const publishableKey = pub[keys.publishableKey];
7161
- const dbUrl = tooling[TOOLING_KEYS.dbUrl];
7162
- const secretKey = tooling[TOOLING_KEYS.secretKey];
7163
- const missing = [
7164
- [
7165
- keys.url,
7166
- apiUrl,
7167
- product.config.env.file
7168
- ],
7169
- [
7170
- keys.publishableKey,
7171
- publishableKey,
7172
- product.config.env.file
7173
- ],
7174
- [
7175
- TOOLING_KEYS.dbUrl,
7176
- dbUrl,
7177
- product.config.env.tooling
7178
- ],
7179
- [
7180
- TOOLING_KEYS.secretKey,
7181
- secretKey,
7182
- product.config.env.tooling
7183
- ]
7184
- ].filter(([, value]) => !value);
7185
- if (missing.length > 0 || !apiUrl || !publishableKey || !dbUrl || !secretKey) throw new TargetError(`A hosted project needs ${missing.map(([key, , file]) => `${String(key)} (in ${String(file)})`).join(", ")}. Run \`devtools setup --hosted\`.`);
7186
- assertNotProduction(product, apiUrl, dbUrl);
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
- kind: "hosted",
7189
- apiUrl,
7190
- dbUrl,
7191
- publishableKey,
7192
- secretKey
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
- * `--local` or `--hosted` if given; else what `setup` recorded in
7197
- * `.env.tooling`; else hosted when that file carries a `DB_URL` (the
7198
- * contributor chose their own project) and the local stack when it does not.
7199
- */
7200
- function chooseKind(product, preference) {
7201
- if (preference) return preference;
7202
- const tooling = readEnvFile(product.paths.toolingFile);
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
- async function resolveTarget(product, cli, run, preference) {
7208
- return chooseKind(product, preference) === "hosted" ? hostedTarget(product) : localTarget(product, cli, run);
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/dev-data.ts
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
- * `dev-data`: fill a development database with believable rows (DogPack's fake
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
- * `devtools.member_id` is the Supabase `auth.users` id of the member, found as
7224
- * the most recent sign-in through "Sign in with DevDogs" unless `--member`
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
- * It refuses any database that is not the local stack or the contributor's own
7229
- * project, and the SQL runs in one transaction.
7230
- */
7231
- var DevDataError = class extends Error {
7232
- /** A failure the contributor or the product's SQL caused, not a devtools bug: never sent to Sentry. */
7233
- expected = true;
7234
- constructor(message) {
7235
- super(message);
7236
- this.name = "DevDataError";
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
- async function runDevData(product, target, open, options) {
7342
- const log = options.log ?? (() => void 0);
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 seed(session, file, text, options, log) {
7363
- const member = options.member ? await session.memberByRef(options.member) : (await session.members(options.provider))[0] ?? null;
7364
- 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).`);
7365
- log(`Seeding as ${member.email ?? member.id} (${member.id})`);
7366
- if (options.dryRun) {
7367
- log(`Would run ${file} (${text.split("\n").length} lines) in one transaction.`);
7368
- return null;
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 session.runAs(member.id, text);
7372
- } catch (err) {
7373
- if (!isSqlFileError(err)) throw err;
7374
- const line = errorLine(err, text);
7375
- 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).`);
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
- member,
7379
- file
7688
+ command: "npx",
7689
+ prefix: ["--yes", `supabase@${pin}`],
7690
+ source: "npx"
7380
7691
  };
7381
7692
  }
7382
7693
  //#endregion
7383
- //#region src/product/management.ts
7384
- /**
7385
- * The two hosted-project auth settings "Sign in with DevDogs" needs and a
7386
- * contributor would otherwise click through the dashboard for:
7387
- *
7388
- * * `custom_oauth_enabled`: off by default, and GoTrue's custom-provider
7389
- * endpoint answers 404 "Custom OAuth providers are disabled" until it is
7390
- * on, so registering `custom:devdogsuga` fails without it;
7391
- * * `uri_allow_list`: the app's return addresses, which the local stack
7392
- * reads from `supabase/config.toml` instead.
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.status = status;
7404
- this.name = "ManagementError";
7705
+ this.name = "TargetError";
7405
7706
  }
7406
7707
  };
7407
- /** The project ref in `https://<ref>.supabase.co`, or null for a custom domain or local URL. */
7408
- function projectRefOf(apiUrl) {
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
- return /^([a-z0-9]{20})\.supabase\.co$/.exec(new URL(apiUrl).hostname)?.[1] ?? null;
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
- /** The allow list with `wanted` added, keeping what was there; comma-separated as GoTrue stores it. */
7416
- function mergeAllowList(current, wanted) {
7417
- const entries = (current ?? "").split(",").map((entry) => entry.trim()).filter(Boolean);
7418
- for (const uri of wanted) if (!entries.includes(uri)) entries.push(uri);
7419
- return entries.join(",");
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
- //#endregion
7452
- //#region src/product/oauth.ts
7453
- /**
7454
- * "Sign in with DevDogs" for a product's development database, without
7455
- * prompts: the non-interactive halves of `devtools oauth`.
7456
- *
7457
- * The wizard (`../oauth/wizard.ts`) is the front door and is what `setup`
7458
- * runs for a person. These two exist for the cases that have no person:
7459
- *
7460
- * * a fresh-machine or CI run that already holds an OAuth client's id and
7461
- * secret (`DEVDOGS_OAUTH_CLIENT_ID` / `DEVDOGS_OAUTH_CLIENT_SECRET`);
7462
- * * `db sync --rebuild`, which resets the local database and with it
7463
- * `auth.custom_oauth_providers`, so the provider is registered again from
7464
- * the credentials the wizard saved to the product's `.env.tooling`.
7465
- */
7466
- var OAuthRegistrationError = class extends Error {
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
- clientId,
7478
- clientSecret,
7479
- baseUrl: env["DEVDOGS_OAUTH_BASE_URL"]
7746
+ kind: "local",
7747
+ apiUrl,
7748
+ dbUrl,
7749
+ publishableKey,
7750
+ secretKey
7480
7751
  };
7481
7752
  }
7482
- /** Registers `custom:devdogsuga` on the target with these credentials. */
7483
- async function registerProvider(credentialsFile, target, credentials) {
7484
- const { issuer } = await fetchIssuerFromAny(credentials.baseUrl ? [credentials.baseUrl] : DEFAULT_API_URLS);
7485
- const row = await upsertDevDogsProvider({
7486
- apiUrl: target.apiUrl,
7487
- serviceRoleKey: target.secretKey
7488
- }, {
7489
- identifier: OAUTH.providerId,
7490
- name: PROVIDER_NAME,
7491
- clientId: credentials.clientId,
7492
- clientSecret: credentials.clientSecret,
7493
- issuer
7494
- });
7495
- upsertEnvFile(credentialsFile, {
7496
- [ENV_KEYS.clientId]: credentials.clientId,
7497
- [ENV_KEYS.clientSecret]: credentials.clientSecret
7498
- });
7499
- return row;
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
- /** Registers the provider again from what the wizard saved to `.env.tooling`. */
7502
- async function reRegisterSavedProvider(credentialsFile, target) {
7503
- const saved = readEnvFile(credentialsFile);
7504
- const clientId = saved[ENV_KEYS.clientId];
7505
- const clientSecret = saved[ENV_KEYS.clientSecret];
7506
- 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\`.`);
7507
- return registerProvider(credentialsFile, target, {
7508
- clientId,
7509
- clientSecret,
7510
- baseUrl: saved[ENV_KEYS.baseUrl]
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/schema-hash.ts
7810
+ //#region src/product/dev-data.ts
7515
7811
  /**
7516
- * The schema hash that names a set of generated types.
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
- * Generated types are never committed (Q82). CI builds them once per schema
7519
- * change and uploads them to a rolling `generated-types` release; a
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
- * It covers:
7526
- * * every file under `supabase/schemas`, by sorted path (POSIX separators,
7527
- * relative to the schemas directory) and LF-normalised contents, so a
7528
- * Windows checkout with CRLF hashes like a Linux one;
7529
- * * the generator and its version (the pinned Supabase CLI for TypeScript,
7530
- * supadart for Dart), since a new generator rewrites the output.
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
- function listFiles(dir) {
7533
- if (!existsSync(dir)) return [];
7534
- const found = [];
7535
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
7536
- const path = join(dir, entry.name);
7537
- if (entry.isDirectory()) found.push(...listFiles(path));
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
- hash.update(`generator:${generator}`);
7557
- return hash.digest("hex");
7558
- }
7837
+ };
7559
7838
  /**
7560
- * The generator and version string a product's types come from. For supadart
7561
- * it is the version `pubspec.lock` resolved, else the constraint in
7562
- * `pubspec.yaml`, so a Dart SDK bump that changes the models changes the hash.
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 generatorFor(product) {
7565
- const { types, supabase } = product.config;
7566
- if (types.kind === "typescript") return `supabase@${supabase.version}`;
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
- /** The release asset CI uploads for a hash, and `db types --download` looks for. */
7583
- function typesAssetName(product, hash) {
7584
- return `${product}-types-${hash}.tgz`;
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
- //#endregion
7587
- //#region src/product/drift.ts
7588
- /** Splits SQL at top-level semicolons, honoring quotes, dollar quotes and comments. Comments are dropped. */
7589
- function splitStatements(sql) {
7590
- const out = [];
7591
- let current = "";
7592
- let i = 0;
7593
- const push = () => {
7594
- const text = current.trim();
7595
- if (text) out.push(text);
7596
- current = "";
7597
- };
7598
- while (i < sql.length) {
7599
- const ch = sql[i];
7600
- const next = sql[i + 1];
7601
- if (ch === "-" && next === "-") {
7602
- while (i < sql.length && sql[i] !== "\n") i++;
7603
- continue;
7604
- }
7605
- if (ch === "/" && next === "*") {
7606
- const end = sql.indexOf("*/", i + 2);
7607
- i = end === -1 ? sql.length : end + 2;
7608
- current += " ";
7609
- continue;
7610
- }
7611
- if (ch === "'" || ch === "\"") {
7612
- let j = i + 1;
7613
- while (j < sql.length) {
7614
- if (sql[j] === ch) {
7615
- if (sql[j + 1] === ch) {
7616
- j += 2;
7617
- continue;
7618
- }
7619
- break;
7620
- }
7621
- j++;
7622
- }
7623
- current += sql.slice(i, j + 1);
7624
- i = j + 1;
7625
- continue;
7626
- }
7627
- if (ch === "$") {
7628
- const tag = /^\$[A-Za-z_]*\$/.exec(sql.slice(i))?.[0];
7629
- if (tag) {
7630
- const end = sql.indexOf(tag, i + tag.length);
7631
- const stop = end === -1 ? sql.length : end + tag.length;
7632
- current += sql.slice(i, stop);
7633
- i = stop;
7634
- continue;
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
- if (ch === ";") {
7638
- push();
7639
- i++;
7640
- continue;
7641
- }
7642
- current += ch;
7643
- i++;
7644
- }
7645
- push();
7646
- return out;
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
- /** Splits `text` at commas that are not inside parentheses or quotes. */
7649
- function splitTopLevel(text) {
7650
- const parts = [];
7651
- let depth = 0;
7652
- let quote = null;
7653
- let start = 0;
7654
- for (let i = 0; i < text.length; i++) {
7655
- const ch = text[i];
7656
- if (quote) {
7657
- if (ch === quote) quote = null;
7658
- } else if (ch === "'" || ch === "\"") quote = ch;
7659
- else if (ch === "(") depth++;
7660
- else if (ch === ")") depth--;
7661
- else if (ch === "," && depth === 0) {
7662
- parts.push(text.slice(start, i).trim());
7663
- start = i + 1;
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 shown(name) {
7679
- return parts(name).map((part) => `"${part}"`).join(".");
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
- const CREATE_KINDS = {
7682
- SCHEMA: "schema",
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
- * Classifies the statements of a live diff. `existing` is what the live
7696
- * database has (for `CREATE OR REPLACE`, which reads the same whether the
7697
- * object is missing or merely different).
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
- function classifyDiff(sql, existing) {
7700
- const creates = [];
7701
- const drops = [];
7702
- const conflicts = /* @__PURE__ */ new Map();
7703
- const extras = [];
7704
- const preamble = [];
7705
- const privileges = [];
7706
- let sawRevoke = false;
7707
- const conflict = (object, why) => {
7708
- const set = conflicts.get(object) ?? /* @__PURE__ */ new Set();
7709
- set.add(why);
7710
- conflicts.set(object, set);
7711
- };
7712
- for (const statement of splitStatements(sql)) {
7713
- const text = statement.replace(/\s+/g, " ");
7714
- if (WORD("SET").test(text)) {
7715
- preamble.push(statement);
7716
- continue;
7717
- }
7718
- if (WORD("GRANT").test(text)) {
7719
- privileges.push({
7720
- sql: statement,
7721
- target: null,
7722
- finding: null
7723
- });
7724
- continue;
7725
- }
7726
- if (WORD("REVOKE").test(text)) {
7727
- sawRevoke = true;
7728
- const on = new RegExp(String.raw`\bON\s+(?:(?:TABLE|SEQUENCE|FUNCTION|SCHEMA|TYPE|DOMAIN)\s+)?(${QNAME})(?:\s*\([^)]*\))?\s+FROM\s+(.+)$`, "i").exec(text);
7729
- privileges.push({
7730
- sql: statement,
7731
- target: on ? qualified(on[1]) : "",
7732
- finding: {
7733
- object: on ? `privileges on ${shown(on[1])}` : "privileges",
7734
- detail: on ? `granted to ${on[2].replace(/"/g, "")} beyond (or instead of) what supabase/schemas grants` : "differ from what supabase/schemas grants"
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 created = new Set(creates.map((c) => c.key));
7826
- const dropped = new Set(drops.map((d) => d.key));
7827
- const restoring = creates.filter((c) => !dropped.has(c.key));
7828
- for (const d of drops) if (created.has(d.key)) conflict(d.finding.object, "it is defined differently from supabase/schemas");
7829
- else extras.push(d.finding);
7830
- const newNames = new Set(restoring.map((c) => c.key).filter((key) => !/^(index|policy|trigger|column|constraint|rls)/.test(key)).map((key) => key.slice(key.indexOf(":") + 1)));
7831
- const tail = [];
7832
- let grants = 0;
7833
- for (const p of privileges) if (p.target === null) {
7834
- tail.push(p.sql);
7835
- grants++;
7836
- } else if (newNames.has(p.target)) tail.push(p.sql);
7837
- else if (p.finding) extras.push(p.finding);
7838
- const restore = [];
7839
- const restored = restoring.map((c) => c.label);
7840
- if (restoring.length > 0 || grants > 0) restore.push(...preamble, ...restoring.map((c) => c.sql), ...tail);
7841
- if (restoring.length === 0 && grants > 0 && !sawRevoke) restored.push("privileges");
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
- restore,
7844
- restored,
7845
- extras: dedupe(extras),
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
- function dedupe(findings) {
7853
- const seen = /* @__PURE__ */ new Set();
7854
- return findings.filter((f) => {
7855
- const key = `${f.object}|${f.detail}`;
7856
- if (seen.has(key)) return false;
7857
- seen.add(key);
7858
- return true;
7859
- });
7860
- }
7861
- function classifyTableAction(table, t, action, ctx) {
7862
- const sql = `ALTER TABLE ${table} ${action}`;
7863
- let m;
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
- else if (m = new RegExp(String.raw`^DROP\s+CONSTRAINT\s+(?:IF\s+EXISTS\s+)?(${IDENT})`, "i").exec(action)) ctx.drops.push({
7882
- key: `constraint:${t}.${parts(m[1])[0]}`,
7883
- finding: {
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
- else if (m = new RegExp(String.raw`^ALTER\s+COLUMN\s+(${IDENT})\s+(.*)$`, "i").exec(action)) {
7889
- const what = m[2];
7890
- 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";
7891
- ctx.conflict(`column ${shown(table)}.${shown(m[1])}`, `its ${aspect} differs from supabase/schemas`);
7892
- } else if (/^(?:ENABLE|FORCE)\s+ROW\s+LEVEL\s+SECURITY\b/i.test(action)) ctx.creates.push({
7893
- key: `rls:${t}:${action.toUpperCase()}`,
7894
- sql,
7895
- label: `row level security on ${shown(table)}`
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
- else ctx.conflict(`table ${shown(table)}`, `a change devtools will not make for you (${action.slice(0, 60)})`);
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 NOTHING;
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 known = new Set(ledger ?? []);
8077
- const unlisted = files.map(versionOf).filter((version) => !known.has(version));
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 needsRecord = target.kind === "hosted" && migrationFiles(migrationsDir).length === 0;
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) log(`Restored ${restoredFiles} applied migration${restoredFiles === 1 ? "" : "s"} from the project's history.`);
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
- if (created.length === 0) {
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 = created[created.length - 1];
8181
- const sql = readFileSync(join(migrationsDir, migration), "utf8");
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 ${migration}`);
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
- throw new SyncError("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).", redact(applied.stderr || applied.stdout));
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