@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
@@ -0,0 +1,242 @@
1
+ # Billing
2
+
3
+ Keel Billing is a subscription-billing layer — a port of [Laravel
4
+ Cashier](https://laravel.com/docs/13.x/billing) — for charging customers,
5
+ managing subscriptions, and reconciling gateway state through webhooks. It ships
6
+ as a Keel [package](./packages.md) and supports two gateways behind one API:
7
+ **Stripe** and **Paddle**.
8
+
9
+ It attaches to a model with a mixin. Your `User` becomes billable, gains a
10
+ gateway customer, and can create subscriptions and charges:
11
+
12
+ ```ts
13
+ import { Model } from "@shaferllc/keel/core";
14
+ import { Billable } from "@shaferllc/keel/billing";
15
+
16
+ export class User extends Billable(Model) {
17
+ static table = "users";
18
+ declare email: string;
19
+ }
20
+ ```
21
+
22
+ ## Install
23
+
24
+ ```ts
25
+ // bootstrap/providers.ts
26
+ import { BillingServiceProvider } from "@shaferllc/keel/billing";
27
+
28
+ export const providers = [AppServiceProvider, BillingServiceProvider];
29
+ ```
30
+
31
+ Publish the config and create the tables:
32
+
33
+ ```bash
34
+ keel vendor:publish --tag billing-config # writes config/billing.ts
35
+ keel migrate # creates subscriptions + subscription_items,
36
+ # and adds billing columns to users
37
+ ```
38
+
39
+ Set your keys in `.env`:
40
+
41
+ ```ini
42
+ BILLING_GATEWAY=stripe # or "paddle"
43
+ STRIPE_SECRET_KEY=sk_...
44
+ STRIPE_WEBHOOK_SECRET=whsec_...
45
+ # Paddle:
46
+ PADDLE_API_KEY=...
47
+ PADDLE_WEBHOOK_SECRET=...
48
+ PADDLE_CLIENT_TOKEN=...
49
+ PADDLE_SANDBOX=true
50
+ ```
51
+
52
+ ## One API, two gateways
53
+
54
+ Everything you call goes through a gateway-neutral interface, so switching from
55
+ Stripe to Paddle is a config change. The active gateway comes from
56
+ `config("billing.default")`; a billable can also carry its own in
57
+ `billing_gateway`. Money is always an integer in the smallest currency unit
58
+ (cents). See [Gateway differences](#gateway-differences) for where Paddle's
59
+ merchant-of-record model diverges.
60
+
61
+ ## Customers
62
+
63
+ A gateway customer is created lazily the first time you need one, but you can
64
+ create it up front:
65
+
66
+ ```ts
67
+ await user.createAsCustomer(); // creates the customer, stores its id
68
+ user.hasBillingId(); // true
69
+ await user.getCustomerId(); // the id (creates if missing)
70
+ ```
71
+
72
+ Override what gets synced by defining `billingName()` / `billingEmail()` on your
73
+ model. By default they read `name` / `email`.
74
+
75
+ ## Subscriptions
76
+
77
+ Build a subscription with the fluent builder:
78
+
79
+ ```ts
80
+ await user
81
+ .newSubscription("default", "price_pro")
82
+ .trialDays(14)
83
+ .quantity(3)
84
+ .create(paymentMethodId); // paymentMethodId optional if a default is on file
85
+ ```
86
+
87
+ Multiple prices (add-ons) are an array; `withMetadata`, `trialUntil`, and
88
+ `skipTrial` are also available. To send the customer to a hosted checkout
89
+ instead of charging now, swap `.create()` for `.checkout()`:
90
+
91
+ ```ts
92
+ const session = await user
93
+ .newSubscription("default", "price_pro")
94
+ .checkout({ successUrl: "...", cancelUrl: "..." });
95
+ // Stripe: redirect to session.url. Paddle: open the overlay with session.clientToken.
96
+ ```
97
+
98
+ ### Status
99
+
100
+ Status questions are answered from local columns — no gateway round-trip:
101
+
102
+ ```ts
103
+ await user.subscribed(); // valid (active | trial | grace)
104
+ await user.subscribedToPrice("price_pro");
105
+ await user.onTrial();
106
+ const sub = await user.subscription(); // the "default" subscription, or null
107
+
108
+ sub.active(); sub.onTrial(); sub.recurring();
109
+ sub.canceled(); sub.onGracePeriod(); sub.ended();
110
+ sub.paused(); sub.valid(); sub.hasIncompletePayment();
111
+ ```
112
+
113
+ ### Changing a subscription
114
+
115
+ ```ts
116
+ await sub.swap("price_enterprise"); // change price(s)
117
+ await sub.updateQuantity(10);
118
+ await sub.incrementQuantity(2);
119
+ await sub.decrementQuantity();
120
+ ```
121
+
122
+ Each of these calls the gateway and syncs the result back into the local row.
123
+
124
+ ### Cancelling
125
+
126
+ ```ts
127
+ await sub.cancel(); // at period end — access continues through the grace period
128
+ await sub.onGracePeriod();// true
129
+ await sub.resume(); // revive a subscription still in its grace period
130
+ await sub.cancelNow(); // immediately; sub.ended() becomes true
131
+ ```
132
+
133
+ ### Trials
134
+
135
+ ```ts
136
+ await sub.endTrial();
137
+ await sub.extendTrial(new Date("2026-01-01"));
138
+ user.onGenericTrial(); // a trial_ends_at on the user, before any subscription
139
+ ```
140
+
141
+ ## Single charges
142
+
143
+ ```ts
144
+ const charge = await user.charge(2000, { paymentMethod: "pm_1", description: "Credits" });
145
+ await user.refund(charge.id); // full refund
146
+ await user.refund(charge.id, 500); // partial
147
+
148
+ const session = await user.checkout("price_onetime", { successUrl, cancelUrl });
149
+ ```
150
+
151
+ ## Payment methods (Stripe)
152
+
153
+ Collect a card with a SetupIntent, then create the subscription with the
154
+ resulting payment method:
155
+
156
+ ```ts
157
+ const intent = await user.createSetupIntent(); // return intent.clientSecret to the front end
158
+ const methods = await user.paymentMethods();
159
+ ```
160
+
161
+ These are Stripe-only capabilities; calling them on the Paddle gateway throws a
162
+ `BillingError` (Paddle collects cards in its own hosted checkout).
163
+
164
+ ## Invoices
165
+
166
+ ```ts
167
+ const invoices = await user.invoices(); // GatewayInvoice[] — total, currency, status, url
168
+ ```
169
+
170
+ ## Webhooks
171
+
172
+ The package mounts one webhook endpoint per gateway at
173
+ `config("billing.webhook.path")`:
174
+
175
+ ```
176
+ POST /billing/webhook/stripe
177
+ POST /billing/webhook/paddle
178
+ ```
179
+
180
+ Point your gateway dashboard at the matching URL. Each request is verified
181
+ against the gateway's signing secret (HMAC-SHA256 over the raw body), the local
182
+ subscription is synced, and typed events fire:
183
+
184
+ ```ts
185
+ import { listen } from "@shaferllc/keel/core";
186
+
187
+ listen("billing.subscription.updated", (e) => {
188
+ // e.gateway, e.subscriptionId, e.providerId, e.status
189
+ });
190
+ listen("billing.webhook.received", (e) => { /* e.gateway, e.type, e.id */ });
191
+ ```
192
+
193
+ Events: `billing.webhook.received`, `billing.subscription.created` / `.updated`
194
+ / `.deleted`.
195
+
196
+ An update to a subscription already in your database is always synced. A brand
197
+ new subscription born from a Paddle checkout has no local row yet — register a
198
+ resolver so the handler can create it:
199
+
200
+ ```ts
201
+ import { resolveBillableUsing } from "@shaferllc/keel/billing";
202
+
203
+ resolveBillableUsing(async (customerId) => {
204
+ const user = (await User.query().where("billing_customer_id", customerId).first());
205
+ return user ? { id: user.id, type: "User" } : null;
206
+ });
207
+ ```
208
+
209
+ ## Gateway differences
210
+
211
+ | Concern | Stripe | Paddle |
212
+ |---------|--------|--------|
213
+ | Create a subscription server-side | `create(pmId)` | Not supported — use `checkout()`; the webhook creates the local row |
214
+ | One-off `charge()` | Confirms a PaymentIntent | Not supported — use `checkout({ mode })` / transactions |
215
+ | SetupIntent / `paymentMethods()` | Supported | Throws `BillingError` (hosted checkout) |
216
+ | Checkout handle | `session.url` (redirect) | `session.clientToken` (overlay/inline) |
217
+ | Webhook signature | `Stripe-Signature: t=…,v1=…` | `Paddle-Signature: ts=…;h1=…` |
218
+
219
+ ## Schema
220
+
221
+ The migration is gateway-neutral: `subscriptions` (with `gateway`,
222
+ `provider_id`, `provider_status`, `provider_price`, trial/grace timestamps),
223
+ `subscription_items`, and columns on `users` (`billing_gateway`,
224
+ `billing_customer_id`, `pm_type`, `pm_last_four`, `trial_ends_at`). Cashier
225
+ targets the standard `users` billable table.
226
+
227
+ ## Testing
228
+
229
+ The package ships a `FakeGateway` — an in-memory gateway that records every call
230
+ — so you can drive billing without touching a network:
231
+
232
+ ```ts
233
+ import { BillingManager, setBilling, FakeGateway } from "@shaferllc/keel/billing";
234
+
235
+ const fake = new FakeGateway();
236
+ const manager = new BillingManager(config); // config.default = "fake"
237
+ manager.register("fake", () => fake);
238
+ setBilling(manager);
239
+
240
+ await user.newSubscription("default", "price_pro").create();
241
+ fake.calls.filter((c) => c.method === "createSubscription"); // assert what was asked
242
+ ```
package/docs/database.md CHANGED
@@ -145,6 +145,23 @@ await db("posts").latest().get(); // ORDER BY created_at DESC
145
145
  await db("posts").oldest("published_at").get();
146
146
  ```
147
147
 
148
+ Joins, grouping, and conditional/raw clauses:
149
+
150
+ ```ts
151
+ await db("posts")
152
+ .join("users", "posts.user_id", "users.id") // also leftJoin(...)
153
+ .select("posts.title", "users.name")
154
+ .get();
155
+
156
+ await db("posts").select("user_id").groupBy("user_id").having("COUNT(*)", ">", 5).get();
157
+ await db("users").distinct().select("country").pluck("country");
158
+
159
+ await db("events").whereColumn("updated_at", ">", "created_at").get();
160
+ await db("users").whereRaw("score >= ?", [10]).orderByRaw("LENGTH(name) DESC").get();
161
+
162
+ await db("users").when(search, (q, term) => q.whereLike("name", `%${term}%`)).get();
163
+ ```
164
+
148
165
  ## Aggregates, single values, and pagination
149
166
 
150
167
  ```ts
@@ -174,6 +191,22 @@ Everything is parameterized — values become bindings, never string-interpolate
174
191
  SQL — so it's injection-safe by construction. Writes return a `WriteResult`;
175
192
  `insertGetId` unwraps it to just the new id.
176
193
 
194
+ Counters, bulk upserts, and paged iteration:
195
+
196
+ ```ts
197
+ await db("posts").where("id", id).increment("views"); // += 1
198
+ await db("posts").where("id", id).decrement("stock", 3, { updated_at: now });
199
+
200
+ // Insert, updating the listed columns on a unique-key conflict (dialect-aware).
201
+ await db("users").upsert([{ id: 1, name: "Ada" }], ["id"], ["name"]);
202
+ await db("logs").insertOrIgnore({ key, value }); // skip duplicates
203
+
204
+ // Process a large table a page at a time (return false to stop early).
205
+ await db("users").orderBy("id").chunk(500, async (rows) => {
206
+ for (const row of rows) await process(row);
207
+ });
208
+ ```
209
+
177
210
  > **Guard your writes.** `update()` and `delete()` apply to every row that
178
211
  > matches the current `where` clause — with no `where`, that's the whole table.
179
212
  > Always scope a write with `where` unless you truly mean to touch every row.
@@ -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
  ---
package/docs/packages.md CHANGED
@@ -7,7 +7,9 @@ adds the conventions a *shippable* package needs so it can carry its own schema
7
7
  and assets instead of asking the app to wire them by hand.
8
8
 
9
9
  [Keel Watch](./watch.md) — the debug dashboard — is a first-party package and the
10
- reference implementation of everything below.
10
+ reference implementation of everything below. [Billing](./billing.md) (a Cashier
11
+ port for Stripe and Paddle) is another, and shows a package contributing models,
12
+ a schema migration, gateway drivers, and verified webhook routes.
11
13
 
12
14
  ## The shape of a package
13
15