@zerotal/orm 1.0.3 → 1.1.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,30 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.1.0] — 2026-08-08
12
+
13
+ ### Fixed
14
+
15
+ - **A `json` column returns the type it was given.** Writing skipped `JSON.stringify` for values that were already strings, so a string went into the column as bare characters — `62812345678`, not `"62812345678"` — and the read side's `JSON.parse` turned it back into a number. A `json`-cast setting holding an account number came back as a number, and only for *some* values, since a string that fails to parse fell through unchanged. Encoding is now symmetric in both directions, and `where()` against a `json` column encodes the same way, so a query finds what a write stores. **Upgrade note:** rows written by an older version hold bare scalars, so a string column may still read back as a number, and a `where()` on a string will not match those older rows — they are stored unquoted. Only affects bare scalars in `json`/`array` columns; objects and arrays were always encoded and are untouched.
16
+ - **`bun zt make:model` generates a file that parses.** The stub emitted `@table('posts').withTimestamps()`, which is not valid decorator syntax — the grammar allows a call at the end of the chain, not in the middle — so every generated model failed with `Expected "class" but found "."`. The stub now emits plain `@table('posts')`; timestamps are on by default and the chained form needs outer parentheses, `@(table("x").withoutTimestamps())`. The same broken form is corrected in the `BaseModel` docblocks, and every generated stub is now parsed by a test rather than checked for substrings.
17
+ - **A column `default` is applied on insert.** A declared field that was never assigned was written as an explicit `NULL`, so the INSERT named the column, the database never applied its own default, and a `NOT NULL` column failed outright — on a model and migration that both declared `default: 0`. `undefined` now means "I didn't say": the declared default is used, or the column is omitted so the database decides. An explicit `null` still stores `NULL`.
18
+ - **A `Date` compared against a timestamp column matches again.** Bound values are serialised through the column's cast metadata, but the framework-managed `created_at` / `updated_at` / `deleted_at` carry no `@column` registration — so a `Date` was bound raw and matched nothing. `where("created_at", ">=", monthStart)` is the commonest reporting query there is, and it silently returned zero rows: a dashboard reading "0 this month" looks like a quiet month, not a broken query.
19
+
20
+ ### Added
21
+
22
+ - `Schema.alter(...)` as an alias of `Schema.table(...)`, and `table.datetime(...)` as an alias of `table.dateTime(...)`. Both are the names other frameworks use, neither was a type error because the blueprint callback is loosely typed, and both therefore failed as a `TypeError` mid-migration — after earlier statements had already run, leaving the schema half-changed.
23
+ - `@column("string", { nullable: true })` — a two-argument form. The shorthand keeps its type and cast; the options cannot contradict them.
24
+ - `@column({ unique: true })` and `@column({ index: true })`, carried through to both `migrate:generate` and `synchronize`. Uniqueness is usually a correctness property, and it was not expressible at all.
25
+ - Generated migrations index any `*_id` column. The reference cannot always be inferred, but the index can, and an unindexed foreign key is a table scan on every join.
26
+ - `"text"` is its own storage type rather than an alias for `"string"`, so a real `TEXT` column is expressible — the distinction matters on Postgres and MySQL.
27
+
28
+ ### Changed
29
+
30
+ - **`create()` narrows its payload to the mass-assignable columns** when a model declares `fillable` as a literal tuple (`as const`). A required column deliberately kept out of `fillable` was demanded by `InsertPayload` and refused by `fill()` at runtime: the type required exactly what the runtime forbade, and there was no spelling of `create()` that satisfied both. Models without a literal list are unaffected.
31
+ - `static fillable` / `static guarded` accept `readonly string[]`.
32
+ - A migration that fails with "already exists" now says that `database.synchronize` is the usual cause, since the raw driver error names nothing actionable.
33
+ - `make:model` generates `fillable` as a literal tuple and documents that a nullable column is declared `?: T | undefined` — under the scaffold's `exactOptionalPropertyTypes`, `?: T` cannot be assigned `undefined`, so the field could never be cleared.
34
+
11
35
  ## [1.0.3] — 2026-08-07
12
36
 
