@devdogsuga/devtools 0.1.51-next.552 → 0.1.51-next.553

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