@shaferllc/keel 0.80.0 → 0.81.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) 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 +89 -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 +109 -4
  33. package/dist/core/model.js +263 -32
  34. package/dist/core/relations.d.ts +53 -0
  35. package/dist/core/relations.js +242 -0
  36. package/dist/teams/config.d.ts +27 -0
  37. package/dist/teams/config.js +23 -0
  38. package/dist/teams/context.d.ts +54 -0
  39. package/dist/teams/context.js +73 -0
  40. package/dist/teams/index.d.ts +25 -0
  41. package/dist/teams/index.js +20 -0
  42. package/dist/teams/invitations.d.ts +38 -0
  43. package/dist/teams/invitations.js +123 -0
  44. package/dist/teams/middleware.d.ts +30 -0
  45. package/dist/teams/middleware.js +92 -0
  46. package/dist/teams/migration.d.ts +9 -0
  47. package/dist/teams/migration.js +52 -0
  48. package/dist/teams/models.d.ts +54 -0
  49. package/dist/teams/models.js +85 -0
  50. package/dist/teams/provider.d.ts +17 -0
  51. package/dist/teams/provider.js +27 -0
  52. package/dist/teams/teams.config.stub +24 -0
  53. package/dist/teams/tenant.d.ts +25 -0
  54. package/dist/teams/tenant.js +45 -0
  55. package/docs/accounts.md +214 -0
  56. package/docs/ai-manifest.json +70 -1
  57. package/docs/database.md +80 -0
  58. package/docs/examples/accounts.ts +150 -0
  59. package/docs/examples/teams.ts +101 -0
  60. package/docs/migrations.md +86 -6
  61. package/docs/models.md +279 -6
  62. package/docs/teams.md +176 -0
  63. package/llms-full.txt +849 -12
  64. package/llms.txt +4 -0
  65. package/package.json +10 -2
