@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.
@@ -216,6 +216,32 @@ function _getCachedTemplate(strings: string[]): TemplateStringsArray {
216
216
  return tpl;
217
217
  }
218
218
 
219
+ /**
220
+ * Turn a JS value into something the driver can actually bind.
221
+ *
222
+ * Only dates need this, and they need it badly. A `Date` handed to Bun's SQLite
223
+ * driver as a bind parameter does not land: `update({ read_at: new Date() })`
224
+ * left the column NULL and **reported no error**, so a "mark all as read"
225
+ * feature shipped as a latent no-op that read correctly in the source. The
226
+ * asymmetry made it easy to write, too — `model.save()` applies casts, so the
227
+ * identical value through a model worked.
228
+ *
229
+ * The comparison path already learned this (see `ModelQueryBuilder._bindValue`:
230
+ * a `Date` in a `where` used to match zero rows). Doing it here covers every
231
+ * bind on every builder — `DB.table()` writes included — from one place, so the
232
+ * next path someone adds cannot reintroduce it.
233
+ *
234
+ * Dialect-aware because MySQL DATETIME rejects ISO 8601's `T`/`Z`; SQLite and
235
+ * PostgreSQL take it as-is.
236
+ */
237
+ function _bindable(value: unknown, dialect: Dialect): unknown {
238
+ const date = value instanceof Carbon ? value.toDate() : value;
239
+ if (!(date instanceof Date)) return value;
240
+ return dialect === "mysql"
241
+ ? date.toISOString().replace("T", " ").slice(0, 19)
242
+ : date.toISOString();
243
+ }
244
+
219
245
  /**
220
246
  * @internal Execute compiled segments on `conn` with prepared-template
221
247
  * interning and QueryExecuted telemetry. Shared by `QueryBuilder._run` and
@@ -230,6 +256,7 @@ export async function _runSegments<T = Record<string, unknown>>(
230
256
  const strings: string[] = [];
231
257
  const values: unknown[] = [];
232
258
  let current = "";
259
+ const dialect = dialectFor(conn);
233
260
 
234
261
  for (const seg of segs) {
235
262
  if (typeof seg === "string") {
@@ -237,7 +264,7 @@ export async function _runSegments<T = Record<string, unknown>>(
237
264
  } else {
238
265
  strings.push(current);
239
266
  current = "";
240
- values.push(seg.val);
267
+ values.push(_bindable(seg.val, dialect));
241
268
  }
242
269
  }
243
270
  strings.push(current);
@@ -245,14 +272,18 @@ export async function _runSegments<T = Record<string, unknown>>(
245
272
  const cacheKey = strings.join("\x00");
246
273
  const tpl = _getCachedTemplate(strings);
247
274
  const ctx = RequestContext.tryGet();
248
- if (trackNPlusOne) trackQuery(ctx, cacheKey);
275
+ // Bindings go through too: without them the detector groups a six-month
276
+ // reporting loop — identical SQL, a different `period` each time — with a
277
+ // genuine per-row lookup, and sends you hunting for a relation to eager-load
278
+ // that does not exist.
279
+ if (trackNPlusOne) trackQuery(ctx, cacheKey, values);
249
280
 
250
281
  const startMs = Date.now();
251
282
  const rows = await conn<T>(tpl, ...values);
252
283
  const durationMs = Date.now() - startMs;
253
284
  FrameworkEvents.emit(
254
285
  new QueryExecuted(
255
- cacheKey.replace(/\x00/g, "?"),
286
+ cacheKey.replaceAll("\x00", "?"),
256
287
  values,
257
288
  startMs,
258
289
  durationMs,
@@ -0,0 +1,187 @@
1
+ /**
2
+ * "no such table: assets", answered.
3
+ *
4
+ * The message is exact and the stack is useless — every frame is inside the SQL
5
+ * driver, because that is where the failure surfaces, not where it comes from.
6
+ * The answer is almost always "you have migrations you have not run", and that
7
+ * lives here, one package away from the error page that needs it.
8
+ *
9
+ * The half that earns this feature is refusing to offer the button when it would
10
+ * not help. There are two situations and they need different answers: a table
11
+ * missing because a migration is pending, and a table missing because nobody ever
12
+ * wrote one. Running every pending migration in the second case changes nothing,
13
+ * leaves the developer where they started, and teaches them not to trust the
14
+ * panel.
15
+ */
16
+ import type { ErrorDiagnosis } from "@zerotal/core";
17
+ import { loadMigrations } from "../commands/_loadMigrations.ts";
18
+ import { MigrationRunner } from "../schema/MigrationRunner.ts";
19
+ import type { MigrationEntry } from "../schema/MigrationRunner.ts";
20
+ import { _getConnection } from "../db/DB.ts";
21
+
22
+ /** What the database said is missing. */
23
+ export interface MissingRelation {
24
+ kind: "table" | "column";
25
+ name: string;
26
+ }
27
+
28
+ /** Dialect error codes, checked before the message text. */
29
+ const CODES: Record<string, MissingRelation["kind"]> = {
30
+ // PostgreSQL SQLSTATE
31
+ "42P01": "table", // undefined_table
32
+ "42703": "column", // undefined_column
33
+ // MySQL
34
+ "1146": "table", // ER_NO_SUCH_TABLE
35
+ "1054": "column", // ER_BAD_FIELD_ERROR
36
+ ER_NO_SUCH_TABLE: "table",
37
+ ER_BAD_FIELD_ERROR: "column",
38
+ };
39
+
40
+ /**
41
+ * Message shapes, as a fallback.
42
+ *
43
+ * SQLite has no error code worth branching on — everything arrives as
44
+ * `SQLITE_ERROR` — so its two messages are matched directly. Postgres and MySQL
45
+ * are matched by code above; their text is included because a driver that wraps
46
+ * the error can drop the code while keeping the message.
47
+ */
48
+ const PATTERNS: Array<{ re: RegExp; kind: MissingRelation["kind"] }> = [
49
+ // Each captures the whole identifier — quotes, schema qualifier and all — and
50
+ // leaves the tidying to `bareName`. Capturing only the last segment needs a
51
+ // lazy qualifier prefix, and getting that subtly wrong is how `app.assets`
52
+ // came back as an empty name.
53
+ { re: /no such table:\s*([\w".`]+)/i, kind: "table" },
54
+ { re: /no such column:\s*([\w".`]+)/i, kind: "column" },
55
+ { re: /relation\s+([\w".]+)\s+does not exist/i, kind: "table" },
56
+ { re: /column\s+([\w".]+)\s+does not exist/i, kind: "column" },
57
+ { re: /table\s+'([\w.]+)'\s+doesn'?t exist/i, kind: "table" },
58
+ { re: /unknown column\s+'([\w.]+)'/i, kind: "column" },
59
+ ];
60
+
61
+ /** Strip a qualifier and quoting: `"public"."assets"` → `assets`. */
62
+ function bareName(raw: string): string {
63
+ const parts = raw.replace(/["`']/g, "").split(".");
64
+ return parts[parts.length - 1] ?? raw;
65
+ }
66
+
67
+ /**
68
+ * Whether this error is a database complaining about something that is not there.
69
+ *
70
+ * Returns `null` for everything else, including every other database error — a
71
+ * diagnoser that recognises errors it does not own is worse than none.
72
+ */
73
+ export function detectMissingRelation(error: Error): MissingRelation | null {
74
+ const message = error.message ?? "";
75
+
76
+ // Code first: it is unambiguous where it exists, and message text is localised
77
+ // on some MySQL builds.
78
+ const code = (error as { code?: string | number; errno?: number }).code;
79
+ const errno = (error as { errno?: number }).errno;
80
+ const kindFromCode = CODES[String(code)] ?? CODES[String(errno)];
81
+
82
+ for (const { re, kind } of PATTERNS) {
83
+ const match = re.exec(message);
84
+ if (match?.[1]) return { kind: kindFromCode ?? kind, name: bareName(match[1]) };
85
+ }
86
+
87
+ // A recognised code with an unrecognised message still tells us the kind, but
88
+ // not the name — worth reporting, because "some table is missing" plus the
89
+ // pending list is still the answer.
90
+ if (kindFromCode) return { kind: kindFromCode, name: "" };
91
+ return null;
92
+ }
93
+
94
+ /** Migrations that exist on disk and have not run. */
95
+ export async function pendingMigrations(): Promise<string[]> {
96
+ const records = await loadMigrations();
97
+ if (records.length === 0) return [];
98
+ const entries: MigrationEntry[] = records.map((r) => ({ name: r.name, migration: r.instance }));
99
+ const runner = new MigrationRunner({ connection: _getConnection() });
100
+ const statuses = await runner.status(entries);
101
+ return statuses.filter((s) => !s.ran).map((s) => s.name);
102
+ }
103
+
104
+ /**
105
+ * Does any migration on disk so much as mention this name?
106
+ *
107
+ * A weak signal used only to sharpen the message in the nothing-pending case:
108
+ * "no migration mentions `assets`" is a better sentence than anything generic,
109
+ * and it is usually right, because a migration that creates a table names it.
110
+ */
111
+ async function anyMigrationMentions(name: string): Promise<boolean> {
112
+ if (name === "") return false;
113
+ const glob = new Bun.Glob("database/migrations/*.ts");
114
+ for await (const file of glob.scan({ cwd: process.cwd() })) {
115
+ try {
116
+ const source = await Bun.file(file).text();
117
+ if (source.includes(name)) return true;
118
+ } catch {
119
+ // Unreadable file — treat as no evidence rather than failing the diagnosis.
120
+ }
121
+ }
122
+ return false;
123
+ }
124
+
125
+ /**
126
+ * Build the diagnosis, given a token minter for the action.
127
+ *
128
+ * `mintToken` is passed in rather than imported so this stays testable without a
129
+ * running server, and so the endpoint owns its own token lifetime.
130
+ */
131
+ export async function diagnoseMissingRelation(
132
+ error: Error,
133
+ options: { endpoint: string; mintToken: () => string },
134
+ ): Promise<ErrorDiagnosis | null> {
135
+ const missing = detectMissingRelation(error);
136
+ if (!missing) return null;
137
+
138
+ const subject = missing.name === "" ? `A ${missing.kind}` : `${missing.kind} \`${missing.name}\``;
139
+
140
+ let pending: string[];
141
+ try {
142
+ pending = await pendingMigrations();
143
+ } catch {
144
+ // No connection, no migrations directory, a driver that cannot answer — the
145
+ // detection still stands, so say what is missing without guessing why.
146
+ return {
147
+ title: `${subject} does not exist.`,
148
+ detail:
149
+ "The migration state could not be read, so this cannot say whether a pending " +
150
+ "migration would create it. Run `bun zt migrate:status` to see where things stand.",
151
+ };
152
+ }
153
+
154
+ if (pending.length > 0) {
155
+ return {
156
+ title: `${subject} does not exist, and ${pending.length} migration${
157
+ pending.length === 1 ? " has" : "s have"
158
+ } not run.`,
159
+ detail:
160
+ "Running them is very likely the fix. This runs the same migrations " +
161
+ "`bun zt migrate` would, in the same order, against the same connection — " +
162
+ "and it is available here only because the app is in development.",
163
+ items: pending,
164
+ action: {
165
+ label: `Run ${pending.length} migration${pending.length === 1 ? "" : "s"}`,
166
+ url: options.endpoint,
167
+ token: options.mintToken(),
168
+ pendingLabel: "Migrating…",
169
+ },
170
+ };
171
+ }
172
+
173
+ // Nothing pending. Deliberately no button: it would run nothing and change
174
+ // nothing, and the developer would be back here having been told to try.
175
+ const mentioned = await anyMigrationMentions(missing.name);
176
+ return {
177
+ title: `${subject} does not exist, and every migration has already run.`,
178
+ detail: mentioned
179
+ ? `A migration does mention \`${missing.name}\`, so this is more likely a rollback ` +
180
+ `that left the schema behind, or a migration that did not create what its name ` +
181
+ `suggests. \`bun zt migrate:status\` shows what ran, and \`migrate:refresh\` ` +
182
+ `rebuilds from scratch — it will destroy the data in this database.`
183
+ : `No migration in \`database/migrations\` mentions \`${missing.name}\`, which usually ` +
184
+ `means the migration that would create it was never written. \`bun zt make:migration\` ` +
185
+ `scaffolds one.`,
186
+ };
187
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The endpoint behind the error page's "Run migrations" button.
3
+ *
4
+ * This mutates a database in response to a request originating from a page that
5
+ * was rendered by a GET, which is a shape worth being paranoid about: a dev
6
+ * server on `localhost:3000` is reachable by any site the developer has open in
7
+ * another tab, and "run every pending migration" is not something a random page
8
+ * should be able to trigger.
9
+ *
10
+ * So it carries three independent guards, and each is checked here rather than
11
+ * inferred from the fact that the overlay is dev-only:
12
+ *
13
+ * 1. **`devSurfacesEnabled()`**, decided at request time. Note this is not
14
+ * `!isProdLike(...)`: `isProdLike("")` is false, so an unset `APP_ENV` would
15
+ * have *passed* that check. This one fails closed — only an explicitly
16
+ * non-production environment, or a process the dev orchestrator supervises,
17
+ * qualifies. It also reads the right thing: `setAppEnv()` overwrites
18
+ * `APP_ENV` with a runtime mode before boot, so reading it directly is wrong.
19
+ * 2. **A single-use token**, minted per error page and spent on first use. This
20
+ * is what a cross-origin caller cannot obtain: it would have to read the
21
+ * page, and the same-origin policy stops it.
22
+ * 3. **The origin guard**, the same one the raw Flow endpoints use — because
23
+ * this is registered as a raw route and so sits outside the CSRF middleware.
24
+ *
25
+ * The route is registered only when dev surfaces are enabled, so in production it
26
+ * does not exist at all. Guard 1 is the belt to that braces.
27
+ */
28
+ import { Router, devSurfacesEnabled } from "@zerotal/core";
29
+ import { isAllowedOrigin } from "@zerotal/core/http";
30
+ import { loadMigrations } from "../commands/_loadMigrations.ts";
31
+ import { MigrationRunner } from "../schema/MigrationRunner.ts";
32
+ import type { MigrationEntry } from "../schema/MigrationRunner.ts";
33
+ import { _getConnection } from "../db/DB.ts";
34
+
35
+ /** Path the button posts to. */
36
+ export const RUN_MIGRATIONS_PATH = "/__zerotal/run-migrations";
37
+ /** Header carrying the single-use token. */
38
+ const TOKEN_HEADER = "X-Zerotal-Diagnosis-Token";
39
+
40
+ /**
41
+ * Outstanding tokens.
42
+ *
43
+ * In memory and per process, which is right: a token is only ever handed to the
44
+ * page this process just rendered, and a restart invalidating them is correct
45
+ * rather than inconvenient — the page is stale by then anyway.
46
+ */
47
+ const _tokens = new Set<string>();
48
+ /** Tokens outlive one render but not a session; a bounded set cannot grow without limit. */
49
+ const MAX_TOKENS = 32;
50
+
51
+ /** Mint a token for one error page. @internal */
52
+ export function _mintDiagnosisToken(): string {
53
+ // The oldest token is dropped rather than letting a long dev session
54
+ // accumulate them. Insertion order is iteration order for a Set.
55
+ if (_tokens.size >= MAX_TOKENS) {
56
+ const oldest = _tokens.values().next().value;
57
+ if (oldest !== undefined) _tokens.delete(oldest);
58
+ }
59
+ const token = crypto.randomUUID();
60
+ _tokens.add(token);
61
+ return token;
62
+ }
63
+
64
+ /** Spend a token. Returns false when it was never minted, or was already used. @internal */
65
+ export function _spendDiagnosisToken(token: string | null): boolean {
66
+ if (!token) return false;
67
+ return _tokens.delete(token);
68
+ }
69
+
70
+ /** Forget every outstanding token. Tests. @internal */
71
+ export function _resetDiagnosisTokens(): void {
72
+ _tokens.clear();
73
+ }
74
+
75
+ /**
76
+ * Run every pending migration, and report what ran.
77
+ *
78
+ * Separated from the route so a test can drive it without a server.
79
+ */
80
+ export async function _runPendingMigrations(): Promise<string[]> {
81
+ const records = await loadMigrations();
82
+ const entries: MigrationEntry[] = records.map((r) => ({ name: r.name, migration: r.instance }));
83
+ const runner = new MigrationRunner({ connection: _getConnection() });
84
+ return runner.run(entries);
85
+ }
86
+
87
+ /**
88
+ * Register the endpoint, unless this is production.
89
+ *
90
+ * Called from `DatabaseProvider.onRegister()`.
91
+ */
92
+ export function registerRunMigrationsEndpoint(allowedOrigins: () => string[]): void {
93
+ if (!devSurfacesEnabled()) return;
94
+
95
+ Router.raw("POST", RUN_MIGRATIONS_PATH, async (req: Request): Promise<Response> => {
96
+ // Guard 1 — re-checked at request time, not inherited from registration.
97
+ if (!devSurfacesEnabled()) {
98
+ return new Response("Not available.", { status: 404 });
99
+ }
100
+
101
+ // Guard 2 — a raw route bypasses the middleware pipeline, so CSRF protection
102
+ // does not apply and this is the check standing in for it.
103
+ if (!isAllowedOrigin(req, allowedOrigins())) {
104
+ return new Response("Forbidden origin.", { status: 403 });
105
+ }
106
+
107
+ // Guard 3 — single use. A caller that cannot read the error page cannot have
108
+ // this, and replaying a captured one does not work twice.
109
+ if (!_spendDiagnosisToken(req.headers.get(TOKEN_HEADER))) {
110
+ return new Response("Invalid or already-used token.", { status: 403 });
111
+ }
112
+
113
+ try {
114
+ const ran = await _runPendingMigrations();
115
+ if (ran.length === 0) return new Response("Nothing to run.", { status: 200 });
116
+ return new Response(`Ran ${ran.length}: ${ran.join(", ")}`, { status: 200 });
117
+ } catch (error) {
118
+ // The migration itself failed — which is a real answer, and more useful
119
+ // than a generic 500. It goes back as text so the panel can show it.
120
+ const message = error instanceof Error ? error.message : String(error);
121
+ return new Response(`Migration failed: ${message}`, { status: 500 });
122
+ }
123
+ });
124
+ }
package/src/index.ts CHANGED
@@ -75,6 +75,9 @@ export { Model, BaseModel } from "./model/BaseModel.ts";
75
75
  // Mixin authoring types. Compose them onto a model with the `Model.using(...)` static —
76
76
  // `class User extends Model.using(Authenticatable, Roles)`.
77
77
  export type { Constructor, Mixin, Compose } from "./model/mixins.ts";
78
+ // The type every metadata registry keys on — a model class rather than an instance.
79
+ // Packages that register columns or relations from outside the ORM take it as a parameter.
80
+ export type { ClassRef } from "./support/classRef.ts";
78
81
  // State-machine behaviour is an opt-in mixin — compose with `Model.using(State)`.
79
82
  export { State } from "./model/State.ts";
80
83
  // Soft deletes are opt-in — compose with `Model.using(SoftDeletes)`.
@@ -46,6 +46,7 @@ import {
46
46
  import { TransactionContext } from "../db/TransactionContext.ts";
47
47
  import type { InsertPayload, UpdatePayload, FillablePayload } from "./payload.ts";
48
48
  import type { WhereOperator, OrderDirection } from "../db/types.ts";
49
+ import type { ClassRef } from "../support/classRef.ts";
49
50
 
50
51
  let _dialect: "sqlite" | "postgres" | "mysql" = "sqlite";
51
52
 
@@ -233,13 +234,13 @@ type StringCast =
233
234
  | `decimal:${number}`;
234
235
  type CastOption = ColumnOptions["cast"];
235
236
 
236
- function getCasts(ctor: Function): Record<string, CastOption> {
237
+ function getCasts(ctor: ClassRef): Record<string, CastOption> {
237
238
  const merged: Record<string, CastOption> = {};
238
- const chain: Function[] = [];
239
- let current: Function | null = ctor;
239
+ const chain: ClassRef[] = [];
240
+ let current: ClassRef | null = ctor;
240
241
  while (current && current !== Function.prototype) {
241
242
  chain.push(current);
242
- current = Object.getPrototypeOf(current) as Function | null;
243
+ current = Object.getPrototypeOf(current) as ClassRef | null;
243
244
  }
244
245
  chain.reverse();
245
246
  // `static encryptable` first, so an explicit cast on the same column still wins —
@@ -426,7 +427,7 @@ type ModelCtor<T extends BaseModel> = typeof BaseModel & { new (): T };
426
427
  * (`Roles(Permissions(Base))`), where each relation lives on a different
427
428
  * class in the chain.
428
429
  */
429
- function relNames(ctor: Function): Set<string> {
430
+ function relNames(ctor: ClassRef): Set<string> {
430
431
  return new Set(relationsFor(ctor).keys());
431
432
  }
432
433
 
@@ -463,7 +464,7 @@ function* ownDataEntries(
463
464
  * (rare edge case: a model with zero @column decorators) so callers can fall back
464
465
  * to the old unrestricted behaviour.
465
466
  */
466
- function _allColumnKeys(cls: Function): Set<string> | null {
467
+ function _allColumnKeys(cls: ClassRef): Set<string> | null {
467
468
  const cols = columnsFor(cls);
468
469
  return cols ? new Set(cols.keys()) : null;
469
470
  }
@@ -1524,8 +1525,8 @@ export class BaseModel {
1524
1525
  const ModelClass = this as unknown as typeof BaseModel;
1525
1526
  const conn = _resolveConn(ModelClass);
1526
1527
  const dialect = dialectFor(conn as unknown as object);
1527
- const casts = getCasts(ModelClass as unknown as Function);
1528
- const colReg = columnsFor(ModelClass as unknown as Function);
1528
+ const casts = getCasts(ModelClass as unknown as ClassRef);
1529
+ const colReg = columnsFor(ModelClass as unknown as ClassRef);
1529
1530
  const useTs = ModelClass.timestamps;
1530
1531
 
1531
1532
  const rows: Record<string, unknown>[] = _writeDialect.run(dialect, () => {
@@ -1610,8 +1611,8 @@ export class BaseModel {
1610
1611
  ): Promise<void> {
1611
1612
  const conn = _resolveConn(this);
1612
1613
  const dialect = dialectFor(conn as unknown as object);
1613
- const casts = getCasts(this as unknown as Function);
1614
- const colReg = columnsFor(this as unknown as Function);
1614
+ const casts = getCasts(this as unknown as ClassRef);
1615
+ const colReg = columnsFor(this as unknown as ClassRef);
1615
1616
 
1616
1617
  const row: Record<string, unknown> = _writeDialect.run(dialect, () => {
1617
1618
  const r: Record<string, unknown> = {};
@@ -1697,8 +1698,8 @@ export class BaseModel {
1697
1698
  const ModelClass = this.constructor as typeof BaseModel;
1698
1699
  const conn = _resolveConn(ModelClass);
1699
1700
  const dialect = dialectFor(conn as unknown as object);
1700
- const rels = relNames(ModelClass as unknown as Function);
1701
- const casts = getCasts(ModelClass as unknown as Function);
1701
+ const rels = relNames(ModelClass as unknown as ClassRef);
1702
+ const casts = getCasts(ModelClass as unknown as ClassRef);
1702
1703
 
1703
1704
  await HookRegistry.run(ModelClass, "beforeSave", this);
1704
1705
 
@@ -1730,8 +1731,8 @@ export class BaseModel {
1730
1731
  }
1731
1732
  }
1732
1733
 
1733
- const colReg = columnsFor(ModelClass as unknown as Function);
1734
- const colKeys = _allColumnKeys(ModelClass as unknown as Function);
1734
+ const colReg = columnsFor(ModelClass as unknown as ClassRef);
1735
+ const colKeys = _allColumnKeys(ModelClass as unknown as ClassRef);
1735
1736
 
1736
1737
  if (!this._exists) {
1737
1738
  // ── INSERT ──
@@ -1995,8 +1996,8 @@ export class BaseModel {
1995
1996
  */
1996
1997
  replicate(except?: string[]): this {
1997
1998
  const ModelClass = this.constructor as typeof BaseModel;
1998
- const rels = relNames(ModelClass as unknown as Function);
1999
- const colKeys = _allColumnKeys(ModelClass as unknown as Function);
1999
+ const rels = relNames(ModelClass as unknown as ClassRef);
2000
+ const colKeys = _allColumnKeys(ModelClass as unknown as ClassRef);
2000
2001
  const skip = new Set<string>([...SYSTEM_KEYS, ...(except ?? [])]);
2001
2002
  const inst = new (this.constructor as new () => this)();
2002
2003
  for (const [k, v] of ownDataEntries(this, skip, rels, colKeys)) {
@@ -2240,7 +2241,7 @@ export class BaseModel {
2240
2241
  * @category Relationships
2241
2242
  */
2242
2243
  associate(relation: string, model: BaseModel): this {
2243
- const meta = relationsFor(this.constructor).get(relation);
2244
+ const meta = relationsFor(this.constructor as ClassRef).get(relation);
2244
2245
  if (!meta || meta.type !== "belongsTo") {
2245
2246
  throw new Error(
2246
2247
  `associate(): "${relation}" is not a belongsTo relation on ${this.constructor.name}`,
@@ -2267,7 +2268,7 @@ export class BaseModel {
2267
2268
  * @category Relationships
2268
2269
  */
2269
2270
  dissociate(relation: string): this {
2270
- const meta = relationsFor(this.constructor).get(relation);
2271
+ const meta = relationsFor(this.constructor as ClassRef).get(relation);
2271
2272
  if (!meta || meta.type !== "belongsTo") {
2272
2273
  throw new Error(
2273
2274
  `dissociate(): "${relation}" is not a belongsTo relation on ${this.constructor.name}`,
@@ -2307,8 +2308,8 @@ export class BaseModel {
2307
2308
  */
2308
2309
  $dirty(): Record<string, unknown> {
2309
2310
  const ModelClass = this.constructor as typeof BaseModel;
2310
- const rels = relNames(ModelClass as unknown as Function);
2311
- const colKeys = _allColumnKeys(ModelClass as unknown as Function);
2311
+ const rels = relNames(ModelClass as unknown as ClassRef);
2312
+ const colKeys = _allColumnKeys(ModelClass as unknown as ClassRef);
2312
2313
  const out: Record<string, unknown> = {};
2313
2314
  for (const [key, val] of ownDataEntries(this, SYSTEM_KEYS, rels, colKeys)) {
2314
2315
  if (val !== this._original[key] || this._forcedDirty.has(key)) {
@@ -2499,8 +2500,8 @@ function _createLazyPivotProxy(
2499
2500
  function _applyRow(inst: BaseModel, row: Record<string, unknown>): void {
2500
2501
  const self = inst as unknown as Record<string, unknown>;
2501
2502
  const orig: Record<string, unknown> = {};
2502
- const ctor = inst.constructor;
2503
- const ModelClass = ctor as typeof BaseModel;
2503
+ const ModelClass = inst.constructor as typeof BaseModel;
2504
+ const ctor: ClassRef = ModelClass;
2504
2505
  const colReg = columnsFor(ctor);
2505
2506
  installReactiveAccessors(inst); // json/array reactiveCasts accessors (registered at decoration)
2506
2507
  const casts = getCasts(ctor);
@@ -22,11 +22,12 @@ import type { BaseModel } from "./BaseModel.ts";
22
22
  import { type ColumnOptions } from "./decorators/column.ts";
23
23
  import { columnsFor, relationsFor } from "./decorators/_metadata.ts";
24
24
  import { currentOrmContext } from "./OrmContext.ts";
25
+ import type { ClassRef } from "../support/classRef.ts";
25
26
 
26
27
  type StringCast = "datetime" | "array" | "json" | "date" | "boolean" | "integer" | "float";
27
28
  type CastOption = ColumnOptions["cast"];
28
29
 
29
- function _getCasts(ctor: Function): Record<string, CastOption> {
30
+ function _getCasts(ctor: ClassRef): Record<string, CastOption> {
30
31
  const merged: Record<string, CastOption> = {};
31
32
  const colReg = columnsFor(ctor);
32
33
  // Mirrors getCasts() in BaseModel: `static encryptable` resolves to casts, and an
@@ -274,9 +275,9 @@ function _createPivotCollection<T extends BaseModel>(
274
275
  // BaseModel as a type only — no cycle.
275
276
 
276
277
  export type GlobalScopeCallback = (qb: ModelQueryBuilder<BaseModel>) => void;
277
- export function _globalScopeRegistry(): Map<Function, Map<string, GlobalScopeCallback>> {
278
+ export function _globalScopeRegistry(): Map<ClassRef, Map<string, GlobalScopeCallback>> {
278
279
  return currentOrmContext().globalScopes as unknown as Map<
279
- Function,
280
+ ClassRef,
280
281
  Map<string, GlobalScopeCallback>
281
282
  >;
282
283
  }
@@ -1142,15 +1143,14 @@ export class ModelQueryBuilder<M extends BaseModel> extends QueryBuilder {
1142
1143
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1143
1144
  withScopes(callback: (scopes: any) => void): this {
1144
1145
  const ModelClass = this._ModelClass;
1145
- const self = this;
1146
1146
  const proxy = new Proxy({} as Record<string, (...args: unknown[]) => void>, {
1147
- get(_target, prop: string | symbol) {
1147
+ get: (_target, prop: string | symbol) => {
1148
1148
  return (...args: unknown[]) => {
1149
1149
  const fn = (ModelClass as unknown as Record<string | symbol, unknown>)[prop];
1150
1150
  if (typeof fn !== "function") return;
1151
1151
  const result = fn(...args) as { apply?: (q: unknown) => void } | null | undefined;
1152
1152
  if (result != null && typeof result.apply === "function") {
1153
- result.apply(self);
1153
+ result.apply(this);
1154
1154
  }
1155
1155
  };
1156
1156
  },
@@ -1249,8 +1249,8 @@ export class ModelQueryBuilder<M extends BaseModel> extends QueryBuilder {
1249
1249
 
1250
1250
  const rawKey = column.split(".").pop() ?? column;
1251
1251
  const camelKey = rawKey.includes("_") ? _toCamel(rawKey) : rawKey;
1252
- const casts = _getCasts(this._ModelClass as unknown as Function);
1253
- const colMeta = columnsFor(this._ModelClass as unknown as Function)?.get(camelKey);
1252
+ const casts = _getCasts(this._ModelClass as unknown as ClassRef);
1253
+ const colMeta = columnsFor(this._ModelClass as unknown as ClassRef)?.get(camelKey);
1254
1254
  const castOpt = casts[rawKey] ?? casts[camelKey] ?? colMeta?.cast;
1255
1255
  const colType = colMeta?.type;
1256
1256
 
@@ -1324,9 +1324,9 @@ export class ModelQueryBuilder<M extends BaseModel> extends QueryBuilder {
1324
1324
  if (spec.children.length > 0 && related.length > 0) {
1325
1325
  if (meta.type === "morphTo") {
1326
1326
  // Mixed related classes — group by constructor and recurse per group.
1327
- const groups = new Map<Function, BaseModel[]>();
1327
+ const groups = new Map<ClassRef, BaseModel[]>();
1328
1328
  for (const r of related) {
1329
- const ctor = r.constructor as Function;
1329
+ const ctor = r.constructor as ClassRef;
1330
1330
  if (!groups.has(ctor)) groups.set(ctor, []);
1331
1331
  groups.get(ctor)!.push(r);
1332
1332
  }
@@ -1,4 +1,5 @@
1
1
  import { HookRegistry, type HookName } from "./hooks/HookRegistry.ts";
2
+ import type { ClassRef } from "../support/classRef.ts";
2
3
 
3
4
  /**
4
5
  * Observer interface — implement any subset of lifecycle methods to react to a
@@ -61,7 +62,7 @@ const _methodToHook: Record<keyof ModelObserver, HookName> = {
61
62
  * @param ObserverClass - An observer class (zero-arg constructor) implementing any subset of {@link ModelObserver}.
62
63
  * @internal
63
64
  */
64
- export function registerObserver<T>(ModelClass: Function, ObserverClass: ObserverClass<T>): void {
65
+ export function registerObserver<T>(ModelClass: ClassRef, ObserverClass: ObserverClass<T>): void {
65
66
  const instance = new ObserverClass();
66
67
 
67
68
  for (const [method, hook] of Object.entries(_methodToHook) as [keyof ModelObserver, HookName][]) {
@@ -25,11 +25,11 @@ export class OrmContext {
25
25
  /** Connections registered by name, selectable via `static connection`. */
26
26
  namedConnections = new Map<string, SQLInstance>();
27
27
  /** Per-model `onTransition` callbacks, keyed by target state (see the `State` mixin). */
28
- transitionCallbacks = new Map<Function, Map<string, unknown[]>>();
28
+ transitionCallbacks = new Map<ClassRef, Map<string, unknown[]>>();
29
29
  /** Per-model registered global query scopes. */
30
- globalScopes = new Map<Function, Map<string, unknown>>();
30
+ globalScopes = new Map<ClassRef, Map<string, unknown>>();
31
31
  /** Per-model lifecycle hooks. */
32
- hooks = new Map<Function, Map<string, unknown[]>>();
32
+ hooks = new Map<ClassRef, Map<string, unknown[]>>();
33
33
  }
34
34
 
35
35
  let _ctx = new OrmContext();
@@ -58,6 +58,7 @@ export function resetOrmContext(): void {
58
58
  }
59
59
 
60
60
  import { registerAppScope } from "@zerotal/core";
61
+ import type { ClassRef } from "../support/classRef.ts";
61
62
 
62
63
  let _appScopeRegistered = false;
63
64
  if (!_appScopeRegistered) {
@@ -22,6 +22,7 @@
22
22
  import { StateError } from "../errors/index.ts";
23
23
  import { currentOrmContext } from "./OrmContext.ts";
24
24
  import type { Constructor } from "./mixins.ts";
25
+ import type { ClassRef } from "../support/classRef.ts";
25
26
 
26
27
  // ── Types ─────────────────────────────────────────────────────────────────────
27
28
 
@@ -87,7 +88,7 @@ export type TransitionCallback<T> = (
87
88
  // ── Callback registry (execution-scoped on the OrmContext) ────────────────────
88
89
 
89
90
  /** @internal Register or retrieve transition callbacks for a model class. */
90
- function _getCallbacks(ModelClass: Function, state: string): TransitionCallback<unknown>[] {
91
+ function _getCallbacks(ModelClass: ClassRef, state: string): TransitionCallback<unknown>[] {
91
92
  const reg = currentOrmContext().transitionCallbacks;
92
93
  if (!reg.has(ModelClass)) reg.set(ModelClass, new Map());
93
94
  const map = reg.get(ModelClass)!;
@@ -169,7 +170,7 @@ export function State<TBase extends Constructor>(Base: TBase) {
169
170
  toState: string,
170
171
  callback: TransitionCallback<T>,
171
172
  ): void {
172
- _getCallbacks(this as unknown as Function, toState).push(
173
+ _getCallbacks(this as unknown as ClassRef, toState).push(
173
174
  callback as TransitionCallback<unknown>,
174
175
  );
175
176
  }
@@ -260,7 +261,7 @@ export function State<TBase extends Constructor>(Base: TBase) {
260
261
  await (this as unknown as { save(): Promise<unknown> }).save();
261
262
 
262
263
  // Fire registered transition callbacks.
263
- const map = currentOrmContext().transitionCallbacks.get(this.constructor as Function);
264
+ const map = currentOrmContext().transitionCallbacks.get(this.constructor as ClassRef);
264
265
  const meta = { from: currentState, to: newState };
265
266
  const toFns = (map?.get(newState) ?? []) as TransitionCallback<unknown>[];
266
267
  const anyFns = (map?.get("*") ?? []) as TransitionCallback<unknown>[];