@shaferllc/keel 0.79.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 (84) hide show
  1. package/README.md +3 -1
  2. package/dist/accounts/accounts.config.stub +50 -0
  3. package/dist/accounts/config.d.ts +46 -0
  4. package/dist/accounts/config.js +39 -0
  5. package/dist/accounts/flows.d.ts +50 -0
  6. package/dist/accounts/flows.js +133 -0
  7. package/dist/accounts/index.d.ts +28 -0
  8. package/dist/accounts/index.js +23 -0
  9. package/dist/accounts/migration.d.ts +14 -0
  10. package/dist/accounts/migration.js +39 -0
  11. package/dist/accounts/provider.d.ts +18 -0
  12. package/dist/accounts/provider.js +37 -0
  13. package/dist/accounts/routes.d.ts +15 -0
  14. package/dist/accounts/routes.js +116 -0
  15. package/dist/accounts/store.d.ts +33 -0
  16. package/dist/accounts/store.js +37 -0
  17. package/dist/accounts/tokens.d.ts +60 -0
  18. package/dist/accounts/tokens.js +116 -0
  19. package/dist/accounts/totp.d.ts +58 -0
  20. package/dist/accounts/totp.js +134 -0
  21. package/dist/accounts/two-factor.d.ts +56 -0
  22. package/dist/accounts/two-factor.js +146 -0
  23. package/dist/billing/billable.d.ts +83 -0
  24. package/dist/billing/billable.js +177 -0
  25. package/dist/billing/billing.config.stub +33 -0
  26. package/dist/billing/builder.d.ts +54 -0
  27. package/dist/billing/builder.js +104 -0
  28. package/dist/billing/config.d.ts +43 -0
  29. package/dist/billing/config.js +35 -0
  30. package/dist/billing/crypto.d.ts +11 -0
  31. package/dist/billing/crypto.js +27 -0
  32. package/dist/billing/drivers/fake.d.ts +58 -0
  33. package/dist/billing/drivers/fake.js +190 -0
  34. package/dist/billing/drivers/index.d.ts +11 -0
  35. package/dist/billing/drivers/index.js +22 -0
  36. package/dist/billing/drivers/paddle.d.ts +39 -0
  37. package/dist/billing/drivers/paddle.js +197 -0
  38. package/dist/billing/drivers/stripe.d.ts +33 -0
  39. package/dist/billing/drivers/stripe.js +278 -0
  40. package/dist/billing/events.d.ts +25 -0
  41. package/dist/billing/events.js +7 -0
  42. package/dist/billing/gateway.d.ts +170 -0
  43. package/dist/billing/gateway.js +24 -0
  44. package/dist/billing/index.d.ts +28 -0
  45. package/dist/billing/index.js +19 -0
  46. package/dist/billing/manager.d.ts +34 -0
  47. package/dist/billing/manager.js +61 -0
  48. package/dist/billing/migration.d.ts +13 -0
  49. package/dist/billing/migration.js +68 -0
  50. package/dist/billing/provider.d.ts +20 -0
  51. package/dist/billing/provider.js +42 -0
  52. package/dist/billing/routes.d.ts +11 -0
  53. package/dist/billing/routes.js +21 -0
  54. package/dist/billing/subscription-item.d.ts +18 -0
  55. package/dist/billing/subscription-item.js +11 -0
  56. package/dist/billing/subscription.d.ts +85 -0
  57. package/dist/billing/subscription.js +157 -0
  58. package/dist/billing/webhooks.d.ts +26 -0
  59. package/dist/billing/webhooks.js +75 -0
  60. package/dist/core/database.d.ts +36 -0
  61. package/dist/core/database.js +141 -4
  62. package/dist/core/index.d.ts +5 -2
  63. package/dist/core/index.js +3 -2
  64. package/dist/core/migrations.d.ts +52 -2
  65. package/dist/core/migrations.js +134 -3
  66. package/dist/core/model-events.d.ts +34 -0
  67. package/dist/core/model-events.js +71 -0
  68. package/dist/core/model-query.d.ts +68 -0
  69. package/dist/core/model-query.js +234 -0
  70. package/dist/core/model.d.ts +91 -4
  71. package/dist/core/model.js +217 -32
  72. package/dist/core/relations.d.ts +53 -0
  73. package/dist/core/relations.js +242 -0
  74. package/docs/accounts.md +214 -0
  75. package/docs/ai-manifest.json +70 -1
  76. package/docs/billing.md +242 -0
  77. package/docs/database.md +33 -0
  78. package/docs/examples/accounts.ts +150 -0
  79. package/docs/migrations.md +32 -3
  80. package/docs/models.md +133 -3
  81. package/docs/packages.md +3 -1
  82. package/llms-full.txt +671 -7
  83. package/llms.txt +3 -0
  84. package/package.json +10 -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 -->