@@ -0,0 +1,234 @@
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 { fireModelEvent } from "./model-events.js";
22
+ /** Group dotted eager-load paths by their first segment. */
23
+ function groupPaths(paths) {
24
+ const groups = new Map();
25
+ for (const path of paths) {
26
+ const [top, ...rest] = path.split(".");
27
+ const nested = groups.get(top) ?? [];
28
+ if (rest.length)
29
+ nested.push(rest.join("."));
30
+ groups.set(top, nested);
31
+ }
32
+ return groups;
33
+ }
34
+ /** Recursively eager-load dotted relation paths onto a set of models. */
35
+ async function eagerLoad(models, paths) {
36
+ if (!models.length)
37
+ return;
38
+ for (const [top, nested] of groupPaths(paths)) {
39
+ const relation = models[0][top]?.();
40
+ if (!relation)
41
+ throw new Error(`${models[0].constructor.name} has no relation "${top}"`);
42
+ await relation.eager(models, top);
43
+ if (nested.length) {
44
+ const children = [];
45
+ for (const model of models) {
46
+ const loaded = model.getRelation(top);
47
+ if (Array.isArray(loaded))
48
+ children.push(...loaded);
49
+ else if (loaded)
50
+ children.push(loaded);
51
+ }
52
+ await eagerLoad(children, nested);
53
+ }
54
+ }
55
+ }
56
+ export class ModelQuery {
57
+ cls;
58
+ builder;
59
+ eagers = [];
60
+ counts = [];
61
+ hasConstraints = [];
62
+ constructor(cls, builder) {
63
+ this.cls = cls;
64
+ this.builder = builder;
65
+ }
66
+ /** The wrapped builder, for anything `ModelQuery` doesn't proxy. */
67
+ toBase() {
68
+ return this.builder;
69
+ }
70
+ /* --------------------------- builder passthrough --------------------------- */
71
+ where(column, opOrValue, value) {
72
+ if (value === undefined)
73
+ this.builder.where(column, opOrValue);
74
+ else
75
+ this.builder.where(column, opOrValue, value);
76
+ return this;
77
+ }
78
+ orWhere(column, opOrValue, value) {
79
+ if (value === undefined)
80
+ this.builder.orWhere(column, opOrValue);
81
+ else
82
+ this.builder.orWhere(column, opOrValue, value);
83
+ return this;
84
+ }
85
+ whereIn(column, values) {
86
+ this.builder.whereIn(column, values);
87
+ return this;
88
+ }
89
+ whereNotIn(column, values) {
90
+ this.builder.whereNotIn(column, values);
91
+ return this;
92
+ }
93
+ whereNull(column) {
94
+ this.builder.whereNull(column);
95
+ return this;
96
+ }
97
+ whereNotNull(column) {
98
+ this.builder.whereNotNull(column);
99
+ return this;
100
+ }
101
+ whereBetween(column, range) {
102
+ this.builder.whereBetween(column, range);
103
+ return this;
104
+ }
105
+ whereLike(column, pattern) {
106
+ this.builder.whereLike(column, pattern);
107
+ return this;
108
+ }
109
+ orderBy(column, direction = "asc") {
110
+ this.builder.orderBy(column, direction);
111
+ return this;
112
+ }
113
+ latest(column = "created_at") {
114
+ this.builder.latest(column);
115
+ return this;
116
+ }
117
+ oldest(column = "created_at") {
118
+ this.builder.oldest(column);
119
+ return this;
120
+ }
121
+ limit(n) {
122
+ this.builder.limit(n);
123
+ return this;
124
+ }
125
+ offset(n) {
126
+ this.builder.offset(n);
127
+ return this;
128
+ }
129
+ /* ---------------------------- relationship ops ----------------------------- */
130
+ /** Eager-load relations (dotted paths for nesting: `"posts.comments"`). */
131
+ with(...names) {
132
+ this.eagers.push(...names);
133
+ return this;
134
+ }
135
+ /** Add a `<relation>_count` to each result. */
136
+ withCount(...names) {
137
+ this.counts.push(...names);
138
+ return this;
139
+ }
140
+ /** Constrain to models that have at least one related row. */
141
+ has(name) {
142
+ this.hasConstraints.push({ name, negate: false });
143
+ return this;
144
+ }
145
+ /** Constrain to models whose related rows match `constrain`. */
146
+ whereHas(name, constrain) {
147
+ this.hasConstraints.push({ name, ...(constrain ? { constrain } : {}), negate: false });
148
+ return this;
149
+ }
150
+ /** Constrain to models with no matching related row. */
151
+ doesntHave(name, constrain) {
152
+ this.hasConstraints.push({ name, ...(constrain ? { constrain } : {}), negate: true });
153
+ return this;
154
+ }
155
+ /* -------------------------------- terminals -------------------------------- */
156
+ async get() {
157
+ await this.resolveHasConstraints();
158
+ const rows = await this.builder.get();
159
+ const models = await Promise.all(rows.map((row) => this.hydrate(row)));
160
+ if (this.eagers.length)
161
+ await eagerLoad(models, this.eagers);
162
+ if (this.counts.length)
163
+ await this.loadCounts(models);
164
+ return models;
165
+ }
166
+ async first() {
167
+ this.builder.limit(1);
168
+ const [model] = await this.get();
169
+ return model ?? null;
170
+ }
171
+ async count() {
172
+ await this.resolveHasConstraints();
173
+ return this.builder.count();
174
+ }
175
+ async exists() {
176
+ return (await this.count()) > 0;
177
+ }
178
+ async paginate(page = 1, perPage = 15) {
179
+ await this.resolveHasConstraints();
180
+ const result = await this.builder.paginate(page, perPage);
181
+ const data = await Promise.all(result.data.map((row) => this.hydrate(row)));
182
+ if (this.eagers.length)
183
+ await eagerLoad(data, this.eagers);
184
+ if (this.counts.length)
185
+ await this.loadCounts(data);
186
+ return { ...result, data };
187
+ }
188
+ /* -------------------------------- internals -------------------------------- */
189
+ async hydrate(row) {
190
+ const model = new this.cls(row);
191
+ await fireModelEvent(this.cls, "retrieved", model);
192
+ return model;
193
+ }
194
+ relationFor(name) {
195
+ const probe = new this.cls();
196
+ const method = probe[name];
197
+ if (typeof method !== "function") {
198
+ throw new Error(`${this.cls.name} has no relation "${name}"`);
199
+ }
200
+ return method.call(probe);
201
+ }
202
+ /** Turn recorded has/doesntHave constraints into `whereIn`/`whereNotIn` on the builder. */
203
+ async resolveHasConstraints() {
204
+ for (const { name, constrain, negate } of this.hasConstraints) {
205
+ const relation = this.relationFor(name);
206
+ const keys = await relation.matchingParentKeys(constrain);
207
+ const column = relation.parentColumn();
208
+ if (negate) {
209
+ if (keys.length)
210
+ this.builder.whereNotIn(column, keys);
211
+ }
212
+ else if (keys.length) {
213
+ this.builder.whereIn(column, keys);
214
+ }
215
+ else {
216
+ // No related rows exist → nothing can match. A contradiction yields zero
217
+ // rows without an invalid empty `IN ()`.
218
+ this.builder.whereNull(column).whereNotNull(column);
219
+ }
220
+ }
221
+ this.hasConstraints = [];
222
+ }
223
+ async loadCounts(models) {
224
+ for (const name of this.counts) {
225
+ const relation = this.relationFor(name);
226
+ const column = relation.parentColumn();
227
+ const keys = [...new Set(models.map((m) => m[column]).filter((v) => v != null))];
228
+ const counts = await relation.countsByParent(keys);
229
+ for (const model of models) {
230
+ model[`${name}_count`] = counts.get(model[column]) ?? 0;
231
+ }
232
+ }
233
+ }
234
+ }
@@ -16,8 +16,22 @@
16
16
  * await user.save();
