@devdogsuga/devtools 0.1.51-next.548 → 0.1.51-next.551
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -11
- package/dist/{cli-DV6tvG1E.js → cli-xAkuiAkl.js} +517 -151
- package/dist/launch.js +2 -2
- package/dist/{menu-CZK0L1Vf.js → menu-Ctqnvduz.js} +5 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -106,17 +106,17 @@ pnpm devtools setup
|
|
|
106
106
|
|
|
107
107
|
Anywhere else, the one-off form is `pnpm dlx @devdogsuga/devtools@next setup`.
|
|
108
108
|
|
|
109
|
-
| Command
|
|
110
|
-
|
|
|
111
|
-
| `setup [--local \| --hosted]`
|
|
112
|
-
| `db sync [--rebuild] [--yes]`
|
|
113
|
-
| `db types [--download] [--pack <file>]`
|
|
114
|
-
| `db hash`
|
|
115
|
-
| `dev-data [--member <email\|id>]`
|
|
116
|
-
| `oauth`
|
|
117
|
-
| `doctor`
|
|
118
|
-
| `supabase <args>`
|
|
119
|
-
| `check env-example`
|
|
109
|
+
| Command | What it does |
|
|
110
|
+
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
111
|
+
| `setup [--local \| --hosted]` | Writes `.env` from `.env.example`; starts the Docker stack (or takes your own hosted project's URL, keys and session-pooler `DB_URL`); `db sync`; the types; then `oauth`. |
|
|
112
|
+
| `db sync [--rebuild] [--strict] [--yes]` | Makes your development database match `supabase/schemas/`: recreates what it lost, applies schema changes you pulled, warns about extras (`--strict` fails on them), refuses to alter what is defined differently. Never resets it; `--rebuild` is the explicit reset (local only) and registers sign in again afterwards. |
|
|
113
|
+
| `db types [--download] [--pack <file>]` | Generates the TypeScript types or Dart models from your database, or downloads the ones CI built for your exact schema hash. `--pack` writes the release asset (CI). |
|
|
114
|
+
| `db hash` | Prints the schema hash that names the generated types. |
|
|
115
|
+
| `dev-data [--member <email\|id>]` | Runs the product's `devData.file` SQL, in one transaction, as the member who last signed in with DevDogs. Refuses the product's production project. |
|
|
116
|
+
| `oauth` | "Sign in with DevDogs" on your database (the same wizard as the platform's). |
|
|
117
|
+
| `doctor` | Docker, the pinned Supabase CLI, `.env`, the database, whether the types match the schema. |
|
|
118
|
+
| `supabase <args>` | The Supabase CLI at the version the product pins. |
|
|
119
|
+
| `check env-example` | CI: `.env.example` holds public values only (it ships in the app). |
|
|
120
120
|
|
|
121
121
|
How it works, in short:
|
|
122
122
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { n as execSync, r as spawnProcess } from "./spawn-BvLQzc_K.js";
|
|
2
2
|
import { n as discoverRepoRoot, r as findRepoRoot } from "./root-RaJ8ECCO.js";
|
|
3
3
|
import { A as findApp, M as listApps, N as resolveAppPath, O as appWorkspaceRoot, P as resolveLayout, _ as dryRunKind, c as supabaseRoot, d as loadEnvLoad, f as loadEnvSession, g as runInGroup, h as reportRan, i as runWithStderr, j as layoutAt, k as devdogsugaRootOrNull, l as getEnvSync, n as reportRuns, o as supabase, p as repoPeerUrl, r as run$1, t as dbPush, u as loadEnv, v as isDryRun, x as wouldRunLine } from "./run-D5iI9zve.js";
|
|
4
|
-
import { A as renderHelp, C as loadProductConfig, D as catalog, M as probeEnvironment, O as helpPath, T as isProductMode, _ as explain, a as recordEnteredTier, b as ProductConfigError, c as nonEmpty, d as sessionLabel, g as errorMessage, h as bail, i as beginInvocation, j as describeEnvironment, k as renderCommandList, l as resolveDbConnection, n as bareGroupStartPath, o as recordResolved, r as runMenu, s as reproducibleCommand, t as bareGroupStartFlags, u as sessionIsLocal, v as explainError, w as activeCatalog, x as currentProduct, y as unwrap } from "./menu-
|
|
4
|
+
import { A as renderHelp, C as loadProductConfig, D as catalog, M as probeEnvironment, O as helpPath, T as isProductMode, _ as explain, a as recordEnteredTier, b as ProductConfigError, c as nonEmpty, d as sessionLabel, g as errorMessage, h as bail, i as beginInvocation, j as describeEnvironment, k as renderCommandList, l as resolveDbConnection, n as bareGroupStartPath, o as recordResolved, r as runMenu, s as reproducibleCommand, t as bareGroupStartFlags, u as sessionIsLocal, v as explainError, w as activeCatalog, x as currentProduct, y as unwrap } from "./menu-Ctqnvduz.js";
|
|
5
5
|
import { a as isVersionRequest, c as cliName, m as positionals, o as ownPackageDir, p as flagValue, s as ownVersion } from "./failure-log-MSdCgvLc.js";
|
|
6
6
|
import { f as hasYes, n as captureDevtoolsError, o as initDevtoolsTelemetry, p as isNonInteractive } from "./telemetry-BDOwjuoC.js";
|
|
7
7
|
import { t as runStackCommand } from "./stack-CzNe6rxl.js";
|
|
@@ -7303,6 +7303,27 @@ function postgresSession(dbUrl) {
|
|
|
7303
7303
|
const present = new Set(rows.map((r) => r.nspname));
|
|
7304
7304
|
return schemas.filter((name) => !present.has(name));
|
|
7305
7305
|
},
|
|
7306
|
+
async existingObjects(schemas) {
|
|
7307
|
+
const list = [...schemas];
|
|
7308
|
+
const found = /* @__PURE__ */ new Set();
|
|
7309
|
+
const functions = await sql`
|
|
7310
|
+
select n.nspname as s, p.proname as n from pg_proc p
|
|
7311
|
+
join pg_namespace n on n.oid = p.pronamespace
|
|
7312
|
+
where n.nspname = any(${list})`;
|
|
7313
|
+
for (const r of functions) found.add(`function:${r.s}.${r.n}`);
|
|
7314
|
+
const views = await sql`
|
|
7315
|
+
select n.nspname as s, c.relname as n from pg_class c
|
|
7316
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
7317
|
+
where n.nspname = any(${list}) and c.relkind in ('v', 'm')`;
|
|
7318
|
+
for (const r of views) found.add(`view:${r.s}.${r.n}`);
|
|
7319
|
+
const triggers = await sql`
|
|
7320
|
+
select n.nspname as s, c.relname as t, g.tgname as n from pg_trigger g
|
|
7321
|
+
join pg_class c on c.oid = g.tgrelid
|
|
7322
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
7323
|
+
where n.nspname = any(${list}) and not g.tgisinternal`;
|
|
7324
|
+
for (const r of triggers) found.add(`trigger:${r.s}.${r.t}:${r.n}`);
|
|
7325
|
+
return found;
|
|
7326
|
+
},
|
|
7306
7327
|
async exec(text) {
|
|
7307
7328
|
await sql.begin(async (tx) => {
|
|
7308
7329
|
await tx.unsafe(text);
|
|
@@ -7563,6 +7584,319 @@ function typesAssetName(product, hash) {
|
|
|
7563
7584
|
return `${product}-types-${hash}.tgz`;
|
|
7564
7585
|
}
|
|
7565
7586
|
//#endregion
|
|
7587
|
+
//#region src/product/drift.ts
|
|
7588
|
+
/** Splits SQL at top-level semicolons, honoring quotes, dollar quotes and comments. Comments are dropped. */
|
|
7589
|
+
function splitStatements(sql) {
|
|
7590
|
+
const out = [];
|
|
7591
|
+
let current = "";
|
|
7592
|
+
let i = 0;
|
|
7593
|
+
const push = () => {
|
|
7594
|
+
const text = current.trim();
|
|
7595
|
+
if (text) out.push(text);
|
|
7596
|
+
current = "";
|
|
7597
|
+
};
|
|
7598
|
+
while (i < sql.length) {
|
|
7599
|
+
const ch = sql[i];
|
|
7600
|
+
const next = sql[i + 1];
|
|
7601
|
+
if (ch === "-" && next === "-") {
|
|
7602
|
+
while (i < sql.length && sql[i] !== "\n") i++;
|
|
7603
|
+
continue;
|
|
7604
|
+
}
|
|
7605
|
+
if (ch === "/" && next === "*") {
|
|
7606
|
+
const end = sql.indexOf("*/", i + 2);
|
|
7607
|
+
i = end === -1 ? sql.length : end + 2;
|
|
7608
|
+
current += " ";
|
|
7609
|
+
continue;
|
|
7610
|
+
}
|
|
7611
|
+
if (ch === "'" || ch === "\"") {
|
|
7612
|
+
let j = i + 1;
|
|
7613
|
+
while (j < sql.length) {
|
|
7614
|
+
if (sql[j] === ch) {
|
|
7615
|
+
if (sql[j + 1] === ch) {
|
|
7616
|
+
j += 2;
|
|
7617
|
+
continue;
|
|
7618
|
+
}
|
|
7619
|
+
break;
|
|
7620
|
+
}
|
|
7621
|
+
j++;
|
|
7622
|
+
}
|
|
7623
|
+
current += sql.slice(i, j + 1);
|
|
7624
|
+
i = j + 1;
|
|
7625
|
+
continue;
|
|
7626
|
+
}
|
|
7627
|
+
if (ch === "$") {
|
|
7628
|
+
const tag = /^\$[A-Za-z_]*\$/.exec(sql.slice(i))?.[0];
|
|
7629
|
+
if (tag) {
|
|
7630
|
+
const end = sql.indexOf(tag, i + tag.length);
|
|
7631
|
+
const stop = end === -1 ? sql.length : end + tag.length;
|
|
7632
|
+
current += sql.slice(i, stop);
|
|
7633
|
+
i = stop;
|
|
7634
|
+
continue;
|
|
7635
|
+
}
|
|
7636
|
+
}
|
|
7637
|
+
if (ch === ";") {
|
|
7638
|
+
push();
|
|
7639
|
+
i++;
|
|
7640
|
+
continue;
|
|
7641
|
+
}
|
|
7642
|
+
current += ch;
|
|
7643
|
+
i++;
|
|
7644
|
+
}
|
|
7645
|
+
push();
|
|
7646
|
+
return out;
|
|
7647
|
+
}
|
|
7648
|
+
/** Splits `text` at commas that are not inside parentheses or quotes. */
|
|
7649
|
+
function splitTopLevel(text) {
|
|
7650
|
+
const parts = [];
|
|
7651
|
+
let depth = 0;
|
|
7652
|
+
let quote = null;
|
|
7653
|
+
let start = 0;
|
|
7654
|
+
for (let i = 0; i < text.length; i++) {
|
|
7655
|
+
const ch = text[i];
|
|
7656
|
+
if (quote) {
|
|
7657
|
+
if (ch === quote) quote = null;
|
|
7658
|
+
} else if (ch === "'" || ch === "\"") quote = ch;
|
|
7659
|
+
else if (ch === "(") depth++;
|
|
7660
|
+
else if (ch === ")") depth--;
|
|
7661
|
+
else if (ch === "," && depth === 0) {
|
|
7662
|
+
parts.push(text.slice(start, i).trim());
|
|
7663
|
+
start = i + 1;
|
|
7664
|
+
}
|
|
7665
|
+
}
|
|
7666
|
+
parts.push(text.slice(start).trim());
|
|
7667
|
+
return parts.filter(Boolean);
|
|
7668
|
+
}
|
|
7669
|
+
const IDENT = String.raw`(?:"(?:[^"]|"")+"|[A-Za-z_][\w$]*)`;
|
|
7670
|
+
const QNAME = String.raw`${IDENT}(?:\s*\.\s*${IDENT})*`;
|
|
7671
|
+
/** `"a"."b"` -> ["a", "b"]; unquoted names fold to lower case like Postgres. */
|
|
7672
|
+
function parts(name) {
|
|
7673
|
+
return (name.match(new RegExp(IDENT, "g")) ?? []).map((part) => part.startsWith("\"") ? part.slice(1, -1).replace(/""/g, "\"") : part.toLowerCase());
|
|
7674
|
+
}
|
|
7675
|
+
function qualified(name) {
|
|
7676
|
+
return parts(name).join(".");
|
|
7677
|
+
}
|
|
7678
|
+
function shown(name) {
|
|
7679
|
+
return parts(name).map((part) => `"${part}"`).join(".");
|
|
7680
|
+
}
|
|
7681
|
+
const CREATE_KINDS = {
|
|
7682
|
+
SCHEMA: "schema",
|
|
7683
|
+
TABLE: "table",
|
|
7684
|
+
"FOREIGN TABLE": "table",
|
|
7685
|
+
SEQUENCE: "sequence",
|
|
7686
|
+
TYPE: "type",
|
|
7687
|
+
DOMAIN: "type",
|
|
7688
|
+
VIEW: "view",
|
|
7689
|
+
"MATERIALIZED VIEW": "view",
|
|
7690
|
+
FUNCTION: "function",
|
|
7691
|
+
PROCEDURE: "function"
|
|
7692
|
+
};
|
|
7693
|
+
const WORD = (word) => new RegExp(`^${word}\\b`, "i");
|
|
7694
|
+
/**
|
|
7695
|
+
* Classifies the statements of a live diff. `existing` is what the live
|
|
7696
|
+
* database has (for `CREATE OR REPLACE`, which reads the same whether the
|
|
7697
|
+
* object is missing or merely different).
|
|
7698
|
+
*/
|
|
7699
|
+
function classifyDiff(sql, existing) {
|
|
7700
|
+
const creates = [];
|
|
7701
|
+
const drops = [];
|
|
7702
|
+
const conflicts = /* @__PURE__ */ new Map();
|
|
7703
|
+
const extras = [];
|
|
7704
|
+
const preamble = [];
|
|
7705
|
+
const privileges = [];
|
|
7706
|
+
let sawRevoke = false;
|
|
7707
|
+
const conflict = (object, why) => {
|
|
7708
|
+
const set = conflicts.get(object) ?? /* @__PURE__ */ new Set();
|
|
7709
|
+
set.add(why);
|
|
7710
|
+
conflicts.set(object, set);
|
|
7711
|
+
};
|
|
7712
|
+
for (const statement of splitStatements(sql)) {
|
|
7713
|
+
const text = statement.replace(/\s+/g, " ");
|
|
7714
|
+
if (WORD("SET").test(text)) {
|
|
7715
|
+
preamble.push(statement);
|
|
7716
|
+
continue;
|
|
7717
|
+
}
|
|
7718
|
+
if (WORD("GRANT").test(text)) {
|
|
7719
|
+
privileges.push({
|
|
7720
|
+
sql: statement,
|
|
7721
|
+
target: null,
|
|
7722
|
+
finding: null
|
|
7723
|
+
});
|
|
7724
|
+
continue;
|
|
7725
|
+
}
|
|
7726
|
+
if (WORD("REVOKE").test(text)) {
|
|
7727
|
+
sawRevoke = true;
|
|
7728
|
+
const on = new RegExp(String.raw`\bON\s+(?:(?:TABLE|SEQUENCE|FUNCTION|SCHEMA|TYPE|DOMAIN)\s+)?(${QNAME})(?:\s*\([^)]*\))?\s+FROM\s+(.+)$`, "i").exec(text);
|
|
7729
|
+
privileges.push({
|
|
7730
|
+
sql: statement,
|
|
7731
|
+
target: on ? qualified(on[1]) : "",
|
|
7732
|
+
finding: {
|
|
7733
|
+
object: on ? `privileges on ${shown(on[1])}` : "privileges",
|
|
7734
|
+
detail: on ? `granted to ${on[2].replace(/"/g, "")} beyond (or instead of) what supabase/schemas grants` : "differ from what supabase/schemas grants"
|
|
7735
|
+
}
|
|
7736
|
+
});
|
|
7737
|
+
continue;
|
|
7738
|
+
}
|
|
7739
|
+
const create = new RegExp(String.raw`^CREATE\s+(OR\s+REPLACE\s+)?(?:UNIQUE\s+)?(?:TEMP(?:ORARY)?\s+)?(SCHEMA|TABLE|FOREIGN TABLE|SEQUENCE|TYPE|DOMAIN|VIEW|MATERIALIZED VIEW|FUNCTION|PROCEDURE|INDEX|POLICY|TRIGGER|CONSTRAINT TRIGGER)\s+(?:IF\s+NOT\s+EXISTS\s+)?(${IDENT}(?:\s*\.\s*${IDENT})*)(.*)$`, "is").exec(text);
|
|
7740
|
+
if (create) {
|
|
7741
|
+
const replace = Boolean(create[1]);
|
|
7742
|
+
const kindWord = create[2].toUpperCase().replace(/\s+/g, " ");
|
|
7743
|
+
const name = create[3];
|
|
7744
|
+
const rest = create[4];
|
|
7745
|
+
let key;
|
|
7746
|
+
let label;
|
|
7747
|
+
if (kindWord === "INDEX") {
|
|
7748
|
+
key = `index:${parts(name).at(-1)}`;
|
|
7749
|
+
label = `index ${shown(name)}`;
|
|
7750
|
+
} else if (kindWord === "POLICY" || kindWord === "TRIGGER" || kindWord === "CONSTRAINT TRIGGER") {
|
|
7751
|
+
const on = new RegExp(String.raw`\bON\s+(${QNAME})`, "i").exec(rest);
|
|
7752
|
+
const table = on ? qualified(on[1]) : "?";
|
|
7753
|
+
const noun = kindWord === "POLICY" ? "policy" : "trigger";
|
|
7754
|
+
key = `${noun}:${table}:${parts(name).at(-1)}`;
|
|
7755
|
+
label = `${noun} ${shown(name)} on ${on ? shown(on[1]) : "?"}`;
|
|
7756
|
+
} else {
|
|
7757
|
+
const kind = CREATE_KINDS[kindWord] ?? kindWord.toLowerCase();
|
|
7758
|
+
key = `${kind}:${qualified(name)}`;
|
|
7759
|
+
label = `${kind} ${shown(name)}`;
|
|
7760
|
+
}
|
|
7761
|
+
if (replace && existing.has(key)) conflict(label, "its definition differs from supabase/schemas");
|
|
7762
|
+
else creates.push({
|
|
7763
|
+
key,
|
|
7764
|
+
sql: statement,
|
|
7765
|
+
label
|
|
7766
|
+
});
|
|
7767
|
+
continue;
|
|
7768
|
+
}
|
|
7769
|
+
const drop = new RegExp(String.raw`^DROP\s+(SCHEMA|TABLE|FOREIGN TABLE|SEQUENCE|TYPE|DOMAIN|VIEW|MATERIALIZED VIEW|FUNCTION|PROCEDURE|INDEX|POLICY|TRIGGER|EXTENSION)\s+(?:IF\s+EXISTS\s+)?(${QNAME})(.*)$`, "is").exec(text);
|
|
7770
|
+
if (drop) {
|
|
7771
|
+
const kindWord = drop[1].toUpperCase();
|
|
7772
|
+
const name = drop[2];
|
|
7773
|
+
const rest = drop[3];
|
|
7774
|
+
if (kindWord === "INDEX") drops.push({
|
|
7775
|
+
key: `index:${parts(name).at(-1)}`,
|
|
7776
|
+
finding: {
|
|
7777
|
+
object: `index ${shown(name)}`,
|
|
7778
|
+
detail: "not in supabase/schemas"
|
|
7779
|
+
}
|
|
7780
|
+
});
|
|
7781
|
+
else if (kindWord === "POLICY" || kindWord === "TRIGGER") {
|
|
7782
|
+
const on = new RegExp(String.raw`\bON\s+(${QNAME})`, "i").exec(rest);
|
|
7783
|
+
const noun = kindWord.toLowerCase();
|
|
7784
|
+
drops.push({
|
|
7785
|
+
key: `${noun}:${on ? qualified(on[1]) : "?"}:${parts(name).at(-1)}`,
|
|
7786
|
+
finding: {
|
|
7787
|
+
object: `${noun} ${shown(name)} on ${on ? shown(on[1]) : "?"}`,
|
|
7788
|
+
detail: "not in supabase/schemas"
|
|
7789
|
+
}
|
|
7790
|
+
});
|
|
7791
|
+
} else {
|
|
7792
|
+
const kind = CREATE_KINDS[kindWord] ?? kindWord.toLowerCase();
|
|
7793
|
+
drops.push({
|
|
7794
|
+
key: `${kind}:${qualified(name)}`,
|
|
7795
|
+
finding: {
|
|
7796
|
+
object: `${kind} ${shown(name)}`,
|
|
7797
|
+
detail: "not in supabase/schemas"
|
|
7798
|
+
}
|
|
7799
|
+
});
|
|
7800
|
+
}
|
|
7801
|
+
continue;
|
|
7802
|
+
}
|
|
7803
|
+
const alterTable = new RegExp(String.raw`^ALTER\s+(?:FOREIGN\s+)?TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?(${QNAME})\s+(.*)$`, "is").exec(text);
|
|
7804
|
+
if (alterTable) {
|
|
7805
|
+
const table = alterTable[1];
|
|
7806
|
+
const t = qualified(table);
|
|
7807
|
+
for (const action of splitTopLevel(alterTable[2])) classifyTableAction(table, t, action, {
|
|
7808
|
+
creates,
|
|
7809
|
+
drops,
|
|
7810
|
+
conflict
|
|
7811
|
+
});
|
|
7812
|
+
continue;
|
|
7813
|
+
}
|
|
7814
|
+
const alterType = new RegExp(String.raw`^ALTER\s+TYPE\s+(${QNAME})\s+ADD\s+VALUE\b`, "i").exec(text);
|
|
7815
|
+
if (alterType) {
|
|
7816
|
+
creates.push({
|
|
7817
|
+
key: `enumvalue:${statement}`,
|
|
7818
|
+
sql: statement,
|
|
7819
|
+
label: `a value of type ${shown(alterType[1])}`
|
|
7820
|
+
});
|
|
7821
|
+
continue;
|
|
7822
|
+
}
|
|
7823
|
+
conflict(text.slice(0, 80), "a difference devtools does not know how to classify, so it will not touch it");
|
|
7824
|
+
}
|
|
7825
|
+
const created = new Set(creates.map((c) => c.key));
|
|
7826
|
+
const dropped = new Set(drops.map((d) => d.key));
|
|
7827
|
+
const restoring = creates.filter((c) => !dropped.has(c.key));
|
|
7828
|
+
for (const d of drops) if (created.has(d.key)) conflict(d.finding.object, "it is defined differently from supabase/schemas");
|
|
7829
|
+
else extras.push(d.finding);
|
|
7830
|
+
const newNames = new Set(restoring.map((c) => c.key).filter((key) => !/^(index|policy|trigger|column|constraint|rls)/.test(key)).map((key) => key.slice(key.indexOf(":") + 1)));
|
|
7831
|
+
const tail = [];
|
|
7832
|
+
let grants = 0;
|
|
7833
|
+
for (const p of privileges) if (p.target === null) {
|
|
7834
|
+
tail.push(p.sql);
|
|
7835
|
+
grants++;
|
|
7836
|
+
} else if (newNames.has(p.target)) tail.push(p.sql);
|
|
7837
|
+
else if (p.finding) extras.push(p.finding);
|
|
7838
|
+
const restore = [];
|
|
7839
|
+
const restored = restoring.map((c) => c.label);
|
|
7840
|
+
if (restoring.length > 0 || grants > 0) restore.push(...preamble, ...restoring.map((c) => c.sql), ...tail);
|
|
7841
|
+
if (restoring.length === 0 && grants > 0 && !sawRevoke) restored.push("privileges");
|
|
7842
|
+
return {
|
|
7843
|
+
restore,
|
|
7844
|
+
restored,
|
|
7845
|
+
extras: dedupe(extras),
|
|
7846
|
+
conflicts: [...conflicts].map(([object, why]) => ({
|
|
7847
|
+
object,
|
|
7848
|
+
detail: [...why].join("; ")
|
|
7849
|
+
}))
|
|
7850
|
+
};
|
|
7851
|
+
}
|
|
7852
|
+
function dedupe(findings) {
|
|
7853
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7854
|
+
return findings.filter((f) => {
|
|
7855
|
+
const key = `${f.object}|${f.detail}`;
|
|
7856
|
+
if (seen.has(key)) return false;
|
|
7857
|
+
seen.add(key);
|
|
7858
|
+
return true;
|
|
7859
|
+
});
|
|
7860
|
+
}
|
|
7861
|
+
function classifyTableAction(table, t, action, ctx) {
|
|
7862
|
+
const sql = `ALTER TABLE ${table} ${action}`;
|
|
7863
|
+
let m;
|
|
7864
|
+
if (m = new RegExp(String.raw`^ADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?(${IDENT})`, "i").exec(action)) ctx.creates.push({
|
|
7865
|
+
key: `column:${t}.${parts(m[1])[0]}`,
|
|
7866
|
+
sql,
|
|
7867
|
+
label: `column ${shown(table)}.${shown(m[1])}`
|
|
7868
|
+
});
|
|
7869
|
+
else if (m = new RegExp(String.raw`^ADD\s+CONSTRAINT\s+(${IDENT})`, "i").exec(action)) ctx.creates.push({
|
|
7870
|
+
key: `constraint:${t}.${parts(m[1])[0]}`,
|
|
7871
|
+
sql,
|
|
7872
|
+
label: `constraint ${shown(m[1])} on ${shown(table)}`
|
|
7873
|
+
});
|
|
7874
|
+
else if (m = new RegExp(String.raw`^DROP\s+COLUMN\s+(?:IF\s+EXISTS\s+)?(${IDENT})`, "i").exec(action)) ctx.drops.push({
|
|
7875
|
+
key: `column:${t}.${parts(m[1])[0]}`,
|
|
7876
|
+
finding: {
|
|
7877
|
+
object: `column ${shown(table)}.${shown(m[1])}`,
|
|
7878
|
+
detail: "not in supabase/schemas"
|
|
7879
|
+
}
|
|
7880
|
+
});
|
|
7881
|
+
else if (m = new RegExp(String.raw`^DROP\s+CONSTRAINT\s+(?:IF\s+EXISTS\s+)?(${IDENT})`, "i").exec(action)) ctx.drops.push({
|
|
7882
|
+
key: `constraint:${t}.${parts(m[1])[0]}`,
|
|
7883
|
+
finding: {
|
|
7884
|
+
object: `constraint ${shown(m[1])} on ${shown(table)}`,
|
|
7885
|
+
detail: "not in supabase/schemas"
|
|
7886
|
+
}
|
|
7887
|
+
});
|
|
7888
|
+
else if (m = new RegExp(String.raw`^ALTER\s+COLUMN\s+(${IDENT})\s+(.*)$`, "i").exec(action)) {
|
|
7889
|
+
const what = m[2];
|
|
7890
|
+
const aspect = /^(?:SET\s+DATA\s+)?TYPE\b/i.test(what) ? "type" : /\bDEFAULT\b/i.test(what) ? "default" : /\bNOT\s+NULL\b/i.test(what) ? "nullability" : "definition";
|
|
7891
|
+
ctx.conflict(`column ${shown(table)}.${shown(m[1])}`, `its ${aspect} differs from supabase/schemas`);
|
|
7892
|
+
} else if (/^(?:ENABLE|FORCE)\s+ROW\s+LEVEL\s+SECURITY\b/i.test(action)) ctx.creates.push({
|
|
7893
|
+
key: `rls:${t}:${action.toUpperCase()}`,
|
|
7894
|
+
sql,
|
|
7895
|
+
label: `row level security on ${shown(table)}`
|
|
7896
|
+
});
|
|
7897
|
+
else ctx.conflict(`table ${shown(table)}`, `a change devtools will not make for you (${action.slice(0, 60)})`);
|
|
7898
|
+
}
|
|
7899
|
+
//#endregion
|
|
7566
7900
|
//#region src/product/sync.ts
|
|
7567
7901
|
/**
|
|
7568
7902
|
* `db sync`: make the contributor's development database match
|
|
@@ -7586,14 +7920,25 @@ function typesAssetName(product, hash) {
|
|
|
7586
7920
|
* Without that, the diff would start from an empty database and write the whole
|
|
7587
7921
|
* schema as one migration, which `db push` rejects as already applied.
|
|
7588
7922
|
*
|
|
7589
|
-
* The record only says what *should* be in the database
|
|
7590
|
-
* sync
|
|
7591
|
-
*
|
|
7592
|
-
*
|
|
7593
|
-
*
|
|
7594
|
-
*
|
|
7595
|
-
*
|
|
7596
|
-
*
|
|
7923
|
+
* The record only says what *should* be in the database, so before the diff,
|
|
7924
|
+
* sync compares the live database with it (`supabase db diff --from <database>
|
|
7925
|
+
* --to migrations --use-pg-delta`, limited to the product's schemas) and sorts
|
|
7926
|
+
* every difference (`drift.ts`):
|
|
7927
|
+
*
|
|
7928
|
+
* - missing (a dropped table, schema, column, index, policy, function; a
|
|
7929
|
+
* missing migration ledger): recreated. Only CREATE and ADD statements are
|
|
7930
|
+
* ever run, and the ledger is rewritten from the local record.
|
|
7931
|
+
* - extra (a table, column, index or sequence a member added): listed as a
|
|
7932
|
+
* warning and left alone. `--strict` fails on them instead.
|
|
7933
|
+
* - conflicting (a column's type, a function body, ... defined differently
|
|
7934
|
+
* in the database and the schema files): refused. Nothing is altered
|
|
7935
|
+
* silently; the member edits the schema files or runs `--rebuild`.
|
|
7936
|
+
*
|
|
7937
|
+
* "Matches" is only ever said after that comparison ran. If it cannot run
|
|
7938
|
+
* (the database is unreachable, the password is wrong, the diff tool fails)
|
|
7939
|
+
* the sync fails and says so. A schema change the member pulled is a
|
|
7940
|
+
* different thing: it is the difference between the files and the record, and
|
|
7941
|
+
* keeps the declarative path below, including the `--yes` question for drops.
|
|
7597
7942
|
*
|
|
7598
7943
|
* It never resets the database. A reset wipes `auth.custom_oauth_providers`,
|
|
7599
7944
|
* which is where "Sign in with DevDogs" is registered (Q83); `--rebuild` is
|
|
@@ -7616,8 +7961,147 @@ function destructiveStatements(sql) {
|
|
|
7616
7961
|
function migrationFiles(dir) {
|
|
7617
7962
|
return existsSync(dir) ? readdirSync(dir).filter((name) => name.endsWith(".sql")).sort() : [];
|
|
7618
7963
|
}
|
|
7964
|
+
const NOTHING = {
|
|
7965
|
+
restored: [],
|
|
7966
|
+
extras: [],
|
|
7967
|
+
compared: false
|
|
7968
|
+
};
|
|
7969
|
+
/** `20261011120000` from `20261011120000_devtools_sync.sql`. */
|
|
7970
|
+
function versionOf(file) {
|
|
7971
|
+
return file.split("_")[0] ?? file;
|
|
7972
|
+
}
|
|
7973
|
+
/** The connection string and its password never reach the terminal. */
|
|
7974
|
+
function redactor(target) {
|
|
7975
|
+
const secrets = /* @__PURE__ */ new Set([target.dbUrl]);
|
|
7976
|
+
try {
|
|
7977
|
+
const url = new URL(target.dbUrl);
|
|
7978
|
+
for (const value of [url.password, decodeURIComponent(url.password)]) if (value) secrets.add(value);
|
|
7979
|
+
} catch {}
|
|
7980
|
+
return (text) => {
|
|
7981
|
+
let out = text;
|
|
7982
|
+
for (const secret of [...secrets].sort((a, b) => b.length - a.length)) out = out.split(secret).join(secret === target.dbUrl ? "<DB_URL>" : "<password>");
|
|
7983
|
+
return out;
|
|
7984
|
+
};
|
|
7985
|
+
}
|
|
7986
|
+
function liveDiffArgs(product, target) {
|
|
7987
|
+
return [
|
|
7988
|
+
"db",
|
|
7989
|
+
"diff",
|
|
7990
|
+
"--from",
|
|
7991
|
+
target.kind === "local" ? "local" : target.dbUrl,
|
|
7992
|
+
"--to",
|
|
7993
|
+
"migrations",
|
|
7994
|
+
"--use-pg-delta",
|
|
7995
|
+
"--schema",
|
|
7996
|
+
product.config.supabase.schemas.join(","),
|
|
7997
|
+
"--output-format",
|
|
7998
|
+
"json"
|
|
7999
|
+
];
|
|
8000
|
+
}
|
|
8001
|
+
/**
|
|
8002
|
+
* The SQL that would turn the live database into what the record describes
|
|
8003
|
+
* (pg-delta, database to migrations). Never applied as is. A diff that cannot
|
|
8004
|
+
* run is an error: there is no fallback to trusting the record.
|
|
8005
|
+
*/
|
|
8006
|
+
async function liveDiff(product, cli, target, run, log, redact) {
|
|
8007
|
+
const args = [...cli.prefix, ...liveDiffArgs(product, target)];
|
|
8008
|
+
let result = await run(cli.command, args, { cwd: product.root });
|
|
8009
|
+
if (result.code !== 0 && isContainerFailure(result)) {
|
|
8010
|
+
log("The shadow database did not start; trying once more.");
|
|
8011
|
+
result = await run(cli.command, args, { cwd: product.root });
|
|
8012
|
+
}
|
|
8013
|
+
const raw = result.stdout + "\n" + result.stderr;
|
|
8014
|
+
let parsed = null;
|
|
8015
|
+
for (const line of result.stdout.split("\n").reverse()) {
|
|
8016
|
+
const text = line.trim();
|
|
8017
|
+
if (!text.startsWith("{")) continue;
|
|
8018
|
+
try {
|
|
8019
|
+
parsed = JSON.parse(text);
|
|
8020
|
+
break;
|
|
8021
|
+
} catch {}
|
|
8022
|
+
}
|
|
8023
|
+
if (result.code === 0 && parsed && typeof parsed.diff === "string") return parsed.diff;
|
|
8024
|
+
const reason = typeof parsed?.error?.message === "string" ? parsed.error.message : result.stderr.trim() || result.stdout.trim();
|
|
8025
|
+
throw new SyncError(isContainerFailure({
|
|
8026
|
+
stdout: "",
|
|
8027
|
+
stderr: raw
|
|
8028
|
+
}) && !parsed?.error ? "Could not compare the database with supabase/schemas: Docker is needed for the shadow database. Start Docker and try again. Nothing was changed,, and the database was not checked against supabase/schemas." : "Could not compare the database 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(reason));
|
|
8029
|
+
}
|
|
8030
|
+
/** Lists the headline objects; their constraints, policies and the like are a count. */
|
|
8031
|
+
function summarize(labels) {
|
|
8032
|
+
const major = labels.filter((label) => /^(schema|table|function|view|sequence|type|column) /.test(label));
|
|
8033
|
+
if (major.length === 0 || major.length === labels.length) return labels.join(", ");
|
|
8034
|
+
const rest = labels.length - major.length;
|
|
8035
|
+
return `${major.join(", ")} (and ${rest} related ${rest === 1 ? "thing" : "things"}: constraints, policies, indexes)`;
|
|
8036
|
+
}
|
|
8037
|
+
/** How to clean up a database that has things the schema files do not define. */
|
|
8038
|
+
function cleanupHint(target) {
|
|
8039
|
+
return target.kind === "local" ? "To clean up: `devtools db sync --rebuild` (it wipes your development database), then `devtools dev-data` to load the sample data again." : "To clean up: reset the project from its dashboard (Database > Settings), then run `devtools db sync` and `devtools dev-data`.";
|
|
8040
|
+
}
|
|
8041
|
+
function listFindings(findings) {
|
|
8042
|
+
return findings.map((f) => `- ${f.object}: ${f.detail}`).join("\n");
|
|
8043
|
+
}
|
|
8044
|
+
/**
|
|
8045
|
+
* Compares the live database with the record and puts right what is safe.
|
|
8046
|
+
* See the header comment for the three kinds of difference.
|
|
8047
|
+
*/
|
|
8048
|
+
async function reconcileLive(product, cli, target, run, options, log, warn, redact) {
|
|
8049
|
+
const files = migrationFiles(product.paths.migrationsDir);
|
|
8050
|
+
if (!options.open || files.length === 0) return NOTHING;
|
|
8051
|
+
const { schemas } = product.config.supabase;
|
|
8052
|
+
let session;
|
|
8053
|
+
let ledger;
|
|
8054
|
+
let existing;
|
|
8055
|
+
try {
|
|
8056
|
+
session = options.open(target.dbUrl);
|
|
8057
|
+
ledger = await session.appliedMigrations();
|
|
8058
|
+
existing = await session.existingObjects(schemas);
|
|
8059
|
+
} catch (err) {
|
|
8060
|
+
await session?.close().catch(() => void 0);
|
|
8061
|
+
throw new SyncError("Could not connect to the database to compare it with supabase/schemas, so nothing was changed, and the database was not checked against supabase/schemas. Fix the connection (is the database running, is the password right?) and run `devtools db sync` again.", redact(err.message));
|
|
8062
|
+
}
|
|
8063
|
+
try {
|
|
8064
|
+
const drift = classifyDiff(await liveDiff(product, cli, target, run, log, redact), existing);
|
|
8065
|
+
if (drift.extras.length > 0 && !options.strict) warn(`Your database has ${drift.extras.length} thing${drift.extras.length === 1 ? "" : "s"} that supabase/schemas does not define. They are left as they are:\n${listFindings(drift.extras)}\n${cleanupHint(target)}`);
|
|
8066
|
+
if (drift.conflicts.length > 0) throw new SyncError(`Your database defines ${drift.conflicts.length} thing${drift.conflicts.length === 1 ? "" : "s"} differently from supabase/schemas. devtools will not change them for you, and nothing was changed.`, `${listFindings(drift.conflicts)}\nIf the database is right, edit supabase/schemas to match it. If the schema files are right, ${target.kind === "local" ? "run `devtools db sync --rebuild` (it wipes your development database), then `devtools dev-data`" : "reset the project from its dashboard (Database > Settings), then run `devtools db sync` and `devtools dev-data`"}.`);
|
|
8067
|
+
if (options.strict && drift.extras.length > 0) throw new SyncError(`--strict: your database has ${drift.extras.length} thing${drift.extras.length === 1 ? "" : "s"} that supabase/schemas does not define. Nothing was changed or deleted.`, `${listFindings(drift.extras)}\n${cleanupHint(target)}`);
|
|
8068
|
+
if (drift.restore.length > 0) {
|
|
8069
|
+
if (drift.restored.length > 0) log(`Your database is missing ${summarize(drift.restored)}; creating ${drift.restored.length === 1 ? "it" : "them"} again from supabase/schemas. Nothing that already exists is changed.`);
|
|
8070
|
+
try {
|
|
8071
|
+
await session.exec(drift.restore.join(";\n") + ";");
|
|
8072
|
+
} catch (err) {
|
|
8073
|
+
throw new SyncError("Could not recreate what the database is missing; nothing was changed.", `${redact(err.message)}\n${target.kind === "local" ? "`devtools db sync --rebuild` starts over (it wipes your development database)." : "Reset the project from its dashboard (Database > Settings), then run `devtools db sync`."}`);
|
|
8074
|
+
}
|
|
8075
|
+
}
|
|
8076
|
+
const known = new Set(ledger ?? []);
|
|
8077
|
+
const unlisted = files.map(versionOf).filter((version) => !known.has(version));
|
|
8078
|
+
if (unlisted.length > 0) {
|
|
8079
|
+
if (ledger === null) log("The database has no migration history table; recreating it from the local record.");
|
|
8080
|
+
const repaired = await run(cli.command, [
|
|
8081
|
+
...cli.prefix,
|
|
8082
|
+
"migration",
|
|
8083
|
+
"repair",
|
|
8084
|
+
"--status",
|
|
8085
|
+
"applied",
|
|
8086
|
+
"--db-url",
|
|
8087
|
+
target.dbUrl,
|
|
8088
|
+
...unlisted
|
|
8089
|
+
], { cwd: product.root });
|
|
8090
|
+
if (repaired.code !== 0) throw new SyncError("Could not record the applied migrations in the database's history.", redact(repaired.stderr || repaired.stdout));
|
|
8091
|
+
}
|
|
8092
|
+
return {
|
|
8093
|
+
restored: drift.restored,
|
|
8094
|
+
extras: drift.extras,
|
|
8095
|
+
compared: true
|
|
8096
|
+
};
|
|
8097
|
+
} finally {
|
|
8098
|
+
await session.close().catch(() => void 0);
|
|
8099
|
+
}
|
|
8100
|
+
}
|
|
7619
8101
|
async function dbSync(product, cli, target, run, options) {
|
|
7620
8102
|
const log = options.log ?? (() => void 0);
|
|
8103
|
+
const warn = options.warn ?? log;
|
|
8104
|
+
const redact = redactor(target);
|
|
7621
8105
|
const { migrationsDir, schemasDir } = product.paths;
|
|
7622
8106
|
if (!existsSync(schemasDir)) throw new SyncError(`${product.config.supabase.dir}/schemas does not exist; there is nothing to sync.`);
|
|
7623
8107
|
mkdirSync(migrationsDir, { recursive: true });
|
|
@@ -7646,7 +8130,7 @@ async function dbSync(product, cli, target, run, options) {
|
|
|
7646
8130
|
"--db-url",
|
|
7647
8131
|
"<DB_URL>"
|
|
7648
8132
|
])} (this machine has no record of what the project has had applied)`);
|
|
7649
|
-
if (options.open) log(`Would run: ${cliLine(cli, liveDiffArgs(product, target).
|
|
8133
|
+
if (options.open) log(`Would run: ${cliLine(cli, liveDiffArgs(product, target).map((arg) => arg === target.dbUrl ? "<DB_URL>" : arg))} (and recreate anything the database is missing; extras are only listed)`);
|
|
7650
8134
|
log(`Would run: ${cliLine(cli, generateArgs.slice(cli.prefix.length))}`);
|
|
7651
8135
|
log(target.kind === "local" ? `Would run: ${cliLine(cli, [
|
|
7652
8136
|
"migration",
|
|
@@ -7662,29 +8146,35 @@ async function dbSync(product, cli, target, run, options) {
|
|
|
7662
8146
|
return {
|
|
7663
8147
|
migration: null,
|
|
7664
8148
|
applied: false,
|
|
7665
|
-
destructive: []
|
|
8149
|
+
destructive: [],
|
|
8150
|
+
...NOTHING
|
|
7666
8151
|
};
|
|
7667
8152
|
}
|
|
7668
8153
|
if (needsRecord) {
|
|
7669
8154
|
const fetched = await run(cli.command, fetchArgs, { cwd: product.root });
|
|
7670
|
-
const
|
|
7671
|
-
if (fetched.code === 0 &&
|
|
8155
|
+
const restoredFiles = migrationFiles(migrationsDir).length;
|
|
8156
|
+
if (fetched.code === 0 && restoredFiles > 0) log(`Restored ${restoredFiles} applied migration${restoredFiles === 1 ? "" : "s"} from the project's history.`);
|
|
7672
8157
|
}
|
|
7673
|
-
const
|
|
8158
|
+
const live = await reconcileLive(product, cli, target, run, options, log, warn, redact);
|
|
8159
|
+
const restoredSomething = live.restored.length > 0;
|
|
7674
8160
|
const before = new Set(migrationFiles(migrationsDir));
|
|
7675
8161
|
let generated = await run(cli.command, generateArgs, { cwd: product.root });
|
|
7676
8162
|
if (generated.code !== 0 && isContainerFailure(generated)) {
|
|
7677
8163
|
log("The shadow database did not start; trying once more.");
|
|
7678
8164
|
generated = await run(cli.command, generateArgs, { cwd: product.root });
|
|
7679
8165
|
}
|
|
7680
|
-
if (generated.code !== 0) throw new SyncError(isContainerFailure(generated) ? "Could not diff the schema: Docker is needed for the shadow database. Start Docker and try again." : "Could not diff the schema against what this database has had applied.", generated.stderr || generated.stdout);
|
|
8166
|
+
if (generated.code !== 0) throw new SyncError(isContainerFailure(generated) ? "Could not diff the schema: Docker is needed for the shadow database. Start Docker and try again." : "Could not diff the schema against what this database has had applied.", redact(generated.stderr || generated.stdout));
|
|
7681
8167
|
const created = migrationFiles(migrationsDir).filter((f) => !before.has(f));
|
|
7682
8168
|
if (created.length === 0) {
|
|
7683
|
-
if (
|
|
8169
|
+
if (live.compared && !restoredSomething) {
|
|
8170
|
+
const n = live.extras.length;
|
|
8171
|
+
log(n === 0 ? "The database already matches supabase/schemas." : `The database matches supabase/schemas, with ${n} extra thing${n === 1 ? "" : "s"} listed above that it does not define.`);
|
|
8172
|
+
}
|
|
7684
8173
|
return {
|
|
7685
8174
|
migration: null,
|
|
7686
|
-
applied:
|
|
7687
|
-
destructive:
|
|
8175
|
+
applied: restoredSomething,
|
|
8176
|
+
destructive: [],
|
|
8177
|
+
...live
|
|
7688
8178
|
};
|
|
7689
8179
|
}
|
|
7690
8180
|
const migration = created[created.length - 1];
|
|
@@ -7713,142 +8203,14 @@ async function dbSync(product, cli, target, run, options) {
|
|
|
7713
8203
|
const applied = await run(cli.command, applyArgs, { cwd: product.root });
|
|
7714
8204
|
if (applied.code !== 0) {
|
|
7715
8205
|
for (const file of created) rmSync(join(migrationsDir, file));
|
|
7716
|
-
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).", applied.stderr || applied.stdout);
|
|
8206
|
+
throw new SyncError("The change did not apply. Your schema files may not be valid SQL, or the database has drifted from what devtools recorded (`devtools db sync --rebuild` starts over).", redact(applied.stderr || applied.stdout));
|
|
7717
8207
|
}
|
|
7718
8208
|
return {
|
|
7719
8209
|
migration,
|
|
7720
8210
|
applied: true,
|
|
7721
|
-
destructive
|
|
7722
|
-
|
|
7723
|
-
}
|
|
7724
|
-
/** `20261011120000` from `20261011120000_devtools_sync.sql`. */
|
|
7725
|
-
function versionOf(file) {
|
|
7726
|
-
return file.split("_")[0] ?? file;
|
|
7727
|
-
}
|
|
7728
|
-
function liveDiffArgs(product, target) {
|
|
7729
|
-
return [
|
|
7730
|
-
"db",
|
|
7731
|
-
"diff",
|
|
7732
|
-
"--from",
|
|
7733
|
-
target.kind === "local" ? "local" : target.dbUrl,
|
|
7734
|
-
"--to",
|
|
7735
|
-
"migrations",
|
|
7736
|
-
"--use-pg-delta",
|
|
7737
|
-
"--schema",
|
|
7738
|
-
product.config.supabase.schemas.join(","),
|
|
7739
|
-
"--output-format",
|
|
7740
|
-
"json"
|
|
7741
|
-
];
|
|
7742
|
-
}
|
|
7743
|
-
/**
|
|
7744
|
-
* The SQL that turns the database into what the record describes, from the
|
|
7745
|
-
* CLI's pg-delta diff (database to migrations baseline). Empty when they
|
|
7746
|
-
* agree. Only the product's own schemas are compared, so the platform's
|
|
7747
|
-
* (auth, storage, ...) never show up as drift.
|
|
7748
|
-
*/
|
|
7749
|
-
async function liveDiff(product, cli, target, run, log) {
|
|
7750
|
-
const args = [...cli.prefix, ...liveDiffArgs(product, target)];
|
|
7751
|
-
let result = await run(cli.command, args, { cwd: product.root });
|
|
7752
|
-
if (result.code !== 0 && isContainerFailure(result)) {
|
|
7753
|
-
log("The shadow database did not start; trying once more.");
|
|
7754
|
-
result = await run(cli.command, args, { cwd: product.root });
|
|
7755
|
-
}
|
|
7756
|
-
if (result.code !== 0) throw new SyncError(isContainerFailure(result) ? "Could not compare the database with its record: Docker is needed for the shadow database. Start Docker and try again." : "Could not compare the database with what devtools recorded as applied.", result.stderr || result.stdout);
|
|
7757
|
-
for (const line of result.stdout.split("\n").reverse()) {
|
|
7758
|
-
const text = line.trim();
|
|
7759
|
-
if (!text.startsWith("{")) continue;
|
|
7760
|
-
try {
|
|
7761
|
-
const parsed = JSON.parse(text);
|
|
7762
|
-
if (typeof parsed.diff === "string") return parsed.diff;
|
|
7763
|
-
} catch {}
|
|
7764
|
-
}
|
|
7765
|
-
throw new SyncError("Could not read the result of comparing the database with its record.", result.stdout || result.stderr);
|
|
7766
|
-
}
|
|
7767
|
-
/**
|
|
7768
|
-
* Makes the database agree with the local record, from the database itself.
|
|
7769
|
-
* The declarative diff only compares `supabase/schemas` with
|
|
7770
|
-
* `supabase/migrations`, so a database that lost objects behind the record's
|
|
7771
|
-
* back (a table dropped by hand, a hosted project reset from the dashboard, a
|
|
7772
|
-
* checkout switched from the local stack to a hosted project) would read as
|
|
7773
|
-
* "already matches". So, with a record to compare against:
|
|
7774
|
-
*
|
|
7775
|
-
* 1. the database is diffed against the record, per configured schema, and
|
|
7776
|
-
* whatever it lacks is created again (data in what remains is untouched);
|
|
7777
|
-
* statements that drop something need the same approval as any change;
|
|
7778
|
-
* 2. the migration ledger is made to list every migration in the record
|
|
7779
|
-
* (marking them applied, which they now are), so the next
|
|
7780
|
-
* `migration up` applies only what is new.
|
|
7781
|
-
*
|
|
7782
|
-
* A missing or unreadable ledger decides nothing by itself: it is rewritten,
|
|
7783
|
-
* never read as "start over". A database that cannot be reached is left to
|
|
7784
|
-
* the apply step to report.
|
|
7785
|
-
*/
|
|
7786
|
-
async function restoreDatabase(product, cli, target, run, options, log) {
|
|
7787
|
-
const nothing = {
|
|
7788
|
-
applied: false,
|
|
7789
|
-
destructive: []
|
|
8211
|
+
destructive,
|
|
8212
|
+
...live
|
|
7790
8213
|
};
|
|
7791
|
-
const files = migrationFiles(product.paths.migrationsDir);
|
|
7792
|
-
if (!options.open || files.length === 0) return nothing;
|
|
7793
|
-
let session;
|
|
7794
|
-
let missing;
|
|
7795
|
-
let ledger;
|
|
7796
|
-
try {
|
|
7797
|
-
session = options.open(target.dbUrl);
|
|
7798
|
-
missing = await session.missingSchemas(product.config.supabase.schemas);
|
|
7799
|
-
} catch (err) {
|
|
7800
|
-
log(`Could not check what the database has (${err.message}); trusting the local record.`);
|
|
7801
|
-
await session?.close().catch(() => void 0);
|
|
7802
|
-
return nothing;
|
|
7803
|
-
}
|
|
7804
|
-
try {
|
|
7805
|
-
try {
|
|
7806
|
-
ledger = await session.appliedMigrations();
|
|
7807
|
-
} catch (err) {
|
|
7808
|
-
ledger = void 0;
|
|
7809
|
-
log(`Could not read the migration history (${err.message}); deciding from the schemas alone.`);
|
|
7810
|
-
}
|
|
7811
|
-
const sql = (await liveDiff(product, cli, target, run, log)).trim();
|
|
7812
|
-
let applied = false;
|
|
7813
|
-
let destructive = [];
|
|
7814
|
-
if (sql) {
|
|
7815
|
-
destructive = destructiveStatements(sql);
|
|
7816
|
-
if (destructive.length > 0 && !options.yes) {
|
|
7817
|
-
if (!(options.confirmDestructive ? await options.confirmDestructive(sql, destructive) : false)) throw new SyncError(`The database differs from supabase/schemas and putting it right drops data in your development database (${destructive.length} statement${destructive.length === 1 ? "" : "s"}). Re-run with --yes to allow it.`, destructive.join(";\n"));
|
|
7818
|
-
}
|
|
7819
|
-
log(missing.length > 0 ? `The database is missing ${missing.length === 1 ? "schema" : "schemas"} ${missing.join(", ")}; creating ${missing.length === 1 ? "it" : "them"} again from supabase/schemas.` : "The database has lost part of supabase/schemas (a dropped table, view or function, perhaps); restoring it. Existing data is kept.");
|
|
7820
|
-
try {
|
|
7821
|
-
await session.exec(sql);
|
|
7822
|
-
} catch (err) {
|
|
7823
|
-
throw new SyncError("Could not restore what the database lost. `devtools db sync --rebuild` starts over (it wipes local data).", err.message);
|
|
7824
|
-
}
|
|
7825
|
-
applied = true;
|
|
7826
|
-
}
|
|
7827
|
-
if (ledger !== void 0) {
|
|
7828
|
-
const known = new Set(ledger ?? []);
|
|
7829
|
-
const unlisted = files.map(versionOf).filter((version) => !known.has(version));
|
|
7830
|
-
if (unlisted.length > 0) {
|
|
7831
|
-
if (ledger === null) log("The database has no migration history table; recreating it from the local record.");
|
|
7832
|
-
const repaired = await run(cli.command, [
|
|
7833
|
-
...cli.prefix,
|
|
7834
|
-
"migration",
|
|
7835
|
-
"repair",
|
|
7836
|
-
"--status",
|
|
7837
|
-
"applied",
|
|
7838
|
-
"--db-url",
|
|
7839
|
-
target.dbUrl,
|
|
7840
|
-
...unlisted
|
|
7841
|
-
], { cwd: product.root });
|
|
7842
|
-
if (repaired.code !== 0) throw new SyncError("Could not record the applied migrations in the database's history.", repaired.stderr || repaired.stdout);
|
|
7843
|
-
}
|
|
7844
|
-
}
|
|
7845
|
-
return {
|
|
7846
|
-
applied,
|
|
7847
|
-
destructive
|
|
7848
|
-
};
|
|
7849
|
-
} finally {
|
|
7850
|
-
await session.close().catch(() => void 0);
|
|
7851
|
-
}
|
|
7852
8214
|
}
|
|
7853
8215
|
function isContainerFailure(result) {
|
|
7854
8216
|
return /docker|container|image/i.test(result.stderr + result.stdout);
|
|
@@ -8112,7 +8474,9 @@ async function runSync(product, deps, rest) {
|
|
|
8112
8474
|
yes: hasYes(rest),
|
|
8113
8475
|
confirmDestructive: deps.interactive() ? confirmDestructive : void 0,
|
|
8114
8476
|
dryRun,
|
|
8477
|
+
strict: rest.includes("--strict"),
|
|
8115
8478
|
log: (line) => log.info(line),
|
|
8479
|
+
warn: (line) => log.warn(line),
|
|
8116
8480
|
open: deps.openSql
|
|
8117
8481
|
});
|
|
8118
8482
|
if (rest.includes("--rebuild") && !dryRun) try {
|
|
@@ -8184,7 +8548,7 @@ function createProductHandlers(deps = defaultDeps) {
|
|
|
8184
8548
|
switch (sub) {
|
|
8185
8549
|
case "sync": {
|
|
8186
8550
|
const { result, cli, target } = await runSync(product, deps, flags);
|
|
8187
|
-
if (result.applied) log.success("Your database now matches supabase/schemas.");
|
|
8551
|
+
if (result.applied) log.success(result.extras.length > 0 ? `Your database now matches supabase/schemas, apart from the ${result.extras.length} extra thing${result.extras.length === 1 ? "" : "s"} listed above.` : "Your database now matches supabase/schemas.");
|
|
8188
8552
|
await regenerateAfterSync(product, cli, target, result, deps);
|
|
8189
8553
|
return DONE;
|
|
8190
8554
|
}
|
|
@@ -8415,9 +8779,11 @@ async function runSetup(product, deps, rest) {
|
|
|
8415
8779
|
yes: hasYes(rest),
|
|
8416
8780
|
confirmDestructive: deps.interactive() ? confirmDestructive : void 0,
|
|
8417
8781
|
log: (line) => log.info(line),
|
|
8782
|
+
warn: (line) => log.warn(line),
|
|
8418
8783
|
open: deps.openSql
|
|
8419
8784
|
});
|
|
8420
|
-
|
|
8785
|
+
if (result.applied) log.success("Applied supabase/schemas to your database.");
|
|
8786
|
+
else if (result.compared) log.success("Your database already matched supabase/schemas.");
|
|
8421
8787
|
if (target.kind === "local") await waitForRest(target, deps, product);
|
|
8422
8788
|
if (rest.includes("--download-types")) try {
|
|
8423
8789
|
await downloadTypes(product, {
|
package/dist/launch.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { i as markOutsideCheckout, n as discoverRepoRoot, r as findRepoRoot, t as RepoNotFoundError } from "./root-RaJ8ECCO.js";
|
|
2
2
|
import { b as setDryRun, d as loadEnvLoad, f as loadEnvSession, m as formatCommand, v as isDryRun, y as resolveDryRun } from "./run-D5iI9zve.js";
|
|
3
|
-
import { D as catalog, E as setProductMode, N as setEnvironmentRoot, O as helpPath, S as findProductRoot, c as nonEmpty, f as enterSessionEnvironment, g as errorMessage, h as bail, m as setMenuEnvHook, n as bareGroupStartPath, p as realEnvEntryDeps, y as unwrap } from "./menu-
|
|
3
|
+
import { D as catalog, E as setProductMode, N as setEnvironmentRoot, O as helpPath, S as findProductRoot, c as nonEmpty, f as enterSessionEnvironment, g as errorMessage, h as bail, m as setMenuEnvHook, n as bareGroupStartPath, p as realEnvEntryDeps, y as unwrap } from "./menu-Ctqnvduz.js";
|
|
4
4
|
import { a as isVersionRequest, c as cliName, t as installFailureLog } from "./failure-log-MSdCgvLc.js";
|
|
5
5
|
import { c as lastSentryEventId, f as hasYes, h as stripTierFlag, m as stripNoEnvFlag, n as captureDevtoolsError, o as initDevtoolsTelemetry, p as isNonInteractive } from "./telemetry-BDOwjuoC.js";
|
|
6
6
|
import { confirm, select } from "@clack/prompts";
|
|
@@ -183,7 +183,7 @@ async function startStack() {
|
|
|
183
183
|
* post-tier-resolution path.
|
|
184
184
|
*/
|
|
185
185
|
async function dispatch(argv) {
|
|
186
|
-
const { main } = await import("./cli-
|
|
186
|
+
const { main } = await import("./cli-xAkuiAkl.js");
|
|
187
187
|
try {
|
|
188
188
|
await main(argv);
|
|
189
189
|
} catch (err) {
|
|
@@ -946,7 +946,7 @@ const productDbCommand = {
|
|
|
946
946
|
{
|
|
947
947
|
name: "sync",
|
|
948
948
|
title: "Apply the schema to my database",
|
|
949
|
-
summary: "
|
|
949
|
+
summary: "Compare your database with supabase/schemas: recreate what it is missing, apply schema changes you pulled, warn about extras, and refuse to alter what is defined differently.",
|
|
950
950
|
envFree: true,
|
|
951
951
|
dryRun: "handled",
|
|
952
952
|
options: [
|
|
@@ -955,6 +955,10 @@ const productDbCommand = {
|
|
|
955
955
|
flag: "--rebuild",
|
|
956
956
|
summary: "Reset the local database and start over from the schema, then register sign in again."
|
|
957
957
|
},
|
|
958
|
+
{
|
|
959
|
+
flag: "--strict",
|
|
960
|
+
summary: "Fail (changing nothing) when the database has things supabase/schemas does not define, instead of warning."
|
|
961
|
+
},
|
|
958
962
|
{
|
|
959
963
|
flag: "--yes",
|
|
960
964
|
summary: "Allow changes that drop tables or columns."
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devdogsuga/devtools",
|
|
3
|
-
"version": "0.1.51-next.
|
|
3
|
+
"version": "0.1.51-next.551",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Contributor CLI for DevDogs repositories (the platform monorepo, DogDays, DogPack): the real tools with the session's tier filled in, `setup`, `db sync`, `db types`, `dev-data`, a menu over them, the checks CI runs, and the `with-env` launcher. Ships built JS; run via `pnpm devtools` in a product repo, `pnpm dlx @devdogsuga/devtools` (or the `devtools` bin once installed) from inside a clone.",
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
"tsdown": "^0.23.0",
|
|
48
48
|
"vitest": "^4.1.11",
|
|
49
49
|
"@devdogsuga/cli-core": "0.0.0",
|
|
50
|
-
"@devdogsuga/
|
|
51
|
-
"@devdogsuga/
|
|
50
|
+
"@devdogsuga/sdk": "0.1.0",
|
|
51
|
+
"@devdogsuga/config": "0.1.4-next.526"
|
|
52
52
|
},
|
|
53
53
|
"scripts": {
|
|
54
54
|
"build": "tsdown && node scripts/write-build-info.mjs && node ../../scripts/check-bundle-imports.mjs",
|