@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
@@ -0,0 +1,150 @@
1
+ // Typechecked example for docs/accounts.md.
2
+ import { Application } from "@shaferllc/keel/core";
3
+ import {
4
+ AccountsServiceProvider,
5
+ attempt,
6
+ completeTwoFactor,
7
+ confirmTwoFactor,
8
+ disableTwoFactor,
9
+ enableTwoFactor,
10
+ hasTwoFactor,
11
+ regenerateRecoveryCodes,
12
+ requestPasswordReset,
13
+ resetPassword,
14
+ sendVerificationEmail,
15
+ verifyEmail,
16
+ accountStore,
17
+ setAccountStore,
18
+ type AccountUser,
19
+ } from "@shaferllc/keel/accounts";
20
+
21
+ const app = new Application();
22
+
23
+ /* ------------------------------- turning it on ---------------------------- */
24
+
25
+ app.register(AccountsServiceProvider);
26
+
27
+ /* ---------------------------------- login --------------------------------- */
28
+
29
+ async function login(email: string, password: string) {
30
+ const result = await attempt(email, password);
31
+
32
+ if (result.status === "failed") {
33
+ return { error: "Those credentials don't match." };
34
+ }
35
+
36
+ if (result.status === "two-factor") {
37
+ // Nothing is logged in yet. Hold the challenge, ask for a code.
38
+ return { twoFactor: true, challenge: result.challenge };
39
+ }
40
+
41
+ return { user: result.user };
42
+ }
43
+
44
+ async function finishTwoFactor(challenge: string, code: string) {
45
+ // Takes an authenticator code or a recovery code.
46
+ const user = await completeTwoFactor(challenge, code);
47
+ if (!user) return { error: "That code isn't valid." };
48
+
49
+ return { user };
50
+ }
51
+
52
+ /* ------------------------------ password reset ---------------------------- */
53
+
54
+ async function forgot(email: string) {
55
+ await requestPasswordReset(email);
56
+ // Always the same answer, whether or not that address has an account.
57
+ return { status: "If that address has an account, a link is on its way." };
58
+ }
59
+
60
+ async function reset(token: string, password: string) {
61
+ const ok = await resetPassword(token, password);
62
+ return ok ? { status: "Password reset." } : { error: "That link is invalid or expired." };
63
+ }
64
+
65
+ /* --------------------------- email verification --------------------------- */
66
+
67
+ async function afterRegistration(user: AccountUser) {
68
+ await sendVerificationEmail(user);
69
+ }
70
+
71
+ async function confirmEmail(token: string) {
72
+ const user = await verifyEmail(token);
73
+ return user ? { status: "Confirmed." } : { error: "That link is invalid or expired." };
74
+ }
75
+
76
+ /* -------------------------------- two factor ------------------------------ */
77
+
78
+ async function startTwoFactor(user: AccountUser) {
79
+ // Step one: a secret and recovery codes — but 2FA is NOT on yet.
80
+ const setup = await enableTwoFactor(user, { issuer: "Acme" });
81
+
82
+ // Render setup.uri to a QR code locally. It contains the secret; never send it
83
+ // to a third-party QR service.
84
+ return {
85
+ uri: setup.uri,
86
+ secret: setup.secret,
87
+ recoveryCodes: setup.recoveryCodes, // shown once
88
+ };
89
+ }
90
+
91
+ async function finishSetup(user: AccountUser, code: string) {
92
+ // Step two: a working code turns it on. Without this, a bad scan locks them out.
93
+ const ok = await confirmTwoFactor(user, code);
94
+ return ok ? { status: "Two-factor is on." } : { error: "That code isn't valid." };
95
+ }
96
+
97
+ async function accountSettings(user: AccountUser) {
98
+ return {
99
+ twoFactorEnabled: hasTwoFactor(user),
100
+ };
101
+ }
102
+
103
+ async function newCodes(user: AccountUser) {
104
+ return regenerateRecoveryCodes(user); // invalidates the old set
105
+ }
106
+
107
+ async function turnOff(user: AccountUser) {
108
+ await disableTwoFactor(user);
109
+ }
110
+
111
+ /* ------------------------------ a custom store ---------------------------- */
112
+
113
+ // Users somewhere other than a `users` table? Replace the whole store. (Anything
114
+ // that can find a user and update one will do — here, a map.)
115
+ const people = new Map<string | number, AccountUser>();
116
+
117
+ setAccountStore({
118
+ async findById(id) {
119
+ return people.get(id) ?? null;
120
+ },
121
+ async findByEmail(email) {
122
+ for (const person of people.values()) {
123
+ if (person.email === email.toLowerCase()) return person;
124
+ }
125
+ return null;
126
+ },
127
+ async update(id, values) {
128
+ const person = people.get(id);
129
+ if (person) people.set(id, { ...person, ...values });
130
+ },
131
+ });
132
+
133
+ // The store the rest of the module reads through.
134
+ const store = accountStore();
135
+
136
+ export {
137
+ login,
138
+ finishTwoFactor,
139
+ forgot,
140
+ reset,
141
+ afterRegistration,
142
+ confirmEmail,
143
+ startTwoFactor,
144
+ finishSetup,
145
+ accountSettings,
146
+ newCodes,
147
+ turnOff,
148
+ app,
149
+ store,
150
+ };
@@ -73,12 +73,41 @@ t.boolean("active").default(true); // sqlite: DEFAULT 1, else DEFAULT true
73
73
  t.integer("retries").default(0); // ... DEFAULT 0
