@zerotal/orm 1.3.0 → 1.5.0

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/CHANGELOG.md CHANGED
@@ -8,6 +8,160 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.5.0] — 2026-08-15
12
+
13
+ ### Fixed
14
+
15
+ - **N+1 detection was running in production.** The gate read `Bun.env.APP_ENV`, which
16
+ by the time a provider boots holds the runtime mode (`web`) rather than the
17
+ deployment name — `setAppEnv()` overwrote it. So the check that exists to help in
18
+ development was wrapping every query on live apps, to warn about something nobody
19
+ was there to read. It now asks `deployEnv()`, which is what the deployment name
20
+ survives in.
21
+
22
+ ### Added
23
+
24
+ - **A missing table now offers to run the migration that would create it.**
25
+ When a query fails because a table or column does not exist, the development
26
+ error page reports which migrations have not run and offers to run them.
27
+ Detection is by driver error code where there is one — `42P01` / `42703` on
28
+ PostgreSQL, `1146` / `1054` on MySQL — and by message on SQLite, which has
29
+ none worth branching on.
30
+
31
+ **The half that matters is when it does _not_ offer the button.** With nothing
32
+ pending, running every migration changes nothing and leaves the developer back
33
+ where they started, so instead it says whether any migration on disk even
34
+ mentions the missing name — if none does, the migration was probably never
35
+ written, which is a different problem with a different fix.
36
+
37
+ The endpoint behind the button carries three guards, each checked on its own
38
+ rather than inferred from the overlay being dev-only: `devSurfacesEnabled()`
39
+ at request time (which **fails closed** — unlike `!isProdLike()`, an unset
40
+ `APP_ENV` does not qualify), a single-use token minted into the page, and the
41
+ same origin check the raw Flow endpoints use, since a raw route sits outside
42
+ CSRF middleware. Outside development the route is never registered at all.
43
+
44
+ - **`migrate:refresh`** — the same command as `migrate:fresh`, under the name it has
45
+ elsewhere. Nothing otherwise pushes anyone to run their `down()` methods, and a
46
+ rollback nobody has exercised is a rollback that does not work.
47
+
48
+ - **`--seed` on `migrate` and `migrate:fresh`.** Wiping a database and repopulating it is
49
+ one thought, and it took two commands — `bun zt migrate:fresh && bun zt db:seed` — with
50
+ the second easy to forget and nothing to remind you. The flag closes that:
51
+
52
+ ```bash
53
+ bun zt migrate:fresh --seed # rebuild the schema, then seed it
54
+ bun zt migrate --fresh --seed # the same thing
55
+ bun zt migrate --seed # apply pending migrations, then seed
56
+ ```
57
+
58
+ `migrate --seed` seeds even when nothing was pending, because topping up an
59
+ already-current dev database is a normal reason to run it.
60
+
61
+ A seeding failure is reported but does not fail the command. The migrations above have
62
+ already committed by then, and exiting non-zero would suggest the whole operation needs
63
+ repeating when only the seeders do — so the output says the schema was rebuilt and
64
+ points at `bun zt db:seed` for the retry.
65
+
66
+ The seeder-loading logic is now shared with `db:seed` rather than duplicated, so all
67
+ three commands accept the same shapes: a class-based `DatabaseSeeder` (named or default
68
+ export) and the legacy `database/seeders/index.ts` default function.
69
+
70
+ ### Changed
71
+
72
+ - **The N+1 detector reads the bindings, not just the SQL text.** Grouping by SQL alone
73
+ made a legitimate loop over six months — identical SQL, a different `period` each
74
+ time — indistinguishable from a per-row lookup, so it told you to eager-load a
75
+ relation that does not exist. The warning now says which of the two it found: _same
76
+ SQL, different arguments_ points at eager loading or `whereIn`; _same SQL, same
77
+ arguments_ points at `RequestContext.remember()`, because there is nothing to
78
+ eager-load when the answer never changes. `NPlusOneError.distinctArgs` carries the
79
+ count.
80
+
81
+ ### Fixed
82
+
83
+ - **A `Date` in a query-builder write is no longer silently discarded.**
84
+ `update({ read_at: new Date() })` bound the `Date` object straight through; SQLite
85
+ dropped it and **reported no error**, so a "mark all as read" feature shipped as a
86
+ latent no-op whose source read correctly. The asymmetry made it easy to write, too —
87
+ `model.save()` applies casts, so the identical value through a model worked. Dates
88
+ and `Carbon` instances are now serialised at the single point every bind passes
89
+ through, dialect-aware (MySQL DATETIME rejects ISO 8601's `T`/`Z`), which covers
90
+ `update`, `insert`, `where` and every builder at once. The comparison path had
91
+ already learned this lesson separately; now there is one place it lives.
92
+
93
+ - **`foreignId(...).nullable().constrained()` type-checks.** `nullable()` returned
94
+ `ColumnBuilder`, so the chain left `ForeignIdColumnBuilder` and `.constrained()` was
95
+ gone — the form the class's own docblock documents, and the first one anyone reaches
96
+ for, since a nullable foreign key is the commonest kind. The two modifiers now
97
+ preserve the subclass while keeping the `nullability` lock, so
98
+ `.nullable().notNullable()` is still a compile error.
99
+
100
+ - **SQLite refuses an impossible `dropColumn` before applying anything.** SQLite cannot
101
+ drop a column a foreign key still names, and it says so _after_ every earlier
102
+ statement in the same `Schema.table()` block has run — the difference between a
103
+ migration that did nothing and one that has to be unpicked by hand. A `PRAGMA
104
+ foreign_key_list` check now runs first and throws a message naming the column, the
105
+ table it references, and the table-rebuild way out. The rebuild itself is still not
106
+ implemented; this makes its absence safe rather than expensive.
107
+
108
+ - **Altering a Postgres column no longer silently drops its NOT NULL and DEFAULT.**
109
+ The regexes that split a column definition into `ALTER COLUMN` sub-commands
110
+ carried literal backspace characters (0x08) where `\b` word boundaries were
111
+ meant — invisible in any editor, and impossible for either pattern to match. So
112
+ `table.string("email").notNullable().alter()` emitted `DROP NOT NULL`, and a
113
+ declared default emitted `DROP DEFAULT`, on every alter, regardless of the
114
+ definition. Found by the lint ratchet (`no-control-regex`); the statements are
115
+ now pinned by tests, not just the column name.
116
+
117
+ ## [1.4.0] — 2026-08-10
118
+
119
+ ### Added
120
+
121
+ - **Encrypted columns.** A column can now hold ciphertext at rest and plaintext on
122
+ the model, keyed by `APP_KEY` with AES-256-GCM. Declare it per-column or as a
123
+ list; the two mean the same thing and resolve to the same cast:
124
+
125
+ ```ts
126
+ @column("encrypted", { nullable: true }) idNumber?: string;
127
+ @column("encrypted:json") medical?: MedicalInfo;
128
+
129
+ // …the same, spelled out — `encrypted` is a cast, not a storage type:
130
+ @column({ type: "text", nullable: true, cast: "encrypted" }) passportNumber?: string;
131
+
132
+ static encryptable = ["idNumber", "passportNumber"];
133
+ ```
134
+
135
+ `encrypted` and `encrypted:json` join the `@column("…")` shorthands, resolving
136
+ to `{ type: "text", cast: "encrypted" }` — so the storage type is right without
137
+ having to know that ciphertext outgrows its plaintext.
138
+
139
+ Encryption happens on the way to the database rather than to the instance, so —
140
+ unlike `hashable` — it is non-destructive: after `save()` the property still
141
+ holds what you assigned. `$dirty` compares plaintext, so an unchanged column is
142
+ not rewritten with a fresh IV on every unrelated save.
143
+
144
+ A column listed in `encryptable` whose `@column({ type })` is `json` encrypts as
145
+ `encrypted:json`, so it round-trips as the structure it was instead of reaching
146
+ the cipher as `"[object Object]"`.
147
+
148
+ **`where()` on an encrypted column throws** rather than returning nothing. The
149
+ bind path runs a column's cast over the search value, which would encrypt it
150
+ under a fresh IV and compare it against ciphertext written with a different one:
151
+ zero rows, no error, and a screen reading "no such client" for a client who is
152
+ right there. `EncryptedColumnError` says so and points at a blind index.
153
+
154
+ **A value the key cannot open fails the read**, naming the model, the column and
155
+ the two causes (a rotated `APP_KEY`, or plaintext that predates the cast).
156
+ Returning the ciphertext instead would put an unreadable value where the
157
+ application expects a real one — displayed, reported on, or re-encrypted by the
158
+ next save, which destroys the original.
159
+
160
+ `migrate:generate` and `synchronize()` widen an encrypted column to TEXT
161
+ whatever it was declared as. A payload is ~1.4× the plaintext plus 28 bytes, and
162
+ MySQL outside strict mode truncates rather than failing — a truncated payload
163
+ never decrypts, so the row would be destroyed silently at write time.
164
+
11
165
  ## [1.3.0] — 2026-08-09
12
166
 
13
167
  ### Changed — BREAKING
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/orm",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -30,8 +30,8 @@
30
30
  "typecheck": "tsc --noEmit"
31
31
  },
