@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 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 from pg_proc p
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) found.add(`function:${r.s}.${r.n}`);
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
- 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
- }
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
- if (replace && existing.has(key)) conflict(label, "its definition differs from supabase/schemas");
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 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");
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|column|constraint|rls)/.test(key)).map((key) => key.slice(key.indexOf(":") + 1)));
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
- 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);
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 || grants > 0) restore.push(...preamble, ...restoring.map((c) => c.sql), ...tail);
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)) 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({
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 NOTHING;
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 known = new Set(ledger ?? []);
8077
- const unlisted = files.map(versionOf).filter((version) => !known.has(version));
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 needsRecord = target.kind === "hosted" && migrationFiles(migrationsDir).length === 0;
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) log(`Restored ${restoredFiles} applied migration${restoredFiles === 1 ? "" : "s"} from the project's history.`);
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
- if (created.length === 0) {
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 = created[created.length - 1];
8181
- const sql = readFileSync(join(migrationsDir, migration), "utf8");
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 ${migration}`);
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-xAkuiAkl.js");
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.551",
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",