@devdogsuga/devtools 0.1.51-next.551 → 0.1.51-next.552
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -1
- package/dist/{cli-xAkuiAkl.js → cli-DfHsw0fp.js} +334 -60
- package/dist/launch.js +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -127,7 +127,14 @@ How it works, in short:
|
|
|
127
127
|
needs `--yes`. It then regenerates the types (and leaves them alone when nothing changed and they are current).
|
|
128
128
|
On a hosted project, a machine with no record (a second laptop, a deleted `supabase/migrations/`) first
|
|
129
129
|
restores it from the project's own history table (`migration fetch --db-url`), so the diff is not taken
|
|
130
|
-
against an empty database.
|
|
130
|
+
against an empty database. The record belongs to one database (`.devtools/record.json` notes which, and which
|
|
131
|
+
files it has applied): another target's record is set aside and that database's own history is fetched, and a
|
|
132
|
+
migration written but never applied is pending, not applied; it is applied on the next sync, with the `--yes`
|
|
133
|
+
question for drops.
|
|
134
|
+
- **What a member changed.** A column that looks renamed (missing here, an extra of the same type beside it) is
|
|
135
|
+
refused, not recreated empty. A difference in privileges on something that exists is applied as a GRANT of what
|
|
136
|
+
`supabase/schemas` grants and reported; privileges beyond that are not taken away. Functions are named with
|
|
137
|
+
their argument types, and a missing overload is recreated beside the member's own.
|
|
131
138
|
- **Types are never committed.** `db types` writes them to the path in the config, which is git-ignored.
|
|
132
139
|
The schema hash covers the sorted schema paths, their LF-normalised contents and the generator version (the
|
|
133
140
|
pinned CLI, or supadart); CI uploads a `<product>-types-<hash>.tgz` asset to a rolling `generated-types`
|
|
@@ -7307,10 +7307,15 @@ function postgresSession(dbUrl) {
|
|
|
7307
7307
|
const list = [...schemas];
|
|
7308
7308
|
const found = /* @__PURE__ */ new Set();
|
|
7309
7309
|
const functions = await sql`
|
|
7310
|
-
select n.nspname as s, p.proname as n
|
|
7310
|
+
select n.nspname as s, p.proname as n,
|
|
7311
|
+
oidvectortypes(p.proargtypes) as sig
|
|
7312
|
+
from pg_proc p
|
|
7311
7313
|
join pg_namespace n on n.oid = p.pronamespace
|
|
7312
|
-
where n.nspname = any(${list})`;
|
|
7313
|
-
for (const r of functions)
|
|
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
|
+
}
|
|
7314
7319
|
const views = await sql`
|
|
7315
7320
|
select n.nspname as s, c.relname as n from pg_class c
|
|
7316
7321
|
join pg_namespace n on n.oid = c.relnamespace
|
|
@@ -7324,6 +7329,17 @@ function postgresSession(dbUrl) {
|
|
|
7324
7329
|
for (const r of triggers) found.add(`trigger:${r.s}.${r.t}:${r.n}`);
|
|
7325
7330
|
return found;
|
|
7326
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
|
+
},
|
|
7327
7343
|
async exec(text) {
|
|
7328
7344
|
await sql.begin(async (tx) => {
|
|
7329
7345
|
await tx.unsafe(text);
|
|
@@ -7678,6 +7694,110 @@ function qualified(name) {
|
|
|
7678
7694
|
function shown(name) {
|
|
7679
7695
|
return parts(name).map((part) => `"${part}"`).join(".");
|
|
7680
7696
|
}
|
|
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, "")}`;
|
|
7722
|
+
}
|
|
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);
|
|
7736
|
+
}
|
|
7737
|
+
return null;
|
|
7738
|
+
}
|
|
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));
|
|
7768
|
+
}
|
|
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
|
+
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, "")
|
|
7799
|
+
};
|
|
7800
|
+
}
|
|
7681
7801
|
const CREATE_KINDS = {
|
|
7682
7802
|
SCHEMA: "schema",
|
|
7683
7803
|
TABLE: "table",
|
|
@@ -7696,14 +7816,18 @@ const WORD = (word) => new RegExp(`^${word}\\b`, "i");
|
|
|
7696
7816
|
* database has (for `CREATE OR REPLACE`, which reads the same whether the
|
|
7697
7817
|
* object is missing or merely different).
|
|
7698
7818
|
*/
|
|
7699
|
-
function classifyDiff(sql, existing) {
|
|
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
|
+
}
|
|
7700
7825
|
const creates = [];
|
|
7701
7826
|
const drops = [];
|
|
7702
7827
|
const conflicts = /* @__PURE__ */ new Map();
|
|
7703
7828
|
const extras = [];
|
|
7704
7829
|
const preamble = [];
|
|
7705
7830
|
const privileges = [];
|
|
7706
|
-
let sawRevoke = false;
|
|
7707
7831
|
const conflict = (object, why) => {
|
|
7708
7832
|
const set = conflicts.get(object) ?? /* @__PURE__ */ new Set();
|
|
7709
7833
|
set.add(why);
|
|
@@ -7715,24 +7839,11 @@ function classifyDiff(sql, existing) {
|
|
|
7715
7839
|
preamble.push(statement);
|
|
7716
7840
|
continue;
|
|
7717
7841
|
}
|
|
7718
|
-
if (WORD("GRANT").test(text)) {
|
|
7842
|
+
if (WORD("GRANT").test(text) || WORD("REVOKE").test(text)) {
|
|
7843
|
+
const p = privilegeOf(text, WORD("REVOKE").test(text));
|
|
7719
7844
|
privileges.push({
|
|
7720
|
-
|
|
7721
|
-
|
|
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
|
-
}
|
|
7845
|
+
...p,
|
|
7846
|
+
sql: statement
|
|
7736
7847
|
});
|
|
7737
7848
|
continue;
|
|
7738
7849
|
}
|
|
@@ -7753,12 +7864,17 @@ function classifyDiff(sql, existing) {
|
|
|
7753
7864
|
const noun = kindWord === "POLICY" ? "policy" : "trigger";
|
|
7754
7865
|
key = `${noun}:${table}:${parts(name).at(-1)}`;
|
|
7755
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}`;
|
|
7756
7871
|
} else {
|
|
7757
7872
|
const kind = CREATE_KINDS[kindWord] ?? kindWord.toLowerCase();
|
|
7758
7873
|
key = `${kind}:${qualified(name)}`;
|
|
7759
7874
|
label = `${kind} ${shown(name)}`;
|
|
7760
7875
|
}
|
|
7761
|
-
|
|
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");
|
|
7762
7878
|
else creates.push({
|
|
7763
7879
|
key,
|
|
7764
7880
|
sql: statement,
|
|
@@ -7788,6 +7904,15 @@ function classifyDiff(sql, existing) {
|
|
|
7788
7904
|
detail: "not in supabase/schemas"
|
|
7789
7905
|
}
|
|
7790
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
|
+
});
|
|
7791
7916
|
} else {
|
|
7792
7917
|
const kind = CREATE_KINDS[kindWord] ?? kindWord.toLowerCase();
|
|
7793
7918
|
drops.push({
|
|
@@ -7822,23 +7947,51 @@ function classifyDiff(sql, existing) {
|
|
|
7822
7947
|
}
|
|
7823
7948
|
conflict(text.slice(0, 80), "a difference devtools does not know how to classify, so it will not touch it");
|
|
7824
7949
|
}
|
|
7825
|
-
const
|
|
7826
|
-
const
|
|
7827
|
-
const
|
|
7828
|
-
|
|
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`);
|
|
7964
|
+
}
|
|
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");
|
|
7829
7972
|
else extras.push(d.finding);
|
|
7830
|
-
const newNames = new Set(restoring.map((c) => c.key).filter((key) => !/^(index|policy|trigger|
|
|
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)));
|
|
7831
7974
|
const tail = [];
|
|
7832
|
-
|
|
7833
|
-
|
|
7834
|
-
|
|
7835
|
-
|
|
7836
|
-
|
|
7837
|
-
|
|
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);
|
|
7984
|
+
}
|
|
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
|
+
});
|
|
7991
|
+
}
|
|
7838
7992
|
const restore = [];
|
|
7839
|
-
const restored = restoring.map((c) => c.label);
|
|
7840
|
-
if (restoring.length > 0 ||
|
|
7841
|
-
if (restoring.length === 0 && grants > 0 && !sawRevoke) restored.push("privileges");
|
|
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);
|
|
7842
7995
|
return {
|
|
7843
7996
|
restore,
|
|
7844
7997
|
restored,
|
|
@@ -7861,12 +8014,19 @@ function dedupe(findings) {
|
|
|
7861
8014
|
function classifyTableAction(table, t, action, ctx) {
|
|
7862
8015
|
const sql = `ALTER TABLE ${table} ${action}`;
|
|
7863
8016
|
let m;
|
|
7864
|
-
if (m = new RegExp(String.raw`^ADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?(${IDENT})`, "i").exec(action))
|
|
7865
|
-
|
|
7866
|
-
|
|
7867
|
-
|
|
7868
|
-
|
|
7869
|
-
|
|
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({
|
|
7870
8030
|
key: `constraint:${t}.${parts(m[1])[0]}`,
|
|
7871
8031
|
sql,
|
|
7872
8032
|
label: `constraint ${shown(m[1])} on ${shown(table)}`
|
|
@@ -7876,6 +8036,10 @@ function classifyTableAction(table, t, action, ctx) {
|
|
|
7876
8036
|
finding: {
|
|
7877
8037
|
object: `column ${shown(table)}.${shown(m[1])}`,
|
|
7878
8038
|
detail: "not in supabase/schemas"
|
|
8039
|
+
},
|
|
8040
|
+
column: {
|
|
8041
|
+
table: t,
|
|
8042
|
+
name: parts(m[1])[0]
|
|
7879
8043
|
}
|
|
7880
8044
|
});
|
|
7881
8045
|
else if (m = new RegExp(String.raw`^DROP\s+CONSTRAINT\s+(?:IF\s+EXISTS\s+)?(${IDENT})`, "i").exec(action)) ctx.drops.push({
|
|
@@ -7966,6 +8130,65 @@ const NOTHING = {
|
|
|
7966
8130
|
extras: [],
|
|
7967
8131
|
compared: false
|
|
7968
8132
|
};
|
|
8133
|
+
function recordFile(product) {
|
|
8134
|
+
return join(product.root, ".devtools", "record.json");
|
|
8135
|
+
}
|
|
8136
|
+
function pendingDir(product) {
|
|
8137
|
+
return join(product.root, ".devtools", "pending");
|
|
8138
|
+
}
|
|
8139
|
+
function readRecord(product) {
|
|
8140
|
+
try {
|
|
8141
|
+
const raw = JSON.parse(readFileSync(recordFile(product), "utf8"));
|
|
8142
|
+
return typeof raw.target === "string" && Array.isArray(raw.applied) ? {
|
|
8143
|
+
target: raw.target,
|
|
8144
|
+
applied: raw.applied.map(String)
|
|
8145
|
+
} : null;
|
|
8146
|
+
} catch {
|
|
8147
|
+
return null;
|
|
8148
|
+
}
|
|
8149
|
+
}
|
|
8150
|
+
function writeRecord(product, target, files) {
|
|
8151
|
+
mkdirSync(dirname(recordFile(product)), { recursive: true });
|
|
8152
|
+
const record = {
|
|
8153
|
+
target: targetKey(target),
|
|
8154
|
+
applied: files.map(versionOf)
|
|
8155
|
+
};
|
|
8156
|
+
writeFileSync(recordFile(product), JSON.stringify(record, null, 2) + "\n");
|
|
8157
|
+
}
|
|
8158
|
+
/** `local`, or `hosted:<project ref>` (never the password). */
|
|
8159
|
+
function targetKey(target) {
|
|
8160
|
+
if (target.kind === "local") return "local";
|
|
8161
|
+
try {
|
|
8162
|
+
const url = new URL(target.dbUrl);
|
|
8163
|
+
const user = decodeURIComponent(url.username);
|
|
8164
|
+
return `hosted:${(user.includes(".") ? user.split(".").slice(1).join(".") : "") || url.hostname}`;
|
|
8165
|
+
} catch {
|
|
8166
|
+
return "hosted";
|
|
8167
|
+
}
|
|
8168
|
+
}
|
|
8169
|
+
/** The migrations the target has applied (null: no history table); throws if it cannot be reached. */
|
|
8170
|
+
async function probe(open, target, redact) {
|
|
8171
|
+
let session;
|
|
8172
|
+
try {
|
|
8173
|
+
session = open(target.dbUrl);
|
|
8174
|
+
return await session.appliedMigrations();
|
|
8175
|
+
} catch (err) {
|
|
8176
|
+
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));
|
|
8177
|
+
} finally {
|
|
8178
|
+
await session?.close().catch(() => void 0);
|
|
8179
|
+
}
|
|
8180
|
+
}
|
|
8181
|
+
/** Puts back migrations a sync that was killed had set aside. */
|
|
8182
|
+
function restorePending(product) {
|
|
8183
|
+
const dir = pendingDir(product);
|
|
8184
|
+
if (!existsSync(dir)) return;
|
|
8185
|
+
mkdirSync(product.paths.migrationsDir, { recursive: true });
|
|
8186
|
+
for (const file of readdirSync(dir)) {
|
|
8187
|
+
const back = join(product.paths.migrationsDir, file);
|
|
8188
|
+
if (existsSync(back)) rmSync(join(dir, file));
|
|
8189
|
+
else renameSync(join(dir, file), back);
|
|
8190
|
+
}
|
|
8191
|
+
}
|
|
7969
8192
|
/** `20261011120000` from `20261011120000_devtools_sync.sql`. */
|
|
7970
8193
|
function versionOf(file) {
|
|
7971
8194
|
return file.split("_")[0] ?? file;
|
|
@@ -8047,34 +8270,56 @@ function listFindings(findings) {
|
|
|
8047
8270
|
*/
|
|
8048
8271
|
async function reconcileLive(product, cli, target, run, options, log, warn, redact) {
|
|
8049
8272
|
const files = migrationFiles(product.paths.migrationsDir);
|
|
8050
|
-
if (!options.open || files.length === 0) return
|
|
8273
|
+
if (!options.open || files.length === 0) return {
|
|
8274
|
+
...NOTHING,
|
|
8275
|
+
pending: []
|
|
8276
|
+
};
|
|
8051
8277
|
const { schemas } = product.config.supabase;
|
|
8052
8278
|
let session;
|
|
8053
8279
|
let ledger;
|
|
8054
8280
|
let existing;
|
|
8281
|
+
let columnTypes;
|
|
8055
8282
|
try {
|
|
8056
8283
|
session = options.open(target.dbUrl);
|
|
8057
8284
|
ledger = await session.appliedMigrations();
|
|
8058
8285
|
existing = await session.existingObjects(schemas);
|
|
8286
|
+
columnTypes = await session.columnTypes(schemas);
|
|
8059
8287
|
} catch (err) {
|
|
8060
8288
|
await session?.close().catch(() => void 0);
|
|
8061
8289
|
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
8290
|
}
|
|
8291
|
+
const record = readRecord(product);
|
|
8292
|
+
const noted = record?.target === targetKey(target) ? record.applied : null;
|
|
8293
|
+
const known = /* @__PURE__ */ new Set([...ledger ?? [], ...noted ?? []]);
|
|
8294
|
+
const everythingApplied = ledger === null && noted === null;
|
|
8295
|
+
const isApplied = (file) => everythingApplied || known.has(versionOf(file));
|
|
8296
|
+
const appliedFiles = files.filter(isApplied);
|
|
8297
|
+
const pending = files.filter((file) => !isApplied(file));
|
|
8298
|
+
if (appliedFiles.length === 0) {
|
|
8299
|
+
await session.close().catch(() => void 0);
|
|
8300
|
+
return {
|
|
8301
|
+
...NOTHING,
|
|
8302
|
+
pending
|
|
8303
|
+
};
|
|
8304
|
+
}
|
|
8305
|
+
const aside = pendingDir(product);
|
|
8306
|
+
if (pending.length > 0) mkdirSync(aside, { recursive: true });
|
|
8307
|
+
for (const file of pending) renameSync(join(product.paths.migrationsDir, file), join(aside, file));
|
|
8063
8308
|
try {
|
|
8064
|
-
const drift = classifyDiff(await liveDiff(product, cli, target, run, log, redact), existing);
|
|
8309
|
+
const drift = classifyDiff(await liveDiff(product, cli, target, run, log, redact), existing, columnTypes);
|
|
8065
8310
|
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
8311
|
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
8312
|
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
8313
|
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
|
|
8314
|
+
if (drift.restored.length > 0) log(`Your database is missing ${summarize(drift.restored)}; creating ${drift.restored.length === 1 ? "it" : "them"} again from supabase/schemas. Nothing that already exists is changed.${drift.restored.some((label) => label.startsWith("privileges on")) ? " For the privileges, what supabase/schemas grants was granted again; privileges beyond that were not taken away." : ""}`);
|
|
8070
8315
|
try {
|
|
8071
8316
|
await session.exec(drift.restore.join(";\n") + ";");
|
|
8072
8317
|
} catch (err) {
|
|
8073
8318
|
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
8319
|
}
|
|
8075
8320
|
}
|
|
8076
|
-
const
|
|
8077
|
-
const unlisted =
|
|
8321
|
+
const listed = new Set(ledger ?? []);
|
|
8322
|
+
const unlisted = appliedFiles.map(versionOf).filter((version) => !listed.has(version));
|
|
8078
8323
|
if (unlisted.length > 0) {
|
|
8079
8324
|
if (ledger === null) log("The database has no migration history table; recreating it from the local record.");
|
|
8080
8325
|
const repaired = await run(cli.command, [
|
|
@@ -8092,9 +8337,14 @@ async function reconcileLive(product, cli, target, run, options, log, warn, reda
|
|
|
8092
8337
|
return {
|
|
8093
8338
|
restored: drift.restored,
|
|
8094
8339
|
extras: drift.extras,
|
|
8095
|
-
compared: true
|
|
8340
|
+
compared: true,
|
|
8341
|
+
pending
|
|
8096
8342
|
};
|
|
8097
8343
|
} finally {
|
|
8344
|
+
for (const file of pending) {
|
|
8345
|
+
const from = join(aside, file);
|
|
8346
|
+
if (existsSync(from)) renameSync(from, join(product.paths.migrationsDir, file));
|
|
8347
|
+
}
|
|
8098
8348
|
await session.close().catch(() => void 0);
|
|
8099
8349
|
}
|
|
8100
8350
|
}
|
|
@@ -8119,17 +8369,23 @@ async function dbSync(product, cli, target, run, options) {
|
|
|
8119
8369
|
...cli.prefix,
|
|
8120
8370
|
"migration",
|
|
8121
8371
|
"fetch",
|
|
8122
|
-
"--db-url",
|
|
8123
|
-
target.dbUrl
|
|
8372
|
+
...target.kind === "local" ? ["--local"] : ["--db-url", target.dbUrl]
|
|
8124
8373
|
];
|
|
8125
|
-
const
|
|
8374
|
+
const key = targetKey(target);
|
|
8375
|
+
const owner = readRecord(product)?.target;
|
|
8376
|
+
const foreign = owner !== void 0 && owner !== key && migrationFiles(migrationsDir).length > 0;
|
|
8377
|
+
let needsRecord = migrationFiles(migrationsDir).length === 0 && (target.kind === "hosted" || owner !== void 0 && owner !== key) || foreign;
|
|
8126
8378
|
if (options.dryRun) {
|
|
8127
|
-
if (needsRecord) log(`Would run: ${cliLine(cli, [
|
|
8379
|
+
if (needsRecord) log(`Would run: ${cliLine(cli, target.kind === "local" ? [
|
|
8380
|
+
"migration",
|
|
8381
|
+
"fetch",
|
|
8382
|
+
"--local"
|
|
8383
|
+
] : [
|
|
8128
8384
|
"migration",
|
|
8129
8385
|
"fetch",
|
|
8130
8386
|
"--db-url",
|
|
8131
8387
|
"<DB_URL>"
|
|
8132
|
-
])} (this machine has no record of what the project has had applied)`);
|
|
8388
|
+
])} (this machine has no record of what ${target.kind === "local" ? "the local stack" : "the project"} has had applied)`);
|
|
8133
8389
|
if (options.open) log(`Would run: ${cliLine(cli, liveDiffArgs(product, target).map((arg) => arg === target.dbUrl ? "<DB_URL>" : arg))} (and recreate anything the database is missing; extras are only listed)`);
|
|
8134
8390
|
log(`Would run: ${cliLine(cli, generateArgs.slice(cli.prefix.length))}`);
|
|
8135
8391
|
log(target.kind === "local" ? `Would run: ${cliLine(cli, [
|
|
@@ -8150,12 +8406,22 @@ async function dbSync(product, cli, target, run, options) {
|
|
|
8150
8406
|
...NOTHING
|
|
8151
8407
|
};
|
|
8152
8408
|
}
|
|
8409
|
+
restorePending(product);
|
|
8410
|
+
if (!needsRecord && target.kind === "local" && options.open && migrationFiles(migrationsDir).length === 0) needsRecord = (await probe(options.open, target, redact) ?? []).length > 0;
|
|
8411
|
+
if (foreign) {
|
|
8412
|
+
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
|
+
forgetMigrations(product);
|
|
8414
|
+
}
|
|
8153
8415
|
if (needsRecord) {
|
|
8154
8416
|
const fetched = await run(cli.command, fetchArgs, { cwd: product.root });
|
|
8417
|
+
if (fetched.code !== 0 && options.open) await probe(options.open, target, redact);
|
|
8155
8418
|
const restoredFiles = migrationFiles(migrationsDir).length;
|
|
8156
|
-
if (fetched.code === 0 && restoredFiles > 0)
|
|
8419
|
+
if (fetched.code === 0 && restoredFiles > 0) {
|
|
8420
|
+
log(`Restored ${restoredFiles} applied migration${restoredFiles === 1 ? "" : "s"} from ${target.kind === "local" ? "the local stack's" : "the project's"} history.`);
|
|
8421
|
+
writeRecord(product, target, migrationFiles(migrationsDir));
|
|
8422
|
+
}
|
|
8157
8423
|
}
|
|
8158
|
-
const live = await reconcileLive(product, cli, target, run, options, log, warn, redact);
|
|
8424
|
+
const { pending, ...live } = await reconcileLive(product, cli, target, run, options, log, warn, redact);
|
|
8159
8425
|
const restoredSomething = live.restored.length > 0;
|
|
8160
8426
|
const before = new Set(migrationFiles(migrationsDir));
|
|
8161
8427
|
let generated = await run(cli.command, generateArgs, { cwd: product.root });
|
|
@@ -8165,11 +8431,13 @@ async function dbSync(product, cli, target, run, options) {
|
|
|
8165
8431
|
}
|
|
8166
8432
|
if (generated.code !== 0) throw new SyncError(isContainerFailure(generated) ? "Could not diff the schema: Docker is needed for the shadow database. Start Docker and try again." : "Could not diff the schema against what this database has had applied.", redact(generated.stderr || generated.stdout));
|
|
8167
8433
|
const created = migrationFiles(migrationsDir).filter((f) => !before.has(f));
|
|
8168
|
-
|
|
8434
|
+
const toApply = [...pending, ...created].sort();
|
|
8435
|
+
if (toApply.length === 0) {
|
|
8169
8436
|
if (live.compared && !restoredSomething) {
|
|
8170
8437
|
const n = live.extras.length;
|
|
8171
8438
|
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
8439
|
}
|
|
8440
|
+
writeRecord(product, target, migrationFiles(migrationsDir));
|
|
8173
8441
|
return {
|
|
8174
8442
|
migration: null,
|
|
8175
8443
|
applied: restoredSomething,
|
|
@@ -8177,8 +8445,8 @@ async function dbSync(product, cli, target, run, options) {
|
|
|
8177
8445
|
...live
|
|
8178
8446
|
};
|
|
8179
8447
|
}
|
|
8180
|
-
const migration =
|
|
8181
|
-
const sql = readFileSync(join(migrationsDir,
|
|
8448
|
+
const migration = toApply[toApply.length - 1];
|
|
8449
|
+
const sql = toApply.map((file) => readFileSync(join(migrationsDir, file), "utf8")).join("\n");
|
|
8182
8450
|
const destructive = destructiveStatements(sql);
|
|
8183
8451
|
if (destructive.length > 0 && !options.yes) {
|
|
8184
8452
|
if (!(options.confirmDestructive ? await options.confirmDestructive(sql, destructive) : false)) {
|
|
@@ -8186,7 +8454,7 @@ async function dbSync(product, cli, target, run, options) {
|
|
|
8186
8454
|
throw new SyncError(`The change drops data in your development database (${destructive.length} statement${destructive.length === 1 ? "" : "s"}). Re-run with --yes to allow it.`, destructive.join(";\n"));
|
|
8187
8455
|
}
|
|
8188
8456
|
}
|
|
8189
|
-
log(`Applying ${
|
|
8457
|
+
log(`Applying ${toApply.join(", ")}`);
|
|
8190
8458
|
const applyArgs = target.kind === "local" ? [
|
|
8191
8459
|
...cli.prefix,
|
|
8192
8460
|
"migration",
|
|
@@ -8205,6 +8473,7 @@ async function dbSync(product, cli, target, run, options) {
|
|
|
8205
8473
|
for (const file of created) rmSync(join(migrationsDir, file));
|
|
8206
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));
|
|
8207
8475
|
}
|
|
8476
|
+
writeRecord(product, target, migrationFiles(migrationsDir));
|
|
8208
8477
|
return {
|
|
8209
8478
|
migration,
|
|
8210
8479
|
applied: true,
|
|
@@ -8219,6 +8488,11 @@ function isContainerFailure(result) {
|
|
|
8219
8488
|
function forgetMigrations(product) {
|
|
8220
8489
|
const files = migrationFiles(product.paths.migrationsDir);
|
|
8221
8490
|
for (const file of files) rmSync(join(product.paths.migrationsDir, file));
|
|
8491
|
+
rmSync(recordFile(product), { force: true });
|
|
8492
|
+
rmSync(pendingDir(product), {
|
|
8493
|
+
recursive: true,
|
|
8494
|
+
force: true
|
|
8495
|
+
});
|
|
8222
8496
|
return files;
|
|
8223
8497
|
}
|
|
8224
8498
|
//#endregion
|
package/dist/launch.js
CHANGED
|
@@ -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-DfHsw0fp.js");
|
|
187
187
|
try {
|
|
188
188
|
await main(argv);
|
|
189
189
|
} catch (err) {
|
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.552",
|
|
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.",
|
|
@@ -46,9 +46,9 @@
|
|
|
46
46
|
"eslint": "^9.39.5",
|
|
47
47
|
"tsdown": "^0.23.0",
|
|
48
48
|
"vitest": "^4.1.11",
|
|
49
|
+
"@devdogsuga/config": "0.1.4-next.526",
|
|
49
50
|
"@devdogsuga/cli-core": "0.0.0",
|
|
50
|
-
"@devdogsuga/sdk": "0.1.0"
|
|
51
|
-
"@devdogsuga/config": "0.1.4-next.526"
|
|
51
|
+
"@devdogsuga/sdk": "0.1.0"
|
|
52
52
|
},
|
|
53
53
|
"scripts": {
|
|
54
54
|
"build": "tsdown && node scripts/write-build-info.mjs && node ../../scripts/check-bundle-imports.mjs",
|