@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 +154 -0
- package/package.json +3 -3
- package/src/casts/encrypted.ts +168 -0
- package/src/commands/DbSeedCommand.ts +15 -43
- package/src/commands/MigrateCommand.ts +46 -7
- package/src/commands/MigrateFreshCommand.ts +45 -2
- package/src/commands/MigrateRefreshCommand.ts +28 -0
- package/src/commands/_runSeeders.ts +71 -0
- package/src/commands/index.ts +1 -0
- package/src/conventions.ts +2 -1
- package/src/db/NPlusOneDetector.ts +93 -15
- package/src/db/QueryBuilder.ts +34 -3
- package/src/diagnostics/missingRelation.ts +187 -0
- package/src/diagnostics/runMigrationsEndpoint.ts +124 -0
- package/src/index.ts +8 -0
- package/src/model/BaseModel.ts +89 -30
- package/src/model/ModelQueryBuilder.ts +28 -10
- package/src/model/Observer.ts +2 -1
- package/src/model/OrmContext.ts +4 -3
- package/src/model/State.ts +4 -3
- package/src/model/decorators/_metadata.ts +19 -18
- package/src/model/decorators/_registerRelation.ts +2 -1
- package/src/model/decorators/column.ts +20 -3
- package/src/model/decorators/table.ts +3 -2
- package/src/model/hooks/HookRegistry.ts +9 -8
- package/src/model/relations/RelationRegistry.ts +3 -1
- package/src/observability.ts +2 -2
- package/src/provider/DatabaseProvider.ts +32 -3
- package/src/schema/Blueprint.ts +15 -2
- package/src/schema/ColumnDefinition.ts +35 -1
- package/src/schema/ModelInspector.ts +33 -5
- package/src/schema/Schema.ts +62 -2
- package/src/support/classRef.ts +23 -0
- package/src/support/identifiers.ts +5 -4
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
|
+
"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.
|
|
34
|
-
"@zerotal/validator": "1.
|
|
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
|
|
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
|
-
|
|
25
|
-
const
|
|
24
|
+
this.section("Database Seeding");
|
|
25
|
+
const outcome = await runSeeders();
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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"
|
|
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
|
-
|
|
62
|
+
} else {
|
|
63
|
+
this.info(`Migrated ${ran.length} migration(s).`);
|
|
64
|
+
this.table(ran.map((name) => [name, "ran"]));
|
|
55
65
|
}
|
|
56
66
|
|
|
57
|
-
|
|
58
|
-
|
|
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
|
+
}
|