@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
package/llms-full.txt
CHANGED
|
@@ -4437,6 +4437,227 @@ router.get("/reports", handler).use(guards);
|
|
|
4437
4437
|
|
|
4438
4438
|
|
|
4439
4439
|
|
|
4440
|
+
---
|
|
4441
|
+
|
|
4442
|
+
<!-- source: docs/accounts.md -->
|
|
4443
|
+
|
|
4444
|
+
# Accounts
|
|
4445
|
+
|
|
4446
|
+
Password reset, email verification, and two-factor authentication — the flows every
|
|
4447
|
+
app with a login needs, built on primitives already in core (`hash`, `encryption`,
|
|
4448
|
+
`mail`, `rate-limit`).
|
|
4449
|
+
|
|
4450
|
+
They live in the framework, tested once, rather than being copy-pasted into each new
|
|
4451
|
+
app. A password-reset flow written five times is four copies that quietly rot.
|
|
4452
|
+
|
|
4453
|
+
```bash
|
|
4454
|
+
npm install @shaferllc/keel
|
|
4455
|
+
```
|
|
4456
|
+
|
|
4457
|
+
```ts
|
|
4458
|
+
// bootstrap/providers.ts
|
|
4459
|
+
import { AccountsServiceProvider } from "@shaferllc/keel/accounts";
|
|
4460
|
+
|
|
4461
|
+
app.register(AccountsServiceProvider);
|
|
4462
|
+
```
|
|
4463
|
+
|
|
4464
|
+
That merges config, adds four columns to your `users` table via a migration, and
|
|
4465
|
+
mounts the JSON endpoints. **Views stay yours** — these are functions and JSON
|
|
4466
|
+
endpoints; your controllers render the forms.
|
|
4467
|
+
|
|
4468
|
+
## Login
|
|
4469
|
+
|
|
4470
|
+
`attempt()` checks a password. What comes back depends on whether the user has 2FA.
|
|
4471
|
+
|
|
4472
|
+
```ts
|
|
4473
|
+
const result = await attempt(email, password);
|
|
4474
|
+
|
|
4475
|
+
if (result.status === "failed") {
|
|
4476
|
+
return { error: "Those credentials don't match." };
|
|
4477
|
+
}
|
|
4478
|
+
|
|
4479
|
+
if (result.status === "two-factor") {
|
|
4480
|
+
// Nothing is logged in yet. Hold the challenge, ask for a code.
|
|
4481
|
+
return { twoFactor: true, challenge: result.challenge };
|
|
4482
|
+
}
|
|
4483
|
+
|
|
4484
|
+
auth().login(result.user.id);
|
|
4485
|
+
```
|
|
4486
|
+
|
|
4487
|
+
A wrong email and a wrong password give the same answer, and take the same time —
|
|
4488
|
+
`attempt()` hashes against `hash.dummy` when no user is found, because a fast "no
|
|
4489
|
+
such user" tells an attacker which addresses are registered.
|
|
4490
|
+
|
|
4491
|
+
## Two-factor
|
|
4492
|
+
|
|
4493
|
+
### The challenge is not a session
|
|
4494
|
+
|
|
4495
|
+
When 2FA is on, a correct password yields a **challenge**, not a login. Nothing is
|
|
4496
|
+
authenticated until the code verifies.
|
|
4497
|
+
|
|
4498
|
+
This matters more than it looks. The usual implementation logs the user in and sets a
|
|
4499
|
+
`needs_2fa` flag for middleware to check — which means they are holding a real
|
|
4500
|
+
authenticated session *before* the second factor. Every route that forgets the
|
|
4501
|
+
middleware, and every `auth()` call that only asks "is anyone logged in?", is then
|
|
4502
|
+
bypassable with just a password. The second factor becomes advisory.
|
|
4503
|
+
|
|
4504
|
+
Here there is no half-authenticated state to forget about, because there is no
|
|
4505
|
+
session. The challenge is a short-lived token bound to a single purpose, so it cannot
|
|
4506
|
+
be swapped for a session cookie or spent as a password-reset link.
|
|
4507
|
+
|
|
4508
|
+
```ts
|
|
4509
|
+
const user = await completeTwoFactor(challenge, code);
|
|
4510
|
+
if (!user) return { error: "That code isn't valid." };
|
|
4511
|
+
|
|
4512
|
+
auth().login(user.id);
|
|
4513
|
+
```
|
|
4514
|
+
|
|
4515
|
+
`completeTwoFactor()` accepts an authenticator code **or** a recovery code.
|
|
4516
|
+
|
|
4517
|
+
### Turning it on takes two steps
|
|
4518
|
+
|
|
4519
|
+
```ts
|
|
4520
|
+
// Step one: a secret and recovery codes. 2FA is NOT on yet.
|
|
4521
|
+
const setup = await enableTwoFactor(user, { issuer: "Acme" });
|
|
4522
|
+
```
|
|
4523
|
+
|
|
4524
|
+
`setup.uri` is an `otpauth://` URI to render as a QR code. `setup.secret` is for
|
|
4525
|
+
manual entry. `setup.recoveryCodes` are shown **once**.
|
|
4526
|
+
|
|
4527
|
+
> Render the QR **locally**. The URI contains the shared secret, so posting it to a
|
|
4528
|
+
> QR-image service hands your users' second factor to a third party.
|
|
4529
|
+
|
|
4530
|
+
```ts
|
|
4531
|
+
// Step two: a working code turns it on.
|
|
4532
|
+
const ok = await confirmTwoFactor(user, code);
|
|
4533
|
+
```
|
|
4534
|
+
|
|
4535
|
+
The two-step dance is deliberate. A one-step "enable" locks out every user who scans
|
|
4536
|
+
the QR wrong or whose phone clock is off — and what's broken is the very thing they'd
|
|
4537
|
+
need to get back in. Until `confirmTwoFactor()` succeeds, `hasTwoFactor()` is false
|
|
4538
|
+
and login works as before.
|
|
4539
|
+
|
|
4540
|
+
### Recovery codes
|
|
4541
|
+
|
|
4542
|
+
Eight by default, hashed at rest, single-use — redeeming one burns it, so a code read
|
|
4543
|
+
over your shoulder is one you have already spent.
|
|
4544
|
+
|
|
4545
|
+
```ts
|
|
4546
|
+
await recoveryCodesRemaining(user); // worth surfacing when it gets low
|
|
4547
|
+
await regenerateRecoveryCodes(user); // invalidates the old set
|
|
4548
|
+
await disableTwoFactor(user); // destroys the secret and the codes
|
|
4549
|
+
```
|
|
4550
|
+
|
|
4551
|
+
### What's stored
|
|
4552
|
+
|
|
4553
|
+
The TOTP secret is **encrypted** at rest and the recovery codes are **hashed**, so a
|
|
4554
|
+
leaked database hands over neither the second factor nor the backdoor.
|
|
4555
|
+
|
|
4556
|
+
TOTP itself is RFC 6238, verified against the RFC's published test vectors, and built
|
|
4557
|
+
on WebCrypto with no dependencies — so it runs unchanged on the edge.
|
|
4558
|
+
|
|
4559
|
+
## Password reset
|
|
4560
|
+
|
|
4561
|
+
```ts
|
|
4562
|
+
await requestPasswordReset(email); // emails a link, or quietly does nothing
|
|
4563
|
+
```
|
|
4564
|
+
|
|
4565
|
+
The answer is the same whether or not that address has an account. "No account with
|
|
4566
|
+
that address" is a free enumeration oracle on an unauthenticated endpoint that anyone
|
|
4567
|
+
can ask about anyone.
|
|
4568
|
+
|
|
4569
|
+
```ts
|
|
4570
|
+
const ok = await resetPassword(token, password);
|
|
4571
|
+
```
|
|
4572
|
+
|
|
4573
|
+
**A reset link works exactly once**, and there is no `password_resets` table. The
|
|
4574
|
+
token carries its own purpose and expiry inside the ciphertext, and it is bound to a
|
|
4575
|
+
fingerprint of the current password hash — so the moment the password changes, every
|
|
4576
|
+
token minted against the old one is dead. Nothing to store, nothing to clean up, and
|
|
4577
|
+
no window where a stale row is still redeemable because a cron job didn't run.
|
|
4578
|
+
|
|
4579
|
+
## Email verification
|
|
4580
|
+
|
|
4581
|
+
```ts
|
|
4582
|
+
await sendVerificationEmail(user);
|
|
4583
|
+
const user = await verifyEmail(token); // idempotent
|
|
4584
|
+
```
|
|
4585
|
+
|
|
4586
|
+
The token is bound to the address it was sent to. A link mailed to an old address
|
|
4587
|
+
cannot verify a new one — otherwise changing your email to someone else's and clicking
|
|
4588
|
+
an older link would mark *their* address as proven.
|
|
4589
|
+
|
|
4590
|
+
## The endpoints
|
|
4591
|
+
|
|
4592
|
+
Mounted at `auth` unless you turn them off (`routes.enabled: false`) and call the
|
|
4593
|
+
functions from your own controllers instead.
|
|
4594
|
+
|
|
4595
|
+
| Method | Path | Notes |
|
|
4596
|
+
| --- | --- | --- |
|
|
4597
|
+
| POST | `/auth/login` | `{ email, password }` → a user, or `{ twoFactor, challenge }` |
|
|
4598
|
+
| POST | `/auth/two-factor` | `{ challenge, code }` — code or recovery code |
|
|
4599
|
+
| POST | `/auth/password/forgot` | Always `202`. Never says who exists. |
|
|
4600
|
+
| POST | `/auth/password/reset` | `{ token, password }` |
|
|
4601
|
+
| POST | `/auth/email/verify` | `{ token }` |
|
|
4602
|
+
| POST | `/auth/email/resend` | Always `202` |
|
|
4603
|
+
|
|
4604
|
+
Every one is unauthenticated and touches credentials, so the group is rate-limited
|
|
4605
|
+
(5 per minute by default). Without a throttle, a six-digit code inside a 30-second
|
|
4606
|
+
window is guessable, and forgot-password is an email cannon pointed at whoever the
|
|
4607
|
+
caller names.
|
|
4608
|
+
|
|
4609
|
+
## Configuration
|
|
4610
|
+
|
|
4611
|
+
```bash
|
|
4612
|
+
keel vendor:publish --tag accounts-config
|
|
4613
|
+
```
|
|
4614
|
+
|
|
4615
|
+
```ts
|
|
4616
|
+
export default {
|
|
4617
|
+
userTable: "users",
|
|
4618
|
+
routes: { enabled: true, prefix: "auth" },
|
|
4619
|
+
passwordReset: { expiresIn: "60m", url: "/reset-password?token=:token" },
|
|
4620
|
+
verification: { expiresIn: "24h", url: "/verify-email?token=:token" },
|
|
4621
|
+
twoFactor: {
|
|
4622
|
+
issuer: env("APP_NAME", "Keel"),
|
|
4623
|
+
window: 1, // ±30s of clock drift
|
|
4624
|
+
challengeExpiresIn: "5m",
|
|
4625
|
+
recoveryCodes: 8,
|
|
4626
|
+
},
|
|
4627
|
+
rateLimit: { max: 5, window: 60 },
|
|
4628
|
+
};
|
|
4629
|
+
```
|
|
4630
|
+
|
|
4631
|
+
`twoFactor.challengeExpiresIn` is the window in which a stolen password alone is
|
|
4632
|
+
enough. Keep it short.
|
|
4633
|
+
|
|
4634
|
+
## A different users table
|
|
4635
|
+
|
|
4636
|
+
Accounts talks to a table through the query builder rather than assuming a `Model`.
|
|
4637
|
+
If your users live somewhere else — an auth service, a legacy schema — replace the
|
|
4638
|
+
store:
|
|
4639
|
+
|
|
4640
|
+
```ts
|
|
4641
|
+
setAccountStore({
|
|
4642
|
+
async findById(id) { /* … */ },
|
|
4643
|
+
async findByEmail(email) { /* … */ },
|
|
4644
|
+
async update(id, values) { /* … */ },
|
|
4645
|
+
});
|
|
4646
|
+
```
|
|
4647
|
+
|
|
4648
|
+
## The schema
|
|
4649
|
+
|
|
4650
|
+
Four columns on your users table, and no tokens table:
|
|
4651
|
+
|
|
4652
|
+
| Column | |
|
|
4653
|
+
| --- | --- |
|
|
4654
|
+
| `email_verified_at` | null until proven |
|
|
4655
|
+
| `two_factor_secret` | encrypted at rest |
|
|
4656
|
+
| `two_factor_recovery_codes` | hashed, then encrypted |
|
|
4657
|
+
| `two_factor_confirmed_at` | null until a working code proves it |
|
|
4658
|
+
|
|
4659
|
+
|
|
4660
|
+
|
|
4440
4661
|
---
|
|
4441
4662
|
|
|
4442
4663
|
<!-- source: docs/ai.md -->
|
|
@@ -7573,6 +7794,23 @@ await db("posts").latest().get(); // ORDER BY created_at DESC
|
|
|
7573
7794
|
await db("posts").oldest("published_at").get();
|
|
7574
7795
|
```
|
|
7575
7796
|
|
|
7797
|
+
Joins, grouping, and conditional/raw clauses:
|
|
7798
|
+
|
|
7799
|
+
```ts
|
|
7800
|
+
await db("posts")
|
|
7801
|
+
.join("users", "posts.user_id", "users.id") // also leftJoin(...)
|
|
7802
|
+
.select("posts.title", "users.name")
|
|
7803
|
+
.get();
|
|
7804
|
+
|
|
7805
|
+
await db("posts").select("user_id").groupBy("user_id").having("COUNT(*)", ">", 5).get();
|
|
7806
|
+
await db("users").distinct().select("country").pluck("country");
|
|
7807
|
+
|
|
7808
|
+
await db("events").whereColumn("updated_at", ">", "created_at").get();
|
|
7809
|
+
await db("users").whereRaw("score >= ?", [10]).orderByRaw("LENGTH(name) DESC").get();
|
|
7810
|
+
|
|
7811
|
+
await db("users").when(search, (q, term) => q.whereLike("name", `%${term}%`)).get();
|
|
7812
|
+
```
|
|
7813
|
+
|
|
7576
7814
|
## Aggregates, single values, and pagination
|
|
7577
7815
|
|
|
7578
7816
|
```ts
|
|
@@ -7602,6 +7840,22 @@ Everything is parameterized — values become bindings, never string-interpolate
|
|
|
7602
7840
|
SQL — so it's injection-safe by construction. Writes return a `WriteResult`;
|
|
7603
7841
|
`insertGetId` unwraps it to just the new id.
|
|
7604
7842
|
|
|
7843
|
+
Counters, bulk upserts, and paged iteration:
|
|
7844
|
+
|
|
7845
|
+
```ts
|
|
7846
|
+
await db("posts").where("id", id).increment("views"); // += 1
|
|
7847
|
+
await db("posts").where("id", id).decrement("stock", 3, { updated_at: now });
|
|
7848
|
+
|
|
7849
|
+
// Insert, updating the listed columns on a unique-key conflict (dialect-aware).
|
|
7850
|
+
await db("users").upsert([{ id: 1, name: "Ada" }], ["id"], ["name"]);
|
|
7851
|
+
await db("logs").insertOrIgnore({ key, value }); // skip duplicates
|
|
7852
|
+
|
|
7853
|
+
// Process a large table a page at a time (return false to stop early).
|
|
7854
|
+
await db("users").orderBy("id").chunk(500, async (rows) => {
|
|
7855
|
+
for (const row of rows) await process(row);
|
|
7856
|
+
});
|
|
7857
|
+
```
|
|
7858
|
+
|
|
7605
7859
|
> **Guard your writes.** `update()` and `delete()` apply to every row that
|
|
7606
7860
|
> matches the current `where` clause — with no `where`, that's the whole table.
|
|
7607
7861
|
> Always scope a write with `where` unless you truly mean to touch every row.
|
|
@@ -12745,12 +12999,41 @@ t.boolean("active").default(true); // sqlite: DEFAULT 1, else DEFAULT true
|
|
|
12745
12999
|
t.integer("retries").default(0); // ... DEFAULT 0
|
|
12746
13000
|
```
|
|
12747
13001
|
|
|
12748
|
-
|
|
12749
|
-
|
|
13002
|
+
### Indexes and foreign keys
|
|
13003
|
+
|
|
13004
|
+
`createTable` builds indexes and foreign keys alongside the columns:
|
|
13005
|
+
|
|
13006
|
+
```ts
|
|
13007
|
+
schema.createTable("members", (t) => {
|
|
13008
|
+
t.id();
|
|
13009
|
+
t.integer("team_id");
|
|
13010
|
+
t.string("email");
|
|
13011
|
+
t.uniqueIndex("email"); // or t.index(["a", "b"]) for composite
|
|
13012
|
+
t.foreign("team_id").references("id").on("teams").onDelete("cascade");
|
|
13013
|
+
});
|
|
13014
|
+
```
|
|
13015
|
+
|
|
13016
|
+
### Altering a table
|
|
13017
|
+
|
|
13018
|
+
`schema.alterTable(name, build)` adds, renames, and drops columns and indexes on
|
|
13019
|
+
an existing table (dialect-aware SQL). Drop an index before the column it covers:
|
|
12750
13020
|
|
|
12751
13021
|
```ts
|
|
12752
13022
|
up: (schema) =>
|
|
12753
|
-
schema.
|
|
13023
|
+
schema.alterTable("users", (t) => {
|
|
13024
|
+
t.string("phone").nullable(); // ADD COLUMN
|
|
13025
|
+
t.renameColumn("name", "full_name");
|
|
13026
|
+
t.index("phone");
|
|
13027
|
+
t.dropIndex("users_legacy_index");
|
|
13028
|
+
t.dropColumn("legacy");
|
|
13029
|
+
}),
|
|
13030
|
+
```
|
|
13031
|
+
|
|
13032
|
+
For anything the builder still doesn't cover, `schema.raw(sql, bindings?)` runs
|
|
13033
|
+
arbitrary SQL:
|
|
13034
|
+
|
|
13035
|
+
```ts
|
|
13036
|
+
up: (schema) => schema.raw("CREATE INDEX idx_posts_user ON posts (user_id)"),
|
|
12754
13037
|
```
|
|
12755
13038
|
|
|
12756
13039
|
> `raw()` writes through the connection **without** placeholder conversion, so
|
|
@@ -13328,6 +13611,85 @@ return json(user); // works directly — json() serializes it
|
|
|
13328
13611
|
user.fill({ name: "X" }); // merge mass-assignable attributes without saving
|
|
13329
13612
|
```
|
|
13330
13613
|
|
|
13614
|
+
Control what `toJSON()` exposes with three statics. `hidden` strips columns;
|
|
13615
|
+
`visible` is an allowlist that wins over everything; `appends` adds computed
|
|
13616
|
+
attributes — a getter or a zero-arg method on the model:
|
|
13617
|
+
|
|
13618
|
+
```ts
|
|
13619
|
+
class User extends Model {
|
|
13620
|
+
static table = "users";
|
|
13621
|
+
static hidden = ["password"]; // never serialized
|
|
13622
|
+
static appends = ["fullName"]; // added to the output
|
|
13623
|
+
get fullName() { return `${this.first} ${this.last}`; }
|
|
13624
|
+
}
|
|
13625
|
+
```
|
|
13626
|
+
|
|
13627
|
+
## Lifecycle events
|
|
13628
|
+
|
|
13629
|
+
A model fires events as it is retrieved, saved, and deleted. Hook onto them to
|
|
13630
|
+
slug a title, bust a cache, or cascade — without touching every call site. The
|
|
13631
|
+
`*ing` events are **cancelable**: a hook returning `false` aborts the write.
|
|
13632
|
+
|
|
13633
|
+
```ts
|
|
13634
|
+
User.creating((user) => { user.uuid = crypto.randomUUID(); });
|
|
13635
|
+
User.saved((user) => cache().forget(`user:${user.id}`));
|
|
13636
|
+
User.deleting((user) => (user.isRoot ? false : undefined)); // veto
|
|
13637
|
+
|
|
13638
|
+
// Or group them in an observer:
|
|
13639
|
+
User.observe({
|
|
13640
|
+
creating: (u) => { u.uuid = crypto.randomUUID(); },
|
|
13641
|
+
deleted: (u) => audit(`deleted ${u.id}`),
|
|
13642
|
+
});
|
|
13643
|
+
```
|
|
13644
|
+
|
|
13645
|
+
Events: `retrieved`, `creating`/`created`, `updating`/`updated`,
|
|
13646
|
+
`saving`/`saved`, `deleting`/`deleted`, `restoring`/`restored`. They're keyed by
|
|
13647
|
+
the exact class (subclasses don't inherit a parent's hooks).
|
|
13648
|
+
|
|
13649
|
+
## Query scopes
|
|
13650
|
+
|
|
13651
|
+
A **global scope** constrains every query a model builds — the base for
|
|
13652
|
+
multi-tenancy, published-only reads, and soft deletes:
|
|
13653
|
+
|
|
13654
|
+
```ts
|
|
13655
|
+
Post.addGlobalScope("published", (q) => q.where("published", true));
|
|
13656
|
+
await Post.all(); // only published
|
|
13657
|
+
await Post.query().where("author_id", 1).get(); // still only published
|
|
13658
|
+
```
|
|
13659
|
+
|
|
13660
|
+
A **local scope** is just a static method returning a query — no framework
|
|
13661
|
+
feature needed:
|
|
13662
|
+
|
|
13663
|
+
```ts
|
|
13664
|
+
class Post extends Model {
|
|
13665
|
+
static popular() { return this.query().where("views", ">", 1000); }
|
|
13666
|
+
}
|
|
13667
|
+
await Post.popular().orderBy("views", "desc").get();
|
|
13668
|
+
```
|
|
13669
|
+
|
|
13670
|
+
## Soft deletes
|
|
13671
|
+
|
|
13672
|
+
Opt in with `static softDeletes = true` and a `deleted_at` column. `delete()`
|
|
13673
|
+
then sets the timestamp instead of removing the row, and a global scope hides
|
|
13674
|
+
soft-deleted rows from every query.
|
|
13675
|
+
|
|
13676
|
+
```ts
|
|
13677
|
+
class User extends Model {
|
|
13678
|
+
static table = "users";
|
|
13679
|
+
static softDeletes = true;
|
|
13680
|
+
static casts = { deleted_at: "date" };
|
|
13681
|
+
}
|
|
13682
|
+
|
|
13683
|
+
await user.delete(); // sets deleted_at; row stays in the table
|
|
13684
|
+
user.trashed(); // true
|
|
13685
|
+
await User.find(user.id); // null — hidden by the scope
|
|
13686
|
+
|
|
13687
|
+
await User.withTrashed().get(); // include soft-deleted
|
|
13688
|
+
await User.onlyTrashed().get(); // only soft-deleted
|
|
13689
|
+
await user.restore(); // clear deleted_at
|
|
13690
|
+
await user.forceDelete(); // remove the row for good
|
|
13691
|
+
```
|
|
13692
|
+
|
|
13331
13693
|
## Relationships
|
|
13332
13694
|
|
|
13333
13695
|
Define a relationship as a method that returns one of `hasMany` / `hasOne` /
|
|
@@ -13377,6 +13739,29 @@ users[0].toJSON(); // includes `posts` and `roles`
|
|
|
13377
13739
|
Loaded relations are stored off the model, so they never leak into `save()`,
|
|
13378
13740
|
and `toJSON()` serializes them (nested models included).
|
|
13379
13741
|
|
|
13742
|
+
### Querying relationships (`with`, `withCount`, `whereHas`)
|
|
13743
|
+
|
|
13744
|
+
`Model.query()` returns a model-aware builder with the relationship operations a
|
|
13745
|
+
raw query can't express. `with()` eager-loads (dotted paths nest), `withCount()`
|
|
13746
|
+
adds a `<relation>_count`, and `has`/`whereHas`/`doesntHave` filter by whether a
|
|
13747
|
+
related row exists:
|
|
13748
|
+
|
|
13749
|
+
```ts
|
|
13750
|
+
const users = await User.query()
|
|
13751
|
+
.where("active", true)
|
|
13752
|
+
.with("posts.comments") // nested eager load
|
|
13753
|
+
.withCount("posts") // users[i].posts_count
|
|
13754
|
+
.whereHas("posts", (q) => q.where("published", true))
|
|
13755
|
+
.get();
|
|
13756
|
+
|
|
13757
|
+
await User.has("posts").get(); // users with at least one post
|
|
13758
|
+
await User.doesntHave("posts").get(); // users with none
|
|
13759
|
+
```
|
|
13760
|
+
|
|
13761
|
+
`with`/`withCount`/`whereHas`/`has`/`doesntHave` are also static shortcuts
|
|
13762
|
+
(`User.with(...)`, `User.whereHas(...)`). Existence filters use the same
|
|
13763
|
+
driver-agnostic two-query strategy as the relations themselves — no JOIN.
|
|
13764
|
+
|
|
13380
13765
|
### Many-to-many
|
|
13381
13766
|
|
|
13382
13767
|
`belongsToMany` reads through a pivot table (default name: the two table names
|
|
@@ -13397,11 +13782,39 @@ this.belongsTo(User, "owner_id", "id");
|
|
|
13397
13782
|
this.belongsToMany(Role, "user_roles", "user_id", "role_id");
|
|
13398
13783
|
```
|
|
13399
13784
|
|
|
13785
|
+
### Polymorphic
|
|
13786
|
+
|
|
13787
|
+
A polymorphic relation lets one model belong to more than one type. The related
|
|
13788
|
+
rows carry `<name>_id` + `<name>_type`; register each owner type so `morphTo`
|
|
13789
|
+
can resolve it:
|
|
13790
|
+
|
|
13791
|
+
```ts
|
|
13792
|
+
class Post extends Model {
|
|
13793
|
+
comments() { return this.morphMany(Comment, "commentable"); }
|
|
13794
|
+
}
|
|
13795
|
+
class Video extends Model {
|
|
13796
|
+
comments() { return this.morphMany(Comment, "commentable"); }
|
|
13797
|
+
}
|
|
13798
|
+
class Comment extends Model {
|
|
13799
|
+
commentable() { return this.morphTo("commentable"); } // resolves back to Post or Video
|
|
13800
|
+
}
|
|
13801
|
+
|
|
13802
|
+
registerMorphType("Post", Post);
|
|
13803
|
+
registerMorphType("Video", Video);
|
|
13804
|
+
|
|
13805
|
+
await post.comments().create({ body: "nice" }); // sets commentable_id/_type
|
|
13806
|
+
const owner = await comment.commentable(); // Post | Video | null
|
|
13807
|
+
```
|
|
13808
|
+
|
|
13809
|
+
`morphOne` is the one-to-one variant. Eager loading (`Model.load` / `with`) works
|
|
13810
|
+
across mixed types.
|
|
13811
|
+
|
|
13400
13812
|
## What this is (and isn't)
|
|
13401
13813
|
|
|
13402
|
-
This is a
|
|
13403
|
-
|
|
13404
|
-
(`
|
|
13814
|
+
This is a compact active-record — CRUD, lifecycle events, scopes, soft deletes,
|
|
13815
|
+
serialization control, eager loading (including nested `with("posts.comments")`),
|
|
13816
|
+
relationship queries (`whereHas`/`withCount`), and polymorphic relations — all on
|
|
13817
|
+
a driver-agnostic query builder, no ORM dependency. For complex one-off queries
|
|
13405
13818
|
you can always drop to `db()` or your driver directly.
|
|
13406
13819
|
|
|
13407
13820
|
---
|
package/llms.txt
CHANGED
|
@@ -9,6 +9,7 @@ Userland imports everything from `@shaferllc/keel/core`.
|
|
|
9
9
|
|
|
10
10
|
## Docs
|
|
11
11
|
|
|
12
|
+
- [Accounts](https://github.com/shaferllc/keel/blob/main/docs/accounts.md): Password reset, email verification, and two-factor authentication — the flows every app with a login needs, built on primitives already in core (hash, encryption, mail, rate-limit).
|
|
12
13
|
- [Building Keel apps with AI](https://github.com/shaferllc/keel/blob/main/docs/ai.md): Keel is built to be written with an AI agent.
|
|
13
14
|
- [API Resources](https://github.com/shaferllc/keel/blob/main/docs/api-resources.md): apiResource(router, Model, options) generates a full CRUD REST API from a Keel model — explicit, server-side, and composed from pieces you already have.
|
|
14
15
|
- [Architecture](https://github.com/shaferllc/keel/blob/main/docs/architecture.md): Keel is small on purpose. This page maps the pieces and traces a request from socket to response. Nothing here is magic — every layer is a short, readable file in src/core/, and this guide is mostly a reading order for it.
|
|
@@ -73,6 +74,7 @@ Userland imports everything from `@shaferllc/keel/core`.
|
|
|
73
74
|
|
|
74
75
|
Every topic has a runnable, type-checked example:
|
|
75
76
|
|
|
77
|
+
- [Accounts example](https://github.com/shaferllc/keel/blob/main/docs/examples/accounts.ts)
|
|
76
78
|
- [API Resources example](https://github.com/shaferllc/keel/blob/main/docs/examples/api-resources.ts)
|
|
77
79
|
- [Authentication example](https://github.com/shaferllc/keel/blob/main/docs/examples/authentication.ts)
|
|
78
80
|
- [Authorization example](https://github.com/shaferllc/keel/blob/main/docs/examples/authorization.ts)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shaferllc/keel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.81.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The house framework for Node.js — a service container, providers, routing, JSX views, and a code-generating console.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -61,6 +61,10 @@
|
|
|
61
61
|
"types": "./dist/api/index.d.ts",
|
|
62
62
|
"import": "./dist/api/index.js"
|
|
63
63
|
},
|
|
64
|
+
"./accounts": {
|
|
65
|
+
"types": "./dist/accounts/index.d.ts",
|
|
66
|
+
"import": "./dist/accounts/index.js"
|
|
67
|
+
},
|
|
64
68
|
"./billing": {
|
|
65
69
|
"types": "./dist/billing/index.d.ts",
|
|
66
70
|
"import": "./dist/billing/index.js"
|
|
@@ -93,7 +97,7 @@
|
|
|
93
97
|
"test:coverage": "node --import tsx --test --experimental-test-coverage --test-coverage-exclude='tests/**' tests/*.test.ts",
|
|
94
98
|
"build:ai": "tsx scripts/build-ai.ts",
|
|
95
99
|
"build:watch-ui": "vite build --config src/watch/ui/vite.config.ts",
|
|
96
|
-
"build:watch-copy": "mkdir -p dist/watch/ui && cp -R src/watch/ui/dist dist/watch/ui/dist && cp src/watch/watch.config.stub dist/watch/ && cp src/openapi/openapi.config.stub dist/openapi/ && cp src/api/api.config.stub dist/api/ && cp src/billing/billing.config.stub dist/billing/",
|
|
100
|
+
"build:watch-copy": "mkdir -p dist/watch/ui && cp -R src/watch/ui/dist dist/watch/ui/dist && cp src/watch/watch.config.stub dist/watch/ && cp src/openapi/openapi.config.stub dist/openapi/ && cp src/api/api.config.stub dist/api/ && cp src/billing/billing.config.stub dist/billing/ && cp src/accounts/accounts.config.stub dist/accounts/",
|
|
97
101
|
"build": "npm run build:ai && npm run build:watch-ui && rm -rf dist && tsc -p tsconfig.build.json && npm run build:watch-copy",
|
|
98
102
|
"prepare": "npm run build",
|
|
99
103
|
"typecheck:tests": "tsc --noEmit -p tsconfig.tests.json",
|