13
37
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/orm",
3
- "version": "1.0.3",
3
+ "version": "1.1.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.0.3",
34
- "@zerotal/validator": "1.0.3"
33
+ "@zerotal/core": "1.1.0",
34
+ "@zerotal/validator": "1.1.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "typescript": "^5.8.0"
@@ -7,13 +7,23 @@ import { createMigrationFile } from "./MakeMigrationCommand.ts";
7
7
  export function modelStub(name: string, tableName: string): string {
8
8
  return `import { BaseModel, column, table } from '@zerotal/orm';
9
9
 
10
- @table('${tableName}').withTimestamps()
10
+ @table('${tableName}')
11
11
  export class ${name} extends BaseModel {
12
12
  // Models guard every attribute by default. List the columns that may be
13
13
  // mass-assigned from user input via create() / fill().
14
- static override fillable: string[] = ['name'];
14
+ //
15
+ // \`as const\` is what lets create() narrow its payload to exactly these columns,
16
+ // so a column kept out of this list is neither required by the type nor accepted
17
+ // at runtime — instead of being demanded by one and refused by the other.
18
+ static override fillable = ['name'] as const;
15
19
 
16
20
  @column() name!: string;
21
+
22
+ // A nullable column is declared \`?: T | undefined\`, not \`?: T\`. The scaffold
23
+ // enables exactOptionalPropertyTypes, under which \`?: T\` means "may be absent,
24
+ // but never undefined" — so clearing the field, which is the whole point of a
25
+ // nullable column, would not typecheck.
26
+ // @column({ nullable: true }) note?: string | undefined;
17
27
  }
18
28
  `;
19
29
  }
@@ -9,10 +9,31 @@ import { ZerotalError } from "@zerotal/core";
9
9
  */
10
10
  export class MigrationError extends ZerotalError {
11
11
  constructor(migrationName: string, cause: Error) {
12
- super(`Migration '${migrationName}' failed: ${cause.message}`, "E_MIGRATION_FAILED", 500, {
13
- migration: migrationName,
14
- });
12
+ super(
13
+ `Migration '${migrationName}' failed: ${cause.message}` + _syncHint(cause),
14
+ "E_MIGRATION_FAILED",
15
+ 500,
16
+ { migration: migrationName },
17
+ );
15
18
  // Preserve the original error so its stack and message survive re-throwing.
16
19
  this.cause = cause;
17
20
  }
18
21
  }
22
+
23
+ /**
24
+ * "table X already exists" on a migration that is supposed to create it almost always
25
+ * means boot-time schema sync got there first: `database.synchronize` built the table
26
+ * from the model before the migration ran. The two cannot both own a table, and the
27
+ * raw driver error gives no hint that a config flag is responsible — so name it here,
28
+ * at the only moment anyone is looking.
29
+ */
30
+ function _syncHint(cause: Error): string {
31
+ if (!/already exists/i.test(cause.message)) return "";
32
+ return (
33
+ `\n\n This usually means database.synchronize created the table from your model ` +
34
+ `before the migration ran.\n` +
35
+ ` A table can have one owner: set synchronize: false in config/database.ts and let ` +
36
+ `migrations build the schema,\n` +
37
+ ` or delete the migration and let synchronize own it.`
38
+ );
39
+ }
@@ -35,7 +35,7 @@ import { type ManyToMany } from "./relations/RelationRegistry.ts";
35
35
  import { installReactiveAccessors, type ColumnOptions } from "./decorators/column.ts";
36
36
  import { columnsFor, relationsFor } from "./decorators/_metadata.ts";
37
37
  import { TransactionContext } from "../db/TransactionContext.ts";
38
- import type { InsertPayload, UpdatePayload } from "./payload.ts";
38
+ import type { InsertPayload, UpdatePayload, FillablePayload } from "./payload.ts";
39
39
  import type { WhereOperator, OrderDirection } from "../db/types.ts";
40
40
 
41
41
  let _dialect: "sqlite" | "postgres" | "mysql" = "sqlite";
