@shaferllc/keel 0.80.0 → 0.81.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.
Files changed (44) hide show
  1. package/dist/accounts/accounts.config.stub +50 -0
  2. package/dist/accounts/config.d.ts +46 -0
  3. package/dist/accounts/config.js +39 -0
  4. package/dist/accounts/flows.d.ts +50 -0
  5. package/dist/accounts/flows.js +133 -0
  6. package/dist/accounts/index.d.ts +28 -0
  7. package/dist/accounts/index.js +23 -0
  8. package/dist/accounts/migration.d.ts +14 -0
  9. package/dist/accounts/migration.js +39 -0
  10. package/dist/accounts/provider.d.ts +18 -0
  11. package/dist/accounts/provider.js +37 -0
  12. package/dist/accounts/routes.d.ts +15 -0
  13. package/dist/accounts/routes.js +116 -0
  14. package/dist/accounts/store.d.ts +33 -0
  15. package/dist/accounts/store.js +37 -0
  16. package/dist/accounts/tokens.d.ts +60 -0
  17. package/dist/accounts/tokens.js +116 -0
  18. package/dist/accounts/totp.d.ts +58 -0
  19. package/dist/accounts/totp.js +134 -0
  20. package/dist/accounts/two-factor.d.ts +56 -0
  21. package/dist/accounts/two-factor.js +146 -0
  22. package/dist/core/database.d.ts +36 -0
  23. package/dist/core/database.js +141 -4
  24. package/dist/core/index.d.ts +5 -2
  25. package/dist/core/index.js +3 -2
  26. package/dist/core/migrations.d.ts +52 -2
  27. package/dist/core/migrations.js +134 -3
  28. package/dist/core/model-events.d.ts +34 -0
  29. package/dist/core/model-events.js +71 -0
  30. package/dist/core/model-query.d.ts +68 -0
  31. package/dist/core/model-query.js +234 -0
  32. package/dist/core/model.d.ts +91 -4
  33. package/dist/core/model.js +217 -32
  34. package/dist/core/relations.d.ts +53 -0
  35. package/dist/core/relations.js +242 -0
  36. package/docs/accounts.md +214 -0
  37. package/docs/ai-manifest.json +63 -1
  38. package/docs/database.md +33 -0
  39. package/docs/examples/accounts.ts +150 -0
  40. package/docs/migrations.md +32 -3
  41. package/docs/models.md +133 -3
  42. package/llms-full.txt +419 -6
  43. package/llms.txt +2 -0
  44. package/package.json +6 -2