32
32
  "dependencies": {
33
- "@zerotal/core": "1.3.0",
34
- "@zerotal/validator": "1.3.0"
33
+ "@zerotal/core": "1.5.0",
34
+ "@zerotal/validator": "1.5.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "typescript": "^5.8.0"
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Encrypted columns — ciphertext at rest, plaintext on the model.
3
+ *
4
+ * The column stores an opaque AES-256-GCM payload keyed by `APP_KEY`; the model
5
+ * property holds the value you assigned. Encryption happens on the way to the
6
+ * database and decryption on the way back, so nothing in between — your code,
7
+ * validation, `$dirty` — has to know the column is encrypted.
8
+ *
9
+ * Two ways to declare one, and they compile to the same thing:
10
+ *
11
+ * ```ts
12
+ * class Client extends BaseModel {
13
+ * @column({ type: "text", nullable: true, cast: "encrypted" })
14
+ * idNumber?: string;
15
+ *
16
+ * // …or, for several columns at once:
17
+ * static encryptable = ["idNumber", "passportNumber"];
18
+ * }
19
+ * ```
20
+ *
21
+ * **The column must be `text`, not `string`.** A payload is roughly a third
22
+ * larger than its plaintext plus 28 bytes of IV and auth tag, so a 13-character
23
+ * ID number lands around 60 characters and a paragraph overflows a `VARCHAR(255)`
24
+ * that comfortably held it before.
25
+ *
26
+ * **You cannot query an encrypted column.** Every write draws a fresh IV, so the
27
+ * same value encrypts to different ciphertext each time and an equality match can
28
+ * never hit. `where()` on one throws rather than returning zero rows — see
29
+ * {@link EncryptedColumnError}. If you need lookup, keep a separate hashed column
30
+ * (a blind index) beside it and query that.
31
+ *
32
+ * **Decryption failure is fatal to the read**, deliberately. Returning the
33
+ * ciphertext instead would put an unreadable value where the application expects
34
+ * a real one — displayed to a user, written into a report, or re-encrypted on the
35
+ * next save, which destroys the original for good.
36
+ *
37
+ * @packageDocumentation
38
+ */
39
+
40
+ import { ZerotalError } from "@zerotal/core";
41
+ import { Crypt } from "@zerotal/core/security";
42
+
43
+ /** The cast names that mean "encrypt this column". */
44
+ export type EncryptedCastName = "encrypted" | "encrypted:json";
45
+
46
+ /** Raised for anything an encrypted column cannot do. */
47
+ export class EncryptedColumnError extends ZerotalError {
48
+ constructor(message: string, code: string, context?: Record<string, unknown>) {
49
+ super(message, code, 500, context);
50
+ }
51
+ }
52
+
53
+ /** Whether a resolved cast option is one of the encrypted ones. */
54
+ export function isEncryptedCast(cast: unknown): cast is EncryptedCastName {
55
+ return cast === "encrypted" || cast === "encrypted:json";
56
+ }
57
+
58
+ /**
59
+ * Model value → the ciphertext written to the column.
60
+ *
61
+ * @param label - `Model.column`, or just the column name, for error messages.
62
+ */
63
+ export function encryptColumn(value: unknown, cast: EncryptedCastName, label: string): unknown {
64
+ if (value === null || value === undefined) return value;
65
+
66
+ if (cast === "encrypted:json") return Crypt.encryptString(JSON.stringify(value));
67
+
68
+ if (typeof value !== "string") {
69
+ // Not coerced with String(). `42` would store as "42" and read back as the
70
+ // string "42" — the value's type silently changing between write and read,
71
+ // which is worse than refusing it, because nothing fails until something
72
+ // downstream compares it.
73
+ throw new EncryptedColumnError(
74
+ `[Zerotal] ${label} is cast "encrypted", which stores strings, but a ` +
75
+ `${Array.isArray(value) ? "array" : typeof value} was assigned. Use ` +
76
+ `cast: "encrypted:json" to encrypt a structured value — it round-trips the type.`,
77
+ "E_ENCRYPTED_COLUMN_NOT_A_STRING",
78
+ { column: label, received: typeof value },
79
+ );
80
+ }
81
+
82
+ return Crypt.encryptString(value);
83
+ }
84
+
85
+ /**
86
+ * Stored ciphertext → the value the model exposes.
87
+ *
88
+ * @param label - `Model.column`, for error messages.
89
+ * @throws {@link EncryptedColumnError} When the stored value is not ciphertext
90
+ * this `APP_KEY` can open.
91
+ */
92
+ export function decryptColumn(value: unknown, cast: EncryptedCastName, label: string): unknown {
93
+ if (value === null || value === undefined) return value;
94
+
95
+ let plain: string;
96
+ try {
97
+ plain = Crypt.decryptString(String(value));
98
+ } catch (cause) {
99
+ throw new EncryptedColumnError(
100
+ `[Zerotal] Could not decrypt ${label}. The column is cast "${cast}", so what is ` +
101
+ `stored has to be ciphertext this APP_KEY can open. Two things cause this: ` +
102
+ `APP_KEY changed since the row was written (decrypt with the old key and ` +
103
+ `re-save), or the column already held plaintext when the cast was added ` +
104
+ `(back-fill the existing rows before switching it on). The row cannot be read ` +
105
+ `until one of those is resolved.`,
106
+ "E_ENCRYPTED_COLUMN_UNREADABLE",
107
+ { column: label, cause: cause instanceof Error ? cause.message : String(cause) },
108
+ );
109
+ }
110
+
111
+ if (cast !== "encrypted:json") return plain;
112
+ try {
113
+ return JSON.parse(plain);
114
+ } catch {
115
+ // Decrypted cleanly, so the key is right and the bytes are intact — the
116
+ // column simply was not written as JSON. Says so, rather than reporting a
117
+ // key problem it does not have.
118
+ throw new EncryptedColumnError(
119
+ `[Zerotal] ${label} decrypted, but its contents are not JSON. The column is cast ` +
120
+ `"encrypted:json"; a column written under plain "encrypted" reads back with that.`,
121
+ "E_ENCRYPTED_COLUMN_NOT_JSON",
122
+ { column: label },
123
+ );
124
+ }
125
+ }
126
+
127
+ /** The error thrown when someone tries to filter on an encrypted column. */
128
+ export function encryptedQueryError(label: string): EncryptedColumnError {
129
+ return new EncryptedColumnError(
130
+ `[Zerotal] Cannot filter on ${label} — it is an encrypted column. Every write draws ` +
131
+ `a fresh IV, so the same value encrypts to different ciphertext each time and an ` +
132
+ `equality match can never hit. Keep a separate hashed lookup column (a blind index) ` +
133
+ `beside it and query that instead.`,
134
+ "E_ENCRYPTED_COLUMN_NOT_QUERYABLE",
135
+ { column: label },
136
+ );
137
+ }
138
+
139
+ /**
140
+ * Resolve `static encryptable = [...]` down a prototype chain into cast entries.
141
+ *
142
+ * Declaring it is exactly equivalent to putting `cast: "encrypted"` on each of
143
+ * those columns, which is why it resolves to casts here rather than being handled
144
+ * separately: read, write, `$dirty` and the query guard then all see one thing.
145
+ *
146
+ * A `json` column resolves to `encrypted:json` on its own — the alternative is
147
+ * `String(someObject)` reaching the cipher as `"[object Object]"`.
148
+ *
149
+ * Entries union down the chain, so a base model marking a column encrypted keeps
150
+ * it encrypted in a subclass that lists its own.
151
+ *
152
+ * @param chain - The constructor chain, base-most first.
153
+ * @param columnType - Declared `@column({ type })` for a property, if any.
154
+ */
155
+ export function collectEncryptable(
156
+ chain: readonly object[],
157
+ columnType: (key: string) => string | undefined,
158
+ ): Record<string, EncryptedCastName> {
159
+ const out: Record<string, EncryptedCastName> = {};
160
+ for (const entry of chain) {
161
+ const keys = (entry as { encryptable?: string[] }).encryptable;
162
+ if (!keys) continue;
163
+ for (const key of keys) {
164
+ out[key] = columnType(key) === "json" ? "encrypted:json" : "encrypted";
165
+ }
166
+ }
167
+ return out;
168
+ }
@@ -1,5 +1,5 @@
1
1
  import { Command } from "@zerotal/core";
2
- import type { Seeder } from "../seeding/Seeder.ts";
2
+ import { runSeeders } from "./_runSeeders.ts";
3
3
 
4
4
  /**
5
5
  * Runs the application's database seeders (`bun zt db:seed`).
@@ -21,51 +21,23 @@ export class DbSeedCommand extends Command {
21
21
  static needsApp = true;
22
22
 
23
23
  async run(): Promise<void> {
24
- const cwd = process.cwd();
25
- const seederPath = `${cwd}/database/seeders/DatabaseSeeder.ts`;
24
+ this.section("Database Seeding");
25
+ const outcome = await runSeeders();
26
26
 
27
- const file = Bun.file(seederPath);
28
- if (!(await file.exists())) {
29
- // Fall back to legacy index.ts seeder format
30
- const legacyPath = `${cwd}/database/seeders/index.ts`;
31
- if (await Bun.file(legacyPath).exists()) {
32
- try {
33
- const mod = await import(legacyPath);
34
- const seed = mod.default as (() => Promise<void>) | undefined;
35
- if (!seed) {
36
- this.error("Seeder index must export a default async function.");
37
- return;
38
- }
39
- await seed();
40
- this.info("Database seeded.");
41
- } catch (err) {
42
- const msg = err instanceof Error ? err.message : String(err);
43
- this.error(`Failed to run seeders: ${msg}`);
44
- }
27
+ switch (outcome.status) {
28
+ case "seeded":
29
+ this.info("Database seeded successfully.");
45
30
  return;
46
- }
47
-
48
- this.error(`Seeder not found: ${seederPath}`);
49
- this.dim("Create it with: bun zerotal.ts make:seeder DatabaseSeeder");
50
- return;
51
- }
52
-
53
- try {
54
- const mod = await import(seederPath);
55
- const SeederClass = (mod.DatabaseSeeder ?? mod.default) as (new () => Seeder) | undefined;
56
-
57
- if (!SeederClass) {
58
- this.error("DatabaseSeeder not found as a named or default export.");
31
+ case "missing":
32
+ this.error(`Seeder not found: ${outcome.path}`);
33
+ this.dim("Create it with: bun zerotal.ts make:seeder DatabaseSeeder");
34
+ return;
35
+ case "invalid":
36
+ this.error(outcome.message);
37
+ return;
38
+ case "failed":
39
+ this.error(`Failed to run seeders: ${outcome.message}`);
59
40
  return;
60
- }
61
-
62
- this.section("Database Seeding");
63
- const seeder = new SeederClass();
64
- await seeder.run();
65
- this.info("Database seeded successfully.");
66
- } catch (err) {
67
- const msg = err instanceof Error ? err.message : String(err);
68
- this.error(`Failed to run seeders: ${msg}`);
69
41
  }
70
42
  }
71
43
  }
@@ -1,20 +1,22 @@
1
- import { Command } from "@zerotal/core";
1
+ import { Command, type FlagDef } from "@zerotal/core";
2
2
  import type { MigrationEntry } from "../schema/MigrationRunner.ts";
3
3
  import { MigrationRunner } from "../schema/MigrationRunner.ts";
4
4
  import { _getConnection } from "../db/DB.ts";
5
5
  import { loadMigrations } from "./_loadMigrations.ts";
6
+ import { runSeeders } from "./_runSeeders.ts";
6
7
 
7
8
  /**
8
9
  * Runs all pending database migrations (`bun zt migrate`).
9
10
  *
10
11
  * Loads every migration under `database/migrations/`, then applies those not
11
12
  * yet run. Passing `--fresh` first drops all tables and re-runs every
12
- * migration from scratch.
13
+ * migration from scratch; `--seed` runs the seeders afterwards.
13
14
  *
14
15
  * @example
15
16
  * ```bash
16
17
  * bun zt migrate
17
18
  * bun zt migrate --fresh
19
+ * bun zt migrate --fresh --seed
18
20
  * ```
19
21
  *
20
22
  * @category Migrations
@@ -24,13 +26,19 @@ export class MigrateCommand extends Command {
24
26
  static aliases = ["db:migrate"];
25
27
  static description = "Run all pending database migrations";
26
28
  static needsApp = true;
27
- static flags = [
29
+ static flags: FlagDef[] = [
28
30
  {
29
31
  name: "fresh",
30
- type: "boolean" as const,
32
+ type: "boolean",
31
33
  description: "Drop all tables and re-run all migrations",
32
34
  default: false,
33
35
  },
36
+ {
37
+ name: "seed",
38
+ type: "boolean",
39
+ description: "Run database seeders once migrations have run",
40
+ default: false,
41
+ },
34
42
  ];
35
43
 
36
44
  async run(): Promise<void> {
@@ -51,10 +59,41 @@ export class MigrateCommand extends Command {
51
59
 
52
60
  if (ran.length === 0) {
53
61
  this.info("Nothing to migrate.");
54
- return;
62
+ } else {
63
+ this.info(`Migrated ${ran.length} migration(s).`);
64
+ this.table(ran.map((name) => [name, "ran"]));
55
65
  }
56
66
 
57
- this.info(`Migrated ${ran.length} migration(s).`);
58
- this.table(ran.map((name) => [name, "ran"]));
67
+ // Seeding runs even when nothing migrated: `migrate --seed` against an
68
+ // already-current schema is a normal way to top up a dev database.
69
+ if (this.flags["seed"] as boolean) await this.#seed();
70
+ }
71
+
72
+ /**
73
+ * Seed after migrating.
74
+ *
75
+ * Reported, not thrown: the migrations above already committed, and failing
76
+ * the command here would suggest they need repeating when only the seeders do.
77
+ */
78
+ async #seed(): Promise<void> {
79
+ this.section("Database Seeding");
80
+ const outcome = await runSeeders();
81
+
82
+ switch (outcome.status) {
83
+ case "seeded":
84
+ this.info("Database seeded successfully.");
85
+ return;
86
+ case "missing":
87
+ this.error(`Seeder not found: ${outcome.path}`);
88
+ this.dim("Create it with: bun zerotal.ts make:seeder DatabaseSeeder");
89
+ return;
90
+ case "invalid":
91
+ this.error(outcome.message);
92
+ return;
93
+ case "failed":
94
+ this.error(`Failed to run seeders: ${outcome.message}`);
95
+ this.dim("Migrations already ran — re-run `bun zt db:seed` once the seeder is fixed.");
96
+ return;
97
+ }
59
98
  }
