@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
@@ -17,8 +17,12 @@
17
17
  */
18
18
  import { db } from "./database.js";
19
19
  import { NotFoundException } from "./exceptions.js";
20
- import { BelongsTo, BelongsToMany, HasMany, HasOne } from "./relations.js";
20
+ import { BelongsTo, BelongsToMany, HasMany, HasOne, MorphMany, MorphOne, MorphTo } from "./relations.js";
21
21
  import { applyCasts, castGet, castSet } from "./casts.js";
22
+ import { addModelHook, addModelObserver, fireModelEvent, } from "./model-events.js";
23
+ import { ModelQuery } from "./model-query.js";
24
+ /** Registered global scopes, keyed by the model class. */
25
+ const globalScopes = new WeakMap();
22
26
  /**
23
27
  * Loaded relations live off the model itself so they never leak into `save()`
24
28
  * (which spreads own columns) — they're keyed here by the owning instance.
@@ -46,14 +50,120 @@ export class Model {
46
50
  static timestamps = false;
47
51
  static createdAtColumn = "created_at";
48
52
  static updatedAtColumn = "updated_at";
53
+ /** Columns stripped from `toJSON()` output (e.g. `["password"]`). */
54
+ static hidden = [];
55
+ /** If set, ONLY these keys survive `toJSON()` — an allowlist that wins over `hidden`. */
56
+ static visible = [];
57
+ /** Computed accessor names (getters or zero-arg methods) added to `toJSON()`. */
58
+ static appends = [];
59
+ /** Soft deletes: `delete()` sets `deleted_at` instead of removing the row. */
60
+ static softDeletes = false;
61
+ static deletedAtColumn = "deleted_at";
49
62
  constructor(attributes = {}) {
50
63
  // Hydration is unguarded (rows come from the database) but always cast.
51
64
  Object.assign(this, applyCasts(attributes, this.constructor.casts, castGet));
52
65
  }
53
66
  /* ------------------------------ static -------------------------------- */
54
- /** A raw query builder scoped to this model's table. */
67
+ /* ---------------------------- scopes & events ------------------------- */
68
+ /** Register a global scope — a constraint applied to every query this model builds. */
69
+ static addGlobalScope(name, scope) {
70
+ let map = globalScopes.get(this);
71
+ if (!map)
72
+ globalScopes.set(this, (map = new Map()));
73
+ map.set(name, scope);
74
+ }
75
+ /** Build this model's base query, applying global scopes and the soft-delete filter. */
76
+ static baseQuery(trashed = "exclude") {
77
+ const query = db(this.table, this.connection);
78
+ for (const scope of globalScopes.get(this)?.values() ?? [])
79
+ scope(query);
80
+ if (this.softDeletes) {
81
+ if (trashed === "exclude")
82
+ query.whereNull(this.deletedAtColumn);
83
+ else if (trashed === "only")
84
+ query.whereNotNull(this.deletedAtColumn);
85
+ }
86
+ return query;
87
+ }
88
+ /** A query builder scoped to this model's table (global scopes applied). */
55
89
  static query() {
56
- return db(this.table, this.connection);
90
+ return this.baseQuery();
91
+ }
92
+ /** Include soft-deleted rows (bypasses the soft-delete scope). */
93
+ static withTrashed() {
94
+ return this.baseQuery("with");
95
+ }
96
+ /** Only soft-deleted rows. */
97
+ static onlyTrashed() {
98
+ return this.baseQuery("only");
99
+ }
100
+ /* --------------------- model-aware query (eager, has) ----------------- */
101
+ /** A model-aware query — hydrates to models and supports `with`/`withCount`/`whereHas`. */
102
+ static newQuery() {
103
+ return new ModelQuery(this, this.baseQuery());
104
+ }
105
+ /** Start a query eager-loading the given relations (dotted paths nest). */
106
+ static with(...names) {
107
+ return this.newQuery().with(...names);
108
+ }
109
+ /** Start a query counting the given relations into `<relation>_count`. */
110
+ static withCount(...names) {
111
+ return this.newQuery().withCount(...names);
112
+ }
113
+ /** Start a query constrained to models that have at least one related row. */
114
+ static has(name) {
115
+ return this.newQuery().has(name);
116
+ }
117
+ /** Start a query constrained to models whose related rows match `constrain`. */
118
+ static whereHas(name, constrain) {
119
+ return this.newQuery().whereHas(name, constrain);
120
+ }
121
+ /** Start a query constrained to models with no matching related row. */
122
+ static doesntHave(name, constrain) {
123
+ return this.newQuery().doesntHave(name, constrain);
124
+ }
125
+ /** Construct a model from a row and fire its `retrieved` event. */
126
+ static async hydrate(row) {
127
+ const model = new this(row);
128
+ await fireModelEvent(this, "retrieved", model);
129
+ return model;
130
+ }
131
+ static creating(hook) {
132
+ addModelHook(this, "creating", hook);
133
+ }
134
+ static created(hook) {
135
+ addModelHook(this, "created", hook);
136
+ }
137
+ static updating(hook) {
138
+ addModelHook(this, "updating", hook);
139
+ }
140
+ static updated(hook) {
141
+ addModelHook(this, "updated", hook);
142
+ }
143
+ static saving(hook) {
144
+ addModelHook(this, "saving", hook);
145
+ }
146
+ static saved(hook) {
147
+ addModelHook(this, "saved", hook);
148
+ }
149
+ static deleting(hook) {
150
+ addModelHook(this, "deleting", hook);
151
+ }
152
+ static deleted(hook) {
153
+ addModelHook(this, "deleted", hook);
154
+ }
155
+ static restoring(hook) {
156
+ addModelHook(this, "restoring", hook);
157
+ }
158
+ static restored(hook) {
159
+ addModelHook(this, "restored", hook);
160
+ }
161
+ static retrieved(hook) {
162
+ addModelHook(this, "retrieved", hook);
163
+ }
164
+ /** Register an observer whose methods are named after the events they handle. */
165
+ static observe(observer) {
166
+ addModelObserver(this, observer);
57
167
  }
