@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.
- package/README.md +3 -1
- 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/billing/billable.d.ts +83 -0
- package/dist/billing/billable.js +177 -0
- package/dist/billing/billing.config.stub +33 -0
- package/dist/billing/builder.d.ts +54 -0
- package/dist/billing/builder.js +104 -0
- package/dist/billing/config.d.ts +43 -0
- package/dist/billing/config.js +35 -0
- package/dist/billing/crypto.d.ts +11 -0
- package/dist/billing/crypto.js +27 -0
- package/dist/billing/drivers/fake.d.ts +58 -0
- package/dist/billing/drivers/fake.js +190 -0
- package/dist/billing/drivers/index.d.ts +11 -0
- package/dist/billing/drivers/index.js +22 -0
- package/dist/billing/drivers/paddle.d.ts +39 -0
- package/dist/billing/drivers/paddle.js +197 -0
- package/dist/billing/drivers/stripe.d.ts +33 -0
- package/dist/billing/drivers/stripe.js +278 -0
- package/dist/billing/events.d.ts +25 -0
- package/dist/billing/events.js +7 -0
- package/dist/billing/gateway.d.ts +170 -0
- package/dist/billing/gateway.js +24 -0
- package/dist/billing/index.d.ts +28 -0
- package/dist/billing/index.js +19 -0
- package/dist/billing/manager.d.ts +34 -0
- package/dist/billing/manager.js +61 -0
- package/dist/billing/migration.d.ts +13 -0
- package/dist/billing/migration.js +68 -0
- package/dist/billing/provider.d.ts +20 -0
- package/dist/billing/provider.js +42 -0
- package/dist/billing/routes.d.ts +11 -0
- package/dist/billing/routes.js +21 -0
- package/dist/billing/subscription-item.d.ts +18 -0
- package/dist/billing/subscription-item.js +11 -0
- package/dist/billing/subscription.d.ts +85 -0
- package/dist/billing/subscription.js +157 -0
- package/dist/billing/webhooks.d.ts +26 -0
- package/dist/billing/webhooks.js +75 -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 +70 -1
- package/docs/billing.md +242 -0
- 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/docs/packages.md +3 -1
- package/llms-full.txt +671 -7
- package/llms.txt +3 -0
- package/package.json +10 -2
package/docs/billing.md
ADDED
|
@@ -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
|
+
};
|
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
|
---
|
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
|
|