74
74
  ```
75
75
 
76
- For anything the builder doesn't cover — indexes, foreign keys, an `ALTER
77
- TABLE` — `schema.raw(sql, bindings?)` runs arbitrary SQL:
76
+ ### Indexes and foreign keys
77
+
78
+ `createTable` builds indexes and foreign keys alongside the columns:
79
+
80
+ ```ts
81
+ schema.createTable("members", (t) => {
82
+ t.id();
83
+ t.integer("team_id");
84
+ t.string("email");
85
+ t.uniqueIndex("email"); // or t.index(["a", "b"]) for composite
86
+ t.foreign("team_id").references("id").on("teams").onDelete("cascade");
87
+ });
88
+ ```
89
+
90
+ ### Altering a table
91
+
92
+ `schema.alterTable(name, build)` adds, renames, and drops columns and indexes on
93
+ an existing table (dialect-aware SQL). Drop an index before the column it covers:
78
94
 
79
95
  ```ts
80
96
  up: (schema) =>
81
- schema.raw("CREATE INDEX idx_posts_user ON posts (user_id)"),
97
+ schema.alterTable("users", (t) => {
98
+ t.string("phone").nullable(); // ADD COLUMN
99
+ t.renameColumn("name", "full_name");
100
+ t.index("phone");
101
+ t.dropIndex("users_legacy_index");
102
+ t.dropColumn("legacy");
103
+ }),
104
+ ```
105
+
106
+ For anything the builder still doesn't cover, `schema.raw(sql, bindings?)` runs
107
+ arbitrary SQL:
108
+
109
+ ```ts
110
+ up: (schema) => schema.raw("CREATE INDEX idx_posts_user ON posts (user_id)"),
82
111
  ```
83
112
 
84
113
  > `raw()` writes through the connection **without** placeholder conversion, so
package/docs/models.md CHANGED
@@ -173,6 +173,85 @@ return json(user); // works directly — json() serializes it
173
173
  user.fill({ name: "X" }); // merge mass-assignable attributes without saving
174
174
  ```
175
175
 
176
+ Control what `toJSON()` exposes with three statics. `hidden` strips columns;
177
+ `visible` is an allowlist that wins over everything; `appends` adds computed
178
+ attributes — a getter or a zero-arg method on the model:
179
+
180
+ ```ts
181
+ class User extends Model {
182
+ static table = "users";
183
+ static hidden = ["password"]; // never serialized
184
+ static appends = ["fullName"]; // added to the output
185
+ get fullName() { return `${this.first} ${this.last}`; }
186
+ }
187
+ ```
188
+
189
+ ## Lifecycle events
190
+
191
+ A model fires events as it is retrieved, saved, and deleted. Hook onto them to
192
+ slug a title, bust a cache, or cascade — without touching every call site. The
193
+ `*ing` events are **cancelable**: a hook returning `false` aborts the write.
194
+
195
+ ```ts
196
+ User.creating((user) => { user.uuid = crypto.randomUUID(); });
197
+ User.saved((user) => cache().forget(`user:${user.id}`));
198
+ User.deleting((user) => (user.isRoot ? false : undefined)); // veto
199
+
200
+ // Or group them in an observer:
201
+ User.observe({
202
+ creating: (u) => { u.uuid = crypto.randomUUID(); },
203
+ deleted: (u) => audit(`deleted ${u.id}`),
204
+ });
205
+ ```
206
+
207
+ Events: `retrieved`, `creating`/`created`, `updating`/`updated`,
208
+ `saving`/`saved`, `deleting`/`deleted`, `restoring`/`restored`. They're keyed by
209
+ the exact class (subclasses don't inherit a parent's hooks).
210
+
211
+ ## Query scopes
212
+
213
+ A **global scope** constrains every query a model builds — the base for
214
+ multi-tenancy, published-only reads, and soft deletes:
215
+
216
+ ```ts
217
+ Post.addGlobalScope("published", (q) => q.where("published", true));
218
+ await Post.all(); // only published
219
+ await Post.query().where("author_id", 1).get(); // still only published
220
+ ```
221
+
222
+ A **local scope** is just a static method returning a query — no framework
223
+ feature needed:
224
+
225
+ ```ts
226
+ class Post extends Model {
227
+ static popular() { return this.query().where("views", ">", 1000); }
228
+ }
229
+ await Post.popular().orderBy("views", "desc").get();
230
+ ```
231
+
232
+ ## Soft deletes
233
+
234
+ Opt in with `static softDeletes = true` and a `deleted_at` column. `delete()`
235
+ then sets the timestamp instead of removing the row, and a global scope hides
236
+ soft-deleted rows from every query.
237
+
238
+ ```ts
239
+ class User extends Model {
240
+ static table = "users";
241
+ static softDeletes = true;
242
+ static casts = { deleted_at: "date" };
243
+ }
244
+
245
+ await user.delete(); // sets deleted_at; row stays in the table
246
+ user.trashed(); // true
247
+ await User.find(user.id); // null — hidden by the scope
248
+
249
+ await User.withTrashed().get(); // include soft-deleted
250
+ await User.onlyTrashed().get(); // only soft-deleted
251
+ await user.restore(); // clear deleted_at
252
+ await user.forceDelete(); // remove the row for good
253
+ ```
254
+
176
255
  ## Relationships