60
99
  }
@@ -1,18 +1,21 @@
1
- import { Command } from "@zerotal/core";
1
+ import { Command, type FlagDef } from "@zerotal/core";
2
2
  import type { MigrationEntry } from "../schema/MigrationRunner.ts";
3
3
  import { MigrationRunner } from "../schema/MigrationRunner.ts";
4
4
  import { _getConnection } from "../db/DB.ts";
5
5
  import { loadMigrations } from "./_loadMigrations.ts";
6
+ import { runSeeders } from "./_runSeeders.ts";
6
7
 
7
8
  /**
8
9
  * Rolls back every migration, then re-runs them from scratch (`bun zt migrate:fresh`).
9
10
  *
10
11
  * Resets the database by reversing all applied migrations and then re-applying
11
- * the full set, giving a clean, fully-migrated schema in one step.
12
+ * the full set, giving a clean, fully-migrated schema in one step. Pass
13
+ * `--seed` to repopulate it afterwards, which is the usual reason for wiping it.
12
14
  *
13
15
  * @example
14
16
  * ```bash
15
17
  * bun zt migrate:fresh
18
+ * bun zt migrate:fresh --seed
16
19
  * ```
17
20
  *
18
21
  * @category Migrations
@@ -22,6 +25,15 @@ export class MigrateFreshCommand extends Command {
22
25
  static description = "Roll back every migration, then re-run them from scratch";
23
26
  static needsApp = true;
24
27
 
28
+ static flags: FlagDef[] = [
29
+ {
30
+ name: "seed",
31
+ type: "boolean",
32
+ description: "Run database seeders once the schema has been rebuilt",
33
+ default: false,
34
+ },
35
+ ];
36
+
25
37
  async run(): Promise<void> {
26
38
  const records = await loadMigrations();
27
39
  const entries: MigrationEntry[] = records.map((r) => ({
@@ -37,5 +49,36 @@ export class MigrateFreshCommand extends Command {
37
49
  if (ran.length > 0) {
38
50
  this.table(ran.map((name) => [name, "migrated"]));
39
51
  }
52
+
53
+ if (this.flags["seed"] as boolean) await this.#seed();
54
+ }
55
+
56
+ /**
57
+ * Seed the freshly-migrated schema.
58
+ *
59
+ * A seeding failure is reported but does not fail the command: the migrations
60
+ * above already ran and committed, and exiting non-zero here would suggest the
61
+ * whole operation needs repeating when only the seeders do.
62
+ */
63
+ async #seed(): Promise<void> {
64
+ this.section("Database Seeding");
65
+ const outcome = await runSeeders();
66
+
67
+ switch (outcome.status) {
68
+ case "seeded":
69
+ this.info("Database seeded successfully.");
70
+ return;
71
+ case "missing":
72
+ this.error(`Seeder not found: ${outcome.path}`);
73
+ this.dim("Create it with: bun zerotal.ts make:seeder DatabaseSeeder");
74
+ return;
75
+ case "invalid":
76
+ this.error(outcome.message);
77
+ return;
78
+ case "failed":
79
+ this.error(`Failed to run seeders: ${outcome.message}`);
80
+ this.dim("The schema was rebuilt — re-run `bun zt db:seed` once the seeder is fixed.");
81
+ return;
82
+ }
40
83
  }
