@zerotal/orm 1.4.0 → 1.5.1
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 +106 -0
- package/package.json +3 -3
- 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 +3 -0
- package/src/model/BaseModel.ts +23 -22
- package/src/model/ModelQueryBuilder.ts +10 -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 +3 -2
- 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 +3 -2
- 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,112 @@ 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
|
+
|
|
11
117
|
## [1.4.0] — 2026-08-10
|
|
12
118
|
|
|
13
119
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerotal/orm",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.1",
|
|
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.1",
|
|
34
|
+
"@zerotal/validator": "1.5.1"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"typescript": "^5.8.0"
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Seeder } from "../seeding/Seeder.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What running the app's seeders came to.
|
|
5
|
+
*
|
|
6
|
+
* Seeding is reported rather than thrown because two commands consume it and
|
|
7
|
+
* they want different things from a failure: `db:seed` has nothing else to do
|
|
8
|
+
* and simply reports, while `migrate:fresh --seed` has already rebuilt the
|
|
9
|
+
* schema by the time seeding runs and must not present that work as undone.
|
|
10
|
+
*/
|
|
11
|
+
export type SeedOutcome =
|
|
12
|
+
| { status: "seeded" }
|
|
13
|
+
/** No seeder file exists. `path` is where one was looked for. */
|
|
14
|
+
| { status: "missing"; path: string }
|
|
15
|
+
/** A seeder file exists but does not export what it should. */
|
|
16
|
+
| { status: "invalid"; message: string }
|
|
17
|
+
/** The seeder ran and threw. */
|
|
18
|
+
| { status: "failed"; message: string };
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Run the application's database seeders.
|
|
22
|
+
*
|
|
23
|
+
* Prefers the class-based `database/seeders/DatabaseSeeder.ts`, falling back to
|
|
24
|
+
* a legacy `database/seeders/index.ts` exporting a default async function.
|
|
25
|
+
*
|
|
26
|
+
* @param cwd Project root to resolve `database/seeders/` against.
|
|
27
|
+
*
|
|
28
|
+
* @internal
|
|
29
|
+
*/
|
|
30
|
+
export async function runSeeders(cwd: string = process.cwd()): Promise<SeedOutcome> {
|
|
31
|
+
const seederPath = `${cwd}/database/seeders/DatabaseSeeder.ts`;
|
|
32
|
+
|
|
33
|
+
if (!(await Bun.file(seederPath).exists())) {
|
|
34
|
+
const legacyPath = `${cwd}/database/seeders/index.ts`;
|
|
35
|
+
if (!(await Bun.file(legacyPath).exists())) {
|
|
36
|
+
return { status: "missing", path: seederPath };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const module = (await import(legacyPath)) as { default?: () => Promise<void> };
|
|
41
|
+
const seed = module.default;
|
|
42
|
+
if (!seed) {
|
|
43
|
+
return { status: "invalid", message: "Seeder index must export a default async function." };
|
|
44
|
+
}
|
|
45
|
+
await seed();
|
|
46
|
+
return { status: "seeded" };
|
|
47
|
+
} catch (error) {
|
|
48
|
+
return { status: "failed", message: error instanceof Error ? error.message : String(error) };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const module = (await import(seederPath)) as {
|
|
54
|
+
DatabaseSeeder?: new () => Seeder;
|
|
55
|
+
default?: new () => Seeder;
|
|
56
|
+
};
|
|
57
|
+
const SeederClass = module.DatabaseSeeder ?? module.default;
|
|
58
|
+
|
|
59
|
+
if (!SeederClass) {
|
|
60
|
+
return {
|
|
61
|
+
status: "invalid",
|
|
62
|
+
message: "DatabaseSeeder not found as a named or default export.",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
await new SeederClass().run();
|
|
67
|
+
return { status: "seeded" };
|
|
68
|
+
} catch (error) {
|
|
69
|
+
return { status: "failed", message: error instanceof Error ? error.message : String(error) };
|
|
70
|
+
}
|
|
71
|
+
}
|
package/src/commands/index.ts
CHANGED
|
@@ -22,6 +22,7 @@ export { MigrateCommand } from "./MigrateCommand.ts";
|
|
|
22
22
|
export { MigrateRollbackCommand } from "./MigrateRollbackCommand.ts";
|
|
23
23
|
export { MigrateStatusCommand } from "./MigrateStatusCommand.ts";
|
|
24
24
|
export { MigrateFreshCommand } from "./MigrateFreshCommand.ts";
|
|
25
|
+
export { MigrateRefreshCommand } from "./MigrateRefreshCommand.ts";
|
|
25
26
|
export { MakeMigrationCommand } from "./MakeMigrationCommand.ts";
|
|
26
27
|
export { MigrateGenerateCommand } from "./MigrateGenerateCommand.ts";
|
|
27
28
|
export { MakeModelCommand } from "./MakeModelCommand.ts";
|
package/src/conventions.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { tableNameFor } from "@zerotal/core";
|
|
|
3
3
|
import { BaseModel } from "./model/BaseModel.ts";
|
|
4
4
|
import { registerModel, modelByName } from "./model/decorators/_metadata.ts";
|
|
5
5
|
import { frameworkLog } from "@zerotal/core/logger";
|
|
6
|
+
import type { ClassRef } from "./support/classRef.ts";
|
|
6
7
|
|
|
7
8
|
function isModelClass(v: unknown): boolean {
|
|
8
9
|
return (
|
|
@@ -26,7 +27,7 @@ export const modelsConcern: ConcernDescriptor = {
|
|
|
26
27
|
for (const exported of Object.values(mod)) {
|
|
27
28
|
if (!isModelClass(exported)) continue;
|
|
28
29
|
const Model = exported as unknown as { name: string; table?: string };
|
|
29
|
-
registerModel(Model as unknown as
|
|
30
|
+
registerModel(Model as unknown as ClassRef);
|
|
30
31
|
// Convention table name — explicit @table / static table always wins.
|
|
31
32
|
if (!Model.table) Model.table = tableNameFor(Model.name);
|
|
32
33
|
}
|
|
@@ -25,15 +25,36 @@ import { NPlusOneDetected } from "../events.ts";
|
|
|
25
25
|
export class NPlusOneError extends ZerotalError {
|
|
26
26
|
readonly fingerprint: string;
|
|
27
27
|
readonly count: number;
|
|
28
|
+
/**
|
|
29
|
+
* How many distinct argument tuples the shape ran with.
|
|
30
|
+
*
|
|
31
|
+
* The difference between the two diagnoses. `1` means the same query with the
|
|
32
|
+
* same arguments ran N times — nothing to eager-load, the answer is to ask
|
|
33
|
+
* once. Anything higher is the classic per-row lookup.
|
|
34
|
+
*/
|
|
35
|
+
readonly distinctArgs: number;
|
|
36
|
+
|
|
37
|
+
constructor(fingerprint: string, count: number, distinctArgs = 0) {
|
|
38
|
+
const sql = fingerprint.replaceAll("\x00", "?");
|
|
39
|
+
// Sending someone to look for a relation to eager-load, when the query is
|
|
40
|
+
// the *same* one repeated with the *same* arguments, wastes the time the
|
|
41
|
+
// warning was supposed to save. Say which of the two this is.
|
|
42
|
+
const diagnosis =
|
|
43
|
+
distinctArgs === 1
|
|
44
|
+
? `with the same arguments every time. That is not a per-row lookup — nothing to\n` +
|
|
45
|
+
`eager-load — it is the same answer fetched repeatedly.\n\n` +
|
|
46
|
+
`Fix: ask once per request.\n` +
|
|
47
|
+
` const rows = await RequestContext.remember('key', () => …);\n`
|
|
48
|
+
: `with ${distinctArgs > 0 ? `${distinctArgs} different argument sets` : "varying arguments"}. ` +
|
|
49
|
+
`This is the classic N+1 access pattern.\n\n` +
|
|
50
|
+
`Fix: load the relation eagerly using .with('relation') on your query,\n` +
|
|
51
|
+
`call await model.load('relation') before the loop, or collapse the loop\n` +
|
|
52
|
+
`into a single .whereIn(...).\n`;
|
|
28
53
|
|
|
29
|
-
constructor(fingerprint: string, count: number) {
|
|
30
|
-
const sql = fingerprint.replace(/\x00/g, "?");
|
|
31
54
|
super(
|
|
32
55
|
`NPlusOneError: The query\n\n` +
|
|
33
56
|
` ${sql}\n\n` +
|
|
34
|
-
`was executed ${count} times in a single request
|
|
35
|
-
`Fix: load the relation eagerly using .with('relation') on your query,\n` +
|
|
36
|
-
`or call await model.load('relation') before the loop.\n\n` +
|
|
57
|
+
`was executed ${count} times in a single request, ${diagnosis}\n` +
|
|
37
58
|
`To suppress for a specific table or pattern:\n` +
|
|
38
59
|
` DB.allowNPlusOne('table_name') // permanent\n` +
|
|
39
60
|
` DB.allowNPlusOne('table_name', { once: true }) // this request only\n\n` +
|
|
@@ -43,13 +64,30 @@ export class NPlusOneError extends ZerotalError {
|
|
|
43
64
|
);
|
|
44
65
|
this.fingerprint = fingerprint;
|
|
45
66
|
this.count = count;
|
|
67
|
+
this.distinctArgs = distinctArgs;
|
|
46
68
|
}
|
|
47
69
|
}
|
|
48
70
|
|
|
49
71
|
// ── State ─────────────────────────────────────────────────────────────────────
|
|
50
72
|
|
|
51
|
-
/**
|
|
52
|
-
|
|
73
|
+
/** What one query shape did during one request. */
|
|
74
|
+
interface ShapeStats {
|
|
75
|
+
count: number;
|
|
76
|
+
/**
|
|
77
|
+
* Distinct argument tuples seen, capped.
|
|
78
|
+
*
|
|
79
|
+
* Capped because a genuine 500-iteration loop would otherwise hold 500 keys
|
|
80
|
+
* for the life of the request to answer a question — "is this one argument or
|
|
81
|
+
* many?" — that a handful already settles.
|
|
82
|
+
*/
|
|
83
|
+
args: Set<string>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** How many distinct argument tuples to remember per shape before giving up counting. */
|
|
87
|
+
const _ARG_SAMPLE_CAP = 32;
|
|
88
|
+
|
|
89
|
+
/** Per-request query-shape stats. Keyed by the HttpContext object. */
|
|
90
|
+
const _counts = new WeakMap<object, Map<string, ShapeStats>>();
|
|
53
91
|
|
|
54
92
|
/** Once-per-request suppressions. Cleared automatically when the context is GC'd. */
|
|
55
93
|
const _onceSuppressed = new WeakMap<object, Set<string>>();
|
|
@@ -126,8 +164,18 @@ export function _resetNPlusOne(): void {
|
|
|
126
164
|
|
|
127
165
|
// ── Core tracking ─────────────────────────────────────────────────────────────
|
|
128
166
|
|
|
129
|
-
/**
|
|
130
|
-
|
|
167
|
+
/**
|
|
168
|
+
* @internal — called by QueryBuilder._run() on every query execution.
|
|
169
|
+
*
|
|
170
|
+
* @param values - The bound parameters. Optional so older call sites still
|
|
171
|
+
* compile; without them the detector cannot tell a per-row lookup from the
|
|
172
|
+
* same read repeated, and says so less precisely.
|
|
173
|
+
*/
|
|
174
|
+
export function trackQuery(
|
|
175
|
+
ctx: object | null | undefined,
|
|
176
|
+
fingerprint: string,
|
|
177
|
+
values?: readonly unknown[],
|
|
178
|
+
): void {
|
|
131
179
|
if (!ctx) return;
|
|
132
180
|
|
|
133
181
|
// Honour explicit opt-in or auto-enable in local/development only
|
|
@@ -158,15 +206,19 @@ export function trackQuery(ctx: object | null | undefined, fingerprint: string):
|
|
|
158
206
|
// Count this fingerprint for the current request
|
|
159
207
|
if (!_counts.has(ctx)) _counts.set(ctx, new Map());
|
|
160
208
|
const map = _counts.get(ctx)!;
|
|
161
|
-
const
|
|
162
|
-
|
|
209
|
+
const stats = map.get(fingerprint) ?? { count: 0, args: new Set<string>() };
|
|
210
|
+
stats.count++;
|
|
211
|
+
if (values !== undefined && stats.args.size < _ARG_SAMPLE_CAP) {
|
|
212
|
+
stats.args.add(_argKey(values));
|
|
213
|
+
}
|
|
214
|
+
map.set(fingerprint, stats);
|
|
163
215
|
|
|
164
|
-
if (count >= _threshold) {
|
|
216
|
+
if (stats.count >= _threshold) {
|
|
165
217
|
// Only fire once (at exactly the threshold), not on every subsequent hit
|
|
166
|
-
if (count > _threshold) return;
|
|
218
|
+
if (stats.count > _threshold) return;
|
|
167
219
|
|
|
168
|
-
const err = new NPlusOneError(fingerprint, count);
|
|
169
|
-
FrameworkEvents.emit(new NPlusOneDetected(fingerprint, count, ctx ?? undefined));
|
|
220
|
+
const err = new NPlusOneError(fingerprint, stats.count, stats.args.size);
|
|
221
|
+
FrameworkEvents.emit(new NPlusOneDetected(fingerprint, stats.count, ctx ?? undefined));
|
|
170
222
|
if (_mode === "throw") {
|
|
171
223
|
throw err;
|
|
172
224
|
} else {
|
|
@@ -174,3 +226,29 @@ export function trackQuery(ctx: object | null | undefined, fingerprint: string):
|
|
|
174
226
|
}
|
|
175
227
|
}
|
|
176
228
|
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* A comparable key for one call's bound parameters.
|
|
232
|
+
*
|
|
233
|
+
* Only ever compared against other keys for the same SQL shape, so it needs to
|
|
234
|
+
* separate arguments rather than describe them. A value JSON cannot represent
|
|
235
|
+
* degrades to its `String()` form, which is enough to tell two calls apart.
|
|
236
|
+
*/
|
|
237
|
+
function _argKey(values: readonly unknown[]): string {
|
|
238
|
+
let key = "";
|
|
239
|
+
for (const value of values) {
|
|
240
|
+
if (value instanceof Date) key += `d${value.getTime()}|`;
|
|
241
|
+
else if (value === null || value === undefined) key += "∅|";
|
|
242
|
+
else if (typeof value === "object") key += `${_safeJson(value)}|`;
|
|
243
|
+
else key += `${String(value)}|`;
|
|
244
|
+
}
|
|
245
|
+
return key;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function _safeJson(value: object): string {
|
|
249
|
+
try {
|
|
250
|
+
return JSON.stringify(value) ?? "?";
|
|
251
|
+
} catch {
|
|
252
|
+
return "?";
|
|
253
|
+
}
|
|
254
|
+
}
|