@zerotal/orm 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,6 +7,7 @@ import {
7
7
  columnsFor,
8
8
  reactiveColumnsFor,
9
9
  } from "./_metadata.ts";
10
+ import type { ClassRef } from "../../support/classRef.ts";
10
11
 
11
12
  // Re-exported here for the public API — populated at class-definition time via @table.
12
13
  export { columnRegistry };
@@ -29,6 +30,8 @@ export { columnRegistry };
29
30
  * | `"date"` | `{ type: "datetime", cast: "date" }` |
30
31
  * | `"json"` | `{ type: "json", cast: "json" }` |
31
32
  * | `"array"` | `{ type: "json", cast: "array" }` |
33
+ * | `"encrypted"` | `{ type: "text", cast: "encrypted" }` |
34
+ * | `"encrypted:json"` | `{ type: "text", cast: "encrypted:json" }` |
32
35
  */
33
36
  export type ColumnShorthand =
34
37
  | "string"
@@ -40,7 +43,9 @@ export type ColumnShorthand =
40
43
  | "datetime"
41
44
  | "date"
42
45
  | "json"
43
- | "array";
46
+ | "array"
47
+ | "encrypted"
48
+ | "encrypted:json";
44
49
 
45
50
  /**
46
51
  * Full option object accepted by `@column({ ... })`.
@@ -105,6 +110,12 @@ export interface ColumnOptions {
105
110
  * - 'integer' — parseInt on both read and write
106
111
  * - 'float' — parseFloat on both read and write
107
112
  * - 'enum' — pass-through; pairs with `enumValues` for TS enum columns
113
+ * - 'encrypted' — AES-256-GCM at rest under `APP_KEY`, plaintext on the model
114
+ * - 'encrypted:json' — the same, for a structured value (stringified, then encrypted)
115
+ *
116
+ * Encrypted columns need `type: "text"` (a payload outgrows the plaintext) and
117
+ * cannot be filtered on — `where()` against one throws, because a fresh IV per
118
+ * write means the ciphertext never repeats. See `casts/encrypted.ts`.
108
119
  */
109
120
  cast?:
110
121
  | "datetime"