58
168
  /** Keep only the attributes mass-assignment allows (fillable / guarded). */
59
169
  static filterFillable(attributes) {
@@ -88,12 +198,12 @@ export class Model {
88
198
  return out;
89
199
  }
90
200
  static async all() {
91
- const rows = await db(this.table, this.connection).get();
92
- return rows.map((row) => new this(row));
201
+ const rows = await this.baseQuery().get();
202
+ return Promise.all(rows.map((row) => this.hydrate(row)));
93
203
  }
94
204
  static async find(id) {
95
- const row = await db(this.table, this.connection).where(this.primaryKey, id).first();
96
- return row ? new this(row) : null;
205
+ const row = await this.baseQuery().where(this.primaryKey, id).first();
206
+ return row ? this.hydrate(row) : null;
97
207
  }
98
208
  static async findOrFail(id) {
99
209
  const model = await this.find(id);
@@ -102,54 +212,49 @@ export class Model {
102
212
  return model;
103
213
  }
104
214
  static async first() {
105
- const row = await db(this.table, this.connection).first();
106
- return row ? new this(row) : null;
215
+ const row = await this.baseQuery().first();
216
+ return row ? this.hydrate(row) : null;
107
217
  }
108
218
  /** Fetch models matching a simple equality condition. */
109
219
  static async where(column, value) {
110
- const rows = await db(this.table, this.connection).where(column, value).get();
111
- return rows.map((row) => new this(row));
220
+ const rows = await this.baseQuery().where(column, value).get();
221
+ return Promise.all(rows.map((row) => this.hydrate(row)));
112
222
  }
113
223
  static async create(attributes) {
114
- const filtered = this.filterFillable(attributes);
115
- const write = this.stampTimestamps(this.toDatabase(filtered), true);
116
- const id = await db(this.table, this.connection).insertGetId(write);
117
- const model = new this(filtered);
118
- if (this.timestamps) {
119
- model[this.createdAtColumn] = write[this.createdAtColumn];
120
- model[this.updatedAtColumn] = write[this.updatedAtColumn];
121
- }
122
- if (id != null)
123
- model[this.primaryKey] = id;
224
+ // Route through save() so mass-assignment, timestamps, and the saving/creating
225
+ // lifecycle events all apply in one place.
226
+ const model = new this();
227
+ model.fill(attributes);
228
+ await model.save();
124
229
  return model;
125
230
  }
126
231
  /** A page of models plus pagination metadata. */
127
232
  static async paginate(page = 1, perPage = 15) {
128
- const result = await db(this.table, this.connection).paginate(page, perPage);
129
- return { ...result, data: result.data.map((row) => new this(row)) };
233
+ const result = await this.baseQuery().paginate(page, perPage);
234
+ return { ...result, data: await Promise.all(result.data.map((row) => this.hydrate(row))) };
130
235
  }
131
236
  /** Find the first row matching `match`, or create one from `{ ...match, ...values }`. */
132
237
  static async firstOrCreate(match, values = {}) {
133
238
  const row = await this.matching(match).first();
134
239
  if (row)
135
- return new this(row);
240
+ return this.hydrate(row);
136
241
  return this.create({ ...match, ...values });
137
242
  }
138
243
  /** Update the first row matching `match` with `values`, or create it. */
139
244
  static async updateOrCreate(match, values = {}) {
140
245
  const row = await this.matching(match).first();
141
246
  if (row) {
142
- const model = new this(row);
247
+ const model = await this.hydrate(row);
143
248
  await model.forceFill(values).save();
144
249
  return model;
145
250
  }
146
251
  return this.create({ ...match, ...values });
147
252
  }
148
- /** A query scoped to every column/value in `match`. */
253
+ /** A query scoped to every column/value in `match` (global scopes applied). */
149
254
  static matching(match) {
150
- let q = db(this.table, this.connection);
255
+ const q = this.baseQuery();
151
256
  for (const [column, value] of Object.entries(match))
152
- q = q.where(column, value);
257
+ q.where(column, value);
153
258
  return q;
154
259
  }
155
260
  /**
@@ -197,6 +302,18 @@ export class Model {
197
302
  .join("_"), foreignPivotKey = `${this.constructor.name.toLowerCase()}_${this.ctor().primaryKey}`, relatedPivotKey = `${related.name.toLowerCase()}_${related.primaryKey}`, parentKey = this.ctor().primaryKey, relatedKey = related.primaryKey) {
198
303
  return new BelongsToMany(this, related, pivotTable, foreignPivotKey, relatedPivotKey, parentKey, relatedKey);
199
304
  }
305
+ /** Polymorphic one-to-many: children carry `<name>_id` + `<name>_type`. */
306
+ morphMany(related, name, localKey = this.ctor().primaryKey) {
307
+ return new MorphMany(this, related, this.constructor.name, `${name}_id`, `${name}_type`, localKey);
308
+ }
309
+ /** Polymorphic one-to-one. */
310
+ morphOne(related, name, localKey = this.ctor().primaryKey) {
311
+ return new MorphOne(this, related, this.constructor.name, `${name}_id`, `${name}_type`, localKey);
312
+ }
313
+ /** The owning side of a polymorphic relation — resolves via `<name>_type` (see `registerMorphType`). */
314
+ morphTo(name, idColumn = `${name}_id`, typeColumn = `${name}_type`) {
315
+ return new MorphTo(this, idColumn, typeColumn);
316
+ }
200
317
  /** Store an eager-loaded relation result under `name`. */
201
318
  setRelation(name, value) {
202
319
  const bag = relationStore.get(this) ?? {};
@@ -208,12 +325,18 @@ export class Model {
208
325
  getRelation(name) {
209
326
  return relationStore.get(this)?.[name];
210
327
  }
211
- /** Insert (no primary key) or update (has one). */
328
+ /** Insert (no primary key) or update (has one). Fires save/create/update events. */
212
329
  async save() {
213
330
  const ctor = this.ctor();
331
+ const cls = this.constructor;
214
332
  const { table, primaryKey, connection } = ctor;
215
333
  const idValue = this[primaryKey];
216
334
  const forInsert = idValue == null;
335
+ // `*ing` hooks run before the write and may mutate the model or veto it.
336
+ if (!(await fireModelEvent(cls, "saving", this)))
337
+ return this;
338
+ if (!(await fireModelEvent(cls, forInsert ? "creating" : "updating", this)))
339
+ return this;
217
340
  const data = ctor.stampTimestamps(ctor.toDatabase({ ...this }), forInsert);
218
341
  // Reflect the stamps back onto the instance.
219
342
  if (ctor.timestamps) {
@@ -230,6 +353,8 @@ export class Model {
230
353
  if (id != null)
231
354
  this[primaryKey] = id;
232
355
  }
356
+ await fireModelEvent(cls, forInsert ? "created" : "updated", this);
357
+ await fireModelEvent(cls, "saved", this);
233
358
  return this;
234
359
  }
235
360
  /** Mass-assign then save — `fill` + `save` in one call. */
@@ -244,9 +369,51 @@ export class Model {
244
369
  Object.assign(this, applyCasts(row, ctor.casts, castGet));
245
370
  return this;
246
371
  }
372
+ /** Delete the row — or, with soft deletes on, set `deleted_at`. Fires delete events. */
247
373
  async delete() {
248
- const { table, primaryKey, connection } = this.ctor();
249
- await db(table, connection).where(primaryKey, this[primaryKey]).delete();
374
+ const ctor = this.ctor();
375
+ const cls = this.constructor;
376
+ const { table, primaryKey, connection } = ctor;
377
+ if (!(await fireModelEvent(cls, "deleting", this)))
378
+ return;
379
+ if (ctor.softDeletes) {
380
+ const now = new Date().toISOString();
381
+ await db(table, connection)
382
+ .where(primaryKey, this[primaryKey])
383
+ .update({ [ctor.deletedAtColumn]: now });
384
+ this[ctor.deletedAtColumn] = now;
385
+ }
386
+ else {
387
+ await db(table, connection).where(primaryKey, this[primaryKey]).delete();
388
+ }
389
+ await fireModelEvent(cls, "deleted", this);
390
+ }
391
+ /** Permanently delete a soft-deletable row (bypasses soft deletes). */
392
+ async forceDelete() {
393
+ const ctor = this.ctor();
394
+ const cls = this.constructor;
395
+ if (!(await fireModelEvent(cls, "deleting", this)))
396
+ return;
397
+ await db(ctor.table, ctor.connection).where(ctor.primaryKey, this[ctor.primaryKey]).delete();
398
+ await fireModelEvent(cls, "deleted", this);
399
+ }
400
+ /** Restore a soft-deleted row (clears `deleted_at`). Fires restore events. */
401
+ async restore() {
402
+ const ctor = this.ctor();
403
+ const cls = this.constructor;
404
+ if (!(await fireModelEvent(cls, "restoring", this)))
405
+ return this;
406
+ await db(ctor.table, ctor.connection)
407
+ .where(ctor.primaryKey, this[ctor.primaryKey])
408
+ .update({ [ctor.deletedAtColumn]: null });
409
+ this[ctor.deletedAtColumn] = null;
410
+ await fireModelEvent(cls, "restored", this);
411
+ return this;
412
+ }
413
+ /** Whether this soft-deletable model is currently trashed. */
414
+ trashed() {
415
+ const ctor = this.ctor();
416
+ return ctor.softDeletes && this[ctor.deletedAtColumn] != null;
250
417
  }
251
418
  /** Merge mass-assignable attributes into the model (cast, not saved). */
252
419
  fill(attributes) {
@@ -261,7 +428,25 @@ export class Model {
261
428
  return this;
262
429
  }
263
430
  toJSON() {
264
- const data = applyCasts({ ...this }, this.ctor().casts, castGet);
431
+ const ctor = this.ctor();
432
+ let data = applyCasts({ ...this }, ctor.casts, castGet);
433
+ // Appended computed attributes: a getter (value) or a zero-arg method.
434
+ for (const name of ctor.appends) {
435
+ const value = this[name];
436
+ data[name] = typeof value === "function" ? value.call(this) : value;
437
+ }
438
+ // `visible` is an allowlist and wins; otherwise `hidden` is a denylist.
439
+ if (ctor.visible.length) {
440
+ const kept = {};
441
+ for (const key of ctor.visible)
442
+ if (key in data)
443
+ kept[key] = data[key];
444
+ data = kept;
445
+ }
446
+ else if (ctor.hidden.length) {
447
+ for (const key of ctor.hidden)
448
+ delete data[key];
449
+ }
265
450
  const relations = relationStore.get(this);
266
451
  if (relations) {
267
452
  for (const [name, value] of Object.entries(relations))
@@ -49,6 +49,9 @@ export declare class HasMany<T extends Model> extends Relation<T, T[]> {
49
49
  query(): QueryBuilder;
50
50
  get(): Promise<T[]>;
51
51
  eager(models: Model[], name: string): Promise<void>;
52
+ parentColumn(): string;
53
+ matchingParentKeys(constrain?: (q: QueryBuilder) => void): Promise<unknown[]>;
54
+ countsByParent(parentKeys: unknown[]): Promise<Map<unknown, number>>;
52
55
  }
53
56
  export declare class HasOne<T extends Model> extends Relation<T, T | null> {
54
57
  private foreignKey;
@@ -58,6 +61,9 @@ export declare class HasOne<T extends Model> extends Relation<T, T | null> {
58
61
  query(): QueryBuilder;
59
62
  get(): Promise<T | null>;
60
63
  eager(models: Model[], name: string): Promise<void>;
64
+ parentColumn(): string;
65
+ matchingParentKeys(constrain?: (q: QueryBuilder) => void): Promise<unknown[]>;
66
+ countsByParent(parentKeys: unknown[]): Promise<Map<unknown, number>>;
61
67
  }
62
68
  export declare class BelongsTo<T extends Model> extends Relation<T, T | null> {
63
69
  private foreignKey;
@@ -67,6 +73,9 @@ export declare class BelongsTo<T extends Model> extends Relation<T, T | null> {
67
73
  query(): QueryBuilder;
68
74
  get(): Promise<T | null>;
69
75
  eager(models: Model[], name: string): Promise<void>;
76
+ parentColumn(): string;
77
+ matchingParentKeys(constrain?: (q: QueryBuilder) => void): Promise<unknown[]>;
78
+ countsByParent(parentKeys: unknown[]): Promise<Map<unknown, number>>;
70
79
  }
71
80
  export declare class BelongsToMany<T extends Model> extends Relation<T, T[]> {
72
81
  private pivotTable;
@@ -86,5 +95,49 @@ export declare class BelongsToMany<T extends Model> extends Relation<T, T[]> {
86
95
  detach(id?: unknown): Promise<void>;
87
96
  /** Make the pivot contain exactly `ids` (detach the rest, attach the new). */
88
97
  sync(ids: unknown[]): Promise<void>;
98
+ parentColumn(): string;
99
+ matchingParentKeys(constrain?: (q: QueryBuilder) => void): Promise<unknown[]>;
100
+ countsByParent(parentKeys: unknown[]): Promise<Map<unknown, number>>;
101
+ }
102
+ /** Register a model under a morph type string (usually its class name). */
103
+ export declare function registerMorphType(type: string, related: ModelClass<Model>): void;
104
+ /** The parent side of a polymorphic one-to-many (`Post.comments()` over `commentable`). */
105
+ export declare class MorphMany<T extends Model> extends Relation<T, T[]> {
106
+ private morphType;
107
+ private idColumn;
108
+ private typeColumn;
109
+ private localKey;
110
+ constructor(parent: Model, related: ModelClass<T>, morphType: string, idColumn: string, typeColumn: string, localKey: string);
111
+ private localValue;
112
+ query(): QueryBuilder;
113
+ get(): Promise<T[]>;
114
+ eager(models: Model[], name: string): Promise<void>;
115
+ parentColumn(): string;
116
+ matchingParentKeys(constrain?: (q: QueryBuilder) => void): Promise<unknown[]>;
117
+ countsByParent(parentKeys: unknown[]): Promise<Map<unknown, number>>;
118
+ /** Create a related row with the morph keys (`*_id` / `*_type`) filled in. */
119
+ create(attributes: Row): Promise<T>;
120
+ }
121
+ /** The parent side of a polymorphic one-to-one. */
122
+ export declare class MorphOne<T extends Model> extends Relation<T, T | null> {
123
+ private morphType;
124
+ private idColumn;
125
+ private typeColumn;
126
+ private localKey;
127
+ constructor(parent: Model, related: ModelClass<T>, morphType: string, idColumn: string, typeColumn: string, localKey: string);
128
+ query(): QueryBuilder;
129
+ get(): Promise<T | null>;
130
+ eager(models: Model[], name: string): Promise<void>;
131
+ }
132
+ /** The owning side of a polymorphic relation — resolves its parent by stored type + id. */
133
+ export declare class MorphTo implements PromiseLike<Model | null> {
134
+ private parent;
135
+ private idColumn;
136
+ private typeColumn;
137
+ constructor(parent: Model, idColumn: string, typeColumn: string);
138
+ private relatedClass;
139
+ get(): Promise<Model | null>;
140
+ eager(models: Model[], name: string): Promise<void>;
141
+ then<R1 = Model | null, R2 = never>(onFulfilled?: ((value: Model | null) => R1 | PromiseLike<R1>) | null, onRejected?: ((reason: unknown) => R2 | PromiseLike<R2>) | null): PromiseLike<R1 | R2>;
89
142
  }
90
143
  export {};