@devdogsuga/devtools 0.1.51-next.547 → 0.1.51-next.548

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.
@@ -7236,9 +7236,18 @@ var DevDataError = class extends Error {
7236
7236
  this.name = "DevDataError";
7237
7237
  }
7238
7238
  };
7239
- /** A Postgres SQLSTATE: five characters, digits and capitals. Connection errors (`ECONNREFUSED`) are not. */
7240
- function isSqlState(code) {
7241
- return typeof code === "string" && /^[0-9A-Z]{5}$/.test(code);
7239
+ /**
7240
+ * Whether `err` is the product's own SQL being wrong: a Postgres error whose
7241
+ * SQLSTATE class is a cardinality violation (21), data exception (22),
7242
+ * integrity constraint violation (23) or syntax error or access rule
7243
+ * violation (42). Everything else is not the file's fault and reports as a
7244
+ * normal failure: a refused or dropped connection (class 08 and
7245
+ * `ECONNREFUSED`), an operator shutdown (57, `admin_shutdown`), a server out
7246
+ * of resources (53), a transaction that was rolled back (40), and so on.
7247
+ */
7248
+ function isSqlFileError(err) {
7249
+ const code = err?.code;
7250
+ return typeof code === "string" && /^(21|22|23|42)[0-9A-Z]{3}$/.test(code);
7242
7251
  }
7243
7252
  /**
7244
7253
  * The line of `text` a Postgres error points at, from the error's 1-based
@@ -7294,6 +7303,11 @@ function postgresSession(dbUrl) {
7294
7303
  const present = new Set(rows.map((r) => r.nspname));
7295
7304
  return schemas.filter((name) => !present.has(name));
7296
7305
  },
7306
+ async exec(text) {
7307
+ await sql.begin(async (tx) => {
7308
+ await tx.unsafe(text);
7309
+ });
7310
+ },
7297
7311
  async runAs(memberId, text) {
7298
7312
  await sql.begin(async (tx) => {
7299
7313
  await tx`select set_config('devtools.member_id', ${memberId}, true)`;
@@ -7335,7 +7349,7 @@ async function seed(session, file, text, options, log) {
7335
7349
  try {
7336
7350
  await session.runAs(member.id, text);
7337
7351
  } catch (err) {
7338
- if (!isSqlState(err?.code)) throw err;
7352
+ if (!isSqlFileError(err)) throw err;
7339
7353
  const line = errorLine(err, text);
7340
7354
  throw new DevDataError(`${file}${line === null ? "" : `:${line}`}: ${err.message}. This is a problem in the product's dev-data SQL, not in devtools; nothing was seeded (the file runs in one transaction).`);
7341
7355
  }
@@ -7572,6 +7586,15 @@ function typesAssetName(product, hash) {
7572
7586
  * Without that, the diff would start from an empty database and write the whole
7573
7587
  * schema as one migration, which `db push` rejects as already applied.
7574
7588
  *
7589
+ * The record only says what *should* be in the database. Before the diff,
7590
+ * sync therefore compares the database itself with the record (`supabase db
7591
+ * diff --from <database> --to migrations`, restricted to the product's
7592
+ * schemas) and re-applies whatever is missing: a dropped table, a dropped
7593
+ * schema, a database reset behind the record's back. The migration ledger is
7594
+ * never trusted to decide anything; it is rewritten to list the record when it
7595
+ * is missing or short. "The database already matches" is therefore a
7596
+ * statement about the live database.
7597
+ *
7575
7598
  * It never resets the database. A reset wipes `auth.custom_oauth_providers`,
7576
7599
  * which is where "Sign in with DevDogs" is registered (Q83); `--rebuild` is
7577
7600
  * the explicit escape hatch for a database that has drifted from its record,
@@ -7623,6 +7646,7 @@ async function dbSync(product, cli, target, run, options) {
7623
7646
  "--db-url",
7624
7647
  "<DB_URL>"
7625
7648
  ])} (this machine has no record of what the project has had applied)`);
7649
+ if (options.open) log(`Would run: ${cliLine(cli, liveDiffArgs(product, target).slice(cli.prefix.length))} (and restore anything the database lost)`);
7626
7650
  log(`Would run: ${cliLine(cli, generateArgs.slice(cli.prefix.length))}`);
7627
7651
  log(target.kind === "local" ? `Would run: ${cliLine(cli, [
7628
7652
  "migration",
@@ -7646,7 +7670,7 @@ async function dbSync(product, cli, target, run, options) {
7646
7670
  const restored = migrationFiles(migrationsDir).length;
7647
7671
  if (fetched.code === 0 && restored > 0) log(`Restored ${restored} applied migration${restored === 1 ? "" : "s"} from the project's history.`);
7648
7672
  }
7649
- await reconcileRecord(product, cli, target, run, options, log);
7673
+ const restored = await restoreDatabase(product, cli, target, run, options, log);
7650
7674
  const before = new Set(migrationFiles(migrationsDir));
7651
7675
  let generated = await run(cli.command, generateArgs, { cwd: product.root });
7652
7676
  if (generated.code !== 0 && isContainerFailure(generated)) {
@@ -7656,11 +7680,11 @@ async function dbSync(product, cli, target, run, options) {
7656
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);
7657
7681
  const created = migrationFiles(migrationsDir).filter((f) => !before.has(f));
7658
7682
  if (created.length === 0) {
7659
- log("The database already matches supabase/schemas.");
7683
+ if (!restored.applied) log("The database already matches supabase/schemas.");
7660
7684
  return {
7661
7685
  migration: null,
7662
- applied: false,
7663
- destructive: []
7686
+ applied: restored.applied,
7687
+ destructive: restored.destructive
7664
7688
  };
7665
7689
  }
7666
7690
  const migration = created[created.length - 1];
@@ -7694,71 +7718,136 @@ async function dbSync(product, cli, target, run, options) {
7694
7718
  return {
7695
7719
  migration,
7696
7720
  applied: true,
7697
- destructive
7721
+ destructive: [...restored.destructive, ...destructive]
7698
7722
  };
7699
7723
  }
7700
7724
  /** `20261011120000` from `20261011120000_devtools_sync.sql`. */
7701
7725
  function versionOf(file) {
7702
7726
  return file.split("_")[0] ?? file;
7703
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
+ }
7704
7743
  /**
7705
- * Makes the local record agree with the database. The declarative diff only
7706
- * compares `supabase/schemas` with `supabase/migrations`, so a database that
7707
- * lost its contents behind the record's back (a hosted project reset from the
7708
- * dashboard, a checkout switched from the local stack to a hosted project)
7709
- * would read as "already matches". This asks the target:
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:
7710
7774
  *
7711
- * - a migration in the record that is not in the database's ledger was never
7712
- * applied there: it is dropped from the record, so the diff re-creates it;
7713
- * - a configured schema that does not exist means the ledger is stale too:
7714
- * its entries are reverted and the whole record dropped, so the diff
7715
- * writes the full schema again.
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.
7716
7781
  *
7717
- * Nothing is reset or dropped in the database; only ledger rows of schemas
7718
- * that are already gone are removed. A database that cannot be reached is
7719
- * left to the apply step to report.
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.
7720
7785
  */
7721
- async function reconcileRecord(product, cli, target, run, options, log) {
7722
- if (!options.open) return;
7723
- const { migrationsDir } = product.paths;
7724
- const files = migrationFiles(migrationsDir);
7725
- let ledger;
7726
- let missing;
7786
+ async function restoreDatabase(product, cli, target, run, options, log) {
7787
+ const nothing = {
7788
+ applied: false,
7789
+ destructive: []
7790
+ };
7791
+ const files = migrationFiles(product.paths.migrationsDir);
7792
+ if (!options.open || files.length === 0) return nothing;
7727
7793
  let session;
7794
+ let missing;
7795
+ let ledger;
7728
7796
  try {
7729
7797
  session = options.open(target.dbUrl);
7730
- ledger = await session.appliedMigrations();
7731
7798
  missing = await session.missingSchemas(product.config.supabase.schemas);
7732
7799
  } catch (err) {
7733
- log(`Could not check what the database has applied (${err.message}); trusting the local record.`);
7734
- return;
7735
- } finally {
7800
+ log(`Could not check what the database has (${err.message}); trusting the local record.`);
7736
7801
  await session?.close().catch(() => void 0);
7802
+ return nothing;
7737
7803
  }
7738
- if (missing.length > 0) {
7739
- const stale = ledger ?? [];
7740
- log(`The database is missing ${missing.length === 1 ? "schema" : "schemas"} ${missing.join(", ")}, so the record of applied migrations is out of date; rebuilding it from supabase/schemas.`);
7741
- if (stale.length > 0) {
7742
- const reverted = await run(cli.command, [
7743
- ...cli.prefix,
7744
- "migration",
7745
- "repair",
7746
- "--status",
7747
- "reverted",
7748
- "--db-url",
7749
- target.dbUrl,
7750
- ...stale
7751
- ], { cwd: product.root });
7752
- if (reverted.code !== 0) throw new SyncError("The database lost its schema but its migration history still lists migrations as applied, and clearing that history failed.", reverted.stderr || reverted.stdout);
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.`);
7753
7810
  }
7754
- for (const file of files) rmSync(join(migrationsDir, file));
7755
- return;
7756
- }
7757
- const applied = new Set(ledger ?? []);
7758
- const unapplied = files.filter((file) => !applied.has(versionOf(file)));
7759
- if (unapplied.length > 0) {
7760
- log(`${unapplied.length} migration${unapplied.length === 1 ? "" : "s"} in the local record ${unapplied.length === 1 ? "is" : "are"} not in this database's history (it was reset, or this checkout switched databases); re-applying ${unapplied.length === 1 ? "it" : "them"}.`);
7761
- for (const file of unapplied) rmSync(join(migrationsDir, file));
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);
7762
7851
  }
7763
7852
  }
7764
7853
  function isContainerFailure(result) {
@@ -8049,7 +8138,7 @@ async function regenerateAfterSync(product, cli, target, result, deps) {
8049
8138
  log.info(`Would regenerate ${product.config.types.out}.`);
8050
8139
  return;
8051
8140
  }
8052
- if (!result.migration && typesAreCurrent(product)) return;
8141
+ if (!result.applied && typesAreCurrent(product)) return;
8053
8142
  try {
8054
8143
  await generateTypes(product, cli, target, deps.run, (line) => log.info(line));
8055
8144
  log.success(`Regenerated ${product.config.types.out}.`);
@@ -8095,7 +8184,7 @@ function createProductHandlers(deps = defaultDeps) {
8095
8184
  switch (sub) {
8096
8185
  case "sync": {
8097
8186
  const { result, cli, target } = await runSync(product, deps, flags);
8098
- if (result.migration) log.success("Your database now matches supabase/schemas.");
8187
+ if (result.applied) log.success("Your database now matches supabase/schemas.");
8099
8188
  await regenerateAfterSync(product, cli, target, result, deps);
8100
8189
  return DONE;
8101
8190
  }
@@ -8328,7 +8417,7 @@ async function runSetup(product, deps, rest) {
8328
8417
  log: (line) => log.info(line),
8329
8418
  open: deps.openSql
8330
8419
  });
8331
- log.success(result.migration ? "Applied supabase/schemas to your database." : "Your database already matched supabase/schemas.");
8420
+ log.success(result.applied ? "Applied supabase/schemas to your database." : "Your database already matched supabase/schemas.");
8332
8421
  if (target.kind === "local") await waitForRest(target, deps, product);
8333
8422
  if (rest.includes("--download-types")) try {
8334
8423
  await downloadTypes(product, {
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-CJtevyVy.js");
186
+ const { main } = await import("./cli-DV6tvG1E.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.547",
3
+ "version": "0.1.51-next.548",
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/sdk": "0.1.0",
51
- "@devdogsuga/config": "0.1.4-next.526"
50
+ "@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",