@@ -122,6 +122,10 @@ export class QueryBuilder {
122
122
  columns = "*";
123
123
  _limit;
124
124
  _offset;
125
+ joins = [];
126
+ groups = [];
127
+ havings = [];
128
+ _distinct = false;
125
129
  // The connection is resolved lazily, when a query actually runs — so building
126
130
  // a query never throws, and an unregistered connection surfaces as a rejected
127
131
  // read/write rather than a synchronous error at construction.
@@ -177,10 +181,57 @@ export class QueryBuilder {
177
181
  this.wheres.push({ boolean: "AND", sql: `${column} LIKE ?`, bindings: [pattern] });
178
182
  return this;
179
183
  }
184
+ /** Compare two columns (no binding): `whereColumn("updated_at", ">", "created_at")`. */
185
+ whereColumn(first, opOrSecond, second) {
186
+ const [op, col] = second === undefined ? ["=", opOrSecond] : [opOrSecond, second];
187
+ this.wheres.push({ boolean: "AND", sql: `${first} ${op} ${col}`, bindings: [] });
188
+ return this;
189
+ }
190
+ /** A raw WHERE fragment with its own bindings. */
191
+ whereRaw(sql, bindings = []) {
192
+ this.wheres.push({ boolean: "AND", sql, bindings });
193
+ return this;
194
+ }
195
+ join(table, first, opOrSecond, second) {
196
+ const [op, col] = second === undefined ? ["=", opOrSecond] : [opOrSecond, second];
197
+ this.joins.push(`INNER JOIN ${table} ON ${first} ${op} ${col}`);
198
+ return this;
199
+ }
200
+ leftJoin(table, first, opOrSecond, second) {
201
+ const [op, col] = second === undefined ? ["=", opOrSecond] : [opOrSecond, second];
202
+ this.joins.push(`LEFT JOIN ${table} ON ${first} ${op} ${col}`);
203
+ return this;
204
+ }
205
+ groupBy(...columns) {
206
+ this.groups.push(...columns);
207
+ return this;
208
+ }
209
+ having(column, opOrValue, value) {
210
+ const [op, val] = value === undefined ? ["=", opOrValue] : [opOrValue, value];
211
+ this.havings.push({ boolean: "AND", sql: `${column} ${op} ?`, bindings: [val] });
212
+ return this;
213
+ }
214
+ distinct() {
215
+ this._distinct = true;
216
+ return this;
217
+ }
218
+ /** Apply `then` only when `condition` is truthy (optionally `otherwise`). */
219
+ when(condition, then, otherwise) {
220
+ if (condition)
221
+ then(this, condition);
222
+ else
223
+ otherwise?.(this, condition);
224
+ return this;
225
+ }
180
226
  orderBy(column, direction = "asc") {
181
227
  this.orders.push(`${column} ${direction.toUpperCase()}`);
182
228
  return this;
183
229
  }
230
+ /** A raw ORDER BY fragment, e.g. `orderByRaw("LENGTH(name) DESC")`. */
231
+ orderByRaw(sql) {
232
+ this.orders.push(sql);
233
+ return this;
234
+ }
184
235
  /** Newest-first / oldest-first by a timestamp column (default `created_at`). */
185
236
  latest(column = "created_at") {
186
237
  return this.orderBy(column, "desc");
@@ -203,17 +254,32 @@ export class QueryBuilder {
203
254
  this.wheres.map((w, i) => (i === 0 ? "" : `${w.boolean} `) + w.sql).join(" ");
204
255
  return { sql, bindings: this.wheres.flatMap((w) => w.bindings) };
205
256
  }
257
+ havingClause() {
258
+ if (!this.havings.length)
259
+ return { sql: "", bindings: [] };
260
+ const sql = " HAVING " +
261
+ this.havings.map((w, i) => (i === 0 ? "" : `${w.boolean} `) + w.sql).join(" ");
262
+ return { sql, bindings: this.havings.flatMap((w) => w.bindings) };
263
+ }
264
+ joinSql() {
265
+ return this.joins.length ? ` ${this.joins.join(" ")}` : "";
266
+ }
206
267
  /* ------------------------------- reads ------------------------------- */
207
268
  async get() {
208
269
  const where = this.whereClause();
209
- let sql = `SELECT ${this.columns} FROM ${this.table}${where.sql}`;
270
+ const having = this.havingClause();
271
+ let sql = `SELECT ${this._distinct ? "DISTINCT " : ""}${this.columns} FROM ${this.table}`;
272
+ sql += this.joinSql() + where.sql;
273
+ if (this.groups.length)
274
+ sql += ` GROUP BY ${this.groups.join(", ")}`;
275
+ sql += having.sql;
210
276
  if (this.orders.length)
211
277
  sql += ` ORDER BY ${this.orders.join(", ")}`;
212
278
  if (this._limit != null)
213
279
  sql += ` LIMIT ${this._limit}`;
214
280
  if (this._offset != null)
215
281
  sql += ` OFFSET ${this._offset}`;
216
- return (await this.runSelect(sql, where.bindings));
282
+ return (await this.runSelect(sql, [...where.bindings, ...having.bindings]));
217
283
  }
218
284
  async first() {
219
285
  this._limit = 1;
@@ -222,7 +288,7 @@ export class QueryBuilder {
222
288
  }
223
289
  async count() {
224
290
  const where = this.whereClause();
225
- const rows = (await this.runSelect(`SELECT COUNT(*) AS count FROM ${this.table}${where.sql}`, where.bindings));
291
+ const rows = (await this.runSelect(`SELECT COUNT(*) AS count FROM ${this.table}${this.joinSql()}${where.sql}`, where.bindings));
226
292
  return Number(rows[0]?.count ?? 0);
227
293
  }
228
294
  async exists() {
@@ -230,7 +296,7 @@ export class QueryBuilder {
230
296
  }
231
297
  async aggregate(fn, column) {
232
298
  const where = this.whereClause();
233
- const rows = (await this.runSelect(`SELECT ${fn}(${column}) AS agg FROM ${this.table}${where.sql}`, where.bindings));
299
+ const rows = (await this.runSelect(`SELECT ${fn}(${column}) AS agg FROM ${this.table}${this.joinSql()}${where.sql}`, where.bindings));
234
300
  return Number(rows[0]?.agg ?? 0);
235
301
  }
236
302
  sum(column) {
@@ -295,6 +361,77 @@ export class QueryBuilder {
295
361
  const where = this.whereClause();
296
362
  return this.runWrite(`DELETE FROM ${this.table}${where.sql}`, where.bindings);
297
363
  }
364
+ /** Atomically add to a numeric column (optionally setting other columns too). */
365
+ increment(column, amount = 1, extra = {}) {
366
+ const where = this.whereClause();
367
+ const sets = [`${column} = ${column} + ?`, ...Object.keys(extra).map((k) => `${k} = ?`)];
368
+ const bindings = [amount, ...Object.values(extra), ...where.bindings];
369
+ return this.runWrite(`UPDATE ${this.table} SET ${sets.join(", ")}${where.sql}`, bindings);
370
+ }
371
+ /** Atomically subtract from a numeric column. */
372
+ decrement(column, amount = 1, extra = {}) {
373
+ const where = this.whereClause();
374
+ const sets = [`${column} = ${column} - ?`, ...Object.keys(extra).map((k) => `${k} = ?`)];
375
+ const bindings = [amount, ...Object.values(extra), ...where.bindings];
376
+ return this.runWrite(`UPDATE ${this.table} SET ${sets.join(", ")}${where.sql}`, bindings);
377
+ }
378
+ /** Insert one or more rows, ignoring any that violate a unique constraint. */
379
+ async insertOrIgnore(rows) {
380
+ const list = Array.isArray(rows) ? rows : [rows];
381
+ if (!list.length)
382
+ return { rowsAffected: 0 };
383
+ const dialect = this.getSource().dialect;
384
+ const keys = Object.keys(list[0]);
385
+ const values = list.map(() => `(${keys.map(() => "?").join(", ")})`).join(", ");
386
+ const bindings = list.flatMap((r) => keys.map((k) => r[k]));
387
+ const verb = dialect === "mysql" ? "INSERT IGNORE" : "INSERT";
388
+ const suffix = dialect === "postgres" || dialect === "sqlite" ? " ON CONFLICT DO NOTHING" : "";
389
+ return this.runWrite(`${verb} INTO ${this.table} (${keys.join(", ")}) VALUES ${values}${suffix}`, bindings);
390
+ }
391
+ /**
392
+ * Insert rows, updating `update` columns on a conflict against `uniqueBy`.
393
+ * Dialect-aware: `ON CONFLICT … DO UPDATE` (sqlite/postgres) or
394
+ * `ON DUPLICATE KEY UPDATE` (mysql).
395
+ */
396
+ async upsert(rows, uniqueBy, update) {
397
+ const list = Array.isArray(rows) ? rows : [rows];
398
+ if (!list.length)
399
+ return { rowsAffected: 0 };
400
+ const dialect = this.getSource().dialect;
401
+ const keys = Object.keys(list[0]);
402
+ const cols = update ?? keys.filter((k) => !uniqueBy.includes(k));
403
+ const values = list.map(() => `(${keys.map(() => "?").join(", ")})`).join(", ");
404
+ const bindings = list.flatMap((r) => keys.map((k) => r[k]));
405
+ let sql = `INSERT INTO ${this.table} (${keys.join(", ")}) VALUES ${values}`;
406
+ if (dialect === "mysql") {
407
+ sql += ` ON DUPLICATE KEY UPDATE ${cols.map((c) => `${c} = VALUES(${c})`).join(", ")}`;
408
+ }
409
+ else {
410
+ sql += ` ON CONFLICT (${uniqueBy.join(", ")}) DO UPDATE SET ${cols
411
+ .map((c) => `${c} = excluded.${c}`)
412
+ .join(", ")}`;
413
+ }
414
+ return this.runWrite(sql, bindings);
415
+ }
416
+ /**
417
+ * Process results in pages of `size`, so a large table never loads at once.
418
+ * The callback runs per page; return `false` from it to stop early.
419
+ */
420
+ async chunk(size, callback) {
421
+ let offset = 0;
422
+ for (;;) {
423
+ this._limit = size;
424
+ this._offset = offset;
425
+ const rows = await this.get();
426
+ if (!rows.length)
427
+ return;
428
+ if ((await callback(rows)) === false)
429
+ return;
430
+ if (rows.length < size)
431
+ return;
432
+ offset += size;
433
+ }
434
+ }
298
435
  }
299
436
  /** Start a query against a table, on the default connection or a named one. */
300
437
  export function db(table, connectionName) {
@@ -40,8 +40,11 @@ export type { CsrfOptions } from "./csrf.js";
40
40
  export { db, connection, getConnection, setConnection, addConnection, setDefaultConnection, connectionNames, clearConnections, transaction, inTransaction, QueryBuilder, } from "./database.js";
41
41
  export type { Connection, TransactionConnection, TransactionHandle, ConnectionHandle, WriteResult, Row, Dialect, Operator, Paginated, } from "./database.js";
42
42
  export { Model } from "./model.js";
43
+ export type { GlobalScope } from "./model.js";
44
+ export { ModelQuery } from "./model-query.js";
45
+ export type { ModelEvent, ModelHook, ModelObserver } from "./model-events.js";
43
46
  export type { CastType, Casts } from "./casts.js";
44
- export { Relation, HasOne, HasMany, BelongsTo, BelongsToMany } from "./relations.js";
47
+ export { Relation, HasOne, HasMany, BelongsTo, BelongsToMany, MorphOne, MorphMany, MorphTo, registerMorphType, } from "./relations.js";
45
48
  export { Faker, Factory as ModelFactory, factory, Seeder, seed } from "./factory.js";
46
49
  export type { Definition } from "./factory.js";
47
50
  export { Mailer, FakeMailer, PendingMail, BaseMail, SendMailJob, ArrayTransport, LogTransport, fetchTransport, mail, mailer, send, sendLater, setMailer, getMailer, fakeMail, restoreMail, } from "./mail.js";
@@ -55,7 +58,7 @@ export { Redis, MemoryRedis, redis, setRedis, redisStore } from "./redis.js";
55
58
  export type { RedisConnection, SetOptions } from "./redis.js";
56
59
  export { Notification, Notifier, MailChannel, DatabaseChannel, ArrayChannel, routeFor, notify, setNotifier, getNotifier, } from "./notification.js";
57
60
  export type { Notifiable, MailContent, Channel } from "./notification.js";
58
- export { SchemaBuilder, Migrator, TableBuilder, Column } from "./migrations.js";
61
+ export { SchemaBuilder, Migrator, TableBuilder, AlterTableBuilder, ForeignKeyBuilder, Column, } from "./migrations.js";
59
62
  export type { Migration } from "./migrations.js";
60
63
  export { ctx, json, text, html, redirect, param, query, header, body, request, response, } from "./request.js";
61
64
  export { decorateRequest, hasRequestDecorator, decorated, setRequestValue, clearRequestDecorators, } from "./decorators.js";
@@ -22,7 +22,8 @@ export { securityHeaders } from "./shield.js";
22
22
  export { csrf, csrfToken, csrfField } from "./csrf.js";
23
23
  export { db, connection, getConnection, setConnection, addConnection, setDefaultConnection, connectionNames, clearConnections, transaction, inTransaction, QueryBuilder, } from "./database.js";
24
24
  export { Model } from "./model.js";
25
- export { Relation, HasOne, HasMany, BelongsTo, BelongsToMany } from "./relations.js";
25
+ export { ModelQuery } from "./model-query.js";
26
+ export { Relation, HasOne, HasMany, BelongsTo, BelongsToMany, MorphOne, MorphMany, MorphTo, registerMorphType, } from "./relations.js";
26
27
  export { Faker, Factory as ModelFactory, factory, Seeder, seed } from "./factory.js";
27
28
  export { Mailer, FakeMailer, PendingMail, BaseMail, SendMailJob, ArrayTransport, LogTransport, fetchTransport, mail, mailer, send, sendLater, setMailer, getMailer, fakeMail, restoreMail, } from "./mail.js";
28
29
  export { Job, Queue, FakeQueue, SyncDriver, MemoryDriver, dispatch, work, setQueue, getQueue, fakeQueue, restoreQueue, exponentialBackoff, linearBackoff, fixedBackoff, noBackoff, } from "./queue.js";
@@ -30,7 +31,7 @@ export { Scheduler, ScheduledTask, scheduler, setScheduler, schedule, cronMatche
30
31
  export { MemoryBroadcaster, broadcast, setBroadcaster, getBroadcaster, channelAuth, authorizeChannel, clearChannels, } from "./broadcasting.js";
31
32
  export { Redis, MemoryRedis, redis, setRedis, redisStore } from "./redis.js";
32
33
  export { Notification, Notifier, MailChannel, DatabaseChannel, ArrayChannel, routeFor, notify, setNotifier, getNotifier, } from "./notification.js";
33
- export { SchemaBuilder, Migrator, TableBuilder, Column } from "./migrations.js";
34
+ export { SchemaBuilder, Migrator, TableBuilder, AlterTableBuilder, ForeignKeyBuilder, Column, } from "./migrations.js";
34
35
  export { ctx, json, text, html, redirect, param, query, header, body, request, response, } from "./request.js";
35
36
  export { decorateRequest, hasRequestDecorator, decorated, setRequestValue, clearRequestDecorators, } from "./decorators.js";
36
37
  export { View } from "./view.js";
@@ -31,9 +31,29 @@ export declare class Column {
31
31
  default(value: unknown): this;
32
32
  toSql(dialect: Dialect): string;
33
33
  }
34
- export declare class TableBuilder {
34
+ /** A foreign-key constraint, built fluently: `foreign("user_id").references("id").on("users")`. */
35
+ export declare class ForeignKeyBuilder {
36
+ private column;
37
+ private _refTable;
38
+ private _refColumn;
39
+ private _onDelete?;
40
+ private _onUpdate?;
41
+ constructor(column: string);
42
+ references(column: string): this;
43
+ on(table: string): this;
44
+ onDelete(action: string): this;
45
+ onUpdate(action: string): this;
46
+ toSql(): string;
47
+ }
48
+ interface IndexDef {
49
+ columns: string[];
50
+ unique: boolean;
51
+ name?: string;
52
+ }
53
+ /** Column-declaring methods shared by `createTable` and `alterTable` builders. */
54
+ declare abstract class ColumnBag {
35
55
  readonly columns: Column[];
36
- private add;
56
+ protected add(name: string, type: ColumnType, length?: number): Column;
37
57
  id(name?: string): Column;
38
58
  string(name: string, length?: number): Column;
39
59
  text(name: string): Column;
@@ -44,13 +64,43 @@ export declare class TableBuilder {
44
64
  json(name: string): Column;
45
65
  /** Adds nullable created_at + updated_at columns. */
46
66
  timestamps(): void;
67
+ }
68
+ export declare class TableBuilder extends ColumnBag {
69
+ readonly indexes: IndexDef[];
70
+ readonly foreignKeys: ForeignKeyBuilder[];
71
+ /** A (possibly composite) index. */
72
+ index(columns: string | string[], name?: string): this;
73
+ /** A (possibly composite) unique index. */
74
+ uniqueIndex(columns: string | string[], name?: string): this;
75
+ /** A foreign key: `foreign("user_id").references("id").on("users")`. */
76
+ foreign(column: string): ForeignKeyBuilder;
77
+ /** The `CREATE TABLE` plus any `CREATE INDEX` statements, in order. */
78
+ toStatements(table: string, dialect: Dialect): string[];
79
+ /** @deprecated Use `toStatements`. Kept for callers that build the SQL directly. */
47
80
  toCreateSql(table: string, dialect: Dialect): string;
48
81
  }
82
+ /** Alterations to an existing table: add/drop/rename columns, add/drop indexes. */
83
+ export declare class AlterTableBuilder extends ColumnBag {
84
+ private readonly drops;
85
+ private readonly renames;
86
+ private readonly addIndexes;
87
+ private readonly dropIndexes;
88
+ /** Drop a column. */
89
+ dropColumn(name: string): this;
90
+ /** Rename a column. */
91
+ renameColumn(from: string, to: string): this;
92
+ index(columns: string | string[], name?: string): this;
93
+ uniqueIndex(columns: string | string[], name?: string): this;
94
+ dropIndex(name: string): this;
95
+ toStatements(table: string, dialect: Dialect): string[];
96
+ }
49
97
  export declare class SchemaBuilder {
50
98
  private conn;
51
99
  private dialect;
52
100
  constructor(conn: Connection, dialect: Dialect);
53
101
  createTable(name: string, build: (table: TableBuilder) => void): Promise<void>;
102
+ /** Alter an existing table — add/drop/rename columns, add/drop indexes. */
103
+ alterTable(name: string, build: (table: AlterTableBuilder) => void): Promise<void>;
54
104
  dropTable(name: string): Promise<void>;
55
105
  raw(sql: string, bindings?: unknown[]): Promise<void>;
56
106
  }
@@ -87,7 +87,49 @@ export class Column {
87
87
  return sql;
88
88
  }
89
89
  }
90
- export class TableBuilder {
90
+ /** A foreign-key constraint, built fluently: `foreign("user_id").references("id").on("users")`. */
91
+ export class ForeignKeyBuilder {
92
+ column;
93
+ _refTable = "";
94
+ _refColumn = "id";
95
+ _onDelete;
96
+ _onUpdate;
97
+ constructor(column) {
98
+ this.column = column;
99
+ }
100
+ references(column) {
101
+ this._refColumn = column;
102
+ return this;
103
+ }
104
+ on(table) {
105
+ this._refTable = table;
106
+ return this;
107
+ }
108
+ onDelete(action) {
109
+ this._onDelete = action;
110
+ return this;
111
+ }
112
+ onUpdate(action) {
113
+ this._onUpdate = action;
114
+ return this;
115
+ }
116
+ toSql() {
117
+ let sql = `FOREIGN KEY (${this.column}) REFERENCES ${this._refTable}(${this._refColumn})`;
118
+ if (this._onDelete)
119
+ sql += ` ON DELETE ${this._onDelete}`;
120
+ if (this._onUpdate)
121
+ sql += ` ON UPDATE ${this._onUpdate}`;
122
+ return sql;
123
+ }
124
+ }
125
+ function toArray(value) {
126
+ return Array.isArray(value) ? value : [value];
127
+ }
128
+ function indexName(table, columns, unique) {
129
+ return `${table}_${columns.join("_")}_${unique ? "unique" : "index"}`;
130
+ }
131
+ /** Column-declaring methods shared by `createTable` and `alterTable` builders. */
132
+ class ColumnBag {
91
133
  columns = [];
92
134
  add(name, type, length) {
93
135
  const col = new Column(name, type, length);
@@ -123,8 +165,89 @@ export class TableBuilder {
123
165
  this.timestamp("created_at").nullable();
124
166
  this.timestamp("updated_at").nullable();
125
167
  }
168
+ }
169
+ export class TableBuilder extends ColumnBag {
170
+ indexes = [];
171
+ foreignKeys = [];
172
+ /** A (possibly composite) index. */
173
+ index(columns, name) {
174
+ this.indexes.push({ columns: toArray(columns), unique: false, ...(name ? { name } : {}) });
175
+ return this;
176
+ }
177
+ /** A (possibly composite) unique index. */
178
+ uniqueIndex(columns, name) {
179
+ this.indexes.push({ columns: toArray(columns), unique: true, ...(name ? { name } : {}) });
180
+ return this;
181
+ }
182
+ /** A foreign key: `foreign("user_id").references("id").on("users")`. */
183
+ foreign(column) {
184
+ const fk = new ForeignKeyBuilder(column);
185
+ this.foreignKeys.push(fk);
186
+ return fk;
187
+ }
188
+ /** The `CREATE TABLE` plus any `CREATE INDEX` statements, in order. */
189
+ toStatements(table, dialect) {
190
+ const parts = [
191
+ ...this.columns.map((c) => c.toSql(dialect)),
192
+ ...this.foreignKeys.map((fk) => fk.toSql()),
193
+ ];
194
+ const create = `CREATE TABLE ${table} (${parts.join(", ")})`;
195
+ return [create, ...this.indexes.map((i) => createIndexSql(table, i))];
196
+ }
197
+ /** @deprecated Use `toStatements`. Kept for callers that build the SQL directly. */
126
198
  toCreateSql(table, dialect) {
127
- return `CREATE TABLE ${table} (${this.columns.map((c) => c.toSql(dialect)).join(", ")})`;
199
+ return this.toStatements(table, dialect)[0];
200
+ }
201
+ }
202
+ function createIndexSql(table, index) {
203
+ const name = index.name ?? indexName(table, index.columns, index.unique);
204
+ return `CREATE ${index.unique ? "UNIQUE " : ""}INDEX ${name} ON ${table} (${index.columns.join(", ")})`;
205
+ }
206
+ /** Alterations to an existing table: add/drop/rename columns, add/drop indexes. */
207
+ export class AlterTableBuilder extends ColumnBag {
208
+ drops = [];
209
+ renames = [];
210
+ addIndexes = [];
211
+ dropIndexes = [];
212
+ /** Drop a column. */
213
+ dropColumn(name) {
214
+ this.drops.push(name);
215
+ return this;
216
+ }
217
+ /** Rename a column. */
218
+ renameColumn(from, to) {
219
+ this.renames.push({ from, to });
220
+ return this;
221
+ }
222
+ index(columns, name) {
223
+ this.addIndexes.push({ columns: toArray(columns), unique: false, ...(name ? { name } : {}) });
224
+ return this;
225
+ }
226
+ uniqueIndex(columns, name) {
227
+ this.addIndexes.push({ columns: toArray(columns), unique: true, ...(name ? { name } : {}) });
228
+ return this;
229
+ }
230
+ dropIndex(name) {
231
+ this.dropIndexes.push(name);
232
+ return this;
233
+ }
234
+ toStatements(table, dialect) {
235
+ const stmts = [];
236
+ // Drop indexes first, so a column an index references can then be dropped.
237
+ for (const name of this.dropIndexes) {
238
+ // MySQL drops indexes through ALTER TABLE; sqlite/postgres use DROP INDEX.
239
+ stmts.push(dialect === "mysql" ? `ALTER TABLE ${table} DROP INDEX ${name}` : `DROP INDEX ${name}`);
240
+ }
241
+ for (const col of this.columns)
242
+ stmts.push(`ALTER TABLE ${table} ADD COLUMN ${col.toSql(dialect)}`);
243
+ for (const { from, to } of this.renames) {
244
+ stmts.push(`ALTER TABLE ${table} RENAME COLUMN ${from} TO ${to}`);
245
+ }
246
+ for (const name of this.drops)
247
+ stmts.push(`ALTER TABLE ${table} DROP COLUMN ${name}`);
248
+ for (const index of this.addIndexes)
249
+ stmts.push(createIndexSql(table, index));
250
+ return stmts;
128
251
  }
129
252
  }
130
253
  export class SchemaBuilder {
@@ -137,7 +260,15 @@ export class SchemaBuilder {
137
260
  async createTable(name, build) {
138
261
  const table = new TableBuilder();
139
262
  build(table);
140
- await this.conn.write(table.toCreateSql(name, this.dialect), []);
263
+ for (const sql of table.toStatements(name, this.dialect))
264
+ await this.conn.write(sql, []);
265
+ }
266
+ /** Alter an existing table — add/drop/rename columns, add/drop indexes. */
267
+ async alterTable(name, build) {
268
+ const table = new AlterTableBuilder();
269
+ build(table);
270
+ for (const sql of table.toStatements(name, this.dialect))
271
+ await this.conn.write(sql, []);
141
272
  }
142
273
  async dropTable(name) {
143
274
  await this.conn.write(`DROP TABLE IF EXISTS ${name}`, []);
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Model lifecycle events. A model fires these as it is retrieved, saved, and
3
+ * deleted, so an app can hook behaviour onto a model without editing every call
4
+ * site — slug a title on `creating`, bust a cache on `saved`, cascade on
5
+ * `deleting`. The `*ing` events are cancelable: a hook returning `false` aborts
6
+ * the operation (the write never runs).
7
+ *
8
+ * User.creating((user) => { user.uuid = crypto.randomUUID(); });
9
+ * User.deleting((user) => user.isAdmin ? false : undefined); // veto
10
+ * User.observe(new UserObserver());
11
+ *
12
+ * Hooks are keyed by the exact model class (subclasses don't inherit a parent's
13
+ * hooks), stored off the class in a WeakMap so they never leak into instances or
14
+ * `save()`'s column spread.
15
+ */
16
+ import type { Model } from "./model.js";
17
+ export type ModelEvent = "retrieved" | "creating" | "created" | "updating" | "updated" | "saving" | "saved" | "deleting" | "deleted" | "restoring" | "restored";
18
+ /** A lifecycle hook. Returning `false` from a cancelable event aborts the op. */
19
+ export type ModelHook<T extends Model = Model> = (model: T) => void | boolean | Promise<void | boolean>;
20
+ /** An observer: an object whose methods are named after the events they handle. */
21
+ export type ModelObserver<T extends Model = Model> = Partial<Record<ModelEvent, ModelHook<T>>>;
22
+ /** The full event list, for wiring up an observer. */
23
+ export declare const MODEL_EVENTS: ModelEvent[];
24
+ /** Register a hook for `event` on a model class. */
25
+ export declare function addModelHook(cls: object, event: ModelEvent, hook: ModelHook): void;
26
+ /** Attach every matching method of an observer object as a hook. */
27
+ export declare function addModelObserver(cls: object, observer: ModelObserver): void;
28
+ /** Drop a class's hooks (or all of them) — a test helper for a clean slate. */
29
+ export declare function clearModelHooks(cls?: object): void;
30
+ /**
31
+ * Fire an event's hooks in registration order. Returns `false` only when a
32
+ * cancelable event had a hook return `false` — the caller then skips the write.
33
+ */
34
+ export declare function fireModelEvent(cls: object, event: ModelEvent, model: Model): Promise<boolean>;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Model lifecycle events. A model fires these as it is retrieved, saved, and
3
+ * deleted, so an app can hook behaviour onto a model without editing every call
4
+ * site — slug a title on `creating`, bust a cache on `saved`, cascade on
5
+ * `deleting`. The `*ing` events are cancelable: a hook returning `false` aborts
6
+ * the operation (the write never runs).
7
+ *
8
+ * User.creating((user) => { user.uuid = crypto.randomUUID(); });
9
+ * User.deleting((user) => user.isAdmin ? false : undefined); // veto
10
+ * User.observe(new UserObserver());
11
+ *
12
+ * Hooks are keyed by the exact model class (subclasses don't inherit a parent's
13
+ * hooks), stored off the class in a WeakMap so they never leak into instances or
14
+ * `save()`'s column spread.
15
+ */
16
+ /** Events whose hooks can veto the operation by returning `false`. */
17
+ const CANCELABLE = new Set(["creating", "updating", "saving", "deleting", "restoring"]);
18
+ /** The full event list, for wiring up an observer. */
19
+ export const MODEL_EVENTS = [
20
+ "retrieved",
21
+ "creating",
22
+ "created",
23
+ "updating",
24
+ "updated",
25
+ "saving",
26
+ "saved",
27
+ "deleting",
28
+ "deleted",
29
+ "restoring",
30
+ "restored",
31
+ ];
32
+ // Class object → event → hooks. A WeakMap so unloaded classes are collectable.
33
+ const registry = new WeakMap();
34
+ /** Register a hook for `event` on a model class. */
35
+ export function addModelHook(cls, event, hook) {
36
+ let byEvent = registry.get(cls);
37
+ if (!byEvent)
38
+ registry.set(cls, (byEvent = new Map()));
39
+ const hooks = byEvent.get(event) ?? [];
40
+ hooks.push(hook);
41
+ byEvent.set(event, hooks);
42
+ }
43
+ /** Attach every matching method of an observer object as a hook. */
44
+ export function addModelObserver(cls, observer) {
45
+ for (const event of MODEL_EVENTS) {
46
+ const hook = observer[event];
47
+ if (typeof hook === "function")
48
+ addModelHook(cls, event, hook);
49
+ }
50
+ }
51
+ /** Drop a class's hooks (or all of them) — a test helper for a clean slate. */
52
+ export function clearModelHooks(cls) {
53
+ if (cls)
54
+ registry.delete(cls);
55
+ }
56
+ /**
57
+ * Fire an event's hooks in registration order. Returns `false` only when a
58
+ * cancelable event had a hook return `false` — the caller then skips the write.
59
+ */
60
+ export async function fireModelEvent(cls, event, model) {
61
+ const hooks = registry.get(cls)?.get(event);
62
+ if (!hooks?.length)
63
+ return true;
64
+ const cancelable = CANCELABLE.has(event);
65
+ for (const hook of hooks) {
66
+ const result = await hook(model);
67
+ if (cancelable && result === false)
68
+ return false;
69
+ }
70
+ return true;
71
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * A model-aware query builder — what `Model.query()` sugar and eager loading are
3
+ * built on. It wraps the plain `QueryBuilder`, hydrates rows into models (firing
4
+ * `retrieved`), and adds the relationship-aware operations Eloquent has and a raw
5
+ * builder can't: `with()` (nested eager loading), `withCount()`, and existence
6
+ * filters `has()` / `whereHas()` / `doesntHave()`.
7
+ *
8
+ * const users = await User.query()
9
+ * .where("active", true)
10
+ * .with("posts.comments")
11
+ * .withCount("posts")
12
+ * .whereHas("posts", (q) => q.where("published", true))
13
+ * .get();
14
+ *
15
+ * Existence filters use the same two-query strategy as the relations themselves
16
+ * (a `whereIn` over keys gathered from the related table), so everything stays on
17
+ * the driver-agnostic builder — no JOIN or correlated subquery required. They're
18
+ * recorded and resolved at `get()`/`first()`/`count()` time so the chain stays
19
+ * synchronous.
20
+ */
21
+ import type { QueryBuilder, Row, Paginated } from "./database.js";
22
+ import type { Model } from "./model.js";
23
+ type ModelClass<T extends Model> = new (attributes?: Row) => T;
24
+ type Constrain = (q: QueryBuilder) => void;
25
+ export declare class ModelQuery<T extends Model> {
26
+ private cls;
27
+ private builder;
28
+ private eagers;
29
+ private counts;
30
+ private hasConstraints;
31
+ constructor(cls: ModelClass<T>, builder: QueryBuilder);
32
+ /** The wrapped builder, for anything `ModelQuery` doesn't proxy. */
33
+ toBase(): QueryBuilder;
34
+ where(column: string, opOrValue: unknown, value?: unknown): this;
35
+ orWhere(column: string, opOrValue: unknown, value?: unknown): this;
36
+ whereIn(column: string, values: unknown[]): this;
37
+ whereNotIn(column: string, values: unknown[]): this;
38
+ whereNull(column: string): this;
39
+ whereNotNull(column: string): this;
40
+ whereBetween(column: string, range: [unknown, unknown]): this;
41
+ whereLike(column: string, pattern: string): this;
42
+ orderBy(column: string, direction?: "asc" | "desc"): this;
43
+ latest(column?: string): this;
44
+ oldest(column?: string): this;
45
+ limit(n: number): this;
46
+ offset(n: number): this;
47
+ /** Eager-load relations (dotted paths for nesting: `"posts.comments"`). */
48
+ with(...names: string[]): this;
49
+ /** Add a `<relation>_count` to each result. */
50
+ withCount(...names: string[]): this;
51
+ /** Constrain to models that have at least one related row. */
52
+ has(name: string): this;
53
+ /** Constrain to models whose related rows match `constrain`. */
54
+ whereHas(name: string, constrain?: Constrain): this;
55
+ /** Constrain to models with no matching related row. */
56
+ doesntHave(name: string, constrain?: Constrain): this;
57
+ get(): Promise<T[]>;
58
+ first(): Promise<T | null>;
59
+ count(): Promise<number>;
60
+ exists(): Promise<boolean>;
61
+ paginate(page?: number, perPage?: number): Promise<Paginated<T>>;
62
+ private hydrate;
63
+ private relationFor;
64
+ /** Turn recorded has/doesntHave constraints into `whereIn`/`whereNotIn` on the builder. */
65
+ private resolveHasConstraints;
66
+ private loadCounts;
67
+ }
68
+ export {};