@@ -311,8 +311,13 @@ function applyCastSet(value: unknown, cast: StringCast): unknown {
311
311
  return value;
312
312
  case "array":
313
313
  case "json":
314
- if (typeof value !== "string") return JSON.stringify(value);
315
- return value;
314
+ // Encode unconditionally. Skipping strings was meant to avoid double-encoding a
315
+ // value that was already JSON text, but that is indistinguishable from a string
316
+ // someone means to store — and guessing wrong changed the value's *type* between
317
+ // write and read: `"62812345678"` went in as bare characters and came back out of
318
+ // `JSON.parse` as a number. Encoding both ways symmetrically is the only version
319
+ // of this that round-trips.
320
+ return JSON.stringify(value);
316
321
  case "date":
317
322
  if (value instanceof Carbon) return _serializeDate(value.toDate());
318
323
  if (value instanceof Date) return _serializeDate(value);
@@ -366,7 +371,9 @@ function _serializeForWrite(
366
371
  serializedVal = applyCastSet(val, castOpt);
367
372
  } else if (colType === "boolean" && val !== null && val !== undefined) {
368
373
  serializedVal = val ? 1 : 0;
369
- } else if (colType === "json" && val !== null && typeof val !== "string") {
374
+ } else if (colType === "json" && val !== null) {
375
+ // See applyCastSet: strings are encoded too, so the column always holds valid JSON
376
+ // and a read returns the type that was written.
370
377
  serializedVal = JSON.stringify(val);
371
378
  } else {
372
379
  serializedVal = val;
@@ -460,7 +467,7 @@ export interface ScopeApplicator {
460
467
  * — TypeScript will catch typos and non-existent column references.
461
468
  *
462
469
  * @example
463
- * \@table("users").withTimestamps()
470
+ * \@table("users")
464
471
  * export class User extends BaseModel {
465
472
  * static fillable: Columns<User>[] = ["name", "email", "role"];
466
473
  * static hidden: Columns<User>[] = ["password"];
@@ -516,7 +523,7 @@ export type Columns<T> = {
516
523
  * @example
517
524
  * Defining a model with `@column`:
518
525
  * ```ts
519
- * @table("users").withTimestamps()
526
+ * @table("users")
520
527
  * export class User extends BaseModel {
521
528
  * static fillable: Columns<User>[] = ["name", "email", "password"];
522
529
  * static hidden: Columns<User>[] = ["password"];
@@ -660,7 +667,7 @@ export class BaseModel {
660
667
  *
661
668
  * @category Attributes & mass assignment
662
669
  */
663
- static fillable?: string[];
670
+ static fillable?: readonly string[];
664
671
 
665
672
  /**
666
673
  * Denylist of camelCase field names blocked from create() / fill().
@@ -669,7 +676,7 @@ export class BaseModel {
669
676
  *
670
677
  * @category Attributes & mass assignment
671
678
  */
672
- static guarded?: string[];
679
+ static guarded?: readonly string[];
673
680
 
674
681
  /**
675
682
  * Disable mass-assignment protection for this model — every attribute passed
@@ -1198,6 +1205,11 @@ export class BaseModel {
1198
1205
  * Mass-assign `data` (respecting {@link fillable} / {@link guarded}) onto a new
1199
1206
  * instance and {@link save} it, returning the persisted model.
1200
1207
  *
1208
+ * When the model declares `static fillable` as a literal tuple (`as const`), the
1209
+ * payload type is narrowed to exactly those columns — so a column deliberately kept
1210
+ * out of `fillable` is neither required nor accepted here, instead of being demanded
1211
+ * by the type and rejected by {@link fill} at runtime.
1212
+ *
1201
1213
  * @throws {MassAssignmentError} when `data` contains a non-fillable key.
1202
1214
  *
1203
1215
  * @example
@@ -1205,7 +1217,10 @@ export class BaseModel {
1205
1217
  *
1206
1218
  * @category Persistence
1207
1219
  */
1208
- static async create<T extends BaseModel>(this: ModelCtor<T>, data: InsertPayload<T>): Promise<T> {
1220
+ static async create<T extends BaseModel, F extends string = string>(
1221
+ this: ModelCtor<T> & { fillable?: readonly F[] | undefined },
1222
+ data: FillablePayload<T, F>,
1223
+ ): Promise<T> {
1209
1224
  const inst = new this();
1210
1225
  inst.fill(data as UpdatePayload<T>);
1211
1226
  return inst.save() as Promise<T>;
@@ -1604,9 +1619,12 @@ export class BaseModel {
1604
1619
  // On INSERT: hash every hashable field that holds a non-empty string.
1605
1620
  // On UPDATE: only hash hashable fields whose value changed since the last
1606
1621
  // save (avoids re-hashing an already-stored bcrypt hash).
1622
+ // One keyed view of the instance for the whole method: the hashable pass and the
1623
+ // insert's default-filling both need to read and write columns by name.
1624
+ const self = this as unknown as Record<string, unknown>;
1625
+
1607
1626
  const hashable = ModelClass.hashable;
1608
1627
  if (hashable && hashable.length > 0) {
1609
- const self = this as unknown as Record<string, unknown>;
1610
1628
  if (!this._exists) {
1611
1629
  for (const key of hashable) {
1612
1630
  const val = self[key];
@@ -1635,7 +1653,24 @@ export class BaseModel {
1635
1653
  const row: Record<string, unknown> = _writeDialect.run(dialect, () => {
1636
1654
  const r: Record<string, unknown> = {};
1637
1655
  for (const [key, val] of ownDataEntries(this, SYSTEM_KEYS, rels, colKeys)) {
1638
- r[toSnake(key)] = _serializeForWrite(key, val, casts, colReg);
1656
+ // A declared field that was never assigned is `undefined`, and writing that
1657
+ // as an explicit NULL made `@column({ default: … })` inert: the INSERT named
1658
+ // the column, so the database never applied its own default and a NOT NULL
1659
+ // column failed outright. Fall back to the declared default, and if there
1660
+ // isn't one, omit the column entirely so the database decides.
1661
+ //
1662
+ // Only `undefined` is treated this way. An explicit `null` is a deliberate
1663
+ // "store NULL" and still writes one.
1664
+ let effective = val;
1665
+ if (effective === undefined) {
1666
+ const declared = colReg?.get(key)?.default;
1667
+ if (declared === undefined) continue; // omit → database default / NULL
1668
+ effective = typeof declared === "function" ? (declared as () => unknown)() : declared;
1669
+ // Keep the instance consistent with the row we are about to write, so the
1670
+ // value is readable straight after save() without a reload.
1671
+ self[key] = effective;
1672
+ }
1673
+ r[toSnake(key)] = _serializeForWrite(key, effective, casts, colReg);
1639
1674
  }
1640
1675
  if (ModelClass.timestamps) {
1641
1676
  const now = _serializeDate(new Date());
@@ -1,6 +1,6 @@
1
1
  import type { SQLInstance } from "../db/sql-types.ts";
2
2
  import { Carbon } from "@zerotal/core/carbon";
3
- import { QueryBuilder, OPERATORS, _inlineValue } from "../db/QueryBuilder.ts";
3
+ import { QueryBuilder, OPERATORS, _inlineValue, dialectFor } from "../db/QueryBuilder.ts";
4
4
  import {
5
5
  toCamelKey as _toCamel,
6
6
  toSnakeColumn as _toSnakeColumn,
@@ -1261,11 +1261,26 @@ export class ModelQueryBuilder<M extends BaseModel> extends QueryBuilder {
1261
1261
  if (colType === "boolean") {
1262
1262
  return value ? 1 : 0;
1263
1263
  }
1264
- if (colType === "json" && typeof value !== "string") {
1264
+ if (colType === "json") {
1265
+ // Match how writes encode: strings are stringified too, so a column holding
1266
+ // `"051001"` is found by `where("value", "051001")` rather than only by a caller
1267
+ // who knows to pre-encode the quotes themselves.
1265
1268
  return JSON.stringify(value);
1266
1269
  }
1267
1270
  // Carbon instances always serialize to ISO string regardless of column metadata.
1268
1271
  if (value instanceof Carbon) return value.toDatabase();
1272
+ // …and so does a plain Date. Without this, a comparison against a column the cast
1273
+ // lookup above can't see — most importantly the framework-managed `created_at` /
1274
+ // `updated_at` / `deleted_at`, which carry no `@column` metadata — bound the Date
1275
+ // object itself and matched nothing. `where("created_at", ">=", monthStart)` is the
1276
+ // commonest reporting query there is, and it silently returned zero rows: a
1277
+ // dashboard reading "0 this month" looks like a quiet month, not a broken query.
1278
+ // A Date in a comparison is unambiguous intent, so serialize it the way writes do.
1279
+ if (value instanceof Date) {
1280
+ return dialectFor(this._sql) === "mysql"
1281
+ ? value.toISOString().replace("T", " ").slice(0, 19)
1282
+ : value.toISOString();
1283
+ }
1269
1284
  return value;
1270
1285
  }
1271
1286
 
@@ -19,8 +19,8 @@ export { columnRegistry };
19
19
  *
20
20
  * | Shorthand | Equivalent options |
21
21
  * |-------------|--------------------------------------------------|
22
- * | `"string"` | `{ type: "string" }` |
23
- * | `"text"` | `{ type: "string" }` (large text, alias) |
22
+ * | `"string"` | `{ type: "string" }` (VARCHAR) |
23
+ * | `"text"` | `{ type: "text" }` (unbounded TEXT) |
24
24
  * | `"integer"` | `{ type: "number", cast: "integer" }` |
25
25
  * | `"number"` | `{ type: "number" }` |
26
26
  * | `"float"` | `{ type: "number", cast: "float" }` |
@@ -68,14 +68,32 @@ export type ColumnShorthand =
68
68
  * ```
69
69
  */
70
70
  export interface ColumnOptions {
71
- /** Logical storage type; drives schema generation and auto-migration. @default "string" */
72
- type?: "string" | "number" | "boolean" | "datetime" | "json";
71
+ /**
72
+ * Logical storage type; drives schema generation and auto-migration.
73
+ *
74
+ * `"string"` is a bounded VARCHAR; `"text"` is the unbounded TEXT type — a real
75
+ * distinction on Postgres and MySQL, where a long body in a VARCHAR(255) is an
76
+ * error rather than a slow column.
77
+ *
78
+ * @default "string"
79
+ */
80
+ type?: "string" | "text" | "number" | "boolean" | "datetime" | "json";
73
81
  /** Mark this column as the table's primary key. */
74
82
  primary?: boolean;
75
83
  /** Allow SQL `NULL` for this column. */
76
84
  nullable?: boolean;
77
85
  /** Default value applied when none is provided. */
78
86
  default?: unknown;
87
+ /**
88
+ * Add a unique index on this column during schema generation / auto-migration.
89
+ *
90
+ * Declared here rather than left to a hand-written migration because uniqueness is
91
+ * usually a correctness property (a webhook idempotency key, an invoice number),
92
+ * and `migrate:generate` can only emit constraints it can see declared.
93
+ */
94
+ unique?: boolean;
95
+ /** Add a plain (non-unique) index on this column. */
96
+ index?: boolean;
79
97
  /**
80
98
  * Shorthand cast types automatically serialize/deserialize the column value.
81
99
  * Can also be a custom object with `get`/`set` functions for full control.
@@ -113,7 +131,7 @@ export interface ColumnOptions {
113
131
  */
114
132
  const SHORTHAND_MAP: Record<ColumnShorthand, ColumnOptions> = {
115
133
  string: { type: "string" },
116
- text: { type: "string" },
134
+ text: { type: "text" },
117
135
  integer: { type: "number", cast: "integer" },
118
136
  number: { type: "number" },
119
137
  float: { type: "number", cast: "float" },
@@ -124,7 +142,31 @@ const SHORTHAND_MAP: Record<ColumnShorthand, ColumnOptions> = {
124
142
  array: { type: "json", cast: "array" },
125
143
  };
126
144
 
127
- function resolveOptions(arg?: ColumnShorthand | ColumnOptions): ColumnOptions {
145
+ /**
146
+ * Resolve the decorator's arguments to one options object.
147
+ *
148
+ * `extra` is the second argument of the `@column("string", { nullable: true })` form —
149
+ * the shorthand covers the common case and nullability is the most common modifier, so
150
+ * writing `@column({ type: "number", cast: "integer", nullable: true })` just to say
151
+ * "nullable int" is noise. The shorthand's own `type`/`cast` win: `extra` is typed as
152
+ * `Omit<ColumnOptions, "type">`, so it cannot contradict the type it is modifying.
153
+ */
154
+ function resolveOptions(
155
+ arg?: ColumnShorthand | ColumnOptions,
156
+ extra?: Omit<ColumnOptions, "type">,
157
+ ): ColumnOptions {
158
+ const base = _resolveBase(arg);
159
+ if (!extra) return base;
160
+ // Spread `base` last for type/cast so the shorthand keeps ownership of them, and never
161
+ // write an explicit `undefined` — under exactOptionalPropertyTypes that is a distinct,
162
+ // and invalid, value rather than an absent key.
163
+ const merged: ColumnOptions = { ...extra };
164
+ if (base.type !== undefined) merged.type = base.type;
165
+ if (extra.cast === undefined && base.cast !== undefined) merged.cast = base.cast;
166
+ return merged;
167
+ }
168
+
169
+ function _resolveBase(arg?: ColumnShorthand | ColumnOptions): ColumnOptions {
128
170
  if (arg === undefined) return { type: "string" };
129
171
  if (typeof arg === "string") {
130
172
  const a = arg as string;
@@ -243,8 +285,15 @@ type ColumnDecorator = (value: undefined, context: ClassFieldDecoratorContext) =
243
285
  export function column(): ColumnDecorator;
244
286
  export function column(type: ColumnShorthand): ColumnDecorator;
245
287
  export function column(options: ColumnOptions): ColumnDecorator;
246
- export function column(arg?: ColumnShorthand | ColumnOptions): ColumnDecorator {
247
- const options = resolveOptions(arg);
288
+ export function column(
289
+ type: ColumnShorthand,
290
+ options: Omit<ColumnOptions, "type">,
291
+ ): ColumnDecorator;
292
+ export function column(
293
+ arg?: ColumnShorthand | ColumnOptions,
294
+ extra?: Omit<ColumnOptions, "type">,
295
+ ): ColumnDecorator {
296
+ const options = resolveOptions(arg, extra);
248
297
  // The decorator BODY runs synchronously at definition time with the correct
249
298
  // `context.name` (the only thing Bun 1.3.x compiles reliably for field decorators).
250
299
  // We can't defer to a field initializer or addInitializer — Bun cross-wires those
@@ -92,6 +92,36 @@ type ColumnKeys<T> = WritableKeys<T> &
92
92
  */
93
93
  export type InsertPayload<T> = Omit<Pick<T, ColumnKeys<T>>, AutoManagedKeys>;
94
94
 
95
+ /**
96
+ * The insert payload narrowed to a model's mass-assignable columns.
97
+ *
98
+ * `create()` funnels into `fill()`, which throws {@link MassAssignmentError} for any key
99
+ * outside `fillable`. Without this narrowing the type demands fields the runtime forbids:
100
+ * a non-optional column deliberately kept out of `fillable` — a compliance flag that must
101
+ * never come from a request body — is *required* by `InsertPayload`, and supplying it
102
+ * throws. There was no way to satisfy both.
103
+ *
104
+ * `Fillable` is inferred from the model's `static fillable`. Declare it `as const` (or as
105
+ * a literal tuple) and the payload becomes exactly the assignable columns: the flag is no
106
+ * longer required, and passing it is a compile error rather than a runtime one. When
107
+ * `fillable` is absent or widened to `string[]`, `Fillable` is `string` and the payload is
108
+ * the unnarrowed {@link InsertPayload}, so existing models are unaffected.
109
+ *
110
+ * @example
111
+ * class Customer extends BaseModel {
112
+ * static fillable = ['name', 'email'] as const;
113
+ * @column() name!: string;
114
+ * @column() email!: string;
115
+ * @column({ type: 'boolean', cast: 'boolean', default: false }) legalHold!: boolean;
116
+ * }
117
+ *
118
+ * await Customer.create({ name: 'Ada', email: 'ada@example.com' }); // ok — legalHold not required
119
+ * await Customer.create({ name: 'Ada', email: 'a@b.c', legalHold: true }); // compile error
120
+ */
121
+ export type FillablePayload<T, Fillable extends string> = string extends Fillable
122
+ ? InsertPayload<T>
123
+ : Pick<InsertPayload<T>, Extract<keyof InsertPayload<T>, Fillable>>;
124
+
95
125
  /**
96
126
  * Data shape accepted when updating an existing record.
97
127
  * Every field is optional — pass only what should change.
@@ -332,6 +332,19 @@ export class Blueprint {
332
332
  return this._add(new ColumnBuilder(name, "TEXT"));
333
333
  }
334
334
 
335
+ /**
336
+ * Alias of {@link Blueprint.dateTime}, spelled the way the column *type* is.
337
+ *
338
+ * The type string is lowercase (`@column({ type: "datetime" })`) while the builder
339
+ * method is camelCase, so reaching for `table.datetime(...)` is the natural mistake —
340
+ * and the blueprint is loosely typed, so it surfaced as a `TypeError` mid-migration
341
+ * rather than a compile error.
342
+ * @category Column types
343
+ */
344
+ datetime(name: string): ColumnBuilder {
345
+ return this.dateTime(name);
346
+ }
347
+
335
348
  /**
336
349
  * Timestamp column. Alias of {@link Blueprint.dateTime} — stored as `TEXT` (ISO-8601).
337
350
  * @category Column types
@@ -6,12 +6,23 @@ import { columnDbName, type ModelColumn, type ModelSchema } from "./ModelInspect
6
6
  // Maps @column({ type }) to the Blueprint method that generates the right SQL.
7
7
  const BLUEPRINT_METHOD: Record<string, string> = {
8
8
  string: "string",
9
+ text: "text",
9
10
  number: "integer",
10
11
  boolean: "boolean",
11
12
  datetime: "dateTime",
12
13
  json: "json",
13
14
  };
14
15
 
16
+ /**
17
+ * A column named `*_id` (or `*Id` before snake-casing) is a foreign key by convention,
18
+ * and an unindexed foreign key is a table scan on every join and every cascade check.
19
+ * The reference itself can't always be inferred — the target table is a guess — but the
20
+ * index can, and it is the half that matters for performance.
21
+ */
22
+ function _looksLikeForeignKey(dbName: string): boolean {
23
+ return dbName.endsWith("_id") && dbName !== "_id";
24
+ }
25
+
15
26
  // ── Code generation helpers ───────────────────────────────────────────────────
16
27
 
17
28
  function blueprintCall(col: ModelColumn, indent: string): string {
@@ -23,6 +34,26 @@ function blueprintCall(col: ModelColumn, indent: string): string {
23
34
  return line;
24
35
  }
25
36
 
37
+ /**
38
+ * The index lines for a table: everything declared via `@column({ unique | index })`,
39
+ * plus an inferred index on each foreign-key-shaped column that doesn't already have one.
40
+ */
41
+ function indexLines(columns: ModelColumn[], indent: string): string[] {
42
+ const lines: string[] = [];
43
+ for (const col of columns) {
44
+ if (col.primary) continue; // the PK is already indexed by increments()
45
+ const dbName = columnDbName(col.name);
46
+ if (col.unique) {
47
+ lines.push(`${indent}table.unique('${dbName}');`);
48
+ } else if (col.index) {
49
+ lines.push(`${indent}table.index('${dbName}');`);
50
+ } else if (_looksLikeForeignKey(dbName)) {
51
+ lines.push(`${indent}table.index('${dbName}');`);
52
+ }
53
+ }
54
+ return lines;
55
+ }
56
+
26
57
  function createTableBlock(schema: ModelSchema): string {
27
58
  const lines: string[] = [];
28
59
  lines.push(` await Schema.create('${schema.table}', (table) => {`);
@@ -36,6 +67,8 @@ function createTableBlock(schema: ModelSchema): string {
36
67
  if (schema.timestamps) lines.push(" table.timestamps();");
37
68
  if (schema.softDeletes) lines.push(" table.softDeletes();");
38
69
 
70
+ lines.push(...indexLines(schema.columns, " "));
71
+
39
72
  lines.push(" });");
40
73
  return lines.join("\n");
41
74
  }
@@ -57,6 +90,12 @@ function alterTableBlocks(newColumns: NewColumn[]): string {
57
90
  const lines: string[] = [];
58
91
  lines.push(` await Schema.table('${table}', (table) => {`);
59
92
  for (const nc of cols) lines.push(blueprintCall(nc.column, " "));
93
+ lines.push(
94
+ ...indexLines(
95
+ cols.map((nc) => nc.column),
96
+ " ",
97
+ ),
98
+ );
60
99
  lines.push(" });");
61
100
  blocks.push(lines.join("\n"));
62
101
  }
@@ -6,10 +6,14 @@ import { columnRegistry, columnsFor } from "../model/decorators/_metadata.ts";
6
6
 
7
7
  export interface ModelColumn {
8
8
  name: string;
9
- type: ColumnOptions["type"]; // 'string' | 'number' | 'boolean' | 'datetime' | 'json'
9
+ type: ColumnOptions["type"]; // 'string' | 'text' | 'number' | 'boolean' | 'datetime' | 'json'
10
10
  nullable: boolean;
11
11
  primary: boolean;
12
12
  default: unknown;
13
+ /** `@column({ unique: true })` — emit a unique index for this column. */
14
+ unique?: boolean;
15
+ /** `@column({ index: true })` — emit a plain index for this column. */
16
+ index?: boolean;
13
17
  }
14
18
 
15
19
  export interface ModelSchema {
@@ -59,6 +63,8 @@ function toModelColumns(fields: Map<string, ColumnOptions>): ModelColumn[] {
59
63
  nullable: opts.nullable ?? false,
60
64
  primary: opts.primary ?? false,
61
65
  default: opts.default,
66
+ unique: opts.unique ?? false,
67
+ index: opts.index ?? false,
62
68
  });
63
69
  }
64
70
  return columns;
@@ -101,6 +101,19 @@ export const Schema = {
101
101
  }
102
102
  },
103
103
 
104
+ /**
105
+ * Alias of {@link Schema.table}, for modifying an existing table.
106
+ *
107
+ * `alter` is the name Laravel and Knex use, so it is the first thing reached for — and
108
+ * because the blueprint callback is loosely typed, `Schema.alter(...)` was not a type
109
+ * error, only a `TypeError` at run time. A migration that fails there has already run
110
+ * whatever statements preceded it, leaving the schema half-changed, which is a worse
111
+ * outcome than one that never starts.
112
+ */
113
+ async alter(name: string, callback: (bp: Blueprint) => void): Promise<void> {
114
+ await Schema.table(name, callback);
115
+ },
116
+
104
117
  /** `DROP TABLE table_name` */
105
118
  async drop(table: string): Promise<void> {
106
119
  await ddl(`DROP TABLE ${table}`);
@@ -9,6 +9,7 @@ import type { ModelColumn } from "./ModelInspector.ts";
9
9
  // `migrate:generate` would - but applies it directly instead of writing a migration file.
10
10
  const BLUEPRINT_METHOD: Record<string, string> = {
11
11
  string: "string",
12
+ text: "text",
12
13
  number: "integer",
13
14
  boolean: "boolean",
14
15
  datetime: "dateTime",
@@ -21,15 +22,22 @@ type TableBuilder = Record<string, (name: string) => ColumnBuilder> & {
21
22
  timestamps(): void;
22
23
  softDeletes(): void;
23
24
  dropColumn(...names: string[]): unknown;
25
+ unique(columns: string | string[], name?: string): unknown;
26
+ index(columns: string | string[], name?: string): unknown;
24
27
  };
25
28
 
26
29
  function applyColumn(table: TableBuilder, col: ModelColumn): void {
27
30
  const method = BLUEPRINT_METHOD[col.type ?? "string"] ?? "string";
28
31
  // Models declare columns in camelCase; the ORM reads/writes snake_case — emit snake_case
29
32
  // so synchronize produces columns the runtime can actually read (e.g. two_factor_secret).
30
- const builder = table[method]!(columnDbName(col.name));
33
+ const dbName = columnDbName(col.name);
34
+ const builder = table[method]!(dbName);
31
35
  if (col.nullable) builder.nullable();
32
36
  if (col.default !== undefined) builder.default(col.default);
37
+ // Declared constraints travel with the column, so a synced schema carries the same
38
+ // uniqueness guarantee the model asserts rather than only the column's storage type.
39
+ if (col.unique) table.unique(dbName);
40
+ else if (col.index) table.index(dbName);
33
41
  }
34
42
 
35
43
  /** Options for {@link synchronizeSchema}. */