17
17
  */
18
18
  import { type QueryBuilder, type Row, type Paginated } from "./database.js";
19
- import { BelongsTo, BelongsToMany, HasMany, HasOne } from "./relations.js";
19
+ import { BelongsTo, BelongsToMany, HasMany, HasOne, MorphMany, MorphOne, MorphTo } from "./relations.js";
20
20
  import { type Casts } from "./casts.js";
21
+ import { type ModelHook, type ModelObserver } from "./model-events.js";
22
+ import { ModelQuery } from "./model-query.js";
23
+ /** A global scope: a constraint applied to every query a model builds. */
24
+ /**
25
+ * A global scope: a constraint applied to every query a model builds.
26
+ *
27
+ * The second argument is the model the query is being built for — which a scope
28
+ * declared on a *base* class needs, since it runs for every subclass and they may
29
+ * not all be configured the same way (a tenant scope reading each model's own
30
+ * `teamColumn`, say).
31
+ */
32
+ export type GlobalScope = (query: QueryBuilder, model: typeof Model) => void;
33
+ /** How a model query treats soft-deleted rows. */
34
+ type TrashedMode = "exclude" | "with" | "only";
21
35
  type ModelClass<T extends Model> = (new (attributes?: Row) => T) & typeof Model;
22
36
  export declare class Model {
23
37
  static table: string;
@@ -34,10 +48,88 @@ export declare class Model {
34
48
  static timestamps: boolean;
35
49
  static createdAtColumn: string;
36
50
  static updatedAtColumn: string;
51
+ /** Columns stripped from `toJSON()` output (e.g. `["password"]`). */
52
+ static hidden: string[];
53
+ /** If set, ONLY these keys survive `toJSON()` — an allowlist that wins over `hidden`. */
54
+ static visible: string[];
55
+ /** Computed accessor names (getters or zero-arg methods) added to `toJSON()`. */
56
+ static appends: string[];
57
+ /** Soft deletes: `delete()` sets `deleted_at` instead of removing the row. */
58
+ static softDeletes: boolean;
59
+ static deletedAtColumn: string;
37
60
  [key: string]: unknown;
38
61
  constructor(attributes?: Row);
39
- /** A raw query builder scoped to this model's table. */
62
+ /** Register a global scope a constraint applied to every query this model builds. */
63
+ static addGlobalScope(name: string, scope: GlobalScope): void;
64
+ /** Build this model's base query, applying global scopes and the soft-delete filter. */
65
+ protected static baseQuery(trashed?: TrashedMode, skip?: Set<string>): QueryBuilder;
66
+ /** A query builder scoped to this model's table (global scopes applied). */
40
67
  static query(): QueryBuilder;
68
+ /**
69
+ * Drop named global scopes from this query.
70
+ *
71
+ * Deliberately explicit and greppable: a query that escapes a tenancy scope is
72
+ * exactly the thing you want to be able to find at audit time, so it has to be
73
+ * *typed out*, not arrived at by forgetting something.
74
+ */
75
+ static withoutGlobalScope(...names: string[]): QueryBuilder;
76
+ /** Drop every global scope. Same warning, louder. */
77
+ static withoutGlobalScopes(): QueryBuilder;
78
+ /** Include soft-deleted rows (bypasses the soft-delete scope). */
79
+ static withTrashed(): QueryBuilder;
80
+ /** Only soft-deleted rows. */
81
+ static onlyTrashed(): QueryBuilder;
82
+ /** A model-aware query — hydrates to models and supports `with`/`withCount`/`whereHas`. */
83
+ static newQuery<T extends Model>(this: ModelClass<T>): ModelQuery<T>;
84
+ /** Start a query eager-loading the given relations (dotted paths nest). */
85
+ static with<T extends Model>(this: ModelClass<T>, ...names: string[]): ModelQuery<T>;
86
+ /** Start a query counting the given relations into `<relation>_count`. */
87
+ static withCount<T extends Model>(this: ModelClass<T>, ...names: string[]): ModelQuery<T>;
88
+ /** Start a query constrained to models that have at least one related row. */
89
+ static has<T extends Model>(this: ModelClass<T>, name: string): ModelQuery<T>;
90
+ /** Start a query constrained to models whose related rows match `constrain`. */
91
+ static whereHas<T extends Model>(this: ModelClass<T>, name: string, constrain?: (q: QueryBuilder) => void): ModelQuery<T>;
92
+ /** Start a query constrained to models with no matching related row. */
93
+ static doesntHave<T extends Model>(this: ModelClass<T>, name: string, constrain?: (q: QueryBuilder) => void): ModelQuery<T>;
94
+ /** Construct a model from a row and fire its `retrieved` event. */
95
+ protected static hydrate<T extends Model>(this: ModelClass<T>, row: Row): Promise<T>;
96
+ static creating<T extends Model>(this: {
97
+ new (...args: never[]): T;
98
+ }, hook: ModelHook<T>): void;
99
+ static created<T extends Model>(this: {
100
+ new (...args: never[]): T;
101
+ }, hook: ModelHook<T>): void;
102
+ static updating<T extends Model>(this: {
103
+ new (...args: never[]): T;
104
+ }, hook: ModelHook<T>): void;
105
+ static updated<T extends Model>(this: {
106
+ new (...args: never[]): T;
107
+ }, hook: ModelHook<T>): void;
108
+ static saving<T extends Model>(this: {
109
+ new (...args: never[]): T;
110
+ }, hook: ModelHook<T>): void;
111
+ static saved<T extends Model>(this: {
112
+ new (...args: never[]): T;
113
+ }, hook: ModelHook<T>): void;
114
+ static deleting<T extends Model>(this: {
115
+ new (...args: never[]): T;
116
+ }, hook: ModelHook<T>): void;
117
+ static deleted<T extends Model>(this: {
118
+ new (...args: never[]): T;
119
+ }, hook: ModelHook<T>): void;
120
+ static restoring<T extends Model>(this: {
121
+ new (...args: never[]): T;
122
+ }, hook: ModelHook<T>): void;
123
+ static restored<T extends Model>(this: {
124
+ new (...args: never[]): T;
125
+ }, hook: ModelHook<T>): void;
126
+ static retrieved<T extends Model>(this: {
127
+ new (...args: never[]): T;
128
+ }, hook: ModelHook<T>): void;
129
+ /** Register an observer whose methods are named after the events they handle. */
130
+ static observe<T extends Model>(this: {
131
+ new (...args: never[]): T;
132
+ }, observer: ModelObserver<T>): void;
41
133
  /** Keep only the attributes mass-assignment allows (fillable / guarded). */
42
134
  static filterFillable(attributes: Row): Row;
43
135
  /** Cast attributes to their storable primitives for a write. */
@@ -57,7 +149,7 @@ export declare class Model {
57
149
  static firstOrCreate<T extends Model>(this: ModelClass<T>, match: Row, values?: Row): Promise<T>;
58
150
  /** Update the first row matching `match` with `values`, or create it. */
59
151
  static updateOrCreate<T extends Model>(this: ModelClass<T>, match: Row, values?: Row): Promise<T>;
60
- /** A query scoped to every column/value in `match`. */
152
+ /** A query scoped to every column/value in `match` (global scopes applied). */
61
153
  private static matching;
62
154
  /**
63
155
  * Eager-load relations onto an array of models with one extra query each —
@@ -75,17 +167,30 @@ export declare class Model {
75
167
  belongsTo<T extends Model>(related: ModelClass<T>, foreignKey?: string, ownerKey?: string): BelongsTo<T>;
76
168
  /** Many-to-many through a pivot table (default name: the two tables, sorted). */
77
169
  belongsToMany<T extends Model>(related: ModelClass<T>, pivotTable?: string, foreignPivotKey?: string, relatedPivotKey?: string, parentKey?: string, relatedKey?: string): BelongsToMany<T>;
170
+ /** Polymorphic one-to-many: children carry `<name>_id` + `<name>_type`. */
171
+ morphMany<T extends Model>(related: ModelClass<T>, name: string, localKey?: string): MorphMany<T>;
172
+ /** Polymorphic one-to-one. */
173
+ morphOne<T extends Model>(related: ModelClass<T>, name: string, localKey?: string): MorphOne<T>;
174
+ /** The owning side of a polymorphic relation — resolves via `<name>_type` (see `registerMorphType`). */
175
+ morphTo(name: string, idColumn?: string, typeColumn?: string): MorphTo;
78
176
  /** Store an eager-loaded relation result under `name`. */
79
177
  setRelation(name: string, value: unknown): this;
80
178
  /** Read a previously loaded relation (returns undefined if not loaded). */
81
179
  getRelation<T = unknown>(name: string): T | undefined;
82
- /** Insert (no primary key) or update (has one). */
180
+ /** Insert (no primary key) or update (has one). Fires save/create/update events. */
83
181
  save(): Promise<this>;
84
182
  /** Mass-assign then save — `fill` + `save` in one call. */
85
183
  update(attributes: Row): Promise<this>;
86
184
  /** Reload this model's columns from the database. */
87
185
  refresh(): Promise<this>;
186
+ /** Delete the row — or, with soft deletes on, set `deleted_at`. Fires delete events. */
88
187
  delete(): Promise<void>;
188
+ /** Permanently delete a soft-deletable row (bypasses soft deletes). */
189
+ forceDelete(): Promise<void>;
190
+ /** Restore a soft-deleted row (clears `deleted_at`). Fires restore events. */
191
+ restore(): Promise<this>;
192
+ /** Whether this soft-deletable model is currently trashed. */
193
+ trashed(): boolean;
89
194
  /** Merge mass-assignable attributes into the model (cast, not saved). */
90
195
  fill(attributes: Row): this;
91
196
  /** Force-assign attributes, bypassing mass-assignment guarding (still cast). */