@zerotal/orm 1.4.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,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.4.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.4.0",
34
- "@zerotal/validator": "1.4.0"
33
+ "@zerotal/core": "1.5.0",
34
+ "@zerotal/validator": "1.5.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "typescript": "^5.8.0"
@@ -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
+ }
@@ -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
+ }
@@ -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";
@@ -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 Function);
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. This is an N+1 query.\n\n` +
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
- /** Per-request query-shape hit counts. Keyed by the HttpContext object. */
52
- const _counts = new WeakMap<object, Map<string, number>>();
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
- /** @internal — called by QueryBuilder._run() on every query execution. */
130
- export function trackQuery(ctx: object | null | undefined, fingerprint: string): void {
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 count = (map.get(fingerprint) ?? 0) + 1;
162
- map.set(fingerprint, count);
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
+ }