177
256
 
178
257
  Define a relationship as a method that returns one of `hasMany` / `hasOne` /
@@ -222,6 +301,29 @@ users[0].toJSON(); // includes `posts` and `roles`
222
301
  Loaded relations are stored off the model, so they never leak into `save()`,
223
302
  and `toJSON()` serializes them (nested models included).
224
303
 
304
+ ### Querying relationships (`with`, `withCount`, `whereHas`)
305
+
306
+ `Model.query()` returns a model-aware builder with the relationship operations a
307
+ raw query can't express. `with()` eager-loads (dotted paths nest), `withCount()`
308
+ adds a `<relation>_count`, and `has`/`whereHas`/`doesntHave` filter by whether a
309
+ related row exists:
310
+
311
+ ```ts
312
+ const users = await User.query()
313
+ .where("active", true)
314
+ .with("posts.comments") // nested eager load
315
+ .withCount("posts") // users[i].posts_count
316
+ .whereHas("posts", (q) => q.where("published", true))
317
+ .get();
318
+
319
+ await User.has("posts").get(); // users with at least one post
320
+ await User.doesntHave("posts").get(); // users with none
321
+ ```
322
+
323
+ `with`/`withCount`/`whereHas`/`has`/`doesntHave` are also static shortcuts
324
+ (`User.with(...)`, `User.whereHas(...)`). Existence filters use the same
325
+ driver-agnostic two-query strategy as the relations themselves — no JOIN.
326
+
225
327
  ### Many-to-many
226
328
 
227
329
  `belongsToMany` reads through a pivot table (default name: the two table names
@@ -242,11 +344,39 @@ this.belongsTo(User, "owner_id", "id");
242
344
  this.belongsToMany(Role, "user_roles", "user_id", "role_id");
243
345
  ```
244
346
 
347
+ ### Polymorphic
348
+
349
+ A polymorphic relation lets one model belong to more than one type. The related
350
+ rows carry `<name>_id` + `<name>_type`; register each owner type so `morphTo`
351
+ can resolve it:
352
+
353
+ ```ts
354
+ class Post extends Model {
355
+ comments() { return this.morphMany(Comment, "commentable"); }
356
+ }
357
+ class Video extends Model {
358
+ comments() { return this.morphMany(Comment, "commentable"); }
359
+ }
360
+ class Comment extends Model {
361
+ commentable() { return this.morphTo("commentable"); } // resolves back to Post or Video
362
+ }
363
+
364
+ registerMorphType("Post", Post);
365
+ registerMorphType("Video", Video);
366
+
367
+ await post.comments().create({ body: "nice" }); // sets commentable_id/_type
368
+ const owner = await comment.commentable(); // Post | Video | null
369
+ ```
370
+
371
+ `morphOne` is the one-to-one variant. Eager loading (`Model.load` / `with`) works
372
+ across mixed types.
373
+
245
374
  ## What this is (and isn't)
246
375
 
247
- This is a deliberately small active-record — enough for CRUD, relationships,
248
- casts, and simple queries without an ORM dependency. Nested eager loading
249
- (`posts.comments`) and query-time `with()` aren't here yet. For complex schemas
376
+ This is a compact active-record — CRUD, lifecycle events, scopes, soft deletes,
377
+ serialization control, eager loading (including nested `with("posts.comments")`),
378
+ relationship queries (`whereHas`/`withCount`), and polymorphic relations all on
379
+ a driver-agnostic query builder, no ORM dependency. For complex one-off queries
250
380
  you can always drop to `db()` or your driver directly.
251
381
 
252
382
  ---