@@ -5332,6 +5553,255 @@ all gates/policies/hooks (a test helper).
5332
5553
 
5333
5554
 
5334
5555
 
5556
+ ---
5557
+
5558
+ <!-- source: docs/billing.md -->
5559
+
5560
+ # Billing
5561
+
5562
+ Keel Billing is a subscription-billing layer — a port of [Laravel
5563
+ Cashier](https://laravel.com/docs/13.x/billing) — for charging customers,
5564
+ managing subscriptions, and reconciling gateway state through webhooks. It ships
5565
+ as a Keel [package](./packages.md) and supports two gateways behind one API:
5566
+ **Stripe** and **Paddle**.
5567
+
5568
+ It attaches to a model with a mixin. Your `User` becomes billable, gains a
5569
+ gateway customer, and can create subscriptions and charges:
5570
+
5571
+ ```ts
5572
+ import { Model } from "@shaferllc/keel/core";
5573
+ import { Billable } from "@shaferllc/keel/billing";
5574
+
5575
+ export class User extends Billable(Model) {
5576
+ static table = "users";
5577
+ declare email: string;
5578
+ }
5579
+ ```
5580
+
5581
+ ## Install
5582
+
5583
+ ```ts
5584
+ // bootstrap/providers.ts
5585
+ import { BillingServiceProvider } from "@shaferllc/keel/billing";
5586
+
5587
+ export const providers = [AppServiceProvider, BillingServiceProvider];
5588
+ ```
5589
+
5590
+ Publish the config and create the tables:
5591
+
5592
+ ```bash
5593
+ keel vendor:publish --tag billing-config # writes config/billing.ts
5594
+ keel migrate # creates subscriptions + subscription_items,
5595
+ # and adds billing columns to users
5596
+ ```
5597
+
5598
+ Set your keys in `.env`:
5599
+
5600
+ ```ini
5601
+ BILLING_GATEWAY=stripe # or "paddle"
5602
+ STRIPE_SECRET_KEY=sk_...
5603
+ STRIPE_WEBHOOK_SECRET=whsec_...
5604
+ # Paddle:
5605
+ PADDLE_API_KEY=...
5606
+ PADDLE_WEBHOOK_SECRET=...
5607
+ PADDLE_CLIENT_TOKEN=...
5608
+ PADDLE_SANDBOX=true
5609
+ ```
5610
+
5611
+ ## One API, two gateways
5612
+
5613
+ Everything you call goes through a gateway-neutral interface, so switching from
5614
+ Stripe to Paddle is a config change. The active gateway comes from
5615
+ `config("billing.default")`; a billable can also carry its own in
5616
+ `billing_gateway`. Money is always an integer in the smallest currency unit
5617
+ (cents). See [Gateway differences](#gateway-differences) for where Paddle's
5618
+ merchant-of-record model diverges.
5619
+
5620
+ ## Customers
5621
+
5622
+ A gateway customer is created lazily the first time you need one, but you can
5623
+ create it up front:
5624
+
5625
+ ```ts
5626
+ await user.createAsCustomer(); // creates the customer, stores its id
5627
+ user.hasBillingId(); // true
5628
+ await user.getCustomerId(); // the id (creates if missing)
5629
+ ```
5630
+
5631
+ Override what gets synced by defining `billingName()` / `billingEmail()` on your
5632
+ model. By default they read `name` / `email`.
5633
+
5634
+ ## Subscriptions
5635
+
5636
+ Build a subscription with the fluent builder:
5637
+
5638
+ ```ts
5639
+ await user
5640
+ .newSubscription("default", "price_pro")
5641
+ .trialDays(14)
5642
+ .quantity(3)
5643
+ .create(paymentMethodId); // paymentMethodId optional if a default is on file
5644
+ ```
5645
+
5646
+ Multiple prices (add-ons) are an array; `withMetadata`, `trialUntil`, and
5647
+ `skipTrial` are also available. To send the customer to a hosted checkout
5648
+ instead of charging now, swap `.create()` for `.checkout()`:
5649
+
5650
+ ```ts
5651
+ const session = await user
5652
+ .newSubscription("default", "price_pro")
5653
+ .checkout({ successUrl: "...", cancelUrl: "..." });
5654
+ // Stripe: redirect to session.url. Paddle: open the overlay with session.clientToken.
5655
+ ```
5656
+
5657
+ ### Status
5658
+
5659
+ Status questions are answered from local columns — no gateway round-trip:
5660
+
5661
+ ```ts
5662
+ await user.subscribed(); // valid (active | trial | grace)
5663
+ await user.subscribedToPrice("price_pro");
5664
+ await user.onTrial();
5665
+ const sub = await user.subscription(); // the "default" subscription, or null
5666
+
5667
+ sub.active(); sub.onTrial(); sub.recurring();
5668
+ sub.canceled(); sub.onGracePeriod(); sub.ended();
5669
+ sub.paused(); sub.valid(); sub.hasIncompletePayment();
5670
+ ```
5671
+
5672
+ ### Changing a subscription
5673
+
5674
+ ```ts
5675
+ await sub.swap("price_enterprise"); // change price(s)
5676
+ await sub.updateQuantity(10);
5677
+ await sub.incrementQuantity(2);
5678
+ await sub.decrementQuantity();
5679
+ ```
5680
+
5681
+ Each of these calls the gateway and syncs the result back into the local row.
5682
+
5683
+ ### Cancelling
5684
+
5685
+ ```ts
5686
+ await sub.cancel(); // at period end — access continues through the grace period
5687
+ await sub.onGracePeriod();// true
5688
+ await sub.resume(); // revive a subscription still in its grace period
5689
+ await sub.cancelNow(); // immediately; sub.ended() becomes true
5690
+ ```
5691
+
5692
+ ### Trials
5693
+
5694
+ ```ts
5695
+ await sub.endTrial();
5696
+ await sub.extendTrial(new Date("2026-01-01"));
5697
+ user.onGenericTrial(); // a trial_ends_at on the user, before any subscription
5698
+ ```
5699
+
5700
+ ## Single charges
5701
+
5702
+ ```ts
5703
+ const charge = await user.charge(2000, { paymentMethod: "pm_1", description: "Credits" });
5704
+ await user.refund(charge.id); // full refund
5705
+ await user.refund(charge.id, 500); // partial
5706
+
5707
+ const session = await user.checkout("price_onetime", { successUrl, cancelUrl });
5708
+ ```
5709
+
5710
+ ## Payment methods (Stripe)
5711
+
5712
+ Collect a card with a SetupIntent, then create the subscription with the
5713
+ resulting payment method:
5714
+
5715
+ ```ts
5716
+ const intent = await user.createSetupIntent(); // return intent.clientSecret to the front end
5717
+ const methods = await user.paymentMethods();
5718
+ ```
5719
+
5720
+ These are Stripe-only capabilities; calling them on the Paddle gateway throws a
5721
+ `BillingError` (Paddle collects cards in its own hosted checkout).
5722
+
5723
+ ## Invoices
5724
+
5725
+ ```ts
5726
+ const invoices = await user.invoices(); // GatewayInvoice[] — total, currency, status, url
5727
+ ```
5728
+
5729
+ ## Webhooks
5730
+
5731
+ The package mounts one webhook endpoint per gateway at
5732
+ `config("billing.webhook.path")`:
5733
+
5734
+ ```
5735
+ POST /billing/webhook/stripe
5736
+ POST /billing/webhook/paddle
5737
+ ```
5738
+
5739
+ Point your gateway dashboard at the matching URL. Each request is verified
5740
+ against the gateway's signing secret (HMAC-SHA256 over the raw body), the local
5741
+ subscription is synced, and typed events fire:
5742
+
5743
+ ```ts
5744
+ import { listen } from "@shaferllc/keel/core";
5745
+
5746
+ listen("billing.subscription.updated", (e) => {
5747
+ // e.gateway, e.subscriptionId, e.providerId, e.status
5748
+ });
5749
+ listen("billing.webhook.received", (e) => { /* e.gateway, e.type, e.id */ });
5750
+ ```
5751
+
5752
+ Events: `billing.webhook.received`, `billing.subscription.created` / `.updated`
5753
+ / `.deleted`.
5754
+
5755
+ An update to a subscription already in your database is always synced. A brand
5756
+ new subscription born from a Paddle checkout has no local row yet — register a
5757
+ resolver so the handler can create it:
5758
+
5759
+ ```ts
5760
+ import { resolveBillableUsing } from "@shaferllc/keel/billing";
5761
+
5762
+ resolveBillableUsing(async (customerId) => {
5763
+ const user = (await User.query().where("billing_customer_id", customerId).first());
5764
+ return user ? { id: user.id, type: "User" } : null;
5765
+ });
5766
+ ```
5767
+
5768
+ ## Gateway differences
5769
+
5770
+ | Concern | Stripe | Paddle |
5771
+ |---------|--------|--------|
5772
+ | Create a subscription server-side | `create(pmId)` | Not supported — use `checkout()`; the webhook creates the local row |
5773
+ | One-off `charge()` | Confirms a PaymentIntent | Not supported — use `checkout({ mode })` / transactions |
5774
+ | SetupIntent / `paymentMethods()` | Supported | Throws `BillingError` (hosted checkout) |
5775
+ | Checkout handle | `session.url` (redirect) | `session.clientToken` (overlay/inline) |
5776
+ | Webhook signature | `Stripe-Signature: t=…,v1=…` | `Paddle-Signature: ts=…;h1=…` |
5777
+
5778
+ ## Schema
5779
+
5780
+ The migration is gateway-neutral: `subscriptions` (with `gateway`,
5781
+ `provider_id`, `provider_status`, `provider_price`, trial/grace timestamps),
5782
+ `subscription_items`, and columns on `users` (`billing_gateway`,
5783
+ `billing_customer_id`, `pm_type`, `pm_last_four`, `trial_ends_at`). Cashier
5784
+ targets the standard `users` billable table.
5785
+
5786
+ ## Testing
5787
+
5788
+ The package ships a `FakeGateway` — an in-memory gateway that records every call
5789
+ — so you can drive billing without touching a network:
5790
+
5791
+ ```ts
5792
+ import { BillingManager, setBilling, FakeGateway } from "@shaferllc/keel/billing";
5793
+
5794
+ const fake = new FakeGateway();
5795
+ const manager = new BillingManager(config); // config.default = "fake"
5796
+ manager.register("fake", () => fake);
5797
+ setBilling(manager);
5798
+
5799
+ await user.newSubscription("default", "price_pro").create();
5800
+ fake.calls.filter((c) => c.method === "createSubscription"); // assert what was asked
5801
+ ```
5802
+
5803
+
5804
+
5335
5805
  ---
5336
5806
 
5337
5807
  <!-- source: docs/broadcasting.md -->
@@ -7324,6 +7794,23 @@ await db("posts").latest().get(); // ORDER BY created_at DESC
7324
7794
  await db("posts").oldest("published_at").get();
7325
7795
  ```
7326
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
+
7327
7814
  ## Aggregates, single values, and pagination
7328
7815
 
7329
7816
  ```ts
@@ -7353,6 +7840,22 @@ Everything is parameterized — values become bindings, never string-interpolate
7353
7840
  SQL — so it's injection-safe by construction. Writes return a `WriteResult`;
7354
7841
  `insertGetId` unwraps it to just the new id.
7355
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
+
7356
7859
  > **Guard your writes.** `update()` and `delete()` apply to every row that
7357
7860
  > matches the current `where` clause — with no `where`, that's the whole table.
7358
7861
  > Always scope a write with `where` unless you truly mean to touch every row.
@@ -12496,12 +12999,41 @@ t.boolean("active").default(true); // sqlite: DEFAULT 1, else DEFAULT true
12496
12999
  t.integer("retries").default(0); // ... DEFAULT 0
12497
13000
  ```
12498
13001
 
12499
- For anything the builder doesn't cover — indexes, foreign keys, an `ALTER
12500
- TABLE` — `schema.raw(sql, bindings?)` runs arbitrary SQL:
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:
12501
13020
 
12502
13021
  ```ts
12503
13022
  up: (schema) =>
12504
- schema.raw("CREATE INDEX idx_posts_user ON posts (user_id)"),
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)"),
12505
13037
  ```
12506
13038
 
12507
13039
  > `raw()` writes through the connection **without** placeholder conversion, so
@@ -13079,6 +13611,85 @@ return json(user); // works directly — json() serializes it
13079
13611
  user.fill({ name: "X" }); // merge mass-assignable attributes without saving
13080
13612
  ```
13081
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
+
13082
13693
  ## Relationships
13083
13694
 
13084
13695
  Define a relationship as a method that returns one of `hasMany` / `hasOne` /
@@ -13128,6 +13739,29 @@ users[0].toJSON(); // includes `posts` and `roles`
13128
13739
  Loaded relations are stored off the model, so they never leak into `save()`,
13129
13740
  and `toJSON()` serializes them (nested models included).
13130
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
+
13131
13765
  ### Many-to-many
13132
13766
 
13133
13767
  `belongsToMany` reads through a pivot table (default name: the two table names
@@ -13148,11 +13782,39 @@ this.belongsTo(User, "owner_id", "id");
13148
13782
  this.belongsToMany(Role, "user_roles", "user_id", "role_id");
13149
13783
  ```
13150
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
+
13151
13812
  ## What this is (and isn't)
13152
13813
 
13153
- This is a deliberately small active-record — enough for CRUD, relationships,
13154
- casts, and simple queries without an ORM dependency. Nested eager loading
13155
- (`posts.comments`) and query-time `with()` aren't here yet. For complex schemas
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
13156
13818
  you can always drop to `db()` or your driver directly.
13157
13819
 
13158
13820
  ---
@@ -14329,7 +14991,9 @@ adds the conventions a *shippable* package needs so it can carry its own schema
14329
14991
  and assets instead of asking the app to wire them by hand.
14330
14992
 
14331
14993
  [Keel Watch](./watch.md) — the debug dashboard — is a first-party package and the
14332
- reference implementation of everything below.
14994
+ reference implementation of everything below. [Billing](./billing.md) (a Cashier
14995
+ port for Stripe and Paddle) is another, and shows a package contributing models,
14996
+ a schema migration, gateway drivers, and verified webhook routes.
14333
14997
 
14334
14998
  ## The shape of a package
14335
14999