@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.
- package/dist/accounts/accounts.config.stub +50 -0
- package/dist/accounts/config.d.ts +46 -0
- package/dist/accounts/config.js +39 -0
- package/dist/accounts/flows.d.ts +50 -0
- package/dist/accounts/flows.js +133 -0
- package/dist/accounts/index.d.ts +28 -0
- package/dist/accounts/index.js +23 -0
- package/dist/accounts/migration.d.ts +14 -0
- package/dist/accounts/migration.js +39 -0
- package/dist/accounts/provider.d.ts +18 -0
- package/dist/accounts/provider.js +37 -0
- package/dist/accounts/routes.d.ts +15 -0
- package/dist/accounts/routes.js +116 -0
- package/dist/accounts/store.d.ts +33 -0
- package/dist/accounts/store.js +37 -0
- package/dist/accounts/tokens.d.ts +60 -0
- package/dist/accounts/tokens.js +116 -0
- package/dist/accounts/totp.d.ts +58 -0
- package/dist/accounts/totp.js +134 -0
- package/dist/accounts/two-factor.d.ts +56 -0
- package/dist/accounts/two-factor.js +146 -0
- package/dist/core/database.d.ts +36 -0
- package/dist/core/database.js +141 -4
- package/dist/core/index.d.ts +5 -2
- package/dist/core/index.js +3 -2
- package/dist/core/migrations.d.ts +52 -2
- package/dist/core/migrations.js +134 -3
- package/dist/core/model-events.d.ts +34 -0
- package/dist/core/model-events.js +71 -0
- package/dist/core/model-query.d.ts +68 -0
- package/dist/core/model-query.js +234 -0
- package/dist/core/model.d.ts +91 -4
- package/dist/core/model.js +217 -32
- package/dist/core/relations.d.ts +53 -0
- package/dist/core/relations.js +242 -0
- package/docs/accounts.md +214 -0
- package/docs/ai-manifest.json +63 -1
- package/docs/database.md +33 -0
- package/docs/examples/accounts.ts +150 -0
- package/docs/migrations.md +32 -3
- package/docs/models.md +133 -3
- package/llms-full.txt +419 -6
- package/llms.txt +2 -0
- 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
|
+
};
|
package/docs/migrations.md
CHANGED
|
@@ -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
|
-
|
|
77
|
-
|
|
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.
|
|
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
|
|
248
|
-
|
|
249
|
-
(`
|
|
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
|
---
|