@@ -116,6 +127,8 @@ export interface ColumnOptions {
116
127
  | "float"
117
128
  | "enum"
118
129
  | "immutable_datetime"
130
+ | "encrypted"
131
+ | "encrypted:json"
119
132
  | `decimal:${number}`
120
133
  | {
121
134
  get?: (dbValue: unknown) => unknown;
@@ -140,6 +153,10 @@ const SHORTHAND_MAP: Record<ColumnShorthand, ColumnOptions> = {
140
153
  date: { type: "datetime", cast: "date" },
141
154
  json: { type: "json", cast: "json" },
142
155
  array: { type: "json", cast: "array" },
156
+ // TEXT, not string: the stored payload is ~1.4× the plaintext plus 28 bytes of
157
+ // IV and auth tag, so a VARCHAR that held the value will not hold its ciphertext.
158
+ encrypted: { type: "text", cast: "encrypted" },
159
+ "encrypted:json": { type: "text", cast: "encrypted:json" },
143
160
  };
144
161
 
145
162
  /**
@@ -315,9 +332,9 @@ export function column(
315
332
  * @internal
316
333
  */
317
334
  export function installReactiveAccessors(instance: object): void {
318
- const reactive = reactiveColumnsFor(instance.constructor as Function);
335
+ const reactive = reactiveColumnsFor(instance.constructor as ClassRef);
319
336
  if (!reactive.length) return;
320
- const cols = columnsFor(instance.constructor as Function);
337
+ const cols = columnsFor(instance.constructor as ClassRef);
321
338
  for (const name of reactive) {
322
339
  const desc = Object.getOwnPropertyDescriptor(instance, name);
323
340
  if (desc && typeof desc.get === "function") continue; // already installed
@@ -1,4 +1,5 @@
1
1
  import { drainPendingMembers } from "./_metadata.ts";
2
+ import type { ClassRef } from "../../support/classRef.ts";
2
3
 
3
4
  /**
4
5
  * Options object accepted as the second argument to `@table()`.
@@ -50,7 +51,7 @@ interface TableConfig {
50
51
  */
51
52
  export interface TableDecoratorBuilder {
52
53
  /** Apply the decorator to a class constructor (called automatically by TS). */
53
- (target: Function, context?: unknown): void;
54
+ (target: ClassRef, context?: unknown): void;
54
55
 
55
56
  /** Enable automatic `created_at` / `updated_at` management. Default: on. */
56
57
  withTimestamps(): TableDecoratorBuilder;
@@ -92,7 +93,7 @@ export function table(tableName: string, options: TableOptions = {}): TableDecor
92
93
  primaryKey: options.primaryKey ?? "id",
93
94
  };
94
95
 
95
- function apply(target: Function, _context?: unknown): void {
96
+ function apply(target: ClassRef, _context?: unknown): void {
96
97
  // Drain the @column / @relation registrations queued while this class's members were
97
98
  // decorated. @table is the definition-time anchor that owns this — see _metadata.ts.
98
99
  drainPendingMembers(target);
@@ -1,5 +1,6 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { currentOrmContext } from "../OrmContext.ts";
3
+ import type { ClassRef } from "../../support/classRef.ts";
3
4
 
4
5
  /**
5
6
  * The set of model lifecycle points a hook can attach to. Each fires once per
@@ -19,8 +20,8 @@ export type HookName =
19
20
 
20
21
  type HookFn<T> = (model: T) => Promise<void> | void;
21
22
 
22
- function _registry(): Map<Function, Map<HookName, HookFn<unknown>[]>> {
23
- return currentOrmContext().hooks as unknown as Map<Function, Map<HookName, HookFn<unknown>[]>>;
23
+ function _registry(): Map<ClassRef, Map<HookName, HookFn<unknown>[]>> {
24
+ return currentOrmContext().hooks as unknown as Map<ClassRef, Map<HookName, HookFn<unknown>[]>>;
24
25
  }
25
26
 
26
27
  /**
@@ -56,7 +57,7 @@ export class HookRegistry {
56
57
  * Optional post-run callback, invoked after a hook's functions run (and only when hooks
57
58
  * are not suppressed). BaseModel sets this to dispatch model events (`dispatchesEvents`).
58
59
  */
59
- static onAfterRun: ((ModelClass: Function, hook: HookName, model: unknown) => void) | undefined;
60
+ static onAfterRun: ((ModelClass: ClassRef, hook: HookName, model: unknown) => void) | undefined;
60
61
 
61
62
  /**
62
63
  * Append a hook callback for a model class at a given lifecycle point.
@@ -65,7 +66,7 @@ export class HookRegistry {
65
66
  * @param hook - Which lifecycle point to fire on.
66
67
  * @param fn - Callback receiving the model instance; may be async.
67
68
  */
68
- static register<T>(ModelClass: Function, hook: HookName, fn: HookFn<T>): void {
69
+ static register<T>(ModelClass: ClassRef, hook: HookName, fn: HookFn<T>): void {
69
70
  const registry = _registry();
70
71
  if (!registry.has(ModelClass)) {
71
72
  registry.set(ModelClass, new Map());
@@ -84,15 +85,15 @@ export class HookRegistry {
84
85
  * @param hook - Which lifecycle point is firing.
85
86
  * @param model - The model instance passed to each callback.
86
87
  */
87
- static async run<T>(ModelClass: Function, hook: HookName, model: T): Promise<void> {
88
+ static async run<T>(ModelClass: ClassRef, hook: HookName, model: T): Promise<void> {
88
89
  if (_suppressCtx.getStore()) return;
89
90
 
90
91
  // Walk the prototype chain to collect inherited hooks
91
- const chain: Function[] = [];
92
- let cur: Function | null = ModelClass;
92
+ const chain: ClassRef[] = [];
93
+ let cur: ClassRef | null = ModelClass;
93
94
  while (cur && cur !== Function.prototype) {
94
95
  chain.unshift(cur);
95
- cur = Object.getPrototypeOf(cur) as Function | null;
96
+ cur = Object.getPrototypeOf(cur) as ClassRef | null;
96
97
  }
97
98
 
98
99
  const registry = _registry();
@@ -1,3 +1,5 @@
1
+ import type { ClassRef } from "../../support/classRef.ts";
2
+
1
3
  /** Discriminator for every relation kind the ORM supports. */
2
4
  export type RelationType =
3
5
  | "hasMany"
@@ -70,7 +72,7 @@ export type RelationDefinition = RelationMetadata;
70
72
  * relation (property) name. Populated by the relation decorators at class-definition
71
73
  * time and consulted by {@link ModelQueryBuilder} when resolving a relation.
72
74
  */
73
- export const relationRegistry = new Map<Function, Map<string, RelationMetadata>>();
75
+ export const relationRegistry = new Map<ClassRef, Map<string, RelationMetadata>>();
74
76
 
75
77
  // ── Pivot collection ─────────────────────────────────────────────────────────
76
78
 
@@ -100,7 +100,7 @@ export function installOrmObservability(app: Application): () => void {
100
100
  if (e.ctx) store.markNPlus(e.ctx);
101
101
  store.recordEvent({
102
102
  kind: "nplus",
103
- label: e.fingerprint.replace(/\x00/g, "?"),
103
+ label: e.fingerprint.replaceAll("\x00", "?"),
104
104
  status: "warn",
105
105
  route: e.ctx ? _ctxPath(e.ctx) : null,
106
106
  data: { count: e.count },
@@ -161,7 +161,7 @@ export function installOrmObservability(app: Application): () => void {
161
161
  }),
162
162
  FrameworkEvents.on(NPlusOneDetected, (e) => {
163
163
  if (!e.ctx) return;
164
- trace.bufferWarning(e.ctx, { sql: e.fingerprint.replace(/\x00/g, "?"), count: e.count });
164
+ trace.bufferWarning(e.ctx, { sql: e.fingerprint.replaceAll("\x00", "?"), count: e.count });
165
165
  }),
166
166
  );
167
167
  }
@@ -1,5 +1,5 @@
1
1
  import type { SQLInstance } from "../db/sql-types.ts";
2
- import { ServiceProvider } from "@zerotal/core";
2
+ import { ServiceProvider, registerErrorDiagnoser, isProdLike, deployEnv } from "@zerotal/core";
3
3
  import type { AppEnvironment } from "@zerotal/core";
4
4
  import type { ConfigManager } from "@zerotal/core/config";
5
5
  import { SQL } from "bun";
@@ -17,6 +17,12 @@ import { validateDatabaseConfig } from "../config.ts";
17
17
  import { autoMigrateConcern } from "../schema/autoMigrate.ts";
18
18
  import { registerImplicitBinding } from "../implicitBinding.ts";
19
19
  import { installOrmObservability } from "../observability.ts";
20
+ import { diagnoseMissingRelation } from "../diagnostics/missingRelation.ts";
21
+ import {
22
+ registerRunMigrationsEndpoint,
23
+ RUN_MIGRATIONS_PATH,
24
+ _mintDiagnosisToken,
25
+ } from "../diagnostics/runMigrationsEndpoint.ts";
20
26
 
21
27
  // Extend the core container registry so 'db' is a typed binding.
22
28
  declare module "@zerotal/core" {
@@ -65,6 +71,19 @@ export class DatabaseProvider extends ServiceProvider {
65
71
  // route-compile time, so model registration order doesn't matter.
66
72
  registerImplicitBinding();
67
73
 
74
+ // "no such table: assets" arrives with a stack that is entirely SQL-driver
75
+ // frames, so the overlay can name the failure and nothing else. This turns it
76
+ // into the list of migrations that have not run — and, when none are pending,
77
+ // says so instead of offering a button that would change nothing.
78
+ registerErrorDiagnoser((error) =>
79
+ diagnoseMissingRelation(error, {
80
+ endpoint: RUN_MIGRATIONS_PATH,
81
+ mintToken: _mintDiagnosisToken,
82
+ }),
83
+ );
84
+ // Registers nothing outside development. See the file for the three guards.
85
+ registerRunMigrationsEndpoint(() => this.app._allowedOrigins?.() ?? []);
86
+
68
87
  setConnectionResolver(() => {
69
88
  try {
70
89
  return this.app.container.makeSync("db") as SQLInstance;
@@ -180,8 +199,12 @@ export class DatabaseProvider extends ServiceProvider {
180
199
 
181
200
  // N+1 query detection — enabled outside production. Previously activated by
182
201
  // the devtools provider; owned here so devtools needs no ORM import.
183
- const env = Bun.env.APP_ENV ?? "";
184
- if (env !== "production" && env !== "prod") {
202
+ //
203
+ // `deployEnv()`, not `Bun.env.APP_ENV`: the latter holds the runtime mode by
204
+ // the time a provider boots (`setAppEnv()` overwrote it), so this read was
205
+ // `"web"` and the detector was installed in production too — wrapping every
206
+ // query on a live app to warn about something nobody was there to read.
207
+ if (!isProdLike(deployEnv())) {
185
208
  preventNPlusOne({ threshold: 5, mode: "warn" });
186
209
  }
187
210
 
@@ -199,6 +222,12 @@ export class DatabaseProvider extends ServiceProvider {
199
222
  runner.registerLazy("migrate:fresh", () =>
200
223
  import("../commands/MigrateFreshCommand.ts").then((m) => m.MigrateFreshCommand),
201
224
  );
225
+ // Same command, the name it has elsewhere. Nothing otherwise pushes anyone to
226
+ // exercise their `down()` methods, and a rollback nobody has run is a
227
+ // rollback that does not work.
228
+ runner.registerLazy("migrate:refresh", () =>
229
+ import("../commands/MigrateRefreshCommand.ts").then((m) => m.MigrateRefreshCommand),
230
+ );
202
231
  runner.registerLazy("migrate:status", () =>
203
232
  import("../commands/MigrateStatusCommand.ts").then((m) => m.MigrateStatusCommand),
204
233
  );
@@ -648,6 +648,19 @@ export class Blueprint {
648
648
  return this;
649
649
  }
650
650
 
651
+ /**
652
+ * The columns this blueprint will drop.
653
+ *
654
+ * Read by {@link Schema.table} so it can refuse an impossible drop on SQLite
655
+ * *before* running any statement, rather than after the earlier ones have
656
+ * already applied.
657
+ *
658
+ * @internal
659
+ */
660
+ get _pendingDrops(): readonly string[] {
661
+ return this._drops;
662
+ }
663
+
651
664
  /**
652
665
  * Rename a column (`ALTER TABLE … RENAME COLUMN from TO to`).
653
666
  * @category Table modifiers
@@ -893,7 +906,7 @@ function _postgresAlterStatements(table: string, col: IAlterCol): string[] {
893
906
  }
894
907
 
895
908
  // NOT NULL / nullable.
896
- if (/NOT NULL/i.test(afterName)) {
909
+ if (/\bNOT NULL\b/i.test(afterName)) {
897
910
  stmts.push(`ALTER TABLE ${table} ALTER COLUMN ${name} SET NOT NULL`);
898
911
  } else {
899
912
  stmts.push(`ALTER TABLE ${table} ALTER COLUMN ${name} DROP NOT NULL`);
@@ -901,7 +914,7 @@ function _postgresAlterStatements(table: string, col: IAlterCol): string[] {
901
914
 
902
915
  // DEFAULT.
903
916
  const defMatch = afterName.match(
904
- /DEFAULT\s+(\S+(?:\s+\S+)*?)(?:\s+(?:NOT NULL|NULL|UNIQUE|CHECK|GENERATED)|$)/i,
917
+ /\bDEFAULT\s+(\S+(?:\s+\S+)*?)(?:\s+(?:NOT NULL|NULL|UNIQUE|CHECK|GENERATED)\b|$)/i,
905
918
  );
906
919
  if (defMatch) {
907
920
  stmts.push(`ALTER TABLE ${table} ALTER COLUMN ${name} SET DEFAULT ${defMatch[1]}`);
@@ -362,7 +362,7 @@ export class ColumnBuilder<Locked extends string = never> {
362
362
  * table.foreignId('author_id').constrained('users', 'uuid'); // → users.uuid
363
363
  * table.foreignId('user_id').nullable().constrained().nullOnDelete();
364
364
  */
365
- export class ForeignIdColumnBuilder extends ColumnBuilder {
365
+ export class ForeignIdColumnBuilder<Locked extends string = never> extends ColumnBuilder<Locked> {
366
366
  constructor(
367
367
  name: string,
368
368
  sqlType: string,
@@ -371,6 +371,40 @@ export class ForeignIdColumnBuilder extends ColumnBuilder {
371
371
  super(name, sqlType);
372
372
  }
373
373
 
374
+ /**
375
+ * Allow NULL, keeping `.constrained()` reachable.
376
+ *
377
+ * The base `nullable()` returns `ColumnBuilder`, which drops the subclass — so
378
+ * the documented `foreignId('user_id').nullable().constrained()` did not
379
+ * compile, and a nullable foreign key is the commonest kind there is. These
380
+ * two overrides re-declare the return as this builder while keeping the
381
+ * phantom lock, so `.nullable().notNullable()` is still a compile error.
382
+ *
383
+ * @locked `nullability` — shared with `notNullable()`.
384
+ * @category Nullability & defaults
385
+ */
386
+ override nullable(): "nullability" extends Locked
387
+ ? never
388
+ : ForeignIdColumnBuilder<Locked | "nullability"> {
389
+ super.nullable();
390
+ // `as any` per this file's own convention (see the header note): TypeScript
391
+ // cannot reduce a deferred conditional inside a generic body.
392
+ return this as any;
393
+ }
394
+
395
+ /**
396
+ * Enforce NOT NULL explicitly, keeping `.constrained()` reachable.
397
+ *
398
+ * @locked `nullability` — shared with `nullable()`.
399
+ * @category Nullability & defaults
400
+ */
401
+ override notNullable(): "nullability" extends Locked
402
+ ? never
403
+ : ForeignIdColumnBuilder<Locked | "nullability"> {
404
+ super.notNullable();
405
+ return this as any;
406
+ }
407
+
374
408
  /**
375
409
  * Add a `FOREIGN KEY` constraint for this column. The referenced table is
376
410
  * inferred from the column name (`user_id` → `users`) unless supplied
@@ -1,6 +1,9 @@
1
1
  import path from "node:path";
2
2
  import type { ColumnOptions } from "../model/decorators/column.ts";
3
3
  import { columnRegistry, columnsFor } from "../model/decorators/_metadata.ts";
4
+ import { ctorChain } from "../support/identifiers.ts";
5
+ import { isEncryptedCast } from "../casts/encrypted.ts";
6
+ import type { ClassRef } from "../support/classRef.ts";
4
7
 
5
8
  // ── Model schema descriptor ───────────────────────────────────────────────────
6
9
 
@@ -48,18 +51,28 @@ export function columnDbName(name: string): string {
48
51
  * declared on `User` appear in `AdminUser`'s schema without needing to be
49
52
  * re-declared.
50
53
  */
51
- function collectColumns(ctor: Function): Map<string, ColumnOptions> | null {
54
+ function collectColumns(ctor: ClassRef): Map<string, ColumnOptions> | null {
52
55
  // columnsFor walks the prototype chain (child overrides parent) and mirrors each
53
56
  // class's metadata into columnRegistry on first read.
54
57
  return columnsFor(ctor);
55
58
  }
56
59
 
57
- function toModelColumns(fields: Map<string, ColumnOptions>): ModelColumn[] {
60
+ function toModelColumns(
61
+ fields: Map<string, ColumnOptions>,
62
+ encrypted: ReadonlySet<string>,
63
+ ): ModelColumn[] {
58
64
  const columns: ModelColumn[] = [];
59
65
  for (const [name, opts] of fields.entries()) {
60
66
  columns.push({
61
67
  name,
62
- type: opts.type,
68
+ // An encrypted column is generated as TEXT whatever it was declared as. The
69
+ // stored payload is the plaintext plus 28 bytes of IV and auth tag, base64'd —
70
+ // about 1.4× longer — so a VARCHAR(255) that comfortably held the value no
71
+ // longer holds its ciphertext. MySQL outside strict mode truncates rather than
72
+ // failing, and a truncated payload will not decrypt: the row is lost, quietly,
73
+ // at write time. The generated migration says `table.text(...)`, so the
74
+ // widening is visible in review rather than only here.
75
+ type: encrypted.has(name) ? "text" : opts.type,
63
76
  nullable: opts.nullable ?? false,
64
77
  primary: opts.primary ?? false,
65
78
  default: opts.default,
@@ -70,6 +83,21 @@ function toModelColumns(fields: Map<string, ColumnOptions>): ModelColumn[] {
70
83
  return columns;
71
84
  }
72
85
 
86
+ /** Columns encrypted by either route — `cast: "encrypted…"` or `static encryptable`. */
87
+ function encryptedColumns(
88
+ chain: readonly object[],
89
+ fields: Map<string, ColumnOptions>,
90
+ ): Set<string> {
91
+ const names = new Set<string>();
92
+ for (const [name, opts] of fields.entries()) {
93
+ if (isEncryptedCast(opts.cast)) names.add(name);
94
+ }
95
+ for (const entry of chain) {
96
+ for (const key of (entry as { encryptable?: string[] }).encryptable ?? []) names.add(key);
97
+ }
98
+ return names;
99
+ }
100
+
73
101
  // ── ModelInspector ────────────────────────────────────────────────────────────
74
102
 
75
103
  /**
@@ -120,7 +148,7 @@ export const ModelInspector = {
120
148
  * Returns null if the class has no `static table` or no `@column()` fields
121
149
  * anywhere in its prototype chain.
122
150
  */
123
- fromClass(ctor: Function): ModelSchema | null {
151
+ fromClass(ctor: ClassRef): ModelSchema | null {
124
152
  const M = ctor as unknown as Record<string, unknown>;
125
153
  const table = M["table"] as string | undefined;
126
154
  if (!table) return null;
@@ -133,7 +161,7 @@ export const ModelInspector = {
133
161
  primaryKey: (M["primaryKey"] as string | undefined) ?? "id",
134
162
  timestamps: (M["timestamps"] as boolean | undefined) ?? true,
135
163
  softDeletes: (M["softDeletes"] as boolean | undefined) ?? false,
136
- columns: toModelColumns(fields),
164
+ columns: toModelColumns(fields, encryptedColumns(ctorChain(ctor), fields)),
137
165
  };
138
166
  },
139
167
  };
@@ -28,6 +28,54 @@ async function query<T = Record<string, unknown>>(sql: string, params: unknown[]
28
28
  return conn<T>(tpl, ...params);
29
29
  }
30
30
 
31
+ /**
32
+ * Throw if a column this blueprint drops is named by a foreign key on the table.
33
+ *
34
+ * SQLite has no way to drop an FK constraint through `ALTER TABLE`, so while the
35
+ * constraint names the column the column cannot go — the engine answers
36
+ * `unknown column "x" in foreign key definition`, and it answers *after* every
37
+ * earlier statement in the block has run. The standard way out is SQLite's own
38
+ * 12-step table rebuild; until that exists here, failing before the
39
+ * first statement is the difference between a migration that did nothing and
40
+ * one that has to be unpicked by hand.
41
+ *
42
+ * The message names the constraint and the way out, because "rebuild the table"
43
+ * is not obvious from the engine's own error.
44
+ */
45
+ async function _assertDroppableOnSqlite(table: string, bp: Blueprint): Promise<void> {
46
+ const drops = bp._pendingDrops;
47
+ if (drops.length === 0) return;
48
+
49
+ // `PRAGMA foreign_key_list` takes no bind parameters, and the table name here
50
+ // comes from the migration's own source rather than from a request.
51
+ const fks = await query<{ id: number; from: string; table: string }>(
52
+ `PRAGMA foreign_key_list(${table})`,
53
+ [],
54
+ );
55
+ if (fks.length === 0) return;
56
+
57
+ const blocked = drops.filter((column) =>
58
+ fks.some((fk) => String(fk.from).toLowerCase() === column.toLowerCase()),
59
+ );
60
+ if (blocked.length === 0) return;
61
+
62
+ const referenced = blocked
63
+ .map((column) => {
64
+ const fk = fks.find((f) => String(f.from).toLowerCase() === column.toLowerCase());
65
+ return `'${column}' (references ${fk?.table ?? "another table"})`;
66
+ })
67
+ .join(", ");
68
+
69
+ throw new Error(
70
+ `[Zerotal ORM] SQLite cannot drop ${referenced} from '${table}' while a foreign key ` +
71
+ `names the column — the constraint has to go first, and SQLite cannot drop one through ` +
72
+ `ALTER TABLE.\n\n` +
73
+ `Rebuild the table instead: create a replacement with the columns you want, copy the ` +
74
+ `rows across, drop the original, and rename. Nothing has been applied — this migration ` +
75
+ `stopped before its first statement, so the schema is exactly as it was.`,
76
+ );
77
+ }
78
+
31
79
  // ── Schema facade ─────────────────────────────────────────────────────────────
32
80
 
33
81
  /**
@@ -96,7 +144,19 @@ export const Schema = {
96
144
  async table(name: string, callback: (bp: Blueprint) => void): Promise<void> {
97
145
  const bp = new Blueprint();
98
146
  callback(bp);
99
- for (const sql of bp.toAlterSQL(name, _getDialect())) {
147
+ const dialect = _getDialect();
148
+
149
+ // Refuse before the first statement, not in the middle of the list.
150
+ //
151
+ // SQLite cannot drop a column a foreign key still names, and the error it
152
+ // raises — `unknown column "x" in foreign key definition` — arrives *after*
153
+ // every earlier statement in the same `Schema.table()` block has run. That
154
+ // is the difference between a migration that does nothing and one that has
155
+ // to be unpicked by hand. The check costs a single PRAGMA on the only path
156
+ // that can hit it.
157
+ if (dialect === "sqlite") await _assertDroppableOnSqlite(name, bp);
158
+
159
+ for (const sql of bp.toAlterSQL(name, dialect)) {
100
160
  await ddl(sql);
101
161
  }
102
162
  },
@@ -104,7 +164,7 @@ export const Schema = {
104
164
  /**
105
165
  * Alias of {@link Schema.table}, for modifying an existing table.
106
166
  *
107
- * `alter` is the name Laravel and Knex use, so it is the first thing reached for — and
167
+ * `alter` is the name most schema builders use, so it is the first thing reached for — and
108
168
  * because the blueprint callback is loosely typed, `Schema.alter(...)` was not a type
109
169
  * error, only a `TypeError` at run time. A migration that fails there has already run
110
170
  * whatever statements preceded it, leaving the schema half-changed, which is a worse
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The ORM's class-keyed registries — columns, relations, hooks, observers, global
3
+ * scopes, state-machine callbacks — all key on the model class and read back by
4
+ * walking its prototype chain. They share the framework's `ClassRef` rather than
5
+ * spelling the constructor type per registry; this module exists so the ORM's own
6
+ * modules import it from one place.
7
+ */
8
+ import type { ClassRef as CoreClassRef } from "@zerotal/core";
9
+
10
+ /**
11
+ * A model class used as a metadata key — the constructor, not an instance.
12
+ *
13
+ * This is the framework-wide `ClassRef` from `@zerotal/core`, re-exported because
14
+ * every ORM signature that registers or reads per-class metadata takes one:
15
+ * `registerColumn`, `registerRelation`, `columnsFor`, `relationsFor`,
16
+ * `registerObserver` and the hook registry all key on it. Being `abstract` with
17
+ * `never[]` constructor arguments, it accepts abstract bases and mixin-composed
18
+ * classes alike, while rejecting the plain callbacks the old `Function` typing let
19
+ * through.
20
+ *
21
+ * @internal Every ORM signature that takes one is itself `@internal`.
22
+ */
23
+ export type ClassRef = CoreClassRef;
@@ -12,6 +12,7 @@
12
12
  * helper that reads a property off a hydrated instance must derive the same
13
13
  * name hydration produced.
14
14
  */
15
+ import type { ClassRef } from "./classRef.ts";
15
16
 
16
17
  // DB schemas are static: the same names appear on every row. Caching the regex
17
18
  // result turns thousands of executions into Map lookups after the first query.
@@ -51,12 +52,12 @@ export function toSnakeColumn(s: string): string {
51
52
  * `Function.prototype` (not a falsy `.name`) so anonymous mixin classes are
52
53
  * still visited. Shared by cast collection and global-scope merging.
53
54
  */
54
- export function ctorChain(ctor: Function): Function[] {
55
- const chain: Function[] = [];
56
- let current: Function | null = ctor;
55
+ export function ctorChain(ctor: ClassRef): ClassRef[] {
56
+ const chain: ClassRef[] = [];
57
+ let current: ClassRef | null = ctor;
57
58
  while (current && current !== Function.prototype) {
58
59
  chain.unshift(current);
59
- current = Object.getPrototypeOf(current) as Function | null;
60
+ current = Object.getPrototypeOf(current) as ClassRef | null;
60
61
  }
61
62
  return chain;
62
63
  }