41
84
  }
@@ -0,0 +1,28 @@
1
+ import { MigrateFreshCommand } from "./MigrateFreshCommand.ts";
2
+
3
+ /**
4
+ * `bun zt migrate:refresh` — a second name for `migrate:fresh`, pointing at the
5
+ * same command.
6
+ *
7
+ * `migrate:fresh` already runs every migration's `down()` and then every `up()`
8
+ * again, which is what "refresh" means to most people arriving here; elsewhere
9
+ * the two names are split, and "fresh" is the one that drops the tables outright
10
+ * without touching `down()`. So the behaviour was always there — only the name
11
+ * people reach for was missing, and reaching for a command that does not exist
12
+ * is how a broken `down()` stays unexercised until the day it matters.
13
+ *
14
+ * A subclass rather than a second implementation: one code path, two names.
15
+ *
16
+ * @example
17
+ * ```bash
18
+ * bun zt migrate:refresh
19
+ * bun zt migrate:refresh --seed
20
+ * ```
21
+ *
22
+ * @category Migrations
23
+ */
24
+ export class MigrateRefreshCommand extends MigrateFreshCommand {
25
+ static override commandName = "migrate:refresh";
26
+ static override description =
27
+ "Roll every migration back through down(), then re-run them (alias of migrate:fresh)";
28
+ }